Compare commits
@@ -30,7 +30,26 @@ quiet, say so in one line before the details.
|
|||||||
connector reads fine but 403s on create operations
|
connector reads fine but 403s on create operations
|
||||||
3. **Backups**: `tail -3 /var/log/wizwar/backup.log` (or its rotation)
|
3. **Backups**: `tail -3 /var/log/wizwar/backup.log` (or its rotation)
|
||||||
for the last run, plus the cron monitor above for missed runs
|
for the last run, plus the cron monitor above for missed runs
|
||||||
4. **The table itself**:
|
4. **Trends** (`/var/lib/wizwar/rollup.jsonl`, one JSON line per UTC day
|
||||||
|
written at 00:10 by /usr/local/bin/wizwar-rollup.sh; counts only, no
|
||||||
|
addresses): the last 7 rows give requests, human vs bot addresses,
|
||||||
|
socket connects, path mix (/, /clips, /watch, /ws), external
|
||||||
|
referrers (reddit.com, boardgamegeek.com after the 2026-09-03
|
||||||
|
announcement), new rooms, human seats and names, reports/replies,
|
||||||
|
and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors,
|
||||||
|
serviceStarts, ledgerMB). Report day-over-day direction, not raw
|
||||||
|
rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed
|
||||||
|
check-in there means the rollup itself is broken). Re-roll a day by
|
||||||
|
hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors
|
||||||
|
(one per address per day) whose link carried `?ref=<name>` — the tags
|
||||||
|
on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers
|
||||||
|
alone can't attribute Reddit or BGG, which send none.
|
||||||
|
**Right now** (on request, or when the announcement is fresh): live
|
||||||
|
sockets `ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l`,
|
||||||
|
rooms touched in the last hour `find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l`,
|
||||||
|
and today so far `wizwar-rollup.sh $(date -u +%F)` (a partial row,
|
||||||
|
replaced again at 00:10; the vitals in it are the moment's).
|
||||||
|
5. **The table itself**:
|
||||||
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
|
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
|
||||||
- New human names this week: room ledgers' join lines minus bots
|
- New human names this week: room ledgers' join lines minus bots
|
||||||
(Automaton*) and known names, on rooms created in the last 7 days
|
(Automaton*) and known names, on rooms created in the last 7 days
|
||||||
@@ -42,8 +61,9 @@ quiet, say so in one line before the details.
|
|||||||
|
|
||||||
## Report
|
## Report
|
||||||
|
|
||||||
Five sections, tight: **Verdict** (one line), **Box**, **Errors &
|
Six sections, tight: **Verdict** (one line), **Box**, **Errors &
|
||||||
monitors**, **Backups**, **The table** (games, new faces, feedback).
|
monitors**, **Backups**, **Trends** (the week's direction from the
|
||||||
|
rollup), **The table** (games, new faces, feedback).
|
||||||
Numbers with their thresholds, not raw dumps. End with any recommended
|
Numbers with their thresholds, not raw dumps. End with any recommended
|
||||||
action, or "nothing needs you."
|
action, or "nothing needs you."
|
||||||
|
|
||||||
@@ -57,7 +77,11 @@ action, or "nothing needs you."
|
|||||||
- Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
|
- Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
|
||||||
requires it; a reboot there is maintenance, not an outage.
|
requires it; a reboot there is maintenance, not an outage.
|
||||||
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
|
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
|
||||||
10MiB × 5); the systemd sandbox denies /var/log/caddy.
|
10MiB × 30); the systemd sandbox denies /var/log/caddy.
|
||||||
|
- Per-address limits (2026-09-03): 12 new rooms and 6 reports per
|
||||||
|
address per hour, in packages/server/src/ratelimit.ts. A player who
|
||||||
|
hits one sees "try again in an hour"; a pulse showing many refused
|
||||||
|
creates from one address is a script, not a friend.
|
||||||
- Restore drill (2026-09-01): a Spaces snapshot booted 56/56 rooms
|
- Restore drill (2026-09-01): a Spaces snapshot booted 56/56 rooms
|
||||||
clean. The path: `rclone copy spaces:kestrel-wizwar-backups/wizwar/
|
clean. The path: `rclone copy spaces:kestrel-wizwar-backups/wizwar/
|
||||||
snapshots/<date> ...` on the droplet, then run a local server with
|
snapshots/<date> ...` on the droplet, then run a local server with
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: wizwar-visitors
|
||||||
|
description: The real-time Wiz-War visitors report — who is at the table right now, who came today and how far each got, what they said, what they filed, and today's traffic so far. Use when Eric asks about new players, visitors, "anyone playing?", or wants a traffic report for today.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Wiz-War visitors
|
||||||
|
|
||||||
|
One command, one screen. The droplet prints the digest; you read it for
|
||||||
|
Eric like a host glancing over the tavern.
|
||||||
|
|
||||||
|
## Gather
|
||||||
|
|
||||||
|
ssh root@104.236.96.198 /usr/local/bin/wizwar-visitors.sh [YYYY-MM-DD]
|
||||||
|
|
||||||
|
Default is today, UTC. The script rolls today's partial rollup row
|
||||||
|
first, so the traffic line is current to the minute. Its output:
|
||||||
|
|
||||||
|
- **as of / live sockets / rooms touched in the last hour** — the
|
||||||
|
"right now" line. Two sockets at a quiet hour is Eric plus one.
|
||||||
|
- **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`:
|
||||||
|
addresses of people (bots split out), front-page vs gallery vs
|
||||||
|
replay views, campaigns (`?ref=` tags on posted links, Facebook's
|
||||||
|
fbclid) and referrers. Reddit and BGG send no referrer.
|
||||||
|
- **players today / NEW today** — names seated in rooms created today;
|
||||||
|
NEW means the name's first room ever is today. Known regulars:
|
||||||
|
Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat).
|
||||||
|
- **rooms today** — one line each: time, code, humans vs bots, state
|
||||||
|
(lobby only / started, no moves / in play), move counts, span, and
|
||||||
|
the last human move with its time. Human chat lines follow the room,
|
||||||
|
verbatim. Automatons' chat is omitted; humans' is the signal.
|
||||||
|
- **REPORT lines and the feedback count** — reports filed today, plus
|
||||||
|
the unanswered total. Unanswered > 0 means run /wizwar-reports.
|
||||||
|
|
||||||
|
## Read it
|
||||||
|
|
||||||
|
Lead with new names and whether anyone is at the table now. Then, for
|
||||||
|
each stranger, say how far they got and where they stopped — the last
|
||||||
|
human move and the span tell it: a room that is "lobby only" stopped
|
||||||
|
before the start button; a game of under five minutes ending on
|
||||||
|
pickUpTreasure is the treasure wall the slips now explain; a long span
|
||||||
|
with many moves is a real session, resumable from their lobby. Quote
|
||||||
|
human chat verbatim. Then the traffic line as a single sentence with
|
||||||
|
the day-over-day direction if a previous row exists.
|
||||||
|
|
||||||
|
Two reads to resist: a ledger has no "finished" mark (the winner comes
|
||||||
|
from replaying it), and refused commands never enter ledgers — what a
|
||||||
|
player TRIED and was refused is invisible; to learn it, fetch the
|
||||||
|
ledger, replay to its end with the engine, and apply the commands they
|
||||||
|
plausibly sent next (see [[wizwar-fetch-game-files]]).
|
||||||
|
|
||||||
|
## Numbers with their meaning
|
||||||
|
|
||||||
|
- Announcement day (2026-09-03): 117 addresses, 9 rooms, 5 strangers;
|
||||||
|
before it, a good day was 27 addresses and a room or two.
|
||||||
|
- A new arrival who never presses start, or quits at a pickup, is a
|
||||||
|
first-impression signal, not a rules dispute — those file reports.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Make a clips directory unfurl well before clips-publish.sh ships it:
|
||||||
|
// - remux every mp4 with its index up front (faststart). Previewers in
|
||||||
|
// iMessage, Discord, Slack and Facebook read the head of the file to
|
||||||
|
// decide whether to show a player, and give up on a tail-indexed one.
|
||||||
|
// - cut a 1200x630 share card per clip — the poster's first-person
|
||||||
|
// viewport, full-bleed, with the title set over it — for og:image.
|
||||||
|
// Share sheets crop anything that isn't 1.91:1; X crops to 2:1.
|
||||||
|
// - stamp each clip's video width/height into clips.json so the page
|
||||||
|
// can emit og:video:width/height, without which Facebook, LinkedIn
|
||||||
|
// and Discord refuse to embed a player.
|
||||||
|
//
|
||||||
|
// npx tsx deploy/clips-prep.mjs <clips-dir>
|
||||||
|
//
|
||||||
|
// Needs ffmpeg + ffprobe and the Playwright chromium cache (see
|
||||||
|
// verify-scenes.mjs). Idempotent: run it again after re-recording.
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { chromiumExe, ff } from "./lib/harness.mjs";
|
||||||
|
|
||||||
|
const dir = process.argv[2];
|
||||||
|
if (!dir || !existsSync(join(dir, "clips.json"))) {
|
||||||
|
console.error("usage: clips-prep.mjs <clips-dir> (a directory holding clips.json)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The workshop poster is a screenshot of the replay stage; the wizard's
|
||||||
|
// viewport sits at this rectangle within it — a 16:9 window.
|
||||||
|
const VIEWPORT = { x: 18, y: 51, w: 700, h: 393 };
|
||||||
|
const CARD = { width: 1200, height: 630 };
|
||||||
|
|
||||||
|
function faststart(file) {
|
||||||
|
const tmp = `${file}.tmp.mp4`;
|
||||||
|
ff("-i", file, "-c", "copy", "-movflags", "+faststart", tmp);
|
||||||
|
renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoSize(file) {
|
||||||
|
const [width, height] = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0",
|
||||||
|
"-show_entries", "stream=width,height", "-of", "csv=p=0", file]).toString().trim().split(",").map(Number);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<");
|
||||||
|
|
||||||
|
/** The share card: the viewport scaled to the card's width (a 16:9 crop
|
||||||
|
* runs 674 tall at 1200 wide, so 22px shave top and bottom), a shade
|
||||||
|
* rising from the bottom, the title over it in the gallery's gold — set
|
||||||
|
* in Georgia, since the card is rendered offline with no web fonts. */
|
||||||
|
function cardHtml(title, viewportPngDataUrl) {
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||||
|
html, body { margin: 0; width: ${CARD.width}px; height: ${CARD.height}px; overflow: hidden; background: #171a20; }
|
||||||
|
.card { position: relative; width: ${CARD.width}px; height: ${CARD.height}px;
|
||||||
|
font-family: Georgia, "Times New Roman", serif; color: #d6d2c4; }
|
||||||
|
.card img { position: absolute; left: 0; top: -22px; width: ${CARD.width}px; height: 674px;
|
||||||
|
image-rendering: auto; }
|
||||||
|
.shade { position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(to bottom, rgba(23,26,33,0) 45%, rgba(23,26,33,0.92) 100%); }
|
||||||
|
.title { position: absolute; left: 56px; right: 56px; bottom: 94px;
|
||||||
|
font-size: 66px; line-height: 1.1; color: #e0b34a; letter-spacing: 0.02em;
|
||||||
|
text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
|
||||||
|
.mark { position: absolute; left: 58px; bottom: 52px; font-size: 26px; letter-spacing: 0.08em;
|
||||||
|
color: #d6d2c4; opacity: 0.9; text-shadow: 0 1px 8px rgba(0,0,0,0.6); }
|
||||||
|
.mark b { font-weight: normal; color: #e0b34a; }
|
||||||
|
</style></head><body><div class="card">
|
||||||
|
<img src="${viewportPngDataUrl}">
|
||||||
|
<div class="shade"></div>
|
||||||
|
<div class="title">${esc(title)}</div>
|
||||||
|
<div class="mark"><b>WIZ-WAR</b> · a clip from the labyrinth</div>
|
||||||
|
</div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipsFile = join(dir, "clips.json");
|
||||||
|
const clips = JSON.parse(readFileSync(clipsFile, "utf8"));
|
||||||
|
const browser = await chromium.launch({ executablePath: chromiumExe() });
|
||||||
|
const page = await browser.newPage({ viewport: CARD, deviceScaleFactor: 1 });
|
||||||
|
try {
|
||||||
|
for (const clip of clips) {
|
||||||
|
for (const take of ["fpv", "board"]) faststart(join(dir, `${clip.name}-${take}.mp4`));
|
||||||
|
Object.assign(clip, videoSize(join(dir, `${clip.name}-fpv.mp4`)));
|
||||||
|
|
||||||
|
const crop = join(dir, `.${clip.name}-viewport.png`);
|
||||||
|
ff("-i", join(dir, `${clip.name}.jpg`),
|
||||||
|
"-vf", `crop=${VIEWPORT.w}:${VIEWPORT.h}:${VIEWPORT.x}:${VIEWPORT.y}`, crop);
|
||||||
|
const dataUrl = `data:image/png;base64,${readFileSync(crop).toString("base64")}`;
|
||||||
|
unlinkSync(crop);
|
||||||
|
await page.setContent(cardHtml(clip.title, dataUrl));
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await page.screenshot({ path: join(dir, `${clip.name}-card.jpg`), type: "jpeg", quality: 88 });
|
||||||
|
console.log(`${clip.name}: ${clip.width}x${clip.height}, faststart, card`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
writeFileSync(clipsFile, JSON.stringify(clips, null, 2) + "\n");
|
||||||
|
console.log(`prepped ${clips.length} clips in ${dir}`);
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Publish showcase clips to the droplet's clip vault (/var/lib/wizwar/clips).
|
# Publish showcase clips to the droplet's clip vault (/var/lib/wizwar/clips).
|
||||||
|
# Run deploy/clips-prep.mjs on the directory first: it remuxes the video
|
||||||
|
# for streaming, cuts the share cards, and stamps frame sizes into clips.json.
|
||||||
# The gallery at /clips serves whatever this ships: <slug>-fpv.mp4,
|
# The gallery at /clips serves whatever this ships: <slug>-fpv.mp4,
|
||||||
# <slug>-board.mp4, <slug>.jpg per clip, described by clips.json.
|
# <slug>-board.mp4, <slug>.jpg, <slug>-card.jpg per clip, described by clips.json.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
host=${1:?usage: clips-publish.sh <host> <clips-dir>}
|
host=${1:?usage: clips-publish.sh <host> <clips-dir>}
|
||||||
dir=${2:?usage: clips-publish.sh <host> <clips-dir>}
|
dir=${2:?usage: clips-publish.sh <host> <clips-dir>}
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ set -euo pipefail
|
|||||||
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
|
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
|
||||||
|
|
||||||
npm run build --workspace=@wizwar/web
|
npm run build --workspace=@wizwar/web
|
||||||
rsync -az --delete \
|
# 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.
|
||||||
|
rsync -az --delete --filter='P packages/web/dist/assets/*' \
|
||||||
--exclude node_modules --exclude data/ --exclude .git --exclude research \
|
--exclude node_modules --exclude data/ --exclude .git --exclude research \
|
||||||
./ "root@$HOST:/opt/wizwar/"
|
./ "root@$HOST:/opt/wizwar/"
|
||||||
ssh "root@$HOST" '
|
ssh "root@$HOST" '
|
||||||
cd /opt/wizwar && npm install --no-audit --no-fund
|
cd /opt/wizwar && npm install --no-audit --no-fund
|
||||||
|
find /opt/wizwar/packages/web/dist/assets -type f -mtime +7 -delete
|
||||||
chown -R wizwar:wizwar /opt/wizwar
|
chown -R wizwar:wizwar /opt/wizwar
|
||||||
cp /opt/wizwar/deploy/wizwar.service /etc/systemd/system/wizwar.service
|
cp /opt/wizwar/deploy/wizwar.service /etc/systemd/system/wizwar.service
|
||||||
# The backup script is cron-run from /usr/local/bin — install it every
|
# Cron runs the backup and the rollup from /usr/local/bin: install
|
||||||
# deploy, or the repo copy and the live copy drift apart (they did:
|
# them on every deploy so the live copies are always the repo copies.
|
||||||
# the Sentry check-ins shipped in the repo and never reached the box).
|
|
||||||
install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh
|
install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh
|
||||||
|
install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh
|
||||||
|
install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh
|
||||||
|
install -m 644 /opt/wizwar/deploy/wizwar-rollup.cron /etc/cron.d/wizwar-rollup
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now wizwar
|
systemctl enable --now wizwar
|
||||||
systemctl restart wizwar
|
systemctl restart wizwar
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Film the screenplay catalog for the public gallery: every scene in two
|
||||||
|
// takes — through the pov wizard's eyes and from the board — as the reel
|
||||||
|
// plays it under a paused Playwright clock, one screenshot per 40ms of
|
||||||
|
// simulated time, muxed by ffmpeg into 25fps mp4s. The poster is the
|
||||||
|
// first pinned beat, settled. Writes <slug>-fpv.mp4, <slug>-board.mp4,
|
||||||
|
// <slug>.jpg and clips.json into the output dir — then run
|
||||||
|
// clips-prep.mjs and clips-publish.sh on it.
|
||||||
|
//
|
||||||
|
// npx tsx deploy/film-clips.mjs <out-dir> [scene ...]
|
||||||
|
//
|
||||||
|
// Run from the repo root AFTER `npx vite build` in packages/web. Needs
|
||||||
|
// ffmpeg and the Playwright chromium cache (see verify-scenes.mjs).
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { chromiumExe, ff, rewindReel, startServer } from "./lib/harness.mjs";
|
||||||
|
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
|
||||||
|
|
||||||
|
const [outDir, ...only] = process.argv.slice(2);
|
||||||
|
if (!outDir) { console.error("usage: film-clips.mjs <out-dir> [scene ...]"); process.exit(1); }
|
||||||
|
const PORT = 8809;
|
||||||
|
const FPS = 25;
|
||||||
|
const TAIL_MS = 2500; // hold on the last move
|
||||||
|
const POSTER_SETTLE_MS = 1100; // the poster beat's tween and fx done, still inside its 1.5s beat
|
||||||
|
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
|
||||||
|
|
||||||
|
const server = startServer(PORT, "wizwar-film");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.up();
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: chromiumExe(),
|
||||||
|
args: ["--disable-gpu", "--force-color-profile=srgb", "--font-render-hinting=none"],
|
||||||
|
});
|
||||||
|
const scenes = SCREENPLAYS.filter((sp) => !only.length || only.includes(sp.name));
|
||||||
|
if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`);
|
||||||
|
const catalog = [];
|
||||||
|
|
||||||
|
for (const sp of scenes) {
|
||||||
|
let seconds = 0;
|
||||||
|
for (const view of ["fpv", "board"]) {
|
||||||
|
const frames = mkdtempSync(join(tmpdir(), `wizwar-frames-${sp.name}-${view}-`));
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.clock.install({ time: EPOCH });
|
||||||
|
await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" });
|
||||||
|
const count = await rewindReel(page);
|
||||||
|
if (view === "fpv") await page.getByText("your eyes").click();
|
||||||
|
await page.clock.pauseAt(EPOCH + 60_000);
|
||||||
|
await page.clock.runFor(400); // let the first frame settle before rolling
|
||||||
|
const box = await page.locator(".replay").boundingBox();
|
||||||
|
const clip = { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) };
|
||||||
|
// Roll: the reel's play button is the ▶ before the step button.
|
||||||
|
await page.locator("button", { hasText: "▶" }).first().click();
|
||||||
|
// Roll until the reel reaches its last move (steps a die decides
|
||||||
|
// run longer than the beat), then hold the tail.
|
||||||
|
const posterBeat = sp.beats?.[0] ?? 1;
|
||||||
|
const MAX_FRAMES = FPS * 240; // four minutes: a reel that never reaches its last move
|
||||||
|
let f = 0, tail = -1, posterIn = -1, posterDone = false;
|
||||||
|
while (tail !== 0 && f < MAX_FRAMES) {
|
||||||
|
const png = await page.screenshot({ clip });
|
||||||
|
writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png);
|
||||||
|
const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]);
|
||||||
|
if (view === "fpv" && !posterDone && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(POSTER_SETTLE_MS / (1000 / FPS));
|
||||||
|
if (posterIn === 0) { writeFileSync(join(frames, "poster.png"), png); posterDone = true; }
|
||||||
|
if (posterIn >= 0) posterIn--;
|
||||||
|
if (tail < 0 && at === count) tail = Math.round(TAIL_MS / (1000 / FPS));
|
||||||
|
if (tail > 0) tail--;
|
||||||
|
await page.clock.runFor(1000 / FPS);
|
||||||
|
f++;
|
||||||
|
}
|
||||||
|
await ctx.close();
|
||||||
|
ff("-framerate", String(FPS), "-i", join(frames, "%05d.png"),
|
||||||
|
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides
|
||||||
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "22",
|
||||||
|
"-movflags", "+faststart", join(outDir, `${sp.name}-${view}.mp4`));
|
||||||
|
if (view === "fpv") {
|
||||||
|
const poster = existsSync(join(frames, "poster.png")) ? join(frames, "poster.png") : join(frames, "00000.png");
|
||||||
|
ff("-i", poster, "-q:v", "3", join(outDir, `${sp.name}.jpg`));
|
||||||
|
seconds = Math.round(f / FPS);
|
||||||
|
}
|
||||||
|
rmSync(frames, { recursive: true, force: true });
|
||||||
|
console.log(`${sp.name} ${view}: ${f} frames`);
|
||||||
|
}
|
||||||
|
catalog.push({ name: sp.name, title: sp.title, blurb: sp.blurb, seconds });
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
// A partial run keeps the rest of an existing catalog.
|
||||||
|
const file = join(outDir, "clips.json");
|
||||||
|
const prior = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : [];
|
||||||
|
const merged = SCREENPLAYS.map((sp) =>
|
||||||
|
catalog.find((c) => c.name === sp.name) ?? prior.find((c) => c.name === sp.name)).filter(Boolean);
|
||||||
|
writeFileSync(file, JSON.stringify(merged, null, 2) + "\n");
|
||||||
|
console.log(`filmed ${catalog.length} scenes into ${outDir}`);
|
||||||
|
} finally {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Shared by the scene gate, the clip recorder, and the card cutter: the
|
||||||
|
// Playwright chromium they all drive, a private game server on a
|
||||||
|
// throwaway data dir, ffmpeg with the flags they all want, and the
|
||||||
|
// reel's rewind. Run from the repo root after `npx vite build` in
|
||||||
|
// packages/web.
|
||||||
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
|
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/** The Playwright chromium executable: $WIZWAR_CHROME, else the newest
|
||||||
|
* build in the ms-playwright cache. */
|
||||||
|
export function chromiumExe() {
|
||||||
|
if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME;
|
||||||
|
const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright");
|
||||||
|
const builds = existsSync(cache)
|
||||||
|
? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort()
|
||||||
|
: [];
|
||||||
|
for (const build of builds.reverse()) {
|
||||||
|
const exe = join(cache, build, "chrome-mac-arm64",
|
||||||
|
"Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
|
||||||
|
if (existsSync(exe)) return exe;
|
||||||
|
}
|
||||||
|
throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A game server on `port` with its own empty data dir. `up()` resolves
|
||||||
|
* once it answers; `stop()` kills it and removes the dir. */
|
||||||
|
export function startServer(port, label = "wizwar-harness") {
|
||||||
|
const dataDir = mkdtempSync(join(tmpdir(), `${label}-`));
|
||||||
|
const server = spawn("npx", ["tsx", "src/index.ts"], {
|
||||||
|
cwd: join(import.meta.dirname, "..", "..", "packages", "server"),
|
||||||
|
env: { ...process.env, PORT: String(port), WIZWAR_DATA_DIR: join(dataDir, "rooms") },
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
const up = async () => {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
try { if ((await fetch(`http://127.0.0.1:${port}/`)).ok) return; } catch { /* not yet */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error("server never came up");
|
||||||
|
};
|
||||||
|
const stop = () => { server.kill(); rmSync(dataDir, { recursive: true, force: true }); };
|
||||||
|
return { up, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ffmpeg, quiet and overwriting. */
|
||||||
|
export const ff = (...args) => execFileSync("ffmpeg", ["-loglevel", "error", "-y", ...args]);
|
||||||
|
|
||||||
|
/** Pause the reel and step it back to its first move. */
|
||||||
|
export async function rewindReel(page) {
|
||||||
|
await page.locator("button", { hasText: "❚❚" }).click();
|
||||||
|
const count = Number((await page.locator(".replay-count").textContent()).match(/of (\d+)/)[1]);
|
||||||
|
for (let i = 0; i < count; i++) await page.locator("button", { hasText: "◀" }).click();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 376 KiB |
@@ -25,7 +25,11 @@ mkdir -p /opt/wizwar /var/lib/wizwar/rooms
|
|||||||
chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar
|
chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar
|
||||||
|
|
||||||
# Caddy vhost: auto-TLS, security headers, proxy to the game.
|
# Caddy vhost: auto-TLS, security headers, proxy to the game.
|
||||||
printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile
|
# Access log kept to 30 rolls of 10MiB: the nightly rollup keeps the
|
||||||
|
# counts; the raw lines back it for a while. A deploy restarts the game
|
||||||
|
# for a few seconds; the proxy holds requests that land in that gap and
|
||||||
|
# keeps dialing, so a visitor waits instead of meeting a 502.
|
||||||
|
printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nlog {\n\toutput file /var/lib/caddy/access.log {\n\t\troll_size 10MiB\n\t\troll_keep 30\n\t}\n}\nreverse_proxy localhost:8787 {\n\tlb_try_duration 30s\n\tlb_try_interval 250ms\n}\n' "$HOST" > /etc/caddy/Caddyfile
|
||||||
systemctl reload caddy
|
systemctl reload caddy
|
||||||
|
|
||||||
# Firewall: ssh + web only. The game server binds loopback and is reached
|
# Firewall: ssh + web only. The game server binds loopback and is reached
|
||||||
|
|||||||
@@ -11,11 +11,12 @@
|
|||||||
// Run from the repo root AFTER `npx vite build` in packages/web (the
|
// Run from the repo root AFTER `npx vite build` in packages/web (the
|
||||||
// wrapper deploy/verify-scenes.sh does both). Needs the Playwright
|
// wrapper deploy/verify-scenes.sh does both). Needs the Playwright
|
||||||
// chromium cache (`npx playwright-core install chromium` if missing).
|
// chromium cache (`npx playwright-core install chromium` if missing).
|
||||||
import { execFileSync, spawn } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
|
import { chromiumExe, rewindReel, startServer } from "./lib/harness.mjs";
|
||||||
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
|
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -31,20 +32,6 @@ const CHANNEL_TOL = 6;
|
|||||||
const FAIL_FRAC = 0.002;
|
const FAIL_FRAC = 0.002;
|
||||||
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
|
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
|
||||||
|
|
||||||
function chromiumExe() {
|
|
||||||
if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME;
|
|
||||||
const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright");
|
|
||||||
const builds = existsSync(cache)
|
|
||||||
? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort()
|
|
||||||
: [];
|
|
||||||
for (const build of builds.reverse()) {
|
|
||||||
const exe = join(cache, build, "chrome-mac-arm64",
|
|
||||||
"Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
|
|
||||||
if (existsSync(exe)) return exe;
|
|
||||||
}
|
|
||||||
throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode a PNG to raw RGBA via ffmpeg — the harness's only image dep. */
|
/** Decode a PNG to raw RGBA via ffmpeg — the harness's only image dep. */
|
||||||
function rgba(png) {
|
function rgba(png) {
|
||||||
return execFileSync("ffmpeg", ["-loglevel", "error", "-i", png,
|
return execFileSync("ffmpeg", ["-loglevel", "error", "-i", png,
|
||||||
@@ -64,26 +51,11 @@ function compare(goldenPng, actualPng) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- A private server on a throwaway data dir. ---------------------------
|
// --- A private server on a throwaway data dir. ---------------------------
|
||||||
const dataDir = mkdtempSync(join(tmpdir(), "wizwar-scenes-"));
|
const server = startServer(PORT, "wizwar-scenes");
|
||||||
const server = spawn("npx", ["tsx", "src/index.ts"], {
|
|
||||||
cwd: join(import.meta.dirname, "..", "packages", "server"),
|
|
||||||
env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") },
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
const up = async () => {
|
|
||||||
for (let i = 0; i < 60; i++) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`http://127.0.0.1:${PORT}/`);
|
|
||||||
if (r.ok) return;
|
|
||||||
} catch { /* not yet */ }
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
|
||||||
}
|
|
||||||
throw new Error("server never came up");
|
|
||||||
};
|
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
try {
|
try {
|
||||||
await up();
|
await server.up();
|
||||||
mkdirSync(GOLDENS, { recursive: true });
|
mkdirSync(GOLDENS, { recursive: true });
|
||||||
rmSync(DIFFS, { recursive: true, force: true });
|
rmSync(DIFFS, { recursive: true, force: true });
|
||||||
mkdirSync(DIFFS, { recursive: true });
|
mkdirSync(DIFFS, { recursive: true });
|
||||||
@@ -95,10 +67,8 @@ try {
|
|||||||
if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`);
|
if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`);
|
||||||
|
|
||||||
for (const sp of scenes) {
|
for (const sp of scenes) {
|
||||||
// One page per view: the reel steps forward beat to beat under the
|
// One page per view: the reel steps forward beat to beat, so a
|
||||||
// paused clock, so a scene's later beats cost only their marginal
|
// scene's later beats cost only their marginal steps.
|
||||||
// steps — and every beat's simulated capture time is still exactly
|
|
||||||
// (steps so far) × 2.6s, identical every run.
|
|
||||||
const takes = [
|
const takes = [
|
||||||
{ view: "fpv", beats: [...(sp.beats ?? [])].sort((a, b) => a - b) },
|
{ view: "fpv", beats: [...(sp.beats ?? [])].sort((a, b) => a - b) },
|
||||||
{ view: "board", beats: sp.boardBeat === undefined ? [] : [sp.boardBeat] },
|
{ view: "board", beats: sp.boardBeat === undefined ? [] : [sp.boardBeat] },
|
||||||
@@ -109,19 +79,20 @@ try {
|
|||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.clock.install({ time: EPOCH });
|
await page.clock.install({ time: EPOCH });
|
||||||
await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" });
|
await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" });
|
||||||
await page.locator("button", { hasText: "❚❚" }).click();
|
await rewindReel(page);
|
||||||
for (let i = 0; i < 4; i++) await page.locator("button", { hasText: "◀" }).click();
|
|
||||||
if (view === "fpv") await page.getByText("your eyes").click();
|
if (view === "fpv") await page.getByText("your eyes").click();
|
||||||
// Freeze the world at a fixed instant, then advance it by hand.
|
// Freeze the world at a fixed instant, then advance it by hand:
|
||||||
// Steps on the way to a beat get 0.6 simulated seconds; the beat
|
// steps on the way to a beat get 0.6 simulated seconds, the beat
|
||||||
// itself gets 2.6 to settle its tween, hold, and fx. The schedule
|
// itself 2.6 to settle its tween, hold, and fx.
|
||||||
// is fixed, so every run renders the same bytes.
|
|
||||||
await page.clock.pauseAt(EPOCH + 60_000);
|
await page.clock.pauseAt(EPOCH + 60_000);
|
||||||
let at = 0;
|
let at = 0;
|
||||||
for (const beat of beats) {
|
for (const beat of beats) {
|
||||||
for (; at < beat; at++) {
|
for (; at < beat; at++) {
|
||||||
await page.locator("button", { hasText: "▶" }).last().click();
|
await page.locator("button", { hasText: "▶" }).last().click();
|
||||||
await page.clock.runFor(at === beat - 1 ? 2600 : 600);
|
// A step the die decides holds the world for the roll's
|
||||||
|
// interlude before anything moves: wait it out on top.
|
||||||
|
const die = (await page.locator(".replay-caption").textContent()).includes("\u{1F3B2}");
|
||||||
|
await page.clock.runFor((at === beat - 1 ? 2600 : 600) + (die ? 2400 : 0));
|
||||||
}
|
}
|
||||||
const box = await page.locator(".replay").boundingBox();
|
const box = await page.locator(".replay").boundingBox();
|
||||||
const name = `${sp.name}-s${beat}-${view}.png`;
|
const name = `${sp.name}-s${beat}-${view}.png`;
|
||||||
@@ -151,8 +122,7 @@ try {
|
|||||||
}
|
}
|
||||||
await browser.close();
|
await browser.close();
|
||||||
} finally {
|
} finally {
|
||||||
server.kill();
|
server.stop();
|
||||||
rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`);
|
if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`);
|
||||||
process.exit(failures ? 1 : 0);
|
process.exit(failures ? 1 : 0);
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh.
|
||||||
|
10 0 * * * root /usr/local/bin/wizwar-rollup.sh
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Nightly rollup: one JSON line per day in /var/lib/wizwar/rollup.jsonl —
|
||||||
|
# yesterday's traffic (from Caddy's access log), the table's growth (from
|
||||||
|
# the room ledgers, stats.json and feedback.jsonl), and the box's vitals.
|
||||||
|
# Counts only: no addresses are kept. The pulse reads it for trends the
|
||||||
|
# self-rotating access log cannot keep. Referrers are what browsers send
|
||||||
|
# (Reddit and BGG send none); campaigns are the ?ref=<name> tags on links
|
||||||
|
# Eric posts, one count per address per day, plus Facebook's fbclid.
|
||||||
|
# Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both).
|
||||||
|
# Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
|
||||||
|
# (absent = no check-ins).
|
||||||
|
set -u
|
||||||
|
OUT="/var/lib/wizwar/rollup.jsonl"
|
||||||
|
LOG="/var/log/wizwar/rollup.log"
|
||||||
|
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
|
||||||
|
CRON_URL=$(cat /root/.wizwar-sentry-cron-rollup 2>/dev/null || true)
|
||||||
|
# The check-in carries the schedule: Sentry only keeps a monitor it has
|
||||||
|
# been told the shape of, and a bare ping to an unknown slug is accepted
|
||||||
|
# and dropped without a word.
|
||||||
|
checkin() {
|
||||||
|
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
|
||||||
|
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"10 0 * * *\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
|
||||||
|
"$CRON_URL" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
checkin in_progress
|
||||||
|
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
|
||||||
|
import json, sys, os, glob, gzip, datetime, collections, subprocess, re
|
||||||
|
day, out = sys.argv[1], sys.argv[2]
|
||||||
|
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||||
|
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||||
|
BOT = ("bot", "crawl", "spider", "facebookexternalhit", "slack", "discord", "twitter", "preview", "curl", "python", "go-http", "wget", "headless")
|
||||||
|
|
||||||
|
# --- traffic: every access log file touched since the day began ---
|
||||||
|
req = 0; human = 0; bot = 0; hips = set(); bips = set()
|
||||||
|
paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0
|
||||||
|
# Where a visit came from, when the link itself says so: ?ref=<name> on a
|
||||||
|
# link Eric posted, a Facebook click's fbclid, or a utm_source.
|
||||||
|
campaigns = collections.Counter(); seen_campaign = set()
|
||||||
|
for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
|
||||||
|
if os.path.getmtime(f) < t0: continue
|
||||||
|
opener = gzip.open if f.endswith(".gz") else open
|
||||||
|
with opener(f, "rt", errors="ignore") as fh:
|
||||||
|
for line in fh:
|
||||||
|
try: r = json.loads(line)
|
||||||
|
except ValueError: continue
|
||||||
|
ts = r.get("ts", 0)
|
||||||
|
if not (t0 <= ts < t1): continue
|
||||||
|
q = r.get("request", {}); h = q.get("headers", {})
|
||||||
|
ua = " ".join(h.get("User-Agent", [""])).lower()
|
||||||
|
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||||
|
isbot = any(k in ua for k in BOT)
|
||||||
|
req += 1
|
||||||
|
if isbot: bot += 1; bips.add(ip)
|
||||||
|
else: human += 1; hips.add(ip)
|
||||||
|
full = q.get("uri", ""); uri = full.split("?")[0]
|
||||||
|
if uri == "/ws": ws += 1
|
||||||
|
if not isbot and "?" in full and ip not in seen_campaign:
|
||||||
|
qs = dict(kv.split("=", 1) if "=" in kv else (kv, "") for kv in full.split("?", 1)[1].split("&"))
|
||||||
|
tag = qs.get("ref") or qs.get("utm_source") or ("facebook" if "fbclid" in qs else None)
|
||||||
|
if tag and re.fullmatch(r"[a-z0-9_-]{1,24}", tag):
|
||||||
|
campaigns[tag] += 1; seen_campaign.add(ip)
|
||||||
|
key = ("/clips" if uri.startswith("/clips") else "/watch" if uri.startswith("/watch")
|
||||||
|
else "(assets)" if uri.startswith("/assets") or re.search(r"\.(png|jpg|svg|ico|css|js|webmanifest|mp4)$", uri)
|
||||||
|
else uri if uri in ("/", "/ws", "/robots.txt") else "(other)")
|
||||||
|
if not isbot: paths[key] += 1 # the path mix is what people looked at
|
||||||
|
status[str(r.get("status", 0))[0] + "xx"] += 1
|
||||||
|
ref = " ".join(h.get("Referer", [""]))
|
||||||
|
m = re.match(r"https?://([^/]+)", ref)
|
||||||
|
host = m.group(1) if m else ""
|
||||||
|
ours = host.endswith("kestrelsnest.social") or "104.236.96.198" in host
|
||||||
|
# A referrer on /.env is a scanner's costume (a forged google.com),
|
||||||
|
# not a reader — and the app answers unknown paths with the page,
|
||||||
|
# so the status cannot tell them apart. Only the pages count.
|
||||||
|
if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1
|
||||||
|
|
||||||
|
# --- the table ---
|
||||||
|
new_rooms = 0; seats = collections.Counter()
|
||||||
|
for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
|
||||||
|
try:
|
||||||
|
with open(f) as fh:
|
||||||
|
meta = json.loads(fh.readline())
|
||||||
|
ca = meta.get("createdAt")
|
||||||
|
if not ca: continue
|
||||||
|
ct = datetime.datetime.fromisoformat(ca.replace("Z", "+00:00")).timestamp()
|
||||||
|
if not (t0 <= ct < t1): continue
|
||||||
|
new_rooms += 1
|
||||||
|
# The host takes the first seat by creating; only others join.
|
||||||
|
if meta.get("hostId"): seats[meta["hostId"]] += 1
|
||||||
|
for line in fh:
|
||||||
|
try: r = json.loads(line)
|
||||||
|
except ValueError: continue
|
||||||
|
if r.get("kind") == "join" and not r.get("bot"): seats[r["name"]] += 1
|
||||||
|
except OSError: continue
|
||||||
|
stats = {}
|
||||||
|
try: stats = json.load(open("/var/lib/wizwar/stats.json"))
|
||||||
|
except Exception: pass
|
||||||
|
reports = 0; replies = 0
|
||||||
|
try:
|
||||||
|
for line in open("/var/lib/wizwar/feedback.jsonl"):
|
||||||
|
try: r = json.loads(line)
|
||||||
|
except ValueError: continue
|
||||||
|
at = datetime.datetime.fromisoformat(r["at"].replace("Z", "+00:00")).timestamp()
|
||||||
|
if t0 <= at < t1: reports += ("happened" in r); replies += ("reportId" in r)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
|
# --- vitals (now, at rollup time) ---
|
||||||
|
def sh(cmd):
|
||||||
|
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
|
||||||
|
except Exception: return ""
|
||||||
|
mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value")
|
||||||
|
disk = sh("df --output=pcent / | tail -1").strip().rstrip("%")
|
||||||
|
load = sh("cut -d' ' -f1 /proc/loadavg")
|
||||||
|
journal = sh("journalctl -u wizwar --since '%s' --until '%s' --no-pager 2>/dev/null" % (day, (d0 + datetime.timedelta(days=1)).date()))
|
||||||
|
errs = str(len(re.findall(r"(?i)unhandled protocol error", journal)))
|
||||||
|
starts = str(journal.count("Started wizwar"))
|
||||||
|
ledger = sum(os.path.getsize(f) for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"))
|
||||||
|
row = {
|
||||||
|
"day": day, "requests": req, "human": human, "bot": bot,
|
||||||
|
"humanAddresses": len(hips), "botAddresses": len(bips), "socketConnects": ws,
|
||||||
|
"paths": dict(paths.most_common()), "status": dict(status), "referrers": dict(refs.most_common(10)),
|
||||||
|
"campaigns": dict(campaigns.most_common(10)),
|
||||||
|
"newRooms": new_rooms, "humanSeats": sum(seats.values()), "humanNames": sorted(seats),
|
||||||
|
"gamesFinishedTotal": stats.get("gamesFinished"), "gamesCreatedTotal": stats.get("gamesCreated"),
|
||||||
|
"reports": reports, "replies": replies,
|
||||||
|
"vitals": {"memMB": round(int(mem) / 1048576) if mem.isdigit() else None,
|
||||||
|
"memPeakMB": round(int(peak) / 1048576) if peak.isdigit() else None,
|
||||||
|
"diskPct": int(disk) if disk.isdigit() else None, "load1": float(load) if load else None,
|
||||||
|
"protocolErrors": int(errs) if errs.isdigit() else None, "serviceStarts": int(starts) if starts.isdigit() else None,
|
||||||
|
"ledgerMB": round(ledger / 1048576, 1)},
|
||||||
|
}
|
||||||
|
# One line per day: a rerun for the same day replaces its line.
|
||||||
|
rows = []
|
||||||
|
if os.path.exists(out):
|
||||||
|
rows = [l for l in open(out).read().splitlines() if l.strip() and json.loads(l).get("day") != day]
|
||||||
|
rows.append(json.dumps(row, separators=(",", ":")))
|
||||||
|
tmp = out + ".tmp"
|
||||||
|
open(tmp, "w").write("\n".join(rows) + "\n"); os.replace(tmp, out)
|
||||||
|
print("%s rollup %s: %d requests, %d human addresses, %d new rooms, %d human seats" % (datetime.datetime.now(datetime.timezone.utc).isoformat(), day, req, len(hips), new_rooms, sum(seats.values())))
|
||||||
|
PY
|
||||||
|
then checkin ok; else checkin error; fi
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# The visitors digest: who is here right now and who came today — the
|
||||||
|
# rooms opened, how far each got, what humans said, what they filed —
|
||||||
|
# with today's traffic so far. Read by the wizwar-visitors skill.
|
||||||
|
# wizwar-visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||||
|
set -u
|
||||||
|
DAY="${1:-$(date -u +%F)}"
|
||||||
|
/usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1
|
||||||
|
python3 - "$DAY" <<'PY'
|
||||||
|
import json, sys, glob, os, datetime, subprocess
|
||||||
|
day = sys.argv[1]
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
|
||||||
|
|
||||||
|
def sh(cmd):
|
||||||
|
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
|
||||||
|
except Exception: return "?"
|
||||||
|
print("live sockets: %s | rooms touched in the last hour: %s" % (
|
||||||
|
sh("ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l"),
|
||||||
|
sh("find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l")))
|
||||||
|
|
||||||
|
# --- traffic so far, from the rollup ---
|
||||||
|
try:
|
||||||
|
rows = [json.loads(l) for l in open("/var/lib/wizwar/rollup.jsonl")]
|
||||||
|
r = [x for x in rows if x["day"] == day][-1]
|
||||||
|
p = r["paths"]
|
||||||
|
print("traffic: %d addresses, %d requests from people (%d bot); front %d, gallery %d, replays %d; campaigns %s; referrers %s" % (
|
||||||
|
r["humanAddresses"], r["human"], r["bot"], p.get("/", 0), p.get("/clips", 0), p.get("/watch", 0),
|
||||||
|
json.dumps(r["campaigns"]), json.dumps(r["referrers"])))
|
||||||
|
except Exception as e:
|
||||||
|
print("traffic: (no rollup row: %s)" % e)
|
||||||
|
|
||||||
|
# --- every room, for first sightings; today's rooms in detail ---
|
||||||
|
first_seen = {}
|
||||||
|
rooms = []
|
||||||
|
for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
|
||||||
|
try:
|
||||||
|
lines = open(f).read().splitlines()
|
||||||
|
meta = json.loads(lines[0])
|
||||||
|
except Exception: continue
|
||||||
|
ca = meta.get("createdAt", "")
|
||||||
|
if not ca: continue
|
||||||
|
humans = [meta["hostId"]]; bots = []; started = False; cmds = 0; chat = []; last = ca; steps = []
|
||||||
|
for l in lines[1:]:
|
||||||
|
try: x = json.loads(l)
|
||||||
|
except Exception: continue
|
||||||
|
k = x.get("kind")
|
||||||
|
if k == "join":
|
||||||
|
(bots if x.get("bot") else humans).append(x["name"])
|
||||||
|
elif k == "start": started = True
|
||||||
|
elif k == "command":
|
||||||
|
cmds += 1; last = x.get("at", last)
|
||||||
|
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", "")))
|
||||||
|
elif k == "chat":
|
||||||
|
last = x.get("at", last)
|
||||||
|
if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"]))
|
||||||
|
for h in humans:
|
||||||
|
if h not in first_seen or ca < first_seen[h]: first_seen[h] = ca
|
||||||
|
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps))
|
||||||
|
|
||||||
|
today = sorted([r for r in rooms if r["created"].startswith(day)], key=lambda r: r["created"])
|
||||||
|
names = sorted({h for r in today for h in r["humans"]})
|
||||||
|
new = [n for n in names if first_seen[n].startswith(day)]
|
||||||
|
print("players today: %s | NEW today: %s" % (", ".join(names) or "none", ", ".join(new) or "none"))
|
||||||
|
print("rooms today: %d" % len(today))
|
||||||
|
for r in today:
|
||||||
|
span = ""
|
||||||
|
if r["cmds"]:
|
||||||
|
t0 = datetime.datetime.fromisoformat(r["created"].replace("Z", "+00:00")); t1 = datetime.datetime.fromisoformat(r["last"].replace("Z", "+00:00"))
|
||||||
|
span = "%d min" % round((t1 - t0).total_seconds() / 60)
|
||||||
|
human_cmds = [s for s in r["steps"] if s[0] in r["humans"]]
|
||||||
|
last_human = ("last human move: %s %s at %s" % (human_cmds[-1][0], human_cmds[-1][1], human_cmds[-1][2][11:16])) if human_cmds else "no human move"
|
||||||
|
state = "lobby only" if not r["started"] else ("started, no moves" if not r["cmds"] else "in play")
|
||||||
|
print(" %s %s %s%s %s %d moves (%d human) %s %s" % (
|
||||||
|
r["created"][11:16], r["id"], "+".join(r["humans"]), (" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "",
|
||||||
|
state, r["cmds"], len(human_cmds), span, last_human))
|
||||||
|
for at, who, text in r["chat"]: print(" %s %s: %s" % (at, who, text))
|
||||||
|
|
||||||
|
# --- the desk ---
|
||||||
|
reports = 0; unanswered = 0; answered = set()
|
||||||
|
try:
|
||||||
|
rows = [json.loads(l) for l in open("/var/lib/wizwar/feedback.jsonl") if l.strip()]
|
||||||
|
answered = {x["reportId"] for x in rows if "reportId" in x}
|
||||||
|
for x in rows:
|
||||||
|
if "happened" not in x: continue
|
||||||
|
if (x.get("id") or x["at"]) not in answered: unanswered += 1
|
||||||
|
if x["at"].startswith(day):
|
||||||
|
reports += 1
|
||||||
|
print(" REPORT %s %s (%s): %s / expected: %s" % (x["at"][11:16], x["player"], x["roomId"], x["happened"], x["expected"]))
|
||||||
|
except OSError: pass
|
||||||
|
print("feedback: %d filed today, %d unanswered overall" % (reports, unanswered))
|
||||||
|
PY
|
||||||
@@ -155,6 +155,13 @@ interface PathResult {
|
|||||||
distance: number;
|
distance: number;
|
||||||
/** A locked door stands on the first step of this path. */
|
/** A locked door stands on the first step of this path. */
|
||||||
doorAhead?: { cell: Cell; side: Side };
|
doorAhead?: { cell: Cell; side: Side };
|
||||||
|
/** The first step lands on a pit: the side of its rim the path leaves by. */
|
||||||
|
exit?: Side;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The move that takes a path's first step, naming the pit exit when there is one. */
|
||||||
|
function stepAlong(path: PathResult): Command {
|
||||||
|
return { type: "move", direction: path.dir, ...(path.exit ? { exit: path.exit } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One walkable step for the clockwork's pathfinding; null = impassable.
|
/** One walkable step for the clockwork's pathfinding; null = impassable.
|
||||||
@@ -582,7 +589,28 @@ function wallBlastTarget(
|
|||||||
* when the clockwork can unlock them; the first such door is reported so the
|
* when the clockwork can unlock them; the first such door is reported so the
|
||||||
* key gets used before the boot.
|
* key gets used before the boot.
|
||||||
*/
|
*/
|
||||||
function pathToward(
|
/** Can a wizard entering the pit at `pit` heading `dir` get off its rim
|
||||||
|
* — the maze's own test: some side other than the way back with a cell
|
||||||
|
* there, no wall between, and no stone on it. */
|
||||||
|
/** The sides a walker may leave a pit by, having entered it heading
|
||||||
|
* `dir`: neighbouring squares first; a warp mouth on the rim only when
|
||||||
|
* no square offers, the reading every revision accepts. */
|
||||||
|
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
|
||||||
|
const footing = (d: Side) => {
|
||||||
|
if (d === opposite(dir)) return null;
|
||||||
|
const t = stepTarget(view.board, pit, d);
|
||||||
|
if (t.kind === "blocked" || view.squareContents[cellKey(t.to)]?.kind === "stone") return null;
|
||||||
|
return t.kind;
|
||||||
|
};
|
||||||
|
const floor = SIDES.filter((d) => footing(d) === "step");
|
||||||
|
return floor.length > 0 ? floor : SIDES.filter((d) => footing(d) === "warp");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
|
||||||
|
return pitExits(view, pit, dir).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathToward(
|
||||||
view: GameView,
|
view: GameView,
|
||||||
from: Cell,
|
from: Cell,
|
||||||
goals: Set<string>,
|
goals: Set<string>,
|
||||||
@@ -607,6 +635,16 @@ function pathToward(
|
|||||||
const k = cellKey(to);
|
const k = cellKey(to);
|
||||||
if (seen.has(k)) continue;
|
if (seen.has(k)) continue;
|
||||||
if (view.squareContents[k]?.kind === "stone") continue;
|
if (view.squareContents[k]?.kind === "stone") continue;
|
||||||
|
const hazard = view.squareContents[k]?.kind;
|
||||||
|
// A pit is entered by leaping to the square beyond it in the same
|
||||||
|
// direction; with nothing to land on, the maze bounces the leaper
|
||||||
|
// back and charges the stride. Goal or waypoint, that is no road.
|
||||||
|
if (hazard === "pit" && !pitLandable(view, to, dir)) continue;
|
||||||
|
// Out of a pit only by one of its exits.
|
||||||
|
if (view.squareContents[cellKey(c)]?.kind === "pit") {
|
||||||
|
const entry = cameBy.get(cellKey(c))?.dir;
|
||||||
|
if (entry && !pitExits(view, c, entry).includes(dir)) continue;
|
||||||
|
}
|
||||||
seen.add(k);
|
seen.add(k);
|
||||||
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
|
||||||
if (goals.has(k)) { found = k; break; }
|
if (goals.has(k)) { found = k; break; }
|
||||||
@@ -615,7 +653,6 @@ function pathToward(
|
|||||||
// turn, and stings — a jail with foliage. Only a wizard with no
|
// turn, and stings — a jail with foliage. Only a wizard with no
|
||||||
// road at all (throughJails) dives in. "Few will dive into one
|
// road at all (throughJails) dives in. "Few will dive into one
|
||||||
// unless they are desperate."
|
// unless they are desperate."
|
||||||
const hazard = view.squareContents[k]?.kind;
|
|
||||||
if (hazard === "thornbush" && !opts.throughJails) continue;
|
if (hazard === "thornbush" && !opts.throughJails) continue;
|
||||||
if (!opts.throughHazards &&
|
if (!opts.throughHazards &&
|
||||||
(hazard === "pit" || hazard === "ooze" ||
|
(hazard === "pit" || hazard === "ooze" ||
|
||||||
@@ -629,15 +666,19 @@ function pathToward(
|
|||||||
}
|
}
|
||||||
if (!found) return null;
|
if (!found) return null;
|
||||||
let cursor = found;
|
let cursor = found;
|
||||||
|
let second: Side | undefined;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const hop = cameBy.get(cursor)!;
|
const hop = cameBy.get(cursor)!;
|
||||||
if (hop.prev === cellKey(from)) {
|
if (hop.prev === cellKey(from)) {
|
||||||
|
const firstIsPit = view.squareContents[cursor]?.kind === "pit";
|
||||||
return {
|
return {
|
||||||
dir: hop.dir,
|
dir: hop.dir,
|
||||||
distance: depth,
|
distance: depth,
|
||||||
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
|
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
|
||||||
|
...(firstIsPit && second ? { exit: second } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
second = hop.dir;
|
||||||
cursor = hop.prev;
|
cursor = hop.prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,7 +766,8 @@ function overLimit(view: GameView): number {
|
|||||||
* clogged hand never refreshes; only cards the brain has no play for are
|
* clogged hand never refreshes; only cards the brain has no play for are
|
||||||
* shed (good counters and attacks are hoarded, as a human would). */
|
* shed (good counters and attacks are hoarded, as a human would). */
|
||||||
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
|
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
|
||||||
const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length);
|
const draw = drawUnderCurses(view, tier.draw);
|
||||||
|
const deficit = draw - (handLimitOf(view) - view.yourHand.length);
|
||||||
if (tier.sheds && deficit > 0) {
|
if (tier.sheds && deficit > 0) {
|
||||||
const shed = [...view.yourHand]
|
const shed = [...view.yourHand]
|
||||||
.filter((c) => discardValue(c, view, style) <= 3)
|
.filter((c) => discardValue(c, view, style) <= 3)
|
||||||
@@ -734,7 +776,16 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
|
|||||||
.map((c) => c.instanceId);
|
.map((c) => c.instanceId);
|
||||||
if (shed.length > 0) return { type: "discard", instanceIds: shed };
|
if (shed.length > 0) return { type: "discard", instanceIds: shed };
|
||||||
}
|
}
|
||||||
return { type: "endTurn", draw: tier.draw };
|
return { type: "endTurn", draw };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what
|
||||||
|
* it can afford, keeping four life in hand, and nothing at all when it
|
||||||
|
* cannot — a bare hand beats a bare grave. */
|
||||||
|
export function drawUnderCurses(view: GameView, draw: number): number {
|
||||||
|
const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
|
||||||
|
if (!slow) return draw;
|
||||||
|
return Math.max(0, Math.min(draw, me(view).life - 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] {
|
function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] {
|
||||||
@@ -1299,6 +1350,13 @@ export function automatonCommand(
|
|||||||
// A Ward is free damage on a thief of OUR gold: always spring it.
|
// A Ward is free damage on a thief of OUR gold: always spring it.
|
||||||
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
|
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
|
||||||
}
|
}
|
||||||
|
if (view.pushPending) {
|
||||||
|
if (view.pushPending.pusheeId !== you) return null;
|
||||||
|
// Shoved to a fork: step out of the giant's line so he cannot keep
|
||||||
|
// pushing; failing that, the first way open.
|
||||||
|
const { direction, exits } = view.pushPending;
|
||||||
|
return { type: "pushChoice", direction: exits.find((d) => d !== direction) ?? exits[0]! };
|
||||||
|
}
|
||||||
if (view.pendingDiscard === you) {
|
if (view.pendingDiscard === you) {
|
||||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) };
|
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) };
|
||||||
}
|
}
|
||||||
@@ -1522,8 +1580,13 @@ export function automatonCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No shot at a wizard: raise a creature to do the walking.
|
// No shot at a wizard: raise a creature to do the walking. A SHADOW
|
||||||
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
|
// drinks a life a turn from its master — no pet for a wizard already
|
||||||
|
// bleeding, or with little blood to spare.
|
||||||
|
const bleeding = view.sustained.some((e) =>
|
||||||
|
(e.cardId === "slow-death" || e.cardId === "walking-dead") && e.targetId === you && !e.data?.reversed);
|
||||||
|
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId) &&
|
||||||
|
!(c.cardId === "shadow" && (bleeding || self.life <= 6)));
|
||||||
if (summon && livingEnemies(view).length > 0) {
|
if (summon && livingEnemies(view).length > 0) {
|
||||||
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
|
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
|
||||||
const spot = summonSpot(view, near);
|
const spot = summonSpot(view, near);
|
||||||
@@ -1700,7 +1763,7 @@ export function automatonCommand(
|
|||||||
return { type: "move", direction: dir };
|
return { type: "move", direction: dir };
|
||||||
}
|
}
|
||||||
const approach = pathToward(view, self.position, new Set([cellKey(crossing.near)]), { canUnlock });
|
const approach = pathToward(view, self.position, new Set([cellKey(crossing.near)]), { canUnlock });
|
||||||
if (approach) return { type: "move", direction: approach.dir };
|
if (approach) return stepAlong(approach);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Or bank one now, when a crossing beats the walk by enough to spend a card.
|
// Or bank one now, when a crossing beats the walk by enough to spend a card.
|
||||||
@@ -1811,7 +1874,7 @@ export function automatonCommand(
|
|||||||
if (run) return { type: "cast", instanceId: run.instanceId, params: { points } };
|
if (run) return { type: "cast", instanceId: run.instanceId, params: { points } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { type: "move", direction: path.dir };
|
return stepAlong(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1823,6 +1886,7 @@ export function automatonCommand(
|
|||||||
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
|
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
|
||||||
const you = view.you;
|
const you = view.you;
|
||||||
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
|
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
|
||||||
|
if (view.pushPending?.pusheeId === you) return { type: "pushChoice", direction: view.pushPending.exits[0]! };
|
||||||
if (view.slowDeathPending?.playerId === you) return { type: "slowDeathChoice" };
|
if (view.slowDeathPending?.playerId === you) return { type: "slowDeathChoice" };
|
||||||
if (view.pendingDiscard === you) {
|
if (view.pendingDiscard === you) {
|
||||||
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
|
||||||
|
|||||||
@@ -238,7 +238,32 @@ export interface CastParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||||
export const CURRENT_RULES_REV = 13;
|
export const CURRENT_RULES_REV = 20;
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* rulings; new entries go here alongside the CURRENT_RULES_REV bump. */
|
||||||
|
export const RULES_REVISIONS: { rev: number; note: string }[] = [
|
||||||
|
{ rev: 2, note: "a wizard beside a door they can open (lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through the doorway; the hallway behind them still cannot." },
|
||||||
|
{ rev: 3, note: "VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included — not only direct attacks." },
|
||||||
|
{ rev: 4, note: "\"the door will relock behind you\" means it — passing through an unlocked door shuts it at the walker's back unless a hand holds it; unpassed, it relocks at turn's end as before." },
|
||||||
|
{ rev: 5, note: "MENTAL FORCE refuses a destination the victim cannot walk to in three spaces, instead of eating the card silently at resolution." },
|
||||||
|
{ rev: 6, note: "STRENGTH's treasure-tear opens a counteraction window (\"this would be an attack\") instead of resolving instantly." },
|
||||||
|
{ rev: 7, note: "DISEASE is a self-cast plague (\"You're the carrier!\") — the caster carries it, sharing a square bites both directions, no counteraction." },
|
||||||
|
{ rev: 8, note: "the fire imp's scorch is a SPELL (FAQ) — counteractable, magical — and SOULSTONE's floor holds even at 3 life or below." },
|
||||||
|
{ rev: 9, note: "the WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does touches its caster." },
|
||||||
|
{ rev: 10, note: "BUTT-HEAD's ram is MOVEMENT — the charge walks real corridors on the turn's legal legs (three plus numbers) and spends them." },
|
||||||
|
{ rev: 11, note: "a REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse (FAQ) — a point gained per card drawn, half a point per space walked, permanently — and a FULL REFLECTION returns a permanent curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of letting it evaporate." },
|
||||||
|
{ rev: 12, note: "MAD DASH doubles \"NUMBER cards and other add-ons\" too — a number riding the cast fuels it, and numbers or POWER RUN points played under the dash double; older games doubled only the base allowance and consumed the rider without effect." },
|
||||||
|
{ rev: 13, note: "SLOW DEATH's per-draw bites land as ONE blow that pauses for a victim holding ABSORB or BLUNT — countered against the total (absorb soaks up to 3, blunt halves rounding up); older games bit instantly." },
|
||||||
|
{ rev: 14, note: "Crossing a pit on a 2, 3, or 4 means edging around its rim: the walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it — and a pit with no way off cannot be entered. Older games bounced the walker back and charged the stride." },
|
||||||
|
{ rev: 15, note: "BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do (FAQ: at an intersection the other player decides). Older games shove straight ahead whenever that way is open; rounding a corner was never possible before and is allowed in every game." },
|
||||||
|
{ rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." },
|
||||||
|
{ rev: 17, note: "A warp mouth on a pit's rim is a way off it like any square: a bare step beside one square and one mouth is a fork the walker names. In every game the mouth may be named, and is taken when no square offers; only an older game's bare step still lands on the square unasked." },
|
||||||
|
{ rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." },
|
||||||
|
{ rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." },
|
||||||
|
{ rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." },
|
||||||
|
];
|
||||||
|
|
||||||
export interface GameConfig {
|
export interface GameConfig {
|
||||||
playerIds: PlayerId[];
|
playerIds: PlayerId[];
|
||||||
@@ -251,43 +276,7 @@ export interface GameConfig {
|
|||||||
/**
|
/**
|
||||||
* Rules revision, frozen per game so stored games replay unchanged.
|
* Rules revision, frozen per game so stored games replay unchanged.
|
||||||
* Absent = rev 1, the baseline all earlier revisions collapsed into.
|
* Absent = rev 1, the baseline all earlier revisions collapsed into.
|
||||||
* Rev 2: a wizard beside a door they can open (lock removed, door
|
* What each revision changed is RULES_REVISIONS, above.
|
||||||
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
|
|
||||||
* the doorway; the hallway behind them still cannot.
|
|
||||||
* Rev 3: VISIONSTONE pierces its one wall for every sight the game
|
|
||||||
* asks of its bearer — creations and utility spells included — not
|
|
||||||
* only direct attacks.
|
|
||||||
* Rev 4: "the door will relock behind you" means it — passing through
|
|
||||||
* an unlocked door shuts it at the walker's back unless a hand holds
|
|
||||||
* it; unpassed, it relocks at turn's end as before.
|
|
||||||
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
|
|
||||||
* in three spaces, instead of eating the card silently at resolution.
|
|
||||||
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this
|
|
||||||
* would be an attack") instead of resolving instantly.
|
|
||||||
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the
|
|
||||||
* caster carries it, sharing a square bites both directions, no
|
|
||||||
* counteraction.
|
|
||||||
* Rev 8: the fire imp's scorch is a SPELL (FAQ) — counteractable,
|
|
||||||
* magical — and SOULSTONE's floor holds even at 3 life or below.
|
|
||||||
* Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ...
|
|
||||||
* otherwise work as written"), though nothing a counter does touches
|
|
||||||
* its caster.
|
|
||||||
* Rev 10: BUTT-HEAD's ram is MOVEMENT — the charge walks real
|
|
||||||
* corridors on the turn's legal legs (three plus numbers) and spends
|
|
||||||
* them.
|
|
||||||
* Rev 11: a REVERSE against SLOW DEATH or WALKING DEAD turns the whole
|
|
||||||
* curse (FAQ) — a point gained per card drawn, half a point per space
|
|
||||||
* walked, permanently — and a FULL REFLECTION returns a permanent
|
|
||||||
* curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of
|
|
||||||
* letting it evaporate.
|
|
||||||
* Rev 12: MAD DASH doubles "NUMBER cards and other add-ons" too — a
|
|
||||||
* number riding the cast fuels it, and numbers or POWER RUN points
|
|
||||||
* played under the dash double; older games doubled only the base
|
|
||||||
* allowance and consumed the rider without effect.
|
|
||||||
* Rev 13: SLOW DEATH's per-draw bites land as ONE blow that pauses
|
|
||||||
* for a victim holding ABSORB or BLUNT — countered against the total
|
|
||||||
* (absorb soaks up to 3, blunt halves rounding up); older games bit
|
|
||||||
* instantly.
|
|
||||||
*/
|
*/
|
||||||
deckRev?: number;
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
@@ -305,6 +294,11 @@ export interface GameState {
|
|||||||
/** A treasure was just grabbed and its owner holds WARD:
|
/** A treasure was just grabbed and its owner holds WARD:
|
||||||
* the table waits while they choose to play it "at that time" or not. */
|
* the table waits while they choose to play it "at that time" or not. */
|
||||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||||
|
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
|
||||||
|
* the pushed one picks which way to go (rev 15). */
|
||||||
|
pushPending: {
|
||||||
|
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
|
||||||
|
} | null;
|
||||||
/** SLOW DEATH's bites hang while the victim weighs counteractions:
|
/** SLOW DEATH's bites hang while the victim weighs counteractions:
|
||||||
* the draw's total is one blow — ABSORB soaks up to three of it,
|
* the draw's total is one blow — ABSORB soaks up to three of it,
|
||||||
* BLUNT halves it rounding up (table ruling). */
|
* BLUNT halves it rounding up (table ruling). */
|
||||||
@@ -446,7 +440,18 @@ function sightThroughOneEdge(
|
|||||||
return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers));
|
return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rev 19: DUST CLOUD blinds whoever stands in it and hides them — "an
|
||||||
|
* LOS spell cannot be cast upon a player in DUST CLOUD, or by a player
|
||||||
|
* who is in a DUST CLOUD" (official FAQ); a spell cast upon oneself
|
||||||
|
* still works, and VISIONSTONE does not see through dust either. Before
|
||||||
|
* it the cloud blocked only sight lines passing through. */
|
||||||
|
function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean {
|
||||||
|
if ((state.config.deckRev ?? 1) < 19 || cellKey(from) === cellKey(to)) return false;
|
||||||
|
return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust";
|
||||||
|
}
|
||||||
|
|
||||||
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
|
||||||
|
if (dustAtEnd(state, from, to)) return false;
|
||||||
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
|
||||||
const blockers = losBlockers(state);
|
const blockers = losBlockers(state);
|
||||||
if (sightBetween(board, from, to, blockers)) return true;
|
if (sightBetween(board, from, to, blockers)) return true;
|
||||||
@@ -666,7 +671,9 @@ export type GameEvent =
|
|||||||
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
|
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
|
||||||
| { type: "spellReused"; player: PlayerId; card: CardInstance }
|
| { type: "spellReused"; player: PlayerId; card: CardInstance }
|
||||||
| { type: "castAroundCorner"; caster: PlayerId }
|
| { type: "castAroundCorner"; caster: PlayerId }
|
||||||
| { type: "moveBumped"; player: PlayerId; direction: Side }
|
/** A stride that went nowhere: into a wall while blind, or into a pit
|
||||||
|
* with nothing to land on beyond it. */
|
||||||
|
| { type: "moveBumped"; player: PlayerId; direction: Side; why?: "pit" }
|
||||||
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
|
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
|
||||||
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
|
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
|
||||||
| { type: "tableTalk"; player: PlayerId; text: string }
|
| { type: "tableTalk"; player: PlayerId; text: string }
|
||||||
@@ -701,7 +708,7 @@ export type GameEvent =
|
|||||||
| { type: "slippedInOoze"; player: PlayerId; at: Cell }
|
| { type: "slippedInOoze"; player: PlayerId; at: Cell }
|
||||||
| { type: "struggledInOoze"; player: PlayerId; stood: boolean }
|
| { type: "struggledInOoze"; player: PlayerId; stood: boolean }
|
||||||
| { type: "steppedOnTacks"; player: PlayerId; at: Cell }
|
| { type: "steppedOnTacks"; player: PlayerId; at: Cell }
|
||||||
| { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell }
|
| { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell; via?: "warp" }
|
||||||
| { type: "fellInPit"; player: PlayerId; at: Cell }
|
| { type: "fellInPit"; player: PlayerId; at: Cell }
|
||||||
| { type: "climbedFromPit"; player: PlayerId; success: boolean }
|
| { type: "climbedFromPit"; player: PlayerId; success: boolean }
|
||||||
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
|
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
|
||||||
@@ -723,6 +730,8 @@ export type GameEvent =
|
|||||||
| { type: "swapFizzled"; player: PlayerId }
|
| { type: "swapFizzled"; player: PlayerId }
|
||||||
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
||||||
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
|
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
|
||||||
|
| { type: "pushWindow"; giant: PlayerId; pushee: PlayerId; exits: Side[] }
|
||||||
|
| { type: "pushChosen"; pushee: PlayerId; direction: Side }
|
||||||
| { type: "chaosShielded"; player: PlayerId }
|
| { type: "chaosShielded"; player: PlayerId }
|
||||||
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
|
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
|
||||||
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
|
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
|
||||||
@@ -790,7 +799,9 @@ export type CastTarget =
|
|||||||
| { kind: "cell"; cell: Cell };
|
| { kind: "cell"; cell: Cell };
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
| { type: "move"; direction: Side; over?: boolean }
|
/** `exit` names the side of a pit to leave by when the way straight
|
||||||
|
* ahead is closed — the ledge runs around the whole rim (rev 14). */
|
||||||
|
| { type: "move"; direction: Side; over?: boolean; exit?: Side }
|
||||||
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
|
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
|
||||||
| { type: "punch"; targetId: PlayerId }
|
| { type: "punch"; targetId: PlayerId }
|
||||||
| { type: "tearTreasure"; targetId: PlayerId }
|
| { type: "tearTreasure"; targetId: PlayerId }
|
||||||
@@ -798,6 +809,8 @@ export type Command =
|
|||||||
| { type: "testIllusion"; cell: Cell; side: Side }
|
| { type: "testIllusion"; cell: Cell; side: Side }
|
||||||
| { type: "armWard" }
|
| { type: "armWard" }
|
||||||
| { type: "wardChoice"; play: boolean }
|
| { type: "wardChoice"; play: boolean }
|
||||||
|
/** The pushed wizard's answer at a fork: the side they go. */
|
||||||
|
| { type: "pushChoice"; direction: Side }
|
||||||
| { type: "slowDeathChoice"; counterInstanceId?: string }
|
| { type: "slowDeathChoice"; counterInstanceId?: string }
|
||||||
| { type: "warpStep" }
|
| { type: "warpStep" }
|
||||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||||
@@ -2364,7 +2377,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
|
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
||||||
const d = walkingDistance(state, caster.position, target.position);
|
// Rev 16: the charge is measured as far as the legs reach. Before it,
|
||||||
|
// the search stopped at six steps and called anything past that
|
||||||
|
// unreachable — a goat with eight legs refused a seven-step ram.
|
||||||
|
const reach = (state.config.deckRev ?? 1) >= 16 ? legs : 6;
|
||||||
|
const d = walkingDistance(state, caster.position, target.position, reach);
|
||||||
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -2372,7 +2389,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
||||||
if ((ctx.state.config.deckRev ?? 1) >= 10) {
|
if ((ctx.state.config.deckRev ?? 1) >= 10) {
|
||||||
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
|
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
|
||||||
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position);
|
const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6;
|
||||||
|
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
|
||||||
if (d === 0) return;
|
if (d === 0) return;
|
||||||
if (d > legs) {
|
if (d > legs) {
|
||||||
// The target slipped beyond the charge (a counter-teleport, say):
|
// The target slipped beyond the charge (a counter-teleport, say):
|
||||||
@@ -2859,6 +2877,48 @@ function waveForce(range: number, dist: number): number {
|
|||||||
return range - dist;
|
return range - dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One line of a wave's sweep: the squares it reaches, nearest first, with
|
||||||
|
* the force left at each. The victims are named before any is pushed
|
||||||
|
* (rev 20). Before it the sweep, walking the way it pushed, could find a
|
||||||
|
* victim it had just shoved and shove them again with the force left: a
|
||||||
|
* wizard washed one square into a wall was crushed twice. Older games
|
||||||
|
* replay that sweep. */
|
||||||
|
function sweepWave(
|
||||||
|
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
|
||||||
|
): void {
|
||||||
|
const gather = ({ cell, force }: { cell: Cell; force: number }) => ({
|
||||||
|
force,
|
||||||
|
players: state.players.filter((p) => p.alive && cellKey(p.position) === cellKey(cell)),
|
||||||
|
creatures: state.creatures.filter((c) => cellKey(c.position) === cellKey(cell)),
|
||||||
|
slime: state.squareContents[cellKey(cell)]?.kind === "slime" ? cell : null,
|
||||||
|
});
|
||||||
|
const apply = (hit: ReturnType<typeof gather>) => {
|
||||||
|
for (const p of hit.players) washBackN(state, events, p, dir, hit.force);
|
||||||
|
for (const c of hit.creatures) {
|
||||||
|
if (!state.creatures.includes(c)) continue;
|
||||||
|
if (c.kind === "fire-imp") destroyCreature(state, events, c, reason);
|
||||||
|
else washBackCreature(state, events, c, dir, hit.force);
|
||||||
|
}
|
||||||
|
if (hit.slime) {
|
||||||
|
delete state.squareContents[cellKey(hit.slime)];
|
||||||
|
delete state.slimeTraps[cellKey(hit.slime)];
|
||||||
|
events.push({ type: "slimeWashed", cell: hit.slime });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ((state.config.deckRev ?? 1) >= 20) line.map(gather).forEach(apply);
|
||||||
|
else for (const step of line) apply(gather(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
function waveLine(start: Cell, dir: Side, range: number): { cell: Cell; force: number }[] {
|
||||||
|
const line: { cell: Cell; force: number }[] = [];
|
||||||
|
let probe = start;
|
||||||
|
for (let dist = 0; dist < range; dist++) {
|
||||||
|
line.push({ cell: probe, force: waveForce(range, dist) });
|
||||||
|
probe = neighbor(probe, dir);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
/** A collapsing waterwall wave from an edge: wash players back `range`. */
|
/** A collapsing waterwall wave from an edge: wash players back `range`. */
|
||||||
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
|
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
|
||||||
// A warp mouth's "far side" is the opposite rim: the collapse washes both
|
// A warp mouth's "far side" is the opposite rim: the collapse washes both
|
||||||
@@ -2873,51 +2933,12 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
|
|||||||
{ start: cell, dir: opposite(side) },
|
{ start: cell, dir: opposite(side) },
|
||||||
{ start: neighbor(cell, side), dir: side },
|
{ start: neighbor(cell, side), dir: side },
|
||||||
];
|
];
|
||||||
for (const { start, dir } of pushes) {
|
for (const { start, dir } of pushes) sweepWave(state, events, waveLine(start, dir, range), dir, reason);
|
||||||
let probe = start;
|
|
||||||
for (let dist = 0; dist < range; dist++) {
|
|
||||||
const force = waveForce(range, dist);
|
|
||||||
for (const p of state.players) {
|
|
||||||
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
|
|
||||||
}
|
|
||||||
for (const c of [...state.creatures]) {
|
|
||||||
if (cellKey(c.position) !== cellKey(probe)) continue;
|
|
||||||
if (c.kind === "fire-imp") {
|
|
||||||
destroyCreature(state, events, c, reason);
|
|
||||||
} else {
|
|
||||||
washBackCreature(state, events, c, dir, force);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.squareContents[cellKey(probe)]?.kind === "slime") {
|
|
||||||
delete state.squareContents[cellKey(probe)];
|
|
||||||
delete state.slimeTraps[cellKey(probe)];
|
|
||||||
events.push({ type: "slimeWashed", cell: probe });
|
|
||||||
}
|
|
||||||
probe = neighbor(probe, dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A wave bursting outward from a cell in all four directions. */
|
/** A wave bursting outward from a cell in all four directions. */
|
||||||
function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range: number): void {
|
function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range: number): void {
|
||||||
for (const dir of SIDES) {
|
for (const dir of SIDES) sweepWave(state, events, waveLine(neighbor(center, dir), dir, range), dir, "rushing water");
|
||||||
let probe = center;
|
|
||||||
for (let dist = 0; dist < range; dist++) {
|
|
||||||
probe = neighbor(probe, dir);
|
|
||||||
const force = waveForce(range, dist);
|
|
||||||
for (const p of state.players) {
|
|
||||||
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
|
|
||||||
}
|
|
||||||
for (const c of [...state.creatures]) {
|
|
||||||
if (cellKey(c.position) !== cellKey(probe)) continue;
|
|
||||||
if (c.kind === "fire-imp") {
|
|
||||||
destroyCreature(state, events, c, "rushing water");
|
|
||||||
} else {
|
|
||||||
washBackCreature(state, events, c, dir, force);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */
|
/** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */
|
||||||
@@ -3680,7 +3701,9 @@ function attachSustained(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
|
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
|
||||||
function walkingDistance(state: GameState, from: Cell, to: Cell): number {
|
/** Steps along real corridors from one cell to another, searched out to
|
||||||
|
* `limit` steps; beyond that the answer is Infinity. */
|
||||||
|
export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
|
||||||
if (cellKey(from) === cellKey(to)) return 0;
|
if (cellKey(from) === cellKey(to)) return 0;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
||||||
@@ -3688,7 +3711,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number {
|
|||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const cur = queue.shift()!;
|
const cur = queue.shift()!;
|
||||||
const d = seen.get(cellKey(cur))!;
|
const d = seen.get(cellKey(cur))!;
|
||||||
if (d >= 6) break;
|
if (d >= limit) break;
|
||||||
for (const side of SIDES) {
|
for (const side of SIDES) {
|
||||||
const step = stepTarget(view, cur, side);
|
const step = stepTarget(view, cur, side);
|
||||||
if (step.kind === "blocked") continue;
|
if (step.kind === "blocked") continue;
|
||||||
@@ -3820,6 +3843,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
wallDamage: {},
|
wallDamage: {},
|
||||||
slimeTraps: {},
|
slimeTraps: {},
|
||||||
wardPending: null,
|
wardPending: null,
|
||||||
|
pushPending: null,
|
||||||
slowDeathPending: null,
|
slowDeathPending: null,
|
||||||
chaosPending: null,
|
chaosPending: null,
|
||||||
doorStates: {},
|
doorStates: {},
|
||||||
@@ -3900,6 +3924,13 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
return doWardChoice(state, command.play);
|
return doWardChoice(state, command.play);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BIG MAN's fork: the stride hangs while the pushed wizard picks a way.
|
||||||
|
if (state.pushPending) {
|
||||||
|
if (playerId !== state.pushPending.pusheeId) return err("waiting on the pushed wizard to choose a way");
|
||||||
|
if (command.type !== "pushChoice") return err("choose which way you are pushed");
|
||||||
|
return doPushChoice(state, command.direction);
|
||||||
|
}
|
||||||
|
|
||||||
// SLOW DEATH's window: the bites hang while the victim weighs an absorb.
|
// SLOW DEATH's window: the bites hang while the victim weighs an absorb.
|
||||||
if (state.slowDeathPending) {
|
if (state.slowDeathPending) {
|
||||||
if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim");
|
if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim");
|
||||||
@@ -4019,7 +4050,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (command.type) {
|
switch (command.type) {
|
||||||
case "move": return doMove(state, command.direction, command.over === true);
|
case "move": return doMove(state, command.direction, command.over === true, command.exit);
|
||||||
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
|
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
|
||||||
case "punch": return doPunch(state, command.targetId);
|
case "punch": return doPunch(state, command.targetId);
|
||||||
case "tearTreasure": return doTearTreasure(state, command.targetId);
|
case "tearTreasure": return doTearTreasure(state, command.targetId);
|
||||||
@@ -4027,6 +4058,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
||||||
case "armWard": return doArmWard();
|
case "armWard": return doArmWard();
|
||||||
case "wardChoice": return err("no grab is hanging on your Ward");
|
case "wardChoice": return err("no grab is hanging on your Ward");
|
||||||
|
case "pushChoice": return err("nobody is pushing you");
|
||||||
case "slowDeathChoice": return err("no slow death bites are hanging");
|
case "slowDeathChoice": return err("no slow death bites are hanging");
|
||||||
case "warpStep": return doWarpStep(state);
|
case "warpStep": return doWarpStep(state);
|
||||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||||
@@ -4054,8 +4086,22 @@ export function activePlayer(state: GameState): PlayerState {
|
|||||||
return state.players[state.turn.activeIndex]!;
|
return state.players[state.turn.activeIndex]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The state is plain data — literals, arrays, numbers (Infinity
|
||||||
|
* included), strings, booleans, and the odd undefined — so a recursive
|
||||||
|
* copy serves, and runs several times faster than structuredClone. Every
|
||||||
|
* command clones once, and a server boot replays every ledger. */
|
||||||
function clone(state: GameState): GameState {
|
function clone(state: GameState): GameState {
|
||||||
return structuredClone(state);
|
return deepCopy(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepCopy<T>(value: T): T {
|
||||||
|
if (Array.isArray(value)) return value.map(deepCopy) as T;
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key in value) out[key] = deepCopy((value as Record<string, unknown>)[key]);
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything an eliminated wizard leaves behind goes with them.
|
/** Everything an eliminated wizard leaves behind goes with them.
|
||||||
@@ -4170,7 +4216,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct
|
|||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
function doMove(prev: GameState, direction: Side, over = false, exit?: Side, shove?: Side): CommandResult {
|
||||||
const blocked = requireActionsAvailable(prev);
|
const blocked = requireActionsAvailable(prev);
|
||||||
if (blocked) return err(blocked);
|
if (blocked) return err(blocked);
|
||||||
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
||||||
@@ -4184,7 +4230,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
|
|
||||||
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
||||||
// move ... bumping into a wall counts as one space of movement."
|
// move ... bumping into a wall counts as one space of movement."
|
||||||
if (isBlinded(state, p)) {
|
if (isBlinded(state, p) && shove === undefined) {
|
||||||
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
|
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4314,24 +4360,59 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
p.position = from;
|
p.position = from;
|
||||||
return err("someone there is stuck fast and cannot be pushed");
|
return err("someone there is stuck fast and cannot be pushed");
|
||||||
}
|
}
|
||||||
const shove = stepTarget(view, p.position, direction);
|
const canShoveTo = (d: Side) => {
|
||||||
const shoveOk =
|
const t = stepTarget(view, p.position, d);
|
||||||
shove.kind === "step" &&
|
return t.kind === "step" &&
|
||||||
state.squareContents[cellKey(shove.to)]?.kind !== "stone" &&
|
state.squareContents[cellKey(t.to)]?.kind !== "stone" &&
|
||||||
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(shove.to) &&
|
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(t.to) &&
|
||||||
sustainedOn(state, o.id, "big-man").length > 0);
|
sustainedOn(state, o.id, "big-man").length > 0)
|
||||||
if (!shoveOk) {
|
? t.to : null;
|
||||||
|
};
|
||||||
|
// Rev 15 (FAQ): "If Big Man pushes someone to an intersection, then
|
||||||
|
// the other player gets to decide which direction to go." The
|
||||||
|
// pushed one leaves by any open side but the way the giant came;
|
||||||
|
// one open side is taken, several are theirs to choose — the stride
|
||||||
|
// hangs until they do. Older games shoved straight ahead only.
|
||||||
|
// A game dealt before rev 15 shoves straight ahead when it can, as it
|
||||||
|
// always did; the corner and the fork were refusals before, so every
|
||||||
|
// game may round a corner now.
|
||||||
|
const fork = (state.config.deckRev ?? 1) >= 15;
|
||||||
|
let landing: Cell | null = null;
|
||||||
|
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
|
||||||
|
if (exits.length === 0) {
|
||||||
|
p.position = from;
|
||||||
|
return err("no room to push them onward");
|
||||||
|
}
|
||||||
|
if (fork || !exits.includes(direction)) {
|
||||||
|
const chosen = shove ?? (exits.length === 1 ? exits[0]! : null);
|
||||||
|
if (chosen === null) {
|
||||||
|
// Hang the stride: nothing has changed but the question.
|
||||||
|
const pushee = pushPlayers[0];
|
||||||
|
if (pushee) {
|
||||||
|
const held = clone(prev);
|
||||||
|
held.pushPending = { giantId: p.id, pusheeId: pushee.id, direction, over, ...(exit ? { exit } : {}), exits };
|
||||||
|
return { ok: true, state: held, events: [{ type: "pushWindow", giant: p.id, pushee: pushee.id, exits }] };
|
||||||
|
}
|
||||||
|
landing = canShoveTo(exits.includes(direction) ? direction : exits[0]!);
|
||||||
|
} else {
|
||||||
|
if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen} — the corridor leads ${exits.join(" or ")}`); }
|
||||||
|
landing = canShoveTo(chosen);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
landing = canShoveTo(direction);
|
||||||
|
}
|
||||||
|
if (!landing) {
|
||||||
p.position = from;
|
p.position = from;
|
||||||
return err("no room to push them onward");
|
return err("no room to push them onward");
|
||||||
}
|
}
|
||||||
for (const o of pushPlayers) {
|
for (const o of pushPlayers) {
|
||||||
const oFrom = o.position;
|
const oFrom = o.position;
|
||||||
o.position = { ...shove.to };
|
o.position = { ...landing };
|
||||||
events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position });
|
events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position });
|
||||||
}
|
}
|
||||||
for (const c of pushCreatures) {
|
for (const c of pushCreatures) {
|
||||||
const cFrom = c.position;
|
const cFrom = c.position;
|
||||||
c.position = { ...shove.to };
|
c.position = { ...landing };
|
||||||
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
|
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4346,6 +4427,47 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
// fall in (2 damage, movement over); otherwise you sail across to the far
|
// fall in (2 damage, movement over); otherwise you sail across to the far
|
||||||
// side (if there is open floor there).
|
// side (if there is open floor there).
|
||||||
if (content?.kind === "pit" && !misted && !p.inPit) {
|
if (content?.kind === "pit" && !misted && !p.inPit) {
|
||||||
|
// Rev 14: the rim is a ledge that runs the whole way round. Before any
|
||||||
|
// roll, the walker needs somewhere off it — straight ahead, or the
|
||||||
|
// named side — or the pit cannot be entered from here at all.
|
||||||
|
const ledge = (state.config.deckRev ?? 1) >= 14;
|
||||||
|
let landing: Side | null = null;
|
||||||
|
let landingCell: Cell | null = null;
|
||||||
|
let landingWarp = false;
|
||||||
|
if (ledge) {
|
||||||
|
const pitCell = p.position;
|
||||||
|
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => {
|
||||||
|
const t = stepTarget(view, pitCell, d);
|
||||||
|
if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null;
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
const ways = SIDES.filter((d) => d !== opposite(direction));
|
||||||
|
const floor = ways.filter((d) => footing(d)?.kind === "step");
|
||||||
|
const mouths = ways.filter((d) => footing(d)?.kind === "warp");
|
||||||
|
// A warp mouth on the rim is a way off it like any square, and may
|
||||||
|
// always be named. Only what a bare step does is a matter of
|
||||||
|
// revision: from rev 17 a square beside a mouth is a fork to name;
|
||||||
|
// before it the step took the square, and takes it still, so the
|
||||||
|
// recorded games replay as they were played.
|
||||||
|
const exits = [...floor, ...mouths];
|
||||||
|
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
|
||||||
|
if (exits.length === 0) {
|
||||||
|
p.position = from;
|
||||||
|
return err("the pit cannot be crossed from here — nothing to land on beside it");
|
||||||
|
}
|
||||||
|
if (exit !== undefined && !exits.includes(exit)) {
|
||||||
|
p.position = from;
|
||||||
|
return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`);
|
||||||
|
}
|
||||||
|
landing = exit ?? (bare.length === 1 ? bare[0]! : null);
|
||||||
|
if (landing === null) {
|
||||||
|
p.position = from;
|
||||||
|
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
|
||||||
|
}
|
||||||
|
const foot = footing(landing)!;
|
||||||
|
landingCell = foot.to;
|
||||||
|
landingWarp = foot.kind === "warp";
|
||||||
|
}
|
||||||
const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
|
const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
|
||||||
if (roll === 1) {
|
if (roll === 1) {
|
||||||
p.inPit = true;
|
p.inPit = true;
|
||||||
@@ -4356,21 +4478,21 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
const beyond = neighbor(p.position, direction);
|
const beyond = landingCell ?? neighbor(p.position, direction);
|
||||||
const beyondOk =
|
const beyondOk = landingCell !== null || (
|
||||||
view.cells[cellKey(beyond)] &&
|
view.cells[cellKey(beyond)] &&
|
||||||
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
|
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
|
||||||
state.squareContents[cellKey(beyond)]?.kind !== "stone";
|
state.squareContents[cellKey(beyond)]?.kind !== "stone");
|
||||||
if (beyondOk) {
|
if (beyondOk) {
|
||||||
const pitCell = p.position;
|
const pitCell = p.position;
|
||||||
p.position = beyond;
|
p.position = beyond;
|
||||||
events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond });
|
events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond, ...(landingWarp ? { via: "warp" as const } : {}) });
|
||||||
content = state.squareContents[cellKey(p.position)];
|
content = state.squareContents[cellKey(p.position)];
|
||||||
} else {
|
} else {
|
||||||
// Nowhere to land: teeter back where you started.
|
// Nowhere to land: teeter back where you started.
|
||||||
p.position = from;
|
p.position = from;
|
||||||
state.turn.movementUsed++;
|
state.turn.movementUsed++;
|
||||||
events.push({ type: "moveBumped", player: p.id, direction });
|
events.push({ type: "moveBumped", player: p.id, direction, why: "pit" });
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4703,6 +4825,18 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
|
|||||||
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
|
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The pushed wizard's answer at a fork: the giant's hanging stride
|
||||||
|
* resumes, shoving them the way they chose. */
|
||||||
|
function doPushChoice(prev: GameState, direction: Side): CommandResult {
|
||||||
|
const pp = prev.pushPending!;
|
||||||
|
if (!pp.exits.includes(direction)) return err(`the corridor leads ${pp.exits.join(" or ")}`);
|
||||||
|
const state = clone(prev);
|
||||||
|
state.pushPending = null;
|
||||||
|
const r = doMove(state, pp.direction, pp.over, pp.exit, direction);
|
||||||
|
if (!r.ok) return r;
|
||||||
|
return { ok: true, state: r.state, events: [{ type: "pushChosen", pushee: pp.pusheeId, direction }, ...r.events] };
|
||||||
|
}
|
||||||
|
|
||||||
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */
|
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */
|
||||||
function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
||||||
const state = clone(prev);
|
const state = clone(prev);
|
||||||
@@ -5743,6 +5877,8 @@ function doCounteract(
|
|||||||
): CommandResult {
|
): CommandResult {
|
||||||
const state = clone(prev);
|
const state = clone(prev);
|
||||||
const stack = state.stack!;
|
const stack = state.stack!;
|
||||||
|
/** What the counter is played against, as the chronicle names it. */
|
||||||
|
const against = stack.attackCard?.cardId ?? stack.sourceName ?? "punch";
|
||||||
const player = state.players.find((p) => p.id === playerId)!;
|
const player = state.players.find((p) => p.id === playerId)!;
|
||||||
const card = player.hand.find((c) => c.instanceId === instanceId);
|
const card = player.hand.find((c) => c.instanceId === instanceId);
|
||||||
if (!card) return err("card not in hand");
|
if (!card) return err("card not in hand");
|
||||||
@@ -5779,7 +5915,7 @@ function doCounteract(
|
|||||||
}
|
}
|
||||||
player.hand.push(attackCard);
|
player.hand.push(attackCard);
|
||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
|
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against },
|
||||||
{ type: "attackAbsorbedIntoHand", player: playerId, attackCard },
|
{ type: "attackAbsorbedIntoHand", player: playerId, attackCard },
|
||||||
{ type: "attackResolved", attacker: stack.attackerId, defender: stack.defenderId, attackCardId: attackCard.cardId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false },
|
{ type: "attackResolved", attacker: stack.attackerId, defender: stack.defenderId, attackCardId: attackCard.cardId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false },
|
||||||
];
|
];
|
||||||
@@ -5809,7 +5945,7 @@ function doCounteract(
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
state,
|
state,
|
||||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
|
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5821,7 +5957,7 @@ function doCounteract(
|
|||||||
takeFromHand(player, instanceId);
|
takeFromHand(player, instanceId);
|
||||||
state.discard.push(card);
|
state.discard.push(card);
|
||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
|
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against },
|
||||||
];
|
];
|
||||||
attachSustained(state, events, "invisible", playerId, playerId, duration);
|
attachSustained(state, events, "invisible", playerId, playerId, duration);
|
||||||
stack.counters.push({ player: playerId, card, nullified: false });
|
stack.counters.push({ player: playerId, card, nullified: false });
|
||||||
@@ -5838,7 +5974,7 @@ function doCounteract(
|
|||||||
takeFromHand(player, instanceId);
|
takeFromHand(player, instanceId);
|
||||||
state.discard.push(card);
|
state.discard.push(card);
|
||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
|
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against },
|
||||||
];
|
];
|
||||||
attachSustained(state, events, "empathy", playerId, playerId, duration);
|
attachSustained(state, events, "empathy", playerId, playerId, duration);
|
||||||
stack.counters.push({ player: playerId, card, nullified: false });
|
stack.counters.push({ player: playerId, card, nullified: false });
|
||||||
@@ -5877,7 +6013,7 @@ function doCounteract(
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
state,
|
state,
|
||||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
|
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5897,7 +6033,7 @@ function doCounteract(
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
state,
|
state,
|
||||||
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
|
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5999,6 +6135,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
|
|
||||||
const attackId = stack.attackCard?.cardId ?? null;
|
const attackId = stack.attackCard?.cardId ?? null;
|
||||||
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
|
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
|
||||||
|
/** REFLECTION's half, when it goes back as an attack of its own. */
|
||||||
|
let returned: { damage: number; duration: number } | null = null;
|
||||||
|
|
||||||
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
|
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
|
||||||
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
|
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
|
||||||
@@ -6236,17 +6374,26 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
state.discard.push(card!);
|
state.discard.push(card!);
|
||||||
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
|
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
|
||||||
}
|
}
|
||||||
if (pipe.reflectedDamage > 0 && !stack.trapped) {
|
// Rev 18: REFLECTION's returning half is a blow on the caster like
|
||||||
|
// FULL REFLECTION's whole — a fresh attack with the caster's own
|
||||||
|
// counteraction window, so an ABSORB or a BLUNT can meet it. Older
|
||||||
|
// games landed the half at once, and replay so.
|
||||||
|
const halfReturns = (state.config.deckRev ?? 1) >= 18 && !stack.trapped && !attackingCreature &&
|
||||||
|
attacker.alive && (pipe.reflectedDamage > 0 || (pipe.splitDuration && pipe.duration > 0));
|
||||||
|
if (pipe.reflectedDamage > 0 && !stack.trapped && !halfReturns) {
|
||||||
if (attackingCreature) {
|
if (attackingCreature) {
|
||||||
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
|
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
|
||||||
} else {
|
} else {
|
||||||
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
|
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (halfReturns) {
|
||||||
|
returned = { damage: pipe.reflectedDamage, duration: pipe.splitDuration ? pipe.duration : 0 };
|
||||||
|
}
|
||||||
// EMPATHY: "Any attack done in any form against you acts against both
|
// EMPATHY: "Any attack done in any form against you acts against both
|
||||||
// you and the caster of the spell."
|
// you and the caster of the spell."
|
||||||
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
|
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
|
||||||
applyDamage(state, events, attacker, damageDealt, `${attackId ?? "punch"} (empathy)`, defender.id, pipe.kind);
|
applyDamage(state, events, attacker, damageDealt, `${attackId ?? stack.sourceName ?? "punch"} (empathy)`, defender.id, pipe.kind);
|
||||||
}
|
}
|
||||||
// SHADOWSTONE: physical damage you deal feeds your life total.
|
// SHADOWSTONE: physical damage you deal feeds your life total.
|
||||||
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
|
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
|
||||||
@@ -6255,7 +6402,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
}
|
}
|
||||||
if (effect?.sustains && pipe.duration > 0 && !pipe.fullyStopped) {
|
if (effect?.sustains && pipe.duration > 0 && !pipe.fullyStopped) {
|
||||||
attachSustained(state, events, attackId!, attacker.id, defender.id, pipe.duration);
|
attachSustained(state, events, attackId!, attacker.id, defender.id, pipe.duration);
|
||||||
if (pipe.splitDuration) {
|
if (pipe.splitDuration && !returned) {
|
||||||
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
|
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6294,6 +6441,22 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
stack,
|
stack,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (returned && attacker.alive) {
|
||||||
|
state.stack = {
|
||||||
|
attackerId: defender.id,
|
||||||
|
defenderId: attacker.id,
|
||||||
|
attackCard: stack.attackCard,
|
||||||
|
numberValue: null,
|
||||||
|
amplifyFactor: 1,
|
||||||
|
extendFactor: 1,
|
||||||
|
powerAttackPoints: 0,
|
||||||
|
params: stack.params,
|
||||||
|
kind: "spell",
|
||||||
|
counters: [],
|
||||||
|
waitingOn: attacker.id,
|
||||||
|
reflectedBase: returned,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface PlayerPublicView {
|
|||||||
extraTurns: number;
|
extraTurns: number;
|
||||||
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
|
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
|
||||||
passWallCharges: number;
|
passWallCharges: number;
|
||||||
|
/** Down a pit, until a roll climbs them out — everyone saw the fall. */
|
||||||
|
inPit: boolean;
|
||||||
displayed: CardInstance[];
|
displayed: CardInstance[];
|
||||||
/** Which of the six physical wizard colors this player plays. */
|
/** Which of the six physical wizard colors this player plays. */
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
@@ -58,6 +60,8 @@ export interface GameView {
|
|||||||
/** Duration spells in play (public knowledge). */
|
/** Duration spells in play (public knowledge). */
|
||||||
sustained: SustainedEffect[];
|
sustained: SustainedEffect[];
|
||||||
squareContents: Record<string, SquareContent>;
|
squareContents: Record<string, SquareContent>;
|
||||||
|
/** Spells lodged in slime, oldest first (public: each cast was seen). */
|
||||||
|
slimeTraps: Record<string, { cardId: string; casterId: PlayerId }[]>;
|
||||||
/** Cells whose contents are glued down (public: the cast was seen). */
|
/** Cells whose contents are glued down (public: the cast was seen). */
|
||||||
gluedCells: Record<string, true>;
|
gluedCells: Record<string, true>;
|
||||||
/** Safes standing open (their combination entered this turn). */
|
/** Safes standing open (their combination entered this turn). */
|
||||||
@@ -89,6 +93,7 @@ export interface GameView {
|
|||||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||||
/** A grab hangs while the treasure's owner decides their Ward. */
|
/** A grab hangs while the treasure's owner decides their Ward. */
|
||||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||||
|
pushPending: GameState["pushPending"];
|
||||||
slowDeathPending: { playerId: PlayerId; points: number } | null;
|
slowDeathPending: { playerId: PlayerId; points: number } | null;
|
||||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||||
yourAmbushes: AmbushState[];
|
yourAmbushes: AmbushState[];
|
||||||
@@ -132,6 +137,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
position: p.position,
|
position: p.position,
|
||||||
home: p.home,
|
home: p.home,
|
||||||
life: p.life,
|
life: p.life,
|
||||||
|
inPit: p.inPit,
|
||||||
alive: p.alive,
|
alive: p.alive,
|
||||||
handCount: p.hand.length,
|
handCount: p.hand.length,
|
||||||
carriedTreasureId: p.carriedTreasureId,
|
carriedTreasureId: p.carriedTreasureId,
|
||||||
@@ -152,6 +158,9 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
pendingDiscard: state.pendingDiscard,
|
pendingDiscard: state.pendingDiscard,
|
||||||
sustained: state.sustained.map((s) => ({ ...s })),
|
sustained: state.sustained.map((s) => ({ ...s })),
|
||||||
squareContents: { ...state.squareContents },
|
squareContents: { ...state.squareContents },
|
||||||
|
slimeTraps: Object.fromEntries(
|
||||||
|
Object.entries(state.slimeTraps).map(([k, traps]) => [k, traps.map((t) => ({ cardId: t.card.cardId, casterId: t.casterId }))]),
|
||||||
|
),
|
||||||
gluedCells: { ...state.gluedCells },
|
gluedCells: { ...state.gluedCells },
|
||||||
openSafes: [...state.openSafes],
|
openSafes: [...state.openSafes],
|
||||||
groundObjects: Object.fromEntries(
|
groundObjects: Object.fromEntries(
|
||||||
@@ -173,6 +182,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
||||||
: null,
|
: null,
|
||||||
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
||||||
|
pushPending: state.pushPending ? { ...state.pushPending, exits: [...state.pushPending.exits] } : null,
|
||||||
slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null,
|
slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null,
|
||||||
yourAmbushes: state.ambushes
|
yourAmbushes: state.ambushes
|
||||||
.filter((a) => a.ownerId === playerId)
|
.filter((a) => a.ownerId === playerId)
|
||||||
@@ -188,12 +198,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Every cell the viewing player can see from where they stand, by the same
|
|
||||||
* line-of-sight rules the engine enforces — computed from the VIEW, so it
|
|
||||||
* reflects what this player knows (illusion walls they believe in block it).
|
|
||||||
* The basis for the client's "dim the ineligible squares" targeting aid.
|
|
||||||
*/
|
|
||||||
/** Every square inside a living FEAR-bearer's aura — dreadDistance walks
|
/** Every square inside a living FEAR-bearer's aura — dreadDistance walks
|
||||||
* through warps, the engine's own yardstick, so the painted aura and the
|
* through warps, the engine's own yardstick, so the painted aura and the
|
||||||
* movement refusal can never disagree. */
|
* movement refusal can never disagree. */
|
||||||
@@ -210,13 +214,25 @@ export function fearCells(view: GameView): Set<string> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every cell the viewing player can see from where they stand, by the same
|
||||||
|
* line-of-sight rules the engine enforces — computed from the VIEW, so it
|
||||||
|
* reflects what this player knows (illusion walls they believe in block it).
|
||||||
|
* The basis for the client's "dim the ineligible squares" targeting aid.
|
||||||
|
*/
|
||||||
export function sightedCellsFor(view: GameView): Set<string> {
|
export function sightedCellsFor(view: GameView): Set<string> {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
const me = view.players.find((p) => p.id === view.you);
|
const me = view.players.find((p) => p.id === view.you);
|
||||||
if (!me) return out;
|
if (!me) return out;
|
||||||
const { board, blockers } = sightBasis(view);
|
const { board, blockers } = sightBasis(view);
|
||||||
|
// In a dust cloud a wizard sees only their own square (rev 19).
|
||||||
|
if (dustAtEnd(view, me.position, me.position, true)) {
|
||||||
|
out.add(`${me.position.x},${me.position.y}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
for (const key of Object.keys(board.cells)) {
|
for (const key of Object.keys(board.cells)) {
|
||||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
|
if (dustAtEnd(view, me.position, { x, y })) continue;
|
||||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||||
}
|
}
|
||||||
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
||||||
@@ -309,16 +325,29 @@ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Recor
|
|||||||
* exists — the renderer's material for drawing the line an attack traveled.
|
* exists — the renderer's material for drawing the line an attack traveled.
|
||||||
*/
|
*/
|
||||||
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
|
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
|
||||||
|
if (dustAtEnd(view, from, to)) return null;
|
||||||
const { board, blockers } = sightBasis(view);
|
const { board, blockers } = sightBasis(view);
|
||||||
return traceSight(board, from, to, blockers);
|
return traceSight(board, from, to, blockers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells;
|
||||||
|
* a square sees itself still. `standing` asks only whether the viewer's
|
||||||
|
* own square is dust. */
|
||||||
|
function dustAtEnd(view: GameView, from: Cell, to: Cell, standing = false): boolean {
|
||||||
|
if (view.deckRev < 19) return false;
|
||||||
|
const dust = (c: Cell) => view.squareContents[`${c.x},${c.y}`]?.kind === "dust";
|
||||||
|
if (standing) return dust(from);
|
||||||
|
if (from.x === to.x && from.y === to.y) return false;
|
||||||
|
return dust(from) || dust(to);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
|
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
|
||||||
* sees a middle square which sees the target — mirroring the engine's
|
* sees a middle square which sees the target — mirroring the engine's
|
||||||
* bentLos so a client can predict whether the modifier will land.
|
* bentLos so a client can predict whether the modifier will land.
|
||||||
*/
|
*/
|
||||||
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||||
|
if (dustAtEnd(view, from, to)) return false;
|
||||||
const { board, blockers } = sightBasis(view);
|
const { board, blockers } = sightBasis(view);
|
||||||
if (sightBetween(board, from, to, blockers)) return true;
|
if (sightBetween(board, from, to, blockers)) return true;
|
||||||
for (const key of Object.keys(board.cells)) {
|
for (const key of Object.keys(board.cells)) {
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import {
|
|||||||
} from "../src/game";
|
} from "../src/game";
|
||||||
import { cellKey, edgeKey } from "../src/board";
|
import { cellKey, edgeKey } from "../src/board";
|
||||||
import { sightedCellsFor, viewFor } from "../src/view";
|
import { sightedCellsFor, viewFor } from "../src/view";
|
||||||
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||||
import { pushSustained } from "./helpers";
|
import { pushSustained } from "./helpers";
|
||||||
|
|
||||||
/** Whose input does the maze want right now? */
|
/** Whose input does the maze want right now? */
|
||||||
function actingSeat(state: GameState): PlayerId {
|
function actingSeat(state: GameState): PlayerId {
|
||||||
return (
|
return (
|
||||||
state.wardPending?.ownerId ??
|
state.wardPending?.ownerId ??
|
||||||
|
state.pushPending?.pusheeId ??
|
||||||
state.slowDeathPending?.playerId ??
|
state.slowDeathPending?.playerId ??
|
||||||
state.stack?.waitingOn ??
|
state.stack?.waitingOn ??
|
||||||
state.pendingDiscard ??
|
state.pendingDiscard ??
|
||||||
@@ -1110,3 +1111,83 @@ describe("interception, escape, and hazard sense", () => {
|
|||||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the clockwork under a curse, and before a pit", () => {
|
||||||
|
/** A two-seat game advanced to the bot's own turn in round 2. */
|
||||||
|
function botsTurn() {
|
||||||
|
let { state } = createGame({ playerIds: ["human", "bot"], seed: 7, sets: ["basic", "expansion1"] });
|
||||||
|
for (let guard = 0; guard < 8; guard++) {
|
||||||
|
if (state.turn.round >= 2 && actingSeat(state) === "bot") break;
|
||||||
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
|
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("draws only what SLOW DEATH lets it afford", () => {
|
||||||
|
const state = botsTurn();
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const plain = viewFor(state, "bot");
|
||||||
|
expect(drawUnderCurses(plain, 2)).toBe(2);
|
||||||
|
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
|
||||||
|
bot.life = 7;
|
||||||
|
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2);
|
||||||
|
bot.life = 5;
|
||||||
|
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1);
|
||||||
|
bot.life = 4;
|
||||||
|
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
|
||||||
|
for (const rig of [
|
||||||
|
(s: GameState) => pushSustained(s, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }),
|
||||||
|
(s: GameState) => { s.players.find((p) => p.id === "bot")!.life = 5; },
|
||||||
|
]) {
|
||||||
|
let state = botsTurn();
|
||||||
|
rig(state);
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
bot.hand = [{ instanceId: "shadow#T", cardId: "shadow" }];
|
||||||
|
// Play the bot's whole turn: nothing it does may be the shadow.
|
||||||
|
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
|
||||||
|
const view = viewFor(state, "bot");
|
||||||
|
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
|
||||||
|
expect(JSON.stringify(cmd)).not.toContain("shadow#T");
|
||||||
|
const r = applyCommand(state, "bot", cmd);
|
||||||
|
if (!r.ok) break;
|
||||||
|
state = r.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never plans a stride into a pit it cannot leap beyond", () => {
|
||||||
|
const state = botsTurn();
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const view0 = viewFor(state, "bot");
|
||||||
|
// Stand the bot at the head of a straight three-square run, dig a pit
|
||||||
|
// in the middle square, and wall the far one with stone: the route in
|
||||||
|
// from here is no route. With the stone gone, the leap is a road again.
|
||||||
|
const delta = { N: { x: 0, y: -1 }, E: { x: 1, y: 0 }, S: { x: 0, y: 1 }, W: { x: -1, y: 0 } } as const;
|
||||||
|
for (const k of Object.keys(view0.board.cells)) {
|
||||||
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||||
|
for (const dir of ["N", "E", "S", "W"] as const) {
|
||||||
|
const pit = { x: x + delta[dir].x, y: y + delta[dir].y };
|
||||||
|
const beyond = { x: pit.x + delta[dir].x, y: pit.y + delta[dir].y };
|
||||||
|
if (!view0.board.cells[cellKey(pit)] || !view0.board.cells[cellKey(beyond)]) continue;
|
||||||
|
if ((view0.board.edges[edgeKey({ x, y }, dir)] ?? "open") !== "open") continue;
|
||||||
|
if ((view0.board.edges[edgeKey(pit, dir)] ?? "open") !== "open") continue;
|
||||||
|
if (state.squareContents[k] || state.squareContents[cellKey(pit)] || state.squareContents[cellKey(beyond)]) continue;
|
||||||
|
bot.position = { x, y };
|
||||||
|
state.squareContents[cellKey(pit)] = { kind: "pit", damage: 0, createdBy: "human" };
|
||||||
|
state.squareContents[cellKey(beyond)] = { kind: "stone", damage: 0, createdBy: "human" };
|
||||||
|
const blocked = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
|
||||||
|
expect(blocked === null || blocked.dir !== dir).toBe(true);
|
||||||
|
delete state.squareContents[cellKey(beyond)];
|
||||||
|
const open = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
|
||||||
|
expect(open?.dir).toBe(dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("no straight three-square run on this board");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -117,6 +117,48 @@ describe("attack spells", () => {
|
|||||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reflection's returning half is the caster's to counteract (rev 18)", () => {
|
||||||
|
const setup = (deckRev: number) => {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const fb = giveCard(state, attacker, "fireball");
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
d.hand[0] = { instanceId: "reflection#T", cardId: "reflection" };
|
||||||
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
|
a.hand.push({ instanceId: "absorb#T", cardId: "absorb" });
|
||||||
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||||
|
state = must(state, defender, { type: "counteract", instanceId: "reflection#T" });
|
||||||
|
state = must(state, attacker, { type: "pass" });
|
||||||
|
state = must(state, defender, { type: "pass" });
|
||||||
|
return { state, attacker, defender };
|
||||||
|
};
|
||||||
|
const life = (s: GameState, id: string) => s.players.find((p) => p.id === id)!.life;
|
||||||
|
// Rev 18: the defender takes their half; the half coming back waits on the caster.
|
||||||
|
{
|
||||||
|
const { state, attacker, defender } = setup(18);
|
||||||
|
expect(life(state, defender)).toBe(12);
|
||||||
|
expect(life(state, attacker)).toBe(15);
|
||||||
|
expect(state.stack?.waitingOn).toBe(attacker);
|
||||||
|
expect(state.stack?.reflectedBase?.damage).toBe(3);
|
||||||
|
// A counter hands the word to the reflector, who may answer; both pass to resolve.
|
||||||
|
const soaked = must(state, attacker, { type: "counteract", instanceId: "absorb#T" });
|
||||||
|
const done = must(must(soaked, defender, { type: "pass" }), attacker, { type: "pass" });
|
||||||
|
expect(life(done, attacker)).toBe(15);
|
||||||
|
expect(done.stack).toBeNull();
|
||||||
|
const taken = must(state, attacker, { type: "pass" });
|
||||||
|
expect(life(taken, attacker)).toBe(12);
|
||||||
|
expect(taken.stack).toBeNull();
|
||||||
|
}
|
||||||
|
// Rev 17: the half landed at once.
|
||||||
|
{
|
||||||
|
const { state, attacker, defender } = setup(17);
|
||||||
|
expect(life(state, defender)).toBe(12);
|
||||||
|
expect(life(state, attacker)).toBe(12);
|
||||||
|
expect(state.stack).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("absorb spell nullifies the attack and takes the card into hand", () => {
|
it("absorb spell nullifies the attack and takes the card into hand", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||||
|
|
||||||
@@ -392,23 +392,112 @@ describe("big man", () => {
|
|||||||
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
|
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** From a giant's square: a step into a square whose exits (all sides but
|
||||||
|
* the way back) number exactly `want` — a corner (1, not straight) or a
|
||||||
|
* fork (2+). Returns the giant's start, the pushee's square, and the exits. */
|
||||||
|
function pushSite(state: GameState, want: "corner" | "fork") {
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const key of Object.keys(view.cells)) {
|
||||||
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
|
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const t1 = stepTarget(view, { x, y }, side);
|
||||||
|
if (t1.kind !== "step") continue;
|
||||||
|
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step");
|
||||||
|
if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits };
|
||||||
|
if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`no ${want} on this board`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pushes a wizard around a corner when that is the only way (rev 15)", () => {
|
||||||
|
const rig = bigRig();
|
||||||
|
let state = rig.state;
|
||||||
|
const site = pushSite(state, "corner");
|
||||||
|
const giant = state.players.find((p) => p.id === rig.giant.id)!;
|
||||||
|
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||||
|
giant.position = { ...site.at };
|
||||||
|
victim.position = { ...site.mid };
|
||||||
|
state = must(state, rig.giant.id, { type: "move", direction: site.side });
|
||||||
|
const after = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||||
|
expect(cellKey(after.position)).toBe(cellKey(neighbor(site.mid, site.exits[0]!)));
|
||||||
|
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("at a fork the pushed wizard chooses, and the stride hangs until they do", () => {
|
||||||
|
const rig = bigRig();
|
||||||
|
let state = rig.state;
|
||||||
|
const site = pushSite(state, "fork");
|
||||||
|
const giant = state.players.find((p) => p.id === rig.giant.id)!;
|
||||||
|
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||||
|
giant.position = { ...site.at };
|
||||||
|
victim.position = { ...site.mid };
|
||||||
|
const used = state.turn.movementUsed;
|
||||||
|
const r = applyCommand(state, rig.giant.id, { type: "move", direction: site.side });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
state = r.state;
|
||||||
|
expect(state.pushPending?.pusheeId).toBe(victim.id);
|
||||||
|
expect(state.turn.movementUsed).toBe(used);
|
||||||
|
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.at));
|
||||||
|
// Only the pushed wizard may answer, and only with pushChoice, and only an open way.
|
||||||
|
expect(applyCommand(state, rig.giant.id, { type: "move", direction: site.side }).ok).toBe(false);
|
||||||
|
expect(applyCommand(state, victim.id, { type: "pass" }).ok).toBe(false);
|
||||||
|
expect(applyCommand(state, victim.id, { type: "pushChoice", direction: opposite(site.side) }).ok).toBe(false);
|
||||||
|
const pick = site.exits[site.exits.length - 1]!;
|
||||||
|
state = must(state, victim.id, { type: "pushChoice", direction: pick });
|
||||||
|
expect(state.pushPending).toBeNull();
|
||||||
|
expect(cellKey(state.players.find((p) => p.id !== rig.giant.id)!.position)).toBe(cellKey(neighbor(site.mid, pick)));
|
||||||
|
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
|
||||||
|
expect(state.turn.movementUsed).toBe(used + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an older game shoves straight ahead when it can, and now rounds a corner when it cannot", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
|
||||||
|
state = toRound2(state);
|
||||||
|
const giant = activePlayer(state);
|
||||||
|
state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} });
|
||||||
|
const victim = state.players.find((p) => p.id !== giant.id)!;
|
||||||
|
// A fork where straight ahead is open: no window, straight it is.
|
||||||
|
const fork = pushSite(state, "fork");
|
||||||
|
if (fork.exits.includes(fork.side)) {
|
||||||
|
giant.position = { ...fork.at };
|
||||||
|
victim.position = { ...fork.mid };
|
||||||
|
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
expect(r.state.pushPending).toBeNull();
|
||||||
|
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(fork.mid, fork.side)));
|
||||||
|
}
|
||||||
|
// A corner: refused before, rounded now.
|
||||||
|
const corner = pushSite(state, "corner");
|
||||||
|
giant.position = { ...corner.at };
|
||||||
|
victim.position = { ...corner.mid };
|
||||||
|
const r = applyCommand(state, giant.id, { type: "move", direction: corner.side });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(corner.mid, corner.exits[0]!)));
|
||||||
|
});
|
||||||
|
|
||||||
it("no room to shove means no way forward", () => {
|
it("no room to shove means no way forward", () => {
|
||||||
const rig = bigRig();
|
const rig = bigRig();
|
||||||
const state = rig.state;
|
const state = rig.state;
|
||||||
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
// Find a step whose far side is walled: victim there cannot be pushed.
|
// A dead-end pocket beside some square: a victim there cannot be pushed anywhere.
|
||||||
for (const side of SIDES) {
|
const giant = state.players.find((p) => p.id === rig.giant.id)!;
|
||||||
const t1 = stepTarget(view, rig.giant.position, side);
|
for (const key of Object.keys(view.cells)) {
|
||||||
if (t1.kind !== "step") continue;
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
const t2 = stepTarget(view, t1.to, side);
|
for (const side of SIDES) {
|
||||||
if (t2.kind === "step") continue;
|
const t1 = stepTarget(view, { x, y }, side);
|
||||||
victim.position = { ...t1.to };
|
if (t1.kind !== "step") continue;
|
||||||
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
|
if (SIDES.some((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step")) continue;
|
||||||
expect(r.ok).toBe(false);
|
giant.position = { x, y };
|
||||||
return;
|
victim.position = { ...t1.to };
|
||||||
|
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error("setup: seed offered no walled pocket beside the home");
|
throw new Error("setup: no dead-end pocket on this board");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a shrunk wizard slips between the giant's boots", () => {
|
it("a shrunk wizard slips between the giant's boots", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game";
|
||||||
import { cellKey, edgeKey, stepTarget } from "../src/board";
|
import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||||
|
|
||||||
@@ -812,3 +812,34 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
|
|||||||
expect(rams).toBeGreaterThan(0);
|
expect(rams).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
|
||||||
|
/** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
|
||||||
|
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
|
||||||
|
const view = boardView(state);
|
||||||
|
const cells = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
|
||||||
|
.filter((c) => !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
|
||||||
|
for (const from of cells) for (const to of cells) {
|
||||||
|
const d = walkingDistance(state, from, to, 12);
|
||||||
|
if (d === 7 || d === 8) return { from, to, steps: d };
|
||||||
|
}
|
||||||
|
throw new Error("no seven-step corridor on this board");
|
||||||
|
}
|
||||||
|
for (const [deckRev, ok] of [[16, true], [15, false]] as const) {
|
||||||
|
it(`a goat with eight legs and a target seven or eight corridor steps off: rev ${deckRev} ${ok ? "charges" : "refuses"}`, () => {
|
||||||
|
let { state } = createGame({ playerIds: ["goat", "mark"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const goat = activePlayer(state);
|
||||||
|
const mark = state.players.find((p) => p.id !== goat.id)!;
|
||||||
|
const run = longRun(state);
|
||||||
|
goat.position = { ...run.from };
|
||||||
|
mark.position = { ...run.to };
|
||||||
|
state.turn.movementAllowance = 8;
|
||||||
|
state.turn.movementUsed = 0;
|
||||||
|
const card = giveCard(state, goat.id, "butt-head");
|
||||||
|
const r = applyCommand(state, goat.id, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: mark.id } });
|
||||||
|
expect(r.ok).toBe(ok);
|
||||||
|
if (!r.ok) expect(r.error).toMatch(/legs, not wings/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,43 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game";
|
||||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
|
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall, toRound2 } from "./helpers";
|
||||||
|
|
||||||
|
/** The sides a walker may leave a pit by, having entered it heading `entry`. */
|
||||||
|
function rimExits(state: GameState, pit: Cell, entry: Side): Side[] {
|
||||||
|
const view = boardView(state);
|
||||||
|
return SIDES.filter((d) => {
|
||||||
|
if (d === opposite(entry)) return false;
|
||||||
|
const b = neighbor(pit, d);
|
||||||
|
return !!view.cells[cellKey(b)] && (view.edges[edgeKey(pit, d)] ?? "open") === "open" &&
|
||||||
|
state.squareContents[cellKey(b)]?.kind !== "stone";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two pit sites on a board: a fork (several ways off) and a corridor (one
|
||||||
|
* way off, straight ahead), each with the square a walker enters from. */
|
||||||
|
function pitSpots(state: GameState) {
|
||||||
|
const view = boardView(state);
|
||||||
|
let fork: { from: Cell; pit: Cell; side: Side; exits: Side[] } | null = null;
|
||||||
|
let corridor: { from: Cell; pit: Cell; side: Side; beyond: Cell } | null = null;
|
||||||
|
for (const k of Object.keys(view.cells)) {
|
||||||
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const t = stepTarget(view, { x, y }, side);
|
||||||
|
if (t.kind !== "step") continue;
|
||||||
|
if (state.squareContents[k] || state.squareContents[cellKey(t.to)]) continue;
|
||||||
|
if (view.homes.some((h) => cellKey(h) === k || cellKey(h) === cellKey(t.to))) continue;
|
||||||
|
const exits = rimExits(state, t.to, side);
|
||||||
|
if (!fork && exits.length >= 2) fork = { from: { x, y }, pit: t.to, side, exits };
|
||||||
|
if (!corridor && exits.length === 1 && exits[0] === side) corridor = { from: { x, y }, pit: t.to, side, beyond: neighbor(t.to, side) };
|
||||||
|
if (fork && corridor) return { fork, corridor };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("board lacks a fork or a corridor pit site");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
describe("expansion terrain", () => {
|
describe("expansion terrain", () => {
|
||||||
it("killer ooze burns on entry and can drop you on your face", () => {
|
it("killer ooze burns on entry and can drop you on your face", () => {
|
||||||
@@ -37,14 +71,62 @@ describe("expansion terrain", () => {
|
|||||||
state = must(state, me.id, {
|
state = must(state, me.id, {
|
||||||
type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell },
|
type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||||
});
|
});
|
||||||
state = must(state, me.id, { type: "move", direction: spot.side });
|
// Rev 14: the rim's exits are the walker's to name; take the first.
|
||||||
|
const exits = rimExits(state, spot.cell, spot.side);
|
||||||
|
if (exits.length === 0) continue;
|
||||||
|
state = must(state, me.id, { type: "move", direction: spot.side, exit: exits[0] });
|
||||||
const p = state.players.find((p) => p.id === me.id)!;
|
const p = state.players.find((p) => p.id === me.id)!;
|
||||||
if (p.inPit) { falls++; expect(p.life).toBe(13); }
|
if (p.inPit) { falls++; expect(p.life).toBe(13); }
|
||||||
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
|
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
|
||||||
else jumps++;
|
else jumps++;
|
||||||
}
|
}
|
||||||
expect(falls + jumps + teeters).toBe(12);
|
expect(teeters).toBe(0);
|
||||||
expect(falls).toBeGreaterThan(0);
|
expect(falls).toBeGreaterThan(0);
|
||||||
|
expect(jumps).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the rim leads off a pit: one way is taken, several must be named, none cannot be entered", () => {
|
||||||
|
const found = pitSpots(newGame(42).state);
|
||||||
|
// Several ways off: a bare step is refused with the choices; a named exit lands there or falls in.
|
||||||
|
{
|
||||||
|
let { state } = newGame(42);
|
||||||
|
const me = activePlayer(state);
|
||||||
|
me.position = { ...found.fork.from };
|
||||||
|
state.squareContents[cellKey(found.fork.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
||||||
|
const bare = applyCommand(state, me.id, { type: "move", direction: found.fork.side });
|
||||||
|
expect(bare.ok).toBe(false);
|
||||||
|
if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/);
|
||||||
|
const chosen = found.fork.exits[1]!;
|
||||||
|
const named = applyCommand(state, me.id, { type: "move", direction: found.fork.side, exit: chosen });
|
||||||
|
expect(named.ok).toBe(true);
|
||||||
|
if (named.ok) {
|
||||||
|
const p = named.state.players.find((p) => p.id === me.id)!;
|
||||||
|
const landing = neighbor(found.fork.pit, chosen);
|
||||||
|
expect(p.inPit || cellKey(p.position) === cellKey(landing)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No way off: refused before any die is rolled (rev 14); bounced and charged in an older game.
|
||||||
|
for (const deckRev of [14, 13]) {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
const me = activePlayer(state);
|
||||||
|
me.position = { ...found.corridor.from };
|
||||||
|
state.squareContents[cellKey(found.corridor.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
||||||
|
state.squareContents[cellKey(found.corridor.beyond)] = { kind: "stone", damage: 0, createdBy: me.id };
|
||||||
|
const r = applyCommand(state, me.id, { type: "move", direction: found.corridor.side });
|
||||||
|
if (deckRev >= 14) {
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toMatch(/cannot be crossed/);
|
||||||
|
} else {
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
const p = r.state.players.find((p) => p.id === me.id)!;
|
||||||
|
if (!p.inPit) {
|
||||||
|
expect(cellKey(p.position)).toBe(cellKey(found.corridor.from));
|
||||||
|
expect(r.events.some((e) => e.type === "moveBumped")).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dust cloud blinds anyone standing inside it", () => {
|
it("dust cloud blinds anyone standing inside it", () => {
|
||||||
@@ -63,6 +145,29 @@ describe("expansion terrain", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a wizard in a dust cloud casts no LOS spell out, and none reaches them (rev 19)", () => {
|
||||||
|
for (const deckRev of [19, 18]) {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const spot = emptyNeighborCell(state, me.position);
|
||||||
|
state.squareContents[cellKey(spot.cell)] = { kind: "dust", damage: 0, createdBy: me.id };
|
||||||
|
me.position = { ...spot.cell };
|
||||||
|
// An empty square beside the cloud, in plain sight but for the dust.
|
||||||
|
const outside = emptyNeighborCell(state, spot.cell).cell;
|
||||||
|
const blind = deckRev >= 19;
|
||||||
|
expect(gameLos(state, me.position, outside, me.id)).toBe(!blind);
|
||||||
|
expect(gameLos(state, outside, me.position)).toBe(!blind);
|
||||||
|
expect(gameLos(state, me.position, me.position, me.id)).toBe(true);
|
||||||
|
const sighted = sightedCellsFor(viewFor(state, me.id));
|
||||||
|
expect(sighted.has(cellKey(outside))).toBe(!blind);
|
||||||
|
expect(sighted.has(cellKey(me.position))).toBe(true);
|
||||||
|
// FILL SQUARE WITH STONE from inside the cloud: refused when blind.
|
||||||
|
const stone = giveCard(state, me.id, "fill-square-with-stone");
|
||||||
|
const r = applyCommand(state, me.id, { type: "cast", instanceId: stone.instanceId, target: { kind: "cell", cell: outside } });
|
||||||
|
expect(r.ok).toBe(!blind);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("glue pins a treasure to the floor until it wears off", () => {
|
it("glue pins a treasure to the floor until it wears off", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
@@ -455,3 +560,149 @@ describe("the boobytrap keeps its secret", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a pit on the board's rim (M4Q7)", () => {
|
||||||
|
/** A square on the outer rim with a warp mouth on one side, entered from
|
||||||
|
* the square opposite the mouth; its other neighbouring squares listed. */
|
||||||
|
function rimPitSite(state: GameState) {
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const k of Object.keys(view.cells)) {
|
||||||
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||||
|
const pit = { x, y };
|
||||||
|
if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue;
|
||||||
|
for (const mouth of SIDES) {
|
||||||
|
if (stepTarget(view, pit, mouth).kind !== "warp") continue;
|
||||||
|
const entry = stepTarget(view, pit, opposite(mouth));
|
||||||
|
if (entry.kind !== "step" || state.squareContents[cellKey(entry.to)]) continue;
|
||||||
|
const floor = SIDES.filter((d) => d !== mouth && d !== opposite(mouth) && stepTarget(view, pit, d).kind === "step");
|
||||||
|
if (floor.length === 0) continue;
|
||||||
|
return { pit, mouth, from: entry.to, floor, far: stepTarget(view, pit, mouth) as { kind: "warp"; to: Cell } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("no rim pit site on this board");
|
||||||
|
}
|
||||||
|
const stoneUp = (state: GameState, pit: Cell, sides: Side[], by: string) => {
|
||||||
|
for (const d of sides) state.squareContents[cellKey(neighbor(pit, d))] = { kind: "stone", damage: 0, createdBy: by };
|
||||||
|
};
|
||||||
|
for (const deckRev of [17, 16]) {
|
||||||
|
it(`walls either side, the warp mouth is the way off (rev ${deckRev})`, () => {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
const site = rimPitSite(state);
|
||||||
|
const me = activePlayer(state);
|
||||||
|
me.position = { ...site.from };
|
||||||
|
state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
||||||
|
stoneUp(state, site.pit, site.floor, me.id);
|
||||||
|
const r = applyCommand(state, me.id, { type: "move", direction: site.mouth });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const p = r.state.players.find((p) => p.id === me.id)!;
|
||||||
|
if (p.inPit) return;
|
||||||
|
expect(cellKey(p.position)).toBe(cellKey(site.far.to));
|
||||||
|
expect(r.events.some((e) => e.type === "jumpedPit" && e.via === "warp")).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("a square beside the mouth: rev 17 asks which, rev 16 steps to the square but may name the mouth", () => {
|
||||||
|
for (const deckRev of [17, 16]) {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
const site = rimPitSite(state);
|
||||||
|
const me = activePlayer(state);
|
||||||
|
me.position = { ...site.from };
|
||||||
|
state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
|
||||||
|
stoneUp(state, site.pit, site.floor.slice(1), me.id);
|
||||||
|
const bare = applyCommand(state, me.id, { type: "move", direction: site.mouth });
|
||||||
|
if (deckRev >= 17) {
|
||||||
|
expect(bare.ok).toBe(false);
|
||||||
|
if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/);
|
||||||
|
const named = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth });
|
||||||
|
expect(named.ok).toBe(true);
|
||||||
|
if (named.ok) {
|
||||||
|
const p = named.state.players.find((p) => p.id === me.id)!;
|
||||||
|
expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(bare.ok).toBe(true);
|
||||||
|
if (bare.ok) {
|
||||||
|
const p = bare.state.players.find((p) => p.id === me.id)!;
|
||||||
|
expect(p.inPit || cellKey(p.position) === cellKey(neighbor(site.pit, site.floor[0]!))).toBe(true);
|
||||||
|
}
|
||||||
|
// Named, the mouth is taken in any revision.
|
||||||
|
const mouth = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth });
|
||||||
|
expect(mouth.ok).toBe(true);
|
||||||
|
if (mouth.ok) {
|
||||||
|
const p = mouth.state.players.find((p) => p.id === me.id)!;
|
||||||
|
expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a wave names its victims before it pushes (LK97)", () => {
|
||||||
|
/** A wall between two squares, a wizard on its near side with one open
|
||||||
|
* square behind them and a wall beyond it, and a caster's square beside. */
|
||||||
|
function site(state: GameState) {
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const k of Object.keys(view.cells)) {
|
||||||
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||||
|
const a = { x, y };
|
||||||
|
if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue;
|
||||||
|
if ((view.edges[edgeKey(a, "S")] ?? "open") !== "wall" || !view.cells[cellKey(neighbor(a, "S"))]) continue;
|
||||||
|
const back = stepTarget(view, a, "N");
|
||||||
|
if (back.kind !== "step" || state.squareContents[cellKey(back.to)] || view.homes.some((h) => cellKey(h) === cellKey(back.to))) continue;
|
||||||
|
if (stepTarget(view, back.to, "N").kind !== "blocked") continue;
|
||||||
|
const beside = stepTarget(view, a, "E");
|
||||||
|
if (beside.kind !== "step" || state.squareContents[cellKey(beside.to)]) continue;
|
||||||
|
return { a, back: back.to, beside: beside.to };
|
||||||
|
}
|
||||||
|
throw new Error("no such wall on this board");
|
||||||
|
}
|
||||||
|
for (const [deckRev, crush] of [[20, 1], [19, 2]] as const) {
|
||||||
|
it(`washed one square into a wall: rev ${deckRev} costs ${crush}`, () => {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||||
|
const s = site(state);
|
||||||
|
caster.position = { ...s.beside };
|
||||||
|
victim.position = { ...s.a };
|
||||||
|
const card = giveCard(state, caster.id, "stone-to-water");
|
||||||
|
const r = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId, target: { kind: "edge", cell: s.a, side: "S" } });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const v = r.state.players.find((p) => p.id === victim.id)!;
|
||||||
|
expect(cellKey(v.position)).toBe(cellKey(s.back));
|
||||||
|
expect(v.life).toBe(15 - crush);
|
||||||
|
expect(r.events.filter((e) => e.type === "washedBack" && e.player === victim.id).length).toBe(crush === 1 ? 1 : 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spells cast at a slime wait in the gel", () => {
|
||||||
|
it("a lightning blast lodges in the slime and goes off on the next wizard in, once", () => {
|
||||||
|
let { state } = newGame(42);
|
||||||
|
state = toRound2(state);
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||||
|
const spot = emptyNeighborCell(state, caster.position);
|
||||||
|
state.squareContents[cellKey(spot.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
|
||||||
|
const bolt = giveCard(state, caster.id, "lightning-blast");
|
||||||
|
const cast = applyCommand(state, caster.id, { type: "cast", instanceId: bolt.instanceId, target: { kind: "cell", cell: spot.cell } });
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
if (!cast.ok) return;
|
||||||
|
expect(cast.events.some((e) => e.type === "spellTrapped")).toBe(true);
|
||||||
|
expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
|
||||||
|
// Nobody is hurt yet; the victim walks in on their own turn.
|
||||||
|
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
|
||||||
|
victim.position = { ...caster.position };
|
||||||
|
const v = state.players.find((p) => p.id === victim.id)!;
|
||||||
|
v.position = { ...caster.position };
|
||||||
|
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
|
||||||
|
expect(walk.ok).toBe(true);
|
||||||
|
if (!walk.ok) return;
|
||||||
|
expect(walk.events.some((e) => e.type === "slimeTrapSprung")).toBe(true);
|
||||||
|
expect(walk.state.stack?.defenderId).toBe(victim.id);
|
||||||
|
expect(walk.state.stack?.trapped).toBe(true);
|
||||||
|
const hit = must(walk.state, victim.id, { type: "pass" });
|
||||||
|
expect(hit.players.find((p) => p.id === victim.id)!.life).toBeLessThan(15);
|
||||||
|
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,26 +9,53 @@ const esc = (s: string) =>
|
|||||||
|
|
||||||
const PAGE_CSS = `
|
const PAGE_CSS = `
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: #171a21; color: #d6d2c4;
|
body { margin: 0; background: #171a20; color: #d8d2c0; font-family: "Archivo Narrow", sans-serif; }
|
||||||
font-family: Georgia, "Times New Roman", serif; }
|
a { color: #e0b34a; }
|
||||||
a { color: #e8c87a; }
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 1.4rem 1.25rem 3rem; }
|
||||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 3rem; }
|
.mast { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 1.6rem;
|
||||||
h1 { font-size: 1.6rem; letter-spacing: 0.04em; margin: 0 0 0.3rem; }
|
border-bottom: 1px solid rgba(233, 225, 203, 0.18); padding-bottom: 0.7rem; }
|
||||||
|
.mast a { font-family: "Oswald", sans-serif; text-transform: uppercase; letter-spacing: 0.18em;
|
||||||
|
font-size: 0.95rem; color: #e9e1cb; text-decoration: none; }
|
||||||
|
.mast .crumb { color: #8d8672; font-size: 0.85rem; letter-spacing: 0.06em; }
|
||||||
|
.mast .crumb a { font: inherit; letter-spacing: inherit; text-transform: none; color: inherit; }
|
||||||
|
.mast .deal { margin-left: auto; font-size: 0.8rem; letter-spacing: 0.12em; color: #e0b34a; }
|
||||||
|
h1 { font-family: "Oswald", sans-serif; font-weight: 500; font-size: 1.7rem; letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase; margin: 0 0 0.3rem; color: #e9e1cb; }
|
||||||
h1 a { color: inherit; text-decoration: none; }
|
h1 a { color: inherit; text-decoration: none; }
|
||||||
p.sub { color: #8b8778; margin: 0 0 1.6rem; }
|
p.sub { color: #a49c86; margin: 0 0 1.6rem; font-size: 1.05rem; max-width: 46rem; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.4rem; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.4rem; }
|
||||||
.card { background: #1f232d; border: 1px solid #2c3140; border-radius: 8px;
|
.card { background: #1f232d; border: 1px solid rgba(233, 225, 203, 0.18); border-radius: 8px;
|
||||||
overflow: hidden; text-decoration: none; color: inherit; display: block; }
|
overflow: hidden; text-decoration: none; color: inherit; display: block; }
|
||||||
|
.card:hover { border-color: #e0b34a; }
|
||||||
.card img { width: 100%; display: block; aspect-ratio: 736 / 577; object-fit: cover; }
|
.card img { width: 100%; display: block; aspect-ratio: 736 / 577; object-fit: cover; }
|
||||||
.card .meta { padding: 0.7rem 0.9rem 0.9rem; }
|
.card .meta { padding: 0.7rem 0.9rem 0.9rem; }
|
||||||
.card .t { color: #e8c87a; letter-spacing: 0.03em; }
|
.card .t { font-family: "Oswald", sans-serif; color: #e0b34a; letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.95rem; }
|
||||||
.card .b { font-size: 0.9rem; line-height: 1.45; margin-top: 0.3rem; color: #b5b0a1; }
|
.card .b { font-size: 0.95rem; line-height: 1.4; margin-top: 0.3rem; color: #b5b0a1; }
|
||||||
.card .s { float: right; color: #8b8778; font-size: 0.85rem; }
|
.card .s { float: right; color: #8d8672; font-size: 0.85rem; font-family: "Courier Prime", monospace; }
|
||||||
video { width: 100%; border-radius: 6px; display: block; background: #000; }
|
video { width: 100%; border-radius: 6px; display: block; background: #000; }
|
||||||
.takes { display: grid; gap: 1.5rem; margin-top: 1.4rem; }
|
.takes { display: grid; gap: 1.5rem; margin-top: 1.4rem; }
|
||||||
.take h2 { font-size: 1.05rem; color: #e8c87a; font-weight: normal;
|
.take h2 { font-family: "Oswald", sans-serif; font-size: 0.9rem; color: #e0b34a; font-weight: 500;
|
||||||
letter-spacing: 0.04em; margin: 0 0 0.5rem; }
|
letter-spacing: 0.14em; text-transform: uppercase; margin: 0 0 0.5rem; }
|
||||||
.foot { margin-top: 2.2rem; color: #8b8778; font-size: 0.95rem; }
|
.foot { margin-top: 2.2rem; color: #8d8672; font-size: 0.95rem; }
|
||||||
|
.share { font: inherit; font-size: 0.85rem; letter-spacing: 0.12em; text-transform: uppercase; cursor: pointer;
|
||||||
|
font-family: "Oswald", sans-serif; color: #43331f; background: #e9e1cb; border: 1.5px solid #43331f;
|
||||||
|
border-radius: 3px; padding: 0.4rem 1rem; margin: -0.6rem 0 0.4rem; }
|
||||||
|
.share:hover { background: #f2d27a; }
|
||||||
|
.share.done { background: #d8d2c0; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// The share button: the phone's own share sheet where there is one,
|
||||||
|
// the clipboard everywhere else. The page URL is the canonical link.
|
||||||
|
const SHARE_JS = `
|
||||||
|
var b = document.querySelector(".share");
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
var data = { title: b.dataset.title, text: b.dataset.text, url: location.origin + location.pathname };
|
||||||
|
if (navigator.share) { navigator.share(data).catch(function () {}); return; }
|
||||||
|
navigator.clipboard.writeText(data.url).then(function () {
|
||||||
|
b.textContent = "Link copied"; b.classList.add("done");
|
||||||
|
setTimeout(function () { b.textContent = "Share this clip"; b.classList.remove("done"); }, 1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const shell = (metas: string[], body: string) => `<!doctype html>
|
const shell = (metas: string[], body: string) => `<!doctype html>
|
||||||
@@ -36,12 +63,31 @@ const shell = (metas: string[], body: string) => `<!doctype html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600&family=Archivo+Narrow:wght@400;600&family=Courier+Prime&display=swap" rel="stylesheet">
|
||||||
${metas.join("\n")}
|
${metas.join("\n")}
|
||||||
<style>${PAGE_CSS}</style>
|
<style>${PAGE_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body><div class="wrap">${body}</div></body>
|
<body><div class="wrap">${body}</div></body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
|
/** The 1200x630 share card cut by deploy/clips-prep.mjs, with the
|
||||||
|
* dimensions that let Facebook show it on the very first share. Falls
|
||||||
|
* back to the poster on a set that was never prepped. */
|
||||||
|
function cardImageMetas(clip: ClipMeta, base: string): string[] {
|
||||||
|
const prepped = clip.width !== undefined;
|
||||||
|
const image = `${base}/clips/${clip.name}${prepped ? "-card" : ""}.jpg`;
|
||||||
|
return [
|
||||||
|
`<meta property="og:image" content="${image}"/>`,
|
||||||
|
...(prepped ? [
|
||||||
|
`<meta property="og:image:width" content="1200"/>`,
|
||||||
|
`<meta property="og:image:height" content="630"/>`,
|
||||||
|
] : []),
|
||||||
|
`<meta name="twitter:image" content="${image}"/>`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
||||||
const metas = [
|
const metas = [
|
||||||
`<title>Wiz-War — clips from the labyrinth</title>`,
|
`<title>Wiz-War — clips from the labyrinth</title>`,
|
||||||
@@ -50,9 +96,9 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
|||||||
`<meta property="og:title" content="Wiz-War — clips from the labyrinth"/>`,
|
`<meta property="og:title" content="Wiz-War — clips from the labyrinth"/>`,
|
||||||
`<meta property="og:description" content="Short scenes of magical combat in a stone maze: wormhole heists, warp snipers, ambushes, and the walls coming down — each seen through the wizard's own eyes and from the board above."/>`,
|
`<meta property="og:description" content="Short scenes of magical combat in a stone maze: wormhole heists, warp snipers, ambushes, and the walls coming down — each seen through the wizard's own eyes and from the board above."/>`,
|
||||||
`<meta property="og:url" content="${base}/clips"/>`,
|
`<meta property="og:url" content="${base}/clips"/>`,
|
||||||
clips[0] ? `<meta property="og:image" content="${base}/clips/${clips[0].name}.jpg"/>` : "",
|
...(clips[0] ? cardImageMetas(clips[0], base) : []),
|
||||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||||
].filter(Boolean);
|
];
|
||||||
const cards = clips.map((c) => `
|
const cards = clips.map((c) => `
|
||||||
<a class="card" href="/clips/${c.name}">
|
<a class="card" href="/clips/${c.name}">
|
||||||
<img src="/clips/${c.name}.jpg" alt="${esc(c.title)}" loading="lazy">
|
<img src="/clips/${c.name}.jpg" alt="${esc(c.title)}" loading="lazy">
|
||||||
@@ -63,6 +109,7 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</a>`).join("\n");
|
</a>`).join("\n");
|
||||||
const body = `
|
const body = `
|
||||||
|
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb">clips</span><a class="deal" href="/">deal yourself in →</a></nav>
|
||||||
<h1>Clips from the labyrinth</h1>
|
<h1>Clips from the labyrinth</h1>
|
||||||
<p class="sub">Scenes of Wiz-War, played by the rules and filmed through the wizards' own eyes.
|
<p class="sub">Scenes of Wiz-War, played by the rules and filmed through the wizards' own eyes.
|
||||||
Each clip has a first-person take and a board take of the same moves.</p>
|
Each clip has a first-person take and a board take of the same moves.</p>
|
||||||
@@ -80,16 +127,21 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
|
|||||||
`<meta property="og:title" content="${esc(clip.title)}"/>`,
|
`<meta property="og:title" content="${esc(clip.title)}"/>`,
|
||||||
`<meta property="og:description" content="${esc(clip.blurb)}"/>`,
|
`<meta property="og:description" content="${esc(clip.blurb)}"/>`,
|
||||||
`<meta property="og:url" content="${base}/clips/${clip.name}"/>`,
|
`<meta property="og:url" content="${base}/clips/${clip.name}"/>`,
|
||||||
`<meta property="og:image" content="${base}/clips/${clip.name}.jpg"/>`,
|
...cardImageMetas(clip, base),
|
||||||
`<meta property="og:video" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
`<meta property="og:video" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
||||||
`<meta property="og:video:secure_url" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
`<meta property="og:video:secure_url" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
||||||
`<meta property="og:video:type" content="video/mp4"/>`,
|
`<meta property="og:video:type" content="video/mp4"/>`,
|
||||||
|
...(clip.width && clip.height ? [
|
||||||
|
`<meta property="og:video:width" content="${clip.width}"/>`,
|
||||||
|
`<meta property="og:video:height" content="${clip.height}"/>`,
|
||||||
|
] : []),
|
||||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||||
`<meta name="twitter:image" content="${base}/clips/${clip.name}.jpg"/>`,
|
|
||||||
];
|
];
|
||||||
const body = `
|
const body = `
|
||||||
<h1><a href="/clips">Clips from the labyrinth</a></h1>
|
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb"><a href="/clips">clips</a> / ${esc(clip.title)}</span><a class="deal" href="/">deal yourself in →</a></nav>
|
||||||
<p class="sub">${esc(clip.title)} — ${esc(clip.blurb)}</p>
|
<h1><a href="/clips">${esc(clip.title)}</a></h1>
|
||||||
|
<p class="sub">${esc(clip.blurb)}</p>
|
||||||
|
<button class="share" type="button" data-title="${esc(clip.title)}" data-text="${esc(clip.blurb)}">Share this clip</button>
|
||||||
<div class="takes">
|
<div class="takes">
|
||||||
<div class="take">
|
<div class="take">
|
||||||
<h2>Through the wizard's eyes</h2>
|
<h2>Through the wizard's eyes</h2>
|
||||||
@@ -101,6 +153,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="foot">Played by the real rules engine — every move on screen is a legal move.
|
<p class="foot">Played by the real rules engine — every move on screen is a legal move.
|
||||||
<a href="/">Deal yourself in</a>.</p>`;
|
<a href="/">Deal yourself in</a>.</p>
|
||||||
|
<script>${SHARE_JS}</script>`;
|
||||||
return shell(metas, body);
|
return shell(metas, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
// {type:"error", message}
|
// {type:"error", message}
|
||||||
|
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { createServer } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
||||||
import { extname, join, normalize, sep } from "node:path";
|
import { extname, join, normalize, sep } from "node:path";
|
||||||
@@ -65,8 +65,9 @@ import {
|
|||||||
abandonRoom,
|
abandonRoom,
|
||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
import { engagementStats, recordHotseat } from "./stats";
|
import { engagementStats, recordHotseat } from "./stats";
|
||||||
import { appendFeedback, readFeedback, readClips, clipAssetPath } from "./store";
|
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG } from "./store";
|
||||||
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
||||||
|
import { SlidingLimit, clientAddress } from "./ratelimit";
|
||||||
import { getShare, loadShares, mintShare } from "./shares";
|
import { getShare, loadShares, mintShare } from "./shares";
|
||||||
import { renderSharePng } from "./ogimage";
|
import { renderSharePng } from "./ogimage";
|
||||||
import { BOT_LINES, type BanterTrigger } from "./banter";
|
import { BOT_LINES, type BanterTrigger } from "./banter";
|
||||||
@@ -81,6 +82,10 @@ if (process.env.SENTRY_DSN) {
|
|||||||
const MAX_SOCKETS = 300; // concurrent connections
|
const MAX_SOCKETS = 300; // concurrent connections
|
||||||
const MAX_ROOMS = 5000; // total rooms on the server
|
const MAX_ROOMS = 5000; // total rooms on the server
|
||||||
const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
|
const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
|
||||||
|
// Per-address limits, held across reconnects: a table of friends never
|
||||||
|
// nears them; a script filling the vault or the reports desk does.
|
||||||
|
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
|
||||||
|
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||||
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
||||||
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
||||||
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
|
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
|
||||||
@@ -179,6 +184,15 @@ function ogPage(metas: string[]): string {
|
|||||||
|
|
||||||
/** Host and proto arrive from request headers — attacker-writable text
|
/** Host and proto arrive from request headers — attacker-writable text
|
||||||
* that must never reach an HTML attribute raw. */
|
* that must never reach an HTML attribute raw. */
|
||||||
|
const noSuchClip = (res: ServerResponse) =>
|
||||||
|
res.writeHead(404, { "content-type": "text/plain" }).end("no such clip — see /clips");
|
||||||
|
|
||||||
|
/** The absolute origin a request came in on, as the proxy saw it. */
|
||||||
|
function requestBase(req: IncomingMessage): string {
|
||||||
|
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
|
||||||
|
return safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
|
||||||
|
}
|
||||||
|
|
||||||
function safeBase(rawHost: string, rawProto: string): string {
|
function safeBase(rawHost: string, rawProto: string): string {
|
||||||
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
||||||
return `${proto}://${escapeHtml(rawHost)}`;
|
return `${proto}://${escapeHtml(rawHost)}`;
|
||||||
@@ -307,16 +321,15 @@ const httpServer = createServer((req, res) => {
|
|||||||
// with Range support — Safari refuses an mp4 whose server can't
|
// with Range support — Safari refuses an mp4 whose server can't
|
||||||
// serve bytes 0-1 on demand.
|
// serve bytes 0-1 on demand.
|
||||||
if (url === "/clips" || url === "/clips/") {
|
if (url === "/clips" || url === "/clips/") {
|
||||||
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
|
const base = requestBase(req);
|
||||||
const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
|
|
||||||
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
|
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
|
||||||
res.end(clipsIndexHtml(readClips(), base));
|
res.end(clipsIndexHtml(readClips(), base));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clipAsset = url.match(/^\/clips\/([a-z0-9-]{1,70}\.(?:mp4|jpg))$/);
|
const clipAsset = url.match(/^\/clips\/([^/]+\.(?:mp4|jpg))$/);
|
||||||
if (clipAsset) {
|
if (clipAsset) {
|
||||||
const path = clipAssetPath(clipAsset[1]!);
|
const path = clipAssetPath(clipAsset[1]!);
|
||||||
if (!path) { res.writeHead(404).end("no such clip"); return; }
|
if (!path) { noSuchClip(res); return; }
|
||||||
const size = statSync(path).size;
|
const size = statSync(path).size;
|
||||||
const type = path.endsWith(".mp4") ? "video/mp4" : "image/jpeg";
|
const type = path.endsWith(".mp4") ? "video/mp4" : "image/jpeg";
|
||||||
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
|
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
|
||||||
@@ -344,17 +357,16 @@ const httpServer = createServer((req, res) => {
|
|||||||
createReadStream(path).pipe(res);
|
createReadStream(path).pipe(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clipPage = url.match(/^\/clips\/([a-z0-9-]{1,60})$/);
|
const clipPage = url.match(/^\/clips\/([^/]+)$/);
|
||||||
if (clipPage) {
|
if (clipPage && CLIP_SLUG.test(clipPage[1]!)) {
|
||||||
const clip = readClips().find((c) => c.name === clipPage[1]);
|
const clip = readClips().find((c) => c.name === clipPage[1]);
|
||||||
if (clip) {
|
if (clip) {
|
||||||
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
|
const base = requestBase(req);
|
||||||
const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
|
|
||||||
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
|
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
|
||||||
res.end(clipPageHtml(clip, base));
|
res.end(clipPageHtml(clip, base));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(404, { "content-type": "text/html" }).end("no such clip — see /clips");
|
noSuchClip(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Room invitations: a living room gets its recruiting card; a dead
|
// Room invitations: a living room gets its recruiting card; a dead
|
||||||
@@ -375,7 +387,14 @@ const httpServer = createServer((req, res) => {
|
|||||||
res.writeHead(403).end();
|
res.writeHead(403).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!existsSync(file)) file = join(staticRoot, "index.html"); // SPA fallback
|
if (!existsSync(file)) {
|
||||||
|
// A hashed asset that is gone is gone: a page built before the last
|
||||||
|
// deploy asking for its old chunk gets a plain 404 it can act on,
|
||||||
|
// never the app shell as text/html. Every other unknown path is the
|
||||||
|
// app's to route.
|
||||||
|
if (url.startsWith("/assets/")) { res.writeHead(404, { "content-type": "text/plain" }).end("gone"); return; }
|
||||||
|
file = join(staticRoot, "index.html");
|
||||||
|
}
|
||||||
// Resolve symlinks and re-verify the real location stays inside the root.
|
// Resolve symlinks and re-verify the real location stays inside the root.
|
||||||
const real = realpathSync(file);
|
const real = realpathSync(file);
|
||||||
if (real !== staticRoot && !real.startsWith(staticRoot + sep)) {
|
if (real !== staticRoot && !real.startsWith(staticRoot + sep)) {
|
||||||
@@ -409,6 +428,8 @@ interface Session {
|
|||||||
spectator: boolean;
|
spectator: boolean;
|
||||||
/** The raw seat token this connection authenticated with (memory only). */
|
/** The raw seat token this connection authenticated with (memory only). */
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
/** Where the connection came from, for the per-address limits. */
|
||||||
|
address: string;
|
||||||
claimFails: number;
|
claimFails: number;
|
||||||
// Token bucket: refills at 15 msg/s up to a burst of 30.
|
// Token bucket: refills at 15 msg/s up to a burst of 30.
|
||||||
bucket: number;
|
bucket: number;
|
||||||
@@ -579,7 +600,7 @@ function broadcastRoomState(room: Room): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wss.on("connection", (socket) => {
|
wss.on("connection", (socket, req) => {
|
||||||
if (sessions.size >= MAX_SOCKETS) {
|
if (sessions.size >= MAX_SOCKETS) {
|
||||||
send(socket, { type: "error", message: "the tavern is packed — try again shortly" });
|
send(socket, { type: "error", message: "the tavern is packed — try again shortly" });
|
||||||
socket.close();
|
socket.close();
|
||||||
@@ -587,6 +608,7 @@ wss.on("connection", (socket) => {
|
|||||||
}
|
}
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
||||||
|
address: clientAddress(req.headers, req.socket.remoteAddress),
|
||||||
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
||||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
||||||
};
|
};
|
||||||
@@ -626,6 +648,9 @@ wss.on("connection", (socket) => {
|
|||||||
if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) {
|
if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) {
|
||||||
return send(socket, { type: "error", message: "no new rooms right now — try again later" });
|
return send(socket, { type: "error", message: "no new rooms right now — try again later" });
|
||||||
}
|
}
|
||||||
|
if (!roomsPerAddress.allow(session.address)) {
|
||||||
|
return send(socket, { type: "error", message: "that's a lot of new tables from one place — try again in an hour" });
|
||||||
|
}
|
||||||
session.roomsCreated++;
|
session.roomsCreated++;
|
||||||
leaveGallery(session);
|
leaveGallery(session);
|
||||||
const { room, token } = createRoom(name);
|
const { room, token } = createRoom(name);
|
||||||
@@ -868,6 +893,9 @@ wss.on("connection", (socket) => {
|
|||||||
String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
|
String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
|
||||||
const happened = clean(msg.happened);
|
const happened = clean(msg.happened);
|
||||||
if (!happened) return send(socket, { type: "error", message: "say what happened" });
|
if (!happened) return send(socket, { type: "error", message: "say what happened" });
|
||||||
|
if (!reportsPerAddress.allow(session.address)) {
|
||||||
|
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
|
||||||
|
}
|
||||||
appendFeedback({
|
appendFeedback({
|
||||||
id: randomBytes(4).toString("hex"),
|
id: randomBytes(4).toString("hex"),
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// A per-key sliding-window limiter for the two doors anyone may walk
|
||||||
|
// through unseated: creating rooms and filing reports. Per-connection
|
||||||
|
// caps reset on reconnect; these are keyed by client address and hold
|
||||||
|
// for the window. Memory is bounded by pruning keys whose windows have
|
||||||
|
// emptied.
|
||||||
|
|
||||||
|
export class SlidingLimit {
|
||||||
|
private hits = new Map<string, number[]>();
|
||||||
|
private lastSweep = 0;
|
||||||
|
|
||||||
|
constructor(private readonly max: number, private readonly windowMs: number) {}
|
||||||
|
|
||||||
|
/** Record a hit for `key` if it is under the limit; false if it is not. */
|
||||||
|
allow(key: string, now = Date.now()): boolean {
|
||||||
|
this.sweep(now);
|
||||||
|
const cutoff = now - this.windowMs;
|
||||||
|
const times = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
|
||||||
|
if (times.length >= this.max) { this.hits.set(key, times); return false; }
|
||||||
|
times.push(now);
|
||||||
|
this.hits.set(key, times);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sweep(now: number): void {
|
||||||
|
if (now - this.lastSweep < this.windowMs) return;
|
||||||
|
this.lastSweep = now;
|
||||||
|
const cutoff = now - this.windowMs;
|
||||||
|
for (const [k, times] of this.hits) {
|
||||||
|
if (!times.some((t) => t > cutoff)) this.hits.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
||||||
|
|
||||||
|
/** The client's address as Caddy reports it. The proxy APPENDS the true
|
||||||
|
* peer to X-Forwarded-For, so the last entry is the trustworthy one; a
|
||||||
|
* client can write anything into the first. And the header is believed
|
||||||
|
* only when the socket peer is the proxy itself (loopback) — reached
|
||||||
|
* any other way, the peer address is the client. */
|
||||||
|
export function clientAddress(headers: Record<string, string | string[] | undefined>, remote: string | undefined): string {
|
||||||
|
const peer = remote || "unknown";
|
||||||
|
if (!LOOPBACK.has(peer)) return peer;
|
||||||
|
const fwd = headers["x-forwarded-for"];
|
||||||
|
const parts = (Array.isArray(fwd) ? fwd.join(",") : fwd ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || peer;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type PlayerId,
|
type PlayerId,
|
||||||
CURRENT_RULES_REV,
|
CURRENT_RULES_REV,
|
||||||
} from "@wizwar/engine";
|
} from "@wizwar/engine";
|
||||||
import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, readAllRooms, readRoom, roomFileExists, type RoomLine } from "./store";
|
import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, ledgerStat, listRoomIds, readRoom, readStubFile, roomFileExists, writeStubFile, type RoomLine } from "./store";
|
||||||
import { recordRoom } from "./stats";
|
import { recordRoom } from "./stats";
|
||||||
|
|
||||||
export interface LoggedCommand {
|
export interface LoggedCommand {
|
||||||
@@ -159,9 +159,16 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
|
|||||||
const idle = now - (room.touchedAt ?? now);
|
const idle = now - (room.touchedAt ?? now);
|
||||||
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
|
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
|
||||||
if (idle < allowance) continue;
|
if (idle < allowance) continue;
|
||||||
sleepingStubs.set(id, stubOf(room));
|
const stub = stubOf(room);
|
||||||
|
sleepingStubs.set(id, stub);
|
||||||
rooms.delete(id);
|
rooms.delete(id);
|
||||||
evicted++;
|
evicted++;
|
||||||
|
try {
|
||||||
|
const stat = ledgerStat(id);
|
||||||
|
if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`could not write stub for ${id}:`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return evicted;
|
return evicted;
|
||||||
}
|
}
|
||||||
@@ -176,6 +183,8 @@ interface RoomStub {
|
|||||||
base: Omit<GameSummary, "name" | "yourTurn" | "attention">;
|
base: Omit<GameSummary, "name" | "yourTurn" | "attention">;
|
||||||
}
|
}
|
||||||
const sleepingStubs = new Map<string, RoomStub>();
|
const sleepingStubs = new Map<string, RoomStub>();
|
||||||
|
/** A stub as it rests on disk — the token map spelled as an object. */
|
||||||
|
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> };
|
||||||
|
|
||||||
function stubOf(room: Room): RoomStub {
|
function stubOf(room: Room): RoomStub {
|
||||||
const { active, turnHolder, waitKind } = turnFacts(room.state);
|
const { active, turnHolder, waitKind } = turnFacts(room.state);
|
||||||
@@ -395,6 +404,7 @@ function actingSeat(room: Room): PlayerId | null {
|
|||||||
if (!s || s.phase !== "playing") return null;
|
if (!s || s.phase !== "playing") return null;
|
||||||
return (
|
return (
|
||||||
s.wardPending?.ownerId ??
|
s.wardPending?.ownerId ??
|
||||||
|
s.pushPending?.pusheeId ??
|
||||||
s.slowDeathPending?.playerId ??
|
s.slowDeathPending?.playerId ??
|
||||||
s.stack?.waitingOn ??
|
s.stack?.waitingOn ??
|
||||||
s.pendingDiscard ??
|
s.pendingDiscard ??
|
||||||
@@ -466,11 +476,12 @@ function turnFacts(s: GameState | null): {
|
|||||||
waitKind: "counteract" | "discard" | "interrupt" | null;
|
waitKind: "counteract" | "discard" | "interrupt" | null;
|
||||||
} {
|
} {
|
||||||
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
|
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
|
||||||
const waitingOn = s?.wardPending?.ownerId ?? s?.slowDeathPending?.playerId ??
|
const waitingOn = s?.wardPending?.ownerId ?? s?.pushPending?.pusheeId ?? s?.slowDeathPending?.playerId ??
|
||||||
s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ??
|
s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ??
|
||||||
s?.outOfTurnWindow?.playerId ?? null;
|
s?.outOfTurnWindow?.playerId ?? null;
|
||||||
const waitKind = s == null ? null
|
const waitKind = s == null ? null
|
||||||
: s.wardPending != null ? "counteract" as const
|
: s.wardPending != null ? "counteract" as const
|
||||||
|
: s.pushPending != null ? "counteract" as const
|
||||||
: s.slowDeathPending != null ? "counteract" as const
|
: s.slowDeathPending != null ? "counteract" as const
|
||||||
: s.stack?.waitingOn != null ? "counteract" as const
|
: s.stack?.waitingOn != null ? "counteract" as const
|
||||||
: s.pendingDiscard != null ? "discard" as const
|
: s.pendingDiscard != null ? "discard" as const
|
||||||
@@ -773,14 +784,29 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
|||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild every persisted room by replaying its file. */
|
/** Bring the rooms back after a restart. A room whose stub still matches
|
||||||
|
* its ledger sleeps on, answering the games ledger from the stub; the
|
||||||
|
* rest are replayed, with the eviction clock set to their last line so
|
||||||
|
* the first sweep puts the long-idle ones back to bed. */
|
||||||
export function loadPersistedRooms(): void {
|
export function loadPersistedRooms(): void {
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
let restored = 0;
|
let restored = 0;
|
||||||
for (const [id, lines] of readAllRooms()) {
|
let asleep = 0;
|
||||||
|
for (const id of listRoomIds()) {
|
||||||
|
const stat = ledgerStat(id);
|
||||||
|
if (!stat) continue;
|
||||||
|
const stored = readStubFile<StoredStub>(id);
|
||||||
|
if (stored && stored.bytes === stat.bytes) {
|
||||||
|
sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) });
|
||||||
|
asleep++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lines = readRoom(id);
|
||||||
|
if (!lines) continue;
|
||||||
try {
|
try {
|
||||||
const room = rebuildRoom(id, lines);
|
const room = rebuildRoom(id, lines);
|
||||||
if (!room) continue;
|
if (!room) continue;
|
||||||
|
room.touchedAt = stat.mtimeMs;
|
||||||
rooms.set(id, room);
|
rooms.set(id, room);
|
||||||
recordRoom(room);
|
recordRoom(room);
|
||||||
restored++;
|
restored++;
|
||||||
@@ -788,5 +814,5 @@ export function loadPersistedRooms(): void {
|
|||||||
console.error(`could not restore room ${id}:`, e);
|
console.error(`could not restore room ${id}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (restored > 0) console.log(`restored ${restored} room(s) from disk`);
|
if (restored + asleep > 0) console.log(`restored ${restored} room(s) from disk, ${asleep} left sleeping`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// command. Because the engine is deterministic, replaying a file rebuilds
|
// command. Because the engine is deterministic, replaying a file rebuilds
|
||||||
// the exact game state — server restarts lose nothing.
|
// the exact game state — server restarts lose nothing.
|
||||||
|
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export interface RoomMetaLine {
|
export interface RoomMetaLine {
|
||||||
@@ -128,24 +128,51 @@ export function countRoomFiles(): number {
|
|||||||
return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).length;
|
return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read every persisted room's lines, keyed by room id. */
|
/** Every room with a ledger on disk. */
|
||||||
export function readAllRooms(): Map<string, RoomLine[]> {
|
export function listRoomIds(): string[] {
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
const rooms = new Map<string, RoomLine[]>();
|
return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length));
|
||||||
for (const file of readdirSync(DATA_DIR)) {
|
}
|
||||||
if (!file.endsWith(".jsonl")) continue;
|
|
||||||
const id = file.slice(0, -".jsonl".length);
|
/** A ledger's size and the moment of its last line. */
|
||||||
try {
|
export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } | null {
|
||||||
const lines = readFileSync(join(DATA_DIR, file), "utf8")
|
if (!safeRoomId(roomId)) return null;
|
||||||
.split("\n")
|
const file = fileFor(roomId);
|
||||||
.filter((l) => l.trim().length > 0)
|
if (!existsSync(file)) return null;
|
||||||
.map((l) => JSON.parse(l) as RoomLine);
|
const st = statSync(file);
|
||||||
if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines);
|
return { bytes: st.size, mtimeMs: st.mtimeMs };
|
||||||
} catch (e) {
|
}
|
||||||
console.error(`skipping unreadable room file ${file}:`, e);
|
|
||||||
}
|
// --- Stubs of sleeping rooms. ---------------------------------------------
|
||||||
|
// A room put to sleep leaves a stub beside the ledgers: what the games
|
||||||
|
// ledger needs to answer for it, and the ledger's size at that moment. A
|
||||||
|
// ledger only ever grows, so at boot a stub whose size still matches is
|
||||||
|
// the room's whole truth and the room stays asleep, unreplayed.
|
||||||
|
|
||||||
|
const stubDir = () => join(DATA_DIR, "..", "stubs");
|
||||||
|
function stubFor(roomId: string): string {
|
||||||
|
if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`);
|
||||||
|
return join(stubDir(), `${roomId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStubFile<T>(roomId: string): { bytes: number; stub: T } | null {
|
||||||
|
if (!safeRoomId(roomId)) return null;
|
||||||
|
const file = stubFor(roomId);
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(file, "utf8")) as { bytes: number; stub: T };
|
||||||
|
return typeof parsed.bytes === "number" && parsed.stub ? parsed : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`unreadable stub file ${roomId}:`, e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return rooms;
|
}
|
||||||
|
|
||||||
|
export function writeStubFile(roomId: string, bytes: number, stub: unknown): void {
|
||||||
|
const file = stubFor(roomId);
|
||||||
|
mkdirSync(stubDir(), { recursive: true });
|
||||||
|
writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8");
|
||||||
|
renameSync(file + ".tmp", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Player surprise reports and the wizards' replies share one JSONL file
|
/** Player surprise reports and the wizards' replies share one JSONL file
|
||||||
@@ -227,28 +254,36 @@ export interface ClipMeta {
|
|||||||
title: string;
|
title: string;
|
||||||
blurb: string;
|
blurb: string;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
|
/** Frame size of the first-person take, stamped by deploy/clips-prep.mjs;
|
||||||
|
* unfurlers won't embed a player without it. Absent on an unprepped set. */
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clipsDir = () => join(DATA_DIR, "..", "clips");
|
const clipsDir = () => join(DATA_DIR, "..", "clips");
|
||||||
|
|
||||||
|
/** A clip's name, which is also its file stem: no separators, no dots,
|
||||||
|
* so a name can never name a path. */
|
||||||
|
const SLUG = "[a-z0-9-]{1,60}";
|
||||||
|
export const CLIP_SLUG = new RegExp(`^${SLUG}$`);
|
||||||
|
const CLIP_FILE = new RegExp(`^${SLUG}(?:(?:-fpv|-board)\\.mp4|(?:-card)?\\.jpg)$`);
|
||||||
|
|
||||||
export function readClips(): ClipMeta[] {
|
export function readClips(): ClipMeta[] {
|
||||||
const file = join(clipsDir(), "clips.json");
|
const file = join(clipsDir(), "clips.json");
|
||||||
if (!existsSync(file)) return [];
|
if (!existsSync(file)) return [];
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(readFileSync(file, "utf8")) as ClipMeta[];
|
const parsed = JSON.parse(readFileSync(file, "utf8")) as ClipMeta[];
|
||||||
return parsed.filter((c) => /^[a-z0-9-]{1,60}$/.test(c.name));
|
return parsed.filter((c) => CLIP_SLUG.test(c.name));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a clip asset request to its path — or null for any name that
|
/** Resolve a clip asset request to its path — or null for any name that
|
||||||
* is not exactly a published clip file shape. The gate IS the security:
|
* is not exactly a published clip file: <slug>-fpv.mp4, <slug>-board.mp4,
|
||||||
* nothing outside <slug>(-fpv|-board).mp4 / <slug>.jpg can be named. */
|
* <slug>.jpg, <slug>-card.jpg. The pattern is the only path guard. */
|
||||||
export function clipAssetPath(file: string): string | null {
|
export function clipAssetPath(file: string): string | null {
|
||||||
if (!/^[a-z0-9-]{1,60}(-fpv|-board)\.mp4$/.test(file) && !/^[a-z0-9-]{1,60}\.jpg$/.test(file)) {
|
if (!CLIP_FILE.test(file)) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const path = join(clipsDir(), file);
|
const path = join(clipsDir(), file);
|
||||||
return existsSync(path) ? path : null;
|
return existsSync(path) ? path : null;
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 157 KiB |
@@ -1,19 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Alter Ego token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Alter Ego token">
|
||||||
<defs>
|
<title>Alter Ego</title><desc>Alter Ego illustrated in shaded ink on parchment.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="31" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#716291" stop-opacity=".2"/><stop offset="1" stop-color="#716291" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M34 113L45 91 51 80 37 67 40 49 54 38Q72 27 89 42L111 61 99 67 99 80 87 86 100 114Z" fill="#66628e" stroke="#302a29" stroke-width="2.0" /><path d="M72 35Q47 25 38 47L34 60 23 66 35 73 36 87 51 92 48 115H73Z" fill="#428c80" stroke="#302a29" stroke-width="2.0" /><path d="M72 35V115" fill="none" stroke="#f8edcf" stroke-width="2" /><path d="M43 58Q51 50 60 58M84 57Q93 50 100 60" fill="none" stroke="#f8edcf" stroke-width="3" /><circle cx="51" cy="57" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><circle cx="92" cy="57" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><path d="M45 77Q53 83 61 77M83 78Q92 83 98 76" fill="none" stroke="#302a29" stroke-width="2" /><path d="M23 36L32 29M110 31L120 40M15 83H24M117 85H128" fill="none" stroke="#d6ad57" stroke-width="3" /></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Alter Ego</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#716291" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Alter Ego</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<path d="M72 34q-30 3-35 36 2 35 35 57Z" fill="#23764b"/>
|
|
||||||
<path d="M72 34q30 3 35 36-2 35-35 57Z" fill="#6c3eae"/>
|
|
||||||
<path d="M72 39v83" fill="none" stroke="#29251e" stroke-width="4"/>
|
|
||||||
<path d="M43 65q12-15 25-1-9 13-25 1Zm33-1q14-14 25 1-16 12-25-1Z" fill="#eee5ca"/>
|
|
||||||
<circle cx="58" cy="64" r="3"/><circle cx="86" cy="64" r="3"/>
|
|
||||||
<path d="M47 91q12 15 21-1m9 0q10 16 21 0" fill="none"/>
|
|
||||||
<path d="M34 50l-12-10m88 10l12-10M35 112l-14 9m88-9l14 9" fill="none" stroke="#8e794d" stroke-width="4"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -1,18 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Boobytrap token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Boobytrap token">
|
||||||
<defs>
|
<title>Boobytrap</title><desc>Top view of a wooden spring mousetrap: rectangular steel striker, coiled spring, retaining arm, and cheese bait.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="32" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#ab784d" stop-opacity=".2"/><stop offset="1" stop-color="#ab784d" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M35 19H109V120H35Z" fill="#88603f" stroke="#302a29" stroke-width="2.0" /><path d="M31 15H105V115H31Z" fill="#caa16a" stroke="#302a29" stroke-width="2.0" /><path d="M37 23V108M99 24V107M42 39L43 55M94 82L94 104" fill="none" stroke="#ae804f" stroke-width="1.3" /><path d="M42 70V29H94V70" fill="none" stroke="#302a29" stroke-width="7" /><path d="M42 70V29H94V70" fill="none" stroke="#ccd0bf" stroke-width="3.5" /><path d="M39 73H97" fill="none" stroke="#302a29" stroke-width="6" /><path d="M39 73H97" fill="none" stroke="#aab4ad" stroke-width="3" /><ellipse cx="49" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M48 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="55" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M54 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="61" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M60 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="67" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M66 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="73" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M72 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="79" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M78 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="85" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M84 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><path d="M57 50H80L84 61H54Z" fill="#967e50" stroke="#302a29" stroke-width="2.0" /><path d="M55 48L70 37 84 45V57L55 60Z" fill="#e6bd58" stroke="#302a29" stroke-width="2.0" /><path d="M55 48L70 37 84 45 68 49Z" fill="#f7d778" stroke="#302a29" stroke-width="2.0" /><circle cx="62" cy="53" r="2.5" fill="#b48b36" stroke="none" stroke-width="2"/><circle cx="77" cy="49" r="2" fill="#b48b36" stroke="none" stroke-width="2"/><circle cx="74" cy="55" r="1.6" fill="#b48b36" stroke="none" stroke-width="2"/><path d="M69 99L70 62 74 58" fill="none" stroke="#302a29" stroke-width="4" /><path d="M69 99L70 62 74 58" fill="none" stroke="#dadcc9" stroke-width="1.8" /><circle cx="69" cy="101" r="3" fill="#727b72" stroke="#302a29" stroke-width="2"/></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Boobytrap</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#ab784d" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Boobytrap</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<path d="M30 91h84v35H30Z" fill="#81562f"/>
|
|
||||||
<path d="M36 76h72l6 15H30Z" fill="#a77a42"/>
|
|
||||||
<path d="M40 96h64M53 92v34m38-34v34" fill="none" stroke="#d1a35c" stroke-width="4"/>
|
|
||||||
<path d="M43 78l-5-27 13 21 10-31 10 31 12-33 8 34 16-22-7 28" fill="#9b9a88"/>
|
|
||||||
<path d="M42 78l-4-24m23 19V44m22 30l8-31m9 36l8-25" fill="none" stroke="#555a59" stroke-width="5"/>
|
|
||||||
<circle cx="72" cy="108" r="8" fill="#29251e"/><circle cx="72" cy="108" r="3" fill="#d54835"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1,18 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dagger token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dagger token">
|
||||||
<defs>
|
<title>Dagger</title><desc>Dagger illustrated in shaded ink on parchment.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="33" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#5e9298" stop-opacity=".2"/><stop offset="1" stop-color="#5e9298" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M27 111L49 82 99 24 123 14 118 38 63 91 37 119Z" fill="#aebcb7" stroke="#302a29" stroke-width="2.0" /><path d="M49 82L123 14 63 91Z" fill="#f7f0d7" stroke="#302a29" stroke-width="2.0" /><path d="M53 85L118 22" fill="none" stroke="#718d8b" stroke-width="2" /><path d="M39 78L72 104 76 98 45 71Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M28 96L42 85 58 98 47 112Z" fill="#9a4736" stroke="#302a29" stroke-width="2.0" /><path d="M30 97L46 109M36 92L51 103" fill="none" stroke="#d6ad57" stroke-width="2" /><path d="M23 108L30 101 44 112 36 121Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M106.0,39.0 107.3,43.7 112.0,45.0 107.3,46.3 106.0,51.0 104.7,46.3 100.0,45.0 104.7,43.7Z" fill="#f8edcf" stroke="none" stroke-width="2.0" /></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Dagger</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#5e9298" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Dagger</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<path d="M46 116l12-25 34-45 10-9-4 14-33 46Z" fill="#b9c0b8"/>
|
|
||||||
<path d="M58 91l34-45-23 52Z" fill="#ece6d2"/>
|
|
||||||
<path d="M43 89l27 20" fill="none" stroke="#8c6a35" stroke-width="8"/>
|
|
||||||
<path d="M46 104l-13 17 8 7 15-18Z" fill="#d54835"/>
|
|
||||||
<circle cx="37" cy="125" r="5" fill="#d9b554"/>
|
|
||||||
<path d="M90 48l-7 18" fill="none" stroke="#ffffff" stroke-width="2" opacity=".7"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -1,20 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Democratic Monster token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Democratic Monster token">
|
||||||
<defs>
|
<title>Democratic Monster</title><desc>Red many-tentacled creature with lobster claws and eyes on stalks.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="34" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#c74f42" stop-opacity=".2"/><stop offset="1" stop-color="#c74f42" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M58 88Q20 73 20 101Q20 115 39 108" fill="none" stroke="#302a29" stroke-width="13" /><path d="M58 88Q20 73 20 101Q20 115 39 108" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M68 92Q38 125 19 117" fill="none" stroke="#302a29" stroke-width="13" /><path d="M68 92Q38 125 19 117" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M83 93Q119 75 123 96Q129 113 110 111" fill="none" stroke="#302a29" stroke-width="13" /><path d="M83 93Q119 75 123 96Q129 113 110 111" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M79 97Q86 123 110 121" fill="none" stroke="#302a29" stroke-width="13" /><path d="M79 97Q86 123 110 121" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M72 91Q63 117 48 116" fill="none" stroke="#302a29" stroke-width="13" /><path d="M72 91Q63 117 48 116" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M47 73Q38 50 33 49L23 37 18 54 29 66 44 65M97 71Q110 55 112 49L122 34 129 53 117 66 104 64Z" fill="#cc5844" stroke="#302a29" stroke-width="2.0" /><path d="M27 45L28 59M122 44L118 58" fill="none" stroke="#f49868" stroke-width="2" /><path d="M47 76Q43 51 71 48Q101 48 100 77L91 94 53 95Z" fill="#c74f42" stroke="#302a29" stroke-width="2.0" /><path d="M59 51L53 34M84 51L91 33" fill="none" stroke="#302a29" stroke-width="7" /><circle cx="52" cy="31" r="7" fill="#ed9970" stroke="#302a29" stroke-width="2"/><circle cx="92" cy="30" r="7" fill="#ed9970" stroke="#302a29" stroke-width="2"/><circle cx="53" cy="30" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><circle cx="91" cy="29" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><path d="M53 72Q74 87 94 68L88 87 59 89Z" fill="#302a29" stroke="#302a29" stroke-width="2.0" /><path d="M59 76L63 83 67 78M79 78L82 84 86 76Z" fill="#f8edcf" stroke="#302a29" stroke-width="2.0" /><path d="M55 58Q64 52 75 56" fill="none" stroke="#f49868" stroke-width="3" /><path d="M32 99L39 96M98 106L103 114M62 104L68 108" fill="none" stroke="#302a29" stroke-width="1.1" opacity=".45"/></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="10" letter-spacing=".3" fill="#29251e">Democratic Monster</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#c74f42" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="9" font-weight="700" fill="#39302a">Democratic Monster</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<path d="M38 129q-2-39 15-57h39q17 18 14 57Z" fill="#a94b3f"/>
|
|
||||||
<circle cx="51" cy="60" r="15" fill="#c45c48"/><circle cx="73" cy="52" r="17" fill="#a63f3b"/><circle cx="95" cy="60" r="15" fill="#d27852"/>
|
|
||||||
<path d="M40 50l-7-12 13 5m19-5l8-13 8 13m13 5l15-5-8 13" fill="#b54d3e"/>
|
|
||||||
<circle cx="47" cy="60" r="2"/><circle cx="56" cy="60" r="2"/><circle cx="68" cy="52" r="2"/><circle cx="78" cy="52" r="2"/><circle cx="91" cy="60" r="2"/><circle cx="100" cy="60" r="2"/>
|
|
||||||
<path d="M45 68q7 6 13 0m8-7q7 6 14 0m8 7q7 6 14 0" fill="none"/>
|
|
||||||
<path d="M43 87l-22 14m82-14l20 14" fill="none" stroke="#a94b3f" stroke-width="9"/>
|
|
||||||
<path d="M18 90l9-5 6 9-6 9Z" fill="#eee5ca"/><path d="M112 85l11 5-2 13-11-5Z" fill="#eee5ca"/>
|
|
||||||
<path d="M53 85q19 13 39 0" fill="none" stroke="#e1a071" stroke-width="4"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -1,18 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dimensional Warp token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dimensional Warp token">
|
||||||
<defs>
|
<title>Dimensional Warp</title><desc>Tilted spiral vortex with a dark center and radiating magical sparks.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="35" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#7e6a9f" stop-opacity=".2"/><stop offset="1" stop-color="#7e6a9f" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="78" rx="53" ry="43" fill="#484562" stroke="#302a29" stroke-width="3"/><ellipse cx="72" cy="78" rx="44" ry="34" fill="#78749e" stroke="#302a29" stroke-width="2"/><ellipse cx="72" cy="78" rx="33" ry="25" fill="#bbb0d4" stroke="none" stroke-width="2"/><ellipse cx="72" cy="78" rx="22" ry="16" fill="#363650" stroke="none" stroke-width="2"/><path d="M29 76Q37 41 82 47Q127 55 107 86Q90 108 59 95Q36 84 59 69Q76 59 87 75Q89 85 76 84" fill="none" stroke="#ece0e9" stroke-width="4" /><path d="M20 60L14 48M42 35L36 24M70 31L72 16M100 38L110 27M124 64L135 60M118 103L130 109M91 118L96 129M57 119L51 130M26 101L14 108" fill="none" stroke="#6e608d" stroke-width="3" /><path d="M30.0,33.0 31.5,38.5 37.0,40.0 31.5,41.5 30.0,47.0 28.5,41.5 23.0,40.0 28.5,38.5Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /><path d="M115.0,109.0 116.5,114.5 122.0,116.0 116.5,117.5 115.0,123.0 113.5,117.5 108.0,116.0 113.5,114.5Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /><path d="M42 60Q58 49 75 51M91 100L104 95" fill="none" stroke="#d7bddb" stroke-width="2" /></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="11.5" letter-spacing=".3" fill="#29251e">Dimensional Warp</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#7e6a9f" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Dimensional Warp</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<ellipse cx="72" cy="79" rx="45" ry="43" fill="#3f3158"/>
|
|
||||||
<ellipse cx="72" cy="79" rx="35" ry="34" fill="#6c3eae"/>
|
|
||||||
<ellipse cx="72" cy="79" rx="23" ry="24" fill="#b196df"/>
|
|
||||||
<ellipse cx="72" cy="79" rx="10" ry="12" fill="#28223b"/>
|
|
||||||
<path d="M72 30v17m0 64v18M22 79h18m64 0h18M37 44l13 13m44 44l13 13m0-70L94 57m-44 44-13 13" fill="none" stroke="#8a5ed0" stroke-width="5"/>
|
|
||||||
<path d="M72 62l5 10 11 2-8 7 2 11-10-6-10 6 2-11-8-7 11-2Z" fill="#eee5ff" stroke-width="1.5"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1,16 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dustcloud token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dustcloud token">
|
||||||
<defs>
|
<title>Dustcloud</title><desc>Dustcloud illustrated in shaded ink on parchment.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="36" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#696b64" stop-opacity=".2"/><stop offset="1" stop-color="#696b64" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M30 112Q8 100 22 80Q10 60 33 54Q29 34 53 36Q66 17 84 36Q111 28 115 52Q138 66 123 84Q137 107 113 116Z" fill="#62615a" stroke="#302a29" stroke-width="2.0" /><path d="M25 91Q21 76 36 73Q24 54 48 51Q48 35 68 42Q91 30 98 48Q118 44 121 63" fill="none" stroke="#959080" stroke-width="5" /><path d="M46 69Q64 54 77 69Q82 83 70 88M99 66Q113 78 103 91M40 91Q34 105 52 108" fill="none" stroke="#3c403e" stroke-width="4" /><path d="M9 104L19 109M124 40L132 34M13 44L23 50M91 17L93 25" fill="none" stroke="#837c6c" stroke-width="2" /></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Dustcloud</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#696b64" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Dustcloud</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<path d="M32 111q-17-17 1-31-11-25 16-27 5-23 27-12 20-18 32 4 25 3 18 28 17 18-2 34 3 20-24 20H48q-23 2-16-16Z" fill="#c8a56a"/>
|
|
||||||
<path d="M44 91q-9-15 7-23m12 44q-14-13 1-27m20-23q12-13 25 0m-18 48q18-5 20-21" fill="none" stroke="#806b48" stroke-width="4"/>
|
|
||||||
<path d="M28 55l-11-8m101-6l11-10M25 119l-13 5m105 1l14 4" fill="none" stroke="#b28b54" stroke-width="5"/>
|
|
||||||
<circle cx="49" cy="57" r="3" fill="#745f3e"/><circle cx="103" cy="78" r="4" fill="#745f3e"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,18 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Fire Imp token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Fire Imp token">
|
||||||
<defs>
|
<title>Fire Imp</title><desc>Fire Imp illustrated in shaded ink on parchment.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="37" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#d8523b" stop-opacity=".2"/><stop offset="1" stop-color="#d8523b" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M30 121Q7 105 28 74Q27 93 42 94Q29 56 56 26Q48 56 67 58Q76 43 81 15Q110 45 99 78Q116 64 118 45Q141 96 114 121Z" fill="#d65c35" stroke="#302a29" stroke-width="2.0" /><path d="M38 118Q24 98 40 92Q48 108 59 90L71 57Q87 87 81 101Q106 80 116 79Q126 108 106 121Z" fill="#f4c25f" stroke="none" stroke-width="2.0" /><path d="M47 77L27 57 25 80 47 96M91 78L115 57 119 81 96 97Z" fill="#b33335" stroke="#302a29" stroke-width="2.0" /><path d="M52 89L45 116 59 122 73 111 86 123 101 115 91 86Z" fill="#c84035" stroke="#302a29" stroke-width="2.0" /><path d="M48 53L44 25 61 42Q72 37 84 43L105 25 98 57 95 78Q71 99 50 77Z" fill="#d8523b" stroke="#302a29" stroke-width="2.0" /><path d="M45 28L60 44 49 48M101 30L96 48 86 45Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M53 59L67 64 57 68ZM78 63L93 56 88 68Z" fill="#f8edcf" stroke="#302a29" stroke-width="2.0" /><path d="M62 77L72 81 85 75M69 78L72 85 76 79" fill="none" stroke="#302a29" stroke-width="2" /><path d="M97 109Q120 123 122 99" fill="none" stroke="#302a29" stroke-width="6" /><path d="M97 109Q120 123 122 99" fill="none" stroke="#b33335" stroke-width="3" /></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Fire Imp</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#d8523b" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Fire Imp</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<path d="M44 128q-7-29 8-47-17-17 3-27-1-19 17-17 18-3 18 17 21 10 3 28 14 19 6 46Z" fill="#d54835"/>
|
|
||||||
<path d="M53 52L39 34l24 12m27 5l17-18-7 25" fill="#d54835"/>
|
|
||||||
<path d="M47 74L24 58l8 25m66-9l23-16-8 25" fill="#ef8a3c"/>
|
|
||||||
<circle cx="63" cy="67" r="5" fill="#f6d34e"/><circle cx="84" cy="67" r="5" fill="#f6d34e"/>
|
|
||||||
<path d="M59 91q14 12 28-2M48 115l-24 12m74-12l23 12" fill="none"/>
|
|
||||||
<path d="M93 106q21-7 18-25 15 16-1 29" fill="none" stroke="#d54835" stroke-width="6"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,21 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Killer Ooze token">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Killer Ooze token">
|
||||||
<defs>
|
<title>Killer Ooze</title><desc>Killer Ooze illustrated in shaded ink on parchment.</desc>
|
||||||
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="38" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
|
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#4e995b" stop-opacity=".2"/><stop offset="1" stop-color="#4e995b" stop-opacity=".03"/></radialGradient></defs>
|
||||||
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
|
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||||
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
|
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||||
</defs>
|
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||||
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
|
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M15 111Q16 97 32 97Q20 76 35 59Q43 27 70 34Q102 23 115 58Q129 73 117 99Q135 104 127 116L91 122 43 119Z" fill="#3c895e" stroke="#302a29" stroke-width="2.0" /><path d="M24 108Q43 102 43 67Q49 38 72 43Q103 32 106 61Q118 90 110 105Q99 114 86 103L72 112 54 104 43 112Z" fill="#74b875" stroke="none" stroke-width="2.0" /><path d="M43 74Q72 57 106 73L103 93Q77 113 46 92Z" fill="#233f36" stroke="#302a29" stroke-width="2.0" /><path d="M48 73L54 88 60 71M65 69L73 86 81 68M89 70L96 86 102 72M56 96L61 86 67 100M83 100L91 85 94 96Z" fill="#f1e6c4" stroke="#302a29" stroke-width="2.0" /><ellipse cx="57" cy="57" rx="5" ry="4" fill="#d6ad57" stroke="none" stroke-width="2"/><ellipse cx="89" cy="54" rx="5" ry="4" fill="#d6ad57" stroke="none" stroke-width="2"/><circle cx="58" cy="57" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><circle cx="88" cy="54" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><path d="M40 54Q44 43 52 43M99 92L96 99" fill="none" stroke="#c9dc9a" stroke-width="3" /><circle cx="31" cy="91" r="4" fill="#90c185" stroke="#302a29" stroke-width="2"/><circle cx="109" cy="43" r="4" fill="#6aa773" stroke="#302a29" stroke-width="2"/><circle cx="24" cy="72" r="3" fill="#6aa773" stroke="#302a29" stroke-width="2"/></g>
|
||||||
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
|
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||||
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Killer Ooze</text>
|
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#4e995b" stroke-width="2"/>
|
||||||
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
|
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Killer Ooze</text>
|
||||||
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
</svg>
|
||||||
<path d="M29 125q15-15 16-42-3-28 20-40 12-8 23 3 27-1 28 22-3 18-16 22 8 22 18 35-21 7-42-2-25 12-47 2Z" fill="#21824d"/>
|
|
||||||
<path d="M47 87q8-35 30-40 25 0 28 33-11 18-29 13-17 5-29-6Z" fill="#154f36"/>
|
|
||||||
<path d="M55 69q20-18 42 1-2 22-21 23-18-2-21-24Z" fill="#252923"/>
|
|
||||||
<path d="M57 69l8 18 8-20 8 20 8-19 7 15" fill="#f4efd9" stroke="#252923" stroke-width="2"/>
|
|
||||||
<path d="M55 55q7-11 15-1m14-3q8-8 14 2" fill="none" stroke="#b8d875" stroke-width="4"/>
|
|
||||||
<circle cx="64" cy="54" r="3" fill="#f0d664"/><circle cx="91" cy="52" r="3" fill="#f0d664"/>
|
|
||||||
<path d="M45 94q-18 2-23 18m84-24q17 3 20 16" fill="none" stroke="#21824d" stroke-width="9"/>
|
|
||||||
<circle cx="36" cy="66" r="4" fill="#3d9b5f"/><circle cx="25" cy="83" r="3" fill="#3d9b5f"/><circle cx="119" cy="75" r="4" fill="#3d9b5f"/>
|
|
||||||
<path d="M43 113q28 11 59-2" fill="none" stroke="#4fa66b" stroke-width="5"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.4 KiB |