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
@@ -10,6 +10,7 @@ import {
|
||||
createLobby,
|
||||
freshGameCode,
|
||||
joinLobby,
|
||||
leaveLobby,
|
||||
playerCountAllowed,
|
||||
reassignHost,
|
||||
setBotSeat,
|
||||
@@ -250,3 +251,96 @@ describe('game codes', () => {
|
||||
assert.match(code, /^[A-Z]+-\d{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a name nobody else at the table is using', () => {
|
||||
/**
|
||||
* The display name is not decoration: it labels the district on the Division map, it is what the
|
||||
* turn chart means by "waiting on Jesse", and `record()` puts it in front of every line that
|
||||
* player causes. Two identical names make all three ambiguous, and the names lock at
|
||||
* `Lobby.Start` — so the refusal has to happen at the door.
|
||||
*/
|
||||
it('refuses a second player using a name already at the table, whatever the case or spacing', () => {
|
||||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
|
||||
for (const attempt of ['Alice', 'alice', ' ALICE ']) {
|
||||
const result = joinLobby(lobby, attempt);
|
||||
assert.equal(result.ok, false, `"${attempt}" was allowed alongside Alice`);
|
||||
if (!result.ok) assert.equal(result.code, 'NAME_TAKEN');
|
||||
}
|
||||
});
|
||||
|
||||
it('frees the name again when that player leaves', () => {
|
||||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
|
||||
const joined = joinLobby(lobby, 'Bob');
|
||||
assert.ok(joined.ok);
|
||||
if (!joined.ok) return;
|
||||
const after = leaveLobby(joined.lobby, joined.session.token);
|
||||
const again = joinLobby(after.lobby, 'Bob');
|
||||
assert.equal(again.ok, true, 'a departed player’s name was still held against the table');
|
||||
assert.equal(after.empty, false, 'the host is still seated');
|
||||
assert.equal(after.lobby.hostToken, session.token, 'a non-host leaving moved the host chair');
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaving a lobby', () => {
|
||||
/**
|
||||
* There was no way out at all before 2026-08-23. Start needs every chair filled and `setBotSeat`
|
||||
* refuses to touch an occupied human seat, so a mis-join or a player who wandered off wedged the
|
||||
* whole table.
|
||||
*/
|
||||
const table = () => {
|
||||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
|
||||
const bob = joinLobby(lobby, 'Bob');
|
||||
assert.ok(bob.ok);
|
||||
if (!bob.ok) throw new Error('Bob could not sit down');
|
||||
const carol = joinLobby(bob.lobby, 'Carol');
|
||||
assert.ok(carol.ok);
|
||||
if (!carol.ok) throw new Error('Carol could not sit down');
|
||||
return { lobby: carol.lobby, alice: session, bob: bob.session, carol: carol.session };
|
||||
};
|
||||
|
||||
it('empties the chair and forgets the token, leaving the rest of the table alone', () => {
|
||||
const { lobby, bob } = table();
|
||||
const after = leaveLobby(lobby, bob.token);
|
||||
assert.equal(after.lobby.seats[1], null, 'the seat was not freed');
|
||||
assert.equal(after.lobby.seats.length, 3, 'the table changed size');
|
||||
assert.ok(!after.lobby.joinOrder.includes(bob.token), 'the departed token is still in the join order');
|
||||
assert.equal((after.lobby.seats[0] as { displayName: string }).displayName, 'Alice');
|
||||
assert.equal((after.lobby.seats[2] as { displayName: string }).displayName, 'Carol');
|
||||
});
|
||||
|
||||
it('names a seat to clear somebody else — what the host’s "remove" does', () => {
|
||||
const { lobby, alice, carol } = table();
|
||||
const after = leaveLobby(lobby, alice.token, 2);
|
||||
assert.equal(after.lobby.seats[2], null, 'the named seat was not cleared');
|
||||
assert.ok(!after.lobby.joinOrder.includes(carol.token));
|
||||
// Seat index IS player index (`lobby.ts`), so nothing may shuffle up to fill the hole.
|
||||
assert.equal((after.lobby.seats[0] as { displayName: string }).displayName, 'Alice');
|
||||
assert.equal((after.lobby.seats[1] as { displayName: string }).displayName, 'Bob');
|
||||
});
|
||||
|
||||
it('passes host rights on when the host is the one who leaves', () => {
|
||||
const { lobby, alice, bob } = table();
|
||||
const after = leaveLobby(lobby, alice.token);
|
||||
assert.equal(after.lobby.hostToken, bob.token, 'the earliest-joined remaining player did not become host');
|
||||
assert.equal(after.empty, false);
|
||||
});
|
||||
|
||||
it('reports the table empty when the last human goes, so the caller can drop it', () => {
|
||||
const { lobby, alice, bob, carol } = table();
|
||||
const one = leaveLobby(lobby, bob.token);
|
||||
const two = leaveLobby(one.lobby, carol.token);
|
||||
assert.equal(two.empty, false, 'Alice is still sitting there');
|
||||
const three = leaveLobby(two.lobby, alice.token);
|
||||
assert.equal(three.empty, true, 'a lobby with nobody human in it did not report itself empty');
|
||||
});
|
||||
|
||||
it('leaves a bot seat to the bot controls, and ignores a token holding no seat', () => {
|
||||
const { lobby, alice } = table();
|
||||
const withBot = setBotSeat(leaveLobby(lobby, alice.token, 1).lobby, 1, true);
|
||||
// `leaveLobby` is about people. A bot is removed with the button that put it there.
|
||||
const after = leaveLobby(withBot, alice.token, 1);
|
||||
assert.deepEqual(after.lobby.seats[1], { kind: 'bot' }, 'leaving cleared a bot seat');
|
||||
const stranger = leaveLobby(withBot, 'not-a-token');
|
||||
assert.equal(stranger.lobby, withBot, 'an unknown token changed the lobby');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,3 +347,68 @@ describe('a save survives a release that did not change the rules', () => {
|
||||
assert.equal(r.ok, false, 'a truncated replay was returned as a healthy session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the four transient signals (2026-08-23)', () => {
|
||||
/**
|
||||
* Multiplayer had none of these: `createRemoteSession` answered every one of them with an empty
|
||||
* value, so a game on a server had no sound, no timetable flash, no announcement when a completed
|
||||
* run paid the table, and no badge on the card you had just drawn. They ride on the push now — and
|
||||
* `justDrawn` is the one that has to be careful, because `game.justDrawn` is ONE field for the
|
||||
* whole game and does not say whose card it is.
|
||||
*/
|
||||
|
||||
/** Drives the game until the current actor draws a card, and returns that turn's pushes. */
|
||||
const drawSomething = (session: GameSession, players: number): { seat: PlayerIndex; pushes: Map<PlayerIndex, Push> } => {
|
||||
for (let seat = 0 as PlayerIndex; seat < players; seat++) {
|
||||
if (session.connect(seat).menu === null) continue;
|
||||
// Two steps: §6's three options are exclusive, so the turn is spent on drawing before a card
|
||||
// actually leaves the deck.
|
||||
const chose = session.intent(seat, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(chose.accepted, true, 'the actor could not choose to draw');
|
||||
const result = session.intent(seat, 2, { type: 'draw.fromHomeOffice' });
|
||||
assert.equal(result.accepted, true, 'the actor could not draw from the Home Office deck');
|
||||
if (!result.accepted) throw new Error('unreachable');
|
||||
return { seat, pushes: result.pushes };
|
||||
}
|
||||
throw new Error('no seat was able to act');
|
||||
};
|
||||
|
||||
it('sends the drawn card to the seat that drew it, and to nobody else', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
|
||||
const { seat, pushes } = drawSomething(session, 3);
|
||||
const mine = pushes.get(seat);
|
||||
assert.ok(mine?.justDrawn, 'the drawing seat was not told which card it drew');
|
||||
for (const [other, push] of pushes) {
|
||||
if (other === seat) continue;
|
||||
assert.equal(
|
||||
push.justDrawn,
|
||||
undefined,
|
||||
`seat ${other} was told which card seat ${seat} drew — that is a hand leak`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the badge across a reconnect, still only for its owner', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
|
||||
const { seat, pushes } = drawSomething(session, 3);
|
||||
const drawn = pushes.get(seat)?.justDrawn;
|
||||
assert.equal(session.connect(seat).justDrawn, drawn, 'a refresh lost the card the player just drew');
|
||||
const other = ((seat + 1) % 3) as PlayerIndex;
|
||||
assert.equal(session.connect(other).justDrawn, undefined, 'a reconnecting seat was told about someone else’s draw');
|
||||
});
|
||||
|
||||
it('sends the shared signals to every seat, identically, and drains them', () => {
|
||||
// A collision anywhere on the Division, the Stage bell, a train running off the end and paying
|
||||
// everyone: these are the table's, not one player's.
|
||||
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
|
||||
const { pushes } = drawSomething(session, 3);
|
||||
const cues = [...pushes.values()].map((p) => JSON.stringify(p.cues ?? []));
|
||||
assert.equal(new Set(cues).size, 1, 'seats were sent different sound cues for the same events');
|
||||
|
||||
// Drained: a signal marks a moment, so a later connect must not replay it.
|
||||
const later = session.connect(0 as PlayerIndex);
|
||||
assert.equal(later.cues, undefined, 'a reconnecting client was sent the sounds of what it missed');
|
||||
assert.equal(later.announcement, undefined, 'a reconnecting client was re-sent an old announcement');
|
||||
assert.equal(later.scheduled, undefined, 'a reconnecting client was re-sent an old timetable flash');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user