/** * Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3. * * `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry * over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three * TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of * those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat, * and that is where nearly all of the bytes are. * * **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes * one district; comparing the whole array as a unit would resend every other player's board on * every step, which is exactly the cost this exists to avoid. On a four-player table that is three * boards of waste per step, and a step is emitted for every bot move as well as every human one. * * It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place * `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced * that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game. * * The convention that does carry over, kept identical so a reader of one file can read the other: * an absent or `null` field means "unchanged since the last thing sent to this receiver", and the * receiving side merges against the last full frame it actually holds. A first connect or a * reconnect after a gap sends a full frame instead — the display stream resets rather than replaying * (§ v0.8.0). * * Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly. */ import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts'; /** * One district with its two heavy fields nulled when unchanged. * * `seat` is the identity and is always present — it is what the receiver matches on. `player` and * `name` are always sent too, and deliberately: Employee Rotation moves players between districts, * so the pairing of seat to player is itself news, and it costs two small fields to never have to * reason about whether a relabelling was missed. */ export type PublicDistrictDelta = Omit & { cells: CellView[] | null; facilities: FacilityView[] | null; }; /** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */ type PublicTable = Omit; /** * A `PublicFrame` reduced to WHAT CHANGED. * * **Partial, not a full frame with holes**, and that distinction was measured rather than assumed. * The first version of this spread `...next` and nulled only the board fields, so every step shipped * all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave * automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to * look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at * ~11 KB each**. As a partial they are a few dozen bytes. */ export type PublicFrameDelta = { /** Only the shared-table fields whose value differs from the previous frame. */ table: Partial; /** The Division, only when it changed. */ division: DivisionView[] | null; /** Only the districts that changed, each carrying only the board fields that changed. */ districts: PublicDistrictDelta[]; }; const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] => (Object.keys(frame) as (keyof PublicFrame)[]).filter( (k): k is keyof PublicTable => k !== 'division' && k !== 'districts', ); /** * `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect * or a reset — in which case everything is sent in full. */ export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta { const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []); const table: Partial = {}; for (const key of TABLE_KEYS(next)) { if (previous === null || !same(previous[key], next[key])) { (table as Record)[key] = next[key]; } } const districts: PublicDistrictDelta[] = []; for (const d of next.districts) { const was = before.get(d.seat); const cells = was && same(was.cells, d.cells) ? null : d.cells; const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities; // A district with nothing new is left out entirely rather than sent as a row of nulls: on a // four-player table three of them are unchanged on every single step. if (was && cells === null && facilities === null && same(was, d)) continue; districts.push({ ...d, cells, facilities }); } return { table, division: previous !== null && same(previous.division, next.division) ? null : next.division, districts, }; } /** The receiving side: merges a delta back onto the last full public frame this receiver holds. */ export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame { const base = previous ?? (delta.table as PublicTable); const merged = { ...base, ...delta.table } as PublicTable; const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d])); for (const d of delta.districts) { const was = bySeat.get(d.seat); bySeat.set(d.seat, { ...d, cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`), facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`), }); } return { ...merged, division: delta.division ?? need(previous?.division, 'division'), districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat), }; } /** * A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the * SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has * been delivered, and quietly producing a frame with a missing board would put a blank district in * front of a player. `frame-delta.ts` throws in the same situation and for the same reason. */ function need(value: T | undefined, what: string): T { if (value === undefined) { throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`); } return value; } /** A face-up or face-down pile a card can move to or from, as the display addresses it. */ export type PileKey = 'home' | 'salvage' | `dept${number}`; /** * WHICH PILES A STEP MOVED — derived, never sent. * * The receiver already holds the frame before a step and the frame after it, so which pile changed * is a diff rather than something the wire has to carry. That matters twice over: nothing is added * to the protocol, and it cannot drift out of step with the projection the way a hand-maintained * hint would. * * WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an * unchanged board — the step holds the screen, and the only thing that moved is a number in a panel * they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast * for me to see"*, which was never about duration. Lighting the pile is what tells the eye where. * * WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about: * * | intent | piles | * | ----------------------- | -------------------------------------------------------- | * | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private | * | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it | * | `card.discard` | that `dept` | * | `card.play` | `salvage`, or nothing here when it lands on the board | * | switching, new trains | nothing here — those show on the board itself | */ export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] { if (before === null) return []; const out: PileKey[] = []; if (before.deck !== after.deck) out.push('home'); after.departmentDepth.forEach((depth, i) => { // The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone // and changes the card everybody can see, which is the half that matters to a watcher. if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) { out.push(`dept${i}`); } }); if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) { out.push('salvage'); } return out; }