flybrain/packages/feed/README.md
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

107 lines
7 KiB
Markdown

# @flybrain/feed
Shared TypeScript types, a binary snapshot codec, a pinned JSON Schema and a runnable fake
`flysim` for local development — the contract between `flysim` (the Rust simulation service) and
its consumers, `flystage` (the display page) and `flybridge` (the Twitch chat/points bridge).
Binding contracts: [`docs/feed-protocol.md`](../../docs/feed-protocol.md) (the WebSocket
snapshot feed) and [`docs/control-api.md`](../../docs/control-api.md) (the localhost HTTP control
API). This package is the TypeScript side of those contracts; `services/flysim` mirrors the same
shapes with serde, and both sides test against `src/schema.json`.
## What is in here
- **`src/types.ts`**: every interface from the two contract docs (`FeedHeader`, `FeedEvent`,
`ClientHello`, `StatusResponse`, `StimulateRequest`/`StimulateResponse`, `RewardRequest`,
`EventsResponse`, and friends), plus the shared constants (`FEED_PROTOCOL`, `FEED_PORT`,
`CONTROL_PORT`, `FRAME_WIDTH`, `FRAME_HEIGHT`, `AUDIO_RATE`).
- **`src/codec.ts`**: `encodeSnapshot` / `decodeSnapshot` implement the exact binary framing from
`docs/feed-protocol.md` (`u32 LE headerLength | header JSON | attachments...`). Pure functions
on `Uint8Array`/`DataView`/`TextEncoder`/`TextDecoder` only, so the browser stage can use the
same code as Node tests.
- **`src/schema.json`**: a strict JSON Schema (draft 2020-12) for `FeedHeader`, hand-written to
match `types.ts`. `additionalProperties: false` on every fixed-shape object and enums on every
closed set of string values (`status`, `game.mode`, `milestone` fields, event `kind`, reward
`kind`, attachment `kind`). The one deliberate exception is `rates`, which is genuinely an open
string-keyed map per the contract doc (`Record<string, number>`) — see the comment in the
schema for why it is not locked down the same way.
- **`src/chat.ts`**: `sanitizeChatText` — the chat text rules from `docs/control-api.md`'s
`POST /chat`, in the same order the Rust side applies them (NFC, no control characters, a
letters/digits/space/punctuation allowlist that excludes emoji and combining marks, whitespace
collapsing, a 200-code-point cap, and a URL refusal). `classifyChatText` returns the rejection
reason, which is the label on flysim's `fly_chat_rejected_total{reason}`.
`tests/fixtures/chat-cases.json` is the hostile-input corpus **both** this package's tests and
`services/flysim/crates/flysim/tests/chat.rs` load, so the two implementations cannot drift.
- **`src/names.ts`**: `validateDisplayName`, the display-name chokepoint (see the bridge README).
- **`src/fixture.ts`**: the `.flyfeed` fixture container — a recorded run of the feed
(`"FLYFEED\0"`, a version, a JSON manifest, then the exact wire messages length-prefixed).
`encodeFlyfeed`/`decodeFlyfeed` for whole files, `encodeFlyfeedHeader`/`encodeFlyfeedRecord`
for a streaming recorder, and `iterateFlyfeedRecords` yielding zero-copy views. Pure
`Uint8Array` functions, so `apps/stage/tools/record-fixture.mts` (Node) writes the files the
stage's player mode (browser) reads. A `.flyfeed.gz` is this format gzipped; the caller
inflates and `isGzip` sniffs which it has. The manifest's `attachmentPolicy` records what the
recorder kept per attachment kind, because a full-rate 120 s recording is hundreds of MB.
- **`src/fake/`**: a self-contained synthetic `flysim` (`simulator.ts`, the state machine; `prng.ts`,
a small seeded PRNG; `server.ts`, the WebSocket + HTTP wiring and CLI). Deterministic given a
seed, and has no dependency on `@flybrain/brain` or the real dataset.
## Running the fake server
```sh
npx tsx packages/feed/src/fake/server.ts \
[--feed-port 7400] [--control-port 7401] \
[--scenario boot|running|stuck|milestone] \
[--allow-reward] [--seed 12345] [--no-chat] [--no-chatter] \
[--spikes-per-tick 30000]
```
- **`boot`**: stays in `booting` status forever — useful for testing the boot screen.
- **`running`** (default): skips the boot wait so the feed is immediately `running`.
- **`stuck`**: milestone rank fixed, `sinceSeconds` climbs without bound, `attempts` increments
periodically — exercises the stuck-o-meter.
- **`milestone`**: the milestone rank steps up the ladder every 90 s. The ladder is 38 rungs, the
same length as the real Pokémon Red one (`docs/design/ladder.md`), so `milestone.total` is 38 and
`rank` runs 0..37. The wording differs from the real ladder's on purpose: the stage draws the
current rung's label from the feed and the others from its own per-game config, and the two
differing is what proves which one is authoritative.
All four scenarios wander `command_0..7` and the other tracked rates, hold a dominant D-pad
direction for ~400 ms at a time with occasional A/B pulses, emit reward events roughly every 20 s,
auto-checkpoint every 5 s, and stream a procedurally drawn 160x144 Game Boy-palette test frame (no
Nintendo assets), a quiet 220 Hz sine at 48 kHz stereo `f32`, and a spikes bitset with ~30,000 bits
set per snapshot by default — matching the live service's measured rate, not a placeholder — at
30 Hz while running and 2 Hz otherwise. Override with `--spikes-per-tick` (or `spikesPerTick` on
`startFakeServer`/`FakeFlysim`) to test against a different density.
It also implements the control API in full: `GET /status`, `POST /stimulate` (6/min global limit,
no overlap with an active pulse, emits a `sugar` `FeedEvent`), `POST /reward` (403 unless
`--allow-reward`), `POST /chat` (name validation, `sanitizeChatText`, 1 per 2 s per name and 5 per
s globally, 403 with `--no-chat`), `POST /checkpoint`, `POST /pause`/`POST /resume`,
`GET /events?since=&limit=`, `GET /healthz`.
The fake also scripts plausible viewer chatter into the header's `chat` ring every 8 to 22
simulated seconds, including the bridge's own `bot: true` template replies, so the stage's
persistent CHAT panel has something to render before either real service exists. `--no-chatter`
leaves the ring to whatever posts to `/chat`; `--no-chat` is the kill switch, and makes the header
omit `chat` entirely.
`startFakeServer(options)` (exported from the `./fake` subpath) starts the same thing
in-process on ports you choose — pass `feedPort: 0` / `controlPort: 0` for ephemeral ports — which
is how the integration tests drive it without spawning a child process.
## How `flystage` and `flybridge` consume this
- **`flystage`** imports `@flybrain/feed` for the types and `decodeSnapshot`, opens the feed
WebSocket, sends `{"protocol":1,"client":"stage","wants":["frame","audio","spikes"]}`, and
renders each decoded `FeedHeader` plus attachments. Against `@flybrain/feed/fake`'s fake server
during development, before `flysim` exists.
- **`flybridge`** imports the control API request/response types, calls `POST /stimulate` on chat
commands and Channel Points redemptions, and reads `GET /status`/`GET /events` for its own
state. It asks for no feed attachments (`wants: []`) since it never renders anything.
## Scripts
```sh
npm test # node --import tsx --test tests/**/*.test.ts
npm run typecheck
```