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:
co-authored by
Claude Fable 5.1
parent
bc99ee3eb7
commit
12a655868b
@@ -38,7 +38,7 @@ import { extname, join, normalize, sep } from "node:path";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
import type { Command, PlayerId } from "@wizwar/engine";
|
||||
import {
|
||||
import { callRematch,
|
||||
catchUpSteps,
|
||||
momentSteps,
|
||||
claimTransferCode,
|
||||
@@ -521,6 +521,9 @@ function roomInfo(room: Room) {
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
audience: audienceCount(room),
|
||||
rematch: room.rematch ?? null,
|
||||
expected: room.expected ?? [],
|
||||
expansion: room.expansion,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
// A mystery machine keeps its mood only while the game lives: once it
|
||||
@@ -840,6 +843,30 @@ wss.on("connection", (socket, req) => {
|
||||
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
|
||||
break;
|
||||
}
|
||||
case "rematch": {
|
||||
// Anyone at a finished table may call; the caller lands in the
|
||||
// new lobby, and the old table hears where it went.
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const called = callRematch(room, session.playerId);
|
||||
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
||||
const next = getRoom(called.roomId);
|
||||
if (!next) return send(socket, { type: "error", message: "the rematch room is gone" });
|
||||
let token = called.token;
|
||||
if (!token) {
|
||||
const joined = joinRoom(next, session.playerId, null);
|
||||
if ("error" in joined) return send(socket, { type: "error", message: joined.error });
|
||||
token = joined.token;
|
||||
}
|
||||
if (called.created) broadcast(room, () => ({ type: "rematch", roomId: room.id, to: next.id, by: session.playerId }));
|
||||
leaveGallery(session);
|
||||
send(socket, { type: "rematched", roomId: next.id });
|
||||
session.roomId = next.id;
|
||||
session.token = token;
|
||||
send(socket, { type: "seat", playerId: session.playerId, token });
|
||||
broadcastRoomState(next);
|
||||
break;
|
||||
}
|
||||
case "chat": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface Room {
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
/** Last time anything looked at this room (memory eviction clock). */
|
||||
touchedAt?: number;
|
||||
/** A finished table that called for a rematch: where it went, and who called. */
|
||||
rematch?: { roomId: string; by: PlayerId };
|
||||
/** A rematch lobby: the wizards of the last table who have not yet sat. */
|
||||
expected?: PlayerId[];
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -91,7 +95,10 @@ export function runningRooms(): Room[] {
|
||||
return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing");
|
||||
}
|
||||
|
||||
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
export function createRoom(
|
||||
hostId: PlayerId,
|
||||
rematch?: { of: string; expected: PlayerId[]; expansion: boolean },
|
||||
): { room: Room; token: string } {
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const room: Room = {
|
||||
id: makeRoomCode(),
|
||||
@@ -99,7 +106,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
players: [hostId],
|
||||
tokens: new Map([[hostId, hashToken(token)]]),
|
||||
seed: randomInt(0, 0xffffffff),
|
||||
expansion: false,
|
||||
expansion: rematch?.expansion ?? false,
|
||||
colorChoices: new Map(),
|
||||
createdAt: new Date().toISOString(),
|
||||
state: null,
|
||||
@@ -108,6 +115,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(rematch ? { expected: [...rematch.expected] } : {}),
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -118,10 +126,29 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
hostTokenHash: hashToken(token),
|
||||
seed: room.seed,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(rematch ? { rematchOf: rematch.of, expected: rematch.expected, expansion: rematch.expansion } : {}),
|
||||
});
|
||||
return { room, token };
|
||||
}
|
||||
|
||||
/** A finished table calls for a rematch: a new room with the same
|
||||
* clockwork at the same tiers and the same expansion setting, waiting for
|
||||
* the same humans. The first caller hosts it; anyone at the old table may
|
||||
* call, and a second call finds the room already made. */
|
||||
export function callRematch(
|
||||
room: Room, byId: PlayerId,
|
||||
): { roomId: string; token: string | null; created: boolean } | { error: string } {
|
||||
if (room.state?.phase !== "finished") return { error: "the game is not over yet" };
|
||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" };
|
||||
if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false };
|
||||
const humans = room.players.filter((p) => !room.bots.has(p) && p !== byId);
|
||||
const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion });
|
||||
for (const [, b] of room.bots) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
|
||||
room.rematch = { roomId: next.id, by: byId };
|
||||
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
|
||||
return { roomId: next.id, token, created: true };
|
||||
}
|
||||
|
||||
export function getRoom(id: string): Room | undefined {
|
||||
const code = id.toUpperCase();
|
||||
const live = rooms.get(code);
|
||||
@@ -211,6 +238,7 @@ function baseSummary(room: Room, active: PlayerId | null): Omit<GameSummary, "na
|
||||
round: room.state?.turn.round ?? null,
|
||||
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
||||
chatCount: room.chat.length,
|
||||
rematch: room.rematch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,6 +495,8 @@ export interface GameSummary {
|
||||
lastMoveAt: string | null;
|
||||
/** Total table-talk messages; the client tracks which it has seen. */
|
||||
chatCount: number;
|
||||
/** A finished table that moved on: the rematch room and who called it. */
|
||||
rematch?: { roomId: string; by: PlayerId } | null;
|
||||
}
|
||||
|
||||
/** Who holds the table's attention, and why — the summary's turn facts.
|
||||
@@ -743,6 +773,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(meta.expected ? { expected: [...meta.expected] } : {}),
|
||||
...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}),
|
||||
};
|
||||
if (lines.some((l) => l.kind === "abandon")) return null;
|
||||
for (const line of lines.slice(1)) {
|
||||
@@ -770,6 +802,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
} else if (line.kind === "start") {
|
||||
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||
} else if (line.kind === "rematch") {
|
||||
room.rematch = { roomId: line.to, by: line.by };
|
||||
} else if (line.kind === "chat") {
|
||||
// File order preserves the interleaving with commands.
|
||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||
|
||||
@@ -16,6 +16,19 @@ export interface RoomMetaLine {
|
||||
hostToken?: string;
|
||||
seed: number;
|
||||
createdAt: string;
|
||||
/** A rematch: the finished room it follows, and the wizards it waits for. */
|
||||
rematchOf?: string;
|
||||
expected?: string[];
|
||||
/** The lobby's expansion default, carried over from the last table. */
|
||||
expansion?: boolean;
|
||||
}
|
||||
|
||||
/** The finished table called for a rematch: the new room it moved to. */
|
||||
export interface RematchLine {
|
||||
kind: "rematch";
|
||||
to: string;
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface JoinLine {
|
||||
@@ -71,7 +84,7 @@ export interface AbandonLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
|
||||
+130
-13
@@ -1389,6 +1389,56 @@
|
||||
|
||||
// Press-and-hold on a wall/door/fire segment (touch has no hover).
|
||||
let peekEdge = $state<string | null>(null);
|
||||
/** An ongoing spell, opened from its chip: its card, with a plain account beneath. */
|
||||
type Sustained = GameView["sustained"][number];
|
||||
let peekEffect = $state<Sustained | null>(null);
|
||||
/** What each ongoing spell does, in a sentence. */
|
||||
const EFFECT_GLOSS: Record<string, string> = {
|
||||
slow: "Moves one square a turn, no numbers or speed spells, and attacks only every other turn.",
|
||||
"no-spell": "Cannot cast any spell; a magic stone's power still works, and cards may be discarded.",
|
||||
medusa: "Cannot move or cast, counteractions included — and takes no damage from anything.",
|
||||
"lock-in-place": "Cannot move or be moved in any way, teleportation included; spells may still be cast.",
|
||||
blind: "Movement, thrown objects, attacks, and spells go in a direction the die decides; self-cast spells work.",
|
||||
invisible: "An attacker must roll the die for a random direction to see whether the blow finds them.",
|
||||
shrink: "Half as likely to be hit by any attack, and walks only two squares a turn.",
|
||||
adrenaline: "May make two attacks in one turn.",
|
||||
"big-man": "Fills the corridor: nobody moves, casts, or punches past them, or enters their square; they can push what stands in their way.",
|
||||
"mist-body": "Passes through anything but stone; neither attacks nor can be attacked; walls of fire still burn.",
|
||||
disease: "Carries the plague: sharing a square with another wizard costs each of them three points.",
|
||||
empathy: "Any attack against them lands on the attacker as well.",
|
||||
fear: "Nobody comes within three squares, walls or no walls; those in range must move away.",
|
||||
strength: "Physical damage they deal is doubled, and they may tear a treasure from a wizard sharing their square.",
|
||||
buddy: "Will not attack the wizard who cast it, until attacked first.",
|
||||
lifesaver: "Not eliminated for losing both treasures to enemy homes.",
|
||||
"force-field": "Stops the spell it answered, then stands: the opponent may not enter this square, nor cast on or past this wizard, on any side.",
|
||||
"around-the-corner": "May cast line-of-sight spells around one corner.",
|
||||
};
|
||||
function effectNote(e: Sustained): string {
|
||||
const gloss = EFFECT_GLOSS[e.cardId] ?? (realCard(e.cardId) ? cardDef(e.cardId).text ?? "" : "");
|
||||
const who = e.casterId === e.targetId ? `${e.casterId} cast it on themself.` : `Cast by ${e.casterId} on ${e.targetId}.`;
|
||||
const ends =
|
||||
e.cardId === "force-field" ? "Stands until the end of the opponent's turn."
|
||||
: e.cardId === "buddy" ? "Holds until the pact is broken by an attack."
|
||||
: isPermanentDuration(e.remainingTurns) ? "Permanent — until dispelled, or the game ends."
|
||||
: e.remainingTurns === 1 ? `Ends at the start of ${e.casterId}'s next turn.`
|
||||
: `Ends at the start of ${e.casterId}'s turn, ${e.remainingTurns} of their turns from now.`;
|
||||
return `${gloss} ${who} ${ends}`;
|
||||
}
|
||||
/** Treasures a wizard has carried home: stolen chests sitting on their home square. */
|
||||
function treasuresHomeOf(p: { id: string; home: { x: number; y: number } }): number {
|
||||
return view ? view.treasures.filter((t) => t.owner !== p.id && t.position &&
|
||||
t.position.x === p.home.x && t.position.y === p.home.y).length : 0;
|
||||
}
|
||||
/** The table is out of reach: play waits, and nothing here can be sent. */
|
||||
const offline = $derived(!local.active && net.roomId != null && net.status !== "connected");
|
||||
// Talk that never arrived goes back into the composer, with a word.
|
||||
$effect(() => {
|
||||
const lost = net.chatUnsent;
|
||||
if (!lost) return;
|
||||
if (!chatDraft.trim()) chatDraft = lost;
|
||||
net.flash("Your message was not sent — it is back in the box");
|
||||
net.chatUnsent = null;
|
||||
});
|
||||
|
||||
/** The preferences slip (client-side taste, saved on this device). */
|
||||
let prefsOpen = $state(false);
|
||||
@@ -1755,6 +1805,14 @@
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{#if offline}
|
||||
<div class="slip offline-slip" role="alert">
|
||||
<strong>Connection lost — reconnecting.</strong> The table waits; your cards stay in hand.
|
||||
{#if net.unsent}<span class="offline-note">{net.unsent[0]!.toUpperCase()}{net.unsent.slice(1)} was not sent. Try again once the table is back.</span>{/if}
|
||||
{#if net.unconfirmed}<span class="offline-note">{net.unconfirmed[0]!.toUpperCase()}{net.unconfirmed.slice(1)} may not have reached the table; the board will say when the connection returns.</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDiscards && view}
|
||||
<div class="scrim attack-scrim" role="button" tabindex="-1" onclick={() => (showDiscards = false)} onkeydown={() => {}}>
|
||||
<div class="discard-book" role="dialog" aria-label="the discard pile" tabindex="-1"
|
||||
@@ -1775,11 +1833,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if peekCard || discardPeek || peekTreasure || peekEdge}
|
||||
{#if peekCard || discardPeek || peekTreasure || peekEdge || peekEffect}
|
||||
<div class="big-peek-scrim" role="button" tabindex="-1"
|
||||
onclick={() => { peekCard = null; peekCreatureId = null; peekToken = null; discardPeek = null; peekTreasure = null; peekEdge = null; }} onkeydown={() => {}}>
|
||||
onclick={() => { peekCard = null; peekCreatureId = null; peekToken = null; discardPeek = null; peekTreasure = null; peekEdge = null; peekEffect = null; }} onkeydown={() => {}}>
|
||||
<div class="big-peek">
|
||||
{#if peekEdge}
|
||||
{#if peekEffect}
|
||||
{#if peekCard}
|
||||
<div class="card-stage">
|
||||
<Card card={peekCard} onfaq={(id) => (faqCardId = id)} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="peek-note big-peek-note effect-note">
|
||||
<strong>{spellName(peekEffect.cardId)}</strong> — {effectNote(peekEffect)}
|
||||
</div>
|
||||
{:else if peekEdge}
|
||||
<div class="peek-note big-peek-note">{peekEdge}</div>
|
||||
{:else if peekTreasure}
|
||||
<img class="big-peek-token" src={peekTreasure.src} alt="" />
|
||||
@@ -2400,7 +2467,7 @@
|
||||
{#if !g}
|
||||
as {seat.name} — unreachable
|
||||
{:else if g.finished}
|
||||
{g.winner === seat.name ? "you won! 🏆" : `${g.winner} won`}
|
||||
{g.winner === seat.name ? "you won! 🏆" : `${g.winner} won`}{#if g.rematch} · {g.rematch.by} called a rematch{/if}
|
||||
{:else if !g.started}
|
||||
waiting to start · {g.players.join(", ")}
|
||||
{:else if g.yourTurn}
|
||||
@@ -2410,6 +2477,10 @@
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if g?.rematch && g.rematch.by !== seat.name}
|
||||
<button class="stamp tiny" title="take your seat at the rematch"
|
||||
onclick={() => { net.you = seat.name; net.spectating = false; net.join(g.rematch!.roomId, seat.name); }}>rematch</button>
|
||||
{/if}
|
||||
<button class="ledger-forget" title="forget this game"
|
||||
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
|
||||
</div>
|
||||
@@ -2504,6 +2575,15 @@
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each net.expected.filter((n) => !net.players.includes(n)) as n (n)}
|
||||
<li class="seat open ghost">
|
||||
<span class="seat-portrait empty"></span>
|
||||
<span class="seat-text">
|
||||
<span class="seat-name">{n}</span>
|
||||
<span class="seat-role">from the last table — not yet seated</span>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if net.players.length < 6}
|
||||
<li class="seat open">
|
||||
<span class="seat-portrait empty"></span>
|
||||
@@ -2578,7 +2658,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{:else if view}
|
||||
<div class="game" class:hand-left={handLeftOn}>
|
||||
<div class="game" class:hand-left={handLeftOn} class:offline>
|
||||
{#snippet tableGuidance()}
|
||||
{#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
|
||||
<div class="hint-strip">
|
||||
@@ -2852,7 +2932,17 @@
|
||||
{#if view.phase === "finished"}
|
||||
<div class="slip winner">🏆 {view.winner} wins!
|
||||
{#if !local.active}
|
||||
{#if net.you === net.hostId}
|
||||
{@const otherHumans = net.players.some((p) => p !== net.you && !net.roomBots[p])}
|
||||
{#if net.rematchCall}
|
||||
{#if net.rematchCall.by === net.you}
|
||||
<button class="stamp tiny" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>back to the rematch</button>
|
||||
{:else}
|
||||
<span class="rematch-call">{net.rematchCall.by} calls for a rematch —</span>
|
||||
<button class="stamp tiny primary" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>take your seat</button>
|
||||
{/if}
|
||||
{:else if otherHumans}
|
||||
<button class="stamp tiny" onclick={() => net.callRematch()} title="a new table with the same wizards and the same clockwork">rematch with this table</button>
|
||||
{:else if net.you === net.hostId}
|
||||
<button class="stamp tiny" onclick={playAgain}>play again</button>
|
||||
{/if}
|
||||
<button class="stamp tiny" onclick={shareTale}>
|
||||
@@ -3040,7 +3130,7 @@
|
||||
</span>
|
||||
<span class="score-life">{p.life}</span>
|
||||
<span class="score-marks">
|
||||
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`} ·
|
||||
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if treasuresHomeOf(p) > 0} · <span class="score-home" class:threat={treasuresHomeOf(p) === 1 && !!p.carriedTreasureId} title={treasuresHomeOf(p) === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {treasuresHomeOf(p)} home</span>{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`} ·
|
||||
<button class="score-chest-btn" aria-label="see the carried treasure"
|
||||
onclick={() => (peekTreasure = { src: chestSrc, note: `${ct?.owner ?? "someone"}'s treasure — in ${p.id}'s arms` })}>
|
||||
<img class="score-chest" alt="" src={chestSrc} /></button>{/if}
|
||||
@@ -3055,9 +3145,9 @@
|
||||
</button>
|
||||
{/each}
|
||||
{#each spellsOn as e (e.id)}
|
||||
<button class="table-card spell-chip" onclick={() => {
|
||||
if (!realCard(e.cardId)) return;
|
||||
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
|
||||
<button class="table-card spell-chip" title="what this spell does, and when it ends" onclick={() => {
|
||||
peekEffect = e;
|
||||
peekCard = realCard(e.cardId) ? { instanceId: `peek-${e.id}`, cardId: e.cardId } : null;
|
||||
peekCreatureId = null;
|
||||
}}>
|
||||
✦ {spellName(e.cardId)}{isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}
|
||||
@@ -3151,9 +3241,11 @@
|
||||
<form class="say-box" onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const t = chatDraft.trim();
|
||||
if (t) net.sendChat(t);
|
||||
chatDraft = "";
|
||||
if (!t) return;
|
||||
if (net.sendChat(t)) chatDraft = "";
|
||||
else net.flash("Not sent — the table is out of reach; your words wait in the box");
|
||||
}}>
|
||||
{#if net.chatPending}<span class="sending">sending…</span>{/if}
|
||||
<input class="say-input" bind:value={chatDraft} maxlength="300"
|
||||
placeholder="say something to the table…" aria-label="table talk" />
|
||||
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
|
||||
@@ -3227,7 +3319,12 @@
|
||||
<option value={2}>2</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="stamp primary" onclick={endTurn}>End turn</button>
|
||||
{#if net.pending}
|
||||
<span class="sending" aria-live="polite">{Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}…</span>
|
||||
{:else if net.confirmed}
|
||||
<span class="sending ok" aria-live="polite">✓ {net.confirmed} confirmed</span>
|
||||
{/if}
|
||||
<button class="stamp primary" onclick={endTurn} disabled={offline}>End turn</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4121,6 +4218,26 @@
|
||||
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
|
||||
.slip.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
|
||||
.slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
/* The table out of reach: one plain notice, and the controls beneath it go quiet. */
|
||||
.slip.offline-slip {
|
||||
border-left: 4px solid #b3372b;
|
||||
background: #f3e3d8;
|
||||
margin: 0.4rem auto 0;
|
||||
max-width: 60rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.6rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.offline-note { font-style: italic; color: #6b3a2f; }
|
||||
.game.offline .hand, .game.offline .board-zone, .game.offline .actions .stamp { pointer-events: none; opacity: 0.55; }
|
||||
.sending { font-family: "Courier Prime", monospace; font-size: 0.78rem; color: #6b5a41; font-style: italic; }
|
||||
.sending.ok { color: #3f6b2f; font-style: normal; }
|
||||
.rematch-call { font-size: 0.9rem; }
|
||||
.seat.ghost .seat-name { font-style: italic; font-weight: 400; }
|
||||
.score-home { white-space: nowrap; }
|
||||
.score-home.threat { color: #b3372b; font-weight: 700; }
|
||||
.effect-note { max-width: 24rem; text-align: left; line-height: 1.4; }
|
||||
.slip.winner {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 1.25rem;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user