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 exactly two teams.'); (new CombatEngine())->endTurn($match); } /** * @return iterable}> */ public static function invalidEndTurnTeamProvider(): iterable { yield 'only alpha' => [['alpha']]; 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, int $attackBonus = 0, ): UnitState { return new UnitState( $id, $team, $position, 10, $health, $attack, $defense, $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)); } public function testEndTurnTalliesObjectiveControlAfterAFullRound(): void { $alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4)); $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); $match = new MatchState( new Battlefield(8, 8), [$alpha, $bravo], 'alpha', objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))], victoryCondition: VictoryCondition::HoldObjective, holdRoundsRequired: 2, ); $afterAlphaEnd = (new CombatEngine())->endTurn($match); $afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd); self::assertSame(['alpha' => 1], $afterAlphaEnd->objectiveControl); self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl); self::assertNull($afterBravoEnd->winnerTeamId); } public function testEndTurnDeclaresWinnerWhenHoldRoundsReached(): void { $alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4)); $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); $match = new MatchState( new Battlefield(8, 8), [$alpha, $bravo], 'alpha', objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))], victoryCondition: VictoryCondition::HoldObjective, holdRoundsRequired: 1, objectiveControl: ['alpha' => 0], ); $afterAlphaEnd = (new CombatEngine())->endTurn($match); $afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd); self::assertSame('alpha', $afterBravoEnd->winnerTeamId); self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl); } public function testEndTurnDoesNotAwardControlWhenNoTeamStandsOnTheObjective(): void { $alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0)); $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); $match = new MatchState( new Battlefield(8, 8), [$alpha, $bravo], 'alpha', objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))], victoryCondition: VictoryCondition::HoldObjective, holdRoundsRequired: 1, ); $next = (new CombatEngine())->endTurn($match); self::assertSame([], $next->objectiveControl); self::assertNull($next->winnerTeamId); } }