Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840e86b9af | ||
|
|
8bec9baab6 | ||
|
|
09f2bda924 | ||
|
|
fb06da3f28 | ||
|
|
241195bbc5 | ||
|
|
4cc457c231 | ||
|
|
9eac4981f4 | ||
|
|
f72072f3ef | ||
|
|
44199ec0a8 | ||
|
|
3185bc3602 | ||
|
|
2db141efc6 | ||
|
|
d80ceae128 | ||
|
|
2ea8cfd83d | ||
|
|
73254cbf80 | ||
|
|
0058a99939 | ||
|
|
d53a84ce85 | ||
|
|
48e7fa9a25 | ||
|
|
4c2586908f | ||
|
|
d118920739 | ||
|
|
06f9c9e37c | ||
|
|
22e38cfebd | ||
|
|
da4982e55d | ||
|
|
df8d4023ba | ||
|
|
47dc95fa56 | ||
|
|
b444f98aa0 | ||
|
|
51f57d309a | ||
|
|
b8451112d3 | ||
|
|
1f9e76afef | ||
|
|
c2f9e0a970 | ||
|
|
01029b43b7 | ||
|
|
9314fc5870 | ||
|
|
3d8c1cfdb2 | ||
|
|
a0dcff2c2a | ||
|
|
a6d93dd1d3 | ||
|
|
e8522a510e | ||
|
|
fbb6960be2 | ||
|
|
ced4363d20 | ||
|
|
02bf9aa390 | ||
|
|
facbb1a311 | ||
|
|
8eefd749be | ||
|
|
78565cfae9 | ||
|
|
7acda302e8 | ||
|
|
eef8724626 | ||
|
|
b4495f24af | ||
|
|
ba8fd00411 | ||
|
|
d15dfc6835 | ||
|
|
61abb93e59 | ||
|
|
5d9af7a4fd | ||
|
|
d387caca86 | ||
|
|
2dc7bc1130 | ||
|
|
7cb670b512 | ||
|
|
12b1547fac | ||
|
|
6a0b147e4e | ||
|
|
7e6240733b | ||
|
|
c2233c1639 | ||
|
|
58b3bc8ece | ||
|
|
db12577e78 | ||
|
|
87a7e10c06 | ||
|
|
3e4184f08d | ||
|
|
0f4a220e70 | ||
|
|
c24964ff96 | ||
|
|
6399e2d86c | ||
|
|
288c42e176 | ||
|
|
8b83df60cd | ||
|
|
3263975d2a | ||
|
|
9ce0c2ac20 | ||
|
|
67de6dab75 | ||
|
|
a05baeb4f6 | ||
|
|
eed678e688 | ||
|
|
17782aab33 | ||
|
|
8df51fc1ed | ||
|
|
18520fee22 | ||
|
|
1d69e89faf | ||
|
|
02addec8df | ||
|
|
86d82c353d | ||
|
|
39b15c35da | ||
|
|
12f5c0bd3a | ||
|
|
6fffaa2306 | ||
|
|
84fb175c0c | ||
|
|
6befe7b3e2 | ||
|
|
088e85c9b4 | ||
|
|
06632ff9b3 | ||
|
|
e70b60696c | ||
|
|
88149ab1cf | ||
|
|
0ac8c06c5e | ||
|
|
78fc802cdc | ||
|
|
de0bc9c43f | ||
|
|
07e58c84fe | ||
|
|
fef22d7b56 | ||
|
|
acfbbf4a91 | ||
|
|
7588b304e1 | ||
|
|
3c6359243a | ||
|
|
3eba823e52 | ||
|
|
1550e2920b | ||
|
|
5297b41208 | ||
|
|
94560da5b4 | ||
|
|
0c5e824942 | ||
|
|
4421c5ae32 | ||
|
|
b789f1b61f | ||
|
|
189be5bc64 | ||
|
|
7b82adbd71 | ||
|
|
a97ecbc6d3 | ||
|
|
e20017f1e5 | ||
|
|
5ddfff2679 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"extends": "airbnb-base"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
*.php text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
@@ -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 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
php:
|
||||
runs-on: ubuntu-latest
|
||||
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 validate --strict
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer check
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run lint
|
||||
- run: npm run format
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
.worktrees/
|
||||
/vendor/
|
||||
/var/cache/
|
||||
/var/phpunit/
|
||||
/var/phpstan/
|
||||
/var/logs/
|
||||
/var/uploads/*
|
||||
!/var/uploads/.htaccess
|
||||
!/var/uploads/index.php
|
||||
/var/secret.key
|
||||
/node_modules/
|
||||
/public/js/*.map
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"tree.indentGuidesStroke": "#3d92ec",
|
||||
"activityBar.background": "#053241",
|
||||
"titleBar.activeBackground": "#07465B",
|
||||
"titleBar.activeForeground": "#F3FBFE",
|
||||
"titleBar.inactiveBackground": "#053241",
|
||||
"titleBar.inactiveForeground": "#F3FBFE",
|
||||
"statusBar.background": "#053241",
|
||||
"statusBar.foreground": "#F3FBFE",
|
||||
"statusBar.debuggingBackground": "#053241",
|
||||
"statusBar.debuggingForeground": "#F3FBFE",
|
||||
"statusBar.noFolderBackground": "#053241",
|
||||
"statusBar.noFolderForeground": "#F3FBFE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# BattleForge
|
||||
|
||||
## Initial Ideas
|
||||
|
||||
- **Concept**: BattleForge is a flexible "tactical game engine" web app that can be used to create skirmish-style games in a variety of genres.
|
||||
- **Core Gameplay**: Players control a team of units on a grid-based battlefield, taking turns to move and attack. The game emphasizes strategic positioning, unit abilities, and resource management.
|
||||
- **Customization**: Players can create and customize their own units, abilities, and battlefields, allowing for endless variety and replayability.
|
||||
- **Multiplayer**: Support asynchronous multiplayer, allowing players to compete against friends or strangers, with turn notifications (via email, in-app, or browser notifications) and matchmaking.
|
||||
- **Single Player**: Sandbox mode for free play with simple AI opponents.
|
||||
- **Art Style**: Images can be uploaded by users, but the app will also provide a library of free assets (units, structures, and terrain) to get started. The art style can be pixel art, isometric, or top-down, depending on user preference.
|
||||
|
||||
## Basic Features
|
||||
|
||||
- **Unit Creation**: Users can create custom units with various stats (health, attack, defense, speed) and abilities (e.g., heal, area damage, buffs).
|
||||
- **Battlefield Editor**: A drag-and-drop interface for designing custom battlefields with different terrain types (e.g., forests, mountains, water) that affect gameplay.
|
||||
- **Turn-Based Combat**: Players take turns moving their units and using abilities, with a simple UI for selecting actions and targeting.
|
||||
- **Resource Management**: Players can earn resources during battles to upgrade their units or purchase new ones for future battles.
|
||||
- **Matchmaking and Leaderboards**: A system for finding opponents and tracking player rankings based on wins, losses, and other performance metrics.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "battleforge/battleforge",
|
||||
"description": "A standalone tactical combat game.",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"squizlabs/php_codesniffer": "^3.10"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BattleForge\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"BattleForge\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "phpcs",
|
||||
"analyse": "phpstan analyse --no-progress",
|
||||
"test": "phpunit",
|
||||
"check": [
|
||||
"@lint",
|
||||
"@analyse",
|
||||
"@test"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
},
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
Generated
+2040
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,912 @@
|
||||
# Plan 3c: JavaScript Frontend Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add the small JavaScript surface that lets the home page list recent scenarios from `localStorage`, the team editor pre-fill from `localStorage`, and the battlefield editor run click-paint grid interactions with JSON fetch save.
|
||||
|
||||
**Architecture:** Two pure-ESM JavaScript files (`public/js/storage.js` and `public/js/grid-editor.js`) attach `window.bfStorage` and run `DOMContentLoaded` on their respective pages. The team editor's form continues to POST to the existing Plan 3b `PostTeamEditor` handler; the battlefield editor's form submit is intercepted by the JS to send a JSON fetch. The Node toolchain (ESLint `airbnb-base` + Prettier) is added with a new `npm run lint` and `npm run format` step in CI.
|
||||
|
||||
**Tech Stack:** PHP 8.3 (unchanged), vanilla ESM JavaScript (no bundler, no transpiler), Node 20+ with ESLint `^8.57.0`, `eslint-config-airbnb-base ^15.0.0`, `eslint-plugin-import ^2.29.0`, Prettier `^3.3.0`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
These apply to every task and are copied verbatim from the design spec.
|
||||
|
||||
- Anonymous browser persistence: scenarios and matches live in the browser's `localStorage` (`scenario:{id}` and `match:current`); the server never stores game state.
|
||||
- Every state-changing form or request uses and verifies a CSRF token before mutation. The token is per-session, signed with the app's secret using `hash_hmac('sha256', …)`. Forms use a hidden `_csrf` field; fetch uses an `X-CSRF-Token` header.
|
||||
- All rendered output is escaped for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers. The JS uses `textContent` and `value` setters, never `innerHTML` (with one exception: building the home page's recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`, never `innerHTML`).
|
||||
- Bundled placeholder images are committed under `public/assets/placeholders/`; user uploads live in `var/uploads/` (git-ignored).
|
||||
- The single small JS file (`public/js/grid-editor.js` and `public/js/storage.js`) passes ESLint `airbnb/base` and Prettier.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
- Lint, static analysis, unit and integration tests, the end-to-end smoke test, and Composer validation are required CI checks.
|
||||
- No third-party JS libraries. The two JS files are pure ESM, no dependencies, no `eval`, no `Function` constructor.
|
||||
- The data shape for scenarios in `localStorage` is the same canonical shape that `BattleForge\Application\ScenarioSerializer::scenarioToArray` produces.
|
||||
- `var/secret.key` is added to `.gitignore` (the Plan 3b `FullFlowTest` creates it on first run).
|
||||
|
||||
## File Structure
|
||||
|
||||
```text
|
||||
public/
|
||||
├── assets/
|
||||
│ ├── archetypes.json (NEW) — 4 ArchetypeTemplate definitions
|
||||
│ ├── placeholders/ (NEW)
|
||||
│ │ ├── defender.png
|
||||
│ │ ├── striker.png
|
||||
│ │ ├── support.png
|
||||
│ │ └── scout.png
|
||||
│ ├── styles.css (unchanged)
|
||||
│ └── js/ (NEW)
|
||||
│ ├── storage.js (NEW) — bfStorage module
|
||||
│ └── grid-editor.js (NEW) — battlefield grid interactions
|
||||
|
||||
src/Views/ (updated — add <script> tags only)
|
||||
├── home.php (add <script type="module" src="/js/storage.js">)
|
||||
├── team-editor.php (add <script type="module" src="/js/storage.js">)
|
||||
└── battlefield-editor.php (add <script type="module" src="/js/grid-editor.js"></script>)
|
||||
|
||||
package.json (NEW)
|
||||
.eslintrc.json (NEW)
|
||||
.prettierrc (NEW)
|
||||
.github/workflows/ci.yml (updated — add npm ci, lint, format steps)
|
||||
.gitignore (updated — add var/secret.key, node_modules/)
|
||||
```
|
||||
|
||||
## Delivery Sequence
|
||||
|
||||
Plan 3c is the third of four plans. It is the last plan in the original "Plan 3" deliverable. It depends on Plan 3a+3b's PHP web layer being merged (which it is on `develop`).
|
||||
|
||||
1. Toolchain + assets (Task 1)
|
||||
2. `storage.js` and template `<script>` tags (Task 2)
|
||||
3. `grid-editor.js` and CI update (Task 3)
|
||||
4. Final whole-branch code review (Task 4)
|
||||
|
||||
## Execution Preflight
|
||||
|
||||
Execute this plan in an isolated worktree on `feature/frontend-js`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green.
|
||||
|
||||
### Task 1: Node toolchain + `archetypes.json` + placeholder images
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`
|
||||
- Create: `.eslintrc.json`
|
||||
- Create: `.prettierrc`
|
||||
- Create: `public/assets/archetypes.json`
|
||||
- Create: `public/assets/placeholders/defender.png`
|
||||
- Create: `public/assets/placeholders/striker.png`
|
||||
- Create: `public/assets/placeholders/support.png`
|
||||
- Create: `public/assets/placeholders/scout.png`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a Node toolchain that lints and formats the JS files (Tasks 2 and 3 land in this toolchain's lint gate). The `archetypes.json` matches `BattleForge\Application\ArchetypeCatalog::templates()` exactly (defender, striker, support, scout). The four placeholder PNGs are valid 32×32 transparent PNGs with simple monochrome silhouettes (a circle for defender, a triangle for striker, a square for support, a diamond for scout — the exact pixel art is up to the implementer; the test only checks that the file is a valid PNG and has the right dimensions). The `.gitignore` excludes `var/secret.key` and `node_modules/`.
|
||||
|
||||
- [ ] **Step 1: Update `.gitignore`**
|
||||
|
||||
Append `var/secret.key` and `node_modules/` to the existing `.gitignore`. Replace the file with:
|
||||
|
||||
```
|
||||
.worktrees/
|
||||
/vendor/
|
||||
/var/cache/
|
||||
/var/phpunit/
|
||||
/var/phpstan/
|
||||
/var/logs/
|
||||
/var/uploads/*
|
||||
!/var/uploads/.htaccess
|
||||
!/var/uploads/index.php
|
||||
/var/secret.key
|
||||
/node_modules/
|
||||
/public/js/*.map
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `package.json`**
|
||||
|
||||
Create `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "battleforge-battleforge",
|
||||
"description": "BattleForge frontend toolchain.",
|
||||
"private": true,
|
||||
"license": "proprietary",
|
||||
"scripts": {
|
||||
"lint": "eslint public/js",
|
||||
"format": "prettier --check public/js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"prettier": "^3.3.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `.eslintrc.json`**
|
||||
|
||||
Create `.eslintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"extends": "airbnb-base"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `.prettierrc`**
|
||||
|
||||
Create `.prettierrc`:
|
||||
|
||||
```json
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `public/assets/archetypes.json`**
|
||||
|
||||
The `ArchetypeTemplate` values must match `BattleForge\Domain\ArchetypeCatalog::templates()` exactly. The Plan 3a catalog:
|
||||
|
||||
- defender: minHealth 10, maxHealth 14, minAttack 2, maxAttack 4, minDefense 3, maxDefense 5, minSpeed 1, maxSpeed 3, allowedAbilities ["buff"]
|
||||
- striker: minHealth 6, maxHealth 10, minAttack 4, maxAttack 6, minDefense 1, maxDefense 2, minSpeed 2, maxSpeed 4, allowedAbilities ["area_damage"]
|
||||
- support: minHealth 5, maxHealth 9, minAttack 1, maxAttack 3, minDefense 1, maxDefense 3, minSpeed 2, maxSpeed 4, allowedAbilities ["heal", "buff"]
|
||||
- scout: minHealth 4, maxHealth 7, minAttack 2, maxAttack 4, minDefense 0, maxDefense 2, minSpeed 4, maxSpeed 6, allowedAbilities []
|
||||
|
||||
Create `public/assets/archetypes.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
|
||||
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
|
||||
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
|
||||
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Create the four placeholder PNGs**
|
||||
|
||||
Each placeholder is a 32×32 transparent PNG with a simple silhouette. Generate them programmatically (the implementer can run a one-off PHP or Node script, or use ImageMagick if installed). The exact pixel art is up to the implementer; the test (added in Step 7) only checks that:
|
||||
|
||||
- The file is a valid PNG (starts with the PNG magic bytes `\x89PNG\r\n\x1a\n`).
|
||||
- The file decodes to a 32×32 image via `getimagesize()`.
|
||||
|
||||
Suggested silhouettes (a 32×32 grid of black-on-transparent pixels; the implementer can use any simple shape that distinguishes the four archetypes):
|
||||
|
||||
- defender (circle): pixels at (16, 8), (12, 10), (20, 10), (8, 14), (24, 14), (8, 18), (24, 18), (12, 22), (20, 22), (16, 24).
|
||||
- striker (triangle): pixels at (16, 6), (10, 14), (22, 14), (12, 16), (20, 16), (14, 18), (18, 18), (16, 22).
|
||||
- support (square): pixels at (8, 8), (24, 8), (8, 24), (24, 24), (10, 10), (22, 10), (10, 22), (22, 22).
|
||||
- scout (diamond): pixels at (16, 6), (12, 10), (20, 10), (8, 16), (24, 16), (12, 22), (20, 22), (16, 26).
|
||||
|
||||
The implementer can create these by hand (e.g. via a small PHP script that calls `imagecreatetruecolor` + `imagesetpixel` + `imagepng`, or via a Node script using `sharp` — but per the global constraints, no third-party JS libraries; the implementer can use a hand-rolled PNG generator or just copy a 32×32 transparent PNG from an existing source and write the silhouette bytes by hand).
|
||||
|
||||
- [ ] **Step 7: Verify the assets are valid**
|
||||
|
||||
Run: `php -r 'foreach (["defender", "striker", "support", "scout"] as $a) { $p = __DIR__ . "/public/assets/placeholders/$a.png"; $i = getimagesize($p); echo "$a: {$i[0]}x{$i[1]} type=$i[2]\n"; }'`
|
||||
Expected: four lines, each `32x32 type=3` (3 = PNG). If any line shows different dimensions, regenerate that file.
|
||||
|
||||
Run: `php -r 'var_dump(json_decode(file_get_contents(__DIR__ . "/public/assets/archetypes.json"), true));' | head -5`
|
||||
Expected: the four archetypes as objects.
|
||||
|
||||
- [ ] **Step 8: Install Node dependencies**
|
||||
|
||||
Run: `npm install`
|
||||
Expected: `node_modules/` is created; `package-lock.json` is generated. `npm run lint` will fail at this point because `public/js/*.js` doesn't exist yet; that's expected and will be fixed in Tasks 2 and 3.
|
||||
|
||||
- [ ] **Step 9: Run the full quality suite and commit**
|
||||
|
||||
Run: `composer check`
|
||||
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files. The new `package.json`, `.eslintrc.json`, `.prettierrc`, and the four PNGs are not PHP, so PHPCS doesn't check them. PHPStan excludes `src/Views/*` and the JS files.
|
||||
|
||||
```powershell
|
||||
git add .gitignore package.json package-lock.json .eslintrc.json .prettierrc public/assets/archetypes.json public/assets/placeholders/
|
||||
git commit -m "chore: add Node toolchain, archetypes.json, and placeholder images"
|
||||
```
|
||||
|
||||
### Task 2: `storage.js` and template `<script>` tags
|
||||
|
||||
**Files:**
|
||||
- Create: `public/js/storage.js`
|
||||
- Modify: `src/Views/home.php` (add `<script type="module" src="/js/storage.js"></script>`)
|
||||
- Modify: `src/Views/team-editor.php` (add `<script type="module" src="/js/storage.js"></script>` and a "Start match" button)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `public/js/storage.js`, an ESM module that attaches `window.bfStorage` with eight methods: `load(id)`, `save(id, scenario)`, `delete(id)`, `listAll()`, `currentMatch()`, `setCurrentMatch(match)`, `removeCurrentMatch()`. The module's top-level code runs `populateRecentScenarios()` on `DOMContentLoaded` (for the home page) and `prefillTeamEditor()` on `DOMContentLoaded` (for the team editor), based on which root element is present in the DOM. The "Start match" button on the team editor POSTs to `/scenarios/{id}/start` and stores the response in `localStorage`.
|
||||
|
||||
- [ ] **Step 1: Implement `storage.js`**
|
||||
|
||||
Create `public/js/storage.js`:
|
||||
|
||||
```js
|
||||
const STORAGE_PREFIX = 'scenario:';
|
||||
const MATCH_KEY = 'match:current';
|
||||
|
||||
function load(id) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + id);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function save(id, scenario) {
|
||||
const stamped = Object.assign({}, scenario, { lastModified: Date.now() });
|
||||
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(stamped));
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
localStorage.removeItem(STORAGE_PREFIX + id);
|
||||
}
|
||||
|
||||
function listAll() {
|
||||
const items = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (key === null || !key.startsWith(STORAGE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const id = key.slice(STORAGE_PREFIX.length);
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
name: typeof scenario.name === 'string' ? scenario.name : id,
|
||||
lastModified: typeof scenario.lastModified === 'number' ? scenario.lastModified : 0,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => b.lastModified - a.lastModified);
|
||||
return items;
|
||||
}
|
||||
|
||||
function currentMatch() {
|
||||
try {
|
||||
const raw = localStorage.getItem(MATCH_KEY);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentMatch(match) {
|
||||
localStorage.setItem(MATCH_KEY, JSON.stringify(match));
|
||||
}
|
||||
|
||||
function removeCurrentMatch() {
|
||||
localStorage.removeItem(MATCH_KEY);
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function populateRecentScenarios() {
|
||||
const root = document.getElementById('recent');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
while (root.firstChild) {
|
||||
root.removeChild(root.firstChild);
|
||||
}
|
||||
const items = listAll();
|
||||
if (items.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'bf-empty';
|
||||
p.appendChild(document.createTextNode('No saved scenarios yet.'));
|
||||
root.appendChild(p);
|
||||
return;
|
||||
}
|
||||
const ul = document.createElement('ul');
|
||||
for (const item of items) {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', '/scenarios/' + encodeURIComponent(item.id) + '/edit/team');
|
||||
a.appendChild(document.createTextNode(item.name + ' (' + item.id + ')'));
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
root.appendChild(ul);
|
||||
}
|
||||
|
||||
async function fetchArchetypes() {
|
||||
const res = await fetch('/assets/archetypes.json');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function prefillTeamEditor() {
|
||||
const form = document.querySelector('form[action*="/edit/team"]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const fields = form.querySelectorAll('input[name], select[name]');
|
||||
fields.forEach((field) => {
|
||||
const name = field.getAttribute('name');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const value = resolveField(scenario, name);
|
||||
if (value !== undefined) {
|
||||
field.value = String(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveField(scenario, path) {
|
||||
const parts = parsePath(path);
|
||||
if (parts === null) {
|
||||
return undefined;
|
||||
}
|
||||
let cursor = scenario;
|
||||
for (const part of parts) {
|
||||
if (cursor === null || cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = cursor[part];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parsePath(name) {
|
||||
const match = name.match(/^(teamA|teamB)\[units\]\[(\d+)\]\[(\w+)\]$/);
|
||||
if (match) {
|
||||
const [, team, indexStr, key] = match;
|
||||
const teamKey = team === 'teamA' ? 'teamA' : 'teamB';
|
||||
return [teamKey, 'units', Number(indexStr), key];
|
||||
}
|
||||
const simple = [
|
||||
'id',
|
||||
'name',
|
||||
'battlefieldWidth',
|
||||
'battlefieldHeight',
|
||||
'victoryCondition',
|
||||
'holdRoundsRequired',
|
||||
];
|
||||
if (simple.includes(name)) {
|
||||
return [name];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function startMatch() {
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-2, -1)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const csrf = getCsrfToken();
|
||||
const res = await fetch('/scenarios/' + encodeURIComponent(id) + '/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(scenario),
|
||||
});
|
||||
if (!res.ok) {
|
||||
alert('Start match failed: HTTP ' + res.status);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.match) {
|
||||
setCurrentMatch(data.match);
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('Start match failed: ' + (data.errors ? data.errors.join(', ') : 'unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
window.bfStorage = {
|
||||
load,
|
||||
save,
|
||||
delete: remove,
|
||||
listAll,
|
||||
currentMatch,
|
||||
setCurrentMatch,
|
||||
removeCurrentMatch,
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRecentScenarios();
|
||||
prefillTeamEditor();
|
||||
const startBtn = document.getElementById('bf-start-match');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
startMatch();
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The module:
|
||||
- Attaches `window.bfStorage` with the 8 methods.
|
||||
- On `DOMContentLoaded`, populates the home page's `<div id="recent">`, pre-fills the team editor's form fields, and wires the "Start match" button.
|
||||
- The "Start match" button calls `POST /scenarios/{id}/start` with the loaded scenario as JSON and `X-CSRF-Token` from the layout's `<meta name="csrf-token">`. On success, stores the match in `localStorage` and navigates to `/`.
|
||||
- Uses `textContent` and `value` setters throughout. Never `innerHTML` (the recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`).
|
||||
- Handles corrupt JSON gracefully: `load()` returns `null`, `listAll()` skips keys that fail to parse.
|
||||
|
||||
- [ ] **Step 2: Update `src/Views/home.php` to import the JS**
|
||||
|
||||
Edit `src/Views/home.php`. After the existing `<div id="recent">` block, add the script import. The exact location depends on the existing template; locate the closing `</body>` tag and add the script before it (or add it after `<div id="recent">` if the template is structured that way).
|
||||
|
||||
Add this line at the end of the template body, before the closure that calls `render_layout`:
|
||||
|
||||
```php
|
||||
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
|
||||
```
|
||||
|
||||
The exact context-dependent insertion point will be clear from the file. Use `Escape::attr` to escape the URL attribute.
|
||||
|
||||
- [ ] **Step 3: Update `src/Views/team-editor.php` to import the JS and add the Start match button**
|
||||
|
||||
Two changes to `src/Views/team-editor.php`:
|
||||
|
||||
1. Add the script import at the end of the template body (before the `render_layout` closure):
|
||||
|
||||
```php
|
||||
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
|
||||
```
|
||||
|
||||
2. Add a "Start match" button at the bottom of the form, just after the existing "Save" submit button:
|
||||
|
||||
```php
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="bf-start-match">Start match</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
The "Start match" button is a `<button type="button">` (not `type="submit"`) so the form's submit handler is not invoked. The JS wires the click event in `storage.js`.
|
||||
|
||||
- [ ] **Step 4: Run the lint and format checks**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 0 errors. ESLint may flag a few unused parameters or stylistic issues; if so, fix them.
|
||||
|
||||
Run: `npm run format`
|
||||
Expected: 0 errors. Prettier may flag formatting drift; if so, run `npx prettier --write public/js/storage.js` and commit the result.
|
||||
|
||||
- [ ] **Step 5: Run the full quality suite and commit**
|
||||
|
||||
Run: `composer check`
|
||||
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files. The new JS file is checked by ESLint and Prettier (not by the PHP suite).
|
||||
|
||||
```powershell
|
||||
git add public/js/storage.js src/Views/home.php src/Views/team-editor.php
|
||||
git commit -m "feat: add localStorage helpers and wire team editor pre-fill"
|
||||
```
|
||||
|
||||
### Task 3: `grid-editor.js` and CI update
|
||||
|
||||
**Files:**
|
||||
- Create: `public/js/grid-editor.js`
|
||||
- Modify: `src/Views/battlefield-editor.php` (add `<script type="module" src="/js/grid-editor.js"></script>`)
|
||||
- Modify: `.github/workflows/ci.yml` (add `npm ci`, `npm run lint`, `npm run format` steps)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `public/js/grid-editor.js`, an ESM module that runs on `DOMContentLoaded` for the battlefield editor. The module:
|
||||
- Reads the `data-scenario-id` attribute from the `<form id="battlefield-form">` element.
|
||||
- Loads the scenario from `bfStorage.load(id)` and populates the in-memory `tileMap`, `deploymentZones`, and `objective` state.
|
||||
- Paints the grid: each tile button's `data-paint` attribute is updated, the objective tile gets `data-objective="1"`, and the zone tiles get `data-zone="alpha"` or `data-zone="bravo"`.
|
||||
- Listens for clicks on `.bf-tile` buttons: in the default terrain-paint mode, updates `tileMap`; in zone mode, toggles zone membership; in objective mode, places the objective. Shift-click in any mode removes the paint or zone membership.
|
||||
- Listens for clicks on the `Save` button (`event.preventDefault()`), builds the canonical scenario JSON, POSTs to `/scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token`. On success, writes the returned `scenario` to `localStorage` and shows a "Saved" toast. On 400 with errors, shows the errors inline in the form.
|
||||
|
||||
- [ ] **Step 1: Implement `grid-editor.js`**
|
||||
|
||||
Create `public/js/grid-editor.js`:
|
||||
|
||||
```js
|
||||
const TERRAINS = ['open', 'forest', 'rough', 'water', 'blocking'];
|
||||
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function getCurrentMode() {
|
||||
const checked = document.querySelector('input[name="zoneMode"]:checked');
|
||||
return checked ? checked.value : 'terrain';
|
||||
}
|
||||
|
||||
function getSelectedPaint() {
|
||||
const active = document.querySelector('.bf-paint.active');
|
||||
if (active) {
|
||||
return active.getAttribute('data-paint');
|
||||
}
|
||||
return 'forest';
|
||||
}
|
||||
|
||||
function loadScenario(id) {
|
||||
if (window.bfStorage && typeof window.bfStorage.load === 'function') {
|
||||
return window.bfStorage.load(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveScenario(id, scenario) {
|
||||
if (window.bfStorage && typeof window.bfStorage.save === 'function') {
|
||||
window.bfStorage.save(id, scenario);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTile(button, paint, zone, isObjective) {
|
||||
button.setAttribute('data-paint', paint);
|
||||
if (zone) {
|
||||
button.setAttribute('data-zone', zone);
|
||||
} else {
|
||||
button.removeAttribute('data-zone');
|
||||
}
|
||||
if (isObjective) {
|
||||
button.setAttribute('data-objective', '1');
|
||||
} else {
|
||||
button.removeAttribute('data-objective');
|
||||
}
|
||||
}
|
||||
|
||||
function loadGrid(tileMap, deploymentZones, objective) {
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
const x = button.getAttribute('data-x');
|
||||
const y = button.getAttribute('data-y');
|
||||
const key = x + ':' + y;
|
||||
if (tileMap[key]) {
|
||||
button.setAttribute('data-paint', tileMap[key]);
|
||||
}
|
||||
button.removeAttribute('data-zone');
|
||||
button.removeAttribute('data-objective');
|
||||
});
|
||||
for (const [team, positions] of Object.entries(deploymentZones)) {
|
||||
positions.forEach((pos) => {
|
||||
const selector = `.bf-tile[data-x="${pos.x}"][data-y="${pos.y}"]`;
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
button.setAttribute('data-zone', team);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (objective) {
|
||||
const selector = `.bf-tile[data-x="${objective.x}"][data-y="${objective.y}"]`;
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
button.setAttribute('data-objective', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readGrid() {
|
||||
const tileMap = {};
|
||||
const deploymentZones = { alpha: [], bravo: [] };
|
||||
let objective = null;
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
const x = Number(button.getAttribute('data-x'));
|
||||
const y = Number(button.getAttribute('data-y'));
|
||||
const paint = button.getAttribute('data-paint');
|
||||
if (paint && paint !== 'open') {
|
||||
tileMap[x + ':' + y] = paint;
|
||||
}
|
||||
const zone = button.getAttribute('data-zone');
|
||||
if (zone === 'alpha' || zone === 'bravo') {
|
||||
deploymentZones[zone].push({ x, y });
|
||||
}
|
||||
if (button.getAttribute('data-objective') === '1') {
|
||||
objective = { x, y };
|
||||
}
|
||||
});
|
||||
return { tileMap, deploymentZones, objective };
|
||||
}
|
||||
|
||||
function buildScenarioJson(scenarioId, tileMap, deploymentZones, objective) {
|
||||
const widthInput = document.querySelector('input[name="battlefieldWidth"]');
|
||||
const heightInput = document.querySelector('input[name="battlefieldHeight"]');
|
||||
return {
|
||||
id: scenarioId,
|
||||
name: scenarioId,
|
||||
battlefieldWidth: widthInput ? Number(widthInput.value) : 8,
|
||||
battlefieldHeight: heightInput ? Number(heightInput.value) : 8,
|
||||
battlefieldTerrain: tileMap,
|
||||
teamA: {
|
||||
units: [],
|
||||
},
|
||||
teamB: {
|
||||
units: [],
|
||||
},
|
||||
deploymentZones,
|
||||
objectives: objective
|
||||
? { 'objective-1': { id: 'objective-1', position: objective } }
|
||||
: {},
|
||||
victoryCondition: objective ? 'hold_objective' : 'eliminate_all',
|
||||
holdRoundsRequired: objective ? 3 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function paintTile(button) {
|
||||
const mode = getCurrentMode();
|
||||
if (event.shiftKey) {
|
||||
applyTile(button, 'open', null, false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'objective') {
|
||||
document.querySelectorAll('.bf-tile[data-objective]').forEach((other) => {
|
||||
other.removeAttribute('data-objective');
|
||||
});
|
||||
applyTile(button, 'open', null, true);
|
||||
return;
|
||||
}
|
||||
if (mode === 'alpha' || mode === 'bravo') {
|
||||
const current = button.getAttribute('data-zone');
|
||||
if (current === mode) {
|
||||
button.removeAttribute('data-zone');
|
||||
} else {
|
||||
button.setAttribute('data-zone', mode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
applyTile(button, getSelectedPaint(), null, false);
|
||||
}
|
||||
|
||||
function selectPalette(event) {
|
||||
document.querySelectorAll('.bf-paint').forEach((b) => b.classList.remove('active'));
|
||||
event.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'bf-toast';
|
||||
toast.appendChild(document.createTextNode(message));
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function showErrors(errors) {
|
||||
const existing = document.querySelector('.bf-errors');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bf-errors';
|
||||
const ul = document.createElement('ul');
|
||||
errors.forEach((msg) => {
|
||||
const li = document.createElement('li');
|
||||
li.appendChild(document.createTextNode(msg));
|
||||
ul.appendChild(li);
|
||||
});
|
||||
div.appendChild(ul);
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (form) {
|
||||
form.parentNode.insertBefore(div, form);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = form.getAttribute('data-scenario-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const { tileMap, deploymentZones, objective } = readGrid();
|
||||
const scenario = buildScenarioJson(id, tileMap, deploymentZones, objective);
|
||||
const csrf = getCsrfToken();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/scenarios/' + encodeURIComponent(id) + '/edit/battlefield', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(scenario),
|
||||
});
|
||||
} catch (e) {
|
||||
showErrors(['Network error: ' + e.message]);
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.scenario) {
|
||||
saveScenario(id, data.scenario);
|
||||
showToast('Saved.');
|
||||
} else {
|
||||
showErrors([(data.errors || ['Unknown error']).join(', ')]);
|
||||
}
|
||||
} else if (res.status === 400) {
|
||||
let data = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
// body wasn't JSON
|
||||
}
|
||||
showErrors(data.errors || ['Validation failed (HTTP 400).']);
|
||||
} else {
|
||||
showErrors(['Save failed: HTTP ' + res.status]);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = form.getAttribute('data-scenario-id');
|
||||
if (id) {
|
||||
const scenario = loadScenario(id);
|
||||
if (scenario) {
|
||||
loadGrid(
|
||||
scenario.battlefieldTerrain || {},
|
||||
scenario.deploymentZones || { alpha: [], bravo: [] },
|
||||
scenario.objectives && scenario.objectives['objective-1']
|
||||
? scenario.objectives['objective-1'].position
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
button.addEventListener('click', () => paintTile(button));
|
||||
});
|
||||
document.querySelectorAll('.bf-paint').forEach((button) => {
|
||||
button.addEventListener('click', selectPalette);
|
||||
});
|
||||
if (document.querySelector('.bf-paint')) {
|
||||
document.querySelector('.bf-paint').classList.add('active');
|
||||
}
|
||||
form.addEventListener('submit', save);
|
||||
});
|
||||
```
|
||||
|
||||
The module:
|
||||
- Reads `data-scenario-id` from the form.
|
||||
- Loads the scenario from `bfStorage.load(id)` and repaints the grid.
|
||||
- Handles click-paint on tiles, including shift-click to erase.
|
||||
- Handles palette selection (clicking a paint button sets the active mode).
|
||||
- On Save, intercepts the form submit, builds the canonical scenario JSON, fetches the existing Plan 3b `PostBattlefieldEditor` endpoint, parses the response, and writes the result back to `localStorage`.
|
||||
- Handles all four response cases: 200 with `scenario`, 400 with `errors`, network failure, and other HTTP failures.
|
||||
|
||||
- [ ] **Step 2: Update `src/Views/battlefield-editor.php`**
|
||||
|
||||
Three changes:
|
||||
|
||||
1. Add `data-scenario-id="<?= $a($scenarioId) ?>"` to the form element:
|
||||
|
||||
```php
|
||||
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
|
||||
```
|
||||
|
||||
2. Add the script import at the end of the template body (before the `render_layout` closure):
|
||||
|
||||
```php
|
||||
<script type="module" src="<?= $a('/js/grid-editor.js') ?>"></script>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `.github/workflows/ci.yml`**
|
||||
|
||||
Add `npm ci`, `npm run lint`, and `npm run format` steps after the existing `composer check` step. The workflow uses pinned action SHAs (per Plan 1's baseline).
|
||||
|
||||
Append the following steps to the existing `php` job in `.github/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run lint
|
||||
- run: npm run format
|
||||
```
|
||||
|
||||
The full workflow should now be:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
php:
|
||||
runs-on: ubuntu-latest
|
||||
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 validate --strict
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer check
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run lint
|
||||
- run: npm run format
|
||||
```
|
||||
|
||||
(Use the actual pinned SHAs from the existing workflow; the SHAs above are placeholders showing the shape.)
|
||||
|
||||
- [ ] **Step 4: Run the lint and format checks**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 0 errors. ESLint may flag a few unused parameters or stylistic issues; if so, fix them.
|
||||
|
||||
Run: `npm run format`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 5: Run the full quality suite and commit**
|
||||
|
||||
Run: `composer check`
|
||||
Expected: 196/196 tests pass, PHPStan clean, PHPCS clean on the changed files.
|
||||
|
||||
```powershell
|
||||
git add public/js/grid-editor.js src/Views/battlefield-editor.php .github/workflows/ci.yml
|
||||
git commit -m "feat: add battlefield grid editor and CI lint step"
|
||||
```
|
||||
|
||||
### Task 4: Final whole-branch code review
|
||||
|
||||
Dispatch a fresh subagent with the merge-base diff for the entire feature branch. The reviewer checks for:
|
||||
|
||||
- Spec compliance (every in-scope capability from the Plan 3c spec is implemented).
|
||||
- Code quality (clean separation of concerns, proper error handling, type safety, ESLint/Prettier clean).
|
||||
- Production readiness (backward compatibility, no obvious bugs).
|
||||
- Security: `bfStorage` reads and writes `localStorage` only, never `eval` or `Function` constructor; the home page's recent-scenarios list uses `textContent`, never `innerHTML`.
|
||||
|
||||
After the review, address Critical and Important findings. Minor findings can be deferred to Plan 4.
|
||||
|
||||
## Completion Check
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
composer validate --strict
|
||||
composer check
|
||||
npm run lint
|
||||
npm run format
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Composer reports a valid manifest.
|
||||
- PHPCS reports no coding-standard violations on the changed files.
|
||||
- PHPStan reports no errors at level 6.
|
||||
- PHPUnit passes 196 tests.
|
||||
- ESLint reports 0 errors on `public/js/*.js`.
|
||||
- Prettier reports 0 formatting drift.
|
||||
- Git reports no untracked files except `.worktrees/` (git-ignored).
|
||||
|
||||
The web app is fully functional from the browser: `php -S 0.0.0.0:8000 -t public` serves the home page with the recent-scenarios list, the team editor with `localStorage` pre-fill, and the battlefield editor with click-paint grid interactions. The battle interface and bundled scenarios land in Plan 4.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
# Persistence, Secure Image Handling, and Scenario Editors Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Plan 3 of the four-plan BattleForge build. Closes the gap between the pure-PHP `Domain` layer (Plans 1 and 2) and the in-browser experience by adding:
|
||||
|
||||
- Anonymous-browser persistence of scenarios and in-progress matches (no user accounts, no cross-device, no server-side storage of game state).
|
||||
- Server-rendered editor pages for teams and battlefields, with the domain's `Scenario` and `ScenarioValidator` enforcing every rule.
|
||||
- A secure image-upload pipeline (content-sniffed, size-limited, dimension-limited, session-scoped).
|
||||
- A minimal plain-PHP web stack with the cross-cutting security concerns the spec demands (CSRF, output escaping, content-validated uploads).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Plan 3 succeeds when a new user, without developer assistance and with no global state, can:
|
||||
|
||||
1. Visit `http://localhost:8000/`, see a home page that lists their recent scenarios, and start a new one.
|
||||
2. Use the team editor to build two teams of 3-6 units each, choosing archetypes, stats, abilities, names, and optional custom images.
|
||||
3. Use the battlefield editor to choose dimensions, paint terrain, place deployment zones, and place an objective (when the victory condition requires it).
|
||||
4. Validate the assembled scenario server-side; see clear errors when something is invalid.
|
||||
5. Upload a custom image and have it appear in the team editor.
|
||||
6. "Start a match" from a saved scenario and have the initial `MatchState` round-trip through the browser.
|
||||
7. Refresh the page, lose the connection, or come back tomorrow and find their saved scenarios still in their browser (no data loss from the user's perspective).
|
||||
8. Reject forged cross-site requests, accept only image types the spec allows, and never let a user access another user's uploads.
|
||||
|
||||
## Out of Scope (deferred to Plan 4 or later)
|
||||
|
||||
- Hot-seat battle interface: Plan 3 ships a placeholder page that reads the assembled `MatchState` from `localStorage` and renders a "Battle interface coming in Plan 4" stub.
|
||||
- Bundled scenarios (the three ready-made scenarios in the spec): Plan 4.
|
||||
- Real-time interaction, drag-and-drop, or any other JS surface beyond the single fetch-based grid editor and the small `localStorage` helper.
|
||||
- Server-side storage of scenarios, matches, or any game state.
|
||||
- User accounts, sessions beyond the CSRF cookie, or any per-user server-side state.
|
||||
- Mobile-native, PWA, offline service workers, or any non-server-rendered path.
|
||||
- Antivirus scanning or upload rate limiting.
|
||||
|
||||
## In Scope
|
||||
|
||||
### Persistence
|
||||
|
||||
- Scenarios are JSON-serialized in the browser's `localStorage`, keyed as `scenario:{id}`.
|
||||
- In-progress matches are JSON-serialized in `localStorage`, keyed as `match:current`.
|
||||
- The server never sees a `Scenario` or `MatchState` after the editor POST completes. Each form submission is validated and acknowledged; the browser then writes to `localStorage` on its own.
|
||||
- The home page lists scenarios by enumerating `localStorage` keys; no server call.
|
||||
- "Resume in-progress match" reads `match:current` and shows a banner on the home page.
|
||||
|
||||
### Team Editor
|
||||
|
||||
- One form per scenario, with sections for: meta (id, name), team A, team B, victory condition.
|
||||
- Each team section has 3-6 unit rows; rows are added and removed client-side (DOM manipulation, no fetch).
|
||||
- Each unit row has: archetype dropdown, display name, image upload button + hidden URL field, four stat inputs (with `min`/`max` from the chosen archetype's template), and an abilities multi-select (limited to the chosen archetype's allowlist).
|
||||
- The archetype JSON is served from `/assets/archetypes.json` and includes the four `ArchetypeTemplate` definitions.
|
||||
- A new "Upload" form submits a single image to `POST /assets/upload` and writes the returned URL into the unit row's hidden `image` field. The unit-editor form is not submitted as part of the upload.
|
||||
- Victory condition: a radio pair (`EliminateAll` / `HoldObjective`). When `HoldObjective` is selected, an input for `holdRoundsRequired` (1-10) appears.
|
||||
- Submitting the form POSTs the full draft to `POST /scenarios/{id}/edit/team`. The server parses, builds a `ScenarioDraft`, runs `ScenarioValidator::validate()`, and on failure re-renders the form with errors inline.
|
||||
|
||||
### Battlefield Editor
|
||||
|
||||
- One page per scenario. Width/height inputs (8-16 each) at the top.
|
||||
- Terrain palette: one button per `Terrain` enum value. The selected button is the "current paint."
|
||||
- Grid: an HTML `<table>` of `<button>` cells with `data-x` and `data-y`. Clicking applies the current paint. Shift-click erases.
|
||||
- Two deployment-zone controls (one per team). Each is a "click on grid to add this tile to my zone" mode.
|
||||
- Objective control: a single "click on grid to place the objective" mode that is only shown when the victory condition is `HoldObjective`.
|
||||
- The page ships with a small ESM helper (~50 lines, no build step) that maintains an in-memory `tileMap` object, posts it as JSON to `POST /scenarios/{id}/edit/battlefield` on save, and writes the server's response to `localStorage`.
|
||||
|
||||
### Image Upload Pipeline
|
||||
|
||||
- Multipart POST to `POST /assets/upload` with a hidden CSRF field.
|
||||
- Server reads `$_FILES['image']`, validates with `ImageValidator` (see below), stores the file at `var/uploads/{userToken}/{hash}.{ext}`, and returns `{"url": "/assets/{userToken}/{hash}.{ext}"}`.
|
||||
- `GET /assets/{userToken}/{filename}` streams the file with the right `Content-Type`. The `{userToken}` is derived from the session cookie and the request's `{userToken}` is verified to match; mismatch returns 404. Misses return 404.
|
||||
- No listing endpoint. No directory traversal.
|
||||
- Bundled placeholder images at `/assets/placeholders/{archetype}.png` are committed under `public/assets/placeholders/` and served without the `{userToken}` prefix.
|
||||
|
||||
### Security and Quality
|
||||
|
||||
- A per-session CSRF token stored in an `HttpOnly`, `SameSite=Lax` cookie, signed with the app's secret using `hash_hmac('sha256', …)`.
|
||||
- Every form includes a hidden `_csrf` field; the fetch helper reads the value from a `<meta name="csrf-token">` tag and sends it in an `X-CSRF-Token` header.
|
||||
- All output escaping goes through `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Code review enforces the pattern; PHPStan has no rule for it.
|
||||
- All uploaded images are content-validated by reading the first 12 bytes and checking the magic number against an allowlist (PNG, JPEG, WebP, GIF), then `getimagesizefromstring()` to confirm dimensions are ≤ 512×512, then size-checked at ≤ 2 MB.
|
||||
- `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'` is set on every page. No `unsafe-inline`.
|
||||
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin` on every page.
|
||||
- Bundled placeholders are committed; user uploads live in `var/uploads/` (git-ignored).
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- The single small JS file (`public/js/grid-editor.js` and `public/js/storage.js`) passes ESLint `airbnb/base` and Prettier.
|
||||
|
||||
## System Boundaries
|
||||
|
||||
The Plan 1+2 system boundaries are preserved and extended:
|
||||
|
||||
1. **Content library** (Domain, unchanged): owns curated unit archetypes, abilities, terrain, and bundled asset metadata. Served read-only to the web layer.
|
||||
2. **Scenario editor** (web, NEW): hosts the team and battlefield editor pages. The `Application` layer translates form input into a `Scenario` and runs `ScenarioValidator`. The web layer never inspects a `Scenario` directly.
|
||||
3. **Rules engine** (Domain, unchanged from Plans 1 and 2): authoritatively validates actions and resolves combat.
|
||||
4. **Battle interface** (Plan 4, stubbed in Plan 3 as a "match is loaded" page that reads `match:current` from `localStorage`).
|
||||
|
||||
The new Application and Http layers sit between the web pages and the Domain. They do not depend on browser presentation code, and the Domain does not depend on them.
|
||||
|
||||
## Architecture
|
||||
|
||||
The four `// @phpstan-ignore` annotations in `src/Domain/` (in `UnitState`, `MatchState`, `Scenario`, `DeploymentZone`) reference "Plan 4" in their parenthetical comments. The comments will be updated to "Plan 3" in a follow-up amend after this design is approved.
|
||||
|
||||
```
|
||||
src/
|
||||
├── Domain/ (Plan 1 + 2, unchanged)
|
||||
│ └── … 19 files …
|
||||
│
|
||||
├── Application/ (NEW)
|
||||
│ ├── ScenarioSerializer.php JSON ↔ Scenario
|
||||
│ ├── ScenarioDraft.php mutable draft state for the editor
|
||||
│ ├── ImageUploadService.php validates + stores a user-uploaded image
|
||||
│ └── ImageValidator.php content-sniff + size + dimension checks
|
||||
│
|
||||
├── Http/ (NEW)
|
||||
│ ├── Router.php tiny path → handler dispatcher
|
||||
│ ├── Request.php value object wrapping $_GET, $_POST, $_FILES, $_SERVER
|
||||
│ ├── Response.php value object with status, headers, body
|
||||
│ ├── CsrfToken.php per-session token store + verify helper
|
||||
│ ├── Escape.php html(), attr(), url() helpers
|
||||
│ └── Handlers/
|
||||
│ ├── GetHomePage.php
|
||||
│ ├── GetTeamEditor.php
|
||||
│ ├── PostTeamEditor.php
|
||||
│ ├── GetBattlefieldEditor.php
|
||||
│ ├── PostBattlefieldEditor.php
|
||||
│ ├── GetAssets.php
|
||||
│ └── PostImageUpload.php
|
||||
│
|
||||
├── Views/ (NEW — plain PHP templates)
|
||||
│ ├── layout.php
|
||||
│ ├── home.php
|
||||
│ ├── team-editor.php
|
||||
│ ├── battlefield-editor.php
|
||||
│ ├── match-stub.php
|
||||
│ └── upload-result.php
|
||||
│
|
||||
└── public/ (NEW — document root for `php -S`)
|
||||
├── index.php front controller
|
||||
├── assets/
|
||||
│ ├── archetypes.json
|
||||
│ ├── placeholders/{defender,striker,support,scout}.png
|
||||
│ └── (user uploads live under var/uploads/, not here)
|
||||
└── js/
|
||||
├── storage.js
|
||||
└── grid-editor.js
|
||||
```
|
||||
|
||||
`var/` is the only runtime-writable directory. It is git-ignored. It contains:
|
||||
|
||||
```
|
||||
var/
|
||||
├── cache/ PHP opcode cache (created by `php -S` if enabled)
|
||||
├── phpunit/ PHPUnit cache
|
||||
├── phpstan/ PHPStan cache
|
||||
└── uploads/
|
||||
├── .htaccess Deny from all (Apache)
|
||||
├── index.php Sentinel that returns 403 (built-in dev server)
|
||||
└── {userToken}/ Per-browser namespace; created on first upload
|
||||
```
|
||||
|
||||
## Data and Action Flow
|
||||
|
||||
### Cold-start flow
|
||||
|
||||
1. Browser hits `GET /`. Server renders the home page.
|
||||
2. A small inline script in the home page enumerates `localStorage` keys matching `scenario:*` and `match:current` and renders the list.
|
||||
3. User picks an existing scenario → the team editor opens with the form pre-filled.
|
||||
4. User picks "New scenario" → the team editor opens with an empty form for a new id.
|
||||
|
||||
### Team editor save flow
|
||||
|
||||
1. User edits the form, presses Save.
|
||||
2. Browser POSTs the form to `POST /scenarios/{id}/edit/team` with `Content-Type: application/x-www-form-urlencoded` and a hidden `_csrf` field.
|
||||
3. Server's `PostTeamEditor` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Parses the form into a `ScenarioDraft`.
|
||||
c. Builds a `Scenario` from the draft.
|
||||
d. Runs `ScenarioValidator::validate($scenario)`.
|
||||
e. On failure: re-renders the editor with the validator's errors next to each field, preserving the form's values.
|
||||
f. On success: renders the editor with an inline `<script>` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))` and shows a "Saved" toast. The server does not store the scenario.
|
||||
|
||||
### Battlefield editor save flow
|
||||
|
||||
1. User clicks tiles in the grid; the small JS helper maintains an in-memory `tileMap` object.
|
||||
2. User presses Save; the JS helper POSTs the assembled JSON to `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and an `X-CSRF-Token` header.
|
||||
3. Server's `PostBattlefieldEditor` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Decodes the JSON body, validates the shape, builds a `ScenarioDraft`, runs the validator.
|
||||
c. On failure: returns `{"ok": false, "errors": [...]}` with HTTP 400.
|
||||
d. On success: returns `{"ok": true, "scenario": ...}` with HTTP 200. The JS helper writes `localStorage` and shows a toast.
|
||||
|
||||
### Image upload flow
|
||||
|
||||
1. User picks an image in the team editor and presses Upload.
|
||||
2. Browser submits the file to `POST /assets/upload` as `multipart/form-data` with a hidden `_csrf` field.
|
||||
3. Server's `PostImageUpload` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Validates the upload with `ImageValidator`.
|
||||
c. Writes the file to `var/uploads/{userToken}/{hash}.{ext}`.
|
||||
d. Returns `{"url": "/assets/{userToken}/{hash}.{ext}"}` with HTTP 200.
|
||||
4. JS writes the returned URL into the unit row's hidden `image` field. The unit-editor form is not submitted as part of the upload.
|
||||
|
||||
### Start-match flow
|
||||
|
||||
1. User on the team editor presses "Continue to battlefield" or on the battlefield editor presses "Start match."
|
||||
2. JS reads the assembled `Scenario` JSON from `localStorage`, POSTs to `POST /scenarios/{id}/start` with `Content-Type: application/json`.
|
||||
3. Server runs `ScenarioValidator` (defense in depth), calls `Scenario::startMatch('alpha')`, returns the initial `MatchState` JSON. (No browser-side `startMatch` call; the JS does not run the domain's PHP code.)
|
||||
4. JS writes `match:current` to `localStorage` and navigates to `GET /match/current`.
|
||||
|
||||
## Validation and Failure Handling
|
||||
|
||||
- `ScenarioValidator` is the single source of truth for "is this scenario valid." Both the server (on every form POST) and the browser (before navigating forward) call it.
|
||||
- A rejected action or form never partially mutates the persisted state. The browser's `localStorage` write happens only on a successful server response.
|
||||
- Failure to save a scenario leaves the user's in-progress form intact; the editor re-renders the same values with errors inline.
|
||||
- The CSRF token's invalid-or-missing case returns a generic 403 page that links back to `GET /`. The user's in-progress form state is preserved in `localStorage` via the on-change JS hook.
|
||||
- A failed image upload shows an inline error next to the upload button; the unit row keeps whatever `image` field it had.
|
||||
- Corrupt or incompatible JSON in `localStorage` (e.g. a hand-edited scenario) is rejected on read: the home page's "Recent scenarios" list skips it and shows a "scenario corrupt" placeholder for that key.
|
||||
|
||||
## Security and Quality Requirements
|
||||
|
||||
- Every state-changing form or request uses and verifies a CSRF token before mutation. The token is per-session, signed with the app's secret, and the form's hidden field is required for HTML forms or the `X-CSRF-Token` header is required for fetch POSTs.
|
||||
- All input is sanitized and validated at the application boundary in the `Request` value object and the `ImageValidator`.
|
||||
- All rendered output is escaped for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers.
|
||||
- Uploaded images are validated by content (magic number) and by dimensions; the stored file's name is a server-generated random hex; the original filename and declared MIME are discarded after validation.
|
||||
- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server).
|
||||
- The dev server is the built-in `php -S` running on a single port. No CGI, no Apache-only assumptions; the `var/uploads/.htaccess` and `var/uploads/index.php` sentinels cover both Apache deployments and the built-in server respectively.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- The small JS surface passes ESLint `airbnb/base` and Prettier.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
- All output includes `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, and a `Content-Security-Policy` header that forbids inline scripts and styles.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- `ScenarioSerializerTest`: round-trip every Plan 2 fixture through `toJson` → `fromJson`; add fixtures for all four archetypes, both victory conditions, every terrain, and at least one uploaded-image reference.
|
||||
- `ScenarioDraftTest`: form fields → `ScenarioDraft` → `Scenario` for every field combination; error cases (out-of-bounds stat, unknown archetype, ability not in allowlist, oversized team, etc.).
|
||||
- `ImageValidatorTest`: valid PNG/JPEG/WebP/GIF headers; invalid headers (text, executable, fake extension); size > 2 MB; dimension > 512; MIME/extension mismatch.
|
||||
- `ImageUploadServiceTest`: round-trips a temp file through the service; asserts the file lands at the expected path; asserts the response URL is well-formed.
|
||||
- `CsrfTokenTest`: round-trip; cross-session rejection; expired-cookie rejection.
|
||||
- `EscapeTest`: regression set for `<`, `>`, `'`, `"`, `&`, control characters, NULL bytes.
|
||||
- `RequestTest`: synthetic `$_GET`/`$_POST`/`$_FILES`/`$_SERVER` extraction.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- One happy-path and one validation-error test per handler. Asserts cover status code, `Content-Type` header, presence/absence of the CSRF token in the response body, and inline error placement.
|
||||
- A round-trip "save scenario" integration test: POST a full team-editor form, assert the response says "saved," then GET the same page and assert the form is pre-filled.
|
||||
- A round-trip "upload + read back" integration test: POST a small PNG to `/assets/upload`, then GET the returned URL and assert the body matches the uploaded bytes exactly.
|
||||
- A round-trip "forged CSRF" test: a POST without the token returns 403.
|
||||
- A round-trip "wrong user" test: an upload stored under one session's `{userToken}` is unreadable from a different session.
|
||||
|
||||
### End-to-end smoke test (seeded in Plan 3, completed in Plan 4)
|
||||
|
||||
- Plan 3 ships the *plumbing* of an E2E test that boots a tiny PHP server in a background process, sends a sequence of HTTP requests, and asserts on response bodies. Plan 4 fills in the battle-interaction assertions.
|
||||
|
||||
### Static analysis / lint
|
||||
|
||||
- PHPCS rules stay `PSR-12`; the new `src/Http/`, `src/Application/`, `src/Views/`, and `public/` directories are added to the `phpcs.xml` `<file>` list.
|
||||
- PHPStan level 6 stays. New exclusions: `src/Views/*` and `public/index.php`.
|
||||
- ESLint `airbnb/base` + Prettier on the single small JS file. `package.json` + `package-lock.json` are committed; CI gains an `npm run lint` step.
|
||||
|
||||
### Manual usability check
|
||||
|
||||
- A new tester with no BattleForge context can: open the dev URL, see the home page, create a new scenario, build two teams with mixed archetypes, paint a simple battlefield, save both, and start a match — without leaving the browser or seeing any error from the validator that wasn't explained inline.
|
||||
|
||||
## Release Boundary
|
||||
|
||||
Plan 3 is releasable when every in-scope capability and success criterion is met, the verification suite is green (PHPUnit, PHPStan, PHPCS, ESLint, Prettier), and no out-of-scope capability (battle interface, bundled scenarios, AI, online play) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`.
|
||||
|
||||
The next plan (Plan 4) will replace the battle-page stub with a real battle interface, add the three bundled scenarios, and ship the end-to-end smoke test that the Plan 3 plumbing seeds.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Plan 3b: Editor Handlers, Views, and Front Controller Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Plan 3b of the four-plan BattleForge build. Completes the web layer that Plan 3a started: the seven remaining PHP tasks that ship the editor pages, the front controller, the full-flow integration test, and CI verification.
|
||||
|
||||
Plan 3a delivered 10 of 17 written tasks: toolchain, output escaping, CSRF, Request/Response, Router, JSON serializer, image validator, image upload service, scenario draft, and the first two HTTP handlers (home page and asset serving). Plan 3b delivers the remaining 7:
|
||||
|
||||
- The five remaining HTTP handlers (team editor GET/POST, battlefield editor GET/POST, image upload POST, start-match POST)
|
||||
- The six view templates
|
||||
- The full front controller that wires the routes together and issues the CSRF + upload tokens
|
||||
- A full-flow integration test
|
||||
- CI verification
|
||||
- Final whole-branch code review
|
||||
|
||||
The design is a delta on `docs/superpowers/specs/2026-07-06-persistence-editors-design.md` (the Plan 3 spec), which is the authoritative design. This document records only the clarifications and decisions that are specific to Plan 3b; everything else is inherited from the Plan 3 spec.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Plan 3b succeeds when a new user, without developer assistance, can:
|
||||
|
||||
1. Visit `http://localhost:8000/`, see the home page, and click "New scenario".
|
||||
2. Fill in two teams of 3-6 units each on the team editor, upload a custom image, and save. The browser shows a "Saved" toast and the scenario lands in `localStorage` under `scenario:{id}`.
|
||||
3. Navigate to the battlefield editor, paint a small grid, place deployment zones, place an objective (when the victory condition requires it), and save. The same `localStorage` key is updated with the assembled scenario.
|
||||
4. Click "Start match" and have the initial `MatchState` round-trip through the server's `PostStartMatch` endpoint, land in `localStorage` under `match:current`, and render the "match loaded" stub.
|
||||
5. Refresh the page, navigate around, and find their scenarios still in their browser.
|
||||
6. Try a forged cross-site POST and see a 403. Try to access another user's uploaded image and see a 404.
|
||||
|
||||
## Out of Scope (deferred to Plan 3c or Plan 4)
|
||||
|
||||
- The JavaScript surface: `public/js/storage.js`, `public/js/grid-editor.js`, ESLint, Prettier. The current handlers and views render with HTML form submits and a `<script type="module">` placeholder; Plan 3c adds the fetch-based battlefield grid and the `localStorage` write helpers.
|
||||
- Bundled placeholder images under `public/assets/placeholders/`.
|
||||
- The `archetypes.json` asset.
|
||||
- The hot-seat battle interface (Plan 4).
|
||||
- Bundled scenarios (Plan 4).
|
||||
- End-to-end smoke test against a real browser (Plan 4).
|
||||
|
||||
## In Scope
|
||||
|
||||
### HTTP Handlers (six new files in `src/Http/Handlers/`)
|
||||
|
||||
All handlers are `final class` with a public `handle(Request $request, array $params): Response` method. Each handler:
|
||||
|
||||
1. Verifies the CSRF token at the top (form POSTs read `_csrf` from `$request->post`; fetch POSTs read `X-CSRF-Token` from `$request->server`; the secret comes from the front controller via a request attribute `__csrf_secret`). Mismatch returns `Response::html(403, '<h1>Forbidden</h1>')` for form POSTs or `Response::json(403, ['error' => 'csrf'])`.
|
||||
2. Parses the form or JSON body.
|
||||
3. Calls the relevant `Application` service (`ScenarioDraft::fromPost`, `ScenarioSerializer::scenarioFromArray`, `ImageUploadService::store`, `Scenario::startMatch`).
|
||||
4. Runs the validator where applicable.
|
||||
5. Returns a `Response` with the right status, headers, and body.
|
||||
|
||||
The handlers:
|
||||
|
||||
- **`GetTeamEditor::handle`** — Returns `Response::html(200, …)` rendering the team editor template. The form is rendered empty; Plan 3c's `storage.js` populates it from `localStorage` on load.
|
||||
- **`PostTeamEditor::handle`** — Reads form fields, builds a `ScenarioDraft`, calls `toScenario()`, runs `ScenarioValidator::validate()`. On success, renders a "Saved" template that contains a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`. On failure, re-renders the editor with errors and original form values.
|
||||
- **`GetBattlefieldEditor::handle`** — Returns `Response::html(200, …)` rendering the battlefield editor with an empty `<table class="bf-grid">` (Plan 3c populates it from `localStorage`).
|
||||
- **`PostBattlefieldEditor::handle`** — Reads `Content-Type: application/json` body via `$request->rawBody`, decodes a `ScenarioDraft`-shaped array, runs the validator, returns `Response::json(200, ['ok' => true, 'scenario' => $array])` on success or `Response::json(400, ['ok' => false, 'errors' => [...]])` on failure.
|
||||
- **`PostImageUpload::handle`** — Reads `$_FILES['image']`, computes the `userToken` (derived from the CSRF secret — see CSRF Model below), constructs `ImageUploadService($userToken, $uploadsRoot)`, calls `store($tempPath, $declaredMime)`, returns `Response::json(200, ['url' => $url])` on success or `Response::json(400, ['error' => $message])` on failure.
|
||||
- **`PostStartMatch::handle`** — Reads the JSON body, decodes a `Scenario` via `ScenarioSerializer::scenarioFromArray`, runs `ScenarioValidator::validate()` (defense in depth), calls `Scenario::startMatch('alpha')`, returns `Response::json(200, ['match' => $array])` on success or `Response::json(400, ['errors' => [...]])` on failure.
|
||||
|
||||
### View Templates (six new files in `src/Views/`)
|
||||
|
||||
All templates are plain PHP files. They:
|
||||
|
||||
1. Begin with `<?php declare(strict_types=1); ?>`.
|
||||
2. Use `require __DIR__ . '/layout.php';` for shared chrome (the layout sets up `<meta name="csrf-token" content="…">`, `<link rel="stylesheet" href="/assets/styles.css">`, security headers via `header()` calls in the front controller, and the document body).
|
||||
3. Print every dynamic value via `Escape::html`, `Escape::attr`, or `Escape::url`.
|
||||
4. Do not call domain code directly. They receive pre-built data from the handler.
|
||||
|
||||
The templates:
|
||||
|
||||
- **`layout.php`** — Shared chrome. Sets `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'` (Plan 3c may tighten this), `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`. Outputs the doctype, `<head>` with the CSRF meta and the stylesheet link, the `<body>` open tag, and `</body></html>`.
|
||||
- **`home.php`** — The home page: heading, "New scenario" link to `/scenarios/new/edit/team`, `<div id="recent">` placeholder (Plan 3c populates it from `localStorage`).
|
||||
- **`team-editor.php`** — The team editor form. Sections for meta (id, name), team A, team B, victory condition, hidden `_csrf` field, an `<input type="file" name="unit-N-image">` per unit row for the upload form, and a "Save" submit button. The actual archetype list and stat min/max are derived from `ArchetypeCatalog::templates()` and inlined in the HTML so the form is fully functional without JS. Plan 3c adds the JS that pre-fills the form from `localStorage`.
|
||||
- **`battlefield-editor.php`** — The battlefield editor: width/height number inputs, terrain palette, `<table class="bf-grid">` rendered empty (Plan 3c populates it from `localStorage`), two deployment-zone fieldsets, an objective fieldset (only when `HoldObjective` is the victory condition), hidden `_csrf` field, "Save" submit.
|
||||
- **`match-stub.php`** — The Plan-3a/3c "Battle interface coming in Plan 4" placeholder. Reads `match:current` from the request's `localStorage` simulation (the integration test sets it via the `__csrf` cookie pattern; the front controller's real version hands the JS a `match:current` key from the dispatch flow).
|
||||
- **`upload-result.php`** — A small fragment used by the upload form's iframe-style response (so the parent form can read the returned URL). Renders a `<script type="text/javascript">` block that calls `window.parent.postMessage({url: "..."}, "*")` and a fallback link.
|
||||
|
||||
### Front Controller (modifies `public/index.php`)
|
||||
|
||||
The Task 1 stub is replaced with a real front controller. The front controller is the only place that touches `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER` directly. It:
|
||||
|
||||
1. Reads `$_GET`, `$_POST`, `$_FILES`, `$_COOKIE`, `$_SERVER`.
|
||||
2. Derives the request method, path, query string, and content type.
|
||||
3. Reads the app secret from `BATTLEFORGE_SECRET` (env var) or, if absent, from `var/secret.key` (a 32-byte random file created on first run and git-ignored).
|
||||
4. Issues a CSRF token on first visit (when the `__csrf` cookie is absent) by setting the cookie to `CsrfToken::issue($secret)[1]` — the cookie holds the HMAC, the form/header holds the original token value. On every response, the controller ensures the cookie is set with `HttpOnly`, `SameSite=Lax`, `Secure` (production), `Path=/`, `Expires=time()+86400`.
|
||||
5. Computes the `__uploads_token` cookie as `hash_hmac('sha256', $secret, 'bf-uploads')` (or reads it from the request) and threads it to `GetAssets` via a request attribute `__uploads_token` (read inside the handler via `$request->server['__uploads_token']`).
|
||||
6. Configures the `Router` with the eight routes:
|
||||
- `GET /` → `GetHomePage`
|
||||
- `GET /scenarios/{id}/edit/team` → `GetTeamEditor`
|
||||
- `POST /scenarios/{id}/edit/team` → `PostTeamEditor`
|
||||
- `GET /scenarios/{id}/edit/battlefield` → `GetBattlefieldEditor`
|
||||
- `POST /scenarios/{id}/edit/battlefield` → `PostBattlefieldEditor`
|
||||
- `POST /scenarios/{id}/start` → `PostStartMatch`
|
||||
- `POST /assets/upload` → `PostImageUpload`
|
||||
- `GET /assets/{kind}/{filename}` → `GetAssets` (the router pattern handles `{kind}` and `{filename}`)
|
||||
7. Builds a `Request` value object with the request data, the CSRF secret (in `__csrf_secret`), the upload token (in `__uploads_token`), and the request method, path, and content type.
|
||||
8. Dispatches and emits the response (status, headers, body).
|
||||
|
||||
### CSRF Model
|
||||
|
||||
A single `__csrf` cookie per session. The cookie holds `hash_hmac('sha256', $token, $secret)` (the `issue()` method's second return value). The form/header holds the original token (`$token`, the first return value). On every state-changing request:
|
||||
|
||||
- Forms: hidden `_csrf` field with `$token` is matched against `CsrfToken::verify($token, $secret, $request->cookies['__csrf'] ?? '')`.
|
||||
- Fetch: `X-CSRF-Token` header with `$token` is matched the same way.
|
||||
|
||||
The upload-endpoint user token is derived from the same secret:
|
||||
|
||||
```php
|
||||
$userToken = hash_hmac('sha256', $secret, 'bf-uploads');
|
||||
```
|
||||
|
||||
`GetAssets` validates `$request->cookies['__uploads_token'] === $params['userToken']`. The front controller sets the `__uploads_token` cookie on first visit.
|
||||
|
||||
**Hardening note for the front controller**: the `userToken` path-param in `GetAssets` should be validated against a strict format (e.g. `/^[a-f0-9]{32,}$/`) to prevent directory traversal. The router's `[^/]+` pattern restricts `$filename` already, but `$userToken` is the path-segment before the file, so a defensive regex there is appropriate.
|
||||
|
||||
### System Boundaries (delta on the Plan 3 spec)
|
||||
|
||||
Plan 3b introduces no new boundaries. The four module boundaries from the Plan 3 spec still hold:
|
||||
|
||||
1. **Content library** (Domain, unchanged).
|
||||
2. **Scenario editor** (web, expanding): now includes the team editor, battlefield editor, and image upload forms.
|
||||
3. **Rules engine** (Domain, unchanged).
|
||||
4. **Battle interface** (Plan 4, stubbed in Plan 3b's `match-stub.php`).
|
||||
|
||||
The `Application` and `Http` layers from Plan 3a continue to mediate between the web pages and the `Domain`. The front controller in `public/index.php` is the only place that touches the superglobals.
|
||||
|
||||
## Data and Action Flow
|
||||
|
||||
### Cold-start flow (no change from Plan 3 spec)
|
||||
|
||||
### Team editor save flow
|
||||
|
||||
1. User edits the form, presses Save.
|
||||
2. Browser POSTs the form to `POST /scenarios/{id}/edit/team` with `Content-Type: application/x-www-form-urlencoded` and a hidden `_csrf` field.
|
||||
3. Server's `PostTeamEditor`:
|
||||
a. Verifies the CSRF token.
|
||||
b. Reads form fields into a `ScenarioDraft`.
|
||||
c. Calls `ScenarioDraft::toScenario()` and `ScenarioValidator::validate($scenario)`.
|
||||
d. On failure: re-renders the editor with errors and original form values.
|
||||
e. On success: renders a "Saved" template that includes a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`.
|
||||
|
||||
### Battlefield editor save flow
|
||||
|
||||
1. User clicks tiles; the small JS helper maintains a `tileMap` object.
|
||||
2. User presses Save; the JS helper POSTs JSON to `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token` header.
|
||||
3. Server's `PostBattlefieldEditor`:
|
||||
a. Verifies the CSRF token from the header.
|
||||
b. Decodes the JSON body.
|
||||
c. Runs `ScenarioValidator::validate()`.
|
||||
d. On failure: returns `Response::json(400, ['ok' => false, 'errors' => [...]])`.
|
||||
e. On success: returns `Response::json(200, ['ok' => true, 'scenario' => $array])`.
|
||||
|
||||
(Note: Plan 3b ships the form-based team editor; the JS-driven fetch is added in Plan 3c. The handler accepts both for compatibility.)
|
||||
|
||||
### Image upload flow
|
||||
|
||||
1. User picks an image, presses Upload.
|
||||
2. Browser submits the file to `POST /assets/upload` as `multipart/form-data` with a hidden `_csrf` field.
|
||||
3. Server's `PostImageUpload`:
|
||||
a. Verifies the CSRF token.
|
||||
b. Reads `$_FILES['image']`.
|
||||
c. Constructs `ImageUploadService($userToken, $uploadsRoot)`, calls `store($tempPath, $declaredMime)`.
|
||||
d. Returns `Response::json(200, ['url' => $url])` on success or `Response::json(400, ['error' => $message])` on failure.
|
||||
|
||||
### Start-match flow
|
||||
|
||||
1. User clicks "Start match" on either editor.
|
||||
2. JS reads the assembled `Scenario` from `localStorage`, POSTs JSON to `POST /scenarios/{id}/start` with `X-CSRF-Token` header.
|
||||
3. Server's `PostStartMatch`:
|
||||
a. Verifies the CSRF token.
|
||||
b. Decodes a `Scenario` via `ScenarioSerializer::scenarioFromArray`.
|
||||
c. Runs `ScenarioValidator::validate()` (defense in depth).
|
||||
d. Calls `Scenario::startMatch('alpha')`.
|
||||
e. Returns `Response::json(200, ['match' => $array])` on success or `Response::json(400, ['errors' => [...]])` on failure.
|
||||
|
||||
## Validation and Failure Handling
|
||||
|
||||
- `ScenarioValidator` is the single source of truth for scenario validity. Both the server (on every form POST) and the browser (when Plan 3c lands) call it.
|
||||
- A rejected action never partially mutates the persisted state. `localStorage` writes happen only on a successful server response.
|
||||
- Failure to save a scenario leaves the user's in-progress form intact; the editor re-renders the same values with errors inline.
|
||||
- The CSRF token's invalid-or-missing case returns a generic 403 page (form POSTs) or 403 JSON (fetch POSTs) that links back to `GET /`. The user's in-progress form state is preserved in `localStorage` (Plan 3c's responsibility).
|
||||
- A failed image upload returns 400 JSON with the error message. The unit row keeps whatever `image` field it had.
|
||||
- Corrupt or incompatible JSON in `localStorage` is rejected on read: the JS helper skips the row and surfaces an error to the user.
|
||||
|
||||
## Security and Quality Requirements
|
||||
|
||||
Inherited from the Plan 3 spec:
|
||||
|
||||
- Every state-changing form or request uses and verifies a CSRF token before mutation. Forms use a hidden `_csrf` field; fetch uses an `X-CSRF-Token` header.
|
||||
- All rendered output is escaped via `Escape::html`, `Escape::attr`, and `Escape::url`. Code review enforces the pattern.
|
||||
- All uploaded images are content-validated and by dimensions; the stored file's name is a server-generated random hex; the original filename and declared MIME are discarded after validation.
|
||||
- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server).
|
||||
- The dev server is the built-in `php -S`.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
- All output includes `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, and the `Content-Security-Policy` header.
|
||||
|
||||
Plan 3b-specific:
|
||||
|
||||
- The front controller's `userToken` validation in `GetAssets` uses a strict format regex (`/^[a-f0-9]{32,}$/`) to harden the path-traversal surface.
|
||||
- The `BATTLEFORGE_SECRET` env var takes precedence over the per-install file. The per-install file is `var/secret.key`, a 32-byte binary file created on first run with `random_bytes(32)` and `chmod 0600`. The file is git-ignored.
|
||||
- The CSRF secret in the `__csrf` cookie is bound to the browser via the cookie's `HttpOnly` + `SameSite=Lax` attributes, and the secret is HMAC-signed server-side. A cross-site request cannot forge the HMAC.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Unit tests (in `tests/Integration/` per the established Plan 3a pattern)
|
||||
|
||||
The handlers are integration-tested, not unit-tested, because their behavior crosses the HTTP / Application / Domain boundary. Each handler has at least:
|
||||
|
||||
- One happy-path test: synthetic `Request` with a valid CSRF token and a valid form/JSON body, asserts on the response status, headers, and body.
|
||||
- One validation-error test: synthetic `Request` with valid CSRF but out-of-bounds data, asserts the response carries the validator's error messages.
|
||||
|
||||
Tests cover (per the Plan 3 spec):
|
||||
|
||||
- `GetTeamEditor` — 200 with security headers and a form.
|
||||
- `PostTeamEditor` — happy path returns 200 with `localStorage.setItem` snippet; out-of-bounds stat returns 200 with the validator's error message inline.
|
||||
- `GetBattlefieldEditor` — 200 with security headers and an empty grid.
|
||||
- `PostBattlefieldEditor` — happy path returns `{ok: true, scenario: ...}`; invalid shape returns `{ok: false, errors: [...]}`.
|
||||
- `PostImageUpload` — 200 with `{url: ...}`; missing file returns 400; invalid file returns 400.
|
||||
- `PostStartMatch` — 200 with `{match: ...}` containing `activeTeamId: 'alpha'` and `round: 1`.
|
||||
- Forged CSRF (no `_csrf` field) returns 403 for form POSTs and 403 JSON for fetch POSTs.
|
||||
- Wrong-user upload (token mismatch) returns 404.
|
||||
|
||||
### Full-flow integration test (`FullFlowTest`)
|
||||
|
||||
A single integration test that walks the full create-and-save flow:
|
||||
|
||||
1. Bootstraps the front controller in-process (no `php -S` boot) by including `public/index.php` with synthetic superglobals.
|
||||
2. Issues a CSRF token.
|
||||
3. GET home page.
|
||||
4. POST team editor with a valid form body, asserts the response says "Saved" and contains the `localStorage.setItem` snippet.
|
||||
5. POST battlefield editor with a valid JSON body, asserts `{ok: true, ...}`.
|
||||
6. POST start-match with the assembled scenario, asserts the response contains the initial match state.
|
||||
|
||||
This test exercises the actual `public/index.php` front controller end-to-end and is the closest the MVP gets to a true E2E test (a real browser-driven E2E is in Plan 4).
|
||||
|
||||
### CI verification (Task 16)
|
||||
|
||||
The existing `.github/workflows/ci.yml` already runs `composer check` (PHPCS + PHPStan + PHPUnit). Plan 3b adds no new dependencies. No workflow change is required; just verify the existing workflow is present.
|
||||
|
||||
### Static analysis / lint
|
||||
|
||||
- PHPCS scope: `src/Http/Handlers/`, `src/Views/` (excluded by `phpstan.neon` but still checked by PHPCS), `public/index.php`. Already covered by the Task 1 `phpcs.xml`.
|
||||
- PHPStan level 6 stays. `src/Views/*` is excluded (templates are not statically analyzed). `public/index.php` is included.
|
||||
- The pre-existing line-length warnings from Plan 1+2 are not addressed in Plan 3b. They remain as a known follow-up. Plan 3b's new files should not introduce new warnings.
|
||||
|
||||
### Manual usability check
|
||||
|
||||
A new tester with no BattleForge context can: open the dev URL, see the home page, click "New scenario", fill in two teams with mixed archetypes, upload a custom image, save the team editor, paint a small grid, save the battlefield editor, click "Start match", and reach the "match loaded" stub — without leaving the browser or seeing any error from the validator that wasn't explained inline.
|
||||
|
||||
## Release Boundary
|
||||
|
||||
Plan 3b is releasable when every in-scope capability and success criterion is met, the verification suite is green (PHPUnit, PHPStan, PHPCS), and no out-of-scope capability (the JS-driven UX, the battle interface, bundled scenarios) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`.
|
||||
|
||||
The next plan (Plan 3c) will add the JavaScript surface (`storage.js`, `grid-editor.js`, placeholder images) and the `archetypes.json` asset. Plan 4 will replace the `match-stub.php` placeholder with the real battle interface, add the three bundled scenarios, and ship the E2E smoke test that this plan seeds.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Plan 3c: JavaScript Frontend Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Plan 3c of the four-plan BattleForge build. Closes the gap between the Plan 3a+3b PHP backend and the in-browser experience by adding the small JavaScript surface that Plan 3a's web layer was waiting for. Plan 3c is the third of four plans and the last one in the original "Plan 3" deliverable.
|
||||
|
||||
Plan 3a's web layer (the front controller, the editor handlers, the views) shipped without any JavaScript beyond a `<script type="module" src="...">` placeholder. Plan 3c delivers the JS that pre-fills the team editor from `localStorage`, runs the battlefield grid interactions, and wires the home page's "Recent scenarios" list. The web app is currently functional from the browser with HTML-only form submits; Plan 3c adds the JS-driven UX that the spec calls for.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Plan 3c succeeds when a new user, without developer assistance, can:
|
||||
|
||||
1. Visit `http://localhost:8000/` and see the home page with a "Recent scenarios" list populated from `localStorage`.
|
||||
2. Click "New scenario", fill the team editor, save — the page reloads, the recent list shows the new scenario, and clicking the new scenario in the recent list opens the team editor pre-filled with the saved data.
|
||||
3. Navigate to the battlefield editor, paint a small grid, place deployment zones, place an objective, save — the saved state is in `localStorage` under the same `scenario:{id}` key, and the home page's recent list shows the updated scenario.
|
||||
4. Refresh the page or come back tomorrow and find their scenarios still in their browser.
|
||||
5. Open the browser dev tools and see no console errors, no `eval`, no unhandled promise rejections, no mixed-content warnings.
|
||||
|
||||
## Out of Scope (deferred to Plan 4)
|
||||
|
||||
- The hot-seat battle interface (Plan 4 ships a real battle page that reads `match:current` from `localStorage` and renders the turn-based UI).
|
||||
- Three bundled scenarios (Plan 4 ships them as JSON fixtures in `public/scenarios/`).
|
||||
- An end-to-end smoke test against a real browser. The spec calls for it under Plan 4, not Plan 3c.
|
||||
- Drag-and-drop on the grid (the spec says it's optional; the click-paint approach in Plan 3c is sufficient for the MVP).
|
||||
- Service worker / offline support (the spec explicitly excludes this).
|
||||
- Animation of combat, terrain, or transitions (Plan 4).
|
||||
- The "Start match" button on the team editor navigates to the home page after a successful match start; Plan 4 adds the route that shows the match in progress.
|
||||
|
||||
## In Scope
|
||||
|
||||
### Two JavaScript files
|
||||
|
||||
**`public/js/storage.js`** — the localStorage helper. Exposes `window.bfStorage` with eight methods:
|
||||
|
||||
```js
|
||||
window.bfStorage = {
|
||||
// Read a scenario by id. Returns the parsed object or null if missing/corrupt.
|
||||
load(id) -> object | null,
|
||||
|
||||
// Write a scenario to localStorage under `scenario:{id}`.
|
||||
// The argument is the canonical Plan-3a scenario shape (the same shape
|
||||
// ScenarioSerializer::scenarioToArray produces).
|
||||
save(id, scenario) -> void,
|
||||
|
||||
// Remove a scenario from localStorage.
|
||||
delete(id) -> void,
|
||||
|
||||
// List all stored scenarios, sorted by last-modified descending.
|
||||
// Returns [{id, name, lastModified}].
|
||||
listAll() -> [{id, name, lastModified}],
|
||||
|
||||
// Read/write the in-progress match.
|
||||
currentMatch() -> object | null,
|
||||
setCurrentMatch(match) -> void,
|
||||
removeCurrentMatch() -> void,
|
||||
};
|
||||
```
|
||||
|
||||
The module handles corrupt JSON gracefully: `load(id)` returns `null` if `JSON.parse` throws, and `listAll()` skips keys that fail to parse (showing a "corrupt scenario" placeholder in the home page).
|
||||
|
||||
**`public/js/grid-editor.js`** — the battlefield editor grid helper. Runs on `DOMContentLoaded`. Reads `data-scenario-id` from the form element, calls `bfStorage.load(id)`, and populates the grid. Manages three modes selected by the existing `name="zoneMode"` radio buttons (alpha, bravo, objective, plus the default terrain-paint mode which is implicit when none of the zone radios are selected). On tile click, applies the current mode to that tile. Shift-click removes the paint or zone membership. The form's submit is intercepted; the JS builds the canonical scenario JSON, fetches `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token: <token>` (read from the layout's `<meta name="csrf-token">`), parses the response, and writes the returned `scenario` to `localStorage`. On 400 with `{ok: false, errors: [...]}`, displays the errors inline in the form.
|
||||
|
||||
### One JSON asset
|
||||
|
||||
**`public/assets/archetypes.json`** — the four `ArchetypeTemplate` definitions, served at `/assets/archetypes.json`. Format:
|
||||
|
||||
```json
|
||||
{
|
||||
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
|
||||
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
|
||||
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
|
||||
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
|
||||
}
|
||||
```
|
||||
|
||||
The `ArchetypeCatalog::templates()` from Plan 3a is the canonical source for these values. Plan 3c's JSON must stay in sync with Plan 3a's PHP enum values. A future task could generate this JSON from the PHP source, but Plan 3c ships a hand-maintained copy.
|
||||
|
||||
### Four placeholder images
|
||||
|
||||
**`public/assets/placeholders/{defender,striker,support,scout}.png`** — 64×64 (or 32×32) transparent PNGs with a simple silhouette representing each archetype. Served at `/assets/placeholders/{archetype}.png` (no `userToken` prefix). The team editor's `<img>` tags for unit art default to these URLs when no uploaded image is set. The exact pixel art is up to the implementer; the test just checks the file exists and is a valid PNG.
|
||||
|
||||
### Updated view templates
|
||||
|
||||
Three templates get new `<script type="module">` tags:
|
||||
|
||||
- **`src/Views/home.php`** — adds `<script type="module" src="/js/storage.js"></script>`. The `<div id="recent">` is populated by `storage.js` on `DOMContentLoaded`.
|
||||
- **`src/Views/team-editor.php`** — adds `<script type="module" src="/js/storage.js"></script>`. The form fields are pre-filled by `storage.js` on `DOMContentLoaded`. Adds a "Start match" button at the bottom of the form that calls `bfStorage.load(id)`, fetches `POST /scenarios/{id}/start`, calls `bfStorage.setCurrentMatch(match)`, and navigates to `GET /`.
|
||||
- **`src/Views/battlefield-editor.php`** — adds `<script type="module" src="/js/grid-editor.js"></script>`. The grid is populated by `grid-editor.js` on `DOMContentLoaded`.
|
||||
|
||||
### Node toolchain
|
||||
|
||||
- **`package.json`** — declares `eslint ^8.57.0`, `eslint-config-airbnb-base ^15.0.0`, `eslint-plugin-import ^2.29.0`, `prettier ^3.3.0`. Scripts: `lint` (`eslint public/js`), `format` (`prettier --check public/js`).
|
||||
- **`.eslintrc.json`** — extends `airbnb-base`, env `browser + es2022`, sourceType `module`.
|
||||
- **`.prettierrc`** — `{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }` (matches the existing PHPCS PSR-12 style).
|
||||
|
||||
### CI update
|
||||
|
||||
- **`.github/workflows/ci.yml`** — adds `npm ci`, `npm run lint`, `npm run format` steps after the existing `composer check` step.
|
||||
- **`.gitignore`** — adds `var/secret.key` (Plan 3b's `FullFlowTest` creates it on first run; it shouldn't be committed) and `node_modules/`.
|
||||
|
||||
## System Boundaries
|
||||
|
||||
Plan 3c introduces no new boundaries. The Plan 3a+3b web layer's four boundaries still hold:
|
||||
|
||||
1. **Content library** (Domain, unchanged): `ArchetypeCatalog` is the canonical source; `archetypes.json` is a hand-maintained mirror used by the JS.
|
||||
2. **Scenario editor** (web, now JS-driven): Plan 3c adds the JS layer that pre-fills forms and runs grid interactions. The form submit still goes to the same PHP handlers.
|
||||
3. **Rules engine** (Domain, unchanged).
|
||||
4. **Battle interface** (Plan 4).
|
||||
|
||||
The JS layer reads and writes the canonical scenario shape (Plan 3a's `ScenarioSerializer::scenarioToArray`). It does not introduce a separate "draft" shape or a UI-only data model. The home page's "Recent scenarios" list parses the same shape for display.
|
||||
|
||||
## Data and Action Flow
|
||||
|
||||
### Cold-start flow
|
||||
|
||||
1. Browser hits `GET /`. `GetHomePage` returns 200 with the home page (which now includes `<script type="module" src="/js/storage.js"></script>`).
|
||||
2. `storage.js` runs. `bfStorage.listAll()` enumerates `localStorage` keys matching `scenario:*`, parses each value, returns an array sorted by `lastModified` descending. The home page's `<div id="recent">` is populated with one `<a>` per item, pointing to `/scenarios/{id}/edit/team`.
|
||||
3. User clicks "New scenario" → `GET /scenarios/new/edit/team`.
|
||||
|
||||
### Team editor flow
|
||||
|
||||
1. Browser hits `GET /scenarios/{id}/edit/team`. `GetTeamEditor` returns 200 with the team editor template (which now includes `<script type="module" src="/js/storage.js"></script>`).
|
||||
2. `storage.js` reads `window.location.pathname`, extracts `{id}` (or `'new'` for the new-scenario case), calls `bfStorage.load(id)`. If a scenario exists, the JS pre-fills each form field by name (e.g. `teamA[units][0][archetype]` → `document.querySelector('[name="teamA[units][0][archetype]"]').value = scenario.units[0].archetype`). If the id is `'new'`, the form stays empty.
|
||||
3. The archetype dropdowns are populated from `/assets/archetypes.json` (fetched once on script load).
|
||||
4. User edits, then presses Save. The form's submit triggers a normal `POST /scenarios/{id}/edit/team` (no JS intercept).
|
||||
5. The server returns 200 with a `<script type="module">` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))`. The browser runs the script and writes.
|
||||
6. On a validation error, the server returns 200 with the form re-rendered and the validator's error in `<div class="bf-errors">`. No JS write happens; the form keeps the user's in-progress values.
|
||||
|
||||
### Battlefield editor flow
|
||||
|
||||
1. Browser hits `GET /scenarios/{id}/edit/battlefield`. `GetBattlefieldEditor` returns 200 with the battlefield editor template (which now includes `<script type="module" src="/js/grid-editor.js"></script>`).
|
||||
2. `grid-editor.js` reads `bfStorage.load(id)`, populates the `tileMap` from `scenario.battlefieldTerrain`, populates the deployment zones (extracted from `scenario.deploymentZones`), and populates the objective position (if `HoldObjective`).
|
||||
3. The grid is repainted: each tile button's `data-paint` attribute is updated, the objective tile gets `data-objective="1"`, and the zone tiles get `data-zone="alpha"` or `data-zone="bravo"`.
|
||||
4. User clicks a palette button to select terrain paint mode, then clicks tiles. Each click updates the in-memory `tileMap` and updates the button's `data-paint`. User clicks one of the `name="zoneMode"` radios (alpha / bravo / objective) and clicks tiles to add to that zone or place the objective. Shift-click in any mode removes the paint or zone membership.
|
||||
5. User presses Save. The JS intercepts the form submit (`event.preventDefault()`), builds the canonical scenario JSON, POSTs to `/scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and `X-CSRF-Token: <token>` (read from the layout's `<meta name="csrf-token">`), parses the response, and writes the returned `scenario` to `localStorage`.
|
||||
6. On 400 with `{ok: false, errors: [...]}`, the JS shows the errors inline in the form (e.g. "Deployment zone alpha includes impassable terrain at (3,4)").
|
||||
|
||||
### Start-match flow
|
||||
|
||||
1. From the team editor, user clicks "Start match" (a new button at the bottom of the form). The JS reads `bfStorage.load(id)`, fetches `POST /scenarios/{id}/start` with `X-CSRF-Token`, parses the response, calls `bfStorage.setCurrentMatch(match)`, and navigates to `GET /`.
|
||||
2. The home page (when reloaded) shows a "Match in progress" banner at the top, driven by `bfStorage.currentMatch()`. Plan 4 adds a real battle page that this banner links to.
|
||||
|
||||
## Validation and Failure Handling
|
||||
|
||||
- `bfStorage.load(id)` returns `null` if the value is missing or fails to JSON-parse. The home page's `listAll()` skips keys that fail to parse, and the team editor's pre-fill gracefully no-ops on `null`.
|
||||
- The team editor's form submit falls back to the server's re-render path (Plan 3a's `PostTeamEditor` re-renders the form on validation failure with the user's in-progress values and the validator's error message). The JS does not intercept the submit.
|
||||
- The battlefield editor's fetch fallback path: on 400, the JS shows the validator's errors inline and keeps the user's in-progress state in the `tileMap` and zone maps. On network failure (e.g. fetch throws), the JS shows a "Network error" message and the form stays editable.
|
||||
- The "Start match" button's failure path: on 400 (invalid scenario), the JS shows the validator's errors inline. On network failure, shows a "Network error" message and keeps the form editable.
|
||||
|
||||
## Security and Quality Requirements
|
||||
|
||||
Inherited from the Plan 3 spec:
|
||||
|
||||
- All rendered output is escaped for its output context. The PHP templates use `Escape::html` / `Escape::attr` / `Escape::url`. The JS uses `textContent` and `value` setters, never `innerHTML` (with one exception: building the home page's recent-scenarios list uses `appendChild` on `<a>` elements with `textContent`, never `innerHTML`).
|
||||
- The single small JS file passes ESLint `airbnb/base` and Prettier checks. CI fails if either fails.
|
||||
- `composer validate --strict` and the existing `composer check` continue to pass.
|
||||
|
||||
Plan 3c-specific:
|
||||
|
||||
- No third-party JS libraries. The two files are pure ESM, no dependencies, no `eval`, no `Function` constructor.
|
||||
- The `archetypes.json` is served from the same origin. The home page's "Recent scenarios" list is read from `localStorage`, never from the network.
|
||||
- `var/secret.key` is added to `.gitignore` so the Plan 3b full-flow test's generated secret file is not committed.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Lint and format (CI gate)
|
||||
|
||||
- `npm run lint` — ESLint `airbnb-base` over `public/js/*.js`. Fails on any rule violation, including unused variables, missing semicolons, and unhandled promise rejections.
|
||||
- `npm run format` — Prettier `--check public/js`. Fails on any formatting drift.
|
||||
|
||||
### Smoke test (optional, recommended)
|
||||
|
||||
- One Playwright test that boots the dev server with `php -S`, loads the home page, asserts the empty "Recent scenarios" list, calls `bfStorage.save('demo', {id: 'demo', name: 'Demo'})` via `page.evaluate`, reloads, and asserts the list shows "Demo". This is the Plan 3c analog of `FullFlowTest` for Plan 3b.
|
||||
- If Playwright is too heavy for Plan 3c, the spec's CI requirement (ESLint + Prettier) is sufficient, and the Playwright test lands in Plan 4 with the battle interface.
|
||||
|
||||
### Manual usability check
|
||||
|
||||
A new tester with no BattleForge context can: open the dev URL, see the home page, click "New scenario", fill the team editor, save, see the recent list show the new scenario, click it, see the team editor pre-filled, paint a small grid, save, see the battlefield state persist, and refresh to find their work still there — without leaving the browser or seeing any error from the validator that wasn't explained inline.
|
||||
|
||||
## Release Boundary
|
||||
|
||||
Plan 3c is releasable when every in-scope capability and success criterion is met, the verification suite is green (`composer check`, `npm run lint`, `npm run format`), and no out-of-scope capability (the battle interface, bundled scenarios, the end-to-end Playwright smoke test) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`, and the JS layer loads from `/js/`.
|
||||
|
||||
The next plan (Plan 4) will replace the home page's "Match in progress" banner with the real battle interface, add the three bundled scenarios, and ship the end-to-end smoke test that this plan's spec leaves as a follow-up.
|
||||
@@ -0,0 +1,267 @@
|
||||
# Plan 4: Battle Interface, Bundled Scenarios, and Release Hardening
|
||||
|
||||
## Purpose
|
||||
|
||||
Plan 4 of the four-plan BattleForge build. Replaces the deleted `match-stub.php` placeholder with a real, server-rendered hot-seat battle interface; ships three bundled scenarios that are playable without the editors; fixes the objective-round-trip gap in `ScenarioSerializer`; and adds a real end-to-end smoke test against the dev server.
|
||||
|
||||
Plans 1–3c have already delivered the standalone PHP project, the rules engine, the curated content, the HTTP layer, the scenario editors, and the JavaScript surface. Plan 4 is the last plan of the MVP and turns the existing scaffold into a finished first release.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Plan 4 succeeds when a new user, without developer assistance, can:
|
||||
|
||||
1. Open the home page, see the three bundled scenarios listed with summaries, and click one to start a match.
|
||||
2. Play the resulting match to a winner using a hot-seat battle interface that enforces alternating team turns and disables actions on the inactive team's units.
|
||||
3. Use move, attack, and the three curated abilities (heal, area damage, buff) during play, observing terrain modifiers, action-remaining counters, and per-round objective control where applicable.
|
||||
4. See the result of each action (unit damage, objective tally, action log) and the final winner banner with a "Return to home" link.
|
||||
5. Reload the page mid-match and resume from the same state.
|
||||
6. Watch the smoke test exercise a full match on a real `php -S` instance in CI.
|
||||
|
||||
The combat remains a local hot-seat experience for two players on one device. Online play, accounts, AI opponents, and replay visualization are explicitly out of scope per the design spec.
|
||||
|
||||
## In Scope
|
||||
|
||||
### Server-Authoritative Match API
|
||||
|
||||
Four new POST endpoints, one per player action, all routed under `/matches/current/`. The `current` segment identifies "the match in `localStorage`"; the `matchId` (a 16-char hex minted at `startMatch`) and the `MatchState` both live in the JSON body:
|
||||
|
||||
- `POST /matches/current/move` — body `{matchId, match, unitId, x, y}`; runs `CombatEngine::move`; returns `{match, turnToken, winnerTeamId}`.
|
||||
- `POST /matches/current/attack` — body `{matchId, match, attackerId, targetId}`; runs `CombatEngine::attack`.
|
||||
- `POST /matches/current/ability` — body `{matchId, match, unitId, abilityId, x, y}`; the `x` and `y` are the target position (for `tile`-targeting abilities, the center; for `ally`/`enemy`/`self` targeting, the affected unit's position).
|
||||
- `POST /matches/current/end-turn` — body `{matchId, match}`; runs `CombatEngine::endTurn`.
|
||||
|
||||
The `matchId` is required for turn-token verification on every action and is the only piece of state not derivable from the match JSON itself. The client persists it from the `PostStartMatch` response alongside the match in `match:current`.
|
||||
|
||||
Each handler:
|
||||
|
||||
1. Verifies the CSRF token against the `__csrf` cookie (existing pattern).
|
||||
2. Verifies the `X-Turn-Token` header against the supplied match state (see Turn Token below).
|
||||
3. Decodes the JSON body into a `MatchState` via `ScenarioSerializer::matchFromArray` (defense in depth; the engine would also reject malformed state, but a 400 here gives a clearer error).
|
||||
4. Calls the matching `CombatEngine` method.
|
||||
5. Computes a fresh `turnToken` for the new state and returns it alongside the serialized match and (if set) `winnerTeamId`.
|
||||
|
||||
`PostStartMatch` is modified in Plan 4 to mint a `matchId` (16 random hex chars from `bin2hex(random_bytes(8))`) and a `turnToken` for the initial state, returning `{match, matchId, turnToken}` instead of just `{match}`. The same `BATTLEFORGE_SECRET` is used.
|
||||
|
||||
Rejected actions are mapped to HTTP statuses by the rules-engine boundary:
|
||||
|
||||
| Failure | HTTP | Body | Browser effect |
|
||||
|---|---|---|---|
|
||||
| CSRF mismatch | 403 | `{"error":"csrf"}` | "Session expired. Reload the page." banner |
|
||||
| Turn token mismatch | 409 | `{"error":"turn"}` | "Turn has changed" banner; client re-reads `localStorage` and re-renders |
|
||||
| Malformed `MatchState` | 400 | `{"errors":[...]}` | First error message as a toast; match unchanged |
|
||||
| `CombatException` (illegal action) | 409 | `{"error":"<reason>"}` | Reason as a toast; match unchanged |
|
||||
| Internal error | 500 | `{"error":"server"}` | "Something went wrong" banner; match unchanged |
|
||||
|
||||
`localStorage` writes only happen on a 200 response. The in-browser state is recoverable by reload.
|
||||
|
||||
### Turn Token
|
||||
|
||||
A new `src/Application/TurnToken.php` value object with two static methods:
|
||||
|
||||
```php
|
||||
public static function issue(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex): string
|
||||
public static function verify(string $secret, string $matchId, string $activeTeamId, int $round, int $lastActionIndex, string $token): bool
|
||||
```
|
||||
|
||||
The token is `hash_hmac('sha256', $secret, $matchId . '|' . $activeTeamId . '|' . $round . '|' . $lastActionIndex)` truncated to 32 hex chars. The handler reads `activeTeamId`, `round`, and `lastActionIndex` (i.e. `count($match->actionLog)`) from the *incoming* match, computes the expected token, and `hash_equals` it against the supplied header. The response carries a fresh token computed against the *new* state — i.e. the response's `turnToken` is `TurnToken::issue($secret, $matchId, $newState->activeTeamId, $newState->round, count($newState->actionLog))`. The first turn token of a match is the one minted in the `PostStartMatch` response.
|
||||
|
||||
The token binding:
|
||||
|
||||
- `matchId` is a per-match random hex set when `startMatch` returns and held in the match's `localStorage` envelope alongside the `MatchState` JSON.
|
||||
- `activeTeamId` and `round` are read from the incoming match.
|
||||
- `lastActionIndex` is `count($match->actionLog)`.
|
||||
|
||||
A stale token (from a previous turn or a different round) fails verification. A tampered token fails verification. A missing token fails verification. The token is a defense-in-depth check; the engine's `assertCanAct` is the real authority.
|
||||
|
||||
### Bundled Scenarios
|
||||
|
||||
Three `Scenario` JSON fixtures committed under `public/assets/scenarios/`:
|
||||
|
||||
- **`skirmish.json`** — 8×8 open battlefield, no terrain, no objectives. Two symmetric teams of 3 (Alpha: defender + striker + support; Bravo: striker + scout + support). Victory: `eliminate_all`. The "show me the game" scenario.
|
||||
- **`hold-the-line.json`** — 10×8 battlefield with a forest corridor down the center (Manhattan-cost 2, +1 defense). Alpha: 3 defenders in the corridor. Bravo: 2 strikers + 1 support + 1 scout approaching from the south. One objective marker at the center of the forest. Victory: `hold_objective`, `holdRoundsRequired: 3`.
|
||||
- **`last-stand.json`** — 12×12 battlefield with a mix of forest, rough, and water. Asymmetric: Alpha has 6 mixed units (defenders, supports, scouts); Bravo has 4 (3 strikers + 1 scout). Victory: `eliminate_all`. The larger end of the rules.
|
||||
|
||||
Two new GET endpoints serve them:
|
||||
|
||||
- `GET /scenarios/bundled` — reads the directory, validates each filename against `/^[a-z0-9-]+$/`, returns `[{id, name, summary}, ...]` sorted by id. The `name` and `summary` are read from a `manifest.json` in the same directory. The manifest is the single source of truth for display strings; the fixture filename is the `id`. The manifest shape is `{ "<id>": {"name": "...", "summary": "..."} }`. Any fixture without a manifest entry is filtered out of the response.
|
||||
- `GET /scenarios/bundled/{id}` — reads the fixture, runs it through `ScenarioSerializer::scenarioFromArray` and `ScenarioValidator::validate` (a fixture that fails validation is a bug; the endpoint returns 500 so CI catches it), returns the JSON. The returned JSON is the `Scenario` shape (same as `ScenarioSerializer::scenarioToArray`), not a `ScenarioDraft`.
|
||||
|
||||
The home page renders a "Play bundled" list of the three fixtures with their summaries. Clicking a scenario fetches the full `Scenario` JSON via `GET /scenarios/bundled/{id}` and POSTs it to the existing `PostStartMatch` endpoint (no new route). `PostStartMatch` is modified in Plan 4 to return `{match, matchId, turnToken}` (it currently returns just `{match}`) so the JS can persist the new `matchId` and seed the next action's turn-token check.
|
||||
|
||||
### Match View
|
||||
|
||||
A single new `src/Views/match.php` template, served by `GetMatchView` at `GET /matches/current`. The template:
|
||||
|
||||
1. Renders the shared layout with a CSRF meta tag and a `<script type="module" src="/js/match.js">` tag.
|
||||
2. Renders a server-rendered shell:
|
||||
- Header bar: active team name, current round, End Turn button.
|
||||
- `<div id="bf-grid" class="bf-grid" data-bf-width="..." data-bf-height="...">` (empty; JS fills it).
|
||||
- `<aside id="bf-panel" class="bf-panel">` with three action buttons (Move, Attack, Ability) and a "selected unit" indicator.
|
||||
- `<div id="bf-log" class="bf-log">` with the last 10 action log entries.
|
||||
- `<div id="bf-result" class="bf-result" hidden>` with the winner banner and a "Return to home" link.
|
||||
3. The End Turn button is disabled when `winnerTeamId` is non-null.
|
||||
4. The active team's units are highlighted via a CSS class set by JS; the inactive team's units are visually dimmed and the action buttons on them are `disabled`.
|
||||
|
||||
`public/js/match.js` is the third ESM file. It:
|
||||
|
||||
1. On `DOMContentLoaded`, reads `match:current` from `localStorage` via `bfStorage.get('match:current')`. If absent, renders the "no match — pick a scenario" state.
|
||||
2. Renders the grid + units + panel from the snapshot. For the active unit, the action panel shows its `archetype.abilities` as radio buttons (since units may have 0–2 abilities).
|
||||
3. Clicking a friendly unit selects it (changes the panel's selected-unit indicator). Clicking an enemy unit is a no-op unless in attack mode.
|
||||
4. Clicking an empty tile in move-mode dispatches `POST /matches/current/move` with the latest match JSON, the new unit's destination, and the latest `turnToken`.
|
||||
5. Clicking an enemy unit in attack-mode dispatches `POST .../attack` with the attacker id and target id.
|
||||
6. Clicking a tile in ability-mode (after selecting an ability radio) dispatches `POST .../ability` with the unit id, ability id, and target position.
|
||||
7. End Turn is a single `POST .../end-turn` with the latest match JSON.
|
||||
8. Each request includes `X-CSRF-Token: <csrf meta>` and `X-Turn-Token: <latest>`.
|
||||
9. On 200, the JS overwrites `match:current` in `localStorage`, stores the new `turnToken` in module-local memory, and re-renders. On 409 (`winnerTeamId` is set), it switches to the result state and disables all action controls. On 409 (`turn` mismatch), it re-reads `localStorage` (the source) and re-renders. On 400/403/500, it shows a toast with the error and leaves `localStorage` alone.
|
||||
10. The action log panel updates from the latest snapshot.
|
||||
|
||||
The disabled-vs-enabled rule: the engine's `assertCanAct` is the authority. The JS sets `disabled` on inactive-team unit buttons and on action buttons when the active unit has zero actions remaining, mirroring the engine's contract. The browser never decides legality.
|
||||
|
||||
### ScenarioSerializer Objective Round-Trip Fix
|
||||
|
||||
`src/Application/ScenarioSerializer.php` currently drops the `objectives` field on the way back from a serialized `MatchState` (`matchFromArray` passes `objectives: []`). Plan 4 fixes this:
|
||||
|
||||
1. `matchToArray` adds `'objectives' => $objectivesArray` where `$objectivesArray` is built by iterating `$match->objectives` and calling `['id' => $objective->id, 'position' => self::positionToArray($objective->position)]`.
|
||||
2. `matchFromArray` builds the `ObjectiveMarker` map from `($data['objectives'] ?? [])` and passes it to the new `MatchState`.
|
||||
|
||||
A new unit test in `tests/Unit/Application/ScenarioSerializerTest.php` exercises round-trip on a `HoldObjective` `MatchState` and asserts the objective's id and position survive. This is the foundation of the bundled Hold the Line scenario working end-to-end.
|
||||
|
||||
### End-to-End Smoke Test
|
||||
|
||||
A new `tests/Integration/PostMatchActionSmokeTest.php`:
|
||||
|
||||
1. Boots `php -S 127.0.0.1:<free port> -t public/` in a background process.
|
||||
2. Reads the port from the boot output.
|
||||
3. Uses `stream_context_create` + `file_get_contents` (no new dependency) to walk the Skirmish scenario to a winner: GET `/`, GET `/scenarios/bundled`, GET `/scenarios/bundled/skirmish`, POST `/scenarios/skirmish/start`, then the four action verbs in sequence until `winnerTeamId` is set.
|
||||
4. Asserts the JSON shapes, the `turnToken` field is present on every action response, and a stale-token request returns 409 with the match unchanged.
|
||||
5. Smoke-loads `hold-the-line` and `last-stand` (start match only — these scenarios are heavier and don't need to be fully played).
|
||||
6. Tears down the server with `proc_terminate` in `tearDown`.
|
||||
|
||||
The test is gated by the `BATTLEFORGE_E2E` env var. The default `composer check` run does *not* set it; the per-commit gate stays fast.
|
||||
|
||||
A new `.github/workflows/ci-e2e.yml` runs on PRs to `develop` and nightly, sets `BATTLEFORGE_E2E=1`, and runs the smoke test with a 5-minute timeout. The existing `ci.yml` is unchanged.
|
||||
|
||||
## Out of Scope (deferred)
|
||||
|
||||
- Headless browser test (Playwright/Puppeteer). Out for the MVP per the design spec.
|
||||
- Replay visualization. The action log is captured and rendered in the panel; full replay is a later plan.
|
||||
- AI opponent. The spec explicitly excludes it.
|
||||
- Online multiplayer, accounts, cloud sync. The spec explicitly excludes them.
|
||||
- Mid-turn undo. The design decision is "no" — actions stand.
|
||||
- Cross-scenario match continuation. A match is bound to its starting scenario.
|
||||
|
||||
## System Boundaries (unchanged from Plan 1)
|
||||
|
||||
1. **Content library** (Domain, unchanged) — archetype catalog, ability catalog, terrain behaviors.
|
||||
2. **Scenario editor** (web, unchanged) — Plan 3a/3b shipped this.
|
||||
3. **Rules engine** (Domain, unchanged) — `CombatEngine` is the single authority; Plan 4 calls it from the new match-action handlers but does not modify it.
|
||||
4. **Battle interface** (web, new) — `src/Views/match.php` + `public/js/match.js` + the four action handlers. Renders match state and submits player intent; never independently decides whether an action is legal.
|
||||
|
||||
The `Application` layer gains `TurnToken` (new) and a small extension to `ScenarioSerializer` (the objectives round-trip). The `Domain` layer is unchanged.
|
||||
|
||||
## Data and Action Flow
|
||||
|
||||
### Cold-start flow (unchanged from Plan 3)
|
||||
|
||||
### Bundled scenario bootstrap
|
||||
|
||||
1. User visits `GET /`.
|
||||
2. Home page renders a "Play bundled" list with the three scenarios' names and summaries (read via `GET /scenarios/bundled`).
|
||||
3. User clicks "Skirmish". JS fetches the full `Scenario` JSON via `GET /scenarios/bundled/skirmish`.
|
||||
4. JS POSTs that JSON to `POST /scenarios/skirmish/start` (the existing `PostStartMatch` handler, modified to return `{match, matchId, turnToken}`).
|
||||
5. JS stores the match in `match:current` as `{matchId, match}` and stores the `turnToken` in module-local memory, then navigates to `/matches/current`.
|
||||
|
||||
If a user lands on `/matches/current` directly with no `match:current` in `localStorage` (recovery flow, e.g. opened in a new tab), the JS renders the "no match — pick a scenario" state with a link back to `/`. They do not start a match from this view.
|
||||
|
||||
### Per-action flow
|
||||
|
||||
1. User clicks a friendly unit to select it.
|
||||
2. User chooses an action mode (Move / Attack / Ability).
|
||||
3. User clicks a destination tile (or enemy unit, or selects an ability radio and clicks a target).
|
||||
4. JS reads the current match from in-memory state, builds the request body, and `fetch()`es the matching endpoint with `X-CSRF-Token` and `X-Turn-Token` headers.
|
||||
5. Server runs the engine, returns the new match and a fresh `turnToken`.
|
||||
6. JS replaces the in-memory state, writes `match:current` to `localStorage`, and re-renders.
|
||||
|
||||
### End-of-match flow
|
||||
|
||||
1. The engine's `attack`, `useAbility`, or `endTurn` returns a `MatchState` with `winnerTeamId` set.
|
||||
2. The handler returns `200` with `winnerTeamId` in the body (not 409 — the match is over, not rejected).
|
||||
3. The JS recognizes `winnerTeamId !== null`, switches the view to the result state, and disables all action controls.
|
||||
4. The "Return to home" link clears `match:current` from `localStorage` and navigates to `/`.
|
||||
|
||||
## Validation and Failure Handling
|
||||
|
||||
Inherited from the design spec:
|
||||
- A rejected action never partially changes match state or consumes resources.
|
||||
- Corrupt or incompatible saved data is rejected safely and cannot start a partially initialized match.
|
||||
- The CSRF token's invalid-or-missing case returns a generic 403.
|
||||
|
||||
Plan 4-specific:
|
||||
- The turn token's invalid-or-stale case returns 409 with `{"error":"turn"}`. The browser re-reads `localStorage` and re-renders. The local copy is always the source of truth.
|
||||
- A malformed `MatchState` (somehow arriving at the server) returns 400 with the validator's error messages. The validator is the source of truth; the engine would also reject it, but a 400 here gives a clearer message.
|
||||
- A bundled scenario fixture that fails `ScenarioValidator::validate` makes the `GET /scenarios/bundled/{id}` endpoint return 500. CI catches it because the integration test fetches each fixture and asserts 200.
|
||||
- A bundled scenario file whose name doesn't match `/^[a-z0-9-]+$/` is rejected by `GET /scenarios/bundled` (it's not in the list) and by `GET /scenarios/bundled/{id}` (404). Path-traversal attempts are blocked.
|
||||
- The smoke test's `php -S` boot is wrapped in a try/finally that always tears the server down, even on test failure.
|
||||
|
||||
## Security and Quality Requirements
|
||||
|
||||
Inherited from the design spec:
|
||||
- Every state-changing form or request uses and verifies a nonce before mutation.
|
||||
- All rendered output is escaped for its output context.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- JavaScript passes ESLint using `airbnb/base` and Prettier checks.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
|
||||
Plan 4-specific:
|
||||
- The turn token is a 32-character hex HMAC-SHA256. The secret is the same `BATTLEFORGE_SECRET` env var or `var/secret.key` file already used for CSRF — no new secret material.
|
||||
- The `X-Turn-Token` is required on all four action endpoints. A missing header returns 409.
|
||||
- The match action handlers validate `matchId` against `/^[a-f0-9]{16,}$/` before computing the token, so a tampered matchId can't reach the HMAC input.
|
||||
- The bundled scenario endpoints validate filenames against `/^[a-z0-9-]+$/` and use `realpath` + `is_file` before reading, preventing path traversal.
|
||||
- `public/js/match.js` uses `textContent` and DOM construction; never `innerHTML` with user data. The match log entries are set with `textContent`.
|
||||
- The Content-Security-Policy header in `layout.php` is extended to allow `script-src 'self'` (already implied) and to remain restrictive on `style-src` and `img-src` (no change from Plan 3c).
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Unit tests (`tests/Unit/Application/`)
|
||||
|
||||
- **`TurnTokenTest`** — `issue` is deterministic for the same inputs; `verify` accepts a freshly-issued token, rejects mutated inputs, rejects tokens for a different `matchId`, rejects tokens for a different `activeTeamId`, rejects tokens for a different `round`, and rejects tokens for a different `lastActionIndex`.
|
||||
- **`ScenarioSerializerTest`** (modify) — adds a "match round-trips its objectives" case: build a `HoldObjective` `MatchState`, serialize, deserialize, assert the objective's id and position survive. Also asserts `matchToArray` includes the `objectives` key with the right shape.
|
||||
|
||||
### Integration tests (`tests/Integration/`)
|
||||
|
||||
- **`GetBundledScenariosTest`** — 200 with three entries; 200 with the right `id`/`name`/`summary` fields; sorted by id; rejects a directory entry with a non-conforming filename (via the test creating a `bad..json` file and asserting it's filtered out, then cleaned up).
|
||||
- **`GetBundledScenarioTest`** — 200 for each bundled id, with a `Scenario` that passes `ScenarioValidator::validate`; 404 for an unknown id; 500 for a fixture that fails validation (the test injects a deliberately-broken fixture and asserts the 500).
|
||||
- **`GetMatchViewTest`** — 200 with the `bf-match` container, the CSRF meta, the `match.js` script tag, and the security headers (CSP, `X-Content-Type-Options`, `Referrer-Policy`).
|
||||
- **`PostMatchActionTest`** — four sub-tests, one per verb. Each has a happy path (asserts `match`, `turnToken`, and (for end-turn after a winning attack) `winnerTeamId`) and a rejection path (asserts 409 + the engine's reason, asserts the response body's `match` equals the request's `match`).
|
||||
|
||||
### End-to-end smoke test (`tests/Integration/PostMatchActionSmokeTest.php`)
|
||||
|
||||
- Boots `php -S` on a free port.
|
||||
- Walks Skirmish to a winner (using the dev server's actual JSON responses and `X-Turn-Token`).
|
||||
- Smoke-loads `hold-the-line` and `last-stand` (start match only).
|
||||
- Asserts a stale turn token returns 409 with the match unchanged.
|
||||
- Tears down `php -S` in `tearDown`.
|
||||
|
||||
Gated by `BATTLEFORGE_E2E=1`. The default `composer check` does not set it.
|
||||
|
||||
### CI
|
||||
|
||||
- `ci.yml` (existing) — per-commit gate. No change. PHPCS, PHPStan, PHPUnit, ESLint, Prettier all green.
|
||||
- `ci-e2e.yml` (new) — runs on PRs to `develop` and nightly. Sets `BATTLEFORGE_E2E=1`. Times out at 5 minutes.
|
||||
|
||||
### Manual usability check
|
||||
|
||||
A new tester with no BattleForge context can: open the dev URL, see the home page, click "Skirmish", play the match to completion (alternating with another person on the same device), see the winner banner, return home, click "Hold the Line", and play that match to completion — without leaving the browser or seeing any error that wasn't explained inline.
|
||||
|
||||
## Release Boundary
|
||||
|
||||
Plan 4 is releasable when:
|
||||
|
||||
- Every in-scope capability and success criterion is met.
|
||||
- The verification suite is green: 196+ existing tests pass, all new tests pass, PHPCS/PHPStan/ESLint/Prettier are clean.
|
||||
- The smoke test passes against a real `php -S` instance in CI.
|
||||
- The bundled scenarios are playable end-to-end.
|
||||
- The `ScenarioSerializer` objective round-trip bug is fixed and covered by a test.
|
||||
- No out-of-scope capability is required to build or finish a local match.
|
||||
|
||||
The MVP is complete. Feedback from this release will decide whether the next investment should deepen combat, expand creation tools, add an AI opponent, or introduce asynchronous online play.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Plan 5: Deeper Combat Design Spec
|
||||
|
||||
## Purpose
|
||||
|
||||
The Plan 4 release delivered a playable MVP. Playtest feedback surfaced four combat-feel gaps: deterministic attacks (every click is a hit), no hit/miss randomness, terrain that doesn't reward positioning, and a small "every fight feels the same" effect from the above. Plan 5 deepens the combat layer — engine-only changes — without introducing new infrastructure, new storage, or new external dependencies.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Plan 5 succeeds when a new user, without developer assistance, can:
|
||||
|
||||
1. Click the Attack button on an enemy unit and see a non-zero miss rate (roll-based, not always-hit).
|
||||
2. Read the action log entry and see the roll, the threshold, and the outcome (hit, miss, miss-with-concealment, crit).
|
||||
3. Position a unit in a forest and see the defender's miss rate increase.
|
||||
4. Position a unit in rough terrain and see only movement slowdown (no defensive bonus).
|
||||
5. Move a unit into water and see it spend the rest of its turn "wading" (no further actions).
|
||||
6. Surround a target with allies and see flanking bonuses.
|
||||
7. Win a match and feel that outcomes depend on positioning and randomness, not pure stat comparison.
|
||||
|
||||
## In Scope
|
||||
|
||||
### To-hit + damage model
|
||||
|
||||
A successful attack is no longer guaranteed. The engine computes a per-attack threshold; the roll either meets it (hit) or doesn't (miss). Damage is rolled within ±15% variance on top of the deterministic base.
|
||||
|
||||
- **To-hit threshold**:
|
||||
`threshold = 40 + (attacker.attack * 3) - (target.defense + terrain.defenseBonus) - flankingBonus`
|
||||
- 40 is the base (matches the "60% hit rate at parity" feel).
|
||||
- `attacker.attack * 3` rewards higher-attack units.
|
||||
- `(target.defense + terrain.defenseBonus)` rewards higher-defense units and favorable terrain.
|
||||
- `flankingBonus` is +15 per ally in any of the 4 orthogonally-adjacent tiles of the target, capped at +30 (3+ allies = +30).
|
||||
- Floor: 5 (always at least 5% chance to hit a target you can reach).
|
||||
- Ceiling: 95 (always at least 5% chance to miss, so flanking has teeth).
|
||||
- **Roll**: `random_int(1, 100)`. Server-side, via the existing `random_int()`.
|
||||
- **Outcome**: `isHit = roll >= threshold`.
|
||||
- **Damage on hit**:
|
||||
`damage = round( max(1, attacker.attack + attacker.attackBonus - (target.defense + terrain.defenseBonus)) * random_int(85, 115) / 100 )`
|
||||
- Variance is ±15% on the deterministic damage floor.
|
||||
- Floor: 1 damage (cannot deal 0 on a hit).
|
||||
- **Damage on miss**: 0. Logged as such for transparency.
|
||||
|
||||
### Crits
|
||||
|
||||
A `roll >= 95` is a crit (5% chance on any attack). On crit:
|
||||
|
||||
- Damage is doubled before the variance multiplier (so a crit can deal substantially more than normal).
|
||||
- Log entry includes ` — crit`.
|
||||
|
||||
Crits bypass the to-hit threshold (the roll is always >= 95, always >= any threshold up to 95). The threshold ceiling at 95 ensures a 5% crit window still exists.
|
||||
|
||||
### Forest concealment
|
||||
|
||||
A defender standing on forest gets an additional 15% miss chance. This is a *second* independent random roll (after the to-hit roll passes, the engine rolls `random_int(1, 100) > 15`; if that roll fails, the attack is a miss-with-concealment instead of a hit). Implementation:
|
||||
|
||||
- Forest defender: `isHit = (roll >= threshold) AND (concealmentRoll = random_int(1, 100) > 15)`
|
||||
- Other terrain: `isHit = (roll >= threshold)` (no concealment roll).
|
||||
|
||||
The action log entry for a forest miss includes ` — concealed`.
|
||||
|
||||
This is independent of the to-hit roll. The flow:
|
||||
1. Roll to-hit. If miss, log miss, damage 0.
|
||||
2. If to-hit, roll concealment (only on forest). If concealment fails, log `miss — concealed`, damage 0.
|
||||
3. If hit, roll damage variance, log `hit` (or `crit` if roll was >= 95).
|
||||
|
||||
The to-hit threshold formula does not include a concealment bonus; concealment is a separate roll on top of an already-passing to-hit. This is so the to-hit formula stays readable (one calculation) and the concealment is a clean, independent effect.
|
||||
|
||||
### Flanking
|
||||
|
||||
A unit attacking a target with one or more allies in the target's 4 orthogonally-adjacent tiles (N/S/E/W) gets a flanking bonus to its to-hit threshold. Specifically:
|
||||
|
||||
- `flankingBonus = min(countAlliesAdjacentTo(target) * 15, 30)`
|
||||
- The flanking check counts any non-defeated ally of the attacker. Multiple allies stack up to 2 (cap 30).
|
||||
- Diagonal positions do not count (e.g., a unit at `(x+1, y+1)` relative to the target does not contribute).
|
||||
- The target itself does not contribute (obviously — it is the target).
|
||||
- Defeated allies do not contribute.
|
||||
|
||||
### Water traversal
|
||||
|
||||
Currently water tiles have `movementCost() === null` (impassable). Under Plan 5:
|
||||
|
||||
- A unit can move INTO a water tile; the cost is 3 movement points.
|
||||
- After moving into water, the unit's `actionsRemaining` is forced to 0 (no further move, attack, or ability this turn).
|
||||
- The unit's `wadedThisTurn` flag is set to true.
|
||||
- `startTurn()` resets `wadedThisTurn` to false.
|
||||
- A unit on water can still attack from there (only the *entering* ends the turn; standing on water doesn't restrict attacks after the turn boundary).
|
||||
|
||||
This makes water a tactical choice: pay 3 movement to get there, but your turn is over. The visual: a unit on a water tile gets a `bf-unit--wading` class (low opacity, slight blur) for the rest of its turn. `startTurn()` clears the class on the next turn's start.
|
||||
|
||||
### Rough terrain
|
||||
|
||||
No change to existing rules: cost 2 movement, no defense bonus, no concealment, no crit window. Rough slows approach but is not a defensive position.
|
||||
|
||||
### Blocking
|
||||
|
||||
No change. Impassable.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- New terrain types (e.g., high ground, walls, doors). Plan 5 keeps the existing 5 terrain types.
|
||||
- New unit archetypes. The 4 archetypes (defender, striker, support, scout) and their stat ranges are unchanged.
|
||||
- New stats (e.g., luck, precision). To-hit is derived from the existing attack/defense/speed stats; no new stat axis.
|
||||
- New abilities. The 3 abilities (heal, area_damage, buff) are unchanged.
|
||||
- New victory conditions. The 2 existing conditions (eliminate_all, hold_objective) are unchanged.
|
||||
- UI redesign. The match view's layout and interaction model are unchanged; only the action log gets longer strings.
|
||||
- A "fog of war" or hidden-information system. The engine is fully observable to both players in hot-seat mode; Plan 5 keeps that.
|
||||
- Per-turn re-rolling of starting positions. The starting position is what the scenario sets.
|
||||
|
||||
## System Boundaries (unchanged from Plan 1)
|
||||
|
||||
1. **Content library** (Domain) — owns archetypes, abilities, terrain, and now the new combat math.
|
||||
2. **Scenario editor** (web) — unchanged. Existing JSON shape; no new fields.
|
||||
3. **Rules engine** (Domain) — `CombatEngine` is the single authority. Plan 5 modifies `attack()` to add to-hit, concealment, variance, crits, and water-wade.
|
||||
4. **Battle interface** (web) — unchanged structurally. The JS renderer just reads the (now richer) action log strings.
|
||||
|
||||
The Application layer is unchanged except for a small extension to `ScenarioSerializer` to round-trip `wadedThisTurn` in the `UnitState`. Web layer is unchanged. Storage layer is unchanged.
|
||||
|
||||
## Data and Action Flow
|
||||
|
||||
### Per-attack flow (modified)
|
||||
|
||||
1. User clicks the Attack button on an enemy unit adjacent to one of their units.
|
||||
2. JS reads the current match state from `localStorage`, builds the action POST with `X-CSRF-Token` and `X-Turn-Token`, and POSTs to `/matches/current/attack`.
|
||||
3. Server: `PostMatchAttack::handle` calls `MatchActionSupport::verify` (existing CSRF/turn-token check).
|
||||
4. Server: `CombatEngine::attack(...)` runs:
|
||||
a. Verifies the attacker can act, the target is a valid enemy, and the target is in attack range (existing checks).
|
||||
b. Computes the to-hit threshold: `40 + attack*3 - (defense + terrainDefense) - flanking`.
|
||||
c. Rolls `roll = random_int(1, 100)`.
|
||||
d. If `roll < threshold`: log miss, return without state change to attacker (still spends the attack action).
|
||||
e. If the target is on forest: roll `concealmentRoll = random_int(1, 100)`. If `concealmentRoll <= 15`: log `miss — concealed`, return.
|
||||
f. Compute damage: `round(max(1, attack+bonus - defense-terrainDefense) * random_int(85, 115) / 100)`.
|
||||
g. If `roll >= 95`: double the damage before rounding. Log `crit`.
|
||||
h. Apply damage to the target. Log hit (or crit) with the roll and threshold.
|
||||
5. Server returns the new match state with the action log entry.
|
||||
6. JS replaces the local envelope, re-renders.
|
||||
|
||||
### Per-turn flow (modified for water)
|
||||
|
||||
1. End Turn: same as today.
|
||||
2. `CombatEngine::endTurn()` rotates the active team, refreshes the next team's units, and resets `actionsRemaining`, `hasAttacked`, `hasUsedAbility`, **and `wadedThisTurn`**.
|
||||
3. The new turn token is computed and returned to the JS as before.
|
||||
|
||||
### Water-entry flow (new)
|
||||
|
||||
1. JS sends a move action with destination on a water tile.
|
||||
2. `CombatEngine::move(...)` validates the destination is in attack range (1 step), is not blocking, and the unit has actions remaining.
|
||||
3. After moving, the engine sets `wadedThisTurn = true` on the unit and `actionsRemaining = 0`.
|
||||
4. The new `UnitState` is included in the response.
|
||||
5. JS renders the unit on the water tile with the `bf-unit--wading` class. No further action buttons are enabled for that unit this turn (the existing "actions remaining" UI gate already handles this).
|
||||
|
||||
## Validation and Failure Handling
|
||||
|
||||
- The to-hit formula is always evaluated; no random roll is skipped. Every attack produces an action log entry, even if the result is "miss."
|
||||
- Water-wade is enforced server-side. The JS cannot bypass it by simply not sending the wade flag — the flag is part of the `UnitState` and the engine sets it.
|
||||
- A 5% crit window always exists (threshold ceiling at 95). A 5% hit window always exists (threshold floor at 5).
|
||||
- The forest concealment is independent of to-hit. A unit in forest can still be hit (the concealment roll is a 15% miss chance, not a 100% miss chance).
|
||||
- The `random_int(1, 100)` call uses PHP's CSPRNG (`/dev/urandom` on Linux, `BCryptGenRandom` on Windows). No seeding; no reproducibility.
|
||||
|
||||
## Security and Quality Requirements
|
||||
|
||||
Inherited from the design spec:
|
||||
- Every state-changing form or request uses and verifies a CSRF token before mutation.
|
||||
- All rendered output is escaped for its output context.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- JavaScript passes ESLint using `airbnb/base` and Prettier checks.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
|
||||
Plan 5-specific:
|
||||
- The `random_int(1, 100)` calls are documented with their purpose at the call site (to-hit roll, concealment roll, damage variance).
|
||||
- The to-hit constants (40, 3, 15, 5, 95) are named constants in `CombatEngine`, not magic numbers, so they're easy to retune. Documentation at the constant declaration explains the choice.
|
||||
- The action log entry format is documented as a string contract; any deviation is a bug.
|
||||
- The Plan 5 changes do not introduce new PHPCS warnings. The `random_int` and to-hit math are short; the log format string is < 200 chars; no line-length regression.
|
||||
- Plan 5 changes are covered by unit tests at the engine level (hit, miss, crit, variance, flanking, terrain, water-wade) and by an integration test that asserts the action log entry format on a sample attack.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- `tests/Unit/Domain/CombatEngineTest.php` (existing, extended):
|
||||
- `testAttackRollsAboveThreshold` — attack with `attack=10, defense=0, no terrain` produces threshold 70, roll of 80 yields hit, damage within ±15% of base.
|
||||
- `testAttackMissesWhenRollBelowThreshold` — same setup, roll of 30 yields miss, damage 0, log has ` — miss`.
|
||||
- `testAttackCritsOnRollAtOrAbove95` — roll 95 yields crit, damage doubled.
|
||||
- `testAttackForestDefenderRollsConcealment` — defender on forest, to-hit passes, concealment roll 10 yields ` — miss — concealed`.
|
||||
- `testAttackForestDefenderPassesConcealmentMostOfTheTime` — repeated roll sample, 80%+ hits through concealment.
|
||||
- `testAttackFlankingBonus` — ally in N/S/E/W of target reduces threshold by 15 (30 with 2+).
|
||||
- `testAttackDoesNotFlankFromDiagonal` — ally at a corner does not contribute.
|
||||
- `testAttackFlankingCapsAt30` — three allies give 30, not 45.
|
||||
- `testAttackDamageVariance` — repeated runs, damage falls within `[floor*0.85, ceil*1.15]`.
|
||||
- `testAttackThresholdFloorAt5` — very-low-attack vs very-high-defense never drops below 5.
|
||||
- `testAttackThresholdCeilingAt95` — very-high-attack vs very-low-defense never exceeds 95.
|
||||
|
||||
### Per-unit-state tests
|
||||
|
||||
- `tests/Unit/Domain/UnitStateTest.php` (existing, extended):
|
||||
- `testStartTurnResetsWadedFlag` — unit that waded has `wadedThisTurn = false` after `startTurn()`.
|
||||
- `testWithWadedSetsFlag` — `withWaded(true)` returns a unit with the flag set.
|
||||
- Round-trip via `ScenarioSerializer::unitToArray` / `unitFromArray` carries the flag.
|
||||
|
||||
### Per-terrain tests
|
||||
|
||||
- `tests/Unit/Domain/TerrainTest.php` (new):
|
||||
- `testWaterIsTraversableWithCost3` — `Water->movementCost() === 3`.
|
||||
- `testForestCostIs2` — `Forest->movementCost() === 2` (existing; regression).
|
||||
- `testWaterIsNotBlocking` — `Water !== Blocking`.
|
||||
- `testForestConcealmentConstantIs15` — a named constant `Forest::CONCEALMENT_MISS_CHANCE === 15`.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- `tests/Integration/PostMatchActionTest.php` (existing, extended):
|
||||
- `testAttackActionLogIncludesRollAndThreshold` — fresh attack, response body, assert `match.actionLog[-1]` matches `/^\S+ attacked \S+ \(rolled \d+ \/ needed \d+\) for \d+ damage/`.
|
||||
- `testAttackActionLogFlagsMiss` — when a miss happens (force via seeded test), log entry contains ` — miss`.
|
||||
- `testMoveIntoWaterEndsTurn` — fresh unit on dry tile, move to adjacent water tile, response carries `wadedThisTurn = true` and `actionsRemaining = 0`.
|
||||
- `testStartTurnResetsWadedFlag` — end turn, next turn the unit's `wadedThisTurn` is back to false.
|
||||
|
||||
### Scenario fixtures
|
||||
|
||||
- `public/assets/scenarios/last-stand.json` — unchanged on disk; the validator's water-traversable rule allows units to START on water, and the editor's water traversal works the same. No JSON edits needed.
|
||||
- `public/assets/scenarios/skirmish.json` — unchanged (no water in this scenario).
|
||||
- `public/assets/scenarios/hold-the-line.json` — unchanged.
|
||||
|
||||
### Manual usability check
|
||||
|
||||
A tester with no BattleForge context can:
|
||||
1. Open the dev URL, click Play bundled → Skirmish.
|
||||
2. Move a unit next to an enemy, click Attack, see the action log entry show `rolled X / needed Y for Z damage` (or ` — miss`).
|
||||
3. Move a unit into forest, defend with it, see ` — concealed` in the log when an attack misses.
|
||||
4. Move a unit into water, see the rest of the turn's actions disabled and the wading visual.
|
||||
5. Surround an enemy with two allies, see the threshold drop and more hits land.
|
||||
|
||||
## Release Boundary
|
||||
|
||||
Plan 5 is releasable when:
|
||||
|
||||
- Every in-scope capability above is implemented and the verification suite is green (PHPUnit, PHPStan, PHPCS, ESLint, Prettier).
|
||||
- A new playthrough of Skirmish and Hold the Line shows the action log entries in the documented format.
|
||||
- A playthrough of Last Stand (which has water tiles) shows the wade behavior.
|
||||
- The to-hit formula's constants (40, 3, 15, 5, 95) are documented as named constants and easy to retune.
|
||||
- The pre-existing PHPCS baseline (91 warnings across 18 files) is unchanged.
|
||||
Generated
+3284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "battleforge-battleforge",
|
||||
"description": "BattleForge frontend toolchain.",
|
||||
"private": true,
|
||||
"license": "proprietary",
|
||||
"scripts": {
|
||||
"lint": "eslint public/js",
|
||||
"format": "prettier --check public/js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"prettier": "^3.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="BattleForge">
|
||||
<description>BattleForge coding standards.</description>
|
||||
|
||||
<file>src</file>
|
||||
<file>tests</file>
|
||||
<file>public/index.php</file>
|
||||
|
||||
<arg name="colors"/>
|
||||
<arg value="sp"/>
|
||||
|
||||
<rule ref="PSR12"/>
|
||||
</ruleset>
|
||||
@@ -0,0 +1,9 @@
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
- public/index.php
|
||||
excludePaths:
|
||||
- src/Views/*
|
||||
tmpDir: var/phpstan
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
cacheDirectory="var/phpunit"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="integration">
|
||||
<directory>tests/Integration</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
|
||||
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
|
||||
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
|
||||
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 B |
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
Binary file not shown.
|
After Width: | Height: | Size: 133 B |
Binary file not shown.
|
After Width: | Height: | Size: 126 B |
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; font-family: sans-serif; }
|
||||
body { padding: 1rem; max-width: 64rem; margin: 0 auto; }
|
||||
fieldset { margin: 0 0 1rem 0; padding: 0.5rem 1rem; }
|
||||
legend { font-weight: bold; }
|
||||
label { display: block; margin: 0.25rem 0; }
|
||||
input, select, button, textarea { font: inherit; }
|
||||
input[type="number"] { width: 5rem; }
|
||||
table.bf-grid { border-collapse: collapse; }
|
||||
table.bf-grid td { padding: 0; }
|
||||
table.bf-grid button { width: 2rem; height: 2rem; border: 1px solid #ccc; background: #fff; }
|
||||
table.bf-grid button[data-paint="open"] { background: #fff; }
|
||||
table.bf-grid button[data-paint="forest"] { background: #cfc; }
|
||||
table.bf-grid button[data-paint="rough"] { background: #fec; }
|
||||
table.bf-grid button[data-paint="water"] { background: #cce; }
|
||||
table.bf-grid button[data-paint="blocking"] { background: #444; color: #fff; }
|
||||
table.bf-grid button[data-objective="1"] { outline: 3px solid #c00; }
|
||||
table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; }
|
||||
table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; }
|
||||
.bf-errors { color: #c00; }
|
||||
.bf-match { display: flex; gap: 1rem; }
|
||||
.bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; grid-template-columns: repeat(var(--bf-cols, 8), 2.5rem); }
|
||||
.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-unit--wading { opacity: 0.55; filter: blur(0.4px); }
|
||||
.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; }
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use BattleForge\Http\Response;
|
||||
use BattleForge\Http\Router;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// 1. Read the app secret.
|
||||
// The app secret reads as the CSRF/turn-token HMAC key. The name below
|
||||
// (kept for historical reasons) is just the variable label; the same
|
||||
// value is used for both the CSRF cookie HMAC and the turn-token HMAC.
|
||||
$csrfSecret = getenv('BATTLEFORGE_SECRET');
|
||||
if ($csrfSecret === false || $csrfSecret === '') {
|
||||
$secretFile = __DIR__ . '/../var/secret.key';
|
||||
if (!is_file($secretFile)) {
|
||||
if (!is_dir(dirname($secretFile))) {
|
||||
mkdir(dirname($secretFile), 0700, true);
|
||||
}
|
||||
file_put_contents($secretFile, random_bytes(32));
|
||||
chmod($secretFile, 0600);
|
||||
}
|
||||
$csrfSecret = file_get_contents($secretFile);
|
||||
if ($csrfSecret === false) {
|
||||
http_response_code(500);
|
||||
echo "Failed to read app secret.\n";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ensure the __csrf cookie is set. If absent OR stale, issue a fresh
|
||||
// token. A cookie pair is "stale" when both cookies are present but
|
||||
// the HMAC (__csrf) does not match the raw token (__csrf_token) under
|
||||
// the current secret. This can happen when a browser session predates
|
||||
// a server restart with a fresh secret.key: the browser still has the
|
||||
// old __csrf cookie, the new server's secret does not match it, and
|
||||
// every CSRF check fails. By re-minting on first request, we recover
|
||||
// the session without requiring the user to clear cookies manually.
|
||||
// The double-submit pattern stores the raw token in a separate
|
||||
// readable cookie so client code (meta tag, JS) can echo it back as
|
||||
// the X-CSRF-Token header. The HMAC cookie is the verification
|
||||
// oracle; the raw-token cookie rides parallel and is readable by
|
||||
// document.cookie or the equivalent server-side read.
|
||||
$cookies = $_COOKIE;
|
||||
$existingCookie = (string) ($cookies['__csrf'] ?? '');
|
||||
$existingRawToken = (string) ($cookies['__csrf_token'] ?? '');
|
||||
$pairIsFresh = false;
|
||||
if ($existingCookie !== '' && $existingRawToken !== '') {
|
||||
$expectedHmac = hash_hmac('sha256', $existingRawToken, $csrfSecret);
|
||||
$pairIsFresh = hash_equals($expectedHmac, $existingCookie);
|
||||
}
|
||||
if ($existingCookie === '' || $existingRawToken === '' || !$pairIsFresh) {
|
||||
[$token, $cookie] = CsrfToken::issue($csrfSecret);
|
||||
setcookie('__csrf', $cookie, [
|
||||
'expires' => time() + 86400,
|
||||
'path' => '/',
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
// The raw token rides in a readable cookie so server-side templates
|
||||
// can echo it into the <meta name="csrf-token"> tag without keeping
|
||||
// the raw token in `index.php` per-request state. HttpOnly off so the
|
||||
// server can read it via the request cookie jar; security comes from
|
||||
// the HMAC verification, not from hiding the raw token (which the
|
||||
// browser would already see in the meta tag anyway).
|
||||
setcookie('__csrf_token', $token, [
|
||||
'expires' => time() + 86400,
|
||||
'path' => '/',
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => false,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
$existingCookie = $cookie;
|
||||
$existingRawToken = $token;
|
||||
$cookies['__csrf'] = $cookie;
|
||||
$cookies['__csrf_token'] = $token;
|
||||
}
|
||||
|
||||
// 3. Compute the upload-endpoint user token (derived from the same secret).
|
||||
$uploadsToken = hash_hmac('sha256', $csrfSecret, 'bf-uploads');
|
||||
|
||||
// 4. Determine the request method, path, and content type.
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$queryString = (string) (parse_url($uri, PHP_URL_QUERY) ?? '');
|
||||
$contentType = isset($_SERVER['CONTENT_TYPE']) ? (string) $_SERVER['CONTENT_TYPE'] : null;
|
||||
if ($contentType !== null) {
|
||||
$semicolon = strpos($contentType, ';');
|
||||
if ($semicolon !== false) {
|
||||
$contentType = substr($contentType, 0, $semicolon);
|
||||
}
|
||||
$contentType = trim($contentType);
|
||||
}
|
||||
|
||||
// 5. Read the raw body for fetch POSTs that submit JSON.
|
||||
$rawBody = (string) ($_SERVER['__raw_body'] ?? file_get_contents('php://input'));
|
||||
|
||||
// 6. Build a Request with the request data, the CSRF secret, and the upload token.
|
||||
$server = $_SERVER;
|
||||
if (!isset($server['__csrf_secret'])) {
|
||||
$server['__csrf_secret'] = $csrfSecret;
|
||||
}
|
||||
$server['__uploads_token'] = $uploadsToken;
|
||||
|
||||
$request = new Request(
|
||||
get: $_GET,
|
||||
post: $_POST,
|
||||
files: $_FILES,
|
||||
cookies: $cookies,
|
||||
server: $server,
|
||||
queryString: $queryString,
|
||||
method: $method,
|
||||
path: $path,
|
||||
contentType: $contentType,
|
||||
rawBody: $rawBody,
|
||||
);
|
||||
|
||||
// 7. Configure the router.
|
||||
$uploadsRoot = __DIR__ . '/../var/uploads';
|
||||
$placeholderDir = __DIR__ . '/assets/placeholders';
|
||||
|
||||
$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);
|
||||
$postBattlefield = static fn (Request $r, array $p): Response => (new PostBattlefieldEditor())->handle($r, $p);
|
||||
$postStart = static fn (Request $r, array $p): Response => (new PostStartMatch())->handle($r, $p);
|
||||
$postUpload = static fn (Request $r, array $p): Response => (new PostImageUpload($uploadsRoot))->handle($r, $p);
|
||||
$getAssets = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
|
||||
return (new GetAssets($placeholderDir, $uploadsRoot))->handle($r, $p);
|
||||
};
|
||||
$getUploadedAsset = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
|
||||
$handler = new GetAssets($placeholderDir, $uploadsRoot);
|
||||
return $handler->handle(
|
||||
$r,
|
||||
['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);
|
||||
$postMatchMove = static function (Request $r, array $p) use ($csrfSecret): Response {
|
||||
return (new PostMatchMove($csrfSecret))->handle($r, $p);
|
||||
};
|
||||
$postMatchAttack = static function (Request $r, array $p) use ($csrfSecret): Response {
|
||||
return (new PostMatchAttack($csrfSecret))->handle($r, $p);
|
||||
};
|
||||
$postMatchAbility = static function (Request $r, array $p) use ($csrfSecret): Response {
|
||||
return (new PostMatchAbility($csrfSecret))->handle($r, $p);
|
||||
};
|
||||
$postMatchEndTurn = static function (Request $r, array $p) use ($csrfSecret): Response {
|
||||
return (new PostMatchEndTurn($csrfSecret))->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);
|
||||
$router->add('POST', '/scenarios/{id}/edit/battlefield', $postBattlefield);
|
||||
$router->add('POST', '/scenarios/{id}/start', $postStart);
|
||||
$router->add('POST', '/assets/upload', $postUpload);
|
||||
$router->add('GET', '/assets/uploads/{userToken}/{filename}', $getUploadedAsset);
|
||||
$router->add('GET', '/assets/{kind}/{filename}', $getAssets);
|
||||
|
||||
// 8. Dispatch and emit.
|
||||
$response = $router->dispatch($request);
|
||||
|
||||
http_response_code($response->status);
|
||||
foreach ($response->headers as $name => $value) {
|
||||
header($name . ': ' . $value);
|
||||
}
|
||||
echo $response->body;
|
||||
|
||||
return $response->status;
|
||||
@@ -0,0 +1,266 @@
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function getCurrentMode() {
|
||||
const checked = document.querySelector('input[name="zoneMode"]:checked');
|
||||
return checked ? checked.value : 'terrain';
|
||||
}
|
||||
|
||||
function getSelectedPaint() {
|
||||
const active = document.querySelector('.bf-paint.active');
|
||||
if (active) {
|
||||
return active.getAttribute('data-paint');
|
||||
}
|
||||
return 'forest';
|
||||
}
|
||||
|
||||
function loadScenario(id) {
|
||||
if (window.bfStorage && typeof window.bfStorage.load === 'function') {
|
||||
return window.bfStorage.load(id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveScenario(id, scenario) {
|
||||
if (window.bfStorage && typeof window.bfStorage.save === 'function') {
|
||||
window.bfStorage.save(id, scenario);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTile(button, paint, zone, isObjective) {
|
||||
button.setAttribute('data-paint', paint);
|
||||
if (zone) {
|
||||
button.setAttribute('data-zone', zone);
|
||||
} else {
|
||||
button.removeAttribute('data-zone');
|
||||
}
|
||||
if (isObjective) {
|
||||
button.setAttribute('data-objective', '1');
|
||||
} else {
|
||||
button.removeAttribute('data-objective');
|
||||
}
|
||||
}
|
||||
|
||||
function loadGrid(tileMap, deploymentZones, objective) {
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
const x = button.getAttribute('data-x');
|
||||
const y = button.getAttribute('data-y');
|
||||
const key = `${x}:${y}`;
|
||||
if (tileMap[key]) {
|
||||
button.setAttribute('data-paint', tileMap[key]);
|
||||
}
|
||||
button.removeAttribute('data-zone');
|
||||
button.removeAttribute('data-objective');
|
||||
});
|
||||
Object.entries(deploymentZones).forEach(([team, positions]) => {
|
||||
positions.forEach((pos) => {
|
||||
const selector = `.bf-tile[data-x="${pos.x}"][data-y="${pos.y}"]`;
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
button.setAttribute('data-zone', team);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (objective) {
|
||||
const selector = `.bf-tile[data-x="${objective.x}"][data-y="${objective.y}"]`;
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
button.setAttribute('data-objective', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readGrid() {
|
||||
const tileMap = {};
|
||||
const deploymentZones = { alpha: [], bravo: [] };
|
||||
let objective = null;
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
const x = Number(button.getAttribute('data-x'));
|
||||
const y = Number(button.getAttribute('data-y'));
|
||||
const paint = button.getAttribute('data-paint');
|
||||
if (paint && paint !== 'open') {
|
||||
tileMap[`${x}:${y}`] = paint;
|
||||
}
|
||||
const zone = button.getAttribute('data-zone');
|
||||
if (zone === 'alpha' || zone === 'bravo') {
|
||||
deploymentZones[zone].push({ x, y });
|
||||
}
|
||||
if (button.getAttribute('data-objective') === '1') {
|
||||
objective = { x, y };
|
||||
}
|
||||
});
|
||||
return { tileMap, deploymentZones, objective };
|
||||
}
|
||||
|
||||
function buildScenarioJson(scenarioId, tileMap, deploymentZones, objective) {
|
||||
const widthInput = document.querySelector('input[name="battlefieldWidth"]');
|
||||
const heightInput = document.querySelector('input[name="battlefieldHeight"]');
|
||||
const result = {
|
||||
id: scenarioId,
|
||||
name: scenarioId,
|
||||
battlefieldWidth: widthInput ? Number(widthInput.value) : 8,
|
||||
battlefieldHeight: heightInput ? Number(heightInput.value) : 8,
|
||||
battlefieldTerrain: tileMap,
|
||||
teamA: {
|
||||
units: [],
|
||||
},
|
||||
teamB: {
|
||||
units: [],
|
||||
},
|
||||
deploymentZones,
|
||||
objectives: {},
|
||||
victoryCondition: 'eliminate_all',
|
||||
holdRoundsRequired: 1,
|
||||
};
|
||||
if (objective) {
|
||||
result.victoryCondition = 'hold_objective';
|
||||
result.holdRoundsRequired = 3;
|
||||
result.objectiveId = 'objective-1';
|
||||
result.objectiveX = objective.x;
|
||||
result.objectiveY = objective.y;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function paintTile(button, ev) {
|
||||
const mode = getCurrentMode();
|
||||
if (ev.shiftKey) {
|
||||
applyTile(button, 'open', null, false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'objective') {
|
||||
document.querySelectorAll('.bf-tile[data-objective]').forEach((other) => {
|
||||
other.removeAttribute('data-objective');
|
||||
});
|
||||
applyTile(button, 'open', null, true);
|
||||
return;
|
||||
}
|
||||
if (mode === 'alpha' || mode === 'bravo') {
|
||||
const current = button.getAttribute('data-zone');
|
||||
if (current === mode) {
|
||||
button.removeAttribute('data-zone');
|
||||
} else {
|
||||
button.setAttribute('data-zone', mode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
applyTile(button, getSelectedPaint(), null, false);
|
||||
}
|
||||
|
||||
function selectPalette(event) {
|
||||
document.querySelectorAll('.bf-paint').forEach((b) => b.classList.remove('active'));
|
||||
event.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'bf-toast';
|
||||
toast.appendChild(document.createTextNode(message));
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function showErrors(errors) {
|
||||
const existing = document.querySelector('.bf-errors');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bf-errors';
|
||||
const ul = document.createElement('ul');
|
||||
errors.forEach((msg) => {
|
||||
const li = document.createElement('li');
|
||||
li.appendChild(document.createTextNode(msg));
|
||||
ul.appendChild(li);
|
||||
});
|
||||
div.appendChild(ul);
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (form) {
|
||||
form.parentNode.insertBefore(div, form);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = form.getAttribute('data-scenario-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const { tileMap, deploymentZones, objective } = readGrid();
|
||||
const scenario = buildScenarioJson(id, tileMap, deploymentZones, objective);
|
||||
const csrf = getCsrfToken();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/scenarios/${encodeURIComponent(id)}/edit/battlefield`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(scenario),
|
||||
});
|
||||
} catch (e) {
|
||||
showErrors([`Network error: ${e.message}`]);
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.scenario) {
|
||||
saveScenario(id, data.scenario);
|
||||
showToast('Saved.');
|
||||
} else {
|
||||
showErrors([(data.errors || ['Unknown error']).join(', ')]);
|
||||
}
|
||||
} else if (res.status === 400) {
|
||||
let data = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
// body wasn't JSON
|
||||
}
|
||||
showErrors(data.errors || ['Validation failed (HTTP 400).']);
|
||||
} else {
|
||||
showErrors([`Save failed: HTTP ${res.status}`]);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('battlefield-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = form.getAttribute('data-scenario-id');
|
||||
if (id) {
|
||||
const scenario = loadScenario(id);
|
||||
if (scenario) {
|
||||
const battlefield = scenario.battlefield || {};
|
||||
const terrain = battlefield.terrain || {};
|
||||
const rawZones = scenario.deploymentZones || {};
|
||||
const deploymentZones = { alpha: [], bravo: [] };
|
||||
['alpha', 'bravo'].forEach((team) => {
|
||||
if (rawZones[team] && Array.isArray(rawZones[team].positions)) {
|
||||
deploymentZones[team] = rawZones[team].positions;
|
||||
}
|
||||
});
|
||||
const objectiveMarker = scenario.objectives ? scenario.objectives['objective-1'] : null;
|
||||
const objective = objectiveMarker ? objectiveMarker.position : null;
|
||||
loadGrid(terrain, deploymentZones, objective);
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.bf-tile').forEach((button) => {
|
||||
button.addEventListener('click', (ev) => paintTile(button, ev));
|
||||
});
|
||||
document.querySelectorAll('.bf-paint').forEach((button) => {
|
||||
button.addEventListener('click', selectPalette);
|
||||
});
|
||||
if (document.querySelector('.bf-paint')) {
|
||||
document.querySelector('.bf-paint').classList.add('active');
|
||||
}
|
||||
form.addEventListener('submit', save);
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
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));
|
||||
// Set the column count via a CSS variable so the grid-template-columns
|
||||
// declaration can read it portably (CSS attr() for non-content properties
|
||||
// is not yet supported in Safari or Firefox as of mid-2026).
|
||||
root.style.setProperty('--bf-cols', String(width));
|
||||
|
||||
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');
|
||||
if (unit.wadedThisTurn) cls.push('bf-unit--wading');
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
const STORAGE_PREFIX = 'scenario:';
|
||||
const MATCH_KEY = 'match:current';
|
||||
|
||||
function load(id) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + id);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function save(id, scenario) {
|
||||
const stamped = { ...scenario, lastModified: Date.now() };
|
||||
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(stamped));
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
localStorage.removeItem(STORAGE_PREFIX + id);
|
||||
}
|
||||
|
||||
function listAll() {
|
||||
const items = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null && key.startsWith(STORAGE_PREFIX)) {
|
||||
const id = key.slice(STORAGE_PREFIX.length);
|
||||
const scenario = load(id);
|
||||
if (scenario !== null) {
|
||||
items.push({
|
||||
id,
|
||||
name: typeof scenario.name === 'string' ? scenario.name : id,
|
||||
lastModified: typeof scenario.lastModified === 'number' ? scenario.lastModified : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.lastModified - a.lastModified);
|
||||
return items;
|
||||
}
|
||||
|
||||
function currentMatch() {
|
||||
try {
|
||||
const raw = localStorage.getItem(MATCH_KEY);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentMatch(match) {
|
||||
localStorage.setItem(MATCH_KEY, JSON.stringify(match));
|
||||
}
|
||||
|
||||
function removeCurrentMatch() {
|
||||
localStorage.removeItem(MATCH_KEY);
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
function parsePath(name) {
|
||||
const match = name.match(/^(teamA|teamB)\[units\]\[(\d+)\]\[(\w+)\]$/);
|
||||
if (match) {
|
||||
const [, team, indexStr, key] = match;
|
||||
const teamKey = team === 'teamA' ? 'teamA' : 'teamB';
|
||||
return [teamKey, 'units', Number(indexStr), key];
|
||||
}
|
||||
const simple = [
|
||||
'id',
|
||||
'name',
|
||||
'battlefieldWidth',
|
||||
'battlefieldHeight',
|
||||
'victoryCondition',
|
||||
'holdRoundsRequired',
|
||||
];
|
||||
if (simple.includes(name)) {
|
||||
return [name];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveField(scenario, path) {
|
||||
const parts = parsePath(path);
|
||||
if (parts === null) {
|
||||
return undefined;
|
||||
}
|
||||
let cursor = scenario;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
if (cursor === null || cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
cursor = cursor[parts[i]];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function populateRecentScenarios() {
|
||||
const root = document.getElementById('recent');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
while (root.firstChild) {
|
||||
root.removeChild(root.firstChild);
|
||||
}
|
||||
const items = listAll();
|
||||
if (items.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'bf-empty';
|
||||
p.appendChild(document.createTextNode('No saved scenarios yet.'));
|
||||
root.appendChild(p);
|
||||
return;
|
||||
}
|
||||
const ul = document.createElement('ul');
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', `/scenarios/${encodeURIComponent(item.id)}/edit/team`);
|
||||
a.appendChild(document.createTextNode(`${item.name} (${item.id})`));
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
root.appendChild(ul);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async function fetchArchetypes() {
|
||||
const res = await fetch('/assets/archetypes.json');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function prefillTeamEditor() {
|
||||
const form = document.querySelector('form[action*="/edit/team"]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-3, -2)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const fields = form.querySelectorAll('input[name], select[name]');
|
||||
fields.forEach((field) => {
|
||||
const name = field.getAttribute('name');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const value = resolveField(scenario, name);
|
||||
if (value !== undefined) {
|
||||
Object.assign(field, { value: String(value) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function startMatch() {
|
||||
const id = decodeURIComponent(window.location.pathname.split('/').slice(-3, -2)[0] || 'new');
|
||||
if (id === 'new') {
|
||||
return;
|
||||
}
|
||||
const scenario = load(id);
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
const csrf = getCsrfToken();
|
||||
const res = await fetch(`/scenarios/${encodeURIComponent(id)}/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(scenario),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Start match failed: HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.match) {
|
||||
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,
|
||||
delete: remove,
|
||||
listAll,
|
||||
currentMatch,
|
||||
setCurrentMatch,
|
||||
removeCurrentMatch,
|
||||
startBundledMatch,
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRecentScenarios();
|
||||
prefillTeamEditor();
|
||||
const startBtn = document.getElementById('bf-start-match');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
startMatch();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-bf-bundle]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-bf-bundle');
|
||||
if (id) {
|
||||
startBundledMatch(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final class ImageUploadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $userToken,
|
||||
private readonly string $uploadsRoot,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(string $tempPath, string $declaredMime): string
|
||||
{
|
||||
$validated = ImageValidator::validate($tempPath, $declaredMime);
|
||||
|
||||
$namespace = $this->uploadsRoot . '/' . $this->userToken;
|
||||
if (!is_dir($namespace)) {
|
||||
mkdir($namespace, 0700, true);
|
||||
}
|
||||
|
||||
$hash = bin2hex(random_bytes(16));
|
||||
$filename = $hash . '.' . $validated->extension;
|
||||
$destination = $namespace . '/' . $filename;
|
||||
|
||||
if (!rename($tempPath, $destination)) {
|
||||
throw new InvalidImageException("Could not move upload to {$destination}.");
|
||||
}
|
||||
|
||||
// Lock the file down: the namespace is already 0700; tighten the
|
||||
// file itself to 0600 in case the server's umask is permissive.
|
||||
chmod($destination, 0600);
|
||||
|
||||
return '/assets/uploads/' . $this->userToken . '/' . $filename;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final class ImageValidator
|
||||
{
|
||||
/**
|
||||
* @return array{string, string} [canonicalMime, extension] for a recognized image, or null.
|
||||
*/
|
||||
private static function detect(string $header): ?array
|
||||
{
|
||||
if (substr($header, 0, 8) === "\x89PNG\r\n\x1a\n") {
|
||||
return ['image/png', 'png'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 3) === "\xff\xd8\xff") {
|
||||
return ['image/jpeg', 'jpg'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 6) === 'GIF87a' || substr($header, 0, 6) === 'GIF89a') {
|
||||
return ['image/gif', 'gif'];
|
||||
}
|
||||
|
||||
if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') {
|
||||
return ['image/webp', 'webp'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function validate(
|
||||
string $tempPath,
|
||||
string $declaredMime,
|
||||
int $maxBytes = 2_000_000,
|
||||
int $maxDimension = 512,
|
||||
): ValidatedImage {
|
||||
if (!is_file($tempPath)) {
|
||||
throw new InvalidImageException('Upload is not a file.');
|
||||
}
|
||||
|
||||
$size = filesize($tempPath);
|
||||
if ($size === false || $size > $maxBytes) {
|
||||
throw new InvalidImageException("File exceeds the {$maxBytes} byte limit.");
|
||||
}
|
||||
|
||||
$handle = fopen($tempPath, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new InvalidImageException('Could not read upload.');
|
||||
}
|
||||
$header = fread($handle, 12);
|
||||
fclose($handle);
|
||||
if ($header === false || strlen($header) < 12) {
|
||||
throw new InvalidImageException('Upload is too small to be an image.');
|
||||
}
|
||||
|
||||
$detected = self::detect($header);
|
||||
if ($detected === null) {
|
||||
throw new InvalidImageException('File is not a supported image type.');
|
||||
}
|
||||
|
||||
[$canonical, $extension] = $detected;
|
||||
|
||||
if ($declaredMime !== $canonical) {
|
||||
throw new InvalidImageException('Declared MIME does not match file contents.');
|
||||
}
|
||||
|
||||
$info = getimagesize($tempPath);
|
||||
if ($info === false) {
|
||||
throw new InvalidImageException('Image is corrupt or unreadable.');
|
||||
}
|
||||
|
||||
[$width, $height] = $info;
|
||||
if ($width > $maxDimension || $height > $maxDimension) {
|
||||
throw new InvalidImageException("Image dimensions exceed the {$maxDimension} pixel limit.");
|
||||
}
|
||||
|
||||
return new ValidatedImage($canonical, $extension);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InvalidImageException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\ObjectiveMarker;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ScenarioDraft
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param array<string, DeploymentZone> $deploymentZones
|
||||
* @param array<string, ObjectiveMarker> $objectives
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public Battlefield $battlefield,
|
||||
public array $units,
|
||||
public array $deploymentZones,
|
||||
public array $objectives,
|
||||
public VictoryCondition $victoryCondition,
|
||||
public int $holdRoundsRequired,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toScenario(): Scenario
|
||||
{
|
||||
return new Scenario(
|
||||
id: $this->id,
|
||||
name: $this->name,
|
||||
battlefield: $this->battlefield,
|
||||
units: $this->units,
|
||||
deploymentZones: $this->deploymentZones,
|
||||
objectives: $this->objectives,
|
||||
victoryCondition: $this->victoryCondition,
|
||||
holdRoundsRequired: $this->holdRoundsRequired,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $post */
|
||||
public static function fromPost(array $post): self
|
||||
{
|
||||
$id = self::stringField($post, 'id');
|
||||
$name = self::stringField($post, 'name');
|
||||
$width = self::intField($post, 'battlefieldWidth');
|
||||
$height = self::intField($post, 'battlefieldHeight');
|
||||
|
||||
$terrain = [];
|
||||
foreach (($post['battlefieldTerrain'] ?? []) as $key => $value) {
|
||||
$terrain[(string) $key] = self::terrainField((string) $value);
|
||||
}
|
||||
$battlefield = new Battlefield($width, $height, $terrain);
|
||||
|
||||
$alphaUnits = self::parseTeamUnits($post['teamA']['units'] ?? [], 'alpha');
|
||||
$bravoUnits = self::parseTeamUnits($post['teamB']['units'] ?? [], 'bravo');
|
||||
$units = [...$alphaUnits, ...$bravoUnits];
|
||||
|
||||
// The form does not yet expose a "deployment zone" picker (3b adds it).
|
||||
// For 3a we synthesize one zone per team containing the unit positions.
|
||||
// When 3b lands, the editor's POST will include explicit zone tiles.
|
||||
$deploymentZones = [
|
||||
'alpha' => new DeploymentZone('alpha', self::collectPositions($alphaUnits)),
|
||||
'bravo' => new DeploymentZone('bravo', self::collectPositions($bravoUnits)),
|
||||
];
|
||||
|
||||
$victory = self::victoryField(self::stringField($post, 'victoryCondition'));
|
||||
$holdRounds = self::intField($post, 'holdRoundsRequired');
|
||||
|
||||
$objectives = [];
|
||||
if ($victory === VictoryCondition::HoldObjective) {
|
||||
$objId = self::stringField($post, 'objectiveId');
|
||||
$objX = self::intField($post, 'objectiveX');
|
||||
$objY = self::intField($post, 'objectiveY');
|
||||
$objectives[$objId] = new ObjectiveMarker($objId, new Position($objX, $objY));
|
||||
}
|
||||
|
||||
return new self(
|
||||
id: $id,
|
||||
name: $name,
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
deploymentZones: $deploymentZones,
|
||||
objectives: $objectives,
|
||||
victoryCondition: $victory,
|
||||
holdRoundsRequired: $holdRounds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return list<UnitState>
|
||||
*/
|
||||
private static function parseTeamUnits(array $rows, string $teamId): array
|
||||
{
|
||||
$units = [];
|
||||
foreach ($rows as $index => $row) {
|
||||
if (!is_array($row)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 3.)
|
||||
throw new InvalidArgumentException("Team {$teamId} unit row {$index} is malformed.");
|
||||
}
|
||||
|
||||
$unitId = self::stringField($row, 'id');
|
||||
$archetype = self::archetypeField(self::stringField($row, 'archetype'));
|
||||
$maxHealth = self::intField($row, 'maxHealth');
|
||||
$attack = self::intField($row, 'attack');
|
||||
$defense = self::intField($row, 'defense');
|
||||
$speed = self::intField($row, 'speed');
|
||||
$abilities = [];
|
||||
foreach (($row['abilities'] ?? []) as $ability) {
|
||||
$abilities[] = (string) $ability;
|
||||
}
|
||||
$position = new Position(
|
||||
self::intField($row, 'x'),
|
||||
self::intField($row, 'y'),
|
||||
);
|
||||
|
||||
$units[] = new UnitState(
|
||||
id: $unitId,
|
||||
teamId: $teamId,
|
||||
position: $position,
|
||||
maxHealth: $maxHealth,
|
||||
health: $maxHealth,
|
||||
attack: $attack,
|
||||
defense: $defense,
|
||||
speed: $speed,
|
||||
actionsRemaining: 2,
|
||||
hasAttacked: false,
|
||||
archetype: $archetype,
|
||||
abilities: $abilities,
|
||||
attackBonus: 0,
|
||||
hasUsedAbility: false,
|
||||
);
|
||||
}
|
||||
|
||||
return $units;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @return list<Position>
|
||||
*/
|
||||
private static function collectPositions(array $units): array
|
||||
{
|
||||
$positions = [];
|
||||
foreach ($units as $unit) {
|
||||
$positions[] = $unit->position;
|
||||
}
|
||||
|
||||
return $positions;
|
||||
}
|
||||
|
||||
private static function archetypeField(string $value): Archetype
|
||||
{
|
||||
$archetype = Archetype::tryFrom($value);
|
||||
if ($archetype === null) {
|
||||
throw new InvalidArgumentException("Unknown archetype: {$value}.");
|
||||
}
|
||||
|
||||
return $archetype;
|
||||
}
|
||||
|
||||
private static function terrainField(string $value): Terrain
|
||||
{
|
||||
$terrain = Terrain::tryFrom($value);
|
||||
if ($terrain === null) {
|
||||
throw new InvalidArgumentException("Unknown terrain: {$value}.");
|
||||
}
|
||||
|
||||
return $terrain;
|
||||
}
|
||||
|
||||
private static function victoryField(string $value): VictoryCondition
|
||||
{
|
||||
$victory = VictoryCondition::tryFrom($value);
|
||||
if ($victory === null) {
|
||||
throw new InvalidArgumentException("Unknown victory condition: {$value}.");
|
||||
}
|
||||
|
||||
return $victory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $array
|
||||
*/
|
||||
private static function stringField(array $array, string $key): string
|
||||
{
|
||||
if (!isset($array[$key]) || !is_string($array[$key]) || $array[$key] === '') {
|
||||
throw new InvalidArgumentException("Field '{$key}' is required.");
|
||||
}
|
||||
|
||||
return $array[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $array
|
||||
*/
|
||||
private static function intField(array $array, string $key): int
|
||||
{
|
||||
if (!isset($array[$key])) {
|
||||
throw new InvalidArgumentException("Field '{$key}' is required.");
|
||||
}
|
||||
|
||||
return (int) $array[$key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\ArchetypeCatalog;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\MatchState;
|
||||
use BattleForge\Domain\ObjectiveMarker;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ScenarioSerializer
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function scenarioToArray(Scenario $scenario): array
|
||||
{
|
||||
$terrainMap = [];
|
||||
|
||||
for ($y = 0; $y < $scenario->battlefield->height; $y++) {
|
||||
for ($x = 0; $x < $scenario->battlefield->width; $x++) {
|
||||
$position = new Position($x, $y);
|
||||
$tile = $scenario->battlefield->terrainAt($position);
|
||||
if ($tile === Terrain::Open) {
|
||||
continue;
|
||||
}
|
||||
$terrainMap[$position->key()] = $tile->value;
|
||||
}
|
||||
}
|
||||
|
||||
$units = [];
|
||||
foreach ($scenario->units as $unit) {
|
||||
$units[] = self::unitToArray($unit);
|
||||
}
|
||||
|
||||
$deploymentZones = [];
|
||||
foreach ($scenario->deploymentZones as $teamId => $zone) {
|
||||
$deploymentZones[$teamId] = self::deploymentZoneToArray($zone);
|
||||
}
|
||||
|
||||
$objectives = [];
|
||||
foreach ($scenario->objectives as $id => $objective) {
|
||||
$objectives[$id] = ['id' => $objective->id, 'position' => self::positionToArray($objective->position)];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $scenario->id,
|
||||
'name' => $scenario->name,
|
||||
'battlefield' => [
|
||||
'width' => $scenario->battlefield->width,
|
||||
'height' => $scenario->battlefield->height,
|
||||
'terrain' => $terrainMap,
|
||||
],
|
||||
'units' => $units,
|
||||
'deploymentZones' => $deploymentZones,
|
||||
'objectives' => $objectives,
|
||||
'victoryCondition' => $scenario->victoryCondition->value,
|
||||
'holdRoundsRequired' => $scenario->holdRoundsRequired,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public static function scenarioFromArray(array $data): Scenario
|
||||
{
|
||||
$terrainMap = [];
|
||||
foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) {
|
||||
$terrainMap[(string) $key] = Terrain::from((string) $value);
|
||||
}
|
||||
|
||||
$battlefield = new Battlefield(
|
||||
(int) $data['battlefield']['width'],
|
||||
(int) $data['battlefield']['height'],
|
||||
$terrainMap,
|
||||
);
|
||||
|
||||
$units = [];
|
||||
foreach (($data['units'] ?? []) as $row) {
|
||||
$units[] = self::unitFromArray($row);
|
||||
}
|
||||
|
||||
$deploymentZones = [];
|
||||
foreach (($data['deploymentZones'] ?? []) as $teamId => $row) {
|
||||
$deploymentZones[(string) $teamId] = self::deploymentZoneFromArray($row);
|
||||
}
|
||||
|
||||
$objectives = [];
|
||||
foreach (($data['objectives'] ?? []) as $id => $row) {
|
||||
$objectives[(string) $id] = new ObjectiveMarker(
|
||||
(string) $row['id'],
|
||||
self::positionFromArray($row['position']),
|
||||
);
|
||||
}
|
||||
|
||||
return new Scenario(
|
||||
id: (string) $data['id'],
|
||||
name: (string) $data['name'],
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
deploymentZones: $deploymentZones,
|
||||
objectives: $objectives,
|
||||
victoryCondition: VictoryCondition::from((string) $data['victoryCondition']),
|
||||
holdRoundsRequired: (int) $data['holdRoundsRequired'],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function matchToArray(MatchState $match): array
|
||||
{
|
||||
$terrainMap = [];
|
||||
for ($y = 0; $y < $match->battlefield->height; $y++) {
|
||||
for ($x = 0; $x < $match->battlefield->width; $x++) {
|
||||
$position = new Position($x, $y);
|
||||
$tile = $match->battlefield->terrainAt($position);
|
||||
if ($tile === Terrain::Open) {
|
||||
continue;
|
||||
}
|
||||
$terrainMap[$position->key()] = $tile->value;
|
||||
}
|
||||
}
|
||||
|
||||
$units = [];
|
||||
foreach ($match->units as $unit) {
|
||||
$units[] = self::unitToArray($unit);
|
||||
}
|
||||
|
||||
$objectives = [];
|
||||
foreach ($match->objectives as $id => $objective) {
|
||||
$objectives[(string) $id] = [
|
||||
'id' => $objective->id,
|
||||
'position' => self::positionToArray($objective->position),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'battlefield' => [
|
||||
'width' => $match->battlefield->width,
|
||||
'height' => $match->battlefield->height,
|
||||
'terrain' => $terrainMap,
|
||||
],
|
||||
'units' => $units,
|
||||
'activeTeamId' => $match->activeTeamId,
|
||||
'round' => $match->round,
|
||||
'winnerTeamId' => $match->winnerTeamId,
|
||||
'actionLog' => $match->actionLog,
|
||||
'objectives' => $objectives,
|
||||
'victoryCondition' => $match->victoryCondition->value,
|
||||
'holdRoundsRequired' => $match->holdRoundsRequired,
|
||||
'objectiveControl' => $match->objectiveControl,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public static function matchFromArray(array $data): MatchState
|
||||
{
|
||||
$terrainMap = [];
|
||||
foreach (($data['battlefield']['terrain'] ?? []) as $key => $value) {
|
||||
$terrainMap[(string) $key] = Terrain::from((string) $value);
|
||||
}
|
||||
|
||||
$battlefield = new Battlefield(
|
||||
(int) $data['battlefield']['width'],
|
||||
(int) $data['battlefield']['height'],
|
||||
$terrainMap,
|
||||
);
|
||||
|
||||
$units = [];
|
||||
foreach (($data['units'] ?? []) as $row) {
|
||||
$units[] = self::unitFromArray($row);
|
||||
}
|
||||
|
||||
$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,
|
||||
activeTeamId: (string) $data['activeTeamId'],
|
||||
round: (int) $data['round'],
|
||||
winnerTeamId: $data['winnerTeamId'] ?? null,
|
||||
actionLog: array_map(static fn (mixed $entry): string => (string) $entry, $data['actionLog'] ?? []),
|
||||
objectives: $objectives,
|
||||
victoryCondition: VictoryCondition::from((string) $data['victoryCondition']),
|
||||
holdRoundsRequired: (int) $data['holdRoundsRequired'],
|
||||
objectiveControl: $data['objectiveControl'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function unitToArray(UnitState $unit): array
|
||||
{
|
||||
return [
|
||||
'id' => $unit->id,
|
||||
'teamId' => $unit->teamId,
|
||||
'position' => self::positionToArray($unit->position),
|
||||
'maxHealth' => $unit->maxHealth,
|
||||
'health' => $unit->health,
|
||||
'attack' => $unit->attack,
|
||||
'defense' => $unit->defense,
|
||||
'speed' => $unit->speed,
|
||||
'actionsRemaining' => $unit->actionsRemaining,
|
||||
'hasAttacked' => $unit->hasAttacked,
|
||||
'archetype' => $unit->archetype->value,
|
||||
'abilities' => $unit->abilities,
|
||||
'wadedThisTurn' => $unit->wadedThisTurn,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private static function unitFromArray(array $row): UnitState
|
||||
{
|
||||
$archetype = Archetype::from((string) $row['archetype']);
|
||||
// Validate the ability allowlist against the catalog before constructing,
|
||||
// because the runtime guard in UnitState only catches non-string entries.
|
||||
$template = ArchetypeCatalog::templates()[$archetype->value] ?? null;
|
||||
if ($template !== null) {
|
||||
$abilities = array_map(static fn (mixed $a): string => (string) $a, $row['abilities'] ?? []);
|
||||
foreach ($abilities as $ability) {
|
||||
if (!in_array($ability, $template->allowedAbilities, true)) {
|
||||
throw new InvalidArgumentException("Ability {$ability} is not in archetype {$archetype->value}'s allowlist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new UnitState(
|
||||
id: (string) $row['id'],
|
||||
teamId: (string) $row['teamId'],
|
||||
position: self::positionFromArray($row['position']),
|
||||
maxHealth: (int) $row['maxHealth'],
|
||||
health: (int) $row['health'],
|
||||
attack: (int) $row['attack'],
|
||||
defense: (int) $row['defense'],
|
||||
speed: (int) $row['speed'],
|
||||
actionsRemaining: (int) ($row['actionsRemaining'] ?? 2),
|
||||
hasAttacked: (bool) ($row['hasAttacked'] ?? false),
|
||||
archetype: $archetype,
|
||||
abilities: $abilities ?? [],
|
||||
attackBonus: 0,
|
||||
hasUsedAbility: false,
|
||||
wadedThisTurn: (bool) ($row['wadedThisTurn'] ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function positionToArray(Position $position): array
|
||||
{
|
||||
return ['x' => $position->x, 'y' => $position->y];
|
||||
}
|
||||
|
||||
private static function positionFromArray(mixed $row): Position
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('Expected position to be an array.');
|
||||
}
|
||||
|
||||
return new Position((int) $row['x'], (int) $row['y']);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function deploymentZoneToArray(DeploymentZone $zone): array
|
||||
{
|
||||
$positions = [];
|
||||
foreach ($zone->positions as $position) {
|
||||
$positions[] = self::positionToArray($position);
|
||||
}
|
||||
|
||||
return ['teamId' => $zone->teamId, 'positions' => $positions];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private static function deploymentZoneFromArray(array $row): DeploymentZone
|
||||
{
|
||||
$positions = [];
|
||||
foreach (($row['positions'] ?? []) as $positionRow) {
|
||||
$positions[] = self::positionFromArray($positionRow);
|
||||
}
|
||||
|
||||
return new DeploymentZone((string) $row['teamId'], $positions);
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Application;
|
||||
|
||||
final readonly class ValidatedImage
|
||||
{
|
||||
public function __construct(
|
||||
public string $canonicalMime,
|
||||
public string $extension,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class AbilityCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, AbilityDefinition>
|
||||
*/
|
||||
public static function definitions(): array
|
||||
{
|
||||
static $cache = null;
|
||||
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$cache = [
|
||||
AbilityId::Heal->value => new AbilityDefinition(
|
||||
id: AbilityId::Heal,
|
||||
label: 'Heal',
|
||||
range: 2,
|
||||
areaRadius: 0,
|
||||
target: AbilityDefinition::TARGET_ALLY,
|
||||
effect: AbilityDefinition::EFFECT_RESTORE_HEALTH,
|
||||
effectValue: 5,
|
||||
),
|
||||
AbilityId::AreaDamage->value => new AbilityDefinition(
|
||||
id: AbilityId::AreaDamage,
|
||||
label: 'Area Damage',
|
||||
range: 3,
|
||||
areaRadius: 1,
|
||||
target: AbilityDefinition::TARGET_TILE,
|
||||
effect: AbilityDefinition::EFFECT_DEAL_DAMAGE,
|
||||
effectValue: 3,
|
||||
),
|
||||
AbilityId::Buff->value => new AbilityDefinition(
|
||||
id: AbilityId::Buff,
|
||||
label: 'Rally',
|
||||
range: 1,
|
||||
areaRadius: 0,
|
||||
target: AbilityDefinition::TARGET_ALLY,
|
||||
effect: AbilityDefinition::EFFECT_APPLY_BUFF,
|
||||
effectValue: 2,
|
||||
),
|
||||
];
|
||||
|
||||
return $cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class AbilityDefinition
|
||||
{
|
||||
public const TARGET_SELF = 'self';
|
||||
public const TARGET_ALLY = 'ally';
|
||||
public const TARGET_ENEMY = 'enemy';
|
||||
public const TARGET_TILE = 'tile';
|
||||
|
||||
public const EFFECT_RESTORE_HEALTH = 'restore_health';
|
||||
public const EFFECT_DEAL_DAMAGE = 'deal_damage';
|
||||
public const EFFECT_APPLY_BUFF = 'apply_buff';
|
||||
|
||||
/** @var list<string> */
|
||||
private const TARGETS = [self::TARGET_SELF, self::TARGET_ALLY, self::TARGET_ENEMY, self::TARGET_TILE];
|
||||
|
||||
/** @var list<string> */
|
||||
private const EFFECTS = [self::EFFECT_RESTORE_HEALTH, self::EFFECT_DEAL_DAMAGE, self::EFFECT_APPLY_BUFF];
|
||||
|
||||
public function __construct(
|
||||
public AbilityId $id,
|
||||
public string $label,
|
||||
public int $range,
|
||||
public int $areaRadius,
|
||||
public string $target,
|
||||
public string $effect,
|
||||
public int $effectValue,
|
||||
) {
|
||||
if ($this->range < 0) {
|
||||
throw new InvalidArgumentException('Ability range cannot be negative.');
|
||||
}
|
||||
|
||||
if ($this->areaRadius < 0) {
|
||||
throw new InvalidArgumentException('Ability area radius cannot be negative.');
|
||||
}
|
||||
|
||||
if (!in_array($this->target, self::TARGETS, true)) {
|
||||
throw new InvalidArgumentException("Unknown ability target: {$this->target}.");
|
||||
}
|
||||
|
||||
if (!in_array($this->effect, self::EFFECTS, true)) {
|
||||
throw new InvalidArgumentException("Unknown ability effect: {$this->effect}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum AbilityId: string
|
||||
{
|
||||
case Heal = 'heal';
|
||||
case AreaDamage = 'area_damage';
|
||||
case Buff = 'buff';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum Archetype: string
|
||||
{
|
||||
case Defender = 'defender';
|
||||
case Striker = 'striker';
|
||||
case Support = 'support';
|
||||
case Scout = 'scout';
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class ArchetypeCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, ArchetypeTemplate>
|
||||
*/
|
||||
public static function templates(): array
|
||||
{
|
||||
static $cache = null;
|
||||
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$cache = [
|
||||
Archetype::Defender->value => new ArchetypeTemplate(
|
||||
minHealth: 10,
|
||||
maxHealth: 14,
|
||||
minAttack: 2,
|
||||
maxAttack: 4,
|
||||
minDefense: 3,
|
||||
maxDefense: 5,
|
||||
minSpeed: 1,
|
||||
maxSpeed: 3,
|
||||
allowedAbilities: ['buff'],
|
||||
),
|
||||
Archetype::Striker->value => new ArchetypeTemplate(
|
||||
minHealth: 6,
|
||||
maxHealth: 10,
|
||||
minAttack: 4,
|
||||
maxAttack: 6,
|
||||
minDefense: 1,
|
||||
maxDefense: 2,
|
||||
minSpeed: 2,
|
||||
maxSpeed: 4,
|
||||
allowedAbilities: ['area_damage'],
|
||||
),
|
||||
Archetype::Support->value => new ArchetypeTemplate(
|
||||
minHealth: 5,
|
||||
maxHealth: 9,
|
||||
minAttack: 1,
|
||||
maxAttack: 3,
|
||||
minDefense: 1,
|
||||
maxDefense: 3,
|
||||
minSpeed: 2,
|
||||
maxSpeed: 4,
|
||||
allowedAbilities: ['heal', 'buff'],
|
||||
),
|
||||
Archetype::Scout->value => new ArchetypeTemplate(
|
||||
minHealth: 4,
|
||||
maxHealth: 7,
|
||||
minAttack: 2,
|
||||
maxAttack: 4,
|
||||
minDefense: 0,
|
||||
maxDefense: 2,
|
||||
minSpeed: 4,
|
||||
maxSpeed: 6,
|
||||
allowedAbilities: [],
|
||||
),
|
||||
];
|
||||
|
||||
return $cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ArchetypeTemplate
|
||||
{
|
||||
/** @param list<string> $allowedAbilities */
|
||||
public function __construct(
|
||||
public int $minHealth,
|
||||
public int $maxHealth,
|
||||
public int $minAttack,
|
||||
public int $maxAttack,
|
||||
public int $minDefense,
|
||||
public int $maxDefense,
|
||||
public int $minSpeed,
|
||||
public int $maxSpeed,
|
||||
public array $allowedAbilities,
|
||||
) {
|
||||
if ($this->minHealth > $this->maxHealth) {
|
||||
throw new InvalidArgumentException('Health range is inverted.');
|
||||
}
|
||||
if ($this->minAttack > $this->maxAttack) {
|
||||
throw new InvalidArgumentException('Attack range is inverted.');
|
||||
}
|
||||
if ($this->minDefense > $this->maxDefense) {
|
||||
throw new InvalidArgumentException('Defense range is inverted.');
|
||||
}
|
||||
if ($this->minSpeed > $this->maxSpeed || $this->minSpeed < 1) {
|
||||
throw new InvalidArgumentException('Speed range must be at least 1 and non-inverted.');
|
||||
}
|
||||
if ($this->minHealth < 1) {
|
||||
throw new InvalidArgumentException('Minimum health must be at least 1.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{maxHealth: int, attack: int, defense: int, speed: int} */
|
||||
public function defaultStats(): array
|
||||
{
|
||||
return [
|
||||
'maxHealth' => (int) ceil(($this->minHealth + $this->maxHealth) / 2),
|
||||
'attack' => (int) ceil(($this->minAttack + $this->maxAttack) / 2),
|
||||
'defense' => (int) ceil(($this->minDefense + $this->maxDefense) / 2),
|
||||
'speed' => (int) ceil(($this->minSpeed + $this->maxSpeed) / 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use SplQueue;
|
||||
|
||||
final readonly class Battlefield
|
||||
{
|
||||
/** @var array<string, Terrain> */
|
||||
private array $terrain;
|
||||
|
||||
/**
|
||||
* @param array<string, Terrain> $terrain
|
||||
*/
|
||||
public function __construct(
|
||||
public int $width,
|
||||
public int $height,
|
||||
array $terrain = [],
|
||||
) {
|
||||
if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
|
||||
throw new InvalidArgumentException('Battlefield dimensions must each be between 8 and 16.');
|
||||
}
|
||||
|
||||
$validatedTerrain = [];
|
||||
|
||||
foreach ($terrain as $key => $terrainType) {
|
||||
if (!self::isTerrain($terrainType)) {
|
||||
throw new InvalidArgumentException("Invalid terrain value at coordinate {$key}.");
|
||||
}
|
||||
|
||||
$position = self::positionFromTerrainKey($key);
|
||||
|
||||
if (!$this->contains($position)) {
|
||||
throw new InvalidArgumentException("Terrain coordinate {$key} is outside the battlefield.");
|
||||
}
|
||||
|
||||
$validatedTerrain[$key] = $terrainType;
|
||||
}
|
||||
|
||||
$this->terrain = $validatedTerrain;
|
||||
}
|
||||
|
||||
private static function isTerrain(mixed $terrain): bool
|
||||
{
|
||||
return $terrain instanceof Terrain;
|
||||
}
|
||||
|
||||
private static function positionFromTerrainKey(mixed $key): Position
|
||||
{
|
||||
if (!is_string($key) || preg_match('/^(\d+):(\d+)$/', $key, $matches) !== 1) {
|
||||
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
|
||||
}
|
||||
|
||||
$position = new Position((int) $matches[1], (int) $matches[2]);
|
||||
|
||||
if ($key !== $position->key()) {
|
||||
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
|
||||
}
|
||||
|
||||
return $position;
|
||||
}
|
||||
|
||||
public function contains(Position $position): bool
|
||||
{
|
||||
return $position->x >= 0
|
||||
&& $position->x < $this->width
|
||||
&& $position->y >= 0
|
||||
&& $position->y < $this->height;
|
||||
}
|
||||
|
||||
public function terrainAt(Position $position): Terrain
|
||||
{
|
||||
return $this->terrain[$position->key()] ?? Terrain::Open;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Position> $occupied
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function reachable(Position $start, int $budget, array $occupied): array
|
||||
{
|
||||
if (!$this->contains($start)) {
|
||||
throw new InvalidArgumentException('Reachability start position is outside the battlefield.');
|
||||
}
|
||||
|
||||
if ($budget < 0) {
|
||||
throw new InvalidArgumentException('Reachability budget cannot be negative.');
|
||||
}
|
||||
|
||||
$occupiedKeys = [];
|
||||
|
||||
foreach ($occupied as $position) {
|
||||
if (!$this->contains($position)) {
|
||||
throw new InvalidArgumentException('Occupied position is outside the battlefield.');
|
||||
}
|
||||
|
||||
$occupiedKeys[$position->key()] = true;
|
||||
}
|
||||
|
||||
$costs = [$start->key() => 0];
|
||||
/** @var SplQueue<Position> $queue */
|
||||
$queue = new SplQueue();
|
||||
$queue->enqueue($start);
|
||||
|
||||
while (!$queue->isEmpty()) {
|
||||
$current = $queue->dequeue();
|
||||
$currentCost = $costs[$current->key()];
|
||||
|
||||
foreach ($this->neighbors($current) as $neighbor) {
|
||||
$key = $neighbor->key();
|
||||
$movementCost = $this->terrainAt($neighbor)->movementCost();
|
||||
|
||||
if ($movementCost === null || isset($occupiedKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cost = $currentCost + $movementCost;
|
||||
|
||||
if ($cost > $budget || (isset($costs[$key]) && $costs[$key] <= $cost)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$costs[$key] = $cost;
|
||||
$queue->enqueue($neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
return $costs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Position>
|
||||
*/
|
||||
private function neighbors(Position $position): array
|
||||
{
|
||||
$neighbors = [
|
||||
new Position($position->x + 1, $position->y),
|
||||
new Position($position->x - 1, $position->y),
|
||||
new Position($position->x, $position->y + 1),
|
||||
new Position($position->x, $position->y - 1),
|
||||
];
|
||||
|
||||
return array_values(array_filter($neighbors, $this->contains(...)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CombatEngine
|
||||
{
|
||||
public const TO_HIT_BASE = 40;
|
||||
public const TO_HIT_PER_ATTACK = 3;
|
||||
public const TO_HIT_FLOOR = 5;
|
||||
public const TO_HIT_CEILING = 95;
|
||||
public const CRIT_THRESHOLD = 95;
|
||||
public const DAMAGE_VARIANCE_MIN_PCT = 85;
|
||||
public const DAMAGE_VARIANCE_MAX_PCT = 115;
|
||||
public const FOREST_CONCEALMENT_MISS_CHANCE = 15;
|
||||
|
||||
public function move(MatchState $match, string $unitId, Position $destination): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$unit = $this->unitOrFail($match, $unitId);
|
||||
$this->assertCanAct($match, $unit);
|
||||
|
||||
$occupied = [];
|
||||
|
||||
foreach ($match->units as $otherUnit) {
|
||||
if ($otherUnit->id !== $unit->id && !$otherUnit->isDefeated()) {
|
||||
$occupied[] = $otherUnit->position;
|
||||
}
|
||||
}
|
||||
|
||||
$reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);
|
||||
|
||||
if ($destination->key() === $unit->position->key() || !isset($reachable[$destination->key()])) {
|
||||
throw new CombatException('Destination is not reachable.');
|
||||
}
|
||||
|
||||
$moved = $unit->moveTo($destination);
|
||||
$logParts = ["{$unit->id} moved to {$destination->key()}"];
|
||||
|
||||
$updatedUnit = $moved;
|
||||
if ($match->battlefield->terrainAt($moved->position) === Terrain::Water) {
|
||||
// Water entry ends the turn: spend both actions to zero them out,
|
||||
// then flag the wade. (UnitState::copy is private, so chain public
|
||||
// spenders instead of editing fields directly.)
|
||||
$updatedUnit = $moved
|
||||
->spendAction()
|
||||
->spendAction()
|
||||
->withWaded(true);
|
||||
$logParts[] = '— wading';
|
||||
} else {
|
||||
$updatedUnit = $moved->spendAction();
|
||||
}
|
||||
|
||||
$next = $match->withUnit($updatedUnit);
|
||||
$next = $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
|
||||
|
||||
return $this->checkVictoryAfterAction($next, $unit->teamId);
|
||||
}
|
||||
|
||||
public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$attacker = $this->unitOrFail($match, $attackerId);
|
||||
$this->assertCanAct($match, $attacker);
|
||||
|
||||
if ($attacker->hasAttacked) {
|
||||
throw new CombatException('Unit has already attacked this turn.');
|
||||
}
|
||||
|
||||
$target = $this->unitOrFail($match, $targetId);
|
||||
|
||||
if ($target->teamId === $attacker->teamId || $target->isDefeated()) {
|
||||
throw new CombatException('Target must be an active enemy unit.');
|
||||
}
|
||||
|
||||
if ($attacker->position->distanceTo($target->position) !== 1) {
|
||||
throw new CombatException('Target is outside attack range.');
|
||||
}
|
||||
|
||||
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
|
||||
$flanking = $this->flankingBonusFor($match, $attacker, $target);
|
||||
$threshold = $this->thresholdFor($attacker, $target, $terrainDefense, $flanking);
|
||||
$roll = random_int(1, 100);
|
||||
|
||||
$logParts = [
|
||||
"{$attacker->id} attacked {$target->id}",
|
||||
"(rolled {$roll} / needed {$threshold})",
|
||||
];
|
||||
|
||||
$updatedAttacker = $attacker->markAttacked()->spendAction();
|
||||
|
||||
if ($roll < $threshold) {
|
||||
// To-hit miss; log and return without applying damage.
|
||||
$logParts[] = 'for 0 damage';
|
||||
$logParts[] = '— miss';
|
||||
$next = $match->withUnit($updatedAttacker);
|
||||
return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
|
||||
}
|
||||
|
||||
// Forest concealment: independent 15% miss roll after to-hit passes.
|
||||
$targetTerrain = $match->battlefield->terrainAt($target->position);
|
||||
if ($targetTerrain === \BattleForge\Domain\Terrain::Forest) {
|
||||
$concealmentRoll = random_int(1, 100);
|
||||
if ($concealmentRoll <= self::FOREST_CONCEALMENT_MISS_CHANCE) {
|
||||
$logParts[] = 'for 0 damage';
|
||||
$logParts[] = '— miss — concealed';
|
||||
$next = $match->withUnit($updatedAttacker);
|
||||
return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
|
||||
}
|
||||
}
|
||||
|
||||
$isCrit = $roll >= self::CRIT_THRESHOLD;
|
||||
$base = max(1, ($attacker->attack + $attacker->attackBonus) - ($target->defense + $terrainDefense));
|
||||
$damage = $this->applyDamage($base, $isCrit);
|
||||
|
||||
$logParts[] = "for {$damage} damage";
|
||||
if ($isCrit) {
|
||||
$logParts[] = '— crit';
|
||||
}
|
||||
|
||||
$updatedTarget = $target->takeDamage($damage);
|
||||
$next = $match
|
||||
->withUnit($updatedAttacker)
|
||||
->withUnit($updatedTarget);
|
||||
$next = $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
|
||||
|
||||
return $this->checkVictoryAfterAction($next, $attacker->teamId);
|
||||
}
|
||||
|
||||
public function useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$caster = $this->unitOrFail($match, $unitId);
|
||||
$this->assertCanAct($match, $caster);
|
||||
|
||||
if ($caster->hasUsedAbility) {
|
||||
throw new CombatException('Unit has already used an ability this turn.');
|
||||
}
|
||||
|
||||
$definitions = AbilityCatalog::definitions();
|
||||
|
||||
if (!isset($definitions[$abilityId])) {
|
||||
throw new CombatException("Unknown ability: {$abilityId}.");
|
||||
}
|
||||
|
||||
$definition = $definitions[$abilityId];
|
||||
|
||||
if (!in_array($abilityId, $caster->allowedAbilities(), true)) {
|
||||
throw new CombatException('Unit archetype forbids that ability.');
|
||||
}
|
||||
|
||||
$affected = $this->resolveAbilityTargets($match, $caster, $definition, $target);
|
||||
|
||||
$next = $match;
|
||||
$logParts = [];
|
||||
|
||||
foreach ($affected as $affectedUnit) {
|
||||
$current = $next->unit($affectedUnit['id']);
|
||||
$updated = match ($definition->effect) {
|
||||
AbilityDefinition::EFFECT_RESTORE_HEALTH => $current->restoreHealth($definition->effectValue),
|
||||
AbilityDefinition::EFFECT_DEAL_DAMAGE => $current->takeDamage($affectedUnit['damage'] ?? 0),
|
||||
AbilityDefinition::EFFECT_APPLY_BUFF => $current->withAttackBonus($definition->effectValue),
|
||||
default => throw new CombatException("Unknown ability effect: {$definition->effect}."),
|
||||
};
|
||||
$next = $next->withUnit($updated);
|
||||
|
||||
$logParts[] = match ($definition->effect) {
|
||||
AbilityDefinition::EFFECT_RESTORE_HEALTH => "{$affectedUnit['id']} for {$definition->effectValue}",
|
||||
AbilityDefinition::EFFECT_DEAL_DAMAGE => "{$affectedUnit['id']} for {$affectedUnit['damage']} damage",
|
||||
AbilityDefinition::EFFECT_APPLY_BUFF => "{$affectedUnit['id']} with +{$definition->effectValue} attack",
|
||||
};
|
||||
}
|
||||
|
||||
$next = $next->withUnit($next->unit($caster->id)->spendAbility());
|
||||
|
||||
$summary = "{$caster->id} used {$definition->label} on " . implode(', ', $logParts);
|
||||
$next = $next->copy(actionLog: [...$next->actionLog, $summary]);
|
||||
|
||||
return $this->checkVictoryAfterAction($next, $caster->teamId);
|
||||
}
|
||||
|
||||
public function endTurn(MatchState $match): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$teamIds = [];
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
$teamIds[$unit->teamId] = true;
|
||||
}
|
||||
|
||||
$teamIds = array_keys($teamIds);
|
||||
sort($teamIds);
|
||||
|
||||
if (count($teamIds) !== 2) {
|
||||
throw new CombatException('Matches require exactly two teams.');
|
||||
}
|
||||
|
||||
$livingTeams = array_fill_keys($teamIds, false);
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
if (!$unit->isDefeated()) {
|
||||
$livingTeams[$unit->teamId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($livingTeams as $teamId => $alive) {
|
||||
if (!$alive) {
|
||||
throw new CombatException('Cannot end turn while a team is eliminated.');
|
||||
}
|
||||
}
|
||||
|
||||
$endingTeamId = $match->activeTeamId;
|
||||
$nextTeamId = $endingTeamId === $teamIds[0] ? $teamIds[1] : $teamIds[0];
|
||||
|
||||
if ($nextTeamId === $teamIds[0] && $match->round === PHP_INT_MAX) {
|
||||
throw new CombatException('Match round limit reached.');
|
||||
}
|
||||
|
||||
$units = [];
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
$units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit;
|
||||
}
|
||||
|
||||
$round = $match->round + ($nextTeamId === $teamIds[0] ? 1 : 0);
|
||||
|
||||
$next = $match->copy(
|
||||
units: $units,
|
||||
activeTeamId: $nextTeamId,
|
||||
round: $round,
|
||||
actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"],
|
||||
);
|
||||
|
||||
return $this->resolveObjectiveVictory($next, $endingTeamId === $teamIds[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: string, damage?: int}>
|
||||
*/
|
||||
private function resolveAbilityTargets(
|
||||
MatchState $match,
|
||||
UnitState $caster,
|
||||
AbilityDefinition $definition,
|
||||
?Position $target,
|
||||
): array {
|
||||
if ($definition->target === AbilityDefinition::TARGET_SELF) {
|
||||
return [['id' => $caster->id]];
|
||||
}
|
||||
|
||||
if ($target === null) {
|
||||
throw new CombatException('Ability requires a target position.');
|
||||
}
|
||||
|
||||
if (!$match->battlefield->contains($target)) {
|
||||
throw new CombatException('Ability target is outside the battlefield.');
|
||||
}
|
||||
|
||||
$distance = $caster->position->distanceTo($target);
|
||||
|
||||
if ($distance > $definition->range) {
|
||||
throw new CombatException('Ability target is out of range.');
|
||||
}
|
||||
|
||||
$tileKeys = [$target->key()];
|
||||
|
||||
if ($definition->areaRadius > 0) {
|
||||
$tileKeys = self::tilesWithinRadius(
|
||||
$match->battlefield,
|
||||
$target,
|
||||
$definition->areaRadius,
|
||||
);
|
||||
}
|
||||
|
||||
if ($definition->effect === AbilityDefinition::EFFECT_DEAL_DAMAGE) {
|
||||
$affected = [];
|
||||
|
||||
foreach ($match->units as $candidate) {
|
||||
if ($candidate->isDefeated()) {
|
||||
continue;
|
||||
}
|
||||
if ($candidate->teamId === $caster->teamId) {
|
||||
continue;
|
||||
}
|
||||
$found = false;
|
||||
foreach ($tileKeys as $key) {
|
||||
if ($candidate->position->key() === $key) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$terrainDefense = $match->battlefield->terrainAt($candidate->position)->defenseBonus();
|
||||
$damage = max(1, $definition->effectValue - $candidate->defense - $terrainDefense);
|
||||
$affected[] = ['id' => $candidate->id, 'damage' => $damage];
|
||||
}
|
||||
|
||||
return $affected;
|
||||
}
|
||||
|
||||
$targetUnit = null;
|
||||
|
||||
foreach ($match->units as $candidate) {
|
||||
if ($candidate->position->key() === $target->key()) {
|
||||
$targetUnit = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetUnit === null) {
|
||||
throw new CombatException('Ability target is empty.');
|
||||
}
|
||||
|
||||
$expectedTeam = $definition->target === AbilityDefinition::TARGET_ALLY ? $caster->teamId : null;
|
||||
|
||||
if ($expectedTeam !== null && $targetUnit->teamId !== $expectedTeam) {
|
||||
throw new CombatException('Ability target must be an ally.');
|
||||
}
|
||||
|
||||
if ($definition->target === AbilityDefinition::TARGET_ENEMY && $targetUnit->teamId === $caster->teamId) {
|
||||
throw new CombatException('Ability target must be an enemy.');
|
||||
}
|
||||
|
||||
return [['id' => $targetUnit->id]];
|
||||
}
|
||||
|
||||
private function checkVictoryAfterAction(MatchState $match, string $actingTeamId): MatchState
|
||||
{
|
||||
foreach ($match->units as $unit) {
|
||||
if ($unit->teamId !== $actingTeamId && !$unit->isDefeated()) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
return $match->withWinner($actingTeamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $endingTeamIsFirstInOrder true when the team that just ended is the first team in
|
||||
* sorted team order; this identifies the first half of a round.
|
||||
*/
|
||||
private function resolveObjectiveVictory(MatchState $match, bool $endingTeamIsFirstInOrder): MatchState
|
||||
{
|
||||
if ($match->victoryCondition !== VictoryCondition::HoldObjective || $match->objectives === []) {
|
||||
return $match;
|
||||
}
|
||||
|
||||
$next = $match;
|
||||
|
||||
if ($endingTeamIsFirstInOrder) {
|
||||
/** @var list<ObjectiveMarker> $objectiveList */
|
||||
$objectiveList = array_values($match->objectives);
|
||||
$objective = $objectiveList[0];
|
||||
$controller = $this->objectiveController($match, $objective->position);
|
||||
|
||||
if ($controller !== null) {
|
||||
$control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller);
|
||||
$next = $match->withObjectiveControl($control->roundsByTeam);
|
||||
}
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
foreach ($match->objectiveControl as $teamId => $count) {
|
||||
if ($count >= $match->holdRoundsRequired) {
|
||||
return $next->withWinner($teamId);
|
||||
}
|
||||
}
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
private function objectiveController(MatchState $match, Position $tile): ?string
|
||||
{
|
||||
$teams = [];
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
if ($unit->isDefeated() || $unit->position->key() !== $tile->key()) {
|
||||
continue;
|
||||
}
|
||||
$teams[$unit->teamId] = true;
|
||||
}
|
||||
|
||||
if (count($teams) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_key_first($teams);
|
||||
}
|
||||
|
||||
private function unitOrFail(MatchState $match, string $unitId): UnitState
|
||||
{
|
||||
try {
|
||||
return $match->unit($unitId);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw new CombatException("Unknown unit: {$unitId}.", previous: $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function thresholdFor(UnitState $attacker, UnitState $target, int $terrainDefense, int $flankingBonus): int
|
||||
{
|
||||
$raw = self::TO_HIT_BASE
|
||||
+ ($attacker->attack * self::TO_HIT_PER_ATTACK)
|
||||
- ($target->defense + $terrainDefense)
|
||||
- $flankingBonus;
|
||||
if ($raw < self::TO_HIT_FLOOR) {
|
||||
return self::TO_HIT_FLOOR;
|
||||
}
|
||||
if ($raw > self::TO_HIT_CEILING) {
|
||||
return self::TO_HIT_CEILING;
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function flankingBonusFor(MatchState $match, UnitState $attacker, UnitState $target): int
|
||||
{
|
||||
$count = $this->flankingAlliesCount($match, $attacker, $target);
|
||||
return min($count * 15, 30);
|
||||
}
|
||||
|
||||
private function flankingAlliesCount(MatchState $match, UnitState $attacker, UnitState $target): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($match->units as $other) {
|
||||
if ($other->id === $attacker->id || $other->id === $target->id) {
|
||||
continue;
|
||||
}
|
||||
if ($other->isDefeated() || $other->teamId !== $attacker->teamId) {
|
||||
continue;
|
||||
}
|
||||
$dx = abs($other->position->x - $target->position->x);
|
||||
$dy = abs($other->position->y - $target->position->y);
|
||||
if (($dx === 1 && $dy === 0) || ($dx === 0 && $dy === 1)) {
|
||||
$count += 1;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function applyDamage(int $base, bool $isCrit): int
|
||||
{
|
||||
$doubled = $isCrit ? $base * 2 : $base;
|
||||
$pct = random_int(self::DAMAGE_VARIANCE_MIN_PCT, self::DAMAGE_VARIANCE_MAX_PCT);
|
||||
return (int) round($doubled * $pct / 100);
|
||||
}
|
||||
|
||||
private function assertMatchActive(MatchState $match): void
|
||||
{
|
||||
if ($match->winnerTeamId !== null) {
|
||||
throw new CombatException('The match is already complete.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function tilesWithinRadius(Battlefield $battlefield, Position $center, int $radius): array
|
||||
{
|
||||
$seen = [$center->key() => true];
|
||||
$frontier = [$center];
|
||||
$result = [$center->key()];
|
||||
|
||||
for ($step = 0; $step < $radius; $step++) {
|
||||
$next = [];
|
||||
foreach ($frontier as $position) {
|
||||
foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as $offset) {
|
||||
$candidate = new Position($position->x + $offset[0], $position->y + $offset[1]);
|
||||
if (!$battlefield->contains($candidate)) {
|
||||
continue;
|
||||
}
|
||||
$key = $candidate->key();
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$result[] = $key;
|
||||
$next[] = $candidate;
|
||||
}
|
||||
}
|
||||
$frontier = $next;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function assertCanAct(MatchState $match, UnitState $unit): void
|
||||
{
|
||||
if ($unit->teamId !== $match->activeTeamId) {
|
||||
throw new CombatException("It is not this unit's turn.");
|
||||
}
|
||||
|
||||
if ($unit->isDefeated()) {
|
||||
throw new CombatException('Defeated units cannot act.');
|
||||
}
|
||||
|
||||
if ($unit->actionsRemaining === 0) {
|
||||
throw new CombatException('Unit has no actions remaining.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class CombatException extends DomainException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class DeploymentZone
|
||||
{
|
||||
/** @param list<Position> $positions */
|
||||
public function __construct(
|
||||
public string $teamId,
|
||||
public array $positions,
|
||||
) {
|
||||
if ($teamId === '') {
|
||||
throw new InvalidArgumentException('Deployment zone team id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($positions === []) {
|
||||
throw new InvalidArgumentException('Deployment zone must contain at least one position.');
|
||||
}
|
||||
|
||||
if (!array_is_list($positions)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Deployment zone positions must be a list.');
|
||||
}
|
||||
}
|
||||
|
||||
public function contains(Position $position): bool
|
||||
{
|
||||
foreach ($this->positions as $candidate) {
|
||||
if ($candidate->key() === $position->key()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class MatchState
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param list<string> $actionLog
|
||||
* @param array<string, ObjectiveMarker> $objectives
|
||||
* @param array<string, int> $objectiveControl
|
||||
*/
|
||||
public function __construct(
|
||||
public Battlefield $battlefield,
|
||||
public array $units,
|
||||
public string $activeTeamId,
|
||||
public int $round = 1,
|
||||
public ?string $winnerTeamId = null,
|
||||
public array $actionLog = [],
|
||||
public array $objectives = [],
|
||||
public VictoryCondition $victoryCondition = VictoryCondition::EliminateAll,
|
||||
public int $holdRoundsRequired = 1,
|
||||
public array $objectiveControl = [],
|
||||
) {
|
||||
if (!self::isList($units)) {
|
||||
throw new InvalidArgumentException('Match units must be a list.');
|
||||
}
|
||||
|
||||
if ($units === []) {
|
||||
throw new InvalidArgumentException('Match must contain at least one unit.');
|
||||
}
|
||||
|
||||
$unitIds = [];
|
||||
$teamIds = [];
|
||||
$occupiedPositions = [];
|
||||
|
||||
foreach ($units as $unit) {
|
||||
if (!self::isUnitState($unit)) {
|
||||
throw new InvalidArgumentException('Match units must contain only UnitState instances.');
|
||||
}
|
||||
|
||||
if (isset($unitIds[$unit->id])) {
|
||||
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
|
||||
}
|
||||
|
||||
if (!$battlefield->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
|
||||
}
|
||||
|
||||
if (!$unit->isDefeated() && $battlefield->terrainAt($unit->position)->movementCost() === null) {
|
||||
throw new InvalidArgumentException("Living unit {$unit->id} must stand on passable terrain.");
|
||||
}
|
||||
|
||||
$positionKey = $unit->position->key();
|
||||
|
||||
if (!$unit->isDefeated() && isset($occupiedPositions[$positionKey])) {
|
||||
throw new InvalidArgumentException("Living units cannot share position {$positionKey}.");
|
||||
}
|
||||
|
||||
if (!$unit->isDefeated()) {
|
||||
$occupiedPositions[$positionKey] = true;
|
||||
}
|
||||
|
||||
$unitIds[$unit->id] = true;
|
||||
$teamIds[$unit->teamId] = true;
|
||||
}
|
||||
|
||||
if ($activeTeamId === '') {
|
||||
throw new InvalidArgumentException('Active team id cannot be empty.');
|
||||
}
|
||||
|
||||
if (!isset($teamIds[$activeTeamId])) {
|
||||
throw new InvalidArgumentException("Active team has no units: {$activeTeamId}.");
|
||||
}
|
||||
|
||||
if ($round < 1) {
|
||||
throw new InvalidArgumentException('Match round must be at least 1.');
|
||||
}
|
||||
|
||||
if (!self::isList($actionLog)) {
|
||||
throw new InvalidArgumentException('Match action log must be a list.');
|
||||
}
|
||||
|
||||
foreach ($actionLog as $entry) {
|
||||
if (!self::isString($entry)) {
|
||||
throw new InvalidArgumentException('Match action log entries must be strings.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($winnerTeamId !== null && $winnerTeamId === '') {
|
||||
throw new InvalidArgumentException('Winner team id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) {
|
||||
throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}.");
|
||||
}
|
||||
|
||||
if ($holdRoundsRequired < 1) {
|
||||
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||
}
|
||||
|
||||
foreach ($objectives as $objective) {
|
||||
if (!$objective instanceof ObjectiveMarker) { // @phpstan-ignore instanceof.alwaysTrue (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Objectives must be ObjectiveMarker instances.');
|
||||
}
|
||||
if (!$battlefield->contains($objective->position)) {
|
||||
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($objectiveControl as $teamId => $count) {
|
||||
if (!self::isString($teamId) || $teamId === '') {
|
||||
throw new InvalidArgumentException('Objective control team id must be a non-empty string.');
|
||||
}
|
||||
if (!self::isInt($count) || $count < 0) {
|
||||
throw new InvalidArgumentException('Objective control count must be a non-negative integer.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function isUnitState(mixed $unit): bool
|
||||
{
|
||||
return $unit instanceof UnitState;
|
||||
}
|
||||
|
||||
private static function isString(mixed $value): bool
|
||||
{
|
||||
return is_string($value);
|
||||
}
|
||||
|
||||
private static function isInt(mixed $value): bool
|
||||
{
|
||||
return is_int($value);
|
||||
}
|
||||
|
||||
private static function isList(mixed $value): bool
|
||||
{
|
||||
return is_array($value) && array_is_list($value);
|
||||
}
|
||||
|
||||
public function unit(string $id): UnitState
|
||||
{
|
||||
foreach ($this->units as $unit) {
|
||||
if ($unit->id === $id) {
|
||||
return $unit;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown unit id: {$id}.");
|
||||
}
|
||||
|
||||
public function withUnit(UnitState $replacement): self
|
||||
{
|
||||
$units = $this->units;
|
||||
|
||||
foreach ($units as $index => $unit) {
|
||||
if ($unit->id === $replacement->id) {
|
||||
$units[$index] = $replacement;
|
||||
|
||||
return $this->copy(units: $units);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown unit id: {$replacement->id}.");
|
||||
}
|
||||
|
||||
public function withWinner(?string $winnerTeamId): self
|
||||
{
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$this->units,
|
||||
$this->activeTeamId,
|
||||
$this->round,
|
||||
$winnerTeamId,
|
||||
$this->actionLog,
|
||||
$this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$this->objectiveControl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $objectiveControl
|
||||
*/
|
||||
public function withObjectiveControl(array $objectiveControl): self
|
||||
{
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$this->units,
|
||||
$this->activeTeamId,
|
||||
$this->round,
|
||||
$this->winnerTeamId,
|
||||
$this->actionLog,
|
||||
$this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$objectiveControl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState>|null $units
|
||||
* @param list<string>|null $actionLog
|
||||
* @param array<string, ObjectiveMarker>|null $objectives
|
||||
* @param array<string, int>|null $objectiveControl
|
||||
*/
|
||||
public function copy(
|
||||
?array $units = null,
|
||||
?string $activeTeamId = null,
|
||||
?int $round = null,
|
||||
?array $actionLog = null,
|
||||
?array $objectives = null,
|
||||
?array $objectiveControl = null,
|
||||
): self {
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$units ?? $this->units,
|
||||
$activeTeamId ?? $this->activeTeamId,
|
||||
$round ?? $this->round,
|
||||
$this->winnerTeamId,
|
||||
$actionLog ?? $this->actionLog,
|
||||
$objectives ?? $this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$objectiveControl ?? $this->objectiveControl,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ObjectiveControl
|
||||
{
|
||||
/** @param array<string, int> $roundsByTeam */
|
||||
public function __construct(public array $roundsByTeam)
|
||||
{
|
||||
foreach ($roundsByTeam as $teamId => $count) {
|
||||
if ($teamId === '') {
|
||||
throw new InvalidArgumentException('Objective control team id cannot be empty.');
|
||||
}
|
||||
if ($count < 0) {
|
||||
throw new InvalidArgumentException('Objective control count cannot be negative.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
public function recordRound(string $controllerTeamId): self
|
||||
{
|
||||
if ($controllerTeamId === '') {
|
||||
throw new InvalidArgumentException('Controller team id cannot be empty.');
|
||||
}
|
||||
|
||||
$next = $this->roundsByTeam;
|
||||
$next[$controllerTeamId] = ($next[$controllerTeamId] ?? 0) + 1;
|
||||
|
||||
return new self($next);
|
||||
}
|
||||
|
||||
/** @return array{teamId: string, rounds: int} */
|
||||
public function forTeam(string $teamId): array
|
||||
{
|
||||
return ['teamId' => $teamId, 'rounds' => $this->roundsByTeam[$teamId] ?? 0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ObjectiveMarker
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public Position $position,
|
||||
) {
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException('Objective id cannot be empty.');
|
||||
}
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final readonly class Position
|
||||
{
|
||||
public function __construct(
|
||||
public int $x,
|
||||
public int $y,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->x . ':' . $this->y;
|
||||
}
|
||||
|
||||
public function distanceTo(self $position): int
|
||||
{
|
||||
return abs($this->x - $position->x) + abs($this->y - $position->y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class Scenario
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param array<string, DeploymentZone> $deploymentZones
|
||||
* @param array<string, ObjectiveMarker> $objectives
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public Battlefield $battlefield,
|
||||
public array $units,
|
||||
public array $deploymentZones,
|
||||
public array $objectives,
|
||||
public VictoryCondition $victoryCondition,
|
||||
public int $holdRoundsRequired,
|
||||
) {
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException('Scenario id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Scenario name cannot be empty.');
|
||||
}
|
||||
|
||||
if (!array_is_list($units)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Scenario units must be a list.');
|
||||
}
|
||||
|
||||
if ($deploymentZones === []) {
|
||||
throw new InvalidArgumentException('Scenario must declare at least one deployment zone.');
|
||||
}
|
||||
|
||||
if (count($deploymentZones) !== 2) {
|
||||
throw new InvalidArgumentException('Scenarios must declare exactly two deployment zones.');
|
||||
}
|
||||
|
||||
if ($holdRoundsRequired < 1) {
|
||||
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||
}
|
||||
|
||||
if ($victoryCondition === VictoryCondition::HoldObjective && count($this->objectives) !== 1) {
|
||||
throw new InvalidArgumentException('Hold objective victory requires exactly one objective.');
|
||||
}
|
||||
|
||||
if ($victoryCondition === VictoryCondition::EliminateAll && $this->objectives !== []) {
|
||||
throw new InvalidArgumentException('Eliminate all victory does not allow objectives.');
|
||||
}
|
||||
|
||||
$unitsByTeam = $this->groupUnitsByTeam($units);
|
||||
|
||||
foreach ($unitsByTeam as $teamId => $teamUnits) {
|
||||
if (count($teamUnits) < 3 || count($teamUnits) > 6) {
|
||||
throw new InvalidArgumentException("Team {$teamId} must have between 3 and 6 units.");
|
||||
}
|
||||
|
||||
if (!isset($deploymentZones[$teamId])) {
|
||||
throw new InvalidArgumentException("Team {$teamId} is missing a deployment zone.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($units as $unit) {
|
||||
if (!$this->battlefield->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
|
||||
}
|
||||
|
||||
$template = ArchetypeCatalog::templates()[$unit->archetype->value] ?? null;
|
||||
if ($template === null) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype.");
|
||||
}
|
||||
|
||||
if ($unit->maxHealth < $template->minHealth || $unit->maxHealth > $template->maxHealth) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} health is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->attack < $template->minAttack || $unit->attack > $template->maxAttack) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} attack is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->defense < $template->minDefense || $unit->defense > $template->maxDefense) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} defense is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->speed < $template->minSpeed || $unit->speed > $template->maxSpeed) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} speed is outside archetype bounds.");
|
||||
}
|
||||
|
||||
foreach ($unit->abilities as $ability) {
|
||||
if (!in_array($ability, $template->allowedAbilities, true)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} has an ability that its archetype forbids.");
|
||||
}
|
||||
}
|
||||
|
||||
$zone = $deploymentZones[$unit->teamId] ?? null;
|
||||
if ($zone === null || !$zone->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} must start inside team {$unit->teamId} deployment zone.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($objectives as $objective) {
|
||||
if (!$this->battlefield->contains($objective->position)) {
|
||||
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function startMatch(string $activeTeamId): MatchState
|
||||
{
|
||||
$resetUnits = [];
|
||||
|
||||
foreach ($this->units as $unit) {
|
||||
$resetUnits[] = new UnitState(
|
||||
$unit->id,
|
||||
$unit->teamId,
|
||||
$unit->position,
|
||||
$unit->maxHealth,
|
||||
$unit->maxHealth,
|
||||
$unit->attack,
|
||||
$unit->defense,
|
||||
$unit->speed,
|
||||
2,
|
||||
false,
|
||||
$unit->archetype,
|
||||
$unit->abilities,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return new MatchState(
|
||||
battlefield: $this->battlefield,
|
||||
units: $resetUnits,
|
||||
activeTeamId: $activeTeamId,
|
||||
objectives: $this->objectives,
|
||||
victoryCondition: $this->victoryCondition,
|
||||
holdRoundsRequired: $this->holdRoundsRequired,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @return array<string, list<UnitState>>
|
||||
*/
|
||||
private function groupUnitsByTeam(array $units): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($units as $unit) {
|
||||
$grouped[$unit->teamId] ??= [];
|
||||
$grouped[$unit->teamId][] = $unit;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class ScenarioValidator
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function validate(Scenario $scenario): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$occupied = [];
|
||||
|
||||
foreach ($scenario->deploymentZones as $teamId => $zone) {
|
||||
foreach ($zone->positions as $position) {
|
||||
if (!$scenario->battlefield->contains($position)) {
|
||||
$errors[] = "Deployment zone for {$teamId} includes a position outside the battlefield.";
|
||||
continue;
|
||||
}
|
||||
if ($scenario->battlefield->terrainAt($position)->movementCost() === null) {
|
||||
$errors[] = "Deployment zone for {$teamId} sits on impassable terrain at {$position->key()}.";
|
||||
}
|
||||
if (isset($occupied[$position->key()])) {
|
||||
$errors[] = "Deployment zones overlap at {$position->key()}.";
|
||||
}
|
||||
$occupied[$position->key()] = $teamId;
|
||||
}
|
||||
}
|
||||
|
||||
$reachableFromDeployment = self::reachableFromDeployment($scenario, $occupied);
|
||||
|
||||
foreach ($scenario->objectives as $objective) {
|
||||
if (!$scenario->battlefield->contains($objective->position)) {
|
||||
$errors[] = "Objective {$objective->id} is outside the battlefield.";
|
||||
continue;
|
||||
}
|
||||
if ($scenario->battlefield->terrainAt($objective->position)->movementCost() === null) {
|
||||
$errors[] = "Objective {$objective->id} sits on impassable terrain.";
|
||||
}
|
||||
if (!isset($reachableFromDeployment[$objective->position->key()])) {
|
||||
$errors[] = "Objective {$objective->id} is unreachable from any deployment zone.";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $occupied
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private static function reachableFromDeployment(Scenario $scenario, array $occupied): array
|
||||
{
|
||||
$merged = $scenario->units;
|
||||
$allReachable = [];
|
||||
|
||||
foreach ($merged as $unit) {
|
||||
$others = [];
|
||||
|
||||
foreach ($merged as $other) {
|
||||
if ($other->id === $unit->id || $other->isDefeated()) {
|
||||
continue;
|
||||
}
|
||||
$others[] = $other->position;
|
||||
}
|
||||
|
||||
$reachable = $scenario->battlefield->reachable($unit->position, $unit->speed, $others);
|
||||
|
||||
foreach ($reachable as $key => $_cost) {
|
||||
if (!isset($occupied[$key])) {
|
||||
$allReachable[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $allReachable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum Terrain: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case Forest = 'forest';
|
||||
case Rough = 'rough';
|
||||
case Water = 'water';
|
||||
case Blocking = 'blocking';
|
||||
|
||||
public function movementCost(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => 1,
|
||||
self::Forest, self::Rough => 2,
|
||||
self::Water => 3,
|
||||
self::Blocking => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function defenseBonus(): int
|
||||
{
|
||||
return $this === self::Forest ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class UnitState
|
||||
{
|
||||
/** @param list<string> $abilities */
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $teamId,
|
||||
public Position $position,
|
||||
public int $maxHealth,
|
||||
public int $health,
|
||||
public int $attack,
|
||||
public int $defense,
|
||||
public int $speed,
|
||||
public int $actionsRemaining,
|
||||
public bool $hasAttacked = false,
|
||||
public Archetype $archetype = Archetype::Defender,
|
||||
public array $abilities = [],
|
||||
public int $attackBonus = 0,
|
||||
public bool $hasUsedAbility = false,
|
||||
public bool $wadedThisTurn = false,
|
||||
) {
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException('Unit id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($teamId === '') {
|
||||
throw new InvalidArgumentException('Unit team id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($maxHealth < 1) {
|
||||
throw new InvalidArgumentException('Unit maximum health must be at least 1.');
|
||||
}
|
||||
|
||||
if ($health < 0 || $health > $maxHealth) {
|
||||
throw new InvalidArgumentException('Unit health must be between 0 and maximum health.');
|
||||
}
|
||||
|
||||
if ($attack < 0 || $defense < 0) {
|
||||
throw new InvalidArgumentException('Unit attack and defense cannot be negative.');
|
||||
}
|
||||
|
||||
if ($speed < 1) {
|
||||
throw new InvalidArgumentException('Unit speed must be at least 1.');
|
||||
}
|
||||
|
||||
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
|
||||
throw new InvalidArgumentException('Unit actions remaining must be between 0 and 2.');
|
||||
}
|
||||
|
||||
if ($attackBonus < 0) {
|
||||
throw new InvalidArgumentException('Unit attack bonus cannot be negative.');
|
||||
}
|
||||
|
||||
if (!self::isList($abilities)) {
|
||||
throw new InvalidArgumentException('Unit abilities must be a list.');
|
||||
}
|
||||
|
||||
foreach ($abilities as $ability) {
|
||||
// @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
if (!is_string($ability) || $ability === '') {
|
||||
throw new InvalidArgumentException('Unit abilities must be non-empty strings.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function isDefeated(): bool
|
||||
{
|
||||
return $this->health === 0;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function allowedAbilities(): array
|
||||
{
|
||||
return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities;
|
||||
}
|
||||
|
||||
public function moveTo(Position $position): self
|
||||
{
|
||||
return $this->copy(position: $position);
|
||||
}
|
||||
|
||||
public function spendAction(): self
|
||||
{
|
||||
if ($this->actionsRemaining === 0) {
|
||||
throw new InvalidArgumentException('Unit has no actions remaining.');
|
||||
}
|
||||
|
||||
return $this->copy(actionsRemaining: $this->actionsRemaining - 1);
|
||||
}
|
||||
|
||||
public function spendAbility(): self
|
||||
{
|
||||
if ($this->hasUsedAbility) {
|
||||
throw new InvalidArgumentException('Unit has already used an ability this turn.');
|
||||
}
|
||||
|
||||
if ($this->actionsRemaining === 0) {
|
||||
throw new InvalidArgumentException('Unit has no actions remaining.');
|
||||
}
|
||||
|
||||
return $this->copy(actionsRemaining: $this->actionsRemaining - 1, hasUsedAbility: true);
|
||||
}
|
||||
|
||||
public function takeDamage(int $damage): self
|
||||
{
|
||||
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
||||
}
|
||||
|
||||
public function restoreHealth(int $amount): self
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new InvalidArgumentException('Restore health amount cannot be negative.');
|
||||
}
|
||||
|
||||
return $this->copy(health: min($this->maxHealth, $this->health + $amount));
|
||||
}
|
||||
|
||||
public function markAttacked(): self
|
||||
{
|
||||
return $this->copy(hasAttacked: true);
|
||||
}
|
||||
|
||||
public function withAttackBonus(int $bonus): self
|
||||
{
|
||||
if ($bonus < 0) {
|
||||
throw new InvalidArgumentException('Attack bonus cannot be negative.');
|
||||
}
|
||||
|
||||
return $this->copy(attackBonus: $bonus);
|
||||
}
|
||||
|
||||
public function withWaded(bool $waded): self
|
||||
{
|
||||
return $this->copy(wadedThisTurn: $waded);
|
||||
}
|
||||
|
||||
public function startTurn(): self
|
||||
{
|
||||
if ($this->isDefeated()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->copy(
|
||||
actionsRemaining: 2,
|
||||
hasAttacked: false,
|
||||
attackBonus: 0,
|
||||
hasUsedAbility: false,
|
||||
wadedThisTurn: false,
|
||||
);
|
||||
}
|
||||
|
||||
private function copy(
|
||||
?Position $position = null,
|
||||
?int $health = null,
|
||||
?int $actionsRemaining = null,
|
||||
?bool $hasAttacked = null,
|
||||
?int $attackBonus = null,
|
||||
?bool $hasUsedAbility = null,
|
||||
?bool $wadedThisTurn = null,
|
||||
): self {
|
||||
return new self(
|
||||
$this->id,
|
||||
$this->teamId,
|
||||
$position ?? $this->position,
|
||||
$this->maxHealth,
|
||||
$health ?? $this->health,
|
||||
$this->attack,
|
||||
$this->defense,
|
||||
$this->speed,
|
||||
$actionsRemaining ?? $this->actionsRemaining,
|
||||
$hasAttacked ?? $this->hasAttacked,
|
||||
$this->archetype,
|
||||
$this->abilities,
|
||||
$attackBonus ?? $this->attackBonus,
|
||||
$hasUsedAbility ?? $this->hasUsedAbility,
|
||||
wadedThisTurn: $wadedThisTurn ?? $this->wadedThisTurn,
|
||||
);
|
||||
}
|
||||
|
||||
private static function isList(mixed $value): bool
|
||||
{
|
||||
return is_array($value) && array_is_list($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum VictoryCondition: string
|
||||
{
|
||||
case EliminateAll = 'eliminate_all';
|
||||
case HoldObjective = 'hold_objective';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class CsrfToken
|
||||
{
|
||||
/** @return array{0: string, 1: string} */
|
||||
public static function issue(string $secret): array
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$cookie = hash_hmac('sha256', $token, $secret);
|
||||
|
||||
return [$token, $cookie];
|
||||
}
|
||||
|
||||
public static function verify(string $submitted, string $secret, string $expectedCookieValue): bool
|
||||
{
|
||||
$computed = hash_hmac('sha256', $submitted, $secret);
|
||||
|
||||
return hash_equals($expectedCookieValue, $computed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class Escape
|
||||
{
|
||||
public static function html(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
public static function attr(mixed $value): string
|
||||
{
|
||||
return self::html($value);
|
||||
}
|
||||
|
||||
public static function url(string $value): string
|
||||
{
|
||||
return rawurlencode($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class GetAssets
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $placeholderDir,
|
||||
private readonly string $uploadsRoot,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$kind = $params['kind'] ?? '';
|
||||
$filename = $params['filename'] ?? '';
|
||||
|
||||
if ($kind === 'placeholders') {
|
||||
return $this->serveFrom($this->placeholderDir . '/' . $filename);
|
||||
}
|
||||
|
||||
if ($kind === 'uploads') {
|
||||
$userToken = $params['userToken'] ?? '';
|
||||
if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) {
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
$requestToken = (string) ($request->server['__uploads_token'] ?? '');
|
||||
|
||||
if ($userToken !== $requestToken) {
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename);
|
||||
}
|
||||
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
private function serveFrom(string $path): Response
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
$body = file_get_contents($path);
|
||||
if ($body === false) {
|
||||
return Response::html(500, '<h1>Read error</h1>');
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$contentType = match ($ext) {
|
||||
'png' => 'image/png',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'webp' => 'image/webp',
|
||||
'gif' => 'image/gif',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
|
||||
return new Response(200, ['Content-Type' => $contentType] + self::securityHeaders(), $body);
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private static function securityHeaders(): array
|
||||
{
|
||||
return [
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Referrer-Policy' => 'same-origin',
|
||||
'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class GetBattlefieldEditor
|
||||
{
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$csrf = $request->cookies['__csrf_token'] ?? '';
|
||||
$scenarioId = $params['id'] ?? 'new';
|
||||
$width = 8;
|
||||
$height = 8;
|
||||
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../../Views/layout.php';
|
||||
require __DIR__ . '/../../Views/battlefield-editor.php';
|
||||
$body = (string) ob_get_clean();
|
||||
|
||||
return Response::html(200, $body);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
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_token'] ?? '';
|
||||
$bundled = $this->loadBundledScenarios();
|
||||
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../../Views/layout.php';
|
||||
require __DIR__ . '/../../Views/home.php';
|
||||
$body = (string) ob_get_clean();
|
||||
|
||||
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_token'] ?? '';
|
||||
|
||||
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,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class GetTeamEditor
|
||||
{
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$csrf = $request->cookies['__csrf_token'] ?? '';
|
||||
$scenarioId = $params['id'] ?? 'new';
|
||||
|
||||
$error = null;
|
||||
$old = [];
|
||||
$post = [];
|
||||
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../../Views/layout.php';
|
||||
require __DIR__ . '/../../Views/team-editor.php';
|
||||
$body = (string) ob_get_clean();
|
||||
|
||||
return Response::html(200, $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Application\ScenarioDraft;
|
||||
use BattleForge\Application\ScenarioSerializer;
|
||||
use BattleForge\Domain\ScenarioValidator;
|
||||
use BattleForge\Http\CsrfToken;
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class PostBattlefieldEditor
|
||||
{
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$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 Response::json(403, ['error' => 'csrf']);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $exception) {
|
||||
return Response::json(400, ['ok' => false, 'errors' => ['Malformed JSON.']]);
|
||||
}
|
||||
|
||||
try {
|
||||
$draft = ScenarioDraft::fromPost($payload);
|
||||
$scenario = $draft->toScenario();
|
||||
ScenarioValidator::validate($scenario);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
return Response::json(400, ['ok' => false, 'errors' => [$exception->getMessage()]]);
|
||||
}
|
||||
|
||||
return Response::json(200, ['ok' => true, 'scenario' => ScenarioSerializer::scenarioToArray($scenario)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Application\ImageUploadService;
|
||||
use BattleForge\Application\InvalidImageException;
|
||||
use BattleForge\Http\CsrfToken;
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class PostImageUpload
|
||||
{
|
||||
public function __construct(private readonly string $uploadsRoot)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$submitted = (string) ($request->post['_csrf'] ?? '');
|
||||
$expected = (string) ($request->cookies['__csrf'] ?? '');
|
||||
$secret = (string) ($request->server['__csrf_secret'] ?? '');
|
||||
|
||||
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
|
||||
return Response::json(403, ['error' => 'csrf']);
|
||||
}
|
||||
|
||||
$file = $request->files['image'] ?? null;
|
||||
if ($file === null) {
|
||||
return Response::json(400, ['error' => 'No image uploaded.']);
|
||||
}
|
||||
|
||||
$tempPath = (string) ($file['tmp_name'] ?? '');
|
||||
$declaredMime = (string) ($file['type'] ?? '');
|
||||
|
||||
if ($tempPath === '' || !is_file($tempPath)) {
|
||||
// Production: $request->files['image']['tmp_name'] is PHP-set from the multipart body,
|
||||
// so is_file() confirms the temp file exists. is_uploaded_file() is stricter but
|
||||
// unsuitable for unit tests that synthesize tmp files via tempnam().
|
||||
return Response::json(400, ['error' => 'Upload is not a file.']);
|
||||
}
|
||||
|
||||
$userToken = hash_hmac('sha256', $secret, 'bf-uploads');
|
||||
|
||||
try {
|
||||
$service = new ImageUploadService($userToken, $this->uploadsRoot);
|
||||
$url = $service->store($tempPath, $declaredMime);
|
||||
} catch (InvalidImageException $exception) {
|
||||
return Response::json(400, ['error' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return Response::json(200, ['url' => $url]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class PostStartMatch
|
||||
{
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$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 Response::json(403, ['error' => 'csrf']);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR);
|
||||
// Accept either the canonical `Scenario` shape (used by the bundled
|
||||
// scenario endpoint at /scenarios/bundled/{id}, where the JS POSTs
|
||||
// the JSON it fetched verbatim) or the editor's `ScenarioDraft`
|
||||
// shape (the form's denormalized fields). The presence of a
|
||||
// nested `battlefield` object distinguishes the two.
|
||||
if (is_array($payload) && is_array($payload['battlefield'] ?? null)) {
|
||||
$scenario = ScenarioSerializer::scenarioFromArray($payload);
|
||||
} else {
|
||||
$draft = ScenarioDraft::fromPost($payload ?? []);
|
||||
$scenario = $draft->toScenario();
|
||||
}
|
||||
ScenarioValidator::validate($scenario);
|
||||
$match = $scenario->startMatch('alpha');
|
||||
} catch (\JsonException $exception) {
|
||||
return Response::json(400, ['errors' => ['Malformed JSON.']]);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
return Response::json(400, ['errors' => [$exception->getMessage()]]);
|
||||
}
|
||||
|
||||
$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,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Application\ScenarioDraft;
|
||||
use BattleForge\Domain\ScenarioValidator;
|
||||
use BattleForge\Http\CsrfToken;
|
||||
use BattleForge\Http\Escape;
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class PostTeamEditor
|
||||
{
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$submitted = (string) ($request->post['_csrf'] ?? '');
|
||||
$expected = (string) ($request->cookies['__csrf'] ?? '');
|
||||
$secret = (string) ($request->server['__csrf_secret'] ?? '');
|
||||
|
||||
if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) {
|
||||
return Response::html(403, '<h1>Forbidden</h1>');
|
||||
}
|
||||
|
||||
try {
|
||||
$draft = ScenarioDraft::fromPost($request->post);
|
||||
$scenario = $draft->toScenario();
|
||||
ScenarioValidator::validate($scenario);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
return $this->renderForm($request, $params['id'] ?? 'new', $request->cookies['__csrf'] ?? '', $exception->getMessage(), $request->post);
|
||||
}
|
||||
|
||||
$json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR);
|
||||
$url = (string) ($params['id'] ?? $scenario->id);
|
||||
$safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
$body = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Saved</title></head>
|
||||
<body>
|
||||
<p>Scenario saved.</p>
|
||||
<script type="module">
|
||||
localStorage.setItem('scenario:{$safeUrl}', $json);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
return Response::html(200, $body);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $post */
|
||||
private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response
|
||||
{
|
||||
$error = Escape::html($error);
|
||||
$csrf = Escape::html($csrf);
|
||||
$scenarioId = Escape::html($scenarioId);
|
||||
|
||||
$old = array_intersect_key($post, array_flip([
|
||||
'id',
|
||||
'name',
|
||||
'battlefieldWidth',
|
||||
'battlefieldHeight',
|
||||
'victoryCondition',
|
||||
'holdRoundsRequired',
|
||||
]));
|
||||
$old = array_map(static fn ($v): string => is_scalar($v) ? (string) $v : '', $old);
|
||||
|
||||
$teamA = $post['teamA']['units'] ?? [];
|
||||
$teamB = $post['teamB']['units'] ?? [];
|
||||
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../../Views/layout.php';
|
||||
require __DIR__ . '/../../Views/team-editor.php';
|
||||
$body = (string) ob_get_clean();
|
||||
|
||||
return Response::html(200, $body);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array
|
||||
{
|
||||
return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final readonly class Request
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $get
|
||||
* @param array<string, mixed> $post
|
||||
* @param array<string, array<string, mixed>> $files
|
||||
* @param array<string, string> $cookies
|
||||
* @param array<string, string> $server
|
||||
*/
|
||||
public function __construct(
|
||||
public array $get,
|
||||
public array $post,
|
||||
public array $files,
|
||||
public array $cookies,
|
||||
public array $server,
|
||||
public string $queryString,
|
||||
public string $method,
|
||||
public string $path,
|
||||
public ?string $contentType,
|
||||
public string $rawBody,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class Response
|
||||
{
|
||||
private const SECURITY_HEADERS = [
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Referrer-Policy' => 'same-origin',
|
||||
'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
|
||||
];
|
||||
|
||||
/** @param array<string, string> $headers */
|
||||
public function __construct(
|
||||
public int $status,
|
||||
public array $headers,
|
||||
public string $body,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function html(int $status, string $body): self
|
||||
{
|
||||
return new self(
|
||||
$status,
|
||||
['Content-Type' => 'text/html; charset=utf-8'] + self::SECURITY_HEADERS,
|
||||
$body,
|
||||
);
|
||||
}
|
||||
|
||||
public static function json(int $status, mixed $body): self
|
||||
{
|
||||
return new self(
|
||||
$status,
|
||||
['Content-Type' => 'application/json; charset=utf-8'] + self::SECURITY_HEADERS,
|
||||
json_encode($body, JSON_THROW_ON_ERROR),
|
||||
);
|
||||
}
|
||||
|
||||
public static function redirect(string $location, int $status = 303): self
|
||||
{
|
||||
return new self(
|
||||
$status,
|
||||
['Location' => $location] + self::SECURITY_HEADERS,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class Router
|
||||
{
|
||||
/** @var list<array{method: string, pattern: string, handler: callable(Request, array<string, string>): Response}> */
|
||||
private array $routes = [];
|
||||
|
||||
public function add(string $method, string $path, callable $handler): void
|
||||
{
|
||||
$pattern = '#^' . preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $path) . '$#';
|
||||
|
||||
$this->routes[] = [
|
||||
'method' => strtoupper($method),
|
||||
'pattern' => $pattern,
|
||||
'handler' => $handler,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(Request $request): Response
|
||||
{
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $request->method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($route['pattern'], $request->path, $matches) === 1) {
|
||||
$params = [];
|
||||
|
||||
foreach ($matches as $key => $value) {
|
||||
if (is_string($key)) {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return ($route['handler'])($request, $params);
|
||||
}
|
||||
}
|
||||
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use BattleForge\Http\Escape;
|
||||
|
||||
/**
|
||||
* @var string $csrf Provided by the front controller.
|
||||
* @var string $scenarioId Provided by the front controller from the URL.
|
||||
* @var int $width Battlefield width (8-16).
|
||||
* @var int $height Battlefield height (8-16).
|
||||
*/
|
||||
$e = static fn (string $v): string => Escape::html($v);
|
||||
$a = static fn (string $v): string => Escape::attr($v);
|
||||
$terrain = ['open', 'forest', 'rough', 'water', 'blocking'];
|
||||
?><?php
|
||||
render_layout(static function () use ($csrf, $e, $a, $scenarioId, $width, $height, $terrain): void {
|
||||
?>
|
||||
<h1>Battlefield editor</h1>
|
||||
<p>Scenario: <strong><?= $e($scenarioId) ?></strong></p>
|
||||
<form id="battlefield-form" method="post" action="<?= $a('/scenarios/' . $e($scenarioId) . '/edit/battlefield') ?>" enctype="application/x-www-form-urlencoded" data-scenario-id="<?= $a($scenarioId) ?>">
|
||||
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
|
||||
<fieldset>
|
||||
<legend>Battlefield</legend>
|
||||
<label>Width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e((string) $width) ?>" required></label>
|
||||
<label>Height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e((string) $height) ?>" required></label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Terrain palette</legend>
|
||||
<?php foreach ($terrain as $t) : ?>
|
||||
<button type="button" data-paint="<?= $a($t) ?>" class="bf-paint"><?= $e($t) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Grid (<?= $e((string) $width) ?> × <?= $e((string) $height) ?>)</legend>
|
||||
<table class="bf-grid" id="bf-grid">
|
||||
<?php for ($y = 0; $y < $height; $y++) : ?>
|
||||
<tr>
|
||||
<?php for ($x = 0; $x < $width; $x++) : ?>
|
||||
<td><button type="button" class="bf-tile" data-x="<?= $e((string) $x) ?>" data-y="<?= $e((string) $y) ?>" data-paint="open">.</button></td>
|
||||
<?php endfor; ?>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</table>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Deployment zones</legend>
|
||||
<label><input type="radio" name="zoneMode" value="alpha" checked> Place team A zones</label>
|
||||
<label><input type="radio" name="zoneMode" value="bravo"> Place team B zones</label>
|
||||
<label><input type="radio" name="zoneMode" value="objective"> Place objective</label>
|
||||
</fieldset>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<script type="module" src="<?= $a('/js/grid-editor.js') ?>"></script>
|
||||
<?php
|
||||
}, $csrf, 'Battlefield editor');
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
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, $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');
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use BattleForge\Http\Escape;
|
||||
|
||||
/**
|
||||
* Render the shared document chrome around a body closure.
|
||||
*
|
||||
* @param callable(): void $body Emits the template's body content.
|
||||
* @param string $csrf The pre-issued CSRF token (HMAC-verified by the front controller).
|
||||
* @param string $title Page title; HTML-escaped by the layout.
|
||||
* @param string $extraHead Optional extra `<head>` content (e.g. a per-template script tag).
|
||||
*/
|
||||
function render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void
|
||||
{
|
||||
$e = static fn (string $v): string => Escape::html($v);
|
||||
$a = static fn (string $v): string => Escape::attr($v);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= $e($title) ?></title>
|
||||
<link rel="stylesheet" href="<?= $a('/assets/styles.css') ?>">
|
||||
<meta name="csrf-token" content="<?= $a($csrf) ?>">
|
||||
<?= $extraHead /* trusted raw HTML for the optional extra-head block */ ?>
|
||||
</head>
|
||||
<body>
|
||||
<?php $body(); ?>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
@@ -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,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\ArchetypeCatalog;
|
||||
use BattleForge\Http\Escape;
|
||||
|
||||
/**
|
||||
* @var string $csrf Provided by the front controller.
|
||||
* @var ?string $error Optional validator error message; HTML-escaped on output.
|
||||
* @var array<string, string> $old Optional old form values, keyed by field name; HTML-escaped on output.
|
||||
* @var array<string, mixed> $post Raw form data (for repopulating per-unit fields).
|
||||
*/
|
||||
$e = static fn (string $v): string => Escape::html($v);
|
||||
$a = static fn (string $v): string => Escape::attr($v);
|
||||
$archetypes = ArchetypeCatalog::templates();
|
||||
?><?php
|
||||
render_layout(static function () use ($csrf, $e, $a, $archetypes, $error, $old, $post): void {
|
||||
?>
|
||||
<h1>Team editor</h1>
|
||||
<?php if ($error !== null) : ?>
|
||||
<div class="bf-errors"><p><?= $e($error) ?></p></div>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="<?= $a('/scenarios/' . $e($old['id'] ?? 'new') . '/edit/team') ?>" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="<?= $a($csrf) ?>">
|
||||
<fieldset>
|
||||
<legend>Scenario</legend>
|
||||
<label>Id: <input type="text" name="id" value="<?= $e($old['id'] ?? '') ?>" required></label>
|
||||
<label>Name: <input type="text" name="name" value="<?= $e($old['name'] ?? '') ?>" required></label>
|
||||
<label>Battlefield width: <input type="number" name="battlefieldWidth" min="8" max="16" value="<?= $e($old['battlefieldWidth'] ?? '8') ?>" required></label>
|
||||
<label>Battlefield height: <input type="number" name="battlefieldHeight" min="8" max="16" value="<?= $e($old['battlefieldHeight'] ?? '8') ?>" required></label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Team A</legend>
|
||||
<?php for ($i = 0; $i < 3; $i++) :
|
||||
$row = $post['teamA']['units'][$i] ?? null; ?>
|
||||
<div class="bf-unit">
|
||||
<input type="text" name="teamA[units][<?= $i ?>][id]" placeholder="a-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
|
||||
<select name="teamA[units][<?= $i ?>][archetype]" required>
|
||||
<?php foreach ($archetypes as $key => $template) : ?>
|
||||
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input type="number" name="teamA[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
|
||||
<input type="number" name="teamA[units][<?= $i ?>][attack]" placeholder="attack" required>
|
||||
<input type="number" name="teamA[units][<?= $i ?>][defense]" placeholder="defense" required>
|
||||
<input type="number" name="teamA[units][<?= $i ?>][speed]" placeholder="speed" required>
|
||||
<input type="text" name="teamA[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '0') ?>" required>
|
||||
<input type="text" name="teamA[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '0') ?>" required>
|
||||
<input type="file" name="teamA[units][<?= $i ?>][image]" accept="image/*">
|
||||
<input type="hidden" name="teamA[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Team B</legend>
|
||||
<?php for ($i = 0; $i < 3; $i++) :
|
||||
$row = $post['teamB']['units'][$i] ?? null; ?>
|
||||
<div class="bf-unit">
|
||||
<input type="text" name="teamB[units][<?= $i ?>][id]" placeholder="b-<?= $i + 1 ?>" value="<?= $e($row['id'] ?? '') ?>" required>
|
||||
<select name="teamB[units][<?= $i ?>][archetype]" required>
|
||||
<?php foreach ($archetypes as $key => $template) : ?>
|
||||
<option value="<?= $a($key) ?>"<?= (($row['archetype'] ?? '') === $key) ? ' selected' : '' ?>><?= $e($key) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input type="number" name="teamB[units][<?= $i ?>][maxHealth]" placeholder="maxHealth" required>
|
||||
<input type="number" name="teamB[units][<?= $i ?>][attack]" placeholder="attack" required>
|
||||
<input type="number" name="teamB[units][<?= $i ?>][defense]" placeholder="defense" required>
|
||||
<input type="number" name="teamB[units][<?= $i ?>][speed]" placeholder="speed" required>
|
||||
<input type="text" name="teamB[units][<?= $i ?>][x]" placeholder="x" value="<?= $e($row['x'] ?? '7') ?>" required>
|
||||
<input type="text" name="teamB[units][<?= $i ?>][y]" placeholder="y" value="<?= $e($row['y'] ?? '7') ?>" required>
|
||||
<input type="file" name="teamB[units][<?= $i ?>][image]" accept="image/*">
|
||||
<input type="hidden" name="teamB[units][<?= $i ?>][imageUrl]" value="<?= $e($row['imageUrl'] ?? '') ?>">
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Victory</legend>
|
||||
<label><input type="radio" name="victoryCondition" value="eliminate_all"<?= (($old['victoryCondition'] ?? 'eliminate_all') === 'eliminate_all') ? ' checked' : '' ?>> Eliminate all</label>
|
||||
<label><input type="radio" name="victoryCondition" value="hold_objective"<?= (($old['victoryCondition'] ?? '') === 'hold_objective') ? ' checked' : '' ?>> Hold objective</label>
|
||||
<label>Hold rounds: <input type="number" name="holdRoundsRequired" min="1" max="10" value="<?= $e($old['holdRoundsRequired'] ?? '1') ?>"></label>
|
||||
</fieldset>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="bf-start-match">Start match</button>
|
||||
</form>
|
||||
<script type="module" src="<?= $a('/js/storage.js') ?>"></script>
|
||||
<?php
|
||||
}, $csrf, 'Team editor');
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Placeholder so PHPUnit's integration test suite has a directory to scan.
|
||||
// The first real integration tests land in a later task.
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Integration;
|
||||
|
||||
use BattleForge\Http\CsrfToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FullFlowTest extends TestCase
|
||||
{
|
||||
private const SECRET = 'unit-test-secret';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Reset superglobals between requests.
|
||||
$_GET = [];
|
||||
$_POST = [];
|
||||
$_FILES = [];
|
||||
$_COOKIE = [];
|
||||
$_SERVER = [];
|
||||
}
|
||||
|
||||
public function testTheFullCreateAndSaveFlow(): void
|
||||
{
|
||||
[$token, $cookie] = CsrfToken::issue(self::SECRET);
|
||||
// Use a deterministic secret so the upload token HMAC matches between upload and fetch.
|
||||
putenv('BATTLEFORGE_SECRET=' . self::SECRET);
|
||||
|
||||
try {
|
||||
// Step 1: GET home page.
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_SERVER['REQUEST_URI'] = '/';
|
||||
$homeBody = $this->runFrontController();
|
||||
self::assertStringContainsString('New scenario', $homeBody);
|
||||
self::assertStringContainsString('name="csrf-token"', $homeBody);
|
||||
|
||||
// Step 2: POST team editor.
|
||||
$_COOKIE['__csrf'] = $cookie;
|
||||
$_COOKIE['__csrf_token'] = $token;
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/team';
|
||||
$_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
|
||||
$_SERVER['__csrf_secret'] = self::SECRET;
|
||||
$_POST = $this->validTeamPost($token);
|
||||
$teamBody = $this->runFrontController();
|
||||
self::assertSame(200, $this->lastStatus);
|
||||
self::assertStringContainsString('localStorage.setItem', $teamBody);
|
||||
self::assertStringContainsString('scenario:"demo"', $teamBody);
|
||||
|
||||
// Step 3: POST battlefield editor.
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/battlefield';
|
||||
$_SERVER['CONTENT_TYPE'] = 'application/json';
|
||||
$_SERVER['HTTP_X_CSRF_TOKEN'] = $token;
|
||||
$_POST = [];
|
||||
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
|
||||
$_SERVER['__raw_body'] = $this->rawBody;
|
||||
$bfBody = $this->runFrontController();
|
||||
self::assertSame(200, $this->lastStatus);
|
||||
$bfJson = json_decode($bfBody, true);
|
||||
self::assertSame(true, $bfJson['ok'] ?? null);
|
||||
|
||||
// Step 4: POST start-match.
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_SERVER['REQUEST_URI'] = '/scenarios/demo/start';
|
||||
$this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR);
|
||||
$_SERVER['__raw_body'] = $this->rawBody;
|
||||
$startBody = $this->runFrontController();
|
||||
self::assertSame(200, $this->lastStatus);
|
||||
$startJson = json_decode($startBody, true);
|
||||
self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null);
|
||||
self::assertSame(1, $startJson['match']['round'] ?? null);
|
||||
self::assertCount(6, $startJson['match']['units'] ?? []);
|
||||
|
||||
// Step 5: POST image upload, then GET the returned URL.
|
||||
$this->uploadAndFetchAsset();
|
||||
} finally {
|
||||
// Always purge the per-install uploads dir after the test.
|
||||
$uploadsRoot = __DIR__ . '/../../var/uploads';
|
||||
if (is_dir($uploadsRoot)) {
|
||||
foreach (glob($uploadsRoot . '/*/*') as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
foreach (glob($uploadsRoot . '/*') as $dir) {
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function uploadAndFetchAsset(): void
|
||||
{
|
||||
$expectedToken = hash_hmac('sha256', self::SECRET, 'bf-uploads');
|
||||
$expectedDir = realpath(__DIR__ . '/../../var/uploads') ?: (__DIR__ . '/../../var/uploads');
|
||||
|
||||
// Create the multipart body manually so we can pre-compute the file we expect to find.
|
||||
$png = base64_decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
|
||||
true,
|
||||
);
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'bf-upload-');
|
||||
file_put_contents($tmp, $png);
|
||||
|
||||
try {
|
||||
[$token, $cookie] = CsrfToken::issue(self::SECRET);
|
||||
$_COOKIE['__csrf'] = $cookie;
|
||||
$_COOKIE['__csrf_token'] = $token;
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_SERVER['REQUEST_URI'] = '/assets/upload';
|
||||
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----test';
|
||||
$_SERVER['__csrf_secret'] = self::SECRET;
|
||||
$_POST = ['_csrf' => $token];
|
||||
$_FILES = [
|
||||
'image' => [
|
||||
'name' => 'test.png',
|
||||
'type' => 'image/png',
|
||||
'tmp_name' => $tmp,
|
||||
'error' => 0,
|
||||
'size' => strlen($png),
|
||||
],
|
||||
];
|
||||
$this->rawBody = '';
|
||||
$_SERVER['__raw_body'] = '';
|
||||
$body = $this->runFrontController();
|
||||
self::assertSame(200, $this->lastStatus, 'upload response: ' . $body);
|
||||
$payload = json_decode($body, true);
|
||||
$url = is_array($payload) ? (string) ($payload['url'] ?? '') : '';
|
||||
self::assertNotSame('', $url);
|
||||
self::assertStringStartsWith('/assets/uploads/' . $expectedToken . '/', $url);
|
||||
|
||||
// Now fetch the asset back through the front controller.
|
||||
$_COOKIE = [];
|
||||
$_POST = [];
|
||||
$_FILES = [];
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_SERVER['REQUEST_URI'] = $url;
|
||||
unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN'], $_SERVER['__raw_body']);
|
||||
$this->rawBody = '';
|
||||
$fetchBody = $this->runFrontController();
|
||||
self::assertSame(200, $this->lastStatus, 'fetch response: ' . $fetchBody);
|
||||
self::assertSame($png, $fetchBody);
|
||||
|
||||
// Clean up the uploaded file so the outer finally does not loop on it.
|
||||
$filename = substr($url, strlen('/assets/uploads/' . $expectedToken . '/'));
|
||||
@unlink($expectedDir . '/' . $expectedToken . '/' . $filename);
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
private string $rawBody = '';
|
||||
private int $lastStatus = 0;
|
||||
|
||||
private function runFrontController(): string
|
||||
{
|
||||
$this->lastStatus = 0;
|
||||
$body = $this->captureOutput(function (): void {
|
||||
$this->lastStatus = (require __DIR__ . '/../../public/index.php') ?? http_response_code();
|
||||
});
|
||||
return $body;
|
||||
}
|
||||
|
||||
/** @param callable(): void $fn */
|
||||
private function captureOutput(callable $fn): string
|
||||
{
|
||||
ob_start();
|
||||
try {
|
||||
$fn();
|
||||
} finally {
|
||||
$output = (string) ob_get_clean();
|
||||
}
|
||||
return $output;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validTeamPost(string $token): array
|
||||
{
|
||||
return [
|
||||
'_csrf' => $token,
|
||||
'id' => 'demo',
|
||||
'name' => 'Demo',
|
||||
'battlefieldWidth' => '8',
|
||||
'battlefieldHeight' => '8',
|
||||
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
|
||||
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
|
||||
'victoryCondition' => 'eliminate_all',
|
||||
'holdRoundsRequired' => '1',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validBattlefieldPayload(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'demo',
|
||||
'name' => 'Demo',
|
||||
'battlefieldWidth' => 8,
|
||||
'battlefieldHeight' => 8,
|
||||
'battlefieldTerrain' => [],
|
||||
'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)],
|
||||
'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)],
|
||||
'victoryCondition' => 'eliminate_all',
|
||||
'holdRoundsRequired' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, string|int>>
|
||||
*/
|
||||
private function unitRows(string $teamPrefix, int $xStart, int $xEnd, int $y): array
|
||||
{
|
||||
$rows = [];
|
||||
for ($i = 0; $i <= $xEnd - $xStart; $i++) {
|
||||
$rows[] = [
|
||||
'id' => "{$teamPrefix}{$i}",
|
||||
'archetype' => 'defender',
|
||||
'maxHealth' => 12,
|
||||
'attack' => 3,
|
||||
'defense' => 4,
|
||||
'speed' => 2,
|
||||
'x' => $xStart + $i,
|
||||
'y' => $y,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Integration;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Handlers\GetAssets;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetAssetsTest extends TestCase
|
||||
{
|
||||
private string $placeholderDir;
|
||||
private string $uploadsDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->placeholderDir = sys_get_temp_dir() . '/bf-placeholders-' . bin2hex(random_bytes(4));
|
||||
mkdir($this->placeholderDir, 0755, true);
|
||||
// 1x1 red PNG
|
||||
$png = base64_decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
|
||||
true,
|
||||
);
|
||||
file_put_contents($this->placeholderDir . '/defender.png', $png);
|
||||
|
||||
$this->uploadsDir = sys_get_temp_dir() . '/bf-assets-' . bin2hex(random_bytes(4));
|
||||
mkdir($this->uploadsDir, 0700, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_dir($this->uploadsDir)) {
|
||||
foreach (glob($this->uploadsDir . '/*/*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
foreach (glob($this->uploadsDir . '/*') as $dir) {
|
||||
rmdir($dir);
|
||||
}
|
||||
rmdir($this->uploadsDir);
|
||||
}
|
||||
|
||||
if (is_dir($this->placeholderDir)) {
|
||||
foreach (glob($this->placeholderDir . '/*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($this->placeholderDir);
|
||||
}
|
||||
}
|
||||
|
||||
public function testItServesAPlaceholderImageWithoutAuth(): void
|
||||
{
|
||||
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
|
||||
$request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/defender.png', null, '');
|
||||
$response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'defender.png']);
|
||||
|
||||
self::assertSame(200, $response->status);
|
||||
self::assertSame('image/png', $response->headers['Content-Type'] ?? '');
|
||||
self::assertGreaterThan(0, strlen($response->body));
|
||||
}
|
||||
|
||||
public function testItReturns404ForAMissingFile(): void
|
||||
{
|
||||
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
|
||||
$request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/missing.png', null, '');
|
||||
$response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'missing.png']);
|
||||
|
||||
self::assertSame(404, $response->status);
|
||||
}
|
||||
|
||||
public function testItServesAnUploadedAssetWhenTheServerTokenMatches(): void
|
||||
{
|
||||
$userToken = str_repeat('a', 64);
|
||||
$namespace = $this->uploadsDir . '/' . $userToken;
|
||||
mkdir($namespace, 0700, true);
|
||||
$payload = base64_decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
|
||||
true,
|
||||
);
|
||||
file_put_contents($namespace . '/demo.png', $payload);
|
||||
|
||||
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
|
||||
$request = new Request(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['__uploads_token' => $userToken],
|
||||
'',
|
||||
'GET',
|
||||
'/assets/uploads/' . $userToken . '/demo.png',
|
||||
null,
|
||||
'',
|
||||
);
|
||||
$response = $handler->handle(
|
||||
$request,
|
||||
['kind' => 'uploads', 'userToken' => $userToken, 'filename' => 'demo.png'],
|
||||
);
|
||||
|
||||
self::assertSame(200, $response->status);
|
||||
self::assertSame('image/png', $response->headers['Content-Type'] ?? '');
|
||||
self::assertSame($payload, $response->body);
|
||||
}
|
||||
|
||||
public function testItRefusesAnUploadedAssetWhenTheServerTokenDiffers(): void
|
||||
{
|
||||
$handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
|
||||
$request = new Request(
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['__uploads_token' => str_repeat('a', 64)],
|
||||
'',
|
||||
'GET',
|
||||
'/assets/uploads/abc/def.png',
|
||||
null,
|
||||
'',
|
||||
);
|
||||
$response = $handler->handle(
|
||||
$request,
|
||||
['kind' => 'uploads', 'userToken' => str_repeat('b', 64), 'filename' => 'def.png'],
|
||||
);
|
||||
|
||||
self::assertSame(404, $response->status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Integration;
|
||||
|
||||
use BattleForge\Http\Handlers\GetBattlefieldEditor;
|
||||
use BattleForge\Http\Request;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetBattlefieldEditorTest extends TestCase
|
||||
{
|
||||
public function testItReturnsTheBattlefieldEditorPageWithAnEmptyGrid(): void
|
||||
{
|
||||
$handler = new GetBattlefieldEditor();
|
||||
$request = new Request([], [], [], [], [], '', 'GET', '/scenarios/demo/edit/battlefield', 'text/html', '');
|
||||
|
||||
$response = $handler->handle($request, ['id' => 'demo']);
|
||||
|
||||
self::assertSame(200, $response->status);
|
||||
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
|
||||
self::assertStringContainsString('class="bf-grid"', $response->body);
|
||||
self::assertStringContainsString('name="_csrf"', $response->body);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user