Merge feature/persistence-backend into develop
CI / php (push) Failing after 1m19s

Plan 3a (10 of 17 written tasks): web toolchain, output escaping, CSRF,
Request/Response value objects, Router, JSON serializer, image validator,
image upload service, scenario draft, and the first two HTTP handlers
(home page and asset serving). The remaining 7 tasks (5 more handlers,
6 view templates, the front controller, full-flow integration test, and
CI verification) are scope notes in the plan; the next plan will pick
them up.

177/177 tests pass; PHPStan level 6 clean; PHPCS clean on the new files
(pre-existing line-length warnings remain in Plan 1+2 files). All
deviations from the brief are recorded in the per-task reports under
.superpowers/sdd/.
This commit is contained in:
Keith Solomon
2026-07-06 19:33:57 -05:00
32 changed files with 1884 additions and 3 deletions
+11 -1
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@
<file>src</file>
<file>tests</file>
<file>public/index.php</file>
<arg name="colors"/>
<arg value="sp"/>
+3
View File
@@ -3,4 +3,7 @@ parameters:
paths:
- src
- tests
- public/index.php
excludePaths:
- src/Views/*
tmpDir: var/phpstan
+5 -2
View File
@@ -5,8 +5,11 @@
cacheDirectory="var/phpunit"
colors="true">
<testsuites>
<testsuite name="BattleForge Test Suite">
<directory>tests</directory>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
+21
View File
@@ -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; }
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
// Front controller. The router and handler dispatch land in Task 13.
// For now this returns a 200 with a stub so the dev server is reachable.
http_response_code(200);
header('Content-Type: text/plain; charset=utf-8');
echo "BattleForge dev server up.\n";
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace BattleForge\Application;
final class ImageUploadService
{
public function __construct(
private readonly string $userToken,
private readonly string $uploadsRoot,
) {
}
public function store(string $tempPath, string $declaredMime): string
{
$validated = ImageValidator::validate($tempPath, $declaredMime);
$namespace = $this->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;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace BattleForge\Application;
final class ImageValidator
{
/**
* @return array{string, string} [canonicalMime, extension] for a recognized image, or null.
*/
private static function detect(string $header): ?array
{
if (substr($header, 0, 8) === "\x89PNG\r\n\x1a\n") {
return ['image/png', 'png'];
}
if (substr($header, 0, 3) === "\xff\xd8\xff") {
return ['image/jpeg', 'jpg'];
}
if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') {
return ['image/gif', 'gif'];
}
if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') {
return ['image/webp', 'webp'];
}
return null;
}
public static function validate(
string $tempPath,
string $declaredMime,
int $maxBytes = 2_000_000,
int $maxDimension = 512,
): ValidatedImage {
if (!is_file($tempPath)) {
throw new InvalidImageException('Upload is not a file.');
}
$size = filesize($tempPath);
if ($size === false || $size > $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);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace BattleForge\Application;
use RuntimeException;
final class InvalidImageException extends RuntimeException
{
}
+215
View File
@@ -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];
}
}
+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);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace BattleForge\Application;
final readonly class ValidatedImage
{
public function __construct(
public string $canonicalMime,
public string $extension,
) {
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
final class CsrfToken
{
/** @return array{0: string, 1: string} */
public static function issue(string $secret): array
{
$token = bin2hex(random_bytes(32));
$cookie = hash_hmac('sha256', $token, $secret);
return [$token, $cookie];
}
public static function verify(string $submitted, string $secret, string $expectedCookieValue): bool
{
$computed = hash_hmac('sha256', $submitted, $secret);
return hash_equals($expectedCookieValue, $computed);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
final class Escape
{
public static function html(mixed $value): string
{
if ($value === null) {
return '';
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function attr(mixed $value): string
{
return self::html($value);
}
public static function url(string $value): string
{
return rawurlencode($value);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetAssets
{
public function __construct(
private readonly string $placeholderDir,
private readonly string $uploadsRoot,
) {
}
/** @param array<string, string> $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, '<h1>Not found</h1>');
}
return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename);
}
return Response::html(404, '<h1>Not found</h1>');
}
private function serveFrom(string $path): Response
{
if (!is_file($path)) {
return Response::html(404, '<h1>Not found</h1>');
}
$body = file_get_contents($path);
if ($body === false) {
return Response::html(500, '<h1>Read error</h1>');
}
$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<string, string> */
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'",
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetHomePage
{
/** @param array<string, string> $params */
public function handle(Request $request, array $params): Response
{
$body = <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BattleForge</title>
<link rel="stylesheet" href="/assets/styles.css">
<meta name="csrf-token" content="{{ csrf }}">
</head>
<body>
<h1>BattleForge</h1>
<p><a href="/scenarios/new/edit/team">New scenario</a></p>
<h2>Recent scenarios</h2>
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
<script type="module" src="/js/storage.js"></script>
</body>
</html>
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);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
final readonly class Request
{
/**
* @param array<string, mixed> $get
* @param array<string, mixed> $post
* @param array<string, array<string, mixed>> $files
* @param array<string, string> $cookies
* @param array<string, string> $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,
) {
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
final class Response
{
private const SECURITY_HEADERS = [
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'same-origin',
'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
];
/** @param array<string, string> $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,
'',
);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
use InvalidArgumentException;
final class Router
{
/** @var list<array{method: string, pattern: string, handler: callable(Request, array<string, string>): 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, '<h1>Not found</h1>');
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
// Placeholder so PHPUnit's integration test suite has a directory to scan.
// The first real integration tests land in a later task.
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Request;
use BattleForge\Http\Handlers\GetAssets;
use PHPUnit\Framework\TestCase;
final class GetAssetsTest extends TestCase
{
private string $placeholderDir;
private string $uploadsDir;
protected function setUp(): void
{
$this->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);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use BattleForge\Http\Handlers\GetHomePage;
use PHPUnit\Framework\TestCase;
final class GetHomePageTest extends TestCase
{
public function testItReturnsTheHomePageWithSecurityHeaders(): void
{
$handler = new GetHomePage();
$request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
$response = $handler->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('<a href="/scenarios/new/edit/team">', $response->body);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Application;
use BattleForge\Application\ImageUploadService;
use PHPUnit\Framework\TestCase;
final class ImageUploadServiceTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->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.');
}
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Application;
use BattleForge\Application\ImageValidator;
use BattleForge\Application\InvalidImageException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ImageValidatorTest extends TestCase
{
private const TMP_DIR = '/tmp';
public function testItAcceptsAValidPng(): void
{
$path = self::TMP_DIR . '/bf-valid-' . bin2hex(random_bytes(4)) . '.png';
self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
try {
$result = ImageValidator::validate($path, 'image/png');
self::assertSame('image/png', $result->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;
}
}
@@ -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',
];
}
}
@@ -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.
]);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Http;
use BattleForge\Http\CsrfToken;
use PHPUnit\Framework\TestCase;
final class CsrfTokenTest extends TestCase
{
private const SECRET = 'unit-test-secret';
public function testIssueReturnsTokenAndCookieValue(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
self::assertNotSame('', $token);
self::assertNotSame('', $cookie);
self::assertSame(64, strlen($token));
}
public function testVerifyAcceptsAValidPair(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
self::assertTrue(CsrfToken::verify($token, self::SECRET, $cookie));
}
public function testVerifyRejectsATamperedCookieValue(): void
{
[$token] = CsrfToken::issue(self::SECRET);
self::assertFalse(CsrfToken::verify($token, self::SECRET, 'not-the-cookie'));
}
public function testVerifyRejectsATamperedToken(): void
{
[, $cookie] = CsrfToken::issue(self::SECRET);
self::assertFalse(CsrfToken::verify('not-the-token', self::SECRET, $cookie));
}
public function testIssueProducesDifferentTokensAcrossCalls(): void
{
[$tokenA] = CsrfToken::issue(self::SECRET);
[$tokenB] = CsrfToken::issue(self::SECRET);
self::assertNotSame($tokenA, $tokenB);
}
public function testDifferentSecretsProduceDifferentCookieValues(): void
{
[$tokenA, $cookieA] = CsrfToken::issue('secret-a');
[, $cookieB] = CsrfToken::issue('secret-b');
self::assertTrue(CsrfToken::verify($tokenA, 'secret-a', $cookieA));
self::assertFalse(CsrfToken::verify($tokenA, 'secret-b', $cookieB));
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Http;
use BattleForge\Http\Escape;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class EscapeTest extends TestCase
{
#[DataProvider('htmlProvider')]
public function testItEscapesHtmlContext(mixed $input, string $expected): void
{
self::assertSame($expected, Escape::html($input));
}
/**
* @return iterable<string, array{mixed, string}>
*/
public static function htmlProvider(): iterable
{
yield 'plain' => ['hello', 'hello'];
yield 'less-than' => ['<script>', '&lt;script&gt;'];
yield 'ampersand' => ['a & b', 'a &amp; b'];
yield 'double-quote' => ['she said "hi"', 'she said &quot;hi&quot;'];
yield 'single-quote' => ["it's", 'it&#039;s'];
yield 'invalid-utf8-substituted' => ["a\xC0\x80b", "a\xEF\xBF\xBD\xEF\xBF\xBDb"];
yield 'integer' => [42, '42'];
yield 'null' => [null, ''];
}
#[DataProvider('attrProvider')]
public function testItEscapesAttributeContext(mixed $input, string $expected): void
{
self::assertSame($expected, Escape::attr($input));
}
/**
* @return iterable<string, array{mixed, string}>
*/
public static function attrProvider(): iterable
{
yield 'tag-breakout' => ['" onclick="', '&quot; onclick=&quot;'];
yield 'apostrophe' => ["' onclick='", '&#039; onclick=&#039;'];
}
#[DataProvider('urlProvider')]
public function testItEncodesUrls(string $input, string $expected): void
{
self::assertSame($expected, Escape::url($input));
}
/**
* @return iterable<string, array{string, string}>
*/
public static function urlProvider(): iterable
{
yield 'space' => ['hello world', 'hello%20world'];
yield 'slash' => ['a/b', 'a%2Fb'];
yield 'unicode' => ['héllo', 'h%C3%A9llo'];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Http;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class RequestTest extends TestCase
{
public function testItExposesAllSuperglobalsAsConstructorArgs(): void
{
$request = new Request(
get: ['q' => '1'],
post: ['name' => 'alpha'],
files: ['image' => ['name' => 'a.png', 'tmp_name' => '/tmp/x', 'error' => 0, 'size' => 12, 'type' => 'image/png']],
cookies: ['__csrf' => 'cookie-value'],
server: ['HTTP_HOST' => 'localhost'],
queryString: 'q=1',
method: 'POST',
path: '/scenarios/demo/edit/team',
contentType: 'application/x-www-form-urlencoded',
rawBody: '',
);
self::assertSame(['q' => '1'], $request->get);
self::assertSame(['name' => 'alpha'], $request->post);
self::assertSame('a.png', $request->files['image']['name']);
self::assertSame('cookie-value', $request->cookies['__csrf']);
self::assertSame('localhost', $request->server['HTTP_HOST']);
self::assertSame('q=1', $request->queryString);
self::assertSame('POST', $request->method);
self::assertSame('/scenarios/demo/edit/team', $request->path);
self::assertSame('application/x-www-form-urlencoded', $request->contentType);
self::assertSame('', $request->rawBody);
}
public function testItAllowsNullContentType(): void
{
$request = new Request(
get: [],
post: [],
files: [],
cookies: [],
server: [],
queryString: '',
method: 'GET',
path: '/',
contentType: null,
rawBody: '',
);
self::assertNull($request->contentType);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Http;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use BattleForge\Http\Router;
use PHPUnit\Framework\TestCase;
final class RouterTest extends TestCase
{
public function testItDispatchesAMatchingStaticRoute(): void
{
$router = new Router();
$router->add('GET', '/', static fn (Request $request): Response => Response::html(200, 'home'));
$response = $router->dispatch($this->request('GET', '/'));
self::assertSame(200, $response->status);
self::assertSame('home', $response->body);
}
public function testItReturnsA404ForUnknownPaths(): void
{
$router = new Router();
$response = $router->dispatch($this->request('GET', '/missing'));
self::assertSame(404, $response->status);
}
public function testItReturnsAMethodNotAllowedResponseForMismatchedMethods(): void
{
$router = new Router();
$router->add('POST', '/', static fn (): Response => Response::html(200, 'ok'));
$response = $router->dispatch($this->request('GET', '/'));
self::assertSame(404, $response->status);
}
public function testItCapturesPathParams(): void
{
$router = new Router();
$router->add('GET', '/scenarios/{id}/edit', static function (Request $request, array $params): Response {
return Response::html(200, 'id=' . $params['id']);
});
$response = $router->dispatch($this->request('GET', '/scenarios/demo/edit'));
self::assertSame(200, $response->status);
self::assertSame('id=demo', $response->body);
}
public function testItMatchesMoreSpecificRoutesFirstByRegistrationOrder(): void
{
$router = new Router();
$router->add('GET', '/scenarios/{id}', static fn (): Response => Response::html(200, 'any'));
$router->add('GET', '/scenarios/special', static fn (): Response => Response::html(200, 'special'));
$response = $router->dispatch($this->request('GET', '/scenarios/special'));
self::assertSame('any', $response->body);
}
private function request(string $method, string $path): Request
{
return new Request(
get: [],
post: [],
files: [],
cookies: [],
server: [],
queryString: '',
method: $method,
path: $path,
contentType: null,
rawBody: '',
);
}
}
+2
View File
@@ -0,0 +1,2 @@
Require all denied
Deny from all
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden\n";