diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index b512e0b..f54372d 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -4,13 +4,19 @@ declare(strict_types=1); namespace BattleForge\Domain; +use InvalidArgumentException; + final class CombatEngine { public function move(MatchState $match, string $unitId, Position $destination): MatchState { $this->assertMatchActive($match); - $unit = $match->unit($unitId); + try { + $unit = $match->unit($unitId); + } catch (InvalidArgumentException $exception) { + throw new CombatException("Unknown unit: {$unitId}.", previous: $exception); + } $this->assertCanAct($match, $unit); $occupied = []; diff --git a/src/Domain/MatchState.php b/src/Domain/MatchState.php index 48ed30b..5e2e6f8 100644 --- a/src/Domain/MatchState.php +++ b/src/Domain/MatchState.php @@ -30,6 +30,7 @@ final readonly class MatchState $unitIds = []; $teamIds = []; + $occupiedPositions = []; foreach ($units as $unit) { if (!self::isUnitState($unit)) { @@ -40,6 +41,24 @@ final readonly class MatchState throw new InvalidArgumentException("Duplicate unit id: {$unit->id}."); } + if (!$battlefield->contains($unit->position)) { + throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield."); + } + + if (!$unit->isDefeated() && $battlefield->terrainAt($unit->position)->movementCost() === null) { + throw new InvalidArgumentException("Living unit {$unit->id} must stand on passable terrain."); + } + + $positionKey = $unit->position->key(); + + if (!$unit->isDefeated() && isset($occupiedPositions[$positionKey])) { + throw new InvalidArgumentException("Living units cannot share position {$positionKey}."); + } + + if (!$unit->isDefeated()) { + $occupiedPositions[$positionKey] = true; + } + $unitIds[$unit->id] = true; $teamIds[$unit->teamId] = true; } diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index 0c8542d..e7d433c 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -9,7 +9,10 @@ use BattleForge\Domain\CombatEngine; use BattleForge\Domain\CombatException; use BattleForge\Domain\MatchState; use BattleForge\Domain\Position; +use BattleForge\Domain\Terrain; use BattleForge\Domain\UnitState; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class CombatEngineTest extends TestCase @@ -36,14 +39,149 @@ final class CombatEngineTest extends TestCase $this->unit('bravo-1', 'bravo', new Position(7, 7)), ], 'alpha', + actionLog: ['match started'], ); $next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0)); self::assertSame('0:0', $match->unit('alpha-1')->position->key()); + self::assertSame(2, $match->unit('alpha-1')->actionsRemaining); + self::assertSame(['match started'], $match->actionLog); self::assertSame('2:0', $next->unit('alpha-1')->position->key()); self::assertSame(1, $next->unit('alpha-1')->actionsRemaining); - self::assertSame(['alpha-1 moved to 2:0'], $next->actionLog); + self::assertSame(['match started', 'alpha-1 moved to 2:0'], $next->actionLog); + } + + public function testMoveTranslatesUnknownUnitAtCommandBoundary(): void + { + $match = $this->match(); + + try { + (new CombatEngine())->move($match, 'missing', new Position(1, 0)); + 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 testMoveRejectsCompletedMatchBeforeResolvingUnit(): void + { + $match = $this->match()->withWinner('alpha'); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('The match is already complete.'); + + (new CombatEngine())->move($match, 'missing', new Position(1, 0)); + } + + public function testMoveRejectsDefeatedActor(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0), health: 0), + $this->unit('bravo-1', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Defeated units cannot act.'); + + (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0)); + } + + public function testMoveRejectsExhaustedActor(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0), actionsRemaining: 0), + $this->unit('bravo-1', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Unit has no actions remaining.'); + + (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0)); + } + + public function testMoveRejectsCurrentPosition(): void + { + $match = $this->match(); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Destination is not reachable.'); + + (new CombatEngine())->move($match, 'alpha-1', new Position(0, 0)); + } + + /** + * @param array $terrain + */ + #[DataProvider('unreachableDestinationProvider')] + public function testMoveRejectsUnreachableDestination(array $terrain, Position $destination): void + { + $match = new MatchState( + new Battlefield(8, 8, $terrain), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0)), + $this->unit('bravo-1', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + + $this->expectException(CombatException::class); + $this->expectExceptionMessage('Destination is not reachable.'); + + (new CombatEngine())->move($match, 'alpha-1', $destination); + } + + /** + * @return iterable, Position}> + */ + public static function unreachableDestinationProvider(): iterable + { + yield 'outside battlefield' => [[], new Position(8, 0)]; + yield 'outside speed budget' => [[], new Position(5, 0)]; + yield 'impassable terrain' => [['1:0' => Terrain::Blocking], new Position(1, 0)]; + } + + public function testDefeatedUnitDoesNotBlockItsPosition(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0)), + $this->unit('alpha-2', 'alpha', new Position(1, 0), health: 0), + $this->unit('bravo-1', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + + $next = (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0)); + + self::assertSame('1:0', $next->unit('alpha-1')->position->key()); + } + + public function testMoveHonorsTerrainCostWithinSpeedBudget(): void + { + $match = new MatchState( + new Battlefield(8, 8, ['1:0' => Terrain::Forest]), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0), speed: 3), + $this->unit('bravo-1', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + + $next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0)); + + self::assertSame('2:0', $next->unit('alpha-1')->position->key()); } public function testMoveRejectsUnitFromInactiveTeam(): void @@ -81,8 +219,26 @@ final class CombatEngineTest extends TestCase (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0)); } - private function unit(string $id, string $team, Position $position): UnitState + private function match(): MatchState { - return new UnitState($id, $team, $position, 10, 10, 4, 2, 4, 2); + return new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha', new Position(0, 0)), + $this->unit('bravo-1', 'bravo', new Position(7, 7)), + ], + 'alpha', + ); + } + + private function unit( + string $id, + string $team, + Position $position, + int $health = 10, + int $speed = 4, + int $actionsRemaining = 2, + ): UnitState { + return new UnitState($id, $team, $position, 10, $health, 4, 2, $speed, $actionsRemaining); } } diff --git a/tests/Unit/Domain/MatchStateTest.php b/tests/Unit/Domain/MatchStateTest.php index 27eb949..f22f634 100644 --- a/tests/Unit/Domain/MatchStateTest.php +++ b/tests/Unit/Domain/MatchStateTest.php @@ -7,8 +7,10 @@ namespace BattleForge\Tests\Unit\Domain; use BattleForge\Domain\Battlefield; use BattleForge\Domain\MatchState; use BattleForge\Domain\Position; +use BattleForge\Domain\Terrain; use BattleForge\Domain\UnitState; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class MatchStateTest extends TestCase @@ -24,6 +26,67 @@ final class MatchStateTest extends TestCase ); } + public function testItRejectsAUnitOutsideTheBattlefield(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unit alpha-1 is outside the battlefield.'); + + new MatchState( + new Battlefield(8, 8), + [$this->unit('alpha-1', 'alpha', new Position(8, 0))], + 'alpha', + ); + } + + #[DataProvider('impassableTerrainProvider')] + public function testItRejectsALivingUnitOnImpassableTerrain(Terrain $terrain): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Living unit alpha-1 must stand on passable terrain.'); + + new MatchState( + new Battlefield(8, 8, ['0:0' => $terrain]), + [$this->unit('alpha-1', 'alpha')], + 'alpha', + ); + } + + /** + * @return iterable + */ + public static function impassableTerrainProvider(): iterable + { + yield 'water' => [Terrain::Water]; + yield 'blocking' => [Terrain::Blocking]; + } + + public function testItRejectsLivingUnitsAtTheSamePosition(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Living units cannot share position 0:0.'); + + new MatchState( + new Battlefield(8, 8), + [$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')], + 'alpha', + ); + } + + public function testItAcceptsLivingAndDefeatedUnitsAtTheSamePosition(): void + { + $match = new MatchState( + new Battlefield(8, 8), + [ + $this->unit('alpha-1', 'alpha'), + $this->unit('bravo-1', 'bravo', health: 0), + ], + 'alpha', + ); + + self::assertSame('0:0', $match->unit('alpha-1')->position->key()); + self::assertSame('0:0', $match->unit('bravo-1')->position->key()); + } + public function testItRejectsAnUnknownUnitLookup(): void { $match = $this->match(); @@ -169,14 +232,21 @@ final class MatchStateTest extends TestCase { return new MatchState( new Battlefield(8, 8), - [$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')], + [ + $this->unit('alpha-1', 'alpha'), + $this->unit('bravo-1', 'bravo', new Position(7, 7)), + ], 'alpha', actionLog: $actionLog, ); } - private function unit(string $id, string $teamId): UnitState - { - return new UnitState($id, $teamId, new Position(0, 0), 10, 10, 4, 2, 4, 2); + private function unit( + string $id, + string $teamId, + ?Position $position = null, + int $health = 10, + ): UnitState { + return new UnitState($id, $teamId, $position ?? new Position(0, 0), 10, $health, 4, 2, 4, 2); } }