diff --git a/.gitignore b/.gitignore
index 3cbc314..f946a1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,13 @@
.worktrees/
/vendor/
-/var/
+/var/cache/
+/var/phpunit/
+/var/phpstan/
+/var/logs/
+/var/uploads/*
+!/var/uploads/.htaccess
+!/var/uploads/index.php
+/node_modules/
+/public/js/*.map
+.phpunit.result.cache
+.phpunit.cache
diff --git a/phpcs.xml b/phpcs.xml
index 367f237..a8df2a9 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -4,6 +4,7 @@
src
tests
+ public/index.php
diff --git a/phpstan.neon b/phpstan.neon
index e7662a4..0d38a3b 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -3,4 +3,7 @@ parameters:
paths:
- src
- tests
+ - public/index.php
+ excludePaths:
+ - src/Views/*
tmpDir: var/phpstan
diff --git a/phpunit.xml b/phpunit.xml
index cf65ccf..dd70c0c 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -5,8 +5,11 @@
cacheDirectory="var/phpunit"
colors="true">
-
- tests
+
+ tests/Unit
+
+
+ tests/Integration
diff --git a/public/assets/styles.css b/public/assets/styles.css
new file mode 100644
index 0000000..f443cb8
--- /dev/null
+++ b/public/assets/styles.css
@@ -0,0 +1,21 @@
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; font-family: sans-serif; }
+body { padding: 1rem; max-width: 64rem; margin: 0 auto; }
+fieldset { margin: 0 0 1rem 0; padding: 0.5rem 1rem; }
+legend { font-weight: bold; }
+label { display: block; margin: 0.25rem 0; }
+input, select, button, textarea { font: inherit; }
+input[type="number"] { width: 5rem; }
+table.bf-grid { border-collapse: collapse; }
+table.bf-grid td { padding: 0; }
+table.bf-grid button { width: 2rem; height: 2rem; border: 1px solid #ccc; background: #fff; }
+table.bf-grid button[data-paint="open"] { background: #fff; }
+table.bf-grid button[data-paint="forest"] { background: #cfc; }
+table.bf-grid button[data-paint="rough"] { background: #fec; }
+table.bf-grid button[data-paint="water"] { background: #cce; }
+table.bf-grid button[data-paint="blocking"] { background: #444; color: #fff; }
+table.bf-grid button[data-objective="1"] { outline: 3px solid #c00; }
+table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; }
+table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; }
+.bf-errors { color: #c00; }
+.bf-toast { background: #dfd; padding: 0.5rem 1rem; margin: 0.5rem 0; }
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..0845a87
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,9 @@
+uploadsRoot . '/' . $this->userToken;
+ if (!is_dir($namespace)) {
+ mkdir($namespace, 0700, true);
+ }
+
+ $hash = bin2hex(random_bytes(16));
+ $filename = $hash . '.' . $validated->extension;
+ $destination = $namespace . '/' . $filename;
+
+ if (!rename($tempPath, $destination)) {
+ throw new InvalidImageException("Could not move upload to {$destination}.");
+ }
+
+ // Lock the file down: the namespace is already 0700; tighten the
+ // file itself to 0600 in case the server's umask is permissive.
+ chmod($destination, 0600);
+
+ return '/assets/' . $this->userToken . '/' . $filename;
+ }
+}
diff --git a/src/Application/ImageValidator.php b/src/Application/ImageValidator.php
new file mode 100644
index 0000000..ee15c84
--- /dev/null
+++ b/src/Application/ImageValidator.php
@@ -0,0 +1,81 @@
+ $maxBytes) {
+ throw new InvalidImageException("File exceeds the {$maxBytes} byte limit.");
+ }
+
+ $handle = fopen($tempPath, 'rb');
+ if ($handle === false) {
+ throw new InvalidImageException('Could not read upload.');
+ }
+ $header = fread($handle, 12);
+ fclose($handle);
+ if ($header === false || strlen($header) < 12) {
+ throw new InvalidImageException('Upload is too small to be an image.');
+ }
+
+ $detected = self::detect($header);
+ if ($detected === null) {
+ throw new InvalidImageException('File is not a supported image type.');
+ }
+
+ [$canonical, $extension] = $detected;
+
+ if ($declaredMime !== $canonical) {
+ throw new InvalidImageException('Declared MIME does not match file contents.');
+ }
+
+ $info = getimagesize($tempPath);
+ if ($info === false) {
+ throw new InvalidImageException('Image is corrupt or unreadable.');
+ }
+
+ [$width, $height] = $info;
+ if ($width > $maxDimension || $height > $maxDimension) {
+ throw new InvalidImageException("Image dimensions exceed the {$maxDimension} pixel limit.");
+ }
+
+ return new ValidatedImage($canonical, $extension);
+ }
+}
diff --git a/src/Application/InvalidImageException.php b/src/Application/InvalidImageException.php
new file mode 100644
index 0000000..5880d6b
--- /dev/null
+++ b/src/Application/InvalidImageException.php
@@ -0,0 +1,11 @@
+ $units
+ * @param array $deploymentZones
+ * @param array $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 $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> $rows
+ * @return list
+ */
+ 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 $units
+ * @return list
+ */
+ 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 $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 $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];
+ }
+}
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/src/Application/ValidatedImage.php b/src/Application/ValidatedImage.php
new file mode 100644
index 0000000..5a9431f
--- /dev/null
+++ b/src/Application/ValidatedImage.php
@@ -0,0 +1,14 @@
+ $params */
+ public function handle(Request $request, array $params): Response
+ {
+ $kind = $params['kind'] ?? '';
+ $filename = $params['filename'] ?? '';
+
+ if ($kind === 'placeholders') {
+ return $this->serveFrom($this->placeholderDir . '/' . $filename);
+ }
+
+ if ($kind === 'uploads') {
+ $userToken = $params['userToken'] ?? '';
+ $requestToken = $request->cookies['__uploads_token'] ?? '';
+
+ if ($userToken === '' || $userToken !== $requestToken) {
+ return Response::html(404, 'Not found
');
+ }
+
+ return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename);
+ }
+
+ return Response::html(404, 'Not found
');
+ }
+
+ private function serveFrom(string $path): Response
+ {
+ if (!is_file($path)) {
+ return Response::html(404, 'Not found
');
+ }
+
+ $body = file_get_contents($path);
+ if ($body === false) {
+ return Response::html(500, 'Read error
');
+ }
+
+ $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+ $contentType = match ($ext) {
+ 'png' => 'image/png',
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'webp' => 'image/webp',
+ 'gif' => 'image/gif',
+ default => 'application/octet-stream',
+ };
+
+ return new Response(200, ['Content-Type' => $contentType] + self::securityHeaders(), $body);
+ }
+
+ /** @return array */
+ private static function securityHeaders(): array
+ {
+ return [
+ 'X-Content-Type-Options' => 'nosniff',
+ 'Referrer-Policy' => 'same-origin',
+ 'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
+ ];
+ }
+}
diff --git a/src/Http/Handlers/GetHomePage.php b/src/Http/Handlers/GetHomePage.php
new file mode 100644
index 0000000..82cd9be
--- /dev/null
+++ b/src/Http/Handlers/GetHomePage.php
@@ -0,0 +1,42 @@
+ $params */
+ public function handle(Request $request, array $params): Response
+ {
+ $body = <<<'HTML'
+
+
+
+
+ BattleForge
+
+
+
+
+ BattleForge
+ New scenario
+ Recent scenarios
+
+
+
+
+HTML;
+
+ // The CSRF token placeholder is filled by the front controller (Task 16)
+ // before the body is sent. The handler does not see the cookie or the
+ // secret; it just receives a pre-issued token from the front controller.
+ $token = $request->cookies['__csrf'] ?? '';
+ $body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body);
+
+ return Response::html(200, $body);
+ }
+}
diff --git a/src/Http/Request.php b/src/Http/Request.php
new file mode 100644
index 0000000..55ec066
--- /dev/null
+++ b/src/Http/Request.php
@@ -0,0 +1,29 @@
+ $get
+ * @param array $post
+ * @param array> $files
+ * @param array $cookies
+ * @param array $server
+ */
+ public function __construct(
+ public array $get,
+ public array $post,
+ public array $files,
+ public array $cookies,
+ public array $server,
+ public string $queryString,
+ public string $method,
+ public string $path,
+ public ?string $contentType,
+ public string $rawBody,
+ ) {
+ }
+}
diff --git a/src/Http/Response.php b/src/Http/Response.php
new file mode 100644
index 0000000..0a3cf56
--- /dev/null
+++ b/src/Http/Response.php
@@ -0,0 +1,49 @@
+ 'nosniff',
+ 'Referrer-Policy' => 'same-origin',
+ 'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
+ ];
+
+ /** @param array $headers */
+ public function __construct(
+ public int $status,
+ public array $headers,
+ public string $body,
+ ) {
+ }
+
+ public static function html(int $status, string $body): self
+ {
+ return new self(
+ $status,
+ ['Content-Type' => 'text/html; charset=utf-8'] + self::SECURITY_HEADERS,
+ $body,
+ );
+ }
+
+ public static function json(int $status, mixed $body): self
+ {
+ return new self(
+ $status,
+ ['Content-Type' => 'application/json; charset=utf-8'] + self::SECURITY_HEADERS,
+ json_encode($body, JSON_THROW_ON_ERROR),
+ );
+ }
+
+ public static function redirect(string $location, int $status = 303): self
+ {
+ return new self(
+ $status,
+ ['Location' => $location] + self::SECURITY_HEADERS,
+ '',
+ );
+ }
+}
diff --git a/src/Http/Router.php b/src/Http/Router.php
new file mode 100644
index 0000000..2d0b54c
--- /dev/null
+++ b/src/Http/Router.php
@@ -0,0 +1,47 @@
+): Response}> */
+ private array $routes = [];
+
+ public function add(string $method, string $path, callable $handler): void
+ {
+ $pattern = '#^' . preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $path) . '$#';
+
+ $this->routes[] = [
+ 'method' => strtoupper($method),
+ 'pattern' => $pattern,
+ 'handler' => $handler,
+ ];
+ }
+
+ public function dispatch(Request $request): Response
+ {
+ foreach ($this->routes as $route) {
+ if ($route['method'] !== $request->method) {
+ continue;
+ }
+
+ if (preg_match($route['pattern'], $request->path, $matches) === 1) {
+ $params = [];
+
+ foreach ($matches as $key => $value) {
+ if (is_string($key)) {
+ $params[$key] = $value;
+ }
+ }
+
+ return ($route['handler'])($request, $params);
+ }
+ }
+
+ return Response::html(404, 'Not found
');
+ }
+}
diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep
new file mode 100644
index 0000000..fa36e81
--- /dev/null
+++ b/tests/Integration/.gitkeep
@@ -0,0 +1,6 @@
+placeholderDir = sys_get_temp_dir() . '/bf-placeholders-' . bin2hex(random_bytes(4));
+ mkdir($this->placeholderDir, 0755, true);
+ // 1x1 red PNG
+ $png = base64_decode(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
+ true,
+ );
+ file_put_contents($this->placeholderDir . '/defender.png', $png);
+
+ $this->uploadsDir = sys_get_temp_dir() . '/bf-assets-' . bin2hex(random_bytes(4));
+ mkdir($this->uploadsDir, 0700, true);
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_dir($this->uploadsDir)) {
+ foreach (glob($this->uploadsDir . '/*/*') as $file) {
+ unlink($file);
+ }
+ foreach (glob($this->uploadsDir . '/*') as $dir) {
+ rmdir($dir);
+ }
+ rmdir($this->uploadsDir);
+ }
+
+ if (is_dir($this->placeholderDir)) {
+ foreach (glob($this->placeholderDir . '/*') as $file) {
+ unlink($file);
+ }
+ rmdir($this->placeholderDir);
+ }
+ }
+
+ public function testItServesAPlaceholderImageWithoutAuth(): void
+ {
+ $handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
+ $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/defender.png', null, '');
+ $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'defender.png']);
+
+ self::assertSame(200, $response->status);
+ self::assertSame('image/png', $response->headers['Content-Type'] ?? '');
+ self::assertGreaterThan(0, strlen($response->body));
+ }
+
+ public function testItReturns404ForAMissingFile(): void
+ {
+ $handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
+ $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/missing.png', null, '');
+ $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'missing.png']);
+
+ self::assertSame(404, $response->status);
+ }
+}
diff --git a/tests/Integration/GetHomePageTest.php b/tests/Integration/GetHomePageTest.php
new file mode 100644
index 0000000..ac85680
--- /dev/null
+++ b/tests/Integration/GetHomePageTest.php
@@ -0,0 +1,26 @@
+handle($request, []);
+
+ self::assertSame(200, $response->status);
+ self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
+ self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
+ self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
+ self::assertStringContainsString('', $response->body);
+ }
+}
diff --git a/tests/Unit/Application/ImageUploadServiceTest.php b/tests/Unit/Application/ImageUploadServiceTest.php
new file mode 100644
index 0000000..a80cfdd
--- /dev/null
+++ b/tests/Unit/Application/ImageUploadServiceTest.php
@@ -0,0 +1,94 @@
+tmpDir = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4));
+ mkdir($this->tmpDir, 0700, true);
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_dir($this->tmpDir)) {
+ foreach (glob($this->tmpDir . '/*/*') as $file) {
+ unlink($file);
+ }
+ foreach (glob($this->tmpDir . '/*') as $dir) {
+ rmdir($dir);
+ }
+ rmdir($this->tmpDir);
+ }
+ }
+
+ public function testItStoresAValidImageAndReturnsAStableUrl(): void
+ {
+ $service = new ImageUploadService('user-token-1', $this->tmpDir);
+ $tmp = tempnam(sys_get_temp_dir(), 'bf-up');
+ file_put_contents($tmp, self::validPng(8, 8));
+
+ $url = $service->store($tmp, 'image/png');
+
+ self::assertStringStartsWith('/assets/user-token-1/', $url);
+ self::assertStringEndsWith('.png', $url);
+
+ $stored = $this->tmpDir . '/user-token-1/' . basename($url);
+ self::assertFileExists($stored);
+ }
+
+ public function testItCreatesTheUserNamespaceDirectory(): void
+ {
+ $service = new ImageUploadService('fresh-user', $this->tmpDir);
+ $tmp = tempnam(sys_get_temp_dir(), 'bf-up');
+ file_put_contents($tmp, self::validPng(8, 8));
+
+ $service->store($tmp, 'image/png');
+
+ self::assertDirectoryExists($this->tmpDir . '/fresh-user');
+ }
+
+ public function testItProducesUniqueFilenamesForRepeatedUploads(): void
+ {
+ $service = new ImageUploadService('user-token-2', $this->tmpDir);
+ $tmp1 = tempnam(sys_get_temp_dir(), 'bf-up');
+ $tmp2 = tempnam(sys_get_temp_dir(), 'bf-up');
+ file_put_contents($tmp1, self::validPng(8, 8));
+ file_put_contents($tmp2, self::validPng(8, 8));
+
+ $url1 = $service->store($tmp1, 'image/png');
+ $url2 = $service->store($tmp2, 'image/png');
+
+ self::assertNotSame($url1, $url2);
+ }
+
+ public function testItRejectsAnInvalidImage(): void
+ {
+ $service = new ImageUploadService('user-token-3', $this->tmpDir);
+ $tmp = tempnam(sys_get_temp_dir(), 'bf-up');
+ file_put_contents($tmp, 'not an image');
+
+ $this->expectException(\BattleForge\Application\InvalidImageException::class);
+ $service->store($tmp, 'image/png');
+ }
+
+ private static function validPng(int $width, int $height): string
+ {
+ if ($width === 8 && $height === 8) {
+ return base64_decode(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
+ 'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
+ true,
+ );
+ }
+ throw new \InvalidArgumentException('Only 8x8 base PNG supported in tests.');
+ }
+}
diff --git a/tests/Unit/Application/ImageValidatorTest.php b/tests/Unit/Application/ImageValidatorTest.php
new file mode 100644
index 0000000..422a378
--- /dev/null
+++ b/tests/Unit/Application/ImageValidatorTest.php
@@ -0,0 +1,180 @@
+canonicalMime);
+ self::assertSame('png', $result->extension);
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function testItRejectsAFileWithTheWrongDeclaredMime(): void
+ {
+ $path = self::TMP_DIR . '/bf-bad-mime-' . bin2hex(random_bytes(4)) . '.png';
+ self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
+
+ try {
+ $this->expectException(InvalidImageException::class);
+ $this->expectExceptionMessage('Declared MIME does not match file contents');
+ ImageValidator::validate($path, 'image/jpeg');
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function testItRejectsTextContent(): void
+ {
+ $path = self::TMP_DIR . '/bf-text-' . bin2hex(random_bytes(4)) . '.txt';
+ self::assertNotFalse(file_put_contents($path, 'this is not an image'));
+
+ try {
+ $this->expectException(InvalidImageException::class);
+ ImageValidator::validate($path, 'image/png');
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function testItRejectsOversizedFiles(): void
+ {
+ $path = self::TMP_DIR . '/bf-huge-' . bin2hex(random_bytes(4)) . '.png';
+ // Create a 2 MB + 1 byte file
+ $bytes = str_repeat('A', 2_000_001);
+ self::assertNotFalse(file_put_contents($path, $bytes));
+
+ try {
+ $this->expectException(InvalidImageException::class);
+ $this->expectExceptionMessage('exceeds the 2000000 byte limit');
+ ImageValidator::validate($path, 'image/png');
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function testItRejectsOversizedDimensions(): void
+ {
+ $path = self::TMP_DIR . '/bf-wide-' . bin2hex(random_bytes(4)) . '.png';
+ self::assertNotFalse(file_put_contents($path, self::validPng(600, 8)));
+
+ try {
+ $this->expectException(InvalidImageException::class);
+ $this->expectExceptionMessage('exceed the 512 pixel limit');
+ ImageValidator::validate($path, 'image/png');
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function testItAcceptsJpegWebpAndGif(): void
+ {
+ $jpeg = self::TMP_DIR . '/bf-jpeg-' . bin2hex(random_bytes(4)) . '.jpg';
+ $webp = self::TMP_DIR . '/bf-webp-' . bin2hex(random_bytes(4)) . '.webp';
+ $gif = self::TMP_DIR . '/bf-gif-' . bin2hex(random_bytes(4)) . '.gif';
+ file_put_contents($jpeg, self::validJpeg(8, 8));
+ file_put_contents($webp, self::validWebp(8, 8));
+ file_put_contents($gif, self::validGif(8, 8));
+
+ try {
+ self::assertSame('jpg', ImageValidator::validate($jpeg, 'image/jpeg')->extension);
+ self::assertSame('webp', ImageValidator::validate($webp, 'image/webp')->extension);
+ self::assertSame('gif', ImageValidator::validate($gif, 'image/gif')->extension);
+ } finally {
+ unlink($jpeg);
+ unlink($webp);
+ unlink($gif);
+ }
+ }
+
+ /**
+ * Build a minimal valid PNG of the given dimensions, using a pre-baked
+ * 8x8 transparent PNG as the base and trusting `getimagesize` to accept
+ * any correctly-formed PNG. We just need the file to (1) have the PNG
+ * magic and (2) be decodable by GD or `getimagesize`.
+ *
+ * For the oversized-dimensions test we synthesize a 600x8 PNG via
+ * `imagecreatetruecolor` + `imagepng` to force the IHDR to claim those
+ * dimensions.
+ */
+ private static function validPng(int $width, int $height): string
+ {
+ if ($width === 8 && $height === 8) {
+ return base64_decode(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
+ 'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
+ true,
+ );
+ }
+
+ $im = imagecreatetruecolor($width, $height);
+ if ($im === false) {
+ throw new \RuntimeException('Failed to create image.');
+ }
+ ob_start();
+ imagepng($im);
+ $bytes = ob_get_clean();
+ imagedestroy($im);
+
+ return (string) $bytes;
+ }
+
+ private static function validJpeg(int $width, int $height): string
+ {
+ $im = imagecreatetruecolor($width, $height);
+ if ($im === false) {
+ throw new \RuntimeException('Failed to create image.');
+ }
+ ob_start();
+ imagejpeg($im);
+ $bytes = ob_get_clean();
+ imagedestroy($im);
+
+ return (string) $bytes;
+ }
+
+ private static function validWebp(int $width, int $height): string
+ {
+ $im = imagecreatetruecolor($width, $height);
+ if ($im === false) {
+ throw new \RuntimeException('Failed to create image.');
+ }
+ ob_start();
+ imagewebp($im);
+ $bytes = ob_get_clean();
+ imagedestroy($im);
+
+ return (string) $bytes;
+ }
+
+ private static function validGif(int $width, int $height): string
+ {
+ $im = imagecreatetruecolor($width, $height);
+ if ($im === false) {
+ throw new \RuntimeException('Failed to create image.');
+ }
+ ob_start();
+ imagegif($im);
+ $bytes = ob_get_clean();
+ imagedestroy($im);
+
+ return (string) $bytes;
+ }
+}
diff --git a/tests/Unit/Application/ScenarioDraftTest.php b/tests/Unit/Application/ScenarioDraftTest.php
new file mode 100644
index 0000000..3283938
--- /dev/null
+++ b/tests/Unit/Application/ScenarioDraftTest.php
@@ -0,0 +1,127 @@
+ '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
+ */
+ 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',
+ ];
+ }
+}
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.
+ ]);
+ }
+}
diff --git a/tests/Unit/Http/CsrfTokenTest.php b/tests/Unit/Http/CsrfTokenTest.php
new file mode 100644
index 0000000..9e635f8
--- /dev/null
+++ b/tests/Unit/Http/CsrfTokenTest.php
@@ -0,0 +1,60 @@
+
+ */
+ public static function htmlProvider(): iterable
+ {
+ yield 'plain' => ['hello', 'hello'];
+ yield 'less-than' => ['