Files
BattleForge/docs/superpowers/specs/2026-07-06-persistence-editors-design.md

19 KiB
Raw Permalink Blame History

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 toJsonfromJson; add fixtures for all four archetypes, both victory conditions, every terrain, and at least one uploaded-image reference.
  • ScenarioDraftTest: form fields → ScenarioDraftScenario 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.