From 3c6359243a2e13a5dc3d71560a14d8f11f3e347d Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 4 Jul 2026 15:53:57 -0500 Subject: [PATCH] feat: resolve attacks and elimination victory --- src/Domain/CombatEngine.php | 56 +++++- tests/Unit/Domain/CombatEngineTest.php | 251 ++++++++++++++++++++++++- 2 files changed, 301 insertions(+), 6 deletions(-) diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index f54372d..eab9624 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -12,11 +12,7 @@ final class CombatEngine { $this->assertMatchActive($match); - try { - $unit = $match->unit($unitId); - } catch (InvalidArgumentException $exception) { - throw new CombatException("Unknown unit: {$unitId}.", previous: $exception); - } + $unit = $this->unitOrFail($match, $unitId); $this->assertCanAct($match, $unit); $occupied = []; @@ -40,6 +36,56 @@ final class CombatEngine return $next->copy(actionLog: $actionLog); } + public function attack(MatchState $match, string $attackerId, string $targetId): MatchState + { + $this->assertMatchActive($match); + + $attacker = $this->unitOrFail($match, $attackerId); + $this->assertCanAct($match, $attacker); + + if ($attacker->hasAttacked) { + throw new CombatException('Unit has already attacked this turn.'); + } + + $target = $this->unitOrFail($match, $targetId); + + if ($target->teamId === $attacker->teamId || $target->isDefeated()) { + throw new CombatException('Target must be an active enemy unit.'); + } + + $distance = abs($attacker->position->x - $target->position->x) + + abs($attacker->position->y - $target->position->y); + + if ($distance !== 1) { + throw new CombatException('Target is outside attack range.'); + } + + $terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus(); + $damage = max(1, $attacker->attack - $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 $next->withWinner($attacker->teamId); + } + + private function unitOrFail(MatchState $match, string $unitId): UnitState + { + try { + return $match->unit($unitId); + } catch (InvalidArgumentException $exception) { + throw new CombatException("Unknown unit: {$unitId}.", previous: $exception); + } + } + private function assertMatchActive(MatchState $match): void { if ($match->winnerTeamId !== null) { diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index e7d433c..773609b 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -219,6 +219,241 @@ final class CombatEngineTest extends TestCase (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0)); } + public function testAttackAppliesForestDefenseAndAppendsLogWithoutMutatingMatch(): void + { + $attacker = $this->unit('alpha-1', 'alpha', new Position(0, 0)); + $target = $this->unit('bravo-1', 'bravo', new Position(1, 0)); + $match = new MatchState( + new Battlefield(8, 8, ['1:0' => Terrain::Forest]), + [$attacker, $target], + 'alpha', + actionLog: ['match started'], + ); + + $next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); + + self::assertSame(10, $target->health); + self::assertSame(2, $attacker->actionsRemaining); + self::assertFalse($attacker->hasAttacked); + self::assertSame(10, $match->unit('bravo-1')->health); + self::assertSame(2, $match->unit('alpha-1')->actionsRemaining); + self::assertFalse($match->unit('alpha-1')->hasAttacked); + self::assertSame(['match started'], $match->actionLog); + self::assertSame(9, $next->unit('bravo-1')->health); + self::assertSame(1, $next->unit('alpha-1')->actionsRemaining); + self::assertTrue($next->unit('alpha-1')->hasAttacked); + self::assertSame( + ['match started', 'alpha-1 attacked bravo-1 for 1 damage'], + $next->actionLog, + ); + } + + public function testAttackRejectsTargetOutsideRange(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0)), + $this->unit('bravo-1', 'bravo', new Position(2, 0)), + ], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Target is outside attack range.'); + + (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); + } + + public function testAttackDefeatingLastEnemyAwardsVictory(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20), + $this->unit('bravo-1', 'bravo', new Position(1, 0)), + ], + 'alpha', + ); + + $next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); + + self::assertSame(0, $next->unit('bravo-1')->health); + self::assertSame('alpha', $next->winnerTeamId); + self::assertNull($match->winnerTeamId); + } + + public function testAttackRejectsUnitThatAlreadyAttacked(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0), hasAttacked: true), + $this->unit('bravo-1', 'bravo', new Position(1, 0)), + ], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Unit has already attacked this turn.'); + + (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); + } + + public function testAttackTranslatesUnknownAttackerAtCommandBoundary(): void + { + $match = $this->match(); + + try { + (new CombatEngine())->attack($match, 'missing', 'bravo-1'); + self::fail('Expected an unknown unit exception.'); + } catch (CombatException $exception) { + self::assertSame('Unknown unit: missing.', $exception->getMessage()); + self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious()); + self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage()); + } + } + + public function testAttackTranslatesUnknownTargetAtCommandBoundary(): void + { + $match = $this->match(); + + try { + (new CombatEngine())->attack($match, 'alpha-1', 'missing'); + self::fail('Expected an unknown unit exception.'); + } catch (CombatException $exception) { + self::assertSame('Unknown unit: missing.', $exception->getMessage()); + self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious()); + self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage()); + } + } + + public function testAttackRejectsCompletedMatchBeforeResolvingAttacker(): void + { + $match = $this->match()->withWinner('alpha'); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('The match is already complete.'); + + (new CombatEngine())->attack($match, 'missing', 'also-missing'); + } + + /** + * @param array{team?: string, health?: int, actionsRemaining?: int, hasAttacked?: bool} $overrides + */ + #[DataProvider('invalidAttackActorProvider')] + public function testAttackRejectsInvalidActor(array $overrides, string $message): void + { + $attacker = $this->unit( + 'attacker', + $overrides['team'] ?? 'alpha', + new Position(0, 0), + health: $overrides['health'] ?? 10, + actionsRemaining: $overrides['actionsRemaining'] ?? 2, + hasAttacked: $overrides['hasAttacked'] ?? false, + ); + $match = new MatchState( + new Battlefield(8, 8), + [ + $attacker, + $this->unit('bravo-1', 'bravo', new Position(1, 0)), + $this->unit('alpha-2', 'alpha', new Position(7, 7)), + ], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage($message); + + (new CombatEngine())->attack($match, 'attacker', 'missing'); + } + + /** + * @return iterable + */ + public static function invalidAttackActorProvider(): iterable + { + yield 'inactive and already attacked' => [ + ['team' => 'bravo', 'hasAttacked' => true], + "It is not this unit's turn.", + ]; + yield 'defeated and already attacked' => [ + ['health' => 0, 'hasAttacked' => true], + 'Defeated units cannot act.', + ]; + yield 'exhausted and already attacked' => [ + ['actionsRemaining' => 0, 'hasAttacked' => true], + 'Unit has no actions remaining.', + ]; + } + + #[DataProvider('invalidAttackTargetProvider')] + public function testAttackRejectsInvalidTarget(string $targetTeam, int $targetHealth): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0)), + $this->unit('target', $targetTeam, new Position(1, 0), health: $targetHealth), + $this->unit('bravo-2', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Target must be an active enemy unit.'); + + (new CombatEngine())->attack($match, 'alpha-1', 'target'); + } + + /** + * @return iterable + */ + public static function invalidAttackTargetProvider(): iterable + { + yield 'friendly' => ['alpha', 10]; + yield 'defeated enemy' => ['bravo', 0]; + } + + public function testAttackAlwaysDealsAtLeastOneDamage(): void + { + $match = new MatchState( + new Battlefield(8, 8, ['1:0' => Terrain::Forest]), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 0), + $this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 20), + ], + 'alpha', + ); + + $next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); + + self::assertSame(9, $next->unit('bravo-1')->health); + self::assertSame(['alpha-1 attacked bravo-1 for 1 damage'], $next->actionLog); + } + + public function testAttackDoesNotAwardVictoryWhileAnotherEnemyLives(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20), + $this->unit('bravo-1', 'bravo', new Position(1, 0)), + $this->unit('bravo-2', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + + $next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); + + self::assertSame(0, $next->unit('bravo-1')->health); + self::assertSame(10, $next->unit('bravo-2')->health); + self::assertNull($next->winnerTeamId); + } + private function match(): MatchState { return new MatchState( @@ -238,7 +473,21 @@ final class CombatEngineTest extends TestCase int $health = 10, int $speed = 4, int $actionsRemaining = 2, + int $attack = 4, + int $defense = 2, + bool $hasAttacked = false, ): UnitState { - return new UnitState($id, $team, $position, 10, $health, 4, 2, $speed, $actionsRemaining); + return new UnitState( + $id, + $team, + $position, + 10, + $health, + $attack, + $defense, + $speed, + $actionsRemaining, + $hasAttacked, + ); } }