feat: add JSON serializer for scenarios and match state
This commit is contained in:
@@ -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.
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user