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}