feat: add ScenarioDraft form-field adapter
This commit is contained in:
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Application;
|
||||||
|
|
||||||
|
use BattleForge\Domain\Archetype;
|
||||||
|
use BattleForge\Domain\Battlefield;
|
||||||
|
use BattleForge\Domain\DeploymentZone;
|
||||||
|
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 readonly class ScenarioDraft
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<UnitState> $units
|
||||||
|
* @param array<string, DeploymentZone> $deploymentZones
|
||||||
|
* @param array<string, ObjectiveMarker> $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<string, mixed> $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<array<string, mixed>> $rows
|
||||||
|
* @return list<UnitState>
|
||||||
|
*/
|
||||||
|
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<UnitState> $units
|
||||||
|
* @return list<Position>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Application;
|
||||||
|
|
||||||
|
use BattleForge\Application\ScenarioDraft;
|
||||||
|
use BattleForge\Domain\Archetype;
|
||||||
|
use BattleForge\Domain\Battlefield;
|
||||||
|
use BattleForge\Domain\DeploymentZone;
|
||||||
|
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 ScenarioDraftTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItBuildsAScenarioFromFormFields(): void
|
||||||
|
{
|
||||||
|
$post = [
|
||||||
|
'id' => '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<string, mixed>
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user