The table says when it is out of reach, a finished table can call a rematch, and spells and treasure read plainly

Connection: while the socket is down a notice says so, the hand, board,
and action buttons go quiet, a card left selected stays selected, and
table talk stays in its box. A command that could not leave is named
as not sent; one that left before the drop is named as unconfirmed
until the board answers. A command on its way shows as sending beside
End turn and confirms when the table replies. Talk shows as sending
until the table echoes it, and comes back to the composer if the
socket drops first.

Rematch: anyone at a finished table may call one. The server makes a
new room hosted by the caller, with the same clockwork at the same
tiers and moods (a mystery stays a mystery) and the same expansion
setting, and keeps seats for the last table's other wizards, who find
the call on the finished table and in their lobby ledger and sit with
one click. The old table's replay stays where it was. A solo table
against the clockwork keeps "play again".

Reading the table: an ongoing spell's chip opens its card with a plain
account — what it does, who cast it on whom, and when it ends, in the
rulebook's own terms. Each score row counts the treasures a wizard has
carried home, and marks a wizard one carry from winning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-16 22:52:55 -04:00
co-authored by Claude Fable 5.1
parent bc99ee3eb7
commit 12a655868b
5 changed files with 312 additions and 23 deletions
+104 -6
View File
@@ -303,6 +303,8 @@ export interface GameSummary {
round: number | null;
lastMoveAt: string | null;
chatCount: number;
/** A finished table that moved on: the rematch room and who called it. */
rematch?: { roomId: string; by: string } | null;
}
export function attentionLabel(a: GameSummary["attention"]): string {
@@ -343,6 +345,22 @@ class Net {
spectating = $state(false);
/** How many watch from the gallery (0 hides the count). */
audience = $state(0);
/** A command on its way: sent, and the table has not answered yet. */
pending = $state<{ label: string; at: number } | null>(null);
/** The last command the table answered, shown for a moment. */
confirmed = $state<string | null>(null);
/** A command that never left: the socket was down when it was tried. */
unsent = $state<string | null>(null);
/** A command that left, then the socket dropped before the table answered. */
unconfirmed = $state<string | null>(null);
/** A line of table talk on its way, until the table echoes it. */
chatPending = $state<string | null>(null);
/** Table talk that never arrived: handed back to the composer. */
chatUnsent = $state<string | null>(null);
/** A finished table's call for a rematch: where it went, and who called. */
rematchCall = $state<{ roomId: string; by: string } | null>(null);
/** A rematch lobby: the last table's wizards not yet seated. */
expected = $state<string[]>([]);
view = $state<GameView | null>(null);
log = $state<LogLine[]>([]);
error = $state<string | null>(null);
@@ -428,6 +446,10 @@ class Net {
ws.onclose = () => {
this.status = "disconnected";
this.ws = null;
// Whatever was in flight is now in doubt: the board will say what
// landed when the connection returns, and the talk goes back in the box.
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
if (this.chatPending) { this.chatUnsent = this.chatPending; this.chatPending = null; }
setTimeout(() => this.connect(), 1500);
};
ws.onmessage = (raw) => {
@@ -486,6 +508,8 @@ class Net {
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
this.audience = msg.audience ?? 0;
this.rematchCall = msg.rematch ?? null;
this.expected = msg.expected ?? [];
if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -497,6 +521,15 @@ class Net {
break;
case "state": {
this.view = msg.view;
// The table answered: the last command landed, and any doubt from
// a dropped socket is settled by the board itself.
if (this.pending) {
const label = this.pending.label;
this.pending = null;
this.confirmed = label;
setTimeout(() => { if (this.confirmed === label) this.confirmed = null; }, 1500);
}
this.unconfirmed = null;
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
this.currentSeq = msg.seq;
// Only the FIRST state after arriving carries a gap worth
@@ -570,7 +603,20 @@ class Net {
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
break;
}
case "rematch":
// The old table hears where the rematch went.
if (msg.roomId === this.roomId) this.rematchCall = { roomId: msg.to, by: msg.by };
break;
case "rematched":
// The caller's own move: the seat and room for the new table follow.
this.resetChronicle();
this.view = null;
this.started = false;
this.roomId = null;
this.roomIdPending = msg.roomId;
break;
case "chat": {
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
this.chatCount += 1;
if (this.roomId) this.markChatSeen();
@@ -621,6 +667,7 @@ class Net {
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
this.roomIdPending = null;
}
this.pending = null;
this.error = msg.message;
// The toast fades; the chronicle remembers why nothing happened.
this.log = [...this.log, { text: `${msg.message}`, turn: null, notable: false }];
@@ -629,8 +676,11 @@ class Net {
}
}
private send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
/** True when the message left; false when the socket was not open to carry it. */
private send(message: unknown): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify(message));
return true;
}
/** A toast the table shows for a moment, as it shows the server's refusals. */
@@ -717,8 +767,25 @@ class Net {
this.send({ type: "rollDie" });
}
sendChat(text: string): void {
this.send({ type: "chat", text });
/** True when the line left; the composer keeps it otherwise. */
sendChat(text: string): boolean {
if (!this.send({ type: "chat", text })) return false;
this.chatPending = text;
return true;
}
/** Call for a rematch from a finished table, or join the one already called. */
callRematch(): void {
this.send({ type: "rematch" });
}
/** Take the seat kept for you at the rematch table. */
acceptRematch(roomId: string): void {
if (!this.you) return;
this.resetChronicle();
this.view = null;
this.started = false;
this.join(roomId, this.you);
}
/** Watching the table counts as reading the talk. */
@@ -789,6 +856,12 @@ class Net {
* line, and wiping it would erase the only notice of why. */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
this.pending = null;
this.unsent = null;
this.unconfirmed = null;
this.chatPending = null;
this.rematchCall = null;
this.expected = [];
this.roomId = null;
this.roomIdPending = null;
this.view = null;
@@ -877,8 +950,33 @@ class Net {
this.momentTurn = null;
}
command(command: Command): void {
this.send({ type: "command", command });
/** Send a command, or say plainly that it did not go. */
command(command: Command): boolean {
const label = describeCommand(command);
if (!this.send({ type: "command", command })) {
this.unsent = label;
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent — the table is out of reach`);
return false;
}
this.unsent = null;
this.pending = { label, at: Date.now() };
return true;
}
}
/** A command as the player would name it, for the sending and not-sent notes. */
function describeCommand(c: Command): string {
switch (c.type) {
case "move": return "your step";
case "cast": return "your spell";
case "counteract": return "your counter";
case "pass": return "your pass";
case "punch": case "punchWall": return "your punch";
case "endTurn": return "ending your turn";
case "pickUpTreasure": case "pickUpObject": return "the pickup";
case "dropTreasure": case "dropObject": return "the drop";
case "playNumberForMovement": return "your number";
default: return "your action";
}
}