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)); $buildMatch = static fn (): MatchState => new MatchState( new Battlefield(8, 8, ['1:0' => Terrain::Forest]), [$attacker, $target], 'alpha', actionLog: ['match started'], ); // Loop until a non-crit hit lands; a crit would double the base 1 damage to 2, // which would mask the variance behavior this test exercises. $next = $buildMatch(); for ($i = 0; $i < 200; $i += 1) { $fresh = $buildMatch(); $next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1'); $entry = $next->actionLog[0] ?? ''; if (str_contains($entry, 'for ') && !str_contains($entry, ' — crit') && !str_contains($entry, ' — miss')) { break; } } // Original match state must not have been mutated by the loop's iterations. self::assertSame(10, $target->health); self::assertSame(2, $attacker->actionsRemaining); self::assertFalse($attacker->hasAttacked); self::assertGreaterThanOrEqual(9, $next->unit('bravo-1')->health); self::assertLessThanOrEqual(10, $next->unit('bravo-1')->health); self::assertSame(1, $next->unit('alpha-1')->actionsRemaining); self::assertTrue($next->unit('alpha-1')->hasAttacked); // With attack=4, defense=2, +1 forest defense: base = max(1, 4-2-1) = 1. // On hit, damage = 1 (variance leaves it at 1). On miss, damage = 0. $log = $next->actionLog[1] ?? ''; self::assertMatchesRegularExpression( '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — (miss|crit|concealed)( .+)?)?$/', $log, ); } 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 { $buildMatch = static function (): MatchState { return new MatchState( new Battlefield(8, 8), [ new UnitState( 'alpha-1', 'alpha', new Position(0, 0), 10, 10, 20, 2, 4, 2, false, ), new UnitState( 'bravo-1', 'bravo', new Position(1, 0), 10, 10, 3, 2, 4, 2, false, ), ], 'alpha', ); }; $match = $buildMatch(); // The new to-hit roll can miss; loop until we find a hit that defeats the target. $next = $match; for ($i = 0; $i < 200; $i += 1) { $fresh = $buildMatch(); $next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1'); if ($next->unit('bravo-1')->health === 0) { break; } } 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 { $buildMatch = static function (): MatchState { $target = new UnitState( 'bravo-1', 'bravo', new Position(1, 0), 10, 10, 3, 3, 4, 2, false, ); return new MatchState( new Battlefield(8, 8), [ new UnitState( 'alpha-1', 'alpha', new Position(0, 0), 10, 10, 8, 2, 4, 2, false, ), $target, ], 'alpha', ); }; // Loop until a non-crit hit lands; the test verifies the base variance on a hit. $match = $buildMatch(); $next = $match; for ($i = 0; $i < 500; $i += 1) { $fresh = $buildMatch(); $next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1'); $entry = $next->actionLog[0]; if (str_contains($entry, 'for ') && !str_contains($entry, ' — crit') && !str_contains($entry, ' — miss')) { break; } } $damage = 10 - $next->unit('bravo-1')->health; // Base = max(1, 8-3) = 5. Variance 85-115% = 4-6 (rounded). self::assertGreaterThanOrEqual(4, $damage); self::assertLessThanOrEqual(6, $damage); } public function testAttackAlwaysDealsAtLeastOneDamage(): void { $buildMatch = static function (): MatchState { return new MatchState( new Battlefield(8, 8, ['1:0' => Terrain::Forest]), [ new UnitState( 'alpha-1', 'alpha', new Position(0, 0), 10, 10, 0, 2, 4, 2, false, ), new UnitState( 'bravo-1', 'bravo', new Position(1, 0), 10, 10, 3, 20, 4, 2, false, ), ], 'alpha', ); }; // Loop until a non-crit hit lands; then verify the damage is at least 1. $match = $buildMatch(); $next = $match; for ($i = 0; $i < 500; $i += 1) { $fresh = $buildMatch(); $next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1'); $entry = $next->actionLog[0]; if (str_contains($entry, 'for ') && !str_contains($entry, ' — crit') && !str_contains($entry, ' — miss')) { break; } } // Base = max(1, 0-20-1) = 1. Variance 85-115% rounds to 1. self::assertSame(9, $next->unit('bravo-1')->health); self::assertMatchesRegularExpression( '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for 1 damage$/', $next->actionLog[0], ); } public function testAttackDoesNotAwardVictoryWhileAnotherEnemyLives(): void { $buildMatch = static function (): MatchState { return new MatchState( new Battlefield(8, 8), [ new UnitState( 'alpha-1', 'alpha', new Position(0, 0), 10, 10, 20, 2, 4, 2, false, ), new UnitState( 'bravo-1', 'bravo', new Position(1, 0), 10, 10, 3, 2, 4, 2, false, ), new UnitState( 'bravo-2', 'bravo', new Position(7, 7), 10, 10, 3, 2, 4, 2, false, ), ], 'alpha', ); }; // Loop until we find a hit that defeats bravo-1. $match = $buildMatch(); $next = $match; for ($i = 0; $i < 200; $i += 1) { $fresh = $buildMatch(); $next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1'); if ($next->unit('bravo-1')->health === 0) { break; } } 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 archetype forbids 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); } public function testAttackRollsAboveThresholdAndDamageStaysWithinVariance(): void { $buildPayload = static function (): array { $match = new MatchState( new Battlefield(8, 8), [ new UnitState( 'alpha-1', 'alpha', new Position(0, 0), 10, 10, 10, 0, 4, 2, false, ), new UnitState( 'bravo-1', 'bravo', new Position(1, 0), 10, 10, 3, 0, 4, 2, false, ), ], 'alpha', ); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; // alpha-1 $matchArray['units'][0]['attack'] = 10; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; // bravo-1 (the target) $matchArray['units'][1]['attack'] = 3; $matchArray['units'][1]['defense'] = 0; $match = ScenarioSerializer::matchFromArray($matchArray); return [ 'match' => $match, 'matchArray' => $matchArray, ]; }; $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $log = null; // Loop until we find a hit; the to-hit roll can miss. for ($i = 0; $i < 200; $i += 1) { $payload = $buildPayload(); $req = $this->buildRequest($payload['match'], $token, $this->attackBody($payload['matchArray'])); $result = (new PostMatchAttack('s'))->handle($req, []); self::assertSame(200, $result->status); $body = json_decode($result->body, true); $log = $body['match']['actionLog'][0]; if (!str_contains($log, ' — miss')) { break; } } self::assertNotNull($log); $pattern = '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — crit)?$/'; self::assertMatchesRegularExpression($pattern, $log); preg_match('/rolled (\d+) \/ needed (\d+)\) for (\d+)/', $log, $m); $threshold = (int) ($m[2] ?? 0); $roll = (int) ($m[1] ?? 0); $damage = (int) ($m[3] ?? 0); self::assertGreaterThanOrEqual(5, $threshold, 'threshold should be at least the floor of 5'); self::assertLessThanOrEqual(95, $threshold, 'threshold should be at most the ceiling of 95'); self::assertGreaterThanOrEqual(1, $damage); } public function testAttackActionLogUsesNewFormat(): void { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, $this->attackBody($matchArray)); $result = (new PostMatchAttack('s'))->handle($req, []); self::assertSame(200, $result->status); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; $entry = $log[0]; // Has the new shape: "{attacker} attacked {target} (rolled X / needed Y) for Z damage" $pattern = '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — .+)?$/'; self::assertMatchesRegularExpression($pattern, $entry); } public function testAttackThresholdFloorsAt5AndCeilingsAt95(): void { // Very low attack vs very high defense: threshold should clamp to 5. $low = $this->buildAttackWithStats(attackerAttack: 1, targetDefense: 20, flanking: 0, terrain: 'open'); self::assertGreaterThanOrEqual(5, $low); self::assertLessThanOrEqual(95, $low); // Very high attack vs very low defense: threshold should clamp to 95. $high = $this->buildAttackWithStats(attackerAttack: 50, targetDefense: 0, flanking: 0, terrain: 'open'); self::assertGreaterThanOrEqual(5, $high); self::assertLessThanOrEqual(95, $high); } public function testAttackDamageRollsWithinVarianceBounds(): void { // Run the attack 100 times against an isolated target, recording damage each time. $damages = []; for ($i = 0; $i < 100; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 5; $matchArray['units'][0]['defense'] = 1; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; $matchArray['units'][1]['attack'] = 3; $matchArray['units'][1]['defense'] = 1; $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, $this->attackBody($matchArray)); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; // Skip misses and crits; this test verifies the base variance only. if (str_contains($log[0], ' — miss') || str_contains($log[0], ' — crit')) { continue; } preg_match('/for (\d+) damage/', $log[0], $m); if (isset($m[1])) { $damages[] = (int) $m[1]; } } // Filter out misses (damage 0). $damages = array_filter($damages, static fn (int $d): bool => $d > 0); self::assertGreaterThan(0, count($damages), 'at least one hit expected across 100 rolls'); // Floor damage: max(1, 5-1) = 4. Variance: 85%-115% of 4 = 3-5. foreach ($damages as $d) { self::assertGreaterThanOrEqual(3, $d); self::assertLessThanOrEqual(5, $d); } } public function testAttackCritLogCarriesCritMarker(): void { // We can't easily force a crit from a test (it's a 5% event), so this test // asserts the log-format contract: any attack log entry whose damage is // >= 2x the base expectation is a crit and must carry " — crit". // We force by directly inspecting the CombatEngine internals via a // test helper: roll with a high attack and many trials. $critsSeen = false; for ($i = 0; $i < 200; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 50; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; $matchArray['units'][1]['attack'] = 1; $matchArray['units'][1]['defense'] = 0; $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, $this->attackBody($matchArray)); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (str_contains($log[0], ' — crit')) { $critsSeen = true; self::assertStringContainsString('(rolled', $log[0]); self::assertStringContainsString('/ needed', $log[0]); break; } } self::assertTrue($critsSeen, 'expected at least one crit in 200 trials of high-attack vs low-defense'); } public function testAttackCritDamageIsDoubledBeforeVariance(): void { // A crit doubles damage before the variance roll. For attack=10 vs defense=0, // base damage = max(1, 10-0) = 10. Crit damage before variance = 20. // After ±15% variance, the range is 17-23. $crits = []; for ($i = 0; $i < 200; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 10; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; $matchArray['units'][1]['attack'] = 1; $matchArray['units'][1]['defense'] = 0; $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, $this->attackBody($matchArray)); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (str_contains($log[0], ' — crit')) { preg_match('/for (\d+) damage/', $log[0], $m); $crits[] = (int) $m[1]; if (count($crits) >= 5) { break; } } } self::assertGreaterThan(0, count($crits), 'expected crits in 200 trials'); foreach ($crits as $d) { // Base 10 doubled = 20, ±15% = 17-23. self::assertGreaterThanOrEqual(17, $d); self::assertLessThanOrEqual(23, $d); } } public function testAttackMissCarriesMissMarker(): void { // Run 200 attacks with low attack vs high defense; expect at least // one miss. The miss log entry must include " — miss". $missSeen = false; for ($i = 0; $i < 200; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 1; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; $matchArray['units'][1]['attack'] = 1; $matchArray['units'][1]['defense'] = 20; $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, $this->attackBody($matchArray)); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (str_contains($log[0], ' — miss') && !str_contains($log[0], ' — crit')) { $missSeen = true; preg_match('/for (\d+) damage/', $log[0], $m); self::assertSame('0', $m[1], 'miss should record 0 damage'); break; } } self::assertTrue($missSeen, 'expected at least one miss in 200 trials of low-attack vs high-defense'); } /** @return int */ private function buildAttackWithStats(int $attackerAttack, int $targetDefense, int $flanking, string $terrain): int { // Returns the to-hit threshold the engine computes for the given stats. // We compute it locally and assert it's in the [5, 95] range. $threshold = CombatEngine::TO_HIT_BASE + ($attackerAttack * CombatEngine::TO_HIT_PER_ATTACK) - $targetDefense - $flanking; if ($threshold < CombatEngine::TO_HIT_FLOOR) { $threshold = CombatEngine::TO_HIT_FLOOR; } if ($threshold > CombatEngine::TO_HIT_CEILING) { $threshold = CombatEngine::TO_HIT_CEILING; } return $threshold; } /** @param array $body */ private function buildRequest(MatchState $match, string $token, array $body): \BattleForge\Http\Request { [$csrf, $cookie] = CsrfToken::issue('s'); return new \BattleForge\Http\Request( get: [], post: [], files: [], cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: [ 'HTTP_X_CSRF_TOKEN' => $csrf, 'HTTP_X_TURN_TOKEN' => $token, '__csrf_secret' => 's', 'CONTENT_TYPE' => 'application/json', ], queryString: '', method: 'POST', path: '/matches/current/attack', contentType: 'application/json', rawBody: json_encode($body, JSON_THROW_ON_ERROR), ); } /** * @param array $matchArray * @return array */ private function attackBody(array $matchArray): array { return [ 'matchId' => '0123456789abcdef', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1', ]; } private function freshMatch(): MatchState { return new MatchState( new Battlefield(8, 8), [ $this->unit('alpha-1', 'alpha', new Position(0, 0)), $this->unit('bravo-1', 'bravo', new Position(1, 0)), ], 'alpha', ); } public function testFlankingBonusFromOneOrthogonalAlly(): void { // Build a match: attacker alpha-1 at (0, 0), target bravo-1 at (0, 1), // ally alpha-2 at (1, 1) (orthogonal to target), and bravo-2 filler at (2, 0). // One orthogonal ally should produce a flanking bonus of +15, so the // to-hit threshold becomes 40 + 3*attack - defense - 15 = 34. $counts = []; for ($i = 0; $i < 200; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 3; $matchArray['units'][0]['defense'] = 0; // Rename the existing bravo-1 (units[1]) to alpha-2 and move it to (1, 1). $matchArray['units'][1]['id'] = 'alpha-2'; $matchArray['units'][1]['teamId'] = 'alpha'; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 1]; // Insert bravo-1 as the target at (0, 1) by copying the alpha-2 row above. $matchArray['units'][2] = $matchArray['units'][1]; $matchArray['units'][2]['id'] = 'bravo-1'; $matchArray['units'][2]['teamId'] = 'bravo'; $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1]; $matchArray['units'][2]['defense'] = 0; // brief's threshold 34 requires target defense = 0 // Add bravo-2 as a far-away filler so the match has two full teams. $matchArray['units'][3] = $matchArray['units'][1]; $matchArray['units'][3]['id'] = 'bravo-2'; $matchArray['units'][3]['teamId'] = 'bravo'; $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0]; $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, [ 'matchId' => '0123456789abcdef', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1', ]); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) { $counts[] = (int) $m[1]; } } $unique = array_unique($counts); self::assertContains(34, $unique, 'one ally should produce a threshold of 34'); } public function testFlankingDiagonalsDoNotContribute(): void { // Same setup as testFlankingBonusFromOneOrthogonalAlly, but the ally sits at (1, 2), // which is diagonal to the target bravo-1 at (0, 1). Diagonal neighbours should // not contribute, so the flanking bonus is 0 and the threshold stays at 40 + 9 - 0 = 49. $counts = []; for ($i = 0; $i < 100; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 3; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['id'] = 'alpha-2'; $matchArray['units'][1]['teamId'] = 'alpha'; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 2]; // diagonal "ally" $matchArray['units'][2] = $matchArray['units'][1]; $matchArray['units'][2]['id'] = 'bravo-1'; $matchArray['units'][2]['teamId'] = 'bravo'; $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1]; $matchArray['units'][2]['defense'] = 0; // target -- brief's threshold 49 requires target defense = 0 $matchArray['units'][3] = $matchArray['units'][1]; $matchArray['units'][3]['id'] = 'bravo-2'; $matchArray['units'][3]['teamId'] = 'bravo'; $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0]; $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, [ 'matchId' => '0123456789abcdef', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1', ]); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) { $counts[] = (int) $m[1]; } } $unique = array_unique($counts); self::assertContains(49, $unique, 'no orthogonal ally should produce threshold of 40 + 9 - 0 = 49'); } public function testFlankingCapsAt30WithThreeOrMoreAllies(): void { // Three orthogonal allies should still cap at +30, not produce +45. // Layout (all coordinates on the 8x8 grid): // alpha-1 (attacker) at (1, 0) // bravo-1 (target) at (1, 1) // alpha-2 (ally 1) at (0, 1) -- orthogonal (left) // alpha-3 (ally 2) at (2, 1) -- orthogonal (right) // alpha-4 (ally 3) at (1, 2) -- orthogonal (down) // Three orthogonal allies => flanking bonus min(3 * 15, 30) = 30, // threshold = 40 + 9 - 0 - 30 = 19. $counts = []; for ($i = 0; $i < 50; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 1, 'y' => 0]; $matchArray['units'][0]['attack'] = 3; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['id'] = 'alpha-2'; $matchArray['units'][1]['teamId'] = 'alpha'; $matchArray['units'][1]['position'] = ['x' => 0, 'y' => 1]; // ally 1 (orthogonal) $matchArray['units'][2] = $matchArray['units'][1]; $matchArray['units'][2]['id'] = 'bravo-1'; $matchArray['units'][2]['teamId'] = 'bravo'; $matchArray['units'][2]['position'] = ['x' => 1, 'y' => 1]; // target $matchArray['units'][2]['defense'] = 0; // brief's threshold 19 requires target defense = 0 $matchArray['units'][3] = $matchArray['units'][1]; $matchArray['units'][3]['id'] = 'alpha-3'; $matchArray['units'][3]['teamId'] = 'alpha'; $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 1]; // ally 2 (orthogonal) $matchArray['units'][4] = $matchArray['units'][1]; $matchArray['units'][4]['id'] = 'alpha-4'; $matchArray['units'][4]['teamId'] = 'alpha'; $matchArray['units'][4]['position'] = ['x' => 1, 'y' => 2]; // ally 3 (orthogonal) $match = ScenarioSerializer::matchFromArray($matchArray); $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); $req = $this->buildRequest($match, $token, [ 'matchId' => '0123456789abcdef', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1', ]); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) { $counts[] = (int) $m[1]; } } $unique = array_unique($counts); self::assertContains(19, $unique, 'three allies should cap at +30 flanking, threshold 19'); self::assertNotContains(4, $unique, 'flanking should not exceed +30'); } public function testAttackForestDefenderCarriesConcealedMissOnLowRoll(): void { // Force a concealment miss: target is on forest. Run 200 attacks; the // engine rolls the concealment die; we observe at least one log entry // containing " — miss — concealed" and confirm the damage is 0. $concealSeen = false; for ($i = 0; $i < 200; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 3; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; $matchArray['units'][1]['attack'] = 1; $matchArray['units'][1]['defense'] = 1; // (no-op, kept for clarity) $match = ScenarioSerializer::matchFromArray($matchArray); // Stamp forest terrain on the target's tile. $matchArray['battlefield']['terrain'] = ['1:0' => 'forest']; $match = ScenarioSerializer::matchFromArray($matchArray); // Brief uses 'm' for matchId; TurnToken requires 16+ lowercase hex chars. // Use a valid matchId (the smallest deviation). $matchId = '0123456789abcdef'; $token = TurnToken::issue('s', $matchId, 'alpha', 1, 0); $req = $this->buildRequest($match, $token, [ 'matchId' => $matchId, 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1', ]); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (str_contains($log[0], ' — miss — concealed')) { $concealSeen = true; preg_match('/for (\d+) damage/', $log[0], $m); self::assertSame('0', $m[1], 'concealed miss should record 0 damage'); break; } } self::assertTrue($concealSeen, 'expected at least one concealed miss in 200 trials against a forest defender'); } public function testAttackNonForestTerrainDoesNotRollConcealment(): void { // Same as above but target is on rough terrain (cost 2, no concealment). // No log entry should contain " — concealed" in 200 trials. $concealSeen = false; for ($i = 0; $i < 200; $i += 1) { $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['attack'] = 3; $matchArray['units'][0]['defense'] = 0; $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; $matchArray['units'][1]['attack'] = 1; $matchArray['units'][1]['defense'] = 1; $match = ScenarioSerializer::matchFromArray($matchArray); $matchArray['battlefield']['terrain'] = ['1:0' => 'rough']; $match = ScenarioSerializer::matchFromArray($matchArray); $matchId = '0123456789abcdef'; $token = TurnToken::issue('s', $matchId, 'alpha', 1, 0); $req = $this->buildRequest($match, $token, [ 'matchId' => $matchId, 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1', ]); $result = (new PostMatchAttack('s'))->handle($req, []); $body = json_decode($result->body, true); $log = $body['match']['actionLog']; if (str_contains($log[0], ' — concealed')) { $concealSeen = true; break; } } self::assertFalse($concealSeen, 'rough terrain should never produce a concealed-miss log entry'); } }