Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa0e17483 | ||
|
|
d37bc62c28 |
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 486 KiB |
+12
-12
@@ -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 <host> <reportId> <resolved|by-design|open> <text...>
|
||||
set -eu
|
||||
HOST="$1"; REPORT_ID="$2"; STATUS="$3"; shift 3
|
||||
set -euo pipefail
|
||||
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="$*"
|
||||
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'
|
||||
|
||||
@@ -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<string> {
|
||||
const self = me(view);
|
||||
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
|
||||
// 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 } };
|
||||
|
||||
@@ -5252,9 +5252,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): 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[] = [];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GameSummary, "name" | "yourTurn" | "attention"> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -152,10 +152,14 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
||||
* 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<string, unknown>): 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<string, FeedbackReport>();
|
||||
for (const raw of readFileSync(file, "utf8").split("\n")) {
|
||||
|
||||
+78
-74
@@ -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<string[]>([]);
|
||||
let draggingId = $state<string | null>(null);
|
||||
const orderedHand = $derived.by(() => {
|
||||
@@ -474,11 +474,14 @@
|
||||
let handEl = $state<HTMLDivElement | null>(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}
|
||||
<div class="reports-head">your reports to the wizards</div>
|
||||
{#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>
|
||||
<div class="report-body">
|
||||
<div class="report-text">“{r.happened}”</div>
|
||||
@@ -2232,8 +2233,8 @@
|
||||
{#if attachedMods.some((m) => m.cardId === "power-attack")}
|
||||
<label class="inline">burn
|
||||
<select class="tier-pick" bind:value={paPoints} aria-label="life points to burn">
|
||||
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||
{#if me && n < me.life}<option value={n}>{n}</option>{/if}
|
||||
{#each paBurnable as n (n)}
|
||||
<option value={n}>{n}</option>
|
||||
{/each}
|
||||
</select>
|
||||
life for +{paPoints} damage</label>
|
||||
@@ -2441,14 +2442,14 @@
|
||||
{:else if view.stack?.defenderId === view.you}
|
||||
Under attack — respond by your hand.
|
||||
{#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}
|
||||
{: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}
|
||||
<span class="respond-card"><Card card={latest.card} onclick={() => {}} /></span>
|
||||
<span class="respond-card"><Card card={latest.card} /></span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -2597,49 +2598,52 @@
|
||||
</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}
|
||||
<div class="cast-panel" class:respond={youMustRespond} aria-label={youMustRespond ? "counteraction" : "casting"}>
|
||||
{#if youMustRespond && view.stack}
|
||||
<div class="cast-panel-head">⚔ incoming</div>
|
||||
{#if view.stack.attackCard}
|
||||
<div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div>
|
||||
{:else}
|
||||
<div class="cast-panel-note">a physical blow — no card behind it</div>
|
||||
{/if}
|
||||
{#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
|
||||
<div class="cast-panel-note">
|
||||
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
|
||||
{view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""}
|
||||
</div>
|
||||
{/if}
|
||||
{#if view.stack.counters.length > 0}
|
||||
<div class="cast-panel-sub">counters so far</div>
|
||||
<div class="cast-panel-minis">
|
||||
{#each view.stack.counters as c (c.card.instanceId)}
|
||||
<span class="cast-mini" class:nullified={c.nullified}>
|
||||
<Card card={c.card} onclick={() => {}} /></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if selectedCard}
|
||||
<div class="cast-panel-card"><Card card={selectedCard} onfaq={(id) => (faqCardId = id)} /></div>
|
||||
{#if attachedNumber || attachedMods.length > 0}
|
||||
<div class="cast-panel-sub">riding along</div>
|
||||
<div class="cast-panel-minis">
|
||||
<div class="cast-panel" class:respond={youMustRespond} aria-label={youMustRespond ? "counteraction" : "casting"}>
|
||||
{#if youMustRespond && view.stack}
|
||||
<div class="cast-panel-head">⚔ incoming</div>
|
||||
{#if view.stack.attackCard}
|
||||
<div class="cast-panel-card"><Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} /></div>
|
||||
{:else}
|
||||
<div class="cast-panel-note">a physical blow — no card behind it</div>
|
||||
{/if}
|
||||
{#if view.stack.numberValue != null || view.stack.amplifyFactor > 1}
|
||||
<div class="cast-panel-note">
|
||||
{view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""}
|
||||
{view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""}
|
||||
</div>
|
||||
{/if}
|
||||
{#if view.stack.counters.length > 0}
|
||||
<div class="cast-panel-sub">counters so far</div>
|
||||
<div class="cast-panel-minis">
|
||||
{#each view.stack.counters as c (c.card.instanceId)}
|
||||
<span class="cast-mini" class:nullified={c.nullified}>
|
||||
<Card card={c.card} /></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if selectedCard}
|
||||
<div class="cast-panel-card"><Card card={selectedCard} onfaq={(id) => (faqCardId = id)} /></div>
|
||||
{#if attachedNumber || attachedMods.length > 0}
|
||||
<div class="cast-panel-sub">riding along</div>
|
||||
<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}
|
||||
<span class="cast-mini"><Card card={attachedNumber} onclick={() => {}} /></span>
|
||||
<div class="cast-panel-note">number total {numberTotal}</div>
|
||||
{/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}
|
||||
{@render tableGuidance()}
|
||||
</div>
|
||||
{@render tableGuidance()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl}
|
||||
onscroll={onChronicleScroll}>
|
||||
@@ -2786,7 +2790,7 @@
|
||||
</div>
|
||||
{/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}
|
||||
<span class="gallery-note">👁 You watch from the Peanut Gallery — hands stay secret, even from you.</span>
|
||||
{/if}
|
||||
@@ -2798,20 +2802,20 @@
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }}
|
||||
ondragend={() => (draggingId = null)}>
|
||||
<Card
|
||||
{card}
|
||||
selected={selectedCard?.instanceId === card.instanceId ||
|
||||
ambushVia?.instanceId === card.instanceId}
|
||||
attached={attachedNumber?.instanceId === card.instanceId ||
|
||||
ambushSpell?.instanceId === card.instanceId ||
|
||||
attachedMods.some((m) => 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)}
|
||||
/>
|
||||
<Card
|
||||
{card}
|
||||
selected={selectedCard?.instanceId === card.instanceId ||
|
||||
ambushVia?.instanceId === card.instanceId}
|
||||
attached={attachedNumber?.instanceId === card.instanceId ||
|
||||
ambushSpell?.instanceId === card.instanceId ||
|
||||
attachedMods.some((m) => 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)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -249,6 +249,17 @@ function loadSeen(): Record<string, number> {
|
||||
}
|
||||
|
||||
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<GameView | null>(null);
|
||||
log = $state<LogLine[]>([]);
|
||||
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. */
|
||||
seats = $state<Seat[]>(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) });
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 563 KiB |
Reference in New Issue
Block a user