Credibility pass: the fast-edit shavings swept from the ops-and-rev-13 batch

Server: turnFacts joins actingSeat's full priority order (a hanging
ward or slow-death window now reads as the lobby's attention, stubs
included), summarize and stubOf share one baseSummary, the stranded
doc comment returns to summarize, the protocol contract names
feedbackList, Sentry init moves below the imports it cannot precede,
randomBytes joins its group, the feedback path hoists once with the
operator-fields note. Engine: the safe's sight check trusts castSight
alone, the locked-safe predicate becomes lockedSafeAt, the doomed-grab
test drives both seats and must reach endTurn. Web: the twin safe
handlers merge, paPoints joins the reset ritual with a derived burnable
range, five decorative empty handlers and a dead class go, the hand
earns real list semantics, fitHandCards cites Card.svelte's tokens,
two wrap-sites reindent, feedbackReports moves home as a named type,
and the chronicle's stay-mounted reason is stated. feedback-reply.sh
adopts its siblings' set/usage conventions and speaks Node. Two test
screenshots leave the repo root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-09-01 11:04:01 -04:00
co-authored by Claude Fable 5
parent d37bc62c28
commit afa0e17483
11 changed files with 178 additions and 146 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ reports — those are the work. Then one block per report, VERBATIM and
UNTRUNCATED: player, room, date, round/seq/deckRev, the full "what UNTRUNCATED: player, room, date, round/seq/deckRev, the full "what
happened" text, the full "what they expected" text, and the full reply happened" text, the full "what they expected" text, and the full reply
with its status (or "unanswered"). Eric reads this desk to hear his with its status (or "unanswered"). Eric reads this desk to hear his
players' voices — never summarize their words or yours into a table. players' voices — never compress their words, or your replies, into a table.
## c) Process an unanswered report ## c) Process an unanswered report
+12 -12
View File
@@ -2,18 +2,18 @@
# Answer a player's feedback report; the reply appears under it in their # Answer a player's feedback report; the reply appears under it in their
# lobby ledger. Legacy reports without an id answer to their timestamp. # lobby ledger. Legacy reports without an id answer to their timestamp.
# deploy/feedback-reply.sh <host> <reportId> <resolved|by-design|open> <text...> # deploy/feedback-reply.sh <host> <reportId> <resolved|by-design|open> <text...>
set -eu set -euo pipefail
HOST="$1"; REPORT_ID="$2"; STATUS="$3"; shift 3 HOST="${1:?usage: feedback-reply.sh <host> <reportId> <status> <text...>}"
REPORT_ID="${2:?usage: feedback-reply.sh <host> <reportId> <status> <text...>}"
STATUS="${3:?usage: feedback-reply.sh <host> <reportId> <status> <text...>}"
shift 3
TEXT="$*" TEXT="$*"
case "$STATUS" in resolved|by-design|open) ;; *) echo "status must be resolved, by-design, or open" >&2; exit 1;; esac case "$STATUS" in resolved|by-design|open) ;; *) echo "status must be resolved, by-design, or open" >&2; exit 1;; esac
LINE=$(python3 - "$REPORT_ID" "$STATUS" "$TEXT" <<'EOF' LINE=$(REPORT_ID="$REPORT_ID" STATUS="$STATUS" TEXT="$TEXT" node -e '
import json, sys, datetime console.log(JSON.stringify({
print(json.dumps({ reportId: process.env.REPORT_ID,
"reportId": sys.argv[1], at: new Date().toISOString(),
"at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), status: process.env.STATUS,
"status": sys.argv[2], text: process.env.TEXT,
"text": sys.argv[3], }));')
}))
EOF
)
printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl' printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl'
+10 -6
View File
@@ -643,6 +643,14 @@ function pathToward(
} }
/** The treasure squares worth marching for. */ /** The treasure squares worth marching for. */
/** A SAFE that stands between this wizard and the floor: not their own
* box, and not opened this turn. */
function lockedSafeAt(view: GameView, key: string): boolean {
return view.squareContents[key]?.kind === "safe" &&
view.squareContents[key]!.createdBy !== view.you &&
!view.openSafes.includes(key);
}
function treasureGoals(view: GameView): Set<string> { function treasureGoals(view: GameView): Set<string> {
const self = me(view); const self = me(view);
const goals = new Set<string>(); const goals = new Set<string>();
@@ -655,10 +663,7 @@ function treasureGoals(view: GameView): Set<string> {
// A chest inside someone else's SAFE is no goal without a way in — a // A chest inside someone else's SAFE is no goal without a way in — a
// lock card turns the combination, DISPEL CREATION removes the box. // lock card turns the combination, DISPEL CREATION removes the box.
// Marching to an unopenable safe stalls the clockwork on it forever. // Marching to an unopenable safe stalls the clockwork on it forever.
const boxKey = cellKey(t.position); if (lockedSafeAt(view, cellKey(t.position)) &&
if (view.squareContents[boxKey]?.kind === "safe" &&
view.squareContents[boxKey]!.createdBy !== view.you &&
!view.openSafes.includes(boxKey) &&
!inHand(view, "pick-lock") && !inHand(view, "master-key") && !inHand(view, "pick-lock") && !inHand(view, "master-key") &&
!inHand(view, "dispel-creation")) { !inHand(view, "dispel-creation")) {
continue; continue;
@@ -1332,8 +1337,7 @@ export function automatonCommand(
if (prize) { if (prize) {
// A SAFE over the prize: turn the combination (or dispel the box) // A SAFE over the prize: turn the combination (or dispel the box)
// before reaching for the gold — the grab itself would be refused. // before reaching for the gold — the grab itself would be refused.
const boxed = view.squareContents[here]?.kind === "safe" && const boxed = lockedSafeAt(view, here);
view.squareContents[here]!.createdBy !== you && !view.openSafes.includes(here);
if (!boxed) return { type: "pickUpTreasure", treasureId: prize.id }; if (!boxed) return { type: "pickUpTreasure", treasureId: prize.id };
const key = inHand(view, "pick-lock") ?? inHand(view, "master-key"); const key = inHand(view, "pick-lock") ?? inHand(view, "master-key");
if (key) return { type: "cast", instanceId: key.instanceId, target: { kind: "cell", cell: self.position } }; if (key) return { type: "cast", instanceId: key.instanceId, target: { kind: "cell", cell: self.position } };
+3 -3
View File
@@ -5252,9 +5252,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
if (effect.requiresLos && !(mods.aroundCorner // castSight carries the whole sight law: straight lines, the
? bentLos(state, caster, caster.position, cell) // VISIONSTONE's one-wall look, and the attached corner bend.
: castSight(state, caster, cmd, cell))) { if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
return err("no line of sight to the safe"); return err("no line of sight to the safe");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
+17 -6
View File
@@ -1034,15 +1034,26 @@ describe("interception, escape, and hazard sense", () => {
state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" }; state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" };
bot.position = { ...chest.position! }; bot.position = { ...chest.position! };
bot.hand = [{ cardId: "number-3", instanceId: "N3" }]; bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
for (let i = 0; i < 8; i++) { // Drive whichever seat the maze wants (a punched foe answers its own
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); // counteraction window). The invariants: the bot never proposes the
if (!cmd) break; // doomed grab, and its turn actually ends instead of idling forever.
expect(cmd.type).not.toBe("pickUpTreasure"); let ended = false;
const r = applyCommand(state, "bot", cmd); for (let i = 0; i < 24 && !ended; i++) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const chosen = automatonCommand(view, "hunter", "archmage");
let cmd = chosen ?? automatonFallback(view, "archmage");
if (seat === "bot") expect(cmd.type).not.toBe("pickUpTreasure");
let r = applyCommand(state, seat, cmd);
if (!r.ok && chosen) {
cmd = automatonFallback(view, "archmage");
r = applyCommand(state, seat, cmd);
}
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
state = r.state; state = r.state;
if (cmd.type === "endTurn") break; ended = seat === "bot" && cmd.type === "endTurn";
} }
expect(ended).toBe(true);
}); });
it("a pressed carrier of any temperament turns to mist", () => { it("a pressed carrier of any temperament turns to mist", () => {
+1 -1
View File
@@ -188,7 +188,7 @@ describe("slow death", () => {
expect(state.players.find((p) => p.id === defender)!.life).toBe(14); expect(state.players.find((p) => p.id === defender)!.life).toBe(14);
}); });
it("declining the window takes the whole total", () => { it("declining the window takes the whole total (rev 13)", () => {
let { state } = newGame(); let { state } = newGame();
state = toRound2(state); state = toRound2(state);
const { attacker, defender } = faceOff(state); const { attacker, defender } = faceOff(state);
+8 -8
View File
@@ -27,17 +27,12 @@
// {type:"chat", player, text, at} one line of table talk // {type:"chat", player, text, at} one line of table talk
// {type:"watching", roomId} you are seated in the gallery // {type:"watching", roomId} you are seated in the gallery
// {type:"audience", count} how many watch from the gallery // {type:"audience", count} how many watch from the gallery
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"} // {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"}
// {type:"error", message} // {type:"error", message}
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
// Errors only, no tracing: the box is small and the ledgers are the real
// telemetry. Without a DSN in the environment this is entirely inert.
if (process.env.SENTRY_DSN) {
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 });
}
import { createServer } from "node:http"; import { createServer } from "node:http";
import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync } from "node:fs"; import { readFileSync, existsSync, realpathSync } from "node:fs";
import { extname, join, normalize, sep } from "node:path"; import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
@@ -71,11 +66,16 @@ import {
} from "./rooms"; } from "./rooms";
import { engagementStats, recordHotseat } from "./stats"; import { engagementStats, recordHotseat } from "./stats";
import { appendFeedback, readFeedback } from "./store"; import { appendFeedback, readFeedback } from "./store";
import { randomBytes } from "node:crypto";
import { getShare, loadShares, mintShare } from "./shares"; import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage"; import { renderSharePng } from "./ogimage";
import { BOT_LINES, type BanterTrigger } from "./banter"; import { BOT_LINES, type BanterTrigger } from "./banter";
// Errors only, no tracing: the box is small and the ledgers are the real
// telemetry.
if (process.env.SENTRY_DSN) {
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 });
}
// --- Abuse limits: this is a public server on a small box. ----------------- // --- Abuse limits: this is a public server on a small box. -----------------
const MAX_SOCKETS = 300; // concurrent connections const MAX_SOCKETS = 300; // concurrent connections
const MAX_ROOMS = 5000; // total rooms on the server const MAX_ROOMS = 5000; // total rooms on the server
+28 -26
View File
@@ -184,17 +184,22 @@ function stubOf(room: Room): RoomStub {
turnHolder, turnHolder,
waitKind, waitKind,
playing: room.state?.phase === "playing", playing: room.state?.phase === "playing",
base: { base: baseSummary(room, active),
roomId: room.id, };
players: [...room.players], }
started: room.state !== null,
finished: room.state?.phase === "finished", /** The per-room half of a summary — everything except whose turn it is. */
winner: room.state?.winner ?? null, function baseSummary(room: Room, active: PlayerId | null): Omit<GameSummary, "name" | "yourTurn" | "attention"> {
activePlayerId: active, return {
round: room.state?.turn.round ?? null, roomId: room.id,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, players: [...room.players],
chatCount: room.chat.length, started: room.state !== null,
}, finished: room.state?.phase === "finished",
winner: room.state?.winner ?? null,
activePlayerId: active,
round: room.state?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
chatCount: room.chat.length,
}; };
} }
@@ -452,16 +457,21 @@ export interface GameSummary {
chatCount: number; chatCount: number;
} }
/** A seat-holder's one-line view of a room, for the lobby ledger. */ /** Who holds the table's attention, and why — the summary's turn facts.
/** Who holds the table's attention, and why — the summary's turn facts. */ * The priority order is actingSeat's: a hanging ward or slow-death window
* outranks the stack, which outranks the turn itself. */
function turnFacts(s: GameState | null): { function turnFacts(s: GameState | null): {
active: PlayerId | null; active: PlayerId | null;
turnHolder: PlayerId | null; turnHolder: PlayerId | null;
waitKind: "counteract" | "discard" | "interrupt" | null; waitKind: "counteract" | "discard" | "interrupt" | null;
} { } {
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null; const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null; const waitingOn = s?.wardPending?.ownerId ?? s?.slowDeathPending?.playerId ??
s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ??
s?.outOfTurnWindow?.playerId ?? null;
const waitKind = s == null ? null const waitKind = s == null ? null
: s.wardPending != null ? "counteract" as const
: s.slowDeathPending != null ? "counteract" as const
: s.stack?.waitingOn != null ? "counteract" as const : s.stack?.waitingOn != null ? "counteract" as const
: s.pendingDiscard != null ? "discard" as const : s.pendingDiscard != null ? "discard" as const
: s.chaosPending?.queue[0] != null ? "counteract" as const : s.chaosPending?.queue[0] != null ? "counteract" as const
@@ -470,23 +480,15 @@ function turnFacts(s: GameState | null): {
return { active, turnHolder: waitingOn ?? active, waitKind }; return { active, turnHolder: waitingOn ?? active, waitKind };
} }
/** A seat-holder's one-line view of a room, for the lobby ledger. */
export function summarize(room: Room, playerId: PlayerId): GameSummary { export function summarize(room: Room, playerId: PlayerId): GameSummary {
const s = room.state; const { active, turnHolder, waitKind } = turnFacts(room.state);
const { active, turnHolder, waitKind } = turnFacts(s); const yourTurn = room.state?.phase === "playing" && turnHolder === playerId;
const yourTurn = s?.phase === "playing" && turnHolder === playerId;
return { return {
roomId: room.id, ...baseSummary(room, active),
name: playerId, name: playerId,
players: [...room.players],
started: s !== null,
finished: s?.phase === "finished",
winner: s?.winner ?? null,
activePlayerId: active,
yourTurn, yourTurn,
attention: yourTurn ? (waitKind ?? "turn") : null, attention: yourTurn ? (waitKind ?? "turn") : null,
round: s?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
chatCount: room.chat.length,
}; };
} }
+7 -3
View File
@@ -152,10 +152,14 @@ export function readAllRooms(): Map<string, RoomLine[]> {
* beside the rooms directory — a report line carries roomId/seq pinning * beside the rooms directory — a report line carries roomId/seq pinning
* its moment; a reply line carries reportId/status/text and folds onto * its moment; a reply line carries reportId/status/text and folds onto
* its report when read. Legacy reports without an id answer to their * its report when read. Legacy reports without an id answer to their
* timestamp. */ * timestamp. A report line may carry fields (deckRev, player context)
* that only the operator's raw read uses; readFeedback keeps the
* player-facing subset. */
const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl");
export function appendFeedback(entry: Record<string, unknown>): void { export function appendFeedback(entry: Record<string, unknown>): void {
ensureDataDir(); ensureDataDir();
appendFileSync(join(DATA_DIR, "..", "feedback.jsonl"), JSON.stringify(entry) + "\n", "utf8"); appendFileSync(feedbackFile(), JSON.stringify(entry) + "\n", "utf8");
} }
export interface FeedbackReport { export interface FeedbackReport {
@@ -171,7 +175,7 @@ export interface FeedbackReport {
} }
export function readFeedback(): FeedbackReport[] { export function readFeedback(): FeedbackReport[] {
const file = join(DATA_DIR, "..", "feedback.jsonl"); const file = feedbackFile();
if (!existsSync(file)) return []; if (!existsSync(file)) return [];
const reports = new Map<string, FeedbackReport>(); const reports = new Map<string, FeedbackReport>();
for (const raw of readFileSync(file, "utf8").split("\n")) { for (const raw of readFileSync(file, "utf8").split("\n")) {
+78 -74
View File
@@ -448,8 +448,8 @@
(selectedCard != null || (youMustRespond && !!view?.stack))); (selectedCard != null || (youMustRespond && !!view?.stack)));
/** The hand in the player's own order: drag a card onto another to take /** The hand in the player's own order: drag a card onto another to take
* its place. The order lives only in this browser — the server's hand * its place. The order lives only in this tab, for this sitting — the
* array is authority for contents, this for arrangement. */ * server's hand array is authority for contents, this for arrangement. */
let handOrder = $state<string[]>([]); let handOrder = $state<string[]>([]);
let draggingId = $state<string | null>(null); let draggingId = $state<string | null>(null);
const orderedHand = $derived.by(() => { const orderedHand = $derived.by(() => {
@@ -474,11 +474,14 @@
let handEl = $state<HTMLDivElement | null>(null); let handEl = $state<HTMLDivElement | null>(null);
function fitHandCards(el: HTMLDivElement) { function fitHandCards(el: HTMLDivElement) {
if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; } if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; }
const cardW = 8.2 * 16, gap = 0.45 * 16, pad = 0.6 * 16; // Card.svelte is the source of truth for the card's box: 8.2rem wide,
// 11.4rem tall. 5rem covers the masthead and the column's padding.
const rem = 16;
const cardW = 8.2 * rem, gap = 0.45 * rem, pad = 0.6 * rem;
const zoomW = (el.clientWidth - pad - gap) / (2 * cardW); const zoomW = (el.clientWidth - pad - gap) / (2 * cardW);
const rows = Math.max(1, Math.ceil((view?.yourHand.length ?? 7) / 2)); const rows = Math.max(1, Math.ceil((view?.yourHand.length ?? 7) / 2));
const cardH = 1.42 * cardW; const cardH = (11.4 / 8.2) * cardW;
const maxH = window.innerHeight - 5 * 16; const maxH = window.innerHeight - 5 * rem;
const zoomH = (maxH - pad) / (rows * (cardH + gap)); const zoomH = (maxH - pad) / (rows * (cardH + gap));
el.style.setProperty("--card-zoom", String(Math.max(0.8, Math.min(zoomW, zoomH, 1.8)).toFixed(3))); el.style.setProperty("--card-zoom", String(Math.max(0.8, Math.min(zoomW, zoomH, 1.8)).toFixed(3)));
} }
@@ -495,7 +498,8 @@
const el = handEl; const el = handEl;
if (el) requestAnimationFrame(() => fitHandCards(el)); if (el) requestAnimationFrame(() => fitHandCards(el));
}); });
/** POWER ATTACK: life points burned into the attached attack. */ /** POWER ATTACK: life points burned into the attached attack — always
* fewer than the caster has left. */
let paPoints = $state(1); let paPoints = $state(1);
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
@@ -516,6 +520,7 @@
} }
function clearSelection() { function clearSelection() {
paPoints = 1;
punchWallMode = false; punchWallMode = false;
swapMeetTarget = null; swapMeetTarget = null;
swapMine = null; swapMine = null;
@@ -786,16 +791,11 @@
confirmTeleport(); confirmTeleport();
return; return;
} }
if (selectedCard && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key") && // A SAFE takes the cast itself: a key turns its combination, and an
view.squareContents[cellKey(cell)]?.kind === "safe") { // attack batters the box (fifteen points bursts it).
// A key aimed at a SAFE's square opens the box, not a door. if (selectedCard && view.squareContents[cellKey(cell)]?.kind === "safe" &&
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } })); (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key" ||
clearSelection(); cardDef(selectedCard.cardId).cardType === "attack")) {
return;
}
if (selectedCard && cardDef(selectedCard.cardId).cardType === "attack" &&
view.squareContents[cellKey(cell)]?.kind === "safe") {
// Brute force: attacks batter the safe itself (fifteen points bursts it).
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } })); dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
clearSelection(); clearSelection();
return; return;
@@ -1325,6 +1325,7 @@
function pass() { dispatch({ type: "pass" }); } function pass() { dispatch({ type: "pass" }); }
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null); const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
const paBurnable = $derived(me ? [1, 2, 3, 4, 5].filter((n) => n < me.life) : []);
/** Two treasures can share a square: one dispatches, more ask whose. */ /** Two treasures can share a square: one dispatches, more ask whose. */
const treasuresHere = $derived( const treasuresHere = $derived(
view && me view && me
@@ -2056,7 +2057,7 @@
{#if net.feedbackReports.length > 0} {#if net.feedbackReports.length > 0}
<div class="reports-head">your reports to the wizards</div> <div class="reports-head">your reports to the wizards</div>
{#each net.feedbackReports as r (r.id)} {#each net.feedbackReports as r (r.id)}
<div class="report-row" class:answered={!!r.reply}> <div class="report-row">
<span class="ledger-code">{r.roomId}</span> <span class="ledger-code">{r.roomId}</span>
<div class="report-body"> <div class="report-body">
<div class="report-text">“{r.happened}”</div> <div class="report-text">“{r.happened}”</div>
@@ -2232,8 +2233,8 @@
{#if attachedMods.some((m) => m.cardId === "power-attack")} {#if attachedMods.some((m) => m.cardId === "power-attack")}
<label class="inline">burn <label class="inline">burn
<select class="tier-pick" bind:value={paPoints} aria-label="life points to burn"> <select class="tier-pick" bind:value={paPoints} aria-label="life points to burn">
{#each [1, 2, 3, 4, 5] as n (n)} {#each paBurnable as n (n)}
{#if me && n < me.life}<option value={n}>{n}</option>{/if} <option value={n}>{n}</option>
{/each} {/each}
</select> </select>
life for +{paPoints} damage</label> life for +{paPoints} damage</label>
@@ -2441,14 +2442,14 @@
{:else if view.stack?.defenderId === view.you} {:else if view.stack?.defenderId === view.you}
Under attack — respond by your hand. Under attack — respond by your hand.
{#if view.stack.attackCard} {#if view.stack.attackCard}
<span class="respond-card"><Card card={view.stack.attackCard} onclick={() => {}} /></span> <span class="respond-card"><Card card={view.stack.attackCard} /></span>
{/if} {/if}
{:else} {:else}
Your spell is countered — respond by your hand. Your spell is countered — respond by your hand.
{#if view.stack?.counters.length} {#if view.stack?.counters.length}
{@const latest = [...view.stack.counters].reverse().find((c) => !c.nullified)} {@const latest = [...view.stack.counters].reverse().find((c) => !c.nullified)}
{#if latest} {#if latest}
<span class="respond-card"><Card card={latest.card} onclick={() => {}} /></span> <span class="respond-card"><Card card={latest.card} /></span>
{/if} {/if}
{/if} {/if}
{/if} {/if}
@@ -2597,49 +2598,52 @@
</div> </div>
</div> </div>
<!-- The chronicle below stays MOUNTED while the panel covers it
(class-hidden, not unrendered) so its scroll position and
stick-to-newest state survive the casting. -->
{#if castPanelOn && view} {#if castPanelOn && view}
<div class="cast-panel" class:respond={youMustRespond} aria-label={youMustRespond ? "counteraction" : "casting"}> <div class="cast-panel" class:respond={youMustRespond} aria-label={youMustRespond ? "counteraction" : "casting"}>
{#if youMustRespond && view.stack} {#if youMustRespond && view.stack}
<div class="cast-panel-head">⚔ incoming</div> <div class="cast-panel-head">⚔ incoming</div>
{#if view.stack.attackCard} {#if view.stack.attackCard}
<div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div> <div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div>
{:else} {:else}
<div class="cast-panel-note">a physical blow — no card behind it</div> <div class="cast-panel-note">a physical blow — no card behind it</div>
{/if} {/if}
{#if view.stack.numberValue != null || view.stack.amplifyFactor > 1} {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
<div class="cast-panel-note"> <div class="cast-panel-note">
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""} {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
{view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""} {view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""}
</div> </div>
{/if} {/if}
{#if view.stack.counters.length > 0} {#if view.stack.counters.length > 0}
<div class="cast-panel-sub">counters so far</div> <div class="cast-panel-sub">counters so far</div>
<div class="cast-panel-minis"> <div class="cast-panel-minis">
{#each view.stack.counters as c (c.card.instanceId)} {#each view.stack.counters as c (c.card.instanceId)}
<span class="cast-mini" class:nullified={c.nullified}> <span class="cast-mini" class:nullified={c.nullified}>
<Card card={c.card} onclick={() => {}} /></span> <Card card={c.card} /></span>
{/each} {/each}
</div> </div>
{/if} {/if}
{:else if selectedCard} {:else if selectedCard}
<div class="cast-panel-card"><Card card={selectedCard} onfaq={(id) => (faqCardId = id)} /></div> <div class="cast-panel-card"><Card card={selectedCard} onfaq={(id) => (faqCardId = id)} /></div>
{#if attachedNumber || attachedMods.length > 0} {#if attachedNumber || attachedMods.length > 0}
<div class="cast-panel-sub">riding along</div> <div class="cast-panel-sub">riding along</div>
<div class="cast-panel-minis"> <div class="cast-panel-minis">
{#if attachedNumber}
<span class="cast-mini"><Card card={attachedNumber} /></span>
{/if}
{#each attachedMods as m (m.instanceId)}
<span class="cast-mini"><Card card={m} /></span>
{/each}
</div>
{#if attachedNumber} {#if attachedNumber}
<span class="cast-mini"><Card card={attachedNumber} onclick={() => {}} /></span> <div class="cast-panel-note">number total {numberTotal}</div>
{/if} {/if}
{#each attachedMods as m (m.instanceId)}
<span class="cast-mini"><Card card={m} onclick={() => {}} /></span>
{/each}
</div>
{#if attachedNumber}
<div class="cast-panel-note">number total {numberTotal}</div>
{/if} {/if}
{/if} {/if}
{/if} {@render tableGuidance()}
{@render tableGuidance()} </div>
</div>
{/if} {/if}
<div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl} <div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl}
onscroll={onChronicleScroll}> onscroll={onChronicleScroll}>
@@ -2786,7 +2790,7 @@
</div> </div>
{/if} {/if}
<div class="hand" bind:this={handEl} class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand"> <div class="hand" bind:this={handEl} role="list" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#if net.spectating} {#if net.spectating}
<span class="gallery-note">👁 You watch from the Peanut Gallery hands stay secret, even from you.</span> <span class="gallery-note">👁 You watch from the Peanut Gallery hands stay secret, even from you.</span>
{/if} {/if}
@@ -2798,20 +2802,20 @@
ondragover={(e) => e.preventDefault()} ondragover={(e) => e.preventDefault()}
ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }} ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }}
ondragend={() => (draggingId = null)}> ondragend={() => (draggingId = null)}>
<Card <Card
{card} {card}
selected={selectedCard?.instanceId === card.instanceId || selected={selectedCard?.instanceId === card.instanceId ||
ambushVia?.instanceId === card.instanceId} ambushVia?.instanceId === card.instanceId}
attached={attachedNumber?.instanceId === card.instanceId || attached={attachedNumber?.instanceId === card.instanceId ||
ambushSpell?.instanceId === card.instanceId || ambushSpell?.instanceId === card.instanceId ||
attachedMods.some((m) => m.instanceId === card.instanceId)} attachedMods.some((m) => m.instanceId === card.instanceId)}
marked={discardSelection.has(card.instanceId)} marked={discardSelection.has(card.instanceId)}
displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false} displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false}
glowing={interruptCards.some((c) => c.instanceId === card.instanceId)} glowing={interruptCards.some((c) => c.instanceId === card.instanceId)}
charges={view.wandCharges[card.instanceId] ?? null} charges={view.wandCharges[card.instanceId] ?? null}
onclick={() => selectCard(card)} onclick={() => selectCard(card)}
onfaq={(id) => (faqCardId = id)} onfaq={(id) => (faqCardId = id)}
/> />
</div> </div>
{/each} {/each}
</div> </div>
+13 -6
View File
@@ -249,6 +249,17 @@ function loadSeen(): Record<string, number> {
} }
export interface Seat { name: string; roomId: string; token: string } export interface Seat { name: string; roomId: string; token: string }
export interface FeedbackReportView {
id: string;
at: string;
roomId: string;
seq: number;
round: number | null;
happened: string;
expected: string;
reply?: { at: string; text: string; status: string };
}
export interface GameSummary { export interface GameSummary {
roomId: string; roomId: string;
name: string; name: string;
@@ -303,6 +314,8 @@ class Net {
view = $state<GameView | null>(null); view = $state<GameView | null>(null);
log = $state<LogLine[]>([]); log = $state<LogLine[]>([]);
error = $state<string | null>(null); error = $state<string | null>(null);
/** Your reports and the wizards' replies, proven by seat tokens. */
feedbackReports = $state<FeedbackReportView[]>([]);
/** Every seat this browser holds, across rooms. */ /** Every seat this browser holds, across rooms. */
seats = $state<Seat[]>(loadSeats()); seats = $state<Seat[]>(loadSeats());
/** Lobby ledger: one summary per live seat. */ /** Lobby ledger: one summary per live seat. */
@@ -611,12 +624,6 @@ class Net {
this.send({ type: "feedback", happened, expected }); this.send({ type: "feedback", happened, expected });
} }
/** Your reports and the wizards' replies, proven by seat tokens. */
feedbackReports = $state<{
id: string; at: string; roomId: string; seq: number; round: number | null;
happened: string; expected: string;
reply?: { at: string; text: string; status: string };
}[]>([]);
refreshFeedback(): void { refreshFeedback(): void {
if (this.seats.length > 0) this.send({ type: "myFeedback", seats: $state.snapshot(this.seats) }); if (this.seats.length > 0) this.send({ type: "myFeedback", seats: $state.snapshot(this.seats) });
} }