feat: add JSON serializer for scenarios and match state

This commit is contained in:
Keith Solomon
2026-07-06 18:20:01 -05:00
parent 8b83df60cd
commit 288c42e176
2 changed files with 408 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace BattleForge\Application;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\ArchetypeCatalog;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use InvalidArgumentException;
final class ScenarioSerializer
{
/** @return array<string, mixed> */
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<string, mixed> $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<string, mixed> */
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<string, mixed> $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<string, mixed> */
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<string, mixed> $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<string, mixed> */
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<string, mixed> */
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<string, mixed> $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);
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Application;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use PHPUnit\Framework\TestCase;
final class ScenarioSerializerTest extends TestCase
{
public function testItRoundTripsACompleteScenario(): void
{
$scenario = new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8, ['0:0' => 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.
]);
}
}