Add web game (plan2.md) on the independent runtime, merge-blocking gates

Builds the browser game around the existing Rust runtime: a window into the
universe, not a second simulation. Pure std, no external crates.

New crates:
- protocol: versioned, hashable client/server messages + hand-rolled JSON value
  and total parser (malformed packet -> Err, never panic).
- game_runtime: authoritative match state. Resolves turns through the
  INDEPENDENT interpreter (runtime_under_test::native_resolve), not the
  reference engine; filters visibility/knowledge; records and regenerates
  replays. A match is a pure function of (seed, roster, ordered inputs).
- web_assets/web_client: embedded browser client (arena, rune editor,
  knowledge panels, replay viewer) + static HTTP delivery.
- server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing),
  turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock.
- web_tests: dependency-free WebSocket test client + Phase H gates.

Trust hardening per review:
- game_runtime no longer delegates to reference_runtime::execute; it runs the
  independent interpreter that the runtime-equivalence gate proves correct.
- Protocol/socket/replay/visibility/resilience gates are merge-blocking
  (added to the merge_group-required job in merge-gates.yml): 1k matches/0
  drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks.
- Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only
  (continue-on-error, artifacts) until CI infrastructure with a browser exists;
  it is treated as unsatisfied, not green. The headless 100-match gate is
  labeled protocol-level coverage, not rendered-browser coverage.
- README documents the hand-rolled crypto/parser audit risk explicitly.

Fixes an integer-overflow panic in observed-volatility inference (i64 sum /
abs near i64::MIN) that could poison the server mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:20:46 -07:00
co-authored by Claude Opus 4.8
parent 659544f0b2
commit 9d9d5ce41c
33 changed files with 5065 additions and 0 deletions
+90
View File
@@ -134,3 +134,93 @@ cargo run --release -p replay_corpus --bin freeze -- 10000
Everything is seed-derived and integer-only (SplitMix64 RNG, FNV-1a content
hashing, wrapping/guarded arithmetic). No floating point enters a canonical
hash, so replay is bit-exact across machines and runs. No external crates.
## The web game (plan2.md)
A browser game is built **around** the existing runtime — it is a playable
window into the Rust universe, never a second simulation. The browser sends only
*intent*; the server is the sole authority; every rune program executes through
the **independent** interpreter (`runtime_under_test::native_resolve`) against
the shared world. The game deliberately does **not** call the reference engine —
the interpreter it uses is the one the runtime-equivalence gate proves correct
(with a negative control proving that gate can fail). Same constraints as the
rest of the repo: pure `std`, no external crates (the WebSocket server
hand-rolls SHA-1, base64, and RFC 6455 framing; JSON is hand-rolled with a total
parser).
> Audit note: the hand-rolled SHA-1 / base64 / RFC-6455 framing and JSON parser
> are checked against published test vectors (RFC 6455 §1.3 accept key, SHA-1
> "abc", base64 length cases) and a fuzz gate, but they are bespoke
> cryptographic/parsing code and carry audit risk relative to a reviewed
> library. They exist to honor the repo's no-external-crates rule; a future
> hardening pass could swap in vetted implementations behind the same interface.
```
Rust runtime → game_runtime (authority) → protocol (WS messages) → server → browser
```
| Crate | Role |
|-------|------|
| `protocol` | Versioned, hashable, **total-decode** client/server messages + JSON value/parser. A malformed packet yields `Err`, never a panic. |
| `game_runtime` | Authoritative match state. Resolves turns through the **independent interpreter** (`runtime_under_test`, not the reference engine), filters visibility/knowledge, records + regenerates replays. A match is a pure function of `(seed, roster, ordered inputs)`. |
| `web_assets` | The embedded browser client (HTML/CSS/JS): arena, rune editor, domain/knowledge panels, replay viewer. |
| `web_client` | Static-asset HTTP delivery (keeps raw assets separate from framing). |
| `server` | `std::net` HTTP + WebSocket server: turn timer, action collection, disconnect handling, panic-proof dispatch. |
| `web_tests` | A dependency-free WebSocket test client + the Phase H gates. |
### Running it
```bash
cargo run --release -p server --bin magicka-server # serve on 127.0.0.1:8080
# then open http://127.0.0.1:8080 in a browser
MAGICKA_ADDR=0.0.0.0:9000 MAGICKA_TURN_MS=8000 cargo run --release -p server --bin magicka-server
```
Join is immediate (1 player + a training dummy). A duel shares a match by id:
two browsers that `JoinMatch` the same `match_id` take slots 1 and 2.
### Web CI gates (Phase H)
These gates are **merge-blocking**: they run inside the merge-required job in
`.github/workflows/merge-gates.yml` (and as fast PR feedback in
`web-gates.yml`). They are the Rust suite in `crates/web_tests`, run with
`cargo test -p web_tests`:
| Gate | Test | Minimum | Status |
|------|------|---------|--------|
| Replay determinism | `determinism.rs` | 1,000 simulated matches, **0 hash mismatches** | merge-blocking |
| Protocol fuzz | `fuzz.rs` | 10,000 fuzz cases, **0 panics** (+ a live server survives a malformed-packet burst) | merge-blocking |
| End-to-end matches | `e2e.rs` | **100** full matches over real sockets; recorded replay reproduces every live per-turn hash | merge-blocking |
| Hidden-state leaks | `visibility.rs` | **0 leaks** — no client-bound frame carries a hidden key; redaction counts every withheld value | merge-blocking |
| Disconnect / timer edges | `resilience.rs` | mid-match disconnect does not corrupt the match; wrong-turn / late submits are rejected deterministically | merge-blocking |
| Rendered-browser E2E | `e2e/specs/play.spec.js` | a real browser joins, casts, and replays a match | **external-blocked (advisory only)** |
Scope honesty — two distinct things, not conflated:
- The "100 E2E matches" merge-blocking gate drives the full
HTTP→WebSocket→protocol→runtime path **headlessly over real sockets**. This is
protocol-level coverage. It is **not** rendered-browser coverage and is not
claimed as such.
- Rendered-browser coverage is **blocked on CI infrastructure**: this CI has no
real browser, so the Playwright suite under `crates/web_tests/e2e/` cannot be
merge-blocking yet. It runs **advisory-only** (`continue-on-error`) in the
`rendered-browser-e2e` job and uploads its report as an artifact. Until a CI
runner with a browser exists, rendered-browser E2E is treated as
**unsatisfied**, not green. Run it locally with:
```bash
cd crates/web_tests/e2e && npm install && npx playwright install chromium && npm test
```
### Acceptance criteria mapping (plan2.md)
| Criterion | Where it holds |
|-----------|----------------|
| A player can join a browser match | `server` join + `web_assets` client; `e2e.rs::single_match_full_playthrough` |
| A turn timer runs | `server` timer thread; client header countdown |
| Inspect / move / attack / cast | `Action` in `protocol`; `game_runtime::apply_action` |
| Rune programs execute only on the server | `game_runtime` is the only caller of the interpreter (`runtime_under_test::native_resolve`); client never imports `EngineConfig` (asserted in `web_assets`) |
| Results return as filtered observations | `VisibleWorldSnapshot`; `visibility.rs` |
| Replay can reproduce the match | `game_runtime::replay`; `determinism.rs`, `e2e.rs` |
| Browser cannot alter hidden truth | intent-only protocol; `visibility.rs` leak gate |
| CI proves protocol, replay, visibility, authority | merge-blocking gates in `merge-gates.yml` (+ `web-gates.yml`); rendered-browser E2E remains external-blocked |