diff --git a/.claude/skills/wizwar-reports/SKILL.md b/.claude/skills/wizwar-reports/SKILL.md index 63b9c81..88532b1 100644 --- a/.claude/skills/wizwar-reports/SKILL.md +++ b/.claude/skills/wizwar-reports/SKILL.md @@ -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 happened" text, the full "what they expected" text, and the full reply 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 diff --git a/deploy/feedback-reply.sh b/deploy/feedback-reply.sh index bdb123f..19f9011 100755 --- a/deploy/feedback-reply.sh +++ b/deploy/feedback-reply.sh @@ -2,18 +2,18 @@ # Answer a player's feedback report; the reply appears under it in their # lobby ledger. Legacy reports without an id answer to their timestamp. # deploy/feedback-reply.sh -set -eu -HOST="$1"; REPORT_ID="$2"; STATUS="$3"; shift 3 +set -euo pipefail +HOST="${1:?usage: feedback-reply.sh }" +REPORT_ID="${2:?usage: feedback-reply.sh }" +STATUS="${3:?usage: feedback-reply.sh }" +shift 3 TEXT="$*" 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' -import json, sys, datetime -print(json.dumps({ - "reportId": sys.argv[1], - "at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), - "status": sys.argv[2], - "text": sys.argv[3], -})) -EOF -) +LINE=$(REPORT_ID="$REPORT_ID" STATUS="$STATUS" TEXT="$TEXT" node -e ' +console.log(JSON.stringify({ + reportId: process.env.REPORT_ID, + at: new Date().toISOString(), + status: process.env.STATUS, + text: process.env.TEXT, +}));') printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl' diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 716c947..01e3dfa 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -643,6 +643,14 @@ function pathToward( } /** 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 { const self = me(view); const goals = new Set(); @@ -655,10 +663,7 @@ function treasureGoals(view: GameView): Set { // 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. // Marching to an unopenable safe stalls the clockwork on it forever. - const boxKey = cellKey(t.position); - if (view.squareContents[boxKey]?.kind === "safe" && - view.squareContents[boxKey]!.createdBy !== view.you && - !view.openSafes.includes(boxKey) && + if (lockedSafeAt(view, cellKey(t.position)) && !inHand(view, "pick-lock") && !inHand(view, "master-key") && !inHand(view, "dispel-creation")) { continue; @@ -1332,8 +1337,7 @@ export function automatonCommand( if (prize) { // A SAFE over the prize: turn the combination (or dispel the box) // before reaching for the gold — the grab itself would be refused. - const boxed = view.squareContents[here]?.kind === "safe" && - view.squareContents[here]!.createdBy !== you && !view.openSafes.includes(here); + const boxed = lockedSafeAt(view, here); if (!boxed) return { type: "pickUpTreasure", treasureId: prize.id }; const key = inHand(view, "pick-lock") ?? inHand(view, "master-key"); if (key) return { type: "cast", instanceId: key.instanceId, target: { kind: "cell", cell: self.position } }; diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b0bc6df..c7a02f0 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -5252,9 +5252,9 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) { return err("you must be in the same square"); } - if (effect.requiresLos && !(mods.aroundCorner - ? bentLos(state, caster, caster.position, cell) - : castSight(state, caster, cmd, cell))) { + // castSight carries the whole sight law: straight lines, the + // VISIONSTONE's one-wall look, and the attached corner bend. + if (effect.requiresLos && !castSight(state, caster, cmd, cell)) { return err("no line of sight to the safe"); } const wandEvents: GameEvent[] = []; diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 9f67577..e0e6cce 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -1034,15 +1034,26 @@ describe("interception, escape, and hazard sense", () => { state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" }; bot.position = { ...chest.position! }; bot.hand = [{ cardId: "number-3", instanceId: "N3" }]; - for (let i = 0; i < 8; i++) { - const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); - if (!cmd) break; - expect(cmd.type).not.toBe("pickUpTreasure"); - const r = applyCommand(state, "bot", cmd); + // Drive whichever seat the maze wants (a punched foe answers its own + // counteraction window). The invariants: the bot never proposes the + // doomed grab, and its turn actually ends instead of idling forever. + let ended = false; + 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); 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", () => { diff --git a/packages/engine/test/stones.test.ts b/packages/engine/test/stones.test.ts index 0486af7..5b11d0c 100644 --- a/packages/engine/test/stones.test.ts +++ b/packages/engine/test/stones.test.ts @@ -188,7 +188,7 @@ describe("slow death", () => { 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(); state = toRound2(state); const { attacker, defender } = faceOff(state); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e012b52..82084ab 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -27,17 +27,12 @@ // {type:"chat", player, text, at} one line of table talk // {type:"watching", roomId} you are seated in 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} 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 { randomBytes } from "node:crypto"; import { readFileSync, existsSync, realpathSync } from "node:fs"; import { extname, join, normalize, sep } from "node:path"; import { WebSocketServer, WebSocket } from "ws"; @@ -71,11 +66,16 @@ import { } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; import { appendFeedback, readFeedback } from "./store"; -import { randomBytes } from "node:crypto"; import { getShare, loadShares, mintShare } from "./shares"; import { renderSharePng } from "./ogimage"; 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. ----------------- const MAX_SOCKETS = 300; // concurrent connections const MAX_ROOMS = 5000; // total rooms on the server diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index eab1670..a1528ba 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -184,17 +184,22 @@ function stubOf(room: Room): RoomStub { turnHolder, waitKind, playing: room.state?.phase === "playing", - base: { - roomId: room.id, - players: [...room.players], - 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, - }, + base: baseSummary(room, active), + }; +} + +/** The per-room half of a summary — everything except whose turn it is. */ +function baseSummary(room: Room, active: PlayerId | null): Omit { + return { + roomId: room.id, + players: [...room.players], + 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; } -/** 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): { active: PlayerId | null; turnHolder: PlayerId | null; waitKind: "counteract" | "discard" | "interrupt" | 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 + : s.wardPending != null ? "counteract" as const + : s.slowDeathPending != null ? "counteract" as const : s.stack?.waitingOn != null ? "counteract" as const : s.pendingDiscard != null ? "discard" 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 }; } +/** A seat-holder's one-line view of a room, for the lobby ledger. */ export function summarize(room: Room, playerId: PlayerId): GameSummary { - const s = room.state; - const { active, turnHolder, waitKind } = turnFacts(s); - const yourTurn = s?.phase === "playing" && turnHolder === playerId; + const { active, turnHolder, waitKind } = turnFacts(room.state); + const yourTurn = room.state?.phase === "playing" && turnHolder === playerId; return { - roomId: room.id, + ...baseSummary(room, active), name: playerId, - players: [...room.players], - started: s !== null, - finished: s?.phase === "finished", - winner: s?.winner ?? null, - activePlayerId: active, yourTurn, 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, }; } diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 1895b5f..f26f1bc 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -152,10 +152,14 @@ export function readAllRooms(): Map { * beside the rooms directory — a report line carries roomId/seq pinning * its moment; a reply line carries reportId/status/text and folds onto * 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): void { ensureDataDir(); - appendFileSync(join(DATA_DIR, "..", "feedback.jsonl"), JSON.stringify(entry) + "\n", "utf8"); + appendFileSync(feedbackFile(), JSON.stringify(entry) + "\n", "utf8"); } export interface FeedbackReport { @@ -171,7 +175,7 @@ export interface FeedbackReport { } export function readFeedback(): FeedbackReport[] { - const file = join(DATA_DIR, "..", "feedback.jsonl"); + const file = feedbackFile(); if (!existsSync(file)) return []; const reports = new Map(); for (const raw of readFileSync(file, "utf8").split("\n")) { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 19e88da..b61cc71 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -448,8 +448,8 @@ (selectedCard != null || (youMustRespond && !!view?.stack))); /** 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 - * array is authority for contents, this for arrangement. */ + * its place. The order lives only in this tab, for this sitting — the + * server's hand array is authority for contents, this for arrangement. */ let handOrder = $state([]); let draggingId = $state(null); const orderedHand = $derived.by(() => { @@ -474,11 +474,14 @@ let handEl = $state(null); function fitHandCards(el: HTMLDivElement) { 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 rows = Math.max(1, Math.ceil((view?.yourHand.length ?? 7) / 2)); - const cardH = 1.42 * cardW; - const maxH = window.innerHeight - 5 * 16; + const cardH = (11.4 / 8.2) * cardW; + const maxH = window.innerHeight - 5 * rem; 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))); } @@ -495,7 +498,8 @@ const el = handEl; 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); const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); @@ -516,6 +520,7 @@ } function clearSelection() { + paPoints = 1; punchWallMode = false; swapMeetTarget = null; swapMine = null; @@ -786,16 +791,11 @@ confirmTeleport(); return; } - if (selectedCard && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key") && - view.squareContents[cellKey(cell)]?.kind === "safe") { - // A key aimed at a SAFE's square opens the box, not a door. - dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } })); - clearSelection(); - 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). + // A SAFE takes the cast itself: a key turns its combination, and an + // attack batters the box (fifteen points bursts it). + if (selectedCard && view.squareContents[cellKey(cell)]?.kind === "safe" && + (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key" || + cardDef(selectedCard.cardId).cardType === "attack")) { dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } })); clearSelection(); return; @@ -1325,6 +1325,7 @@ function pass() { dispatch({ type: "pass" }); } 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. */ const treasuresHere = $derived( view && me @@ -2056,7 +2057,7 @@ {#if net.feedbackReports.length > 0}
your reports to the wizards
{#each net.feedbackReports as r (r.id)} -
+
{r.roomId}
“{r.happened}”
@@ -2232,8 +2233,8 @@ {#if attachedMods.some((m) => m.cardId === "power-attack")} @@ -2441,14 +2442,14 @@ {:else if view.stack?.defenderId === view.you} Under attack — respond by your hand. {#if view.stack.attackCard} - {}} /> + {/if} {:else} Your spell is countered — respond by your hand. {#if view.stack?.counters.length} {@const latest = [...view.stack.counters].reverse().find((c) => !c.nullified)} {#if latest} - {}} /> + {/if} {/if} {/if} @@ -2597,49 +2598,52 @@
+ {#if castPanelOn && view} -
- {#if youMustRespond && view.stack} -
⚔ incoming
- {#if view.stack.attackCard} -
(faqCardId = id)} />
- {:else} -
a physical blow — no card behind it
- {/if} - {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1} -
- {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""} - {view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""} -
- {/if} - {#if view.stack.counters.length > 0} -
counters so far
-
- {#each view.stack.counters as c (c.card.instanceId)} - - {}} /> - {/each} -
- {/if} - {:else if selectedCard} -
(faqCardId = id)} />
- {#if attachedNumber || attachedMods.length > 0} -
riding along
-
+
+ {#if youMustRespond && view.stack} +
⚔ incoming
+ {#if view.stack.attackCard} +
(faqCardId = id)} />
+ {:else} +
a physical blow — no card behind it
+ {/if} + {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1} +
+ {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""} + {view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""} +
+ {/if} + {#if view.stack.counters.length > 0} +
counters so far
+
+ {#each view.stack.counters as c (c.card.instanceId)} + + + {/each} +
+ {/if} + {:else if selectedCard} +
(faqCardId = id)} />
+ {#if attachedNumber || attachedMods.length > 0} +
riding along
+
+ {#if attachedNumber} + + {/if} + {#each attachedMods as m (m.instanceId)} + + {/each} +
{#if attachedNumber} - {}} /> +
number total {numberTotal}
{/if} - {#each attachedMods as m (m.instanceId)} - {}} /> - {/each} -
- {#if attachedNumber} -
number total {numberTotal}
{/if} {/if} - {/if} - {@render tableGuidance()} -
+ {@render tableGuidance()} +
{/if}
@@ -2786,7 +2790,7 @@
{/if} -
+
{#if net.spectating} 👁 You watch from the Peanut Gallery — hands stay secret, even from you. {/if} @@ -2798,20 +2802,20 @@ ondragover={(e) => e.preventDefault()} ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }} ondragend={() => (draggingId = null)}> - m.instanceId === card.instanceId)} - marked={discardSelection.has(card.instanceId)} - 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)} - charges={view.wandCharges[card.instanceId] ?? null} - onclick={() => selectCard(card)} - onfaq={(id) => (faqCardId = id)} - /> + m.instanceId === card.instanceId)} + marked={discardSelection.has(card.instanceId)} + 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)} + charges={view.wandCharges[card.instanceId] ?? null} + onclick={() => selectCard(card)} + onfaq={(id) => (faqCardId = id)} + />
{/each}
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 55c30a2..cb574f6 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -249,6 +249,17 @@ function loadSeen(): Record { } 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 { roomId: string; name: string; @@ -303,6 +314,8 @@ class Net { view = $state(null); log = $state([]); error = $state(null); + /** Your reports and the wizards' replies, proven by seat tokens. */ + feedbackReports = $state([]); /** Every seat this browser holds, across rooms. */ seats = $state(loadSeats()); /** Lobby ledger: one summary per live seat. */ @@ -611,12 +624,6 @@ class Net { 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 { if (this.seats.length > 0) this.send({ type: "myFeedback", seats: $state.snapshot(this.seats) }); }