Compare commits
10
Commits
1b18dffd28
...
c79fdb4adb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c79fdb4adb | ||
|
|
9d087ccaa1 | ||
|
|
13a6fc2603 | ||
|
|
f68b3cef06 | ||
|
|
35d805fda3 | ||
|
|
31bfcf7bc0 | ||
|
|
5193b12b8e | ||
|
|
66b5c06f17 | ||
|
|
c4a555f8df | ||
|
|
be96dd6047 |
No files matched your search
@@ -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>`.
|
||||
|
||||
@@ -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)}`);
|
||||
@@ -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,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"
|
||||
@@ -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/"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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."],
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 & 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} · dazed {p.lostTurns}{/if}{#if treasuresHomeOf(p) > 0} · <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`} ·
|
||||
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if home > 0} · <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`} ·
|
||||
<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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user