Compare commits
47
Commits
main
..
87a7e10c06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87a7e10c06 | ||
|
|
3e4184f08d | ||
|
|
0f4a220e70 | ||
|
|
c24964ff96 | ||
|
|
6399e2d86c | ||
|
|
288c42e176 | ||
|
|
8b83df60cd | ||
|
|
3263975d2a | ||
|
|
9ce0c2ac20 | ||
|
|
67de6dab75 | ||
|
|
a05baeb4f6 | ||
|
|
eed678e688 | ||
|
|
17782aab33 | ||
|
|
8df51fc1ed | ||
|
|
18520fee22 | ||
|
|
1d69e89faf | ||
|
|
02addec8df | ||
|
|
86d82c353d | ||
|
|
39b15c35da | ||
|
|
12f5c0bd3a | ||
|
|
6fffaa2306 | ||
|
|
84fb175c0c | ||
|
|
6befe7b3e2 | ||
|
|
088e85c9b4 | ||
|
|
06632ff9b3 | ||
|
|
e70b60696c | ||
|
|
88149ab1cf | ||
|
|
0ac8c06c5e | ||
|
|
78fc802cdc | ||
|
|
de0bc9c43f | ||
|
|
07e58c84fe | ||
|
|
fef22d7b56 | ||
|
|
acfbbf4a91 | ||
|
|
7588b304e1 | ||
|
|
3c6359243a | ||
|
|
3eba823e52 | ||
|
|
1550e2920b | ||
|
|
5297b41208 | ||
|
|
94560da5b4 | ||
|
|
0c5e824942 | ||
|
|
4421c5ae32 | ||
|
|
b789f1b61f | ||
|
|
189be5bc64 | ||
|
|
7b82adbd71 | ||
|
|
a97ecbc6d3 | ||
|
|
e20017f1e5 | ||
|
|
5ddfff2679 |
@@ -0,0 +1 @@
|
||||
*.php text eol=lf
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
php:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
- run: composer validate --strict
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer check
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
.worktrees/
|
||||
/vendor/
|
||||
/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
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"tree.indentGuidesStroke": "#3d92ec",
|
||||
"activityBar.background": "#053241",
|
||||
"titleBar.activeBackground": "#07465B",
|
||||
"titleBar.activeForeground": "#F3FBFE",
|
||||
"titleBar.inactiveBackground": "#053241",
|
||||
"titleBar.inactiveForeground": "#F3FBFE",
|
||||
"statusBar.background": "#053241",
|
||||
"statusBar.foreground": "#F3FBFE",
|
||||
"statusBar.debuggingBackground": "#053241",
|
||||
"statusBar.debuggingForeground": "#F3FBFE",
|
||||
"statusBar.noFolderBackground": "#053241",
|
||||
"statusBar.noFolderForeground": "#F3FBFE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# BattleForge
|
||||
|
||||
## Initial Ideas
|
||||
|
||||
- **Concept**: BattleForge is a flexible "tactical game engine" web app that can be used to create skirmish-style games in a variety of genres.
|
||||
- **Core Gameplay**: Players control a team of units on a grid-based battlefield, taking turns to move and attack. The game emphasizes strategic positioning, unit abilities, and resource management.
|
||||
- **Customization**: Players can create and customize their own units, abilities, and battlefields, allowing for endless variety and replayability.
|
||||
- **Multiplayer**: Support asynchronous multiplayer, allowing players to compete against friends or strangers, with turn notifications (via email, in-app, or browser notifications) and matchmaking.
|
||||
- **Single Player**: Sandbox mode for free play with simple AI opponents.
|
||||
- **Art Style**: Images can be uploaded by users, but the app will also provide a library of free assets (units, structures, and terrain) to get started. The art style can be pixel art, isometric, or top-down, depending on user preference.
|
||||
|
||||
## Basic Features
|
||||
|
||||
- **Unit Creation**: Users can create custom units with various stats (health, attack, defense, speed) and abilities (e.g., heal, area damage, buffs).
|
||||
- **Battlefield Editor**: A drag-and-drop interface for designing custom battlefields with different terrain types (e.g., forests, mountains, water) that affect gameplay.
|
||||
- **Turn-Based Combat**: Players take turns moving their units and using abilities, with a simple UI for selecting actions and targeting.
|
||||
- **Resource Management**: Players can earn resources during battles to upgrade their units or purchase new ones for future battles.
|
||||
- **Matchmaking and Leaderboards**: A system for finding opponents and tracking player rankings based on wins, losses, and other performance metrics.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "battleforge/battleforge",
|
||||
"description": "A standalone tactical combat game.",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"squizlabs/php_codesniffer": "^3.10"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BattleForge\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"BattleForge\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "phpcs",
|
||||
"analyse": "phpstan analyse --no-progress",
|
||||
"test": "phpunit",
|
||||
"check": [
|
||||
"@lint",
|
||||
"@analyse",
|
||||
"@test"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
},
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
Generated
+2040
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
# Persistence, Secure Image Handling, and Scenario Editors Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Plan 3 of the four-plan BattleForge build. Closes the gap between the pure-PHP `Domain` layer (Plans 1 and 2) and the in-browser experience by adding:
|
||||
|
||||
- Anonymous-browser persistence of scenarios and in-progress matches (no user accounts, no cross-device, no server-side storage of game state).
|
||||
- Server-rendered editor pages for teams and battlefields, with the domain's `Scenario` and `ScenarioValidator` enforcing every rule.
|
||||
- A secure image-upload pipeline (content-sniffed, size-limited, dimension-limited, session-scoped).
|
||||
- A minimal plain-PHP web stack with the cross-cutting security concerns the spec demands (CSRF, output escaping, content-validated uploads).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Plan 3 succeeds when a new user, without developer assistance and with no global state, can:
|
||||
|
||||
1. Visit `http://localhost:8000/`, see a home page that lists their recent scenarios, and start a new one.
|
||||
2. Use the team editor to build two teams of 3-6 units each, choosing archetypes, stats, abilities, names, and optional custom images.
|
||||
3. Use the battlefield editor to choose dimensions, paint terrain, place deployment zones, and place an objective (when the victory condition requires it).
|
||||
4. Validate the assembled scenario server-side; see clear errors when something is invalid.
|
||||
5. Upload a custom image and have it appear in the team editor.
|
||||
6. "Start a match" from a saved scenario and have the initial `MatchState` round-trip through the browser.
|
||||
7. Refresh the page, lose the connection, or come back tomorrow and find their saved scenarios still in their browser (no data loss from the user's perspective).
|
||||
8. Reject forged cross-site requests, accept only image types the spec allows, and never let a user access another user's uploads.
|
||||
|
||||
## Out of Scope (deferred to Plan 4 or later)
|
||||
|
||||
- Hot-seat battle interface: Plan 3 ships a placeholder page that reads the assembled `MatchState` from `localStorage` and renders a "Battle interface coming in Plan 4" stub.
|
||||
- Bundled scenarios (the three ready-made scenarios in the spec): Plan 4.
|
||||
- Real-time interaction, drag-and-drop, or any other JS surface beyond the single fetch-based grid editor and the small `localStorage` helper.
|
||||
- Server-side storage of scenarios, matches, or any game state.
|
||||
- User accounts, sessions beyond the CSRF cookie, or any per-user server-side state.
|
||||
- Mobile-native, PWA, offline service workers, or any non-server-rendered path.
|
||||
- Antivirus scanning or upload rate limiting.
|
||||
|
||||
## In Scope
|
||||
|
||||
### Persistence
|
||||
|
||||
- Scenarios are JSON-serialized in the browser's `localStorage`, keyed as `scenario:{id}`.
|
||||
- In-progress matches are JSON-serialized in `localStorage`, keyed as `match:current`.
|
||||
- The server never sees a `Scenario` or `MatchState` after the editor POST completes. Each form submission is validated and acknowledged; the browser then writes to `localStorage` on its own.
|
||||
- The home page lists scenarios by enumerating `localStorage` keys; no server call.
|
||||
- "Resume in-progress match" reads `match:current` and shows a banner on the home page.
|
||||
|
||||
### Team Editor
|
||||
|
||||
- One form per scenario, with sections for: meta (id, name), team A, team B, victory condition.
|
||||
- Each team section has 3-6 unit rows; rows are added and removed client-side (DOM manipulation, no fetch).
|
||||
- Each unit row has: archetype dropdown, display name, image upload button + hidden URL field, four stat inputs (with `min`/`max` from the chosen archetype's template), and an abilities multi-select (limited to the chosen archetype's allowlist).
|
||||
- The archetype JSON is served from `/assets/archetypes.json` and includes the four `ArchetypeTemplate` definitions.
|
||||
- A new "Upload" form submits a single image to `POST /assets/upload` and writes the returned URL into the unit row's hidden `image` field. The unit-editor form is not submitted as part of the upload.
|
||||
- Victory condition: a radio pair (`EliminateAll` / `HoldObjective`). When `HoldObjective` is selected, an input for `holdRoundsRequired` (1-10) appears.
|
||||
- Submitting the form POSTs the full draft to `POST /scenarios/{id}/edit/team`. The server parses, builds a `ScenarioDraft`, runs `ScenarioValidator::validate()`, and on failure re-renders the form with errors inline.
|
||||
|
||||
### Battlefield Editor
|
||||
|
||||
- One page per scenario. Width/height inputs (8-16 each) at the top.
|
||||
- Terrain palette: one button per `Terrain` enum value. The selected button is the "current paint."
|
||||
- Grid: an HTML `<table>` of `<button>` cells with `data-x` and `data-y`. Clicking applies the current paint. Shift-click erases.
|
||||
- Two deployment-zone controls (one per team). Each is a "click on grid to add this tile to my zone" mode.
|
||||
- Objective control: a single "click on grid to place the objective" mode that is only shown when the victory condition is `HoldObjective`.
|
||||
- The page ships with a small ESM helper (~50 lines, no build step) that maintains an in-memory `tileMap` object, posts it as JSON to `POST /scenarios/{id}/edit/battlefield` on save, and writes the server's response to `localStorage`.
|
||||
|
||||
### Image Upload Pipeline
|
||||
|
||||
- Multipart POST to `POST /assets/upload` with a hidden CSRF field.
|
||||
- Server reads `$_FILES['image']`, validates with `ImageValidator` (see below), stores the file at `var/uploads/{userToken}/{hash}.{ext}`, and returns `{"url": "/assets/{userToken}/{hash}.{ext}"}`.
|
||||
- `GET /assets/{userToken}/{filename}` streams the file with the right `Content-Type`. The `{userToken}` is derived from the session cookie and the request's `{userToken}` is verified to match; mismatch returns 404. Misses return 404.
|
||||
- No listing endpoint. No directory traversal.
|
||||
- Bundled placeholder images at `/assets/placeholders/{archetype}.png` are committed under `public/assets/placeholders/` and served without the `{userToken}` prefix.
|
||||
|
||||
### Security and Quality
|
||||
|
||||
- A per-session CSRF token stored in an `HttpOnly`, `SameSite=Lax` cookie, signed with the app's secret using `hash_hmac('sha256', …)`.
|
||||
- Every form includes a hidden `_csrf` field; the fetch helper reads the value from a `<meta name="csrf-token">` tag and sends it in an `X-CSRF-Token` header.
|
||||
- All output escaping goes through `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Code review enforces the pattern; PHPStan has no rule for it.
|
||||
- All uploaded images are content-validated by reading the first 12 bytes and checking the magic number against an allowlist (PNG, JPEG, WebP, GIF), then `getimagesizefromstring()` to confirm dimensions are ≤ 512×512, then size-checked at ≤ 2 MB.
|
||||
- `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'` is set on every page. No `unsafe-inline`.
|
||||
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin` on every page.
|
||||
- Bundled placeholders are committed; user uploads live in `var/uploads/` (git-ignored).
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- The single small JS file (`public/js/grid-editor.js` and `public/js/storage.js`) passes ESLint `airbnb/base` and Prettier.
|
||||
|
||||
## System Boundaries
|
||||
|
||||
The Plan 1+2 system boundaries are preserved and extended:
|
||||
|
||||
1. **Content library** (Domain, unchanged): owns curated unit archetypes, abilities, terrain, and bundled asset metadata. Served read-only to the web layer.
|
||||
2. **Scenario editor** (web, NEW): hosts the team and battlefield editor pages. The `Application` layer translates form input into a `Scenario` and runs `ScenarioValidator`. The web layer never inspects a `Scenario` directly.
|
||||
3. **Rules engine** (Domain, unchanged from Plans 1 and 2): authoritatively validates actions and resolves combat.
|
||||
4. **Battle interface** (Plan 4, stubbed in Plan 3 as a "match is loaded" page that reads `match:current` from `localStorage`).
|
||||
|
||||
The new Application and Http layers sit between the web pages and the Domain. They do not depend on browser presentation code, and the Domain does not depend on them.
|
||||
|
||||
## Architecture
|
||||
|
||||
The four `// @phpstan-ignore` annotations in `src/Domain/` (in `UnitState`, `MatchState`, `Scenario`, `DeploymentZone`) reference "Plan 4" in their parenthetical comments. The comments will be updated to "Plan 3" in a follow-up amend after this design is approved.
|
||||
|
||||
```
|
||||
src/
|
||||
├── Domain/ (Plan 1 + 2, unchanged)
|
||||
│ └── … 19 files …
|
||||
│
|
||||
├── Application/ (NEW)
|
||||
│ ├── ScenarioSerializer.php JSON ↔ Scenario
|
||||
│ ├── ScenarioDraft.php mutable draft state for the editor
|
||||
│ ├── ImageUploadService.php validates + stores a user-uploaded image
|
||||
│ └── ImageValidator.php content-sniff + size + dimension checks
|
||||
│
|
||||
├── Http/ (NEW)
|
||||
│ ├── Router.php tiny path → handler dispatcher
|
||||
│ ├── Request.php value object wrapping $_GET, $_POST, $_FILES, $_SERVER
|
||||
│ ├── Response.php value object with status, headers, body
|
||||
│ ├── CsrfToken.php per-session token store + verify helper
|
||||
│ ├── Escape.php html(), attr(), url() helpers
|
||||
│ └── Handlers/
|
||||
│ ├── GetHomePage.php
|
||||
│ ├── GetTeamEditor.php
|
||||
│ ├── PostTeamEditor.php
|
||||
│ ├── GetBattlefieldEditor.php
|
||||
│ ├── PostBattlefieldEditor.php
|
||||
│ ├── GetAssets.php
|
||||
│ └── PostImageUpload.php
|
||||
│
|
||||
├── Views/ (NEW — plain PHP templates)
|
||||
│ ├── layout.php
|
||||
│ ├── home.php
|
||||
│ ├── team-editor.php
|
||||
│ ├── battlefield-editor.php
|
||||
│ ├── match-stub.php
|
||||
│ └── upload-result.php
|
||||
│
|
||||
└── public/ (NEW — document root for `php -S`)
|
||||
├── index.php front controller
|
||||
├── assets/
|
||||
│ ├── archetypes.json
|
||||
│ ├── placeholders/{defender,striker,support,scout}.png
|
||||
│ └── (user uploads live under var/uploads/, not here)
|
||||
└── js/
|
||||
├── storage.js
|
||||
└── grid-editor.js
|
||||
```
|
||||
|
||||
`var/` is the only runtime-writable directory. It is git-ignored. It contains:
|
||||
|
||||
```
|
||||
var/
|
||||
├── cache/ PHP opcode cache (created by `php -S` if enabled)
|
||||
├── phpunit/ PHPUnit cache
|
||||
├── phpstan/ PHPStan cache
|
||||
└── uploads/
|
||||
├── .htaccess Deny from all (Apache)
|
||||
├── index.php Sentinel that returns 403 (built-in dev server)
|
||||
└── {userToken}/ Per-browser namespace; created on first upload
|
||||
```
|
||||
|
||||
## Data and Action Flow
|
||||
|
||||
### Cold-start flow
|
||||
|
||||
1. Browser hits `GET /`. Server renders the home page.
|
||||
2. A small inline script in the home page enumerates `localStorage` keys matching `scenario:*` and `match:current` and renders the list.
|
||||
3. User picks an existing scenario → the team editor opens with the form pre-filled.
|
||||
4. User picks "New scenario" → the team editor opens with an empty form for a new id.
|
||||
|
||||
### Team editor save flow
|
||||
|
||||
1. User edits the form, presses Save.
|
||||
2. Browser POSTs the form to `POST /scenarios/{id}/edit/team` with `Content-Type: application/x-www-form-urlencoded` and a hidden `_csrf` field.
|
||||
3. Server's `PostTeamEditor` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Parses the form into a `ScenarioDraft`.
|
||||
c. Builds a `Scenario` from the draft.
|
||||
d. Runs `ScenarioValidator::validate($scenario)`.
|
||||
e. On failure: re-renders the editor with the validator's errors next to each field, preserving the form's values.
|
||||
f. On success: renders the editor with an inline `<script>` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))` and shows a "Saved" toast. The server does not store the scenario.
|
||||
|
||||
### Battlefield editor save flow
|
||||
|
||||
1. User clicks tiles in the grid; the small JS helper maintains an in-memory `tileMap` object.
|
||||
2. User presses Save; the JS helper POSTs the assembled JSON to `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and an `X-CSRF-Token` header.
|
||||
3. Server's `PostBattlefieldEditor` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Decodes the JSON body, validates the shape, builds a `ScenarioDraft`, runs the validator.
|
||||
c. On failure: returns `{"ok": false, "errors": [...]}` with HTTP 400.
|
||||
d. On success: returns `{"ok": true, "scenario": ...}` with HTTP 200. The JS helper writes `localStorage` and shows a toast.
|
||||
|
||||
### Image upload flow
|
||||
|
||||
1. User picks an image in the team editor and presses Upload.
|
||||
2. Browser submits the file to `POST /assets/upload` as `multipart/form-data` with a hidden `_csrf` field.
|
||||
3. Server's `PostImageUpload` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Validates the upload with `ImageValidator`.
|
||||
c. Writes the file to `var/uploads/{userToken}/{hash}.{ext}`.
|
||||
d. Returns `{"url": "/assets/{userToken}/{hash}.{ext}"}` with HTTP 200.
|
||||
4. JS writes the returned URL into the unit row's hidden `image` field. The unit-editor form is not submitted as part of the upload.
|
||||
|
||||
### Start-match flow
|
||||
|
||||
1. User on the team editor presses "Continue to battlefield" or on the battlefield editor presses "Start match."
|
||||
2. JS reads the assembled `Scenario` JSON from `localStorage`, POSTs to `POST /scenarios/{id}/start` with `Content-Type: application/json`.
|
||||
3. Server runs `ScenarioValidator` (defense in depth), calls `Scenario::startMatch('alpha')`, returns the initial `MatchState` JSON. (No browser-side `startMatch` call; the JS does not run the domain's PHP code.)
|
||||
4. JS writes `match:current` to `localStorage` and navigates to `GET /match/current`.
|
||||
|
||||
## Validation and Failure Handling
|
||||
|
||||
- `ScenarioValidator` is the single source of truth for "is this scenario valid." Both the server (on every form POST) and the browser (before navigating forward) call it.
|
||||
- A rejected action or form never partially mutates the persisted state. The browser's `localStorage` write happens only on a successful server response.
|
||||
- Failure to save a scenario leaves the user's in-progress form intact; the editor re-renders the same values with errors inline.
|
||||
- The CSRF token's invalid-or-missing case returns a generic 403 page that links back to `GET /`. The user's in-progress form state is preserved in `localStorage` via the on-change JS hook.
|
||||
- A failed image upload shows an inline error next to the upload button; the unit row keeps whatever `image` field it had.
|
||||
- Corrupt or incompatible JSON in `localStorage` (e.g. a hand-edited scenario) is rejected on read: the home page's "Recent scenarios" list skips it and shows a "scenario corrupt" placeholder for that key.
|
||||
|
||||
## Security and Quality Requirements
|
||||
|
||||
- Every state-changing form or request uses and verifies a CSRF token before mutation. The token is per-session, signed with the app's secret, and the form's hidden field is required for HTML forms or the `X-CSRF-Token` header is required for fetch POSTs.
|
||||
- All input is sanitized and validated at the application boundary in the `Request` value object and the `ImageValidator`.
|
||||
- All rendered output is escaped for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers.
|
||||
- Uploaded images are validated by content (magic number) and by dimensions; the stored file's name is a server-generated random hex; the original filename and declared MIME are discarded after validation.
|
||||
- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server).
|
||||
- The dev server is the built-in `php -S` running on a single port. No CGI, no Apache-only assumptions; the `var/uploads/.htaccess` and `var/uploads/index.php` sentinels cover both Apache deployments and the built-in server respectively.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- The small JS surface passes ESLint `airbnb/base` and Prettier.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
- All output includes `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, and a `Content-Security-Policy` header that forbids inline scripts and styles.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- `ScenarioSerializerTest`: round-trip every Plan 2 fixture through `toJson` → `fromJson`; add fixtures for all four archetypes, both victory conditions, every terrain, and at least one uploaded-image reference.
|
||||
- `ScenarioDraftTest`: form fields → `ScenarioDraft` → `Scenario` for every field combination; error cases (out-of-bounds stat, unknown archetype, ability not in allowlist, oversized team, etc.).
|
||||
- `ImageValidatorTest`: valid PNG/JPEG/WebP/GIF headers; invalid headers (text, executable, fake extension); size > 2 MB; dimension > 512; MIME/extension mismatch.
|
||||
- `ImageUploadServiceTest`: round-trips a temp file through the service; asserts the file lands at the expected path; asserts the response URL is well-formed.
|
||||
- `CsrfTokenTest`: round-trip; cross-session rejection; expired-cookie rejection.
|
||||
- `EscapeTest`: regression set for `<`, `>`, `'`, `"`, `&`, control characters, NULL bytes.
|
||||
- `RequestTest`: synthetic `$_GET`/`$_POST`/`$_FILES`/`$_SERVER` extraction.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- One happy-path and one validation-error test per handler. Asserts cover status code, `Content-Type` header, presence/absence of the CSRF token in the response body, and inline error placement.
|
||||
- A round-trip "save scenario" integration test: POST a full team-editor form, assert the response says "saved," then GET the same page and assert the form is pre-filled.
|
||||
- A round-trip "upload + read back" integration test: POST a small PNG to `/assets/upload`, then GET the returned URL and assert the body matches the uploaded bytes exactly.
|
||||
- A round-trip "forged CSRF" test: a POST without the token returns 403.
|
||||
- A round-trip "wrong user" test: an upload stored under one session's `{userToken}` is unreadable from a different session.
|
||||
|
||||
### End-to-end smoke test (seeded in Plan 3, completed in Plan 4)
|
||||
|
||||
- Plan 3 ships the *plumbing* of an E2E test that boots a tiny PHP server in a background process, sends a sequence of HTTP requests, and asserts on response bodies. Plan 4 fills in the battle-interaction assertions.
|
||||
|
||||
### Static analysis / lint
|
||||
|
||||
- PHPCS rules stay `PSR-12`; the new `src/Http/`, `src/Application/`, `src/Views/`, and `public/` directories are added to the `phpcs.xml` `<file>` list.
|
||||
- PHPStan level 6 stays. New exclusions: `src/Views/*` and `public/index.php`.
|
||||
- ESLint `airbnb/base` + Prettier on the single small JS file. `package.json` + `package-lock.json` are committed; CI gains an `npm run lint` step.
|
||||
|
||||
### Manual usability check
|
||||
|
||||
- A new tester with no BattleForge context can: open the dev URL, see the home page, create a new scenario, build two teams with mixed archetypes, paint a simple battlefield, save both, and start a match — without leaving the browser or seeing any error from the validator that wasn't explained inline.
|
||||
|
||||
## Release Boundary
|
||||
|
||||
Plan 3 is releasable when every in-scope capability and success criterion is met, the verification suite is green (PHPUnit, PHPStan, PHPCS, ESLint, Prettier), and no out-of-scope capability (battle interface, bundled scenarios, AI, online play) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`.
|
||||
|
||||
The next plan (Plan 4) will replace the battle-page stub with a real battle interface, add the three bundled scenarios, and ship the end-to-end smoke test that the Plan 3 plumbing seeds.
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="BattleForge">
|
||||
<description>BattleForge coding standards.</description>
|
||||
|
||||
<file>src</file>
|
||||
<file>tests</file>
|
||||
<file>public/index.php</file>
|
||||
|
||||
<arg name="colors"/>
|
||||
<arg value="sp"/>
|
||||
|
||||
<rule ref="PSR12"/>
|
||||
</ruleset>
|
||||
@@ -0,0 +1,9 @@
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
- public/index.php
|
||||
excludePaths:
|
||||
- src/Views/*
|
||||
tmpDir: var/phpstan
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
cacheDirectory="var/phpunit"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="integration">
|
||||
<directory>tests/Integration</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,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,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class AbilityCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, AbilityDefinition>
|
||||
*/
|
||||
public static function definitions(): array
|
||||
{
|
||||
static $cache = null;
|
||||
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$cache = [
|
||||
AbilityId::Heal->value => new AbilityDefinition(
|
||||
id: AbilityId::Heal,
|
||||
label: 'Heal',
|
||||
range: 2,
|
||||
areaRadius: 0,
|
||||
target: AbilityDefinition::TARGET_ALLY,
|
||||
effect: AbilityDefinition::EFFECT_RESTORE_HEALTH,
|
||||
effectValue: 5,
|
||||
),
|
||||
AbilityId::AreaDamage->value => new AbilityDefinition(
|
||||
id: AbilityId::AreaDamage,
|
||||
label: 'Area Damage',
|
||||
range: 3,
|
||||
areaRadius: 1,
|
||||
target: AbilityDefinition::TARGET_TILE,
|
||||
effect: AbilityDefinition::EFFECT_DEAL_DAMAGE,
|
||||
effectValue: 3,
|
||||
),
|
||||
AbilityId::Buff->value => new AbilityDefinition(
|
||||
id: AbilityId::Buff,
|
||||
label: 'Rally',
|
||||
range: 1,
|
||||
areaRadius: 0,
|
||||
target: AbilityDefinition::TARGET_ALLY,
|
||||
effect: AbilityDefinition::EFFECT_APPLY_BUFF,
|
||||
effectValue: 2,
|
||||
),
|
||||
];
|
||||
|
||||
return $cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class AbilityDefinition
|
||||
{
|
||||
public const TARGET_SELF = 'self';
|
||||
public const TARGET_ALLY = 'ally';
|
||||
public const TARGET_ENEMY = 'enemy';
|
||||
public const TARGET_TILE = 'tile';
|
||||
|
||||
public const EFFECT_RESTORE_HEALTH = 'restore_health';
|
||||
public const EFFECT_DEAL_DAMAGE = 'deal_damage';
|
||||
public const EFFECT_APPLY_BUFF = 'apply_buff';
|
||||
|
||||
/** @var list<string> */
|
||||
private const TARGETS = [self::TARGET_SELF, self::TARGET_ALLY, self::TARGET_ENEMY, self::TARGET_TILE];
|
||||
|
||||
/** @var list<string> */
|
||||
private const EFFECTS = [self::EFFECT_RESTORE_HEALTH, self::EFFECT_DEAL_DAMAGE, self::EFFECT_APPLY_BUFF];
|
||||
|
||||
public function __construct(
|
||||
public AbilityId $id,
|
||||
public string $label,
|
||||
public int $range,
|
||||
public int $areaRadius,
|
||||
public string $target,
|
||||
public string $effect,
|
||||
public int $effectValue,
|
||||
) {
|
||||
if ($this->range < 0) {
|
||||
throw new InvalidArgumentException('Ability range cannot be negative.');
|
||||
}
|
||||
|
||||
if ($this->areaRadius < 0) {
|
||||
throw new InvalidArgumentException('Ability area radius cannot be negative.');
|
||||
}
|
||||
|
||||
if (!in_array($this->target, self::TARGETS, true)) {
|
||||
throw new InvalidArgumentException("Unknown ability target: {$this->target}.");
|
||||
}
|
||||
|
||||
if (!in_array($this->effect, self::EFFECTS, true)) {
|
||||
throw new InvalidArgumentException("Unknown ability effect: {$this->effect}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum AbilityId: string
|
||||
{
|
||||
case Heal = 'heal';
|
||||
case AreaDamage = 'area_damage';
|
||||
case Buff = 'buff';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum Archetype: string
|
||||
{
|
||||
case Defender = 'defender';
|
||||
case Striker = 'striker';
|
||||
case Support = 'support';
|
||||
case Scout = 'scout';
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class ArchetypeCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, ArchetypeTemplate>
|
||||
*/
|
||||
public static function templates(): array
|
||||
{
|
||||
static $cache = null;
|
||||
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$cache = [
|
||||
Archetype::Defender->value => new ArchetypeTemplate(
|
||||
minHealth: 10,
|
||||
maxHealth: 14,
|
||||
minAttack: 2,
|
||||
maxAttack: 4,
|
||||
minDefense: 3,
|
||||
maxDefense: 5,
|
||||
minSpeed: 1,
|
||||
maxSpeed: 3,
|
||||
allowedAbilities: ['buff'],
|
||||
),
|
||||
Archetype::Striker->value => new ArchetypeTemplate(
|
||||
minHealth: 6,
|
||||
maxHealth: 10,
|
||||
minAttack: 4,
|
||||
maxAttack: 6,
|
||||
minDefense: 1,
|
||||
maxDefense: 2,
|
||||
minSpeed: 2,
|
||||
maxSpeed: 4,
|
||||
allowedAbilities: ['area_damage'],
|
||||
),
|
||||
Archetype::Support->value => new ArchetypeTemplate(
|
||||
minHealth: 5,
|
||||
maxHealth: 9,
|
||||
minAttack: 1,
|
||||
maxAttack: 3,
|
||||
minDefense: 1,
|
||||
maxDefense: 3,
|
||||
minSpeed: 2,
|
||||
maxSpeed: 4,
|
||||
allowedAbilities: ['heal', 'buff'],
|
||||
),
|
||||
Archetype::Scout->value => new ArchetypeTemplate(
|
||||
minHealth: 4,
|
||||
maxHealth: 7,
|
||||
minAttack: 2,
|
||||
maxAttack: 4,
|
||||
minDefense: 0,
|
||||
maxDefense: 2,
|
||||
minSpeed: 4,
|
||||
maxSpeed: 6,
|
||||
allowedAbilities: [],
|
||||
),
|
||||
];
|
||||
|
||||
return $cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ArchetypeTemplate
|
||||
{
|
||||
/** @param list<string> $allowedAbilities */
|
||||
public function __construct(
|
||||
public int $minHealth,
|
||||
public int $maxHealth,
|
||||
public int $minAttack,
|
||||
public int $maxAttack,
|
||||
public int $minDefense,
|
||||
public int $maxDefense,
|
||||
public int $minSpeed,
|
||||
public int $maxSpeed,
|
||||
public array $allowedAbilities,
|
||||
) {
|
||||
if ($this->minHealth > $this->maxHealth) {
|
||||
throw new InvalidArgumentException('Health range is inverted.');
|
||||
}
|
||||
if ($this->minAttack > $this->maxAttack) {
|
||||
throw new InvalidArgumentException('Attack range is inverted.');
|
||||
}
|
||||
if ($this->minDefense > $this->maxDefense) {
|
||||
throw new InvalidArgumentException('Defense range is inverted.');
|
||||
}
|
||||
if ($this->minSpeed > $this->maxSpeed || $this->minSpeed < 1) {
|
||||
throw new InvalidArgumentException('Speed range must be at least 1 and non-inverted.');
|
||||
}
|
||||
if ($this->minHealth < 1) {
|
||||
throw new InvalidArgumentException('Minimum health must be at least 1.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{maxHealth: int, attack: int, defense: int, speed: int} */
|
||||
public function defaultStats(): array
|
||||
{
|
||||
return [
|
||||
'maxHealth' => (int) ceil(($this->minHealth + $this->maxHealth) / 2),
|
||||
'attack' => (int) ceil(($this->minAttack + $this->maxAttack) / 2),
|
||||
'defense' => (int) ceil(($this->minDefense + $this->maxDefense) / 2),
|
||||
'speed' => (int) ceil(($this->minSpeed + $this->maxSpeed) / 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use SplQueue;
|
||||
|
||||
final readonly class Battlefield
|
||||
{
|
||||
/** @var array<string, Terrain> */
|
||||
private array $terrain;
|
||||
|
||||
/**
|
||||
* @param array<string, Terrain> $terrain
|
||||
*/
|
||||
public function __construct(
|
||||
public int $width,
|
||||
public int $height,
|
||||
array $terrain = [],
|
||||
) {
|
||||
if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
|
||||
throw new InvalidArgumentException('Battlefield dimensions must each be between 8 and 16.');
|
||||
}
|
||||
|
||||
$validatedTerrain = [];
|
||||
|
||||
foreach ($terrain as $key => $terrainType) {
|
||||
if (!self::isTerrain($terrainType)) {
|
||||
throw new InvalidArgumentException("Invalid terrain value at coordinate {$key}.");
|
||||
}
|
||||
|
||||
$position = self::positionFromTerrainKey($key);
|
||||
|
||||
if (!$this->contains($position)) {
|
||||
throw new InvalidArgumentException("Terrain coordinate {$key} is outside the battlefield.");
|
||||
}
|
||||
|
||||
$validatedTerrain[$key] = $terrainType;
|
||||
}
|
||||
|
||||
$this->terrain = $validatedTerrain;
|
||||
}
|
||||
|
||||
private static function isTerrain(mixed $terrain): bool
|
||||
{
|
||||
return $terrain instanceof Terrain;
|
||||
}
|
||||
|
||||
private static function positionFromTerrainKey(mixed $key): Position
|
||||
{
|
||||
if (!is_string($key) || preg_match('/^(\d+):(\d+)$/', $key, $matches) !== 1) {
|
||||
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
|
||||
}
|
||||
|
||||
$position = new Position((int) $matches[1], (int) $matches[2]);
|
||||
|
||||
if ($key !== $position->key()) {
|
||||
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
|
||||
}
|
||||
|
||||
return $position;
|
||||
}
|
||||
|
||||
public function contains(Position $position): bool
|
||||
{
|
||||
return $position->x >= 0
|
||||
&& $position->x < $this->width
|
||||
&& $position->y >= 0
|
||||
&& $position->y < $this->height;
|
||||
}
|
||||
|
||||
public function terrainAt(Position $position): Terrain
|
||||
{
|
||||
return $this->terrain[$position->key()] ?? Terrain::Open;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Position> $occupied
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function reachable(Position $start, int $budget, array $occupied): array
|
||||
{
|
||||
if (!$this->contains($start)) {
|
||||
throw new InvalidArgumentException('Reachability start position is outside the battlefield.');
|
||||
}
|
||||
|
||||
if ($budget < 0) {
|
||||
throw new InvalidArgumentException('Reachability budget cannot be negative.');
|
||||
}
|
||||
|
||||
$occupiedKeys = [];
|
||||
|
||||
foreach ($occupied as $position) {
|
||||
if (!$this->contains($position)) {
|
||||
throw new InvalidArgumentException('Occupied position is outside the battlefield.');
|
||||
}
|
||||
|
||||
$occupiedKeys[$position->key()] = true;
|
||||
}
|
||||
|
||||
$costs = [$start->key() => 0];
|
||||
/** @var SplQueue<Position> $queue */
|
||||
$queue = new SplQueue();
|
||||
$queue->enqueue($start);
|
||||
|
||||
while (!$queue->isEmpty()) {
|
||||
$current = $queue->dequeue();
|
||||
$currentCost = $costs[$current->key()];
|
||||
|
||||
foreach ($this->neighbors($current) as $neighbor) {
|
||||
$key = $neighbor->key();
|
||||
$movementCost = $this->terrainAt($neighbor)->movementCost();
|
||||
|
||||
if ($movementCost === null || isset($occupiedKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cost = $currentCost + $movementCost;
|
||||
|
||||
if ($cost > $budget || (isset($costs[$key]) && $costs[$key] <= $cost)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$costs[$key] = $cost;
|
||||
$queue->enqueue($neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
return $costs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Position>
|
||||
*/
|
||||
private function neighbors(Position $position): array
|
||||
{
|
||||
$neighbors = [
|
||||
new Position($position->x + 1, $position->y),
|
||||
new Position($position->x - 1, $position->y),
|
||||
new Position($position->x, $position->y + 1),
|
||||
new Position($position->x, $position->y - 1),
|
||||
];
|
||||
|
||||
return array_values(array_filter($neighbors, $this->contains(...)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CombatEngine
|
||||
{
|
||||
public function move(MatchState $match, string $unitId, Position $destination): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$unit = $this->unitOrFail($match, $unitId);
|
||||
$this->assertCanAct($match, $unit);
|
||||
|
||||
$occupied = [];
|
||||
|
||||
foreach ($match->units as $otherUnit) {
|
||||
if ($otherUnit->id !== $unit->id && !$otherUnit->isDefeated()) {
|
||||
$occupied[] = $otherUnit->position;
|
||||
}
|
||||
}
|
||||
|
||||
$reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);
|
||||
|
||||
if ($destination->key() === $unit->position->key() || !isset($reachable[$destination->key()])) {
|
||||
throw new CombatException('Destination is not reachable.');
|
||||
}
|
||||
|
||||
$movedUnit = $unit->moveTo($destination)->spendAction();
|
||||
$next = $match->withUnit($movedUnit);
|
||||
$actionLog = [...$match->actionLog, "{$unit->id} moved to {$destination->key()}"];
|
||||
|
||||
return $next->copy(actionLog: $actionLog);
|
||||
}
|
||||
|
||||
public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$attacker = $this->unitOrFail($match, $attackerId);
|
||||
$this->assertCanAct($match, $attacker);
|
||||
|
||||
if ($attacker->hasAttacked) {
|
||||
throw new CombatException('Unit has already attacked this turn.');
|
||||
}
|
||||
|
||||
$target = $this->unitOrFail($match, $targetId);
|
||||
|
||||
if ($target->teamId === $attacker->teamId || $target->isDefeated()) {
|
||||
throw new CombatException('Target must be an active enemy unit.');
|
||||
}
|
||||
|
||||
$distance = $attacker->position->distanceTo($target->position);
|
||||
|
||||
if ($distance !== 1) {
|
||||
throw new CombatException('Target is outside attack range.');
|
||||
}
|
||||
|
||||
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
|
||||
$damage = max(1, $attacker->attack + $attacker->attackBonus - $target->defense - $terrainDefense);
|
||||
$updatedAttacker = $attacker->markAttacked()->spendAction();
|
||||
$updatedTarget = $target->takeDamage($damage);
|
||||
$next = $match->withUnit($updatedAttacker)->withUnit($updatedTarget);
|
||||
$actionLog = [...$match->actionLog, "{$attacker->id} attacked {$target->id} for {$damage} damage"];
|
||||
$next = $next->copy(actionLog: $actionLog);
|
||||
|
||||
return $this->checkVictoryAfterAction($next, $attacker->teamId);
|
||||
}
|
||||
|
||||
public function useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$caster = $this->unitOrFail($match, $unitId);
|
||||
$this->assertCanAct($match, $caster);
|
||||
|
||||
if ($caster->hasUsedAbility) {
|
||||
throw new CombatException('Unit has already used an ability this turn.');
|
||||
}
|
||||
|
||||
$definitions = AbilityCatalog::definitions();
|
||||
|
||||
if (!isset($definitions[$abilityId])) {
|
||||
throw new CombatException("Unknown ability: {$abilityId}.");
|
||||
}
|
||||
|
||||
$definition = $definitions[$abilityId];
|
||||
|
||||
if (!in_array($abilityId, $caster->allowedAbilities(), true)) {
|
||||
throw new CombatException('Unit archetype forbids that ability.');
|
||||
}
|
||||
|
||||
$affected = $this->resolveAbilityTargets($match, $caster, $definition, $target);
|
||||
|
||||
$next = $match;
|
||||
$logParts = [];
|
||||
|
||||
foreach ($affected as $affectedUnit) {
|
||||
$current = $next->unit($affectedUnit['id']);
|
||||
$updated = match ($definition->effect) {
|
||||
AbilityDefinition::EFFECT_RESTORE_HEALTH => $current->restoreHealth($definition->effectValue),
|
||||
AbilityDefinition::EFFECT_DEAL_DAMAGE => $current->takeDamage($affectedUnit['damage'] ?? 0),
|
||||
AbilityDefinition::EFFECT_APPLY_BUFF => $current->withAttackBonus($definition->effectValue),
|
||||
default => throw new CombatException("Unknown ability effect: {$definition->effect}."),
|
||||
};
|
||||
$next = $next->withUnit($updated);
|
||||
|
||||
$logParts[] = match ($definition->effect) {
|
||||
AbilityDefinition::EFFECT_RESTORE_HEALTH => "{$affectedUnit['id']} for {$definition->effectValue}",
|
||||
AbilityDefinition::EFFECT_DEAL_DAMAGE => "{$affectedUnit['id']} for {$affectedUnit['damage']} damage",
|
||||
AbilityDefinition::EFFECT_APPLY_BUFF => "{$affectedUnit['id']} with +{$definition->effectValue} attack",
|
||||
};
|
||||
}
|
||||
|
||||
$next = $next->withUnit($next->unit($caster->id)->spendAbility());
|
||||
|
||||
$summary = "{$caster->id} used {$definition->label} on " . implode(', ', $logParts);
|
||||
$next = $next->copy(actionLog: [...$next->actionLog, $summary]);
|
||||
|
||||
return $this->checkVictoryAfterAction($next, $caster->teamId);
|
||||
}
|
||||
|
||||
public function endTurn(MatchState $match): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$teamIds = [];
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
$teamIds[$unit->teamId] = true;
|
||||
}
|
||||
|
||||
$teamIds = array_keys($teamIds);
|
||||
sort($teamIds);
|
||||
|
||||
if (count($teamIds) !== 2) {
|
||||
throw new CombatException('Matches require exactly two teams.');
|
||||
}
|
||||
|
||||
$livingTeams = array_fill_keys($teamIds, false);
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
if (!$unit->isDefeated()) {
|
||||
$livingTeams[$unit->teamId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($livingTeams as $teamId => $alive) {
|
||||
if (!$alive) {
|
||||
throw new CombatException('Cannot end turn while a team is eliminated.');
|
||||
}
|
||||
}
|
||||
|
||||
$endingTeamId = $match->activeTeamId;
|
||||
$nextTeamId = $endingTeamId === $teamIds[0] ? $teamIds[1] : $teamIds[0];
|
||||
|
||||
if ($nextTeamId === $teamIds[0] && $match->round === PHP_INT_MAX) {
|
||||
throw new CombatException('Match round limit reached.');
|
||||
}
|
||||
|
||||
$units = [];
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
$units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit;
|
||||
}
|
||||
|
||||
$round = $match->round + ($nextTeamId === $teamIds[0] ? 1 : 0);
|
||||
|
||||
$next = $match->copy(
|
||||
units: $units,
|
||||
activeTeamId: $nextTeamId,
|
||||
round: $round,
|
||||
actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"],
|
||||
);
|
||||
|
||||
return $this->resolveObjectiveVictory($next, $endingTeamId === $teamIds[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: string, damage?: int}>
|
||||
*/
|
||||
private function resolveAbilityTargets(
|
||||
MatchState $match,
|
||||
UnitState $caster,
|
||||
AbilityDefinition $definition,
|
||||
?Position $target,
|
||||
): array {
|
||||
if ($definition->target === AbilityDefinition::TARGET_SELF) {
|
||||
return [['id' => $caster->id]];
|
||||
}
|
||||
|
||||
if ($target === null) {
|
||||
throw new CombatException('Ability requires a target position.');
|
||||
}
|
||||
|
||||
if (!$match->battlefield->contains($target)) {
|
||||
throw new CombatException('Ability target is outside the battlefield.');
|
||||
}
|
||||
|
||||
$distance = $caster->position->distanceTo($target);
|
||||
|
||||
if ($distance > $definition->range) {
|
||||
throw new CombatException('Ability target is out of range.');
|
||||
}
|
||||
|
||||
$tileKeys = [$target->key()];
|
||||
|
||||
if ($definition->areaRadius > 0) {
|
||||
$tileKeys = self::tilesWithinRadius(
|
||||
$match->battlefield,
|
||||
$target,
|
||||
$definition->areaRadius,
|
||||
);
|
||||
}
|
||||
|
||||
if ($definition->effect === AbilityDefinition::EFFECT_DEAL_DAMAGE) {
|
||||
$affected = [];
|
||||
|
||||
foreach ($match->units as $candidate) {
|
||||
if ($candidate->isDefeated()) {
|
||||
continue;
|
||||
}
|
||||
if ($candidate->teamId === $caster->teamId) {
|
||||
continue;
|
||||
}
|
||||
$found = false;
|
||||
foreach ($tileKeys as $key) {
|
||||
if ($candidate->position->key() === $key) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$terrainDefense = $match->battlefield->terrainAt($candidate->position)->defenseBonus();
|
||||
$damage = max(1, $definition->effectValue - $candidate->defense - $terrainDefense);
|
||||
$affected[] = ['id' => $candidate->id, 'damage' => $damage];
|
||||
}
|
||||
|
||||
return $affected;
|
||||
}
|
||||
|
||||
$targetUnit = null;
|
||||
|
||||
foreach ($match->units as $candidate) {
|
||||
if ($candidate->position->key() === $target->key()) {
|
||||
$targetUnit = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetUnit === null) {
|
||||
throw new CombatException('Ability target is empty.');
|
||||
}
|
||||
|
||||
$expectedTeam = $definition->target === AbilityDefinition::TARGET_ALLY ? $caster->teamId : null;
|
||||
|
||||
if ($expectedTeam !== null && $targetUnit->teamId !== $expectedTeam) {
|
||||
throw new CombatException('Ability target must be an ally.');
|
||||
}
|
||||
|
||||
if ($definition->target === AbilityDefinition::TARGET_ENEMY && $targetUnit->teamId === $caster->teamId) {
|
||||
throw new CombatException('Ability target must be an enemy.');
|
||||
}
|
||||
|
||||
return [['id' => $targetUnit->id]];
|
||||
}
|
||||
|
||||
private function checkVictoryAfterAction(MatchState $match, string $actingTeamId): MatchState
|
||||
{
|
||||
foreach ($match->units as $unit) {
|
||||
if ($unit->teamId !== $actingTeamId && !$unit->isDefeated()) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
return $match->withWinner($actingTeamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $endingTeamIsFirstInOrder true when the team that just ended is the first team in
|
||||
* sorted team order; this identifies the first half of a round.
|
||||
*/
|
||||
private function resolveObjectiveVictory(MatchState $match, bool $endingTeamIsFirstInOrder): MatchState
|
||||
{
|
||||
if ($match->victoryCondition !== VictoryCondition::HoldObjective || $match->objectives === []) {
|
||||
return $match;
|
||||
}
|
||||
|
||||
$next = $match;
|
||||
|
||||
if ($endingTeamIsFirstInOrder) {
|
||||
/** @var list<ObjectiveMarker> $objectiveList */
|
||||
$objectiveList = array_values($match->objectives);
|
||||
$objective = $objectiveList[0];
|
||||
$controller = $this->objectiveController($match, $objective->position);
|
||||
|
||||
if ($controller !== null) {
|
||||
$control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller);
|
||||
$next = $match->withObjectiveControl($control->roundsByTeam);
|
||||
}
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
foreach ($match->objectiveControl as $teamId => $count) {
|
||||
if ($count >= $match->holdRoundsRequired) {
|
||||
return $next->withWinner($teamId);
|
||||
}
|
||||
}
|
||||
|
||||
return $next;
|
||||
}
|
||||
|
||||
private function objectiveController(MatchState $match, Position $tile): ?string
|
||||
{
|
||||
$teams = [];
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
if ($unit->isDefeated() || $unit->position->key() !== $tile->key()) {
|
||||
continue;
|
||||
}
|
||||
$teams[$unit->teamId] = true;
|
||||
}
|
||||
|
||||
if (count($teams) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_key_first($teams);
|
||||
}
|
||||
|
||||
private function unitOrFail(MatchState $match, string $unitId): UnitState
|
||||
{
|
||||
try {
|
||||
return $match->unit($unitId);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw new CombatException("Unknown unit: {$unitId}.", previous: $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertMatchActive(MatchState $match): void
|
||||
{
|
||||
if ($match->winnerTeamId !== null) {
|
||||
throw new CombatException('The match is already complete.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function tilesWithinRadius(Battlefield $battlefield, Position $center, int $radius): array
|
||||
{
|
||||
$seen = [$center->key() => true];
|
||||
$frontier = [$center];
|
||||
$result = [$center->key()];
|
||||
|
||||
for ($step = 0; $step < $radius; $step++) {
|
||||
$next = [];
|
||||
foreach ($frontier as $position) {
|
||||
foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as $offset) {
|
||||
$candidate = new Position($position->x + $offset[0], $position->y + $offset[1]);
|
||||
if (!$battlefield->contains($candidate)) {
|
||||
continue;
|
||||
}
|
||||
$key = $candidate->key();
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$result[] = $key;
|
||||
$next[] = $candidate;
|
||||
}
|
||||
}
|
||||
$frontier = $next;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function assertCanAct(MatchState $match, UnitState $unit): void
|
||||
{
|
||||
if ($unit->teamId !== $match->activeTeamId) {
|
||||
throw new CombatException("It is not this unit's turn.");
|
||||
}
|
||||
|
||||
if ($unit->isDefeated()) {
|
||||
throw new CombatException('Defeated units cannot act.');
|
||||
}
|
||||
|
||||
if ($unit->actionsRemaining === 0) {
|
||||
throw new CombatException('Unit has no actions remaining.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class CombatException extends DomainException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class DeploymentZone
|
||||
{
|
||||
/** @param list<Position> $positions */
|
||||
public function __construct(
|
||||
public string $teamId,
|
||||
public array $positions,
|
||||
) {
|
||||
if ($teamId === '') {
|
||||
throw new InvalidArgumentException('Deployment zone team id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($positions === []) {
|
||||
throw new InvalidArgumentException('Deployment zone must contain at least one position.');
|
||||
}
|
||||
|
||||
if (!array_is_list($positions)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Deployment zone positions must be a list.');
|
||||
}
|
||||
}
|
||||
|
||||
public function contains(Position $position): bool
|
||||
{
|
||||
foreach ($this->positions as $candidate) {
|
||||
if ($candidate->key() === $position->key()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class MatchState
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param list<string> $actionLog
|
||||
* @param array<string, ObjectiveMarker> $objectives
|
||||
* @param array<string, int> $objectiveControl
|
||||
*/
|
||||
public function __construct(
|
||||
public Battlefield $battlefield,
|
||||
public array $units,
|
||||
public string $activeTeamId,
|
||||
public int $round = 1,
|
||||
public ?string $winnerTeamId = null,
|
||||
public array $actionLog = [],
|
||||
public array $objectives = [],
|
||||
public VictoryCondition $victoryCondition = VictoryCondition::EliminateAll,
|
||||
public int $holdRoundsRequired = 1,
|
||||
public array $objectiveControl = [],
|
||||
) {
|
||||
if (!self::isList($units)) {
|
||||
throw new InvalidArgumentException('Match units must be a list.');
|
||||
}
|
||||
|
||||
if ($units === []) {
|
||||
throw new InvalidArgumentException('Match must contain at least one unit.');
|
||||
}
|
||||
|
||||
$unitIds = [];
|
||||
$teamIds = [];
|
||||
$occupiedPositions = [];
|
||||
|
||||
foreach ($units as $unit) {
|
||||
if (!self::isUnitState($unit)) {
|
||||
throw new InvalidArgumentException('Match units must contain only UnitState instances.');
|
||||
}
|
||||
|
||||
if (isset($unitIds[$unit->id])) {
|
||||
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
|
||||
}
|
||||
|
||||
if (!$battlefield->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
|
||||
}
|
||||
|
||||
if (!$unit->isDefeated() && $battlefield->terrainAt($unit->position)->movementCost() === null) {
|
||||
throw new InvalidArgumentException("Living unit {$unit->id} must stand on passable terrain.");
|
||||
}
|
||||
|
||||
$positionKey = $unit->position->key();
|
||||
|
||||
if (!$unit->isDefeated() && isset($occupiedPositions[$positionKey])) {
|
||||
throw new InvalidArgumentException("Living units cannot share position {$positionKey}.");
|
||||
}
|
||||
|
||||
if (!$unit->isDefeated()) {
|
||||
$occupiedPositions[$positionKey] = true;
|
||||
}
|
||||
|
||||
$unitIds[$unit->id] = true;
|
||||
$teamIds[$unit->teamId] = true;
|
||||
}
|
||||
|
||||
if ($activeTeamId === '') {
|
||||
throw new InvalidArgumentException('Active team id cannot be empty.');
|
||||
}
|
||||
|
||||
if (!isset($teamIds[$activeTeamId])) {
|
||||
throw new InvalidArgumentException("Active team has no units: {$activeTeamId}.");
|
||||
}
|
||||
|
||||
if ($round < 1) {
|
||||
throw new InvalidArgumentException('Match round must be at least 1.');
|
||||
}
|
||||
|
||||
if (!self::isList($actionLog)) {
|
||||
throw new InvalidArgumentException('Match action log must be a list.');
|
||||
}
|
||||
|
||||
foreach ($actionLog as $entry) {
|
||||
if (!self::isString($entry)) {
|
||||
throw new InvalidArgumentException('Match action log entries must be strings.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($winnerTeamId !== null && $winnerTeamId === '') {
|
||||
throw new InvalidArgumentException('Winner team id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) {
|
||||
throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}.");
|
||||
}
|
||||
|
||||
if ($holdRoundsRequired < 1) {
|
||||
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||
}
|
||||
|
||||
foreach ($objectives as $objective) {
|
||||
if (!$objective instanceof ObjectiveMarker) { // @phpstan-ignore instanceof.alwaysTrue (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Objectives must be ObjectiveMarker instances.');
|
||||
}
|
||||
if (!$battlefield->contains($objective->position)) {
|
||||
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($objectiveControl as $teamId => $count) {
|
||||
if (!self::isString($teamId) || $teamId === '') {
|
||||
throw new InvalidArgumentException('Objective control team id must be a non-empty string.');
|
||||
}
|
||||
if (!self::isInt($count) || $count < 0) {
|
||||
throw new InvalidArgumentException('Objective control count must be a non-negative integer.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function isUnitState(mixed $unit): bool
|
||||
{
|
||||
return $unit instanceof UnitState;
|
||||
}
|
||||
|
||||
private static function isString(mixed $value): bool
|
||||
{
|
||||
return is_string($value);
|
||||
}
|
||||
|
||||
private static function isInt(mixed $value): bool
|
||||
{
|
||||
return is_int($value);
|
||||
}
|
||||
|
||||
private static function isList(mixed $value): bool
|
||||
{
|
||||
return is_array($value) && array_is_list($value);
|
||||
}
|
||||
|
||||
public function unit(string $id): UnitState
|
||||
{
|
||||
foreach ($this->units as $unit) {
|
||||
if ($unit->id === $id) {
|
||||
return $unit;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown unit id: {$id}.");
|
||||
}
|
||||
|
||||
public function withUnit(UnitState $replacement): self
|
||||
{
|
||||
$units = $this->units;
|
||||
|
||||
foreach ($units as $index => $unit) {
|
||||
if ($unit->id === $replacement->id) {
|
||||
$units[$index] = $replacement;
|
||||
|
||||
return $this->copy(units: $units);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown unit id: {$replacement->id}.");
|
||||
}
|
||||
|
||||
public function withWinner(?string $winnerTeamId): self
|
||||
{
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$this->units,
|
||||
$this->activeTeamId,
|
||||
$this->round,
|
||||
$winnerTeamId,
|
||||
$this->actionLog,
|
||||
$this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$this->objectiveControl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $objectiveControl
|
||||
*/
|
||||
public function withObjectiveControl(array $objectiveControl): self
|
||||
{
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$this->units,
|
||||
$this->activeTeamId,
|
||||
$this->round,
|
||||
$this->winnerTeamId,
|
||||
$this->actionLog,
|
||||
$this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$objectiveControl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState>|null $units
|
||||
* @param list<string>|null $actionLog
|
||||
* @param array<string, ObjectiveMarker>|null $objectives
|
||||
* @param array<string, int>|null $objectiveControl
|
||||
*/
|
||||
public function copy(
|
||||
?array $units = null,
|
||||
?string $activeTeamId = null,
|
||||
?int $round = null,
|
||||
?array $actionLog = null,
|
||||
?array $objectives = null,
|
||||
?array $objectiveControl = null,
|
||||
): self {
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$units ?? $this->units,
|
||||
$activeTeamId ?? $this->activeTeamId,
|
||||
$round ?? $this->round,
|
||||
$this->winnerTeamId,
|
||||
$actionLog ?? $this->actionLog,
|
||||
$objectives ?? $this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$objectiveControl ?? $this->objectiveControl,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ObjectiveControl
|
||||
{
|
||||
/** @param array<string, int> $roundsByTeam */
|
||||
public function __construct(public array $roundsByTeam)
|
||||
{
|
||||
foreach ($roundsByTeam as $teamId => $count) {
|
||||
if ($teamId === '') {
|
||||
throw new InvalidArgumentException('Objective control team id cannot be empty.');
|
||||
}
|
||||
if ($count < 0) {
|
||||
throw new InvalidArgumentException('Objective control count cannot be negative.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
public function recordRound(string $controllerTeamId): self
|
||||
{
|
||||
if ($controllerTeamId === '') {
|
||||
throw new InvalidArgumentException('Controller team id cannot be empty.');
|
||||
}
|
||||
|
||||
$next = $this->roundsByTeam;
|
||||
$next[$controllerTeamId] = ($next[$controllerTeamId] ?? 0) + 1;
|
||||
|
||||
return new self($next);
|
||||
}
|
||||
|
||||
/** @return array{teamId: string, rounds: int} */
|
||||
public function forTeam(string $teamId): array
|
||||
{
|
||||
return ['teamId' => $teamId, 'rounds' => $this->roundsByTeam[$teamId] ?? 0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class ObjectiveMarker
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public Position $position,
|
||||
) {
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException('Objective id cannot be empty.');
|
||||
}
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final readonly class Position
|
||||
{
|
||||
public function __construct(
|
||||
public int $x,
|
||||
public int $y,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->x . ':' . $this->y;
|
||||
}
|
||||
|
||||
public function distanceTo(self $position): int
|
||||
{
|
||||
return abs($this->x - $position->x) + abs($this->y - $position->y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class Scenario
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param array<string, DeploymentZone> $deploymentZones
|
||||
* @param array<string, ObjectiveMarker> $objectives
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public Battlefield $battlefield,
|
||||
public array $units,
|
||||
public array $deploymentZones,
|
||||
public array $objectives,
|
||||
public VictoryCondition $victoryCondition,
|
||||
public int $holdRoundsRequired,
|
||||
) {
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException('Scenario id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Scenario name cannot be empty.');
|
||||
}
|
||||
|
||||
if (!array_is_list($units)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Scenario units must be a list.');
|
||||
}
|
||||
|
||||
if ($deploymentZones === []) {
|
||||
throw new InvalidArgumentException('Scenario must declare at least one deployment zone.');
|
||||
}
|
||||
|
||||
if (count($deploymentZones) !== 2) {
|
||||
throw new InvalidArgumentException('Scenarios must declare exactly two deployment zones.');
|
||||
}
|
||||
|
||||
if ($holdRoundsRequired < 1) {
|
||||
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||
}
|
||||
|
||||
if ($victoryCondition === VictoryCondition::HoldObjective && count($this->objectives) !== 1) {
|
||||
throw new InvalidArgumentException('Hold objective victory requires exactly one objective.');
|
||||
}
|
||||
|
||||
if ($victoryCondition === VictoryCondition::EliminateAll && $this->objectives !== []) {
|
||||
throw new InvalidArgumentException('Eliminate all victory does not allow objectives.');
|
||||
}
|
||||
|
||||
$unitsByTeam = $this->groupUnitsByTeam($units);
|
||||
|
||||
foreach ($unitsByTeam as $teamId => $teamUnits) {
|
||||
if (count($teamUnits) < 3 || count($teamUnits) > 6) {
|
||||
throw new InvalidArgumentException("Team {$teamId} must have between 3 and 6 units.");
|
||||
}
|
||||
|
||||
if (!isset($deploymentZones[$teamId])) {
|
||||
throw new InvalidArgumentException("Team {$teamId} is missing a deployment zone.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($units as $unit) {
|
||||
if (!$this->battlefield->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
|
||||
}
|
||||
|
||||
$template = ArchetypeCatalog::templates()[$unit->archetype->value] ?? null;
|
||||
if ($template === null) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype.");
|
||||
}
|
||||
|
||||
if ($unit->maxHealth < $template->minHealth || $unit->maxHealth > $template->maxHealth) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} health is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->attack < $template->minAttack || $unit->attack > $template->maxAttack) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} attack is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->defense < $template->minDefense || $unit->defense > $template->maxDefense) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} defense is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->speed < $template->minSpeed || $unit->speed > $template->maxSpeed) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} speed is outside archetype bounds.");
|
||||
}
|
||||
|
||||
foreach ($unit->abilities as $ability) {
|
||||
if (!in_array($ability, $template->allowedAbilities, true)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} has an ability that its archetype forbids.");
|
||||
}
|
||||
}
|
||||
|
||||
$zone = $deploymentZones[$unit->teamId] ?? null;
|
||||
if ($zone === null || !$zone->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} must start inside team {$unit->teamId} deployment zone.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($objectives as $objective) {
|
||||
if (!$this->battlefield->contains($objective->position)) {
|
||||
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function startMatch(string $activeTeamId): MatchState
|
||||
{
|
||||
$resetUnits = [];
|
||||
|
||||
foreach ($this->units as $unit) {
|
||||
$resetUnits[] = new UnitState(
|
||||
$unit->id,
|
||||
$unit->teamId,
|
||||
$unit->position,
|
||||
$unit->maxHealth,
|
||||
$unit->maxHealth,
|
||||
$unit->attack,
|
||||
$unit->defense,
|
||||
$unit->speed,
|
||||
2,
|
||||
false,
|
||||
$unit->archetype,
|
||||
$unit->abilities,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return new MatchState(
|
||||
battlefield: $this->battlefield,
|
||||
units: $resetUnits,
|
||||
activeTeamId: $activeTeamId,
|
||||
objectives: $this->objectives,
|
||||
victoryCondition: $this->victoryCondition,
|
||||
holdRoundsRequired: $this->holdRoundsRequired,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @return array<string, list<UnitState>>
|
||||
*/
|
||||
private function groupUnitsByTeam(array $units): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($units as $unit) {
|
||||
$grouped[$unit->teamId] ??= [];
|
||||
$grouped[$unit->teamId][] = $unit;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class ScenarioValidator
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function validate(Scenario $scenario): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$occupied = [];
|
||||
|
||||
foreach ($scenario->deploymentZones as $teamId => $zone) {
|
||||
foreach ($zone->positions as $position) {
|
||||
if (!$scenario->battlefield->contains($position)) {
|
||||
$errors[] = "Deployment zone for {$teamId} includes a position outside the battlefield.";
|
||||
continue;
|
||||
}
|
||||
if ($scenario->battlefield->terrainAt($position)->movementCost() === null) {
|
||||
$errors[] = "Deployment zone for {$teamId} sits on impassable terrain at {$position->key()}.";
|
||||
}
|
||||
if (isset($occupied[$position->key()])) {
|
||||
$errors[] = "Deployment zones overlap at {$position->key()}.";
|
||||
}
|
||||
$occupied[$position->key()] = $teamId;
|
||||
}
|
||||
}
|
||||
|
||||
$reachableFromDeployment = self::reachableFromDeployment($scenario, $occupied);
|
||||
|
||||
foreach ($scenario->objectives as $objective) {
|
||||
if (!$scenario->battlefield->contains($objective->position)) {
|
||||
$errors[] = "Objective {$objective->id} is outside the battlefield.";
|
||||
continue;
|
||||
}
|
||||
if ($scenario->battlefield->terrainAt($objective->position)->movementCost() === null) {
|
||||
$errors[] = "Objective {$objective->id} sits on impassable terrain.";
|
||||
}
|
||||
if (!isset($reachableFromDeployment[$objective->position->key()])) {
|
||||
$errors[] = "Objective {$objective->id} is unreachable from any deployment zone.";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $occupied
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private static function reachableFromDeployment(Scenario $scenario, array $occupied): array
|
||||
{
|
||||
$merged = $scenario->units;
|
||||
$allReachable = [];
|
||||
|
||||
foreach ($merged as $unit) {
|
||||
$others = [];
|
||||
|
||||
foreach ($merged as $other) {
|
||||
if ($other->id === $unit->id || $other->isDefeated()) {
|
||||
continue;
|
||||
}
|
||||
$others[] = $other->position;
|
||||
}
|
||||
|
||||
$reachable = $scenario->battlefield->reachable($unit->position, $unit->speed, $others);
|
||||
|
||||
foreach ($reachable as $key => $_cost) {
|
||||
if (!isset($occupied[$key])) {
|
||||
$allReachable[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $allReachable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum Terrain: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case Forest = 'forest';
|
||||
case Rough = 'rough';
|
||||
case Water = 'water';
|
||||
case Blocking = 'blocking';
|
||||
|
||||
public function movementCost(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => 1,
|
||||
self::Forest, self::Rough => 2,
|
||||
self::Water, self::Blocking => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function defenseBonus(): int
|
||||
{
|
||||
return $this === self::Forest ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class UnitState
|
||||
{
|
||||
/** @param list<string> $abilities */
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $teamId,
|
||||
public Position $position,
|
||||
public int $maxHealth,
|
||||
public int $health,
|
||||
public int $attack,
|
||||
public int $defense,
|
||||
public int $speed,
|
||||
public int $actionsRemaining,
|
||||
public bool $hasAttacked = false,
|
||||
public Archetype $archetype = Archetype::Defender,
|
||||
public array $abilities = [],
|
||||
public int $attackBonus = 0,
|
||||
public bool $hasUsedAbility = false,
|
||||
) {
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException('Unit id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($teamId === '') {
|
||||
throw new InvalidArgumentException('Unit team id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($maxHealth < 1) {
|
||||
throw new InvalidArgumentException('Unit maximum health must be at least 1.');
|
||||
}
|
||||
|
||||
if ($health < 0 || $health > $maxHealth) {
|
||||
throw new InvalidArgumentException('Unit health must be between 0 and maximum health.');
|
||||
}
|
||||
|
||||
if ($attack < 0 || $defense < 0) {
|
||||
throw new InvalidArgumentException('Unit attack and defense cannot be negative.');
|
||||
}
|
||||
|
||||
if ($speed < 1) {
|
||||
throw new InvalidArgumentException('Unit speed must be at least 1.');
|
||||
}
|
||||
|
||||
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
|
||||
throw new InvalidArgumentException('Unit actions remaining must be between 0 and 2.');
|
||||
}
|
||||
|
||||
if ($attackBonus < 0) {
|
||||
throw new InvalidArgumentException('Unit attack bonus cannot be negative.');
|
||||
}
|
||||
|
||||
if (!self::isList($abilities)) {
|
||||
throw new InvalidArgumentException('Unit abilities must be a list.');
|
||||
}
|
||||
|
||||
foreach ($abilities as $ability) {
|
||||
// @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
if (!is_string($ability) || $ability === '') {
|
||||
throw new InvalidArgumentException('Unit abilities must be non-empty strings.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function isDefeated(): bool
|
||||
{
|
||||
return $this->health === 0;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function allowedAbilities(): array
|
||||
{
|
||||
return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities;
|
||||
}
|
||||
|
||||
public function moveTo(Position $position): self
|
||||
{
|
||||
return $this->copy(position: $position);
|
||||
}
|
||||
|
||||
public function spendAction(): self
|
||||
{
|
||||
if ($this->actionsRemaining === 0) {
|
||||
throw new InvalidArgumentException('Unit has no actions remaining.');
|
||||
}
|
||||
|
||||
return $this->copy(actionsRemaining: $this->actionsRemaining - 1);
|
||||
}
|
||||
|
||||
public function spendAbility(): self
|
||||
{
|
||||
if ($this->hasUsedAbility) {
|
||||
throw new InvalidArgumentException('Unit has already used an ability this turn.');
|
||||
}
|
||||
|
||||
if ($this->actionsRemaining === 0) {
|
||||
throw new InvalidArgumentException('Unit has no actions remaining.');
|
||||
}
|
||||
|
||||
return $this->copy(actionsRemaining: $this->actionsRemaining - 1, hasUsedAbility: true);
|
||||
}
|
||||
|
||||
public function takeDamage(int $damage): self
|
||||
{
|
||||
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
||||
}
|
||||
|
||||
public function restoreHealth(int $amount): self
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new InvalidArgumentException('Restore health amount cannot be negative.');
|
||||
}
|
||||
|
||||
return $this->copy(health: min($this->maxHealth, $this->health + $amount));
|
||||
}
|
||||
|
||||
public function markAttacked(): self
|
||||
{
|
||||
return $this->copy(hasAttacked: true);
|
||||
}
|
||||
|
||||
public function withAttackBonus(int $bonus): self
|
||||
{
|
||||
if ($bonus < 0) {
|
||||
throw new InvalidArgumentException('Attack bonus cannot be negative.');
|
||||
}
|
||||
|
||||
return $this->copy(attackBonus: $bonus);
|
||||
}
|
||||
|
||||
public function startTurn(): self
|
||||
{
|
||||
if ($this->isDefeated()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->copy(
|
||||
actionsRemaining: 2,
|
||||
hasAttacked: false,
|
||||
attackBonus: 0,
|
||||
hasUsedAbility: false,
|
||||
);
|
||||
}
|
||||
|
||||
private function copy(
|
||||
?Position $position = null,
|
||||
?int $health = null,
|
||||
?int $actionsRemaining = null,
|
||||
?bool $hasAttacked = null,
|
||||
?int $attackBonus = null,
|
||||
?bool $hasUsedAbility = null,
|
||||
): self {
|
||||
return new self(
|
||||
$this->id,
|
||||
$this->teamId,
|
||||
$position ?? $this->position,
|
||||
$this->maxHealth,
|
||||
$health ?? $this->health,
|
||||
$this->attack,
|
||||
$this->defense,
|
||||
$this->speed,
|
||||
$actionsRemaining ?? $this->actionsRemaining,
|
||||
$hasAttacked ?? $this->hasAttacked,
|
||||
$this->archetype,
|
||||
$this->abilities,
|
||||
$attackBonus ?? $this->attackBonus,
|
||||
$hasUsedAbility ?? $this->hasUsedAbility,
|
||||
);
|
||||
}
|
||||
|
||||
private static function isList(mixed $value): bool
|
||||
{
|
||||
return is_array($value) && array_is_list($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
enum VictoryCondition: string
|
||||
{
|
||||
case EliminateAll = 'eliminate_all';
|
||||
case HoldObjective = 'hold_objective';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class CsrfToken
|
||||
{
|
||||
/** @return array{0: string, 1: string} */
|
||||
public static function issue(string $secret): array
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$cookie = hash_hmac('sha256', $token, $secret);
|
||||
|
||||
return [$token, $cookie];
|
||||
}
|
||||
|
||||
public static function verify(string $submitted, string $secret, string $expectedCookieValue): bool
|
||||
{
|
||||
$computed = hash_hmac('sha256', $submitted, $secret);
|
||||
|
||||
return hash_equals($expectedCookieValue, $computed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http;
|
||||
|
||||
final class Escape
|
||||
{
|
||||
public static function html(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
public static function attr(mixed $value): string
|
||||
{
|
||||
return self::html($value);
|
||||
}
|
||||
|
||||
public static function url(string $value): string
|
||||
{
|
||||
return rawurlencode($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\AbilityCatalog;
|
||||
use BattleForge\Domain\AbilityDefinition;
|
||||
use BattleForge\Domain\AbilityId;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AbilityCatalogTest extends TestCase
|
||||
{
|
||||
public function testItExposesExactlyTheThreePlannedAbilities(): void
|
||||
{
|
||||
$keys = array_keys(AbilityCatalog::definitions());
|
||||
|
||||
sort($keys);
|
||||
|
||||
self::assertSame(['area_damage', 'buff', 'heal'], $keys);
|
||||
}
|
||||
|
||||
public function testHealTargetsAnAllyWithinRangeAndRestoresHealth(): void
|
||||
{
|
||||
$definition = AbilityCatalog::definitions()[AbilityId::Heal->value];
|
||||
|
||||
self::assertInstanceOf(AbilityDefinition::class, $definition);
|
||||
self::assertSame('Heal', $definition->label);
|
||||
self::assertSame(2, $definition->range);
|
||||
self::assertSame(0, $definition->areaRadius);
|
||||
self::assertSame('ally', $definition->target);
|
||||
self::assertSame('restore_health', $definition->effect);
|
||||
self::assertSame(5, $definition->effectValue);
|
||||
}
|
||||
|
||||
public function testAreaDamageTargetsAnEnemyTileAndAffectsANeighborhood(): void
|
||||
{
|
||||
$definition = AbilityCatalog::definitions()[AbilityId::AreaDamage->value];
|
||||
|
||||
self::assertSame('Area Damage', $definition->label);
|
||||
self::assertSame(3, $definition->range);
|
||||
self::assertSame(1, $definition->areaRadius);
|
||||
self::assertSame('tile', $definition->target);
|
||||
self::assertSame('deal_damage', $definition->effect);
|
||||
self::assertSame(3, $definition->effectValue);
|
||||
}
|
||||
|
||||
public function testBuffTargetsAnAllyAndAppliesAnAttackBonus(): void
|
||||
{
|
||||
$definition = AbilityCatalog::definitions()[AbilityId::Buff->value];
|
||||
|
||||
self::assertSame('Rally', $definition->label);
|
||||
self::assertSame(1, $definition->range);
|
||||
self::assertSame(0, $definition->areaRadius);
|
||||
self::assertSame('ally', $definition->target);
|
||||
self::assertSame('apply_buff', $definition->effect);
|
||||
self::assertSame(2, $definition->effectValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\ArchetypeCatalog;
|
||||
use BattleForge\Domain\ArchetypeTemplate;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ArchetypeCatalogTest extends TestCase
|
||||
{
|
||||
public function testItExposesExactlyTheFourPlannedArchetypes(): void
|
||||
{
|
||||
$keys = array_keys(ArchetypeCatalog::templates());
|
||||
|
||||
sort($keys);
|
||||
|
||||
self::assertSame(['defender', 'scout', 'striker', 'support'], $keys);
|
||||
}
|
||||
|
||||
public function testEveryArchetypeTemplateCarriesAllFourStatRanges(): void
|
||||
{
|
||||
foreach (ArchetypeCatalog::templates() as $key => $template) {
|
||||
self::assertInstanceOf(ArchetypeTemplate::class, $template);
|
||||
self::assertLessThanOrEqual($template->maxHealth, $template->minHealth);
|
||||
self::assertLessThanOrEqual($template->maxAttack, $template->minAttack);
|
||||
self::assertLessThanOrEqual($template->maxDefense, $template->minDefense);
|
||||
self::assertLessThanOrEqual($template->maxSpeed, $template->minSpeed);
|
||||
self::assertGreaterThanOrEqual(1, $template->minSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
public function testEveryArchetypeTemplateDefaultsToTheCenterOfItsRanges(): void
|
||||
{
|
||||
foreach (ArchetypeCatalog::templates() as $template) {
|
||||
$defaults = $template->defaultStats();
|
||||
|
||||
self::assertSame((int) ceil(($template->minHealth + $template->maxHealth) / 2), $defaults['maxHealth']);
|
||||
self::assertSame((int) ceil(($template->minAttack + $template->maxAttack) / 2), $defaults['attack']);
|
||||
self::assertSame((int) ceil(($template->minDefense + $template->maxDefense) / 2), $defaults['defense']);
|
||||
self::assertSame((int) ceil(($template->minSpeed + $template->maxSpeed) / 2), $defaults['speed']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDefaultStatsReturnAllFourKeys(): void
|
||||
{
|
||||
$template = ArchetypeCatalog::templates()[Archetype::Defender->value];
|
||||
|
||||
self::assertSame(
|
||||
['maxHealth', 'attack', 'defense', 'speed'],
|
||||
array_keys($template->defaultStats()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\ArchetypeTemplate;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ArchetypeTemplateTest extends TestCase
|
||||
{
|
||||
public function testItRejectsInvertedStatRanges(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
new ArchetypeTemplate(
|
||||
minHealth: 10,
|
||||
maxHealth: 5,
|
||||
minAttack: 1,
|
||||
maxAttack: 3,
|
||||
minDefense: 0,
|
||||
maxDefense: 2,
|
||||
minSpeed: 1,
|
||||
maxSpeed: 4,
|
||||
allowedAbilities: [],
|
||||
);
|
||||
}
|
||||
|
||||
public function testItRejectsZeroMinimumSpeed(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
new ArchetypeTemplate(
|
||||
minHealth: 5,
|
||||
maxHealth: 10,
|
||||
minAttack: 1,
|
||||
maxAttack: 3,
|
||||
minDefense: 0,
|
||||
maxDefense: 2,
|
||||
minSpeed: 0,
|
||||
maxSpeed: 4,
|
||||
allowedAbilities: [],
|
||||
);
|
||||
}
|
||||
|
||||
public function testItExposesAllowedAbilitiesVerbatim(): void
|
||||
{
|
||||
$template = new ArchetypeTemplate(
|
||||
minHealth: 5,
|
||||
maxHealth: 10,
|
||||
minAttack: 1,
|
||||
maxAttack: 3,
|
||||
minDefense: 0,
|
||||
maxDefense: 2,
|
||||
minSpeed: 1,
|
||||
maxSpeed: 4,
|
||||
allowedAbilities: ['heal', 'buff'],
|
||||
);
|
||||
|
||||
self::assertSame(['heal', 'buff'], $template->allowedAbilities);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BattlefieldTest extends TestCase
|
||||
{
|
||||
public function testItRejectsDimensionsOutsideTheSupportedRange(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new Battlefield(7, 16);
|
||||
}
|
||||
|
||||
public function testItAcceptsMinimumAndMaximumDimensions(): void
|
||||
{
|
||||
$minimum = new Battlefield(8, 8);
|
||||
$maximum = new Battlefield(16, 16);
|
||||
|
||||
self::assertSame([8, 8], [$minimum->width, $minimum->height]);
|
||||
self::assertSame([16, 16], [$maximum->width, $maximum->height]);
|
||||
}
|
||||
|
||||
public function testItRejectsDimensionsAboveTheSupportedRange(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new Battlefield(16, 17);
|
||||
}
|
||||
|
||||
public function testItRejectsNonCanonicalTerrainCoordinates(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new Battlefield(8, 8, ['01:0' => Terrain::Forest]);
|
||||
}
|
||||
|
||||
public function testItRejectsMalformedTerrainCoordinates(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new Battlefield(8, 8, ['not-a-coordinate' => Terrain::Forest]);
|
||||
}
|
||||
|
||||
public function testItRejectsInvalidTerrainValues(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
// @phpstan-ignore argument.type
|
||||
new Battlefield(8, 8, ['0:0' => 'forest']);
|
||||
}
|
||||
|
||||
public function testReachableRejectsAStartOutsideTheBattlefield(): void
|
||||
{
|
||||
$battlefield = new Battlefield(8, 8);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$battlefield->reachable(new Position(-1, 0), 2, []);
|
||||
}
|
||||
|
||||
public function testReachableRejectsANegativeBudget(): void
|
||||
{
|
||||
$battlefield = new Battlefield(8, 8);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$battlefield->reachable(new Position(0, 0), -1, []);
|
||||
}
|
||||
|
||||
public function testReachableRejectsOccupiedPositionsOutsideTheBattlefield(): void
|
||||
{
|
||||
$battlefield = new Battlefield(8, 8);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$battlefield->reachable(new Position(0, 0), 2, [new Position(8, 0)]);
|
||||
}
|
||||
|
||||
public function testItFindsReachableTilesUsingTerrainCostsAndObstacles(): void
|
||||
{
|
||||
$battlefield = new Battlefield(8, 8, [
|
||||
'1:0' => Terrain::Forest,
|
||||
'0:1' => Terrain::Rough,
|
||||
'2:0' => Terrain::Water,
|
||||
'1:1' => Terrain::Blocking,
|
||||
]);
|
||||
|
||||
$reachable = $battlefield->reachable(
|
||||
new Position(0, 0),
|
||||
2,
|
||||
[new Position(0, 2)],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'0:0' => 0,
|
||||
'1:0' => 2,
|
||||
'0:1' => 2,
|
||||
], $reachable);
|
||||
}
|
||||
|
||||
public function testReachableReplacesAHighCostRouteWithALowerCostDetour(): void
|
||||
{
|
||||
$battlefield = new Battlefield(8, 8, [
|
||||
'1:0' => Terrain::Forest,
|
||||
'2:0' => Terrain::Forest,
|
||||
'3:0' => Terrain::Forest,
|
||||
]);
|
||||
|
||||
$reachable = $battlefield->reachable(new Position(0, 0), 7, []);
|
||||
|
||||
self::assertSame(6, $reachable['4:0']);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\MatchState;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MatchStateTest extends TestCase
|
||||
{
|
||||
public function testItRejectsDuplicateUnitIds(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(
|
||||
new Battlefield(8, 8),
|
||||
[$this->unit('unit-1', 'alpha'), $this->unit('unit-1', 'bravo')],
|
||||
'alpha',
|
||||
);
|
||||
}
|
||||
|
||||
public function testItRejectsAUnitOutsideTheBattlefield(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Unit alpha-1 is outside the battlefield.');
|
||||
|
||||
new MatchState(
|
||||
new Battlefield(8, 8),
|
||||
[$this->unit('alpha-1', 'alpha', new Position(8, 0))],
|
||||
'alpha',
|
||||
);
|
||||
}
|
||||
|
||||
#[DataProvider('impassableTerrainProvider')]
|
||||
public function testItRejectsALivingUnitOnImpassableTerrain(Terrain $terrain): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Living unit alpha-1 must stand on passable terrain.');
|
||||
|
||||
new MatchState(
|
||||
new Battlefield(8, 8, ['0:0' => $terrain]),
|
||||
[$this->unit('alpha-1', 'alpha')],
|
||||
'alpha',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{Terrain}>
|
||||
*/
|
||||
public static function impassableTerrainProvider(): iterable
|
||||
{
|
||||
yield 'water' => [Terrain::Water];
|
||||
yield 'blocking' => [Terrain::Blocking];
|
||||
}
|
||||
|
||||
public function testItRejectsLivingUnitsAtTheSamePosition(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Living units cannot share position 0:0.');
|
||||
|
||||
new MatchState(
|
||||
new Battlefield(8, 8),
|
||||
[$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')],
|
||||
'alpha',
|
||||
);
|
||||
}
|
||||
|
||||
public function testItAcceptsLivingAndDefeatedUnitsAtTheSamePosition(): void
|
||||
{
|
||||
$match = new MatchState(
|
||||
new Battlefield(8, 8),
|
||||
[
|
||||
$this->unit('alpha-1', 'alpha'),
|
||||
$this->unit('bravo-1', 'bravo', health: 0),
|
||||
],
|
||||
'alpha',
|
||||
);
|
||||
|
||||
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
||||
self::assertSame('0:0', $match->unit('bravo-1')->position->key());
|
||||
}
|
||||
|
||||
public function testItRejectsAnUnknownUnitLookup(): void
|
||||
{
|
||||
$match = $this->match();
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$match->unit('unknown');
|
||||
}
|
||||
|
||||
public function testItRejectsAnUnknownReplacement(): void
|
||||
{
|
||||
$match = $this->match();
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$match->withUnit($this->unit('unknown', 'alpha'));
|
||||
}
|
||||
|
||||
public function testReplacingAUnitDoesNotMutateTheMatch(): void
|
||||
{
|
||||
$match = $this->match();
|
||||
$replacement = $match->unit('alpha-1')->moveTo(new Position(2, 0));
|
||||
$replaced = $match->withUnit($replacement);
|
||||
|
||||
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
||||
self::assertSame('2:0', $replaced->unit('alpha-1')->position->key());
|
||||
}
|
||||
|
||||
public function testCopyReplacesMutableMatchProgressIncludingAnEmptyLog(): void
|
||||
{
|
||||
$match = $this->match(actionLog: ['started']);
|
||||
$copied = $match->copy(activeTeamId: 'bravo', round: 2, actionLog: []);
|
||||
|
||||
self::assertSame('alpha', $match->activeTeamId);
|
||||
self::assertSame(1, $match->round);
|
||||
self::assertSame(['started'], $match->actionLog);
|
||||
self::assertSame('bravo', $copied->activeTeamId);
|
||||
self::assertSame(2, $copied->round);
|
||||
self::assertSame([], $copied->actionLog);
|
||||
}
|
||||
|
||||
public function testWinnerCanBeSetAndClearedWithoutChangingOtherState(): void
|
||||
{
|
||||
$match = $this->match(actionLog: ['started']);
|
||||
$won = $match->withWinner('alpha');
|
||||
$cleared = $won->withWinner(null);
|
||||
|
||||
self::assertSame('alpha', $won->winnerTeamId);
|
||||
self::assertNull($cleared->winnerTeamId);
|
||||
self::assertSame($match->battlefield, $cleared->battlefield);
|
||||
self::assertSame($match->units, $cleared->units);
|
||||
self::assertSame($match->activeTeamId, $cleared->activeTeamId);
|
||||
self::assertSame($match->round, $cleared->round);
|
||||
self::assertSame($match->actionLog, $cleared->actionLog);
|
||||
}
|
||||
|
||||
public function testItRejectsANonListUnitsArray(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
// @phpstan-ignore argument.type
|
||||
new MatchState(new Battlefield(8, 8), ['first' => $this->unit('alpha-1', 'alpha')], 'alpha');
|
||||
}
|
||||
|
||||
public function testItRejectsAnEmptyUnitsArray(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(new Battlefield(8, 8), [], 'alpha');
|
||||
}
|
||||
|
||||
public function testItRejectsANonUnitMember(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
// @phpstan-ignore argument.type
|
||||
new MatchState(new Battlefield(8, 8), ['not-a-unit'], 'alpha');
|
||||
}
|
||||
|
||||
public function testItRejectsAnEmptyActiveTeam(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], '');
|
||||
}
|
||||
|
||||
public function testItRejectsAnActiveTeamWithoutAUnit(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'bravo');
|
||||
}
|
||||
|
||||
public function testItRejectsARoundBelowOne(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', 0);
|
||||
}
|
||||
|
||||
public function testItRejectsANonListActionLog(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(
|
||||
new Battlefield(8, 8),
|
||||
[$this->unit('alpha-1', 'alpha')],
|
||||
'alpha',
|
||||
// @phpstan-ignore argument.type
|
||||
actionLog: ['first' => 'started'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testItRejectsANonStringActionLogEntry(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
// @phpstan-ignore argument.type
|
||||
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', actionLog: [1]);
|
||||
}
|
||||
|
||||
public function testItRejectsAnEmptyWinnerTeam(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', winnerTeamId: '');
|
||||
}
|
||||
|
||||
public function testItRejectsAWinnerWithoutAUnit(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new MatchState(
|
||||
new Battlefield(8, 8),
|
||||
[$this->unit('alpha-1', 'alpha')],
|
||||
'alpha',
|
||||
winnerTeamId: 'bravo',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $actionLog
|
||||
*/
|
||||
private function match(array $actionLog = []): MatchState
|
||||
{
|
||||
return new MatchState(
|
||||
new Battlefield(8, 8),
|
||||
[
|
||||
$this->unit('alpha-1', 'alpha'),
|
||||
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
||||
],
|
||||
'alpha',
|
||||
actionLog: $actionLog,
|
||||
);
|
||||
}
|
||||
|
||||
private function unit(
|
||||
string $id,
|
||||
string $teamId,
|
||||
?Position $position = null,
|
||||
int $health = 10,
|
||||
): UnitState {
|
||||
return new UnitState($id, $teamId, $position ?? new Position(0, 0), 10, $health, 4, 2, 4, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\CombatEngine;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\MatchState;
|
||||
use BattleForge\Domain\ObjectiveMarker;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScenarioTest extends TestCase
|
||||
{
|
||||
public function testItRejectsNonListUnits(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8),
|
||||
units: ['alpha-1' => $this->unit('alpha-1', 'alpha', new Position(0, 0))], // @phpstan-ignore argument.type (Test fixture intentionally passes a non-list units array to verify constructor validation.)
|
||||
deploymentZones: ['alpha' => new DeploymentZone('alpha', [new Position(0, 0)])],
|
||||
objectives: [],
|
||||
victoryCondition: VictoryCondition::EliminateAll,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
}
|
||||
|
||||
public function testItRejectsTeamsOutsideTheThreeToSixSizeWindow(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Team alpha must have between 3 and 6 units.');
|
||||
|
||||
$units = [
|
||||
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
||||
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
|
||||
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
||||
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
|
||||
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
|
||||
];
|
||||
|
||||
new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8),
|
||||
units: $units,
|
||||
deploymentZones: [
|
||||
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0)]),
|
||||
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||
],
|
||||
objectives: [],
|
||||
victoryCondition: VictoryCondition::EliminateAll,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
}
|
||||
|
||||
public function testItRejectsAbilitiesOutsideTheArchetypeAllowlist(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Unit alpha-1 has an ability that its archetype forbids.');
|
||||
|
||||
$bad = $this->unit('alpha-1', 'alpha', new Position(0, 0), archetype: Archetype::Scout, abilities: ['heal'], attack: 3, defense: 1, speed: 5, maxHealth: 6);
|
||||
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(1, 0));
|
||||
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(2, 0));
|
||||
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
||||
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
|
||||
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
|
||||
|
||||
new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8),
|
||||
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
public function testItRejectsUnitsOutsideTheirDeploymentZone(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Unit alpha-1 must start inside team alpha deployment zone.');
|
||||
|
||||
$bad = $this->unit('alpha-1', 'alpha', new Position(3, 0));
|
||||
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(0, 0));
|
||||
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(1, 0));
|
||||
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
||||
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
|
||||
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
|
||||
|
||||
new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8),
|
||||
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
public function testHoldObjectiveRequiresExactlyOneObjective(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Hold objective victory requires exactly one objective.');
|
||||
|
||||
$units = $this->validUnitSet();
|
||||
$zones = $this->validZones();
|
||||
|
||||
new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8),
|
||||
units: $units,
|
||||
deploymentZones: $zones,
|
||||
objectives: [],
|
||||
victoryCondition: VictoryCondition::HoldObjective,
|
||||
holdRoundsRequired: 3,
|
||||
);
|
||||
}
|
||||
|
||||
public function testStartMatchReturnsAFreshMatchStateWithResetUnits(): void
|
||||
{
|
||||
$scenario = $this->validScenario();
|
||||
|
||||
$match = $scenario->startMatch('alpha');
|
||||
|
||||
self::assertInstanceOf(MatchState::class, $match);
|
||||
self::assertSame('alpha', $match->activeTeamId);
|
||||
self::assertSame(1, $match->round);
|
||||
self::assertNull($match->winnerTeamId);
|
||||
foreach ($match->units as $unit) {
|
||||
self::assertSame(2, $unit->actionsRemaining);
|
||||
self::assertFalse($unit->hasAttacked);
|
||||
self::assertSame(0, $unit->attackBonus);
|
||||
self::assertFalse($unit->hasUsedAbility);
|
||||
}
|
||||
}
|
||||
|
||||
public function testStartMatchDoesNotShareUnitInstancesWithTheScenario(): void
|
||||
{
|
||||
$scenario = $this->validScenario();
|
||||
|
||||
$match = $scenario->startMatch('alpha');
|
||||
|
||||
$original = $scenario->units[0];
|
||||
$fromMatch = $match->unit($original->id);
|
||||
|
||||
self::assertNotSame($original, $fromMatch);
|
||||
self::assertSame($original->id, $fromMatch->id);
|
||||
}
|
||||
|
||||
public function testStartingAMatchWithAbilityAndObjectivePlaysThrough(): void
|
||||
{
|
||||
$units = [
|
||||
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Striker, ['area_damage']),
|
||||
new UnitState('alpha-2', 'alpha', new Position(1, 0), 9, 9, 3, 2, 4, 2, false, Archetype::Support, ['heal', 'buff']),
|
||||
new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
|
||||
new UnitState('bravo-1', 'bravo', new Position(7, 7), 7, 7, 4, 2, 5, 2, false, Archetype::Scout),
|
||||
new UnitState('bravo-2', 'bravo', new Position(6, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Striker),
|
||||
new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
|
||||
];
|
||||
$scenario = new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8),
|
||||
units: $units,
|
||||
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(0, 0))],
|
||||
victoryCondition: VictoryCondition::HoldObjective,
|
||||
holdRoundsRequired: 2,
|
||||
);
|
||||
|
||||
$match = $scenario->startMatch('alpha');
|
||||
$engine = new CombatEngine();
|
||||
$end1 = $engine->endTurn($match);
|
||||
$end2 = $engine->endTurn($end1);
|
||||
|
||||
self::assertSame('alpha', $end2->activeTeamId);
|
||||
self::assertSame(['alpha' => 1], $end2->objectiveControl);
|
||||
self::assertNull($end2->winnerTeamId);
|
||||
}
|
||||
|
||||
/** @return list<UnitState> */
|
||||
private function validUnitSet(): array
|
||||
{
|
||||
return [
|
||||
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
||||
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
|
||||
$this->unit('alpha-3', 'alpha', new Position(2, 0)),
|
||||
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
||||
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
|
||||
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, DeploymentZone> */
|
||||
private function validZones(): array
|
||||
{
|
||||
return [
|
||||
'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),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private function validScenario(): Scenario
|
||||
{
|
||||
return new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: new Battlefield(8, 8),
|
||||
units: $this->validUnitSet(),
|
||||
deploymentZones: $this->validZones(),
|
||||
objectives: [],
|
||||
victoryCondition: VictoryCondition::EliminateAll,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $abilities
|
||||
*/
|
||||
private function unit(
|
||||
string $id,
|
||||
string $team,
|
||||
Position $position,
|
||||
Archetype $archetype = Archetype::Defender,
|
||||
array $abilities = [],
|
||||
int $attack = 4,
|
||||
int $defense = 3,
|
||||
int $speed = 3,
|
||||
int $maxHealth = 10,
|
||||
): UnitState {
|
||||
return new UnitState(
|
||||
$id,
|
||||
$team,
|
||||
$position,
|
||||
$maxHealth,
|
||||
$maxHealth,
|
||||
$attack,
|
||||
$defense,
|
||||
$speed,
|
||||
2,
|
||||
false,
|
||||
$archetype,
|
||||
$abilities,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
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\ScenarioValidator;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScenarioValidatorTest extends TestCase
|
||||
{
|
||||
public function testItReturnsAnEmptyListForAValidScenario(): void
|
||||
{
|
||||
$scenario = $this->validScenario();
|
||||
|
||||
self::assertSame([], ScenarioValidator::validate($scenario));
|
||||
}
|
||||
|
||||
public function testItRejectsAnObjectiveOnImpassableTerrain(): void
|
||||
{
|
||||
$scenario = $this->validScenario(
|
||||
terrain: ['4:4' => Terrain::Water],
|
||||
objectivePosition: new Position(4, 4),
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertNotEmpty($errors);
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
|
||||
}
|
||||
|
||||
public function testItRejectsAnObjectiveUnreachableFromAnyDeploymentZone(): void
|
||||
{
|
||||
$scenario = $this->validScenario(
|
||||
terrain: [
|
||||
'3:4' => Terrain::Blocking,
|
||||
'4:3' => Terrain::Blocking,
|
||||
'5:4' => Terrain::Blocking,
|
||||
'4:5' => Terrain::Blocking,
|
||||
],
|
||||
objectivePosition: new Position(4, 4),
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'unreachable')));
|
||||
}
|
||||
|
||||
public function testItRejectsOverlappingDeploymentZones(): void
|
||||
{
|
||||
$units = $this->unitSet();
|
||||
$battlefield = new Battlefield(8, 8);
|
||||
$scenario = new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
deploymentZones: [
|
||||
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||
'bravo' => new DeploymentZone('bravo', [new Position(1, 0), new Position(2, 0), new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||
],
|
||||
objectives: [],
|
||||
victoryCondition: VictoryCondition::EliminateAll,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'overlap')));
|
||||
}
|
||||
|
||||
public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void
|
||||
{
|
||||
$units = $this->unitSet();
|
||||
$battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]);
|
||||
$scenario = new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
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,
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Terrain> $terrain
|
||||
*/
|
||||
private function validScenario(
|
||||
array $terrain = [],
|
||||
?Position $objectivePosition = null,
|
||||
): Scenario {
|
||||
$units = $this->unitSet();
|
||||
$battlefield = new Battlefield(8, 8, $terrain);
|
||||
$objectives = [];
|
||||
|
||||
if ($objectivePosition !== null) {
|
||||
$objectives = ['objective-1' => new ObjectiveMarker('objective-1', $objectivePosition)];
|
||||
}
|
||||
|
||||
return new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
deploymentZones: $this->zones(),
|
||||
objectives: $objectives,
|
||||
victoryCondition: $objectivePosition === null
|
||||
? VictoryCondition::EliminateAll
|
||||
: VictoryCondition::HoldObjective,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, DeploymentZone> */
|
||||
private function zones(): array
|
||||
{
|
||||
return [
|
||||
'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),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<UnitState> */
|
||||
private function unitSet(): array
|
||||
{
|
||||
return [
|
||||
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
|
||||
new UnitState('alpha-2', 'alpha', new Position(1, 0), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
|
||||
new UnitState('alpha-3', 'alpha', new Position(2, 0), 7, 7, 2, 2, 3, 2, false, Archetype::Support),
|
||||
new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
|
||||
new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
|
||||
new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 3, 1, 5, 2, false, Archetype::Scout),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UnitStateTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param callable(): UnitState $createUnit
|
||||
*/
|
||||
#[DataProvider('invalidUnitProvider')]
|
||||
public function testItRejectsInvalidConstructorArguments(callable $createUnit): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$createUnit();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{callable(): UnitState}>
|
||||
*/
|
||||
public static function invalidUnitProvider(): iterable
|
||||
{
|
||||
yield 'empty unit id' => [
|
||||
static fn (): UnitState => new UnitState('', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2),
|
||||
];
|
||||
yield 'empty team id' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', '', new Position(0, 0), 10, 10, 4, 2, 4, 2),
|
||||
];
|
||||
yield 'zero maximum health' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 0, 0, 4, 2, 4, 2),
|
||||
];
|
||||
yield 'negative health' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, -1, 4, 2, 4, 2),
|
||||
];
|
||||
yield 'health above maximum' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 11, 4, 2, 4, 2),
|
||||
];
|
||||
yield 'negative attack' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, -1, 2, 4, 2),
|
||||
];
|
||||
yield 'negative defense' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, -1, 4, 2),
|
||||
];
|
||||
yield 'zero speed' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 0, 2),
|
||||
];
|
||||
yield 'negative actions remaining' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, -1),
|
||||
];
|
||||
yield 'too many actions remaining' => [
|
||||
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 3),
|
||||
];
|
||||
}
|
||||
|
||||
public function testItReportsWhetherItIsDefeated(): void
|
||||
{
|
||||
self::assertTrue($this->unit(health: 0)->isDefeated());
|
||||
self::assertFalse($this->unit(health: 1)->isDefeated());
|
||||
}
|
||||
|
||||
public function testMovingReturnsAChangedCopy(): void
|
||||
{
|
||||
$unit = $this->unit();
|
||||
$moved = $unit->moveTo(new Position(2, 3));
|
||||
|
||||
self::assertNotSame($unit, $moved);
|
||||
self::assertSame('0:0', $unit->position->key());
|
||||
self::assertSame('2:3', $moved->position->key());
|
||||
}
|
||||
|
||||
public function testSpendingAnActionReturnsAChangedCopyAndRejectsExhaustedUnits(): void
|
||||
{
|
||||
$unit = $this->unit(actionsRemaining: 1);
|
||||
$spent = $unit->spendAction();
|
||||
|
||||
self::assertSame(1, $unit->actionsRemaining);
|
||||
self::assertSame(0, $spent->actionsRemaining);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$spent->spendAction();
|
||||
}
|
||||
|
||||
public function testDamageCannotReduceHealthBelowZero(): void
|
||||
{
|
||||
$unit = $this->unit(health: 3);
|
||||
$damaged = $unit->takeDamage(5);
|
||||
|
||||
self::assertSame(3, $unit->health);
|
||||
self::assertSame(0, $damaged->health);
|
||||
}
|
||||
|
||||
public function testNegativeDamageIsANoOp(): void
|
||||
{
|
||||
$unit = $this->unit(health: 7);
|
||||
$damaged = $unit->takeDamage(-3);
|
||||
|
||||
self::assertNotSame($unit, $damaged);
|
||||
self::assertSame(7, $damaged->health);
|
||||
}
|
||||
|
||||
public function testMarkingAttackedReturnsAChangedCopy(): void
|
||||
{
|
||||
$unit = $this->unit();
|
||||
$attacked = $unit->markAttacked();
|
||||
|
||||
self::assertFalse($unit->hasAttacked);
|
||||
self::assertTrue($attacked->hasAttacked);
|
||||
}
|
||||
|
||||
public function testStartingTurnResetsALivingUnit(): void
|
||||
{
|
||||
$unit = $this->unit(actionsRemaining: 0, hasAttacked: true);
|
||||
$started = $unit->startTurn();
|
||||
|
||||
self::assertNotSame($unit, $started);
|
||||
self::assertSame(2, $started->actionsRemaining);
|
||||
self::assertFalse($started->hasAttacked);
|
||||
}
|
||||
|
||||
public function testStartingTurnReturnsTheSameDefeatedUnit(): void
|
||||
{
|
||||
$unit = $this->unit(health: 0, actionsRemaining: 0, hasAttacked: true);
|
||||
|
||||
self::assertSame($unit, $unit->startTurn());
|
||||
}
|
||||
|
||||
public function testItCarriesArchetypeAbilitiesAndBonus(): void
|
||||
{
|
||||
$unit = new UnitState(
|
||||
id: 'unit-1',
|
||||
teamId: 'alpha',
|
||||
position: new Position(0, 0),
|
||||
maxHealth: 10,
|
||||
health: 10,
|
||||
attack: 4,
|
||||
defense: 2,
|
||||
speed: 4,
|
||||
actionsRemaining: 2,
|
||||
hasAttacked: false,
|
||||
archetype: \BattleForge\Domain\Archetype::Support,
|
||||
abilities: ['heal', 'buff'],
|
||||
attackBonus: 2,
|
||||
hasUsedAbility: false,
|
||||
);
|
||||
|
||||
self::assertSame(\BattleForge\Domain\Archetype::Support, $unit->archetype);
|
||||
self::assertSame(['heal', 'buff'], $unit->abilities);
|
||||
self::assertSame(2, $unit->attackBonus);
|
||||
self::assertFalse($unit->hasUsedAbility);
|
||||
}
|
||||
|
||||
public function testItRejectsEmptyStringAbilities(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Unit abilities must be non-empty strings.');
|
||||
|
||||
new UnitState(
|
||||
id: 'unit-1',
|
||||
teamId: 'alpha',
|
||||
position: new Position(0, 0),
|
||||
maxHealth: 10,
|
||||
health: 10,
|
||||
attack: 4,
|
||||
defense: 2,
|
||||
speed: 4,
|
||||
actionsRemaining: 2,
|
||||
hasAttacked: false,
|
||||
archetype: \BattleForge\Domain\Archetype::Defender,
|
||||
abilities: [''],
|
||||
);
|
||||
}
|
||||
|
||||
public function testItExposesAllowedAbilitiesFromTheArchetypeCatalog(): void
|
||||
{
|
||||
$unit = $this->unit();
|
||||
|
||||
self::assertSame(
|
||||
\BattleForge\Domain\ArchetypeCatalog::templates()[$unit->archetype->value]->allowedAbilities,
|
||||
$unit->allowedAbilities(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testWithAttackBonusReturnsAChangedCopyAndRejectsNegativeBonus(): void
|
||||
{
|
||||
$unit = $this->unit();
|
||||
$buffed = $unit->withAttackBonus(3);
|
||||
|
||||
self::assertNotSame($unit, $buffed);
|
||||
self::assertSame(0, $unit->attackBonus);
|
||||
self::assertSame(3, $buffed->attackBonus);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$unit->withAttackBonus(-1);
|
||||
}
|
||||
|
||||
public function testSpendAbilityMarksAndConsumesOneActionButNotTwo(): void
|
||||
{
|
||||
$unit = $this->unit();
|
||||
$spent = $unit->spendAbility();
|
||||
|
||||
self::assertSame(1, $spent->actionsRemaining);
|
||||
self::assertTrue($spent->hasUsedAbility);
|
||||
}
|
||||
|
||||
public function testSpendAbilityRejectsAUnitThatAlreadyUsedAnAbility(): void
|
||||
{
|
||||
$unit = $this->unit(hasUsedAbility: true);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$unit->spendAbility();
|
||||
}
|
||||
|
||||
public function testStartTurnClearsAttackBonusAndAbilityUse(): void
|
||||
{
|
||||
$unit = $this->unit(actionsRemaining: 0, hasAttacked: true, attackBonus: 3, hasUsedAbility: true);
|
||||
$started = $unit->startTurn();
|
||||
|
||||
self::assertSame(2, $started->actionsRemaining);
|
||||
self::assertFalse($started->hasAttacked);
|
||||
self::assertSame(0, $started->attackBonus);
|
||||
self::assertFalse($started->hasUsedAbility);
|
||||
}
|
||||
|
||||
private function unit(
|
||||
int $health = 10,
|
||||
int $actionsRemaining = 2,
|
||||
bool $hasAttacked = false,
|
||||
int $attackBonus = 0,
|
||||
bool $hasUsedAbility = false,
|
||||
): UnitState {
|
||||
return new UnitState(
|
||||
'unit-1',
|
||||
'alpha',
|
||||
new Position(0, 0),
|
||||
10,
|
||||
$health,
|
||||
4,
|
||||
2,
|
||||
4,
|
||||
$actionsRemaining,
|
||||
$hasAttacked,
|
||||
archetype: \BattleForge\Domain\Archetype::Defender,
|
||||
abilities: [],
|
||||
attackBonus: $attackBonus,
|
||||
hasUsedAbility: $hasUsedAbility,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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