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)); } 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 testAttackDealsCalculatedOpenTerrainDamage(): void { $target = $this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 3); $match = new MatchState( new Battlefield(8, 8), [ $this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 8), $target, ], 'alpha', ); $next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); self::assertSame(10, $target->health); self::assertSame(5, $next->unit('bravo-1')->health); self::assertSame(['alpha-1 attacked bravo-1 for 5 damage'], $next->actionLog); } 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); } public function testEndTurnPassesControlToBravoAndRefreshesItsLivingUnits(): void { $match = new MatchState( new Battlefield(8, 8), [ $this->unit( 'alpha-1', 'alpha', new Position(0, 0), actionsRemaining: 1, hasAttacked: true, ), $this->unit( 'bravo-1', 'bravo', new Position(7, 7), actionsRemaining: 0, hasAttacked: true, ), ], 'alpha', actionLog: ['match started'], ); $next = (new CombatEngine())->endTurn($match); self::assertSame('alpha', $match->activeTeamId); self::assertSame(1, $match->round); self::assertSame(1, $match->unit('alpha-1')->actionsRemaining); self::assertTrue($match->unit('alpha-1')->hasAttacked); self::assertSame(0, $match->unit('bravo-1')->actionsRemaining); self::assertTrue($match->unit('bravo-1')->hasAttacked); self::assertSame(['match started'], $match->actionLog); self::assertSame('bravo', $next->activeTeamId); self::assertSame(1, $next->round); self::assertSame(1, $next->unit('alpha-1')->actionsRemaining); self::assertTrue($next->unit('alpha-1')->hasAttacked); self::assertSame(2, $next->unit('bravo-1')->actionsRemaining); self::assertFalse($next->unit('bravo-1')->hasAttacked); self::assertSame(['match started', 'alpha ended turn'], $next->actionLog); } public function testEndTurnPassesControlToAlphaAndStartsANewRound(): void { $match = new MatchState( new Battlefield(8, 8), [ $this->unit( 'alpha-1', 'alpha', new Position(0, 0), actionsRemaining: 0, hasAttacked: true, ), $this->unit('bravo-1', 'bravo', new Position(7, 7), actionsRemaining: 1), ], 'bravo', round: 1, actionLog: ['alpha ended turn'], ); $next = (new CombatEngine())->endTurn($match); self::assertSame('alpha', $next->activeTeamId); self::assertSame(2, $next->round); self::assertSame(2, $next->unit('alpha-1')->actionsRemaining); self::assertFalse($next->unit('alpha-1')->hasAttacked); self::assertSame(1, $next->unit('bravo-1')->actionsRemaining); self::assertSame(['alpha ended turn', 'bravo ended turn'], $next->actionLog); self::assertSame('bravo', $match->activeTeamId); self::assertSame(1, $match->round); self::assertSame(0, $match->unit('alpha-1')->actionsRemaining); self::assertTrue($match->unit('alpha-1')->hasAttacked); self::assertSame(['alpha ended turn'], $match->actionLog); } public function testEndTurnRejectsCompletedMatchBeforeValidatingTeams(): void { $match = new MatchState( new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha', new Position(0, 0))], 'alpha', winnerTeamId: 'alpha', ); $this->expectException(CombatException::class); $this->expectExceptionMessage('The match is already complete.'); (new CombatEngine())->endTurn($match); } /** * @param list $teamIds */ #[DataProvider('invalidEndTurnTeamProvider')] public function testEndTurnRejectsMatchesWithoutExactlyAlphaAndBravoTeams(array $teamIds): void { $units = []; foreach ($teamIds as $index => $teamId) { $units[] = $this->unit("{$teamId}-{$index}", $teamId, new Position($index, 0)); } $match = new MatchState( new Battlefield(8, 8), $units, 'alpha', ); $this->expectException(CombatException::class); $this->expectExceptionMessage('Matches require alpha and bravo teams.'); (new CombatEngine())->endTurn($match); } /** * @return iterable}> */ public static function invalidEndTurnTeamProvider(): iterable { yield 'only alpha' => [['alpha']]; yield 'alpha and charlie' => [['alpha', 'charlie']]; yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']]; } #[DataProvider('eliminatedEndTurnTeamProvider')] public function testEndTurnRejectsEliminatedTeam(string $activeTeamId, string $eliminatedTeamId): void { $match = new MatchState( new Battlefield(8, 8), [ $this->unit( 'alpha-1', 'alpha', new Position(0, 0), health: $eliminatedTeamId === 'alpha' ? 0 : 10, ), $this->unit( 'bravo-1', 'bravo', new Position(7, 7), health: $eliminatedTeamId === 'bravo' ? 0 : 10, ), ], $activeTeamId, ); $this->expectException(CombatException::class); $this->expectExceptionMessage('Cannot end turn while a team is eliminated.'); (new CombatEngine())->endTurn($match); } /** * @return iterable */ public static function eliminatedEndTurnTeamProvider(): iterable { yield 'incoming team' => ['alpha', 'bravo']; yield 'active team' => ['alpha', 'alpha']; } public function testEndTurnRejectsEliminatedTeamBeforeRoundLimit(): 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)), ], 'bravo', round: PHP_INT_MAX, ); $this->expectException(CombatException::class); $this->expectExceptionMessage('Cannot end turn while a team is eliminated.'); (new CombatEngine())->endTurn($match); } public function testEndTurnRejectsRoundIncrementBeyondIntegerLimit(): 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)), ], 'bravo', round: PHP_INT_MAX, ); $this->expectException(CombatException::class); $this->expectExceptionMessage('Match round limit reached.'); (new CombatEngine())->endTurn($match); } public function testEndTurnAllowsAlphaToPassAtIntegerRoundLimit(): 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), actionsRemaining: 0), ], 'alpha', round: PHP_INT_MAX, ); $next = (new CombatEngine())->endTurn($match); self::assertSame('bravo', $next->activeTeamId); self::assertSame(PHP_INT_MAX, $next->round); self::assertSame(2, $next->unit('bravo-1')->actionsRemaining); self::assertSame(['alpha ended turn'], $next->actionLog); } public function testEndTurnDoesNotRefreshDefeatedUnitsOnIncomingTeam(): void { $match = new MatchState( new Battlefield(8, 8), [ $this->unit('alpha-1', 'alpha', new Position(0, 0)), $this->unit( 'bravo-defeated', 'bravo', new Position(6, 7), health: 0, actionsRemaining: 0, hasAttacked: true, ), $this->unit( 'bravo-living', 'bravo', new Position(7, 7), actionsRemaining: 0, hasAttacked: true, ), ], 'alpha', ); $next = (new CombatEngine())->endTurn($match); self::assertSame(0, $next->unit('bravo-defeated')->actionsRemaining); self::assertTrue($next->unit('bravo-defeated')->hasAttacked); self::assertSame(2, $next->unit('bravo-living')->actionsRemaining); self::assertFalse($next->unit('bravo-living')->hasAttacked); } 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, int $attack = 4, int $defense = 2, bool $hasAttacked = false, ): UnitState { return new UnitState( $id, $team, $position, 10, $health, $attack, $defense, $speed, $actionsRemaining, $hasAttacked, ); } }