powder-and-steppe — HANDOFF.md
# Handoff — Powder & Steppe
One static file, `index.html` — no build step, no external requests, no dependencies to install to play it. This document is for a human picking the project up cold.
## Run it (MacBook)
```
cd /root/studio/projects/powder-and-steppe
python3 -m http.server 8000
```
Open `http://localhost:8000` in a desktop browser. Mouse-drag pans the camera, wheel zooms, click selects/orders (mirrors the touch verbs 1:1 — see `DECISIONS.md` → "Hotkey sprawl").
Run the test suite:
```
node --test test/
```
(Puppeteer, used only by `test/browser.test.js`, is resolved via `NODE_PATH=/root/studio/guardrails/webgate/node_modules` — see `gate.sh` for the exact invocation used in CI.)
## Run it (iPhone Safari)
Serve on the LAN instead of localhost so a phone on the same Wi-Fi can reach it:
```
cd /root/studio/projects/powder-and-steppe
python3 -m http.server 8000 --bind 0.0.0.0
```
Find the host machine's LAN IP (`ipconfig getifaddr en0` on macOS Wi-Fi), then on the iPhone open `http://<that-ip>:8000` in Safari.
**Add to Home Screen** (Share → Add to Home Screen) for the real target experience: launching from the home-screen icon drops the Safari chrome entirely, which is when the no-zoom/no-scroll/safe-area behavior below actually matters — testing inside a normal Safari tab still has browser UI stealing screen space the home-screen launch doesn't.
Expected on-device behavior:
- **No page zoom** — `viewport-fit=cover` + `user-scalable=no` locks pinch-zoom to the in-game camera zoom (0.5x–2.0x) only; the page itself never rubber-bands or double-tap-zooms.
- **No scroll** — the map fills the viewport; there is no page-level scroll in any direction (see `docs/architecture.md`'s browser-gate test: "no horizontal overflow" is asserted at 390x844).
- **HUD clear of notch/home-bar** — all HUD chrome (top resource bar, bottom banner tray, floating '+') is padded by `env(safe-area-inset-*)` so nothing sits under the notch or gets swallowed by the home-indicator gesture strip.
## Preview deployment
The game is one static file — any static file host works, no build step, and it plays fully offline after the first load (no runtime fetches). Two ways to stand up a preview:
1. Copy `index.html` to any static web root (nginx, GitHub Pages, S3+CloudFront, etc.) as-is.
2. `npx http-server -p 8000` from the project dir for a throwaway local/LAN preview (same LAN-IP + Add-to-Home-Screen flow as above), or behind this studio's Caddy preview proxy on the dashboard host if a persistent internal preview URL is wanted — document the exact port chosen in the project's own notes when that's set up (Live at https://studio.eden09.com/play/ (basic auth owner/DASHBOARD_PASSWORD), provisioned 2026-07-15).
## Human test checklist
- [ ] Start on iPhone Safari at 390px width (home-screen launch, see above)
- [ ] Pan the map with one-finger drag; pinch to zoom
- [ ] Select a company via its banner chip in the bottom tray
- [ ] Attack-move by tapping open ground with a company selected
- [ ] Toggle March, then tap ground — company moves without engaging
- [ ] X-deselect, then tap the always-visible '+' to open the build sheet with nothing selected
- [ ] With a company selected, tap '+' again and confirm the build sheet is still reachable
- [ ] Tap your own raided mine WITH an army selected — watch the army get ORDERED there (defend), not the mine get selected
- [ ] Shift a farm's slot-mode slider toward wood and watch the wood rate change live
- [ ] Shift a mine's slot-mode slider and watch the iron/coal rates change live
- [ ] Let food run to zero, watch units start losing HP, then recover by rebalancing labor
- [ ] Dry-fire muskets (spend all coal) and confirm bayonet melee fallback kicks in
- [ ] Watch a full 20-hussar Kozar company STEAL your unguarded farm — a capture progress ring, not flames, and the farm keeps its HP
- [ ] Steal a cannon as Kozar (Kozar has no Foundry — this is its only path to cannons)
- [ ] Play a full match to a win vs `ai_standard` and read the final hash/tick on the victory screen
- [ ] Play at 2x speed and confirm combat/economy resolve identically in kind, just faster wall-clock
- [ ] Background the browser tab mid-match and confirm the sim auto-pauses (no ticks lost or double-counted on return)
## Measured performance
- **Headless full match, wall-clock (Node, `test/match.test.js` test 6):** velmark(ai) vs kozar(ai), seed 2 — **1.66s** (budget: < 30s). The reverse side assignment (kozar(ai) vs velmark(ai), same seed) is slower — measured **2.50s** in this run — because that matchup runs more ticks before a winner emerges, still comfortably inside budget.
- **Headless balance batch (Node, `test/balance.test.js`):** 20 seeded AI-vs-AI matches (both side assignments) complete in **~32.5s** total (~1.6s/match average) — see `DECISIONS.md` → "Tuning log" for the win-spread result this run measures.
- **Browser frame time (Puppeteer, `test/browser.test.js` test 5):** mean rAF frame time over 10s of forced AI-vs-AI play, headless Chromium at 390x844 dpr3 — **3.08ms mean** (budget: < 33ms). HUD panels are DOM (rebuilt only on content-relevant key change); canvas draw passes only touch on-screen tiles/entities.
- **Peak entity count:** measured **110 entities** simultaneously alive (both players' units + buildings combined) in a full AI-vs-AI match to a townhall result — comfortably under the pop-cap-driven ceiling (2 x 200 max pop plus buildings) the design targets; frame time stays inside budget at this load per the browser measurement above.
## Known limits
- **AI is fog-omniscient by design.** `GAME_AI` controllers are commands-only (no resource cheats, no extra APM — see `DECISIONS.md` → "AI honesty and design") but read full map state rather than the per-player coarse visibility grid a human plays under. Disclosed, not hidden; scoped out as roughly doubling AI scope for a subtlety most players won't notice in match one (see `DECISIONS.md` → "Deferred with intent").
- **Single authored map.** No random map generation (see `DECISIONS.md` → conventions table and "Deferred with intent").
- **No mid-match saves.** A match is `{dataVersion, seed, players, commands[]}` and replays byte-identically from the start, but there is no snapshot-resume feature.
- **Timeout-value rule.** At the 30-sim-minute (18000-tick) cap, the player with higher total military+economy value wins rather than leaving the match undecided — this is a deliberate tiebreak, not a bug, and `test/balance.test.js`/`test/match.test.js` both exercise matches that legitimately resolve this way.
## Architecture in 10 lines
1. `index.html` is FOUR script blocks in fixed order: `game-data` (pure data — resources/units/buildings/nations/map/aiProfiles/tuning), `game-sim` (the deterministic engine, zero DOM/time/random globals), `game-ai` (data-driven controller interpreter, same purity rules), `game-ui` (the only block allowed to touch DOM/canvas/rAF/audio).
2. `test/load-game.js` is a Node shim: regex-extracts the four blocks from the shipped `index.html` and `vm`-executes them — tests exercise the real shipped file, never a copy.
3. Determinism: fixed 10-ticks/sec timestep, x100 fixed-point resource/damage accounting, round-half-up modifier math, a single sim-owned `mulberry32` PRNG whose seed AND current state are hashed fields of the canonical snapshot.
4. Each AI controller owns its OWN private `mulberry32` stream (`matchSeed ^ imul(playerIdx+1, 0x9E3779B9)`) for decision variance (raid-target picks, trigger jitter) — it never touches the sim's stream, so a logged command stream alone replays a match byte-identically with no AI attached.
5. Everything enters the sim as a stamped `{tick, player, seq, type, ...}` command — human and AI input are the same envelope, applied in one total order every tick.
6. Data lives entirely in `GAME_DATA`: unit/building/nation/resource stats, nation modifier DSL (`{scope, when?, effect}`), AI profiles (phased economy/army targets + combat triggers) — the engine only knows mechanics VOCABULARY (stat names, resource role flags, tag semantics), never an id literal (enforced by `test/purity.test.js`).
7. Companies (≤20 units, auto-formed, one banner) are the command/selection unit; individual soldiers are still simulated and rendered underneath.
8. Capture is seize-over-burn: a capturing player's units suppress damage on a target while their capture timer advances; a guard entering guard-radius resets it — see `DECISIONS.md` → conventions table.
9. To add a nation: one JSON object merged into `GAME_DATA.nations` (+ variant unit/building entries + an AI profile) — no engine code, proven live by `test/growth.test.js` and documented in `GROWTH.md`.
10. `docs/architecture.md` is the NORMATIVE spec for all of the above — read it first when anything here seems to conflict.