Compare commits

..
10 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 c79fdb4adb The collection is Kestrel's Hall
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 19:51:06 -04:00
Eric WagonerandClaude Fable 5.1 9d087ccaa1 The front page and the about page point to the other games of the Kestrel's Nest
The list is the kit's family.json, shipped as /family.json and refreshed
at deploy when the kit is beside this checkout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 12:26:49 -04:00
Eric WagonerandClaude Fable 5.1 13a6fc2603 The host may let the Peanut Gallery talk
A gallery line wears the eye and a name that no wizard at the table
holds; the host opens or hushes it from the lobby or the mast, on the
ledger, and the gallery is reined per address.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 09:47:26 -04:00
Eric WagonerandClaude Fable 5.1 f68b3cef06 The desk's replies no longer ring the bell: reports and players' answers do, the desk's own mail does not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 08:54:08 -04:00
Eric WagonerandClaude Fable 5.1 35d805fda3 The lobby tells a caller what hour it is where the keeper lives, and that other games can go on in another tab
WIZWAR_KEEPER_TZ names the zone (Eastern by default); the room carries it
and the client words the hour by the minute.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 08:49:37 -04:00
Eric WagonerandClaude Fable 5.1 31bfcf7bc0 Rev 26: BUTT-HEAD's ram is priced into the blow, so STRENGTH, AMPLIFY, and the counters weigh it (KWUZ)
Ramses cast STRENGTH, then an amplified BUTT-HEAD three squares off, and
the goat dealt three: the ram was applied in onResolved, past the pipe
that doubles, soaks, and adds. Older games keep the bare distance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 08:16:30 -04:00
Eric WagonerandClaude Fable 5.1 5193b12b8e Rev 25: a bush or the ooze is attacked like a wall — POWER ATTACK counts, thrown weapons land at its foot
Older games took neither; both sides pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-22 12:19:15 -04:00
Eric WagonerandClaude Fable 5.1 66b5c06f17 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-22 09:56:49 -04:00
Eric WagonerandClaude Fable 5.1 c4a555f8df Fuller banter pools, and the blow's own names decide who dealt pain and who killed
Damage lands on the defender's step, so a trigger keyed to the actor
never credited the attacker: across thirty three-bot games the kill line
fired zero times and the dealt-pain line five, against 328 takes. Both
now read the attackResolved, punched, and died events themselves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-22 09:39:05 -04:00
Eric WagonerandClaude Fable 5.1 be96dd6047 The archmage cuts off a winning delivery and aims at the leader when no kill is on the table
Reuben's three wins today were walked home unseen: the clockwork
fought whoever stood in front of it. Now a delivery that would end the
game pulls every automaton toward that wizard's home (where the banked
gold is lifted off the doorstep) or the road to it, and among visible
targets the thief still dies first, a kill is still taken, and only then
does the aim shift to whoever has more treasures banked. Measured with
deploy/brain-trial.mjs, which seats the working tree's brain against an
older checkout's at one table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-22 09:11:34 -04:00
28 changed files with 1059 additions and 286 deletions

No files matched your search

+3 -2
View File
@@ -24,8 +24,9 @@ One JSONL line per entry:
word back, sent from their lobby under the desk's reply.
A report is ANSWERED when the LAST line under it is the desk's; a
player's answer reopens it. Never re-answer a settled one unless Eric
asks for a revision. Every report, answer, and desk reply rings a
Sentry issue on Eric's phone (fingerprinted per report or per line).
asks for a revision. Every report and every player's answer rings a
Sentry issue on Eric's phone (fingerprinted per report or per line);
the desk's own replies ring nothing — Eric watches them go out here.
- Picture: `{reportId, image: "<id>.png", at}` — the player attached a
screenshot; the file is `/var/lib/wizwar/feedback-images/<id>.<ext>`.
+70
View File
@@ -0,0 +1,70 @@
// Two brains at one table: the working tree's automaton against an older
// checkout's, over seeded games, to measure a brain change before it
// ships. The older engine only chooses; the working tree's engine rules
// the table, so both play the same game under the same law.
//
// npx tsx deploy/brain-trial.mjs <old-checkout> [seeds=30] [--table new,old,old,old] [--style hunter] [--basic]
//
// Make the old checkout with `git worktree add <dir> <commit>`. Each seed
// is played once per rotation of the table so seat order cancels out.
import { actingSeat, applyCommand, automatonFallback, createGame, viewFor } from "../packages/engine/src/index.ts";
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
const args = process.argv.slice(2);
const oldDir = args[0];
if (!oldDir || oldDir.startsWith("--")) { console.error("first argument: the old checkout's directory"); process.exit(2); }
const seeds = Number(args.find((a, i) => i > 0 && /^\d+$/.test(a)) ?? 30);
const table = (args.includes("--table") ? args[args.indexOf("--table") + 1] : "new,old").split(",");
const style = args.includes("--style") ? args[args.indexOf("--style") + 1] : "hunter";
const sets = args.includes("--basic") ? ["basic"] : ["basic", "expansion1"];
const old = await import(pathToFileURL(resolve(oldDir, "packages/engine/src/index.ts")).href);
const brains = {
new: (await import("../packages/engine/src/index.ts")).automatonCommand,
old: old.automatonCommand,
};
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]]]));
let { state } = createGame({ playerIds: ids, seed, sets });
let commands = 0, stuck = 0;
while (state.phase === "playing" && commands < 6000) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const chosen = brainOf[seat](view, style, "archmage");
let r = applyCommand(state, seat, chosen ?? automatonFallback(view, "archmage"));
if (!r.ok) {
if (chosen) r = applyCommand(state, seat, automatonFallback(view, "archmage"));
if (!r.ok) {
if (++stuck > 3) throw new Error(`stuck at ${seat} after ${commands}: ${r.error}`);
r = applyCommand(state, seat, { type: "endTurn", draw: 0 });
if (!r.ok) r = applyCommand(state, seat, { type: "pass" });
if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`);
}
} else stuck = 0;
state = r.state;
commands++;
}
return state;
}
const wins = { new: 0, old: 0, none: 0 };
const reasons = {};
const t0 = Date.now();
let games = 0;
for (let seed = 1; seed <= seeds; seed++) {
for (let rot = 0; rot < table.length; rot++) {
const order = [...table.slice(rot), ...table.slice(0, rot)];
const state = playOut(seed, order);
games++;
const w = state.winner;
if (!w) wins.none++;
else { wins[w.replace(/\d+$/, "")]++; reasons[state.winReason] = (reasons[state.winReason] ?? 0) + 1; }
}
}
const share = (b) => table.filter((x) => x === b).length / table.length;
console.log(`${games} games (${table.join(" vs ")}, ${style}, ${sets.length === 1 ? "basic" : "expansion"}) in ${((Date.now() - t0) / 1000).toFixed(0)}s`);
console.log(`new ${wins.new} (${(100 * wins.new / games).toFixed(0)}%, seats ${(100 * share("new")).toFixed(0)}%) old ${wins.old} (${(100 * wins.old / games).toFixed(0)}%, seats ${(100 * share("old")).toFixed(0)}%) unfinished ${wins.none}`);
console.log(`won by ${JSON.stringify(reasons)}`);
+4
View File
@@ -3,6 +3,10 @@
set -euo pipefail
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
# The family list is the kit's; the site deploys with the kit's latest copy when the kit is beside this checkout.
KIT_FAMILY="$(dirname "$0")/../../game-kit/family.json"
[ -f "$KIT_FAMILY" ] && cp "$KIT_FAMILY" packages/web/public/family.json
npm run build --workspace=@wizwar/web
# Old hashed chunks stay a week: a page loaded before this deploy can still
# fetch the module it was built against instead of failing mid-game.
-17
View File
@@ -17,20 +17,3 @@ console.log(JSON.stringify({
text: process.env.TEXT,
}));')
printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl'
# The desk's bell rings for the reply too, so Eric sees the exchange move:
# an event straight to Sentry from the droplet, with the DSN the service holds.
ssh "root@$HOST" "REPORT_ID='$REPORT_ID' STATUS='$STATUS' TEXT=\"\$(cat)\" python3 - <<'PY'
import json, os, re, uuid, urllib.request, datetime
svc = open('/etc/systemd/system/wizwar.service').read()
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()],
'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}'})
try: urllib.request.urlopen(req, timeout=10); print('bell rung')
except Exception as e: print('bell failed:', e)
PY" <<< "$TEXT"
+4 -4
View File
@@ -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/"
+1
View File
@@ -11,6 +11,7 @@ Environment=PORT=8787
Environment=HOST=127.0.0.1
Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms
Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist
Environment=WIZWAR_KEEPER_TZ=America/New_York
Environment=SENTRY_DSN=https://a47ea012dff08b52dd230a95ccbe7414@o4509525984149504.ingest.us.sentry.io/4512011462049793
ExecStart=/opt/wizwar/node_modules/.bin/tsx src/index.ts
Restart=always
+80 -12
View File
@@ -730,6 +730,52 @@ function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTrea
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
}
/** 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 && cellKey(t.position) === cellKey(p.home)).length;
}
/** The enemy whose delivery in progress would end the game, if any. */
function winningCarrier(view: GameView) {
return livingEnemies(view).find((p) => deliveryWins(view, p)) ?? null;
}
/**
* Where to cut off a carrier. Their home square first, when the clockwork
* can get there no later than they can: it is the one square they must
* reach, and the gold already banked there is free for the taking, which
* unmakes the win outright. Otherwise the nearest square on their
* shortest road home that the clockwork reaches before they do; failing
* that, the home square anyway. Ties break toward their home, so the goal
* holds still as the clockwork walks. The carrier's road is measured as
* if every door opened for them.
*/
export function interceptCell(
view: GameView, carrier: { position: Cell; home: Cell }, from: Cell, canUnlock: boolean,
): Cell {
const fromHome = distancesFrom(view, [carrier.home], true);
const fromCarrier = distancesFrom(view, [carrier.position], true);
const fromMe = distancesFrom(view, [from], canUnlock);
const road = fromCarrier.get(cellKey(carrier.home));
if (road == null) return carrier.home;
const mineToHome = fromMe.get(cellKey(carrier.home));
if (mineToHome != null && mineToHome <= road) return carrier.home;
let best: { cell: Cell; mine: number; toHome: number } | null = null;
for (const k of Object.keys(view.board.cells)) {
const theirs = fromCarrier.get(k);
const toHome = fromHome.get(k);
const mine = fromMe.get(k);
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) as [number, number];
best = { cell: { x, y }, mine, toHome };
}
}
return best?.cell ?? carrier.home;
}
/** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) {
const carriers = new Set(
@@ -1455,14 +1501,27 @@ export function automatonCommand(
.filter((p) => sighted.has(cellKey(p.position)) &&
!shieldedFromMe(p.id) && !myPact(p.id) && !bushOrMist(view, p.id));
if (visible.length > 0) {
const target = thief && visible.some((p) => p.id === thief.id)
? thief
: visible.sort((a, b) => a.life - b.life)[0]!;
// The thief of my gold dies first, and a kill on the table is
// taken. Otherwise the aim shifts to whoever is nearest to winning:
// a delivery in progress that would end the game, then a wizard
// with more treasures banked than I have. The clockwork still
// plays its own game — it hunts the leader only when the weakest
// 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 = 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 {
@@ -1690,23 +1749,32 @@ 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 &&
livingEnemies(view).some((p) =>
!p.carriedTreasureId &&
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
// INTERCEPTION: a delivery that would end the game is every
// clockwork's business, whatever its temperament — the road to that
// wizard's home is the only march that matters, and the post, once
// reached, is held (the strike above fires the moment they walk into
// sight). My own delivery and my own stolen gold still come first.
const runner = !self.carriedTreasureId && !chasing && !cursedIdiot ? winningCarrier(view) : null;
const cutoff = runner ? interceptCell(view, runner, self.position, canUnlock) : null;
if (cutoff && cellKey(cutoff) === here) return endTurnDrawing(view, tier, style);
const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells
: chasing
? new Set([cellKey(thief.position)])
: bankThreatened
? new Set([cellKey(self.home)])
: style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
: cutoff
? new Set([cellKey(cutoff)])
: bankThreatened
? new Set([cellKey(self.home)])
: style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
const path =
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ??
+83 -35
View File
@@ -247,7 +247,7 @@ export interface CastParams {
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 24;
export const CURRENT_RULES_REV = 26;
/** Every rulings revision since the baseline, newest last — the entries a
* game's deckRev freezes it before or after. Shown to players as the house
@@ -276,6 +276,8 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 22, note: "TELEPORT ignores the maze's outer edge as it ignores any wall: a teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on. Older games crossed the edge only at the lettered openings." },
{ rev: 23, note: "POWER DRAIN drains the number played: the caster gains it whether the blow is BLUNTed or ABSORBed (FAQ: the counter blunts the damage done, not the drain), and a wall drained for its points gives them up too. Older games gave the caster only what the opponent lost, and nothing from a wall." },
{ rev: 24, note: "A REFLECTION against a permanent curse afflicts both wizards: WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties. Older games left the caster untouched." },
{ rev: 25, note: "An attack on a bush or the ooze is an attack on an inanimate object like any wall: POWER ATTACK's bought points count, and a thrown DAGGER or LARGE ROCK lands at the foot of the bush instead of vanishing into the discard. Older games took neither." },
{ rev: 26, note: "BUTT-HEAD's ram is physical damage like any other: STRENGTH doubles it, AMPLIFY doubles it, WEAKNESS doubles it, POWER ATTACK adds to it, and ABSORB or BLUNT soak it. Older games dealt the bare distance, whatever rode the cast." },
];
export interface GameConfig {
@@ -613,9 +615,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";
}
@@ -2454,7 +2453,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
ctx.state.turn.movementUsed += d;
ctx.attacker.position = { ...ctx.defender.position };
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
resolvedBlow(ctx, d, "goat ram", "physical");
// Rev 26: the ram was priced into the blow itself (ramDistance),
// so STRENGTH, AMPLIFY, and the counters have already weighed it.
// Older games dealt the bare distance here, past all of them.
if ((ctx.state.config.deckRev ?? 1) < 26) resolvedBlow(ctx, d, "goat ram", "physical");
return;
}
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
@@ -3826,6 +3828,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<string, number>([[cellKey(from), 0]]);
@@ -3835,20 +3851,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 +5542,36 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): 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");
}
// Rev 25: an inanimate object like any wall — POWER ATTACK's bought
// points count, and thrown weapons land at its foot.
const likeAWall = (state.config.deckRev ?? 1) >= 25;
const dmg =
effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length) +
(likeAWall ? mods.powerAttackPoints : 0);
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}`);
if (likeAWall && mods.powerAttackPoints > 0) {
caster.life -= mods.powerAttackPoints;
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
@@ -5573,6 +5587,14 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
delete state.squareContents[cellKey(cell)];
events.push({ type: "squareContentDestroyed", attacker: caster.id, cell: { ...cell }, kind: content.kind });
}
if (likeAWall && (inHand.cardId === "dagger" || inHand.cardId === "large-rock")) {
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
if (di !== -1) {
const [card] = state.discard.splice(di, 1);
state.groundObjects[cellKey(cell)] = [...(state.groundObjects[cellKey(cell)] ?? []), card!];
events.push({ type: "objectThrown", attacker: caster.id, cardId: inHand.cardId, landedAt: cell });
}
}
return { ok: true, state, events };
}
}
@@ -6017,6 +6039,14 @@ function checkAmbushes(
/** Damage dealt inside an attack's own resolution hook still honors
* EMPATHY — "Any attack done in ANY form against you acts against both you
* and the caster". */
/** How far the goat charges: the corridor distance to its mark within
* this turn's legs, or zero when the mark is underfoot or out of reach. */
function ramDistance(state: GameState, attacker: PlayerState, defender: PlayerState): number {
const legs = state.turn.movementAllowance - state.turn.movementUsed;
const d = walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs));
return d > legs ? 0 : d;
}
function resolvedBlow(
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
): void {
@@ -6353,6 +6383,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId));
base = (stack.numberValue ?? 1) * inPlay.length;
}
// Rev 26: BUTT-HEAD's ram is priced here, as the physical blow it is,
// so everything below weighs it like any other.
if (attackId === "butt-head" && (state.config.deckRev ?? 1) >= 26) base = ramDistance(state, attacker, defender);
base *= stack.amplifyFactor;
base += stack.powerAttackPoints;
// SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper.
@@ -6402,24 +6435,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 +6463,6 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
pipe.damage = 0;
pipe.duration = 0;
pipe.fullyStopped = true;
weigh();
continue;
}
if (stack.creatureId &&
@@ -6442,13 +6475,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 +6650,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 +6661,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
stack,
});
}
if (effect?.onResolved && !pipe.redirected) {
if (effect?.onResolved) {
effect.onResolved({
state,
events,
@@ -7384,3 +7417,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
);
}
+13 -25
View File
@@ -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<string>();
const dist = new Map<string, number>([[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);
+83 -1
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game";
import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, drawUnderSlowDeath, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { automatonCommand, automatonFallback, drawUnderSlowDeath, interceptCell, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
@@ -1191,3 +1191,85 @@ describe("the clockwork under a curse, and before a pit", () => {
throw new Error("no straight three-square run on this board");
});
});
describe("the common enemy and the interception", () => {
/** Past the opening round, at the bot's turn, every hand emptied. */
function tableOf(players: PlayerId[], seed = 7): GameState {
let { state } = createGame({ playerIds: players, seed, sets: ["basic", "expansion1"] });
for (let i = 0; i < players.length; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
for (const p of state.players) p.hand = [];
return state;
}
const bank = (state: GameState, owner: PlayerId, at: PlayerId) => {
const t = state.treasures.find((t) => t.owner === owner && t.position && !t.carriedBy)!;
t.position = { ...state.players.find((p) => p.id === at)!.home };
};
it("aims at the wizard with more banked than itself when no kill is on the table", () => {
const state = tableOf(["bot", "weak", "leader"]);
const bot = state.players.find((p) => p.id === "bot")!;
for (const id of ["weak", "leader"]) state.players.find((p) => p.id === id)!.position = { ...bot.position };
state.players.find((p) => p.id === "weak")!.life = 10;
state.players.find((p) => p.id === "leader")!.life = 12;
bank(state, "weak", "leader");
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "fireball#T", target: { kind: "player", playerId: "leader" } });
});
it("still takes a kill over the leader", () => {
const state = tableOf(["bot", "weak", "leader"]);
const bot = state.players.find((p) => p.id === "bot")!;
for (const id of ["weak", "leader"]) state.players.find((p) => p.id === id)!.position = { ...bot.position };
state.players.find((p) => p.id === "weak")!.life = 5;
state.players.find((p) => p.id === "leader")!.life = 12;
bank(state, "weak", "leader");
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", target: { kind: "player", playerId: "weak" } });
});
it("cuts off a delivery that would win: their home when it can get there first, else their road", () => {
const state = tableOf(["bot", "foe"], 5);
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
// The foe has one banked and carries the second, far from home.
bank(state, "bot", "foe");
const second = state.treasures.find((t) => t.owner === "bot" && t.position && !t.carriedBy &&
cellKey(t.position) !== cellKey(foe.home))!;
second.position = null; second.carriedBy = "foe"; foe.carriedTreasureId = second.id;
foe.position = { ...bot.home };
bot.position = { x: foe.home.x, y: foe.home.y === 0 ? 1 : foe.home.y - 1 };
const view = viewFor(state, "bot");
const cut = interceptCell(view, foe, bot.position, false);
expect(cellKey(cut)).toBe(cellKey(foe.home));
// The march heads there — and, arrived, lifts the banked gold off
// their doorstep, which unmakes the win outright.
const step = pathToward(view, bot.position, new Set([cellKey(cut)]), {});
expect(automatonCommand(view, "hunter", "archmage")).toEqual({ type: "move", direction: step!.dir });
bot.position = { ...foe.home };
expect(automatonCommand(viewFor(state, "bot"), "hunter", "archmage")).toMatchObject({ type: "pickUpTreasure" });
});
it("leaves a carrier alone whose delivery would not win", () => {
const state = tableOf(["bot", "foe"], 5);
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, 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);
});
});
@@ -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);
@@ -895,3 +895,47 @@ describe("cards that keep their whole promise (rev 21)", () => {
}
});
});
describe("the goat's ram is a physical blow like any other (rev 26)", () => {
/** A goat and its mark a charge apart, the goat holding STRENGTH, AMPLIFY, and the ram. */
function charged(deckRev?: number) {
let { state } = createGame({ playerIds: ["goat", "mark"], seed: 42, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) });
state = toRound2(state);
const goat = activePlayer(state);
const mark = state.players.find((p) => p.id !== goat.id)!;
const bh = giveCard(state, goat.id, "butt-head");
giveCard(state, goat.id, "amplify", "A", 1);
giveCard(state, goat.id, "strength", "S", 2);
giveCard(state, goat.id, "number-2", "N", 3);
state = must(state, goat.id, { type: "cast", instanceId: "strength#S", numberInstanceIds: ["number-2#N"] });
// Any square the charge reaches at two or more steps will do.
for (const key of Object.keys(boardView(state).cells)) {
if (key === cellKey(goat.position)) continue;
const [x, y] = key.split(",").map(Number) as [number, number];
const trial = structuredClone(state);
trial.players.find((p) => p.id === mark.id)!.position = { x, y };
const r = applyCommand(trial, goat.id, {
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: mark.id }, amplifyInstanceIds: ["amplify#A"],
});
if (!r.ok) continue;
const done = applyCommand(r.state, mark.id, { type: "pass" });
if (!done.ok) continue;
const ram = done.events.find((e) => e.type === "rammed");
if (!ram || ram.type !== "rammed" || ram.distance < 2) continue;
return { events: done.events, distance: ram.distance, state: done.state, goat: goat.id, mark: mark.id };
}
throw new Error("no square a charge of two or more reaches on this board");
}
it("STRENGTH and AMPLIFY each double the ram", () => {
const { events, distance, state, mark } = charged();
expect(events).toContainEqual(expect.objectContaining({ type: "damaged", player: mark, amount: distance * 4 }));
expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance * 4);
});
it("older games dealt the bare distance, whatever rode the cast", () => {
const { events, distance, state, mark } = charged(25);
expect(events).toContainEqual(expect.objectContaining({ type: "damaged", player: mark, amount: distance }));
expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance);
});
});
+36 -4
View File
@@ -727,11 +727,42 @@ describe("attacks aimed at a bush or the ooze", () => {
expect(r.state.turn.attackUsed).toBe(true);
});
it("a thrown dagger scratches a rosebush, and the scratch stays", () => {
it("a thrown dagger scratches a rosebush, the scratch stays, and the dagger lies at its foot", () => {
const { state, me, cell } = withContent("rosebush");
const dagger = giveCard(state, me.id, "dagger");
const next = must(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 3 }));
expect(next.groundObjects[cellKey(cell)]).toEqual([expect.objectContaining({ cardId: "dagger" })]);
expect(next.discard.some((c) => c.instanceId === dagger.instanceId)).toBe(false);
});
it("POWER ATTACK's bought points count against a bush, at the caster's cost", () => {
const { state, me, cell } = withContent("thornbush");
const rock = giveCard(state, me.id, "large-rock");
giveCard(state, me.id, "power-attack", "PA", 1);
const next = must(state, me.id, {
type: "cast", instanceId: rock.instanceId, target: { kind: "cell", cell },
powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 2,
});
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 4 }));
expect(next.players.find((p) => p.id === me.id)!.life).toBe(13);
});
it("older games let a thrown weapon vanish and POWER ATTACK count for nothing against a bush", () => {
const state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }).state);
const me = activePlayer(state);
const { cell } = emptyNeighborCell(state, me.position);
state.squareContents[cellKey(cell)] = { kind: "rosebush", damage: 0, createdBy: "bob" };
const rock = giveCard(state, me.id, "large-rock");
giveCard(state, me.id, "power-attack", "PA", 1);
const next = must(state, me.id, {
type: "cast", instanceId: rock.instanceId, target: { kind: "cell", cell },
powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 2,
});
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 2 }));
expect(next.players.find((p) => p.id === me.id)!.life).toBe(15);
expect(next.groundObjects[cellKey(cell)]).toBeUndefined();
expect(next.discard.some((c) => c.instanceId === rock.instanceId)).toBe(true);
});
it("only fire hurts the ooze", () => {
@@ -755,12 +786,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, {
+237 -17
View File
@@ -12,6 +12,11 @@ export type BanterTrigger =
| "grabGold" | "deliverGold" | "dealPain" | "takePain" | "kill" | "die"
| "summon" | "buildWall" | "escape" | "springTrap" | "dodge" | "win";
// Voice guide: Hunter speaks in clipped businesslike capitals; Berserker
// delights in a good fight, also in capitals; Worrier frets in lowercase,
// with flashes of practical courage. Lines must fit every event in their
// trigger: deliveries need not score, summons vary, and either win counts.
export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, string[]>>> = {
hunter: {
grabGold: [
@@ -19,101 +24,316 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"YOUR GOLD HAS BEEN REALLOCATED.",
"ASSET SECURED.",
"APPRAISAL: ADEQUATE. TAKING IT ANYWAY.",
"CHAIN OF CUSTODY: ME.",
"AN EXCELLENT ADDITION TO MY PORTFOLIO.",
"POSSESSION SIMPLIFIES THE PAPERWORK.",
"THIS WILL TRAVEL AS HAND LUGGAGE.",
],
deliverGold: [
"DELIVERY CONFIRMED. PLEASURE DOING BUSINESS.",
"LOGISTICS IS MY LOVE LANGUAGE.",
"RECEIPT AVAILABLE UPON REQUEST.",
"THE COURIER WOULD APPRECIATE A QUIET MOMENT.",
"NO DELIVERY SURCHARGE. THIS TIME.",
"PLEASE ADMIRE THE EFFICIENT DISTRIBUTION NETWORK.",
"ANOTHER SATISFIED ME.",
"A GOOD PLACE TO PUT SOMETHING VALUABLE.",
],
dealPain: [
"AN INEFFICIENT USE OF YOUR TURN.",
"COSTS HAVE BEEN PASSED TO THE CONSUMER.",
"THIS IS BILLABLE.",
"A SMALL HANDLING CHARGE.",
"PLEASE DIRECT COMPLAINTS TO THE FIREBALL DEPARTMENT.",
"NEGOTIATIONS HAVE BECOME EXPENSIVE.",
"I PREFER GOLD. THIS WILL DO.",
"CONSIDER THAT AN ITEMIZED OBJECTION.",
],
takePain: [
"DAMAGE NOTED. INVOICE PENDING.",
"THAT COMES OUT OF YOUR ESTATE.",
"I DID NOT APPROVE THIS EXPENDITURE.",
"THAT WAS NOT IN THE QUOTATION.",
"MY ROBE IS NOT A DEPRECIATING ASSET.",
"I SHALL BE REVIEWING OUR ARRANGEMENT.",
"THE PROFIT MARGIN NARROWS.",
"CUSTOMER RELATIONS WILL HEAR OF THIS.",
],
kill: [
"INVENTORY TRANSFERRED. CONDOLENCES.",
"ACCOUNT CLOSED.",
"ONE FEWER BIDDER.",
"YOUR CUSTOM WAS BRIEF BUT MEMORABLE.",
"COMPETITION HAS BEEN REDUCED.",
"A MOMENT OF SILENCE. THEN THE AUDIT.",
],
die: [
"FILING FOR BANKRUPTCY.",
"THE MARKET HAS CORRECTED.",
"PLEASE FORWARD MY MAIL TO THE ESTATE.",
"I OBJECT TO THE CLOSING FIGURES.",
"THIS WAS SUPPOSED TO BE A GROWTH OPPORTUNITY.",
"MY EXECUTOR WILL BE IN TOUCH.",
],
summon: [
"A SUBCONTRACTOR HAS BEEN RETAINED.",
"WELCOME TO THE FIRM. MIND THE WALLS.",
"ADDITIONAL STAFF. SAME OVERHEAD.",
"THE CONTRACT SPECIFIES MAZE WORK.",
"A PROMISING APPLICANT. REFERENCES WAIVED.",
"PLEASE KEEP PERSONAL FEUDS ON COMPANY TIME.",
],
buildWall: [
"ZONING APPROVED.",
"PROPERTY DEVELOPMENT.",
"ACCESS IS NOW A NEGOTIABLE BENEFIT.",
"THIS CORRIDOR HAS BEEN RESTRUCTURED.",
"AN INVESTMENT IN SEPARATION.",
"ALL COMPLAINTS MUST GO THE LONG WAY ROUND.",
],
escape: [
"RELOCATION EXPENSES: JUSTIFIED.",
"I SHALL CONDUCT BUSINESS FROM ELSEWHERE.",
"NEW PREMISES. SAME AMBITIONS.",
"PLEASE UPDATE YOUR ADDRESS BOOK.",
"COMMUTING IS FOR PEOPLE WITHOUT SPELLS.",
"A CHANGE OF ADDRESS, NOT OF OWNERSHIP.",
],
springTrap: [
"AN UNFORESEEN LIABILITY.",
"I SHOULD HAVE READ THE SMALL PRINT.",
"THIS DECK HAS HIDDEN FEES.",
"AN UNWELCOME ENCLOSURE.",
"WHO SIGNED OFF ON THIS CARD?",
"THAT CLAUSE WAS NOT DISCLOSED.",
],
dodge: [
"PROJECTIONS SAID YOU WOULD MISS.",
"DELIVERY REFUSED.",
"YOUR ESTIMATE WAS OPTIMISTIC.",
"NO CHARGE RECEIVED.",
"A WELCOME ROUNDING ERROR.",
"YOU MAY WISH TO REVISE YOUR PROJECTION.",
],
win: [
"PLEASURE DOING BUSINESS WITH ALL OF YOU.",
"THE BOOKS BALANCE. IN MY FAVOR.",
"A SATISFACTORY RETURN ON WIZARDRY.",
"THANK YOU FOR YOUR CONTRIBUTIONS.",
"I LOOK FORWARD TO OUR NEXT TRANSACTION.",
"THE FORECAST WAS GOLDEN.",
],
summon: ["A SUBCONTRACTOR HAS BEEN RETAINED."],
buildWall: ["ZONING APPROVED."],
escape: ["RELOCATION EXPENSES: JUSTIFIED."],
springTrap: ["AN UNFORESEEN LIABILITY."],
dodge: ["PROJECTIONS SAID YOU WOULD MISS."],
win: ["PLEASURE DOING BUSINESS WITH ALL OF YOU."],
},
berserker: {
grabGold: [
"GOLD IS MERELY BAIT.",
"SHINY. IRRELEVANT.",
"COME AND ARGUE ABOUT IT.",
"HEAVY. I LIKE HEAVY.",
"DOES THIS COME WITH AN ANGRY OWNER?",
"I HOPE SOMEBODY WANTS THIS BACK.",
"A TROPHY WITH A HANDLE.",
"THIS SHOULD GET YOUR ATTENTION.",
],
deliverGold: [
"THERE. NOW MY HANDS ARE FREE.",
"ENOUGH CARRYING. WHERE WERE WE?",
"THE SHINY THING CAN WAIT HERE.",
"I HAVE BEEN VERY RESPONSIBLE. NOW FOR THE FUN.",
"IF YOU WANT IT, COME THROUGH ME.",
"GOLD DOWN. CHIN UP.",
"A FINE TROPHY. NEEDS A SCORCH MARK.",
"I CAN CARRY TREASURE AND A GRUDGE.",
],
dealPain: [
"YES. AGAIN.",
"PAIN RECEIPT PRINTED.",
"HOLD STILL. THIS IS THE FUN PART.",
"THE MAZE PROVIDES.",
"THAT IS THE SOUND I CAME FOR.",
"MUCH BETTER THAN SMALL TALK.",
"WE ARE GETTING SOMEWHERE NOW.",
"FINALLY. A PROPER CONVERSATION.",
],
takePain: [
"GOOD. NOW IT IS INTERESTING.",
"I FELT THAT. DO IT AGAIN.",
"THERE YOU ARE!",
"A WORTHY INTRODUCTION.",
"I WAS BEGINNING TO FEEL NEGLECTED.",
"THAT ONE HAD CONVICTION.",
"WE SHALL CALL THAT THE WARM-UP.",
"I LIKE YOUR ATTITUDE. KEEP IT WITHIN REACH.",
],
kill: [
"SCHEDULED DEMISE: DELIVERED.",
"NEXT.",
"THE PILE GROWS.",
"THE MAZE ECHOES BETTER NOW.",
"A GOOD FIGHT. WHO WANTS THE NEXT ONE?",
"YOU MADE ME WORK FOR THAT.",
],
die: [
"SAVE MY SEAT.",
"I WILL BE BACK FOR THE REMATCH.",
"NOW THAT WAS AN ENDING.",
"I DEMAND A LOUDER FUNERAL.",
"BURY ME FACING THE FIGHT.",
"A FINE BLOW. I RESENT IT ENORMOUSLY.",
],
summon: [
"I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY.",
"IT EATS WHAT I TELL IT TO EAT.",
"AT LAST. SOMEONE WITH MY INTERESTS.",
"WELCOME. PICK A FEUD.",
"THE MAZE COULD USE MORE TROUBLE.",
"TRY TO LEAVE SOME FOR ME.",
],
buildWall: [
"MORE CORNERS TO TRAP YOU IN.",
"LET THE MAZE HAVE A SAY IN THIS.",
"THE LONG WAY ROUND BUILDS CHARACTER.",
"A WALL TODAY. RUBBLE EVENTUALLY.",
"NOW THIS CORRIDOR HAS SOME BACKBONE.",
"A LITTLE SCENERY FOR OUR NEXT ARGUMENT.",
],
escape: [
"A TACTICAL LEAP. NOT A RETREAT.",
"A BETTER ANGLE FOR THE NEXT BLOW.",
"I GO WHERE THE TROUBLE NEEDS ME.",
"SAME FEUD. DIFFERENT SQUARE.",
"DO NOT MISTAKE DISTANCE FOR FORGIVENESS.",
"WALKING WAS TAKING TOO LONG.",
],
springTrap: [
"WHO PUT THAT THERE. RESPECT.",
"THE DECK HAS TEETH. EXCELLENT.",
"EVEN THE CARDS WANT A FIGHT.",
"A RUDE LITTLE PIECE OF CARDBOARD.",
"THAT CARD HAS SPIRIT.",
"I WAS EXPECTING A SPELL. THIS HAS PERSONALITY.",
],
dodge: [
"SWING HARDER.",
"I WAS RIGHT THERE!",
"THE AIR WILL NEVER RECOVER.",
"PUT SOME FEELING INTO IT.",
"A TERRIBLE WASTE OF HOSTILITY.",
"ALL THAT FUSS. NO BRUISE.",
],
win: [
"LAST ONE STANDING. AS FORETOLD.",
"A FINE MAZE. LET US DO THAT AGAIN.",
"I WAS JUST GETTING COMFORTABLE.",
"VICTORY! WHO WANTS A REMATCH?",
"THE MAZE WILL REMEMBER THE NOISE.",
"A GOOD DAY TO BE DIFFICULT.",
],
buildWall: ["MORE CORNERS TO TRAP YOU IN."],
escape: ["A TACTICAL LEAP. NOT A RETREAT."],
springTrap: ["WHO PUT THAT THERE. RESPECT."],
dodge: ["SWING HARDER."],
win: ["LAST ONE STANDING. AS FORETOLD."],
},
worrier: {
grabGold: [
"taking this. sorry. sorry.",
"borrowing it. permanently. sorry.",
"please don't rattle. they'll hear us.",
"it looked lighter from over there.",
"mine for now. a very nervous now.",
"i can explain. preferably from farther away.",
"one treasure. several new concerns.",
"this is going to make me conspicuous.",
],
deliverGold: [
"home. safe. breathe.",
"there. i would like to stop carrying problems now.",
"stay. please be good at staying.",
"down you go. quietly.",
"that was much too much adventure.",
"i shall worry about it from here.",
"a place for everything. preferably behind a wall.",
"good. now i can wring both hands.",
],
deliverGold: ["home. safe. breathe."],
dealPain: [
"ow. logged.",
"unnecessary!",
"that looked like it hurt. it was supposed to. i think.",
"could we consider that a warning and stop?",
"i practiced that. nervously.",
"please don't make this a feud.",
"i'd feel worse if you were farther away.",
"that was my brave thing for today.",
],
takePain: [
"rude.",
"i wrote it down. the list is long.",
"yes, that was one of the things i feared.",
"i preferred the worrying to the confirmation.",
"i was using those life-points.",
"my concerns have become very specific.",
"could you put future complaints in writing?",
"i should have stayed behind the other wall.",
],
kill: [
"oh no. oh no. it worked?",
"i'm not proud. mostly.",
"is it over? that part, i mean.",
"one less worry. a new kind of guilt.",
"please don't haunt me. i'm busy enough.",
"i think i need to sit down. somewhere else.",
],
die: [
"called it.",
"this is fine. this is fine. this is not fine.",
"i had a whole list of things left to worry about.",
"at least nobody can ask me to go first now.",
"please put 'quite reasonably concerned' on the stone.",
"i knew i should have packed a second robe.",
],
summon: [
"please be nice to it. actually don't.",
"hello. we should discuss the exits.",
"please look braver than i do.",
"i made company. now i'm responsible for company.",
"we're both new to this arrangement.",
"i hope you brought your own courage.",
],
summon: ["please be nice to it. actually don't."],
buildWall: [
"good wall. safe wall.",
"one more wall between me and everything.",
"finally. something solid to agree with me.",
"i like a conversation with a wall in it.",
"yes. more of that between us.",
"a little privacy for my panic.",
],
escape: [
"nope nope nope.",
"here seems promising. let me worry about it.",
"a change of scenery for the same concerns.",
"i left before i could talk myself out of it.",
"please send any further unpleasantness to my old square.",
"i would call that graceful if i weren't shaking.",
],
springTrap: [
"i knew the floor was suspicious.",
"i had a bad feeling about the next card. every next card.",
"the deck and i are no longer on speaking terms.",
"i would like to put that back unread.",
"even the paper is against me.",
"i worried about the wrong thing. briefly.",
],
dodge: [
"missed me. oh thank goodness. i mean: ha.",
"i shall take full credit once i stop trembling.",
"excellent. nothing happened to me.",
"i had a complaint prepared and everything.",
"i'm going to need a moment to appreciate that.",
"please don't correct your mistake.",
],
win: [
"wait. me? check again.",
"we can stop now, yes? officially?",
"i'd like the quietest possible victory parade.",
"all that worrying. apparently useful.",
"i survived the maze. now there's the attention.",
"thank you. please celebrate from a sensible distance.",
],
escape: ["nope nope nope."],
springTrap: ["i knew the floor was suspicious."],
dodge: ["missed me. oh thank goodness. i mean: ha."],
win: ["wait. me? check again."],
},
};
+78 -47
View File
@@ -8,11 +8,15 @@
// {type:"makeTransfer"} mint a seat-transfer phrase
// {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"chat", text} table talk to the room
// {type:"chat", text, name?} table talk to the room (name: from the gallery)
// {type:"galleryTalk", on} host lets the Peanut Gallery talk, or not
// {type:"feedback", happened, expected} a surprise report, pinned to room+seq
// {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 +31,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,
@@ -51,6 +59,9 @@ import { callKeeper, callRematch,
runningRooms,
addAutomaton,
addChat,
addGalleryChat,
setGalleryTalk,
GALLERY_MARK,
driveOneAutomaton,
makeTransferCode,
redactFor,
@@ -64,10 +75,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 +92,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<string, string>; extra: Record<string, unknown> }): 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,10 +151,12 @@ 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";
/** Where the keeper sleeps, so a caller can tell four in the morning from four in the afternoon. */
const KEEPER_ZONE = process.env.WIZWAR_KEEPER_TZ ?? "America/New_York";
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
/** The gallery talks under a tighter rein than the table: no seat, no token, one address. */
const galleryPerAddress = new SlidingLimit(30, 10 * 60 * 1000);
const MAX_COMMAND_BYTES = 16384; // serialized game command
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
@@ -143,7 +165,8 @@ const NAME_MAX = 24;
/** Player/room names: printable, trimmed, bounded. */
function cleanName(raw: unknown): string {
return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX);
// The gallery's mark is not a name anyone may take.
return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").replaceAll(GALLERY_MARK, "").trim().slice(0, NAME_MAX);
}
const port = Number(process.env.PORT ?? 8787);
@@ -580,6 +603,8 @@ function roomInfo(room: Room) {
expansion: room.expansion,
challenge: room.challenge ?? null,
keeper: KEEPER,
keeperZone: KEEPER_ZONE,
galleryTalk: room.galleryTalk,
colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries(
// A mystery machine keeps its mood only while the game lives: once it
@@ -617,15 +642,17 @@ function banterTrigger(
if (actor === seat) {
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold";
if (e.type === "damaged" && e.player !== seat) return "dealPain";
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
}
// A counter-teleport escape resolves during the ATTACKER's step, and a
// last-standing win can land on the victim's turn: self-referential
// triggers hold whoever acted.
// Blows land on the DEFENDER's step (their pass or counter resolves the
// stack), a counter-teleport escape resolves during the attacker's, and
// a last-standing win can land on the victim's turn: these triggers
// read the event's own names, not whoever acted.
if (e.type === "attackResolved" && e.attacker === seat && e.defender !== seat && (e.damageDealt as number) > 0) return "dealPain";
if (e.type === "punched" && e.attacker === seat) return "dealPain";
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
if (e.type === "gameWon" && e.player === seat) return "win";
if (e.type === "damaged" && e.player === seat) return "takePain";
@@ -767,8 +794,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 });
}
@@ -798,19 +824,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);
@@ -959,12 +980,28 @@ wss.on("connection", (socket, req) => {
}
case "chat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (!room) return send(socket, { type: "error", message: "not in a room" });
if (session.spectator) {
if (!galleryPerAddress.allow(session.address)) return send(socket, { type: "error", message: "the gallery has said plenty for now" });
const said = addGalleryChat(room, cleanName(msg.name), String(msg.text ?? ""));
if ("error" in said) return send(socket, { type: "error", message: said.error });
broadcast(room, () => ({ type: "chat", player: said.player, text: said.text, at: said.at }));
break;
}
if (!session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = addChat(room, session.playerId, String(msg.text ?? ""));
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break;
}
case "galleryTalk": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const set = setGalleryTalk(room, session.playerId, msg.on === true);
if ("error" in set) return send(socket, { type: "error", message: set.error });
broadcastRoomState(room);
break;
}
case "makeTransfer": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
@@ -1064,15 +1101,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;
}
@@ -1080,6 +1114,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<string, unknown>;
const roomId = String(seat.roomId ?? "").toUpperCase();
const name = String(seat.name ?? "");
@@ -1090,19 +1127,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;
}
+53 -20
View File
@@ -5,6 +5,7 @@
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
import {
actingSeat as engineActingSeat,
applyCommand,
automatonCommand,
automatonFallback,
@@ -58,10 +59,22 @@ export interface Room {
expected?: PlayerId[];
/** The table called the keeper of this site to a seat, and by whom. */
challenge?: { by: PlayerId; at: string };
/** The host lets the Peanut Gallery talk at the table. */
galleryTalk: boolean;
}
const rooms = new Map<string, Room>();
/** 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. */
@@ -116,6 +129,7 @@ export function createRoom(
events: [],
chat: [],
bots: new Map(),
galleryTalk: false,
touchedAt: Date.now(),
...(rematch ? { expected: [...rematch.expected] } : {}),
};
@@ -141,10 +155,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 +304,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));
@@ -410,15 +424,42 @@ const CHAT_KEEP = 500;
export function addChat(room: Room, playerId: PlayerId, rawText: string): { text: string; at: string } | { error: string } {
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
return recordChat(room, playerId, rawText);
}
/** The gallery's mark: a name wearing it speaks from the seats, not the table. */
export const GALLERY_MARK = "\u{1F441}";
/** A word from the Peanut Gallery, when the host allows it. The speaker's
* name wears the gallery's mark, and may not be a name at the table. */
export function addGalleryChat(room: Room, name: string, rawText: string): { player: PlayerId; text: string; at: string } | { error: string } {
if (!room.galleryTalk) return { error: "the gallery keeps quiet at this table" };
if (!name) return { error: "sign your name first" };
if (room.players.some((p) => p.toLowerCase() === name.toLowerCase())) return { error: "a wizard at the table has that name" };
const player = `${GALLERY_MARK} ${name}`;
const said = recordChat(room, player, rawText);
return "error" in said ? said : { player, ...said };
}
/** Host only: open or close the gallery's mouth, on the ledger. */
export function setGalleryTalk(room: Room, byId: PlayerId, on: boolean): { on: boolean } | { error: string } {
if (byId !== room.hostId) return { error: "only the host decides whether the gallery may talk" };
if (room.galleryTalk === on) return { on };
room.galleryTalk = on;
appendLine(room.id, { kind: "galleryTalk", on, by: byId, at: new Date().toISOString() });
return { on };
}
function recordChat(room: Room, speaker: PlayerId, rawText: string): { text: string; at: string } | { error: string } {
const text = rawText.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, CHAT_MAX_LENGTH);
if (!text) return { error: "say something" };
const at = new Date().toISOString();
room.chat.push({ player: playerId, text, at });
room.chat.push({ player: speaker, text, at });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
// Talk takes its place in the chronicle where it was said, so rejoining
// players see it interwoven with the action, not sunk to the bottom.
room.events.push({ type: "tableTalk", player: playerId, text });
appendLine(room.id, { kind: "chat", player: playerId, text, at });
room.events.push({ type: "tableTalk", player: speaker, text });
appendLine(room.id, { kind: "chat", player: speaker, text, at });
return { text, at };
}
@@ -431,7 +472,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 +492,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;
}
/**
@@ -792,6 +823,7 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
events: [],
chat: [],
bots: new Map(),
galleryTalk: false,
touchedAt: Date.now(),
...(meta.expected ? { expected: [...meta.expected] } : {}),
...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}),
@@ -827,8 +859,9 @@ 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 === "galleryTalk") {
room.galleryTalk = line.on;
} else if (line.kind === "chat") {
// File order preserves the interleaving with commands.
room.chat.push({ player: line.player, text: line.text, at: line.at });
+10 -6
View File
@@ -30,6 +30,14 @@ export interface ChallengeLine {
at: string;
}
/** The host opened (or closed) the Peanut Gallery's mouth. */
export interface GalleryTalkLine {
kind: "galleryTalk";
on: boolean;
by: string;
at: string;
}
/** The finished table called for a rematch: the new room it moved to. */
export interface RematchLine {
kind: "rematch";
@@ -91,7 +99,7 @@ export interface AbandonLine {
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine;
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
@@ -220,8 +228,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 +254,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;
}
+29
View File
@@ -0,0 +1,29 @@
{
"family": "Kestrel's Hall",
"games": [
{
"slug": "wizwar",
"name": "Wiz-War",
"url": "https://wizwar.kestrelsnest.social",
"pitch": "Steal treasure. Blast through walls. Outwit your friends.",
"players": "2 to 6",
"origin": "Tom Jolly's board game, sixth edition, 1993"
},
{
"slug": "waving-hands",
"name": "Waving Hands",
"url": "https://hands.kestrelsnest.social",
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
"players": "2 to 12",
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
},
{
"slug": "hnefatafl",
"name": "Hnefatafl",
"url": "https://tafl.kestrelsnest.social",
"pitch": "The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.",
"players": "2",
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
}
]
}
+116 -44
View File
@@ -9,7 +9,8 @@
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 { otherGames, type FamilyGame } from "./family";
import { summarizeMissed } from "./catchup";
import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
@@ -26,6 +27,11 @@
net.startGamePolling();
let name = $state(prefs.wizardName);
/** The other games of Kestrel's Hall, for the front page. */
let family = $state<FamilyGame[]>([]);
$effect(() => {
void otherGames("wizwar").then((g) => (family = g));
});
let joinCode = $state("");
let claimPhrase = $state("");
/** A /join/<CODE> recruiting link. The visitor lands looking at the
@@ -299,7 +305,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,12 +1726,26 @@
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. */
/** The hour where the keeper lives, in words, kept current by the minute. */
let minuteTick = $state(0);
$effect(() => {
const id = setInterval(() => { minuteTick++; }, 60_000);
return () => clearInterval(id);
});
const keeperHour = $derived.by(() => {
void minuteTick;
if (!net.keeperZone) return null;
try {
const hour = Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone: net.keeperZone }).format(new Date())) % 24;
const word = ["twelve", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven"][hour % 12];
const part = hour < 5 ? "in the small hours" : hour < 12 ? "in the morning" : hour < 18 ? "in the afternoon" : "in the evening";
return `${word} ${part}`;
} catch { return null; }
});
let castTrace = $state.raw<SightLine | null>(null);
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
@@ -1839,6 +1859,44 @@
}
</script>
{#snippet sayBox(extra: string, withDie: boolean)}
<form class="say-box {extra}" onsubmit={(e) => {
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}<span class="sending">sending…</span>{/if}
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
{#if withDie}
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => net.rollTableDie()}>🎲</button>
{/if}
</form>
{/snippet}
{#snippet gallerySayBox(extra: string)}
<form class="say-box gallery-say {extra}" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
const who = name.trim();
if (!t || !who) return;
setPref("wizardName", who);
if (net.sendChat(t, who)) chatDraft = "";
else net.flash("Not sent — the table is out of reach; your words wait in the box");
}}>
{#if net.chatPending}<span class="sending">sending…</span>{/if}
<input class="say-name" bind:value={name} maxlength="20" placeholder="your name" aria-label="your name in the gallery" />
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something from the gallery…" aria-label="gallery talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim() || !name.trim()}>say</button>
</form>
{/snippet}
<svelte:window onkeydown={onBoardKey} />
{#if fpvWorkshop || tokenWorkshop || fxWorkshop}
@@ -1875,8 +1933,12 @@
onclick={copyInvite}>
{#if net.spectating}watching{:else}<span class="mast-invite">invite friends · </span>room{/if} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
</button>
{#if net.audience > 0}
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
{#if net.you === net.hostId && net.roomId && (net.audience > 0 || net.galleryTalk)}
<button class="mast-audience as-button" class:talking={net.galleryTalk}
title={net.galleryTalk ? "the Peanut Gallery may talk — click to hush it" : "the Peanut Gallery listens — click to let it talk"}
onclick={() => net.setGalleryTalk(!net.galleryTalk)}>👁 {net.audience}{net.galleryTalk ? " 💬" : ""}</button>
{:else if net.audience > 0}
<span class="mast-audience" title={net.galleryTalk ? "the Peanut Gallery, allowed to talk" : "the Peanut Gallery"}>👁 {net.audience}{net.galleryTalk ? " 💬" : ""}</span>
{/if}
{#if !net.spectating}
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
@@ -2505,6 +2567,10 @@
<img src="/hero.jpg" alt="Through a wizard's eyes: an ambush watches its corridor through a wormhole" />
</a>
<a class="about-link clips-link" href="/clips">watch clips from the labyrinth</a>
{#if family.length}
<p class="family-line">Also in Kestrel's Hall:
{#each family as g, i (g.slug)}{i > 0 ? ", " : " "}<a href={g.url} title={g.pitch}>{g.name}</a>{/each}</p>
{/if}
</div>
<details class="more-ways" open={local.hasSave()}>
@@ -2581,23 +2647,24 @@
{#if net.feedbackReports.length > 0}
<div class="reports-head">your reports to the wizards</div>
{#each net.feedbackReports as r (r.id)}
{@const thread = r.thread ?? []}
<div class="report-row">
<span class="ledger-code">{r.roomId}</span>
<div class="report-body">
<div class="report-text">“{r.happened}”{#if r.image}<span class="report-pic" title="with your screenshot"> 📷</span>{/if}</div>
{#each r.thread ?? [] as line, j (j)}
{#each thread as line, j (j)}
{#if line.from === "player"}
<div class="report-reply yours">you — {line.text}</div>
{:else}
<div class="report-reply"><span class="report-status">{line.status}</span> — {line.text}</div>
{/if}
{/each}
{#if !(r.thread ?? []).length}
{#if !thread.length}
<div class="report-reply pending">the wizards are studying the moment…</div>
{:else if (r.thread ?? [])[(r.thread ?? []).length - 1]!.from === "player"}
{:else if thread[thread.length - 1]!.from === "player"}
<div class="report-reply pending">your word is with the wizards…</div>
{/if}
{#if (r.thread ?? []).some((l) => l.from === "desk")}
{#if thread.some((l) => l.from === "desk")}
<form class="report-answer" onsubmit={(e) => {
e.preventDefault();
const t = (reportAnswers[r.id] ?? "").trim();
@@ -2715,19 +2782,21 @@
<summary>the link itself, for pasting by hand</summary>
<code class="invite-link">{inviteLink}</code>
</details>
{#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}
<div class="challenge-row">
<button class="stamp quiet-lid" onclick={() => net.challengeKeeper()}
title="a raven flies to {net.keeper}, who keeps this table">
⚔ Challenge {net.keeper}, the keeper</button>
<div class="challenge-note">{net.keeper} keeps this table and answers every raven — sometimes within the minute,
sometimes after a night's sleep. The seat is held either way.</div>
sometimes after a night's sleep. {keeperHour ? `It is ${keeperHour} where ${net.keeper} lives.` : ""} The seat is held either way.</div>
</div>
{:else if net.challenge}
<div class="challenge-note">The raven has flown to {net.keeper}, called by {net.challenge.by}. A seat is held for them:
<div class="challenge-note">The raven has flown to {net.keeper}, called by {net.challenge.by}.
{keeperHour ? `It is ${keeperHour} there.` : ""} A seat is held for them:
if they can come now, they will take it; if they are at supper, or asleep, or off in the world, they will
leave a word below when they can. Meanwhile the table is yours — seat a clockwork wizard, invite a friend,
or leave the room on the ledger and come back to it later.</div>
or open another game in a new tab and play on; this room keeps its place on your ledger until you come
back, as long as nobody abandons it.</div>
{/if}
<!-- Talk before the boards flip: to say when, or to leave a word. -->
{@const lobbyTalk = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
@@ -2735,16 +2804,7 @@
{#each lobbyTalk as line, i (i)}
<div class="lobby-line">{line.text}</div>
{/each}
<form class="say-box lobby-say" onsubmit={(e) => {
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");
}}>
<input class="say-input" bind:value={chatDraft} maxlength="300" placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
</form>
{@render sayBox("lobby-say", false)}
</div>
{#if net.you === net.hostId && addingBot && net.players.length < 6}
<div class="bot-panel">
@@ -2770,6 +2830,18 @@
Add computer wizard</button>
</div>
{/if}
{:else}
{@const galleryTalkLines = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
<div class="lobby-talk">
{#each galleryTalkLines as line, i (i)}
<div class="lobby-line">{line.text}</div>
{/each}
{#if net.galleryTalk}
{@render gallerySayBox("lobby-say")}
{:else}
<p class="waiting">👁 The gallery listens; the host has not opened its mouth.</p>
{/if}
</div>
{/if}
{#if net.you === net.hostId}
@@ -2777,6 +2849,10 @@
<input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands
</label>
<label class="check option">
<input type="checkbox" checked={net.galleryTalk} onchange={(e) => net.setGalleryTalk(e.currentTarget.checked)} />
Let the Peanut Gallery talk at the table
</label>
<div class="ready">
{#if net.players.length < 2}
<div class="ready-note">Waiting for one more wizard</div>
@@ -3283,6 +3359,7 @@
<div class="scoresheet">
<div class="scoresheet-head">life-points</div>
{#each view.players as p (p.id)}
{@const home = treasuresHomeOf(p)}
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
<span class="dot" style:background={playerColor(p.id)}></span>
<span class="score-name">
@@ -3291,7 +3368,7 @@
</span>
<span class="score-life">{p.life}</span>
<span class="score-marks">
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if treasuresHomeOf(p) > 0}&nbsp;· <span class="score-home" class:threat={treasuresHomeOf(p) === 1 && !!p.carriedTreasureId} title={treasuresHomeOf(p) === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {treasuresHomeOf(p)} home</span>{/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`}&nbsp;·
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if home > 0}&nbsp;· <span class="score-home" class:threat={home === 1 && !!p.carriedTreasureId} title={home === 1 && p.carriedTreasureId ? "one treasure home and carrying the second — a step from winning" : "stolen treasures carried home"}>🏆 {home} home</span>{/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`}&nbsp;·
<button class="score-chest-btn" aria-label="see the carried treasure"
onclick={() => (peekTreasure = { src: chestSrc, note: `${ct?.owner ?? "someone"}'s treasure — in ${p.id}'s arms` })}>
<img class="score-chest" alt="" src={chestSrc} /></button>{/if}
@@ -3420,20 +3497,9 @@
</div>
{/if}
{#if !local.active && net.roomId && !net.spectating}
<form class="say-box" onsubmit={(e) => {
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}<span class="sending">sending…</span>{/if}
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => net.rollTableDie()}>🎲</button>
</form>
{@render sayBox("", true)}
{:else if net.spectating && net.galleryTalk}
{@render gallerySayBox("")}
{/if}
</aside>
@@ -3531,7 +3597,7 @@
</select>
</label>
{#if net.pending}
<span class="sending" aria-live="polite">{Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}…</span>
<span class="sending" aria-live="polite">sending {net.pending.label}…</span>
{:else if net.confirmed}
<span class="sending ok" aria-live="polite">✓ {net.confirmed} confirmed</span>
{/if}
@@ -3765,6 +3831,9 @@
padding: 0 0.3rem;
}
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
.mast-audience.as-button { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; font-size: 0.85rem; }
.mast-audience.talking { color: #6b5a41; }
.gallery-say .say-name { width: 7rem; flex: 0 0 auto; }
.mind-read { max-width: min(92vw, 46rem); }
.caution-reason { padding: 0.15rem 0; }
.caution-reason + .caution-reason { border-top: 1px dotted #4a4536; }
@@ -3845,9 +3914,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 +4014,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;
@@ -4049,6 +4118,8 @@
margin-bottom: 1.2rem;
}
.about-link:hover { color: #43331f; }
.family-line { margin: 0.5rem 0 0; font-size: 0.85rem; color: #7b6a51; }
.family-line a { color: #5a4630; }
.clips-link { display: inline-block; margin: 0 0 0.9rem; }
.field { display: block; text-align: left; margin-bottom: 1rem; }
.field span {
@@ -4380,7 +4451,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; }
+31 -16
View File
@@ -3,6 +3,7 @@
// body, the card library with proper entries, the house rulings, the
// story of the table, and the tally. Every section and card has a link
// a player can send; the desk remembers where the reader was.
import { otherGames, type FamilyGame } from "./family";
import { cardDef } from "@wizwar/engine";
import { tick } from "svelte";
import Card from "./Card.svelte";
@@ -10,7 +11,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,
@@ -127,8 +128,23 @@
if (initialAnchor) void jumpTo(initialAnchor);
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
});
/** The other games of Kestrel's Hall, for the about page. */
let family = $state<FamilyGame[]>([]);
$effect(() => {
void otherGames("wizwar").then((g) => (family = g));
});
</script>
{#snippet section(s: RefSection, withSource: boolean)}
<h3 id={s.id} class="linked">
{s.title}
{#if withSource}<span class="source">{SOURCE_LABELS[s.source]}</span>{/if}
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
</h3>
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
{/snippet}
<svelte:window {onkeydown} />
{#if entryDef}
@@ -277,7 +293,7 @@
<a class="tile" href="/?fpv">
<img src="/hero.jpg" alt="" />
<span class="tile-name">The first-person workshop</span>
<span class="tile-note">the maze through a wizard's own eyes — or <span class="tile-link">watch the demo reel</span></span>
<span class="tile-note">the maze through a wizard's own eyes</span>
</a>
</div>
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
@@ -301,6 +317,14 @@
The keeper of this table roosts at
<a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.
</p>
{#if family.length}
<h3>More games in Kestrel's Hall</h3>
<ul class="family">
{#each family as g (g.slug)}
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="family-note">{g.players} players; {g.origin}.</span></li>
{/each}
</ul>
{/if}
<p class="colophon">
Wiz-War © Tom Jolly. Sixth edition published by Chessex, 1993.
Built by Eric and a very enthusiastic AI, 2026.
@@ -358,13 +382,7 @@
{#if rulesSearch.trim()}
<p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
{#each refResults as s (s.id)}
<h3 id={s.id} class="linked">
{s.title}
<span class="source">{SOURCE_LABELS[s.source]}</span>
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
</h3>
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
{@render section(s, true)}
{/each}
{:else}
<nav class="contents" aria-label="contents">
@@ -387,12 +405,7 @@
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
{/if}
{#each group.sections as s (s.id)}
<h3 id={s.id} class="linked">
{s.title}
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
</h3>
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
{@render section(s, false)}
{/each}
{/each}
{/if}
@@ -622,7 +635,6 @@
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
.tile-note { font-size: 0.85rem; color: #6b5a41; }
.tile-link { text-decoration: underline; text-decoration-style: dotted; }
.search-row { margin-bottom: 0.8rem; }
.search-row input {
@@ -693,4 +705,7 @@
.entry-card :global(.card:hover) { transform: none; }
.contents { columns: 1; }
}
.family { margin: 0.4rem 0 0.8rem 1.2rem; padding: 0; }
.family li + li { margin-top: 0.3rem; }
.family-note { color: #7b6a51; }
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { ReplayStep } from "./catchup";
import { motion } from "./motion";
import Board from "./Board.svelte";
import { wizardColor } from "./colors";
@@ -22,7 +23,7 @@
endLabel = null,
startAt = 0,
}: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
steps: ReplayStep[];
onclose: () => void;
/** An instant replay of one turn: open straight into first person,
* through the eyes of the wizard whose turn it is. */
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { ReplayStep } from "./catchup";
// The public share page (/watch/<id>): one turn of one game, watchable
// by anyone, spectator-redacted — the rest of the game stays private.
// The reel opens in first person through the turn-owner's eyes, framed
@@ -11,7 +12,7 @@
const API = location.port === "5173" ? `http://${location.hostname}:8787` : "";
interface ShareSteps {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
steps: ReplayStep[];
actor: string;
round: number;
whole?: boolean;
+8 -5
View File
@@ -2,9 +2,12 @@
// read from the missed steps as the server redacted them for you, so it
// tells you only what you would have seen at the table.
import { cardDef, type GameEvent, type GameView } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
import { cardName } from "./reference";
export interface MissedStep {
/** One step of a replayed stretch: who acted, what happened, the table
* after, and any talk that landed alongside. */
export interface ReplayStep {
seq: number;
actor: string;
events: GameEvent[];
@@ -19,8 +22,7 @@ export interface MissedFact {
}
function spell(id: string | null): string {
if (!id) return "a punch";
try { return cardDef(id).name; } catch { return id; }
return id ? cardName(id) : "a punch";
}
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
@@ -31,7 +33,7 @@ function nearHome(view: GameView, you: string, cell: { x: number; y: number }):
/** The facts of the missed steps that touch you, in order, capped so the
* slip stays a slip. Each fact points at its step for the reel. */
export function summarizeMissed(steps: readonly MissedStep[], you: string, now: GameView | null): MissedFact[] {
export function summarizeMissed(steps: readonly ReplayStep[], you: string, now: GameView | null): MissedFact[] {
const facts: MissedFact[] = [];
let lifeLost = 0;
let lifeLostStep = -1;
@@ -77,6 +79,7 @@ export function summarizeMissed(steps: readonly MissedStep[], you: string, now:
}
}
});
// Life lost reads second: the first fact names who came for you.
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
if (facts.length === 0) {
// Nothing touched you: say what each wizard was up to, all the same.
+23
View File
@@ -0,0 +1,23 @@
// The other games of Kestrel's Hall, from the family list the kit keeps
// (family.json, one copy shipped with every game), so a player who liked one
// table can find the next. This game is left out of its own list.
export interface FamilyGame {
slug: string;
name: string;
url: string;
pitch: string;
players: string;
origin: string;
}
export async function otherGames(self: string): Promise<FamilyGame[]> {
try {
const res = await fetch('/family.json', { cache: 'no-cache' });
if (!res.ok) return [];
const data = (await res.json()) as { games?: FamilyGame[] };
return (data.games ?? []).filter((g) => g.slug !== self);
} catch {
return [];
}
}
+2 -1
View File
@@ -4,7 +4,8 @@
import type { CardQuery } from "./reference";
export type HelpTab = "play" | "rules" | "cards" | "rulings" | "about" | "tally";
export const HELP_TABS = ["play", "rules", "cards", "rulings", "about", "tally"] as const;
export type HelpTab = (typeof HELP_TABS)[number];
export const helpState: {
tab: HelpTab;
+31 -17
View File
@@ -1,5 +1,6 @@
// Websocket client + reactive session state (Svelte 5 runes).
import type { ReplayStep } from "./catchup";
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef, isPermanentDuration } from "@wizwar/engine";
import { receiptFor } from "./receipt";
@@ -301,7 +302,6 @@ export interface FeedbackReportView {
happened: string;
expected: string;
player?: string;
reply?: { at: string; text: string; status: string };
/** The exchange in order: the desk's replies and the player's answers. */
thread?: { at: string; text: string; from: "desk" | "player"; status?: string }[];
image?: string;
@@ -381,8 +381,12 @@ class Net {
expected = $state<string[]>([]);
/** The table called the keeper of this site, and by whom. */
challenge = $state<{ by: string; at: string } | null>(null);
/** The keeper's name, as the server knows it. */
keeper = $state("Kestrel");
/** The keeper's name, once the room has said it. */
keeper = $state<string | null>(null);
/** The keeper's time zone (IANA), for telling a caller what hour it is there. */
keeperZone = $state<string | null>(null);
/** The host lets the Peanut Gallery talk. */
galleryTalk = $state(false);
view = $state<GameView | null>(null);
log = $state<LogLine[]>([]);
error = $state<string | null>(null);
@@ -410,15 +414,15 @@ class Net {
/** Board flourishes: the app hooks in to animate live event batches. */
onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
catchUp = $state<ReplayStep[] | null>(null);
/** The missed steps, fetched for the summary slip before any reel is opened. */
missedSteps = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
missedSteps = $state<ReplayStep[] | null>(null);
/** Where the catch-up reel opens: the step a summary line pointed at. */
catchUpStart = $state(0);
private catchUpWanted: "summary" | "reel" = "reel";
/** One turn's reel, summoned from a chronicle line's instant-replay
* eye — steps plus the wizard whose eyes the camera wears. */
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
moment = $state<{ steps: ReplayStep[]; owner: string } | null>(null);
/** Turns witnessed so far: counts the same boundary events the server
* counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1;
@@ -541,6 +545,8 @@ class Net {
this.expected = msg.expected ?? [];
this.challenge = msg.challenge ?? null;
if (typeof msg.keeper === "string") this.keeper = msg.keeper;
if (typeof msg.keeperZone === "string") this.keeperZone = msg.keeperZone;
this.galleryTalk = msg.galleryTalk === true;
if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -648,14 +654,12 @@ class Net {
break;
case "rematched":
// The caller's own move: the seat and room for the new table follow.
this.resetChronicle();
this.view = null;
this.started = false;
this.leaveTable();
this.roomId = null;
this.roomIdPending = msg.roomId;
break;
case "chat": {
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
if ((msg.player === this.you || this.spectating) && this.chatPending === msg.text) this.chatPending = null;
this.lastTalkBy = msg.player;
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
this.chatCount += 1;
@@ -693,8 +697,8 @@ class Net {
// stays — a restarting server, a stale restore, or the wrong
// backend all answer with ignorance, and ignorance is not
// deletion. The wallet is capped by age instead of by trust.
// And a seat is dropped only on the SECOND refusal in a row: one
// refusal around a restart or a stale answer must not cost a seat.
// A seat goes only on the SECOND refusal in a row: one refusal
// around a restart or a stale answer must not cost a seat.
const voided = new Set((msg.voided as string[] | undefined) ?? []);
const doubted = new Set<string>();
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
@@ -833,12 +837,17 @@ class Net {
}
/** True when the line left; the composer keeps it otherwise. */
sendChat(text: string): boolean {
if (!this.send({ type: "chat", text })) return false;
sendChat(text: string, galleryName?: string): boolean {
if (!this.send(galleryName ? { type: "chat", text, name: galleryName } : { type: "chat", text })) return false;
this.chatPending = text;
return true;
}
/** Host only: let the Peanut Gallery talk at this table, or hush it. */
setGalleryTalk(on: boolean): void {
this.send({ type: "galleryTalk", on });
}
/** Call the keeper of this site to the table: a seat is held, and their phone rings. */
challengeKeeper(): void {
this.send({ type: "challengeKeeper" });
@@ -852,9 +861,7 @@ class Net {
/** Take the seat kept for you at the rematch table. */
acceptRematch(roomId: string): void {
if (!this.you) return;
this.resetChronicle();
this.view = null;
this.started = false;
this.leaveTable();
this.join(roomId, this.you);
}
@@ -996,6 +1003,13 @@ class Net {
}
/** The chronicle and its turn count reset together, always. */
/** Rise from a finished table: the chronicle, the view, and the game go. */
private leaveTable(): void {
this.resetChronicle();
this.view = null;
this.started = false;
}
private resetChronicle(): void {
this.log = [];
this.turnCounter = -1;
+3 -3
View File
@@ -3,7 +3,8 @@
// engine's own events, so it explains what happened rather than what the
// cards promise.
import { cardDef, isPermanentDuration, type GameEvent } from "@wizwar/engine";
import { isPermanentDuration, type GameEvent } from "@wizwar/engine";
import { cardName } from "./reference";
export interface Receipt {
title: string;
@@ -11,8 +12,7 @@ export interface Receipt {
}
function nameOf(id: string | null): string {
if (!id) return "The punch";
try { return cardDef(id).name; } catch { return id; }
return id ? cardName(id) : "The punch";
}
/** The receipt for a resolution event, or null when there is nothing to
+12 -7
View File
@@ -8,6 +8,15 @@ import { RULES_SECTIONS } from "./rules";
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
import { FAQ_GENERAL } from "./faq-general";
/** A card's printed name, or the id itself for one the deck does not know. */
export function cardName(id: string): string {
try { return cardDef(id).name; } catch { return id; }
}
function escapeRe(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
export function slug(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -132,13 +141,13 @@ export function mentionedCards(cardId: string): CardDef[] {
const def = cardDef(cardId);
let text = def.text ?? "";
if (!text) return [];
text = text.replace(new RegExp(`\\b${def.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ");
text = text.replace(new RegExp(`\\b${escapeRe(def.name.toUpperCase())}\\b`, "g"), " ");
const found: CardDef[] = [];
const candidates = CARD_POOL
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
.sort((a, b) => b.name.length - a.name.length);
for (const d of candidates) {
const re = new RegExp(`\\b${d.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const re = new RegExp(`\\b${escapeRe(d.name.toUpperCase())}\\b`);
if (re.test(text)) {
found.push(d);
text = text.replace(new RegExp(re.source, "g"), " ");
@@ -282,10 +291,6 @@ export const HOUSE_RULINGS: HouseRuling[] = [
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
},
{
id: "lifesaver", title: "Lifesaver", cards: ["lifesaver"],
body: ["A wizard holding LIFESAVER is not eliminated for losing both treasures, exactly as the card promises."],
},
{
id: "force-field", title: "Force Field", cards: ["force-field"],
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
@@ -302,7 +307,7 @@ export function searchRulings(query: string): HouseRuling[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return HOUSE_RULINGS;
return HOUSE_RULINGS.filter((r) => {
const names = r.cards.map((id) => { try { return cardDef(id).name; } catch { return id; } });
const names = r.cards.map(cardName);
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
return words.every((w) => hay.includes(w));
});