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)}
+
+{/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"}