A first game's first turn: the step light, the goal, and a held opening
Three strangers this week pressed play, looked at the board for a minute, and closed it. What they saw: a small maze under a roll-off card, a column of prose, seven strange cards, and nothing that said where they were or what to tap. Now, on a browser's first game, the squares a stride can reach glow and the rest of the maze falls into shadow, with one line hung over the wizard for the first stride. The turn banner says what winning is for the first two rounds. And when the clockwork rolls first, its opening turn waits behind the roll-off card instead of playing out unwatched, then arrives a beat at a time. The step light switches off once that first game is won, and lives in preferences for anyone who wants it back. 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
96c0c18d97
commit
e5d82a94af
@@ -223,7 +223,7 @@
|
|||||||
});
|
});
|
||||||
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
||||||
function dismissRolls() {
|
function dismissRolls() {
|
||||||
net.openingRolls = null;
|
net.dismissRolls();
|
||||||
local.openingRolls = null;
|
local.openingRolls = null;
|
||||||
}
|
}
|
||||||
/** A face-up card being peeked at from the scoresheet (view-only). */
|
/** A face-up card being peeked at from the scoresheet (view-only). */
|
||||||
@@ -1405,6 +1405,8 @@
|
|||||||
const room = net.roomId;
|
const room = net.roomId;
|
||||||
if (!room || finishedRoom === room) return;
|
if (!room || finishedRoom === room) return;
|
||||||
finishedRoom = room;
|
finishedRoom = room;
|
||||||
|
// The step light is for a first game; the second is played by sight.
|
||||||
|
if (prefs.finishedGames === 0) setPref("stepLight", false);
|
||||||
setPref("finishedGames", prefs.finishedGames + 1);
|
setPref("finishedGames", prefs.finishedGames + 1);
|
||||||
if (!prefs.tierChosen && prefs.botTier === "apprentice") setPref("botTier", "adept");
|
if (!prefs.tierChosen && prefs.botTier === "apprentice") setPref("botTier", "adept");
|
||||||
});
|
});
|
||||||
@@ -1479,7 +1481,26 @@
|
|||||||
// answer to "how can he even see me?" when sight ran through a warp mouth.
|
// answer to "how can he even see me?" when sight ran through a warp mouth.
|
||||||
const sightTrace = $derived(view ? stackSightTrace(view) : null);
|
const sightTrace = $derived(view ? stackSightTrace(view) : null);
|
||||||
|
|
||||||
|
/** The step light: with no card in hand, your square and the squares a
|
||||||
|
* stride reaches are lit and the rest of the maze falls into shadow. */
|
||||||
|
const stepLight = $derived(
|
||||||
|
!!view && !!me && prefs.stepLight && isYourTurn && !view.turn.actionsEnded && !selectedCard &&
|
||||||
|
!net.spectating && !youMustRespond && view.turn.movementAllowance - view.turn.movementUsed > 0,
|
||||||
|
);
|
||||||
|
const stepCells = $derived.by(() => {
|
||||||
|
if (!stepLight || !view || !me) return [];
|
||||||
|
return SIDES.map((side) => stepTarget(view!.board, me!.position, side))
|
||||||
|
.filter((t) => t.kind !== "blocked")
|
||||||
|
.map((t) => cellKey(t.to));
|
||||||
|
});
|
||||||
|
/** The first stride of a first game gets one line on the board itself. */
|
||||||
|
const stepCaption = $derived(
|
||||||
|
stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0
|
||||||
|
? { cell: me.position, text: "This is you — tap a lit square to walk" }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
const litCells = $derived.by(() => {
|
const litCells = $derived.by(() => {
|
||||||
|
if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]);
|
||||||
if (!view || !selectedDef || !yourMoment) return null;
|
if (!view || !selectedDef || !yourMoment) return null;
|
||||||
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
@@ -1833,6 +1854,11 @@
|
|||||||
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} />
|
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} />
|
||||||
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span>
|
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="pref-row pref-check">
|
||||||
|
<input type="checkbox" checked={prefs.stepLight}
|
||||||
|
onchange={(e) => setPref("stepLight", e.currentTarget.checked)} />
|
||||||
|
<span>Step light: on your turn, the squares you can walk to glow and the rest of the maze dims (on for a first game)</span>
|
||||||
|
</label>
|
||||||
<label class="pref-row pref-check">
|
<label class="pref-row pref-check">
|
||||||
<input type="checkbox" checked={prefs.cautions}
|
<input type="checkbox" checked={prefs.cautions}
|
||||||
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
||||||
@@ -2603,6 +2629,8 @@
|
|||||||
onIllusionClick={(cell, side) => (illusionPrompt = { cell, side })}
|
onIllusionClick={(cell, side) => (illusionPrompt = { cell, side })}
|
||||||
onEdgePeek={(tip) => (peekEdge = tip)}
|
onEdgePeek={(tip) => (peekEdge = tip)}
|
||||||
{litCells}
|
{litCells}
|
||||||
|
spotCells={stepCells}
|
||||||
|
spotCaption={stepCaption}
|
||||||
{sightTrace}
|
{sightTrace}
|
||||||
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
|
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
|
||||||
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||||
@@ -2675,6 +2703,9 @@
|
|||||||
{:else if isYourTurn}
|
{:else if isYourTurn}
|
||||||
<div class="slip yours">
|
<div class="slip yours">
|
||||||
Your turn — round {view.turn.round}.
|
Your turn — round {view.turn.round}.
|
||||||
|
{#if prefs.stepLight && view.turn.round <= 2}
|
||||||
|
<div class="goal">Win by carrying two treasures that aren't yours to your home, or by being the last wizard standing.</div>
|
||||||
|
{/if}
|
||||||
{#if view.turn.actionsEnded}
|
{#if view.turn.actionsEnded}
|
||||||
{#if me?.carriedTreasureId}
|
{#if me?.carriedTreasureId}
|
||||||
picking up ended your actions for this turn, and the treasure comes with you —
|
picking up ended your actions for this turn, and the treasure comes with you —
|
||||||
@@ -3751,6 +3782,7 @@
|
|||||||
transform: rotate(-0.4deg);
|
transform: rotate(-0.4deg);
|
||||||
}
|
}
|
||||||
.slip.yours { border-left: 4px solid #2e7d32; }
|
.slip.yours { border-left: 4px solid #2e7d32; }
|
||||||
|
.slip .goal { margin-top: 0.3rem; font-size: 0.86rem; opacity: 0.85; }
|
||||||
.slip.coach { border-left: 4px solid #c9a72a; }
|
.slip.coach { border-left: 4px solid #c9a72a; }
|
||||||
.slip.coach .stamp { margin-left: 0.5rem; }
|
.slip.coach .stamp { margin-left: 0.5rem; }
|
||||||
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
|
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
markedCell = null,
|
markedCell = null,
|
||||||
markedCells = null,
|
markedCells = null,
|
||||||
litCells = null,
|
litCells = null,
|
||||||
|
spotCells = [],
|
||||||
|
spotCaption = null,
|
||||||
markedSector = null,
|
markedSector = null,
|
||||||
ghostSlots = null,
|
ghostSlots = null,
|
||||||
onGhostClick,
|
onGhostClick,
|
||||||
@@ -65,6 +67,10 @@
|
|||||||
markedCells?: { x: number; y: number }[] | null;
|
markedCells?: { x: number; y: number }[] | null;
|
||||||
/** When set, squares NOT in this set dim — the targeting aid. */
|
/** When set, squares NOT in this set dim — the targeting aid. */
|
||||||
litCells?: Set<string> | null;
|
litCells?: Set<string> | null;
|
||||||
|
/** The step light's squares: a pulsing rim on each. */
|
||||||
|
spotCells?: string[];
|
||||||
|
/** One line hung above a square — the first stride's cue. */
|
||||||
|
spotCaption?: { cell: { x: number; y: number }; text: string } | null;
|
||||||
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
|
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
|
||||||
markedSector?: { x: number; y: number } | null;
|
markedSector?: { x: number; y: number } | null;
|
||||||
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
|
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
|
||||||
@@ -680,6 +686,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- the step light: the squares a stride reaches, rimmed and pulsing -->
|
||||||
|
{#each spotCells as key (key)}
|
||||||
|
{@const sx = Number(key.split(",")[0])}
|
||||||
|
{@const sy = Number(key.split(",")[1])}
|
||||||
|
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
|
||||||
|
{/each}
|
||||||
|
{#if spotCaption}
|
||||||
|
{@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
|
||||||
|
{@const cw = spotCaption.text.length * 6.6 + 20}
|
||||||
|
{@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
|
||||||
|
{@const above = spotCaption.cell.y > 0}
|
||||||
|
{@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
|
||||||
|
<g class="spot-caption" transform={`translate(${cx}, ${cy})`}>
|
||||||
|
<rect x={-cw / 2} y={-12} width={cw} height={24} rx="12" />
|
||||||
|
<text x="0" y="4">{spotCaption.text}</text>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
|
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
|
||||||
{#each fearAura as key (key)}
|
{#each fearAura as key (key)}
|
||||||
{@const bx = Number(key.split(",")[0])}
|
{@const bx = Number(key.split(",")[0])}
|
||||||
@@ -823,6 +846,26 @@
|
|||||||
fill: rgba(12, 9, 5, 0.55);
|
fill: rgba(12, 9, 5, 0.55);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.spot-cell {
|
||||||
|
fill: none;
|
||||||
|
stroke: #f2cf5b;
|
||||||
|
stroke-width: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: spot-pulse 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes spot-pulse {
|
||||||
|
0%, 100% { opacity: 0.35; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.spot-caption { pointer-events: none; }
|
||||||
|
.spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
|
||||||
|
.spot-caption text {
|
||||||
|
fill: #f7ecc8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-anchor: middle;
|
||||||
|
}
|
||||||
.marked-cell {
|
.marked-cell {
|
||||||
fill: rgba(211, 133, 43, 0.18);
|
fill: rgba(211, 133, 43, 0.18);
|
||||||
stroke: #d3852b;
|
stroke: #d3852b;
|
||||||
|
|||||||
+193
-164
@@ -408,174 +408,201 @@ class Net {
|
|||||||
ws.onmessage = (raw) => {
|
ws.onmessage = (raw) => {
|
||||||
const msg = JSON.parse(raw.data as string);
|
const msg = JSON.parse(raw.data as string);
|
||||||
this.lastHeard = Date.now();
|
this.lastHeard = Date.now();
|
||||||
switch (msg.type) {
|
// While the opening roll-off card is up, the game holds still: the
|
||||||
case "pong":
|
// clockwork's first turn would otherwise play out behind the card
|
||||||
break;
|
// in a blur nobody watched. Its moves wait, then arrive at a pace.
|
||||||
case "seat": {
|
if ((this.openingRolls || this.draining) && this.view) {
|
||||||
this.token = msg.token;
|
if (msg.type === "state" || msg.type === "events") { this.held.push(msg); return; }
|
||||||
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
|
||||||
if (seatRoom) {
|
|
||||||
const seat: Seat = { name: msg.playerId, roomId: seatRoom, token: msg.token };
|
|
||||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
|
||||||
this.rememberSeat(seat);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "watching":
|
|
||||||
this.spectating = true;
|
|
||||||
this.roomId = msg.roomId;
|
|
||||||
break;
|
|
||||||
case "audience":
|
|
||||||
this.audience = msg.count ?? 0;
|
|
||||||
break;
|
|
||||||
case "room":
|
|
||||||
this.roomId = msg.roomId;
|
|
||||||
this.roomColors = msg.colors ?? {};
|
|
||||||
this.roomBots = msg.bots ?? {};
|
|
||||||
this.audience = msg.audience ?? 0;
|
|
||||||
if (this.you && this.token) {
|
|
||||||
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
|
||||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
|
||||||
this.rememberSeat(seat);
|
|
||||||
}
|
|
||||||
this.players = msg.players;
|
|
||||||
this.hostId = msg.hostId;
|
|
||||||
this.started = msg.started;
|
|
||||||
break;
|
|
||||||
case "state": {
|
|
||||||
this.view = msg.view;
|
|
||||||
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
|
|
||||||
this.currentSeq = msg.seq;
|
|
||||||
// Only the FIRST state after arriving carries a gap worth
|
|
||||||
// announcing. Later states were watched live: a caught-up
|
|
||||||
// watcher stays caught up, and an announced gap stays FROZEN
|
|
||||||
// until watched or skipped. A hidden tab accumulates its gap
|
|
||||||
// honestly.
|
|
||||||
if (this.watching === this.roomId && document.visibilityState === "visible") {
|
|
||||||
if (this.missedMoves === 0) this.markSeen();
|
|
||||||
} else {
|
|
||||||
const last = this.seen[this.roomId] ?? 0;
|
|
||||||
this.missedMoves = Math.max(0, msg.seq - last);
|
|
||||||
if (this.missedMoves === 0) this.markSeen();
|
|
||||||
}
|
|
||||||
if (document.visibilityState === "visible") this.watching = this.roomId;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "catchUp":
|
|
||||||
this.catchUp = msg.steps;
|
|
||||||
break;
|
|
||||||
case "moment":
|
|
||||||
this.moment = { steps: msg.steps, owner: msg.owner };
|
|
||||||
break;
|
|
||||||
case "share":
|
|
||||||
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
|
||||||
this.shareResolve = null;
|
|
||||||
this.shareReject = null;
|
|
||||||
break;
|
|
||||||
case "events": {
|
|
||||||
let talk = 0;
|
|
||||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
|
||||||
for (const e of msg.events as GameEvent[]) {
|
|
||||||
if (e.type === "tableTalk") talk++;
|
|
||||||
if (e.type === "gameStarted" && !msg.replayed) {
|
|
||||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
|
||||||
}
|
|
||||||
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
|
||||||
const line = humanize(e);
|
|
||||||
if (line) {
|
|
||||||
// Every turn wears its eye, on the header line that opens
|
|
||||||
// it — the players judge what is share-worthy. The game-
|
|
||||||
// winning line carries its own besides.
|
|
||||||
const notable = this.turnCounter >= 0 &&
|
|
||||||
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
|
|
||||||
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (talk > 0) {
|
|
||||||
this.chatCount += talk;
|
|
||||||
if (this.roomId) this.markChatSeen();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "kicked":
|
|
||||||
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
|
||||||
this.leaveLocal();
|
|
||||||
break;
|
|
||||||
case "roomAbandoned":
|
|
||||||
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
|
||||||
this.leaveLocal();
|
|
||||||
break;
|
|
||||||
case "transferCode":
|
|
||||||
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
|
||||||
break;
|
|
||||||
case "transferClaimed": {
|
|
||||||
const seat = msg.seat as Seat & { roomId: string };
|
|
||||||
this.rememberSeat({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
|
||||||
this.refreshGames();
|
|
||||||
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "chat": {
|
|
||||||
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false }];
|
|
||||||
this.chatCount += 1;
|
|
||||||
if (this.roomId) this.markChatSeen();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "stats": {
|
|
||||||
this.stats = msg.stats;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "feedbackList":
|
|
||||||
this.feedbackReports = msg.reports ?? [];
|
|
||||||
break;
|
|
||||||
case "feedbackReceived":
|
|
||||||
this.refreshFeedback();
|
|
||||||
break;
|
|
||||||
case "games": {
|
|
||||||
this.games = msg.games;
|
|
||||||
for (const g of msg.games as GameSummary[]) {
|
|
||||||
const key = `${g.roomId}:${g.name}`;
|
|
||||||
const was = this.lastYourTurn.get(key) ?? false;
|
|
||||||
if (g.yourTurn && !was) this.notifyTurn(g);
|
|
||||||
this.lastYourTurn.set(key, g.yourTurn);
|
|
||||||
}
|
|
||||||
// Prune ONLY seats the server proved dead: the room exists and
|
|
||||||
// refused this exact token. A seat merely ABSENT from the reply
|
|
||||||
// stays — a restarting server, a stale restore, or the wrong
|
|
||||||
// backend all answer with ignorance, and ignorance is not
|
|
||||||
// deletion. The wallet is capped by age instead of by trust.
|
|
||||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
|
||||||
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
|
||||||
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
|
||||||
if (kept.length !== this.seats.length) {
|
|
||||||
this.seats = kept;
|
|
||||||
saveSeats(this.seats);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
// "Name is taken" means THIS token was tried and refused: the
|
|
||||||
// credential itself is dead, and holding it helps nobody. But
|
|
||||||
// "no such room" proves nothing about the seat — a restarting
|
|
||||||
// server, a stale restore, or the wrong backend all say it.
|
|
||||||
// The seat stays; the error shows; a later reconnect against
|
|
||||||
// the right server walks back in.
|
|
||||||
if (this.roomIdPending && /name is taken/.test(msg.message)) {
|
|
||||||
localStorage.removeItem(SEAT_KEY);
|
|
||||||
}
|
|
||||||
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
|
||||||
this.roomIdPending = 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 }];
|
|
||||||
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
this.handle(msg);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private held: { type: string }[] = [];
|
||||||
|
private draining = false;
|
||||||
|
|
||||||
|
/** Put the roll-off card away and let the held moves play, a beat apart. */
|
||||||
|
dismissRolls(): void {
|
||||||
|
this.openingRolls = null;
|
||||||
|
if (this.draining) return;
|
||||||
|
this.draining = true;
|
||||||
|
const step = () => {
|
||||||
|
const m = this.held.shift();
|
||||||
|
if (!m || !this.roomId) { this.held = []; this.draining = false; return; }
|
||||||
|
this.handle(m);
|
||||||
|
setTimeout(step, m.type === "events" ? 900 : 0);
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handle(msg: any): void {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "pong":
|
||||||
|
break;
|
||||||
|
case "seat": {
|
||||||
|
this.token = msg.token;
|
||||||
|
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
||||||
|
if (seatRoom) {
|
||||||
|
const seat: Seat = { name: msg.playerId, roomId: seatRoom, token: msg.token };
|
||||||
|
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||||
|
this.rememberSeat(seat);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "watching":
|
||||||
|
this.spectating = true;
|
||||||
|
this.roomId = msg.roomId;
|
||||||
|
break;
|
||||||
|
case "audience":
|
||||||
|
this.audience = msg.count ?? 0;
|
||||||
|
break;
|
||||||
|
case "room":
|
||||||
|
this.roomId = msg.roomId;
|
||||||
|
this.roomColors = msg.colors ?? {};
|
||||||
|
this.roomBots = msg.bots ?? {};
|
||||||
|
this.audience = msg.audience ?? 0;
|
||||||
|
if (this.you && this.token) {
|
||||||
|
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
||||||
|
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||||
|
this.rememberSeat(seat);
|
||||||
|
}
|
||||||
|
this.players = msg.players;
|
||||||
|
this.hostId = msg.hostId;
|
||||||
|
this.started = msg.started;
|
||||||
|
break;
|
||||||
|
case "state": {
|
||||||
|
this.view = msg.view;
|
||||||
|
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
|
||||||
|
this.currentSeq = msg.seq;
|
||||||
|
// Only the FIRST state after arriving carries a gap worth
|
||||||
|
// announcing. Later states were watched live: a caught-up
|
||||||
|
// watcher stays caught up, and an announced gap stays FROZEN
|
||||||
|
// until watched or skipped. A hidden tab accumulates its gap
|
||||||
|
// honestly.
|
||||||
|
if (this.watching === this.roomId && document.visibilityState === "visible") {
|
||||||
|
if (this.missedMoves === 0) this.markSeen();
|
||||||
|
} else {
|
||||||
|
const last = this.seen[this.roomId] ?? 0;
|
||||||
|
this.missedMoves = Math.max(0, msg.seq - last);
|
||||||
|
if (this.missedMoves === 0) this.markSeen();
|
||||||
|
}
|
||||||
|
if (document.visibilityState === "visible") this.watching = this.roomId;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "catchUp":
|
||||||
|
this.catchUp = msg.steps;
|
||||||
|
break;
|
||||||
|
case "moment":
|
||||||
|
this.moment = { steps: msg.steps, owner: msg.owner };
|
||||||
|
break;
|
||||||
|
case "share":
|
||||||
|
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
||||||
|
this.shareResolve = null;
|
||||||
|
this.shareReject = null;
|
||||||
|
break;
|
||||||
|
case "events": {
|
||||||
|
let talk = 0;
|
||||||
|
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||||
|
for (const e of msg.events as GameEvent[]) {
|
||||||
|
if (e.type === "tableTalk") talk++;
|
||||||
|
if (e.type === "gameStarted" && !msg.replayed) {
|
||||||
|
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||||
|
}
|
||||||
|
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
||||||
|
const line = humanize(e);
|
||||||
|
if (line) {
|
||||||
|
// Every turn wears its eye, on the header line that opens
|
||||||
|
// it — the players judge what is share-worthy. The game-
|
||||||
|
// winning line carries its own besides.
|
||||||
|
const notable = this.turnCounter >= 0 &&
|
||||||
|
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
|
||||||
|
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (talk > 0) {
|
||||||
|
this.chatCount += talk;
|
||||||
|
if (this.roomId) this.markChatSeen();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "kicked":
|
||||||
|
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
||||||
|
this.leaveLocal();
|
||||||
|
break;
|
||||||
|
case "roomAbandoned":
|
||||||
|
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
||||||
|
this.leaveLocal();
|
||||||
|
break;
|
||||||
|
case "transferCode":
|
||||||
|
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
|
||||||
|
break;
|
||||||
|
case "transferClaimed": {
|
||||||
|
const seat = msg.seat as Seat & { roomId: string };
|
||||||
|
this.rememberSeat({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
||||||
|
this.refreshGames();
|
||||||
|
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "chat": {
|
||||||
|
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false }];
|
||||||
|
this.chatCount += 1;
|
||||||
|
if (this.roomId) this.markChatSeen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "stats": {
|
||||||
|
this.stats = msg.stats;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "feedbackList":
|
||||||
|
this.feedbackReports = msg.reports ?? [];
|
||||||
|
break;
|
||||||
|
case "feedbackReceived":
|
||||||
|
this.refreshFeedback();
|
||||||
|
break;
|
||||||
|
case "games": {
|
||||||
|
this.games = msg.games;
|
||||||
|
for (const g of msg.games as GameSummary[]) {
|
||||||
|
const key = `${g.roomId}:${g.name}`;
|
||||||
|
const was = this.lastYourTurn.get(key) ?? false;
|
||||||
|
if (g.yourTurn && !was) this.notifyTurn(g);
|
||||||
|
this.lastYourTurn.set(key, g.yourTurn);
|
||||||
|
}
|
||||||
|
// Prune ONLY seats the server proved dead: the room exists and
|
||||||
|
// refused this exact token. A seat merely ABSENT from the reply
|
||||||
|
// stays — a restarting server, a stale restore, or the wrong
|
||||||
|
// backend all answer with ignorance, and ignorance is not
|
||||||
|
// deletion. The wallet is capped by age instead of by trust.
|
||||||
|
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||||
|
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
||||||
|
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
||||||
|
if (kept.length !== this.seats.length) {
|
||||||
|
this.seats = kept;
|
||||||
|
saveSeats(this.seats);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
// "Name is taken" means THIS token was tried and refused: the
|
||||||
|
// credential itself is dead, and holding it helps nobody. But
|
||||||
|
// "no such room" proves nothing about the seat — a restarting
|
||||||
|
// server, a stale restore, or the wrong backend all say it.
|
||||||
|
// The seat stays; the error shows; a later reconnect against
|
||||||
|
// the right server walks back in.
|
||||||
|
if (this.roomIdPending && /name is taken/.test(msg.message)) {
|
||||||
|
localStorage.removeItem(SEAT_KEY);
|
||||||
|
}
|
||||||
|
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
||||||
|
this.roomIdPending = 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 }];
|
||||||
|
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private send(message: unknown): void {
|
private send(message: unknown): void {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
|
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -733,6 +760,8 @@ class Net {
|
|||||||
this.roomId = null;
|
this.roomId = null;
|
||||||
this.roomIdPending = null;
|
this.roomIdPending = null;
|
||||||
this.view = null;
|
this.view = null;
|
||||||
|
this.held = [];
|
||||||
|
this.draining = false;
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.players = [];
|
this.players = [];
|
||||||
this.token = null;
|
this.token = null;
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export interface Prefs {
|
|||||||
tierChosen: boolean;
|
tierChosen: boolean;
|
||||||
/** Games this browser has seen through to a winner. */
|
/** Games this browser has seen through to a winner. */
|
||||||
finishedGames: number;
|
finishedGames: number;
|
||||||
|
/** The step light: on your turn, the squares a stride can reach glow
|
||||||
|
* and the rest of the maze falls into shadow. On for a browser's first
|
||||||
|
* game, off once that game is won, and yours to switch on again. */
|
||||||
|
stepLight: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "wizwar-prefs";
|
const KEY = "wizwar-prefs";
|
||||||
@@ -39,7 +43,7 @@ function load(): Prefs {
|
|||||||
const fallback: Prefs = {
|
const fallback: Prefs = {
|
||||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||||
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false,
|
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false,
|
||||||
coached: false, tierChosen: false, finishedGames: 0,
|
coached: false, tierChosen: false, finishedGames: 0, stepLight: true,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(KEY);
|
const raw = localStorage.getItem(KEY);
|
||||||
@@ -61,6 +65,9 @@ function load(): Prefs {
|
|||||||
coached: p.coached === true,
|
coached: p.coached === true,
|
||||||
tierChosen: p.tierChosen === true,
|
tierChosen: p.tierChosen === true,
|
||||||
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
|
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
|
||||||
|
// A browser that has already won a game meets the step light switched
|
||||||
|
// off; a fresh one has it on for its first.
|
||||||
|
stepLight: typeof p.stepLight === "boolean" ? p.stepLight : !(typeof p.finishedGames === "number" && p.finishedGames > 0),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
Reference in New Issue
Block a user