From 6fffaa230697121e9a550481d4f796f93d66fac9 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 20:11:35 -0500 Subject: [PATCH] feat: validate scenario deployment zones and objectives --- src/Domain/ScenarioValidator.php | 82 ++++++++++ tests/Unit/Domain/ScenarioValidatorTest.php | 162 ++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 src/Domain/ScenarioValidator.php create mode 100644 tests/Unit/Domain/ScenarioValidatorTest.php diff --git a/src/Domain/ScenarioValidator.php b/src/Domain/ScenarioValidator.php new file mode 100644 index 0000000..e90bbe5 --- /dev/null +++ b/src/Domain/ScenarioValidator.php @@ -0,0 +1,82 @@ + + */ + public static function validate(Scenario $scenario): array + { + $errors = []; + + $occupied = []; + + foreach ($scenario->deploymentZones as $teamId => $zone) { + foreach ($zone->positions as $position) { + if (!$scenario->battlefield->contains($position)) { + $errors[] = "Deployment zone for {$teamId} includes a position outside the battlefield."; + continue; + } + if ($scenario->battlefield->terrainAt($position)->movementCost() === null) { + $errors[] = "Deployment zone for {$teamId} sits on impassable terrain at {$position->key()}."; + } + if (isset($occupied[$position->key()])) { + $errors[] = "Deployment zones overlap at {$position->key()}."; + } + $occupied[$position->key()] = $teamId; + } + } + + $reachableFromDeployment = self::reachableFromDeployment($scenario, $occupied); + + foreach ($scenario->objectives as $objective) { + if (!$scenario->battlefield->contains($objective->position)) { + $errors[] = "Objective {$objective->id} is outside the battlefield."; + continue; + } + if ($scenario->battlefield->terrainAt($objective->position)->movementCost() === null) { + $errors[] = "Objective {$objective->id} sits on impassable terrain."; + } + if (!isset($reachableFromDeployment[$objective->position->key()])) { + $errors[] = "Objective {$objective->id} is unreachable from any deployment zone."; + } + } + + return $errors; + } + + /** + * @param array $occupied + * @return array + */ + private static function reachableFromDeployment(Scenario $scenario, array $occupied): array + { + $merged = $scenario->units; + $allReachable = []; + + foreach ($merged as $unit) { + $others = []; + + foreach ($merged as $other) { + if ($other->id === $unit->id || $other->isDefeated()) { + continue; + } + $others[] = $other->position; + } + + $reachable = $scenario->battlefield->reachable($unit->position, $unit->speed, $others); + + foreach ($reachable as $key => $_cost) { + if (!isset($occupied[$key])) { + $allReachable[$key] = true; + } + } + } + + return $allReachable; + } +} diff --git a/tests/Unit/Domain/ScenarioValidatorTest.php b/tests/Unit/Domain/ScenarioValidatorTest.php new file mode 100644 index 0000000..27f4342 --- /dev/null +++ b/tests/Unit/Domain/ScenarioValidatorTest.php @@ -0,0 +1,162 @@ +validScenario(); + + self::assertSame([], ScenarioValidator::validate($scenario)); + } + + public function testItRejectsAnObjectiveOnImpassableTerrain(): void + { + $scenario = $this->validScenario( + terrain: ['4:4' => Terrain::Water], + objectivePosition: new Position(4, 4), + ); + + $errors = ScenarioValidator::validate($scenario); + + self::assertNotEmpty($errors); + self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable'))); + } + + public function testItRejectsAnObjectiveUnreachableFromAnyDeploymentZone(): void + { + $scenario = $this->validScenario( + terrain: [ + '3:4' => Terrain::Blocking, + '4:3' => Terrain::Blocking, + '5:4' => Terrain::Blocking, + '4:5' => Terrain::Blocking, + ], + objectivePosition: new Position(4, 4), + ); + + $errors = ScenarioValidator::validate($scenario); + + self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'unreachable'))); + } + + public function testItRejectsOverlappingDeploymentZones(): void + { + $units = $this->unitSet(); + $battlefield = new Battlefield(8, 8); + $scenario = new Scenario( + id: 'demo', + name: 'Demo', + battlefield: $battlefield, + units: $units, + deploymentZones: [ + 'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]), + 'bravo' => new DeploymentZone('bravo', [new Position(1, 0), new Position(2, 0), new Position(7, 7), new Position(6, 7), new Position(5, 7)]), + ], + objectives: [], + victoryCondition: VictoryCondition::EliminateAll, + holdRoundsRequired: 1, + ); + + $errors = ScenarioValidator::validate($scenario); + + self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'overlap'))); + } + + public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void + { + $units = $this->unitSet(); + $battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]); + $scenario = new Scenario( + id: 'demo', + name: 'Demo', + battlefield: $battlefield, + units: $units, + 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, + ); + + $errors = ScenarioValidator::validate($scenario); + + self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable'))); + } + + /** + * @param array $terrain + */ + private function validScenario( + array $terrain = [], + ?Position $objectivePosition = null, + ): Scenario { + $units = $this->unitSet(); + $battlefield = new Battlefield(8, 8, $terrain); + $objectives = []; + + if ($objectivePosition !== null) { + $objectives = ['objective-1' => new ObjectiveMarker('objective-1', $objectivePosition)]; + } + + return new Scenario( + id: 'demo', + name: 'Demo', + battlefield: $battlefield, + units: $units, + deploymentZones: $this->zones(), + objectives: $objectives, + victoryCondition: $objectivePosition === null + ? VictoryCondition::EliminateAll + : VictoryCondition::HoldObjective, + holdRoundsRequired: 1, + ); + } + + /** @return array */ + private function zones(): 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), + ]), + ]; + } + + /** @return list */ + private function unitSet(): array + { + return [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 3, 2, 2, false, Archetype::Defender), + new UnitState('alpha-2', 'alpha', new Position(1, 0), 8, 8, 5, 2, 3, 2, false, Archetype::Striker), + new UnitState('alpha-3', 'alpha', new Position(2, 0), 7, 7, 2, 2, 3, 2, false, Archetype::Support), + new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 3, 2, 2, false, Archetype::Defender), + new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 3, 1, 5, 2, false, Archetype::Scout), + ]; + } +}