unit('unit-1', 'alpha', new Position(0, 0)); $match = new MatchState(new Battlefield(8, 8), [$unit], 'alpha'); $moved = $unit->moveTo(new Position(1, 0))->spendAction(); $next = $match->withUnit($moved); self::assertSame('0:0', $match->unit('unit-1')->position->key()); self::assertSame('1:0', $next->unit('unit-1')->position->key()); self::assertSame(1, $next->unit('unit-1')->actionsRemaining); } public function testMoveUpdatesUnitAndAppendsActionLogWithoutMutatingMatch(): void { $match = 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', 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(['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 { $match = 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', ); $this->expectException(CombatException::class); $this->expectExceptionMessage("It is not this unit's turn."); (new CombatEngine())->move($match, 'bravo-1', new Position(6, 7)); } public function testMoveRejectsOccupiedDestination(): 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)), $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', new Position(1, 0)); } private function match(): MatchState { 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); } }