Compare commits
15
Commits
1d69e89faf
...
87a7e10c06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87a7e10c06 | ||
|
|
3e4184f08d | ||
|
|
0f4a220e70 | ||
|
|
c24964ff96 | ||
|
|
6399e2d86c | ||
|
|
288c42e176 | ||
|
|
8b83df60cd | ||
|
|
3263975d2a | ||
|
|
9ce0c2ac20 | ||
|
|
67de6dab75 | ||
|
|
a05baeb4f6 | ||
|
|
eed678e688 | ||
|
|
17782aab33 | ||
|
|
8df51fc1ed | ||
|
|
18520fee22 |
+11
-1
@@ -1,3 +1,13 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
/vendor/
|
/vendor/
|
||||||
/var/
|
/var/cache/
|
||||||
|
/var/phpunit/
|
||||||
|
/var/phpstan/
|
||||||
|
/var/logs/
|
||||||
|
/var/uploads/*
|
||||||
|
!/var/uploads/.htaccess
|
||||||
|
!/var/uploads/index.php
|
||||||
|
/node_modules/
|
||||||
|
/public/js/*.map
|
||||||
|
.phpunit.result.cache
|
||||||
|
.phpunit.cache
|
||||||
|
|||||||
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.
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<file>src</file>
|
<file>src</file>
|
||||||
<file>tests</file>
|
<file>tests</file>
|
||||||
|
<file>public/index.php</file>
|
||||||
|
|
||||||
<arg name="colors"/>
|
<arg name="colors"/>
|
||||||
<arg value="sp"/>
|
<arg value="sp"/>
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ parameters:
|
|||||||
paths:
|
paths:
|
||||||
- src
|
- src
|
||||||
- tests
|
- tests
|
||||||
|
- public/index.php
|
||||||
|
excludePaths:
|
||||||
|
- src/Views/*
|
||||||
tmpDir: var/phpstan
|
tmpDir: var/phpstan
|
||||||
|
|||||||
+5
-2
@@ -5,8 +5,11 @@
|
|||||||
cacheDirectory="var/phpunit"
|
cacheDirectory="var/phpunit"
|
||||||
colors="true">
|
colors="true">
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="BattleForge Test Suite">
|
<testsuite name="unit">
|
||||||
<directory>tests</directory>
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="integration">
|
||||||
|
<directory>tests/Integration</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
* { 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-toast { background: #dfd; padding: 0.5rem 1rem; margin: 0.5rem 0; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Front controller. The router and handler dispatch land in Task 13.
|
||||||
|
// For now this returns a 200 with a stub so the dev server is reachable.
|
||||||
|
http_response_code(200);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "BattleForge dev server up.\n";
|
||||||
@@ -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/' . $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,270 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
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,
|
||||||
|
'archetype' => $unit->archetype->value,
|
||||||
|
'abilities' => $unit->abilities,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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: 2,
|
||||||
|
hasAttacked: false,
|
||||||
|
archetype: $archetype,
|
||||||
|
abilities: $abilities ?? [],
|
||||||
|
attackBonus: 0,
|
||||||
|
hasUsedAbility: 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,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,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,74 @@
|
|||||||
|
<?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'] ?? '';
|
||||||
|
$requestToken = $request->cookies['__uploads_token'] ?? '';
|
||||||
|
|
||||||
|
if ($userToken === '' || $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,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Http\Handlers;
|
||||||
|
|
||||||
|
use BattleForge\Http\Request;
|
||||||
|
use BattleForge\Http\Response;
|
||||||
|
|
||||||
|
final class GetHomePage
|
||||||
|
{
|
||||||
|
/** @param array<string, string> $params */
|
||||||
|
public function handle(Request $request, array $params): Response
|
||||||
|
{
|
||||||
|
$body = <<<'HTML'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>BattleForge</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
|
<meta name="csrf-token" content="{{ csrf }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>BattleForge</h1>
|
||||||
|
<p><a href="/scenarios/new/edit/team">New scenario</a></p>
|
||||||
|
<h2>Recent scenarios</h2>
|
||||||
|
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
|
||||||
|
<script type="module" src="/js/storage.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
// The CSRF token placeholder is filled by the front controller (Task 16)
|
||||||
|
// before the body is sent. The handler does not see the cookie or the
|
||||||
|
// secret; it just receives a pre-issued token from the front controller.
|
||||||
|
$token = $request->cookies['__csrf'] ?? '';
|
||||||
|
$body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body);
|
||||||
|
|
||||||
|
return Response::html(200, $body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,70 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Integration;
|
||||||
|
|
||||||
|
use BattleForge\Http\Request;
|
||||||
|
use BattleForge\Http\Response;
|
||||||
|
use BattleForge\Http\Handlers\GetHomePage;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GetHomePageTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItReturnsTheHomePageWithSecurityHeaders(): void
|
||||||
|
{
|
||||||
|
$handler = new GetHomePage();
|
||||||
|
$request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
|
||||||
|
$response = $handler->handle($request, []);
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status);
|
||||||
|
self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? '');
|
||||||
|
self::assertSame('nosniff', $response->headers['X-Content-Type-Options']);
|
||||||
|
self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']);
|
||||||
|
self::assertStringContainsString('<a href="/scenarios/new/edit/team">', $response->body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Application;
|
||||||
|
|
||||||
|
use BattleForge\Application\ImageUploadService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ImageUploadServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tmpDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->tmpDir = sys_get_temp_dir() . '/bf-uploads-' . 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);
|
||||||
|
}
|
||||||
|
foreach (glob($this->tmpDir . '/*') as $dir) {
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
rmdir($this->tmpDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItStoresAValidImageAndReturnsAStableUrl(): void
|
||||||
|
{
|
||||||
|
$service = new ImageUploadService('user-token-1', $this->tmpDir);
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
|
||||||
|
file_put_contents($tmp, self::validPng(8, 8));
|
||||||
|
|
||||||
|
$url = $service->store($tmp, 'image/png');
|
||||||
|
|
||||||
|
self::assertStringStartsWith('/assets/user-token-1/', $url);
|
||||||
|
self::assertStringEndsWith('.png', $url);
|
||||||
|
|
||||||
|
$stored = $this->tmpDir . '/user-token-1/' . basename($url);
|
||||||
|
self::assertFileExists($stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItCreatesTheUserNamespaceDirectory(): void
|
||||||
|
{
|
||||||
|
$service = new ImageUploadService('fresh-user', $this->tmpDir);
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
|
||||||
|
file_put_contents($tmp, self::validPng(8, 8));
|
||||||
|
|
||||||
|
$service->store($tmp, 'image/png');
|
||||||
|
|
||||||
|
self::assertDirectoryExists($this->tmpDir . '/fresh-user');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItProducesUniqueFilenamesForRepeatedUploads(): void
|
||||||
|
{
|
||||||
|
$service = new ImageUploadService('user-token-2', $this->tmpDir);
|
||||||
|
$tmp1 = tempnam(sys_get_temp_dir(), 'bf-up');
|
||||||
|
$tmp2 = tempnam(sys_get_temp_dir(), 'bf-up');
|
||||||
|
file_put_contents($tmp1, self::validPng(8, 8));
|
||||||
|
file_put_contents($tmp2, self::validPng(8, 8));
|
||||||
|
|
||||||
|
$url1 = $service->store($tmp1, 'image/png');
|
||||||
|
$url2 = $service->store($tmp2, 'image/png');
|
||||||
|
|
||||||
|
self::assertNotSame($url1, $url2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnInvalidImage(): void
|
||||||
|
{
|
||||||
|
$service = new ImageUploadService('user-token-3', $this->tmpDir);
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'bf-up');
|
||||||
|
file_put_contents($tmp, 'not an image');
|
||||||
|
|
||||||
|
$this->expectException(\BattleForge\Application\InvalidImageException::class);
|
||||||
|
$service->store($tmp, 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function validPng(int $width, int $height): string
|
||||||
|
{
|
||||||
|
if ($width === 8 && $height === 8) {
|
||||||
|
return base64_decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
|
||||||
|
'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new \InvalidArgumentException('Only 8x8 base PNG supported in tests.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Application;
|
||||||
|
|
||||||
|
use BattleForge\Application\ImageValidator;
|
||||||
|
use BattleForge\Application\InvalidImageException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ImageValidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private const TMP_DIR = '/tmp';
|
||||||
|
|
||||||
|
public function testItAcceptsAValidPng(): void
|
||||||
|
{
|
||||||
|
$path = self::TMP_DIR . '/bf-valid-' . bin2hex(random_bytes(4)) . '.png';
|
||||||
|
self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = ImageValidator::validate($path, 'image/png');
|
||||||
|
self::assertSame('image/png', $result->canonicalMime);
|
||||||
|
self::assertSame('png', $result->extension);
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAFileWithTheWrongDeclaredMime(): void
|
||||||
|
{
|
||||||
|
$path = self::TMP_DIR . '/bf-bad-mime-' . bin2hex(random_bytes(4)) . '.png';
|
||||||
|
self::assertNotFalse(file_put_contents($path, self::validPng(8, 8)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->expectException(InvalidImageException::class);
|
||||||
|
$this->expectExceptionMessage('Declared MIME does not match file contents');
|
||||||
|
ImageValidator::validate($path, 'image/jpeg');
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsTextContent(): void
|
||||||
|
{
|
||||||
|
$path = self::TMP_DIR . '/bf-text-' . bin2hex(random_bytes(4)) . '.txt';
|
||||||
|
self::assertNotFalse(file_put_contents($path, 'this is not an image'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->expectException(InvalidImageException::class);
|
||||||
|
ImageValidator::validate($path, 'image/png');
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsOversizedFiles(): void
|
||||||
|
{
|
||||||
|
$path = self::TMP_DIR . '/bf-huge-' . bin2hex(random_bytes(4)) . '.png';
|
||||||
|
// Create a 2 MB + 1 byte file
|
||||||
|
$bytes = str_repeat('A', 2_000_001);
|
||||||
|
self::assertNotFalse(file_put_contents($path, $bytes));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->expectException(InvalidImageException::class);
|
||||||
|
$this->expectExceptionMessage('exceeds the 2000000 byte limit');
|
||||||
|
ImageValidator::validate($path, 'image/png');
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsOversizedDimensions(): void
|
||||||
|
{
|
||||||
|
$path = self::TMP_DIR . '/bf-wide-' . bin2hex(random_bytes(4)) . '.png';
|
||||||
|
self::assertNotFalse(file_put_contents($path, self::validPng(600, 8)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->expectException(InvalidImageException::class);
|
||||||
|
$this->expectExceptionMessage('exceed the 512 pixel limit');
|
||||||
|
ImageValidator::validate($path, 'image/png');
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItAcceptsJpegWebpAndGif(): void
|
||||||
|
{
|
||||||
|
$jpeg = self::TMP_DIR . '/bf-jpeg-' . bin2hex(random_bytes(4)) . '.jpg';
|
||||||
|
$webp = self::TMP_DIR . '/bf-webp-' . bin2hex(random_bytes(4)) . '.webp';
|
||||||
|
$gif = self::TMP_DIR . '/bf-gif-' . bin2hex(random_bytes(4)) . '.gif';
|
||||||
|
file_put_contents($jpeg, self::validJpeg(8, 8));
|
||||||
|
file_put_contents($webp, self::validWebp(8, 8));
|
||||||
|
file_put_contents($gif, self::validGif(8, 8));
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::assertSame('jpg', ImageValidator::validate($jpeg, 'image/jpeg')->extension);
|
||||||
|
self::assertSame('webp', ImageValidator::validate($webp, 'image/webp')->extension);
|
||||||
|
self::assertSame('gif', ImageValidator::validate($gif, 'image/gif')->extension);
|
||||||
|
} finally {
|
||||||
|
unlink($jpeg);
|
||||||
|
unlink($webp);
|
||||||
|
unlink($gif);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal valid PNG of the given dimensions, using a pre-baked
|
||||||
|
* 8x8 transparent PNG as the base and trusting `getimagesize` to accept
|
||||||
|
* any correctly-formed PNG. We just need the file to (1) have the PNG
|
||||||
|
* magic and (2) be decodable by GD or `getimagesize`.
|
||||||
|
*
|
||||||
|
* For the oversized-dimensions test we synthesize a 600x8 PNG via
|
||||||
|
* `imagecreatetruecolor` + `imagepng` to force the IHDR to claim those
|
||||||
|
* dimensions.
|
||||||
|
*/
|
||||||
|
private static function validPng(int $width, int $height): string
|
||||||
|
{
|
||||||
|
if ($width === 8 && $height === 8) {
|
||||||
|
return base64_decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' .
|
||||||
|
'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$im = imagecreatetruecolor($width, $height);
|
||||||
|
if ($im === false) {
|
||||||
|
throw new \RuntimeException('Failed to create image.');
|
||||||
|
}
|
||||||
|
ob_start();
|
||||||
|
imagepng($im);
|
||||||
|
$bytes = ob_get_clean();
|
||||||
|
imagedestroy($im);
|
||||||
|
|
||||||
|
return (string) $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function validJpeg(int $width, int $height): string
|
||||||
|
{
|
||||||
|
$im = imagecreatetruecolor($width, $height);
|
||||||
|
if ($im === false) {
|
||||||
|
throw new \RuntimeException('Failed to create image.');
|
||||||
|
}
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($im);
|
||||||
|
$bytes = ob_get_clean();
|
||||||
|
imagedestroy($im);
|
||||||
|
|
||||||
|
return (string) $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function validWebp(int $width, int $height): string
|
||||||
|
{
|
||||||
|
$im = imagecreatetruecolor($width, $height);
|
||||||
|
if ($im === false) {
|
||||||
|
throw new \RuntimeException('Failed to create image.');
|
||||||
|
}
|
||||||
|
ob_start();
|
||||||
|
imagewebp($im);
|
||||||
|
$bytes = ob_get_clean();
|
||||||
|
imagedestroy($im);
|
||||||
|
|
||||||
|
return (string) $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function validGif(int $width, int $height): string
|
||||||
|
{
|
||||||
|
$im = imagecreatetruecolor($width, $height);
|
||||||
|
if ($im === false) {
|
||||||
|
throw new \RuntimeException('Failed to create image.');
|
||||||
|
}
|
||||||
|
ob_start();
|
||||||
|
imagegif($im);
|
||||||
|
$bytes = ob_get_clean();
|
||||||
|
imagedestroy($im);
|
||||||
|
|
||||||
|
return (string) $bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Application;
|
||||||
|
|
||||||
|
use BattleForge\Application\ScenarioDraft;
|
||||||
|
use BattleForge\Domain\Archetype;
|
||||||
|
use BattleForge\Domain\Battlefield;
|
||||||
|
use BattleForge\Domain\DeploymentZone;
|
||||||
|
use BattleForge\Domain\Position;
|
||||||
|
use BattleForge\Domain\Scenario;
|
||||||
|
use BattleForge\Domain\Terrain;
|
||||||
|
use BattleForge\Domain\UnitState;
|
||||||
|
use BattleForge\Domain\VictoryCondition;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ScenarioDraftTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItBuildsAScenarioFromFormFields(): void
|
||||||
|
{
|
||||||
|
$post = [
|
||||||
|
'id' => 'demo',
|
||||||
|
'name' => 'Demo',
|
||||||
|
'battlefieldWidth' => '8',
|
||||||
|
'battlefieldHeight' => '8',
|
||||||
|
'battlefieldTerrain' => [
|
||||||
|
'0:0' => 'forest',
|
||||||
|
],
|
||||||
|
'teamA' => [
|
||||||
|
'units' => [
|
||||||
|
[
|
||||||
|
'id' => 'a1',
|
||||||
|
'x' => '0',
|
||||||
|
'y' => '0',
|
||||||
|
'archetype' => 'defender',
|
||||||
|
'maxHealth' => '12',
|
||||||
|
'attack' => '3',
|
||||||
|
'defense' => '4',
|
||||||
|
'speed' => '2',
|
||||||
|
'abilities' => ['buff'],
|
||||||
|
'image' => '/assets/placeholders/defender.png',
|
||||||
|
],
|
||||||
|
['id' => 'a2', 'x' => '1', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
|
||||||
|
['id' => 'a3', 'x' => '2', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'teamB' => [
|
||||||
|
'units' => [
|
||||||
|
['id' => 'b1', 'x' => '7', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => ['area_damage'], 'image' => ''],
|
||||||
|
['id' => 'b2', 'x' => '6', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
|
||||||
|
['id' => 'b3', 'x' => '5', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'victoryCondition' => 'eliminate_all',
|
||||||
|
'holdRoundsRequired' => '1',
|
||||||
|
];
|
||||||
|
|
||||||
|
$draft = ScenarioDraft::fromPost($post);
|
||||||
|
$scenario = $draft->toScenario();
|
||||||
|
|
||||||
|
self::assertSame('demo', $scenario->id);
|
||||||
|
self::assertSame(8, $scenario->battlefield->width);
|
||||||
|
self::assertSame(Terrain::Forest, $scenario->battlefield->terrainAt(new Position(0, 0)));
|
||||||
|
self::assertCount(6, $scenario->units);
|
||||||
|
self::assertSame('a1', $scenario->units[0]->id);
|
||||||
|
self::assertSame(Archetype::Defender, $scenario->units[0]->archetype);
|
||||||
|
self::assertSame(['buff'], $scenario->units[0]->abilities);
|
||||||
|
self::assertSame(VictoryCondition::EliminateAll, $scenario->victoryCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsUnknownArchetype(): void
|
||||||
|
{
|
||||||
|
$post = $this->validPost();
|
||||||
|
$post['teamA']['units'][0]['archetype'] = 'rogue';
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
ScenarioDraft::fromPost($post)->toScenario();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsUnknownTerrain(): void
|
||||||
|
{
|
||||||
|
$post = $this->validPost();
|
||||||
|
$post['battlefieldTerrain'] = ['0:0' => 'lava'];
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
ScenarioDraft::fromPost($post)->toScenario();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsUnknownVictoryCondition(): void
|
||||||
|
{
|
||||||
|
$post = $this->validPost();
|
||||||
|
$post['victoryCondition'] = 'first_blood';
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
ScenarioDraft::fromPost($post)->toScenario();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validPost(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => 'demo',
|
||||||
|
'name' => 'Demo',
|
||||||
|
'battlefieldWidth' => '8',
|
||||||
|
'battlefieldHeight' => '8',
|
||||||
|
'teamA' => [
|
||||||
|
'units' => [
|
||||||
|
['id' => 'a1', 'x' => '0', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
|
||||||
|
['id' => 'a2', 'x' => '1', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
|
||||||
|
['id' => 'a3', 'x' => '2', 'y' => '0', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'abilities' => [], 'image' => ''],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'teamB' => [
|
||||||
|
'units' => [
|
||||||
|
['id' => 'b1', 'x' => '7', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
|
||||||
|
['id' => 'b2', 'x' => '6', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
|
||||||
|
['id' => 'b3', 'x' => '5', 'y' => '7', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'abilities' => [], 'image' => ''],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'victoryCondition' => 'eliminate_all',
|
||||||
|
'holdRoundsRequired' => '1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Application;
|
||||||
|
|
||||||
|
use BattleForge\Application\ScenarioSerializer;
|
||||||
|
use BattleForge\Domain\Archetype;
|
||||||
|
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 PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ScenarioSerializerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItRoundTripsACompleteScenario(): void
|
||||||
|
{
|
||||||
|
$scenario = new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8, ['0:0' => Terrain::Forest]),
|
||||||
|
units: [
|
||||||
|
new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, ['buff'], 0, false),
|
||||||
|
new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
|
||||||
|
new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
|
||||||
|
new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, ['area_damage'], 0, false),
|
||||||
|
new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
|
||||||
|
new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
|
||||||
|
],
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$array = ScenarioSerializer::scenarioToArray($scenario);
|
||||||
|
$reconstructed = ScenarioSerializer::scenarioFromArray($array);
|
||||||
|
|
||||||
|
self::assertSame($scenario->id, $reconstructed->id);
|
||||||
|
self::assertSame($scenario->name, $reconstructed->name);
|
||||||
|
self::assertSame($scenario->battlefield->width, $reconstructed->battlefield->width);
|
||||||
|
self::assertSame($scenario->battlefield->height, $reconstructed->battlefield->height);
|
||||||
|
self::assertSame('forest', $reconstructed->battlefield->terrainAt(new Position(0, 0))->value);
|
||||||
|
self::assertCount(6, $reconstructed->units);
|
||||||
|
self::assertSame('alpha-1', $reconstructed->units[0]->id);
|
||||||
|
self::assertSame(Archetype::Defender, $reconstructed->units[0]->archetype);
|
||||||
|
self::assertSame(['buff'], $reconstructed->units[0]->abilities);
|
||||||
|
self::assertSame(12, $reconstructed->units[0]->maxHealth);
|
||||||
|
self::assertSame(3, $reconstructed->units[0]->attack);
|
||||||
|
self::assertSame(4, $reconstructed->units[0]->defense);
|
||||||
|
self::assertSame(2, $reconstructed->units[0]->speed);
|
||||||
|
self::assertSame($scenario->victoryCondition, $reconstructed->victoryCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRoundTripsAHoldObjectiveScenario(): void
|
||||||
|
{
|
||||||
|
$scenario = new Scenario(
|
||||||
|
id: 'hold',
|
||||||
|
name: 'Hold',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: [
|
||||||
|
new UnitState('alpha-1', 'alpha', new Position(0, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
|
||||||
|
new UnitState('alpha-2', 'alpha', new Position(1, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
|
||||||
|
new UnitState('alpha-3', 'alpha', new Position(2, 0), 12, 12, 3, 4, 2, 2, false, Archetype::Defender, [], 0, false),
|
||||||
|
new UnitState('bravo-1', 'bravo', new Position(7, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
|
||||||
|
new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
|
||||||
|
new UnitState('bravo-3', 'bravo', new Position(5, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker, [], 0, false),
|
||||||
|
],
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
|
||||||
|
victoryCondition: VictoryCondition::HoldObjective,
|
||||||
|
holdRoundsRequired: 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
$reconstructed = ScenarioSerializer::scenarioFromArray(ScenarioSerializer::scenarioToArray($scenario));
|
||||||
|
|
||||||
|
self::assertSame(VictoryCondition::HoldObjective, $reconstructed->victoryCondition);
|
||||||
|
self::assertSame(3, $reconstructed->holdRoundsRequired);
|
||||||
|
self::assertArrayHasKey('objective-1', $reconstructed->objectives);
|
||||||
|
self::assertSame('4:4', $reconstructed->objectives['objective-1']->position->key());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRoundTripsAMatchState(): void
|
||||||
|
{
|
||||||
|
$scenario = new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: [
|
||||||
|
new UnitState('alpha-1', 'alpha', new Position(0, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
|
||||||
|
new UnitState('alpha-2', 'alpha', new Position(1, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
|
||||||
|
new UnitState('alpha-3', 'alpha', new Position(2, 0), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
|
||||||
|
new UnitState('bravo-1', 'bravo', new Position(7, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
|
||||||
|
new UnitState('bravo-2', 'bravo', new Position(6, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
|
||||||
|
new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 4, 2, 4, 2, false, Archetype::Scout, [], 0, false),
|
||||||
|
],
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
$match = $scenario->startMatch('alpha');
|
||||||
|
|
||||||
|
$reconstructed = ScenarioSerializer::matchFromArray(ScenarioSerializer::matchToArray($match));
|
||||||
|
|
||||||
|
self::assertSame('alpha', $reconstructed->activeTeamId);
|
||||||
|
self::assertSame(1, $reconstructed->round);
|
||||||
|
self::assertCount(6, $reconstructed->units);
|
||||||
|
self::assertSame(2, $reconstructed->units[0]->actionsRemaining);
|
||||||
|
self::assertFalse($reconstructed->units[0]->hasAttacked);
|
||||||
|
self::assertSame(0, $reconstructed->units[0]->attackBonus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsShapeErrors(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
ScenarioSerializer::scenarioFromArray([
|
||||||
|
'id' => 'demo',
|
||||||
|
// missing 'name', 'battlefield', 'units', etc.
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Http;
|
||||||
|
|
||||||
|
use BattleForge\Http\CsrfToken;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CsrfTokenTest extends TestCase
|
||||||
|
{
|
||||||
|
private const SECRET = 'unit-test-secret';
|
||||||
|
|
||||||
|
public function testIssueReturnsTokenAndCookieValue(): void
|
||||||
|
{
|
||||||
|
[$token, $cookie] = CsrfToken::issue(self::SECRET);
|
||||||
|
|
||||||
|
self::assertNotSame('', $token);
|
||||||
|
self::assertNotSame('', $cookie);
|
||||||
|
self::assertSame(64, strlen($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyAcceptsAValidPair(): void
|
||||||
|
{
|
||||||
|
[$token, $cookie] = CsrfToken::issue(self::SECRET);
|
||||||
|
|
||||||
|
self::assertTrue(CsrfToken::verify($token, self::SECRET, $cookie));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyRejectsATamperedCookieValue(): void
|
||||||
|
{
|
||||||
|
[$token] = CsrfToken::issue(self::SECRET);
|
||||||
|
|
||||||
|
self::assertFalse(CsrfToken::verify($token, self::SECRET, 'not-the-cookie'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyRejectsATamperedToken(): void
|
||||||
|
{
|
||||||
|
[, $cookie] = CsrfToken::issue(self::SECRET);
|
||||||
|
|
||||||
|
self::assertFalse(CsrfToken::verify('not-the-token', self::SECRET, $cookie));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIssueProducesDifferentTokensAcrossCalls(): void
|
||||||
|
{
|
||||||
|
[$tokenA] = CsrfToken::issue(self::SECRET);
|
||||||
|
[$tokenB] = CsrfToken::issue(self::SECRET);
|
||||||
|
|
||||||
|
self::assertNotSame($tokenA, $tokenB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDifferentSecretsProduceDifferentCookieValues(): void
|
||||||
|
{
|
||||||
|
[$tokenA, $cookieA] = CsrfToken::issue('secret-a');
|
||||||
|
[, $cookieB] = CsrfToken::issue('secret-b');
|
||||||
|
|
||||||
|
self::assertTrue(CsrfToken::verify($tokenA, 'secret-a', $cookieA));
|
||||||
|
self::assertFalse(CsrfToken::verify($tokenA, 'secret-b', $cookieB));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Http;
|
||||||
|
|
||||||
|
use BattleForge\Http\Escape;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class EscapeTest extends TestCase
|
||||||
|
{
|
||||||
|
#[DataProvider('htmlProvider')]
|
||||||
|
public function testItEscapesHtmlContext(mixed $input, string $expected): void
|
||||||
|
{
|
||||||
|
self::assertSame($expected, Escape::html($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{mixed, string}>
|
||||||
|
*/
|
||||||
|
public static function htmlProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'plain' => ['hello', 'hello'];
|
||||||
|
yield 'less-than' => ['<script>', '<script>'];
|
||||||
|
yield 'ampersand' => ['a & b', 'a & b'];
|
||||||
|
yield 'double-quote' => ['she said "hi"', 'she said "hi"'];
|
||||||
|
yield 'single-quote' => ["it's", 'it's'];
|
||||||
|
yield 'invalid-utf8-substituted' => ["a\xC0\x80b", "a\xEF\xBF\xBD\xEF\xBF\xBDb"];
|
||||||
|
yield 'integer' => [42, '42'];
|
||||||
|
yield 'null' => [null, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('attrProvider')]
|
||||||
|
public function testItEscapesAttributeContext(mixed $input, string $expected): void
|
||||||
|
{
|
||||||
|
self::assertSame($expected, Escape::attr($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{mixed, string}>
|
||||||
|
*/
|
||||||
|
public static function attrProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'tag-breakout' => ['" onclick="', '" onclick="'];
|
||||||
|
yield 'apostrophe' => ["' onclick='", '' onclick=''];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('urlProvider')]
|
||||||
|
public function testItEncodesUrls(string $input, string $expected): void
|
||||||
|
{
|
||||||
|
self::assertSame($expected, Escape::url($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string, string}>
|
||||||
|
*/
|
||||||
|
public static function urlProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'space' => ['hello world', 'hello%20world'];
|
||||||
|
yield 'slash' => ['a/b', 'a%2Fb'];
|
||||||
|
yield 'unicode' => ['héllo', 'h%C3%A9llo'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Http;
|
||||||
|
|
||||||
|
use BattleForge\Http\Request;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class RequestTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItExposesAllSuperglobalsAsConstructorArgs(): void
|
||||||
|
{
|
||||||
|
$request = new Request(
|
||||||
|
get: ['q' => '1'],
|
||||||
|
post: ['name' => 'alpha'],
|
||||||
|
files: ['image' => ['name' => 'a.png', 'tmp_name' => '/tmp/x', 'error' => 0, 'size' => 12, 'type' => 'image/png']],
|
||||||
|
cookies: ['__csrf' => 'cookie-value'],
|
||||||
|
server: ['HTTP_HOST' => 'localhost'],
|
||||||
|
queryString: 'q=1',
|
||||||
|
method: 'POST',
|
||||||
|
path: '/scenarios/demo/edit/team',
|
||||||
|
contentType: 'application/x-www-form-urlencoded',
|
||||||
|
rawBody: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['q' => '1'], $request->get);
|
||||||
|
self::assertSame(['name' => 'alpha'], $request->post);
|
||||||
|
self::assertSame('a.png', $request->files['image']['name']);
|
||||||
|
self::assertSame('cookie-value', $request->cookies['__csrf']);
|
||||||
|
self::assertSame('localhost', $request->server['HTTP_HOST']);
|
||||||
|
self::assertSame('q=1', $request->queryString);
|
||||||
|
self::assertSame('POST', $request->method);
|
||||||
|
self::assertSame('/scenarios/demo/edit/team', $request->path);
|
||||||
|
self::assertSame('application/x-www-form-urlencoded', $request->contentType);
|
||||||
|
self::assertSame('', $request->rawBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItAllowsNullContentType(): void
|
||||||
|
{
|
||||||
|
$request = new Request(
|
||||||
|
get: [],
|
||||||
|
post: [],
|
||||||
|
files: [],
|
||||||
|
cookies: [],
|
||||||
|
server: [],
|
||||||
|
queryString: '',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/',
|
||||||
|
contentType: null,
|
||||||
|
rawBody: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($request->contentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Http;
|
||||||
|
|
||||||
|
use BattleForge\Http\Request;
|
||||||
|
use BattleForge\Http\Response;
|
||||||
|
use BattleForge\Http\Router;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class RouterTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItDispatchesAMatchingStaticRoute(): void
|
||||||
|
{
|
||||||
|
$router = new Router();
|
||||||
|
$router->add('GET', '/', static fn (Request $request): Response => Response::html(200, 'home'));
|
||||||
|
|
||||||
|
$response = $router->dispatch($this->request('GET', '/'));
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status);
|
||||||
|
self::assertSame('home', $response->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReturnsA404ForUnknownPaths(): void
|
||||||
|
{
|
||||||
|
$router = new Router();
|
||||||
|
|
||||||
|
$response = $router->dispatch($this->request('GET', '/missing'));
|
||||||
|
|
||||||
|
self::assertSame(404, $response->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReturnsAMethodNotAllowedResponseForMismatchedMethods(): void
|
||||||
|
{
|
||||||
|
$router = new Router();
|
||||||
|
$router->add('POST', '/', static fn (): Response => Response::html(200, 'ok'));
|
||||||
|
|
||||||
|
$response = $router->dispatch($this->request('GET', '/'));
|
||||||
|
|
||||||
|
self::assertSame(404, $response->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItCapturesPathParams(): void
|
||||||
|
{
|
||||||
|
$router = new Router();
|
||||||
|
$router->add('GET', '/scenarios/{id}/edit', static function (Request $request, array $params): Response {
|
||||||
|
return Response::html(200, 'id=' . $params['id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$response = $router->dispatch($this->request('GET', '/scenarios/demo/edit'));
|
||||||
|
|
||||||
|
self::assertSame(200, $response->status);
|
||||||
|
self::assertSame('id=demo', $response->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItMatchesMoreSpecificRoutesFirstByRegistrationOrder(): void
|
||||||
|
{
|
||||||
|
$router = new Router();
|
||||||
|
$router->add('GET', '/scenarios/{id}', static fn (): Response => Response::html(200, 'any'));
|
||||||
|
$router->add('GET', '/scenarios/special', static fn (): Response => Response::html(200, 'special'));
|
||||||
|
|
||||||
|
$response = $router->dispatch($this->request('GET', '/scenarios/special'));
|
||||||
|
|
||||||
|
self::assertSame('any', $response->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $method, string $path): Request
|
||||||
|
{
|
||||||
|
return new Request(
|
||||||
|
get: [],
|
||||||
|
post: [],
|
||||||
|
files: [],
|
||||||
|
cookies: [],
|
||||||
|
server: [],
|
||||||
|
queryString: '',
|
||||||
|
method: $method,
|
||||||
|
path: $path,
|
||||||
|
contentType: null,
|
||||||
|
rawBody: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Require all denied
|
||||||
|
Deny from all
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Forbidden\n";
|
||||||
Reference in New Issue
Block a user