Merge feature/battle-interface into develop

Implements Plan 4: battle interface, bundled scenarios, smoke test, release hardening.

- Domain: TurnToken HMAC + ScenarioSerializer objective round-trip
- Web layer: PostStartMatch mints matchId+turnToken; four per-verb action handlers (move/attack/ability/end-turn) on MatchActionSupport
- Bundled scenarios: skirmish, hold-the-line, last-stand + manifest
- Match view: server-rendered shell + match.js renderer with hot-seat action modes
- E2E smoke test gated by BATTLEFORGE_E2E=1 + ci-e2e.yml workflow (PR-to-develop + nightly)

Final review: 1 Critical (permanently disabled action buttons) addressed in d53a84c; re-review clean.

234 PHPUnit tests pass (+47 new since Plan 1 baseline); PHPStan clean; PHPCS delta 0; ESLint clean; Prettier clean.
This commit is contained in:
Keith Solomon
2026-07-26 10:28:36 -05:00
34 changed files with 2694 additions and 7 deletions
+29
View File
@@ -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
@@ -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
}
+34
View File
@@ -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
}
+14
View File
@@ -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 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",
"summary": "12x12 with mixed terrain. Six defenders versus four strikers. Eliminate the enemy team."
}
}
+24
View File
@@ -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
}
+20
View File
@@ -19,3 +19,23 @@ 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--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; }
.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; }
+39 -1
View File
@@ -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);
+388
View File
@@ -0,0 +1,388 @@
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');
btn.removeAttribute('disabled');
}
});
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();
});
+60 -2
View File
@@ -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);
}
});
});
});
+18 -1
View File
@@ -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'] ?? [],
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace BattleForge\Application;
use InvalidArgumentException;
final class TurnToken
{
private const MATCH_ID_PATTERN = '/^[a-f0-9]{16,}$/';
public static function issue(
string $secret,
string $matchId,
string $activeTeamId,
int $round,
int $lastActionIndex
): string {
self::assertValidInputs($matchId, $activeTeamId, $round, $lastActionIndex);
$payload = self::payload($matchId, $activeTeamId, $round, $lastActionIndex);
return substr(hash_hmac('sha256', $payload, $secret), 0, 32);
}
public static function verify(
string $secret,
string $matchId,
string $activeTeamId,
int $round,
int $lastActionIndex,
string $token
): bool {
if (
$token === ''
|| !self::isValidMatchId($matchId)
|| $activeTeamId === ''
|| $round < 1
|| $lastActionIndex < 0
) {
return false;
}
$expected = self::issue($secret, $matchId, $activeTeamId, $round, $lastActionIndex);
return hash_equals($expected, $token);
}
private static function assertValidInputs(
string $matchId,
string $activeTeamId,
int $round,
int $lastActionIndex
): void {
if (!self::isValidMatchId($matchId)) {
throw new InvalidArgumentException('Match id must be 16+ lowercase hex characters.');
}
if ($activeTeamId === '') {
throw new InvalidArgumentException('Active team id cannot be empty.');
}
if ($round < 1) {
throw new InvalidArgumentException('Round must be at least 1.');
}
if ($lastActionIndex < 0) {
throw new InvalidArgumentException('Last action index cannot be negative.');
}
}
private static function isValidMatchId(string $matchId): bool
{
return preg_match(self::MATCH_ID_PATTERN, $matchId) === 1;
}
private static function payload(string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
{
return implode('|', [$matchId, $activeTeamId, (string) $round, (string) $lastActionIndex]);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use InvalidArgumentException;
final class GetBundledScenario
{
private const ID_PATTERN = '/^[a-z0-9-]+$/';
public function __construct(private readonly string $scenariosDir)
{
}
/** @param array<string, string> $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));
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetBundledScenarios
{
private const FILENAME_PATTERN = '/^[a-z0-9-]+\.json$/';
public function __construct(private readonly string $scenariosDir)
{
}
/** @param array<string, string> $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);
}
}
+36
View File
@@ -9,10 +9,15 @@ use BattleForge\Http\Response;
final class GetHomePage
{
public function __construct(private readonly string $scenariosDir = '')
{
}
/** @param array<string, string> $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<array{id: string, name: string, summary: string}> */
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;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class GetMatchView
{
/** @param array<string, string> $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);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchAbility
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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));
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchAttack
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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));
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchEndTurn
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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));
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchMove
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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<string, mixed> */
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,
]);
}
}
+16 -1
View File
@@ -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,
]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
use BattleForge\Domain\MatchState;
/**
* @phpstan-type MatchIdAndMatch = array{matchId: string, match: MatchState}
*/
final readonly class MatchActionResult
{
/**
* @param ?MatchIdAndMatch $matchIdAndMatch
*/
public function __construct(
public ?Response $response,
public ?array $matchIdAndMatch,
) {
}
/** @param MatchIdAndMatch $pair */
public static function ok(array $pair): self
{
return new self(null, $pair);
}
public static function reject(Response $response): self
{
return new self($response, null);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
final class MatchActionSupport
{
public function __construct(private readonly string $secret)
{
}
public function verify(Request $request): MatchActionResult
{
$submitted = (string) ($request->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]);
}
}
+13 -2
View File
@@ -5,15 +5,26 @@ declare(strict_types=1);
use BattleForge\Http\Escape;
/** @var string $csrf Provided by the front controller. */
/** @var list<array{id: string, name: string, summary: string}> $bundled Provided by GetHomePage. */
?><?php
render_layout(static function () use ($csrf): void {
render_layout(static function () use ($csrf, $bundled): void {
$e = static fn (string $v): string => Escape::html($v);
$a = static fn (string $v): string => Escape::attr($v);
?>
<h1>BattleForge</h1>
<p><a href="<?= $a('/scenarios/new/edit/team') ?>">New scenario</a></p>
<h2>Play bundled</h2>
<ul id="bf-bundled">
<?php foreach ($bundled as $entry) : ?>
<li>
<button type="button" data-bf-bundle="<?= $a($entry['id']) ?>">
<strong><?= $e($entry['name']) ?></strong> &mdash; <?= $e($entry['summary']) ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<h2>Recent scenarios</h2>
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
<?php
}, $csrf, 'BattleForge');
}, $csrf, 'BattleForge');
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use BattleForge\Http\Escape;
/** @var string $csrf Provided by the front controller. */
?><?php
render_layout(static function (): void {
$a = static fn (string $v): string => Escape::attr($v);
?>
<header id="bf-match-header" data-bf-active-team="" data-bf-round="0">
<h1>Battle</h1>
<p>
Active team: <strong id="bf-active-team">&mdash;</strong>
&middot; Round: <strong id="bf-round">0</strong>
</p>
<p>
<button id="bf-end-turn" type="button">End Turn</button>
</p>
</header>
<main>
<div id="bf-grid" class="bf-grid" data-bf-width="0" data-bf-height="0"></div>
<aside id="bf-panel" class="bf-panel">
<p>Selected: <span id="bf-selected-unit">none</span></p>
<p>
<button id="bf-action-move" type="button">Move</button>
<button id="bf-action-attack" type="button">Attack</button>
<button id="bf-action-ability" type="button">Ability</button>
</p>
<div id="bf-ability-options"></div>
</aside>
</main>
<section id="bf-log" class="bf-log" aria-label="Action log"></section>
<section id="bf-result" class="bf-result" hidden>
<h2>Match over</h2>
<p>Winner: <strong id="bf-winner"></strong></p>
<p><a id="bf-return-home" href="/">Return to home</a></p>
</section>
<div id="bf-toast-host"></div>
<script type="module" src="<?= $a('/js/match.js') ?>"></script>
<?php
}, $csrf, 'Battle');
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetBundledScenario;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetBundledScenarioTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->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<string, mixed> */
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<array{0: int, 1: int}> $coords
* @return array<string, mixed>
*/
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];
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetBundledScenarios;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetBundledScenariosTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->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<string, array{name: string, summary: string}> $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);
}
}
+33
View File
@@ -23,4 +23,37 @@ final class GetHomePageTest extends TestCase
self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
self::assertStringContainsString('<a href="/scenarios/new/edit/team">', $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);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Http\Handlers\GetMatchView;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class GetMatchViewTest extends TestCase
{
public function testItRendersTheMatchShellWithSecurityHeaders(): void
{
$handler = new GetMatchView();
$request = new Request([], [], [], [], [], '', 'GET', '/matches/current', 'text/html', '');
$response = $handler->handle($request, []);
self::assertSame(200, $response->status);
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
self::assertStringContainsString('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);
}
}
@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Application\TurnToken;
use PHPUnit\Framework\TestCase;
/**
* End-to-end smoke test. Boots a real `php -S` dev server and walks the
* Skirmish bundled scenario to a winner. Gated by BATTLEFORGE_E2E=1 so the
* per-commit CI does not pay its cost.
*/
final class PostMatchActionSmokeTest extends TestCase
{
private const SECRET = 'e2e-smoke-secret';
private const BASE_URL = 'http://127.0.0.1:8765';
private const CSRF_COOKIE_NAME = '__csrf';
/** @var resource|null */
private $serverProcess = null;
private string $csrfToken = '';
private string $csrfCookie = '';
protected function setUp(): void
{
if (getenv('BATTLEFORGE_E2E') !== '1') {
self::markTestSkipped('Set BATTLEFORGE_E2E=1 to run the end-to-end smoke test.');
}
$this->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::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, $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);
$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'];
$matchId = $startResponse['body']['matchId'];
$staleToken = TurnToken::issue(self::SECRET, $matchId, 'alpha', 99, 0);
$response = $this->postJson('/matches/current/end-turn', [
'matchId' => $matchId,
'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<string, mixed> */
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<string, mixed> $body
* @return array{status: int, body: array<string, mixed>}
*/
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'] ?? '');
$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<string, mixed> $match
* @param string $matchId
* @return array{verb: string, body: array<string, mixed>}
*/
private function pickAction(array $match, string $matchId): 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' => $matchId,
'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' => $matchId,
'match' => $match,
'unitId' => $unit['id'],
'x' => $c['x'],
'y' => $c['y'],
],
];
}
}
}
return [
'verb' => 'end-turn',
'body' => [
'matchId' => $matchId,
'match' => $match,
],
];
}
}
+366
View File
@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostMatchAbility;
use BattleForge\Http\Handlers\PostMatchAttack;
use BattleForge\Http\Handlers\PostMatchEndTurn;
use BattleForge\Http\Handlers\PostMatchMove;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostMatchActionTest extends TestCase
{
private const SECRET = 'match-action-test-secret';
private const MATCH_ID = '0123456789abcdef';
private const HEAL = ['heal'];
public function testMoveHappyPathReturnsTheUpdatedMatchAndAFreshTurnToken(): 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' => 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<string, string> $cookies
* @param array<string, mixed> $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));
}
}
+31
View File
@@ -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<string, string> $cookies */
private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = []): Request
{
@@ -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);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Application;
use BattleForge\Application\TurnToken;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class TurnTokenTest extends TestCase
{
private const SECRET = 'turn-token-test-secret';
public function testIssueIsDeterministicForTheSameInputs(): void
{
$a = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
$b = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertSame($a, $b);
self::assertSame(32, strlen($a));
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $a);
}
public function testVerifyAcceptsAFreshlyIssuedToken(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertTrue(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $token));
}
public function testVerifyRejectsAMutatedToken(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
$tampered = substr($token, 0, -1) . '0';
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 0, $tampered));
}
public function testVerifyRejectsATokenFromADifferentMatch(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, 'fedcba9876543210', 'alpha', 1, 0, $token));
}
public function testVerifyRejectsATokenFromADifferentTeam(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'bravo', 1, 0, $token));
}
public function testVerifyRejectsATokenFromADifferentRound(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 2, 0, $token));
}
public function testVerifyRejectsATokenFromADifferentLastActionIndex(): void
{
$token = TurnToken::issue(self::SECRET, '0123456789abcdef', 'alpha', 1, 0);
self::assertFalse(TurnToken::verify(self::SECRET, '0123456789abcdef', 'alpha', 1, 1, $token));
}
public function testIssueRejectsAMalformedMatchId(): void
{
$this->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);
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Http;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class MatchActionSupportTest extends TestCase
{
private const SECRET = 'support-test-secret';
public function testItRejectsARequestWithAMissingCsrfToken(): void
{
$support = new MatchActionSupport(self::SECRET);
$request = $this->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<string, mixed> */
private function validMatchArray(): array
{
return \BattleForge\Application\ScenarioSerializer::matchToArray($this->validMatch());
}
/**
* @param array<string, mixed> $match
* @param array<string, string> $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
);
}
}