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:
co-authored by
Claude Opus 5
parent
42adfda390
commit
06db36e5b5
+65
-6
@@ -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' };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user