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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'] ?? [],
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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> — <?= $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');
|
||||
@@ -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">—</strong>
|
||||
· 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user