expectException(InvalidArgumentException::class); new Scenario( id: 'demo', name: 'Demo', battlefield: new Battlefield(8, 8), units: ['alpha-1' => $this->unit('alpha-1', 'alpha', new Position(0, 0))], // @phpstan-ignore argument.type (Test fixture intentionally passes a non-list units array to verify constructor validation.) deploymentZones: ['alpha' => new DeploymentZone('alpha', [new Position(0, 0)])], objectives: [], victoryCondition: VictoryCondition::EliminateAll, holdRoundsRequired: 1, ); } public function testItRejectsTeamsOutsideTheThreeToSixSizeWindow(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Team alpha must have between 3 and 6 units.'); $units = [ $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)), $this->unit('bravo-2', 'bravo', new Position(6, 7)), $this->unit('bravo-3', 'bravo', new Position(5, 7)), ]; new Scenario( id: 'demo', name: 'Demo', battlefield: new Battlefield(8, 8), units: $units, deploymentZones: [ 'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0)]), 'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]), ], objectives: [], victoryCondition: VictoryCondition::EliminateAll, holdRoundsRequired: 1, ); } public function testItRejectsAbilitiesOutsideTheArchetypeAllowlist(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unit alpha-1 has an ability that its archetype forbids.'); $bad = $this->unit('alpha-1', 'alpha', new Position(0, 0), archetype: Archetype::Scout, abilities: ['heal'], attack: 3, defense: 1, speed: 5, maxHealth: 6); $alpha2 = $this->unit('alpha-2', 'alpha', new Position(1, 0)); $alpha3 = $this->unit('alpha-3', 'alpha', new Position(2, 0)); $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); $bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7)); $bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7)); new Scenario( id: 'demo', name: 'Demo', battlefield: new Battlefield(8, 8), units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3], deploymentZones: [ 'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]), 'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]), ], objectives: [], victoryCondition: VictoryCondition::EliminateAll, holdRoundsRequired: 1, ); } public function testItRejectsUnitsOutsideTheirDeploymentZone(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unit alpha-1 must start inside team alpha deployment zone.'); $bad = $this->unit('alpha-1', 'alpha', new Position(3, 0)); $alpha2 = $this->unit('alpha-2', 'alpha', new Position(0, 0)); $alpha3 = $this->unit('alpha-3', 'alpha', new Position(1, 0)); $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7)); $bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7)); $bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7)); new Scenario( id: 'demo', name: 'Demo', battlefield: new Battlefield(8, 8), units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3], deploymentZones: [ 'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]), 'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]), ], objectives: [], victoryCondition: VictoryCondition::EliminateAll, holdRoundsRequired: 1, ); } public function testHoldObjectiveRequiresExactlyOneObjective(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Hold objective victory requires exactly one objective.'); $units = $this->validUnitSet(); $zones = $this->validZones(); new Scenario( id: 'demo', name: 'Demo', battlefield: new Battlefield(8, 8), units: $units, deploymentZones: $zones, objectives: [], victoryCondition: VictoryCondition::HoldObjective, holdRoundsRequired: 3, ); } public function testStartMatchReturnsAFreshMatchStateWithResetUnits(): void { $scenario = $this->validScenario(); $match = $scenario->startMatch('alpha'); self::assertInstanceOf(MatchState::class, $match); self::assertSame('alpha', $match->activeTeamId); self::assertSame(1, $match->round); self::assertNull($match->winnerTeamId); foreach ($match->units as $unit) { self::assertSame(2, $unit->actionsRemaining); self::assertFalse($unit->hasAttacked); self::assertSame(0, $unit->attackBonus); self::assertFalse($unit->hasUsedAbility); } } public function testStartMatchDoesNotShareUnitInstancesWithTheScenario(): void { $scenario = $this->validScenario(); $match = $scenario->startMatch('alpha'); $original = $scenario->units[0]; $fromMatch = $match->unit($original->id); self::assertNotSame($original, $fromMatch); self::assertSame($original->id, $fromMatch->id); } /** @return list */ private function validUnitSet(): array { return [ $this->unit('alpha-1', 'alpha', new Position(0, 0)), $this->unit('alpha-2', 'alpha', new Position(1, 0)), $this->unit('alpha-3', 'alpha', new Position(2, 0)), $this->unit('bravo-1', 'bravo', new Position(7, 7)), $this->unit('bravo-2', 'bravo', new Position(6, 7)), $this->unit('bravo-3', 'bravo', new Position(5, 7)), ]; } /** @return array */ private function validZones(): array { return [ 'alpha' => new DeploymentZone('alpha', [ new Position(0, 0), new Position(1, 0), new Position(2, 0), ]), 'bravo' => new DeploymentZone('bravo', [ new Position(7, 7), new Position(6, 7), new Position(5, 7), ]), ]; } private function validScenario(): Scenario { return new Scenario( id: 'demo', name: 'Demo', battlefield: new Battlefield(8, 8), units: $this->validUnitSet(), deploymentZones: $this->validZones(), objectives: [], victoryCondition: VictoryCondition::EliminateAll, holdRoundsRequired: 1, ); } /** * @param list $abilities */ private function unit( string $id, string $team, Position $position, Archetype $archetype = Archetype::Defender, array $abilities = [], int $attack = 4, int $defense = 3, int $speed = 3, int $maxHealth = 10, ): UnitState { return new UnitState( $id, $team, $position, $maxHealth, $maxHealth, $attack, $defense, $speed, 2, false, $archetype, $abilities, 0, false, ); } }