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
195 lines
9.8 KiB
TypeScript
195 lines
9.8 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|