flybrain/packages/feed
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
..
src flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
tests flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
package.json flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
README.md flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
tsconfig.json flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00

@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 (the WebSocket snapshot feed) and 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

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

npm test         # node --import tsx --test tests/**/*.test.ts
npm run typecheck