Files
station-master/src/sim/public-delta.ts
T
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00

195 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<PublicDistrict, 'cells' | 'facilities'> & {
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<PublicFrame, 'division' | 'districts'>;
/**
* 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<PublicTable>;
/** 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<PublicTable> = {};
for (const key of TABLE_KEYS(next)) {
if (previous === null || !same(previous[key], next[key])) {
(table as Record<string, unknown>)[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<T>(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 |
*/
/**
* Mainline cards that became a different card between two public boards — a Realignment, which is the
* one play that changes the Division itself.
*
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
* the event, so the flash lands with the step that shows it and not when the intent arrived.
*/
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
if (before === null) return [];
const out: number[] = [];
after.division.forEach((node, i) => {
const was = before.division[i];
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
});
return out;
}
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;
}