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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user