From 0f4a220e7092f904aff770344c5be0fdc0dec67b Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 18:46:38 -0500 Subject: [PATCH] feat: add ScenarioDraft form-field adapter --- src/Application/ScenarioDraft.php | 215 +++++++++++++++++++ tests/Unit/Application/ScenarioDraftTest.php | 127 +++++++++++ 2 files changed, 342 insertions(+) create mode 100644 src/Application/ScenarioDraft.php create mode 100644 tests/Unit/Application/ScenarioDraftTest.php diff --git a/src/Application/ScenarioDraft.php b/src/Application/ScenarioDraft.php new file mode 100644 index 0000000..8a5a8ff --- /dev/null +++ b/src/Application/ScenarioDraft.php @@ -0,0 +1,215 @@ + $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, + ) { + } + + public function toScenario(): Scenario + { + return new Scenario( + id: $this->id, + name: $this->name, + battlefield: $this->battlefield, + units: $this->units, + deploymentZones: $this->deploymentZones, + objectives: $this->objectives, + victoryCondition: $this->victoryCondition, + holdRoundsRequired: $this->holdRoundsRequired, + ); + } + + /** @param array $post */ + public static function fromPost(array $post): self + { + $id = self::stringField($post, 'id'); + $name = self::stringField($post, 'name'); + $width = self::intField($post, 'battlefieldWidth'); + $height = self::intField($post, 'battlefieldHeight'); + + $terrain = []; + foreach (($post['battlefieldTerrain'] ?? []) as $key => $value) { + $terrain[(string) $key] = self::terrainField((string) $value); + } + $battlefield = new Battlefield($width, $height, $terrain); + + $alphaUnits = self::parseTeamUnits($post['teamA']['units'] ?? [], 'alpha'); + $bravoUnits = self::parseTeamUnits($post['teamB']['units'] ?? [], 'bravo'); + $units = [...$alphaUnits, ...$bravoUnits]; + + // The form does not yet expose a "deployment zone" picker (3b adds it). + // For 3a we synthesize one zone per team containing the unit positions. + // When 3b lands, the editor's POST will include explicit zone tiles. + $deploymentZones = [ + 'alpha' => new DeploymentZone('alpha', self::collectPositions($alphaUnits)), + 'bravo' => new DeploymentZone('bravo', self::collectPositions($bravoUnits)), + ]; + + $victory = self::victoryField(self::stringField($post, 'victoryCondition')); + $holdRounds = self::intField($post, 'holdRoundsRequired'); + + $objectives = []; + if ($victory === VictoryCondition::HoldObjective) { + $objId = self::stringField($post, 'objectiveId'); + $objX = self::intField($post, 'objectiveX'); + $objY = self::intField($post, 'objectiveY'); + $objectives[$objId] = new ObjectiveMarker($objId, new Position($objX, $objY)); + } + + return new self( + id: $id, + name: $name, + battlefield: $battlefield, + units: $units, + deploymentZones: $deploymentZones, + objectives: $objectives, + victoryCondition: $victory, + holdRoundsRequired: $holdRounds, + ); + } + + /** + * @param list> $rows + * @return list + */ + private static function parseTeamUnits(array $rows, string $teamId): array + { + $units = []; + foreach ($rows as $index => $row) { + if (!is_array($row)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 3.) + throw new InvalidArgumentException("Team {$teamId} unit row {$index} is malformed."); + } + + $unitId = self::stringField($row, 'id'); + $archetype = self::archetypeField(self::stringField($row, 'archetype')); + $maxHealth = self::intField($row, 'maxHealth'); + $attack = self::intField($row, 'attack'); + $defense = self::intField($row, 'defense'); + $speed = self::intField($row, 'speed'); + $abilities = []; + foreach (($row['abilities'] ?? []) as $ability) { + $abilities[] = (string) $ability; + } + $position = new Position( + self::intField($row, 'x'), + self::intField($row, 'y'), + ); + + $units[] = new UnitState( + id: $unitId, + teamId: $teamId, + position: $position, + maxHealth: $maxHealth, + health: $maxHealth, + attack: $attack, + defense: $defense, + speed: $speed, + actionsRemaining: 2, + hasAttacked: false, + archetype: $archetype, + abilities: $abilities, + attackBonus: 0, + hasUsedAbility: false, + ); + } + + return $units; + } + + /** + * @param list $units + * @return list + */ + private static function collectPositions(array $units): array + { + $positions = []; + foreach ($units as $unit) { + $positions[] = $unit->position; + } + + return $positions; + } + + private static function archetypeField(string $value): Archetype + { + $archetype = Archetype::tryFrom($value); + if ($archetype === null) { + throw new InvalidArgumentException("Unknown archetype: {$value}."); + } + + return $archetype; + } + + private static function terrainField(string $value): Terrain + { + $terrain = Terrain::tryFrom($value); + if ($terrain === null) { + throw new InvalidArgumentException("Unknown terrain: {$value}."); + } + + return $terrain; + } + + private static function victoryField(string $value): VictoryCondition + { + $victory = VictoryCondition::tryFrom($value); + if ($victory === null) { + throw new InvalidArgumentException("Unknown victory condition: {$value}."); + } + + return $victory; + } + + /** + * @param array $array + */ + private static function stringField(array $array, string $key): string + { + if (!isset($array[$key]) || !is_string($array[$key]) || $array[$key] === '') { + throw new InvalidArgumentException("Field '{$key}' is required."); + } + + return $array[$key]; + } + + /** + * @param array $array + */ + private static function intField(array $array, string $key): int + { + if (!isset($array[$key])) { + throw new InvalidArgumentException("Field '{$key}' is required."); + } + + return (int) $array[$key]; + } +} diff --git a/tests/Unit/Application/ScenarioDraftTest.php b/tests/Unit/Application/ScenarioDraftTest.php new file mode 100644 index 0000000..3283938 --- /dev/null +++ b/tests/Unit/Application/ScenarioDraftTest.php @@ -0,0 +1,127 @@ + 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => '8', + 'battlefieldHeight' => '8', + 'battlefieldTerrain' => [ + '0:0' => 'forest', + ], + 'teamA' => [ + 'units' => [ + [ + 'id' => 'a1', + 'x' => '0', + 'y' => '0', + 'archetype' => 'defender', + 'maxHealth' => '12', + 'attack' => '3', + 'defense' => '4', + 'speed' => '2', + 'abilities' => ['buff'], + 'image' => '/assets/placeholders/defender.png', + ], + ['id' => 'a2', 'x' => '1', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ['id' => 'a3', 'x' => '2', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'x' => '7', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => ['area_damage'], 'image' => ''], + ['id' => 'b2', 'x' => '6', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ['id' => 'b3', 'x' => '5', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => '1', + ]; + + $draft = ScenarioDraft::fromPost($post); + $scenario = $draft->toScenario(); + + self::assertSame('demo', $scenario->id); + self::assertSame(8, $scenario->battlefield->width); + self::assertSame(Terrain::Forest, $scenario->battlefield->terrainAt(new Position(0, 0))); + self::assertCount(6, $scenario->units); + self::assertSame('a1', $scenario->units[0]->id); + self::assertSame(Archetype::Defender, $scenario->units[0]->archetype); + self::assertSame(['buff'], $scenario->units[0]->abilities); + self::assertSame(VictoryCondition::EliminateAll, $scenario->victoryCondition); + } + + public function testItRejectsUnknownArchetype(): void + { + $post = $this->validPost(); + $post['teamA']['units'][0]['archetype'] = 'rogue'; + + $this->expectException(\InvalidArgumentException::class); + ScenarioDraft::fromPost($post)->toScenario(); + } + + public function testItRejectsUnknownTerrain(): void + { + $post = $this->validPost(); + $post['battlefieldTerrain'] = ['0:0' => 'lava']; + + $this->expectException(\InvalidArgumentException::class); + ScenarioDraft::fromPost($post)->toScenario(); + } + + public function testItRejectsUnknownVictoryCondition(): void + { + $post = $this->validPost(); + $post['victoryCondition'] = 'first_blood'; + + $this->expectException(\InvalidArgumentException::class); + ScenarioDraft::fromPost($post)->toScenario(); + } + + /** + * @return array + */ + private function validPost(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => '8', + 'battlefieldHeight' => '8', + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'x' => '0', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ['id' => 'a2', 'x' => '1', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ['id' => 'a3', 'x' => '2', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'x' => '7', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ['id' => 'b2', 'x' => '6', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ['id' => 'b3', 'x' => '5', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => '1', + ]; + } +}