commit 660c3cf00d1de9df5861351a1cff7a3e6868faf8 Author: acamilo Date: Mon Sep 21 15:09:46 2026 +0000 flybrain v0.4.0: public tree (history retained privately) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..901747d --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,201 @@ +# flybrain CI (Forgejo Actions). Identical in substance to .github/workflows/ci.yml. +# +# Forgejo runner notes (the only differences from the GitHub copy: this block, and one `uses:`): +# * `runs-on: ubuntu-latest` needs a runner registered with that label (the usual +# `ubuntu-latest:docker://...node:22-bookworm` mapping). A bare node image has no rustup and +# no shellcheck, which is why the Rust job installs its own toolchain and the lint job +# tolerates a failed install. +# * `actions/checkout`, `actions/setup-node` and `actions/cache` resolve through +# code.forgejo.org, so no github.com access is needed for `uses:`. +# * `cache: npm` and `actions/cache` need the runner's cache server enabled; without it these +# jobs still pass, just slower. +# +# Four jobs, in the order `make ci` / `npm run ci` runs them locally: +# node npm ci + connectome checksum verify + npm test + npm run typecheck +# rust cargo test --workspace --release (services/flysim) +# infra-lint infra/tests/lint.sh +# stage-e2e Playwright against apps/stage — allowed to fail, see the job comment +# +# What this workflow deliberately does NOT need: +# +# * No ROM. Game Boy cartridges never enter the repo (`.gitignore` excludes `*.gb`), and every +# test that needs one is gated on `FLY_ROM`. `FLY_ROM` is left unset here, so those tests +# return early instead of failing: `crates/flybrain-gb/tests/rom.rs` goes through +# `skip_without_rom!` (prints "skipped: FLY_ROM is not set"), and +# `crates/flysim/tests/integration.rs` uses `let Some(rom) = rom_path() else { eprintln!(...); +# return; }`. Both are ordinary passes with a skip line on stderr. +# * No GPU. The `cuda` feature of `flybrain-core`/`flysim` is off by default and `cudarc` is +# built with `dynamic-loading`, so a default build never links or opens libcuda. +# * No secrets. Nothing here reads a token, a stream key or `pass`. `infra/06-secrets.sh` is +# linted, never executed. +# * No network fetch of data. The FlyWire artifacts in `data/fafb-v783` are committed (11 MB +# total, largest `targets.binz` at 7.1 MB) and CC BY-NC 4.0 — see +# `data/fafb-v783/ATTRIBUTION.md`. Only the multi-GB *raw Codex exports* that +# `tools/build_flywire.py` reads are gitignored (`.tools/`), and nothing in CI rebuilds them. +# The `node` job verifies the committed copies against `tools/artifact-checksums.txt`. +name: ci + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + node: + name: node 22 (test + typecheck) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + # Keys off package-lock.json; this is the npm cache. + cache: npm + + - name: Install workspace + run: npm ci + + # The connectome is in git, so this is a verification and not a download. If it ever + # moves out of git, replace this step with `uv run python3 tools/build_flywire.py` + # (several GB of Codex exports) and gate `packages/brain/tests/dataset.test.ts` on the + # artifacts being present instead. + - name: Verify committed FlyWire artifacts + run: sha256sum -c ../../tools/artifact-checksums.txt + working-directory: data/fafb-v783 + + - name: npm test + run: npm test + + - name: npm run typecheck + run: npm run typecheck + + rust: + name: rust stable (cargo test --workspace --release) + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/flysim + steps: + - uses: actions/checkout@v4 + + # No third-party toolchain action, so this file works unchanged on a Forgejo runner that + # cannot reach github.com for `uses:`. `--profile minimal` skips docs/clippy we do not run. + - name: Install stable Rust + working-directory: . + run: | + if command -v rustup >/dev/null 2>&1; then + rustup toolchain install stable --profile minimal + rustup default stable + else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + rustc --version + cargo --version + + - name: Cache cargo registry and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + services/flysim/target + key: cargo-${{ runner.os }}-release-${{ hashFiles('services/flysim/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}-release- + + # `--release`, not the default debug profile, and that is load-bearing: + # `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed` + # asserts the feed's 30 Hz contract (it accepts 25.0..31.0 Hz), and a debug build of the sim + # loop only reaches ~9.5-10.4 Hz, so it fails on any box in debug. That is a known, + # pre-existing debug-profile artefact recorded throughout `infra/docs/macros-traps.md`, not + # a regression. Two independent reasons it cannot bite here: the release profile, and the + # fact that the test is also `FLY_ROM`-gated and therefore skips on this runner. + # If you ever need a debug run, exclude exactly that one test instead: + # cargo test --workspace -- --skip the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed + # `[profile.release] debug = 1` in services/flysim/Cargo.toml keeps line tables, and the + # release profile is not allowed to reassociate floats, so bit-exactness with the + # TypeScript oracle still holds — the golden tests are meaningful in release. + - name: cargo test --workspace --release + run: cargo test --workspace --release + + infra-lint: + name: infra/tests/lint.sh + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # lint.sh falls back to `bash -n` without shellcheck and skips the unit-file pass without + # systemd-analyze, so this is best-effort: install shellcheck if the image lacks it, but + # never fail the job over the installer. `sudo` is absent on some container runners. + - name: Install shellcheck (best effort) + run: | + if command -v shellcheck >/dev/null 2>&1; then + shellcheck --version + exit 0 + fi + SUDO='' + if [ "$(id -u)" -ne 0 ]; then SUDO='sudo'; fi + $SUDO apt-get update && $SUDO apt-get install -y shellcheck || \ + echo 'shellcheck unavailable; lint.sh will fall back to bash -n' + + - name: infra/tests/lint.sh + run: infra/tests/lint.sh + + stage-e2e: + # ALLOWED TO FAIL, on purpose. `apps/stage/playwright.config.ts` runs the real `vite build` + # output at 1920x1080 DPR 1 and compares screenshots at maxDiffPixelRatio 0.002. Two things + # make that flaky on a hosted runner and neither is a product bug: + # * fonts — a runner's fontconfig is not the broadcast host's, so glyph rasterisation + # differs by more than 0.2% of the frame even with `--disable-lcd-text`; + # * timing — the suite drives a 30 Hz feed with a 90 s per-test timeout and a single + # worker, and the shared-runner CPU budget is not the capture host's pinned cpuset. + # So this job reports and uploads its HTML report, but `continue-on-error` keeps a red + # screenshot diff from blocking a merge. the operator reviews stage frames as PNGs + # (`apps/stage/mockups/`), which is the real gate for how the page looks. + name: playwright apps/stage (allowed to fail) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install workspace + run: npm ci + + # Chromium only: the config declares exactly one `chromium` project. + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: apps/stage + + - name: playwright test + run: npm run test:e2e --workspace @flybrain/stage + env: + CI: '1' + + # forgejo/upload-artifact, not actions/upload-artifact: a Forgejo runner does not speak + # the v4 GitHub artifact API. This is the only non-comment line that differs. + - uses: forgejo/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + apps/stage/playwright-report + apps/stage/test-results + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ce62205 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,189 @@ +# flybrain CI. +# +# Four jobs, in the order `make ci` / `npm run ci` runs them locally: +# node npm ci + connectome checksum verify + npm test + npm run typecheck +# rust cargo test --workspace --release (services/flysim) +# infra-lint infra/tests/lint.sh +# stage-e2e Playwright against apps/stage — allowed to fail, see the job comment +# +# What this workflow deliberately does NOT need: +# +# * No ROM. Game Boy cartridges never enter the repo (`.gitignore` excludes `*.gb`), and every +# test that needs one is gated on `FLY_ROM`. `FLY_ROM` is left unset here, so those tests +# return early instead of failing: `crates/flybrain-gb/tests/rom.rs` goes through +# `skip_without_rom!` (prints "skipped: FLY_ROM is not set"), and +# `crates/flysim/tests/integration.rs` uses `let Some(rom) = rom_path() else { eprintln!(...); +# return; }`. Both are ordinary passes with a skip line on stderr. +# * No GPU. The `cuda` feature of `flybrain-core`/`flysim` is off by default and `cudarc` is +# built with `dynamic-loading`, so a default build never links or opens libcuda. +# * No secrets. Nothing here reads a token, a stream key or `pass`. `infra/06-secrets.sh` is +# linted, never executed. +# * No network fetch of data. The FlyWire artifacts in `data/fafb-v783` are committed (11 MB +# total, largest `targets.binz` at 7.1 MB) and CC BY-NC 4.0 — see +# `data/fafb-v783/ATTRIBUTION.md`. Only the multi-GB *raw Codex exports* that +# `tools/build_flywire.py` reads are gitignored (`.tools/`), and nothing in CI rebuilds them. +# The `node` job verifies the committed copies against `tools/artifact-checksums.txt`. +name: ci + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + node: + name: node 22 (test + typecheck) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + # Keys off package-lock.json; this is the npm cache. + cache: npm + + - name: Install workspace + run: npm ci + + # The connectome is in git, so this is a verification and not a download. If it ever + # moves out of git, replace this step with `uv run python3 tools/build_flywire.py` + # (several GB of Codex exports) and gate `packages/brain/tests/dataset.test.ts` on the + # artifacts being present instead. + - name: Verify committed FlyWire artifacts + run: sha256sum -c ../../tools/artifact-checksums.txt + working-directory: data/fafb-v783 + + - name: npm test + run: npm test + + - name: npm run typecheck + run: npm run typecheck + + rust: + name: rust stable (cargo test --workspace --release) + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/flysim + steps: + - uses: actions/checkout@v4 + + # No third-party toolchain action, so this file works unchanged on a Forgejo runner that + # cannot reach github.com for `uses:`. `--profile minimal` skips docs/clippy we do not run. + - name: Install stable Rust + working-directory: . + run: | + if command -v rustup >/dev/null 2>&1; then + rustup toolchain install stable --profile minimal + rustup default stable + else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + rustc --version + cargo --version + + - name: Cache cargo registry and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + services/flysim/target + key: cargo-${{ runner.os }}-release-${{ hashFiles('services/flysim/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}-release- + + # `--release`, not the default debug profile, and that is load-bearing: + # `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed` + # asserts the feed's 30 Hz contract (it accepts 25.0..31.0 Hz), and a debug build of the sim + # loop only reaches ~9.5-10.4 Hz, so it fails on any box in debug. That is a known, + # pre-existing debug-profile artefact recorded throughout `infra/docs/macros-traps.md`, not + # a regression. Two independent reasons it cannot bite here: the release profile, and the + # fact that the test is also `FLY_ROM`-gated and therefore skips on this runner. + # If you ever need a debug run, exclude exactly that one test instead: + # cargo test --workspace -- --skip the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed + # `[profile.release] debug = 1` in services/flysim/Cargo.toml keeps line tables, and the + # release profile is not allowed to reassociate floats, so bit-exactness with the + # TypeScript oracle still holds — the golden tests are meaningful in release. + - name: cargo test --workspace --release + run: cargo test --workspace --release + + infra-lint: + name: infra/tests/lint.sh + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # lint.sh falls back to `bash -n` without shellcheck and skips the unit-file pass without + # systemd-analyze, so this is best-effort: install shellcheck if the image lacks it, but + # never fail the job over the installer. `sudo` is absent on some container runners. + - name: Install shellcheck (best effort) + run: | + if command -v shellcheck >/dev/null 2>&1; then + shellcheck --version + exit 0 + fi + SUDO='' + if [ "$(id -u)" -ne 0 ]; then SUDO='sudo'; fi + $SUDO apt-get update && $SUDO apt-get install -y shellcheck || \ + echo 'shellcheck unavailable; lint.sh will fall back to bash -n' + + - name: infra/tests/lint.sh + run: infra/tests/lint.sh + + stage-e2e: + # ALLOWED TO FAIL, on purpose. `apps/stage/playwright.config.ts` runs the real `vite build` + # output at 1920x1080 DPR 1 and compares screenshots at maxDiffPixelRatio 0.002. Two things + # make that flaky on a hosted runner and neither is a product bug: + # * fonts — a runner's fontconfig is not the broadcast host's, so glyph rasterisation + # differs by more than 0.2% of the frame even with `--disable-lcd-text`; + # * timing — the suite drives a 30 Hz feed with a 90 s per-test timeout and a single + # worker, and the shared-runner CPU budget is not the capture host's pinned cpuset. + # So this job reports and uploads its HTML report, but `continue-on-error` keeps a red + # screenshot diff from blocking a merge. the operator reviews stage frames as PNGs + # (`apps/stage/mockups/`), which is the real gate for how the page looks. + name: playwright apps/stage (allowed to fail) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install workspace + run: npm ci + + # Chromium only: the config declares exactly one `chromium` project. + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: apps/stage + + - name: playwright test + run: npm run test:e2e --workspace @flybrain/stage + env: + CI: '1' + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + apps/stage/playwright-report + apps/stage/test-results + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81983ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +node_modules/ +dist/ +coverage/ +test-results/ +*.tsbuildinfo +*.log +.env +.env.* +!.env.example +# Real infra env files never enter this repo: they name the host, the container +# id, the LAN address, the channel and the `pass` entry. infra/env/example.env is +# the only env file here; see infra/env/README.md. +infra/env/*.env +!infra/env/example.env +.DS_Store +__pycache__/ +*.pyc +.venv/ +# Raw FlyWire Codex downloads (fetched by tools/build_flywire.py) +.tools/ +# Rust build output +target/ +# Game cartridges and saves never enter this repo +*.gb +*.gbc +*.rom +*.sav +*.state +local/ +# Rust build output +target/ + +# Playwright +/apps/stage/playwright-report/ +/apps/stage/test-results/ +# Legibility downscale artifacts are regenerated by the e2e suite on every run +/apps/stage/tests/e2e/artifacts/ +# Agent worktrees (never committed) +.claude/worktrees/ +.local/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e30bb93 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# flybrain + +A simulated fruit-fly brain (FlyWire connectome, 139,255 neurons) that plays Game Boy games on a +24/7 stream. Monorepo: `packages/brain` (TypeScript reference core), `packages/feed` (contracts, +codec, fake sim), `services/flysim` (Rust service: brain + emulator + adapters), `services/bridge` +(Twitch), `apps/stage` (broadcast page), `infra/` (LXC provisioning and units), `docs/`. + +Read `docs/architecture-tour.md` first. Then `docs/stream-mvp-plan.md` for decisions and status. + +## Binding contracts + +- `docs/feed-protocol.md` and `docs/control-api.md`. Where a design doc differs, the contracts win. +- `packages/brain` is the oracle. Never change its semantics to match another implementation; fix + the other side. Default-config version strings `lif-1ms-f64-v2` and `fly-kc-mbon-rstdp-v2` stay. + +## Hard rules + +- Never go live on Twitch without the operator's explicit approval for that run. `flypush.service` stays + disabled; local MediaMTX demos are fine. Stream keys and tokens live in `pass`, never in git. +- No AI attribution lines in commit messages. +- ROMs are never committed, copied into the repo, shown on stream, or linked. +- Fable (the coordinator) plans, writes contracts and reviews; opus and sonnet agents build and + test, each on its own feature branch in a worktree, merged with `--no-ff`. Run `npm test`, + `npm run typecheck`, `cargo test --workspace` and `infra/tests/lint.sh` before merging. +- The operator reviews screens as PNGs (`apps/stage/mockups/`), never as prose. On-screen copy is terse. +- Work on the deployment host is serialised: **one agent at a time**. Claim the container before + touching it and release it when you are done, by appending a dated line to the host's agent + claim log — the file the operator's `AGENT_CLAIM_LOG` names (`infra/env/example.env`, + `infra/README.md`). No claim, no host work. Never touch a guest this repo did not provision; + other services share the host. +- This repo is public. Nothing that identifies the operator's network goes in it: no hostnames, + LAN addresses, container ids, host paths, account ids, channel names, people's names, `pass` + entry names or forge URLs. Say "the host", "the release container", "the dev container", "the + channel", "the operator"; put the real values in the operator's infra repo. Real values belong + in an env file outside the checkout — see `infra/env/README.md`. `infra/tests/lint.sh` refuses + the patterns; the rules live in the operator's infra repo and + `infra/tests/de-pii-allow.txt` the few legitimate mentions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..01ac4e5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,175 @@ +# Contributing + +A simulated fly brain that plays Game Boy games on a 24/7 stream. Start with +`docs/architecture-tour.md`: it walks every layer once, says why each is built the way it is, and +points at the code. Then `docs/stream-mvp-plan.md` for what has been decided and what state it is in. + +There is no game in this repository. `ROM-POLICY.md` explains what that means and how to run +everything without one — which is almost all of it. + +## Branches and worktrees + +Work happens on a branch, in its own git worktree, never on `main` directly. + +```sh +git worktree add -b feat/my-change .claude/worktrees/feat-my-change main +cd .claude/worktrees/feat-my-change +``` + +`.claude/worktrees/` is gitignored, so the checkouts never enter history. One branch per piece of +work, named for what it does (`feat/`, `fix/`, `docs/`, `chore/`, `publish/`). Several can be in +flight at once because each has its own tree; that is the point of using worktrees rather than +switching branches in place. + +Merge into `main` with `--no-ff`, so a branch stays visible as a unit in the history: + +```sh +git merge --no-ff feat/my-change +git worktree remove .claude/worktrees/feat-my-change +``` + +Never rewrite published history on `main`. + +## The suites, before every merge + +All four, green, on the branch. Not a subset. + +```sh +npm ci +npm test # TypeScript: brain, feed, stage units, bridge +npm run typecheck # tsc across every workspace +cd services/flysim && cargo test --workspace # Rust: core, gb, service; ROM-gated tests skip +bash infra/tests/lint.sh # shellcheck + systemd-analyze over infra/ +``` + +Plus the stage's browser suite when anything visual changed: + +```sh +cd apps/stage && npm run test:e2e # Playwright: screenshot baselines per fixture and tab, + # text-size lint, phone-downscale legibility, structure +npm run mockups # regenerate the review PNGs +``` + +Notes that save time: + +- The ROM-gated Rust tests skip with a printed line when `FLY_ROM` is unset. That is a normal green + run, not a hole. See `ROM-POLICY.md`. +- The dataset tests read `data/fafb-v783`, which is committed; they need no download. +- The Rust golden tests compare against `services/flysim/golden/*.flygold` at 0 ulp. If one fails + after a change to the Rust side, the Rust side is wrong — see the oracle rule below. Goldens are + regenerated from the TypeScript with `packages/brain/tools/golden.ts`, never edited by hand. +- Screens are reviewed as PNGs under `apps/stage/mockups/`, not as prose descriptions. On-screen + copy is terse. + +## Commit messages + +Plain and factual. A short imperative subject with a scope prefix, a body when the change needs one, +and nothing else. + +``` +feat(decoder): add blocked-direction cooldown + +Raises the habituation of a channel the sim loop has watched produce no +movement for a whole hold. Does not name a position or an alternative. +``` + +**No attribution trailers of any kind.** No `Co-Authored-By`, no generated-with lines, no tool or +model credits, no session links. This applies to every commit, including ones written by an agent. + +No secrets in a commit, ever — no stream keys, tokens, passwords or credentials. Nothing from a +cartridge. Check the diff before committing, not after. + +## Licences and file headers + +The repository is not under a single licence. `LICENSES.md` at the root says which paths fall under +which terms; the short version is Apache-2.0 for the code, CC BY 4.0 for the documentation, +on-screen copy and stage assets, and CC BY-NC 4.0 — unchanged and not ours to relicense — for the +FlyWire connectome artifacts in `data/fafb-v783/`. + +- **No per-file licence headers are required.** Do not add them to new files, and do not add them to + existing ones. `LICENSE`, `LICENSES.md` and `NOTICE` at the root carry the terms for the whole + tree; a header on every file is noise that then has to be kept accurate. +- **`NOTICE` carries the attributions.** If you add third-party material — a vendored source, a + font, a dataset, anything you did not write — put its attribution in `NOTICE` and its row in + `LICENSES.md` in the same branch as the material, with its own licence text alongside it in the + tree. Material whose upstream licence you cannot establish does not go in; if it does go in, + `NOTICE` says so in plain words rather than guessing. +- **Contributions are accepted under Apache-2.0.** By submitting a change you license it under the + Apache License 2.0, per section 5 of that licence. There is no separate CLA and none is planned. +- **Anything derived from `data/fafb-v783/` is CC BY-NC 4.0, like the data.** + `services/flysim/golden/real.flygold` is the one such artifact in the tree today. If a change adds + another — a recorded run, a prerendered image, a checked-in trace — say so in the commit and add + it to section 3 of `LICENSES.md`. The four `.flyfeed` fixtures are generated by the fake simulator + and are deliberately not in that category; keep it that way. + +## The two binding contracts + +- `docs/feed-protocol.md` — the WebSocket snapshot format the service publishes and the page reads. +- `docs/control-api.md` — the loopback HTTP API the bridge calls. + +These are binding. Where any other document, design note or comment disagrees with them, **the +contracts win** and the other document is the thing that is wrong. Changing a contract is its own +change, made deliberately, with both sides of it updated in the same branch and the reason written +down. Both have a Rust implementation and a TypeScript implementation and a shared fixture that +each must pass; a change that only satisfies one side is not done. + +One property of the control API is load-bearing: **there is no button endpoint.** Nothing outside +the simulation can press a button, and a test asserts the route table contains no such route. Do not +add one. + +## The oracle rule + +**`packages/brain` is the reference implementation.** Its semantics define what the neural core does: +the LIF kernel, plasticity, the decoder, the agent loop, the checkpoint envelope. + +When the Rust port, the service, the page or anything else disagrees with it, **fix the other side.** +Never adjust `packages/brain` to make another implementation's output match — that converts a bug +into the specification. The verbatim prototype modules under `packages/brain/tests/legacy/` and the +bit-exact oracle tests against them exist to make this rule enforceable, and so do the Rust goldens. + +The default-config version strings `lif-1ms-f64-v2` and `fly-kc-mbon-rstdp-v2` stay as they are. +Checkpoints are keyed on them; changing one invalidates saved state on the release box. + +If the reference itself is genuinely wrong, that is a deliberate change to the reference with a new +version string, regenerated goldens, and the reasoning recorded — not a quiet edit. + +## The honesty rule + +Nothing on the stream is scripted. This is not a style preference; it is the point of the project, +and it constrains contributions. + +- **The buttons are always the fly's.** Nothing outside the simulation chooses, biases, defaults or + times a button press. There is no fallback press, no scripted objective, no nudge on a timeout. + A scene the fly ignores waits. +- **Rewards are read out of memory after the fact.** A reward rule may observe what the game state + became and stimulate the modulatory pathway. It may not tell the fly where to go or what to press. + Every value is positive by design; there are no penalties. +- **The readout is fixed, not learned**, and it knows nothing about maps, doors or goals. +- **Failure is shown.** Stalls, rollbacks, unsupported cartridges and lag appear on screen with the + reason. They are not hidden and not smoothed over. +- **What is real and what is scaffolding is documented.** `docs/limitations.md` is the honest list — + the reward modulator is synthetic, the retina is not fly optics, learning has not been shown to + improve play. `docs/rewards-learning.md` has its own "Honesty" section stating exactly what a + reward can and cannot move. If your change alters where that line falls, update those pages in the + same branch and say so plainly. If a claim has not been measured, write that it has not been + measured. + +A change that makes the demo look better by making it less true will be rejected, however small. + +## Where the documents are + +- `docs/architecture-tour.md` — read first; the whole system, layer by layer, with the reasons. +- `docs/feed-protocol.md`, `docs/control-api.md` — the binding contracts. +- `docs/stream-mvp-plan.md` — decisions and current status. +- `docs/model.md`, `docs/plasticity.md`, `docs/readout.md`, `docs/rewards-learning.md`, + `docs/dataset-format.md`, `docs/verification.md` — the neural core, what it learns, how it decides, + what it is paid for, the data layout, and how all of it is checked. +- `docs/limitations.md` — what this is not. +- `docs/design/` — per-feature design notes (`flysim.md`, `stage-bridge.md`, `fly-avatar.md`, + `animation.md`, `ladder.md`, `macros.md`, `platformer.md`, `room-escape.md`, `gpu.md`, `infra.md`, + and others). Designs, not contracts: where one differs from a contract, the contract wins. +- `infra/docs/` — the runbook, provisioning records, measurements and spike write-ups for the + machines that actually run it. +- `docs/publish/` — notes prepared for publication, including how the licence decision was reached. +- `LICENSE`, `LICENSES.md`, `NOTICE` — the terms, the path-by-path split, and the attributions. +- `ROM-POLICY.md` — no game here, and how to run everything anyway. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..57bc88a --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/LICENSES.md b/LICENSES.md new file mode 100644 index 0000000..ababf58 --- /dev/null +++ b/LICENSES.md @@ -0,0 +1,105 @@ +# Licences + +This repository is not under a single licence. Three sets of terms apply to three kinds of thing, +and some third-party material inside it is under neither because it was never ours to license. + +Read this file before reusing anything. `LICENSE` at the root is the Apache-2.0 text; `NOTICE` +carries the attributions those terms require. + +Decided 2026-09-17 by the copyright holder (see NOTICE), who holds the copyright in the original work. +## 1. Code — Apache License 2.0 + +Copyright 2026 Alex Camilo. Full text in `LICENSE`, with the required attributions in `NOTICE`. + +| Path | What | +| --- | --- | +| `packages/brain/`, `packages/feed/` | the reference neural core, contracts, codec, fake sim | +| `apps/stage/src/`, `apps/stage/tools/`, `apps/stage/tests/` | the broadcast page | +| `services/flysim/crates/` | the Rust service, emulator wrapper, reward adapters | +| `services/bridge/src/`, `services/bridge/tests/` | the Twitch bridge | +| `infra/` | provisioning scripts, systemd units, host tooling, test suites | +| `tools/` | the dataset builder and repository tooling | +| build and config files at any level | `package.json`, `Cargo.toml`, `tsconfig.json`, and friends | + +You may use, modify and redistribute this code, commercially included, under the Apache-2.0 terms: +keep the licence and copyright notices, pass on the `NOTICE` content, and state the files you +changed. The licence includes an express patent grant from contributors and grants no trademark +rights. + +Excluded from this grant, because they are third-party material — see section 4: +`services/flysim/vendor/binjgb/`, `apps/stage/public/fonts/*.woff2`, and the extracted symbol +names and addresses in `services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs`. + +## 2. Documentation, on-screen copy and stage assets — CC BY 4.0 + +Copyright 2026 Alex Camilo, licensed under +[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/). + +| Path | What | +| --- | --- | +| `docs/` | the architecture tour, the binding contracts, the model and learning pages, the design notes, the limitations list | +| `infra/docs/` | runbooks, provisioning records, measurements, spike write-ups | +| `README.md`, `CONTRIBUTING.md`, `ROM-POLICY.md`, this file | root documents | +| `apps/stage/mockups/` | the review PNGs | +| `apps/stage/public/fixtures/` | the four recorded `.flyfeed` feed fixtures | +| on-screen copy | the caption bands, chip labels, panel headings and ticker strings the broadcast page displays | + +You may share and adapt these, commercially included, with attribution and an indication of any +changes. + +Two things worth stating so nobody has to guess: + +- **On-screen copy is text, but it lives inside source files.** The strings the page displays are + CC BY 4.0 as text; the `.tsx` and `.rs` files that contain them are Apache-2.0 as code. The same + copyright holder grants both, so a reuser may rely on whichever fits what they are taking. +- **The feed fixtures are synthetic.** All four `.flyfeed` recordings in + `apps/stage/public/fixtures/` are generated by the fake simulator in `packages/feed/src/fake/`, + not recorded from a run on the connectome. They carry no FlyWire-derived content and so are not + bound by section 3. + +## 3. The connectome artifacts — CC BY-NC 4.0, unchanged + +`data/fafb-v783/` — all twelve files. + +These are **not ours to relicense.** They are artifacts derived from the FlyWire FAFB public Codex +v783 exports, used under +[CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/), and they keep those terms for +everyone downstream. Nothing in sections 1 or 2 loosens them. + +**The authoritative statement of source, modifications and required citations is +[`data/fafb-v783/ATTRIBUTION.md`](data/fafb-v783/ATTRIBUTION.md).** It must travel with any copy of +these files. `NOTICE` reproduces it in summary; that file is the original. + +What the licence asks of you if you redistribute them or an adaptation of them: + +- keep the creator and attribution-party names, the copyright notice, the licence notice and the + disclaimer notice; +- link to the material and to the licence; +- say that the material is modified, and keep the indication of previous modifications — these + artifacts are already an adaptation, described in `ATTRIBUTION.md`; +- do not use them for commercial advantage or monetary compensation; +- do not add legal terms or technological measures that stop anyone else doing what the licence + permits; +- do not imply endorsement by FlyWire or the cited authors. + +The data and anything derived from it stay under CC BY-NC 4.0. + +One derived artifact inside the repository inherits these terms: `services/flysim/golden/real.flygold` +is a recorded run on this dataset. The other golden files are synthetic and are Apache-2.0 with the +code. +## 4. Third-party material, under its own terms + +| What | Where | Terms | +| --- | --- | --- | +| binjgb emulator core, vendored unmodified at `c60e138` | `services/flysim/vendor/binjgb/` | MIT, © 2016 Ben Smith — `LICENSE`, `PROVENANCE.md` there | +| Press Start 2P, Pixelify Sans, Silkscreen, VT323 | `apps/stage/public/fonts/` | SIL OFL 1.1, each with its `OFL-*.txt` alongside | +| pret/pokered RAM symbol names and addresses at `0cd19d3` | `.../pokemon_red/symbols.rs` | **no upstream licence statement.** Names and numeric addresses only; no assembly source, game code or game asset | +| npm and cargo dependencies | not redistributed here | their own; `lightningcss` is MPL-2.0 and `caniuse-lite` is CC-BY-4.0, both build-time | + +## 5. No game ROM + +No Game Boy ROM, ROM fragment, save file, emulator save state or ripped game asset is in this +repository, and none is distributed with it. See [`ROM-POLICY.md`](ROM-POLICY.md). "Pokémon" and +"Super Mario Land" are trademarks of Nintendo, used here descriptively to say which adapter reads +which game; no affiliation or endorsement is claimed, and no licence granted here extends to any +third party's trademarks. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..84193be --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +# The same gates CI runs, in the same order, on this box. +# +# make ci install -> artifacts -> test -> typecheck -> rust -> lint +# make e2e the Playwright suite (allowed to fail in CI; run it by hand here) +# make all ci + e2e +# +# `npm run ci` is the same chain minus `npm ci` itself (an npm script cannot safely wipe the +# node_modules it is running out of), so `make install && npm run ci` == `make ci`. +# +# Nothing here needs a ROM, a GPU or a secret. FLY_ROM stays unset, so the ROM-gated Rust tests +# skip with a note on stderr; set it yourself for a full local run: +# FLY_ROM="$$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" make rust + +SHELL := /usr/bin/env bash +.SHELLFLAGS := -euo pipefail -c +.DEFAULT_GOAL := ci + +.PHONY: ci all install artifacts test typecheck rust lint e2e browsers clean + +ci: install artifacts test typecheck rust lint + +all: ci e2e + +install: + npm ci + +# The FlyWire connectome is committed (11 MB, CC BY-NC 4.0 — data/fafb-v783/ATTRIBUTION.md), so +# this verifies rather than downloads. Only the raw multi-GB Codex exports are fetched, and only +# by `uv run python3 tools/build_flywire.py`, which CI never runs. +artifacts: + cd data/fafb-v783 && sha256sum -c ../../tools/artifact-checksums.txt + +test: + npm test + +typecheck: + npm run typecheck + +# --release, not debug: the flysim integration test that asserts the feed's 30 Hz contract only +# reaches ~9.5-10.4 Hz in a debug build (known, pre-existing — infra/docs/macros-traps.md). For a +# debug run, skip exactly that test: +# cd services/flysim && cargo test --workspace -- \ +# --skip the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed +rust: + cd services/flysim && cargo test --workspace --release + +lint: + infra/tests/lint.sh + +browsers: + cd apps/stage && npx playwright install --with-deps chromium + +# Screenshot comparisons at maxDiffPixelRatio 0.002 against the real `vite build` output. Font +# rasterisation and timing make this flaky on a shared runner, which is why CI marks the job +# allowed-to-fail; locally it is a real gate. +e2e: + npm run test:e2e --workspace @flybrain/stage + +clean: + rm -rf apps/stage/playwright-report apps/stage/test-results services/flysim/target diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..d583b43 --- /dev/null +++ b/NOTICE @@ -0,0 +1,93 @@ +flybrain +Copyright 2026 Alex Camilo + +This product includes software developed as part of the flybrain project, +licensed under the Apache License, Version 2.0 (see LICENSE). + +Portions of this product are licensed separately. LICENSES.md records which +paths fall under which terms; the attributions those terms require are below. + +-------------------------------------------------------------------------------- +FlyWire FAFB Codex v783 connectome artifacts (data/fafb-v783/) +-------------------------------------------------------------------------------- + +This product includes artifacts derived from the FlyWire FAFB public Codex v783 +exports, used under the Creative Commons Attribution-NonCommercial 4.0 +International licence (CC BY-NC 4.0). + + Source: https://codex.flywire.ai/ + Licence: https://creativecommons.org/licenses/by-nc/4.0/ + +The artifacts are modified: connectivity is aggregated by directed neuron pair, +assigned stable numeric indices, encoded as typed sparse arrays, and joined with +classification, representative-coordinate, cell-type and optic-lobe column +annotations. Functional role predicates and neural-model signs are project +modelling choices. The full statement of source, modifications and required +citations is data/fafb-v783/ATTRIBUTION.md, which accompanies these artifacts +and must travel with any copy of them. + + Dorkenwald et al., "Neuronal wiring diagram of an adult brain," + Nature 634 (2024), https://doi.org/10.1038/s41586-024-07558-y + + Schlegel et al., "Whole-brain annotation and multi-connectome cell typing," + Nature 634 (2024), https://doi.org/10.1038/s41586-024-07686-5 + + Matsliah et al., "Neuronal parts list and wiring diagram for a visual system," + Nature 634 (2024), https://doi.org/10.1038/s41586-024-07981-1 + +No endorsement by FlyWire or the cited authors is implied. These artifacts, and +anything derived from them, are under CC BY-NC 4.0; see LICENSES.md. + +-------------------------------------------------------------------------------- +binjgb (services/flysim/vendor/binjgb/) +-------------------------------------------------------------------------------- + +This product includes the binjgb Game Boy emulator core, used under the MIT +licence and vendored unmodified at revision +c60e138da5a795ebb55e56b11b7e90024e41112c. + + Copyright (c) 2016 Ben Smith + https://github.com/binji/binjgb + +The MIT licence text is services/flysim/vendor/binjgb/LICENSE and the vendoring +is described in services/flysim/vendor/binjgb/PROVENANCE.md. + +-------------------------------------------------------------------------------- +pret/pokered symbol names and addresses +-------------------------------------------------------------------------------- + +services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs reproduces RAM +symbol names and numeric addresses extracted from the pret/pokered +disassembly at revision 0cd19d3b877b7dc66d12c7050bed9a7f38154d4b. + + https://github.com/pret/pokered + +That upstream project carries no licence statement of its own, which is stated +here rather than assumed. Only names and addresses are reproduced: no assembly +source, no game code and no game asset from the disassembly is present in this +product, and no game ROM is distributed with it (see ROM-POLICY.md). + +-------------------------------------------------------------------------------- +Fonts (apps/stage/public/fonts/) +-------------------------------------------------------------------------------- + +This product includes four typefaces used under the SIL Open Font License 1.1, +each accompanied by its own licence text in the same directory: + + Press Start 2P OFL-PressStart2P.txt + Pixelify Sans OFL-PixelifySans.txt + Silkscreen OFL-Silkscreen.txt + VT323 OFL-VT323.txt + +The font files are unmodified subsets. They are not relicensed by this product. + +-------------------------------------------------------------------------------- +Build and runtime dependencies +-------------------------------------------------------------------------------- + +Dependencies resolved by npm and cargo are not redistributed in this source +repository and keep their own licences. Two npm dependencies carry terms worth +naming because they are not notice-only permissive: + + lightningcss (and its linux-x64 binaries) MPL-2.0, build-time, unmodified + caniuse-lite CC-BY-4.0, build-time data diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bef93f --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# flybrain + +A simulated fruit-fly brain that plays video games. A connectome-constrained spiking network reads +the screen, its population rates become controller inputs, and a scalar reward nudges a bounded set +of Kenyon-cell to MBON gains. + +The library is `@flybrain/brain` in `packages/brain`. It holds the connectome dataset format, the +LIF kernel, the plasticity rule, the population-rate readout and the activity-map geometry. It +holds no game, no emulator and no reward rules: those belong to whatever embeds it. + +## Workspace layout + +| Path | Contents | +| --- | --- | +| `packages/brain` | the library (`@flybrain/brain`) | +| `data/fafb-v783` | FlyWire-derived browser artifacts (CC BY-NC 4.0) | +| `tools/` | the Python builder that regenerates `data/` from official Codex exports | +| `docs/` | overview, dataset format, model, plasticity, readout, integration, limitations, verification | +| `services/flysim` | the Rust service: brain, emulator, snapshot feed, control API, checkpoints | +| `apps/` | planned: one directory per game demo | +| `infra/` | planned: deployment for the 24/7 stream (see `docs/streaming-plan.md`) | + +## Quick start + +```sh +npm ci +npm test +npm run typecheck +``` + +76 tests, about 7 seconds. There is no build step. `npx tsx packages/brain/examples/node-random-frames.ts 60` +runs the full 139,255-neuron brain with the Game Boy readout on noise frames in plain Node +(about 0.9x Game Boy real time single-threaded on a WSL laptop). + +## Usage + +```ts +import { NeuralAgent, gameboyDecoderConfig, toButtonMask } from '@flybrain/brain'; +import { loadBrainDatasetFromDir } from '@flybrain/brain/node'; + +const dataset = await loadBrainDatasetFromDir('data/fafb-v783'); +const agent = new NeuralAgent(dataset, { decoder: gameboyDecoderConfig() }); +agent.warmup(firstFrame); // 2,500 ms with plasticity off, then calibrate + +// every emulator frame: +const { active } = agent.tick(framebuffer, { // RGBA 160x144 by default; any size via config + rewards: [{ value: 0.5 }], // scalar rewards your game adapter detected + boot: !inGame, // relaxes Start/Select throttling on title screens +}); +emulator.setButtons(toButtonMask(active)); + +const checkpoint = agent.exportState(); // bit-exact resume, validated on import +``` + +The lower layers (`LifNetwork`, `RewardModulatedStdp`, `PopulationDecoder`) are exported too for +hosts that want to run the loop themselves. + +[integration.md](docs/integration.md) has the full per-frame loop, the fractional frame timing and +the checkpoint contract. + +## Documentation + +- [Overview](docs/overview.md): the pipeline, the layer map and the design principles. +- [Dataset format](docs/dataset-format.md): artifacts, CSR layout, weight encoding, every role and + its count, fingerprinting, regeneration, license. +- [Model](docs/model.md): the 1-ms LIF kernel step by step, every default constant, the retina + projection, the RNG, state export and version strings. +- [Plasticity](docs/plasticity.md): edge selection, the eligibility and reinforcement equations, + statistics, topology hash and the explicit non-claims. +- [Readout](docs/readout.md): scores, exclusive groups, pulse channels, the blocked-direction + cooldown, the Game Boy preset table and checkpoint versions. +- [Integration](docs/integration.md): what a game must provide, the Pokemon Red integration as a + worked example, and a sketch of a platformer adapter. +- [Limitations](docs/limitations.md): what is not claimed, what is unproven, measured throughput. +- [Verification](docs/verification.md): the oracle-test strategy and what each test file covers. +- [Streaming plan](docs/streaming-plan.md): headless capture, Twitch, VM design and the phased + plan for a 24/7 stream. +- [Artifact builder](tools/README.md): how to regenerate and verify `data/fafb-v783`. +- [Data attribution](data/fafb-v783/ATTRIBUTION.md): source, license and citations. + +## Provenance + +The network, plasticity rule, readout, FlyWire pipeline and activity viewer were extracted from +the `fly-plays-pokemon` prototype so several game demos can share one core. The default +configuration reproduces that prototype's kernel bit for bit, and verbatim copies of its modules +live in `packages/brain/tests/legacy/` as oracles. Built with Astra. + +## Licensing + +The `data/fafb-v783` artifacts are derived from the FlyWire FAFB public Codex v783 exports and are +licensed [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/). That is a +non-commercial license, so a commercial demo needs a different data source or separate permission. +Citations and the list of modifications are in +[`data/fafb-v783/ATTRIBUTION.md`](data/fafb-v783/ATTRIBUTION.md). + +No license has been chosen for the code in this repository yet. + +ROMs and save states never enter this repository. diff --git a/ROM-POLICY.md b/ROM-POLICY.md new file mode 100644 index 0000000..145bf1d --- /dev/null +++ b/ROM-POLICY.md @@ -0,0 +1,115 @@ +# ROM policy + +**There is no game in this repository, and there never has been.** + +This project simulates a fruit-fly brain and lets it press buttons on a Game Boy emulator. To watch +it play a commercial game you need that game's cartridge image, and you have to supply it yourself. +Nothing here will help you find one. + +## What that means in practice + +- **No ROM is committed.** `.gitignore` excludes `*.gb`, `*.gbc`, `*.rom`, `*.sav` and `*.state`, so + a cartridge image or a save cannot be added by accident. +- **No ROM is copied into the working tree**, not even temporarily, and not into a fixture, a test + resource or a checkpoint. The emulator reads the file from a path outside the repo and nothing + writes it back in. +- **No ROM is linked.** You will not find a download link, a torrent, a "known-good dump" name, a + mirror or a hint about where to look, in the code, the docs, the commit history or the issues. + Requests for one will be closed. +- **No ROM appears on stream.** The game's video is on screen because the fly is playing it; the + file is not offered, served or made downloadable from the broadcast, and the stream page exposes + no path to it. +- **No game assets are vendored.** No sprites, tiles, palettes, music, text or disassembly source. + +What the repository does contain, none of which is game content: + +- **One SHA-256 digest** of the supported cartridge. A hash is a fingerprint, not the file: it lets + the service refuse to run against something other than the build the reward rules were written + for. Semantic rewards are enabled for exactly that one digest; any other cartridge boots and + plays, pays nothing, and says `UNSUPPORTED ROM . SEMANTIC REWARDS OFF` on screen. +- **Audited RAM addresses and symbol names**, generated from the public pret/pokered disassembly at + a pinned commit, in `services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs`. These are + numbers and names that describe where the running game keeps its progress flags. The disassembly + checkout they came from lives outside this repository. +- **The emulator core**: [binjgb](https://github.com/binji/binjgb), MIT-licensed, vendored as seven + unmodified upstream C files under `services/flysim/vendor/binjgb/` with its licence and a + provenance note. + +## How the release box gets a ROM + +Not from git. The deployment takes two values from an environment file that lives outside this +repository, on the machine doing the deploy: + +- a **path** to the cartridge, which the service opens read-only, and +- a **SHA-256 pin** for that file. + +The service hashes the file it opened and compares it against the pin. A mismatch is a startup +failure (`FLY_ROM_SHA256 does not match the cartridge on disk`), not a warning: it will not quietly +play the wrong build. The file is staged into the container as `/srv/fly/rom/.gb`, mode +`0400`, owned by the service user, and it stays there — never on the hypervisor, never in a backup +of this repository, never in a log or a journal line. + +If the pin is empty, the deploy writes no ROM path at all and the service runs without one. + +## Tests that need a cartridge + +They are gated on an environment variable and **skip cleanly when it is unset** — they do not fail, +and they do not need to be excluded from a test run. The variables are `FLY_ROM` for the Pokémon Red +adapter and `FLY_ROM_PLATFORMER` for the Super Mario Land one. With neither set, each such test +prints `skipped: FLY_ROM is not set` and returns; the ROM-driven example binaries print instructions +instead of running. + +```sh +cd services/flysim && cargo test --workspace # ROM-gated tests skip, everything else runs +FLY_ROM=/path/to/your/cartridge.gb cargo test --workspace # ROM-gated tests run too +``` + +So `cargo test --workspace` is a complete, honest green run without a cartridge anywhere in sight. +That is the default, and it is what CI-equivalent checks are expected to do. + +## Running everything else with no ROM at all + +Almost the whole system is exercisable without a game. The emulator is the only part that needs one. + +**The fake sim.** A stand-in for the real service that speaks the same feed protocol and control API +over the same ports, so the page, the bridge and any client can be developed and tested against it. +No brain, no emulator, no cartridge. + +```sh +npx tsx packages/feed/src/fake/server.ts --scenario running # serves :7400 feed, :7401 control +``` + +**Recorded fixtures.** Four `.flyfeed` recordings ship in `apps/stage/public/fixtures/`. The stage +page replays them frame by frame, which is how its layout, motion, tabs and screenshot baselines are +developed and reviewed. + +```sh +cd apps/stage && npm run dev +# then open ?mode=player&fixture=steady (also cold-open, big-moment, macros) +# ?tab=senses|connectome|ladder ?fly=webgl|paper|off +``` + +**The TypeScript oracle tests.** `packages/brain` is the reference implementation of the neural +core, and its suite includes bit-exact comparisons against verbatim copies of the original prototype +modules, plus a run on the real connectome dataset — which *is* in the repository, under +`data/fafb-v783`. No cartridge involved. + +```sh +npm ci && npm test && npm run typecheck +``` + +**The Rust golden tests.** The Rust port is checked against golden files generated from the +TypeScript oracle (`services/flysim/golden/*.flygold`, produced by `packages/brain/tools/golden.ts`). +They cover the LIF kernel, the maths, plasticity, the decoder, the agent loop, checkpoint restore, +the version strings and the platformer preset, and they assert 0-ulp agreement. They are part of +`cargo test --workspace` and none of them needs a ROM. + +**The infra suite and the stage end-to-end suite.** + +```sh +bash infra/tests/lint.sh # shell scripts and systemd units +cd apps/stage && npm run test:e2e && npm run mockups # Playwright baselines and the review PNGs +``` + +If you want to see the fly actually play a commercial game, supply your own legally obtained +cartridge image and point `FLY_ROM` at it. That is the whole extent of the help available here. diff --git a/apps/stage/README.md b/apps/stage/README.md new file mode 100644 index 0000000..b4308f4 --- /dev/null +++ b/apps/stage/README.md @@ -0,0 +1,650 @@ +# flystage + +The 1920x1080 broadcast page for the 24/7 stream: a simulated fruit-fly connectome (139,255 +neurons, FlyWire FAFB v783) plays a Game Boy game, and this page is what the encoder captures. + +It is a **display only**. The simulation runs as a service (`flysim`) and this page renders what +arrives over a local WebSocket. There is no ROM here, no emulator, no input path, and no code +path that accepts text from anywhere but the feed. + +Binding contracts: [`docs/feed-protocol.md`](../../docs/feed-protocol.md) and +[`docs/control-api.md`](../../docs/control-api.md). Design: `docs/design/stage-bridge.md` +sections 0 and A. Deviations from that design are listed at the bottom of this file. + +## Running it + +```sh +npm ci # from the repo root + +# Player mode: replay a recorded fixture, no service needed. This is the default. +npm run dev -w @flybrain/stage +# http://127.0.0.1:5273/?mode=player&fixture=steady + +# Live mode: the real feed. Needs flysim, or the fake one: +npx tsx packages/feed/src/fake/server.ts --scenario running +npm run dev -w @flybrain/stage +# http://127.0.0.1:5273/?mode=live +``` + +### Query parameters + +Everything the page can be told is a query parameter, because the only two things that launch it +are a `chromium --kiosk` line in a systemd unit and a Playwright test. + +| Parameter | Values | Meaning | +|---|---|---| +| `mode` | `player` (default), `live` | Replay a fixture, or open the feed socket. | +| `fixture` | `cold-open`, `steady`, `big-moment`, `macros` | Which recording to replay. `macros` is the only one with `[macros] mode = macros`; the other three predate the macro channels and replay the raw layout. | +| `t` | seconds | Seek there. **Without `play=1` the page holds and freezes its clock**, which is what makes a screenshot reproducible. | +| `play` | `1` | Keep playing after a seek. | +| `loop` | `0` | Stop at the end instead of looping. | +| `res` | `1080` (default), `720` | `720` is `transform: scale(0.6667)` on the one 1920x1080 stage: thumbnails and the downscale tests. | +| `theme` | `t1` (default), `t2`, `t3` | T1 Instrument, T2 Phosphor, T3 Field lab. | +| `fly` | `webgl` (default), `paper`, `off` | Which renderer draws the fly strip. `paper` is the 2D fallback for a host with no usable WebGL, and `webgl` **falls back to it by itself** when the GL context cannot be created (the capture container's `--disable-gpu` Chromium, measured on the P0 spike); `off` draws no fly and creates no GL context. `window.__stage.fly()` reports the renderer actually drawing as `mode` and the query parameter as `requested`. | +| `tab` | `senses`, `connectome`, `ladder` | Pin the tab slot and stop the cycle. Every visual test and every mockup uses it. | +| `chat` | `off` (or `0`) | The chat kill switch: no panel at all, whatever the feed carries. | +| `game` | `pokemon-red` (default), `platformer` | Per-game config. | +| `feed` | ws URL | Feed override for `mode=live`. Default `ws://127.0.0.1:7400/feed`. | +| `gain`, `gamegain`, `sfxgain` | 0..1 | Master / game / SFX gain. Defaults 0.9 / 0.8 / 0.5. | +| `audio` | `0` | Do not create an AudioContext at all. | + +`window.__stage` exposes the operator surface: `metrics()` (per-stage paint timings), `audio()` +(context state, ring fill, underruns, drops), `health()` (accepted snapshots, feed gaps, decode +errors), `manifest()`, `seek(seconds)`, `stopFeed()`, `gameScale()`, `fly()` (renderer mode, gait +phase, leg tips, proboscis extension), `motion()` (which tab and why, the moment on stage and its +phase, the queue depth, live particles), `pam()` (the PAM centroid the flare spreads from), and +`fire(type, label, detail)` — the one deliberate way to drive the moment catalogue by hand, which +is what the moment mockups and the moment assertions use instead of waiting for a fixture to +contain one of each. + +### Recording fixtures + +```sh +# Starts a fake flysim itself, records 120 s, writes public/fixtures/steady.flyfeed.gz +npm run record -w @flybrain/stage -- --name steady --scenario running --seconds 120 + +# From a service that is already running, with two sugar redemptions in the middle +npm run record -w @flybrain/stage -- --name live --url ws://127.0.0.1:7400/feed \ + --control-url http://127.0.0.1:7401 --seconds 60 --stimulate-at 8,34 +``` + +The `.flyfeed` container lives in `packages/feed/src/fixture.ts` (shared, because the recorder is +Node and the player is the browser). `--spikes-stride 1 --audio-seconds 0` records at full +fidelity; the committed fixtures do not, and the reasons and numbers are in the recorder's header +comment and in each file's own manifest. + +### Audio in player mode + +Two things look like bugs in player mode and are not: + +- the committed fixtures carry audio only for their first 12 s, so past that the ring buffer + underruns continuously and `__stage.audio().underruns` climbs by about 375 a second (one per + 128-sample block). Record with `--audio-seconds 999` if you need a continuous stream. +- the ring fill sits near 60 ms rather than the 250 ms target, and the servo holds its rate at + 0.997 trying to grow it. A fixture delivers audio at exactly 1x real time, so there is no + surplus to build the cushion out of; a real `flysim` running slightly ahead of the sound card + builds it in a few seconds. The servo is pulling in the right direction either way. + +### Mockups (the sign-off gate) + +```sh +npm run mockups -w @flybrain/stage +``` + +Builds, serves, and writes sixteen PNGs to `mockups/`, at 1920x1080 and DPR 1: +`steady-t1-{senses,connectome,ladder}` and `describe` for the four tabs, `big-moment-t1` 2.5 s into that +fixture's milestone, `moment-{milestone,badge,sugar,rollback}` shot 300 ms after the trigger (the +middle of every arrival in the catalogue), `macros-{overworld,running,outcome,battle,indoors}` for +the macro strip's five states, and the two `fly-*` review crops at 2x. `--only ` shoots +just the ones whose name contains it, which is how one panel gets re-reviewed without rewriting +every committed PNG. + +The theme sweep these used to be is gone: T1 was chosen in September, so what the images are for +now is the layout and the motion. They still serve the kickoff playbook's purpose — a human looks +at a picture before anyone argues about prose. + +```sh +npm run fonts -w @flybrain/stage +``` + +Writes the eleventh PNG, `mockups/gameboy-fonts.png`: the three OFL pixel body candidates +`docs/design/gameboy-theme.md` names, each rendered in the parts of the rail where a body face has +to work — the 30 px rung line, the 38-cell spine, the cluster's readouts and footer, two ticker +lines and two chat lines — at their real token sizes and inside the dialogue-box frame. This was +the document's gate, and **the operator picked Silkscreen from it on 2026-09-15**; the frame marks the +pick, and the other two faces stay committed so the comparison can be re-rendered. + +Neither the mockup tool nor the e2e suite waits on a timer for the page any more. `data-ready="1"` +means the fonts and the brain map's base bitmap are in — not that the fixture is — and on a cold +browser context `steady` (23.3 MB) reports ready at 1.7 s and its first accepted snapshot at 4.2 s. +Both now wait for `__stage.health().accepted` to move before they shoot, because a mockup of the +page's own initial zeroes is worse than a slow one. + +### Tests + +```sh +npm test -w @flybrain/stage # node --test: 213 unit tests +npm run typecheck -w @flybrain/stage +npm run test:e2e -w @flybrain/stage # Playwright, against the real build via vite preview + +npx playwright install chromium # once +npm run test:e2e -w @flybrain/stage -- --update-snapshots # after an intended visual change +``` + +The e2e suite builds the app and serves it with `vite preview`, because the page that goes on air +is the build output, not the dev server. The downscale artifacts the legibility test produces land +in `tests/e2e/artifacts/` and are attached to the HTML report. + +### Building a release + +`__STAGE_VERSION__` (the title strip's version chip, `data-testid="stage-version"`) is +`vite.config.ts`'s `git describe --tags --always --dirty`, read from the working tree at build +time — a tagged, clean checkout gives `v0.1.0`; an untagged one falls back to the short sha on its +own; a dirty tree appends `-dirty`; the dev server always reads `dev` instead, since HMR does not +represent a build. **The stage build has to run from the tagged checkout for the string to be +right** — `npm run build -w @flybrain/stage` (or the `npm run build` a release pipeline calls) +reads `git describe` from wherever it is invoked, not from a version this repo tracks separately, +so a build off a branch that has moved past the tag, or off a dirty tree, bakes that into the page. +`infra/build/package-release.sh` and `infra/05-deploy.sh` do not pass the stage build anything +extra for this — they already require and record the tag independently (`infra/05-deploy.sh`'s +`require_release_tag`) — so getting the version chip right is entirely about building from the +right commit before packaging. + +## Geometry + +Layout B at **1920x1080**, 48 px safe insets, all geometry in `src/lib/geometry.ts`. There is one +authoring resolution and it is the broadcast resolution, so `#stage` carries no transform at all +on air; `?res=720` is `transform: scale(0.6667)` on that one element, for thumbnails and the +downscale tests. + +The arithmetic, which closes exactly in both columns: + +``` +left column 800 wide: title 40 + game 720 + gap 4 + fly strip 220 = 984 = 1080 - 2x48 +fly strip 220 tall: 4 border + button row 32 + gap 4 + row 176 = 220 + its row: fly canvas 416 + gap 12 + macro palette 364 = 792 = 800 - 2x4 +right rail 1012 wide: cluster 144 + slot 420 + events 100 + chat 244 + + 3 gutters of 12 = 944 = 720 + 4 + 220 +width 800 + 12 + 1012 = 1824 = 1920 - 2x48 +tab slot 420 tall: 48 tab strip + 370 pane + 2 border = 420 +``` + +The title strip spans the full usable width and the game abuts it with no gutter, which is what +makes the left column close on 984. The rail starts level with the top of the game and ends level +with the bottom of the fly strip, at y=1032. + +Text floors, in authoring pixels: body/ticker >= 24, labels 30-36 — 10% down from 27/33-39 +(2026-09-16, once the VT323 split below gave the rail room to spare) — and +`tests/e2e/text-size.spec.ts` enforces both those and the 16 / 20-24 they land on in the 720p +thumbnail mode. Layout v2 has no 72 px hero: see the deviations. + +## Rail layout v2 + +Locked in `docs/stream-mvp-plan.md` ("Rail layout v2, locked 2026-09-15 night") and built here. +Four rail panels instead of layout v1's five, on a 12 px gutter; the left column is unchanged: + +| Panel | Box | What it shows | Source | +|---|---|---|---| +| Title strip | 1824x40 | Wordmark, mode chip (hidden when unknown), the DAY N slide | `panels/TitleStrip.tsx` | +| Game | 800x720 | The framebuffer at exactly 5x, and the rollback rewind wipe | `panels/GamePanel.tsx` | +| Fly strip | 800x220 | A plain 32 px button row along the top edge, the 3D fly filling the rest | `panels/FlyStrip.tsx` | +| Progress cluster | 1012x144 | The whole progress readout, in one panel | `panels/ProgressCluster.tsx` | +| Tab slot | 1012x420 | A 48 px tab strip and one of four panes | `panels/TabSlot.tsx` | +| EVENTS | 1012x100 | Three ticker rows, dwell-gated, tiered. No title | `panels/EventsTicker.tsx` | +| CHAT | 1012x244 | The last seven chat lines, or nothing at all | `panels/ChatPanel.tsx` | +| Moment layer | — | Caption band, rail flash, particles, day slide | `panels/MomentLayer.tsx` | +| Stale feed banner | 1824x40 | Over the title strip after 2 s of silence | `panels/StaleBanner.tsx` | + +### The progress cluster + +One panel where layout v1 had four (ladder, stuck-o-meter, run clock, sugar), because that was 14 +panel borders and four titles for eight numbers (the middot and the ring are drawn, not typed): + +``` + here for brain +0/37 Boot screen -> Left the bedroom 1m34s 11.4 Hz +############################################################### +0/8 badges * 214 places try 1 06:12:33 * day 3 () SUGAR READY +``` + +The rung line is one line of 30 px mono — between the 27 px body floor and the 33 px label band, +deliberately neither. The spine draws one cell per rung of the ladder, from `milestone.total` when +the service sends it and from the game config's ladder otherwise (`src/lib/ladder.ts`), which is 38 +for this demo. The SUGAR chip carries the cooldown ring. + +### The tabs + +Four tabs — SENSES, CONNECTOME, LADDER, DESCRIBE — in one 420 px slot, with an amber underline that +slides and a 300 ms crossfade. `src/lib/tabs.ts` decides which one is up, and `?tab=` pins it: + +- **Focus.** A moment gives its own tab the slot for the moment's duration and then hands it back. + This is what replaced layout v1's promotion of the brain map over the whole rail. +- **Event steering.** A rung change goes to LADDER, a reward to CONNECTOME, gated by a 12 s dwell + so a reward every twenty seconds cannot make the slot flicker. +- **The cycle.** Otherwise the slot advances on its own every 45 to 60 s, and "walking with high + command activity" biases that rotation toward SENSES rather than owning it. DESCRIBE only ever + arrives this way: nothing steers to it and no moment focuses it, because it says what the stream + is rather than what just happened. + +| Pane | What it shows | +|---|---| +| SENSES | The retina raster at 548x316, both eyes, beside six labelled circuit groups with their peak-hold and decision-threshold ticks, plus a MACROS row of whichever macro channels the scene has bound | +| CONNECTOME | The 2D brain map at the pane's own 1008x370, with the reward flare spreading from the PAM cluster's measured centroid | +| LADDER | All 38 rungs in three column-major columns, the rollback budget (this rung and lifetime) and the stall meter | +| DESCRIBE | What this is, one card at a time: a Silkscreen label, a paragraph of VT323 at the body floor, and a cell per card showing where the cycle is | + +**All four panes stay mounted.** Only one is `data-visible="1"` — which is what the structural +test counts — and the others are `visibility: hidden`, out of paint entirely. They stay mounted +because the connectome's base bitmap is a worker's 139,255-point raster: unmounting the canvas +would throw it away and re-raster it on every tab cycle, twenty times an hour, for ever. + +DESCRIBE's copy is `src/games/describe.ts` and nowhere else — one entry per card, in the reading +order, pending the operator's review (`docs/design/describe-tab.md`). The cards' numbers are placeholders +the page fills from the dataset metadata, the game config and the build's version +(`src/lib/describe.ts`), so a count on that tab cannot outlive the connectome it describes, and +`tests/unit/describe.test.ts` holds the file against the doc card for card. One card is up per +appearance of the tab, which is the rail's own 45-to-60 s cadence; `src/motion/director.ts` +advances it. + +### Chat + +The last seven lines of `header.chat`. Strictly text: no links, no images, no markup, no embeds. + +The service is the authority — `packages/feed/src/chat.ts` is the sanitizer, and +`services/flysim/crates/flysim/src/chat.rs` enforces byte-identical rules in Rust — and +`src/chat/sanitize.ts` runs **that same shared implementation** again at the point of render, +dropping any line that fails. Not a local copy of the rules: there is no second set to drift. + +A line that fails is not truncated or masked, it is not shown. With no `chat` in the header (an +older service, or `[chat] enabled = false`, which omits the key) and with `?chat=off`, the panel +renders nothing at all — no border, no title, no empty box. "The panel is there but empty" is what +a viewer reads as "the stream is broken", so it is the case the structural test pins. + +Bot lines green, names amber, text ink: a viewer has to be able to tell the bridge's own template +replies from a person at a glance, because the bridge is the only thing on this stream that can be +made to say something by accident. + +### Moments + +`docs/design/animation.md`'s catalogue, wired. The engine (`src/motion/`, landed separately) owns +the queue, the particle pool and the catalogue as data; `src/motion/director.ts` is the half that +touches the rail, and it runs once per animation frame: + +| Trigger | What happens | +|---|---| +| Milestone | Caption band in from the left over the tab slot, the new rung pulses twice then fills, amber sparks from that rung, LADDER focus for 9 s, chime | +| Badge | All of that plus the rail border flashing 120/600 ms, the badge count bouncing 1.15x, a fountain over the rail, a shockwave from the count, the connectome flaring, fanfare | +| Sugar | Proboscis and head glow (already rate-driven), the ring filling and draining over the cooldown, warm sparks drifting from the fly's head to the sugar chip, tone | +| Small reward | The ticker row slides up 240 ms, its value flashes amber 120/600 ms, sparks scaled by the reward's value tier, tick | +| Rollback | A 400 ms horizontal rewind wipe over the game canvas with a scanline flicker and backward streaks, the try count, "REWIND . try 3", rewind sweep | +| Mode change | The chip's text rolls vertically over 200 ms | +| Day rollover | "DAY N" slides across the title strip once, 2 s, soft stinger | +| HERE FOR 1 h / 3 h / 6 h | The number pulses once and its colour steps warmer | + +Two things about the numbers. The holds are 9 s of *total* stage time (320 ms in, 8360 holding, +320 out), because that is what the design's own verification measures and what "LADDER focus 9 s" +means on screen. And every readout is lerped by the paint loop rather than committed by React +(`src/motion/readouts.ts`): the components render the numeric elements *empty* and the loop owns +their text, with a 120-300 ms time constant. The three discrete ones — the rung index, the try +count, the rollback budgets — are deliberately not lerped, because "RUNG 4.6/37" is not smooth, +it is wrong. + +The particle layer is one 2D canvas over the frame, capped at 400, additive, pooled, seeded so a +screenshot is reproducible. It spans the whole stage because the sugar sparks have to cross from +the fly's head to the sugar chip, and it is clipped to the rail plus the fly strip so a badge's +shockwave can never cross the game. + +**Paint cost**, measured on this laptop over 10 s of the `steady` fixture with a badge fired 4 s +in — the most expensive frame the page ever draws: a fountain, a shockwave, the rail flash, the +caption band, the connectome flare and the counter bounce, all at once. About 600 frames per tab, +and `tests/e2e/behaviour.spec.ts` prints the same figures on every run: + +| Tab | whole frame p50 | p95 | `motion` p95 | brain map p95 | +|---|---|---|---|---| +| SENSES | 1.8 ms | 3.5 ms | 1.7 ms | 1.0 ms | +| CONNECTOME | 1.8 ms | 3.3 ms | 1.8 ms | 0.8 ms | +| LADDER | 1.7 ms | 2.9 ms | 1.4 ms | 0.8 ms | +| idle, no moment | 1.8 ms | 3.2 ms | — | 0.8 ms | + +The design's budget is a whole-frame p95 under 4 ms, and the `motion` stage — the moment queue, +the particle simulation and its draw, the lerped readouts and the tab slot — is under 2 ms of it. +The `max` column is left out on purpose: it is 20 to 46 ms on every one of these runs, always on +the first frame, and always the WebGL fly's context creation and shader compile. It never recurs, +and `?fly=paper` or `?fly=off` removes it. + +### The fly + +A small 3D fly sits under the game, facing the screen, with a plain row of eight button +indicators along the top of its strip. Every motion of the *fly itself* is a real population +rate, and nothing about it presses a button: tripod gait speed from `forward`/`backward`, body +yaw and stride asymmetry from `steer_left`/`steer_right`, wing beat amplitude and frequency (and +haltere jitter) from the sum of `command_0..7` — a stand-in for a `motor` role rate until one is +in the feed, see below — the proboscis from `proboscis` and sugar events, the head and thorax +glow from `reward_pam`, and the abdomen's breathing from the population rate. Nothing is scripted +or random except a small idle floor on the wing beat. The binding brief is +[`docs/design/fly-avatar.md`](../../docs/design/fly-avatar.md). + +| File | What it is | +|---|---| +| `src/fly/rig.ts` | The animal: proportions, the tripod gait, leg IK, the wing beat, drives to joints. Emits world-space points and knows nothing about drawing. | +| `src/fly/drives.ts` | Feed rates to 0..1 drives, through the same running reference the circuit bars use. Holds `WING_DRIVE_ROLES`, the one table that maps the wing/flight drive to its source roles. | +| `src/fly/camera.ts` | The one camera both renderers share. | +| `src/fly/webgl.ts` | three.js, one GL context, well under 1,600 triangles, flat-shaded Lambert. | +| `src/fly/paper.ts` | The same rig projected by hand into a 2D canvas, painter's algorithm. | + +TODO: `docs/design/fly-avatar.md`'s neuron table calls for a `motor` role (110 neurons) driving +the wings; `docs/feed-protocol.md`'s `rates` does not carry `motor` yet, so `WING_DRIVE_ROLES` in +`src/fly/drives.ts` sums the eight `command_0..7` descending-command rates instead. Swapping in +`motor` once the feed grows it is a one-line change in that file. + +Every drive is normalized against its role's own running reference (`src/lib/circuit-scale.ts`) +and then re-centred on the resting level that scale implies (1 / headroom, about 0.67), so the fly +reads as still when the fly is at its own normal and moves when a rate rises above it. A raw +fraction would leave the proboscis half out and the head half lit for ever. + +**Paint cost**, measured over 12 s of the `steady` fixture playing on this laptop, one sample per +*drawn* frame at the 30 fps cap (`window.__stage.metrics().stages.fly`): + +| Renderer | n | mean | p50 | p95 | max | whole-frame p95 | +|---|---|---|---|---|---|---| +| `webgl` | 371 | 0.35 ms | 0.30 ms | 0.40 ms | 27.20 ms | 1.30 ms | +| `paper` | 371 | 0.09 ms | 0.10 ms | 0.20 ms | 1.50 ms | 1.10 ms | +| `off` | — | — | — | — | — | 1.00 ms | + +The WebGL `max` is the first frame — context creation and shader compilation — and never recurs; +every later frame is inside the design's 4 ms budget with two orders of magnitude to spare. The +paper fly is cheaper still and looks plainer, which is the trade the design accepts for a host +with no usable WebGL. + +### Copy + +Terse and instrument-like, per the direction of 2026-09-15: short nouns for panel titles, no +parenthetical justifications, no sentences under widgets, no captions. + +``` +A FLY BRAIN PLAYS POKEMON RED +SENSES CONNECTOME LADDER RETINA CIRCUITS RUNG HERE FOR BRAIN CHAT SUGAR READY +``` + +The neuron count and a learning/frozen chip used to sit beside the wordmark; both are gone +(2026-09-15 review) — the count duplicated the rotating card's own credit line, and "learning" was +one more piece of operator status. The mode chip stays, in plain words (`walking`, `battle`, +`menu`, `boot`, `demo`, …), and disappears entirely rather than show a placeholder when the +adapter reports `UNKNOWN`. + +**No explainer card anywhere**, per the locked layout. Layout v1 rotated one card through the +narrative lane for the last minute of every four minutes; v2 gives that lane to the ticker +outright, and the persistent chat panel is what fills the space an explanation used to. The eight +explainer cards and the two-column real-vs-scaffolding panel went in the copy pass before it, and +so did the per-widget captions they duplicated: "one dot per L1 column", "legs: real, wired to +nothing", "stimulates dopamine, never a button", "what the fly sees", "what just happened". + +`ROTATING_CARDS` and `src/lib/schedule.ts` are still in the tree, unrendered, and that is a +deliberate loose end rather than dead code left by accident: the four lines include the FlyWire +credit with its CC BY-NC licence, which has to end up *somewhere* (the channel's about page, a +periodic bridge message, or a panel nobody has designed yet). Deleting the strings would lose the +only reviewed copy of them; rendering them would break the locked layout. They stay until that is +decided, in one place, with this paragraph attached. + +`tests/unit/labels.test.ts` enforces the register mechanically: every on-screen label is 24 +characters or fewer, and none of them contains a full stop, an exclamation mark or a parenthesis. + +The middot in the wordmark is in the committed Press Start 2P subset — checked with +`document.fonts.check` and a width comparison against a glyph the subset does not have, because a +missing glyph falls back to a proportional face and shows as tofu on air. The É was in the subset +too, but the face draws it at x-height, so POKÉMON read as POKéMON — a little smaller than the +caps around it. The wordmark in `src/games/pokemon-red.ts` drops the accent (POKEMON) for that +reason; Pokémon Red keeps its accent everywhere else this doc names the game. + +### Why the brain map has no WebGL + +Decision 3 of the plan: the capture VM has no GPU, and an accidental SwiftShader context costs +one to two cores silently. So the map is three tiers of 2D canvas (design A5): a base bitmap of +all 139,255 neurons rasterised once in a worker, a 252x185 density accumulator over the spike +bitset with a 110 ms decay, and at most 256 pre-rendered sprites on the brightest cells. On top of +those, one moment effect: the reward flare, an expanding ring from the PAM cluster's own centroid, +which the worker computes from `meta.json`'s `reward_pam` role and the normalized positions rather +than from a hand-placed coordinate that would point at the wrong part of the brain the first time +the dataset is rebuilt. + +The accumulator's grid takes a different divisor per axis — 1008/4 and 370/2 — because 370 is not +a multiple of 4 and a fractional cell would put the sprite pass a subpixel off the cell it belongs +to. The particle layer is 2D canvas too, for the same reason. + +That decision still holds for the map. The fly is the one deliberate exception, and it is +counted rather than trusted: `tests/e2e/structure.spec.ts` asserts the page requests **exactly +one** GL context with `?fly=webgl` and **zero** with `?fly=paper` or `?fly=off`. + +## The game config contract + +There will be a second demo, so the page is game-agnostic by construction. `src/games/types.ts` +is the contract; `src/games/pokemon-red.ts` and `src/games/platformer.ts` (a stub) implement it, +and `?game=` selects one. + +**The split:** live values always come from the feed header. A config only supplies the human copy +for them, plus the wordmark. + +| On screen | Value from | Copy from | +|---|---|---| +| Current rung name | `milestone.label` | — (the service is authoritative) | +| Next rung | `milestone.next` | — | +| The rung count | `milestone.total` | `milestoneLadder`'s length, as the fallback (`src/lib/ladder.ts`) | +| The ladder's rung names | — | `milestoneLadder` (the header carries only the current one) | +| Game mode | `game.mode` | `modeLabels` | +| Ticker rows | `events[].rewardKind` | `rewardCopy[kind].label` + `tier` + `dedupeMs` | +| Counters | `game.badges`, `game.uniqueLocations` | `counters[]` | +| The DESCRIBE cards' game name | — | `name`, reached through `{game}` in `src/games/describe.ts` | + +Two rules the tests enforce: + +- No game is named outside `src/games/`. `tests/unit/labels.test.ts` asserts the dataset-level + copy mentions no game vocabulary, and `tests/e2e/structure.spec.ts` loads the page with + `?game=platformer` and asserts the other game's name appears nowhere in the frame. +- The role-to-label mapping for the circuit bars is **dataset-level**, not game-level — it is the + same fly for both demos — so it lives in `src/lib/labels.ts`, and a unit test asserts it covers + every role key in `data/fafb-v783/meta.json` and `circuit-roles.json`. A dataset rebuild that + adds a role fails the test instead of silently dropping a bar. + +Adding the platformer for real means replacing the ladder and the copy in +`src/games/platformer.ts`. Nothing else. + +Three places outside `src/games/` still contain the string, and none of them is display copy: + +- `src/lib/query.ts` and `src/games/index.ts` carry `'pokemon-red'` as the default `?game=` id. + A default has to name something, and this is a registry key. +- `RewardKind` includes `'pokedex'`, and `src/feed/store.ts` watches `rewardCounts.pokedex` to + promote the brain map when a counter moves without its event. That key is in the feed protocol + contract, not in this page, and the config is what turns it into words ("found a secret" for the + platformer). +- two source comments cite the research the layout came from. + +`grep -ril pokemon apps/stage/src` is the check, and `tests/e2e/structure.spec.ts` asserts the +rendered frame contains no trace of the other game when `?game=platformer` is loaded. + +## Deviations from design A and from the locked layout + +Every one of these is a case where a specification's numbers do not close, or where rendering it +showed the choice failing its own legibility requirement. The first five are about rail layout v2 +and supersede the layout v1 deviations they replace. + +1. **The rail is the locked four rows** (superseding A2's four *and* layout v1's five). Layout v1 + needed five rows because the brain map was an inset with its own band; v2 makes the map a tab, + so the rows are the locked 144 / 420 / 100 / 244 on a 12 px gutter, which closes on 944 + exactly. `src/motion/catalogue.ts`'s region boxes read those numbers out of `geometry.ts` + rather than restating them. +2. **The button row lives inside the fly strip, not above the game** (A2). A2 puts eight glyph + cells at the bottom of the left column, inside the zone Twitch overlays with chat; this page + puts them along the fly strip's own top edge instead (32 of its 220 px, full width), with the + fly's canvas filling the rest. The row briefly moved further still, into the fly's own 3D scene + as the Game Boy's caps it tapped, but came back out on review: the fly's legs and wings are + wired to real motor neurons, not to button presses, so tapping a button was never honest + (`docs/design/fly-avatar.md`). Only the fly's own canvas reaches into the bottom-left + no-content zone; the button row sits well above it. + + Since 2026-09-16 the strip's lower row is **the fly on the left and the macro strip on the + right**, sharing one baseline and the strip's one frame (the operator: "slide the fly over and put the + macro palette right next to it", `docs/design/macros.md` section 6). The fly's canvas is 416 px + because that is the widest it can be while the strip's left edge stays at x = 480, the + no-content zone's right edge — the macro cells carry text and its bottom rows are inside the + zone's band. It is one column of six 27 px cells, not two columns of three, because a cell has + to hold "BUY POTION" in Silkscreen at the 24 px floor (168 px) and two columns leave 164. Since + section 12 a cell is its channel tag and the macro's name: the tag is eight characters at its + longest (`MB·WARP`), which is 140 px of the row's 356, and that is where the gloss column went + — the gloss is still on the wire, it just has no room on the strip. +3. **The brain map is a tab, and there is no promotion at all** (A2/A5, and superseding layout + v1's promote-over-the-rail). A2 promotes the map "over the left column", which puts it over the + game — the one thing a broadcast overlay must never do. Layout v1 promoted it over the rail + instead; v2 deletes the promotion outright, because the locked layout makes the map one of three + tabs and "big moments pre-empt for 9 s" is a *tab focus*. So the backing store is the pane's own + 1008x370, allocated once, never rescaled, and the 520 ms transform is off the broadcast's + critical path. The one thing a moment now draws outside a panel is the caption band, and + `tests/e2e/behaviour.spec.ts` asserts it is inside the tab slot and clear of the game, the fly + and the title. +4. **The spine runs across the cluster, not down a panel** (A3). A vertical spine gives each rung + a couple of pixels, which is under one pixel at the phone downscale the legibility test checks: + gone. Across the progress cluster's full 984 px, 38 rungs are 24.7 px each, which survives — + and the current rung's 20 px floor (`docs/design/ladder.md`, measured in + `tests/e2e/ladder.spec.ts`) no longer even binds. +5. **No 72 px hero anywhere** (A2/A4). The type floors are unchanged, but layout v1's hero role is + unused: the four panels that carried one (the run clock's Hz, the stuck-o-meter's time) are one + 144 px cluster now, and 72 px of anything does not fit beside a 38-rung spine. The cluster's + headline is one line of 30 px mono — deliberately between the 27 px body floor and the 33 px + label band — with the two right-hand readouts at `label-lg`. `tests/e2e/text-size.spec.ts` + asserts the absence, so a hero cannot creep back in unmeasured. +6. **The grid LUT is a `Uint32Array`** (A5), not a `Uint16Array`. The grid is 252x185 (46,620 + cells), which is inside a u16 — but the LUT is indexed by *neuron*, and there are 139,255 of + them, so the array is 139,255 entries of cell index either way. It stays `Uint32Array` because + a future larger grid would overflow silently, which is the failure a `Uint16Array` would buy + for 278 KB in a worker. +7. **There is no backing-store multiplier at all** (A1). A1 multiplies the game and retina backing + stores by 1.5 for the 1080p mode; 1080p *is* the authoring size now, so every backing store is + already 1:1 with the encoded frame and `?res=720` only ever scales down. +8. **Resolved 2026-09-16 (was: the rung line abbreviates the *next* rung, and the LADDER tab + abbreviates every rung).** The Game Boy pass gave both to Press Start 2P or Silkscreen, and the + arithmetic never closed at any size inside the floors — a single rung name alone wanted 330 to + 450 px in Press Start 2P or 250 to 335 in Silkscreen, against a rung line with about 370 to + spare, and the LADDER tab's three columns left each name 147 px, seven characters of Silkscreen, + so "Viridian City" and "Viridian Forest" both read "Viridia…". The 2026-09-16 pairing moves both + to `--font-text` (VT323, `src/theme/tokens.css`) — 0.4 em a character against Silkscreen's + 0.73 — and neither needs the cap or the abbreviation any more: the ladder's longest names + (15 characters) measure about 162 px at 27 px, and both rung names together with the arrow and + gaps come to roughly 350 px of the rung line's own column. `src/theme/rail.css`'s + `.rung-line__name`/`.rung-line__next` keep a generous `max-width` and an ellipsis as a + structural backstop for a service that free-texts something longer than any name in this + build's ladder, not because either is expected to hit it. +9. **Tabular digits come from a second face, not from the body/label face** + (`docs/design/gameboy-theme.md`, Type). The design asks for tabular digits "everywhere numbers + change" and notes that monospace makes it automatic. Silkscreen is proportional and carries no + `tnum` — measured, every digit is 20/27 em except "1", which is 17 — so `.num` renders in Press + Start 2P (`--font-num`), the one monospaced face on the page and the design's own face for big + numbers. This held through the 2026-09-16 font-pairing revision too: VT323 *is* monospaced, but + `.num`'s digits stayed on Press Start 2P rather than following `--font-text`, because the two + faces have different cap-heights and swapping mid-line under a `--font-label` word would set a + readout and its label on two different baselines (`src/theme/tokens.css`). Three consequences of + the original pick: the two cluster readouts sit at 30 px rather than 36 (2026-09-16's -10% pass; + 33/39 before it), the label band's floor rather than its ceiling, because a full em per + character took more of the cluster's 980 than the rung line could give up; the clock's day and + the Hz unit are split out of `.num` into words; and the try count and the ladder's rung index + are *not* `.num`, because neither is a number that changes often enough for a viewer to catch it + moving. +10. **Resolved 2026-09-16 (was: the progress cluster's counters line truncates)**. Silkscreen was + 1.8x VT323's width, and the cluster's footer held five readouts wanting about 1,060 px in 980 + even after the splits in (9), so the counters line carried `truncate` and "0/8 badges · 214 + places" ellipsised. Under `--font-text` (VT323) the same line measures well under its column + (`ProgressCluster.tsx` no longer sets `truncate` on it). **The SENSES head's lost unit word is + still gone**, though, and is unrelated to the font pairing: the head is 422 px, of which + "circuits" (`--font-label`) is 229 and the gap 12, and " spikes" alone was 121 against 135 to + 189 for the number it labels — the number is the half that cannot be paraphrased, so the word + stays off. Visible in `mockups/steady-t1-senses.png`. +11. **The realtime factor is not on screen.** A2 does not ask for it, and "1.00x" is exactly the + operator status vocabulary the audit complained about. It is on `window.__stage` and in the + feed header for whoever is on call. +12. **`@flybrain/brain/view/layout` is a new subpath export.** The design says to import + `normalizePositions`/`classifyByRoles` from `@flybrain/brain/view` and *not* from + `view/connectome.ts`, but `./view` resolves to `connectome.ts`, which imports `three`. The + new subpath reaches `view/layout.ts` directly, which is what the design meant. +13. **Fixtures are over the 5 MB target** (cold-open 0.28 MB, big-moment 13.0 MB, steady + 23.3 MB), and gzip does not fix it. Measured: one snapshot from the fake flysim gzips to + 9.7 KB, so 120 s at 30 Hz is 35 MB at full fidelity, and at 30 Hz the *headers alone* are + 2.3 MB gzipped over 120 s while the spike bitsets are close to incompressible. The recorder's + attachment stride already cuts 442 MB to 9.8 MB; getting under 5 MB would have meant 15 Hz + frames and 5 Hz spikes, which costs more than the bytes do. big-moment and steady roughly + doubled on 2026-09-16 when `@flybrain/feed`'s fake simulator changed its default + `spikesPerTick` from 2,000 to the live service's measured ~30,000 (`packages/feed`'s + `fake/simulator.ts` and README): the bitset's raw size does not change — it is fixed at + `ceil(139_255 / 8) = 17,407` bytes regardless of density — but a ~21.5% full bitset is far + less compressible than a ~1.4% full one, so the kept-every-third-snapshot spike bytes gzip + much worse. cold-open is unaffected because its `boot` scenario never reaches `running` and + so never emits spikes. +14. **Fixtures live in `public/fixtures/`**, not `fixtures/`, so Vite serves and copies them + without a second bespoke middleware (the dataset artifacts already need one). +15. **The SFX bank is synthesised, not sampled** (A8 asks for six short OFL/CC0 samples). Eight + recipes of oscillators and gain envelopes rendered into `AudioBuffer`s at startup — the + original six plus the rollback's rewind sweep and the day-rollover stinger, which the moment + catalogue's sound tiers had been borrowing other samples for. No audio assets in the repo and + no third-party licence to track. `src/audio/sfx.ts`. +16. **`apps/stage` does not set `noUncheckedIndexedAccess`.** It typechecks `packages/brain` and + `packages/feed` sources directly (they are source-only workspace packages), and those are + written against the workspace's own stricter-than-default-but-not-that-strict settings. +17. **The particle layer spans the stage, not just the rail** + (`docs/design/animation.md`, addendum). The addendum puts particles "on a 2D canvas layer over + the rail" and then asks for sugar sparks that "drift from the fly's head toward the sugar + chip" — a path from x=448 in the left column to x=1790 in the rail, which a rail-sized canvas + cannot draw. The canvas is the whole frame and is *clipped* to the rail plus the fly strip + instead, so the wider surface buys the effect the addendum describes without buying a licence + to paint over the game. It also costs nothing on an idle frame: with no live particles the + layer is not cleared and not drawn. +18. **HERE FOR's thresholds are not queued moments** (`docs/design/animation.md`, catalogue). Every + other row of the catalogue is a moment; this one is a property of a number that is on screen + all the time, so the warmth is a function of the *value* (a page that loads into a fly stuck + for four hours is already warm) and only the crossing pulses. Queueing it would have meant a + moment that could be pre-empted by a badge and then never seen. +19. **`viewer` events do not reach the EVENTS ticker.** Every accepted chat line logs one, and v2 + gives chat its own panel, so a `viewer` row in EVENTS is the same line twice — once with its + text and once as the word "chat". Sugar is its own event kind and still lands in EVENTS, which + is the viewer action that panel is about. +20. **The body face is split in two, VT323 replacing Silkscreen for running text** + (2026-09-16, the operator, from the sign-off mockup). `--font-body` is `--font-label` (Silkscreen) and + `--font-text` (VT323) now, not one face doing both jobs: `--font-label` for panel titles, tab + titles, chips (mode, sugar, button-row glyphs) and the circuit bar row labels — everything + short and closed-vocabulary, which is what Silkscreen's boxy weight was picked for; `--font-text` + for everything an open vocabulary or the feed writes — the rung names, the ladder tab, the + ticker, chat, the clock's day word. `--font-pixel` (Press Start 2P) keeps exactly `.num` and the + wordmark, unchanged. This is what let deviations 8 and 10 above resolve, and it came with a + 10% cut to the type floors (`--fs-body` 27 -> 24, `--fs-label` 33-39 -> 30-36) once the + narrower running-text face gave the rail room to spare — `tests/e2e/legibility.spec.ts`'s 0.31 + downscale check is the guard on that cut, and every region cleared it with margin to spare + (chat 0.104, events 0.126, the rung line 0.177-0.190, the retina 0.094-0.146 by tab, all against + a 0.035 floor), so nothing reverted to the old size. +21. **The retina raster's coordinates are an axial hex lattice, not Cartesian** (found 2026-09-16, + The operator: "the retina seems squashed"). `column_assignment.csv`'s `x`/`y`, copied straight through + by `tools/build_flywire.py`, are integer axial hex column coordinates (measured: 18 columns by + 60 rows, uniform raw nearest-neighbour distance of 1.0) — plotted as Cartesian, one eye's + bounding box is 17 units wide and 59 tall, which is the squash. `src/paint/retina.ts`'s + `hexToCartesian` (standard pointy-top axial-to-pixel) runs once in `setColumns`, ahead of the + existing per-eye letterboxed fit, and takes the aspect to about 1.5 (taller than wide, a real + compound eye's own shape) rather than the mirrored assignment's 0.25 (also measured, and worse). +22. **The LADDER tab has no best-snapshot thumbnail** (dropped 2026-09-16, the operator: give the width + back to the rung names). It cost 160 of the stats column's 236 px for a picture, not the ratchet + the tab is about; `LADDER_STATS_WIDTH` is 130 now (rollbacks, lifetime, last and the stall meter + only), and the freed width plus the VT323 split in (20) is what lets all three columns show + every rung name whole rather than the seven-character "Viridia…" deviation 8 used to describe. + A name that still cannot fit — none of this build's do — gets a slow stepped marquee pan + (`src/lib/marquee.ts`, `useLadderMarquee` in `LadderTab.tsx`) instead of an ellipsis, because + the pixel cursor makes a rung's name the one thing on this pane a viewer is meant to read in + full. +23. **The title strip carries a release version, last and quietest** (2026-09-16, the operator). + `__STAGE_VERSION__` (`vite.config.ts`, `git describe --tags --always --dirty`; `dev` from the + dev server) renders beside the mode chip in `--font-label` at the label floor, dim ink — for + whoever is on call, not a viewer's read of the game. The string is only right when the build + runs from the tagged checkout the release is cut from; see "Building a release" below. + +## Things this page deliberately cannot do + +- It cannot press a button. The feed is one-directional and the control API has no button + endpoint; both are structural, not configuration. +- It cannot render text from outside the feed. Viewer display names arrive only inside a + `FeedEvent`, are validated by the bridge before the sim call, and are re-validated here on + render (`safeDisplayName`, one chokepoint, tested). +- It cannot generate prose. Every string on screen is a constant in `src/lib/labels.ts` or a game + config, or a template-generated label from the service. diff --git a/apps/stage/components.json b/apps/stage/components.json new file mode 100644 index 0000000..2b0833f --- /dev/null +++ b/apps/stage/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/stage/index.html b/apps/stage/index.html new file mode 100644 index 0000000..cbc6343 --- /dev/null +++ b/apps/stage/index.html @@ -0,0 +1,20 @@ + + + + + Fly brain plays + + + + + + + +
+ + + diff --git a/apps/stage/mockups/big-moment-t1.png b/apps/stage/mockups/big-moment-t1.png new file mode 100644 index 0000000..49f4d89 Binary files /dev/null and b/apps/stage/mockups/big-moment-t1.png differ diff --git a/apps/stage/mockups/chat-wrap.png b/apps/stage/mockups/chat-wrap.png new file mode 100644 index 0000000..dad2bdb Binary files /dev/null and b/apps/stage/mockups/chat-wrap.png differ diff --git a/apps/stage/mockups/describe.png b/apps/stage/mockups/describe.png new file mode 100644 index 0000000..def4e7c Binary files /dev/null and b/apps/stage/mockups/describe.png differ diff --git a/apps/stage/mockups/fly-paper.png b/apps/stage/mockups/fly-paper.png new file mode 100644 index 0000000..61d5d39 Binary files /dev/null and b/apps/stage/mockups/fly-paper.png differ diff --git a/apps/stage/mockups/fly-webgl.png b/apps/stage/mockups/fly-webgl.png new file mode 100644 index 0000000..3549b05 Binary files /dev/null and b/apps/stage/mockups/fly-webgl.png differ diff --git a/apps/stage/mockups/gameboy-fonts.png b/apps/stage/mockups/gameboy-fonts.png new file mode 100644 index 0000000..fae20dc Binary files /dev/null and b/apps/stage/mockups/gameboy-fonts.png differ diff --git a/apps/stage/mockups/macros-center.png b/apps/stage/mockups/macros-center.png new file mode 100644 index 0000000..559e3a6 Binary files /dev/null and b/apps/stage/mockups/macros-center.png differ diff --git a/apps/stage/mockups/macros-indoors.png b/apps/stage/mockups/macros-indoors.png new file mode 100644 index 0000000..f1ab277 Binary files /dev/null and b/apps/stage/mockups/macros-indoors.png differ diff --git a/apps/stage/mockups/macros-outcome.png b/apps/stage/mockups/macros-outcome.png new file mode 100644 index 0000000..68d8a26 Binary files /dev/null and b/apps/stage/mockups/macros-outcome.png differ diff --git a/apps/stage/mockups/macros-overworld.png b/apps/stage/mockups/macros-overworld.png new file mode 100644 index 0000000..972ae36 Binary files /dev/null and b/apps/stage/mockups/macros-overworld.png differ diff --git a/apps/stage/mockups/macros-pc.png b/apps/stage/mockups/macros-pc.png new file mode 100644 index 0000000..e7a29f7 Binary files /dev/null and b/apps/stage/mockups/macros-pc.png differ diff --git a/apps/stage/mockups/macros-running.png b/apps/stage/mockups/macros-running.png new file mode 100644 index 0000000..f8865e8 Binary files /dev/null and b/apps/stage/mockups/macros-running.png differ diff --git a/apps/stage/mockups/macros-shop.png b/apps/stage/mockups/macros-shop.png new file mode 100644 index 0000000..af07b67 Binary files /dev/null and b/apps/stage/mockups/macros-shop.png differ diff --git a/apps/stage/mockups/macros-tab.png b/apps/stage/mockups/macros-tab.png new file mode 100644 index 0000000..6e08c03 Binary files /dev/null and b/apps/stage/mockups/macros-tab.png differ diff --git a/apps/stage/mockups/moment-badge.png b/apps/stage/mockups/moment-badge.png new file mode 100644 index 0000000..af92d05 Binary files /dev/null and b/apps/stage/mockups/moment-badge.png differ diff --git a/apps/stage/mockups/moment-milestone.png b/apps/stage/mockups/moment-milestone.png new file mode 100644 index 0000000..17e275e Binary files /dev/null and b/apps/stage/mockups/moment-milestone.png differ diff --git a/apps/stage/mockups/moment-rollback.png b/apps/stage/mockups/moment-rollback.png new file mode 100644 index 0000000..554448f Binary files /dev/null and b/apps/stage/mockups/moment-rollback.png differ diff --git a/apps/stage/mockups/moment-sugar.png b/apps/stage/mockups/moment-sugar.png new file mode 100644 index 0000000..0de3b2d Binary files /dev/null and b/apps/stage/mockups/moment-sugar.png differ diff --git a/apps/stage/mockups/motion/.gitignore b/apps/stage/mockups/motion/.gitignore new file mode 100644 index 0000000..8dff538 --- /dev/null +++ b/apps/stage/mockups/motion/.gitignore @@ -0,0 +1,14 @@ +# The 42 individual frames (`-.png`, six per moment at 1920x1080) are build output, not +# review material: what a human reads is the committed contact sheet per moment, and the frames +# regenerate in about twenty seconds with +# +# cd apps/stage && npx tsx tools/motion-strip.mts +# +# A run is ~12 MB, which is not something the repo should carry again on every animation tweak. The +# sheets themselves (`-sheet.png`, 38-66 KB each) are committed and are not ignored. +*-0.png +*-100.png +*-300.png +*-600.png +*-1200.png +*-9500.png diff --git a/apps/stage/mockups/motion/badge-sheet.png b/apps/stage/mockups/motion/badge-sheet.png new file mode 100644 index 0000000..f4de43e Binary files /dev/null and b/apps/stage/mockups/motion/badge-sheet.png differ diff --git a/apps/stage/mockups/motion/dayRollover-sheet.png b/apps/stage/mockups/motion/dayRollover-sheet.png new file mode 100644 index 0000000..0e3d732 Binary files /dev/null and b/apps/stage/mockups/motion/dayRollover-sheet.png differ diff --git a/apps/stage/mockups/motion/milestone-sheet.png b/apps/stage/mockups/motion/milestone-sheet.png new file mode 100644 index 0000000..cbfed71 Binary files /dev/null and b/apps/stage/mockups/motion/milestone-sheet.png differ diff --git a/apps/stage/mockups/motion/modeChange-sheet.png b/apps/stage/mockups/motion/modeChange-sheet.png new file mode 100644 index 0000000..4d2bf12 Binary files /dev/null and b/apps/stage/mockups/motion/modeChange-sheet.png differ diff --git a/apps/stage/mockups/motion/reward-sheet.png b/apps/stage/mockups/motion/reward-sheet.png new file mode 100644 index 0000000..86075f1 Binary files /dev/null and b/apps/stage/mockups/motion/reward-sheet.png differ diff --git a/apps/stage/mockups/motion/rollback-sheet.png b/apps/stage/mockups/motion/rollback-sheet.png new file mode 100644 index 0000000..634a114 Binary files /dev/null and b/apps/stage/mockups/motion/rollback-sheet.png differ diff --git a/apps/stage/mockups/motion/sugar-sheet.png b/apps/stage/mockups/motion/sugar-sheet.png new file mode 100644 index 0000000..003ffb5 Binary files /dev/null and b/apps/stage/mockups/motion/sugar-sheet.png differ diff --git a/apps/stage/mockups/pad-strip.png b/apps/stage/mockups/pad-strip.png new file mode 100644 index 0000000..12f9abf Binary files /dev/null and b/apps/stage/mockups/pad-strip.png differ diff --git a/apps/stage/mockups/retina-before-after.png b/apps/stage/mockups/retina-before-after.png new file mode 100644 index 0000000..9ed2688 Binary files /dev/null and b/apps/stage/mockups/retina-before-after.png differ diff --git a/apps/stage/mockups/steady-t1-connectome.png b/apps/stage/mockups/steady-t1-connectome.png new file mode 100644 index 0000000..869c2e9 Binary files /dev/null and b/apps/stage/mockups/steady-t1-connectome.png differ diff --git a/apps/stage/mockups/steady-t1-ladder.png b/apps/stage/mockups/steady-t1-ladder.png new file mode 100644 index 0000000..0567e0f Binary files /dev/null and b/apps/stage/mockups/steady-t1-ladder.png differ diff --git a/apps/stage/mockups/steady-t1-senses.png b/apps/stage/mockups/steady-t1-senses.png new file mode 100644 index 0000000..0707c22 Binary files /dev/null and b/apps/stage/mockups/steady-t1-senses.png differ diff --git a/apps/stage/motion-harness/index.html b/apps/stage/motion-harness/index.html new file mode 100644 index 0000000..8b631af --- /dev/null +++ b/apps/stage/motion-harness/index.html @@ -0,0 +1,198 @@ + + + + + + flystage · motion harness + + + + +
+ + + diff --git a/apps/stage/package.json b/apps/stage/package.json new file mode 100644 index 0000000..2613bf9 --- /dev/null +++ b/apps/stage/package.json @@ -0,0 +1,48 @@ +{ + "name": "@flybrain/stage", + "version": "0.1.1", + "description": "flystage: the fixed 1920x1080 broadcast page for the 24/7 stream of a simulated fly connectome playing a Game Boy game.", + "license": "Apache-2.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json --noEmit --pretty false && vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", + "test": "node --import tsx --test tests/unit/*.test.ts", + "test:e2e": "playwright test", + "mockups": "tsx tools/mockup.mts", + "fonts": "tsx tools/font-compare.mts", + "record": "tsx tools/record-fixture.mts" + }, + "dependencies": { + "@flybrain/brain": "*", + "@flybrain/feed": "*", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slot": "1.3.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "react": "19.2.8", + "react-dom": "19.2.8", + "tailwind-merge": "3.6.0", + "three": "0.178.0", + "zustand": "5.0.15" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "@tailwindcss/vite": "4.3.3", + "@types/node": "22.17.0", + "@types/three": "0.178.1", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "@types/ws": "8.18.1", + "@vitejs/plugin-react": "5.2.0", + "tailwindcss": "4.3.3", + "tsx": "4.20.3", + "typescript": "5.9.2", + "vite": "7.3.6", + "ws": "8.21.3" + } +} diff --git a/apps/stage/playwright.config.ts b/apps/stage/playwright.config.ts new file mode 100644 index 0000000..78c6b4f --- /dev/null +++ b/apps/stage/playwright.config.ts @@ -0,0 +1,70 @@ +/** + * Playwright against the real build, at the real broadcast size: 1920x1080, DPR 1. + * + * Not the dev server: the page that goes on air is the `vite build` output served by `vite + * preview`, and the differences that matter (asset URLs, the worklet module, the worker chunk, + * no HMR client) all live in the build. + * + * The Chromium flags mirror what `flystage`'s systemd unit must use, per the two corrections at + * the top of the design document: keep `--autoplay-policy=no-user-gesture-required`, drop + * `--mute-audio` (the page is the stream's audio source), and drop every SwiftShader and ANGLE + * flag, so the browser picks its own GL path exactly as the capture host will. + * + * There is now exactly one intentional GL context on the page — the fly + * (`docs/design/fly-avatar.md`) — and `tests/e2e/structure.spec.ts` asserts it is exactly one with + * `?fly=webgl` and none at all with `?fly=paper` or `?fly=off`. The brain map is still 2D canvas. + */ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Preview port, 4300 unless `FLYSTAGE_E2E_PORT` says otherwise. + * + * Overridable because `--strictPort` means two suites on one machine collide, and two of them at + * once is normal here: a second worktree running the same suite, or a dev preview already holding + * 4300. `FLYSTAGE_E2E_PORT=4400 npm run test:e2e` is the whole workaround. + */ +const PORT = Number(process.env.FLYSTAGE_E2E_PORT ?? 4300); + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './test-results', + snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 90_000, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']], + + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.002, + animations: 'disabled', + }, + }, + + use: { + baseURL: `http://127.0.0.1:${PORT}`, + viewport: { width: 1920, height: 1080 }, + deviceScaleFactor: 1, + trace: 'retain-on-failure', + launchOptions: { + args: ['--autoplay-policy=no-user-gesture-required', '--disable-lcd-text'], + }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 }, + }, + ], + + webServer: { + command: `npm run build && npx vite preview --host 127.0.0.1 --port ${PORT} --strictPort`, + url: `http://127.0.0.1:${PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + stdout: 'ignore', + stderr: 'pipe', + }, +}); diff --git a/apps/stage/public/fixtures/big-moment.flyfeed.gz b/apps/stage/public/fixtures/big-moment.flyfeed.gz new file mode 100644 index 0000000..2bcdf29 Binary files /dev/null and b/apps/stage/public/fixtures/big-moment.flyfeed.gz differ diff --git a/apps/stage/public/fixtures/bigpad.flyfeed.gz b/apps/stage/public/fixtures/bigpad.flyfeed.gz new file mode 100644 index 0000000..3d6acad Binary files /dev/null and b/apps/stage/public/fixtures/bigpad.flyfeed.gz differ diff --git a/apps/stage/public/fixtures/center.flyfeed.gz b/apps/stage/public/fixtures/center.flyfeed.gz new file mode 100644 index 0000000..95300c8 Binary files /dev/null and b/apps/stage/public/fixtures/center.flyfeed.gz differ diff --git a/apps/stage/public/fixtures/cold-open.flyfeed.gz b/apps/stage/public/fixtures/cold-open.flyfeed.gz new file mode 100644 index 0000000..10b3e53 Binary files /dev/null and b/apps/stage/public/fixtures/cold-open.flyfeed.gz differ diff --git a/apps/stage/public/fixtures/macros.flyfeed.gz b/apps/stage/public/fixtures/macros.flyfeed.gz new file mode 100644 index 0000000..2ca23c8 Binary files /dev/null and b/apps/stage/public/fixtures/macros.flyfeed.gz differ diff --git a/apps/stage/public/fixtures/shop.flyfeed.gz b/apps/stage/public/fixtures/shop.flyfeed.gz new file mode 100644 index 0000000..43f2607 Binary files /dev/null and b/apps/stage/public/fixtures/shop.flyfeed.gz differ diff --git a/apps/stage/public/fixtures/steady.flyfeed.gz b/apps/stage/public/fixtures/steady.flyfeed.gz new file mode 100644 index 0000000..c6ad031 Binary files /dev/null and b/apps/stage/public/fixtures/steady.flyfeed.gz differ diff --git a/apps/stage/public/fonts/OFL-PixelifySans.txt b/apps/stage/public/fonts/OFL-PixelifySans.txt new file mode 100644 index 0000000..6aa5bfb --- /dev/null +++ b/apps/stage/public/fonts/OFL-PixelifySans.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/stage/public/fonts/OFL-PressStart2P.txt b/apps/stage/public/fonts/OFL-PressStart2P.txt new file mode 100644 index 0000000..d836cf5 --- /dev/null +++ b/apps/stage/public/fonts/OFL-PressStart2P.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/stage/public/fonts/OFL-Silkscreen.txt b/apps/stage/public/fonts/OFL-Silkscreen.txt new file mode 100644 index 0000000..3dd7cfb --- /dev/null +++ b/apps/stage/public/fonts/OFL-Silkscreen.txt @@ -0,0 +1,93 @@ +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/stage/public/fonts/OFL-VT323.txt b/apps/stage/public/fonts/OFL-VT323.txt new file mode 100644 index 0000000..bcb092f --- /dev/null +++ b/apps/stage/public/fonts/OFL-VT323.txt @@ -0,0 +1,93 @@ +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/stage/public/fonts/PixelifySans-Latin.woff2 b/apps/stage/public/fonts/PixelifySans-Latin.woff2 new file mode 100644 index 0000000..5833047 Binary files /dev/null and b/apps/stage/public/fonts/PixelifySans-Latin.woff2 differ diff --git a/apps/stage/public/fonts/PressStart2P-Latin.woff2 b/apps/stage/public/fonts/PressStart2P-Latin.woff2 new file mode 100644 index 0000000..947a979 Binary files /dev/null and b/apps/stage/public/fonts/PressStart2P-Latin.woff2 differ diff --git a/apps/stage/public/fonts/Silkscreen-Latin.woff2 b/apps/stage/public/fonts/Silkscreen-Latin.woff2 new file mode 100644 index 0000000..e9df9fd Binary files /dev/null and b/apps/stage/public/fonts/Silkscreen-Latin.woff2 differ diff --git a/apps/stage/public/fonts/VT323-Latin.woff2 b/apps/stage/public/fonts/VT323-Latin.woff2 new file mode 100644 index 0000000..b1d6e78 Binary files /dev/null and b/apps/stage/public/fonts/VT323-Latin.woff2 differ diff --git a/apps/stage/src/App.tsx b/apps/stage/src/App.tsx new file mode 100644 index 0000000..c898567 --- /dev/null +++ b/apps/stage/src/App.tsx @@ -0,0 +1,777 @@ +/** + * The stage: one page, one rAF loop, one feed. + * + * Everything is wired here rather than inside the panels, because the panels must not own + * anything that ticks: React commits at 4 Hz, the paint loop runs at 60, and the feed arrives at + * 30. See `src/feed/store.ts` for that split and `src/paint/loop.ts` for the instrumentation. + * Rail layout v2's choreography — tabs, moments, lerped readouts — is one object too + * (`src/motion/director.ts`), so this file stays wiring rather than becoming the animation. + * + * `data-ready="1"` goes on `` only after `document.fonts.ready` *and* the brain map's base + * bitmap, because both the capture launcher and every test wait on it. A broadcast page that + * paints once and runs for weeks will happily broadcast a fallback font forever if nobody gates + * the first frame. + */ +import { useEffect, useMemo, useRef } from 'react'; + +import { loadCompressed } from '@flybrain/brain/browser'; +import { GAMEBOY_BUTTONS } from '@flybrain/brain'; +import type { BrainBaseRequest, BrainBaseResponse } from '@/workers/brain-base.worker'; +import BrainBaseWorker from '@/workers/brain-base.worker?worker'; +import { AudioEngine } from '@/audio/engine'; +import { FixturePlayer } from '@/feed/fixture'; +import { FeedSocket } from '@/feed/socket'; +import type { FeedSource } from '@/feed/source'; +import { AFTERGLOW_MS, FeedIngest, hot, useStage } from '@/feed/store'; +import { createFlyRenderer, type FlyFrame, type FlyRenderer } from '@/fly'; +import { readFlyDrives } from '@/fly/drives'; +import { FlyRig, idleDrives } from '@/fly/rig'; +import { resolveGame } from '@/games'; +import { CIRCUIT_DECISION_THRESHOLD, circuitFraction, thresholdRateHz } from '@/lib/circuit-scale'; +import { BAR_ROLES, CIRCUIT_GROUPS, MACRO_CIRCUIT } from '@/lib/labels'; +import { + FLY_CANVAS_HEIGHT, + FLY_CANVAS_WIDTH, + GAME_HEIGHT, + GAME_WIDTH, + LAYOUT, + MACRO_CELL_GAP, + MACRO_CELL_HEIGHT, + MACRO_CELL_ROWS, + MACRO_CELL_WIDTH, + MACRO_PALETTE_PAD, + MAP_GRID_HEIGHT, + MAP_GRID_WIDTH, + MAP_HERO_HEIGHT, + MAP_HERO_WIDTH, + RETINA_CANVAS_HEIGHT, + RETINA_CANVAS_WIDTH, + NO_CONTENT_ZONE, + boxStyle, +} from '@/lib/geometry'; +import { stageOptions } from '@/lib/query'; +import { Director, FLY_HEAD_ANCHOR } from '@/motion/director'; +import { MotionEngine } from '@/motion/engine'; +import type { MomentType } from '@/motion/moments'; +import { MOTION_SEED, resolveMotionColours } from '@/motion/catalogue'; +import { RailSignals } from '@/motion/rail-signals'; +import { BAR_STEP, quantizePixels } from '@/motion/lerp'; +import { DIR } from '@/motion/particles'; +import { BrainMapSurface } from '@/paint/brainmap'; +import { GameSurface } from '@/paint/game'; +import { PaintLoop, type TimeFn } from '@/paint/loop'; +import { RetinaSurface } from '@/paint/retina'; +import { readCanvasPalette } from '@/theme/colors'; +import { ChatPanel } from '@/panels/ChatPanel'; +import { EventsTicker } from '@/panels/EventsTicker'; +import { FlyStrip } from '@/panels/FlyStrip'; +import { GamePanel } from '@/panels/GamePanel'; +import { MomentLayer } from '@/panels/MomentLayer'; +import { ProgressCluster } from '@/panels/ProgressCluster'; +import { StaleBanner } from '@/panels/StaleBanner'; +import { TabSlot } from '@/panels/TabSlot'; +import { TitleStrip } from '@/panels/TitleStrip'; + +/** Where the dataset artifacts are served from (dev middleware and build copy both use this). */ +const DATASET_BASE = '/data/fafb-v783'; + +/** The brain map repaints at up to 30 Hz; its 110 ms decay does not need 60. */ +const MAP_INTERVAL_MS = 33; + +/** The fly is capped at 30 fps, per `docs/design/fly-avatar.md`. */ +const FLY_INTERVAL_MS = 33; + +/** + * How long a finished macro's outcome stays on its cell. + * + * `docs/design/macros.md` section 6: "then shows its outcome for a beat (done / blocked / + * timeout)". A second, on the simulation clock, which is long enough to read a seven-character + * word at a glance and short enough that the cell is back to its gloss before the fly's next + * decision (the sim consults the channels again at most once per `holdMs`, 800 ms). + */ +const MACRO_OUTCOME_MS = 1000; + +/** How new a running macro has to be for its cell to throw sparks: two snapshots' worth. */ +const MACRO_BURST_MS = 200; + +/** If the base bitmap never arrives, go ready anyway after this long, and say so. */ +const READY_TIMEOUT_MS = 15_000; + +export function App() { + const options = useMemo(() => stageOptions(), []); + const game = useMemo(() => resolveGame(options.game), [options.game]); + + const gameCanvas = useRef(null); + const retinaCanvas = useRef(null); + const mapCanvas = useRef(null); + const flyCanvas = useRef(null); + const particleCanvas = useRef(null); + + useEffect(() => { + const root = document.documentElement; + root.dataset.theme = options.theme; + root.dataset.res = options.res; + root.dataset.game = game.id; + }, [options.theme, options.res, game.id]); + + useEffect(() => { + const root = document.documentElement; + const stage = document.getElementById('stage') ?? root; + const palette = readCanvasPalette(root); + // One authoring resolution, and it is the native broadcast size: the backing stores are + // already 1:1 with the encoded frame, so there is no multiplier to apply. `?res=720` only ever + // scales *down* (thumbnails and the downscale tests), which needs no extra backing pixels. + const backingScale = 1; + + const gameEl = gameCanvas.current; + const retinaEl = retinaCanvas.current; + const mapEl = mapCanvas.current; + if (!gameEl || !retinaEl || !mapEl) return; + + const gameSurface = new GameSurface(gameEl, GAME_WIDTH, GAME_HEIGHT, backingScale); + const retinaSurface = new RetinaSurface(retinaEl, RETINA_CANVAS_WIDTH, RETINA_CANVAS_HEIGHT, backingScale); + const mapSurface = new BrainMapSurface(mapEl); + + const backgroundCss = `rgb(${palette.background[0]} ${palette.background[1]} ${palette.background[2]})`; + gameSurface.drawPlaceholder(backgroundCss); + mapSurface.drawPlaceholder(backgroundCss); + retinaSurface.setColors(palette.sensory, palette.panel); + retinaSurface.clear(); + mapSurface.setColors({ sensory: palette.sensory, internal: palette.internal, output: palette.output }); + + // -- The animation engine, and the rail's own derived readouts -------------------------------- + const motion = new MotionEngine({ + seed: MOTION_SEED, + palette: resolveMotionColours(root), + // The fly is inside a canvas, so its head is the one emission point with no element to + // measure; every other anchor is replaced from the DOM each frame by the director. + anchors: { flyHead: FLY_HEAD_ANCHOR }, + }); + const signals = new RailSignals(); + + const ingest = new FeedIngest(game, motion, signals); + const engine = options.audio ? new AudioEngine(options.gains) : null; + void engine?.start(); + + const director = new Director({ + root: stage as HTMLElement, + game, + engine: motion, + signals, + palette, + map: mapSurface, + gameSurface, + particleCtx: particleCanvas.current?.getContext('2d') ?? null, + forcedTab: options.tab, + playSfx: engine ? (name, gain) => engine.playSfx(name, gain) : null, + }); + + // -- Button afterglow, as a plain row of eight indicators ------------------------------------- + // The row is DOM, not canvas (`src/panels/FlyStrip.tsx`): one cell per `GAMEBOY_BUTTONS` + // entry, each mutated only through `data-down`/`data-glow` so nothing here re-renders at + // 30 Hz. Lit on the rising edge and for 250 ms past the *falling* edge, which is what keeps + // an 85 ms A press visible. + const buttonCells = new Map(); + for (const button of GAMEBOY_BUTTONS) { + const cell = document.querySelector(`[data-button="${button}"]`); + if (cell) buttonCells.set(button, cell); + } + const buttonPainted = new Map(); + const paintButtons = (nowMs: number): void => { + for (const [button, cell] of buttonCells) { + const state = hot.buttonStates[button]; + if (!state) continue; + const glow = state.down || nowMs - state.upAtMs < AFTERGLOW_MS; + const key = `${state.down ? 'd' : '-'}${glow ? 'g' : '-'}`; + if (buttonPainted.get(button) === key) continue; + buttonPainted.set(button, key); + cell.dataset.down = state.down ? '1' : '0'; + cell.dataset.glow = glow ? '1' : '0'; + } + }; + + // -- The macro cells ------------------------------------------------------------------------- + // Two places draw them since section 14's decided layout: the strip under the game, which is + // the pad now, and the MACROS tab, which is the whole keyboard. React owns both lots of text, + // because it changes once per scene (`src/panels/MacroPalette.tsx`, + // `src/panels/tabs/MacrosTab.tsx`); what is painted here is which cell is lit and what its + // outcome was, on the same 30 Hz path as the button afterglow above, for both at once — a + // macro that is running is running in both places, and matching by name makes that one loop + // rather than two. The DOM is re-queried whenever React re-deals, since the cells are keyed on + // the scene and a cached node list goes stale on every scene change. + const paletteRoot = document.querySelector('[data-testid="macro-palette"]'); + const boardRoot = document.querySelector('[data-testid="macros-tab"]'); + let paletteCells: HTMLElement[] = []; + let paletteKey = ''; + const refreshPaletteCells = (): void => { + if (!paletteRoot) return; + const key = `${paletteRoot.dataset.mode ?? ''}:${paletteRoot.dataset.scene ?? ''}`; + const stale = paletteCells.length === 0 || !(paletteCells[0] as HTMLElement).isConnected; + if (key === paletteKey && !stale) return; + paletteKey = key; + paletteCells = [paletteRoot, boardRoot].flatMap((element) => + element === null ? [] : [...element.querySelectorAll('[data-macro-row]')], + ); + }; + + // Sparks from the chosen cell, the moment it lights: the rail's own burst, aimed with + // arithmetic rather than a measured box, because the palette's geometry is fixed + // (`src/lib/geometry.ts`) and `PARTICLE_CLIP` already covers the fly strip. + // + // Aimed by `data-macro-slot`, the cell's place *in the strip*, rather than by its type: the + // strip packs the pad from the top into two columns of seven, so type 29 can be its third + // cell. A macro that is only on the board — the pad is allowed to be wider than the strip — + // has no slot and gets no burst, because the burst belongs to the strip's geometry and the + // board's tab may not even be up. + const motionColours = resolveMotionColours(root); + const cellCentre = (slot: number): { x: number; y: number } => ({ + x: + LAYOUT.macroPalette.x + + MACRO_PALETTE_PAD + + Math.floor(slot / MACRO_CELL_ROWS) * (MACRO_CELL_WIDTH + MACRO_CELL_GAP) + + MACRO_CELL_WIDTH / 2, + y: + LAYOUT.macroPalette.y + + MACRO_PALETTE_PAD + + (slot % MACRO_CELL_ROWS) * (MACRO_CELL_HEIGHT + MACRO_CELL_GAP) + + MACRO_CELL_HEIGHT / 2, + }); + + let lastMacroKey = ''; + const paintPalette = (): void => { + refreshPaletteCells(); + if (paletteCells.length === 0) return; + + // Read the two fields straight off the header rather than through `paletteView`: this runs + // 60 times a second and the view allocates all thirty-one cells, which the *commit* path + // (4 Hz, where the cells are what React needs) can afford and this one should not. + const game = hot.header?.game; + const inPalette = game?.macroMode === 'macros'; + const macro = (inPalette ? game?.macro : null) ?? null; + // The outcome beat runs on the *simulation* clock, not the page's: a held fixture seek + // freezes the feed, so a page clock would tick the beat away under a screenshot. + const brainMs = hot.header?.brainMs ?? 0; + const reported = (inPalette ? game?.macroOutcome : null) ?? null; + const outcome = reported !== null && brainMs - reported.atMs <= MACRO_OUTCOME_MS ? reported : null; + + // A cell is matched by the macro's *name*, not by the wire slot or by its place on screen: + // a type is a channel (`docs/design/macros.md` section 12), so the name is what identifies + // it — and an outcome the feed is still holding from the scene before then lights nothing, + // rather than lighting whichever cell inherited its slot. It is also what makes the strip + // and the board one loop: the same macro matches its cell in each. + let liveSlot = -1; + for (const cell of paletteCells) { + const name = cell.dataset.macroName ?? ''; + const live = macro !== null && name !== '' && macro.name === name ? '1' : '0'; + if (live === '1' && cell.dataset.macroSlot !== undefined) liveSlot = Number(cell.dataset.macroSlot); + if (cell.dataset.live !== live) cell.dataset.live = live; + + // A cell that is running again shows the run, not the last result. + const word = outcome !== null && name !== '' && outcome.name === name && live === '0' ? outcome.outcome : ''; + if (cell.dataset.outcome !== word) { + cell.dataset.outcome = word; + const label = cell.querySelector('.macro-cell__outcome'); + if (label) label.textContent = word.toUpperCase(); + } + } + + // `sinceMs` counts from the start, so the start's own clock value identifies the run: a + // second `GO EXIT` on the same slot is a different burst. The burst only fires for a macro + // that started *just now*, which is what keeps it off two frames it does not belong to: the + // page connecting in the middle of a long macro, and a fixture seek, whose silent catch-up + // lands on a snapshot mid-run and would otherwise freeze a burst into every screenshot. + const key = macro === null ? '' : `${macro.slot}:${macro.name}:${Math.round(brainMs - macro.sinceMs)}`; + if (key !== lastMacroKey) { + if (key !== '' && macro !== null && liveSlot >= 0 && macro.sinceMs <= MACRO_BURST_MS) { + const at = cellCentre(liveSlot); + motion.field.sparks(at.x, at.y, 10, motionColours.amber, DIR.right, Math.PI / 2.5, { speed: 0.14 }); + } + lastMacroKey = key; + } + }; + + // -- The fly --------------------------------------------------------------------------------- + const rig = new FlyRig(); + const flyDrives = idleDrives(); + let fly: FlyRenderer | null = null; + let lastFlyFrame: FlyFrame | null = null; + let lastFlyMs = Number.NEGATIVE_INFINITY; + let lastFlyClockMs = Number.NEGATIVE_INFINITY; + let flyStoppedDrawn = false; + let flyAccepted = -1; + + if (options.fly !== 'off' && flyCanvas.current) { + void createFlyRenderer(options.fly, { + canvas: flyCanvas.current, + width: FLY_CANVAS_WIDTH, + height: FLY_CANVAS_HEIGHT, + palette, + }).then((renderer) => { + if (disposed) { + renderer?.dispose(); + return; + } + fly = renderer; + }); + } + + // `time` is passed in rather than wrapping the whole call, so the `fly` histogram holds one + // sample per *drawn* frame. Timing the skipped frames too would bury the real cost under a + // pile of zeroes and report a p50 of 0 ms for a renderer doing real work at 30 fps. + const paintFly = (nowMs: number, time: TimeFn): void => { + if (!fly) return; + // 30 fps while the clock moves, plus a frame whenever a snapshot lands. When the clock has + // stopped — a fixture held on a seek target — draw exactly one more frame and then nothing: + // the rig zeroes its gait phase on a stopped clock, so that one frame is the same fly every + // time, which is what makes the screenshot tests reproducible. + const stopped = nowMs === lastFlyClockMs; + lastFlyClockMs = nowMs; + const due = stopped + ? !flyStoppedDrawn + : nowMs - lastFlyMs >= FLY_INTERVAL_MS || hot.accepted !== flyAccepted; + flyStoppedDrawn = stopped; + if (!due) return; + lastFlyMs = nowMs; + flyAccepted = hot.accepted; + const sugar = motion.snapshot().active?.type === 'sugar' ? 1 : 0; + const frame = rig.advance(readFlyDrives(sugar, flyDrives), nowMs); + lastFlyFrame = frame; + time('fly', () => fly?.draw(frame)); + }; + + // -- Circuit bars ------------------------------------------------------------------------- + // Each bar scales against its own adaptive reference (`hot.circuitReferenceHz`, kept warm by + // `FeedIngest`, see `src/lib/circuit-scale.ts`), not a fixed Hz ceiling: a fixed scale is what + // pegged every bar at 100% on the first live run, whose per-role rates run far above whatever + // the fixture generator produced (`infra/docs/p0-local-encoded-frame.png`). + // + // The fills are also quantised to the theme's 8 px cells (`docs/design/gameboy-theme.md`: + // "Bars are chunky: 12 px tall, hard edges, filled in 8 px steps (quantized), no gradients"), + // which is why each bar's track width is measured once here: the quantum is 8 px of the + // element's own width, and a bar in a flex row has no width this file could derive. + const barFills = new Map(); + const barTracks = new Map(); + const barPeaks = new Map(); + const barThresholds = new Map(); + const fullScale = new Map(); + const decisionThreshold = new Map(); + for (const group of CIRCUIT_GROUPS) { + for (const bar of group.bars) { + fullScale.set(bar.role, group.fullScaleHz); + const threshold = CIRCUIT_DECISION_THRESHOLD[group.id]; + if (threshold !== undefined) decisionThreshold.set(bar.role, threshold); + } + } + for (const role of BAR_ROLES) { + const fill = document.querySelector(`[data-bar="${role}"]`); + const peak = document.querySelector(`[data-peak="${role}"]`); + const threshold = document.querySelector(`[data-threshold="${role}"]`); + if (fill) { + barFills.set(role, fill); + barTracks.set(role, fill.parentElement?.clientWidth ?? 0); + } + if (peak) barPeaks.set(role, peak); + if (threshold) barThresholds.set(role, threshold); + } + /** + * The MACROS row's bars, which come and go with the scene. + * + * The six fixed groups are queried once above; these are re-queried whenever React re-renders + * the row (`src/panels/tabs/SensesTab.tsx`), keyed on the channel tags it drew. Everything + * else is the same arithmetic: the fill scales against the role's own adaptive reference and is + * quantised to the theme's 8 px cells. + */ + let macroBars: { role: string; fill: HTMLElement; peak: HTMLElement | null; track: number }[] = []; + let macroBarKey = '\u0000'; + const refreshMacroBars = (): void => { + const rows = [...document.querySelectorAll('[data-macro-bar]')]; + const key = rows.map((row) => row.dataset.macroBar ?? '').join(','); + const stale = macroBars.length > 0 && !(macroBars[0] as { fill: HTMLElement }).fill.isConnected; + if (key === macroBarKey && !stale) return; + macroBarKey = key; + macroBars = []; + for (const row of rows) { + const fill = row.querySelector('[data-bar]'); + const role = fill?.dataset.bar; + if (!fill || !role) continue; + macroBars.push({ + role, + fill, + peak: row.querySelector('[data-peak]'), + track: fill.parentElement?.clientWidth ?? 0, + }); + } + }; + + const barPainted = new Map(); + const paintBars = (): void => { + for (const [role, element] of barFills) { + const reference = hot.circuitReferenceHz[role] ?? fullScale.get(role) ?? 30; + const value = circuitFraction(hot.rates[role] ?? 0, reference); + // Re-measure while the track reads zero: the effect that caches these can run before the + // slot's panes have been laid out, and an unmeasured track would quietly mean no steps. + let track = barTracks.get(role) ?? 0; + if (track === 0) { + track = element.parentElement?.clientWidth ?? 0; + barTracks.set(role, track); + } + const quantised = quantizePixels(value, track, BAR_STEP); + if (barPainted.get(role) === quantised) continue; + barPainted.set(role, quantised); + element.style.transform = `scaleX(${quantised})`; + } + for (const [role, element] of barPeaks) { + const reference = hot.circuitReferenceHz[role] ?? fullScale.get(role) ?? 30; + const peak = circuitFraction(hot.peaks[role]?.value ?? 0, reference); + element.style.transform = `translateX(${(peak * 100).toFixed(1)}%)`; + } + for (const [role, element] of barThresholds) { + const threshold = decisionThreshold.get(role); + if (threshold === undefined) continue; + const reference = hot.circuitReferenceHz[role] ?? fullScale.get(role) ?? 30; + const median = hot.circuitMedianHz[role] ?? 0; + const fraction = circuitFraction(thresholdRateHz(threshold, median), reference); + element.style.transform = `translateX(${(fraction * 100).toFixed(1)}%)`; + } + + refreshMacroBars(); + for (const bar of macroBars) { + const reference = hot.circuitReferenceHz[bar.role] ?? MACRO_CIRCUIT.fullScaleHz; + const value = circuitFraction(hot.rates[bar.role] ?? 0, reference); + if (bar.track === 0) bar.track = bar.fill.parentElement?.clientWidth ?? 0; + const quantised = quantizePixels(value, bar.track, BAR_STEP); + if (barPainted.get(bar.role) !== quantised) { + barPainted.set(bar.role, quantised); + bar.fill.style.transform = `scaleX(${quantised})`; + } + if (bar.peak) { + const peak = circuitFraction(hot.peaks[bar.role]?.value ?? 0, reference); + bar.peak.style.transform = `translateX(${(peak * 100).toFixed(1)}%)`; + } + } + }; + + // -- Sound effects the moments do not own ------------------------------------------------- + // Every moment's sound comes from the engine's cue queue, drained by the director. What is + // left here is the two alarms, which are states rather than moments: the stuck-o-meter + // crossing its threshold, and the feed going stale. + let sfxArmed = false; + let stuckFired = false; + let staleFired = false; + const fireSfx = (): void => { + if (!engine) return; + const state = useStage.getState(); + + if (!sfxArmed) { + // Arm after the first frame so a seek's replayed history is silent. + stuckFired = state.milestone.sinceSeconds >= game.stuckAlarmSeconds; + staleFired = state.stale; + sfxArmed = true; + return; + } + + const stuck = state.milestone.sinceSeconds >= game.stuckAlarmSeconds; + if (stuck && !stuckFired) engine.playSfx('stuck'); + stuckFired = stuck; + + if (state.stale && !staleFired) engine.playSfx('stale'); + staleFired = state.stale; + }; + + // -- The loop ----------------------------------------------------------------------------- + let lastMapMs = Number.NEGATIVE_INFINITY; + let mapDirty = false; + let source: FeedSource | null = null; + + const loop = new PaintLoop((rawNowMs, dtMs, time) => { + if (source) time('pump', () => source?.pump(rawNowMs)); + + // A fixture held on a seek target freezes the clock, so everything that is a function of + // elapsed time stops with it and a screenshot is reproducible. + const nowMs = source?.clock ? source.clock(rawNowMs) : rawNowMs; + + if (hot.frameDirty && hot.frame) { + const frame = hot.frame; + hot.frameDirty = false; + time('game', () => gameSurface.drawFrame(frame, rawNowMs)); + if (retinaSurface.ready()) time('retina', () => retinaSurface.drawFrame(frame)); + } else if (gameSurface.rewinding(rawNowMs)) { + // The rollback wipe is 400 ms of animation over a 30 Hz picture, so the game canvas is + // repainted on the frames between snapshots for its duration and on no others. + time('game', () => gameSurface.redraw(rawNowMs)); + } + + if (hot.spikesDirty && hot.spikes) { + mapSurface.ingestSpikes(hot.spikes); + hot.spikesDirty = false; + mapDirty = true; + } + + // Redraw at up to 30 Hz while the feed is moving, and not at all when it is not: the + // accumulator's decay would otherwise keep fading a frozen frame under a screenshot. A + // flare is animation rather than data, so it keeps the map repainting for its 500 ms. + const feedIdle = nowMs - hot.lastSnapshotMs > 500; + const flaring = mapSurface.flaring(rawNowMs); + if (mapSurface.ready() && (mapDirty || flaring || !feedIdle) && nowMs - lastMapMs >= MAP_INTERVAL_MS) { + lastMapMs = nowMs; + mapDirty = false; + time('brainmap', () => mapSurface.draw(rawNowMs)); + } + + time('buttons', () => paintButtons(nowMs)); + time('palette', () => paintPalette()); + time('bars', () => paintBars()); + paintFly(nowMs, time); + time('motion', () => director.frame(nowMs, rawNowMs, dtMs)); + + if (engine && hot.audioQueue.length > 0) { + const chunks = hot.audioQueue.splice(0, hot.audioQueue.length); + time('audio', () => { + for (const chunk of chunks) engine.push(chunk); + }); + } + + time('commit', () => ingest.commit(nowMs)); + time('sfx', () => fireSfx()); + }); + + // -- Dataset, worker, feed ---------------------------------------------------------------- + let disposed = false; + const worker = new BrainBaseWorker(); + let readyTimer: ReturnType | null = null; + + const markReady = (degraded: boolean): void => { + if (disposed || root.dataset.ready === '1') return; + if (degraded) root.dataset.degraded = '1'; + root.dataset.ready = '1'; + }; + + worker.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (message.type === 'error') { + console.warn(`brain map base failed: ${message.message}`); + markReady(true); + return; + } + mapSurface.setBase( + message.bitmap, + message.lut, + message.cellClasses, + message.neuronCount, + message.pam, + message.fit, + ); + // On a paused fixture there is no later snapshot to trigger a repaint, so ask for one. + mapDirty = true; + void document.fonts.ready.then(() => markReady(false)); + worker.terminate(); + }); + + const request: BrainBaseRequest = { + type: 'load', + base: DATASET_BASE, + width: MAP_HERO_WIDTH, + height: MAP_HERO_HEIGHT, + gridWidth: MAP_GRID_WIDTH, + gridHeight: MAP_GRID_HEIGHT, + colors: { sensory: palette.sensory, internal: palette.internal, output: palette.output }, + }; + worker.postMessage(request); + readyTimer = setTimeout(() => markReady(true), READY_TIMEOUT_MS); + + void (async () => { + try { + const response = await fetch(`${DATASET_BASE}/meta.json`); + if (response.ok) { + const meta = (await response.json()) as { + neurons: number; + edges: number; + dataset: string; + visual: { count: number }; + }; + if (!disposed) { + useStage.getState().setDataset({ + neurons: meta.neurons, + edges: meta.edges, + name: /v\d+/.exec(meta.dataset)?.[0] ?? meta.dataset, + }); + } + } + } catch (error) { + console.warn(`dataset metadata unavailable: ${(error as Error).message}`); + } + + try { + const [xy, hemisphere] = await Promise.all([ + loadCompressed(`${DATASET_BASE}/visual-xy.binz`, (buffer) => new Float32Array(buffer)), + loadCompressed(`${DATASET_BASE}/visual-hemisphere.binz`, (buffer) => new Uint8Array(buffer)), + ]); + if (!disposed) { + retinaSurface.setColumns({ xy, hemisphere, count: hemisphere.length }); + // Same race as the brain map: the columns can land after the last snapshot of a seek. + if (hot.frame) retinaSurface.drawFrame(hot.frame); + } + } catch (error) { + console.warn(`retina columns unavailable: ${(error as Error).message}`); + } + })(); + + const player = + options.mode === 'player' + ? new FixturePlayer(`/fixtures/${options.fixture}.flyfeed.gz`, ingest, { + seekSeconds: options.seekSeconds, + autoplay: options.autoplay, + loop: options.loop, + }) + : null; + source = player ?? new FeedSocket(options.feedUrl, ingest); + + void source.start().catch((error: unknown) => { + console.error(`feed source failed: ${(error as Error).message}`); + }); + + loop.start(); + + // -- Test and operator surface ------------------------------------------------------------ + window.__stage = { + options, + game: game.id, + metrics: () => loop.metrics(), + audio: () => engine?.state() ?? null, + manifest: () => player?.manifest() ?? null, + seek: (seconds: number) => { + director.reset(); + player?.seek(seconds); + }, + stopFeed: () => source?.stop(), + sprites: () => mapSurface.sprites(), + health: () => ({ gaps: hot.gaps, decodeErrors: hot.decodeErrors, accepted: hot.accepted }), + state: () => useStage.getState(), + gameScale: () => gameSurface.scale(), + motion: () => director.state(), + pam: () => mapSurface.pamCentroid(), + // The brain map's own geometry and saturation. The one way to tell, from outside the page, + // whether the CONNECTOME tab's canvas, its LUT and the worker's fit still agree. + brainmap: () => mapSurface.stats(), + // The one deliberate way to drive the catalogue by hand: the moment mockups and the e2e + // moment assertions fire a trigger rather than waiting for a fixture to contain one. + fire: (type, label = '', detail = '') => + motion.enqueue({ type, label, detail, intensity: 1, source: 'event' }), + /** + * Replace the held feed's chat ring, the same way `fire` replaces a moment: by hand, for the + * chat mockup and the wrapping assertions, because the committed fixtures were recorded off a + * live bridge and none of them happens to contain a 200-character line. + * + * Written into `hot.header`, not into the store, because the paint loop's commit rebuilds + * `chat` from the header every time it runs and a `setState` here would last 250 ms. The + * lines still go through `sanitizeChatRing` on the way to the panel, so this cannot put + * anything on screen that a real line could not. + * + * The wall time is a day old on purpose: a line older than the page's connect time is + * history rather than an arrival, so an injected name cannot pin the DESCRIBE tab + * (`src/lib/chatters.ts`) and move a screenshot that was meant to be about chat. + */ + chat: (lines) => { + const header = hot.header; + if (!header) return 0; + const wallMs = Date.now() - 86_400_000; + header.chat = lines.map((line, index) => ({ + id: index + 1, + wallMs, + by: line.by, + text: line.text, + ...(line.bot === true ? { bot: true } : {}), + })); + // Forced, because a fixture held on a seek target freezes the clock the commit gate reads: + // without this the panel would not see the new ring until something else opened the gate. + ingest.commit(hot.lastSnapshotMs, true); + return useStage.getState().chat.length; + }, + fly: () => ({ + // The renderer that is actually drawing, which is not always the one that was asked for: + // `webgl` falls back to `paper` on a host with no usable GL context (`src/fly/index.ts`). + // `requested` keeps the query parameter visible so the two can be told apart on air. + mode: fly ? fly.mode : options.fly, + requested: options.fly, + rendering: fly !== null, + gaitPhase: rig.gaitPhase, + // Extension of the proboscis in rig units, which a sugar event visibly grows. + proboscis: lastFlyFrame + ? Math.hypot( + lastFlyFrame.proboscis.b[0] - lastFlyFrame.proboscis.a[0], + lastFlyFrame.proboscis.b[1] - lastFlyFrame.proboscis.a[1], + lastFlyFrame.proboscis.b[2] - lastFlyFrame.proboscis.a[2], + ) + : 0, + // The tip of every leg as last drawn, so a test can check the fly is still whenever the + // clock is (`tests/e2e/behaviour.spec.ts`). + legTips: (lastFlyFrame?.legs ?? []).map((joints) => joints[3] as [number, number, number]), + }), + }; + + return () => { + disposed = true; + if (readyTimer !== null) clearTimeout(readyTimer); + loop.stop(); + source?.stop(); + fly?.dispose(); + worker.terminate(); + void engine?.stop(); + delete root.dataset.ready; + }; + // The stage is built once. Every option is a page-load-time decision by design: the capture + // launcher restarts the page to change anything. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ {/* Declared, and asserted by the structural test: no text or readout may land here. */} +
+ + + + + + + + + + + + +
+ ); +} + +declare global { + interface Window { + __stage?: { + options: ReturnType; + game: string; + metrics: () => ReturnType; + audio: () => ReturnType | null; + manifest: () => ReturnType | null; + seek: (seconds: number) => void; + stopFeed: () => void; + sprites: () => number; + health: () => { gaps: number; decodeErrors: number; accepted: number }; + state: () => ReturnType; + gameScale: () => number; + motion: () => ReturnType; + pam: () => { x: number; y: number } | null; + brainmap: () => ReturnType; + fire: (type: MomentType, label?: string, detail?: string) => number | null; + /** Replace the held feed's chat ring by hand; returns how many lines the panel accepted. */ + chat: (lines: readonly { by: string; text: string; bot?: boolean }[]) => number; + fly: () => { + mode: string; + requested: string; + rendering: boolean; + gaitPhase: number; + proboscis: number; + legTips: [number, number, number][]; + }; + }; + } +} diff --git a/apps/stage/src/audio/drift.ts b/apps/stage/src/audio/drift.ts new file mode 100644 index 0000000..a62f2d1 --- /dev/null +++ b/apps/stage/src/audio/drift.ts @@ -0,0 +1,91 @@ +/** + * Ring-buffer policy and the varispeed drift servo (design A8). + * + * The emulator produces audio in 30 Hz chunks off the simulation clock; the AudioContext consumes + * it off the sound card's clock. Those two clocks are never the same, so over an hour the buffer + * either fills until it overflows or drains until it clicks. A fractional read index resampling at + * 1 ± 0.3 percent absorbs the difference inaudibly, and only a gross excursion (double the target + * fill, or an empty buffer) is corrected by dropping or inserting samples. + * + * The policy lives here, in TypeScript, and is handed to the AudioWorklet as `processorOptions`: + * the worklet applies the formula but owns none of the numbers, so there is exactly one place to + * change them and `tests/unit/drift.test.ts` can check them without an AudioContext. + */ + +/** Interleaved stereo. */ +export const CHANNELS = 2; + +export interface DriftPolicy { + /** Target buffered audio, ms. */ + targetMs: number; + /** Above this, the servo is pulling the read rate up. */ + highMs: number; + /** Below this, the servo is pulling the read rate down. */ + lowMs: number; + /** Maximum fractional rate deviation, e.g. 0.003 for ±0.3 percent. */ + maxDrift: number; + /** Proportional gain on the normalised fill error. */ + gain: number; + /** Ring capacity, ms. Must comfortably exceed `highMs`. */ + capacityMs: number; +} + +export const DEFAULT_DRIFT_POLICY: DriftPolicy = { + targetMs: 250, + highMs: 400, + lowMs: 120, + maxDrift: 0.003, + gain: 0.5, + capacityMs: 1500, +}; + +/** Frames (per channel) for a duration at a sample rate. */ +export function framesFor(ms: number, sampleRate: number): number { + return Math.round((ms / 1000) * sampleRate); +} + +/** + * Playback rate for the current fill level. + * + * Above the target the rate goes slightly *up* (consume faster, drain the excess); below, down. + * Clamped to `maxDrift` in both directions, which is the whole point: the correction must be + * inaudible, so it is never allowed to be fast. + */ +export function varispeedRate(fillFrames: number, policy: DriftPolicy, sampleRate: number): number { + const target = framesFor(policy.targetMs, sampleRate); + if (target <= 0) return 1; + const error = (fillFrames - target) / target; + const correction = Math.max(-policy.maxDrift, Math.min(policy.maxDrift, error * policy.gain)); + return 1 + correction; +} + +/** What the servo cannot fix on its own. */ +export type HardCorrection = 'drop' | 'insert' | null; + +/** + * Gross excursions. + * + * `drop` when the buffer holds more than twice the target (the producer ran ahead, e.g. after the + * page was throttled): discard down to the target rather than play a growing delay for the rest of + * the broadcast. `insert` when it is empty: emit silence and count an underrun. + */ +export function hardCorrection(fillFrames: number, policy: DriftPolicy, sampleRate: number): HardCorrection { + const target = framesFor(policy.targetMs, sampleRate); + if (fillFrames <= 0) return 'insert'; + if (fillFrames > target * 2) return 'drop'; + return null; +} + +/** Frames to discard to bring an over-full buffer back to the target. */ +export function dropCount(fillFrames: number, policy: DriftPolicy, sampleRate: number): number { + const target = framesFor(policy.targetMs, sampleRate); + return Math.max(0, fillFrames - target); +} + +/** Where the servo currently is, for the health readout. */ +export function fillZone(fillFrames: number, policy: DriftPolicy, sampleRate: number): 'low' | 'ok' | 'high' { + const ms = (fillFrames / sampleRate) * 1000; + if (ms < policy.lowMs) return 'low'; + if (ms > policy.highMs) return 'high'; + return 'ok'; +} diff --git a/apps/stage/src/audio/engine.ts b/apps/stage/src/audio/engine.ts new file mode 100644 index 0000000..c0b6016 --- /dev/null +++ b/apps/stage/src/audio/engine.ts @@ -0,0 +1,232 @@ +/** + * The audio engine: game PCM from the feed through a worklet ring buffer, plus the SFX bank + * (design A8). + * + * The page is the stream's audio source: `flycast` captures the same X display and the same Pulse + * sink, so A/V sync is the browser's problem and not ffmpeg's. That makes three failure modes + * worth designing against, all of which look like success: + * + * - the AudioContext never leaves `suspended` (no `--autoplay-policy=no-user-gesture-required`) + * - the worklet module fails to load + * - the sink exists but nothing is written to it + * + * So every step is guarded and reported rather than thrown, `state()` exposes what actually + * happened, and nothing here can take the page down. In tests the context is usually suspended + * and `start()` must still resolve without throwing — `tests/unit` covers the policy maths and + * the e2e "no console errors" check covers the rest. + */ +import { AUDIO_RATE } from '@flybrain/feed'; +import { CHANNELS, DEFAULT_DRIFT_POLICY, framesFor, type DriftPolicy } from './drift'; +import { SFX_NAMES, renderSfxBank, type SfxName } from './sfx'; +import workletUrl from './ring-worklet.js?url'; + +export interface AudioGains { + master: number; + game: number; + sfx: number; +} + +export interface AudioEngineState { + /** `unavailable` when the browser has no Web Audio at all. */ + context: AudioContextState | 'unavailable'; + worklet: 'idle' | 'ready' | 'failed'; + fillMs: number; + rate: number; + underruns: number; + drops: number; + pushedFrames: number; + sfxLoaded: number; + lastError: string | null; +} + +interface WorkletStats { + type: 'stats'; + fillFrames: number; + rate: number; + underruns: number; + drops: number; + pushed: number; +} + +export class AudioEngine { + private context: AudioContext | null = null; + private node: AudioWorkletNode | null = null; + private masterGain: GainNode | null = null; + private gameGain: GainNode | null = null; + private sfxGain: GainNode | null = null; + private bank = new Map(); + private stats: WorkletStats | null = null; + private workletState: 'idle' | 'ready' | 'failed' = 'idle'; + private lastError: string | null = null; + private readonly policy: DriftPolicy; + + constructor( + private gains: AudioGains, + policy: DriftPolicy = DEFAULT_DRIFT_POLICY, + ) { + this.policy = policy; + } + + /** + * Bring up the context, the worklet and the SFX bank. + * + * Never throws. A failure is recorded in `state().lastError` and the page keeps running silent, + * which is exactly what `flycast`'s pre-flight check is for: it refuses to go live when + * `state().context !== 'running'`. + */ + async start(): Promise { + if (typeof AudioContext === 'undefined') { + this.lastError = 'this browser has no AudioContext'; + return; + } + + try { + // 48 kHz is the feed's rate and Web Audio's native rate on Linux, so nothing resamples. + this.context = new AudioContext({ sampleRate: AUDIO_RATE, latencyHint: 'playback' }); + } catch (error) { + this.lastError = `AudioContext: ${(error as Error).message}`; + return; + } + + const context = this.context; + this.masterGain = context.createGain(); + this.gameGain = context.createGain(); + this.sfxGain = context.createGain(); + this.applyGains(); + this.gameGain.connect(this.masterGain); + this.sfxGain.connect(this.masterGain); + this.masterGain.connect(context.destination); + + try { + await context.audioWorklet.addModule(workletUrl); + const node = new AudioWorkletNode(context, 'ring-player', { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [CHANNELS], + processorOptions: { + channels: CHANNELS, + capacityFrames: framesFor(this.policy.capacityMs, context.sampleRate), + targetFrames: framesFor(this.policy.targetMs, context.sampleRate), + maxDrift: this.policy.maxDrift, + gain: this.policy.gain, + reportEveryFrames: framesFor(100, context.sampleRate), + }, + }); + node.port.onmessage = (event: MessageEvent) => { + if (event.data?.type === 'stats') this.stats = event.data; + }; + node.connect(this.gameGain); + this.node = node; + this.workletState = 'ready'; + } catch (error) { + this.workletState = 'failed'; + this.lastError = `audio worklet: ${(error as Error).message}`; + } + + try { + this.bank = await renderSfxBank(context.sampleRate); + } catch (error) { + this.lastError = `sfx: ${(error as Error).message}`; + } + + // A kiosk Chromium launched with `--autoplay-policy=no-user-gesture-required` starts running; + // anywhere else this is a no-op that leaves the context suspended, which is not an error. + try { + if (context.state === 'suspended') await context.resume(); + } catch { + // Suspended is a legitimate state in a test browser. Nothing to do. + } + } + + /** Queue one snapshot's PCM. Transfers the buffer, so the caller must not reuse it. */ + push(chunk: Float32Array): void { + const node = this.node; + if (!node || chunk.length === 0) return; + try { + node.port.postMessage(chunk, [chunk.buffer]); + } catch { + // A detached buffer or a torn-down node: drop the chunk rather than fail the frame. + } + } + + /** Drop everything buffered. Used when a fixture loops or the feed reconnects. */ + flush(): void { + try { + this.node?.port.postMessage({ type: 'flush' }); + } catch { + // Nothing to flush. + } + } + + /** + * Fire one effect. Silent and harmless when the bank or the context is unavailable. + * + * `gain` is the per-cue level the moment catalogue's sound tier asks for + * (`src/motion/sfx-tiers.ts`), applied through its own one-shot `GainNode` under the SFX bus, so + * a tier that deliberately under-plays its sample cannot change the bus level for everything + * after it. 1 is the common case and allocates nothing extra. + */ + playSfx(name: SfxName, gain = 1): void { + const context = this.context; + const buffer = this.bank.get(name); + const target = this.sfxGain; + if (!context || !buffer || !target || context.state !== 'running') return; + try { + const source = context.createBufferSource(); + source.buffer = buffer; + if (gain >= 1) { + source.connect(target); + } else { + const trim = context.createGain(); + trim.gain.value = Math.max(0, gain); + source.connect(trim).connect(target); + } + source.start(); + } catch { + // Same rule: a sound effect never breaks the page. + } + } + + setGains(gains: AudioGains): void { + this.gains = gains; + this.applyGains(); + } + + state(): AudioEngineState { + const sampleRate = this.context?.sampleRate ?? AUDIO_RATE; + return { + context: this.context ? this.context.state : 'unavailable', + worklet: this.workletState, + fillMs: this.stats ? (this.stats.fillFrames / sampleRate) * 1000 : 0, + rate: this.stats?.rate ?? 1, + underruns: this.stats?.underruns ?? 0, + drops: this.stats?.drops ?? 0, + pushedFrames: this.stats?.pushed ?? 0, + sfxLoaded: SFX_NAMES.filter((name) => this.bank.has(name)).length, + lastError: this.lastError, + }; + } + + async stop(): Promise { + try { + this.node?.port.postMessage({ type: 'close' }); + this.node?.disconnect(); + await this.context?.close(); + } catch { + // Shutting down is best-effort. + } + this.node = null; + this.context = null; + } + + private applyGains(): void { + if (this.masterGain) this.masterGain.gain.value = clamp01(this.gains.master); + if (this.gameGain) this.gameGain.gain.value = clamp01(this.gains.game); + if (this.sfxGain) this.sfxGain.gain.value = clamp01(this.gains.sfx); + } +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value)); +} diff --git a/apps/stage/src/audio/ring-worklet.js b/apps/stage/src/audio/ring-worklet.js new file mode 100644 index 0000000..22a8dae --- /dev/null +++ b/apps/stage/src/audio/ring-worklet.js @@ -0,0 +1,150 @@ +/** + * AudioWorklet processor: a ring buffer with a fractional read index (design A8). + * + * Plain JavaScript on purpose. An AudioWorklet module is loaded by URL into its own global scope, + * so it is not part of the bundle graph; keeping it as hand-written ES module JS avoids relying on + * static imports inside worklet scope, which is not a portable thing to depend on. + * + * It owns no policy: `targetFrames`, `maxDrift`, `gain` and the capacity all arrive in + * `processorOptions` from `src/audio/drift.ts`, which is where they are documented and tested. + * This file only applies them. + * + * Audio arrives as transferred `Float32Array`s over `port.postMessage` — deliberately not a + * SharedArrayBuffer, which would need COOP/COEP headers on the page, while 30 messages a second + * costs nothing. + */ + +class RingPlayer extends AudioWorkletProcessor { + constructor(options) { + super(); + const config = (options && options.processorOptions) || {}; + + this.channels = config.channels || 2; + this.capacity = Math.max(2048, config.capacityFrames || 72000); + this.targetFrames = config.targetFrames || 12000; + this.maxDrift = config.maxDrift || 0.003; + this.gain = config.gain || 0.5; + this.reportEvery = config.reportEveryFrames || 4800; + + // One interleaved ring, so a chunk is a single copy in. + this.ring = new Float32Array(this.capacity * this.channels); + this.writeIndex = 0; + this.readIndex = 0; // fractional, in frames + this.fill = 0; // frames available + + this.underruns = 0; + this.drops = 0; + this.pushed = 0; + this.rate = 1; + this.framesSinceReport = 0; + this.closed = false; + + this.port.onmessage = (event) => { + const data = event.data; + if (data instanceof Float32Array) { + this.push(data); + return; + } + if (data && data.type === 'flush') { + this.writeIndex = 0; + this.readIndex = 0; + this.fill = 0; + return; + } + if (data && data.type === 'close') { + this.closed = true; + } + }; + } + + /** Append interleaved frames, dropping the oldest if the producer has run away. */ + push(interleaved) { + const frames = Math.floor(interleaved.length / this.channels); + if (frames <= 0) return; + this.pushed += frames; + + for (let frame = 0; frame < frames; frame++) { + const slot = (this.writeIndex % this.capacity) * this.channels; + for (let channel = 0; channel < this.channels; channel++) { + this.ring[slot + channel] = interleaved[frame * this.channels + channel]; + } + this.writeIndex += 1; + } + + this.fill += frames; + if (this.fill > this.capacity) { + // Hard overflow: keep the newest audio, count it, carry on. + const excess = this.fill - this.capacity; + this.readIndex += excess; + this.fill = this.capacity; + this.drops += excess; + } + } + + /** One interpolated frame at the fractional read index. */ + sample(channel, position) { + const base = Math.floor(position); + const fraction = position - base; + const a = this.ring[(base % this.capacity) * this.channels + channel]; + const b = this.ring[((base + 1) % this.capacity) * this.channels + channel]; + return a + (b - a) * fraction; + } + + process(_inputs, outputs) { + const output = outputs[0]; + if (!output || output.length === 0) return !this.closed; + const blockFrames = output[0].length; + + // Gross excursion first: an over-full ring is trimmed to the target rather than played out. + if (this.fill > this.targetFrames * 2) { + const excess = this.fill - this.targetFrames; + this.readIndex += excess; + this.fill -= excess; + this.drops += excess; + } + + // Varispeed: pull the read rate towards whatever keeps the fill at the target. + const error = this.targetFrames > 0 ? (this.fill - this.targetFrames) / this.targetFrames : 0; + const correction = Math.max(-this.maxDrift, Math.min(this.maxDrift, error * this.gain)); + this.rate = 1 + correction; + + // Needing more than the ring holds is an underrun: emit silence and say so. + const needed = Math.ceil(blockFrames * this.rate) + 2; + if (this.fill < needed) { + this.underruns += 1; + for (let channel = 0; channel < output.length; channel++) output[channel].fill(0); + this.report(blockFrames); + return !this.closed; + } + + for (let frame = 0; frame < blockFrames; frame++) { + const position = this.readIndex + frame * this.rate; + for (let channel = 0; channel < output.length; channel++) { + output[channel][frame] = this.sample(Math.min(channel, this.channels - 1), position); + } + } + + const consumed = blockFrames * this.rate; + this.readIndex += consumed; + this.fill -= consumed; + + this.report(blockFrames); + return !this.closed; + } + + report(blockFrames) { + this.framesSinceReport += blockFrames; + if (this.framesSinceReport < this.reportEvery) return; + this.framesSinceReport = 0; + this.port.postMessage({ + type: 'stats', + fillFrames: this.fill, + rate: this.rate, + underruns: this.underruns, + drops: this.drops, + pushed: this.pushed, + }); + } +} + +registerProcessor('ring-player', RingPlayer); diff --git a/apps/stage/src/audio/sfx.ts b/apps/stage/src/audio/sfx.ts new file mode 100644 index 0000000..ef08730 --- /dev/null +++ b/apps/stage/src/audio/sfx.ts @@ -0,0 +1,210 @@ +/** + * The SFX bank, synthesised rather than shipped (design A8 asks for six short samples). + * + * Eight procedurally generated sounds, rendered once into `AudioBuffer`s with an + * `OfflineAudioContext` and oscillators, so the repo carries no audio assets and no third-party + * licence: a reward tick, a badge fanfare, a milestone chime, a sugar sparkle, the stuck-threshold + * tone, a feed-stale alarm, the rollback's rewind sweep and the day-rollover stinger. The last two + * are the gap `src/motion/sfx-tiers.ts` recorded as `NEEDS_SAMPLE`: its `rewind` tier was playing + * the stuck alarm and its `stinger` the milestone chime at half level, so both moments sounded + * like something they were not. They are deliberately plain shapes at modest level — this plays + * *under* game audio on a 24/7 stream, so the design goal is "noticeable once", not "musical". + * + * Every entry point is guarded: a suspended or unavailable AudioContext must never throw, because + * an exception here would take the broadcast page down for a sound effect. + */ + +export type SfxName = 'reward' | 'badge' | 'milestone' | 'sugar' | 'stuck' | 'stale' | 'rewind' | 'stinger'; + +export const SFX_NAMES: readonly SfxName[] = [ + 'reward', + 'badge', + 'milestone', + 'sugar', + 'stuck', + 'stale', + 'rewind', + 'stinger', +]; + +interface Voice { + /** Oscillator type. */ + type: OscillatorType; + /** Frequency envelope as [timeFraction, Hz] pairs. */ + freq: readonly [number, number][]; + /** Gain envelope as [timeFraction, gain] pairs. */ + gain: readonly [number, number][]; + /** Start offset as a fraction of the sample duration. */ + start?: number; +} + +interface Recipe { + durationSeconds: number; + voices: readonly Voice[]; +} + +/** + * The recipes. Kept declarative so a sound can be retuned without touching the renderer. + */ +const RECIPES: Record = { + // A soft blip: one short sine, the sound of a +0.05 exploration tick. + reward: { + durationSeconds: 0.09, + voices: [ + { + type: 'sine', + freq: [ + [0, 880], + [1, 1180], + ], + gain: [ + [0, 0], + [0.1, 0.5], + [1, 0], + ], + }, + ], + }, + // Badge: a rising triad, the loudest thing in the bank. + badge: { + durationSeconds: 0.75, + voices: [ + { type: 'triangle', freq: [[0, 523]], gain: [[0, 0], [0.05, 0.5], [0.45, 0.3], [1, 0]] }, + { type: 'triangle', freq: [[0, 659]], gain: [[0, 0], [0.05, 0.4], [0.6, 0.25], [1, 0]], start: 0.12 }, + { type: 'triangle', freq: [[0, 784]], gain: [[0, 0], [0.05, 0.4], [0.7, 0.25], [1, 0]], start: 0.26 }, + { type: 'sine', freq: [[0, 1046]], gain: [[0, 0], [0.1, 0.25], [1, 0]], start: 0.4 }, + ], + }, + // Milestone: two notes, a step up. Quieter than a badge; it happens more often. + milestone: { + durationSeconds: 0.45, + voices: [ + { type: 'sine', freq: [[0, 587]], gain: [[0, 0], [0.08, 0.4], [1, 0]] }, + { type: 'sine', freq: [[0, 880]], gain: [[0, 0], [0.08, 0.35], [1, 0]], start: 0.2 }, + ], + }, + // Sugar: a quick sparkle up, to go with the dopamine bar jumping. + sugar: { + durationSeconds: 0.3, + voices: [ + { + type: 'sine', + freq: [ + [0, 660], + [1, 1760], + ], + gain: [ + [0, 0], + [0.06, 0.4], + [1, 0], + ], + }, + { type: 'square', freq: [[0, 220]], gain: [[0, 0], [0.05, 0.08], [0.4, 0]] }, + ], + }, + // Stuck threshold: a low, flat, slightly ominous pair. This is the "interesting part" cue. + stuck: { + durationSeconds: 0.9, + voices: [ + { type: 'sine', freq: [[0, 196]], gain: [[0, 0], [0.15, 0.35], [0.8, 0.2], [1, 0]] }, + { type: 'sine', freq: [[0, 185]], gain: [[0, 0], [0.2, 0.25], [1, 0]], start: 0.25 }, + ], + }, + // Rollback: the rewind sweep. A fast fall through two octaves with a second voice a beat behind + // it, which is what a tape running backwards sounds like without sampling one. + rewind: { + durationSeconds: 0.5, + voices: [ + { + type: 'triangle', + freq: [ + [0, 1320], + [1, 180], + ], + gain: [ + [0, 0], + [0.06, 0.4], + [0.7, 0.22], + [1, 0], + ], + }, + { + type: 'square', + freq: [ + [0, 660], + [1, 120], + ], + gain: [ + [0, 0], + [0.08, 0.1], + [1, 0], + ], + start: 0.12, + }, + ], + }, + // Day rollover: a soft stinger. Two sine notes a fifth apart at low level — structure, not an + // achievement, so it must not sound like a rung. + stinger: { + durationSeconds: 0.55, + voices: [ + { type: 'sine', freq: [[0, 392]], gain: [[0, 0], [0.12, 0.2], [1, 0]] }, + { type: 'sine', freq: [[0, 587]], gain: [[0, 0], [0.12, 0.16], [1, 0]], start: 0.18 }, + ], + }, + // Feed stale: a two-tone alarm, the only sound that means something is wrong. + stale: { + durationSeconds: 0.6, + voices: [ + { type: 'square', freq: [[0, 440]], gain: [[0, 0], [0.05, 0.22], [0.45, 0.22], [0.5, 0]] }, + { type: 'square', freq: [[0, 330]], gain: [[0, 0], [0.05, 0.22], [0.45, 0.22], [0.5, 0]], start: 0.5 }, + ], + }, +}; + +/** Render the whole bank into buffers. Returns an empty map if Web Audio is unusable. */ +export async function renderSfxBank(sampleRate: number): Promise> { + const bank = new Map(); + if (typeof OfflineAudioContext === 'undefined') return bank; + + for (const name of SFX_NAMES) { + try { + const buffer = await renderOne(RECIPES[name], sampleRate); + bank.set(name, buffer); + } catch { + // A missing sound effect is not worth failing a broadcast over. + } + } + return bank; +} + +async function renderOne(recipe: Recipe, sampleRate: number): Promise { + const length = Math.max(1, Math.ceil(recipe.durationSeconds * sampleRate)); + const offline = new OfflineAudioContext({ numberOfChannels: 2, length, sampleRate }); + + for (const voice of recipe.voices) { + const startAt = (voice.start ?? 0) * recipe.durationSeconds; + const span = recipe.durationSeconds - startAt; + if (span <= 0) continue; + + const oscillator = offline.createOscillator(); + oscillator.type = voice.type; + const gainNode = offline.createGain(); + + oscillator.frequency.setValueAtTime(voice.freq[0]?.[1] ?? 440, startAt); + for (const [fraction, hz] of voice.freq.slice(1)) { + oscillator.frequency.linearRampToValueAtTime(hz, startAt + fraction * span); + } + + gainNode.gain.setValueAtTime(voice.gain[0]?.[1] ?? 0, startAt); + for (const [fraction, value] of voice.gain.slice(1)) { + gainNode.gain.linearRampToValueAtTime(value, startAt + fraction * span); + } + + oscillator.connect(gainNode).connect(offline.destination); + oscillator.start(startAt); + oscillator.stop(recipe.durationSeconds); + } + + return offline.startRendering(); +} diff --git a/apps/stage/src/chat/sanitize.ts b/apps/stage/src/chat/sanitize.ts new file mode 100644 index 0000000..a7f40df --- /dev/null +++ b/apps/stage/src/chat/sanitize.ts @@ -0,0 +1,77 @@ +/** + * Chat re-validation, on the page, at the point of render (defence in depth). + * + * The service is the authority. `docs/feed-protocol.md` says `header.chat[].text` has already been + * through the shared sanitizer and `by` through `validateDisplayName`, and + * `services/flysim/crates/flysim/src/chat.rs` enforces byte-identical rules in Rust. This module + * runs **the same shared implementation again** — `sanitizeChatText` from `@flybrain/feed`, not a + * local copy of its rules — and drops anything that fails. + * + * Why bother, when the service already did it: this is the only text on a 24/7 broadcast that + * originates with a stranger. The Nothing, Forever precedent (a 14-day ban for generated text) is + * about what reaches the frame, not about whose bug let it through, and a page that re-validates + * cannot be made to render a slur by a service regression, a replayed fixture, or a future + * transport nobody has written yet. Calling the shared function rather than reimplementing it is + * what makes the second check free of the usual cost of defence in depth — there is no second set + * of rules to drift. + * + * Drop, never repair: a line that fails is not truncated or masked, it is not shown. A missing + * line is invisible; a half-cleaned one is a liability. The one thing this module accepts from the + * sanitizer is its *cleaning* (NFC, folded whitespace), because that is what the service already + * put in the header. + * + * Pure, so `tests/unit/chat-render.test.ts` can drive the whole table. + */ +import { CHAT_MAX_TEXT_LENGTH, sanitizeChatText, validateDisplayName, type ChatLine } from '@flybrain/feed'; + +export { CHAT_MAX_TEXT_LENGTH }; + +/** True when a display name is one the page will render, by the bridge's own rule. */ +export function isSafeChatName(name: unknown): name is string { + return typeof name === 'string' && validateDisplayName(name) === name; +} + +/** The line's text as it will be rendered, or null when any rule refuses it. */ +export function safeChatText(text: unknown): string | null { + return sanitizeChatText(text); +} + +/** + * Re-validate one line. Returns a fresh object with only the fields the panel renders, or null. + * + * A fresh object, not the input: whatever else the service may have put on that line, nothing + * beyond `id`, `wallMs`, `by`, `text` and `bot` can reach a component from here. + */ +export function sanitizeChatLine(line: unknown): ChatLine | null { + if (typeof line !== 'object' || line === null) return null; + const candidate = line as Partial; + if (typeof candidate.id !== 'number' || !Number.isFinite(candidate.id)) return null; + if (!isSafeChatName(candidate.by)) return null; + const text = safeChatText(candidate.text); + if (text === null) return null; + return { + id: candidate.id, + wallMs: typeof candidate.wallMs === 'number' && Number.isFinite(candidate.wallMs) ? candidate.wallMs : 0, + by: candidate.by, + text, + ...(candidate.bot === true ? { bot: true } : {}), + }; +} + +/** + * The last `keep` renderable lines of a chat ring, oldest first. + * + * Oldest first because that is the reading order on screen and the newest line is the one that + * slides in at the bottom. The header already arrives oldest-first; sorting by id rather than + * trusting the order costs nothing and makes the panel independent of that promise. + */ +export function sanitizeChatRing(lines: unknown, keep: number): ChatLine[] { + if (!Array.isArray(lines)) return []; + const out: ChatLine[] = []; + for (const line of lines) { + const safe = sanitizeChatLine(line); + if (safe) out.push(safe); + } + out.sort((a, b) => a.id - b.id); + return out.length > keep ? out.slice(out.length - keep) : out; +} diff --git a/apps/stage/src/chat/types.ts b/apps/stage/src/chat/types.ts new file mode 100644 index 0000000..5ca3449 --- /dev/null +++ b/apps/stage/src/chat/types.ts @@ -0,0 +1,7 @@ +/** + * `ChatLine`, from the protocol. + * + * This file declared the shape locally while `header.chat` was in flight on another branch; the + * field has landed, so it is a re-export and the page has one definition of a chat line again. + */ +export type { ChatLine } from '@flybrain/feed'; diff --git a/apps/stage/src/components/ui/badge.tsx b/apps/stage/src/components/ui/badge.tsx new file mode 100644 index 0000000..2a7c426 --- /dev/null +++ b/apps/stage/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +/** + * A chip. + * + * `docs/design/gameboy-theme.md`: "Chips (buttons, mode) are boxes with the same double frame, + * 4 px corner cut instead of radius." `.chip-cut` (`src/theme/panels.css`) carries the cut and both + * lines of the frame; the frame is 2 px + 2 px rather than the panels' 4 + 2, because the title + * strip is 40 px and 27 px of type inside a 6 px frame does not fit in it. + * + * No `font-semibold`: neither face on the page has a bold, and a synthetic one at the body floor is + * a smear. A chip that needs emphasis takes the accent border and the accent ink, which every + * variant but `secondary` already does. + */ +const badgeVariants = cva( + 'chip-cut inline-flex items-center justify-center gap-1 px-2 py-0.5 whitespace-nowrap uppercase tracking-[0.06em]', + { + variants: { + variant: { + default: 'border-accent bg-transparent text-accent', + secondary: 'border-bezel bg-bg-2 text-ink-1', + warn: 'border-warn bg-transparent text-warn', + alarm: 'border-alarm bg-alarm text-bg-0', + ok: 'border-ok bg-transparent text-ok', + outline: 'border-ink-2 bg-transparent text-ink-1', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span'; + return ; +} + +export { Badge, badgeVariants }; diff --git a/apps/stage/src/components/ui/card.tsx b/apps/stage/src/components/ui/card.tsx new file mode 100644 index 0000000..f61be0f --- /dev/null +++ b/apps/stage/src/components/ui/card.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/stage/src/components/ui/progress.tsx b/apps/stage/src/components/ui/progress.tsx new file mode 100644 index 0000000..95637df --- /dev/null +++ b/apps/stage/src/components/ui/progress.tsx @@ -0,0 +1,36 @@ +import * as ProgressPrimitive from '@radix-ui/react-progress'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +/** + * shadcn/ui Progress. The indicator animates with `transform: translateX`, which stays on the + * compositor — the one requirement the broadcast page puts on it (design A3: bars never touch + * layout, and nothing animated carries a shadow or a blur). + */ +function Progress({ + className, + value, + indicatorClassName, + ...props +}: React.ComponentProps & { indicatorClassName?: string }) { + return ( + + + + ); +} + +export { Progress }; diff --git a/apps/stage/src/components/ui/separator.tsx b/apps/stage/src/components/ui/separator.tsx new file mode 100644 index 0000000..6a0d61b --- /dev/null +++ b/apps/stage/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +/** + * shadcn/ui Separator, thickened to 2 px: x264 at 3000 kbps erases 1 px hairlines (the audit + * finding), so the broadcast page has no hairlines anywhere. + */ +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/stage/src/feed/decode.ts b/apps/stage/src/feed/decode.ts new file mode 100644 index 0000000..2b35d3b --- /dev/null +++ b/apps/stage/src/feed/decode.ts @@ -0,0 +1,85 @@ +/** + * Thin adapter over `@flybrain/feed`'s `decodeSnapshot`: one wire message in, typed views out. + * + * Two things this layer exists for, both of them sharp edges: + * + * 1. **Alignment.** The wire format is `u32 headerLength | header JSON | attachments...`, and the + * header JSON length is arbitrary, so an attachment's byte offset is arbitrary too. A + * `Float32Array` view needs a 4-byte-aligned offset, so the audio attachment is copied when it + * is not aligned. Skipping this check gives you a `RangeError` at some random snapshot hours + * into a broadcast. + * 2. **Shape.** A snapshot whose `frame` is not 160x144 RGBA, or whose `spikes` bitset is not the + * dataset's size, is dropped as a decode error rather than painted as garbage. The page shows + * the stale banner instead, which is the honest failure. + */ +import { FRAME_HEIGHT, FRAME_WIDTH, decodeSnapshot, type FeedHeader } from '@flybrain/feed'; + +/** Bytes in a `frame` attachment: 160 x 144 RGBA. */ +export const FRAME_BYTES = FRAME_WIDTH * FRAME_HEIGHT * 4; + +/** One decoded snapshot: the header plus whichever attachments came with it. */ +export interface DecodedSnapshot { + header: FeedHeader; + /** 160x144 RGBA, or null when the snapshot carried no frame. */ + frame: Uint8Array | null; + /** Interleaved stereo f32 at 48 kHz, or null. Always a copy, so it can be transferred. */ + audio: Float32Array | null; + /** Spike bitset, bit `i` set when neuron `i` fired, or null. A zero-copy view. */ + spikes: Uint8Array | null; +} + +/** Thrown when a message decodes but does not describe a snapshot this page can paint. */ +export class SnapshotShapeError extends Error { + constructor(message: string) { + super(message); + this.name = 'SnapshotShapeError'; + } +} + +/** + * Decode one binary feed message. + * + * `expectedSpikeBytes` is `ceil(neurons / 8)` once the dataset is known; pass 0 before then to + * accept any size (the page paints the game and the readouts long before the brain map's worker + * has finished loading positions). + */ +export function decodeFeedMessage(bytes: Uint8Array, expectedSpikeBytes = 0): DecodedSnapshot { + const { header, attachments } = decodeSnapshot(bytes); + + const frameBytes = attachments.get('frame') ?? null; + if (frameBytes && frameBytes.byteLength !== FRAME_BYTES) { + throw new SnapshotShapeError( + `frame attachment is ${frameBytes.byteLength} bytes, expected ${FRAME_BYTES} (${FRAME_WIDTH}x${FRAME_HEIGHT} RGBA)`, + ); + } + + const spikeBytes = attachments.get('spikes') ?? null; + if (spikeBytes && expectedSpikeBytes > 0 && spikeBytes.byteLength !== expectedSpikeBytes) { + throw new SnapshotShapeError( + `spikes bitset is ${spikeBytes.byteLength} bytes, expected ${expectedSpikeBytes}`, + ); + } + + return { + header, + frame: frameBytes, + audio: toFloat32(attachments.get('audio')), + spikes: spikeBytes, + }; +} + +/** + * Copy an audio attachment into a `Float32Array`. + * + * Always a copy: the caller transfers it to the AudioWorklet, and a view into a shared message + * buffer cannot be transferred without taking the frame and spikes with it. + */ +function toFloat32(bytes: Uint8Array | undefined): Float32Array | null { + if (!bytes) return null; + if (bytes.byteLength % 4 !== 0) { + throw new SnapshotShapeError(`audio attachment is ${bytes.byteLength} bytes, not a whole number of f32 samples`); + } + const copy = new Float32Array(bytes.byteLength / 4); + new Uint8Array(copy.buffer).set(bytes); + return copy; +} diff --git a/apps/stage/src/feed/fixture.ts b/apps/stage/src/feed/fixture.ts new file mode 100644 index 0000000..2e89cac --- /dev/null +++ b/apps/stage/src/feed/fixture.ts @@ -0,0 +1,238 @@ +/** + * Player mode: replay a recorded `.flyfeed` with no service running (design A7). + * + * The timeline comes from the recording itself — every snapshot header carries `wallMs` — so one + * affine map does all the work: + * + * clock(record) = baseClock + (record.wallMs - firstRecord.wallMs) + * + * Seeking to `?t=95` is then not a special case: set `baseClock = now - 95_000` and pump. Every + * snapshot up to that point is ingested against its own virtual clock value, so the ticker's + * dwell timers, the button afterglow and the moment overlay all end up in exactly the state they + * would have been in had the page watched those 95 seconds live. Intermediate snapshots are + * ingested `silent`, so the catch-up neither paints 2,850 frames nor queues 95 seconds of audio. + * + * The same catch-up path covers a slow frame during normal playback, which is why there is no + * separate "we fell behind" branch. + */ +import { isGzip, iterateFlyfeedRecords, readFlyfeedManifest, type FlyfeedManifest } from '@flybrain/feed'; +import { decodeFeedMessage, type DecodedSnapshot } from './decode'; +import type { FeedIngest } from './store'; +import type { FeedSource } from './source'; + +export interface FixturePlayerOptions { + /** Seconds to seek to before the first paint. */ + seekSeconds?: number | null; + /** False to hold on the seek target instead of playing (what a screenshot wants). */ + autoplay?: boolean; + /** Restart from the beginning when the recording ends. */ + loop?: boolean; + /** Bytes of the spikes bitset to require, or 0 to accept any. */ + expectedSpikeBytes?: number; +} + +/** + * Slack on the "is this snapshot due yet?" comparison. + * + * `baseClock = nowMs - seek` and then `dueAt = baseClock + seek` is not exactly `nowMs` in + * IEEE 754, so without slack a seek lands on snapshot 2850 or 2851 depending on the fractional + * part of `performance.now()` — which makes a screenshot non-deterministic in a way that is + * almost impossible to see. Half a millisecond is nothing against a 33 ms frame. + */ +const DUE_EPSILON_MS = 0.5; + +export class FixturePlayer implements FeedSource { + private readonly url: string; + private readonly ingest: FeedIngest; + private readonly options: Required; + + private messages: Uint8Array[] = []; + private manifestValue: FlyfeedManifest | null = null; + private index = 0; + private firstWallMs = 0; + private baseClock = 0; + private paused = false; + /** Virtual clock of the last ingested snapshot, used while paused. */ + private frozenClock: number | null = null; + /** Decoded one record ahead, so `pump` can ask when the next snapshot is due. */ + private next: DecodedSnapshot | null = null; + private started = false; + + constructor(url: string, ingest: FeedIngest, options: FixturePlayerOptions = {}) { + this.url = url; + this.ingest = ingest; + this.options = { + seekSeconds: options.seekSeconds ?? null, + autoplay: options.autoplay ?? true, + loop: options.loop ?? true, + expectedSpikeBytes: options.expectedSpikeBytes ?? 0, + }; + } + + /** The recording's manifest, once loaded. */ + manifest(): FlyfeedManifest | null { + return this.manifestValue; + } + + /** Length of the recording in ms, from the manifest. */ + durationMs(): number { + return this.manifestValue?.durationMs ?? 0; + } + + async start(): Promise { + const bytes = await fetchFixture(this.url); + const { manifest, bodyOffset } = readFlyfeedManifest(bytes); + this.manifestValue = manifest; + this.messages = [...iterateFlyfeedRecords(bytes, bodyOffset)]; + if (this.messages.length === 0) throw new Error(`fixture ${this.url} holds no snapshots`); + + const first = this.firstDecodable(); + if (first === null) { + throw new Error(`fixture ${this.url} holds no snapshot this page can decode`); + } + this.firstWallMs = first; + this.started = true; + this.rewind(performance.now()); + } + + /** + * The clock to use while held on a seek target: the virtual time of the last snapshot + * ingested, so nothing that depends on elapsed time keeps moving under a screenshot. + */ + clock(nowMs: number): number { + return this.paused && this.frozenClock !== null ? this.frozenClock : nowMs; + } + + /** True while held on a seek target (`?t=` without `&play=1`). */ + isPaused(): boolean { + return this.paused; + } + + pump(nowMs: number): void { + if (!this.started || this.paused) return; + + // Catch up: ingest everything due, painting and sounding only the last of a burst. Each + // record is decoded exactly once, one ahead, so "is the next one also due?" is free. + for (let guard = 0; guard < 200_000; guard++) { + if (!this.next) { + this.next = this.advance(); + if (!this.next) { + // End of the recording. Looping restarts from the top *without* re-applying the seek + // target: a `?t=` past the end would otherwise restart, run out, and restart again + // forever (it did, until this test). + if (!this.options.loop) return; + this.restart(nowMs); + this.next = this.advance(); + if (!this.next) return; + } + } + + const dueAt = this.dueAt(this.next); + if (dueAt > nowMs + DUE_EPSILON_MS) return; + + const snapshot = this.next; + this.next = this.advance(); + const followerDue = this.next !== null && this.dueAt(this.next) <= nowMs + DUE_EPSILON_MS; + + this.frozenClock = dueAt; + this.ingest.ingest(snapshot, dueAt, { silent: followerDue }); + } + } + + private dueAt(snapshot: DecodedSnapshot): number { + return this.baseClock + (snapshot.header.wallMs - this.firstWallMs); + } + + /** Decode forward to the next usable record, or null at the end of the recording. */ + private advance(): DecodedSnapshot | null { + while (this.index < this.messages.length) { + const decoded = this.decodeAt(this.index); + this.index += 1; + if (decoded) return decoded; + } + return null; + } + + stop(): void { + this.started = false; + this.messages = []; + this.next = null; + } + + /** Jump to `seconds` into the recording, then hold or play per `autoplay`. */ + seek(seconds: number, nowMs = performance.now()): void { + this.restart(nowMs - seconds * 1000); + this.paused = false; + this.pump(nowMs); + this.paused = !this.options.autoplay; + } + + /** Rewind to the top of the recording. Does not pump. */ + private restart(baseClock: number): void { + this.index = 0; + this.next = null; + this.frozenClock = null; + this.ingest.reset(); + this.baseClock = baseClock; + } + + private rewind(nowMs: number): void { + const seek = this.options.seekSeconds; + if (seek !== null && seek > 0) { + this.seek(seek, nowMs); + return; + } + this.restart(nowMs); + this.paused = !this.options.autoplay; + } + + /** + * `wallMs` of the first record this page can decode, or null if there is none. + * + * Scanned rather than assumed: a recording of a real service can start with a snapshot this + * build rejects, and the timeline has to come from a record that actually decodes. + */ + private firstDecodable(): number | null { + for (let index = 0; index < this.messages.length; index++) { + const decoded = this.decodeAt(index); + if (decoded) return decoded.header.wallMs; + } + return null; + } + + /** + * Decode one record, counting and skipping a bad one. + * + * A fixture is a recording of a real service, so it can contain a message the current page + * rejects (a dataset with a different neuron count, say). Skipping keeps the recording playable + * and the count visible in the honesty panel. + */ + private decodeAt(index: number): DecodedSnapshot | null { + const message = this.messages[index]; + if (!message) return null; + try { + return decodeFeedMessage(message, this.options.expectedSpikeBytes); + } catch { + this.ingest.noteDecodeError(); + return null; + } + } +} + +/** + * Fetch a `.flyfeed` or `.flyfeed.gz`. + * + * The `.gz` path inflates with `DecompressionStream`, the same way `loadCompressed` handles the + * dataset's `.binz` artifacts, and for the same reason: the server must serve the bytes raw + * without `content-encoding`, so the page controls when inflation happens. + */ +export async function fetchFixture(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`unable to load fixture ${url}: HTTP ${response.status}`); + + const raw = new Uint8Array(await response.arrayBuffer()); + if (!isGzip(raw)) return raw; + + const stream = new Blob([raw as BlobPart]).stream().pipeThrough(new DecompressionStream('gzip')); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} diff --git a/apps/stage/src/feed/socket.ts b/apps/stage/src/feed/socket.ts new file mode 100644 index 0000000..f87feae --- /dev/null +++ b/apps/stage/src/feed/socket.ts @@ -0,0 +1,135 @@ +/** + * Live mode: the real WebSocket client (design A6). + * + * Implemented and unit-tested now, but only reachable with `?mode=live`, because S3 (wiring the + * page to a running flysim) waits on the theme being chosen from the mockups. What it does: + * + * - sends exactly one message, ever: the `hello` with its `wants` list. The page is a display. + * - reconnects with exponential backoff and jitter, forever, because a 24/7 stream outlives + * every service restart underneath it. + * - counts, rather than hides, the things that go wrong: a decode failure increments a counter + * the honesty panel shows, and 2 s of silence raises the STALE FEED banner (the banner is + * driven by the store's clock, so it appears whether the socket noticed or not — a half-open + * TCP connection is exactly the case where the socket does *not* notice). + */ +import { FEED_PROTOCOL, type AttachmentKind, type ClientHello } from '@flybrain/feed'; +import { decodeFeedMessage } from './decode'; +import type { FeedIngest } from './store'; +import type { FeedSource } from './source'; +import { useStage } from './store'; + +export interface FeedSocketOptions { + wants?: AttachmentKind[]; + /** First retry delay. Doubles per attempt up to `maxBackoffMs`. */ + backoffMs?: number; + maxBackoffMs?: number; + expectedSpikeBytes?: number; + /** Injected for tests; defaults to the platform `WebSocket`. */ + factory?: (url: string) => WebSocket; +} + +export class FeedSocket implements FeedSource { + private readonly url: string; + private readonly ingest: FeedIngest; + private readonly wants: AttachmentKind[]; + private readonly backoffMs: number; + private readonly maxBackoffMs: number; + private readonly expectedSpikeBytes: number; + private readonly factory: (url: string) => WebSocket; + + private socket: WebSocket | null = null; + private retries = 0; + private timer: ReturnType | null = null; + private closed = false; + + constructor(url: string, ingest: FeedIngest, options: FeedSocketOptions = {}) { + this.url = url; + this.ingest = ingest; + this.wants = options.wants ?? ['frame', 'audio', 'spikes']; + this.backoffMs = options.backoffMs ?? 250; + this.maxBackoffMs = options.maxBackoffMs ?? 5000; + this.expectedSpikeBytes = options.expectedSpikeBytes ?? 0; + this.factory = options.factory ?? ((target) => new WebSocket(target)); + } + + async start(): Promise { + this.closed = false; + this.open(); + } + + /** Nothing to pump: messages arrive by event. Kept so both sources share one interface. */ + pump(): void {} + + stop(): void { + this.closed = true; + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + const socket = this.socket; + this.socket = null; + if (socket) { + socket.onopen = null; + socket.onmessage = null; + socket.onclose = null; + socket.onerror = null; + socket.close(); + } + } + + /** Delay before retry `attempt` (0-based), with jitter so restarts do not synchronise. */ + backoffFor(attempt: number): number { + const base = Math.min(this.maxBackoffMs, this.backoffMs * 2 ** attempt); + return Math.round(base * (0.75 + Math.random() * 0.5)); + } + + private open(): void { + if (this.closed) return; + useStage.getState().setConnection('connecting'); + + let socket: WebSocket; + try { + socket = this.factory(this.url); + } catch { + this.scheduleReconnect(); + return; + } + socket.binaryType = 'arraybuffer'; + this.socket = socket; + + socket.onopen = () => { + this.retries = 0; + useStage.getState().setConnection('open'); + const hello: ClientHello = { protocol: FEED_PROTOCOL as 1, client: 'stage', wants: this.wants }; + socket.send(JSON.stringify(hello)); + }; + + socket.onmessage = (event: MessageEvent) => { + const data = event.data; + if (!(data instanceof ArrayBuffer)) return; + try { + this.ingest.ingest(decodeFeedMessage(new Uint8Array(data), this.expectedSpikeBytes), performance.now()); + } catch { + this.ingest.noteDecodeError(); + } + }; + + socket.onerror = () => { + // `onclose` always follows, and that is where the retry lives. + }; + + socket.onclose = () => { + if (this.socket === socket) this.socket = null; + useStage.getState().setConnection('closed'); + this.scheduleReconnect(); + }; + } + + private scheduleReconnect(): void { + if (this.closed) return; + const delay = this.backoffFor(this.retries); + this.retries += 1; + this.timer = setTimeout(() => { + this.timer = null; + this.open(); + }, delay); + } +} diff --git a/apps/stage/src/feed/source.ts b/apps/stage/src/feed/source.ts new file mode 100644 index 0000000..092d59f --- /dev/null +++ b/apps/stage/src/feed/source.ts @@ -0,0 +1,26 @@ +/** + * What the page needs from a feed, whichever end it comes from. + * + * Both implementations are pumped by the one rAF loop rather than owning a timer, so the whole + * page runs on a single clock: seeking a fixture, catching up after a slow frame and painting all + * read the same `nowMs`. + */ + +export interface FeedSource { + /** Open the socket or load the fixture. Resolves once the first snapshot could arrive. */ + start(): Promise; + /** Called once per animation frame with the paint clock. */ + pump(nowMs: number): void; + /** Close everything. Safe to call twice. */ + stop(): void; + /** + * The clock the rest of the page should use this frame. + * + * Normally `nowMs`. A fixture held on a seek target (`?t=` without `&play=1`) returns the + * virtual time of the snapshot it stopped on, which freezes the ticker's dwell timers, the + * button afterglow, the moment overlay and the stale check — everything whose state is a + * function of elapsed time. That is what makes a screenshot of a seek reproducible instead of + * depending on how long the test took to get around to taking it. + */ + clock?(nowMs: number): number; +} diff --git a/apps/stage/src/feed/store.ts b/apps/stage/src/feed/store.ts new file mode 100644 index 0000000..66894a3 --- /dev/null +++ b/apps/stage/src/feed/store.ts @@ -0,0 +1,514 @@ +/** + * The decoupling that makes a 30 Hz feed paintable at 60 Hz without React in the way (design A6). + * + * Three clocks, on purpose: + * - **ingest, 30 Hz.** `ingest()` writes typed arrays and scalars into `hot`, a plain mutable + * object with no subscribers. Nothing re-renders. + * - **paint, 60 Hz.** The rAF loop reads `hot` directly and repaints only dirty surfaces. + * - **React, 4 Hz.** `commit()` copies the handful of values the DOM shows into a zustand store, + * coalesced to 250 ms, plus immediate pushes for the ticker and moment overlay, which are + * event-driven and must not wait for the next commit tick. + * + * Every method takes `nowMs` rather than calling `performance.now()`, because the fixture player + * seeks by replaying the recording against virtual time: with an injected clock, a seek to t=95 + * leaves the ticker, the afterglow and the moment overlay in exactly the state they would have + * been in had the page watched those 95 seconds live. + */ +import { GAMEBOY_BUTTONS, GAMEBOY_BUTTON_BITS } from '@flybrain/brain'; +import { + paletteView, + type FeedHeader, + type FeedMilestone, + type FeedStatus, + type GameMode, + type GameScene, + type MacroMode, + type PaletteCell, + type RewardKind, +} from '@flybrain/feed'; +import { create } from 'zustand'; +import { sanitizeChatRing } from '@/chat/sanitize'; +import type { ChatLine } from '@/chat/types'; +import type { GameConfig } from '@/games'; +import { CircuitScale, RunningMedian } from '@/lib/circuit-scale'; +import { CHAT_LINES } from '@/lib/geometry'; +import { BAR_ROLES, CIRCUIT_GROUPS, MACRO_BAR_ROLES, MACRO_CIRCUIT } from '@/lib/labels'; +import { TickerQueue, type TickerItem } from '@/lib/ticker'; +import type { MotionEngine } from '@/motion/engine'; +import type { MomentSnapshot } from '@/motion/moments'; +import type { RailSignals } from '@/motion/rail-signals'; +import type { DecodedSnapshot } from './decode'; + +/** Silence after which the page stops claiming the numbers are live (design A6). */ +export const STALE_AFTER_MS = 2000; + +/** Cold-store commit interval. */ +export const COMMIT_MS = 250; + +/** Afterglow after the falling edge of a button (design A3). */ +export const AFTERGLOW_MS = 250; + +/** Peak-hold decay on a circuit bar. */ +export const PEAK_HOLD_MS = 600; + +/** Per-button edge state the paint loop turns into an afterglow class. */ +export interface ButtonState { + /** True while the mask bit is set. */ + down: boolean; + /** Clock value of the last rising edge, or -Infinity. */ + downAtMs: number; + /** Clock value of the last falling edge, or -Infinity. */ + upAtMs: number; +} + +/** + * The hot store. Mutable, unobserved, read by the paint loop every frame. + * + * Typed arrays here are views into the last message (or, for audio, owned copies), so nothing is + * allocated per snapshot beyond the audio chunk. + */ +export interface HotStore { + header: FeedHeader | null; + /** Clock value of the last accepted snapshot; drives the stale banner. */ + lastSnapshotMs: number; + /** Monotonic count of accepted snapshots, for the dropped-frame check. */ + accepted: number; + /** Snapshots whose `seq` skipped, i.e. the service dropped them rather than queueing. */ + gaps: number; + /** Decode failures since load. Surfaced in the honesty panel rather than hidden. */ + decodeErrors: number; + + frame: Uint8Array | null; + frameDirty: boolean; + spikes: Uint8Array | null; + spikesDirty: boolean; + + buttons: number; + buttonStates: Record; + + rates: Record; + /** Peak-hold value per role, decayed by the paint loop. */ + peaks: Record; + /** + * Per-role adaptive reference (Hz) each circuit bar's fill scales against — see + * `src/lib/circuit-scale.ts`. Updated once per snapshot, not once per animation frame, so a + * fixture's silent seek catch-up (`src/feed/fixture.ts`) builds the same reference a viewer + * watching live would have settled into. + */ + circuitReferenceHz: Record; + /** + * Per-role running median (Hz): the display's stand-in "resting level" for the threshold tick, + * since the feed does not carry the decoder's real calibration baseline (`docs/readout.md`). + */ + circuitMedianHz: Record; + populationRate: number; + /** + * The same adaptive reference, for the whole-brain rate: the fly's breathing and wing tremor + * scale against it rather than against a hard-coded Hz (`docs/design/fly-avatar.md`). + */ + populationReferenceHz: number; + /** Last reported spike count from a snapshot that actually carried the bitset. */ + spikeCount: number; + + /** Audio chunks waiting for the engine. Drained, not accumulated. */ + audioQueue: Float32Array[]; +} + +function freshButtonStates(): Record { + const states: Record = {}; + for (const button of GAMEBOY_BUTTONS) { + states[button] = { down: false, downAtMs: Number.NEGATIVE_INFINITY, upAtMs: Number.NEGATIVE_INFINITY }; + } + return states; +} + +/** Seed Hz per bar role, from the group table (`src/lib/labels.ts`), for the adaptive trackers. */ +const CIRCUIT_SEED_HZ = new Map(); +for (const group of CIRCUIT_GROUPS) { + for (const bar of group.bars) CIRCUIT_SEED_HZ.set(bar.role, group.fullScaleHz); +} +// All 31 macro channels are tracked too, though only the scene's bound ones have a rate bar at any +// moment (`docs/design/macros.md` section 12): a channel the scene binds again a minute later has +// to come back with the reference it had, not with a cold one. +for (const role of MACRO_BAR_ROLES) CIRCUIT_SEED_HZ.set(role, MACRO_CIRCUIT.fullScaleHz); + +/** Every role with an adaptive reference: the fixed bars plus the macro channels. */ +const SCALED_ROLES: readonly string[] = [...BAR_ROLES, ...MACRO_BAR_ROLES]; + +function freshCircuitScales(): Map { + const scales = new Map(); + for (const role of SCALED_ROLES) scales.set(role, new CircuitScale(CIRCUIT_SEED_HZ.get(role) ?? 10)); + return scales; +} + +function freshCircuitMedians(): Map { + const medians = new Map(); + for (const role of SCALED_ROLES) medians.set(role, new RunningMedian(CIRCUIT_SEED_HZ.get(role) ?? 10)); + return medians; +} + +function freshCircuitReferenceHz(): Record { + const out: Record = {}; + for (const role of SCALED_ROLES) out[role] = CIRCUIT_SEED_HZ.get(role) ?? 10; + return out; +} + +/** Seed for the whole-brain rate's own envelope. A resting fly brain sits in this neighbourhood. */ +const POPULATION_SEED_HZ = 10; + +/** Module-singleton per-role trackers. Not part of `HotStore` itself: the paint loop only ever + * reads the plain Hz numbers those trackers publish into `hot.circuitReferenceHz` / + * `circuitMedianHz`, never the tracker instances. */ +let circuitScales = freshCircuitScales(); +let circuitMedians = freshCircuitMedians(); +let populationScale = new CircuitScale(POPULATION_SEED_HZ); + +/** The one hot store instance. Deliberately a module singleton: there is one stage per page. */ +export const hot: HotStore = { + header: null, + lastSnapshotMs: Number.NEGATIVE_INFINITY, + accepted: 0, + gaps: 0, + decodeErrors: 0, + frame: null, + frameDirty: false, + spikes: null, + spikesDirty: false, + buttons: 0, + buttonStates: freshButtonStates(), + rates: {}, + peaks: {}, + circuitReferenceHz: freshCircuitReferenceHz(), + circuitMedianHz: freshCircuitReferenceHz(), + populationRate: 0, + populationReferenceHz: POPULATION_SEED_HZ, + spikeCount: 0, + audioQueue: [], +}; + +/** What React renders. Nothing here changes more than 4 times a second except ticker/moment. */ +export interface ColdState { + connection: 'idle' | 'connecting' | 'open' | 'closed'; + status: FeedStatus; + stale: boolean; + mode: GameMode; + realtimeFactor: number; + runSeconds: number; + uptimeSeconds: number; + populationRate: number; + spikeCount: number; + badges: number; + uniqueLocations: number; + rewardTotal: number; + rewardCounts: Record | null; + semanticRewards: boolean; + /** + * The scene's macros, as `paletteView` normalizes them (`docs/design/macros.md` sections 6, + * 12 and 14): one cell per macro type in the contract's order, bound or not. + * + * Here rather than in `hot` because it is React's to draw: the rows change once per scene, not + * once per snapshot, and the SENSES panel's MACROS row is drawn from the same list. What the + * *paint loop* needs — which cell is lit, what its outcome was, and each channel's rate — it + * reads straight off `hot`, the same split the button row's afterglow uses. + */ + scene: GameScene; + macroMode: MacroMode; + palette: readonly PaletteCell[]; + learning: { enabled: boolean; updates: number; changed: number; synapses: number; signal: number }; + /** The header's milestone object verbatim, so a protocol field cannot go missing here. */ + milestone: FeedMilestone; + sugar: { active: boolean; remainingMs: number; cooldownMs: number; lastBy: string | null; todayCount: number }; + ticker: readonly TickerItem[]; + /** The moment on stage, as the caption band renders it (`src/motion/moments.ts`). */ + moment: MomentSnapshot['active']; + /** + * The last seven chat lines, re-validated on render. + * + * Empty both when the feed carries no `chat` (an older service, or the kill switch) and when + * every line it carried failed re-validation, and the panel renders nothing in either case — + * which is the same on-screen outcome and deliberately indistinguishable. + */ + chat: readonly ChatLine[]; + /** Feed gaps and decode errors, shown in the honesty panel. */ + health: { gaps: number; decodeErrors: number }; + /** + * Set once the dataset metadata has loaded. + * + * `neurons` and `edges` are `meta.json`'s own counts and `name` its version (`v783`). The + * DESCRIBE tab quotes all three (`src/panels/tabs/DescribeTab.tsx`), which is why `edges` is + * here: the doc's rule is that a number on that tab comes from the dataset rather than from a + * sentence someone typed, so the card cannot outlive a connectome rebuild. + */ + dataset: { neurons: number; edges: number; name: string } | null; + + setConnection: (connection: ColdState['connection']) => void; + setDataset: (dataset: { neurons: number; edges: number; name: string }) => void; +} + +const INITIAL_COLD = { + connection: 'idle', + status: 'booting', + stale: false, + mode: 'BOOT', + realtimeFactor: 0, + runSeconds: 0, + uptimeSeconds: 0, + populationRate: 0, + spikeCount: 0, + badges: 0, + uniqueLocations: 0, + rewardTotal: 0, + rewardCounts: null, + semanticRewards: true, + scene: 'unknown', + macroMode: 'raw', + palette: paletteView(null).cells, + learning: { enabled: false, updates: 0, changed: 0, synapses: 0, signal: 0 }, + milestone: { rank: 0, label: '', next: '', sinceSeconds: 0, attempts: 0 } as FeedMilestone, + sugar: { active: false, remainingMs: 0, cooldownMs: 0, lastBy: null, todayCount: 0 }, + ticker: [] as readonly TickerItem[], + moment: null as MomentSnapshot['active'], + chat: [] as readonly ChatLine[], + health: { gaps: 0, decodeErrors: 0 }, + dataset: null, +} satisfies Omit; + +export const useStage = create()((set) => ({ + ...INITIAL_COLD, + setConnection: (connection) => set({ connection }), + setDataset: (dataset) => set({ dataset }), +})); + +/** + * Ingest and commit. One instance, created by `App` once the game config is known. + * + * `silent` ingestion is what a seek uses: state advances, the ticker and moment overlay evolve, + * but audio is not queued (nobody wants 95 seconds of fast-forwarded sound) and no surface is + * marked dirty until the last snapshot of the seek. + */ +export class FeedIngest { + private readonly ticker: TickerQueue; + private lastSeq = -1; + private lastCommitMs = Number.NEGATIVE_INFINITY; + private lastTickerVersion = -1; + private lastMomentId = 0; + private lastCircuitMedianMs: number | null = null; + + /** + * The motion engine and the rail's derived signals, if they are wired. + * + * Both optional so the store stays testable on its own, and a seam so every decision about + * *what a moment means* lives in `src/motion/` rather than in the ingest path: this file hands + * over the snapshot and its clock and asks nothing else. + */ + constructor( + game: GameConfig, + private readonly motion: MotionEngine | null = null, + private readonly signals: RailSignals | null = null, + ) { + this.ticker = new TickerQueue(game); + } + + /** Reset every derived piece of state. Used when a fixture seeks or loops. */ + reset(): void { + this.lastSeq = -1; + // Both commit gates reopen: a seek replays history against a virtual clock, and the next + // commit has to happen whatever that clock says relative to the last one. + this.lastCommitMs = Number.NEGATIVE_INFINITY; + this.lastTickerVersion = -1; + this.lastMomentId = 0; + this.motion?.reset(); + this.signals?.reset(); + hot.buttonStates = freshButtonStates(); + hot.audioQueue.length = 0; + hot.gaps = 0; + // A fixture seek replays from the top against a virtual clock (`src/feed/fixture.ts`), so + // the circuit bar trackers reset with everything else rather than carrying a stale reference + // in from whatever the page had loaded before. + circuitScales = freshCircuitScales(); + circuitMedians = freshCircuitMedians(); + populationScale = new CircuitScale(POPULATION_SEED_HZ); + hot.circuitReferenceHz = freshCircuitReferenceHz(); + hot.circuitMedianHz = freshCircuitReferenceHz(); + hot.populationReferenceHz = POPULATION_SEED_HZ; + this.lastCircuitMedianMs = null; + } + + /** Count a message that failed to decode. The page keeps running; the honesty panel says so. */ + noteDecodeError(): void { + hot.decodeErrors += 1; + } + + ingest(snapshot: DecodedSnapshot, nowMs: number, options: { silent?: boolean } = {}): void { + const { header } = snapshot; + const silent = options.silent ?? false; + + if (this.lastSeq >= 0 && header.seq > this.lastSeq + 1) hot.gaps += header.seq - this.lastSeq - 1; + this.lastSeq = header.seq; + + hot.header = header; + hot.lastSnapshotMs = nowMs; + hot.accepted += 1; + hot.rates = header.rates; + hot.populationRate = header.populationRate; + + // A snapshot without the spikes attachment reports spikeCount 0 per the contract, so the + // readout holds the last real measurement rather than blinking to zero (the recorder strides + // spikes down to 10 Hz in the committed fixtures). + if (snapshot.spikes) { + hot.spikes = snapshot.spikes; + hot.spikesDirty = !silent; + hot.spikeCount = header.spikeCount; + } + + if (snapshot.frame) { + hot.frame = snapshot.frame; + hot.frameDirty = !silent; + } + + if (snapshot.audio && !silent) hot.audioQueue.push(snapshot.audio); + + this.updateButtons(header.buttons, nowMs); + this.updatePeaks(header.rates, nowMs); + this.updateCircuitScales(header.rates, nowMs); + populationScale.observe(header.populationRate, nowMs); + hot.populationReferenceHz = populationScale.referenceHz; + + for (const event of header.events) this.ticker.push(event, nowMs); + // The engine reads the events *and* the header deltas itself (`TriggerMapper`), so it is given + // the whole snapshot rather than a replay of the loop above. + this.motion?.ingest(header, nowMs); + this.signals?.observe(header, nowMs); + this.ticker.tick(nowMs); + } + + /** Called by the paint loop every frame. Commits the cold store at most every `COMMIT_MS`. */ + commit(nowMs: number, force = false): void { + this.ticker.tick(nowMs); + this.decayPeaks(nowMs); + + const tickerChanged = this.ticker.version() !== this.lastTickerVersion; + // A clock that jumps *backwards* also means "commit now". A fixture held on a seek target + // freezes its clock at the virtual time of the snapshot it landed on, which is earlier than + // the real clock the loop was using before the seek finished loading — so a plain + // `elapsed >= COMMIT_MS` gate stays shut forever and the page renders its initial state. That + // is what the cold-open fixture caught: every readout stuck at zero on a feed that had + // already delivered sixteen snapshots. + const elapsed = nowMs - this.lastCommitMs; + const due = force || !(elapsed >= 0 && elapsed < COMMIT_MS); + + // A moment must reach the DOM the frame it starts, not up to 250 ms later: the caption band, + // the rail flash and the tab focus all key off it, and the SFX fires immediately. A rollback + // is the case that proves it — its ticker row is queued behind the dwell gate, so nothing + // else on this path would have opened the commit gate for it. + const moment = this.motion?.snapshot().active ?? null; + const momentChanged = (moment?.id ?? 0) !== this.lastMomentId; + + if (!due && !tickerChanged && !momentChanged) return; + + const header = hot.header; + const stale = header !== null && nowMs - hot.lastSnapshotMs > STALE_AFTER_MS; + const palette = paletteView(header); + + if (tickerChanged) this.lastTickerVersion = this.ticker.version(); + if (due) this.lastCommitMs = nowMs; + this.lastMomentId = moment?.id ?? 0; + + useStage.setState({ + ...(header + ? { + status: header.status, + mode: header.game.mode, + realtimeFactor: header.realtimeFactor, + runSeconds: header.runSeconds, + uptimeSeconds: header.uptimeSeconds, + populationRate: header.populationRate, + spikeCount: hot.spikeCount, + badges: header.game.badges, + uniqueLocations: header.game.uniqueLocations, + rewardTotal: header.game.rewardTotal, + rewardCounts: header.game.rewardCounts, + semanticRewards: header.game.semanticRewards, + scene: palette.scene, + macroMode: palette.mode, + palette: palette.cells, + learning: header.learning, + milestone: header.milestone, + sugar: header.sugar, + chat: sanitizeChatRing((header as FeedHeader & { chat?: unknown }).chat, CHAT_LINES), + } + : {}), + stale, + ticker: [...this.ticker.items()], + moment, + health: { gaps: hot.gaps, decodeErrors: hot.decodeErrors }, + }); + } + + /** The ticker, for tests and for the panel that needs the queued count. */ + queue(): TickerQueue { + return this.ticker; + } + + private updateButtons(mask: number, nowMs: number): void { + for (const button of GAMEBOY_BUTTONS) { + const bit = GAMEBOY_BUTTON_BITS[button]; + const down = (mask & bit) !== 0; + const state = hot.buttonStates[button]; + if (!state) continue; + if (down && !state.down) state.downAtMs = nowMs; + if (!down && state.down) state.upAtMs = nowMs; + state.down = down; + } + hot.buttons = mask; + } + + private updatePeaks(rates: Record, nowMs: number): void { + for (const [role, value] of Object.entries(rates)) { + const peak = hot.peaks[role]; + if (!peak || value >= peak.value) { + hot.peaks[role] = { value, atMs: nowMs }; + } + } + } + + /** Advance each bar role's adaptive reference and resting-level median by one snapshot. */ + private updateCircuitScales(rates: Record, nowMs: number): void { + for (const role of SCALED_ROLES) { + const value = rates[role] ?? 0; + + const scale = circuitScales.get(role); + if (scale) { + scale.observe(value, nowMs); + hot.circuitReferenceHz[role] = scale.referenceHz; + } + + const median = circuitMedians.get(role); + if (median) { + // The median only needs the *elapsed* time between snapshots, not the absolute virtual + // clock, so it advances correctly whether it is fed live snapshots roughly every 33 ms or + // a seek's silent catch-up stream. + const dtMs = this.lastCircuitMedianMs === null ? 0 : nowMs - this.lastCircuitMedianMs; + median.observe(value, dtMs); + hot.circuitMedianHz[role] = median.medianHz; + } + } + this.lastCircuitMedianMs = nowMs; + } + + /** Linear decay of the peak-hold tick so a spike stays visible after the value drops. */ + private decayPeaks(nowMs: number): void { + for (const [role, peak] of Object.entries(hot.peaks)) { + const age = nowMs - peak.atMs; + if (age <= 0) continue; + const current = hot.rates[role] ?? 0; + if (age >= PEAK_HOLD_MS) { + hot.peaks[role] = { value: current, atMs: nowMs }; + continue; + } + const decayed = peak.value + (current - peak.value) * (age / PEAK_HOLD_MS); + if (decayed <= current) hot.peaks[role] = { value: current, atMs: nowMs }; + else hot.peaks[role] = { value: decayed, atMs: peak.atMs }; + } + } +} diff --git a/apps/stage/src/fly/camera.ts b/apps/stage/src/fly/camera.ts new file mode 100644 index 0000000..f308752 --- /dev/null +++ b/apps/stage/src/fly/camera.ts @@ -0,0 +1,22 @@ +/** + * The one camera both renderers use, so the paper fly and the WebGL fly frame the same shot. + * + * Behind and above the fly at about 30 degrees, looking over its head toward the game screen — + * the design's words. There is no 3D Game Boy in this scene any more (the button row is its own + * plain strip, `src/panels/FlyStrip.tsx`), so the shot is the fly and the floor alone, pulled in + * closer than the old framing: same angle, same side, just nearer, which is what makes the fly + * "a little larger now that the strip is its own." + */ +import type { Vec3 } from './rig'; + +export const CAMERA = { + position: [0, 3.6, -4.7] as Vec3, + target: [0, 0.45, 0.8] as Vec3, + /** Vertical field of view, degrees. */ + fov: 25, + near: 0.1, + far: 40, +} as const; + +/** Floor plane the fly stands on, drawn as a quiet gradient rather than a lit surface. */ +export const FLOOR = { halfWidth: 12, nearZ: -6, farZ: 9 } as const; diff --git a/apps/stage/src/fly/drives.ts b/apps/stage/src/fly/drives.ts new file mode 100644 index 0000000..a1bca72 --- /dev/null +++ b/apps/stage/src/fly/drives.ts @@ -0,0 +1,96 @@ +/** + * Feed rates to fly drives. + * + * Every value the fly moves on is a fraction of that role's *own* running reference + * (`src/lib/circuit-scale.ts`), exactly as a circuit bar is: the page never sees the decoder's + * calibration baseline, and a fixed Hz ceiling is what pegged every bar at 100% on the first live + * run. The design says it in one line — "Every value is normalized the same way the circuits panel + * does (running reference per role), never hard-coded Hz" — and this file is the whole of it. + * + * The population rate gets the same treatment through its own reference, which `FeedIngest` + * advances per snapshot alongside the bar roles. + */ +import { GAMEBOY_BUTTONS } from '@flybrain/brain'; + +import { hot } from '@/feed/store'; +import { circuitFraction, DEFAULT_HEADROOM } from '@/lib/circuit-scale'; +import type { FlyDrives } from './rig'; + +/** Command roles in `GAMEBOY_BUTTONS` order: `command_0` is up, `command_7` is Select. */ +const COMMAND_ROLES = GAMEBOY_BUTTONS.map((_, index) => `command_${index}`); + +/** + * The wing/flight drive's source roles — the one table `docs/design/fly-avatar.md` asks this + * file to keep, so swapping the source later is a one-line change. + * + * The honest source is a `motor` role rate (110 neurons in `data/fafb-v783/meta.json`), but + * `docs/feed-protocol.md`'s `rates` does not carry `motor` today — only the tracked roles it + * lists, `command_0..7` among them. Those eight *are* descending motor commands (their neuron + * counts sum to exactly the dataset's `descending` total), so their sum stands in for the wing + * drive until the feed grows a `motor` rate. + * + * TODO(motor-in-feed): once `rates.motor` exists, change this to `['motor']`. `sumDrive` below + * scores a sum of one role the same way it scores eight, so nothing else in this file changes. + */ +export const WING_DRIVE_ROLES: readonly string[] = COMMAND_ROLES; + +/** + * What "resting" reads as on the circuit scale. + * + * A role sitting exactly at its own running reference scores `1 / headroom`, about 0.67 — that + * headroom is deliberate, so a burst still has somewhere to go. A *bar* can sit at two thirds + * forever and look right; a fly cannot. Left raw, the proboscis would be half out and the head + * half lit at all times, which is neither honest nor what the design asks for ("above resting it + * walks in place"; "idle: subtle breathing only"). + * + * So every drive is re-centred on that resting level: zero at or below its own recent normal, + * rising to one as the rate reaches the top of its own scale. It is the same running reference, + * read as a deviation rather than as a level. + */ +const RESTING = 1 / DEFAULT_HEADROOM; + +function aboveResting(fraction: number): number { + return fraction <= RESTING ? 0 : (fraction - RESTING) / (1 - RESTING); +} + +/** One role's rate as a 0..1 drive: how far above its own resting level it is running. */ +function drive(role: string): number { + const reference = hot.circuitReferenceHz[role]; + if (reference === undefined) return 0; + return aboveResting(circuitFraction(hot.rates[role] ?? 0, reference)); +} + +/** + * Several roles' rates and references summed before scoring, so the group reads as one drive + * against the sum of its own recent normals — the same formula `drive` uses for a single role, + * which is what makes `WING_DRIVE_ROLES` a one-line swap later. + */ +function sumDrive(roles: readonly string[]): number { + let rateSum = 0; + let referenceSum = 0; + for (const role of roles) { + rateSum += hot.rates[role] ?? 0; + referenceSum += hot.circuitReferenceHz[role] ?? 0; + } + if (referenceSum <= 0) return 0; + return aboveResting(circuitFraction(rateSum, referenceSum)); +} + +/** + * Read one frame of drives out of the hot store. + * + * No button state to time here any more — the button row's own afterglow is painted straight + * from `hot.buttonStates` in `src/App.tsx`, off the page's own clock. + */ +export function readFlyDrives(sugarPulse: number, out: FlyDrives): FlyDrives { + out.forward = drive('forward'); + out.backward = drive('backward'); + out.steerLeft = drive('steer_left'); + out.steerRight = drive('steer_right'); + out.wing = sumDrive(WING_DRIVE_ROLES); + out.proboscis = drive('proboscis'); + out.reward = drive('reward_pam'); + out.population = aboveResting(circuitFraction(hot.populationRate, hot.populationReferenceHz)); + out.sugarPulse = sugarPulse; + return out; +} diff --git a/apps/stage/src/fly/index.ts b/apps/stage/src/fly/index.ts new file mode 100644 index 0000000..4be215b --- /dev/null +++ b/apps/stage/src/fly/index.ts @@ -0,0 +1,74 @@ +/** + * The fly strip's renderer, and the `?fly=` switch that picks one. + * + * Two renderers draw the same rig (`src/fly/rig.ts`): + * + * - `webgl` — three.js, one WebGL context, low-poly meshes with flat shading. The design's + * primary, and the default. + * - `paper` — the same joints projected by hand into a 2D canvas and filled as flat polygons. + * It exists because the capture host may have no usable WebGL at all; the design calls it the + * "paper fly" and accepts that it is plainer. + * + * `off` renders nothing and creates no context, which is the honest escape hatch if the fly ever + * costs more than it is worth on air. + * + * `webgl` is loaded through a dynamic import so that a page running `paper` or `off` never parses + * three.js at all — the fallback exists for the weakest host on the list, and handing it half a + * megabyte of dead module would be a strange way to help it. + */ +import type { CanvasPalette } from '@/theme/colors'; +import type { FlyDrives, FlyFrame } from './rig'; + +export type FlyMode = 'off' | 'webgl' | 'paper'; + +export interface FlyRenderer { + readonly mode: FlyMode; + /** Draw one posed frame. Called at most 30 times a second by the paint loop. */ + draw(frame: FlyFrame): void; + dispose(): void; +} + +export interface FlyRendererOptions { + canvas: HTMLCanvasElement; + width: number; + height: number; + palette: CanvasPalette; +} + +/** + * Build the renderer for a mode. Resolves to null for `off`, and also if no renderer can start — + * a broadcast page drops the fly rather than failing to paint. + * + * `webgl` falls back to `paper` by itself when the GL context cannot be created. Measured on the + * P0 spike (2026-09-15, run 2): the capture container's Chromium runs with `--disable-gpu + * --disable-software-rasterizer` (`infra/config/chromium-flags`), so `getContext('webgl')` returns + * null, `new WebglFly(...)` throws, and the page went to air at the default `?fly=webgl` with + * **no fly at all** — `__stage.fly()` reporting `{mode: 'webgl', rendering: false, legTips: []}`. + * That is the exact host the paper fly was written for, so it is taken automatically instead of + * requiring an operator to have already known to put `&fly=paper` in the kiosk URL. `?fly=paper` + * and `?fly=off` are unchanged, and a host that does have WebGL still gets the WebGL fly, so the + * "exactly one GL context at `?fly=webgl`, zero at `?fly=paper`/`off`" count in + * `tests/e2e/structure.spec.ts` still holds. + */ +export async function createFlyRenderer(mode: FlyMode, options: FlyRendererOptions): Promise { + if (mode === 'webgl') { + try { + const { WebglFly } = await import('./webgl'); + return new WebglFly(options); + } catch (error) { + console.warn(`fly renderer webgl unavailable, falling back to paper: ${(error as Error).message}`); + mode = 'paper'; + } + } + if (mode === 'paper') { + try { + const { PaperFly } = await import('./paper'); + return new PaperFly(options); + } catch (error) { + console.warn(`fly renderer paper unavailable: ${(error as Error).message}`); + } + } + return null; +} + +export type { FlyDrives, FlyFrame }; diff --git a/apps/stage/src/fly/paper.ts b/apps/stage/src/fly/paper.ts new file mode 100644 index 0000000..4355a45 --- /dev/null +++ b/apps/stage/src/fly/paper.ts @@ -0,0 +1,270 @@ +/** + * The paper fly: the same rig, projected by hand into a 2D canvas and filled flat. + * + * It exists for one reason, stated in the design: the capture host may have no usable WebGL, and + * three.js has no maintained software renderer. So this is a hand-written perspective projection + * and a painter's-algorithm fill — ellipses for the body blobs, quads for the limb segments, + * polygons for the wings. Plainer than the WebGL fly, and the same animal in the same pose from + * the same camera: every number it draws comes from the same `FlyFrame`. + * + * Shading is one directional light evaluated per shape rather than per pixel, which is what + * "flat-shaded" means here — a low-poly look by construction rather than by a shader. + */ +import type { FlyRenderer, FlyRendererOptions } from './index'; +import { CAMERA } from './camera'; +import { LEG_RADII, type Blob, type FlyFrame, type Vec3 } from './rig'; + +/** Matches `webgl.ts`: the animal's colours, not the theme's. */ +const CHITIN: Rgb = [111, 92, 56]; +const CHITIN_DARK: Rgb = [74, 61, 37]; +const EYE_RED: Rgb = [179, 54, 42]; +const WING: Rgb = [200, 220, 234]; + +type Rgb = [number, number, number]; + +/** One thing to fill, with the view-space depth it sorts by. */ +interface Shape { + depth: number; + kind: 'poly' | 'ellipse'; + points: number[][]; + /** Ellipse: centre x, y and the two radii. */ + ellipse?: { x: number; y: number; rx: number; ry: number }; + fill: string; +} + +const LIGHT: Vec3 = [-0.55, 0.74, -0.38]; + +export class PaperFly implements FlyRenderer { + readonly mode = 'paper' as const; + + private readonly ctx: CanvasRenderingContext2D; + private readonly width: number; + private readonly height: number; + private readonly background: string; + private readonly floor: string; + private readonly accent: Rgb; + + /** Camera basis, built once: right, up, forward. */ + private readonly right: Vec3; + private readonly up: Vec3; + private readonly forward: Vec3; + private readonly focal: number; + + private readonly shapes: Shape[] = []; + + constructor({ canvas, width, height, palette }: FlyRendererOptions) { + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { alpha: false }); + if (!ctx) throw new Error('paper fly: no 2d context'); + this.ctx = ctx; + this.width = width; + this.height = height; + this.background = css(palette.panel); + this.floor = css(palette.background); + this.accent = [...palette.output] as Rgb; + + this.forward = normalize(sub(CAMERA.target, CAMERA.position)); + this.right = normalize(cross(this.forward, [0, 1, 0])); + this.up = cross(this.right, this.forward); + this.focal = 1 / Math.tan(((CAMERA.fov * Math.PI) / 180) / 2); + } + + draw(frame: FlyFrame): void { + const ctx = this.ctx; + ctx.fillStyle = this.background; + ctx.fillRect(0, 0, this.width, this.height); + + this.shapes.length = 0; + + // -- The fly -------------------------------------------------------------------------------- + for (const wing of frame.wings) this.pushPoly(wing.points, WING, 1.35, 0.3); + + for (let index = 0; index < frame.abdomen.length; index++) { + this.pushEllipse(frame.abdomen[index] as Blob, index % 2 === 0 ? CHITIN : CHITIN_DARK, 1); + } + for (const haltere of frame.halteres) this.pushEllipse(haltere, CHITIN_DARK, 1); + + for (const leg of frame.legs) { + for (let joint = 0; joint < 3; joint++) { + this.pushLimb( + leg[joint] as Vec3, + leg[joint + 1] as Vec3, + LEG_RADII[joint] as number, + LEG_RADII[joint + 1] as number, + joint === 2 ? CHITIN_DARK : CHITIN, + ); + } + } + + const glow = 1 + frame.glow * 0.9; + this.pushEllipse(frame.thorax, mix(CHITIN, this.accent, frame.glow * 0.45), glow); + this.pushEllipse(frame.head, mix(CHITIN, this.accent, frame.glow * 0.45), glow); + for (const eye of frame.eyes) this.pushEllipse(eye, EYE_RED, 1.15); + for (const segment of frame.antennae) this.pushLimb(segment.a, segment.b, segment.ra, segment.rb, CHITIN_DARK); + this.pushLimb(frame.proboscis.a, frame.proboscis.b, frame.proboscis.ra, frame.proboscis.rb, CHITIN_DARK); + + // -- Paint, far to near --------------------------------------------------------------------- + this.paintFloor(); + this.shapes.sort((a, b) => b.depth - a.depth); + for (const shape of this.shapes) { + ctx.fillStyle = shape.fill; + ctx.beginPath(); + if (shape.kind === 'ellipse' && shape.ellipse) { + ctx.ellipse(shape.ellipse.x, shape.ellipse.y, shape.ellipse.rx, shape.ellipse.ry, 0, 0, Math.PI * 2); + } else { + for (let index = 0; index < shape.points.length; index++) { + const point = shape.points[index] as number[]; + if (index === 0) ctx.moveTo(point[0] as number, point[1] as number); + else ctx.lineTo(point[0] as number, point[1] as number); + } + ctx.closePath(); + } + ctx.fill(); + } + } + + dispose(): void { + // Nothing to release: a 2D context owns no GPU resources. + } + + /** A horizon band, so the empty half of the strip is not a flat rectangle. */ + private paintFloor(): void { + const horizon = this.project([0, 0, 40]); + const y = horizon ? Math.max(0, Math.min(this.height, horizon[1] as number)) : this.height * 0.3; + const gradient = this.ctx.createLinearGradient(0, y, 0, this.height); + gradient.addColorStop(0, this.floor); + gradient.addColorStop(1, this.background); + this.ctx.fillStyle = gradient; + this.ctx.fillRect(0, y, this.width, this.height - y); + } + + /** World point to canvas pixels, or null when it is behind the camera. */ + private project(p: Vec3): [number, number, number] | null { + const d = sub(p, CAMERA.position); + const z = dot(d, this.forward); + if (z <= 0.05) return null; + const x = dot(d, this.right); + const y = dot(d, this.up); + const aspect = this.width / this.height; + return [ + (0.5 + ((x * this.focal) / (aspect * z)) * 0.5) * this.width, + (0.5 - ((y * this.focal) / z) * 0.5) * this.height, + z, + ]; + } + + /** Pixels per world unit at a given view depth. */ + private pixelsPerUnit(z: number): number { + return (this.focal / z) * 0.5 * this.height; + } + + private pushPoly(points: readonly Vec3[], colour: Rgb, shade: number, alpha = 1): void { + const projected: number[][] = []; + let depth = 0; + for (const point of points) { + const p = this.project(point); + if (!p) return; + projected.push([p[0], p[1]]); + depth += p[2]; + } + if (projected.length < 3) return; + this.shapes.push({ + depth: depth / projected.length, + kind: 'poly', + points: projected, + fill: css(scaleColour(colour, shade * faceShade(points)), alpha), + }); + } + + private pushEllipse(blob: Blob, colour: Rgb, shade: number): void { + const p = this.project(blob.c); + if (!p) return; + const scale = this.pixelsPerUnit(p[2]); + this.shapes.push({ + depth: p[2], + kind: 'ellipse', + points: [], + ellipse: { + x: p[0], + y: p[1], + rx: Math.max(0.6, ((blob.r[0] + blob.r[2]) / 2) * scale), + ry: Math.max(0.6, ((blob.r[1] + blob.r[2]) / 2) * scale), + }, + fill: css(scaleColour(colour, shade), 1), + }); + } + + /** A limb segment as a screen-space quad between two projected joints. */ + private pushLimb(a: Vec3, b: Vec3, ra: number, rb: number, colour: Rgb): void { + const pa = this.project(a); + const pb = this.project(b); + if (!pa || !pb) return; + const dx = (pb[0] as number) - (pa[0] as number); + const dy = (pb[1] as number) - (pa[1] as number); + const length = Math.hypot(dx, dy); + if (length < 0.2) return; + const nx = -dy / length; + const ny = dx / length; + const wa = Math.max(0.7, ra * this.pixelsPerUnit(pa[2])); + const wb = Math.max(0.6, rb * this.pixelsPerUnit(pb[2])); + this.shapes.push({ + depth: (pa[2] + pb[2]) / 2, + kind: 'poly', + points: [ + [pa[0] + nx * wa, pa[1] + ny * wa], + [pb[0] + nx * wb, pb[1] + ny * wb], + [pb[0] - nx * wb, pb[1] - ny * wb], + [pa[0] - nx * wa, pa[1] - ny * wa], + ], + fill: css(scaleColour(colour, 1), 1), + }); + } +} + +/** Lambert term for a polygon, from its own winding normal. */ +function faceShade(points: readonly Vec3[]): number { + if (points.length < 3) return 1; + const normal = normalize(cross(sub(points[1] as Vec3, points[0] as Vec3), sub(points[2] as Vec3, points[0] as Vec3))); + return 0.55 + 0.6 * Math.abs(dot(normal, LIGHT)); +} + +function scaleColour(colour: Rgb, factor: number): Rgb { + return [ + Math.max(0, Math.min(255, colour[0] * factor)), + Math.max(0, Math.min(255, colour[1] * factor)), + Math.max(0, Math.min(255, colour[2] * factor)), + ]; +} + +function mix(a: Rgb, b: readonly number[], t: number): Rgb { + return [ + a[0] + ((b[0] as number) - a[0]) * t, + a[1] + ((b[1] as number) - a[1]) * t, + a[2] + ((b[2] as number) - a[2]) * t, + ]; +} + +function css(colour: readonly number[], alpha = 1): string { + const r = Math.round(colour[0] as number); + const g = Math.round(colour[1] as number); + const b = Math.round(colour[2] as number); + return alpha >= 1 ? `rgb(${r} ${g} ${b})` : `rgb(${r} ${g} ${b} / ${alpha})`; +} + +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function cross(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +function normalize(a: Vec3): Vec3 { + const l = Math.hypot(a[0], a[1], a[2]); + return l < 1e-6 ? [0, 0, 0] : [a[0] / l, a[1] / l, a[2] / l]; +} diff --git a/apps/stage/src/fly/rig.ts b/apps/stage/src/fly/rig.ts new file mode 100644 index 0000000..914387b --- /dev/null +++ b/apps/stage/src/fly/rig.ts @@ -0,0 +1,410 @@ +/** + * The fly's skeleton, and the pose it takes for one frame. + * + * This file is the whole animal: proportions, the tripod gait, the leg IK, the wing beat and the + * mapping from normalized neural drives to joints. It knows nothing about how it will be drawn — + * it emits world-space points — which is what lets the WebGL renderer and the 2D "paper" fallback + * be the *same* fly rather than two lookalikes (`docs/design/fly-avatar.md`). + * + * There is no Game Boy in this file. The fly used to tap one with its front legs; per review + * ("the fly isn't really pressing buttons; just have his limbs and wings wired up to the motor + * neurons"), the legs and wings are driven only by real population rates now, and the eight + * button indicators are their own plain row again, above this scene (`src/panels/FlyStrip.tsx`). + * + * Rig space: +X right, +Y up, +Z toward the game screen, floor at y = 0, the fly's thorax over + * the origin facing +Z. One unit is about 0.4 mm of fly, and the camera is set so the body reads + * clearly in the fly's own share of the 800x220 strip. + * + * Nothing here is random and nothing is scripted: every number below is either a fixed proportion + * or a function of `drives` and the clock. That is the design's one hard rule for this panel. + */ + +export type Vec3 = [number, number, number]; + +/** An ellipsoid body part. */ +export interface Blob { + c: Vec3; + /** Radii per axis. */ + r: Vec3; +} + +/** A tapered limb segment between two joints. */ +export interface Segment { + a: Vec3; + b: Vec3; + ra: number; + rb: number; +} + +/** A flat polygon (the wings). */ +export interface Poly { + points: Vec3[]; +} + +/** One frame of the fly, in world space. */ +export interface FlyFrame { + head: Blob; + eyes: [Blob, Blob]; + antennae: Segment[]; + thorax: Blob; + abdomen: Blob[]; + /** Six legs, four joints each: coxa, trochanter, knee, tarsus tip. */ + legs: Vec3[][]; + wings: [Poly, Poly]; + halteres: [Blob, Blob]; + proboscis: Segment; + /** Warm glow inside head and thorax, 0..1, from the PAM rate. */ + glow: number; +} + +/** + * Normalized 0..1 drives. Every one comes from the running-reference scaler in `src/fly/drives.ts`, + * never from raw Hz. + */ +export interface FlyDrives { + forward: number; + backward: number; + steerLeft: number; + steerRight: number; + /** Wing/flight drive: amplitude and frequency of the wing beat, and haltere jitter follow it. + * See `src/fly/drives.ts` for what feeds this today and why. */ + wing: number; + proboscis: number; + reward: number; + population: number; + /** 1 while a sugar moment is on screen. Extends the proboscis whatever the taste rate does. */ + sugarPulse: number; +} + +/** An all-zero drive set: what the rig poses from before the first snapshot. */ +export function idleDrives(): FlyDrives { + return { + forward: 0, + backward: 0, + steerLeft: 0, + steerRight: 0, + wing: 0, + proboscis: 0, + reward: 0, + population: 0, + sugarPulse: 0, + }; +} + +// -- Proportions ------------------------------------------------------------------------------- + +const THORAX: Blob = { c: [0, 0.6, 0], r: [0.4, 0.36, 0.52] }; +const HEAD: Blob = { c: [0, 0.68, 0.6], r: [0.3, 0.3, 0.28] }; +const EYE_OFFSET = 0.25; +const EYE: Blob = { c: [0, 0.71, 0.64], r: [0.21, 0.24, 0.22] }; + +/** Four abdominal segments, tapering back and down. The banding is a per-segment shade. */ +const ABDOMEN: readonly Blob[] = [ + { c: [0, 0.58, -0.48], r: [0.34, 0.32, 0.26] }, + { c: [0, 0.55, -0.76], r: [0.32, 0.3, 0.24] }, + { c: [0, 0.5, -1.02], r: [0.27, 0.25, 0.22] }, + { c: [0, 0.45, -1.26], r: [0.19, 0.18, 0.2] }, +]; + +/** Where each leg leaves the thorax, and where its foot rests. Left, right, front to back. */ +const COXA: readonly Vec3[] = [ + [-0.3, 0.46, 0.4], + [0.3, 0.46, 0.4], + [-0.34, 0.44, 0.02], + [0.34, 0.44, 0.02], + [-0.3, 0.44, -0.34], + [0.3, 0.44, -0.34], +]; + +const STANCE: readonly Vec3[] = [ + [-0.62, 0, 0.55], + [0.62, 0, 0.55], + [-0.72, 0, 0.02], + [0.72, 0, 0.02], + [-0.66, 0, -0.62], + [0.66, 0, -0.62], +]; + +/** Femur and tibia. */ +const FEMUR = 0.62; +const TIBIA = 0.7; +/** The short coxa stub before the two-bone chain, which is what makes the leg three-jointed. */ +const COXA_STUB = 0.14; + +/** Tripod gait: legs 0, 3, 4 step together, then 1, 2, 5. */ +const GAIT_OFFSET: readonly number[] = [0, 0.5, 0.5, 0, 0, 0.5]; + +/** Top step rate, in steps per second, at a fully-driven walk. */ +const STEP_HZ = 2.2; +/** How far a foot travels fore-and-aft in one stride, and how high it lifts in swing. */ +const STRIDE = 0.26; +const LIFT = 0.16; +/** Body yaw at full one-sided steering, radians (about 7 degrees). */ +const YAW_MAX = 0.12; + +/** + * How much a leg's own stride lengthens or shortens per unit of differential steering. + * + * Turning is a differential-drive read of `steer_left`/`steer_right`: the leg on the side away + * from the stronger steering signal is the "outer" leg and takes a longer stride, the near side + * a shorter one — the same shape a tank uses to turn, and what makes the walk visibly lean into a + * turn rather than just yawing the body. + */ +const STEER_STRIDE_GAIN = 0.85; +const STRIDE_SCALE_MIN = 0.15; +const STRIDE_SCALE_MAX = 1.85; + +/** Wing beat, idle floor and full drive. Frequency and amplitude both ramp with `drives.wing`. */ +const WING_BEAT_HZ_IDLE = 3; +const WING_BEAT_HZ_MAX = 22; +const WING_AMPLITUDE_IDLE = 0.015; +const WING_AMPLITUDE_MAX = 0.2; +/** Halteres beat antiphase to the wings (the real animal's own gyroscopic pairing), smaller. */ +const HALTERE_AMPLITUDE_SCALE = 0.35; + +const WING_OUTLINE: readonly Vec3[] = [ + [0.14, 0.92, -0.05], + [0.34, 0.95, -0.55], + [0.46, 0.94, -1.25], + [0.3, 0.92, -1.45], + [0.18, 0.91, -0.9], +]; + +const HALTERE: Blob = { c: [0.24, 0.52, -0.4], r: [0.07, 0.07, 0.07] }; + +const ANTENNA_BASE: Vec3 = [0.1, 0.52, 0.78]; +const ANTENNA_MID: Vec3 = [0.14, 0.44, 0.9]; +const ANTENNA_TIP: Vec3 = [0.13, 0.33, 0.95]; + +const PROBOSCIS_BASE: Vec3 = [0, 0.46, 0.74]; +/** Retracted length, and how much the taste circuit (or a sugar pulse) adds. */ +const PROBOSCIS_MIN = 0.1; +const PROBOSCIS_MAX = 0.52; + +// -- Small vector helpers ---------------------------------------------------------------------- + +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function add(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +function scale(a: Vec3, k: number): Vec3 { + return [a[0] * k, a[1] * k, a[2] * k]; +} + +function length(a: Vec3): number { + return Math.hypot(a[0], a[1], a[2]); +} + +function normalize(a: Vec3): Vec3 { + const l = length(a); + return l < 1e-6 ? [0, 0, 0] : [a[0] / l, a[1] / l, a[2] / l]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function clampRange(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value; +} + +/** Smooth 0..1 ramp, used for the proboscis extension. */ +function ease(t: number): number { + const x = clamp01(t); + return x * x * (3 - 2 * x); +} + +// -- The rig ----------------------------------------------------------------------------------- + +/** + * A posed fly. + * + * `advance` is the only stateful call: it integrates the gait phase and the wing phase, and it + * does so from the clock the caller passes, which on a held fixture seek is frozen. A clock that + * has stopped (dt = 0) or jumped (dt over `PHASE_RESET_MS`, which is what a seek looks like) + * resets both phases to zero rather than integrating across it. That is what makes a screenshot + * of a held page reproducible: a frozen clock always poses the same fly, whatever the page did on + * its way there. + */ +export class FlyRig { + private phase = 0; + private wingPhase = 0; + private lastMs: number | null = null; + + /** Beyond this, the clock jumped (a seek, a tab wake) and the phases restart. */ + private static readonly PHASE_RESET_MS = 250; + + /** The gait phase, 0..1. Exposed for the tests. */ + get gaitPhase(): number { + return this.phase; + } + + reset(): void { + this.phase = 0; + this.wingPhase = 0; + this.lastMs = null; + } + + advance(drives: FlyDrives, nowMs: number): FlyFrame { + const forward = clamp01(drives.forward); + const backward = clamp01(drives.backward); + const speed = Math.max(forward, backward); + const direction = backward > forward ? -1 : 1; + + const dtMs = this.lastMs === null ? 0 : nowMs - this.lastMs; + this.lastMs = nowMs; + if (dtMs <= 0 || dtMs > FlyRig.PHASE_RESET_MS) { + this.phase = 0; + this.wingPhase = 0; + } else { + this.phase = (this.phase + (dtMs / 1000) * STEP_HZ * speed) % 1; + const wingHz = WING_BEAT_HZ_IDLE + (WING_BEAT_HZ_MAX - WING_BEAT_HZ_IDLE) * clamp01(drives.wing); + this.wingPhase = (this.wingPhase + (dtMs / 1000) * wingHz) % 1; + } + + return this.pose(drives, nowMs, speed, direction); + } + + private pose(drives: FlyDrives, nowMs: number, speed: number, direction: number): FlyFrame { + const seconds = nowMs / 1000; + + // Body. Yaw toward the stronger steering side; bob with the gait; breathe with the + // population rate (and a little even at rest, which is the design's "idle: breathing only"). + const steerDelta = clamp01(drives.steerRight) - clamp01(drives.steerLeft); + const yaw = steerDelta * YAW_MAX; + const bob = Math.sin(seconds * STEP_HZ * speed * 4 * Math.PI) * 0.018 * speed; + const breathAmplitude = 0.012 + 0.05 * clamp01(drives.population); + const breath = 1 + breathAmplitude * Math.sin(seconds * 2.4); + + const cos = Math.cos(yaw); + const sin = Math.sin(yaw); + /** Body space to world: yaw about Y through the origin, then the gait bob. */ + const toWorld = (p: Vec3): Vec3 => [p[0] * cos + p[2] * sin, p[1] + bob, -p[0] * sin + p[2] * cos]; + + const head = { c: toWorld(HEAD.c), r: [...HEAD.r] as Vec3 }; + const thorax = { c: toWorld(THORAX.c), r: [...THORAX.r] as Vec3 }; + + const eyes: [Blob, Blob] = [ + { c: toWorld([EYE.c[0] - EYE_OFFSET, EYE.c[1], EYE.c[2]]), r: [...EYE.r] as Vec3 }, + { c: toWorld([EYE.c[0] + EYE_OFFSET, EYE.c[1], EYE.c[2]]), r: [...EYE.r] as Vec3 }, + ]; + + // The abdomen breathes: each segment swells about its own centre, and the whole train + // stretches slightly, which is what reads as breathing at this size. + const abdomen = ABDOMEN.map((segment) => ({ + c: toWorld([segment.c[0], segment.c[1], segment.c[2] * (2 - breath)]), + r: [segment.r[0] * breath, segment.r[1] * breath, segment.r[2]] as Vec3, + })); + + const antennae: Segment[] = []; + for (const side of [-1, 1]) { + const base = toWorld([ANTENNA_BASE[0] * side, ANTENNA_BASE[1], ANTENNA_BASE[2]]); + const mid = toWorld([ANTENNA_MID[0] * side, ANTENNA_MID[1], ANTENNA_MID[2]]); + const tip = toWorld([ANTENNA_TIP[0] * side, ANTENNA_TIP[1], ANTENNA_TIP[2]]); + antennae.push({ a: base, b: mid, ra: 0.035, rb: 0.03 }, { a: mid, b: tip, ra: 0.03, rb: 0.055 }); + } + + // Wings: amplitude and frequency both ramp with the wing drive (`drives.wing`); an idle floor + // keeps a small tremor rather than dead stillness. Halteres jitter antiphase, at a fraction of + // the same amplitude — the real animal's own gyroscopic pairing. + const wingDrive = clamp01(drives.wing); + const wingAmplitude = WING_AMPLITUDE_IDLE + (WING_AMPLITUDE_MAX - WING_AMPLITUDE_IDLE) * wingDrive; + const wingBeat = Math.sin(this.wingPhase * 2 * Math.PI); + + const wings = [-1, 1].map((side) => ({ + points: WING_OUTLINE.map((point) => { + const lift = (point[2] + 0.05) * wingBeat * wingAmplitude * side; + return toWorld([point[0] * side, point[1] + lift, point[2]]); + }), + })) as [Poly, Poly]; + + const haltereJitter = Math.sin(this.wingPhase * 2 * Math.PI + Math.PI) * wingAmplitude * HALTERE_AMPLITUDE_SCALE; + const halteres: [Blob, Blob] = [ + { c: toWorld([-HALTERE.c[0], HALTERE.c[1] + haltereJitter, HALTERE.c[2]]), r: [...HALTERE.r] as Vec3 }, + { c: toWorld([HALTERE.c[0], HALTERE.c[1] + haltereJitter, HALTERE.c[2]]), r: [...HALTERE.r] as Vec3 }, + ]; + + // Proboscis: the taste circuit's own rate, and a sugar redemption overrides it upward. + const extend = Math.max(clamp01(drives.proboscis), clamp01(drives.sugarPulse)); + const probBase = toWorld(PROBOSCIS_BASE); + const probDirection = normalize(toWorld([0, -0.55, 0.84])); + const probLength = PROBOSCIS_MIN + (PROBOSCIS_MAX - PROBOSCIS_MIN) * ease(extend); + const proboscis: Segment = { + a: probBase, + b: add(probBase, scale(probDirection, probLength)), + ra: 0.075, + rb: 0.05, + }; + + // Legs: a tripod gait when moving, otherwise the resting stance (idle: no gait motion at + // all, per the design's "idle: subtle breathing only" — the body's own bob and breath already + // zero out at speed 0, so a resting leg is simply still). + const legs: Vec3[][] = []; + for (let leg = 0; leg < 6; leg++) { + const coxa = toWorld(COXA[leg] as Vec3); + const stance = toWorld(STANCE[leg] as Vec3); + let tip = stance; + + if (speed > 0.002) { + const side = (STANCE[leg] as Vec3)[0] < 0 ? -1 : 1; + const strideScale = clampRange(1 - side * STEER_STRIDE_GAIN * steerDelta, STRIDE_SCALE_MIN, STRIDE_SCALE_MAX); + const reach = STRIDE * strideScale; + const u = (this.phase + (GAIT_OFFSET[leg] ?? 0)) % 1; + const along = u < 0.5 ? reach * (1 - 4 * u) : reach * (4 * (u - 0.5) - 1); + tip = [tip[0], tip[1], tip[2] + along * direction * speed]; + if (u >= 0.5) tip[1] += Math.sin(Math.PI * (u - 0.5) * 2) * LIFT * speed; + } + + legs.push(solveLeg(coxa, tip, (STANCE[leg] as Vec3)[0] < 0 ? -1 : 1)); + } + + return { + head, + eyes, + antennae, + thorax, + abdomen, + legs, + wings, + halteres, + proboscis, + glow: clamp01(drives.reward), + }; + } +} + +/** + * Two-bone IK with a short coxa stub in front of it: coxa, trochanter, knee, tarsus tip. + * + * The knee is placed on the side of the femur-tibia plane that points up and away from the body, + * which is what gives a fly its high-elbow stance instead of a mammal's. + */ +export function solveLeg(coxa: Vec3, tip: Vec3, side: number): Vec3[] { + const outward: Vec3 = normalize([side * 1, 1.25, 0]); + const trochanter = add(coxa, scale(normalize(add(outward, sub(tip, coxa))), COXA_STUB)); + + const delta = sub(tip, trochanter); + const distance = Math.min(Math.max(length(delta), Math.abs(FEMUR - TIBIA) + 0.02), FEMUR + TIBIA - 0.02); + const direction = normalize(delta); + + const along = (FEMUR * FEMUR - TIBIA * TIBIA + distance * distance) / (2 * distance); + const out = Math.sqrt(Math.max(0, FEMUR * FEMUR - along * along)); + + // Bend axis: `outward` with its component along the limb removed, so the knee rises sideways. + const projected = sub(outward, scale(direction, dot(outward, direction))); + const bend = length(projected) < 1e-4 ? ([0, 1, 0] as Vec3) : normalize(projected); + + const knee = add(add(trochanter, scale(direction, along)), scale(bend, out)); + return [coxa, trochanter, knee, [...tip] as Vec3]; +} + +/** Limb thickness at each joint, for whichever renderer is drawing the segments. */ +export const LEG_RADII = [0.075, 0.06, 0.042, 0.022]; diff --git a/apps/stage/src/fly/webgl.ts b/apps/stage/src/fly/webgl.ts new file mode 100644 index 0000000..8f68ac1 --- /dev/null +++ b/apps/stage/src/fly/webgl.ts @@ -0,0 +1,240 @@ +/** + * The fly in three.js: one WebGL context, low-poly primitives, flat shading. + * + * Everything is allocated once in the constructor and only transformed afterwards, because this + * runs 30 times a second forever on a machine that is also running an emulator, a simulation and + * an encoder. No geometry is rebuilt per frame; the only per-frame writes are positions, scales, + * quaternions, one emissive colour and the wings' six vertices. + * + * There is no Game Boy mesh here: the fly's legs and wings are driven by motor rates, not by + * button taps, and the eight button indicators are their own plain DOM row above this canvas + * (`src/panels/FlyStrip.tsx`). What's left is well under the design's 3,000-triangle budget. + * Materials are Lambert rather than Standard: a physically-based material costs far more in a + * software rasteriser and buys nothing at this size. + */ +import { + AmbientLight, + BufferAttribute, + BufferGeometry, + Color, + CylinderGeometry, + DirectionalLight, + DoubleSide, + DynamicDrawUsage, + Mesh, + MeshBasicMaterial, + MeshLambertMaterial, + PerspectiveCamera, + PlaneGeometry, + Quaternion, + Scene, + SphereGeometry, + Vector3, + WebGLRenderer, +} from 'three'; + +import type { FlyRenderer, FlyRendererOptions } from './index'; +import { CAMERA, FLOOR } from './camera'; +import { LEG_RADII, type Blob, type FlyFrame, type Segment, type Vec3 } from './rig'; + +/** The fly's own colours. These are the animal, not the theme, so they do not move with `?theme=`. */ +const CHITIN = 0x6f5c38; +const CHITIN_DARK = 0x4a3d25; +const EYE_RED = 0xb3362a; +const WING = 0xc8dcea; + +const UP = new Vector3(0, 1, 0); + +export class WebglFly implements FlyRenderer { + readonly mode = 'webgl' as const; + + private readonly renderer: WebGLRenderer; + private readonly scene = new Scene(); + private readonly camera: PerspectiveCamera; + + private readonly head: Mesh; + private readonly thorax: Mesh; + private readonly eyes: Mesh[] = []; + private readonly abdomen: Mesh[] = []; + private readonly halteres: Mesh[] = []; + private readonly legSegments: Mesh[] = []; + private readonly antennaSegments: Mesh[] = []; + private readonly proboscis: Mesh; + private readonly wings: { mesh: Mesh; position: BufferAttribute }[] = []; + + private readonly glowMaterial: MeshLambertMaterial; + private readonly accent: Color; + + private readonly scratchA = new Vector3(); + private readonly scratchB = new Vector3(); + private readonly scratchDirection = new Vector3(); + private readonly scratchQuaternion = new Quaternion(); + + constructor({ canvas, width, height, palette }: FlyRendererOptions) { + this.renderer = new WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'low-power' }); + this.renderer.setPixelRatio(1); + this.renderer.setSize(width, height, false); + this.renderer.setClearColor(new Color(rgb(palette.panel)), 1); + + this.camera = new PerspectiveCamera(CAMERA.fov, width / height, CAMERA.near, CAMERA.far); + this.camera.position.set(...CAMERA.position); + this.camera.lookAt(new Vector3(...CAMERA.target)); + + this.accent = new Color(rgb(palette.output)); + + this.scene.add(new AmbientLight(0x5a6272, 1.5)); + const key = new DirectionalLight(0xfff0d6, 1.7); + key.position.set(-2.5, 4, -1.5); + this.scene.add(key); + + // The floor: built once, never touched again. + const floor = new Mesh( + new PlaneGeometry(FLOOR.halfWidth * 2, FLOOR.farZ - FLOOR.nearZ), + new MeshBasicMaterial({ color: new Color(rgb(palette.background)) }), + ); + floor.rotation.x = -Math.PI / 2; + floor.position.set(0, 0, (FLOOR.farZ + FLOOR.nearZ) / 2); + this.scene.add(floor); + + // -- The fly -------------------------------------------------------------------------------- + const sphere = new SphereGeometry(1, 8, 6); + this.glowMaterial = new MeshLambertMaterial({ color: CHITIN, flatShading: true }); + const shell = new MeshLambertMaterial({ color: CHITIN, flatShading: true }); + const band = new MeshLambertMaterial({ color: CHITIN_DARK, flatShading: true }); + + this.head = new Mesh(sphere, this.glowMaterial); + this.thorax = new Mesh(sphere, this.glowMaterial); + this.scene.add(this.head, this.thorax); + + const eyeMaterial = new MeshLambertMaterial({ color: EYE_RED, flatShading: true }); + for (let index = 0; index < 2; index++) { + const eye = new Mesh(sphere, eyeMaterial); + this.eyes.push(eye); + this.scene.add(eye); + } + + // Four segments, alternating shade: that alternation is the abdomen's banding. + for (let index = 0; index < 4; index++) { + const segment = new Mesh(sphere, index % 2 === 0 ? shell : band); + this.abdomen.push(segment); + this.scene.add(segment); + } + + for (let index = 0; index < 2; index++) { + const haltere = new Mesh(sphere, band); + this.halteres.push(haltere); + this.scene.add(haltere); + } + + // Legs: three tapered segments each, their radii fixed per joint so only the length changes. + for (let leg = 0; leg < 6; leg++) { + for (let joint = 0; joint < 3; joint++) { + const mesh = new Mesh( + new CylinderGeometry(LEG_RADII[joint + 1] as number, LEG_RADII[joint] as number, 1, 6), + joint === 2 ? band : shell, + ); + this.legSegments.push(mesh); + this.scene.add(mesh); + } + } + + for (let index = 0; index < 4; index++) { + const mesh = new Mesh(new CylinderGeometry(index % 2 === 0 ? 0.03 : 0.055, 0.035, 1, 6), band); + this.antennaSegments.push(mesh); + this.scene.add(mesh); + } + + this.proboscis = new Mesh(new CylinderGeometry(0.05, 0.075, 1, 6), band); + this.scene.add(this.proboscis); + + const wingMaterial = new MeshBasicMaterial({ color: WING, transparent: true, opacity: 0.24, side: DoubleSide }); + for (let index = 0; index < 2; index++) { + const geometry = new BufferGeometry(); + const position = new BufferAttribute(new Float32Array(9 * 3), 3); + position.setUsage(DynamicDrawUsage); + geometry.setAttribute('position', position); + const mesh = new Mesh(geometry, wingMaterial); + mesh.frustumCulled = false; + this.wings.push({ mesh, position }); + this.scene.add(mesh); + } + } + + draw(frame: FlyFrame): void { + placeBlob(this.head, frame.head); + placeBlob(this.thorax, frame.thorax); + for (let index = 0; index < 2; index++) placeBlob(this.eyes[index] as Mesh, frame.eyes[index] as Blob); + for (let index = 0; index < frame.abdomen.length; index++) { + placeBlob(this.abdomen[index] as Mesh, frame.abdomen[index] as Blob); + } + for (let index = 0; index < 2; index++) placeBlob(this.halteres[index] as Mesh, frame.halteres[index] as Blob); + + for (let leg = 0; leg < frame.legs.length; leg++) { + const joints = frame.legs[leg] as Vec3[]; + for (let joint = 0; joint < 3; joint++) { + this.placeSegment(this.legSegments[leg * 3 + joint] as Mesh, joints[joint] as Vec3, joints[joint + 1] as Vec3); + } + } + + for (let index = 0; index < this.antennaSegments.length; index++) { + const segment = frame.antennae[index] as Segment; + this.placeSegment(this.antennaSegments[index] as Mesh, segment.a, segment.b); + } + this.placeSegment(this.proboscis, frame.proboscis.a, frame.proboscis.b); + + for (let index = 0; index < 2; index++) { + const wing = this.wings[index]; + if (!wing) continue; + const points = frame.wings[index]?.points ?? []; + // A triangle fan over the outline, written straight into the attribute. + let cursor = 0; + for (let corner = 1; corner + 1 < points.length; corner++) { + cursor = writePoint(wing.position, cursor, points[0] as Vec3); + cursor = writePoint(wing.position, cursor, points[corner] as Vec3); + cursor = writePoint(wing.position, cursor, points[corner + 1] as Vec3); + } + wing.position.needsUpdate = true; + } + + // Head and thorax warm with the PAM rate. Emissive, not colour, so the shading survives. + this.glowMaterial.emissive.copy(this.accent).multiplyScalar(frame.glow * 0.45); + + this.renderer.render(this.scene, this.camera); + } + + /** Stretch a unit-height cylinder between two joints. */ + private placeSegment(mesh: Mesh, a: Vec3, b: Vec3): void { + this.scratchA.set(a[0], a[1], a[2]); + this.scratchB.set(b[0], b[1], b[2]); + this.scratchDirection.subVectors(this.scratchB, this.scratchA); + const length = this.scratchDirection.length(); + if (length < 1e-5) { + mesh.visible = false; + return; + } + mesh.visible = true; + this.scratchDirection.divideScalar(length); + mesh.position.copy(this.scratchA).addScaledVector(this.scratchDirection, length / 2); + mesh.quaternion.copy(this.scratchQuaternion.setFromUnitVectors(UP, this.scratchDirection)); + mesh.scale.set(1, length, 1); + } + + dispose(): void { + this.renderer.dispose(); + } +} + +function placeBlob(mesh: Mesh, blob: Blob): void { + mesh.position.set(blob.c[0], blob.c[1], blob.c[2]); + mesh.scale.set(blob.r[0], blob.r[1], blob.r[2]); +} + +function writePoint(attribute: BufferAttribute, cursor: number, point: Vec3): number { + attribute.setXYZ(cursor, point[0], point[1], point[2]); + return cursor + 1; +} + +/** `[r, g, b]` 0-255 to the 0xrrggbb three.js wants. */ +function rgb(color: readonly [number, number, number]): number { + return (Math.round(color[0]) << 16) | (Math.round(color[1]) << 8) | Math.round(color[2]); +} diff --git a/apps/stage/src/games/describe.ts b/apps/stage/src/games/describe.ts new file mode 100644 index 0000000..e1aeeaf --- /dev/null +++ b/apps/stage/src/games/describe.ts @@ -0,0 +1,51 @@ +/** + * The DESCRIBE tab's copy. **This file is the whole of it.** + * + * `docs/design/describe-tab.md` is the review surface and this is the build's copy of it: one + * card, and nothing else in the repo carries a word of it. A copy change after the operator's review is + * an edit here and a rebuild — no component, no CSS, no test holds a sentence of its own + * (`tests/unit/describe.test.ts` asserts this file still matches the doc, title and paragraph, so + * the two cannot drift). + * + * The copy is **approved by the operator (2026-09-17)**: one densely packed card, no cycling. Do not + * paraphrase it here; change the doc, have it reviewed, then bring it across. + * + * `{…}` placeholders are the one thing that is not static: the doc's rule is that "names and + * numbers come from the feed where they exist", so the card writes a placeholder and the renderer + * fills it from the live page (`src/lib/describe.ts` resolves them, and the numbers are formatted + * there rather than typed out here, so the count on screen cannot drift from the dataset): + * + * {neurons} the loaded dataset's neuron count, e.g. `139,255` + * {synapses} its connection count, rounded, e.g. `2.7 million` + * {game} the game config's human name — the only place a game may be named + * {dataset} the dataset version, e.g. `v783` + * {version} the release version the page was built at + * + * All five resolve; the approved card uses two. A future card that wants the build's version + * writes `{version}` and gets it, which is why the list is longer than the copy needs. + */ + +/** The card: the Silkscreen title, and the VT323 paragraph under it. */ +export interface DescribeCard { + /** The card's title, in the caps the doc gives it (`### …` in `docs/design/describe-tab.md`). */ + title: string; + /** The paragraph. Placeholders as above. */ + text: string; +} + +/** + * The card, as approved. + * + * One, not eight. The operator, 2026-09-17: "keep it to a densely packed card" — so there is no cycle, no + * card index and no cell row anywhere downstream of this file, and a reader who arrives at any + * moment gets the whole explanation rather than one eighth of it. + */ +export const DESCRIBE_CARD: DescribeCard = { + title: 'A CONNECTOME MEETS A GAME BOY', + text: + "This is a real fly's brain, {neurons} mapped neurons and {synapses} synapses, running live. " + + 'The screen is its eye. Its motor neurons press the buttons. Each scene offers a few actions, ' + + 'walk to a door, talk, attack; the fly picks one. When the game rewards it, a few thousand ' + + 'synapses shift, and what worked gets likelier. !sugar sends it a small reward pulse, no ' + + 'buttons. FlyWire connectome.', +}; diff --git a/apps/stage/src/games/index.ts b/apps/stage/src/games/index.ts new file mode 100644 index 0000000..f44eed6 --- /dev/null +++ b/apps/stage/src/games/index.ts @@ -0,0 +1,25 @@ +/** + * Game config registry, selected by `?game=`. + * + * Static imports, not dynamic: two configs are a few kilobytes, the page must be able to paint + * before any network round-trip finishes, and a broken `?game=` must fall back rather than throw + * on a live broadcast. + */ +import { platformer } from './platformer'; +import { pokemonRed } from './pokemon-red'; +import type { GameConfig } from './types'; + +export type { GameConfig, GameCounter, RewardCopy, RewardTier } from './types'; + +export const GAMES: Record = { + 'pokemon-red': pokemonRed, + platformer, +}; + +export const DEFAULT_GAME = 'pokemon-red'; + +/** Resolve a `?game=` value, falling back to the default rather than failing on air. */ +export function resolveGame(id: string | null | undefined): GameConfig { + const config = id ? GAMES[id] : undefined; + return config ?? (GAMES[DEFAULT_GAME] as GameConfig); +} diff --git a/apps/stage/src/games/platformer.ts b/apps/stage/src/games/platformer.ts new file mode 100644 index 0000000..d5478ee --- /dev/null +++ b/apps/stage/src/games/platformer.ts @@ -0,0 +1,104 @@ +/** + * Per-game config for the second demo, Super Mario Land (`sml-progress-v1`). + * + * This is the only file in the app that names this game. The ladder is the adapter's 16 ranks from + * `docs/design/platformer.md` §3; the feed header stays authoritative for the *current* rank's + * label, so a ladder change in the service shows up on screen without a page release. The same goes + * for the mode and the reward counters: this file supplies words, never values. + */ +import type { GameConfig } from './types'; + +/** + * Ticker words for the adapter's nine reward kinds. + * + * Seven of them reach the page as one of the feed's published `RewardKind` counters and are worded + * in `rewardCopy` below; the mapping is in `docs/feed-protocol.md`. `started` and `clear` map to no + * counter, because each pays once in a lifetime, so the ticker shows the adapter's own event label + * for them ("RUN STARTED", "GAME CLEARED"). This record is the one place all nine words live, so a + * reader can see the catalogue in one glance; the two unmapped entries are documentation until the + * feed grows a counter for them. + */ +export const PLATFORMER_REWARD_WORDS: Record = { + started: 'run started', + band: 'new ground', + coin: 'coin', + score: 'points', + powerup: 'power-up', + life: '1UP', + level: 'level cleared', + world: 'world cleared', + clear: 'game clear', +}; + +export const platformer: GameConfig = { + id: 'platformer', + // 34 characters, four over the ~30 the title strip fits at 18 px (see `GameConfig.wordmark`), so + // the strip measures this one at 16 px. The game's name is not negotiable and abbreviating it + // ("SUPER MARIO") would read as a different game. + wordmark: 'A FLY BRAIN PLAYS SUPER MARIO LAND', + name: 'Super Mario Land', + + // The adapter's ladder, in the page's lower case. Rungs 4 to 14 are "4 + highest cleared level", + // and the boss names come from the design, which took them from Mario Wiki. + milestoneLadder: [ + 'booting', + 'started a run', + 'found a coin', + 'halfway through 1-1', + 'cleared 1-1', + 'cleared 1-2', + 'cleared world 1', + 'cleared 2-1', + 'cleared 2-2', + 'cleared world 2', + 'cleared 3-1', + 'cleared 3-2', + 'cleared world 3', + 'cleared 4-1', + 'cleared 4-2', + 'finished the game', + ], + + // Short state words, not sentences: these read as a badge in the title strip. The adapter's five + // modes fold onto the feed's closed set (`docs/feed-protocol.md`): `IN LEVEL -` is + // OVERWORLD, and GAME OVER is TRANSITION -- which is why the TRANSITION word has to cover a level + // load, a death, a pause and a game over at once. BATTLE and SAFARI are unreachable for this + // adapter; they are here because the mode set is closed. + modeLabels: { + BOOT: 'booting', + OVERWORLD: 'in the level', + BATTLE: 'boss', + TRANSITION: 'not in play', + DEMO: 'attract demo', + SAFARI: 'bonus game', + UNKNOWN: 'unknown', + }, + + // Ticker rows are noun phrases, so a row reads as a log line rather than as narration. The keys + // are the feed's counters; the adapter kind each one carries is named in the comment. + rewardCopy: { + explore: { label: 'new ground', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'new ground' }, // band + wildwin: { label: 'coin', tier: 'quiet', dedupeMs: 15_000, collapsedNoun: 'coins' }, // coin + area: { label: 'points', tier: 'quiet', dedupeMs: 10_000, collapsedNoun: 'points' }, // score + pokedex: { label: 'power-up', tier: 'notable', dedupeMs: 0 }, // powerup + trainer: { label: '1UP', tier: 'notable', dedupeMs: 0 }, // life + story: { label: 'level cleared', tier: 'notable', dedupeMs: 0 }, // level + badge: { label: 'world cleared', tier: 'moment', dedupeMs: 0 }, // world + }, + + // `badges` carries the adapter's headline counter, which for this game is lives, so there is no + // denominator: a 1UP can push it past three. `uniqueLocations` is the band ledger, i.e. ten-column + // stretches of ground the fly has been paid for. + counters: [ + { field: 'badges', label: 'lives' }, + { field: 'uniqueLocations', label: 'ground' }, + ], + + // Ranks are hours apart here -- a level clear is a rare event for a fly at 250 ms per decision -- + // so the alarm sits where the Pokémon demo's does in spirit rather than in value. The design's + // 5-minute "time since the last new band" dial is a different measurement, and the feed carries no + // field for it yet; `milestone.sinceSeconds` is what this threshold reads. + stuckAlarmSeconds: 2 * 3600, +}; + +export default platformer; diff --git a/apps/stage/src/games/pokemon-red.ts b/apps/stage/src/games/pokemon-red.ts new file mode 100644 index 0000000..d2968c9 --- /dev/null +++ b/apps/stage/src/games/pokemon-red.ts @@ -0,0 +1,100 @@ +/** + * Per-game config for the first demo. + * + * This is the only file in the app that names this game. The feed header stays authoritative for + * the *current* rank's label and for how many rungs exist (`milestone.total`), so a ratchet change + * in the service shows up on screen without a page release. + * + * The full 38 labels are here anyway, and that is a decision rather than an oversight: the header + * carries `rank`, `label`, `next` and `total` but **not** the ladder's other 37 names, and the + * LADDER tab's whole job is to spell the ladder out — the visible long-horizon goal the research + * says holds an audience for months. So the list is transcribed from `docs/design/ladder.md`'s + * table, index for index with the service's own ratchet, and `tests/unit/rungs.test.ts` pins its + * length at the header's `total` for this game. The drift risk is real and is paid for with a test + * rather than by leaving the tab blank. + */ +import type { GameConfig } from './types'; + +export const pokemonRed: GameConfig = { + id: 'pokemon-red', + // Unaccented on purpose: Press Start 2P draws É at x-height, so "POKÉMON" reads as "POKéMON" — + // smaller and off next to the surrounding caps. `apps/stage/README.md` records the glyph check. + wordmark: 'A FLY BRAIN PLAYS POKEMON RED', + name: 'Pokémon Red', + + // 38 rungs, 0..37, transcribed from `docs/design/ladder.md`'s table. Short on purpose: they + // render three to a row in a 1008 px pane, and the current one also shares one 30 px mono line + // with the next rung in the progress cluster. + milestoneLadder: [ + 'Boot screen', + 'Bedroom', + 'Downstairs', + 'Pallet Town', + "Oak's lab", + 'Got a starter', + "Oak's parcel", + 'Pokédex', + 'Viridian City', + 'Viridian Forest', + 'Pewter City', + 'Boulder Badge', + 'Mt. Moon', + 'Cerulean City', + 'Cascade Badge', + 'Nugget Bridge', + 'Met Bill', + 'Vermilion City', + 'HM Cut', + 'Thunder Badge', + 'Rock Tunnel', + 'Lavender Town', + 'Celadon City', + 'Silph Scope', + 'Rainbow Badge', + 'Poké Flute', + 'Fuchsia City', + 'Soul Badge', + 'Silph Co. freed', + 'Marsh Badge', + 'Cinnabar Island', + 'Volcano Badge', + 'Earth Badge', + 'Indigo Plateau', + 'Beat Lorelei', + 'Beat Bruno', + 'Beat Agatha', + 'Champion', + ], + + // Short state words, not sentences: these read as a badge in the title strip. `UNKNOWN` has no + // real word — `TitleStrip` hides the chip entirely rather than show a placeholder. + modeLabels: { + BOOT: 'boot', + OVERWORLD: 'walking', + BATTLE: 'battle', + TRANSITION: 'menu', + DEMO: 'demo', + SAFARI: 'safari zone', + UNKNOWN: 'unknown', + }, + + // Ticker rows are noun phrases, so a row reads as a log line rather than as narration. + rewardCopy: { + explore: { label: 'new place', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'new places' }, + area: { label: 'new area', tier: 'notable', dedupeMs: 20_000, collapsedNoun: 'new areas' }, + wildwin: { label: 'wild win', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'wild wins' }, + trainer: { label: 'trainer beaten', tier: 'notable', dedupeMs: 0 }, + pokedex: { label: 'new Pokédex entry', tier: 'notable', dedupeMs: 0 }, + story: { label: 'story', tier: 'notable', dedupeMs: 0 }, + badge: { label: 'gym badge', tier: 'moment', dedupeMs: 0 }, + }, + + counters: [ + { field: 'badges', label: 'badges', outOf: 8 }, + { field: 'uniqueLocations', label: 'places' }, + ], + + stuckAlarmSeconds: 6 * 3600, +}; + +export default pokemonRed; diff --git a/apps/stage/src/games/types.ts b/apps/stage/src/games/types.ts new file mode 100644 index 0000000..1003ec5 --- /dev/null +++ b/apps/stage/src/games/types.ts @@ -0,0 +1,77 @@ +/** + * The per-game config contract. + * + * There will be a second demo (a Game Boy platformer), so the page is game-agnostic by + * construction: the *live values* always come from the feed header (`milestone.label`, + * `milestone.next`, `game.mode`, `game.rewardCounts` keys, `game.badges`), and a game config + * supplies only the human copy for those values plus the wordmark. The config is selected by + * `?game=` and resolved in `src/games/index.ts`. + * + * The rule this encodes: no game's name, ladder or vocabulary appears anywhere outside its own + * file in `src/games/`. Grep for the game's name and you should find exactly one file. + */ +import type { GameMode, RewardKind } from '@flybrain/feed'; + +/** How loudly a reward is presented in the ticker (design A3: value tiers drive presentation). */ +export type RewardTier = 'quiet' | 'notable' | 'moment'; + +/** Human copy for one reward kind the adapter can report. */ +export interface RewardCopy { + /** Short ticker text. Constant; never interpolated with feed values beyond the amount. */ + label: string; + tier: RewardTier; + /** + * Identical events within this window collapse into one ticker row with a count + * ("+3 new places"). 0 disables collapsing for the kind. + */ + dedupeMs: number; + /** Plural noun for a collapsed row, e.g. `new places`. */ + collapsedNoun?: string; +} + +/** A counter shown in the progress row beside the milestone ladder. */ +export interface GameCounter { + /** Which `FeedGame` field to read. */ + field: 'badges' | 'uniqueLocations' | 'rewardTotal'; + label: string; + /** Denominator for a "3 of 8" readout, when the total is known and fixed. */ + outOf?: number; +} + +export interface GameConfig { + /** Matches the file name and the `?game=` value. */ + id: string; + /** + * Title-strip wordmark, rendered in Press Start 2P. + * + * Press Start 2P is one em per character and the strip is 1216 px wide, so at the 18 px the + * strip uses this has to stay at or under about 30 characters to leave room for the subtitle + * and the status badges. Measured, not guessed. + */ + wordmark: string; + /** Human name used in the sugar honesty line and the explainer cards. */ + name: string; + /** + * Fallback rung labels, index = `milestone.rank`. + * + * The feed is authoritative for both the current rung's label and the number of rungs + * (`milestone.total`); this is what the panel falls back to when a header carries neither, which + * is a recorded fixture or a flysim older than `total`. So its length no longer defines how many + * rungs exist, and it does not have to match the service's ladder rung for rung. + */ + milestoneLadder: readonly string[]; + /** Human copy for each `game.mode` the adapter can report. */ + modeLabels: Record; + /** Human copy and presentation tier for each reward kind. */ + rewardCopy: Record; + /** + * Counters shown on the progress cluster's footer line (`ProgressCluster`), e.g. + * "3/8 badges · 214 places". Per-game *data*, not copy: the values come + * from the matching `FeedGame` field, and this config only supplies the label and denominator. + */ + counters: readonly GameCounter[]; + /** Stuck-o-meter threshold in simulated seconds past which the panel changes colour. */ + stuckAlarmSeconds: number; + /** True while the config is a placeholder for a demo that does not exist yet. */ + stub?: boolean; +} diff --git a/apps/stage/src/index.css b/apps/stage/src/index.css new file mode 100644 index 0000000..7dfe114 --- /dev/null +++ b/apps/stage/src/index.css @@ -0,0 +1,391 @@ +@import 'tailwindcss'; +@import './theme/tokens.css'; +@import './theme/panels.css'; +@import './theme/rail.css'; +@import './theme/motion.css'; + +/** + * Self-hosted OFL faces. `font-display: block` with the default 3 s block period is deliberate: + * this page paints once and then broadcasts for weeks, so a flash of fallback text is worse than + * a late first paint, and `data-ready` gates the capture on `document.fonts.ready` anyway. + * + * Two faces are on the page (`docs/design/gameboy-theme.md`): Press Start 2P for titles, rung + * names, big numbers, tab labels and chips, and one pixel monospace for everything else. Inter + * and IBM Plex Mono are gone — the document drops them "from the page entirely" — and so are + * their files. + * + * All three of that document's body candidates are declared, because only the one `--font-body` + * names is ever fetched: an unused `@font-face` costs nothing, and having them all here is what + * makes the pick a one-line change in `src/theme/tokens.css` rather than a commit. They are + * subset to the same Latin repertoire as the Press Start 2P subset (`tools/font-compare.mts` + * documents how, and `mockups/gameboy-fonts.png` is the frame they were picked from). + * + * The `*-Fallback` faces carry `size-adjust` so that if a load ever fails the metrics still match + * and the grid does not reflow (design A4, risk 3). Against DejaVu Sans Mono's 0.602 em advance: + * VT323's is 0.4, hence 66%, with ascent/descent 0.8/0.2 em; Silkscreen's mean is 0.734, hence + * 122%, with 1.04/0.26. Silkscreen is proportional, so 122% matches its *average* line rather + * than every glyph — which is the best a single number can do, and is why the fallback is a + * safety net for a failed load and not a layout the page is designed against. + */ +@font-face { + font-family: 'Press Start 2P'; + font-style: normal; + font-weight: 400; + font-display: block; + src: url('/fonts/PressStart2P-Latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'VT323'; + font-style: normal; + font-weight: 400; + font-display: block; + src: url('/fonts/VT323-Latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'Silkscreen'; + font-style: normal; + font-weight: 400; + font-display: block; + src: url('/fonts/Silkscreen-Latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'Pixelify Sans'; + font-style: normal; + font-weight: 400; + font-display: block; + src: url('/fonts/PixelifySans-Latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'VT323 Fallback'; + src: local('DejaVu Sans Mono'), local('Courier New'); + size-adjust: 66%; + ascent-override: 80%; + descent-override: 20%; + line-gap-override: 0%; +} + +@font-face { + font-family: 'Silkscreen Fallback'; + src: local('DejaVu Sans Mono'), local('Courier New'); + size-adjust: 122%; + ascent-override: 104%; + descent-override: 26%; + line-gap-override: 0%; +} + +@font-face { + font-family: 'Press Start 2P Fallback'; + src: local('DejaVu Sans Mono'), local('Courier New'); + size-adjust: 78%; + ascent-override: 100%; + descent-override: 25%; + line-gap-override: 0%; +} + +/** + * Tailwind v4 CSS-first theme. Only the tokens the utilities need are mapped; the panels read the + * CSS variables directly, which keeps one source of truth for a theme swap at runtime. + * + * `--font-sans` and `--font-mono` are the same face now: there is no separate text face on the + * page at all, so a utility asking for either gets the body pick. `--font-mono` is a Tailwind name + * rather than a promise — Silkscreen is a pixel face, not a fixed-advance one, and `.num` is where + * the page gets fixed advances from. + */ +@theme { + --color-bg-0: var(--bg-0); + --color-bg-1: var(--bg-1); + --color-bg-2: var(--bg-2); + --color-panel: var(--panel); + --color-bezel: var(--bezel); + --color-ink-0: var(--ink-0); + --color-ink-1: var(--ink-1); + --color-ink-2: var(--ink-2); + --color-accent: var(--accent); + --color-accent-warm: var(--accent-warm); + --color-sensory: var(--sensory); + --color-motor: var(--motor); + --color-dopamine: var(--dopamine); + --color-ok: var(--ok); + --color-warn: var(--warn); + --color-alarm: var(--alarm); + + --font-sans: var(--font-body); + --font-mono: var(--font-body); + + --radius-panel: var(--radius); +} + +@layer base { + * { + box-sizing: border-box; + } + + html, + body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + /* No scrollbars, ever: the structural test asserts it (audit finding). */ + overflow: hidden; + background: var(--bg-0); + color: var(--ink-0); + font-family: var(--font-body); + font-size: var(--fs-body); + /* + * No ligatures anywhere. A readout page has no use for them, and Pixelify Sans's f-l ligature + * renders as a capital A at the body floor ("silly fly" came out "silly Ay" in the candidate + * frame), which is the sort of thing that reaches air unnoticed. + * + * No `font-variation-settings` and no `cv05`: neither face is variable and neither has stylistic + * sets. No `tnum` either, but for a blunter reason — measured, Silkscreen carries no such + * feature, so asking for it here would read as a guarantee the page does not have. The + * guarantee lives on `.num` instead, which changes face rather than asking a face for a + * table it has not got. + */ + font-variant-ligatures: none; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + + /* The page is a broadcast surface: no selection, no caret, no focus rings in the frame. */ + body { + user-select: none; + cursor: none; + } + + canvas { + display: block; + } +} + +/* -- The stage ------------------------------------------------------------------------------- */ + +#stage { + position: relative; + width: 1920px; + height: 1080px; + overflow: hidden; + transform-origin: top left; + background-color: var(--bg-0); + /* A grid wash, flat colours only, so the encoder has something stable to chew on. */ + background-image: + linear-gradient(to right, var(--grid) 0 1px, transparent 1px 100%), + linear-gradient(to bottom, var(--grid) 0 1px, transparent 1px 100%); + background-size: 48px 48px; +} + +/* + * 1920x1080 is the authoring size and the broadcast size, so it needs no transform at all. + * `?res=720` is the 2/3 downscale for thumbnails and the downscale tests: 1920 x 0.6667 = 1280. + */ +:root[data-res='720'] #stage { + transform: scale(0.6667); +} + +/* -- Panels: the Game Boy dialogue box ------------------------------------------------------- */ + +/** + * `docs/design/gameboy-theme.md`: "Every panel is a Game Boy dialogue box: square corners, a 4 px + * outer border with a 2 px inner line (the double frame), no drop shadows, no rounded radius." + * + * The outer frame is a real `border`, so the panel's box arithmetic is unchanged (`box-sizing: + * border-box`, and `src/lib/geometry.ts`'s `BORDER_WIDTH` is the same 4). The inner line is + * `::after` at `inset: 0` of the padding box, which puts it immediately inside the frame and, being + * the last generated child, over the panel's own contents rather than under them — the DMG's text + * boxes draw their inner rule the same way round. + * + * `::before` is still the scanline wash, so the two do not collide. + */ +.panel { + position: absolute; + background: var(--panel); + border: var(--border-w) solid var(--frame); + border-radius: var(--radius); + overflow: hidden; +} + +.panel::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border: var(--border-inner-w) solid var(--frame-inner); +} + +.panel__body { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + padding: 9px 12px; + gap: 6px; +} + +/* Panel body texture / scanlines: never behind text, so it is a pseudo-element under content. */ +.panel::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + to bottom, + color-mix(in srgb, var(--ink-0) 100%, transparent) 0 1px, + transparent 1px 3px + ); + opacity: var(--scanline-opacity); +} + +/** + * A panel title is a *title*, so it is `--font-label` (Silkscreen) — the 2026-09-16 pairing's + * chip and title face, not Press Start 2P: RUNG, HERE FOR, BRAIN, CIRCUITS, RETINA, EVENTS and + * CHAT are all short, uppercase and closed-vocabulary, which is exactly what Silkscreen's boxy + * weight suits and where it never runs into the width tax that moved everything else to VT323 + * (`src/theme/tokens.css`). + * + * `font-weight` is gone from everything in the pixel faces: neither has a bold, so a 700 here is + * a synthetic smear, which at the body floor is the difference between a readable label and a + * blurred one. Where layout v1 used weight to mark something, this theme uses the accent colour + * or the pixel cursor. + * + * 1.05, not Silkscreen's own 1.30 em box: its *ink* is only 0.76 em (0.63 ascent, 0.13 descent, + * measured), so the fixed-height rows this sits above keep the leading they were tuned at and + * clip nothing — the extra 0.30 em of the declared box is side bearing, not ink. + */ +.panel-title { + font-family: var(--font-label); + font-size: var(--fs-body); + letter-spacing: 0.06em; + color: var(--ink-2); + text-transform: uppercase; + line-height: 1.05; + flex: 0 0 auto; +} + +/* -- Type roles the text-size lint checks ---------------------------------------------------- */ + +[data-role='body'] { + font-size: var(--fs-body); + color: var(--ink-1); + line-height: 1.25; +} + +[data-role='label'] { + font-size: var(--fs-label); + color: var(--ink-0); + line-height: 1.15; +} + +/* + * 1, not 1.1: the body face's *ink* is 0.76 em at the outside (Silkscreen, measured: 0.63 ascent, + * 0.13 descent), and the progress cluster is 144 px for four rows — the 4 px of leading this gives + * back is what keeps the panel from clipping its own footer. The face's declared box is 1.30 em, + * but that is side bearing and nothing is drawn in it. + */ +[data-role='label-lg'] { + font-size: var(--fs-label-lg); + color: var(--ink-0); + line-height: 1; +} + +[data-role='hero'] { + font-family: var(--font-pixel); + font-size: var(--fs-hero); + color: var(--ink-0); + line-height: 1.3; +} + +/* + * Digits that change, in the one monospaced face on the page. + * + * `docs/design/gameboy-theme.md`: "Tabular digits everywhere numbers change (monospace makes this + * automatic)." With Silkscreen as the body pick it is not automatic — the face is proportional and + * has no `tnum`, and every digit is 20/27 em except "1", which is 17 — so a run clock ticking at + * 1 Hz and a Hz readout lerped at 30 Hz would each shuffle 3 px sideways per "1" that came or + * went, and the two cluster readouts would drag the whole rung line with them. + * + * So `.num` is Press Start 2P (`--font-num`), which is exactly one em per character. That is the + * design's own face for "big numbers", and every element carrying this class is a number the + * paint loop rewrites: the rung count, HERE FOR, the Hz, the try count, the run clock. + * + * `font-variant-numeric` stays for the same reason the fallback stack does: it costs nothing and + * it is correct the moment `--font-num` points at a face that honours it. + */ +.num { + font-family: var(--font-num); + font-variant-numeric: tabular-nums; +} + +/** + * Press Start 2P, for the two things that still carry it: `.num` above, and the wordmark + * (`src/panels/TitleStrip.tsx`). Needs a taller line box than 1.0 or a `truncate` ancestor clips + * its descenders. + */ +.pixel, +[data-role='body'].pixel, +[data-role='label'].pixel, +[data-role='label-lg'].pixel { + font-family: var(--font-pixel); + letter-spacing: 0; + line-height: 1.3; +} + +/** + * The `--font-label` counterpart to `.pixel`, for a chip or glyph that is not a `.panel-title` and + * not a chip built on `.chip-cut` (which sets the same face itself): today, only the fly strip's + * eight button-row glyphs (`src/panels/FlyStrip.tsx`). Silkscreen's ink box is short enough that it + * needs none of `.pixel`'s extra leading. + */ +.label-face { + font-family: var(--font-label); + letter-spacing: 0; + line-height: 1; +} + +/* -- Opaque backing plate for text that crosses the map (design A2) -------------------------- */ + +.plate { + background: color-mix(in srgb, var(--bg-0) 88%, transparent); + border-radius: 0; + padding: 3px 9px; +} + +/* -- The pixel cursor ------------------------------------------------------------------------ */ + +/** + * The selection cursor, as a *drawn* triangle rather than a typed glyph. + * + * `docs/design/gameboy-theme.md` asks for "Selection cursor '▶' (as a small pixel triangle)" on + * the current rung and the active tab, and none of the three body candidates carries U+25B6 (nor + * U+2192, which is why the rung line's arrow is drawn from this same shape). A `clip-path` + * staircase is the honest way to get it: seven 2 px rows, so it is pixel art at the page's own + * resolution instead of a smooth vector triangle that the encoder would soften. + */ +.cursor { + flex: 0 0 auto; + width: 8px; + height: 14px; + background: currentcolor; + clip-path: polygon( + 0 0, + 2px 0, + 2px 2px, + 4px 2px, + 4px 4px, + 6px 4px, + 6px 6px, + 8px 6px, + 8px 8px, + 6px 8px, + 6px 10px, + 4px 10px, + 4px 12px, + 2px 12px, + 2px 14px, + 0 14px + ); +} diff --git a/apps/stage/src/lib/chatters.ts b/apps/stage/src/lib/chatters.ts new file mode 100644 index 0000000..ed673f4 --- /dev/null +++ b/apps/stage/src/lib/chatters.ts @@ -0,0 +1,72 @@ +/** + * "Somebody new is here": the first sighting of a display name, which the rail answers by putting + * DESCRIBE on the slot for a few seconds (`docs/design/describe-tab.md`, 2026-09-17). + * + * The operator's ask, verbatim: "when new user joins chat, switch to it for a few sec, cool down timer." + * The hold and the cooldown are the tab controller's, because they are cadence and cadence lives + * in `src/lib/tabs.ts` (`NEW_CHATTER_HOLD_MS`, `NEW_CHATTER_COOLDOWN_MS`). This module answers the + * one question the controller cannot: *is this name new*. + * + * Three ways a line is not a new chatter, and all three matter on a 24/7 broadcast: + * + * - **the bridge's own replies.** `bot: true` lines are flybridgebot answering a viewer + * (`services/bridge/src/onscreen-chat.ts`), and the bot is not a person arriving. It is still + * recorded as seen, so nothing about it can trigger later either. + * - **history.** `header.chat` is a ring the feed re-sends every snapshot, so the lines present + * when the page connected were said before anyone was watching *this* page, and a socket + * reconnect hands the whole ring over again. A line older than the page's connect time is + * therefore never a trigger — which is also what keeps a recorded fixture inert: its lines + * carry the wall time of the recording, hours or days before the page loaded, so replaying + * `steady` cannot make the slot jump (and cannot move a screenshot baseline). + * - **a name already seen.** Every line's name is recorded whether or not it triggered, so the + * second sighting is never the first, including after the cooldown swallowed the first switch. + * + * Names are keyed case-folded: Twitch display names differ from logins only by case and + * punctuation, and `Dendrite` arriving after `dendrite` is the same person. + * + * Pure and DOM-free, clock supplied by the caller, so `tests/unit/new-chatter.test.ts` drives the + * whole thing with a scripted chat ring. + */ +import type { ChatLine } from '@/chat/types'; + +export class ChatterWatch { + private readonly seen = new Set(); + + /** + * Wall-clock instant the page connected. Lines older than this are history, never arrivals. + * + * Wall clock rather than the page's data clock on purpose: the data clock is virtual under a + * fixture and freezes on a held seek, while `ChatLine.wallMs` is a real `Date.now()` from + * whoever accepted the line. Comparing the two in the same units is the only version of this + * test that is right for both a live socket and a replay. + */ + constructor(private readonly connectedAtWallMs: number) {} + + /** A fixture seek or loop: nothing this page saw, it saw. The connect time is not a seek's to move. */ + reset(): void { + this.seen.clear(); + } + + /** + * Record one snapshot's chat ring. True when at least one line in it is somebody new arriving. + * + * Every line is recorded as seen either way, so a name that shows up while the cooldown is + * running does not get a second chance at it later. + */ + observe(lines: readonly ChatLine[] | undefined): boolean { + if (!lines || lines.length === 0) return false; + let arrived = false; + for (const line of lines) { + const key = line.by.toLowerCase(); + const fresh = !this.seen.has(key); + this.seen.add(key); + if (fresh && line.bot !== true && line.wallMs >= this.connectedAtWallMs) arrived = true; + } + return arrived; + } + + /** How many distinct names this page has seen, for `window.__stage` and the tests. */ + get count(): number { + return this.seen.size; + } +} diff --git a/apps/stage/src/lib/circuit-scale.ts b/apps/stage/src/lib/circuit-scale.ts new file mode 100644 index 0000000..c16b1ff --- /dev/null +++ b/apps/stage/src/lib/circuit-scale.ts @@ -0,0 +1,161 @@ +/** + * How a NAMED CIRCUITS bar decides what "full" means, without ever being told the feed's + * absolute Hz range. + * + * The bug this replaces: the panel used to divide every role's rate by a fixed `fullScaleHz` + * picked against the fixture generator's own numbers (`src/lib/labels.ts`). The first live run + * (`infra/docs/p0-local-encoded-frame.png`) showed every command bar pegged at 100% because the + * real per-role rates run far above what the fixture ever produced — a fixed ceiling can always + * be exceeded by a feed nobody measured yet. + * + * The fix is the same idea `docs/readout.md` already uses for the decoder itself: score a rate + * against a *reference* recorded from the feed, not against a constant. `PopulationDecoder` + * scores `(rate + 1) / (baseline + 1)` against a baseline captured once at calibration. A bar + * cannot do that — the page never sees the decoder's calibration baseline — so `CircuitScale` + * keeps its own slow-moving reference per role instead: an envelope that climbs toward a + * sustained high rate over `attackHalfLifeMs` and relaxes back down over `releaseHalfLifeMs`. + * + * Two time constants, not one, is what makes a burst read as a burst instead of just being + * "the new normal" a frame later: a fast pulse (an A/B press, well under a second) barely moves + * an envelope with a multi-second attack, so it still reads near full scale while it lasts, and + * the envelope only relaxes back toward the lower steady rate afterward, over tens of seconds + * — which is also why a bar sits mid-scale rather than pinned at 100% once the reference has + * caught up: `headroom` keeps the fill at `1 / headroom` when the rate exactly equals its own + * reference, leaving room above for the next burst to still be visible as one. + */ + +/** How far above its own reference a role's rate reads as "full", so steady state has headroom + * left for a real burst to still stand out. 1.5 puts a settled bar at 1/1.5 ≈ 67%. */ +export const DEFAULT_HEADROOM = 1.5; + +/** How fast the reference climbs toward a sustained higher rate. */ +export const DEFAULT_ATTACK_HALF_LIFE_MS = 4_000; + +/** How fast the reference relaxes back down once the rate drops — the "decays back" half of the + * unit test, and roughly the 60 s memory window the fix asks for (three half-lives is ~87%). */ +export const DEFAULT_RELEASE_HALF_LIFE_MS = 20_000; + +/** Never let a reference (or the median below) collapse toward zero during a quiet boot. */ +export const DEFAULT_FLOOR_HZ = 1; + +export interface CircuitScaleOptions { + attackHalfLifeMs?: number; + releaseHalfLifeMs?: number; + headroom?: number; + floorHz?: number; +} + +/** Clamp a raw Hz value to a `[0, 1]` bar fill against a reference, `headroom` included. Pure — + * used for the live rate, the peak-hold dot and the threshold tick alike, so all three read off + * the same scale. */ +export function circuitFraction(valueHz: number, referenceHz: number, headroom: number = DEFAULT_HEADROOM): number { + const safeReference = Math.max(referenceHz, 1e-6) * headroom; + const fraction = Math.max(0, valueHz) / safeReference; + return Math.max(0, Math.min(1, fraction)); +} + +/** + * Per-role adaptive envelope. One instance per bar role, fed every snapshot (not every animation + * frame) so a fixture seek's silent catch-up replay builds the same reference a live viewer would + * have watched settle in real time (`src/feed/fixture.ts`'s seek contract). + */ +export class CircuitScale { + private reference: number; + private lastMs: number | null = null; + private readonly attackHalfLifeMs: number; + private readonly releaseHalfLifeMs: number; + private readonly headroom: number; + private readonly floorHz: number; + + constructor(seedHz: number, options: CircuitScaleOptions = {}) { + this.attackHalfLifeMs = options.attackHalfLifeMs ?? DEFAULT_ATTACK_HALF_LIFE_MS; + this.releaseHalfLifeMs = options.releaseHalfLifeMs ?? DEFAULT_RELEASE_HALF_LIFE_MS; + this.headroom = options.headroom ?? DEFAULT_HEADROOM; + this.floorHz = options.floorHz ?? DEFAULT_FLOOR_HZ; + this.reference = Math.max(seedHz, this.floorHz); + } + + /** Advance the envelope to `nowMs` given the latest rate. Call once per role per snapshot. */ + observe(valueHz: number, nowMs: number): void { + const value = Math.max(0, valueHz); + if (this.lastMs !== null) { + const dtMs = Math.max(0, nowMs - this.lastMs); + const halfLife = value >= this.reference ? this.attackHalfLifeMs : this.releaseHalfLifeMs; + const decay = halfLife > 0 ? Math.pow(0.5, dtMs / halfLife) : 0; + this.reference = value + (this.reference - value) * decay; + } + this.lastMs = nowMs; + this.reference = Math.max(this.reference, this.floorHz); + } + + /** `observe` then read the fraction back in one call — the common case for a live bar. */ + update(valueHz: number, nowMs: number): number { + this.observe(valueHz, nowMs); + return this.toFraction(valueHz); + } + + /** Project any Hz value (the peak-hold dot, an inferred threshold) against the *current* + * reference without advancing it. */ + toFraction(valueHz: number): number { + return circuitFraction(valueHz, this.reference, this.headroom); + } + + get referenceHz(): number { + return this.reference; + } +} + +/** + * A cheap streaming approximation of the running median, used as the display's stand-in for the + * decoder's real calibration baseline (`docs/readout.md`'s `(rate + 1) / (baseline + 1)` score), + * which the feed protocol does not carry to the page (`docs/feed-protocol.md`'s `FeedHeader` has + * no baseline field). It is not an exact order statistic — it nudges toward the value at a fixed + * Hz-per-second rate rather than maintaining a sorted window — but it converges to the true + * median of a role's rate over tens of seconds, which is precise enough for a faint tick mark. + * Documented here and in `docs/readout.md` per the fix's own "document it" instruction. + */ +export class RunningMedian { + private median: number; + private readonly stepHzPerMs: number; + private readonly floorHz: number; + + constructor(seedHz: number, stepHzPerSecond = 2, floorHz: number = DEFAULT_FLOOR_HZ) { + this.floorHz = floorHz; + this.median = Math.max(seedHz, floorHz); + this.stepHzPerMs = stepHzPerSecond / 1000; + } + + observe(valueHz: number, dtMs: number): void { + const value = Math.max(0, valueHz); + const step = this.stepHzPerMs * Math.max(0, dtMs); + if (value > this.median) this.median = Math.min(value, this.median + step); + else if (value < this.median) this.median = Math.max(value, this.median - step); + this.median = Math.max(this.median, this.floorHz); + } + + get medianHz(): number { + return this.median; + } +} + +/** + * Invert the decoder's own score formula (`docs/readout.md`: `score = (rate + 1) / (baseline + + * 1)`) to find the rate a role would need to cross a given decision threshold, using the running + * median in place of the real baseline. This is what the faint threshold tick is positioned at. + */ +export function thresholdRateHz(decisionThreshold: number, medianHz: number): number { + return Math.max(0, decisionThreshold * (Math.max(0, medianHz) + 1) - 1); +} + +/** + * The decoder's decision threshold (`docs/readout.md`), by `CircuitGroup.id`, for the two groups + * the readout actually gates on a fixed score: A/B at 1, Start/Select at 1.35 after boot. The + * drive D-pad is an exclusive argmax with no fixed threshold, so it is absent here. + * + * TODO: this belongs on `CircuitGroup` in `src/lib/labels.ts` next to `fullScaleHz` — kept here + * instead for now because another pass is editing that file's copy concurrently with this fix. + */ +export const CIRCUIT_DECISION_THRESHOLD: Record = { + press: 1, + menu: 1.35, +}; diff --git a/apps/stage/src/lib/describe.ts b/apps/stage/src/lib/describe.ts new file mode 100644 index 0000000..35de800 --- /dev/null +++ b/apps/stage/src/lib/describe.ts @@ -0,0 +1,86 @@ +/** + * The DESCRIBE tab's placeholder resolver: the half of the copy that is not copy. + * + * `src/games/describe.ts` holds every word the tab says and nothing else, so the live numbers it + * quotes arrive as `{…}` placeholders and are filled here from the page's own sources — the loaded + * dataset metadata (`store.dataset`), the game config's name, and the build's version string. The + * doc's rule, verbatim: "names and numbers come from the feed where they exist (neuron count, + * version); the rest is static copy". + * + * Pure and DOM-free, so `tests/unit/describe.test.ts` can render every card against the dataset's + * own `meta.json` and compare the result with the doc. + * + * An unknown value renders as the page's own em dash rather than a guess or a hardcoded default: a + * card that quoted a baked-in 139,255 while the dataset said something else would be the one thing + * this tab cannot afford, which is a sentence that is not true. + */ +import { formatCount } from './format'; + +/** Everything a card may interpolate. Keys are the placeholder names, minus the braces. */ +export interface DescribeValues { + /** Dataset neuron count, or null until `meta.json` has landed. */ + neurons: number | null; + /** Dataset connection count, or null. */ + synapses: number | null; + /** The game config's human name: the only place a game is named. */ + game: string; + /** Dataset version, e.g. `v783`, or null. */ + dataset: string | null; + /** Release version from the build (`__STAGE_VERSION__`). */ + version: string; +} + +/** The placeholders `fill` resolves. A copy edit may use any of these and no others. */ +export const DESCRIBE_PLACEHOLDERS: readonly (keyof DescribeValues)[] = [ + 'neurons', + 'synapses', + 'game', + 'dataset', + 'version', +]; + +/** What an unfilled number reads as, matching `src/lib/format.ts`. */ +const UNKNOWN = '—'; + +/** + * A synapse count as the copy says it: `2.7 million`, not `2,700,513`. + * + * The doc's register is "every sentence a fact", and 2,700,513 in the middle of a sentence is a + * number a viewer stops to parse. One decimal, trailing `.0` dropped, and anything under a million + * falls back to plain separators — a smaller connectome would be a different dataset, not a + * rounding problem. + */ +export function formatMillions(value: number): string { + if (!Number.isFinite(value)) return UNKNOWN; + if (Math.abs(value) < 1_000_000) return formatCount(value); + const millions = (value / 1_000_000).toFixed(1).replace(/\.0$/, ''); + return `${millions} million`; +} + +/** Placeholder name -> rendered string. */ +function resolve(values: DescribeValues): Record { + return { + neurons: values.neurons === null ? UNKNOWN : formatCount(values.neurons), + synapses: values.synapses === null ? UNKNOWN : formatMillions(values.synapses), + game: values.game, + dataset: values.dataset ?? UNKNOWN, + version: values.version, + }; +} + +/** + * Fill one card's text. + * + * An unknown placeholder is left exactly as it was written, braces and all, so a typo shows up on + * the mockup a reviewer is looking at instead of silently deleting half a sentence on air. The + * unit test fails on one, which is where it is meant to be caught. + */ +export function fillDescribe(text: string, values: DescribeValues): string { + const table = resolve(values); + return text.replace(/\{(\w+)\}/g, (whole, key: string) => table[key] ?? whole); +} + +/** Every placeholder a card's text uses, in order of appearance. */ +export function placeholdersIn(text: string): string[] { + return [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1] as string); +} diff --git a/apps/stage/src/lib/fit.ts b/apps/stage/src/lib/fit.ts new file mode 100644 index 0000000..26e65df --- /dev/null +++ b/apps/stage/src/lib/fit.ts @@ -0,0 +1,88 @@ +/** + * Uniform, aspect-preserving fit of a 2D point cloud into a rectangular canvas. + * + * One scale for both axes — never two — sized so the whole extent fits inside the box without + * cropping either axis, and centered (letterboxed on whichever axis has slack). Shared by the + * connectome's base raster, its density-accumulator LUT and its PAM-centroid flare origin + * (`workers/brain-base.worker.ts`), and by their unit tests, so none of them can compute a + * different scale than the others and quietly pile points onto an edge. + * + * The one thing this deliberately does not do is clamp: a point outside the fitted box is + * dropped (`project` returns `null`). Fitting a point cloud to its own true extent, with margin, + * means nothing should ever fall outside it — but "should never happen" is exactly the case a + * clamp turns into a silent pileup on the first or last row instead of a bug report. + */ + +/** Margin so the extreme point sits just inside the edge, never touching it. */ +export const FIT_MARGIN = 0.97; + +/** The position-space box that a uniform letterboxed fit maps onto a `width` x `height` canvas. */ +export interface HalfExtent { + /** Half-width, in the point cloud's own units, that maps exactly to the canvas's left/right edge. */ + x: number; + /** Half-height, in the point cloud's own units, that maps exactly to the canvas's top/bottom edge. */ + y: number; +} + +/** + * The half-extents of the box that fits `maxAbsX` x `maxAbsY` inside `width` x `height`. + * + * `Math.min` picks whichever axis is the tighter fit, and the same resulting scale sizes both + * halves, so whatever is plotted with them keeps its own aspect ratio — it is letterboxed on the + * other axis, never stretched to fill it. + */ +export function fitHalfExtent( + maxAbsX: number, + maxAbsY: number, + width: number, + height: number, + margin = FIT_MARGIN, +): HalfExtent { + const scale = Math.min(width / (2 * (maxAbsX || 1)), height / (2 * (maxAbsY || 1))) * margin; + return { x: width / 2 / scale, y: height / 2 / scale }; +} + +/** + * The half-extents of a flattened xyz point cloud on x and y, which is what `fitHalfExtent` has + * to be fed to fit that cloud to a canvas. + * + * Shared rather than inlined at the one call site (`brain-base.worker.ts`) so that the unit tests + * can fit the real dataset the way the worker fits it — the worker itself cannot be imported into + * a test, since it installs a `self` message listener on load. A loop this small is exactly the + * kind that gets copied into a test, drifts, and leaves the test passing over a fit the worker no + * longer computes. + */ +export function pointCloudExtent(positions: Float32Array): { maxAbsX: number; maxAbsY: number } { + const count = Math.floor(positions.length / 3); + let maxAbsX = 0; + let maxAbsY = 0; + for (let i = 0; i < count; i++) { + maxAbsX = Math.max(maxAbsX, Math.abs(positions[i * 3] as number)); + maxAbsY = Math.max(maxAbsY, Math.abs(positions[i * 3 + 1] as number)); + } + return { maxAbsX, maxAbsY }; +} + +/** + * Project one centered point into pixel coordinates against `halfExtent`, or `null` when it + * falls outside the fitted box. + * + * Dropped, never clamped: an out-of-range point disappears rather than piling onto the first or + * last row or column. + */ +export function project( + x: number, + y: number, + halfExtent: HalfExtent, + width: number, + height: number, +): { x: number; y: number } | null { + const nx = x / halfExtent.x; + const ny = y / halfExtent.y; + if (nx < -1 || nx > 1 || ny < -1 || ny > 1) return null; + return { + x: Math.min(width - 1, Math.round(((nx + 1) / 2) * (width - 1))), + // Screen y grows downward; the point cloud's y grows upward. + y: Math.min(height - 1, Math.round(((1 - ny) / 2) * (height - 1))), + }; +} diff --git a/apps/stage/src/lib/format.ts b/apps/stage/src/lib/format.ts new file mode 100644 index 0000000..453d281 --- /dev/null +++ b/apps/stage/src/lib/format.ts @@ -0,0 +1,130 @@ +/** + * Number and duration formatting for the readouts. + * + * Everything here is tabular-safe: fixed decimal places and fixed-width unit words, so a value + * changing 30 times a second never reflows the layout (the jitter the audit found on the old + * page). The face does the rest — `.num` in `src/index.css`, which is Press Start 2P at exactly + * one em per character, because the body face (Silkscreen) is proportional and has no `tnum`. + */ + +/** `3 h 41 m`, `12 m 04 s`, `48 s`. Coarse on purpose: a stream clock is not a stopwatch. */ +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '—'; + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + + if (hours > 0) return `${hours} h ${String(minutes).padStart(2, '0')} m`; + if (minutes > 0) return `${minutes} m ${String(secs).padStart(2, '0')} s`; + return `${secs} s`; +} + +/** + * `3h41m`, `12m04s`, `48s`: the same information as {@link formatDuration} with no spaces. + * + * Used for HERE FOR and the two "ago" readouts. Layout v2 has no hero (README deviation 5), but + * the six-character ceiling this form guarantees matters more than it did: HERE FOR is Press + * Start 2P at 33 px, a full em per character, in a column that starts at five characters. The + * spaced form would be nine and take the width out of the rung line beside it. + */ +export function formatDurationCompact(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '—'; + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + + if (hours > 0) return `${hours}h${String(minutes).padStart(2, '0')}m`; + if (minutes > 0) return `${minutes}m${String(secs).padStart(2, '0')}s`; + return `${secs}s`; +} + +/** + * `0:47`, `2:00`: the stall meter, which is the one readout on the page measured in seconds. + * + * Its window is 120 s (`docs/design/ladder.md`), so `formatDurationCompact`'s "48s" then "1m00s" + * changes width halfway through and the meter jumps. Minutes and seconds, always, fixed width. + */ +export function formatMinutesSeconds(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; + const total = Math.floor(seconds); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; +} + +/** `104:12:33`, for the run clock where every digit is wanted. */ +export function formatClock(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '--:--:--'; + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; +} + +/** Day 1 is the first 24 h of simulated run time. */ +export function dayNumber(runSeconds: number): number { + if (!Number.isFinite(runSeconds) || runSeconds < 0) return 1; + return Math.floor(runSeconds / 86_400) + 1; +} + +/** One decimal, always. `13.2`. */ +export function formatHz(hz: number): string { + if (!Number.isFinite(hz)) return '—'; + return hz.toFixed(1); +} + +/** Thousands separators with a thin space, which survives the encoder better than a comma. */ +export function formatCount(value: number): string { + if (!Number.isFinite(value)) return '—'; + return Math.round(value).toLocaleString('en-US'); +} + +/** `+0.05`, `+3.0`. Reward values are always shown signed so a tick reads as a gain. */ +export function formatReward(value: number): string { + if (!Number.isFinite(value)) return '—'; + const decimals = Math.abs(value) >= 1 ? 1 : 2; + return `${value >= 0 ? '+' : '−'}${Math.abs(value).toFixed(decimals)}`; +} + +/** `0.98x`, the realtime factor. */ +export function formatRealtime(factor: number): string { + if (!Number.isFinite(factor)) return '—'; + return `${factor.toFixed(2)}x`; +} + +/** Seconds remaining on a cooldown, rounded up so it never shows 0 while still blocking. */ +export function formatCooldown(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return 'ready'; + return `${Math.ceil(ms / 1000)} s`; +} + +/** One `game.counters` entry, formatted: `3/8 badges` when `outOf` is set, else `214 places`. */ +export function formatCounter(counter: { field: string; label: string; outOf?: number }, value: number): string { + const shown = formatCount(value); + return counter.outOf !== undefined ? `${shown}/${counter.outOf} ${counter.label}` : `${shown} ${counter.label}`; +} + +/** + * The ladder panel's terse game-counters line: `3/8 badges · 214 places`. No sentence, no label + * beyond the counters themselves. + */ +export function formatCounters( + counters: readonly { field: string; label: string; outOf?: number }[], + values: Record, +): string { + return counters.map((counter) => formatCounter(counter, values[counter.field] ?? 0)).join(' · '); +} + +/** + * Only display names the feed carried, and only in the shape the bridge promises + * (`^[\p{L}\p{N}_]{1,25}$`). Defence in depth: the bridge validates before the sim call, and the + * page validates again on render, so no path exists from chat text to the video frame. + */ +const DISPLAY_NAME = /^[\p{L}\p{N}_]{1,25}$/u; + +/** A safe display name, or the literal fallback the bridge uses. */ +export function safeDisplayName(name: string | null | undefined): string { + if (typeof name !== 'string') return 'a viewer'; + return DISPLAY_NAME.test(name) ? name : 'a viewer'; +} diff --git a/apps/stage/src/lib/geometry.ts b/apps/stage/src/lib/geometry.ts new file mode 100644 index 0000000..eb9bb7a --- /dev/null +++ b/apps/stage/src/lib/geometry.ts @@ -0,0 +1,425 @@ +/** + * Rail layout v2 geometry in 1920x1080 authoring pixels, as numbers. + * + * One authoring resolution: everything is laid out in these CSS pixels inside `#stage`, which is + * the native broadcast frame (`docs/design/fly-avatar.md`, "Canvas"). `?res=720` is + * `transform: scale(0.6667)` on that one element and exists only for thumbnails and the + * downscale tests. So these numbers are the only layout truth in the app, the panels take their + * absolute positions from them, and the e2e tests assert against them rather than against + * hand-copied constants. + * + * The rail is the locked "Rail layout v2" of `docs/stream-mvp-plan.md`: a compact progress + * cluster, one tabbed slot, the events ticker, and a persistent chat panel. Verified arithmetic: + * + * left 40 + 720 + 4 + 220 = 984 (title, game, gap, fly strip) + * rail 144 + 12 + 420 + 12 + 100 + 12 + 244 = 944 (= 720 + 4 + 220) + * width 800 + 12 + 1012 = 1824 + * rail y 88 .. 1032 (usable, after the 48 px insets) + * slot 420 = 48 tab strip + 4 frame + 368 content + * strip 4 + 416 + 12 + 364 + 4 = 800 (frame, fly, gap, macro pad, frame) + */ +import { MACRO_TYPES } from '@flybrain/feed'; + +/** Authoring viewport. Not configurable: the encoder, the capture and every test assume it. */ +export const STAGE_WIDTH = 1920; +export const STAGE_HEIGHT = 1080; + +/** Safe inset on all four sides. Background art may bleed past it; nothing load-bearing may. */ +export const INSET = 48; + +/** Gutter between cells inside one panel. */ +export const GUTTER = 4; + +/** + * Gutter between the rail's four panels. + * + * 12, not the 4 of layout v1: four panels instead of five, and the locked geometry puts the tab + * slot at 244 and the chat panel at 788, which are only reachable with 12 px between rows. + */ +export const RAIL_GUTTER = 12; + +/** Gutter between the two columns. */ +export const COLUMN_GAP = 12; + +/** Usable box after the insets. */ +export const USABLE_WIDTH = STAGE_WIDTH - INSET * 2; +export const USABLE_HEIGHT = STAGE_HEIGHT - INSET * 2; + +/** Integer scale of the 160x144 emulator framebuffer. 5x is 800x720 in a native 1080p frame. */ +export const GAME_SCALE = 5; +export const GAME_NATIVE_WIDTH = 160; +export const GAME_NATIVE_HEIGHT = 144; +export const GAME_WIDTH = GAME_NATIVE_WIDTH * GAME_SCALE; +export const GAME_HEIGHT = GAME_NATIVE_HEIGHT * GAME_SCALE; + +/** Title strip across the full usable width. */ +export const TITLE_HEIGHT = 40; + +/** + * The fly strip: a plain row of eight button indicators along its top edge, then the 3D fly on one + * side and the macro palette beside it, under the game. + */ +export const FLY_STRIP_WIDTH = GAME_WIDTH; +export const FLY_STRIP_HEIGHT = 220; + +/** Height of the plain button row along the fly strip's top edge, at 1080p authoring. */ +export const FLY_BUTTON_ROW_HEIGHT = 32; + +/** Right rail. */ +export const RAIL_WIDTH = USABLE_WIDTH - GAME_WIDTH - COLUMN_GAP; + +/** + * Panel frame width, mirroring `--border-w` in `src/theme/tokens.css`. + * + * 4, not layout v1's 2: `docs/design/gameboy-theme.md` makes every panel a dialogue box with "a + * 4 px outer border with a 2 px inner line". The outer one is the real `border`, so it is the one + * the interior arithmetic has to subtract — the inner line is a pseudo-element over the padding + * box and takes no space. Every panel's *outer* box is unchanged (`box-sizing: border-box`), so + * the only numbers that move are the ones derived from the inside of the tab slot. + */ +export const BORDER_WIDTH = 4; + +/** The inner line of the double frame, mirroring `--border-inner-w`. Drawn, never laid out. */ +export const BORDER_INNER_WIDTH = 2; + +/** The row below the button row: the fly and the palette share it, and its baseline. */ +export const FLY_ROW_HEIGHT = FLY_STRIP_HEIGHT - BORDER_WIDTH * 2 - FLY_BUTTON_ROW_HEIGHT - GUTTER; + +/** Gap between the fly's canvas and the palette beside it. */ +export const FLY_PALETTE_GAP = 12; + +/** + * The fly's own canvas, now 416 wide instead of the strip's full 792. + * + * The operator, 2026-09-16: "slide the fly over and put the macro palette right next to it". The number + * is not a taste call — it is the *widest* the fly can be and still leave the palette clear of the + * no-content zone. Twitch overlays chat and extensions over the bottom left of the player + * ({@link NO_CONTENT_ZONE}, x < 480), the palette's cells carry text, and the palette's bottom row + * is at y ≈ 970, inside the zone's band. So the palette starts at exactly x = 480 and the fly gets + * everything left of it: 52 (the strip's inner edge) + 416 + 12 = 480. The fly's canvas may reach + * into the zone, as it always has, because it carries no text at all. + * + * The vertical field of view is unchanged (`src/fly/camera.ts`), so the fly is the same size in + * pixels and the narrower canvas crops the shot rather than shrinking the animal — which is what + * "shrunk to make room" has to mean for a perspective camera with a fixed vertical framing. + */ +export const FLY_CANVAS_WIDTH = 416; +export const FLY_CANVAS_HEIGHT = FLY_ROW_HEIGHT; + +/** + * The pad beside it: the rest of the strip's inner width, and all of its inner height. + * + * 364 wide is not a choice — it is what is left once the fly's canvas has stopped at x = 480, the + * no-content zone's right edge ({@link NO_CONTENT_ZONE}, and {@link FLY_CANVAS_WIDTH} has the + * reasoning). The fly did not shrink for section 14's second column: 480 is where the pad's text + * has to start, so a narrower fly would only move readable cells under Twitch's chat overlay. + * What the pad took instead was the 36 px band the button row used to span, which is height + * nothing else wanted. + */ +export const MACRO_PALETTE_WIDTH = + FLY_STRIP_WIDTH - BORDER_WIDTH * 2 - FLY_CANVAS_WIDTH - FLY_PALETTE_GAP; +export const MACRO_PALETTE_HEIGHT = FLY_STRIP_HEIGHT - BORDER_WIDTH * 2; + +/** + * The strip's cells: the macros on the pad *now*, up to fourteen, two columns of seven. + * + * The layout decision at the end of `docs/design/macros.md` section 14: the strip under the game + * shows every button on the pad now, at the 24 px floor, and the whole keyboard of thirty-one + * types lives on the MACROS tab instead ({@link MACRO_BOARD_COLUMNS}). Fourteen is what two + * columns of seven hold and it is comfortably above the widest pad any scene deals — ten, the + * indoor overworld inside a centre (section 13.1) — so nothing is cut in practice, and a pad that + * did overflow would lose its *last* types rather than its first. + * + * Seven rows of 28 plus six 2 px gaps is the pad's 208 px of inner height exactly, and two + * columns of 179 plus one 2 px gap is its 360 px of inner width. The 2 px gap rather than a frame + * per cell is the spine's treatment (`docs/design/gameboy-theme.md`: "a row of square cells with + * 2 px gaps"), and it is also what lets a 24 px line sit in the row. + */ +export const MACRO_CELL_COLUMNS = 2; +export const MACRO_CELL_ROWS = 7; +export const MACRO_CELL_COUNT = MACRO_CELL_COLUMNS * MACRO_CELL_ROWS; +export const MACRO_CELL_GAP = 2; +/** Inset between the palette's own edge and its cells. */ +export const MACRO_PALETTE_PAD = 2; +export const MACRO_CELL_HEIGHT = + (MACRO_PALETTE_HEIGHT - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACRO_CELL_ROWS - 1)) / MACRO_CELL_ROWS; +export const MACRO_CELL_WIDTH = + (MACRO_PALETTE_WIDTH - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACRO_CELL_COLUMNS - 1)) / MACRO_CELL_COLUMNS; + +/** + * Width of a cell's channel tag, and the *minimum* width of its name column. + * + * Both are measured in VT323 at the 24 px body floor, which is the change section 14's second + * column paid for: the cells were Silkscreen, whose 0.75 em advance made `MB·FRONT` 135 px and + * `GO OBJECTIVE` 195, and 330 px of one cell does not go into 179 twice. VT323 is the page's own + * body face at 0.4 em (`src/theme/tokens.css`), so the same eight-character tag is 77 px and the + * cell holds the tag *and* a name at the floor rather than one of them above it. + * + * 85 is that 77 plus the glyph chip's 8 px of padding. 87 is nine characters of VT323, which is + * what {@link MACRO_SHORT_NAME_MAX} caps the strip's names at, and the two plus the cell's own + * padding and gap spend 178 of the 179 a cell has. The MACROS tab's cells are 332 px and carry the + * full name instead ({@link MACROS_TAB_CELL_WIDTH}); the gloss stays on the wire in both places. + */ +export const MACRO_CHANNEL_WIDTH = 85; +export const MACRO_NAME_WIDTH = 87; + +/** + * Longest short name the pad's cells draw, in characters. + * + * Nine of VT323 at the floor is 87 px ({@link MACRO_NAME_WIDTH}), which is what a 179 px cell has + * left once the tag chip has taken its 85. Six of the contract's names are longer than that, and + * `src/lib/macro-names.ts` is the table that shortens them — with a truncating fallback, so a type + * this page has never heard of still draws a name that fits rather than an ellipsis. + */ +export const MACRO_SHORT_NAME_MAX = 9; + +/** + * The SENSES panel's MACROS row: one labelled bar per bound channel (section 12). + * + * Two columns of three rather than a stack of six, and the numbers are measured rather than + * chosen. The groups column is 317 px and the six fixed groups take 260 of it (35 for a group + * whose name sets its height, 60 for the two with four bars), so this row has 57 px. Six labelled + * rows in one column need 94 — a 24 px label's cap box is 13.4 px of VT323, so the pitch cannot go + * below 16 — and every bar on the panel gets squeezed when it overflows. Three rows of two fit in + * 46. + * + * Which is what the width is spent on: this group's name column is 148 ("MACROS" in Silkscreen at + * the label size is 142) instead of the 200 that "dopamine" needs, leaving 264 for two 126 px + * cells — the channel tag in VT323 at the body floor (eight characters at 0.4 em is 77 px) and a + * 38 px track. + */ +export const MACRO_BAR_ROW_HEIGHT = 14; +export const MACRO_BAR_LABEL_WIDTH = 84; +/** The MACROS row's own name column: 148, not `CIRCUIT_NAME_WIDTH`, to pay for the second column. */ +export const MACRO_CIRCUIT_NAME_WIDTH = 148; + +/** Rail row heights, top to bottom. Locked in `docs/stream-mvp-plan.md`, "Rail layout v2". */ +export const RAIL_ROWS = { + /** Progress cluster: rung line, 38-rung spine, counters, clock, sugar chip. */ + progress: 144, + /** The tabbed slot: 48 px tab strip plus the pane. */ + tabs: 420, + /** EVENTS: three ticker rows, no title (the rows say what they are). */ + events: 100, + /** CHAT: seven lines and a title. */ + chat: 244, +} as const; + +/** Total height of the right rail: four rows plus three inter-row gutters (944). */ +export const RAIL_HEIGHT = + RAIL_ROWS.progress + RAIL_ROWS.tabs + RAIL_ROWS.events + RAIL_ROWS.chat + RAIL_GUTTER * 3; + +/** Height of the tab strip inside the slot. */ +export const TAB_STRIP_HEIGHT = 48; + +/** + * Retina raster canvas, inside the SENSES pane's left cell. + * + * Both eyes, big: the pane is 1004x368 and the retina takes 560 of it, which leaves 548x316 for + * the canvas after the cell's 6 px padding, its 30 px label row and the gap between them. 2.1x + * the area the raster had in layout v1's 360 px rail cell, which is what the locked layout's + * "retina raster left (both eyes, big)" asks for. The height is measured rather than derived: the + * label's line box rounds up, and the structural test fails the panel if the sum overruns. + */ +export const SENSES_RETINA_CELL_WIDTH = 560; +export const RETINA_CANVAS_WIDTH = 548; +export const RETINA_CANVAS_HEIGHT = 316; + +/** + * Fixed width of a circuit group's name, inside the SENSES pane's narrower right cell. + * + * 200, because the longest of the six group labels is "dopamine" and in Silkscreen at the 33 px + * label size that is 196 px (measured). It was 150 under VT323, whose 0.4 em advance made the same + * word 119; the pick changed and this is one of the places that had to follow it, because the + * alternative was a group name reading "dopamin…" on the panel that names the fly's reward + * circuit. The 50 px comes off the bar track, which is measured at runtime and quantised against + * whatever it turns out to be (`src/App.tsx`), so no cell arithmetic depends on this number. + */ +export const CIRCUIT_NAME_WIDTH = 200; + +/** + * Width of the LADDER pane's right-hand column: rollbacks, lifetime, last and the stall meter. + * + * 130, not the 236 it was when this column also held the best-snapshot thumbnail (dropped + * 2026-09-16, the operator: give the freed width to the rung names instead — three names a column no + * longer abbreviate to "Viridia…" at this width, see `docs/design/gameboy-theme.md` deviation 8, + * now resolved). 130 is a compact narrow column rather than the tightest that would fit: the + * widest readout here ("0:47 ago" style rollback age) still occasionally ellipsises at the far + * end of an hour-plus run, which the column accepts as a rare cost. + */ +export const LADDER_STATS_WIDTH = 130; + +/** Chat lines kept on screen. */ +export const CHAT_LINES = 7; + +/** Absolute positions of every region, in authoring pixels from the top left of `#stage`. */ +export const LAYOUT = (() => { + const left = INSET; + const top = INSET; + + const title = { x: left, y: top, width: USABLE_WIDTH, height: TITLE_HEIGHT }; + const game = { x: left, y: title.y + title.height, width: GAME_WIDTH, height: GAME_HEIGHT }; + const flyStrip = { + x: left, + y: game.y + game.height + GUTTER, + width: FLY_STRIP_WIDTH, + height: FLY_STRIP_HEIGHT, + }; + + /** The fly's canvas and the palette: one row inside the strip's frame, one baseline. */ + const flyRowY = flyStrip.y + BORDER_WIDTH + FLY_BUTTON_ROW_HEIGHT + GUTTER; + const flyPane = { + x: flyStrip.x + BORDER_WIDTH, + y: flyRowY, + width: FLY_CANVAS_WIDTH, + height: FLY_ROW_HEIGHT, + }; + const macroPalette = { + x: flyPane.x + flyPane.width + FLY_PALETTE_GAP, + y: flyStrip.y + BORDER_WIDTH, + width: MACRO_PALETTE_WIDTH, + height: MACRO_PALETTE_HEIGHT, + }; + + const railX = left + GAME_WIDTH + COLUMN_GAP; + let y = title.y + title.height; + const row = (height: number, width = RAIL_WIDTH) => { + const box = { x: railX, y, width, height }; + y += height + RAIL_GUTTER; + return box; + }; + + const progress = row(RAIL_ROWS.progress); + const tabs = row(RAIL_ROWS.tabs); + const events = row(RAIL_ROWS.events); + const chat = row(RAIL_ROWS.chat); + + /** The pane below the tab strip: the box every tab's content is drawn into. */ + const tabContent = { + x: tabs.x + BORDER_WIDTH, + y: tabs.y + TAB_STRIP_HEIGHT, + width: tabs.width - BORDER_WIDTH * 2, + height: tabs.height - TAB_STRIP_HEIGHT - BORDER_WIDTH, + }; + + return { title, game, flyStrip, flyPane, macroPalette, progress, tabs, tabContent, events, chat } as const; +})(); + +/** The rail as one box: what the border flash outlines and the particle layer is sized against. */ +export const RAIL_BOX = { + x: LAYOUT.progress.x, + y: LAYOUT.progress.y, + width: RAIL_WIDTH, + height: RAIL_HEIGHT, +} as const; + +/** + * The brain map's backing store: the tab pane, exactly. + * + * The map is no longer an inset that promotes over the rail (layout v1) — it is the CONNECTOME + * tab, drawn at slot size and nothing else, so the backing store is the pane's own 1004x368 and + * there is no promotion transform at all. `docs/stream-mvp-plan.md`'s "big moments pre-empt for + * 9 s" is now a tab focus, which is a crossfade rather than a scale. + */ +export const MAP_HERO_WIDTH = LAYOUT.tabContent.width; +export const MAP_HERO_HEIGHT = LAYOUT.tabContent.height; + +/** + * Density accumulator grid. + * + * 1004/4 = 251 and 368/2 = 184, so the cell is 4x2 device pixels: the two axes take different + * divisors because the pane's height is not a multiple of 4 and a fractional grid would put the + * sprite pass a subpixel off the cell it belongs to. `brainmap.ts` derives the per-axis scale + * from these rather than from one shared divisor. + */ +export const MAP_GRID_WIDTH = MAP_HERO_WIDTH / 4; +export const MAP_GRID_HEIGHT = MAP_HERO_HEIGHT / 2; + +/** + * The MACROS tab's keyboard: every macro type there is, three columns, rows as needed. + * + * `docs/design/macros.md` section 14: "a new rail tab MACROS shows the whole keyboard, three + * columns of eleven, bound cells lit and unbound dim, the running one bright". Eleven rows is the + * 31-type contract; on this one it is eight, because the row count is derived from the contract's + * own table and not written down twice — the tab grows a row when three types are added and needs + * no edit here. + * + * Three columns of 332 in the pane's 1004, and rows of 43.75 (eight) down to 31.3 (eleven): every + * one of them carries a 24 px line with room, which is the whole reason the keyboard is here and + * not under the game. + */ +export const MACROS_TAB_COLUMNS = 3; +export const MACROS_TAB_ROWS = Math.ceil(MACRO_TYPES.length / MACROS_TAB_COLUMNS); + +/** + * One cell of the MACROS tab's keyboard, inside the pane. + * + * Derived here rather than beside {@link MACROS_TAB_COLUMNS} because it needs the pane's box, and + * the pane is `LAYOUT`'s. The pad's own 2 px inset and 2 px gaps are reused, so the keyboard and + * the pad are the same grid at two sizes and one CSS rule set draws both. + */ +export const MACROS_TAB_CELL_WIDTH = + (LAYOUT.tabContent.width - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACROS_TAB_COLUMNS - 1)) / + MACROS_TAB_COLUMNS; +export const MACROS_TAB_CELL_HEIGHT = + (LAYOUT.tabContent.height - MACRO_PALETTE_PAD * 2 - MACRO_CELL_GAP * (MACROS_TAB_ROWS - 1)) / + MACROS_TAB_ROWS; + +/** + * The MACROS tab — same numbers as {@link MACROS_TAB_COLUMNS} and {@link MACROS_TAB_ROWS}, the + * alias kept because the rail tab that shows the keyboard is named `macros` + * (`src/lib/tabs.ts`) and the geometry of "the macros tab" is what callers reach for. The pad and + * the keyboard share the 2 px inset and 2 px gap (above) so these match the tab's actual + * dimensions exactly. + */ +export const MACRO_BOARD_COLUMNS = MACROS_TAB_COLUMNS; +export const MACRO_BOARD_ROWS = MACROS_TAB_ROWS; +export const MACRO_BOARD_PAD = MACRO_PALETTE_PAD; +export const MACRO_BOARD_GAP = MACRO_CELL_GAP; +export const MACRO_BOARD_CELL_WIDTH = MACROS_TAB_CELL_WIDTH; +export const MACRO_BOARD_CELL_HEIGHT = MACROS_TAB_CELL_HEIGHT; + +/** The caption band a milestone/badge moment slides over the top of the tab slot. */ +export const MAP_CAPTION_HEIGHT = 72; + +/** + * Twitch overlays chat and extensions over the bottom left of the player, so nothing + * load-bearing goes here. At 1080p the fly strip is what reaches into this box, and it carries no + * text below its button row. + */ +export const NO_CONTENT_ZONE = { x: 0, y: STAGE_HEIGHT - 180, width: 480, height: 180 } as const; + +/** Type of one absolute box. */ +export interface Box { + x: number; + y: number; + width: number; + height: number; +} + +/** Inline style for an absolutely positioned region. */ +export function boxStyle(box: Box): { + position: 'absolute'; + left: string; + top: string; + width: string; + height: string; +} { + return { + position: 'absolute', + left: `${box.x}px`, + top: `${box.y}px`, + width: `${box.width}px`, + height: `${box.height}px`, + }; +} + +/** True when two boxes share any area. Used by the no-content-zone assertion. */ +export function intersects(a: Box, b: Box): boolean { + return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +} + +/** Centre of a box, in stage coordinates. Particle emitters aim at these. */ +export function centreOf(box: Box): { x: number; y: number } { + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} diff --git a/apps/stage/src/lib/labels.ts b/apps/stage/src/lib/labels.ts new file mode 100644 index 0000000..b2a95be --- /dev/null +++ b/apps/stage/src/lib/labels.ts @@ -0,0 +1,252 @@ +/** + * The single source of human copy for the connectome itself. + * + * This table is **dataset-level, not game-level**: it is the same fly in both demos, so the + * role-to-label mapping lives here and the per-game copy lives in `src/games/*`. Nothing in this + * file may name a game. + * + * The authority for the role keys is `data/fafb-v783/meta.json` (`roles`) plus its + * `circuit-roles.json` sidecar. `tests/unit/labels.test.ts` asserts every role key in those files + * has an entry here, so a dataset rebuild cannot silently drop a bar. + * + * **Register** (from the 2026-09-15 copy pass): terse and instrument-like. Panel titles are + * short nouns. No parenthetical justifications, no sentences under widgets, no captions. Every + * explanation this page makes lives in `ROTATING_CARDS` and nowhere else — one short line each. + * + * Neuron counts are deliberately absent: they are read from the loaded `meta.json` at runtime and + * rendered from there, so the count on screen cannot drift from the dataset. + */ + +import { MACRO_TYPES, macroChannel, macroRateRole } from '@flybrain/feed'; + +/** Which colour token a bar or dot uses. */ +export type CircuitTint = 'sensory' | 'motor' | 'dopamine' | 'ink-2'; + +/** Where a role surfaces on screen. */ +export type RoleSurface = 'bar' | 'retina' | 'map'; + +/** + * The 31 macro channels (`docs/design/macros.md` sections 12 to 14), labelled with their tags. + * + * Derived from the contract rather than typed out again: the role is `macro_` plus the type's name + * lowercased, and the label is the tag the strip's cell already draws, so a type added to the + * contract cannot arrive here as an unlabelled bar. Motor, because a macro is a button. + */ +const MACRO_ROLE_LABELS: Record = Object.fromEntries( + MACRO_TYPES.map((name) => [ + macroRateRole(name), + { label: macroChannel(name), surface: 'bar', tint: 'motor' } satisfies RoleLabel, + ]), +); + +/** Human copy for one dataset role. */ +export interface RoleLabel { + /** Short on-screen name. Lower case; the panel uppercases what it renders. */ + label: string; + /** Where this role is rendered. `map` roles only tint the brain map. */ + surface: RoleSurface; + tint: CircuitTint; +} + +/** + * Every role key in the dataset. + * + * `command_0..7` are the readout's eight output channels, in `GAMEBOY_BUTTON_BITS` order + * (`packages/brain/src/readout/presets/gameboy.ts`): up, down, left, right, A, B, Start, Select. + * `macro_*` are the 31 macro channels above. + */ +export const ROLE_LABELS: Record = { + ...MACRO_ROLE_LABELS, + command_0: { label: 'up', surface: 'bar', tint: 'motor' }, + command_1: { label: 'down', surface: 'bar', tint: 'motor' }, + command_2: { label: 'left', surface: 'bar', tint: 'motor' }, + command_3: { label: 'right', surface: 'bar', tint: 'motor' }, + command_4: { label: 'A', surface: 'bar', tint: 'motor' }, + command_5: { label: 'B', surface: 'bar', tint: 'motor' }, + command_6: { label: 'Start', surface: 'bar', tint: 'motor' }, + command_7: { label: 'Select', surface: 'bar', tint: 'motor' }, + + reward_pam: { label: 'PAM', surface: 'bar', tint: 'dopamine' }, + proboscis: { label: 'proboscis', surface: 'bar', tint: 'sensory' }, + + forward: { label: 'fwd', surface: 'bar', tint: 'ink-2' }, + backward: { label: 'back', surface: 'bar', tint: 'ink-2' }, + steer_left: { label: 'left', surface: 'bar', tint: 'ink-2' }, + steer_right: { label: 'right', surface: 'bar', tint: 'ink-2' }, + + visual_l1: { label: 'L1', surface: 'retina', tint: 'sensory' }, + + sensory: { label: 'sensory', surface: 'map', tint: 'sensory' }, + motor: { label: 'motor', surface: 'map', tint: 'motor' }, + descending: { label: 'descending', surface: 'map', tint: 'motor' }, + kenyon: { label: 'kenyon', surface: 'map', tint: 'ink-2' }, + mbon: { label: 'mbon', surface: 'map', tint: 'ink-2' }, +}; + +/** One sub-bar inside a circuit group. */ +export interface CircuitBar { + /** Feed `rates` key. */ + role: string; + /** Short label as rendered. */ + label: string; +} + +/** One labelled group in the CIRCUITS panel. */ +export interface CircuitGroup { + id: string; + /** Group name, uppercased on screen. A noun, with nothing after it. */ + label: string; + tint: CircuitTint; + /** Full-scale rate for the bars, Hz. */ + fullScaleHz: number; + bars: readonly CircuitBar[]; +} + +/** + * The six groups of the CIRCUITS panel, top to bottom (design A2: 6 groups). + * + * A3's table lists PRESS A and PRESS B as separate groups, which would make seven; they share one + * group with two sub-bars here so the panel matches A2's count and pitch. + */ +export const CIRCUIT_GROUPS: readonly CircuitGroup[] = [ + { + id: 'drive', + label: 'drive', + tint: 'motor', + fullScaleHz: 30, + bars: [ + { role: 'command_0', label: 'up' }, + { role: 'command_1', label: 'down' }, + { role: 'command_2', label: 'left' }, + { role: 'command_3', label: 'right' }, + ], + }, + { + id: 'press', + label: 'A / B', + tint: 'motor', + fullScaleHz: 30, + bars: [ + { role: 'command_4', label: 'A' }, + { role: 'command_5', label: 'B' }, + ], + }, + { + id: 'menu', + label: 'menu', + tint: 'motor', + fullScaleHz: 20, + bars: [ + { role: 'command_6', label: 'Start' }, + { role: 'command_7', label: 'Select' }, + ], + }, + { + id: 'dopamine', + label: 'dopamine', + tint: 'dopamine', + fullScaleHz: 25, + bars: [{ role: 'reward_pam', label: 'PAM' }], + }, + { + id: 'taste', + label: 'taste', + tint: 'sensory', + fullScaleHz: 10, + bars: [{ role: 'proboscis', label: 'proboscis' }], + }, + { + /** + * The legs are real and drive nothing. That used to be said on a line under the panel ("legs: + * real, wired to nothing"), which is exactly the explanatory micro-copy the copy direction + * rejects; the fact now lives in the scaffolding card, and the row is just LEGS. + */ + id: 'legs', + label: 'legs', + tint: 'ink-2', + fullScaleHz: 12, + bars: [ + { role: 'forward', label: 'fwd' }, + { role: 'backward', label: 'back' }, + { role: 'steer_left', label: 'left' }, + { role: 'steer_right', label: 'right' }, + ], + }, +]; + +/** + * The MACROS row in the SENSES panel (`docs/design/macros.md` section 12), beside DRIVE and A/B. + * + * Not one of {@link CIRCUIT_GROUPS}, because its bars are not fixed: the row draws the channels the + * scene has bound right now, in the palette's own cell order, and it draws nothing in raw mode. + * The full scale is the direction group's, since these are decided against the same thresholds. + */ +export const MACRO_CIRCUIT = { id: 'macros', label: 'macros', tint: 'motor', fullScaleHz: 30 } as const; + +/** Every macro channel's rate role, in the contract's type order. */ +export const MACRO_BAR_ROLES: readonly string[] = MACRO_TYPES.map(macroRateRole); + +/** Every role a circuit bar reads, in render order. */ +export const BAR_ROLES: readonly string[] = CIRCUIT_GROUPS.flatMap((group) => group.bars.map((bar) => bar.role)); + +/** + * Copy that describes the whole apparatus rather than any one game. + * + * All strings are constants: the page never generates or interpolates prose (the "no generated + * text on a Twitch stream" rule from the Nothing, Forever precedent). + */ +export const DATASET_COPY = { + hzUnit: 'Hz', + retinaTitle: 'retina', + circuitsTitle: 'circuits', + eventsTitle: 'events', + hereForTitle: 'here for', + /** The whole-brain rate's label, over the Hz readout in the progress cluster. */ + brainTitle: 'brain', + /** The persistent chat panel's title. Rendered only when there are lines to show. */ + chatTitle: 'chat', + /** LADDER tab's stats column: the rollback budget lines and the stall meter. */ + rollbacksTitle: 'rollbacks', + lifetimeTitle: 'lifetime', + lastTitle: 'last', + stallTitle: 'stall', + agoSuffix: 'ago', + /** The run clock's day counter, beside the clock rather than inside it. See `readouts.ts`. */ + dayPrefix: 'day', + sugarReady: 'SUGAR READY', + /** `{name}` is a feed-supplied display name, re-validated on render. */ + sugarBy: 'SUGAR by {name}', + mapTitle: 'connectome', + staleBanner: 'STALE FEED', + feedDown: 'FEED DOWN', + simError: 'SIM ERROR', + emptyTicker: 'no events yet', +} as const; + +/** + * The rotating card: the one place on the page that explains anything. + * + * Four topics, one short line each — what is real, what is scaffolding, what sugar does, and the + * dataset credit with its licence. One card takes the narrative lane for the last minute of every + * four-minute cycle (`src/lib/schedule.ts`), and the next cycle shows the next card. + * + * Everything the page used to say in captions under widgets is in here, or nowhere. + */ +export const ROTATING_CARDS: readonly { title: string; line: string }[] = [ + { + title: 'real', + line: 'Measured wiring, simulated spikes, reward-modulated plasticity on real synapses.', + }, + { + title: 'scaffolding', + line: 'Our mapping: eyes to the screen, eight command groups to buttons. The legs drive nothing.', + }, + { + title: 'sugar', + line: 'Sugar fires one dopamine pulse. There is no path from chat to a button.', + }, + { + title: 'credit', + line: 'FlyWire FAFB v783 · CC BY-NC 4.0 · 139,255 neurons, 2.7 million connections.', + }, +]; diff --git a/apps/stage/src/lib/ladder.ts b/apps/stage/src/lib/ladder.ts new file mode 100644 index 0000000..446be4f --- /dev/null +++ b/apps/stage/src/lib/ladder.ts @@ -0,0 +1,62 @@ +/** + * How many rungs the milestone spine draws. + * + * The feed is authoritative. `milestone.total` is the running game adapter's ladder length + * (`docs/feed-protocol.md`), so the service can change its ladder — Pokémon Red's went from 16 + * rungs to 38 in `docs/design/ladder.md` — and the spine follows without a page release. + * + * The per-game config's `milestoneLadder` is the fallback, for a feed that predates `total`: a + * recorded fixture, or an older flysim. It is also still the source of the rung *labels* the panel + * falls back to when the header's own label is empty, which is why it does not simply go away. + */ +export const DEFAULT_RUNG_COUNT = 16; + +/** + * Resolve the spine's rung count from the feed's `total`, falling back to the config's ladder. + * + * A `total` of 0, a negative, a fraction or a non-number is ignored rather than trusted: this + * value sizes a render loop on a page that has to keep painting on air, and the feed is a network + * input. The fallback chain ends at {@link DEFAULT_RUNG_COUNT} so the count is never 0, which + * would erase the panel. + */ +export function rungCount(total: number | undefined, ladderLength: number): number { + if (typeof total === 'number' && Number.isInteger(total) && total >= 1) { + return total; + } + if (Number.isInteger(ladderLength) && ladderLength >= 1) { + return ladderLength; + } + return DEFAULT_RUNG_COUNT; +} + +/** + * The labels the LADDER tab spells out, padded or trimmed to `total`. + * + * The header carries `rank`, `label`, `next` and `total` but not the ladder's other names + * (`docs/feed-protocol.md`), so the list comes from the per-game config and the *count* from the + * feed. Padded rather than truncated to the config's length, because a service reporting more + * rungs than this build knows the names of must not silently shorten the ladder on screen: the + * unnamed rungs exist, and the tab shows them as numbered blanks. + */ +export function rungLabels(total: number, labels: readonly string[]): string[] { + const out: string[] = []; + for (let index = 0; index < total; index++) out.push(labels[index] ?? ''); + return out; +} + +/** + * Deal `items` into `columns` top-to-bottom columns of equal height. + * + * Column-major, not row-major: the ladder is a sequence, and a reader following 0, 1, 2 down the + * first column and on to the next is following the fly's own path. Row-major would put rung 1 next + * to rung 14. + */ +export function ladderColumns(items: readonly T[], columns: number): T[][] { + const count = Math.max(1, Math.floor(columns)); + const perColumn = Math.ceil(items.length / count); + const out: T[][] = []; + for (let column = 0; column < count; column++) { + out.push(items.slice(column * perColumn, (column + 1) * perColumn)); + } + return out; +} diff --git a/apps/stage/src/lib/macro-names.ts b/apps/stage/src/lib/macro-names.ts new file mode 100644 index 0000000..4188fb2 --- /dev/null +++ b/apps/stage/src/lib/macro-names.ts @@ -0,0 +1,55 @@ +/** + * Short names for the macro pad's cells. + * + * The pad under the game is two columns of seven at the 24 px body floor + * (`docs/design/macros.md` section 14), which leaves a cell 179 px: 85 for the channel tag's chip + * and 87 for the name, nine characters of VT323 (`src/lib/geometry.ts`). Six of the contract's + * names are longer than that, so the pad draws a short form and the MACROS tab — whose cells are + * 332 px — draws the contract's own name in full. Nothing here is a second vocabulary: a short + * name is the same words, shortened the way the channel tag already shortens them + * (`GO OBJECTIVE` / `MB·GOAL` -> `GO GOAL`), so a viewer reading the tab and then the pad sees one + * button and not two. + * + * **Why a table and not a rule.** A rule that cut words to fit produced `GO OBJEC` and + * `BUY ANTID`, which read as a rendering bug at the floor rather than as an abbreviation. The + * table is six lines; the rule below it is the fallback, and it exists for exactly one case: a + * producer or a contract ahead of this page. `tests/unit/macro-names.test.ts` holds every type in + * `MACRO_TYPES` to the cap and to being distinct, so a name added to the contract that needs a + * line here fails the suite rather than the broadcast. + */ +import { MACRO_SHORT_NAME_MAX } from './geometry'; + +/** + * The names that do not fit, shortened. + * + * Keyed on the contract's name, and deliberately including the nine types of + * `docs/design/macros.md` sections 13 and 14 that are still in flight: a table that only knows + * today's contract would put `BUY ANTID` on air on the day they land. + */ +const SHORT_NAMES: Record = { + 'GO OBJECTIVE': 'GO GOAL', + 'GO FRONTIER': 'GO FRONT', + 'BUY POTION': 'BUY POTN', + 'BUY ANTIDOTE': 'BUY ANTI', + // The fly's own ball, against the mart's `BUY BALL`: the verb is what distinguishes them and the + // noun is what does not fit, so the verb is what stays. + 'THROW BALL': 'THROW', +}; + +/** + * The name the pad's cell draws for a macro type. + * + * Total, and never empty: a name that fits is returned unchanged, a name in the table is its short + * form, and anything else is cut to the cap at a word boundary if there is one inside it. No + * ellipsis — a character of "…" is a character of name at this size, and the cell's `overflow` + * remains the structural backstop. + */ +export function shortMacroName(name: string): string { + if (name.length <= MACRO_SHORT_NAME_MAX) return name; + + const short = SHORT_NAMES[name]; + if (short !== undefined) return short; + + const space = name.lastIndexOf(' ', MACRO_SHORT_NAME_MAX); + return space > 0 ? name.slice(0, space) : name.slice(0, MACRO_SHORT_NAME_MAX); +} diff --git a/apps/stage/src/lib/marquee.ts b/apps/stage/src/lib/marquee.ts new file mode 100644 index 0000000..376e989 --- /dev/null +++ b/apps/stage/src/lib/marquee.ts @@ -0,0 +1,50 @@ +/** + * The LADDER tab's fallback for a rung name that still does not fit its column at the 24 px type + * floor, even with the extra width the 2026-09-16 pass gave it back from the dropped thumbnail + * (`src/lib/geometry.ts`'s `LADDER_STATS_WIDTH`). + * + * `docs/design/gameboy-theme.md`'s "Selection cursor marks the current rung" makes a rung's name + * the one piece of state a viewer hunts for, so an ellipsis that hides the tail of "Viridian + * Forest" is the wrong failure — a slow, stepped horizontal pan is the honest one: nothing is ever + * permanently hidden, it is just not all on screen at once. `src/panels/tabs/LadderTab.tsx` is the + * one place this drives a real DOM element; these are the pure numbers, kept separate so they can + * be tested without a browser. + */ + +/** The pixel-motion grid every stepped transition on the page snaps to (`--pixel-step`). */ +export const MARQUEE_STEP_PX = 8; + +/** How long the name sits fully visible at each end of its pan before reversing. */ +export const MARQUEE_HOLD_MS = 1200; + +/** How long one 8 px step of the pan takes. */ +export const MARQUEE_STEP_MS = 160; + +/** + * How far a name has to travel to bring its clipped tail fully into view, rounded up to a whole + * step so the pan always lands on the pixel grid. + * + * `contentWidth` is the label's own unclipped width (an element's `scrollWidth`) and `boxWidth` + * its visible column (`clientWidth`). Zero when the name already fits — the caller's cue not to + * marquee it at all. + */ +export function marqueeDistance(contentWidth: number, boxWidth: number, step = MARQUEE_STEP_PX): number { + const overflow = contentWidth - boxWidth; + if (overflow <= 0) return 0; + return Math.ceil(overflow / step) * step; +} + +/** + * Total time for one full pass: out, hold at the far end, back, hold at the start — the shape a + * CSS `animation` keyed on this duration plays on `infinite alternate`. + */ +export function marqueeCycleMs( + distance: number, + stepPx = MARQUEE_STEP_PX, + stepMs = MARQUEE_STEP_MS, + holdMs = MARQUEE_HOLD_MS, +): number { + if (distance <= 0) return 0; + const steps = distance / stepPx; + return steps * stepMs * 2 + holdMs * 2; +} diff --git a/apps/stage/src/lib/query.ts b/apps/stage/src/lib/query.ts new file mode 100644 index 0000000..a770f39 --- /dev/null +++ b/apps/stage/src/lib/query.ts @@ -0,0 +1,123 @@ +/** + * The page's entire configuration surface is the query string, because the only two things that + * ever launch it are a systemd `chromium --kiosk ` line and a Playwright test. + * + * `?mode=player` (the default) replays a recorded `.flyfeed`; `?mode=live` opens the real socket. + * `?t=` seeks the fixture and, unless `&play=1` is given, holds there — that is what makes a + * screenshot deterministic. + */ + +import { parseTab, type TabId } from './tabs'; + +export type StageMode = 'player' | 'live'; +export type StageTheme = 't1' | 't2' | 't3'; +export type StageRes = '720' | '1080'; +export type StageFly = 'off' | 'webgl' | 'paper'; + +/** + * The chat panel's source. + * + * `feed` renders `header.chat` and disappears when the header does not carry it — a service with + * chat disabled omits the key entirely (`docs/feed-protocol.md`), which is the same on-screen + * outcome as this page's own kill switch. `off` is that kill switch: no panel at all, whatever + * the feed says, which is what a moderation incident needs to be one query parameter away. + */ +export type StageChat = 'feed' | 'off'; + +export interface StageOptions { + mode: StageMode; + fixture: string; + /** Seek target in seconds, or null to play from the start. */ + seekSeconds: number | null; + /** False when `?t=` was given without `&play=1`: seek, then hold for the screenshot. */ + autoplay: boolean; + loop: boolean; + theme: StageTheme; + res: StageRes; + /** + * Which fly renderer draws the strip: `webgl` (three.js, one GL context), `paper` (the 2D + * fallback for a host with no usable WebGL), or `off` (no fly, no context). + */ + fly: StageFly; + /** + * Pin the tab slot to one tab and stop the cycle (`?tab=senses|connectome|ladder|describe|macros`). + * + * A screenshot of a slot that cycles every 45 to 60 s is a screenshot of whatever the clock + * happened to be doing, so every visual test and every mockup pins the tab. + */ + tab: TabId | null; + /** Where the chat panel reads its lines from. */ + chat: StageChat; + /** Per-game config id, resolved against `src/games`. */ + game: string; + /** Feed WebSocket URL for `mode=live`. */ + feedUrl: string; + gains: { master: number; game: number; sfx: number }; + /** `?audio=0` disables the AudioContext entirely (default in tests). */ + audio: boolean; + /** `?metrics=1` keeps the paint-stage histogram and prints it on demand. */ + metrics: boolean; +} + +const THEMES: readonly StageTheme[] = ['t1', 't2', 't3']; +const FLIES: readonly StageFly[] = ['off', 'webgl', 'paper']; + +function num(params: URLSearchParams, key: string, fallback: number): number { + const raw = params.get(key); + if (raw === null) return fallback; + const value = Number.parseFloat(raw); + return Number.isFinite(value) ? value : fallback; +} + +function flag(params: URLSearchParams, key: string, fallback: boolean): boolean { + const raw = params.get(key); + if (raw === null) return fallback; + return raw !== '0' && raw !== 'false'; +} + +/** Parse the options out of a URL query string. Pure, so the unit tests can drive it. */ +export function parseStageOptions(search: string, defaultFeedUrl = 'ws://127.0.0.1:7400/feed'): StageOptions { + const params = new URLSearchParams(search); + + const mode: StageMode = params.get('mode') === 'live' ? 'live' : 'player'; + const themeParam = params.get('theme'); + const theme: StageTheme = THEMES.includes(themeParam as StageTheme) ? (themeParam as StageTheme) : 't1'; + // 1080 is the authoring and broadcast size and therefore the default; 720 is the 2/3 downscale. + const res: StageRes = params.get('res') === '720' ? '720' : '1080'; + + const flyParam = params.get('fly'); + const fly: StageFly = FLIES.includes(flyParam as StageFly) ? (flyParam as StageFly) : 'webgl'; + + const tRaw = params.get('t'); + const seekSeconds = tRaw === null ? null : Number.parseFloat(tRaw.replace(/s$/, '')); + + const chatParam = params.get('chat'); + const chat: StageChat = chatParam === '0' || chatParam === 'off' ? 'off' : 'feed'; + + return { + mode, + fixture: params.get('fixture') ?? 'steady', + seekSeconds: seekSeconds !== null && Number.isFinite(seekSeconds) ? seekSeconds : null, + autoplay: flag(params, 'play', seekSeconds === null), + loop: flag(params, 'loop', true), + theme, + res, + fly, + tab: parseTab(params.get('tab')), + chat, + game: params.get('game') ?? 'pokemon-red', + feedUrl: params.get('feed') ?? defaultFeedUrl, + gains: { + master: num(params, 'gain', 0.9), + game: num(params, 'gamegain', 0.8), + sfx: num(params, 'sfxgain', 0.5), + }, + audio: flag(params, 'audio', true), + metrics: flag(params, 'metrics', false), + }; +} + +/** Read the options from `window.location`. */ +export function stageOptions(): StageOptions { + return parseStageOptions(window.location.search); +} diff --git a/apps/stage/src/lib/schedule.ts b/apps/stage/src/lib/schedule.ts new file mode 100644 index 0000000..1ee3460 --- /dev/null +++ b/apps/stage/src/lib/schedule.ts @@ -0,0 +1,51 @@ +/** + * The narrative lane's rotation schedule (design A3, "Honesty panel"). + * + * One panel carries two states on a fixed 240 s cycle, driven by simulated run time so the + * rotation is deterministic in a fixture and survives a restart: + * + * 0 s .. 180 s the event ticker (the default; what just happened) + * 180 s .. 240 s the rotating card, one of `ROTATING_CARDS`, a different one each cycle + * + * Two states, not three: the copy pass collapsed the eight explainer cards and + * the two-column real-vs-scaffolding panel into one rotating card of four short lines, because + * explanations are allowed in exactly one place on this page. A3's requirement that survives is + * the one that matters — the honest statement of what is real and what is scaffolding appears on + * a schedule, in full view, rather than in a footnote. + */ + +/** Length of one rotation cycle. */ +export const CYCLE_MS = 240_000; + +/** When the rotating card takes the lane. It holds until `CYCLE_MS`. */ +export const CARD_START_MS = 180_000; + +/** What the narrative lane should be showing. */ +export type NarrativeState = { kind: 'ticker' } | { kind: 'card'; index: number }; + +/** + * Resolve the lane state at `runMs` of simulated run time. + * + * `cardCount` is the number of cards available, so the rotation follows the card list without + * this file knowing anything about it. + */ +export function narrativeStateAt(runMs: number, cardCount: number): NarrativeState { + if (!Number.isFinite(runMs) || runMs < 0) return { kind: 'ticker' }; + + const cycle = Math.floor(runMs / CYCLE_MS); + const phase = runMs - cycle * CYCLE_MS; + + if (phase >= CARD_START_MS && cardCount > 0) return { kind: 'card', index: cycle % cardCount }; + return { kind: 'ticker' }; +} + +/** + * Fraction 0..1 through the current lane state, for a progress hairline on the rotating card. + * Returns 0 for the ticker state, which has no countdown worth showing. + */ +export function narrativeProgressAt(runMs: number): number { + if (!Number.isFinite(runMs) || runMs < 0) return 0; + const phase = runMs - Math.floor(runMs / CYCLE_MS) * CYCLE_MS; + if (phase >= CARD_START_MS) return (phase - CARD_START_MS) / (CYCLE_MS - CARD_START_MS); + return 0; +} diff --git a/apps/stage/src/lib/stall.ts b/apps/stage/src/lib/stall.ts new file mode 100644 index 0000000..2e347c1 --- /dev/null +++ b/apps/stage/src/lib/stall.ts @@ -0,0 +1,113 @@ +/** + * The stall meter and the rollback budget: the two readouts on the LADDER tab that turn "nothing + * is happening" into the thing people came to watch. + * + * The research ranks the stuck-o-meter as the highest value per hour and the cheapest thing on + * the page, and `docs/design/ladder.md` fixes the numbers: a stall is 120 s without new + * exploration, attempts per rung stay 3, and the lifetime budget is 36 for the 38-rung ladder. + * + * Two things the feed does not carry, and therefore have to be derived here: + * + * - **time since the last explore event.** `milestone.sinceSeconds` is time at the rung, which + * is hours, not the tens of seconds the stall window measures. So the meter watches + * `events[]` for `explore`/`area` rewards. + * - **lifetime rollbacks.** `milestone.attempts` resets at every rung ("rollbacks since + * reaching this rank"), so the lifetime figure is a count of `recovery` events since the page + * loaded, and it says so on screen rather than pretending to know the run's whole history. + * + * Pure and clock-injected, like everything else that has to survive a fixture seek. + */ + +/** The stall window from `docs/design/ladder.md`. */ +export const STALL_WINDOW_SECONDS = 120; + +/** Recovery attempts allowed per rung. */ +export const ATTEMPTS_PER_RUNG = 3; + +/** Lifetime recovery budget for the 38-rung ladder. */ +export const LIFETIME_BUDGET = 36; + +/** Reward kinds that count as exploration for the stall window. */ +const EXPLORE_KINDS: ReadonlySet = new Set(['explore', 'area']); + +/** True when a feed event resets the stall window. */ +export function isExploreEvent(event: { kind: string; rewardKind?: string }): boolean { + return event.kind === 'reward' && event.rewardKind !== undefined && EXPLORE_KINDS.has(event.rewardKind); +} + +/** + * Time since the last exploration, as a fraction of the 120 s window. + * + * Armed on the first snapshot rather than at construction, so a page that has been open for ten + * minutes with no feed does not claim the fly has been stalled for ten minutes. + */ +export class StallMeter { + private lastExploreMs: number | null = null; + private armedMs: number | null = null; + + /** Forget everything. A seek replays the recording from the top. */ + reset(): void { + this.lastExploreMs = null; + this.armedMs = null; + } + + /** Called on every snapshot, whether or not it carried events. */ + arm(nowMs: number): void { + if (this.armedMs === null) this.armedMs = nowMs; + } + + /** Called for each exploration event. */ + noteExplore(nowMs: number): void { + this.arm(nowMs); + this.lastExploreMs = nowMs; + } + + /** Seconds since the last exploration, or since the meter was armed. */ + secondsSince(nowMs: number): number { + const since = this.lastExploreMs ?? this.armedMs; + if (since === null) return 0; + return Math.max(0, (nowMs - since) / 1000); + } + + /** 0..1 through the stall window. Saturates at 1; a stall does not get worse than stalled. */ + fraction(nowMs: number, windowSeconds = STALL_WINDOW_SECONDS): number { + if (windowSeconds <= 0) return 0; + return Math.min(1, this.secondsSince(nowMs) / windowSeconds); + } + + /** True once the window has run out: what colours the meter. */ + stalled(nowMs: number, windowSeconds = STALL_WINDOW_SECONDS): boolean { + return this.fraction(nowMs, windowSeconds) >= 1; + } +} + +/** + * Lifetime rollbacks, counted from `recovery` events. + * + * `sinceLoad` is deliberately named: the feed carries no lifetime total, so this counts what this + * page has seen and the panel labels it that way. + */ +export class RollbackCounter { + private count = 0; + private lastMs: number | null = null; + + reset(): void { + this.count = 0; + this.lastMs = null; + } + + note(nowMs: number): void { + this.count += 1; + this.lastMs = nowMs; + } + + get sinceLoad(): number { + return this.count; + } + + /** Seconds since the last rollback, or null when there has not been one. */ + secondsSinceLast(nowMs: number): number | null { + if (this.lastMs === null) return null; + return Math.max(0, (nowMs - this.lastMs) / 1000); + } +} diff --git a/apps/stage/src/lib/tabs.ts b/apps/stage/src/lib/tabs.ts new file mode 100644 index 0000000..c442700 --- /dev/null +++ b/apps/stage/src/lib/tabs.ts @@ -0,0 +1,281 @@ +/** + * Which tab the slot is showing, and why (`docs/stream-mvp-plan.md`, "Rail layout v2": "three + * tabs auto-cycling … slow cadence steered by activity, big moments pre-empt for 9 s"). + * + * Five tabs since 2026-09-17: DESCRIBE joined the rotation (`docs/design/describe-tab.md`) and + * MACROS joined it with section 14's decided layout (`docs/design/macros.md`), which put the fly's + * whole thirty-one-button keyboard on the rail because it does not fit under the game at the 24 px + * floor. Both change the cadence in one way only — a full turn of the rotation is five dwells + * rather than three — because nothing steers to either and no moment focuses them. + * + * Three inputs, in descending authority: + * + * 1. **Focus.** A moment takes the slot for its hold and then hands it back to whatever was up + * before. This is what replaced layout v1's promotion of the brain map over the rail. + * 2. **Event steering.** A rung change goes to LADDER and a reward goes to CONNECTOME, because + * those are the tabs that show the thing that just happened. Gated by `STEER_DWELL_MS` so a + * reward every twenty seconds cannot make the slot flicker. + * 3. **The cycle.** Otherwise the slot advances on its own every 45 to 60 s. Sustained + * steering — "SENSES while walking with high command activity" — is applied *at* a cycle + * boundary rather than immediately, for the same reason: a condition that is true for + * minutes at a time must bias the rotation, not own it. + * + * Pure and clock-injected: no timers, no `Math.random`, so `tests/unit/tabs.test.ts` drives the + * whole state machine and a fixture seek reproduces the same tab at the same virtual time. + */ + +export type TabId = 'senses' | 'connectome' | 'ladder' | 'describe' | 'macros'; + +/** + * Rotation order, and the order the tab strip renders. + * + * DESCRIBE is fourth (`docs/design/describe-tab.md`: "Fourth tab on the rail after + * SENSES / CONNECTOME / LADDER") and MACROS last. Neither is steered to and no moment focuses + * either: DESCRIBE says what the stream *is*, which is never what just happened, and MACROS says + * what the fly *can* press, which changes with the scene rather than with an event — the button + * that fired is already lit under the game. So both arrive only on the cycle's own turn. + */ +export const TABS: readonly TabId[] = ['senses', 'connectome', 'ladder', 'describe', 'macros']; + +/** On-screen names. Caps are applied by CSS, not here. */ +export const TAB_LABELS: Record = { + senses: 'senses', + connectome: 'connectome', + ladder: 'ladder', + describe: 'describe', + macros: 'macros', +}; + +/** Auto-cycle bounds from the locked layout. */ +export const CYCLE_MIN_MS = 45_000; +export const CYCLE_MAX_MS = 60_000; + +/** No steer may interrupt a tab that has been up for less than this. */ +export const STEER_DWELL_MS = 12_000; + +/** + * The new-chatter switch, both numbers, in the one place that owns cadence. + * + * The operator, 2026-09-17: "when new user joins chat, switch to it for a few sec, cool down timer." A + * display name nobody on this page has seen before takes the slot to DESCRIBE for + * `NEW_CHATTER_HOLD_MS`, then hands it back to whatever was up — the same focus-and-return a big + * moment uses, so it is the rail's existing tab-change motion and not a second kind of switch. + * + * `NEW_CHATTER_COOLDOWN_MS` is why it is watchable rather than annoying: a raid or a busy evening + * is a dozen new names a minute, and without a cooldown the slot would sit on DESCRIBE and the + * rest of the rail would never be seen. Two minutes means at most one interruption per rotation + * and a half. Names that arrive inside the cooldown still count as seen + * (`src/lib/chatters.ts`), so the switch is "somebody new turned up recently", not a queue. + * + * Documented in `docs/design/describe-tab.md` (2026-09-17). Whoever tunes these tunes them here. + */ +export const NEW_CHATTER_HOLD_MS = 8_000; +export const NEW_CHATTER_COOLDOWN_MS = 120_000; + +/** + * Command-channel activity above which, while walking, SENSES is the interesting tab. + * + * The signal is the mean bar fill of the drive group, which sits near `1 / headroom` (about 0.67) + * at rest (`src/lib/circuit-scale.ts`), so the threshold has to be above that to mean anything. + */ +export const WALKING_ACTIVITY = 0.72; + +/** Why the slot is showing what it is showing. Surfaced on `window.__stage` for the tests. */ +export type TabReason = 'seed' | 'cycle' | 'steer' | 'focus' | 'return' | 'forced'; + +/** One frame of the world, as the controller sees it. */ +export interface TabSignals { + /** True while the adapter reports the walking/overworld mode. */ + walking: boolean; + /** 0..1 mean fill of the drive bars. */ + commandActivity: number; + /** A reward event landed since the last update. */ + reward: boolean; + /** The milestone rank changed since the last update. */ + rankChanged: boolean; + /** + * A chat line from a display name this page had not seen before landed since the last update + * (`src/lib/chatters.ts` decides what counts). Optional so a caller that does not watch chat — + * `tests/unit/tabs.test.ts`'s older cases, and any future one — reads as "no". + */ + newChatter?: boolean; +} + +export interface TabControllerOptions { + /** Start here. Defaults to SENSES. */ + initial?: TabId; + /** Pin the slot and disable cycling and steering (`?tab=`). */ + forced?: TabId | null; + minMs?: number; + maxMs?: number; + steerDwellMs?: number; + /** How long a new chatter holds DESCRIBE. Defaults to `NEW_CHATTER_HOLD_MS`. */ + newChatterHoldMs?: number; + /** How long after a switch further new names are ignored. Defaults to `NEW_CHATTER_COOLDOWN_MS`. */ + newChatterCooldownMs?: number; + /** Seed for the dwell sequence. Same seed, same cadence — which is what makes it testable. */ + seed?: number; +} + +export class TabController { + private tab: TabId; + private reasonValue: TabReason; + private changedAtMs: number | null = null; + private dwellMs: number; + private focus: { tab: TabId; untilMs: number; returnTo: TabId } | null = null; + private seed: number; + /** Clock value of the last new-chatter switch; -Infinity means "never". */ + private chatterSwitchMs = Number.NEGATIVE_INFINITY; + private readonly minMs: number; + private readonly maxMs: number; + private readonly steerDwellMs: number; + private readonly holdMs: number; + private readonly cooldownMs: number; + private readonly forced: TabId | null; + + constructor(options: TabControllerOptions = {}) { + this.forced = options.forced ?? null; + this.tab = this.forced ?? options.initial ?? 'senses'; + this.reasonValue = this.forced ? 'forced' : 'seed'; + this.minMs = options.minMs ?? CYCLE_MIN_MS; + this.maxMs = options.maxMs ?? CYCLE_MAX_MS; + this.steerDwellMs = options.steerDwellMs ?? STEER_DWELL_MS; + this.holdMs = options.newChatterHoldMs ?? NEW_CHATTER_HOLD_MS; + this.cooldownMs = options.newChatterCooldownMs ?? NEW_CHATTER_COOLDOWN_MS; + this.seed = (options.seed ?? 20260915) | 0; + this.dwellMs = this.nextDwell(); + } + + get current(): TabId { + return this.tab; + } + + get reason(): TabReason { + return this.reasonValue; + } + + /** Clock value of the last change, for the crossfade. */ + get changedAt(): number | null { + return this.changedAtMs; + } + + /** True while a moment owns the slot. */ + get focused(): boolean { + return this.focus !== null; + } + + /** True when the slot is pinned by `?tab=`. */ + get isForced(): boolean { + return this.forced !== null; + } + + /** Clock value of the last new-chatter switch, or null before the first. For `window.__stage`. */ + get lastChatterSwitch(): number | null { + return this.chatterSwitchMs === Number.NEGATIVE_INFINITY ? null : this.chatterSwitchMs; + } + + /** + * Give `tab` the slot until `untilMs`, then hand it back. + * + * Idempotent for the same tab, and that is load-bearing: the director calls this on *every* + * frame a moment is on stage, so a version that re-`set`s each time would reset `changedAt` + * sixty times a second and the crossfade would never get past its first frame. Extending the + * deadline is all a repeat call may do. + */ + focusOn(tab: TabId, untilMs: number, nowMs: number): void { + if (this.forced) return; + const focus = this.focus; + if (focus && focus.tab === tab) { + focus.untilMs = Math.max(focus.untilMs, untilMs); + return; + } + const returnTo = focus?.returnTo ?? this.tab; + this.focus = { tab, untilMs, returnTo }; + this.set(tab, nowMs, 'focus'); + } + + /** Advance one frame. Returns the tab to show. */ + update(nowMs: number, signals: TabSignals): TabId { + if (this.changedAtMs === null) this.changedAtMs = nowMs; + if (this.forced) return this.tab; + + // A new chatter, before the focus check: it *becomes* a focus, on the same machinery a moment + // uses, so the hold expires and the slot returns to the tab that was up. Never while a moment + // already owns the slot — a badge or a rollback is the more interesting thing on screen and + // that hold is short — and the cooldown is not spent in that case either, so the next new + // name after the moment still gets its switch. + if ( + signals.newChatter === true && + this.focus === null && + nowMs - this.chatterSwitchMs >= this.cooldownMs + ) { + this.chatterSwitchMs = nowMs; + this.focusOn('describe', nowMs + this.holdMs, nowMs); + return this.tab; + } + + const focus = this.focus; + if (focus) { + if (nowMs < focus.untilMs) { + if (this.tab !== focus.tab) this.set(focus.tab, nowMs, 'focus'); + return this.tab; + } + this.focus = null; + this.set(focus.returnTo, nowMs, 'return'); + return this.tab; + } + + const steered = this.steerTarget(signals); + if (steered && steered !== this.tab && nowMs - this.changedAtMs >= this.steerDwellMs) { + this.set(steered, nowMs, 'steer'); + return this.tab; + } + + if (nowMs - this.changedAtMs >= this.dwellMs) { + this.set(this.nextTab(signals), nowMs, 'cycle'); + this.dwellMs = this.nextDwell(); + } + return this.tab; + } + + /** The event steer for this frame, or null. Rung change out-ranks a reward. */ + private steerTarget(signals: TabSignals): TabId | null { + if (signals.rankChanged) return 'ladder'; + if (signals.reward) return 'connectome'; + return null; + } + + /** + * Where the cycle goes next: the sustained bias if it applies and is not already up, else the + * next tab in rotation. + */ + private nextTab(signals: TabSignals): TabId { + if (signals.walking && signals.commandActivity >= WALKING_ACTIVITY && this.tab !== 'senses') { + return 'senses'; + } + const index = TABS.indexOf(this.tab); + return TABS[(index + 1) % TABS.length] as TabId; + } + + private set(tab: TabId, nowMs: number, reason: TabReason): void { + this.tab = tab; + this.reasonValue = reason; + this.changedAtMs = nowMs; + } + + /** A dwell in `[minMs, maxMs]`, from the seeded sequence. */ + private nextDwell(): number { + this.seed = (this.seed + 0x6d2b79f5) | 0; + let t = this.seed; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + const unit = ((t ^ (t >>> 14)) >>> 0) / 4294967296; + return this.minMs + unit * (this.maxMs - this.minMs); + } +} + +/** Parse a `?tab=` value. Anything unrecognised means "do not pin", never a crash on air. */ +export function parseTab(value: string | null | undefined): TabId | null { + if (!value) return null; + return (TABS as readonly string[]).includes(value) ? (value as TabId) : null; +} diff --git a/apps/stage/src/lib/ticker.ts b/apps/stage/src/lib/ticker.ts new file mode 100644 index 0000000..ec892e8 --- /dev/null +++ b/apps/stage/src/lib/ticker.ts @@ -0,0 +1,201 @@ +/** + * The reward ticker's queue: a human-paced feed, not a log (design A3). + * + * The audit finding this fixes: on the old page a gym badge and a +0.05 exploration tick got the + * same 2 s tint, and the reward label was sticky forever. So: + * + * - **minimum dwell.** A promoted row stays up at least `minDwellMs` (4 s) before the next one + * is allowed in, however fast events arrive. A 30 Hz feed cannot flicker the panel. + * - **dedupe.** Repeated events of the same kind inside that kind's `dedupeMs` window collapse + * into one row with a count ("3 new places"), instead of three identical rows. + * - **tiers.** A `moment` event (a badge) jumps the dwell gate, because making the viewer wait + * 4 s to be told about the badge is exactly the wrong pacing. Quiet and notable events queue. + * + * Pure and clock-injected: every method takes `nowMs`, so `tests/unit/ticker.test.ts` drives it + * without timers and the fixture player can replay it against virtual time. + */ +import type { FeedEvent, FeedEventKind, RewardKind } from '@flybrain/feed'; +import type { GameConfig, RewardTier } from '@/games'; + +/** Presentation tier of a row. Non-reward events get `system`. */ +export type TickerTier = RewardTier | 'system'; + +/** One row in the ticker. */ +export interface TickerItem { + /** Feed event id of the first event folded into this row. Stable React key. */ + id: number; + kind: FeedEventKind; + rewardKind?: RewardKind; + tier: TickerTier; + /** The text as rendered. Constant copy from the game config, or the feed's own label. */ + label: string; + /** Summed reward value across folded events, when the kind carries one. */ + amount?: number; + /** How many events are folded into this row. 1 unless deduped. */ + count: number; + /** Viewer display name for sugar/viewer events. */ + by?: string; + /** When the row was created. */ + createdMs: number; + /** When the row became visible, or null while it is still queued. */ + promotedMs: number | null; +} + +export interface TickerOptions { + /** Rows on screen. Design A2: 3. */ + visibleRows?: number; + /** Minimum time a promoted row holds the panel before the next promotion. */ + minDwellMs?: number; + /** Hard cap on queued-but-not-yet-shown rows. */ + maxQueue?: number; +} + +export const DEFAULT_TICKER_OPTIONS = { + visibleRows: 3, + minDwellMs: 4000, + maxQueue: 12, +} as const; + +/** + * Events that never reach the ticker. + * + * `checkpoint`: a 5 s heartbeat is not news. + * + * `viewer`: every accepted chat line logs one (`packages/feed/src/fake/simulator.ts`, and the real + * service does the same), and rail layout v2 gives chat its own panel — so a `viewer` event in + * EVENTS is the same line twice, once with its text and once as the word "chat". Sugar is its own + * kind and still lands here, which is the viewer action EVENTS is actually about. + */ +const HIDDEN_KINDS: ReadonlySet = new Set(['checkpoint', 'viewer']); + +export class TickerQueue { + private readonly visibleRows: number; + private readonly minDwellMs: number; + private readonly maxQueue: number; + private readonly game: GameConfig; + + private visible: TickerItem[] = []; + private pending: TickerItem[] = []; + private lastPromotionMs = Number.NEGATIVE_INFINITY; + /** Bumped whenever the visible list changes, so the store can push to React on change only. */ + private revision = 0; + + constructor(game: GameConfig, options: TickerOptions = {}) { + this.game = game; + this.visibleRows = options.visibleRows ?? DEFAULT_TICKER_OPTIONS.visibleRows; + this.minDwellMs = options.minDwellMs ?? DEFAULT_TICKER_OPTIONS.minDwellMs; + this.maxQueue = options.maxQueue ?? DEFAULT_TICKER_OPTIONS.maxQueue; + } + + /** Rows currently on screen, newest first. */ + items(): readonly TickerItem[] { + return this.visible; + } + + /** Rows waiting for the dwell gate. Exposed for the panel's "+2 more" affordance and tests. */ + queued(): readonly TickerItem[] { + return this.pending; + } + + /** Changes when the visible list changes. */ + version(): number { + return this.revision; + } + + /** Feed one event in. Returns true when it produced or folded into a row. */ + push(event: FeedEvent, nowMs: number): boolean { + if (HIDDEN_KINDS.has(event.kind)) return false; + + const tier = this.tierOf(event); + const label = this.labelOf(event); + + const folded = this.fold(event, nowMs); + if (folded) return true; + + const item: TickerItem = { + id: event.id, + kind: event.kind, + tier, + label, + count: 1, + createdMs: nowMs, + promotedMs: null, + ...(event.rewardKind === undefined ? {} : { rewardKind: event.rewardKind }), + ...(event.value === undefined ? {} : { amount: event.value }), + ...(event.by === undefined ? {} : { by: event.by }), + }; + + if (tier === 'moment') { + // Jump the gate: the badge is the reason anyone is watching. + this.promote(item, nowMs); + return true; + } + + this.pending.push(item); + if (this.pending.length > this.maxQueue) this.pending.splice(0, this.pending.length - this.maxQueue); + this.tick(nowMs); + return true; + } + + /** Advance the dwell gate. Call once per paint; cheap and idempotent. */ + tick(nowMs: number): void { + if (this.pending.length === 0) return; + if (nowMs - this.lastPromotionMs < this.minDwellMs) return; + const next = this.pending.shift(); + if (next) this.promote(next, nowMs); + } + + private promote(item: TickerItem, nowMs: number): void { + item.promotedMs = nowMs; + this.visible.unshift(item); + if (this.visible.length > this.visibleRows) this.visible.length = this.visibleRows; + this.lastPromotionMs = nowMs; + this.revision += 1; + } + + /** + * Fold a repeat into an existing row when the kind allows it. + * + * The window is measured from the row's creation, so a steady trickle of exploration ticks + * collapses into one row that counts up and then ages out, rather than a row that never dies. + */ + private fold(event: FeedEvent, nowMs: number): boolean { + if (event.kind !== 'reward' || !event.rewardKind) return false; + const copy = this.game.rewardCopy[event.rewardKind]; + if (!copy || copy.dedupeMs <= 0) return false; + + for (const item of [...this.visible, ...this.pending]) { + if (item.rewardKind !== event.rewardKind) continue; + if (nowMs - item.createdMs > copy.dedupeMs) continue; + item.count += 1; + if (event.value !== undefined) item.amount = (item.amount ?? 0) + event.value; + item.label = copy.collapsedNoun ? `${item.count} ${copy.collapsedNoun}` : copy.label; + this.revision += 1; + return true; + } + return false; + } + + private tierOf(event: FeedEvent): TickerTier { + if (event.kind === 'reward' && event.rewardKind) { + return this.game.rewardCopy[event.rewardKind]?.tier ?? 'quiet'; + } + if (event.kind === 'milestone' || event.kind === 'sugar') return 'moment'; + if (event.kind === 'recovery') return 'notable'; + return 'system'; + } + + /** + * Copy for a row. + * + * Reward rows use the game config's constant string, never the service's free text, so the + * page's vocabulary is the page's own. Non-reward rows use the feed's `label`, which the + * service generates from its own templates and never from chat. + */ + private labelOf(event: FeedEvent): string { + if (event.kind === 'reward' && event.rewardKind) { + return this.game.rewardCopy[event.rewardKind]?.label ?? event.label; + } + return event.label; + } +} diff --git a/apps/stage/src/lib/utils.ts b/apps/stage/src/lib/utils.ts new file mode 100644 index 0000000..5837119 --- /dev/null +++ b/apps/stage/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** shadcn/ui's class merge helper (`components.json` points `@/lib/utils` here). */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/apps/stage/src/main.tsx b/apps/stage/src/main.tsx new file mode 100644 index 0000000..a53d5da --- /dev/null +++ b/apps/stage/src/main.tsx @@ -0,0 +1,18 @@ +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; +import './index.css'; + +const root = document.getElementById('root'); +if (!root) throw new Error('#root is missing from index.html'); + +/** + * Deliberately no `StrictMode`. + * + * StrictMode double-invokes effects in development, and this page's single effect builds a paint + * loop, a worker, an AudioContext and a feed connection. They are all disposed correctly, but + * running two of each while measuring paint timings and recording mockups makes every number a + * lie. Correctness here is checked by the e2e suite against the real build, not by a dev-only + * double render. + */ +createRoot(root).render(); diff --git a/apps/stage/src/motion/catalogue.ts b/apps/stage/src/motion/catalogue.ts new file mode 100644 index 0000000..3c237ac --- /dev/null +++ b/apps/stage/src/motion/catalogue.ts @@ -0,0 +1,324 @@ +/** + * One table per moment: which regions it touches, which emitters fire where, which tween, which + * sound tier. `docs/design/animation.md` ends its catalogue with an instruction — + * + * "Implement in one module (`src/moments.ts`) with the queue, and one CSS file for keyframes, so + * the catalogue above stays reviewable." + * + * — and this is the machine-readable half of that: the recipes are *data*, so the rail rework that + * wires the panels up reads a row rather than writing a branch, and a reviewer can diff this file + * against the design's own table line by line. + * + * Nothing here draws. `MOMENT_CATALOGUE` is inert; `engine.ts` is what walks a row and calls the + * emitters in `particles.ts`. + * + * ## The regions + * + * Rail layout v2 (`docs/stream-mvp-plan.md`, locked 2026-09-15 night), read straight out of + * `src/lib/geometry.ts` — which is what the panels are laid out from, so a moment cannot animate a + * box the page does not actually have. The engine shipped with these as local literals because the + * v2 panels were being rebuilt at the same time in another pass; that pass has landed, and this is + * the "should become a thin read of `LAYOUT`" its own comment asked for. + * + * The arithmetic those numbers close on, unchanged: + * + * left column title 40 + game 720 + gutter 4 + fly strip 220 = 984 = 1080 - 2x48 + * right rail cluster 144 + slot 420 + ticker 100 + chat 244 + 3 x 12 = 944 = 720 + 4 + 220 + * width 800 + 12 + 1012 = 1824 = 1920 - 2x48 + */ +import { LAYOUT, RAIL_BOX } from '@/lib/geometry'; +import type { Rect } from './particles'; +import { DIR } from './particles'; +import { MOMENT_TIMING, type MomentTiming, type MomentType } from './moments'; +import type { SfxTier } from './sfx-tiers'; + +/** Named rail regions a moment is allowed to animate. */ +export type MotionRegion = + | 'titleStrip' + | 'game' + | 'flyStrip' + | 'rail' + | 'progressCluster' + | 'tabSlot' + | 'ticker' + | 'chat'; + +/** Absolute boxes, stage pixels from the top left of `#stage`. */ +export const MOTION_REGIONS: Record = { + titleStrip: LAYOUT.title, + game: LAYOUT.game, + flyStrip: LAYOUT.flyStrip, + rail: RAIL_BOX, + progressCluster: LAYOUT.progress, + tabSlot: LAYOUT.tabs, + ticker: LAYOUT.events, + chat: LAYOUT.chat, +}; + +/** + * Emission points. + * + * The addendum aims particles at *things*, not at panels ("drift from the fly's head toward the + * sugar chip", "amber sparks from the new rung", "a shockwave ring from the badge count"). + * + * These are the *defaults*: a point derived from a region, which is what the harness and the + * contact sheets run on because they have no panels. On the real page every one of them except + * `flyHead` is replaced at run time with the centre of the element it names, measured from the DOM + * and pushed in with `MotionEngine.setAnchors` (`src/motion/director.ts`) — the spine's current + * rung moves as the fly climbs the ladder, so that one has to be re-measured rather than derived. + * `flyHead` keeps its derived point because the fly is inside a canvas and has no element to + * measure. + */ +export type MotionAnchor = + | 'flyHead' + | 'sugarChip' + | 'badgeCount' + | 'spineRung' + | 'tickerLine' + | 'tabSlotCentre' + | 'railCentre' + | 'titleStripCentre' + | 'gameCentre'; + +function centre(box: Rect): { x: number; y: number } { + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +export const MOTION_ANCHORS: Record = { + // The fly's head is high and left of centre in the strip (`docs/design/fly-avatar.md`). + flyHead: { x: MOTION_REGIONS.flyStrip.x + 300, y: MOTION_REGIONS.flyStrip.y + 70 }, + // SUGAR READY chip: bottom right of the compact progress cluster, per rail v2's field order. + sugarChip: { + x: MOTION_REGIONS.progressCluster.x + MOTION_REGIONS.progressCluster.width - 120, + y: MOTION_REGIONS.progressCluster.y + MOTION_REGIONS.progressCluster.height - 34, + }, + // "badges · places" sits mid-cluster. + badgeCount: { x: MOTION_REGIONS.progressCluster.x + 640, y: MOTION_REGIONS.progressCluster.y + 88 }, + // The 38-rung spine runs across the cluster under the rung name. + spineRung: { x: MOTION_REGIONS.progressCluster.x + 300, y: MOTION_REGIONS.progressCluster.y + 108 }, + // The ticker's top line, which is the one that just slid up. + tickerLine: { x: MOTION_REGIONS.ticker.x + 120, y: MOTION_REGIONS.ticker.y + 30 }, + tabSlotCentre: centre(MOTION_REGIONS.tabSlot), + railCentre: centre(MOTION_REGIONS.rail), + titleStripCentre: centre(MOTION_REGIONS.titleStrip), + gameCentre: centre(MOTION_REGIONS.game), +}; + +/** + * Colour tokens, as CSS custom properties with a fallback. + * + * The canvases already resolve their colours out of the theme this way (`src/theme/colors.ts`), so + * a theme swap moves the particles with the panels and there is still exactly one definition of + * each colour in `src/theme/tokens.css`. The fallbacks are the same literals that file's palette + * uses, which is also what makes the recipes testable and drawable without a DOM. + */ +export type ColourToken = 'amber' | 'sugar' | 'cool' | 'ink'; + +export const MOTION_COLOURS: Record = { + // The stage's one accent: every reward, rung and badge is this colour. + amber: { cssVar: '--accent', fallback: '#ffb020' }, + // Dopamine / PAM pink, used by the sugar ring and the sugar drift. + sugar: { cssVar: '--dopamine', fallback: '#f472a8' }, + // Sensory blue: the rollback's rewind field, which is the one cold moment on the stage. + cool: { cssVar: '--sensory', fallback: '#4fc3f7' }, + ink: { cssVar: '--ink-1', fallback: '#c8d0dc' }, +}; + +/** Resolved colour per token. Falls back to the literals with no DOM (tests, Node tools). */ +export type MotionPalette = Record; + +export function resolveMotionColours(root?: Element): MotionPalette { + const palette: MotionPalette = { + amber: MOTION_COLOURS.amber.fallback, + sugar: MOTION_COLOURS.sugar.fallback, + cool: MOTION_COLOURS.cool.fallback, + ink: MOTION_COLOURS.ink.fallback, + }; + const element = root ?? (typeof document === 'undefined' ? null : document.documentElement); + if (!element || typeof getComputedStyle === 'undefined') return palette; + const style = getComputedStyle(element); + for (const token of Object.keys(MOTION_COLOURS) as ColourToken[]) { + const value = style.getPropertyValue(MOTION_COLOURS[token].cssVar).trim(); + if (value) palette[token] = value; + } + return palette; +} + +/** + * One emitter call, as data. + * + * `count` is the count at full intensity; the engine multiplies it by the trigger's `intensity`, so + * the addendum's "a few sparks from the ticker line scaled by value" is one number here and no code. + * `delayMs` is measured from the moment's start, which is how the badge's shockwave lands *after* + * the fountain has left the ground. + */ +export type EmitterSpec = + | { + emitter: 'sparks'; + at: MotionAnchor; + count: number; + colour: ColourToken; + /** Cone axis, canvas radians. */ + dir: number; + /** Cone width, radians. */ + spread: number; + speed?: number; + delayMs?: number; + } + | { emitter: 'fountain'; over: MotionRegion; count: number; colour: ColourToken; delayMs?: number } + | { emitter: 'shockwave'; at: MotionAnchor; colour: ColourToken; radius: number; delayMs?: number } + | { + emitter: 'streamField'; + over: MotionRegion; + dir: number; + count: number; + colour: ColourToken; + speed?: number; + delayMs?: number; + } + | { emitter: 'drift'; from: MotionAnchor; to: MotionAnchor; count: number; colour: ColourToken; delayMs?: number }; + +/** + * Default RNG seed for the particle field. + * + * Fixed rather than time-based so the same moment produces the same burst in the harness, in the + * contact sheets (`tools/motion-strip.mts`) and in a Playwright screenshot diff. A 24/7 broadcast + * has no reason to want a different badge every time either: nobody sees two badges side by side. + */ +export const MOTION_SEED = 0x0f1ab1e5; + +/** Which tab the slot takes focus on for the duration of a moment, per the catalogue. */ +export type TabFocus = 'SENSES' | 'CONNECTOME' | 'LADDER'; + +export interface MomentRecipe { + type: MomentType; + /** Regions the moment animates. The later wiring uses this to know what to mark dirty. */ + regions: readonly MotionRegion[]; + /** Arrival / hold / exit, from `moments.ts` so the queue and the renderer cannot disagree. */ + timing: MomentTiming; + emitters: readonly EmitterSpec[]; + sfx: SfxTier; + /** Tab the slot is pinned to while the moment holds, if any. */ + focusTab?: TabFocus; + /** Whether a caption band enters over the tab slot. */ + caption: boolean; + /** The catalogue row in `docs/design/animation.md` this is a transcription of. */ + note: string; +} + +/** + * The catalogue. + * + * Read each row against the design's table. Where a row here is quieter than the prose, that is the + * "never busy" half of the intensity brief: the whole-frame paint p95 has to stay under 4 ms with + * the game, the retina, the brain map and the fly all painting too, so no moment emits more than + * about a third of the 400-particle pool except the badge, which is allowed the frame. + */ +export const MOMENT_CATALOGUE: Record = { + badge: { + type: 'badge', + regions: ['rail', 'progressCluster', 'tabSlot', 'ticker'], + timing: MOMENT_TIMING.badge, + emitters: [ + // "a larger fountain over the rail" + { emitter: 'fountain', over: 'rail', count: 120, colour: 'amber' }, + // "plus a shockwave ring from the badge count", after the fountain has cleared the edge + { emitter: 'shockwave', at: 'badgeCount', colour: 'amber', radius: 240, delayMs: 120 }, + // the badge count's own 1.15x bounce, punctuated + { emitter: 'sparks', at: 'badgeCount', count: 18, colour: 'amber', dir: DIR.up, spread: Math.PI, delayMs: 120 }, + ], + sfx: 'fanfare', + focusTab: 'LADDER', + caption: true, + note: 'Badge: rail border flash, badge count bounce, connectome flare, caption band, LADDER focus 9 s.', + }, + milestone: { + type: 'milestone', + regions: ['tabSlot', 'progressCluster', 'ticker'], + timing: MOMENT_TIMING.milestone, + emitters: [ + // "milestone spawns amber sparks from the new rung" + { emitter: 'sparks', at: 'spineRung', count: 28, colour: 'amber', dir: DIR.up, spread: Math.PI / 2 }, + // the rung "pulses twice then fills amber": the second pulse gets its own few sparks + { emitter: 'sparks', at: 'spineRung', count: 12, colour: 'amber', dir: DIR.up, spread: Math.PI / 3, delayMs: 260 }, + ], + sfx: 'chime', + focusTab: 'LADDER', + caption: true, + note: 'Milestone: caption band in from the left, spine rung pulses twice then fills, LADDER focus 9 s.', + }, + rollback: { + type: 'rollback', + regions: ['game', 'tabSlot'], + timing: MOMENT_TIMING.rollback, + emitters: [ + // "rollback spawns a backward-streaming line field over the game" + { emitter: 'streamField', over: 'game', dir: DIR.left, count: 90, colour: 'cool', speed: 1.4 }, + ], + sfx: 'rewind', + focusTab: 'LADDER', + caption: true, + note: 'Rollback: 400 ms horizontal rewind wipe with scanline flicker, "REWIND · try 3".', + }, + sugar: { + type: 'sugar', + regions: ['flyStrip', 'progressCluster', 'ticker'], + timing: MOMENT_TIMING.sugar, + emitters: [ + // "sugar spawns a burst of small warm sparks that drift from the fly's head toward the sugar chip" + { emitter: 'drift', from: 'flyHead', to: 'sugarChip', count: 34, colour: 'sugar' }, + // the head glow "blooms then decays with the PAM rate" + { emitter: 'sparks', at: 'flyHead', count: 10, colour: 'sugar', dir: DIR.up, spread: Math.PI, delayMs: 60 }, + ], + sfx: 'tone', + caption: false, + note: 'Sugar: proboscis extends 400 ms, head glow blooms, sugar ring fills then drains over the cooldown.', + }, + reward: { + type: 'reward', + regions: ['ticker'], + timing: MOMENT_TIMING.reward, + emitters: [ + // "rewards spawn a few sparks from the ticker line scaled by value" — count x intensity + // 20 at full value, 7 at the lowest tier: "a few sparks … scaled by value", with the bottom + // of the scale still legible at the 27 px body floor. + { emitter: 'sparks', at: 'tickerLine', count: 20, colour: 'amber', dir: DIR.up, spread: (Math.PI * 2) / 3, speed: 0.12 }, + ], + sfx: 'tick', + caption: false, + note: 'Small reward: ticker line slides up 240 ms, value flashes amber 120/600 ms.', + }, + dayRollover: { + type: 'dayRollover', + regions: ['titleStrip'], + timing: MOMENT_TIMING.dayRollover, + emitters: [ + // The strip is 40 px tall, so the only particle that fits is a thin trail behind the slide. + { emitter: 'sparks', at: 'titleStripCentre', count: 10, colour: 'ink', dir: DIR.right, spread: Math.PI / 8, speed: 0.22 }, + ], + sfx: 'stinger', + caption: false, + note: 'Day rollover: "DAY N" slides across the strip once, 2 s.', + }, + modeChange: { + type: 'modeChange', + regions: ['titleStrip'], + timing: MOMENT_TIMING.modeChange, + emitters: [], + sfx: 'silent', + caption: false, + note: 'Mode change: chip text swaps with a 200 ms vertical roll. No sound, no particles.', + }, +}; + +/** The recipe for a type. Total, so the engine never has to null-check a row. */ +export function recipeFor(type: MomentType): MomentRecipe { + return MOMENT_CATALOGUE[type]; +} + +/** Every region any moment can animate, for a renderer that wants to size one canvas layer. */ +export function regionsUnion(): readonly MotionRegion[] { + const seen = new Set(); + for (const recipe of Object.values(MOMENT_CATALOGUE)) for (const region of recipe.regions) seen.add(region); + return [...seen]; +} diff --git a/apps/stage/src/motion/director.ts b/apps/stage/src/motion/director.ts new file mode 100644 index 0000000..a1e523a --- /dev/null +++ b/apps/stage/src/motion/director.ts @@ -0,0 +1,506 @@ +/** + * The director: the one place that turns the motion engine and the tab controller into pixels on + * the rail. + * + * The engine (`src/motion/engine.ts`) decides *what is on stage* and draws the particle layer. + * It deliberately stops there — it owns no panel and no DOM — so this is the other half: the part + * the engine's own header calls "the rail rework that wires the panels up". + * + * What it does, once per animation frame: + * + * - hands the engine the emission points it can only get from the DOM (`setAnchors`) + * - runs the tab controller and paints the strip: underline, crossfade, which pane is visible + * - gives a moment's tab the slot for the moment's own duration, which is what replaced layout + * v1's promotion of the brain map over the rail + * - flips the attribute effects: rail flash, spine pulse, counter bounce, thumbnail blink, + * HERE FOR warmth and its threshold pulse, the day slide + * - starts the connectome flare and the game's rewind wipe + * - drains the engine's sound cues into the page's `AudioContext` + * - clears the particle canvas and lets the engine draw it + * - steps every lerped readout (`src/motion/readouts.ts`) + * + * **Two clocks.** `nowMs` is the page's data clock, which a held fixture seek freezes so a + * screenshot is reproducible; the moment queue, the readouts and the stall meter all run on it. + * `rawNowMs` is `performance.now()`, which never stops, and it drives the fixed-length DOM + * animations (a 120 ms flash, a 400 ms wipe) so a frozen frame still shows a settled end state + * rather than an animation pinned mid-flight. + * + * Effects are attribute flips plus CSS keyframes (`src/theme/motion.css`), never JS-driven style + * animation: compositor-only properties, a static end state in a frozen frame, and no layout on + * the broadcast's critical path. + */ +import type { SfxName } from '@/audio/sfx'; +import { hot, useStage } from '@/feed/store'; +import type { GameConfig } from '@/games'; +import { circuitFraction } from '@/lib/circuit-scale'; +import { rungCount } from '@/lib/ladder'; +import { type Box, LAYOUT, RAIL_BOX, STAGE_WIDTH } from '@/lib/geometry'; +import { CIRCUIT_GROUPS } from '@/lib/labels'; +import { STALL_WINDOW_SECONDS } from '@/lib/stall'; +import { TabController, TABS, type TabId } from '@/lib/tabs'; +import type { BrainMapSurface } from '@/paint/brainmap'; +import type { GameSurface } from '@/paint/game'; +import type { CanvasPalette } from '@/theme/colors'; +import { type MotionAnchor, recipeFor, type TabFocus } from './catalogue'; +import type { MotionEngine } from './engine'; +import { clamp01, cssSteps, EASINGS, PIXEL_STEP, quantize } from './lerp'; +import type { ActiveMoment } from './moments'; +import type { RailSignals } from './rail-signals'; +import { BOUNCE_MS, CROSSFADE_MS, DAY_SLIDE_MS, FLASH_UP_MS, hereForTier, SPINE_PULSE_MS } from './rail-motion'; +import { Readouts, type ReadoutInputs } from './readouts'; + +/** Roles whose mean bar fill is the "command activity" the tab steering reads. */ +const DRIVE_ROLES: readonly string[] = + CIRCUIT_GROUPS.find((group) => group.id === 'drive')?.bars.map((bar) => bar.role) ?? []; + +/** The catalogue's tab names, as `src/lib/tabs.ts` spells them. */ +const FOCUS_TAB: Record = { + SENSES: 'senses', + CONNECTOME: 'connectome', + LADDER: 'ladder', +}; + +/** + * Where a moment is allowed to paint particles. + * + * The layer's canvas spans the whole stage, because the sugar burst "drift[s] from the fly's head + * toward the sugar chip" (`docs/design/animation.md`, addendum) and that path starts at x=448 in + * the left column and ends at x=1790 in the rail — a rail-sized canvas cannot draw it. What keeps + * that from becoming a licence to paint over the broadcast is this clip: the rail and the fly + * strip, and nothing else. The game canvas and the title strip are outside it, so the badge's + * shockwave stops at the rail's edge instead of crossing the game, which is the one thing a + * broadcast overlay must never do. + */ +export const PARTICLE_CLIP: readonly Box[] = [RAIL_BOX, LAYOUT.flyStrip]; + +/** Selectors for the elements whose measured centres replace the catalogue's default anchors. */ +const ANCHOR_SELECTORS: Partial> = { + sugarChip: '[data-testid="sugar-chip"]', + badgeCount: '[data-readout="counters"]', + tickerLine: '.ticker-row', +}; + +export interface DirectorOptions { + /** Where the director queries for its elements: `#stage`. */ + root: HTMLElement; + game: GameConfig; + engine: MotionEngine; + signals: RailSignals; + palette: CanvasPalette; + map: BrainMapSurface; + gameSurface: GameSurface; + /** The particle layer's context, cleared here and drawn by the engine. */ + particleCtx: CanvasRenderingContext2D | null; + /** Pinned tab from `?tab=`, or null to cycle. */ + forcedTab: TabId | null; + playSfx: ((name: SfxName, gain: number) => void) | null; +} + +export class Director { + private readonly tabs: TabController; + private readonly readouts: Readouts; + + private railFlashUntil = Number.NEGATIVE_INFINITY; + private spinePulseUntil = Number.NEGATIVE_INFINITY; + private spinePulseRung = -1; + private bounceUntil = Number.NEGATIVE_INFINITY; + private hereForPulseUntil = Number.NEGATIVE_INFINITY; + private daySlideUntil = Number.NEGATIVE_INFINITY; + private hereForWarm = 0; + private lastMomentKey = ''; + private firstFrame = true; + private lastRank = -1; + /** The tab the last frame painted, or null on a first frame and after a seek. */ + private paintedTab: TabId | null = null; + /** Wall-clock start of the running crossfade; -Infinity means "already settled". */ + private crossfadeFromMs = Number.NEGATIVE_INFINITY; + + constructor(private readonly options: DirectorOptions) { + this.tabs = new TabController({ forced: options.forcedTab }); + this.readouts = new Readouts(options.root, options.game); + } + + /** What the slot is showing and why, for `window.__stage`. */ + state(): { + tab: TabId; + reason: string; + focused: boolean; + particles: number; + moment: string | null; + momentPhase: string | null; + pending: number; + } { + const snapshot = this.options.engine.snapshot(); + return { + tab: this.tabs.current, + reason: this.tabs.reason, + focused: this.tabs.focused, + particles: this.options.engine.field.live, + moment: snapshot.active?.type ?? null, + momentPhase: snapshot.active?.phase ?? null, + pending: snapshot.pending, + }; + } + + /** A fixture seek: nothing that was in flight happened. */ + reset(): void { + this.railFlashUntil = Number.NEGATIVE_INFINITY; + this.spinePulseUntil = Number.NEGATIVE_INFINITY; + this.bounceUntil = Number.NEGATIVE_INFINITY; + this.hereForPulseUntil = Number.NEGATIVE_INFINITY; + this.daySlideUntil = Number.NEGATIVE_INFINITY; + this.lastMomentKey = ''; + this.firstFrame = true; + this.paintedTab = null; + this.crossfadeFromMs = Number.NEGATIVE_INFINITY; + } + + /** One frame. `nowMs` is the data clock, `rawNowMs` the wall clock. */ + frame(nowMs: number, rawNowMs: number, dtMs: number): void { + const state = useStage.getState(); + const engine = this.options.engine; + + // 1. Emission points, measured. The catalogue ships arithmetic defaults; these are the real + // elements, and the spine's current rung moves every time the fly gains one. + this.updateAnchors(state.milestone.rank); + + // 2. The queue and the particle simulation, on the data clock. + engine.tick(nowMs); + const active = engine.active(nowMs); + + // 3. Everything a newly-arrived moment does to the DOM, once each. + if (active) this.onMoment(active, rawNowMs, state.milestone.rank); + else this.lastMomentKey = ''; + + // 4. Sound. The engine queues cues; the page owns the AudioContext. + for (let cue = engine.takeCue(); cue; cue = engine.takeCue()) { + if (cue.cue.sfx) this.options.playSfx?.(cue.cue.sfx, cue.cue.gain); + } + + // 5. The tab slot. + const steering = this.options.signals.takeSteering(); + if (active) { + const focus = recipeFor(active.type).focusTab; + if (focus) this.tabs.focusOn(FOCUS_TAB[focus], active.endsMs, nowMs); + } + const tab = this.tabs.update(nowMs, { + walking: state.mode === 'OVERWORLD', + commandActivity: driveActivity(), + reward: steering.reward, + rankChanged: steering.rankChanged, + newChatter: steering.newChatter, + }); + this.paintTabs(tab, rawNowMs); + + // 6. Attribute effects with a deadline, and the two that are functions of a value. + this.expire(rawNowMs); + this.paintHereFor(state.milestone.sinceSeconds, rawNowMs); + + // 7. Numbers. + const inputs = this.readInputs(nowMs, state); + if (this.firstFrame) { + this.readouts.snap(inputs); + this.firstFrame = false; + } + this.readouts.update(inputs, dtMs); + + // 8. The particle layer, last, so it draws over everything it was aimed at. The engine's + // painter contract has no `clearRect`, so the clear is the page's job — and it is skipped + // entirely on an idle frame, which is almost all of them. + this.paintParticles(); + } + + // -- Moments --------------------------------------------------------------------------------- + + /** + * The DOM half of one moment, applied once. + * + * Keyed by id *and* coalesce count, the same key the engine uses for its emitters, so a second + * sugar folding into the first re-pulses the ring as well as re-firing the sparks. + */ + private onMoment(active: ActiveMoment, rawNowMs: number, rank: number): void { + const key = `${String(active.id)}:${String(active.count)}`; + if (key === this.lastMomentKey) return; + this.lastMomentKey = key; + + const intensity = clamp01(active.trigger.intensity); + + switch (active.type) { + case 'badge': + this.railFlashUntil = rawNowMs + FLASH_UP_MS; + this.bounceUntil = rawNowMs + BOUNCE_MS; + this.pulseSpine(rank, rawNowMs); + this.options.map.flare(rawNowMs, 1); + break; + case 'milestone': + this.pulseSpine(rank, rawNowMs); + break; + case 'rollback': + this.options.gameSurface.startRewind(rawNowMs); + break; + case 'sugar': + this.options.map.flare(rawNowMs, 1); + break; + case 'reward': + // The flare scales with the reward: a +0.05 exploration tick is a ripple, a story beat is + // a wave. `intensity` is already the tier scale (`src/motion/moments.ts`). + this.options.map.flare(rawNowMs, 0.3 + 0.7 * intensity); + break; + case 'dayRollover': + this.daySlideUntil = rawNowMs + DAY_SLIDE_MS; + this.setDayText(active.trigger.label); + break; + case 'modeChange': + // The chip's vertical roll is a CSS animation keyed on the mode in `TitleStrip`, so React + // owns it: the mode is a value it already renders, and a second driver would fight it. + break; + } + } + + private pulseSpine(rank: number, rawNowMs: number): void { + this.spinePulseRung = rank; + this.spinePulseUntil = rawNowMs + SPINE_PULSE_MS; + } + + /** Clear the particle layer's last frame and let the engine draw the new one. */ + private paintParticles(): void { + const ctx = this.options.particleCtx; + if (!ctx) return; + const engine = this.options.engine; + const live = engine.field.live; + if (live === 0 && this.drawnParticles === 0) return; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.save(); + ctx.beginPath(); + for (const box of PARTICLE_CLIP) ctx.rect(box.x, box.y, box.width, box.height); + ctx.clip(); + engine.draw(ctx); + ctx.restore(); + this.drawnParticles = live; + } + + private drawnParticles = 0; + + /** + * Push the measured emission points into the engine. + * + * Cheap and idempotent: three `getBoundingClientRect` calls plus one for the rung, and only when + * the rung has actually changed. Measured rather than recomputed from `geometry.ts` because the + * rung's x is a flex division of the spine's width, and duplicating that arithmetic here is how + * the sparks end up two rungs off after a padding change. + */ + private updateAnchors(rank: number): void { + const anchors: Partial> = {}; + for (const [anchor, selector] of Object.entries(ANCHOR_SELECTORS) as [MotionAnchor, string][]) { + const point = this.pointOf(selector); + if (point) anchors[anchor] = point; + } + if (rank !== this.lastRank) { + this.lastRank = rank; + const rung = this.pointOf(`[data-rung="${rank}"]`); + if (rung) anchors.spineRung = rung; + } + if (Object.keys(anchors).length > 0) this.options.engine.setAnchors(anchors); + } + + /** Centre of an element, in stage coordinates. */ + private pointOf(selector: string): { x: number; y: number } | null { + const element = this.options.root.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return null; + const stage = this.options.root.getBoundingClientRect(); + const scale = stage.width / STAGE_WIDTH || 1; + return { + x: (rect.left - stage.left + rect.width / 2) / scale, + y: (rect.top - stage.top + rect.height / 2) / scale, + }; + } + + // -- Attribute effects ------------------------------------------------------------------------ + + private expire(rawNowMs: number): void { + this.flag('[data-rail-flash]', 'railFlash', rawNowMs < this.railFlashUntil); + this.flag('[data-day-slide]', 'daySlide', rawNowMs < this.daySlideUntil); + + const counters = this.options.root.querySelector('[data-readout="counters"]'); + if (counters) counters.dataset.bounce = rawNowMs < this.bounceUntil ? '1' : '0'; + + const pulsing = rawNowMs < this.spinePulseUntil ? this.spinePulseRung : -1; + const lit = this.options.root.querySelector('[data-rung][data-pulse="1"]'); + if (lit && Number(lit.dataset.rung) !== pulsing) lit.dataset.pulse = '0'; + if (pulsing >= 0) { + const target = this.options.root.querySelector(`[data-rung="${pulsing}"]`); + if (target) target.dataset.pulse = '1'; + } + } + + private flag(selector: string, attribute: string, on: boolean): void { + const element = this.options.root.querySelector(selector); + if (!element) return; + const value = on ? '1' : '0'; + if (element.dataset[attribute] === value) return; + element.dataset[attribute] = value; + } + + private setDayText(label: string): void { + const element = this.options.root.querySelector('[data-day-text]'); + if (element) element.textContent = label; + } + + /** + * HERE FOR: warmer at each threshold, and a single pulse on the crossing. + * + * The warmth is a function of the *value*, not of an event, so a page that loads into a fly that + * has been stuck for four hours is already warm rather than waiting for a crossing that happened + * before it started. The pulse is the crossing, detected here rather than queued as a moment: + * the number is on screen all the time, and the catalogue's row for it is a pulse on one + * element, not a moment that owns the frame. + */ + private paintHereFor(sinceSeconds: number, rawNowMs: number): void { + const element = this.options.root.querySelector('[data-here-for]'); + if (!element) return; + const warm = hereForTier(sinceSeconds); + if (warm !== this.hereForWarm) { + // Only a crossing *upward* pulses: a rank-up resets `sinceSeconds` to zero, and that is a + // milestone's moment, not a HERE FOR one. + if (warm > this.hereForWarm) this.hereForPulseUntil = rawNowMs + SPINE_PULSE_MS / 2; + this.hereForWarm = warm; + element.dataset.warm = String(warm); + } + const pulse = rawNowMs < this.hereForPulseUntil ? '1' : '0'; + if (element.dataset.pulse !== pulse) element.dataset.pulse = pulse; + } + + // -- Tabs ------------------------------------------------------------------------------------- + + /** + * The tab strip and the crossfade. + * + * Exactly one pane carries `data-visible="1"` at any instant, including mid-crossfade: the + * outgoing pane is `data-visible="0"` with a falling opacity, which is what the structural test + * counts. A pane that is neither is `visibility: hidden` and therefore out of paint entirely, + * while staying mounted so the connectome keeps its worker-rasterised base bitmap. + */ + private paintTabs(tab: TabId, rawNowMs: number): void { + const previous = this.paintedTab; + if (previous !== tab) { + // A first frame is an arrival, not a transition, so it starts already finished. + this.crossfadeFromMs = previous === null ? Number.NEGATIVE_INFINITY : rawNowMs; + this.paintedTab = tab; + } + + // The wall clock, not the data clock — the rule this file's header states for every + // fixed-length DOM animation, and the crossfade is one. On the data clock a held fixture seek + // froze the fade at zero, which paints the *outgoing* panes at full opacity and the incoming + // one at nothing: the brain map showing through whichever tab was pinned, which is what the + // cold-open screenshot baselines had been quietly recording. On the wall clock the fade + // finishes 300 ms after the change however the page's own clock is behaving, so a frozen frame + // is a settled frame. + const linear = clamp01((rawNowMs - this.crossfadeFromMs) / CROSSFADE_MS); + const eased = EASINGS.inOutCubic(linear); + + for (const id of TABS) { + const pane = this.options.root.querySelector(`[data-tab-pane="${id}"]`); + if (!pane) continue; + const isCurrent = id === tab; + const opacity = isCurrent ? eased : linear < 1 ? 1 - eased : 0; + const visible = isCurrent ? '1' : '0'; + + if (pane.dataset.visible !== visible) pane.dataset.visible = visible; + pane.style.opacity = opacity.toFixed(3); + pane.style.visibility = opacity > 0.001 ? 'visible' : 'hidden'; + + const button = this.options.root.querySelector(`[data-tab="${id}"]`); + if (button && button.dataset.active !== visible) button.dataset.active = visible; + } + + this.paintUnderline(tab); + } + + /** + * Slide the amber underline to the active tab, in 4 px steps. + * + * `docs/design/gameboy-theme.md` puts the tab underline on the stepped easing, and CSS can only + * quantise *time*, so the step count has to be a function of the distance — which is why this is + * set here rather than taken from the `--ease-pixel` token the fixed-distance elements use. The + * strip's tabs are 170 to 290 px wide, so a cycle moves the underline anywhere from 180 to 470 px + * and one constant would be right for none of them. + * + * The destination is quantised as well, so the underline comes to rest on the grid and not half a + * pixel off it. + */ + private paintUnderline(tab: TabId): void { + const button = this.options.root.querySelector(`[data-tab="${tab}"]`); + const underline = this.options.root.querySelector('[data-tab-underline]'); + const strip = button?.parentElement; + if (!button || !underline || !strip) return; + + const stageRect = this.options.root.getBoundingClientRect(); + const scale = stageRect.width / STAGE_WIDTH || 1; + const buttonRect = button.getBoundingClientRect(); + const left = quantize((buttonRect.left - strip.getBoundingClientRect().left) / scale, PIXEL_STEP); + const width = quantize(buttonRect.width / scale, PIXEL_STEP); + + const transform = `translateX(${left}px)`; + if (underline.style.transform !== transform) { + const easing = `steps(${cssSteps(Math.abs(left - this.underlineLeft))}, end)`; + if (underline.style.transitionTimingFunction !== easing) { + underline.style.transitionTimingFunction = easing; + } + this.underlineLeft = left; + underline.style.transform = transform; + } + const cssWidth = `${width}px`; + if (underline.style.width !== cssWidth) underline.style.width = cssWidth; + } + + /** Where the underline was last put, so the next move knows how far it is going. */ + private underlineLeft = 0; + + // -- Readouts --------------------------------------------------------------------------------- + + private readInputs(nowMs: number, state: ReturnType): ReadoutInputs { + const signals = this.options.signals; + const total = rungCount(state.milestone.total, this.options.game.milestoneLadder.length); + const rank = Math.max(0, Math.min(total - 1, state.milestone.rank)); + + return { + hz: state.populationRate, + hereForSeconds: state.milestone.sinceSeconds, + tries: state.milestone.attempts + 1, + runSeconds: state.runSeconds, + badges: state.badges, + places: state.uniqueLocations, + rewardTotal: state.rewardTotal, + spikes: state.spikeCount, + sugarFraction: state.sugar.cooldownMs > 0 ? Math.min(1, state.sugar.cooldownMs / 10_000) : 0, + stallSeconds: signals.stall.secondsSince(nowMs), + stallFraction: signals.stall.fraction(nowMs, STALL_WINDOW_SECONDS), + rollbacksThisRung: state.milestone.attempts, + rollbacksLifetime: signals.rollbacks.sinceLoad, + rollbackAgeSeconds: signals.rollbacks.secondsSinceLast(nowMs), + rank, + total, + // The word RUNG is the cluster's own label (`ProgressCluster`), so the line carries the + // count alone: `12/37`. + rungText: `${rank}/${Math.max(0, total - 1)}`, + }; + } +} + +/** Mean fill of the drive bars: the tab steering's "high command activity". */ +function driveActivity(): number { + if (DRIVE_ROLES.length === 0) return 0; + let sum = 0; + for (const role of DRIVE_ROLES) { + sum += circuitFraction(hot.rates[role] ?? 0, hot.circuitReferenceHz[role] ?? 30); + } + return sum / DRIVE_ROLES.length; +} + +/** The fly's head, in stage pixels: the sugar drift's origin, which has no element to measure. */ +export const FLY_HEAD_ANCHOR = { + x: LAYOUT.flyStrip.x + LAYOUT.flyStrip.width / 2, + y: LAYOUT.flyStrip.y + 96, +} as const; diff --git a/apps/stage/src/motion/engine.ts b/apps/stage/src/motion/engine.ts new file mode 100644 index 0000000..af91606 --- /dev/null +++ b/apps/stage/src/motion/engine.ts @@ -0,0 +1,299 @@ +/** + * The engine: the one object the page holds, and the only file that knows the queue, the catalogue + * and the particle pool all exist. + * + * It exists to satisfy one constraint from `src/paint/loop.ts`, which is not allowed to change: the + * loop owns the single `requestAnimationFrame` for the whole page and calls surfaces that are + * "dirty". So the engine exposes exactly the two hooks that fits — `tick(nowMs)` and `draw(ctx)` — + * plus `surface(ctx)`, which packages them as the loop's own `PaintSurface` shape so wiring it in is + * one line in `App.tsx` later and nothing in this directory has to know how the loop is structured. + * + * The split of responsibilities, top to bottom: + * + * `TriggerMapper` what happened (feed header + events -> triggers) + * `MomentQueue` what is on stage (priority, coalescing, the 9 s hold, phase progress) + * `MOMENT_CATALOGUE` what it looks like (regions, emitters, sound tier) + * `ParticleField` the pixels (pooled, additive, 400 max) + * + * The engine is the wiring between those four and owns nothing else. In particular it does not play + * sound: the `AudioContext` and the master gain belong to `src/audio/engine.ts`, which is created by + * `App` after a user gesture, so the engine *queues* cues and the page drains them with `takeCue()`. + * That also keeps the whole engine testable in Node with no Web Audio at all. + */ +import type { FeedEvent, FeedHeader } from '@flybrain/feed'; + +import { + type EmitterSpec, + MOTION_ANCHORS, + MOTION_REGIONS, + MOTION_SEED, + type MomentRecipe, + type MotionAnchor, + type MotionPalette, + type MotionRegion, + recipeFor, + resolveMotionColours, +} from './catalogue'; +import { + type ActiveMoment, + type MomentSnapshot, + MomentQueue, + type MomentTrigger, + TriggerMapper, +} from './moments'; +import { ParticleField, type ParticlePainter } from './particles'; +import { type SfxCue, type SfxTier, sfxCue } from './sfx-tiers'; + +/** The loop's surface shape (`src/paint/loop.ts`), restated so nothing here imports the loop. */ +export interface MotionSurface { + readonly name: string; + draw(nowMs: number, dtMs: number): void; +} + +/** One queued sound request, drained by whoever owns the `AudioContext`. */ +export interface QueuedCue { + tier: SfxTier; + cue: SfxCue; + /** Moment id it belongs to, for a log line or a test. */ + momentId: number; +} + +export interface MotionEngineOptions { + clock?: () => number; + /** Particle pool size. Defaults to the design's 400. */ + capacity?: number; + /** RNG seed, so a screenshot run is reproducible. */ + seed?: number; + /** Colour overrides. Defaults to the theme's CSS variables, or the token fallbacks with no DOM. */ + palette?: MotionPalette; + /** Anchor overrides, for when the v2 panels can report their real element boxes. */ + anchors?: Partial>; + /** Scale every hold — 0.1 for a demo reel, 1 on air. */ + holdScale?: number; + /** + * Longest frame the simulation will integrate in one step. A backgrounded tab or a GC pause can + * hand the loop a 2 s `dt`, and integrating that would teleport every particle off screen; the + * broadcast page's own dropped-frame threshold is 33.4 ms, so 100 ms is three of those. + */ + maxStepMs?: number; +} + +export class MotionEngine { + readonly queue: MomentQueue; + readonly field: ParticleField; + readonly mapper = new TriggerMapper(); + + private readonly clock: () => number; + private readonly palette: MotionPalette; + /** + * Emission points, mutable. + * + * Not `readonly`: the catalogue's defaults are derived from the region boxes, and the page + * replaces them with the measured centres of the elements they name once the panels have laid + * out — the spine's current rung in particular moves every time the fly gains a rung. See + * `setAnchors`. + */ + private anchors: Record; + private readonly maxStepMs: number; + + private lastTickMs: number | null = null; + /** Which emitter specs of the moment on stage have already fired. */ + private firedMask: boolean[] = []; + /** Identity of the burst the mask belongs to: a coalesce bumps `count` and re-arms the emitters. */ + private firedKey = ''; + private readonly cues: QueuedCue[] = []; + + constructor(options: MotionEngineOptions = {}) { + this.clock = options.clock ?? (() => performance.now()); + this.queue = new MomentQueue({ clock: this.clock, holdScale: options.holdScale }); + this.field = new ParticleField({ + capacity: options.capacity, + seed: options.seed ?? MOTION_SEED, + }); + this.palette = options.palette ?? resolveMotionColours(); + this.anchors = { ...MOTION_ANCHORS, ...options.anchors }; + this.maxStepMs = options.maxStepMs ?? 100; + } + + /** One feed snapshot in: events and header deltas both become moments. */ + ingest(header: FeedHeader, nowMs: number = this.clock()): void { + for (const trigger of this.mapper.observe(header)) this.queue.enqueue(trigger, nowMs); + } + + /** Ask for a moment directly — a `FeedEvent`, or a trigger the harness built by hand. */ + enqueue(input: MomentTrigger | FeedEvent, nowMs: number = this.clock()): number | null { + return this.queue.enqueue(input, nowMs); + } + + /** + * Advance the moment state machine and the particle simulation to `nowMs`. + * + * Safe to call every frame whether or not anything is happening: with no moment and no live + * particles it is a handful of comparisons. + */ + tick(nowMs: number = this.clock()): void { + const previous = this.lastTickMs; + this.lastTickMs = nowMs; + // A negative step means the clock moved backwards, which a fixture seek does on purpose + // (`src/feed/fixture.ts`); treat it as a fresh start rather than integrating backwards. + const raw = previous === null ? 0 : nowMs - previous; + const dtMs = raw < 0 ? 0 : Math.min(raw, this.maxStepMs); + + this.queue.tick(nowMs); + const active = this.queue.active(nowMs); + if (active) this.fireDue(active); + else this.firedKey = ''; + + this.field.tick(dtMs); + } + + /** Paint the particle layer. Returns the number of draw calls, 0 when nothing is alive. */ + draw(ctx: ParticlePainter): number { + return this.field.draw(ctx); + } + + /** + * Package `tick` and `draw` as one paint surface for the rAF loop. + * + * The loop hands its surfaces `(nowMs, dtMs)` and each surface owns its own context, so the + * context is bound here rather than passed per frame. + */ + surface(ctx: ParticlePainter, name = 'motion'): MotionSurface { + return { + name, + draw: (nowMs: number) => { + this.tick(nowMs); + this.draw(ctx); + }, + }; + } + + /** True when this frame has something to paint — the loop's dirty check. */ + get dirty(): boolean { + return !this.field.idle || this.queue.getSnapshot().active !== null; + } + + /** The moment on stage, with progress at `nowMs`. Reused object; read it, do not keep it. */ + active(nowMs: number = this.clock()): ActiveMoment | null { + return this.queue.active(nowMs); + } + + /** The React-facing snapshot. */ + snapshot(): MomentSnapshot { + return this.queue.getSnapshot(); + } + + subscribe(listener: () => void): () => void { + return this.queue.subscribe(listener); + } + + /** Recipe of whatever is on stage, for a renderer that wants its regions. */ + activeRecipe(nowMs: number = this.clock()): MomentRecipe | null { + const active = this.queue.active(nowMs); + return active ? recipeFor(active.type) : null; + } + + /** Take the next queued sound cue, or null. Drained by the page's audio engine. */ + takeCue(): QueuedCue | null { + return this.cues.shift() ?? null; + } + + /** How many cues are waiting. */ + get cueCount(): number { + return this.cues.length; + } + + /** + * Replace some or all of the emission points with measured ones. + * + * Called every frame by the rail's director with the element centres it can see, so a recipe + * that says "sparks from the new rung" fires from the rung the viewer is looking at rather than + * from the arithmetic guess the catalogue ships as a default. + */ + setAnchors(anchors: Partial>): void { + this.anchors = { ...this.anchors, ...anchors }; + } + + /** Where a region is, for a renderer or the harness. */ + region(which: MotionRegion): { x: number; y: number; width: number; height: number } { + return MOTION_REGIONS[which]; + } + + /** + * Drop every moment, particle and cue. + * + * The particle field is *reseeded*, not just cleared: a reset means "replay from here", and a + * fixture seek or a screenshot run that replays the same moment has to produce the same burst, + * or the contact sheets would show six frames of six different explosions. + */ + reset(): void { + this.queue.reset(); + this.mapper.reset(); + this.field.clear(); + this.field.reseed(); + this.cues.length = 0; + this.firedKey = ''; + this.firedMask = []; + this.lastTickMs = null; + } + + /** + * Fire the emitters of the moment on stage whose delay has elapsed, once each. + * + * Keyed by moment id *and* coalesce count, so a second sugar folding into the first re-fires the + * burst — which is the visible half of "equal priority coalesces": the caption does not change but + * the sparks say it happened again. + */ + private fireDue(active: ActiveMoment): void { + const recipe = recipeFor(active.type); + const key = `${String(active.id)}:${String(active.count)}`; + if (key !== this.firedKey) { + this.firedKey = key; + this.firedMask = recipe.emitters.map(() => false); + const cue = sfxCue(recipe.sfx); + if (cue.sfx) this.cues.push({ tier: recipe.sfx, cue, momentId: active.id }); + } + + for (let i = 0; i < recipe.emitters.length; i += 1) { + if (this.firedMask[i]) continue; + const spec = recipe.emitters[i] as EmitterSpec; + if (active.elapsedMs < (spec.delayMs ?? 0)) continue; + this.fire(spec, active.trigger.intensity); + this.firedMask[i] = true; + } + } + + /** One catalogue row's emitter call, with the trigger's intensity scaling its count. */ + private fire(spec: EmitterSpec, intensity: number): void { + const scale = intensity <= 0 ? 0 : Math.min(1, intensity); + const count = (base: number) => Math.max(1, Math.round(base * scale)); + switch (spec.emitter) { + case 'sparks': { + const at = this.anchors[spec.at]; + this.field.sparks(at.x, at.y, count(spec.count), this.palette[spec.colour], spec.dir, spec.spread, { + speed: spec.speed, + }); + return; + } + case 'fountain': + this.field.fountain(MOTION_REGIONS[spec.over], count(spec.count), this.palette[spec.colour]); + return; + case 'shockwave': { + const at = this.anchors[spec.at]; + this.field.shockwave(at.x, at.y, this.palette[spec.colour], { radius: spec.radius }); + return; + } + case 'streamField': + this.field.streamField(MOTION_REGIONS[spec.over], spec.dir, count(spec.count), this.palette[spec.colour], { + speed: spec.speed, + }); + return; + case 'drift': { + const from = this.anchors[spec.from]; + const to = this.anchors[spec.to]; + this.field.drift(from, to, count(spec.count), this.palette[spec.colour]); + return; + } + } + } +} diff --git a/apps/stage/src/motion/harness.tsx b/apps/stage/src/motion/harness.tsx new file mode 100644 index 0000000..bce0679 --- /dev/null +++ b/apps/stage/src/motion/harness.tsx @@ -0,0 +1,423 @@ +/** + * The motion harness: every moment, one at a time, over placeholder rectangles. + * + * Dev only. It is the entry module for `motion-harness/index.html`, which `vite dev` serves at + * `/motion-harness/` and `vite build` never sees (the build's only input is the app's own + * `index.html`), so nothing here can reach the broadcast bundle. + * + * What it is for. The effects have to be judged before they are wired into panels that are being + * rebuilt for rail layout v2 next door: a badge fountain over a 1012x944 rectangle labelled RAIL is + * a fair test of the fountain, and it cannot break a panel or fight another pass for the same file. + * So this page draws the v2 regions as dashed boxes, lights the ones the active moment's recipe + * claims, and runs the real engine over them. + * + * Two things it deliberately demonstrates rather than reimplements: + * + * - **The paint loop.** It drives the engine through the app's own `PaintLoop` + * (`src/paint/loop.ts`), unmodified, via `MotionEngine.surface(ctx)`. If the engine did not fit + * that interface, this page would not run — which is the point of the harness existing before + * the wiring does. + * - **The React binding.** The caption text comes from `useSyncExternalStore` over + * `MomentQueue.subscribe` / `getSnapshot`, at snapshot rate, while every transform is mutated + * straight onto the DOM node inside the frame callback. That is the same 4 Hz-React / + * 60 Hz-paint split `src/feed/store.ts` already enforces for the rest of the page. + * + * `window.__motion` is the control surface `tools/motion-strip.mts` drives: a virtual clock + * (`seek`) so a screenshot lands on an exact millisecond rather than "about 300 ms", and `shoot`, + * which resets, fires and seeks in one call. + */ +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { PaintLoop } from '@/paint/loop'; + +import { MOTION_ANCHORS, MOTION_REGIONS, recipeFor, resolveMotionColours, type MotionRegion } from './catalogue'; +import { MotionEngine } from './engine'; +import { EASINGS, Smoothed } from './lerp'; +import { MOMENT_TYPES, momentTotalMs, type MomentTrigger, type MomentType } from './moments'; +import { MAX_PARTICLES } from './particles'; + +/** Caption band height, matching the promoted map's band in `src/lib/geometry.ts`. */ +const CAPTION_HEIGHT = 84; + +/** The label each button fires, and the caption copy that goes with it. */ +const DEMO_TRIGGERS: Record> = { + badge: { label: 'BOULDER BADGE', detail: '1 of 8', value: 1, intensity: 1, source: 'event' }, + milestone: { label: 'Reached: Left the bedroom', detail: 'rung 4 of 38', value: 4, intensity: 1, source: 'event' }, + rollback: { label: 'REWIND', detail: 'try 3', value: 3, intensity: 1, source: 'event' }, + sugar: { label: 'SUGAR · ada', detail: 'PAM pulse 400 ms', intensity: 1, by: 'ada', source: 'event' }, + reward: { label: 'new place', detail: '+0.05', value: 0.05, intensity: 0.35, source: 'event' }, + dayRollover: { label: 'DAY 2', intensity: 0.6, source: 'header' }, + modeChange: { label: 'BATTLE', detail: 'was OVERWORLD', intensity: 0.3, source: 'header' }, +}; + +interface HarnessApi { + ready: boolean; + /** Fire one moment now. */ + fire(type: MomentType): void; + /** Three moments at once, to watch the priority queue sort them out. */ + storm(): void; + /** Clear the stage and put the virtual clock back to zero. */ + reset(): void; + /** Advance the virtual clock to `tMs` since the last reset, one 60 Hz step at a time. */ + seek(tMs: number): void; + /** Reset, fire `type` at t=0, then seek to `tMs`. One deterministic frame. */ + shoot(type: MomentType, tMs: number): void; + /** What is on stage, for an assertion. */ + state(): { type: MomentType | null; phase: string | null; live: number; calls: number; tMs: number }; + /** Measure a full pool's tick+draw on the real canvas. */ + perf(samples?: number): { p50Ms: number; p95Ms: number; maxMs: number; live: number }; + /** Every moment type, so the tool does not hard-code the list. */ + moments: readonly MomentType[]; + /** Total on-stage time per type, so the tool can pick sensible capture times. */ + totalMs(type: MomentType): number; +} + +declare global { + // eslint-disable-next-line no-var + interface Window { + __motion?: HarnessApi; + } +} + +const params = new URLSearchParams(window.location.search); +const SHOW_CONTROLS = params.get('controls') !== '0'; +const MANUAL = params.get('manual') === '1'; + +function Harness(): React.JSX.Element { + const canvasRef = useRef(null); + const captionRef = useRef(null); + const badgeRef = useRef(null); + const dayRef = useRef(null); + const flashRef = useRef(null); + const readoutRef = useRef(null); + const stageRef = useRef(null); + const [fit, setFit] = useState(1); + + // The clock. In manual mode the engine only ever sees the virtual time `seek` sets, which is what + // makes a contact sheet reproducible to the millisecond. + const clock = useRef({ virtualMs: 0 }); + const engine = useMemo( + () => + new MotionEngine({ + clock: () => (MANUAL ? clock.current.virtualMs : performance.now()), + palette: resolveMotionColours(), + }), + [], + ); + + const snapshot = useSyncExternalStore( + (listener) => engine.subscribe(listener), + () => engine.snapshot(), + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.width = 1920; + canvas.height = 1080; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // The badge count's pop and the ticker flash are `Smoothed`, not tweens: a target held at 1 for + // the rise window and released afterwards, with asymmetric time constants, is the catalogue's + // "1.15x bounce" and "flashes amber 120/600 ms" with no timers involved. + const pop = new Smoothed(0, { riseTauMs: 55, fallTauMs: 150 }); + const flash = new Smoothed(0, { riseTauMs: 40, fallTauMs: 200 }); + let lastMomentId = 0; + let calls = 0; + + const paint = (nowMs: number, dtMs: number) => { + engine.tick(nowMs); + const active = engine.active(nowMs); + + if (active && active.id !== lastMomentId) lastMomentId = active.id; + // A flash is "up for 120 ms, then down over 600": the target is *held* at 1 for the rise + // window and released afterwards. Releasing it on the same frame it was set would only ever + // reach one frame's worth of the rise (about a third), which is how the first contact sheet + // came out with an invisible reward flash. + const held = (type: MomentType, forMs: number) => + active?.type === type && active.elapsedMs <= forMs ? 1 : 0; + pop.to(held('badge', 90)); + flash.to(held('reward', 120)); + pop.tick(dtMs); + flash.tick(dtMs); + + // The caption band: in from the left on out-expo, out on the exit, nothing between. + const caption = captionRef.current; + if (caption) { + const presence = active && recipeFor(active.type).caption ? active.presence : 0; + caption.style.opacity = String(presence); + caption.style.transform = `translate3d(${String(Math.round(-120 * (1 - presence)))}px, 0, 0)`; + } + + const badge = badgeRef.current; + if (badge) { + // Out-back on the way up, so the number overshoots by about 15% and settles. + const bounce = 1 + 0.15 * EASINGS.outBack(Math.min(1, pop.value)); + badge.style.transform = `translate3d(-50%, -50%, 0) scale(${bounce.toFixed(4)})`; + } + + const day = dayRef.current; + if (day) { + const sliding = active?.type === 'dayRollover'; + const progress = sliding ? EASINGS.inOutCubic(active.elapsedMs / momentTotalMs('dayRollover')) : 0; + day.style.opacity = sliding ? String(Math.min(1, active.presence)) : '0'; + day.style.transform = `translate3d(${String(Math.round(-700 + 1400 * progress))}px, -50%, 0)`; + } + + const line = flashRef.current; + if (line) line.style.background = `rgba(255, 176, 32, ${(0.55 * flash.value).toFixed(3)})`; + + ctx.clearRect(0, 0, 1920, 1080); + calls = engine.draw(ctx); + + const readout = readoutRef.current; + if (readout) { + readout.textContent = + `t ${((MANUAL ? clock.current.virtualMs : nowMs) / 1000).toFixed(2)}s ` + + `moment ${active ? `${active.type}/${active.phase} ${(active.presence * 100).toFixed(0)}%` : '—'} ` + + `particles ${String(engine.field.live)} draws ${String(calls)} ` + + `dropped ${String(engine.field.dropped)} cues ${String(engine.cueCount)}`; + } + }; + + const loop = new PaintLoop((nowMs, dtMs, time) => { + time('motion', () => { + paint(nowMs, dtMs); + }); + }); + if (!MANUAL) loop.start(); + + const api: HarnessApi = { + ready: true, + moments: MOMENT_TYPES, + totalMs: momentTotalMs, + fire: (type) => { + engine.enqueue({ type, ...DEMO_TRIGGERS[type] }, engine.active(0) ? clock.current.virtualMs : undefined); + }, + storm: () => { + // Deliberately out of priority order, to watch the queue reorder them. + engine.enqueue({ type: 'reward', ...DEMO_TRIGGERS.reward }); + engine.enqueue({ type: 'sugar', ...DEMO_TRIGGERS.sugar }); + engine.enqueue({ type: 'badge', ...DEMO_TRIGGERS.badge }); + }, + reset: () => { + engine.reset(); + clock.current.virtualMs = 0; + lastMomentId = 0; + pop.snap(0); + flash.snap(0); + ctx.clearRect(0, 0, 1920, 1080); + paint(0, 0); + }, + seek: (tMs) => { + // 60 Hz steps: the same integration a live frame gets, so a seek and a watch agree. + const step = 1000 / 60; + while (clock.current.virtualMs + step <= tMs) { + clock.current.virtualMs += step; + paint(clock.current.virtualMs, step); + } + const rest = tMs - clock.current.virtualMs; + if (rest > 0) { + clock.current.virtualMs = tMs; + paint(tMs, rest); + } + }, + shoot: (type, tMs) => { + api.reset(); + engine.enqueue({ type, ...DEMO_TRIGGERS[type] }, 0); + paint(0, 0); + if (tMs > 0) api.seek(tMs); + }, + state: () => { + const active = engine.active(MANUAL ? clock.current.virtualMs : performance.now()); + return { + type: active?.type ?? null, + phase: active?.phase ?? null, + live: engine.field.live, + calls, + tMs: MANUAL ? clock.current.virtualMs : performance.now(), + }; + }, + perf: (samples = 200) => { + const field = engine.field; + const fill = () => { + field.clear(); + field.sparks(960, 540, 200, '#ffb020', -Math.PI / 2, Math.PI / 2, { lifeMs: 1200 }); + field.streamField(MOTION_REGIONS.game, Math.PI, 150, '#4fc3f7', { lifeMs: 1200 }); + field.fountain(MOTION_REGIONS.rail, 49, '#ffb020', { lifeMs: 1200 }); + field.shockwave(MOTION_ANCHORS.badgeCount.x, MOTION_ANCHORS.badgeCount.y, '#ffb020', { lifeMs: 1200 }); + }; + + fill(); + for (let i = 0; i < 60; i += 1) { + field.tick(1); + field.draw(ctx); + } + + const timings: number[] = []; + fill(); + for (let i = 0; i < samples; i += 1) { + if (field.live < MAX_PARTICLES) fill(); + const started = performance.now(); + field.tick(1); + field.draw(ctx); + timings.push(performance.now() - started); + } + const live = field.live; + timings.sort((a, b) => a - b); + field.clear(); + ctx.clearRect(0, 0, 1920, 1080); + return { + p50Ms: timings[Math.floor(timings.length * 0.5)] ?? 0, + p95Ms: timings[Math.floor(timings.length * 0.95)] ?? 0, + maxMs: timings[timings.length - 1] ?? 0, + live, + }; + }, + }; + + window.__motion = api; + api.reset(); + document.documentElement.dataset.motionReady = '1'; + + const requested = params.get('moment'); + if (requested && (MOMENT_TYPES as readonly string[]).includes(requested)) { + api.fire(requested as MomentType); + } + + return () => { + loop.stop(); + delete window.__motion; + delete document.documentElement.dataset.motionReady; + }; + }, [engine]); + + // One transform on one element, exactly as the broadcast page scales itself: the harness is + // authored at 1920x1080 and only ever shrunk to fit a smaller window. + useEffect(() => { + const measure = () => { + setFit(Math.min(1, window.innerWidth / 1920, (window.innerHeight - (SHOW_CONTROLS ? 56 : 0)) / 1080)); + }; + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, []); + + const litRegions = new Set(snapshot.active ? recipeFor(snapshot.active.type).regions : []); + const slot = MOTION_REGIONS.tabSlot; + + return ( + <> +
+
+ {(Object.keys(MOTION_REGIONS) as MotionRegion[]) + // `rail` is the union of the four rail rows, so drawing it as a box as well would just + // double every border. It still lights up, as the badge's own region. + .filter((name) => name !== 'rail') + .map((name) => ( +
+ {SHOW_CONTROLS ? {name} : null} +
+ ))} + + {/* The ticker's top line, which flashes amber on a reward. */} +
+ + {/* The badge count. */} +
+ 1/8 +
+ + {/* DAY N, sliding across the title strip. */} +
+ {snapshot.active?.type === 'dayRollover' ? snapshot.active.label : 'DAY 2'} +
+ + {/* The caption band, inside the tab slot's own bottom edge. */} +
+ {(snapshot.active?.type ?? '').toUpperCase()} + {snapshot.active?.label ?? ''} + + {snapshot.active?.detail ?? ''} + {snapshot.active && snapshot.active.count > 1 ? ` ×${String(snapshot.active.count)}` : ''} + +
+ + +
+
+ + {SHOW_CONTROLS ? ( +
+ {MOMENT_TYPES.map((type) => ( + + ))} + + + +
+
+ ) : ( +