From 66b5c06f17cb00865018df0bcf2355705bbe7a47 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 22 Sep 2026 09:56:49 -0400 Subject: [PATCH] Credibility pass: one teleport step, one bell, one step type, and the comments that argued are gone Since 6e49fc9. Engine: the teleport BFS in the view and the engine's wallIgnoringDistance share teleportStep; the counter trail is weighed between steps instead of on six arms; the bush attack takes its adjacency from SIDES like the wall does; a dead redirected clause, a one-member set, an identity ternary, and a stray try/catch are gone; two tests now assert what their names promise. Server: KEEPER and MAX_SEATS live in rooms.ts, the three Sentry bells ring through one helper, the unread reply field is dropped, actingSeat comes from the engine, and the protocol header names every message. Web: ReplayStep is one type, cardName one helper, the say box and the reference section one snippet each, the keeper's name arrives from the server rather than the source, and the lifesaver ruling that ruled nothing is out. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- deploy/brain-trial.mjs | 8 +- deploy/feedback-reply.sh | 5 +- deploy/sentry-slack-alert.sh | 8 +- packages/engine/src/automaton.ts | 19 +++-- packages/engine/src/game.ts | 81 +++++++++++-------- packages/engine/src/view.ts | 38 +++------ packages/engine/test/automaton.test.ts | 12 +-- .../engine/test/durations-doors-cards.test.ts | 2 +- .../engine/test/expansion-terrain.test.ts | 7 +- packages/server/src/index.ts | 80 +++++++++--------- packages/server/src/rooms.ts | 34 ++++---- packages/server/src/store.ts | 6 +- packages/web/src/App.svelte | 76 +++++++++-------- packages/web/src/Help.svelte | 30 ++++--- packages/web/src/Replay.svelte | 3 +- packages/web/src/SharePage.svelte | 3 +- packages/web/src/catchup.ts | 13 +-- packages/web/src/help-state.ts | 3 +- packages/web/src/net.svelte.ts | 31 +++---- packages/web/src/receipt.ts | 6 +- packages/web/src/reference.ts | 19 +++-- 21 files changed, 245 insertions(+), 239 deletions(-) diff --git a/deploy/brain-trial.mjs b/deploy/brain-trial.mjs index 9200517..67bec8e 100644 --- a/deploy/brain-trial.mjs +++ b/deploy/brain-trial.mjs @@ -8,7 +8,7 @@ // Make the old checkout with `git worktree add `. Each seed // is played once per rotation of the table so seat order cancels out. -import { applyCommand, automatonFallback, createGame, viewFor } from "../packages/engine/src/index.ts"; +import { actingSeat, applyCommand, automatonFallback, createGame, viewFor } from "../packages/engine/src/index.ts"; import { pathToFileURL } from "node:url"; import { resolve } from "node:path"; @@ -25,12 +25,6 @@ const brains = { old: old.automatonCommand, }; -function actingSeat(state) { - return state.wardPending?.ownerId ?? state.pushPending?.pusheeId ?? state.slowDeathPending?.playerId - ?? state.pendingDiscard ?? state.chaosPending?.queue[0] ?? state.stack?.waitingOn - ?? state.outOfTurnWindow?.playerId ?? state.players[state.turn.activeIndex].id; -} - function playOut(seed, order) { const ids = order.map((b, i) => `${b}${i + 1}`); const brainOf = Object.fromEntries(ids.map((id, i) => [id, brains[order[i]]])); diff --git a/deploy/feedback-reply.sh b/deploy/feedback-reply.sh index d8d0065..a5bf384 100755 --- a/deploy/feedback-reply.sh +++ b/deploy/feedback-reply.sh @@ -26,8 +26,9 @@ m = re.search(r'SENTRY_DSN=https://([0-9a-f]+)@([^/]+)/(\\d+)', svc) if not m: raise SystemExit(0) key, host, project = m.groups() text = os.environ['TEXT']; rid = os.environ['REPORT_ID']; status = os.environ['STATUS'] -event = {'event_id': uuid.uuid4().hex, 'timestamp': datetime.datetime.now(datetime.UTC).isoformat(), 'level': 'error', 'platform': 'other', - 'message': {'formatted': f'Desk replies on report {rid} ({status}): {text[:100]}'}, 'fingerprint': ['report-reply', rid, datetime.datetime.now(datetime.UTC).isoformat()], +now = datetime.datetime.now(datetime.UTC).isoformat() +event = {'event_id': uuid.uuid4().hex, 'timestamp': now, 'level': 'error', 'platform': 'other', + 'message': {'formatted': f'Desk replies on report {rid} ({status}): {text[:100]}'}, 'fingerprint': ['report-reply', rid, now], 'tags': {'report': rid, 'status': status}, 'extra': {'text': text}} body = json.dumps({'event_id': event['event_id'], 'sent_at': event['timestamp']}) + '\\n' + json.dumps({'type': 'event'}) + '\\n' + json.dumps(event) req = urllib.request.Request(f'https://{host}/api/{project}/envelope/', data=body.encode(), headers={'Content-Type': 'application/x-sentry-envelope', 'X-Sentry-Auth': f'Sentry sentry_version=7, sentry_client=wizwar-desk/1, sentry_key={key}'}) diff --git a/deploy/sentry-slack-alert.sh b/deploy/sentry-slack-alert.sh index d98953a..07ddd1c 100755 --- a/deploy/sentry-slack-alert.sh +++ b/deploy/sentry-slack-alert.sh @@ -14,9 +14,9 @@ CHANNEL="${1:-#wizwar-notifications}" API="https://us.sentry.io/api/0/organizations/$ORG" AUTH="Authorization: Bearer $SENTRY_TOKEN" -# What the API wants, learned the hard way: the Slack action needs the -# channel's ID as well as its name, the project's detector is found by -# project id, and the binding is a PUT of the workflow with detectorIds. +# The Slack action takes the channel's ID as well as its name; the +# project's error detector is looked up by project id; a workflow is bound +# to its detectors by PUT after creation. SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}" CHANNEL_ID="${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}" PROJECT_ID="${SENTRY_PROJECT_ID:-4512011462049793}" @@ -68,6 +68,6 @@ if [ "$CODE" != "201" ]; then fi WORKFLOW_ID=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))') echo "created workflow $WORKFLOW_ID" -# A fresh workflow may come back without its detectors; bind them by PUT. +# Binding to the detector is done by PUT, the whole workflow resent. curl -s -o /dev/null -w "bound to detector $DETECTOR_ID: %{http_code}\n" -X PUT "$API/workflows/$WORKFLOW_ID/" -H "$AUTH" -H "Content-Type: application/json" -d "$PAYLOAD" echo "done: https://$ORG.sentry.io/monitors/alerts/$WORKFLOW_ID/" diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index ca5f365..6345fb2 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -733,7 +733,7 @@ function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTrea /** Treasures a wizard has delivered: the scoreboard the table reads. */ function bankedBy(view: GameView, p: { id: PlayerId; home: Cell }): number { return view.treasures.filter( - (t) => t.owner !== p.id && t.position && !t.carriedBy && cellKey(t.position) === cellKey(p.home)).length; + (t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home)).length; } /** The enemy whose delivery in progress would end the game, if any. */ @@ -769,8 +769,8 @@ export function interceptCell( if (theirs == null || toHome == null || mine == null) continue; if (theirs + toHome !== road || mine > theirs) continue; if (!best || mine < best.mine || (mine === best.mine && toHome < best.toHome)) { - const [x, y] = k.split(",").map(Number); - best = { cell: { x: x!, y: y! }, mine, toHome }; + const [x, y] = k.split(",").map(Number) as [number, number]; + best = { cell: { x, y }, mine, toHome }; } } return best?.cell ?? carrier.home; @@ -1509,19 +1509,19 @@ export function automatonCommand( // would not have died anyway. const weakest = [...visible].sort((a, b) => a.life - b.life)[0]!; const finisher = bestAttack(view, weakest.id, tier); + const thiefInSight = thief && visible.some((p) => p.id === thief.id) ? thief : null; const runner = visible.find((p) => deliveryWins(view, p)); const myBank = bankedBy(view, self); const leader = [...visible] .filter((p) => bankedBy(view, p) > myBank) .sort((a, b) => bankedBy(view, b) - bankedBy(view, a) || a.life - b.life)[0]; - const target = thief && visible.some((p) => p.id === thief.id) ? thief - : finisher && finisher.damage >= weakest.life ? weakest - : runner ?? leader ?? weakest; + const target = thiefInSight + ?? (finisher && finisher.damage >= weakest.life ? weakest : null) + ?? runner ?? leader ?? weakest; // DROP OBJECT shakes the treasure out of the hands that matter: // the thief of my gold, or any carrier whose delivery wins. { - const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief - : visible.find((p) => deliveryWins(view, p)); + const mark = thiefInSight ?? runner; const dob = mark ? inHand(view, "drop-object") : undefined; if (mark && dob) { return { @@ -1749,8 +1749,7 @@ export function automatonCommand( // BANK GUARD: enemy gold delivered to my home is my scoreboard, and // anyone may snatch it off the floor. A raider nearer my bank than I // am, with the bank stocked, outranks the next grab — run home. - const bankedCount = view.treasures.filter( - (t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length; + const bankedCount = bankedBy(view, self); // The predicate reads only ENEMY positions — my own steps must not // flip it mid-march or the walker shuttles between goals. const bankThreatened = bankedCount > 0 && !self.carriedTreasureId && diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 598d695..24ebfd2 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -613,9 +613,6 @@ function castSightEdge( return false; } -/** The attacks that burn: what the FAQ lets hurt a KILLER OOZE. */ -const FIRE_ATTACKS = new Set(["fireball"]); - function inThornbush(state: GameState, p: PlayerState): boolean { return state.squareContents[cellKey(p.position)]?.kind === "thornbush"; } @@ -3826,6 +3823,20 @@ export function edgeReentry(board: AssembledBoard, cur: Cell, side: Side): Cell * `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any * wall: a line leaving the maze re-enters at the opposite edge, one space * on. Without it, only the lettered openings carry a teleporter across. */ +/** + * One teleporter's step from `cur` toward `side`: the neighbouring + * square, the far mouth of a warp (one step, like any doorway), or — with + * `wrapEdges` — the square the board's opposite edge lets back in. Null + * where the maze ends. + */ +export function teleportStep(board: AssembledBoard, cur: Cell, side: Side, wrapEdges: boolean): Cell | null { + const n = neighbor(cur, side); + if (board.cells[cellKey(n)]) return n; + const w = board.warps.find((w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side); + if (w) return w.to.cell; + return wrapEdges ? edgeReentry(board, cur, side) : null; +} + export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number { if (cellKey(from) === cellKey(to)) return 0; const seen = new Map([[cellKey(from), 0]]); @@ -3835,20 +3846,8 @@ export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell const d = seen.get(cellKey(cur))!; if (d >= 8) break; // teleport range is 4; stop early for (const side of SIDES) { - let n = neighbor(cur, side); - if (!board.cells[cellKey(n)]) { - // The maze wraps for walkers; it wraps for teleporters too — - // a warp mouth is one step, same as any doorway. - const w = board.warps.find( - (w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side, - ); - if (w) n = w.to.cell; - else if (wrapEdges) { - const back = edgeReentry(board, cur, side); - if (!back) continue; - n = back; - } else continue; - } + const n = teleportStep(board, cur, side, wrapEdges); + if (!n) continue; if (seen.has(cellKey(n))) continue; seen.set(cellKey(n), d + 1); if (cellKey(n) === cellKey(to)) return d + 1; @@ -5538,26 +5537,27 @@ function doCast(prev: GameState, cmd: Extract): Comma const cell = cmd.target.cell; const content = state.squareContents[cellKey(cell)]; if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) { - const what = content.kind === "ooze" ? "ooze" : content.kind; + const what = content.kind; if (effect.sameSquare) { - const dx = cell.x - origin.x, dy = cell.y - origin.y; - const side: Side | null = dx === 1 && dy === 0 ? "E" : dx === -1 && dy === 0 ? "W" : dy === 1 && dx === 0 ? "S" : dy === -1 && dx === 0 ? "N" : null; + const side = SIDES.find((s) => cellKey(neighbor(origin, s)) === cellKey(cell)); if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") { return err(`you must stand beside the ${what} to use that`); } } else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) { return err(`no line of sight to the ${what}`); } - if (content.kind === "ooze" && !FIRE_ATTACKS.has(inHand.cardId)) { + // The FAQ lets only fire hurt a KILLER OOZE, and FIREBALL is the + // deck's fire. + if (content.kind === "ooze" && inHand.cardId !== "fireball") { return err("only fire hurts the ooze"); } + const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length); + if (dmg <= 0) return err(`that spell would not singe the ${what}`); const wandEvents: GameEvent[] = []; { const werr = spendWandCharge(state, caster, wandEvents); if (werr) return err(werr); } - const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length); - if (dmg <= 0) return err(`that spell would not singe the ${what}`); consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); if (state.turn.attackUsed) state.turn.secondAttackUsed = true; state.turn.attackUsed = true; @@ -6402,24 +6402,25 @@ function resolveStack(state: GameState, events: GameEvent[]): void { stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield"); } } + // Each counter's step is weighed once the next begins, or the loop + // ends: what it left of the blow. + let mark: CounterStep | null = null; + const weigh = () => { if (mark) { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; } }; for (const counter of stack.counters) { - trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration }); - const mark = trail[trail.length - 1]!; - const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; }; + mark = { cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration }; + trail.push(mark); if (counter.nullified) continue; if (isNumberCard(counter.card.cardId)) { // SHIELDSTONE number counter: reduce point AND duration effects. const v = numberValue(counter.card.cardId); pipe.damage = Math.max(0, pipe.damage - v); pipe.duration = Math.max(0, pipe.duration - v); - weigh(); continue; } if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") { // Fire meets water, whichever was thrown first: entirely stopped. pipe.damage = 0; pipe.fullyStopped = true; - weigh(); continue; } if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") { @@ -6429,7 +6430,6 @@ function resolveStack(state: GameState, events: GameEvent[]): void { pipe.damage = 0; pipe.duration = 0; pipe.fullyStopped = true; - weigh(); continue; } if (stack.creatureId && @@ -6442,13 +6442,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } else { pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage) } - weigh(); continue; } const ce = CARD_EFFECTS[counter.card.cardId]; if (ce && ce.kind === "counter") ce.apply(pipe); - weigh(); } + weigh(); + // A surviving teleport counter whisks the defender away before anything lands. const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell); @@ -6617,7 +6617,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { // REFLECTION's split to halve, so the curse is laid on the caster as // well, as if the defender had cast it back. const curseShared = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 24 && - !pipe.redirected && !pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive && + !pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive && stack.counters.some((c) => !c.nullified && c.card.cardId === "reflection"); if (effect?.onResolved && curseShared) { effect.onResolved({ @@ -6628,7 +6628,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { stack, }); } - if (effect?.onResolved && !pipe.redirected) { + if (effect?.onResolved) { effect.onResolved({ state, events, @@ -7384,3 +7384,18 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { impCheck(state, events, state.players[next]!.id); return { ok: true, state, events }; } + +/** Whose input the maze wants right now: a hanging window first, then + * the stack, then the discard, then the turn's holder. */ +export function actingSeat(state: GameState): PlayerId { + return ( + state.wardPending?.ownerId ?? + state.pushPending?.pusheeId ?? + state.slowDeathPending?.playerId ?? + state.stack?.waitingOn ?? + state.pendingDiscard ?? + state.chaosPending?.queue[0] ?? + state.outOfTurnWindow?.playerId ?? + state.players[state.turn.activeIndex]!.id + ); +} diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 367c34f..b00aeb3 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -20,6 +20,7 @@ import { type TreasureState, type TurnState, dreadDistance, + teleportStep, } from "./game"; export interface PlayerPublicView { @@ -367,13 +368,6 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean { return false; } -/** - * The sight line behind the attack currently on the stack — the board's - * answer to "how can he even see me?". Null when nothing should draw: - * no stack, a creature's or physical attack, a non-LOS card, attacker and - * defender sharing a square, or no sight under this viewer's knowledge - * (a believed illusion wall can honestly hide the line). - */ /** A sight line as the board draws it: the legs, the corner bend, and the * one wall a VISIONSTONE dissolved. */ export interface SightLine { @@ -429,7 +423,13 @@ export function sightTraceBetween( return null; } -/** The sight line of the attack on the stack, for the board to draw. */ +/** + * The sight line behind the attack currently on the stack — the board's + * answer to "how can he even see me?". Null when nothing should draw: + * no stack, a creature's or physical attack, a non-LOS card, attacker and + * defender sharing a square, or no sight under this viewer's knowledge + * (a believed illusion wall can honestly hide the line). + */ export function stackSightTrace(view: GameView): SightLine | null { const stack = view.stack; if (!stack || stack.creatureId) return null; @@ -453,9 +453,7 @@ export function castSightTrace( events: readonly GameEvent[], afterTheFact = false, ): SightLine | null { - let def; - try { def = cardDef(cast.cardId); } catch { return null; } - if (def.los !== true) return null; + if (cardDef(cast.cardId).los !== true) return null; const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null; if (!to) return null; const caster = view.players.find((p) => p.id === cast.caster); @@ -517,9 +515,8 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa if (cardId === "teleport") { // Up to four spaces, walls and objects ignored; not into solid stone. - // BFS over existing cells, matching the engine's wallIgnoringDistance — - // the maze wraps for teleporters as it does for walkers, a warp mouth - // being one step like any doorway. + // The maze wraps for teleporters as it does for walkers, and from + // rev 22 the outer edge is no more than a wall to them. const out = new Set(); const dist = new Map([[key(me.position.x, me.position.y), 0]]); const queue = [me.position]; @@ -528,17 +525,8 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa const d = dist.get(key(cur.x, cur.y))!; if (d >= 4) continue; for (const side of SIDES) { - let n = neighbor(cur, side); - if (!view.board.cells[key(n.x, n.y)]) { - const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side); - if (w) n = w.to.cell; - else if (view.deckRev >= 22) { - // Rev 22: the outer edge is no more than a wall to a teleporter. - const back = edgeReentry(view.board, cur, side); - if (!back) continue; - n = back; - } else continue; - } + const n = teleportStep(view.board, cur, side, view.deckRev >= 22); + if (!n) continue; const nk = key(n.x, n.y); if (dist.has(nk)) continue; dist.set(nk, d + 1); diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index f64aa34..4d4719d 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -1262,14 +1262,14 @@ describe("the common enemy and the interception", () => { it("leaves a carrier alone whose delivery would not win", () => { const state = tableOf(["bot", "foe"], 5); - const bot = state.players.find((p) => p.id === "bot")!; const foe = state.players.find((p) => p.id === "foe")!; + const forGold = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); + const forFoe = automatonCommand(viewFor(state, "bot"), "berserker", "archmage"); + expect(forGold).not.toEqual(forFoe); const t = state.treasures.find((t) => t.owner === "bot" && t.position && !t.carriedBy)!; t.position = null; t.carriedBy = "foe"; foe.carriedTreasureId = t.id; - // Nothing banked: the foe is merely a thief of my gold — the chase - // rule, not the interception, decides; with no attack in hand the - // hunter goes for gold as before. - const plain = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); - expect(plain?.type).toBe("move"); + // Nothing banked: the foe is merely a thief of my gold, and with no + // attack in hand there is no chase — the hunter's march is the gold's. + expect(automatonCommand(viewFor(state, "bot"), "hunter", "archmage")).toEqual(forGold); }); }); diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index 80136be..3979df4 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -597,7 +597,7 @@ describe("POWER DRAIN drains the number played (rev 23)", () => { expect(state.players.find((p) => p.id === me.id)!.life).toBe(18); }); - it("older games gave only what the opponent lost, and nothing from a wall", () => { + it("older games took nothing from a drained wall", () => { let state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 22 }).state); const me = activePlayer(state); const board = boardView(state); diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 84c1b54..5549404 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -755,12 +755,13 @@ describe("attacks aimed at a bush or the ooze", () => { expect(near.ok).toBe(true); if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 })); - const far = sightedCellsFor(viewFor(state, me.id)); - const farKey = [...far].find((k) => { + // Adjacency is checked before sight, so any square two or more away + // will do — seen or not. + const farKey = Object.keys(state.board.cells).find((k) => { const [x, y] = k.split(",").map(Number); return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k]; }); - if (!farKey) return; + if (!farKey) throw new Error("no square two or more away on this board"); const [fx, fy] = farKey.split(",").map(Number); state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" }; const refused = applyCommand(state, me.id, { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d06ff84..14af39b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -13,6 +13,9 @@ // {type:"myFeedback", seats} your reports + the wizards' replies // {type:"rollDie"} the tabletop D4, published as talk // {type:"addBot", style?, tier?} host seats an automaton +// {type:"challengeKeeper"} call the keeper of the site to a seat +// {type:"rematch"} a finished table asks for another +// {type:"feedbackAnswer", seat, reportId, text} a player's word under the desk's reply // {type:"watch", roomId} join the Peanut Gallery: nameless, read-only // {type:"leave"} detach this socket from table or gallery // {type:"myGames", seats} summaries for held seats @@ -27,18 +30,22 @@ // {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:"rematch", roomId, to, by} the table hears where the rematch went +// {type:"rematched", roomId} the caller's own seat at the new table // {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"} // {type:"error", message} import * as Sentry from "@sentry/node"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomBytes } from "node:crypto"; -import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs"; +import { readFileSync, existsSync, realpathSync, statSync, createReadStream, mkdirSync, writeFileSync } from "node:fs"; import { extname, join, normalize, sep } from "node:path"; import { WebSocketServer, WebSocket } from "ws"; import { cardDef } from "@wizwar/engine"; import type { Command, PlayerId } from "@wizwar/engine"; -import { callKeeper, callRematch, +import { + callKeeper, + callRematch, catchUpSteps, momentSteps, claimTransferCode, @@ -64,10 +71,11 @@ import { callKeeper, callRematch, type Room, kickSeat, abandonRoom, + KEEPER, + MAX_SEATS, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store"; -import { mkdirSync, writeFileSync } from "node:fs"; import { clipsIndexHtml, clipPageHtml } from "./clips"; import { SlidingLimit, clientAddress } from "./ratelimit"; import { getShare, loadShares, mintShare } from "./shares"; @@ -80,6 +88,14 @@ if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 }); } +/** The bell the keeper's phone answers: a Sentry issue at error level, + * because the alert rule rings for high-priority issues and a warning is + * filed as medium — a bell nobody hears. The SDK drops it when no DSN is + * set. */ +function ringBell(title: string, detail: { fingerprint: string[]; tags: Record; extra: Record }): void { + Sentry.captureMessage(title, { level: "error", ...detail }); +} + // --- 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 @@ -131,8 +147,6 @@ function receiveFeedbackImage(req: IncomingMessage, res: ServerResponse, reportI /** Calls to the keeper: a real person's phone rings for each. */ const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000); -/** The keeper of this table: the wizard a lobby may challenge. */ -const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel"; const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, ""); const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000); const MAX_COMMAND_BYTES = 16384; // serialized game command @@ -769,8 +783,7 @@ wss.on("connection", (socket, req) => { if (!room) return send(socket, { type: "error", message: "no such room" }); const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null); if ("error" in result) { - // A refused seat is worth a line: a lost seat mid-game is the - // costliest quiet failure this table has. + // Logged: a refused seat is otherwise invisible after the fact. console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)} — ${result.error}`); return send(socket, { type: "error", message: result.error }); } @@ -800,19 +813,14 @@ wss.on("connection", (socket, req) => { } const called = callKeeper(room, session.playerId, KEEPER); if ("error" in called) return send(socket, { type: "error", message: called.error }); - // The alarm the keeper listens for: one issue per room, so each - // call rings once, with the door in the message. Error level, - // because Sentry's alerts ring for high-priority issues and a - // warning is filed as medium — a bell nobody hears. + // One issue per room, so each call rings once, with the door in + // the message. const link = `${PUBLIC_URL}/join/${room.id}`; - if (process.env.SENTRY_DSN) { - Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, { - level: "error", - fingerprint: ["challenge", room.id], - tags: { room: room.id, challenger: session.playerId }, - extra: { link, players: room.players.join(", ") }, - }); - } + ringBell(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, { + fingerprint: ["challenge", room.id], + tags: { room: room.id, challenger: session.playerId }, + extra: { link, players: room.players.join(", ") }, + }); const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`); if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at })); broadcastRoomState(room); @@ -1066,15 +1074,12 @@ wss.on("connection", (socket, req) => { happened, expected: clean(msg.expected), }); - // The desk's bell: one issue per report, the gist in the title. - if (process.env.SENTRY_DSN) { - Sentry.captureMessage(`Report from ${session.playerId ?? "the gallery"} in ${room.id}: ${happened.slice(0, 100)}`, { - level: "error", - fingerprint: ["report", reportId], - tags: { room: room.id, report: reportId, player: session.playerId ?? "(gallery)" }, - extra: { happened, expected: clean(msg.expected), link: `${PUBLIC_URL}/join/${room.id}`, seq: room.log.length }, - }); - } + // One issue per report, the gist in the title. + ringBell(`Report from ${session.playerId ?? "the gallery"} in ${room.id}: ${happened.slice(0, 100)}`, { + fingerprint: ["report", reportId], + tags: { room: room.id, report: reportId, player: session.playerId ?? "(gallery)" }, + extra: { happened, expected: clean(msg.expected), link: `${PUBLIC_URL}/join/${room.id}`, seq: room.log.length }, + }); send(socket, { type: "feedbackReceived", id: reportId }); break; } @@ -1082,6 +1087,9 @@ wss.on("connection", (socket, req) => { // A player's word under the desk's reply, from their lobby: the // seat is proven as the ledger proves it, and the report must be // theirs. Each answer rings the desk's bell. + if (!reportsPerAddress.allow(session.address)) { + return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" }); + } const seat = (msg.seat ?? {}) as Record; const roomId = String(seat.roomId ?? "").toUpperCase(); const name = String(seat.name ?? ""); @@ -1092,19 +1100,13 @@ wss.on("connection", (socket, req) => { if (!report || report.player !== name || report.roomId !== roomId) return send(socket, { type: "error", message: "no such report of yours" }); const text = String(msg.text ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000); if (!text) return send(socket, { type: "error", message: "say something" }); - if (!reportsPerAddress.allow(session.address)) { - return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" }); - } const at = new Date().toISOString(); appendFeedback({ reportId, from: "player", player: name, text, at }); - if (process.env.SENTRY_DSN) { - Sentry.captureMessage(`${name} answers on report ${reportId} (${roomId}): ${text.slice(0, 100)}`, { - level: "error", - fingerprint: ["report-answer", reportId, at], - tags: { room: roomId, report: reportId, player: name }, - extra: { text, link: `${PUBLIC_URL}/join/${roomId}` }, - }); - } + ringBell(`${name} answers on report ${reportId} (${roomId}): ${text.slice(0, 100)}`, { + fingerprint: ["report-answer", reportId, at], + tags: { room: roomId, report: reportId, player: name }, + extra: { text, link: `${PUBLIC_URL}/join/${roomId}` }, + }); send(socket, { type: "feedbackReceived" }); break; } diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 3961758..48d9c09 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -5,6 +5,7 @@ import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto"; import { + actingSeat as engineActingSeat, applyCommand, automatonCommand, automatonFallback, @@ -62,6 +63,16 @@ export interface Room { const rooms = new Map(); +/** Six wizards fit at a table: the physical set's standees. */ +export const MAX_SEATS = 6; +/** The keeper of this table: the wizard a lobby may challenge. */ +export const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel"; + +/** Hold a seat for the keeper among the expected, once. */ +function expectKeeper(room: Room, keeper: PlayerId): void { + room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper]; +} + const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; /** Tokens live hashed at rest (memory and disk); clients hold the raw form. */ @@ -141,10 +152,10 @@ export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at: if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" }; if (room.challenge) return { error: `${keeper} has already been called to this table` }; if (room.players.includes(keeper)) return { error: `${keeper} is already here` }; - if (room.players.length + (room.expected?.length ?? 0) >= 6) return { error: "the table is full" }; + if (room.players.length + (room.expected?.length ?? 0) >= MAX_SEATS) return { error: "room is full" }; const at = new Date().toISOString(); room.challenge = { by: byId, at }; - room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper]; + expectKeeper(room, keeper); appendLine(room.id, { kind: "challenge", by: byId, at }); return { at }; } @@ -290,7 +301,7 @@ export function joinRoom( return { error: "that wizard name is taken in this room" }; } if (room.state) return { error: "game already started" }; - if (room.players.length >= 6) return { error: "room is full" }; + if (room.players.length >= MAX_SEATS) return { error: "room is full" }; const fresh = randomBytes(16).toString("hex"); room.players.push(playerId); room.tokens.set(playerId, hashToken(fresh)); @@ -431,7 +442,7 @@ export function addAutomaton( tierWanted?: string, ): { name: PlayerId } | { error: string } { if (room.state) return { error: "the game has started" }; - if (room.players.length >= 6) return { error: "room is full" }; + if (room.players.length >= MAX_SEATS) return { error: "room is full" }; const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n)); if (!name) return { error: "the workshop is empty" }; const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle); @@ -451,17 +462,7 @@ export function addAutomaton( /** Whose input does the maze want right now? */ function actingSeat(room: Room): PlayerId | null { const s = room.state; - if (!s || s.phase !== "playing") return null; - return ( - s.wardPending?.ownerId ?? - s.pushPending?.pusheeId ?? - s.slowDeathPending?.playerId ?? - s.stack?.waitingOn ?? - s.pendingDiscard ?? - s.chaosPending?.queue[0] ?? - s.outOfTurnWindow?.playerId ?? - s.players[s.turn.activeIndex]!.id - ); + return s && s.phase === "playing" ? engineActingSeat(s) : null; } /** @@ -827,8 +828,7 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { room.rematch = { roomId: line.to, by: line.by }; } else if (line.kind === "challenge") { room.challenge = { by: line.by, at: line.at }; - const keeper = process.env.WIZWAR_KEEPER ?? "Kestrel"; - if (!room.players.includes(keeper)) room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper]; + if (!room.players.includes(KEEPER)) expectKeeper(room, KEEPER); } else if (line.kind === "chat") { // File order preserves the interleaving with commands. room.chat.push({ player: line.player, text: line.text, at: line.at }); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index ef5ddb8..b7b3b53 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -220,8 +220,6 @@ export interface FeedbackReport { round: number | null; happened: string; expected: string; - /** The latest word from the desk, for the lobby's one-line summary. */ - reply?: { at: string; text: string; status: string }; /** The whole exchange in order: the desk's replies and the player's answers. */ thread: { at: string; text: string; from: "desk" | "player"; status?: string }[]; /** A screenshot's file name under feedback-images/, when one was sent. */ @@ -248,9 +246,7 @@ export function readFeedback(): FeedbackReport[] { } else if (report && line.from === "player") { report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), from: "player" }); } else if (report) { - const reply = { at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved") }; - report.reply = reply; - report.thread.push({ ...reply, from: "desk" }); + report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"), from: "desk" }); } continue; } diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index fe186e4..ae7cc73 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -9,7 +9,7 @@ import Faq from "./Faq.svelte"; import Card from "./Card.svelte"; import Help from "./Help.svelte"; - import type { HelpTab } from "./help-state"; + import { HELP_TABS, type HelpTab } from "./help-state"; import { summarizeMissed } from "./catchup"; import Replay from "./Replay.svelte"; import FxGallery from "./FxGallery.svelte"; @@ -299,7 +299,7 @@ const q = new URLSearchParams(location.search); const card = q.get("card"); const help = q.get("help"); - const tabs: HelpTab[] = ["play", "rules", "cards", "rulings", "about", "tally"]; + const tabs: readonly HelpTab[] = HELP_TABS; if (card) { helpCard = card; showHelp = true; } else if (help) { const [t, ...rest] = help.split("/"); @@ -1720,9 +1720,6 @@ cellKey(p.position) === cellKey(me!.position)) : [], ); - /** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */ - // While an LOS attack sits on the stack, draw the line it traveled — the - // answer to "how can he even see me?" when sight ran through a warp mouth. /** The attack on the stack draws its sight line; a cast that resolved * at once (a creation, a curse) draws the line it was accepted on for a * few seconds, so the table can see how the aim was legal. */ @@ -1839,6 +1836,26 @@ } +{#snippet sayBox(extra: string, withDie: boolean)} +
{ + e.preventDefault(); + const t = chatDraft.trim(); + if (!t) return; + if (net.sendChat(t)) chatDraft = ""; + else net.flash("Not sent — the table is out of reach; your words wait in the box"); + }}> + {#if net.chatPending}sending…{/if} + + + {#if withDie} + + {/if} +
+{/snippet} + + {#if fpvWorkshop || tokenWorkshop || fxWorkshop} @@ -2581,23 +2598,24 @@ {#if net.feedbackReports.length > 0}
your reports to the wizards
{#each net.feedbackReports as r (r.id)} + {@const thread = r.thread ?? []}
{r.roomId}
“{r.happened}”{#if r.image} 📷{/if}
- {#each r.thread ?? [] as line, j (j)} + {#each thread as line, j (j)} {#if line.from === "player"}
you — {line.text}
{:else}
{line.status} — {line.text}
{/if} {/each} - {#if !(r.thread ?? []).length} + {#if !thread.length}
the wizards are studying the moment…
- {:else if (r.thread ?? [])[(r.thread ?? []).length - 1]!.from === "player"} + {:else if thread[thread.length - 1]!.from === "player"}
your word is with the wizards…
{/if} - {#if (r.thread ?? []).some((l) => l.from === "desk")} + {#if thread.some((l) => l.from === "desk")}
{ e.preventDefault(); const t = (reportAnswers[r.id] ?? "").trim(); @@ -2715,7 +2733,7 @@ the link itself, for pasting by hand {inviteLink} - {#if !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6} + {#if net.keeper && !net.challenge && !net.players.includes(net.keeper) && net.players.length + net.expected.length < 6}
- + {@render sayBox("lobby-say", false)}
{#if net.you === net.hostId && addingBot && net.players.length < 6}
@@ -3283,6 +3292,7 @@
life-points
{#each view.players as p (p.id)} + {@const home = treasuresHomeOf(p)}
@@ -3291,7 +3301,7 @@ {p.life} - {p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if treasuresHomeOf(p) > 0} · 🏆 {treasuresHomeOf(p)} home{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`} · + {p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if home > 0} · 🏆 {home} home{/if}{#if p.carriedTreasureId}{@const ct = view.treasures.find((t) => t.id === p.carriedTreasureId)}{@const chestSrc = `/tokens-svg/treasure-${colorIndexOf(view, ct?.owner ?? p.id) % 6}.svg`} · {/if} @@ -3420,20 +3430,7 @@
{/if} {#if !local.active && net.roomId && !net.spectating} -
{ - e.preventDefault(); - const t = chatDraft.trim(); - if (!t) return; - if (net.sendChat(t)) chatDraft = ""; - else net.flash("Not sent — the table is out of reach; your words wait in the box"); - }}> - {#if net.chatPending}sending…{/if} - - - -
+ {@render sayBox("", true)} {/if} @@ -3531,7 +3528,7 @@ {#if net.pending} - {Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}… + sending {net.pending.label}… {:else if net.confirmed} ✓ {net.confirmed} confirmed {/if} @@ -3845,9 +3842,9 @@ background: #f4eede; cursor: pointer; overflow: hidden; + position: relative; } .standee img { width: 100%; height: 100%; object-fit: cover; display: block; } - .standee { position: relative; } .standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); } .standee.current::after { content: "✓"; @@ -3945,7 +3942,7 @@ color: #3a2f1f; margin: 0.2rem 0 0.1rem; } - /* The eyes toggle floats over the board's corner so it costs the maze no height. */ + /* The board's toolbar: the eyes toggle and the zoom, one short row. */ .board-tools { display: flex; justify-content: space-between; @@ -4380,7 +4377,8 @@ .fpv-primary .board-viewport { flex: 0 1 30dvh; min-height: 5rem; } .fpv-primary .board-frame { min-height: 0; } /* The pane keeps its aspect but never more height than the row can - spare above the board strip and the dock. */ + spare above the board strip and the dock: the strip's 5rem floor, + the dock, the hand, and the toolbar come to 34.5rem. */ .fpv-primary :global(.live-fp) { flex: 0 1 auto; min-height: 0; } .fpv-primary :global(.fpv-canvas) { width: auto; max-width: 100%; max-height: calc(100dvh - 34.5rem); margin: 0 auto; } .game:not(.hand-left) .table-edge { margin-top: 0.5rem; } diff --git a/packages/web/src/Help.svelte b/packages/web/src/Help.svelte index ea97ca0..8bd11e1 100644 --- a/packages/web/src/Help.svelte +++ b/packages/web/src/Help.svelte @@ -10,7 +10,7 @@ import { RULEBOOK_COPYRIGHT } from "./rulebook"; import { helpState, type HelpTab } from "./help-state"; import { - REFERENCE, SOURCE_LABELS, searchReference, renderInline, + REFERENCE, SOURCE_LABELS, searchReference, renderInline, type RefSection, CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel, HOUSE_RULINGS, rulingsFor, searchRulings, type RefSource, @@ -129,6 +129,16 @@ }); +{#snippet section(s: RefSection, withSource: boolean)} +

+ {s.title} + {#if withSource}{SOURCE_LABELS[s.source]}{/if} + +

+ {#each s.paragraphs as p, i (i)}

{@html renderInline(p)}

{/each} +{/snippet} + {#if entryDef} @@ -277,7 +287,7 @@ The first-person workshop - the maze through a wizard's own eyes — or watch the demo reel + the maze through a wizard's own eyes

Watch the demo: two clockwork wizards play a stretch, then the reel replays it.

@@ -358,13 +368,7 @@ {#if rulesSearch.trim()}

{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}

{#each refResults as s (s.id)} -

- {s.title} - {SOURCE_LABELS[s.source]} - -

- {#each s.paragraphs as p, i (i)}

{@html renderInline(p)}

{/each} + {@render section(s, true)} {/each} {:else}