From a0dcff2c2a4d5e7d5eb43ddab45590b5450e9f1c Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 22:21:25 -0500 Subject: [PATCH 01/18] fix: round-trip match objectives through ScenarioSerializer --- src/Application/ScenarioSerializer.php | 19 +++++++++- .../Application/ScenarioSerializerTest.php | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/Application/ScenarioSerializer.php b/src/Application/ScenarioSerializer.php index f9c0c60..49a95dc 100644 --- a/src/Application/ScenarioSerializer.php +++ b/src/Application/ScenarioSerializer.php @@ -130,6 +130,14 @@ final class ScenarioSerializer $units[] = self::unitToArray($unit); } + $objectives = []; + foreach ($match->objectives as $id => $objective) { + $objectives[(string) $id] = [ + 'id' => $objective->id, + 'position' => self::positionToArray($objective->position), + ]; + } + return [ 'battlefield' => [ 'width' => $match->battlefield->width, @@ -141,6 +149,7 @@ final class ScenarioSerializer 'round' => $match->round, 'winnerTeamId' => $match->winnerTeamId, 'actionLog' => $match->actionLog, + 'objectives' => $objectives, 'victoryCondition' => $match->victoryCondition->value, 'holdRoundsRequired' => $match->holdRoundsRequired, 'objectiveControl' => $match->objectiveControl, @@ -166,6 +175,14 @@ final class ScenarioSerializer $units[] = self::unitFromArray($row); } + $objectives = []; + foreach (($data['objectives'] ?? []) as $id => $row) { + $objectives[(string) $id] = new ObjectiveMarker( + (string) $row['id'], + self::positionFromArray($row['position']), + ); + } + return new MatchState( battlefield: $battlefield, units: $units, @@ -173,7 +190,7 @@ final class ScenarioSerializer round: (int) $data['round'], winnerTeamId: $data['winnerTeamId'] ?? null, actionLog: array_map(static fn (mixed $entry): string => (string) $entry, $data['actionLog'] ?? []), - objectives: [], + objectives: $objectives, victoryCondition: VictoryCondition::from((string) $data['victoryCondition']), holdRoundsRequired: (int) $data['holdRoundsRequired'], objectiveControl: $data['objectiveControl'] ?? [], diff --git a/tests/Unit/Application/ScenarioSerializerTest.php b/tests/Unit/Application/ScenarioSerializerTest.php index f64b8e9..e34d11e 100644 --- a/tests/Unit/Application/ScenarioSerializerTest.php +++ b/tests/Unit/Application/ScenarioSerializerTest.php @@ -135,4 +135,41 @@ final class ScenarioSerializerTest extends TestCase // missing 'name', 'battlefield', 'units', etc. ]); } + + public function testMatchRoundTripsItsObjectives(): void + { + $battlefield = new Battlefield(10, 8); + $units = [ + new UnitState('a1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false), + new UnitState('a2', 'alpha', new Position(1, 0), 10, 10, 3, 3, 2, 2, false), + new UnitState('a3', 'alpha', new Position(2, 0), 10, 10, 3, 3, 2, 2, false), + new UnitState('b1', 'bravo', new Position(9, 7), 10, 10, 3, 3, 2, 2, false), + new UnitState('b2', 'bravo', new Position(8, 7), 10, 10, 3, 3, 2, 2, false), + new UnitState('b3', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false), + ]; + $objective = new ObjectiveMarker('objective-1', new Position(5, 4)); + $original = new MatchState( + battlefield: $battlefield, + units: $units, + activeTeamId: 'alpha', + objectives: ['objective-1' => $objective], + victoryCondition: VictoryCondition::HoldObjective, + holdRoundsRequired: 3, + ); + + $array = ScenarioSerializer::matchToArray($original); + self::assertArrayHasKey('objectives', $array); + self::assertSame( + ['id' => 'objective-1', 'position' => ['x' => 5, 'y' => 4]], + $array['objectives']['objective-1'] ?? null, + ); + + $rebuilt = ScenarioSerializer::matchFromArray($array); + self::assertArrayHasKey('objective-1', $rebuilt->objectives); + self::assertSame('objective-1', $rebuilt->objectives['objective-1']->id); + self::assertSame(5, $rebuilt->objectives['objective-1']->position->x); + self::assertSame(4, $rebuilt->objectives['objective-1']->position->y); + self::assertSame(VictoryCondition::HoldObjective, $rebuilt->victoryCondition); + self::assertSame(3, $rebuilt->holdRoundsRequired); + } } From 3d8c1cfdb2ac537e1bcf486d8a9d0800bebfb952 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 22:39:29 -0500 Subject: [PATCH 02/18] feat: add TurnToken HMAC for match-action anti-cheat --- src/Application/TurnToken.php | 79 +++++++++++++++++++++ tests/Unit/Application/TurnTokenTest.php | 88 ++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/Application/TurnToken.php create mode 100644 tests/Unit/Application/TurnTokenTest.php diff --git a/src/Application/TurnToken.php b/src/Application/TurnToken.php new file mode 100644 index 0000000..33f13fd --- /dev/null +++ b/src/Application/TurnToken.php @@ -0,0 +1,79 @@ +expectException(InvalidArgumentException::class); + + TurnToken::issue(self::SECRET, 'not-hex!', 'alpha', 1, 0); + } + + public function testIssueRejectsAnEmptyActiveTeamId(): void + { + $this->expectException(InvalidArgumentException::class); + + TurnToken::issue(self::SECRET, '0123456789abcdef', '', 1, 0); + } + + public function testIssueRejectsANegativeRound(): void + { + $this->expectException(InvalidArgumentException::class); + + TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 0, 0); + } +} From 9314fc587030686454b26bf1007119ddfc38337d Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 22:52:34 -0500 Subject: [PATCH 03/18] feat: mint matchId and turnToken in PostStartMatch --- src/Http/Handlers/PostStartMatch.php | 17 ++++++++++++- tests/Integration/PostStartMatchTest.php | 31 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Http/Handlers/PostStartMatch.php b/src/Http/Handlers/PostStartMatch.php index ce076d3..d65dd6d 100644 --- a/src/Http/Handlers/PostStartMatch.php +++ b/src/Http/Handlers/PostStartMatch.php @@ -6,6 +6,7 @@ namespace BattleForge\Http\Handlers; use BattleForge\Application\ScenarioDraft; use BattleForge\Application\ScenarioSerializer; +use BattleForge\Application\TurnToken; use BattleForge\Domain\ScenarioValidator; use BattleForge\Http\CsrfToken; use BattleForge\Http\Request; @@ -36,6 +37,20 @@ final class PostStartMatch return Response::json(400, ['errors' => [$exception->getMessage()]]); } - return Response::json(200, ['match' => ScenarioSerializer::matchToArray($match)]); + $matchId = bin2hex(random_bytes(8)); + $matchArray = ScenarioSerializer::matchToArray($match); + $turnToken = TurnToken::issue( + $secret, + $matchId, + $match->activeTeamId, + $match->round, + count($match->actionLog), + ); + + return Response::json(200, [ + 'match' => $matchArray, + 'matchId' => $matchId, + 'turnToken' => $turnToken, + ]); } } diff --git a/tests/Integration/PostStartMatchTest.php b/tests/Integration/PostStartMatchTest.php index 1229f9c..3280014 100644 --- a/tests/Integration/PostStartMatchTest.php +++ b/tests/Integration/PostStartMatchTest.php @@ -38,6 +38,10 @@ final class PostStartMatchTest extends TestCase self::assertSame('alpha', $body['match']['activeTeamId'] ?? null); self::assertSame(1, $body['match']['round'] ?? null); self::assertCount(6, $body['match']['units'] ?? []); + self::assertIsString($body['matchId'] ?? null); + self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $body['matchId']); + self::assertIsString($body['turnToken'] ?? null); + self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']); } public function testItReturns400OnAnInvalidScenario(): void @@ -54,6 +58,33 @@ final class PostStartMatchTest extends TestCase self::assertSame(400, $response->status); } + public function testItReturnsATurnTokenValidForTheInitialMatchState(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostStartMatch(); + $request = $this->buildRequest( + rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + $matchId = $body['matchId']; + $turnToken = $body['turnToken']; + $lastActionIndex = count($body['match']['actionLog'] ?? []); + + self::assertTrue(\BattleForge\Application\TurnToken::verify( + self::SECRET, + $matchId, + $body['match']['activeTeamId'], + $body['match']['round'], + $lastActionIndex, + $turnToken, + )); + } + /** @param array $cookies */ private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = []): Request { From 01029b43b7104d06acdce71885a9de85da4283b8 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 23:15:57 -0500 Subject: [PATCH 04/18] feat: add three bundled scenario fixtures and manifest --- public/assets/scenarios/hold-the-line.json | 30 +++++++++++++++++++ public/assets/scenarios/last-stand.json | 34 ++++++++++++++++++++++ public/assets/scenarios/manifest.json | 14 +++++++++ public/assets/scenarios/skirmish.json | 24 +++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 public/assets/scenarios/hold-the-line.json create mode 100644 public/assets/scenarios/last-stand.json create mode 100644 public/assets/scenarios/manifest.json create mode 100644 public/assets/scenarios/skirmish.json diff --git a/public/assets/scenarios/hold-the-line.json b/public/assets/scenarios/hold-the-line.json new file mode 100644 index 0000000..bbb410b --- /dev/null +++ b/public/assets/scenarios/hold-the-line.json @@ -0,0 +1,30 @@ +{ + "id": "hold-the-line", + "name": "Hold the Line", + "battlefield": { + "width": 10, + "height": 8, + "terrain": { + "2:2": "forest", "2:3": "forest", "2:4": "forest", "2:5": "forest", + "3:2": "forest", "3:3": "forest" + } + }, + "units": [ + { "id": "alpha-1", "teamId": "alpha", "position": { "x": 2, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, + { "id": "alpha-2", "teamId": "alpha", "position": { "x": 2, "y": 4 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, + { "id": "alpha-3", "teamId": "alpha", "position": { "x": 2, "y": 5 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, + { "id": "bravo-1", "teamId": "bravo", "position": { "x": 9, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, + { "id": "bravo-2", "teamId": "bravo", "position": { "x": 9, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, + { "id": "bravo-3", "teamId": "bravo", "position": { "x": 9, "y": 7 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }, + { "id": "bravo-4", "teamId": "bravo", "position": { "x": 9, "y": 4 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] } + ], + "deploymentZones": { + "alpha": { "teamId": "alpha", "positions": [{ "x": 2, "y": 3 }, { "x": 2, "y": 4 }, { "x": 2, "y": 5 }] }, + "bravo": { "teamId": "bravo", "positions": [{ "x": 9, "y": 1 }, { "x": 9, "y": 3 }, { "x": 9, "y": 7 }, { "x": 9, "y": 4 }] } + }, + "objectives": { + "objective-1": { "id": "objective-1", "position": { "x": 4, "y": 4 } } + }, + "victoryCondition": "hold_objective", + "holdRoundsRequired": 3 +} diff --git a/public/assets/scenarios/last-stand.json b/public/assets/scenarios/last-stand.json new file mode 100644 index 0000000..9ad26ac --- /dev/null +++ b/public/assets/scenarios/last-stand.json @@ -0,0 +1,34 @@ +{ + "id": "last-stand", + "name": "Last Stand", + "battlefield": { + "width": 12, + "height": 12, + "terrain": { + "2:2": "forest", "2:3": "forest", "2:8": "forest", "2:9": "forest", + "3:2": "forest", "3:3": "forest", "3:8": "forest", "3:9": "forest", + "5:5": "rough", "5:6": "rough", "6:5": "rough", "6:6": "rough", + "8:2": "water", "8:3": "water", "8:8": "water", "8:9": "water", + "9:2": "water", "9:3": "water", "9:8": "water", "9:9": "water" + } + }, + "units": [ + { "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 0 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, + { "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, + { "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 14, "health": 14, "attack": 3, "defense": 5, "speed": 2, "archetype": "defender", "abilities": [] }, + { "id": "alpha-4", "teamId": "alpha", "position": { "x": 0, "y": 9 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }, + { "id": "alpha-5", "teamId": "alpha", "position": { "x": 1, "y": 5 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }, + { "id": "alpha-6", "teamId": "alpha", "position": { "x": 2, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }, + { "id": "bravo-1", "teamId": "bravo", "position": { "x": 11, "y": 2 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, + { "id": "bravo-2", "teamId": "bravo", "position": { "x": 11, "y": 5 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, + { "id": "bravo-3", "teamId": "bravo", "position": { "x": 11, "y": 8 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, + { "id": "bravo-4", "teamId": "bravo", "position": { "x": 10, "y": 11 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] } + ], + "deploymentZones": { + "alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 0 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }, { "x": 0, "y": 9 }, { "x": 1, "y": 5 }, { "x": 2, "y": 11 }] }, + "bravo": { "teamId": "bravo", "positions": [{ "x": 11, "y": 2 }, { "x": 11, "y": 5 }, { "x": 11, "y": 8 }, { "x": 10, "y": 11 }] } + }, + "objectives": {}, + "victoryCondition": "eliminate_all", + "holdRoundsRequired": 1 +} diff --git a/public/assets/scenarios/manifest.json b/public/assets/scenarios/manifest.json new file mode 100644 index 0000000..3fc72ba --- /dev/null +++ b/public/assets/scenarios/manifest.json @@ -0,0 +1,14 @@ +{ + "skirmish": { + "name": "Skirmish", + "summary": "8x8 open ground. Three units per side. Eliminate the enemy team." + }, + "hold-the-line": { + "name": "Hold the Line", + "summary": "10x8 with a forest corridor. Three defenders hold the center against four attackers. Hold the objective for three rounds." + }, + "last-stand": { + "name": "Last Stand", + "summary": "12x12 with mixed terrain. Six defenders versus four strikers. Eliminate the enemy team." + } +} diff --git a/public/assets/scenarios/skirmish.json b/public/assets/scenarios/skirmish.json new file mode 100644 index 0000000..dedfa8b --- /dev/null +++ b/public/assets/scenarios/skirmish.json @@ -0,0 +1,24 @@ +{ + "id": "skirmish", + "name": "Skirmish", + "battlefield": { + "width": 8, + "height": 8, + "terrain": {} + }, + "units": [ + { "id": "alpha-1", "teamId": "alpha", "position": { "x": 0, "y": 1 }, "maxHealth": 12, "health": 12, "attack": 3, "defense": 4, "speed": 2, "archetype": "defender", "abilities": [] }, + { "id": "alpha-2", "teamId": "alpha", "position": { "x": 0, "y": 3 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, + { "id": "alpha-3", "teamId": "alpha", "position": { "x": 0, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] }, + { "id": "bravo-1", "teamId": "bravo", "position": { "x": 7, "y": 1 }, "maxHealth": 8, "health": 8, "attack": 5, "defense": 2, "speed": 3, "archetype": "striker", "abilities": ["area_damage"] }, + { "id": "bravo-2", "teamId": "bravo", "position": { "x": 7, "y": 3 }, "maxHealth": 6, "health": 6, "attack": 3, "defense": 1, "speed": 5, "archetype": "scout", "abilities": [] }, + { "id": "bravo-3", "teamId": "bravo", "position": { "x": 7, "y": 6 }, "maxHealth": 7, "health": 7, "attack": 2, "defense": 2, "speed": 3, "archetype": "support", "abilities": ["heal"] } + ], + "deploymentZones": { + "alpha": { "teamId": "alpha", "positions": [{ "x": 0, "y": 1 }, { "x": 0, "y": 3 }, { "x": 0, "y": 6 }] }, + "bravo": { "teamId": "bravo", "positions": [{ "x": 7, "y": 1 }, { "x": 7, "y": 3 }, { "x": 7, "y": 6 }] } + }, + "objectives": {}, + "victoryCondition": "eliminate_all", + "holdRoundsRequired": 1 +} From c2f9e0a97032840fa1c60ac4e558bb485b2dc3d7 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 23:19:56 -0500 Subject: [PATCH 05/18] fix: update hold-the-line summary to match corrected fixture --- public/assets/scenarios/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/assets/scenarios/manifest.json b/public/assets/scenarios/manifest.json index 3fc72ba..094e771 100644 --- a/public/assets/scenarios/manifest.json +++ b/public/assets/scenarios/manifest.json @@ -5,7 +5,7 @@ }, "hold-the-line": { "name": "Hold the Line", - "summary": "10x8 with a forest corridor. Three defenders hold the center against four attackers. Hold the objective for three rounds." + "summary": "10x8 with a small forest cluster on the alpha side. Three defenders cover an open objective at (4,4); four bravo units push from the right edge. Hold the objective for three rounds." }, "last-stand": { "name": "Last Stand", From 1f9e76afef0d80211143a24e469e440f776eb26c Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 23:37:27 -0500 Subject: [PATCH 06/18] feat: add GetBundledScenarios handler with manifest-driven list --- src/Http/Handlers/GetBundledScenarios.php | 51 +++++++++ tests/Integration/GetBundledScenariosTest.php | 102 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/Http/Handlers/GetBundledScenarios.php create mode 100644 tests/Integration/GetBundledScenariosTest.php diff --git a/src/Http/Handlers/GetBundledScenarios.php b/src/Http/Handlers/GetBundledScenarios.php new file mode 100644 index 0000000..56c157d --- /dev/null +++ b/src/Http/Handlers/GetBundledScenarios.php @@ -0,0 +1,51 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $manifestPath = $this->scenariosDir . '/manifest.json'; + $manifest = []; + if (is_file($manifestPath)) { + $decoded = json_decode((string) file_get_contents($manifestPath), true); + if (is_array($decoded)) { + $manifest = $decoded; + } + } + + $entries = []; + foreach (scandir($this->scenariosDir) ?: [] as $file) { + if (!preg_match(self::FILENAME_PATTERN, $file)) { + continue; + } + $id = substr($file, 0, -5); + $entry = $manifest[$id] ?? null; + if (!is_array($entry) || !isset($entry['name'], $entry['summary'])) { + continue; + } + $entries[] = [ + 'id' => $id, + 'name' => (string) $entry['name'], + 'summary' => (string) $entry['summary'], + ]; + } + + usort($entries, static fn (array $a, array $b): int => strcmp($a['id'], $b['id'])); + + return Response::json(200, $entries); + } +} diff --git a/tests/Integration/GetBundledScenariosTest.php b/tests/Integration/GetBundledScenariosTest.php new file mode 100644 index 0000000..5a739f8 --- /dev/null +++ b/tests/Integration/GetBundledScenariosTest.php @@ -0,0 +1,102 @@ +tmpDir = sys_get_temp_dir() . '/bf-bundled-' . 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); + } + rmdir($this->tmpDir); + } + } + + public function testReturnsBundledScenariosFromTheConfiguredDirectory(): void + { + $this->writeManifest([ + 'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'], + 'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'], + ]); + $this->writeFixture('skirmish', '{}'); + $this->writeFixture('hold-the-line', '{}'); + + $handler = new GetBundledScenarios($this->tmpDir); + $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame( + [ + ['id' => 'hold-the-line', 'name' => 'Hold the Line', 'summary' => 'Defend.'], + ['id' => 'skirmish', 'name' => 'Skirmish', 'summary' => 'Open ground.'], + ], + $body, + ); + } + + public function testFiltersOutFixturesWithoutAManifestEntry(): void + { + $this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]); + $this->writeFixture('skirmish', '{}'); + $this->writeFixture('rogue', '{}'); + + $handler = new GetBundledScenarios($this->tmpDir); + $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); + + $body = json_decode($response->body, true); + self::assertCount(1, $body); + self::assertSame('skirmish', $body[0]['id']); + } + + public function testIgnoresFilesWithDisallowedFilenames(): void + { + $this->writeManifest(['skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.']]); + $this->writeFixture('skirmish', '{}'); + $this->writeFixture('bad..json', '{}'); + $this->writeFixture('README', 'plain text'); + + $handler = new GetBundledScenarios($this->tmpDir); + $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); + + $body = json_decode($response->body, true); + self::assertCount(1, $body); + self::assertSame('skirmish', $body[0]['id']); + } + + public function testReturnsEmptyArrayWhenManifestIsMissing(): void + { + $handler = new GetBundledScenarios($this->tmpDir); + $response = $handler->handle(new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled', null, ''), []); + + self::assertSame(200, $response->status); + self::assertSame([], json_decode($response->body, true)); + } + + /** @param array $entries */ + private function writeManifest(array $entries): void + { + file_put_contents($this->tmpDir . '/manifest.json', json_encode($entries, JSON_THROW_ON_ERROR)); + } + + private function writeFixture(string $name, string $body): void + { + file_put_contents($this->tmpDir . '/' . $name . '.json', $body); + } +} From b8451112d34f81b884b7d7b0e4e6c332ba2c7ebb Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 25 Jul 2026 23:47:09 -0500 Subject: [PATCH 07/18] feat: add GetBundledScenario handler with fixture validation --- src/Http/Handlers/GetBundledScenario.php | 71 ++++++++ tests/Integration/GetBundledScenarioTest.php | 162 +++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/Http/Handlers/GetBundledScenario.php create mode 100644 tests/Integration/GetBundledScenarioTest.php diff --git a/src/Http/Handlers/GetBundledScenario.php b/src/Http/Handlers/GetBundledScenario.php new file mode 100644 index 0000000..d94e8f0 --- /dev/null +++ b/src/Http/Handlers/GetBundledScenario.php @@ -0,0 +1,71 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $id = (string) ($params['id'] ?? ''); + + if (preg_match(self::ID_PATTERN, $id) !== 1) { + return Response::json(404, ['error' => 'not found']); + } + + $manifestPath = $this->scenariosDir . '/manifest.json'; + $manifest = []; + if (is_file($manifestPath)) { + $decoded = json_decode((string) file_get_contents($manifestPath), true); + if (is_array($decoded)) { + $manifest = $decoded; + } + } + + if (!isset($manifest[$id])) { + return Response::json(404, ['error' => 'not found']); + } + + $fixturePath = $this->scenariosDir . '/' . $id . '.json'; + $real = realpath($fixturePath); + $realDir = realpath($this->scenariosDir); + $insideBase = $real !== false + && $realDir !== false + && str_starts_with($real, $realDir . DIRECTORY_SEPARATOR); + if ($insideBase === false || !is_file($real)) { + return Response::json(404, ['error' => 'not found']); + } + + $data = json_decode((string) file_get_contents($real), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($data)) { + return Response::json(500, ['error' => 'fixture invalid', 'details' => ['Fixture JSON is not an object.']]); + } + + try { + $scenario = ScenarioSerializer::scenarioFromArray($data); + } catch (InvalidArgumentException $exception) { + return Response::json(500, ['error' => 'fixture invalid', 'details' => [$exception->getMessage()]]); + } + + $errors = ScenarioValidator::validate($scenario); + if ($errors !== []) { + return Response::json(500, ['error' => 'fixture invalid', 'details' => $errors]); + } + + return Response::json(200, ScenarioSerializer::scenarioToArray($scenario)); + } +} diff --git a/tests/Integration/GetBundledScenarioTest.php b/tests/Integration/GetBundledScenarioTest.php new file mode 100644 index 0000000..09e597f --- /dev/null +++ b/tests/Integration/GetBundledScenarioTest.php @@ -0,0 +1,162 @@ +tmpDir = sys_get_temp_dir() . '/bf-bundled-one-' . bin2hex(random_bytes(4)); + mkdir($this->tmpDir, 0700, true); + file_put_contents( + $this->tmpDir . '/manifest.json', + json_encode(['valid' => ['name' => 'Valid', 'summary' => 'OK']], JSON_THROW_ON_ERROR), + ); + $this->writeValidFixture(); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->tmpDir); + } + } + + public function testReturnsTheScenarioJsonForAKnownId(): void + { + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/valid', null, ''), + ['id' => 'valid'], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame('valid', $body['id'] ?? null); + self::assertSame('Valid', $body['name'] ?? null); + self::assertSame(8, $body['battlefield']['width'] ?? null); + } + + public function testReturns404ForAnUnknownId(): void + { + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/unknown', null, ''), + ['id' => 'unknown'], + ); + + self::assertSame(404, $response->status); + } + + public function testReturns404ForAnIdThatFailsTheRegex(): void + { + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/..', null, ''), + ['id' => '..'], + ); + + self::assertSame(404, $response->status); + } + + public function testReturns500WhenTheFixtureFailsValidation(): void + { + $bad = [ + 'id' => 'broken', + 'name' => 'Broken', + 'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []], + 'units' => [], + 'deploymentZones' => [], + 'objectives' => [], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + file_put_contents($this->tmpDir . '/broken.json', json_encode($bad, JSON_THROW_ON_ERROR)); + file_put_contents( + $this->tmpDir . '/manifest.json', + json_encode([ + 'valid' => ['name' => 'Valid', 'summary' => 'OK'], + 'broken' => ['name' => 'Broken', 'summary' => 'Bad'], + ], JSON_THROW_ON_ERROR), + ); + + $handler = new GetBundledScenario($this->tmpDir); + $response = $handler->handle( + new Request([], [], [], [], [], '', 'GET', '/scenarios/bundled/broken', null, ''), + ['id' => 'broken'], + ); + + self::assertSame(500, $response->status); + $body = json_decode($response->body, true); + self::assertSame('fixture invalid', $body['error'] ?? null); + self::assertIsArray($body['details'] ?? null); + self::assertNotEmpty($body['details']); + } + + private function writeValidFixture(): void + { + $fixture = [ + 'id' => 'valid', + 'name' => 'Valid', + 'battlefield' => ['width' => 8, 'height' => 8, 'terrain' => []], + 'units' => [ + $this->unit('a1', 'alpha', 0, 0), + $this->unit('a2', 'alpha', 1, 0), + $this->unit('a3', 'alpha', 2, 0), + $this->unit('b1', 'bravo', 7, 7), + $this->unit('b2', 'bravo', 6, 7), + $this->unit('b3', 'bravo', 5, 7), + ], + 'deploymentZones' => [ + 'alpha' => $this->deploymentZone('alpha', [[0, 0], [1, 0], [2, 0]]), + 'bravo' => $this->deploymentZone('bravo', [[7, 7], [6, 7], [5, 7]]), + ], + 'objectives' => [], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + file_put_contents($this->tmpDir . '/valid.json', json_encode($fixture, JSON_THROW_ON_ERROR)); + } + + /** @return array */ + private function unit(string $id, string $teamId, int $x, int $y): array + { + return [ + 'id' => $id, + 'teamId' => $teamId, + 'position' => ['x' => $x, 'y' => $y], + 'maxHealth' => 10, + 'health' => 10, + 'attack' => 3, + 'defense' => 3, + 'speed' => 2, + 'archetype' => 'defender', + 'abilities' => [], + ]; + } + + /** + * @param list $coords + * @return array + */ + private function deploymentZone(string $teamId, array $coords): array + { + $positions = []; + foreach ($coords as $coord) { + $positions[] = ['x' => $coord[0], 'y' => $coord[1]]; + } + + return ['teamId' => $teamId, 'positions' => $positions]; + } +} From 51f57d309a8f213137192ad34bc0128e2aabe050 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 00:01:00 -0500 Subject: [PATCH 08/18] feat: render the bundled-scenarios list on the home page --- src/Http/Handlers/GetHomePage.php | 36 +++++++++++++++++++++++++++ src/Views/home.php | 15 +++++++++-- tests/Integration/GetHomePageTest.php | 33 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/Http/Handlers/GetHomePage.php b/src/Http/Handlers/GetHomePage.php index 32275ef..62f708a 100644 --- a/src/Http/Handlers/GetHomePage.php +++ b/src/Http/Handlers/GetHomePage.php @@ -9,10 +9,15 @@ use BattleForge\Http\Response; final class GetHomePage { + public function __construct(private readonly string $scenariosDir = '') + { + } + /** @param array $params */ public function handle(Request $request, array $params): Response { $csrf = $request->cookies['__csrf'] ?? ''; + $bundled = $this->loadBundledScenarios(); ob_start(); require_once __DIR__ . '/../../Views/layout.php'; @@ -21,4 +26,35 @@ final class GetHomePage return Response::html(200, $body); } + + /** @return list */ + private function loadBundledScenarios(): array + { + if ($this->scenariosDir === '' || !is_file($this->scenariosDir . '/manifest.json')) { + return []; + } + $decoded = json_decode((string) file_get_contents($this->scenariosDir . '/manifest.json'), true); + if (!is_array($decoded)) { + return []; + } + $entries = []; + foreach ($decoded as $id => $entry) { + if (!is_string($id) || !preg_match('/^[a-z0-9-]+$/', $id) || !is_array($entry)) { + continue; + } + if (!isset($entry['name'], $entry['summary'])) { + continue; + } + if (!is_file($this->scenariosDir . '/' . $id . '.json')) { + continue; + } + $entries[] = [ + 'id' => $id, + 'name' => (string) $entry['name'], + 'summary' => (string) $entry['summary'], + ]; + } + usort($entries, static fn (array $a, array $b): int => strcmp($a['id'], $b['id'])); + return $entries; + } } diff --git a/src/Views/home.php b/src/Views/home.php index 81143f2..428aceb 100644 --- a/src/Views/home.php +++ b/src/Views/home.php @@ -5,15 +5,26 @@ declare(strict_types=1); use BattleForge\Http\Escape; /** @var string $csrf Provided by the front controller. */ +/** @var list $bundled Provided by GetHomePage. */ ?> Escape::html($v); $a = static fn (string $v): string => Escape::attr($v); ?>

BattleForge

New scenario

+

Play bundled

+
    + +
  • + +
  • + +

Recent scenarios

No saved scenarios yet.

headers['Content-Security-Policy']); self::assertStringContainsString('', $response->body); } + + public function testItRendersTheBundledScenariosListFromTheManifest(): void + { + $tmpDir = sys_get_temp_dir() . '/bf-home-bundled-' . bin2hex(random_bytes(4)); + mkdir($tmpDir, 0700, true); + file_put_contents( + $tmpDir . '/manifest.json', + json_encode([ + 'skirmish' => ['name' => 'Skirmish', 'summary' => 'Open ground.'], + 'hold-the-line' => ['name' => 'Hold the Line', 'summary' => 'Defend.'], + ], JSON_THROW_ON_ERROR), + ); + foreach (['skirmish.json', 'hold-the-line.json'] as $file) { + file_put_contents($tmpDir . '/' . $file, '{}'); + } + + $handler = new GetHomePage($tmpDir); + $request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', ''); + $response = $handler->handle($request, []); + + self::assertSame(200, $response->status); + self::assertStringContainsString('id="bf-bundled"', $response->body); + self::assertStringContainsString('data-bf-bundle="skirmish"', $response->body); + self::assertStringContainsString('data-bf-bundle="hold-the-line"', $response->body); + self::assertStringContainsString('Open ground.', $response->body); + self::assertStringContainsString('Defend.', $response->body); + + unlink($tmpDir . '/manifest.json'); + foreach (['skirmish.json', 'hold-the-line.json'] as $file) { + unlink($tmpDir . '/' . $file); + } + rmdir($tmpDir); + } } From b444f98aa059b7ff25fdc4353a23899a6b5deb88 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 00:13:40 -0500 Subject: [PATCH 09/18] feat: add match view server-rendered shell --- src/Http/Handlers/GetMatchView.php | 24 ++++++++++++++ src/Views/match.php | 43 ++++++++++++++++++++++++++ tests/Integration/GetMatchViewTest.php | 39 +++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/Http/Handlers/GetMatchView.php create mode 100644 src/Views/match.php create mode 100644 tests/Integration/GetMatchViewTest.php diff --git a/src/Http/Handlers/GetMatchView.php b/src/Http/Handlers/GetMatchView.php new file mode 100644 index 0000000..31f8521 --- /dev/null +++ b/src/Http/Handlers/GetMatchView.php @@ -0,0 +1,24 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $csrf = $request->cookies['__csrf'] ?? ''; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/match.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } +} diff --git a/src/Views/match.php b/src/Views/match.php new file mode 100644 index 0000000..dcd7859 --- /dev/null +++ b/src/Views/match.php @@ -0,0 +1,43 @@ + Escape::attr($v); + ?> +
+

Battle

+

+ Active team: + · Round: 0 +

+

+ +

+
+
+
+ +
+
+
+
+ + 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('id="bf-match-header"', $response->body); + self::assertStringContainsString('id="bf-end-turn"', $response->body); + self::assertStringContainsString('id="bf-grid"', $response->body); + self::assertStringContainsString('id="bf-panel"', $response->body); + self::assertStringContainsString('id="bf-action-move"', $response->body); + self::assertStringContainsString('id="bf-action-attack"', $response->body); + self::assertStringContainsString('id="bf-action-ability"', $response->body); + self::assertStringContainsString('id="bf-selected-unit"', $response->body); + self::assertStringContainsString('id="bf-log"', $response->body); + self::assertStringContainsString('id="bf-result"', $response->body); + self::assertStringContainsString('id="bf-winner"', $response->body); + self::assertStringContainsString('id="bf-return-home"', $response->body); + self::assertStringContainsString('src="/js/match.js"', $response->body); + self::assertStringContainsString('name="csrf-token"', $response->body); + } +} From 47dc95fa5670b72a7ae5038c996cec21153a0502 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 00:36:49 -0500 Subject: [PATCH 10/18] feat: add MatchActionSupport for shared CSRF/turn/state verification --- src/Http/MatchActionResult.php | 33 ++++ src/Http/MatchActionSupport.php | 61 +++++++ tests/Unit/Http/MatchActionSupportTest.php | 184 +++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 src/Http/MatchActionResult.php create mode 100644 src/Http/MatchActionSupport.php create mode 100644 tests/Unit/Http/MatchActionSupportTest.php diff --git a/src/Http/MatchActionResult.php b/src/Http/MatchActionResult.php new file mode 100644 index 0000000..bb67ac7 --- /dev/null +++ b/src/Http/MatchActionResult.php @@ -0,0 +1,33 @@ +server['HTTP_X_CSRF_TOKEN'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return MatchActionResult::reject(Response::json(403, ['error' => 'csrf'])); + } + + $payload = json_decode($request->rawBody, true); + if (!is_array($payload)) { + return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']])); + } + + $matchId = (string) ($payload['matchId'] ?? ''); + $matchData = $payload['match'] ?? null; + if (!is_array($matchData)) { + return MatchActionResult::reject(Response::json(400, ['errors' => ['Match is required.']])); + } + + try { + $match = ScenarioSerializer::matchFromArray($matchData); + } catch (\JsonException $exception) { + return MatchActionResult::reject(Response::json(400, ['errors' => ['Malformed JSON.']])); + } catch (\InvalidArgumentException $exception) { + return MatchActionResult::reject(Response::json(400, ['errors' => [$exception->getMessage()]])); + } + + $supplied = (string) ($request->server['HTTP_X_TURN_TOKEN'] ?? ''); + if ( + !TurnToken::verify( + $this->secret, + $matchId, + $match->activeTeamId, + $match->round, + count($match->actionLog), + $supplied, + ) + ) { + return MatchActionResult::reject(Response::json(409, ['error' => 'turn'])); + } + + return MatchActionResult::ok(['matchId' => $matchId, 'match' => $match]); + } +} diff --git a/tests/Unit/Http/MatchActionSupportTest.php b/tests/Unit/Http/MatchActionSupportTest.php new file mode 100644 index 0000000..d0d9cbc --- /dev/null +++ b/tests/Unit/Http/MatchActionSupportTest.php @@ -0,0 +1,184 @@ +buildRequest( + match: $this->validMatchArray(), + csrf: '', + turnToken: 'irrelevant', + ); + + $result = $support->verify($request); + + self::assertNotNull($result->response); + self::assertSame(403, $result->response->status); + self::assertNull($result->matchIdAndMatch); + } + + public function testItRejectsARequestWithABadTurnToken(): void + { + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + $match = $this->validMatch(); + $matchId = '0123456789abcdef'; + $badToken = TurnToken::issue(self::SECRET, $matchId, 'bravo', 1, 0); + + $support = new MatchActionSupport(self::SECRET); + $request = $this->buildRequest( + match: $this->validMatchArray(), + csrf: $csrf, + turnToken: $badToken, + cookies: ['__csrf' => $cookie], + ); + + $result = $support->verify($request); + + self::assertNotNull($result->response); + self::assertSame(409, $result->response->status); + $body = json_decode($result->response->body, true); + self::assertSame('turn', $body['error'] ?? null); + } + + public function testItRejectsAMalformedMatchState(): void + { + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + $support = new MatchActionSupport(self::SECRET); + $request = $this->buildRequest( + match: ['this' => 'is not a match'], + csrf: $csrf, + turnToken: 'whatever', + cookies: ['__csrf' => $cookie], + ); + + $result = $support->verify($request); + + self::assertNotNull($result->response); + self::assertSame(400, $result->response->status); + } + + public function testItReturnsTheValidatedPairForAValidRequest(): void + { + $match = $this->validMatch(); + $matchArray = $this->validMatchArray(); + $matchId = '0123456789abcdef'; + $token = TurnToken::issue( + self::SECRET, + $matchId, + $match->activeTeamId, + $match->round, + count($match->actionLog) + ); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $support = new MatchActionSupport(self::SECRET); + $body = json_encode(['matchId' => $matchId, 'match' => $matchArray], JSON_THROW_ON_ERROR); + $request = new Request( + [], + [], + [], + ['__csrf' => $cookie], + [ + 'HTTP_X_CSRF_TOKEN' => $csrf, + 'HTTP_X_TURN_TOKEN' => $token, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ], + '', + 'POST', + '/matches/current/move', + 'application/json', + $body + ); + + $result = $support->verify($request); + + self::assertNull($result->response); + self::assertNotNull($result->matchIdAndMatch); + self::assertSame($matchId, $result->matchIdAndMatch['matchId']); + self::assertInstanceOf(MatchState::class, $result->matchIdAndMatch['match']); + } + + private function validMatch(): MatchState + { + return new MatchState( + new Battlefield(8, 8), + [ + $this->makeUnit('alpha-1', 'alpha', 0, 0, Archetype::Defender), + $this->makeUnit('alpha-2', 'alpha', 1, 0, Archetype::Defender), + $this->makeUnit('alpha-3', 'alpha', 2, 0, Archetype::Defender), + $this->makeUnit('bravo-1', 'bravo', 7, 7, Archetype::Striker), + $this->makeUnit('bravo-2', 'bravo', 6, 7, Archetype::Striker), + $this->makeUnit('bravo-3', 'bravo', 5, 7, Archetype::Striker), + ], + 'alpha', + ); + } + + private function makeUnit(string $id, string $teamId, int $x, int $y, Archetype $archetype): UnitState + { + return new UnitState( + $id, + $teamId, + new Position($x, $y), + 10, + 10, + 3, + 3, + 2, + 2, + false, + $archetype, + ); + } + + /** @return array */ + private function validMatchArray(): array + { + return \BattleForge\Application\ScenarioSerializer::matchToArray($this->validMatch()); + } + + /** + * @param array $match + * @param array $cookies + */ + private function buildRequest(array $match, string $csrf, string $turnToken, array $cookies = []): Request + { + $body = json_encode(['matchId' => '0123456789abcdef', 'match' => $match], JSON_THROW_ON_ERROR); + return new Request( + [], + [], + [], + $cookies, + [ + 'HTTP_X_CSRF_TOKEN' => $csrf, + 'HTTP_X_TURN_TOKEN' => $turnToken, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ], + '', + 'POST', + '/matches/current/move', + 'application/json', + $body + ); + } +} From df8d4023baaeb2c0aac7644f6b2b47d44aea849e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 01:05:14 -0500 Subject: [PATCH 11/18] feat: add four per-verb match action handlers --- src/Http/Handlers/PostMatchAbility.php | 45 +++ src/Http/Handlers/PostMatchAttack.php | 42 +++ src/Http/Handlers/PostMatchEndTurn.php | 38 +++ src/Http/Handlers/PostMatchMove.php | 76 +++++ tests/Integration/PostMatchActionTest.php | 366 ++++++++++++++++++++++ 5 files changed, 567 insertions(+) create mode 100644 src/Http/Handlers/PostMatchAbility.php create mode 100644 src/Http/Handlers/PostMatchAttack.php create mode 100644 src/Http/Handlers/PostMatchEndTurn.php create mode 100644 src/Http/Handlers/PostMatchMove.php create mode 100644 tests/Integration/PostMatchActionTest.php diff --git a/src/Http/Handlers/PostMatchAbility.php b/src/Http/Handlers/PostMatchAbility.php new file mode 100644 index 0000000..8604a8c --- /dev/null +++ b/src/Http/Handlers/PostMatchAbility.php @@ -0,0 +1,45 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + $payload = json_decode($request->rawBody, true); + $unitId = (string) ($payload['unitId'] ?? ''); + $abilityId = (string) ($payload['abilityId'] ?? ''); + $x = (int) ($payload['x'] ?? -1); + $y = (int) ($payload['y'] ?? -1); + + try { + $next = (new CombatEngine())->useAbility($match, $unitId, $abilityId, new Position($x, $y)); + } catch (CombatException $exception) { + return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next)); + } +} diff --git a/src/Http/Handlers/PostMatchAttack.php b/src/Http/Handlers/PostMatchAttack.php new file mode 100644 index 0000000..34d1773 --- /dev/null +++ b/src/Http/Handlers/PostMatchAttack.php @@ -0,0 +1,42 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + $payload = json_decode($request->rawBody, true); + $attackerId = (string) ($payload['attackerId'] ?? ''); + $targetId = (string) ($payload['targetId'] ?? ''); + + try { + $next = (new CombatEngine())->attack($match, $attackerId, $targetId); + } catch (CombatException $exception) { + return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next)); + } +} diff --git a/src/Http/Handlers/PostMatchEndTurn.php b/src/Http/Handlers/PostMatchEndTurn.php new file mode 100644 index 0000000..6f444d8 --- /dev/null +++ b/src/Http/Handlers/PostMatchEndTurn.php @@ -0,0 +1,38 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + try { + $next = (new CombatEngine())->endTurn($match); + } catch (CombatException $exception) { + return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next)); + } +} diff --git a/src/Http/Handlers/PostMatchMove.php b/src/Http/Handlers/PostMatchMove.php new file mode 100644 index 0000000..8f0b30a --- /dev/null +++ b/src/Http/Handlers/PostMatchMove.php @@ -0,0 +1,76 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + $payload = json_decode($request->rawBody, true); + $unitId = (string) ($payload['unitId'] ?? ''); + $x = (int) ($payload['x'] ?? -1); + $y = (int) ($payload['y'] ?? -1); + + try { + $next = (new CombatEngine())->move($match, $unitId, new Position($x, $y)); + } catch (CombatException $exception) { + return self::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, self::successResponse($this->secret, $matchId, $next)); + } + + /** @return array */ + public static function successResponse( + string $secret, + string $matchId, + \BattleForge\Domain\MatchState $next + ): array { + $turnToken = TurnToken::issue($secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)); + + return [ + 'match' => ScenarioSerializer::matchToArray($next), + 'turnToken' => $turnToken, + 'winnerTeamId' => $next->winnerTeamId, + ]; + } + + public static function rejectionResponse( + string $secret, + string $matchId, + \BattleForge\Domain\MatchState $match, + string $reason + ): Response { + $turnToken = TurnToken::issue($secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)); + + return Response::json(409, [ + 'error' => $reason, + 'match' => ScenarioSerializer::matchToArray($match), + 'turnToken' => $turnToken, + ]); + } +} diff --git a/tests/Integration/PostMatchActionTest.php b/tests/Integration/PostMatchActionTest.php new file mode 100644 index 0000000..94b91c1 --- /dev/null +++ b/tests/Integration/PostMatchActionTest.php @@ -0,0 +1,366 @@ +freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchMove(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-1', + 'x' => 1, + 'y' => 0, + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + $posKey = $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']; + self::assertSame('1:0', $posKey); + self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']); + self::assertNull($body['winnerTeamId']); + } + + public function testMoveRejectsAnUnreachableDestinationAndDoesNotMutateTheMatch(): void + { + $match = $this->freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchMove(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-1', + 'x' => 0, + 'y' => 0, + ], + ), + [], + ); + + self::assertSame(409, $response->status); + $body = json_decode($response->body, true); + self::assertStringContainsString('not reachable', $body['error']); + $posKey = $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']; + self::assertSame('0:0', $posKey); + } + + public function testAttackHappyPathReducesTargetHealth(): void + { + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + $matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7]; + $match = ScenarioSerializer::matchFromArray($matchArray); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAttack(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'attackerId' => 'alpha-1', + 'targetId' => 'bravo-1', + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + $bravo = null; + foreach ($body['match']['units'] as $unit) { + if ($unit['id'] === 'bravo-1') { + $bravo = $unit; + break; + } + } + self::assertNotNull($bravo); + self::assertLessThan(10, $bravo['health']); + } + + public function testAttackRejectsAnOutOfRangeTarget(): void + { + $match = $this->freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAttack(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'attackerId' => 'alpha-1', + 'targetId' => 'bravo-1', + ], + ), + [], + ); + + self::assertSame(409, $response->status); + } + + public function testAbilityHappyPathHealsAnAlly(): void + { + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + foreach ($matchArray['units'] as &$unit) { + if ($unit['id'] === 'alpha-3') { + $unit['archetype'] = 'support'; + $unit['abilities'] = ['heal']; + } + } + unset($unit); + foreach ($matchArray['units'] as &$unit) { + if ($unit['id'] === 'alpha-2') { + $unit['health'] = 2; + } + } + unset($unit); + $match = ScenarioSerializer::matchFromArray($matchArray); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAbility(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-3', + 'abilityId' => 'heal', + 'x' => 0, + 'y' => 1, + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + $healed = null; + foreach ($body['match']['units'] as $unit) { + if ($unit['id'] === 'alpha-2') { + $healed = $unit; + break; + } + } + self::assertGreaterThan(2, $healed['health']); + } + + public function testAbilityRejectsATargetOutsideRange(): void + { + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + foreach ($matchArray['units'] as &$unit) { + if ($unit['id'] === 'alpha-3') { + $unit['archetype'] = 'support'; + $unit['abilities'] = ['heal']; + } + } + unset($unit); + $match = ScenarioSerializer::matchFromArray($matchArray); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAbility(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-3', + 'abilityId' => 'heal', + 'x' => 7, + 'y' => 7, + ], + ), + [], + ); + + self::assertSame(409, $response->status); + } + + public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void + { + $match = $this->freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchEndTurn(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame('bravo', $body['match']['activeTeamId']); + self::assertSame(1, $body['match']['round']); + } + + public function testEndTurnRejectsARequestWithAStaleTurnToken(): void + { + $match = $this->freshMatch(); + $stale = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 99, 0); + $wrongRound = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 2, 0); + $good = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $staleResponse = (new PostMatchEndTurn(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $stale, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + ], + ), + [], + ); + self::assertSame(409, $staleResponse->status); + $body = json_decode($staleResponse->body, true); + self::assertSame('turn', $body['error'] ?? null); + + $wrongRoundResponse = (new PostMatchEndTurn(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $wrongRound, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + ], + ), + [], + ); + self::assertSame(409, $wrongRoundResponse->status); + } + + private function freshMatch(): MatchState + { + $support = new UnitState( + 'alpha-3', + 'alpha', + new Position(1, 1), + 10, + 10, + 3, + 3, + 2, + 2, + false, + Archetype::Support, + self::HEAL, + ); + + return new MatchState( + new Battlefield(8, 8), + [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender), + new UnitState('alpha-2', 'alpha', new Position(0, 1), 10, 10, 3, 3, 2, 2, false, Archetype::Defender), + $support, + new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker), + new UnitState('bravo-2', 'bravo', new Position(6, 6), 10, 10, 3, 3, 2, 2, false, Archetype::Striker), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker), + ], + 'alpha', + ); + } + + private function tokenFor(MatchState $match): string + { + return TurnToken::issue( + self::SECRET, + self::MATCH_ID, + $match->activeTeamId, + $match->round, + count($match->actionLog), + ); + } + + /** + * @param array $cookies + * @param array $body + */ + private function buildRequest( + MatchState $match, + string $csrf, + string $turnToken, + array $cookies, + array $body + ): Request { + return new Request([], [], [], $cookies, [ + 'HTTP_X_CSRF_TOKEN' => $csrf, + 'HTTP_X_TURN_TOKEN' => $turnToken, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ], '', 'POST', '/matches/current/test', 'application/json', json_encode($body, JSON_THROW_ON_ERROR)); + } +} From da4982e55d402500e4dd9cc8ac226ac76a916a27 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 01:19:49 -0500 Subject: [PATCH 12/18] feat: register bundled-scenario and match-action routes --- public/index.php | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/public/index.php b/public/index.php index 37a9d30..d95dea4 100644 --- a/public/index.php +++ b/public/index.php @@ -5,10 +5,17 @@ declare(strict_types=1); use BattleForge\Http\CsrfToken; use BattleForge\Http\Handlers\GetAssets; use BattleForge\Http\Handlers\GetBattlefieldEditor; +use BattleForge\Http\Handlers\GetBundledScenario; +use BattleForge\Http\Handlers\GetBundledScenarios; use BattleForge\Http\Handlers\GetHomePage; +use BattleForge\Http\Handlers\GetMatchView; use BattleForge\Http\Handlers\GetTeamEditor; use BattleForge\Http\Handlers\PostBattlefieldEditor; use BattleForge\Http\Handlers\PostImageUpload; +use BattleForge\Http\Handlers\PostMatchAbility; +use BattleForge\Http\Handlers\PostMatchAttack; +use BattleForge\Http\Handlers\PostMatchEndTurn; +use BattleForge\Http\Handlers\PostMatchMove; use BattleForge\Http\Handlers\PostStartMatch; use BattleForge\Http\Handlers\PostTeamEditor; use BattleForge\Http\Request; @@ -96,7 +103,11 @@ $request = new Request( $uploadsRoot = __DIR__ . '/../var/uploads'; $placeholderDir = __DIR__ . '/assets/placeholders'; -$homePage = static fn (Request $r, array $p): Response => (new GetHomePage())->handle($r, $p); +$scenariosDir = __DIR__ . '/assets/scenarios'; + +$homePage = static function (Request $r, array $p) use ($scenariosDir): Response { + return (new GetHomePage($scenariosDir))->handle($r, $p); +}; $getTeam = static fn (Request $r, array $p): Response => (new GetTeamEditor())->handle($r, $p); $postTeam = static fn (Request $r, array $p): Response => (new PostTeamEditor())->handle($r, $p); $getBattlefield = static fn (Request $r, array $p): Response => (new GetBattlefieldEditor())->handle($r, $p); @@ -113,9 +124,36 @@ $getUploadedAsset = static function (Request $r, array $p) use ($placeholderDir, ['kind' => 'uploads', 'userToken' => $p['userToken'] ?? '', 'filename' => $p['filename'] ?? ''], ); }; +$getBundledList = static function (Request $r, array $p) use ($scenariosDir): Response { + return (new GetBundledScenarios($scenariosDir))->handle($r, $p); +}; +$getBundledOne = static function (Request $r, array $p) use ($scenariosDir): Response { + return (new GetBundledScenario($scenariosDir))->handle($r, $p); +}; +$getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p); +$secret = (string) ($_SERVER['__csrf_secret'] ?? ''); +$postMatchMove = static function (Request $r, array $p) use ($secret): Response { + return (new PostMatchMove($secret))->handle($r, $p); +}; +$postMatchAttack = static function (Request $r, array $p) use ($secret): Response { + return (new PostMatchAttack($secret))->handle($r, $p); +}; +$postMatchAbility = static function (Request $r, array $p) use ($secret): Response { + return (new PostMatchAbility($secret))->handle($r, $p); +}; +$postMatchEndTurn = static function (Request $r, array $p) use ($secret): Response { + return (new PostMatchEndTurn($secret))->handle($r, $p); +}; $router = new Router(); $router->add('GET', '/', $homePage); +$router->add('GET', '/scenarios/bundled', $getBundledList); +$router->add('GET', '/scenarios/bundled/{id}', $getBundledOne); +$router->add('GET', '/matches/current', $getMatchView); +$router->add('POST', '/matches/current/move', $postMatchMove); +$router->add('POST', '/matches/current/attack', $postMatchAttack); +$router->add('POST', '/matches/current/ability', $postMatchAbility); +$router->add('POST', '/matches/current/end-turn', $postMatchEndTurn); $router->add('GET', '/scenarios/{id}/edit/team', $getTeam); $router->add('POST', '/scenarios/{id}/edit/team', $postTeam); $router->add('GET', '/scenarios/{id}/edit/battlefield', $getBattlefield); From 22e38cfebd7415d3242dbd13f23a5ee33097cf64 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 01:56:17 -0500 Subject: [PATCH 13/18] feat: add match.js renderer and battle UI styles --- public/assets/styles.css | 19 ++ public/js/match.js | 387 +++++++++++++++++++++++++++++++++++++++ public/js/storage.js | 62 ++++++- 3 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 public/js/match.js diff --git a/public/assets/styles.css b/public/assets/styles.css index f443cb8..d0cf731 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -19,3 +19,22 @@ 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; } +.bf-match { display: flex; gap: 1rem; } +.bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; } +.bf-tile { width: 2.5rem; height: 2.5rem; border: 0; background: #fff; padding: 0; position: relative; } +.bf-tile[data-bf-terrain="forest"] { background: #cfc; } +.bf-tile[data-bf-terrain="rough"] { background: #fec; } +.bf-tile[data-bf-terrain="water"] { background: #cce; } +.bf-tile[data-bf-terrain="blocking"] { background: #444; } +.bf-unit { width: 100%; height: 100%; font-size: 0.7rem; border: 0; cursor: pointer; } +.bf-unit--alpha { background: #cce5ff; } +.bf-unit--bravo { background: #ffcccc; } +.bf-unit--inactive { opacity: 0.4; cursor: not-allowed; } +.bf-unit--winner { outline: 3px solid #c00; } +.bf-action--active { font-weight: bold; } +.bf-toast { background: #ffd; padding: 0.5rem 1rem; margin: 0.25rem 0; border: 1px solid #cc9; } +.bf-log { max-height: 12rem; overflow: auto; background: #f4f4f4; padding: 0.5rem; margin-top: 1rem; } +.bf-log-entry { margin: 0; font-family: monospace; font-size: 0.85rem; } +.bf-empty { color: #777; font-style: italic; } +.bf-result { margin-top: 1rem; padding: 1rem; background: #efe; border: 2px solid #393; } +.bf-result[hidden] { display: none; } diff --git a/public/js/match.js b/public/js/match.js new file mode 100644 index 0000000..dca5edc --- /dev/null +++ b/public/js/match.js @@ -0,0 +1,387 @@ +const CSRF_META = 'meta[name="csrf-token"]'; +const ACTIVE_TEAM_LABEL = { alpha: 'Alpha', bravo: 'Bravo' }; + +function getCsrfToken() { + const meta = document.querySelector(CSRF_META); + return meta ? meta.getAttribute('content') : ''; +} + +function getEnvelope() { + const raw = localStorage.getItem('match:current'); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !parsed.match || !parsed.matchId) return null; + return parsed; + } catch (e) { + return null; + } +} + +function setEnvelope(envelope) { + localStorage.setItem('match:current', JSON.stringify(envelope)); +} + +function removeEnvelope() { + localStorage.removeItem('match:current'); +} + +function el(tag, attrs = {}, text = null) { + const node = document.createElement(tag); + Object.entries(attrs).forEach(([k, v]) => { + if (v !== null && v !== undefined) { + node.setAttribute(k, String(v)); + } + }); + if (text !== null) { + node.appendChild(document.createTextNode(String(text))); + } + return node; +} + +function renderNoMatch(root) { + while (root.firstChild) root.removeChild(root.firstChild); + const empty = el('p', { class: 'bf-empty' }, 'No match in progress.'); + const link = el('a', { href: '/' }, 'Return to home'); + root.appendChild(empty); + root.appendChild(link); +} + +function renderHeader(match) { + const header = document.getElementById('bf-match-header'); + const team = document.getElementById('bf-active-team'); + const round = document.getElementById('bf-round'); + header.setAttribute('data-bf-active-team', match.activeTeamId); + header.setAttribute('data-bf-round', String(match.round)); + while (team.firstChild) team.removeChild(team.firstChild); + team.appendChild( + document.createTextNode(ACTIVE_TEAM_LABEL[match.activeTeamId] || match.activeTeamId), + ); + while (round.firstChild) round.removeChild(round.firstChild); + round.appendChild(document.createTextNode(String(match.round))); +} + +function renderGrid(root, match) { + while (root.firstChild) root.removeChild(root.firstChild); + const { width, height, terrain } = match.battlefield; + root.setAttribute('data-bf-width', String(width)); + root.setAttribute('data-bf-height', String(height)); + + const unitsByKey = {}; + match.units.forEach((unit) => { + if (unit.health > 0) { + unitsByKey[`${unit.position.x}:${unit.position.y}`] = unit; + } + }); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const key = `${x}:${y}`; + const terrainType = (terrain && terrain[key]) || 'open'; + const tile = el('button', { + type: 'button', + class: 'bf-tile', + 'data-bf-x': x, + 'data-bf-y': y, + 'data-bf-terrain': terrainType, + }); + const unit = unitsByKey[key]; + if (unit) { + const isActive = unit.teamId === match.activeTeamId; + const isWinner = match.winnerTeamId && unit.teamId === match.winnerTeamId; + const cls = ['bf-unit', `bf-unit--${unit.teamId}`]; + if (isActive) cls.push('bf-unit--active'); + else cls.push('bf-unit--inactive'); + if (isWinner) cls.push('bf-unit--winner'); + const unitBtn = el( + 'button', + { + type: 'button', + class: cls.join(' '), + 'data-bf-unit-id': unit.id, + 'data-bf-team': unit.teamId, + }, + `${unit.id.split('-')[1] || unit.id} (${unit.health})`, + ); + tile.appendChild(unitBtn); + } + root.appendChild(tile); + } + } +} + +function renderLog(root, match) { + while (root.firstChild) root.removeChild(root.firstChild); + const log = Array.isArray(match.actionLog) ? match.actionLog.slice(-10) : []; + log.forEach((entry) => { + root.appendChild(el('p', { class: 'bf-log-entry' }, entry)); + }); +} + +function renderResult(match) { + const result = document.getElementById('bf-result'); + const winner = document.getElementById('bf-winner'); + while (winner.firstChild) winner.removeChild(winner.firstChild); + winner.appendChild( + document.createTextNode(ACTIVE_TEAM_LABEL[match.winnerTeamId] || match.winnerTeamId), + ); + result.removeAttribute('hidden'); + const endBtn = document.getElementById('bf-end-turn'); + if (endBtn) endBtn.setAttribute('disabled', 'disabled'); + ['bf-action-move', 'bf-action-attack', 'bf-action-ability'].forEach((id) => { + const btn = document.getElementById(id); + if (btn) btn.setAttribute('disabled', 'disabled'); + }); +} + +function renderState() { + const envelope = getEnvelope(); + const grid = document.getElementById('bf-grid'); + if (!envelope) { + renderNoMatch(grid); + return; + } + const { match } = envelope; + renderHeader(match); + renderGrid(grid, match); + renderLog(document.getElementById('bf-log'), match); + if (match.winnerTeamId) { + renderResult(match); + } else { + document.getElementById('bf-result').setAttribute('hidden', 'hidden'); + } +} + +function toast(message) { + const host = document.getElementById('bf-toast-host'); + if (!host) return; + const node = el('div', { class: 'bf-toast' }, message); + host.appendChild(node); + setTimeout(() => node.remove(), 3000); +} + +async function dispatch(verb, params) { + const envelope = getEnvelope(); + if (!envelope) { + toast('No match in progress.'); + return; + } + const body = { matchId: envelope.matchId, match: envelope.match, ...params }; + try { + const response = await fetch(`/matches/current/${verb}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken(), + 'X-Turn-Token': envelope.turnToken || '', + }, + body: JSON.stringify(body), + }); + const data = await response.json().catch(() => ({})); + if (response.status === 200) { + setEnvelope({ + matchId: envelope.matchId, + match: data.match, + turnToken: data.turnToken, + }); + renderState(); + return; + } + if (response.status === 409) { + if (data.error === 'turn') { + const fresh = getEnvelope(); + if (fresh) { + setEnvelope(fresh); + renderState(); + toast('Turn has changed; resynced.'); + } else { + renderNoMatch(document.getElementById('bf-grid')); + } + return; + } + if (data.match) { + setEnvelope({ + matchId: envelope.matchId, + match: data.match, + turnToken: data.turnToken || envelope.turnToken, + }); + renderState(); + } + if (data.error) toast(data.error); + return; + } + if (data.error) { + toast(data.error); + } else { + toast(`Action failed: HTTP ${response.status}`); + } + } catch (e) { + toast(`Network error: ${e.message}`); + } +} + +let activeMode = 'move'; +let selectedUnitId = null; + +function updateSelectedUnitIndicator() { + const node = document.getElementById('bf-selected-unit'); + if (!node) return; + while (node.firstChild) node.removeChild(node.firstChild); + node.appendChild(document.createTextNode(selectedUnitId || 'none')); +} + +function renderAbilityOptions() { + const host = document.getElementById('bf-ability-options'); + if (!host) return; + while (host.firstChild) host.removeChild(host.firstChild); + if (activeMode !== 'ability' || !selectedUnitId) { + return; + } + const envelope = getEnvelope(); + if (!envelope) return; + const unit = envelope.match.units.find((u) => u.id === selectedUnitId); + if (!unit || !Array.isArray(unit.abilities) || unit.abilities.length === 0) { + host.appendChild(el('p', { class: 'bf-empty' }, 'No abilities.')); + return; + } + unit.abilities.forEach((abilityId) => { + const radio = el('input', { + type: 'radio', + name: 'bf-ability', + value: abilityId, + id: `bf-ability-${abilityId}`, + }); + const label = el('label', { for: `bf-ability-${abilityId}` }, abilityId); + const wrap = el('span', {}); + wrap.appendChild(radio); + wrap.appendChild(label); + host.appendChild(wrap); + }); +} + +function setMode(mode) { + activeMode = mode; + ['move', 'attack', 'ability'].forEach((m) => { + const btn = document.getElementById(`bf-action-${m}`); + if (btn) { + if (m === mode) btn.classList.add('bf-action--active'); + else btn.classList.remove('bf-action--active'); + } + }); + renderAbilityOptions(); +} + +function onTileClick(event) { + const tile = event.target.closest('.bf-tile'); + if (!tile) return; + const x = Number(tile.getAttribute('data-bf-x')); + const y = Number(tile.getAttribute('data-bf-y')); + const unitBtn = tile.querySelector('.bf-unit'); + if (unitBtn) { + const unitId = unitBtn.getAttribute('data-bf-unit-id'); + const team = unitBtn.getAttribute('data-bf-team'); + const envelope = getEnvelope(); + if (!envelope) return; + if (team !== envelope.match.activeTeamId) { + toast('That unit is not on the active team.'); + return; + } + if (activeMode === 'attack') { + toast('Pick a friendly unit to attack with, then click an enemy.'); + selectedUnitId = unitId; + updateSelectedUnitIndicator(); + return; + } + selectedUnitId = unitId; + updateSelectedUnitIndicator(); + return; + } + if (activeMode === 'move' && selectedUnitId) { + dispatch('move', { unitId: selectedUnitId, x, y }); + return; + } + if (activeMode === 'attack' && selectedUnitId) { + const envelope = getEnvelope(); + if (!envelope) return; + const attacker = envelope.match.units.find((u) => u.id === selectedUnitId); + if (!attacker) return; + const target = envelope.match.units.find( + (u) => u.position.x === x && u.position.y === y && u.teamId !== attacker.teamId, + ); + if (!target) { + toast('No enemy on that tile.'); + return; + } + dispatch('attack', { attackerId: selectedUnitId, targetId: target.id }); + return; + } + if (activeMode === 'ability' && selectedUnitId) { + const radio = document.querySelector('input[name="bf-ability"]:checked'); + if (!radio) { + toast('Pick an ability first.'); + return; + } + dispatch('ability', { + unitId: selectedUnitId, + abilityId: radio.value, + x, + y, + }); + } +} + +function onUnitClick(event) { + const unitBtn = event.target.closest('.bf-unit'); + if (!unitBtn) return; + const unitId = unitBtn.getAttribute('data-bf-unit-id'); + const team = unitBtn.getAttribute('data-bf-team'); + const envelope = getEnvelope(); + if (!envelope) return; + if (activeMode === 'attack' && team !== envelope.match.activeTeamId) { + if (!selectedUnitId) { + toast('Pick a friendly attacker first.'); + return; + } + dispatch('attack', { attackerId: selectedUnitId, targetId: unitId }); + return; + } + if (team === envelope.match.activeTeamId) { + selectedUnitId = unitId; + updateSelectedUnitIndicator(); + return; + } + toast('That unit is not on the active team.'); +} + +function wireEvents() { + document.getElementById('bf-grid').addEventListener('click', (event) => { + const unitBtn = event.target.closest('.bf-unit'); + if (unitBtn) { + onUnitClick(event); + } else { + onTileClick(event); + } + }); + ['move', 'attack', 'ability'].forEach((mode) => { + const btn = document.getElementById(`bf-action-${mode}`); + if (btn) btn.addEventListener('click', () => setMode(mode)); + }); + const endBtn = document.getElementById('bf-end-turn'); + if (endBtn) { + endBtn.addEventListener('click', () => dispatch('end-turn', {})); + } + const returnHome = document.getElementById('bf-return-home'); + if (returnHome) { + returnHome.addEventListener('click', (event) => { + event.preventDefault(); + removeEnvelope(); + window.location.href = '/'; + }); + } +} + +document.addEventListener('DOMContentLoaded', () => { + wireEvents(); + setMode('move'); + renderState(); +}); diff --git a/public/js/storage.js b/public/js/storage.js index 43f2fc2..6e5a5eb 100644 --- a/public/js/storage.js +++ b/public/js/storage.js @@ -188,14 +188,63 @@ async function startMatch() { } const data = await res.json(); if (data.match) { - setCurrentMatch(data.match); - window.location.href = '/'; + setCurrentMatch({ matchId: data.matchId, match: data.match, turnToken: data.turnToken }); + window.location.href = '/matches/current'; } else { // eslint-disable-next-line no-alert alert(`Start match failed: ${data.errors ? data.errors.join(', ') : 'unknown'}`); } } +async function startBundledMatch(id) { + if (typeof id !== 'string' || !/^[a-z0-9-]+$/.test(id)) { + return; + } + const csrf = getCsrfToken(); + let manifest; + try { + const res = await fetch(`/scenarios/bundled/${encodeURIComponent(id)}`); + if (!res.ok) { + // eslint-disable-next-line no-alert + alert(`Could not load scenario: HTTP ${res.status}`); + return; + } + manifest = await res.json(); + } catch (e) { + // eslint-disable-next-line no-alert + alert(`Network error: ${e.message}`); + return; + } + let response; + try { + response = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf, + }, + body: JSON.stringify(manifest), + }); + } catch (e) { + // eslint-disable-next-line no-alert + alert(`Network error: ${e.message}`); + return; + } + if (!response.ok) { + // eslint-disable-next-line no-alert + alert(`Start match failed: HTTP ${response.status}`); + return; + } + const data = await response.json(); + if (data.match && data.matchId && data.turnToken) { + setCurrentMatch({ matchId: data.matchId, match: data.match, turnToken: data.turnToken }); + window.location.href = '/matches/current'; + } else { + // eslint-disable-next-line no-alert + alert(`Start match failed: ${(data.errors || ['unknown']).join(', ')}`); + } +} + window.bfStorage = { load, save, @@ -204,6 +253,7 @@ window.bfStorage = { currentMatch, setCurrentMatch, removeCurrentMatch, + startBundledMatch, }; document.addEventListener('DOMContentLoaded', () => { @@ -216,4 +266,12 @@ document.addEventListener('DOMContentLoaded', () => { startMatch(); }); } + document.querySelectorAll('[data-bf-bundle]').forEach((button) => { + button.addEventListener('click', () => { + const id = button.getAttribute('data-bf-bundle'); + if (id) { + startBundledMatch(id); + } + }); + }); }); From 06f9c9e37cbf6668c90c6f66a6bc63a1e4fb1664 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 02:05:29 -0500 Subject: [PATCH 14/18] fix: add .bf-unit--active CSS rule --- public/assets/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/public/assets/styles.css b/public/assets/styles.css index d0cf731..e392f98 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -29,6 +29,7 @@ table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; } .bf-unit { width: 100%; height: 100%; font-size: 0.7rem; border: 0; cursor: pointer; } .bf-unit--alpha { background: #cce5ff; } .bf-unit--bravo { background: #ffcccc; } +.bf-unit--active { outline: 2px solid #060; } .bf-unit--inactive { opacity: 0.4; cursor: not-allowed; } .bf-unit--winner { outline: 3px solid #c00; } .bf-action--active { font-weight: bold; } From d118920739581efac9b586f0e48a584e7f72d291 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 03:25:57 -0500 Subject: [PATCH 15/18] test: add end-to-end smoke test for the battle interface --- .../Integration/PostMatchActionSmokeTest.php | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/Integration/PostMatchActionSmokeTest.php diff --git a/tests/Integration/PostMatchActionSmokeTest.php b/tests/Integration/PostMatchActionSmokeTest.php new file mode 100644 index 0000000..5698a1e --- /dev/null +++ b/tests/Integration/PostMatchActionSmokeTest.php @@ -0,0 +1,366 @@ +serverProcess = $this->bootServer(); + $this->primeCsrf(); + } + + protected function tearDown(): void + { + if (is_resource($this->serverProcess)) { + proc_terminate($this->serverProcess, 9); + proc_close($this->serverProcess); + } + @unlink(__DIR__ . '/../../var/secret.key'); + } + + public function testHomePageReturnsTheBundledScenariosList(): void + { + $body = $this->get('/'); + self::assertStringContainsString('id="bf-bundled"', $body); + self::assertStringContainsString('data-bf-bundle="skirmish"', $body); + } + + public function testSkirmishScenarioStartsAndCanBePlayedToAWinner(): void + { + $scenario = $this->getJson('/scenarios/bundled/skirmish'); + self::assertSame('skirmish', $scenario['id'] ?? null); + + $startResponse = $this->postJson('/scenarios/skirmish/start', $scenario); + self::assertSame(200, $startResponse['status']); + $match = $startResponse['body']['match'] ?? null; + $matchId = $startResponse['body']['matchId'] ?? null; + $turnToken = $startResponse['body']['turnToken'] ?? null; + self::assertIsArray($match); + self::assertSame(self::MATCH_ID, $matchId); + self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken); + + for ($i = 0; $i < 200; $i += 1) { + if (!empty($match['winnerTeamId'])) { + break; + } + $action = $this->pickAction($match); + $response = $this->postJson("/matches/current/{$action['verb']}", $action['body']); + $errMsg = "Step {$i} verb={$action['verb']} failed: " . ($response['body']['error'] ?? ''); + self::assertSame(200, $response['status'], $errMsg); + $match = $response['body']['match']; + $turnToken = $response['body']['turnToken']; + } + self::assertNotNull($match['winnerTeamId'] ?? null, 'Skirmish did not produce a winner within 200 actions.'); + } + + public function testStaleTurnTokenReturns409(): void + { + $scenario = $this->getJson('/scenarios/bundled/skirmish'); + $startResponse = $this->postJson('/scenarios/skirmish/start', $scenario); + self::assertSame(200, $startResponse['status']); + $match = $startResponse['body']['match']; + $staleToken = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 99, 0); + + $response = $this->postJson('/matches/current/end-turn', [ + 'matchId' => self::MATCH_ID, + 'match' => $match, + ], $staleToken); + + self::assertSame(409, $response['status']); + self::assertSame('turn', $response['body']['error'] ?? null); + } + + public function testBundledHoldTheLineAndLastStandStartMatches(): void + { + foreach (['hold-the-line', 'last-stand'] as $id) { + $scenario = $this->getJson("/scenarios/bundled/{$id}"); + $response = $this->postJson("/scenarios/{$id}/start", $scenario); + self::assertSame(200, $response['status'], "Failed to start {$id}"); + self::assertSame( + 'alpha', + $response['body']['match']['activeTeamId'] ?? null, + "{$id} did not start with alpha", + ); + self::assertNotEmpty($response['body']['matchId']); + self::assertNotEmpty($response['body']['turnToken']); + } + } + + /** @return resource */ + private function bootServer() + { + $this->writeSecret(); + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $env = [ + 'PATH' => getenv('PATH') ?: '', + 'BATTLEFORGE_SECRET' => self::SECRET, + ]; + $process = proc_open( + ['php', '-S', '127.0.0.1:8765', '-t', __DIR__ . '/../../public'], + $descriptors, + $pipes, + __DIR__ . '/../..', + $env, + ); + if (!is_resource($process)) { + self::fail('Failed to start php -S'); + } + // Wait for the server to print "PHP X.Y.Z Development Server (...) started" + $start = microtime(true); + while (microtime(true) - $start < 5.0) { + $line = fgets($pipes[1]); + if ($line !== false && str_contains($line, 'started')) { + break; + } + usleep(50_000); + } + return $process; + } + + private function writeSecret(): void + { + @unlink(__DIR__ . '/../../var/secret.key'); + $dir = __DIR__ . '/../../var'; + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + // We don't need an actual random secret because BATTLEFORGE_SECRET env wins. + // Just touch the file to make sure the dir exists. + file_put_contents($dir . '/secret.key', 'unused-because-env-wins'); + } + + private function primeCsrf(): void + { + $ctx = stream_context_create(['http' => ['ignore_errors' => true, 'header' => '']]); + $body = (string) file_get_contents(self::BASE_URL . '/', false, $ctx); + $headers = (string) ($http_response_header[0] ?? ''); + foreach ($http_response_header as $line) { + if (preg_match('/^Set-Cookie:\\s*' . self::CSRF_COOKIE_NAME . '=([^;]+)/i', $line, $m)) { + $this->csrfCookie = $m[1]; + $this->csrfToken = $this->csrfCookie; + return; + } + } + self::fail('Could not prime CSRF cookie: ' . $body); + } + + /** @return array */ + private function getJson(string $path): array + { + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n", + 'ignore_errors' => true, + ], + ]); + $body = (string) file_get_contents(self::BASE_URL . $path, false, $ctx); + $data = json_decode($body, true); + if (!is_array($data)) { + self::fail("GET {$path} did not return JSON: {$body}"); + } + return $data; + } + + private function get(string $path): string + { + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n", + 'ignore_errors' => true, + ], + ]); + return (string) file_get_contents(self::BASE_URL . $path, false, $ctx); + } + + /** + * @param array $body + * @return array{status: int, body: array} + */ + private function postJson(string $path, array $body, ?string $turnToken = null): array + { + $headers = [ + 'Content-Type: application/json', + 'X-CSRF-Token: ' . $this->csrfToken, + 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie, + ]; + if ($turnToken !== null) { + $headers[] = 'X-Turn-Token: ' . $turnToken; + } elseif (isset($body['match'])) { + $match = $body['match']; + $matchId = (string) ($body['matchId'] ?? self::MATCH_ID); + $turnToken = TurnToken::issue( + self::SECRET, + $matchId, + (string) ($match['activeTeamId'] ?? 'alpha'), + (int) ($match['round'] ?? 1), + count((array) ($match['actionLog'] ?? [])), + ); + $headers[] = 'X-Turn-Token: ' . $turnToken; + } + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => json_encode($body, JSON_THROW_ON_ERROR), + 'ignore_errors' => true, + ], + ]); + $response = (string) file_get_contents(self::BASE_URL . $path, false, $ctx); + $status = 0; + foreach ($http_response_header as $line) { + if (preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) { + $status = (int) $m[1]; + break; + } + } + $decoded = json_decode($response, true); + return ['status' => $status, 'body' => is_array($decoded) ? $decoded : ['raw' => $response]]; + } + + /** + * Pick a sensible action for the current match. The strategy: + * - if any active unit is adjacent to an enemy, attack. + * - otherwise move toward the nearest enemy. + * - if no enemy is reachable this turn, end the turn. + * + * @param array $match + * @return array{verb: string, body: array} + */ + private function pickAction(array $match): array + { + $units = $match['units']; + $active = $match['activeTeamId']; + $enemy = $active === 'alpha' ? 'bravo' : 'alpha'; + foreach ($units as $unit) { + if ($unit['teamId'] !== $active || $unit['health'] <= 0) { + continue; + } + foreach ($units as $other) { + if ($other['teamId'] !== $enemy || $other['health'] <= 0) { + continue; + } + $dist = abs($unit['position']['x'] - $other['position']['x']) + + abs($unit['position']['y'] - $other['position']['y']); + if ($dist === 1 && $unit['actionsRemaining'] > 0 && empty($unit['hasAttacked'])) { + return [ + 'verb' => 'attack', + 'body' => [ + 'matchId' => self::MATCH_ID, + 'match' => $match, + 'attackerId' => $unit['id'], + 'targetId' => $other['id'], + ], + ]; + } + } + } + foreach ($units as $unit) { + if ($unit['teamId'] !== $active || $unit['health'] <= 0 || $unit['actionsRemaining'] === 0) { + continue; + } + $bestTarget = null; + $bestDist = PHP_INT_MAX; + foreach ($units as $other) { + if ($other['teamId'] !== $enemy || $other['health'] <= 0) { + continue; + } + $dist = abs($unit['position']['x'] - $other['position']['x']) + + abs($unit['position']['y'] - $other['position']['y']); + if ($dist < $bestDist) { + $bestDist = $dist; + $bestTarget = $other; + } + } + if ($bestTarget) { + $dx = $bestTarget['position']['x'] - $unit['position']['x']; + $dy = $bestTarget['position']['y'] - $unit['position']['y']; + $stepX = $dx === 0 ? 0 : ($dx > 0 ? 1 : -1); + $stepY = $dy === 0 ? 0 : ($dy > 0 ? 1 : -1); + $candidates = []; + if ($stepX !== 0) { + $candidates[] = [ + 'x' => $unit['position']['x'] + $stepX, + 'y' => $unit['position']['y'], + ]; + } + if ($stepY !== 0) { + $candidates[] = [ + 'x' => $unit['position']['x'], + 'y' => $unit['position']['y'] + $stepY, + ]; + } + $candidates[] = [ + 'x' => $unit['position']['x'] + ($stepX ?: 0), + 'y' => $unit['position']['y'] + ($stepY ?: 0), + ]; + foreach ($candidates as $c) { + $inBounds = $c['x'] >= 0 && $c['x'] < $match['battlefield']['width'] + && $c['y'] >= 0 && $c['y'] < $match['battlefield']['height']; + if (!$inBounds) { + continue; + } + $occupied = false; + foreach ($units as $other) { + if ($other['id'] === $unit['id'] || $other['health'] <= 0) { + continue; + } + if ($other['position']['x'] === $c['x'] && $other['position']['y'] === $c['y']) { + $occupied = true; + break; + } + } + if ($occupied) { + continue; + } + return [ + 'verb' => 'move', + 'body' => [ + 'matchId' => self::MATCH_ID, + 'match' => $match, + 'unitId' => $unit['id'], + 'x' => $c['x'], + 'y' => $c['y'], + ], + ]; + } + } + } + return [ + 'verb' => 'end-turn', + 'body' => [ + 'matchId' => self::MATCH_ID, + 'match' => $match, + ], + ]; + } +} From 4c2586908f27771039cd2d695bfd5c8b714ab841 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 03:32:54 -0500 Subject: [PATCH 16/18] fix: use server-minted matchId in smoke test actions --- .../Integration/PostMatchActionSmokeTest.php | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/Integration/PostMatchActionSmokeTest.php b/tests/Integration/PostMatchActionSmokeTest.php index 5698a1e..1fadea0 100644 --- a/tests/Integration/PostMatchActionSmokeTest.php +++ b/tests/Integration/PostMatchActionSmokeTest.php @@ -15,7 +15,6 @@ use PHPUnit\Framework\TestCase; final class PostMatchActionSmokeTest extends TestCase { private const SECRET = 'e2e-smoke-secret'; - private const MATCH_ID = '0123456789abcdef'; private const BASE_URL = 'http://127.0.0.1:8765'; private const CSRF_COOKIE_NAME = '__csrf'; @@ -60,14 +59,15 @@ final class PostMatchActionSmokeTest extends TestCase $matchId = $startResponse['body']['matchId'] ?? null; $turnToken = $startResponse['body']['turnToken'] ?? null; self::assertIsArray($match); - self::assertSame(self::MATCH_ID, $matchId); + self::assertIsString($matchId); + self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $matchId); self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken); for ($i = 0; $i < 200; $i += 1) { if (!empty($match['winnerTeamId'])) { break; } - $action = $this->pickAction($match); + $action = $this->pickAction($match, $matchId); $response = $this->postJson("/matches/current/{$action['verb']}", $action['body']); $errMsg = "Step {$i} verb={$action['verb']} failed: " . ($response['body']['error'] ?? ''); self::assertSame(200, $response['status'], $errMsg); @@ -83,10 +83,11 @@ final class PostMatchActionSmokeTest extends TestCase $startResponse = $this->postJson('/scenarios/skirmish/start', $scenario); self::assertSame(200, $startResponse['status']); $match = $startResponse['body']['match']; - $staleToken = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 99, 0); + $matchId = $startResponse['body']['matchId']; + $staleToken = TurnToken::issue(self::SECRET, $matchId, 'alpha', 99, 0); $response = $this->postJson('/matches/current/end-turn', [ - 'matchId' => self::MATCH_ID, + 'matchId' => $matchId, 'match' => $match, ], $staleToken); @@ -217,7 +218,7 @@ final class PostMatchActionSmokeTest extends TestCase $headers[] = 'X-Turn-Token: ' . $turnToken; } elseif (isset($body['match'])) { $match = $body['match']; - $matchId = (string) ($body['matchId'] ?? self::MATCH_ID); + $matchId = (string) ($body['matchId'] ?? ''); $turnToken = TurnToken::issue( self::SECRET, $matchId, @@ -254,9 +255,10 @@ final class PostMatchActionSmokeTest extends TestCase * - if no enemy is reachable this turn, end the turn. * * @param array $match + * @param string $matchId * @return array{verb: string, body: array} */ - private function pickAction(array $match): array + private function pickAction(array $match, string $matchId): array { $units = $match['units']; $active = $match['activeTeamId']; @@ -275,7 +277,7 @@ final class PostMatchActionSmokeTest extends TestCase return [ 'verb' => 'attack', 'body' => [ - 'matchId' => self::MATCH_ID, + 'matchId' => $matchId, 'match' => $match, 'attackerId' => $unit['id'], 'targetId' => $other['id'], @@ -345,7 +347,7 @@ final class PostMatchActionSmokeTest extends TestCase return [ 'verb' => 'move', 'body' => [ - 'matchId' => self::MATCH_ID, + 'matchId' => $matchId, 'match' => $match, 'unitId' => $unit['id'], 'x' => $c['x'], @@ -358,7 +360,7 @@ final class PostMatchActionSmokeTest extends TestCase return [ 'verb' => 'end-turn', 'body' => [ - 'matchId' => self::MATCH_ID, + 'matchId' => $matchId, 'match' => $match, ], ]; From 48e7fa9a25f9dfdf18bee40242235c8fe0c363c1 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 03:43:57 -0500 Subject: [PATCH 17/18] ci: add end-to-end smoke test workflow --- .github/workflows/ci-e2e.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci-e2e.yml diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml new file mode 100644 index 0000000..4b946ac --- /dev/null +++ b/.github/workflows/ci-e2e.yml @@ -0,0 +1,29 @@ +name: CI E2E + +on: + pull_request: + branches: + - develop + schedule: + - cron: '0 6 * * *' + +permissions: + contents: read + +jobs: + php-e2e: + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + BATTLEFORGE_E2E: '1' + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2 + with: + php-version: '8.3' + coverage: none + tools: composer:v2 + - run: composer install --no-interaction --prefer-dist + - run: vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php From d53a84ce8572ecb7a11c207062e32052d459f241 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 03:58:03 -0500 Subject: [PATCH 18/18] fix: enable action-mode buttons in match view (final-review Critical) --- public/js/match.js | 1 + src/Views/match.php | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/public/js/match.js b/public/js/match.js index dca5edc..3ef2b79 100644 --- a/public/js/match.js +++ b/public/js/match.js @@ -266,6 +266,7 @@ function setMode(mode) { if (btn) { if (m === mode) btn.classList.add('bf-action--active'); else btn.classList.remove('bf-action--active'); + btn.removeAttribute('disabled'); } }); renderAbilityOptions(); diff --git a/src/Views/match.php b/src/Views/match.php index dcd7859..f7a4fda 100644 --- a/src/Views/match.php +++ b/src/Views/match.php @@ -24,9 +24,9 @@ render_layout(static function (): void {