From 288c42e176d9cb0235fefd33aee2a52d5f5a9eec Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 18:12:23 -0500 Subject: [PATCH] feat: add JSON serializer for scenarios and match state --- src/Application/ScenarioSerializer.php | 270 ++++++++++++++++++ .../Application/ScenarioSerializerTest.php | 138 +++++++++ 2 files changed, 408 insertions(+) create mode 100644 src/Application/ScenarioSerializer.php create mode 100644 tests/Unit/Application/ScenarioSerializerTest.php diff --git a/src/Application/ScenarioSerializer.php b/src/Application/ScenarioSerializer.php new file mode 100644 index 0000000..f9c0c60 --- /dev/null +++ b/src/Application/ScenarioSerializer.php @@ -0,0 +1,270 @@ + */ + public static function scenarioToArray(Scenario $scenario): array + { + $terrainMap = []; + + for ($y = 0; $y < $scenario->battlefield->height; $y++) { + for ($x = 0; $x < $scenario->battlefield->width; $x++) { + $position = new Position($x, $y); + $tile = $scenario->battlefield->terrainAt($position); + if ($tile === Terrain::Open) { + continue; + } + $terrainMap[$position->key()] = $tile->value; + } + } + + $units = []; + foreach ($scenario->units as $unit) { + $units[] = self::unitToArray($unit); + } + + $deploymentZones = []; + foreach ($scenario->deploymentZones as $teamId => $zone) { + $deploymentZones[$teamId] = self::deploymentZoneToArray($zone); + } + + $objectives = []; + foreach ($scenario->objectives as $id => $objective) { + $objectives[$id] = ['id' => $objective->id, 'position' => self::positionToArray($objective->position)]; + } + + return [ + 'id' => $scenario->id, + 'name' => $scenario->name, + 'battlefield' => [ + 'width' => $scenario->battlefield->width, + 'height' => $scenario->battlefield->height, + 'terrain' => $terrainMap, + ], + 'units' => $units, + 'deploymentZones' => $deploymentZones, + 'objectives' => $objectives, + 'victoryCondition' => $scenario->victoryCondition->value, + 'holdRoundsRequired' => $scenario->holdRoundsRequired, + ]; + } + + /** @param array $data */ + public static function scenarioFromArray(array $data): Scenario + { + $terrainMap = []; + foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) { + $terrainMap[(string) $key] = Terrain::from((string) $value); + } + + $battlefield = new Battlefield( + (int) $data['battlefield']['width'], + (int) $data['battlefield']['height'], + $terrainMap, + ); + + $units = []; + foreach (($data['units'] ?? []) as $row) { + $units[] = self::unitFromArray($row); + } + + $deploymentZones = []; + foreach (($data['deploymentZones'] ?? []) as $teamId => $row) { + $deploymentZones[(string) $teamId] = self::deploymentZoneFromArray($row); + } + + $objectives = []; + foreach (($data['objectives'] ?? []) as $id => $row) { + $objectives[(string) $id] = new ObjectiveMarker( + (string) $row['id'], + self::positionFromArray($row['position']), + ); + } + + return new Scenario( + id: (string) $data['id'], + name: (string) $data['name'], + battlefield: $battlefield, + units: $units, + deploymentZones: $deploymentZones, + objectives: $objectives, + victoryCondition: VictoryCondition::from((string) $data['victoryCondition']), + holdRoundsRequired: (int) $data['holdRoundsRequired'], + ); + } + + /** @return array */ + public static function matchToArray(MatchState $match): array + { + $terrainMap = []; + for ($y = 0; $y < $match->battlefield->height; $y++) { + for ($x = 0; $x < $match->battlefield->width; $x++) { + $position = new Position($x, $y); + $tile = $match->battlefield->terrainAt($position); + if ($tile === Terrain::Open) { + continue; + } + $terrainMap[$position->key()] = $tile->value; + } + } + + $units = []; + foreach ($match->units as $unit) { + $units[] = self::unitToArray($unit); + } + + return [ + 'battlefield' => [ + 'width' => $match->battlefield->width, + 'height' => $match->battlefield->height, + 'terrain' => $terrainMap, + ], + 'units' => $units, + 'activeTeamId' => $match->activeTeamId, + 'round' => $match->round, + 'winnerTeamId' => $match->winnerTeamId, + 'actionLog' => $match->actionLog, + 'victoryCondition' => $match->victoryCondition->value, + 'holdRoundsRequired' => $match->holdRoundsRequired, + 'objectiveControl' => $match->objectiveControl, + ]; + } + + /** @param array $data */ + public static function matchFromArray(array $data): MatchState + { + $terrainMap = []; + foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) { + $terrainMap[(string) $key] = Terrain::from((string) $value); + } + + $battlefield = new Battlefield( + (int) $data['battlefield']['width'], + (int) $data['battlefield']['height'], + $terrainMap, + ); + + $units = []; + foreach (($data['units'] ?? []) as $row) { + $units[] = self::unitFromArray($row); + } + + return new MatchState( + battlefield: $battlefield, + units: $units, + activeTeamId: (string) $data['activeTeamId'], + round: (int) $data['round'], + winnerTeamId: $data['winnerTeamId'] ?? null, + actionLog: array_map(static fn (mixed $entry): string => (string) $entry, $data['actionLog'] ?? []), + objectives: [], + victoryCondition: VictoryCondition::from((string) $data['victoryCondition']), + holdRoundsRequired: (int) $data['holdRoundsRequired'], + objectiveControl: $data['objectiveControl'] ?? [], + ); + } + + /** @return array */ + private static function unitToArray(UnitState $unit): array + { + return [ + 'id' => $unit->id, + 'teamId' => $unit->teamId, + 'position' => self::positionToArray($unit->position), + 'maxHealth' => $unit->maxHealth, + 'health' => $unit->health, + 'attack' => $unit->attack, + 'defense' => $unit->defense, + 'speed' => $unit->speed, + 'archetype' => $unit->archetype->value, + 'abilities' => $unit->abilities, + ]; + } + + /** @param array $row */ + private static function unitFromArray(array $row): UnitState + { + $archetype = Archetype::from((string) $row['archetype']); + // Validate the ability allowlist against the catalog before constructing, + // because the runtime guard in UnitState only catches non-string entries. + $template = ArchetypeCatalog::templates()[$archetype->value] ?? null; + if ($template !== null) { + $abilities = array_map(static fn (mixed $a): string => (string) $a, $row['abilities'] ?? []); + foreach ($abilities as $ability) { + if (!in_array($ability, $template->allowedAbilities, true)) { + throw new InvalidArgumentException("Ability {$ability} is not in archetype {$archetype->value}'s allowlist."); + } + } + } + + return new UnitState( + id: (string) $row['id'], + teamId: (string) $row['teamId'], + position: self::positionFromArray($row['position']), + maxHealth: (int) $row['maxHealth'], + health: (int) $row['health'], + attack: (int) $row['attack'], + defense: (int) $row['defense'], + speed: (int) $row['speed'], + actionsRemaining: 2, + hasAttacked: false, + archetype: $archetype, + abilities: $abilities ?? [], + attackBonus: 0, + hasUsedAbility: false, + ); + } + + /** @return array */ + private static function positionToArray(Position $position): array + { + return ['x' => $position->x, 'y' => $position->y]; + } + + private static function positionFromArray(mixed $row): Position + { + if (!is_array($row)) { + throw new InvalidArgumentException('Expected position to be an array.'); + } + + return new Position((int) $row['x'], (int) $row['y']); + } + + /** @return array */ + private static function deploymentZoneToArray(DeploymentZone $zone): array + { + $positions = []; + foreach ($zone->positions as $position) { + $positions[] = self::positionToArray($position); + } + + return ['teamId' => $zone->teamId, 'positions' => $positions]; + } + + /** @param array $row */ + private static function deploymentZoneFromArray(array $row): DeploymentZone + { + $positions = []; + foreach (($row['positions'] ?? []) as $positionRow) { + $positions[] = self::positionFromArray($positionRow); + } + + return new DeploymentZone((string) $row['teamId'], $positions); + } +} diff --git a/tests/Unit/Application/ScenarioSerializerTest.php b/tests/Unit/Application/ScenarioSerializerTest.php new file mode 100644 index 0000000..f64b8e9 --- /dev/null +++ b/tests/Unit/Application/ScenarioSerializerTest.php @@ -0,0 +1,138 @@ + Terrain::Forest]), + units: [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, ['buff'], 0, false), + new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, ['area_damage'], 0, false), + new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + ], + 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, + ); + + $array = ScenarioSerializer::scenarioToArray($scenario); + $reconstructed = ScenarioSerializer::scenarioFromArray($array); + + self::assertSame($scenario->id, $reconstructed->id); + self::assertSame($scenario->name, $reconstructed->name); + self::assertSame($scenario->battlefield->width, $reconstructed->battlefield->width); + self::assertSame($scenario->battlefield->height, $reconstructed->battlefield->height); + self::assertSame('forest', $reconstructed->battlefield->terrainAt(new Position(0, 0))->value); + self::assertCount(6, $reconstructed->units); + self::assertSame('alpha-1', $reconstructed->units[0]->id); + self::assertSame(Archetype::Defender, $reconstructed->units[0]->archetype); + self::assertSame(['buff'], $reconstructed->units[0]->abilities); + self::assertSame(12, $reconstructed->units[0]->maxHealth); + self::assertSame(3, $reconstructed->units[0]->attack); + self::assertSame(4, $reconstructed->units[0]->defense); + self::assertSame(2, $reconstructed->units[0]->speed); + self::assertSame($scenario->victoryCondition, $reconstructed->victoryCondition); + } + + public function testItRoundTripsAHoldObjectiveScenario(): void + { + $scenario = new Scenario( + id: 'hold', + name: 'Hold', + battlefield: new Battlefield(8, 8), + units: [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false), + new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false), + ], + 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: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))], + victoryCondition: VictoryCondition::HoldObjective, + holdRoundsRequired: 3, + ); + + $reconstructed = ScenarioSerializer::scenarioFromArray(ScenarioSerializer::scenarioToArray($scenario)); + + self::assertSame(VictoryCondition::HoldObjective, $reconstructed->victoryCondition); + self::assertSame(3, $reconstructed->holdRoundsRequired); + self::assertArrayHasKey('objective-1', $reconstructed->objectives); + self::assertSame('4:4', $reconstructed->objectives['objective-1']->position->key()); + } + + public function testItRoundTripsAMatchState(): void + { + $scenario = new Scenario( + id: 'demo', + name: 'Demo', + battlefield: new Battlefield(8, 8), + units: [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('alpha-2', 'alpha', new Position(1, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('alpha-3', 'alpha', new Position(2, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('bravo-1', 'bravo', new Position(7, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('bravo-2', 'bravo', new Position(6, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false), + ], + 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, + ); + $match = $scenario->startMatch('alpha'); + + $reconstructed = ScenarioSerializer::matchFromArray(ScenarioSerializer::matchToArray($match)); + + self::assertSame('alpha', $reconstructed->activeTeamId); + self::assertSame(1, $reconstructed->round); + self::assertCount(6, $reconstructed->units); + self::assertSame(2, $reconstructed->units[0]->actionsRemaining); + self::assertFalse($reconstructed->units[0]->hasAttacked); + self::assertSame(0, $reconstructed->units[0]->attackBonus); + } + + public function testItRejectsShapeErrors(): void + { + $this->expectException(\InvalidArgumentException::class); + + ScenarioSerializer::scenarioFromArray([ + 'id' => 'demo', + // missing 'name', 'battlefield', 'units', etc. + ]); + } +}