From 6befe7b3e22af1991566b0dc472e0fedc9461e31 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 19:43:17 -0500 Subject: [PATCH] feat: support curated abilities and objective victory --- src/Domain/CombatEngine.php | 268 +++++++++++++++++++++++-- src/Domain/UnitState.php | 9 + tests/Unit/Domain/CombatEngineTest.php | 217 +++++++++++++++++++- 3 files changed, 477 insertions(+), 17 deletions(-) diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index cfcaeee..77e4375 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -60,20 +60,71 @@ final class CombatEngine } $terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus(); - $damage = max(1, $attacker->attack - $target->defense - $terrainDefense); + $damage = max(1, $attacker->attack + $attacker->attackBonus - $target->defense - $terrainDefense); $updatedAttacker = $attacker->markAttacked()->spendAction(); $updatedTarget = $target->takeDamage($damage); $next = $match->withUnit($updatedAttacker)->withUnit($updatedTarget); $actionLog = [...$match->actionLog, "{$attacker->id} attacked {$target->id} for {$damage} damage"]; $next = $next->copy(actionLog: $actionLog); - foreach ($next->units as $unit) { - if ($unit->teamId !== $attacker->teamId && !$unit->isDefeated()) { - return $next; - } + return $this->checkVictoryAfterAction($next, $attacker->teamId); + } + + public function useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState + { + $this->assertMatchActive($match); + + $caster = $this->unitOrFail($match, $unitId); + $this->assertCanAct($match, $caster); + + if ($caster->hasUsedAbility) { + throw new CombatException('Unit has already used an ability this turn.'); } - return $next->withWinner($attacker->teamId); + $definitions = AbilityCatalog::definitions(); + + if (!isset($definitions[$abilityId])) { + throw new CombatException("Unknown ability: {$abilityId}."); + } + + $definition = $definitions[$abilityId]; + + if (!in_array($abilityId, $caster->abilities, true)) { + throw new CombatException('Unit does not know that ability.'); + } + + if (!in_array($abilityId, $caster->allowedAbilities(), true)) { + throw new CombatException('Unit archetype forbids that ability.'); + } + + $affected = $this->resolveAbilityTargets($match, $caster, $definition, $target); + + $next = $match; + $logParts = []; + + foreach ($affected as $affectedUnit) { + $current = $next->unit($affectedUnit['id']); + $updated = match ($definition->effect) { + AbilityDefinition::EFFECT_RESTORE_HEALTH => $current->restoreHealth($definition->effectValue), + AbilityDefinition::EFFECT_DEAL_DAMAGE => $current->takeDamage($affectedUnit['damage'] ?? 0), + AbilityDefinition::EFFECT_APPLY_BUFF => $current->withAttackBonus($definition->effectValue), + default => throw new CombatException("Unknown ability effect: {$definition->effect}."), + }; + $next = $next->withUnit($updated); + + $logParts[] = match ($definition->effect) { + AbilityDefinition::EFFECT_RESTORE_HEALTH => "{$affectedUnit['id']} for {$definition->effectValue}", + AbilityDefinition::EFFECT_DEAL_DAMAGE => "{$affectedUnit['id']} for {$affectedUnit['damage']} damage", + AbilityDefinition::EFFECT_APPLY_BUFF => "{$affectedUnit['id']} with +{$definition->effectValue} attack", + }; + } + + $next = $next->withUnit($next->unit($caster->id)->spendAbility()); + + $summary = "{$caster->id} used {$definition->label} on " . implode(', ', $logParts); + $next = $next->copy(actionLog: [...$next->actionLog, $summary]); + + return $this->checkVictoryAfterAction($next, $caster->teamId); } public function endTurn(MatchState $match): MatchState @@ -89,11 +140,11 @@ final class CombatEngine $teamIds = array_keys($teamIds); sort($teamIds); - if ($teamIds !== ['alpha', 'bravo']) { - throw new CombatException('Matches require alpha and bravo teams.'); + if (count($teamIds) !== 2) { + throw new CombatException('Matches require exactly two teams.'); } - $livingTeams = ['alpha' => false, 'bravo' => false]; + $livingTeams = array_fill_keys($teamIds, false); foreach ($match->units as $unit) { if (!$unit->isDefeated()) { @@ -101,14 +152,16 @@ final class CombatEngine } } - if (!$livingTeams['alpha'] || !$livingTeams['bravo']) { - throw new CombatException('Cannot end turn while a team is eliminated.'); + foreach ($livingTeams as $teamId => $alive) { + if (!$alive) { + throw new CombatException('Cannot end turn while a team is eliminated.'); + } } $endingTeamId = $match->activeTeamId; - $nextTeamId = $endingTeamId === 'alpha' ? 'bravo' : 'alpha'; + $nextTeamId = $endingTeamId === $teamIds[0] ? $teamIds[1] : $teamIds[0]; - if ($nextTeamId === 'alpha' && $match->round === PHP_INT_MAX) { + if ($nextTeamId === $teamIds[0] && $match->round === PHP_INT_MAX) { throw new CombatException('Match round limit reached.'); } @@ -118,12 +171,167 @@ final class CombatEngine $units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit; } - return $match->copy( + $round = $match->round + ($nextTeamId === $teamIds[0] ? 1 : 0); + + $next = $match->copy( units: $units, activeTeamId: $nextTeamId, - round: $match->round + ($nextTeamId === 'alpha' ? 1 : 0), + round: $round, actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"], ); + + $winner = $this->resolveObjectiveVictory($next); + if ($winner !== null) { + $next = $next->withWinner($winner); + } + + return $next; + } + + /** + * @return list + */ + private function resolveAbilityTargets( + MatchState $match, + UnitState $caster, + AbilityDefinition $definition, + ?Position $target, + ): array { + if ($definition->target === AbilityDefinition::TARGET_SELF) { + return [['id' => $caster->id]]; + } + + if ($target === null) { + throw new CombatException('Ability requires a target position.'); + } + + if (!$match->battlefield->contains($target)) { + throw new CombatException('Ability target is outside the battlefield.'); + } + + $distance = $caster->position->distanceTo($target); + + if ($distance > $definition->range) { + throw new CombatException('Ability target is out of range.'); + } + + $tileKeys = [$target->key()]; + + if ($definition->areaRadius > 0) { + $tileKeys = self::tilesWithinRadius( + $match->battlefield, + $target, + $definition->areaRadius, + ); + } + + if ($definition->effect === AbilityDefinition::EFFECT_DEAL_DAMAGE) { + $affected = []; + + foreach ($match->units as $candidate) { + if ($candidate->isDefeated()) { + continue; + } + if ($candidate->teamId === $caster->teamId) { + continue; + } + $found = false; + foreach ($tileKeys as $key) { + if ($candidate->position->key() === $key) { + $found = true; + break; + } + } + if (!$found) { + continue; + } + + $terrainDefense = $match->battlefield->terrainAt($candidate->position)->defenseBonus(); + $damage = max(1, $definition->effectValue - $candidate->defense - $terrainDefense); + $affected[] = ['id' => $candidate->id, 'damage' => $damage]; + } + + return $affected; + } + + $targetUnit = null; + + foreach ($match->units as $candidate) { + if ($candidate->position->key() === $target->key()) { + $targetUnit = $candidate; + break; + } + } + + if ($targetUnit === null) { + throw new CombatException('Ability target is empty.'); + } + + $expectedTeam = $definition->target === AbilityDefinition::TARGET_ALLY ? $caster->teamId : null; + + if ($expectedTeam !== null && $targetUnit->teamId !== $expectedTeam) { + throw new CombatException('Ability target must be an ally.'); + } + + if ($definition->target === AbilityDefinition::TARGET_ENEMY && $targetUnit->teamId === $caster->teamId) { + throw new CombatException('Ability target must be an enemy.'); + } + + return [['id' => $targetUnit->id]]; + } + + private function checkVictoryAfterAction(MatchState $match, string $actingTeamId): MatchState + { + foreach ($match->units as $unit) { + if ($unit->teamId !== $actingTeamId && !$unit->isDefeated()) { + return $match; + } + } + + return $match->withWinner($actingTeamId); + } + + private function resolveObjectiveVictory(MatchState $match): ?string + { + if ($match->victoryCondition !== VictoryCondition::HoldObjective || $match->objectives === []) { + return null; + } + + /** @var list $objectiveList */ + $objectiveList = array_values($match->objectives); + $objective = $objectiveList[0]; + $controller = $this->objectiveController($match, $objective->position); + + if ($controller === null) { + return null; + } + + $control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller); + $next = $match->withObjectiveControl($control->roundsByTeam); + + if (($control->roundsByTeam[$controller] ?? 0) >= $next->holdRoundsRequired) { + return $controller; + } + + return null; + } + + private function objectiveController(MatchState $match, Position $tile): ?string + { + $teams = []; + + foreach ($match->units as $unit) { + if ($unit->isDefeated() || $unit->position->key() !== $tile->key()) { + continue; + } + $teams[$unit->teamId] = true; + } + + if (count($teams) !== 1) { + return null; + } + + return array_key_first($teams); } private function unitOrFail(MatchState $match, string $unitId): UnitState @@ -142,6 +350,36 @@ final class CombatEngine } } + /** @return list */ + private static function tilesWithinRadius(Battlefield $battlefield, Position $center, int $radius): array + { + $seen = [$center->key() => true]; + $frontier = [$center]; + $result = [$center->key()]; + + for ($step = 0; $step < $radius; $step++) { + $next = []; + foreach ($frontier as $position) { + foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as $offset) { + $candidate = new Position($position->x + $offset[0], $position->y + $offset[1]); + if (!$battlefield->contains($candidate)) { + continue; + } + $key = $candidate->key(); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $result[] = $key; + $next[] = $candidate; + } + } + $frontier = $next; + } + + return $result; + } + private function assertCanAct(MatchState $match, UnitState $unit): void { if ($unit->teamId !== $match->activeTeamId) { diff --git a/src/Domain/UnitState.php b/src/Domain/UnitState.php index 5ae486c..04f1dc0 100644 --- a/src/Domain/UnitState.php +++ b/src/Domain/UnitState.php @@ -112,6 +112,15 @@ final readonly class UnitState return $this->copy(health: max(0, $this->health - max(0, $damage))); } + public function restoreHealth(int $amount): self + { + if ($amount < 0) { + throw new InvalidArgumentException('Restore health amount cannot be negative.'); + } + + return $this->copy(health: min($this->maxHealth, $this->health + $amount)); + } + public function markAttacked(): self { return $this->copy(hasAttacked: true); diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index e2bcd63..b64ddb4 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace BattleForge\Tests\Unit\Domain; +use BattleForge\Domain\Archetype; use BattleForge\Domain\Battlefield; use BattleForge\Domain\CombatEngine; use BattleForge\Domain\CombatException; @@ -583,7 +584,7 @@ final class CombatEngineTest extends TestCase ); $this->expectException(CombatException::class); - $this->expectExceptionMessage('Matches require alpha and bravo teams.'); + $this->expectExceptionMessage('Matches require exactly two teams.'); (new CombatEngine())->endTurn($match); } @@ -594,7 +595,6 @@ final class CombatEngineTest extends TestCase public static function invalidEndTurnTeamProvider(): iterable { yield 'only alpha' => [['alpha']]; - yield 'alpha and charlie' => [['alpha', 'charlie']]; yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']]; } @@ -746,6 +746,7 @@ final class CombatEngineTest extends TestCase int $attack = 4, int $defense = 2, bool $hasAttacked = false, + int $attackBonus = 0, ): UnitState { return new UnitState( $id, @@ -758,6 +759,218 @@ final class CombatEngineTest extends TestCase $speed, $actionsRemaining, $hasAttacked, + attackBonus: $attackBonus, ); } + + public function testHealAbilityRestoresAlliedHealthAndConsumesOneAction(): void + { + $caster = new UnitState( + id: 'support-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 8, + health: 8, + attack: 2, + defense: 2, + speed: 3, + actionsRemaining: 2, + hasAttacked: false, + archetype: Archetype::Support, + abilities: ['heal'], + attackBonus: 0, + hasUsedAbility: false, + ); + $wounded = new UnitState( + id: 'alpha-1', + teamId: 'alpha', + position: new Position(0, 1), + maxHealth: 10, + health: 3, + attack: 4, + defense: 2, + speed: 4, + actionsRemaining: 2, + hasAttacked: false, + ); + $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); + $match = new MatchState( + new Battlefield(8, 8), + [$caster, $wounded, $bravo], + 'alpha', + ); + + $next = (new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1)); + + self::assertSame(8, $next->unit('alpha-1')->health); + self::assertSame(1, $next->unit('support-1')->actionsRemaining); + self::assertTrue($next->unit('support-1')->hasUsedAbility); + self::assertSame(['support-1 used Heal on alpha-1 for 5'], $next->actionLog); + } + + public function testAreaDamageAbilityHitsEveryEnemyInsideTheRadius(): void + { + $caster = new UnitState( + id: 'striker-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 8, + health: 8, + attack: 5, + defense: 1, + speed: 3, + actionsRemaining: 2, + hasAttacked: false, + archetype: Archetype::Striker, + abilities: ['area_damage'], + attackBonus: 0, + hasUsedAbility: false, + ); + $alpha = $this->unit('alpha-2', 'alpha', new Position(2, 0)); + $bravoA = $this->unit('bravo-1', 'bravo', new Position(1, 1), defense: 1); + $bravoB = $this->unit('bravo-2', 'bravo', new Position(2, 1), defense: 1); + $bravoC = $this->unit('bravo-3', 'bravo', new Position(3, 1), defense: 1); + $bravoD = $this->unit('bravo-4', 'bravo', new Position(5, 5)); + $match = new MatchState( + new Battlefield(8, 8), + [$caster, $alpha, $bravoA, $bravoB, $bravoC, $bravoD], + 'alpha', + ); + + $next = (new CombatEngine())->useAbility($match, 'striker-1', 'area_damage', new Position(2, 1)); + + self::assertSame(8, $next->unit('bravo-1')->health); + self::assertSame(8, $next->unit('bravo-2')->health); + self::assertSame(8, $next->unit('bravo-3')->health); + self::assertSame(10, $next->unit('bravo-4')->health); + self::assertSame(10, $next->unit('alpha-2')->health); + self::assertSame(1, $next->unit('striker-1')->actionsRemaining); + } + + public function testBuffAbilityReplacesTheAttackBonusOnAnAlly(): void + { + $caster = new UnitState( + id: 'support-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 8, + health: 8, + attack: 2, + defense: 2, + speed: 3, + actionsRemaining: 2, + hasAttacked: false, + archetype: Archetype::Support, + abilities: ['buff'], + attackBonus: 0, + hasUsedAbility: false, + ); + $alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1), attackBonus: 1); + $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); + $match = new MatchState( + new Battlefield(8, 8), + [$caster, $alpha, $bravo], + 'alpha', + ); + + $next = (new CombatEngine())->useAbility($match, 'support-1', 'buff', new Position(0, 1)); + + self::assertSame(2, $next->unit('alpha-2')->attackBonus); + } + + public function testUseAbilityRejectsAUnitThatHasAlreadyUsedOne(): void + { + $caster = new UnitState( + id: 'support-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 8, + health: 8, + attack: 2, + defense: 2, + speed: 3, + actionsRemaining: 2, + hasAttacked: false, + archetype: Archetype::Support, + abilities: ['heal'], + attackBonus: 0, + hasUsedAbility: true, + ); + $alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1)); + $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); + $match = new MatchState( + new Battlefield(8, 8), + [$caster, $alpha, $bravo], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Unit has already used an ability this turn.'); + + (new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1)); + } + + public function testUseAbilityRejectsTargetsOutsideRange(): void + { + $caster = new UnitState( + id: 'support-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 8, + health: 8, + attack: 2, + defense: 2, + speed: 3, + actionsRemaining: 2, + hasAttacked: false, + archetype: Archetype::Support, + abilities: ['heal'], + attackBonus: 0, + hasUsedAbility: false, + ); + $alpha = $this->unit('alpha-2', 'alpha', new Position(5, 5)); + $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); + $match = new MatchState( + new Battlefield(8, 8), + [$caster, $alpha, $bravo], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Ability target is out of range.'); + + (new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(5, 5)); + } + + public function testUseAbilityRejectsAnAbilityTheArchetypeForbids(): void + { + $caster = new UnitState( + id: 'scout-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 6, + health: 6, + attack: 3, + defense: 1, + speed: 5, + actionsRemaining: 2, + hasAttacked: false, + archetype: Archetype::Scout, + abilities: [], + attackBonus: 0, + hasUsedAbility: false, + ); + $alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1)); + $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); + $match = new MatchState( + new Battlefield(8, 8), + [$caster, $alpha, $bravo], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Unit does not know that ability.'); + + (new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1)); + } }