v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound

The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never
been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md.

- Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block —
  they had drifted, and each was missing a question the other asked.
- A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and
  keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is
  in, not just the last one.
- The start of a game is drawn: a handoff beat, an announcement, the code and type in the header.
- Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client;
  justDrawn goes to the seat that drew it and nobody else.
- Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board
  never named the Superintendent, bot seats were reported as absent players, and rule section
  numbers are out of every string a player reads.

Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
This commit is contained in:
Jesse.Markowitz
2026-08-23 05:46:42 -04:00
co-authored by Claude Opus 5
parent 42adfda390
commit 06db36e5b5
41 changed files with 4387 additions and 588 deletions
+65 -6
View File
@@ -42,14 +42,33 @@ export type Push = {
/** Narration since the LAST push to this specific seat, not the whole game's log. */
lines: { text: string; tone: string }[];
/**
* Connection news about ANOTHER seat — never this push's own recipient. `lobby-and-sessions.md`
* Connection news about OTHER seats — never this push's own recipient. `lobby-and-sessions.md`
* §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go
* through the engine or the shared narration log (which must stay replayable from a seed). Built
* and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never
* sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no
* `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`.
*
* A LIST, since 2026-08-23: a connecting client is told about every other seat at once. It used to
* learn of a seat only when that seat disconnected AFTER it connected, so a player arriving at a
* table where two people had not shown up yet was told nothing at all. `seen` distinguishes "was
* here and dropped" from "has never opened the game".
*/
presence?: { seat: PlayerIndex; connected: boolean };
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
/**
* THE FOUR TRANSIENT SIGNALS (2026-08-23) — what solitaire has always drawn and multiplayer never
* did: sound cues, the timetable slot a D12 just filled, a one-line announcement, and the card
* that just came into this seat's hand.
*
* The first three are SHARED — a collision anywhere on the Division, the Stage bell, a train
* running off the end pays everyone — so they are identical in every seat's push and drained once
* per broadcast. `justDrawn` is not: it goes ONLY to the seat that drew it (`game.justDrawn` is
* one field for the whole game and does not say whose). `test/redaction.test.ts` is the guard.
*/
cues?: string[];
scheduled?: number | null;
announcement?: string | null;
justDrawn?: string | null;
};
/**
@@ -141,6 +160,9 @@ function buildSession(
lastMoveAtInit: number,
): GameSession {
let lastMoveAt = lastMoveAtInit;
/** Who drew the card `game.justDrawn` names. The engine records WHICH card came into hand but not
* whose hand it went into, and that is the whole difference between a badge and a leak. */
let lastDraw: { seat: PlayerIndex; cardId: string } | null = null;
const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>();
@@ -166,16 +188,41 @@ function buildSession(
return seat === currentActor(game) ? actionMenu(game, seat) : null;
}
function pushFor(seat: PlayerIndex): Push {
/** What the last batch of events earned, drained from the game exactly once and then handed to
* every seat. `null` on a (re)connect: a fresh connection is drawing a STATE, and replaying the
* sounds of everything it missed would be a burst of noise about the past. */
type Moment = { cues: string[]; scheduled: number | null; announcement: string | null };
function takeMoment(): Moment {
const cues = game.cues.splice(0, game.cues.length);
const scheduled = game.scheduled;
game.scheduled = null;
const announcement = game.announced;
game.announced = null;
return { cues, scheduled, announcement };
}
function pushFor(seat: PlayerIndex, moment: Moment | null): Push {
const frame = frameFor(seat);
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
lastFrame.set(seat, frame);
return { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
if (moment) {
if (moment.cues.length > 0) push.cues = moment.cues;
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
if (moment.announcement !== null) push.announcement = moment.announcement;
}
// The drawer's own card, and nobody else's — on a reconnect too, so the badge survives a refresh.
if (lastDraw !== null && lastDraw.seat === seat) push.justDrawn = lastDraw.cardId;
return push;
}
function pushesForAll(): Map<PlayerIndex, Push> {
const moment = takeMoment();
const out = new Map<PlayerIndex, Push>();
for (let seat = 0; seat < playerNames.length; seat++) out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex));
for (let seat = 0; seat < playerNames.length; seat++) {
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment));
}
return out;
}
@@ -224,13 +271,21 @@ function buildSession(
const options = legalActions(game.state, actor);
if (options.length === 0) return;
const choice = developerBot.choose(game.state, actor, options);
const drawnBefore = game.justDrawn;
const ok = submit(game, choice);
if (game.justDrawn !== drawnBefore && game.justDrawn !== null) {
lastDraw = { seat: actor, cardId: game.justDrawn };
}
/* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */
if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`);
settleTiming();
}
}
driveBots();
// Whatever the opening bot turns earned belongs to a game nobody was connected to yet — dropped
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
// bound on a server, which nothing was draining before this.)
takeMoment();
return {
playerCount: playerNames.length,
@@ -240,7 +295,7 @@ function buildSession(
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
// (or a server restart, Phase 3) — so the honest thing is a full Frame, not a delta.
lastFrame.delete(seat);
return pushFor(seat);
return pushFor(seat, null);
},
intent(seat, seq, i) {
@@ -259,7 +314,11 @@ function buildSession(
const code = check(game.state, seat, i);
if (code) return { accepted: false, code };
const drawnBefore = game.justDrawn;
const applied = submit(game, i);
if (game.justDrawn !== drawnBefore && game.justDrawn !== null) {
lastDraw = { seat, cardId: game.justDrawn };
}
/* c8 ignore next -- `check` above already proved this intent is legal; `submit` cannot then refuse it. */
if (!applied) return { accepted: false, code: 'REJECTED' };