diff --git a/src/Domain/MatchState.php b/src/Domain/MatchState.php index 5e2e6f8..06bf1dc 100644 --- a/src/Domain/MatchState.php +++ b/src/Domain/MatchState.php @@ -11,6 +11,8 @@ final readonly class MatchState /** * @param list $units * @param list $actionLog + * @param array $objectives + * @param array $objectiveControl */ public function __construct( public Battlefield $battlefield, @@ -19,6 +21,10 @@ final readonly class MatchState public int $round = 1, public ?string $winnerTeamId = null, public array $actionLog = [], + public array $objectives = [], + public VictoryCondition $victoryCondition = VictoryCondition::EliminateAll, + public int $holdRoundsRequired = 1, + public array $objectiveControl = [], ) { if (!self::isList($units)) { throw new InvalidArgumentException('Match units must be a list.'); @@ -92,6 +98,28 @@ final readonly class MatchState if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) { throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}."); } + + if ($holdRoundsRequired < 1) { + throw new InvalidArgumentException('Hold rounds required must be at least 1.'); + } + + foreach ($objectives as $objective) { + if (!$objective instanceof ObjectiveMarker) { // @phpstan-ignore instanceof.alwaysTrue (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.) + throw new InvalidArgumentException('Objectives must be ObjectiveMarker instances.'); + } + if (!$battlefield->contains($objective->position)) { + throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield."); + } + } + + foreach ($objectiveControl as $teamId => $count) { + if (!self::isString($teamId) || $teamId === '') { + throw new InvalidArgumentException('Objective control team id must be a non-empty string.'); + } + if (!self::isInt($count) || $count < 0) { + throw new InvalidArgumentException('Objective control count must be a non-negative integer.'); + } + } } private static function isUnitState(mixed $unit): bool @@ -104,6 +132,11 @@ final readonly class MatchState return is_string($value); } + private static function isInt(mixed $value): bool + { + return is_int($value); + } + private static function isList(mixed $value): bool { return is_array($value) && array_is_list($value); @@ -144,18 +177,45 @@ final readonly class MatchState $this->round, $winnerTeamId, $this->actionLog, + $this->objectives, + $this->victoryCondition, + $this->holdRoundsRequired, + $this->objectiveControl, + ); + } + + /** + * @param array $objectiveControl + */ + public function withObjectiveControl(array $objectiveControl): self + { + return new self( + $this->battlefield, + $this->units, + $this->activeTeamId, + $this->round, + $this->winnerTeamId, + $this->actionLog, + $this->objectives, + $this->victoryCondition, + $this->holdRoundsRequired, + $objectiveControl, ); } /** * @param list|null $units * @param list|null $actionLog + * @param array|null $objectives + * @param array|null $objectiveControl */ public function copy( ?array $units = null, ?string $activeTeamId = null, ?int $round = null, ?array $actionLog = null, + ?array $objectives = null, + ?array $objectiveControl = null, ): self { return new self( $this->battlefield, @@ -164,6 +224,10 @@ final readonly class MatchState $round ?? $this->round, $this->winnerTeamId, $actionLog ?? $this->actionLog, + $objectives ?? $this->objectives, + $this->victoryCondition, + $this->holdRoundsRequired, + $objectiveControl ?? $this->objectiveControl, ); } } diff --git a/src/Domain/Scenario.php b/src/Domain/Scenario.php new file mode 100644 index 0000000..4a3c4ac --- /dev/null +++ b/src/Domain/Scenario.php @@ -0,0 +1,161 @@ + $units + * @param array $deploymentZones + * @param array $objectives + */ + public function __construct( + public string $id, + public string $name, + public Battlefield $battlefield, + public array $units, + public array $deploymentZones, + public array $objectives, + public VictoryCondition $victoryCondition, + public int $holdRoundsRequired, + ) { + if ($id === '') { + throw new InvalidArgumentException('Scenario id cannot be empty.'); + } + + if ($name === '') { + throw new InvalidArgumentException('Scenario name cannot be empty.'); + } + + if (!array_is_list($units)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.) + throw new InvalidArgumentException('Scenario units must be a list.'); + } + + if ($deploymentZones === []) { + throw new InvalidArgumentException('Scenario must declare at least one deployment zone.'); + } + + if (count($deploymentZones) !== 2) { + throw new InvalidArgumentException('Scenarios must declare exactly two deployment zones.'); + } + + if ($holdRoundsRequired < 1) { + throw new InvalidArgumentException('Hold rounds required must be at least 1.'); + } + + if ($victoryCondition === VictoryCondition::HoldObjective && count($this->objectives) !== 1) { + throw new InvalidArgumentException('Hold objective victory requires exactly one objective.'); + } + + if ($victoryCondition === VictoryCondition::EliminateAll && $this->objectives !== []) { + throw new InvalidArgumentException('Eliminate all victory does not allow objectives.'); + } + + $unitsByTeam = $this->groupUnitsByTeam($units); + + foreach ($unitsByTeam as $teamId => $teamUnits) { + if (count($teamUnits) < 3 || count($teamUnits) > 6) { + throw new InvalidArgumentException("Team {$teamId} must have between 3 and 6 units."); + } + + if (!isset($deploymentZones[$teamId])) { + throw new InvalidArgumentException("Team {$teamId} is missing a deployment zone."); + } + } + + foreach ($units as $unit) { + if (!$this->battlefield->contains($unit->position)) { + throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield."); + } + + if (!ArchetypeCatalog::templates()[$unit->archetype->value] ?? false) { // @phpstan-ignore nullCoalesce.expr, if.alwaysFalse, booleanNot.alwaysFalse (Runtime guard: defense in depth against an unknown Archetype enum value.) + throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype."); + } + + $template = ArchetypeCatalog::templates()[$unit->archetype->value]; + + if ($unit->maxHealth < $template->minHealth || $unit->maxHealth > $template->maxHealth) { + throw new InvalidArgumentException("Unit {$unit->id} health is outside archetype bounds."); + } + if ($unit->attack < $template->minAttack || $unit->attack > $template->maxAttack) { + throw new InvalidArgumentException("Unit {$unit->id} attack is outside archetype bounds."); + } + if ($unit->defense < $template->minDefense || $unit->defense > $template->maxDefense) { + throw new InvalidArgumentException("Unit {$unit->id} defense is outside archetype bounds."); + } + if ($unit->speed < $template->minSpeed || $unit->speed > $template->maxSpeed) { + throw new InvalidArgumentException("Unit {$unit->id} speed is outside archetype bounds."); + } + + foreach ($unit->abilities as $ability) { + if (!in_array($ability, $template->allowedAbilities, true)) { + throw new InvalidArgumentException("Unit {$unit->id} has an ability that its archetype forbids."); + } + } + + $zone = $deploymentZones[$unit->teamId] ?? null; + if ($zone === null || !$zone->contains($unit->position)) { + throw new InvalidArgumentException("Unit {$unit->id} must start inside team {$unit->teamId} deployment zone."); + } + } + + foreach ($objectives as $objective) { + if (!$this->battlefield->contains($objective->position)) { + throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield."); + } + } + } + + public function startMatch(string $activeTeamId): MatchState + { + $resetUnits = []; + + foreach ($this->units as $unit) { + $resetUnits[] = new UnitState( + $unit->id, + $unit->teamId, + $unit->position, + $unit->maxHealth, + $unit->maxHealth, + $unit->attack, + $unit->defense, + $unit->speed, + 2, + false, + $unit->archetype, + $unit->abilities, + 0, + false, + ); + } + + return new MatchState( + battlefield: $this->battlefield, + units: $resetUnits, + activeTeamId: $activeTeamId, + objectives: $this->objectives, + victoryCondition: $this->victoryCondition, + holdRoundsRequired: $this->holdRoundsRequired, + ); + } + + /** + * @param list $units + * @return array> + */ + private function groupUnitsByTeam(array $units): array + { + $grouped = []; + + foreach ($units as $unit) { + $grouped[$unit->teamId] ??= []; + $grouped[$unit->teamId][] = $unit; + } + + return $grouped; + } +} diff --git a/tests/Unit/Domain/ScenarioTest.php b/tests/Unit/Domain/ScenarioTest.php new file mode 100644 index 0000000..18dad5b --- /dev/null +++ b/tests/Unit/Domain/ScenarioTest.php @@ -0,0 +1,245 @@ +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, + ); + } +}