diff --git a/.claude/skills/wizwar-pulse/SKILL.md b/.claude/skills/wizwar-pulse/SKILL.md index 3257cce..beb9ea2 100644 --- a/.claude/skills/wizwar-pulse/SKILL.md +++ b/.claude/skills/wizwar-pulse/SKILL.md @@ -34,26 +34,23 @@ quiet, say so in one line before the details. 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, + referrers, 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=` — the tags - on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers + (one per address per day) whose link carried a tag the rollup header + names (`?ref=`, a utm_source, Facebook's 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). + **Right now** (on request): `wizwar-visitors.sh` prints it — live + sockets, rooms touched in the last hour, and today's partial row — + see /wizwar-visitors. 5. **The table itself**: - Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l` - - New human names this week: room ledgers' join lines minus bots - (Automaton*) and known names, on rooms created in the last 7 days - (meta line `createdAt`) + - New human names this week: the rollup rows' `humanNames` (host and + joiners alike, bots excluded) minus the regulars the visitors skill + lists - Unanswered feedback count from /var/lib/wizwar/feedback.jsonl — if any, offer to run the reports desk (`/wizwar-reports`) - Access-log pulse: `wc -l /var/lib/caddy/access.log` and a count of @@ -71,9 +68,9 @@ action, or "nothing needs you." - The service restarts cleanly (ledgers replay); restarts in the journal during deploys are normal, not incidents. -- Room memory is evicted after idle (finished 30 min, others 24 h); - boot-restores load everything then shed, so post-deploy memory spikes - settle within the hour. +- Room memory is evicted after idle (finished 30 min, others 24 h), each + sleeper leaving a stub on disk; a boot replays only rooms whose stub is + stale and leaves the rest asleep, so a deploy costs no memory spike. - Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch requires it; a reboot there is maintenance, not an outage. - Caddy access logs live at /var/lib/caddy/access.log (self-rotating, diff --git a/.claude/skills/wizwar-visitors/SKILL.md b/.claude/skills/wizwar-visitors/SKILL.md index 6c57f2f..4c9cfea 100644 --- a/.claude/skills/wizwar-visitors/SKILL.md +++ b/.claude/skills/wizwar-visitors/SKILL.md @@ -19,8 +19,9 @@ first, so the traffic line is current to the minute. Its output: "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. + replay views, campaigns (the tags a link carries: `?ref=`, a + utm_source, 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). @@ -37,7 +38,7 @@ 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 +pickUpTreasure is the treasure wall (the in-game slips explain it); 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. diff --git a/deploy/wizwar-rollup.cron b/deploy/wizwar-rollup.cron index 2ef4fcd..b7e9117 100644 --- a/deploy/wizwar-rollup.cron +++ b/deploy/wizwar-rollup.cron @@ -1,2 +1,3 @@ # Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh. +# The time is also SCHEDULE in wizwar-rollup.sh, which tells Sentry to expect it; change both. 10 0 * * * root /usr/local/bin/wizwar-rollup.sh diff --git a/deploy/wizwar-rollup.sh b/deploy/wizwar-rollup.sh index 7ec67ae..e5e62d6 100755 --- a/deploy/wizwar-rollup.sh +++ b/deploy/wizwar-rollup.sh @@ -4,22 +4,26 @@ # 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= 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). +# (Reddit and BGG send none); campaigns are the tags a link itself carries +# — ?ref= on a link Eric posts, a utm_source, or Facebook's fbclid — +# one count per address per day. +# Cron: see SCHEDULE below and /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)}" +# The crontab line, as /etc/cron.d/wizwar-rollup spells it: the Sentry +# monitor is told the same shape, so a missed night is noticed. +SCHEDULE="10 0 * * *" 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\"}}" \ + -d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \ "$CRON_URL" || true } @@ -31,7 +35,9 @@ 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 --- +# --- traffic: every access log file touched since the day began. Address +# and request counts split people from bots; the path mix and referrers +# count people only. --- 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= on a @@ -63,15 +69,15 @@ for f in sorted(glob.glob("/var/lib/caddy/access*.log*")): 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 + if not isbot: paths[key] += 1 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. + # Scanners forge referrers on paths like /.env, and the app answers + # unknown paths with the page itself, so the status cannot tell a + # probe from a reader. Only the pages count. if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1 # --- the table --- @@ -107,7 +113,7 @@ 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 "" + 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") diff --git a/deploy/wizwar-visitors.sh b/deploy/wizwar-visitors.sh index 5e802f2..898916d 100755 --- a/deploy/wizwar-visitors.sh +++ b/deploy/wizwar-visitors.sh @@ -7,7 +7,7 @@ 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 +import json, sys, glob, 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)) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 93bc0de..e720035 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -7,7 +7,7 @@ // BERSERKER for blood, the WORRIER for the shadows between the two. import { cardDef, type CardInstance } from "./cards"; -import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; +import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side, pitRimExits } from "./board"; import { bentSightFor, sightedCellsFor, type GameView } from "./view"; import { wallIgnoringDistance } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game"; @@ -584,31 +584,19 @@ function wallBlastTarget( return { cell: best.cell, side: best.side }; } +/** 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 one reading legal under every deckRev. */ +function pitExits(view: GameView, pit: Cell, dir: Side): Side[] { + const { floor, mouths } = pitRimExits(view.board, pit, dir, (c) => view.squareContents[cellKey(c)]?.kind === "stone"); + return floor.length > 0 ? floor : mouths; +} + /** * BFS over walkable steps toward the nearest goal. Doors count as passable * when the clockwork can unlock them; the first such door is reported so the * key gets used before the boot. */ -/** 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, @@ -634,12 +622,12 @@ export function pathToward( const { to, viaDoor } = step; const k = cellKey(to); if (seen.has(k)) continue; - if (view.squareContents[k]?.kind === "stone") continue; const hazard = view.squareContents[k]?.kind; + if (hazard === "stone") continue; // 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; + if (hazard === "pit" && pitExits(view, to, dir).length === 0) 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; @@ -766,7 +754,7 @@ function overLimit(view: GameView): number { * clogged hand never refreshes; only cards the brain has no play for are * shed (good counters and attacks are hoarded, as a human would). */ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command { - const draw = drawUnderCurses(view, tier.draw); + const draw = drawUnderSlowDeath(view, tier.draw); const deficit = draw - (handLimitOf(view) - view.yourHand.length); if (tier.sheds && deficit > 0) { const shed = [...view.yourHand] @@ -782,7 +770,7 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle /** 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 { +export function drawUnderSlowDeath(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)); diff --git a/packages/engine/src/board.ts b/packages/engine/src/board.ts index 96fe91c..cd29438 100644 --- a/packages/engine/src/board.ts +++ b/packages/engine/src/board.ts @@ -253,6 +253,24 @@ export function stepTarget( return { kind: "blocked", by: "wall" }; } +/** The sides a walker may leave a pit by, having entered it heading + * `entry`: the neighbouring squares that offer footing, and the warp + * mouths on the rim, the way back excluded. `filled` names the squares + * nothing can land on. */ +export function pitRimExits( + board: AssembledBoard, pit: Cell, entry: Side, filled: (c: Cell) => boolean, +): { floor: Side[]; mouths: Side[] } { + const floor: Side[] = []; + const mouths: Side[] = []; + for (const d of SIDES) { + if (d === opposite(entry)) continue; + const t = stepTarget(board, pit, d); + if (t.kind === "blocked" || filled(t.to)) continue; + (t.kind === "step" ? floor : mouths).push(d); + } + return { floor, mouths }; +} + /** * Line of sight from the center of `from` to the center of `to`, blocked by * wall/door/firewall edges the segment crosses and by any `blockedCells` diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index a52844c..c555234 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -27,7 +27,7 @@ import { bentSightThroughGap, neighbor, stepTarget, - opposite, + opposite, pitRimExits, } from "./board"; import { buildDeck, @@ -80,6 +80,12 @@ export interface PlayerState { } /** A duration spell in play. Expires at the START of the caster's turns. */ +/** 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). */ +export interface PushPending { + giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[]; +} + export interface SustainedEffect { id: string; cardId: string; @@ -298,11 +304,7 @@ export interface GameState { /** A treasure was just grabbed and its owner holds WARD: * the table waits while they choose to play it "at that time" or not. */ 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; + pushPending: PushPending | null; /** SLOW DEATH's bites hang while the victim weighs counteractions: * the draw's total is one blow — ABSORB soaks up to three of it, * BLUNT halves it rounding up (table ruling). */ @@ -2401,11 +2403,7 @@ const CARD_EFFECTS: Record if ((state.config.deckRev ?? 1) < 10 || !target) return null; const caster = activePlayer(state); const legs = state.turn.movementAllowance - state.turn.movementUsed; - // 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); + const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs)); if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn"; return null; }, @@ -2413,8 +2411,7 @@ const CARD_EFFECTS: Record if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; if ((ctx.state.config.deckRev ?? 1) >= 10) { const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed; - const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6; - const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach); + const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs)); if (d === 0) return; if (d > legs) { // The target slipped beyond the charge (a counter-teleport, say): @@ -2903,10 +2900,8 @@ function waveForce(range: number, dist: number): number { /** 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. */ + * (rev 20); older games sweep square by square, so a wizard shoved along + * the sweep's own line is caught and shoved again. */ function sweepWave( state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string, ): void { @@ -3724,9 +3719,16 @@ function attachSustained( }); } -/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */ -/** Steps along real corridors from one cell to another, searched out to - * `limit` steps; beyond that the answer is Infinity. */ +/** How far a BUTT-HEAD charge is measured: as far as the goat's legs + * reach (rev 16); older games search six steps and call the rest + * unreachable. */ +function chargeReach(state: GameState, legs: number): number { + return (state.config.deckRev ?? 1) >= 16 ? legs : 6; +} + +/** Steps along real corridors from one cell to another (MENTAL FORCE's + * three moved spaces, a goat's charge), 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; const view = boardView(state); @@ -4254,6 +4256,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // move ... bumping into a wall counts as one space of movement." + // A resumed push carries the direction its pushee chose: no stagger. if (isBlinded(state, p) && shove === undefined) { direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!; } @@ -4397,10 +4400,9 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho // 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. + // hangs until they do. Older games (deckRev < 15) shove straight + // ahead when that way is open; rounding a corner is allowed in every + // game. const fork = (state.config.deckRev ?? 1) >= 15; let landing: Cell | null = null; const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null); @@ -4461,19 +4463,12 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho 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"); + const { floor, mouths } = pitRimExits(view, pitCell, direction, + (c) => state.squareContents[cellKey(c)]?.kind === "stone"); // 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. + // before it a bare step takes the square. const exits = [...floor, ...mouths]; const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor; if (exits.length === 0) { @@ -4489,7 +4484,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho 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)!; + const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell }; landingCell = foot.to; landingWarp = foot.kind === "warp"; } @@ -6074,16 +6069,13 @@ function doCounteract( // opponent from entering the space you occupy or casting spells on or // past you. Lasts only until the end of the opponent's turn." The // field faces every side, not one: this table's simplification. + const events: GameEvent[] = [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }]; if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) { - const against = state.players.findIndex((q) => q.id === stack.attackerId); - attachSustained(state, [], "force-field", playerId, playerId, PERMANENT_TURNS, { against }); + const facing = state.players.findIndex((q) => q.id === stack.attackerId); + attachSustained(state, events, "force-field", playerId, playerId, PERMANENT_TURNS, { against: facing }); } stack.waitingOn = stack.attackerId; - return { - ok: true, - state, - events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }], - }; + return { ok: true, state, events }; } if (playerId === stack.attackerId) { @@ -6844,8 +6836,8 @@ function checkVictory(state: GameState, events: GameEvent[]): void { return owner !== null && owner !== p.id && ownerPlayer?.alive === true; }); // Rev 21: LIFESAVER — "immune to the effects of losing both of your - // treasures to other players' home bases." The card was cast and - // remembered but never asked here; older games eliminated its holder. + // treasures to other players' home bases." Older games eliminate its + // holder. const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0; if (lost && !saved) { p.alive = false; @@ -7178,10 +7170,8 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { state.openSafes = []; // A FORCE FIELD raised against this wizard lasts only until their turn ends. - { - const idx = state.players.findIndex((q) => q.id === p.id); - state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === idx)); - } + const seat = state.players.indexOf(p); + state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === seat)); events.push({ type: "turnEnded", player: p.id }); if (p.extraTurns > 0) { diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index fe7928b..f0f621a 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -9,6 +9,7 @@ import { LOS_BLOCKING_CONTENT, type AmbushState, type CastStack, + type PushPending, type CreatureState, type GameState, type PlayerId, @@ -93,7 +94,7 @@ export interface GameView { chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; /** A grab hangs while the treasure's owner decides their Ward. */ wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; - pushPending: GameState["pushPending"]; + pushPending: PushPending | null; slowDeathPending: { playerId: PlayerId; points: number } | null; /** YOUR armed ambushes. Other players' ambushes are invisible. */ yourAmbushes: AmbushState[]; @@ -229,7 +230,7 @@ export function sightedCellsFor(view: GameView, from?: Cell): Set { const eye = from ?? me.position; const { board, blockers } = sightBasis(view); // In a dust cloud a wizard sees only their own square (rev 19). - if (dustAtEnd(view, eye, eye, true)) { + if (inDust(view, eye)) { out.add(`${eye.x},${eye.y}`); return out; } @@ -333,15 +334,15 @@ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace 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); +/** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */ +function inDust(view: GameView, cell: Cell): boolean { + return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust"; +} + +/** Dust at either end kills a sight line; a square sees itself still. */ +function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean { if (from.x === to.x && from.y === to.y) return false; - return dust(from) || dust(to); + return inDust(view, from) || inDust(view, to); } /** diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 243800f..caa35c6 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -8,7 +8,7 @@ import { } from "../src/game"; import { cellKey, edgeKey } from "../src/board"; import { sightedCellsFor, viewFor } from "../src/view"; -import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; +import { automatonCommand, automatonFallback, drawUnderSlowDeath, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; import { pushSustained } from "./helpers"; /** Whose input does the maze want right now? */ @@ -1129,14 +1129,14 @@ describe("the clockwork under a curse, and before a pit", () => { const state = botsTurn(); const bot = state.players.find((p) => p.id === "bot")!; const plain = viewFor(state, "bot"); - expect(drawUnderCurses(plain, 2)).toBe(2); + expect(drawUnderSlowDeath(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); + expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2); bot.life = 5; - expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1); + expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1); bot.life = 4; - expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0); + expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(0); }); it("raises no SHADOW while bleeding, nor with little blood to spare", () => { @@ -1152,7 +1152,7 @@ describe("the clockwork under a curse, and before a pit", () => { 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"); + expect(cmd.type === "cast" && cmd.instanceId === "shadow#T").toBe(false); const r = applyCommand(state, "bot", cmd); if (!r.ok) break; state = r.state; diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index c21dc76..0ae6fe0 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -396,7 +396,7 @@ describe("big man", () => { /** 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") { + function pushSite(state: GameState, want: "corner" | "fork" | "straight-fork") { const view = boardView(state); for (const key of Object.keys(view.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; @@ -407,6 +407,7 @@ describe("big man", () => { 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 }; + if (want === "straight-fork" && exits.length >= 2 && exits.includes(side)) return { at: { x, y }, side, mid: t1.to, exits }; } } throw new Error(`no ${want} on this board`); @@ -453,15 +454,15 @@ describe("big man", () => { 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", () => { + it("a pre-rev-15 game shoves straight ahead when it can and 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)) { + const fork = pushSite(state, "straight-fork"); + { giant.position = { ...fork.at }; victim.position = { ...fork.mid }; const r = applyCommand(state, giant.id, { type: "move", direction: fork.side }); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index f59c7b0..b6f0f4c 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -813,7 +813,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => { }); }); -describe("butt-head's charge reaches as far as its legs (K4T3)", () => { +describe("butt-head's charge reaches as far as its legs (rev 16)", () => { /** 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); diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index e991606..7353125 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -561,7 +561,7 @@ describe("the boobytrap keeps its secret", () => { }); }); -describe("a pit on the board's rim (M4Q7)", () => { +describe("a pit on the board's rim (rev 17)", () => { /** 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) { @@ -637,7 +637,7 @@ describe("a pit on the board's rim (M4Q7)", () => { }); }); -describe("a wave names its victims before it pushes (LK97)", () => { +describe("a wave names its victims before it pushes (rev 20)", () => { /** 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) { @@ -692,7 +692,6 @@ describe("spells cast at a slime wait in the gel", () => { 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 }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4237fe7..4f4d657 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -204,9 +204,10 @@ function safeBase(rawHost: string, rawProto: string): string { * carried home, a blow that landed, a spell cast — before the bare fact * of whose turn it was. */ function headlineOf(data: ShareData): string | null { + // A card retired since the share was written keeps its id as its name. const name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } }; - let best: { rank: number; text: string } | null = null; - const offer = (rank: number, text: string) => { if (!best || rank > best.rank) best = { rank, text }; }; + const deeds: { rank: number; text: string }[] = []; + const offer = (rank: number, text: string) => { deeds.push({ rank, text }); }; for (const step of data.steps) { for (const e of step.events) { switch (e.type) { @@ -225,7 +226,9 @@ function headlineOf(data: ShareData): string | null { } } } - return best ? (best as { text: string }).text : null; + let best: { rank: number; text: string } | null = null; + for (const d of deeds) if (!best || d.rank > best.rank) best = d; + return best?.text ?? null; } function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string { diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index a4ba6d0..a4ad524 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -165,7 +165,7 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number evicted++; try { const stat = ledgerStat(id); - if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) }); + if (stat) writeStubFile(id, stat.bytes, toStored(stub)); } catch (e) { console.error(`could not write stub for ${id}:`, e); } @@ -185,6 +185,8 @@ interface RoomStub { const sleepingStubs = new Map(); /** A stub as it rests on disk — the token map spelled as an object. */ type StoredStub = Omit & { tokens: Record }; +const toStored = (stub: RoomStub): StoredStub => ({ ...stub, tokens: Object.fromEntries(stub.tokens) }); +const fromStored = (stored: StoredStub): RoomStub => ({ ...stored, tokens: new Map(Object.entries(stored.tokens)) }); function stubOf(room: Room): RoomStub { const { active, turnHolder, waitKind } = turnFacts(room.state); @@ -797,7 +799,7 @@ export function loadPersistedRooms(): void { if (!stat) continue; const stored = readStubFile(id); if (stored && stored.bytes === stat.bytes) { - sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) }); + sleepingStubs.set(id, fromStored(stored.stub)); asleep++; continue; } diff --git a/packages/server/src/shares.ts b/packages/server/src/shares.ts index 4afbfb1..d281c9e 100644 --- a/packages/server/src/shares.ts +++ b/packages/server/src/shares.ts @@ -5,7 +5,7 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { randomInt } from "node:crypto"; -import { statsDir } from "./store"; +import { dataRoot } from "./store"; export interface Share { id: string; @@ -20,7 +20,7 @@ const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable const shares = new Map(); function sharesFile(): string { - return join(statsDir(), "shares.jsonl"); + return join(dataRoot(), "shares.jsonl"); } export function loadShares(): void { diff --git a/packages/server/src/stats.ts b/packages/server/src/stats.ts index b3b61da..94b5a07 100644 --- a/packages/server/src/stats.ts +++ b/packages/server/src/stats.ts @@ -6,7 +6,7 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { statsDir } from "./store"; +import { dataRoot } from "./store"; import type { Room } from "./rooms"; export interface EngagementStats { @@ -35,7 +35,7 @@ interface StatsFile extends Omit { roomStages: Record; } -const FILE = () => join(statsDir(), "stats.json"); +const FILE = () => join(dataRoot(), "stats.json"); let data: StatsFile = { gamesCreated: 0, gamesStarted: 0, gamesFinished: 0, diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 2d95864..c0cb7ed 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -86,8 +86,10 @@ function fileFor(roomId: string): string { return join(DATA_DIR, `${roomId}.jsonl`); } -/** Directory holding stats.json — the parent of the rooms dir. */ -export function statsDir(): string { +/** The data root — the parent of the rooms dir — where everything that + * is not a room ledger lives: stats, feedback, stubs, the graveyard, the + * clip vault. */ +export function dataRoot(): string { return join(DATA_DIR, ".."); } @@ -149,7 +151,7 @@ export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } | // 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"); +const stubDir = () => join(dataRoot(), "stubs"); function stubFor(roomId: string): string { if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`); return join(stubDir(), `${roomId}.json`); @@ -168,7 +170,7 @@ export function readStubFile(roomId: string): { bytes: number; stub: T } | nu } } -export function writeStubFile(roomId: string, bytes: number, stub: unknown): void { +export function writeStubFile(roomId: string, bytes: number, stub: T): void { const file = stubFor(roomId); mkdirSync(stubDir(), { recursive: true }); writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8"); @@ -182,7 +184,7 @@ export function writeStubFile(roomId: string, bytes: number, stub: unknown): voi * timestamp. A report line may carry fields (deckRev, player context) * that only the operator's raw read uses; readFeedback keeps the * player-facing subset. */ -const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl"); +const feedbackFile = () => join(dataRoot(), "feedback.jsonl"); export function appendFeedback(entry: Record): void { ensureDataDir(); @@ -239,7 +241,7 @@ export function readFeedback(): FeedbackReport[] { export function archiveRoomFile(roomId: string): void { const src = fileFor(roomId); if (!existsSync(src)) return; - const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); + const graveyard = join(dataRoot(), "rooms-abandoned"); mkdirSync(graveyard, { recursive: true }); renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`)); } @@ -260,7 +262,7 @@ export interface ClipMeta { height?: number; } -const clipsDir = () => join(DATA_DIR, "..", "clips"); +const clipsDir = () => join(dataRoot(), "clips"); /** A clip's name, which is also its file stem: no separators, no dots, * so a name can never name a path. */ diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 614170a..2ec0334 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,6 +1,7 @@ - - - + + + - - - + + + diff --git a/packages/web/src/fx-sprites/Fireworks.svelte b/packages/web/src/fx-sprites/Fireworks.svelte index 6a01f4a..d071878 100644 --- a/packages/web/src/fx-sprites/Fireworks.svelte +++ b/packages/web/src/fx-sprites/Fireworks.svelte @@ -35,7 +35,7 @@ style={`transform-origin: ${c.x}px ${c.y}px`} /> {/each} - + diff --git a/packages/web/src/fx-sprites/OozeSlip.svelte b/packages/web/src/fx-sprites/OozeSlip.svelte index ad83abb..8faee33 100644 --- a/packages/web/src/fx-sprites/OozeSlip.svelte +++ b/packages/web/src/fx-sprites/OozeSlip.svelte @@ -8,9 +8,9 @@ - - - + + + diff --git a/packages/web/src/fx-sprites/PitFall.svelte b/packages/web/src/fx-sprites/PitFall.svelte index 9c9f5b9..20d0722 100644 --- a/packages/web/src/fx-sprites/PitFall.svelte +++ b/packages/web/src/fx-sprites/PitFall.svelte @@ -11,9 +11,9 @@ - - - + + + diff --git a/packages/web/src/fx-sprites/SectorGrind.svelte b/packages/web/src/fx-sprites/SectorGrind.svelte index 2ae77a0..c0d8010 100644 --- a/packages/web/src/fx-sprites/SectorGrind.svelte +++ b/packages/web/src/fx-sprites/SectorGrind.svelte @@ -51,7 +51,7 @@ {/if} {#each CORNERS as p, i (i)} - + {/each} @@ -86,6 +86,7 @@ fill: rgba(160, 150, 130, 0.85); opacity: 0; animation: dust-kick 0.6s ease-out 0.85s forwards; + transform-box: fill-box; transform-origin: center; } .dust.d1 { animation-delay: 0.9s; } .dust.d2 { animation-delay: 0.95s; } @@ -128,5 +129,4 @@ 30% { opacity: 0.9; } 100% { opacity: 0; transform: translateY(-9px) scale(1.6); } } - .dust { transform-box: fill-box; transform-origin: center; } diff --git a/packages/web/src/fx-sprites/Sparkle.svelte b/packages/web/src/fx-sprites/Sparkle.svelte index 5f5f6b2..6c21d38 100644 --- a/packages/web/src/fx-sprites/Sparkle.svelte +++ b/packages/web/src/fx-sprites/Sparkle.svelte @@ -11,11 +11,11 @@ - + - - - + + + diff --git a/packages/web/src/fx-sprites/geom.ts b/packages/web/src/fx-sprites/geom.ts index d77a63f..b8daa0b 100644 --- a/packages/web/src/fx-sprites/geom.ts +++ b/packages/web/src/fx-sprites/geom.ts @@ -6,16 +6,15 @@ export const center = (c: { x: number; y: number }) => ({ y: c.y * CELL + CELL / 2, }); - /** Small cel silhouettes, expressed in board coordinates so their animation * origins stay attached to the existing cell and edge anchors. */ export const glint = (x: number, y: number, r: number) => - `M ${x} ${y-r} Q ${x+r*.13} ${y-r*.13} ${x+r*.84} ${y+.04*r} Q ${x+r*.15} ${y+r*.15} ${x+.06*r} ${y+r} Q ${x-r*.15} ${y+r*.13} ${x-r*.88} ${y} Q ${x-r*.14} ${y-r*.15} ${x} ${y-r} Z`; + `M ${x} ${y-r} Q ${x+r*0.13} ${y-r*0.13} ${x+r*0.84} ${y+0.04*r} Q ${x+r*0.15} ${y+r*0.15} ${x+0.06*r} ${y+r} Q ${x-r*0.15} ${y+r*0.13} ${x-r*0.88} ${y} Q ${x-r*0.14} ${y-r*0.15} ${x} ${y-r} Z`; export const puff = (x: number, y: number, r: number) => - `M ${x-r} ${y+r*.3} C ${x-r*1.2} ${y-r*.15} ${x-r*.85} ${y-r*.6} ${x-r*.5} ${y-r*.5} C ${x-r*.65} ${y-r*1.12} ${x+r*.15} ${y-r*1.1} ${x+r*.32} ${y-r*.55} C ${x+r*.95} ${y-r*.75} ${x+r*1.22} ${y+r*.1} ${x+r*.75} ${y+r*.4} C ${x+r*.58} ${y+r*.9} ${x-r*.72} ${y+r*.8} ${x-r} ${y+r*.3} Z`; + `M ${x-r} ${y+r*0.3} C ${x-r*1.2} ${y-r*0.15} ${x-r*0.85} ${y-r*0.6} ${x-r*0.5} ${y-r*0.5} C ${x-r*0.65} ${y-r*1.12} ${x+r*0.15} ${y-r*1.1} ${x+r*0.32} ${y-r*0.55} C ${x+r*0.95} ${y-r*0.75} ${x+r*1.22} ${y+r*0.1} ${x+r*0.75} ${y+r*0.4} C ${x+r*0.58} ${y+r*0.9} ${x-r*0.72} ${y+r*0.8} ${x-r} ${y+r*0.3} Z`; export const drop = (x: number, y: number, r: number) => - `M ${x+r*.2} ${y-r*1.7} C ${x+r*.45} ${y-r*.65} ${x+r*1.12} ${y-r*.1} ${x+r*.8} ${y+r*.6} C ${x+r*.45} ${y+r*1.25} ${x-r*.85} ${y+r*.9} ${x-r*.8} ${y+r*.1} C ${x-r*.75} ${y-r*.6} ${x-r*.1} ${y-r} ${x+r*.2} ${y-r*1.7} Z`; + `M ${x+r*0.2} ${y-r*1.7} C ${x+r*0.45} ${y-r*0.65} ${x+r*1.12} ${y-r*0.1} ${x+r*0.8} ${y+r*0.6} C ${x+r*0.45} ${y+r*1.25} ${x-r*0.85} ${y+r*0.9} ${x-r*0.8} ${y+r*0.1} C ${x-r*0.75} ${y-r*0.6} ${x-r*0.1} ${y-r} ${x+r*0.2} ${y-r*1.7} Z`; export const impact = (x: number, y: number, r: number) => - `M ${x-r*.06} ${y-r} L ${x+r*.22} ${y-r*.34} ${x+r*.91} ${y-r*.58} ${x+r*.46} ${y-r*.05} ${x+r} ${y+r*.39} ${x+r*.31} ${y+r*.28} ${x+r*.2} ${y+r*.92} ${x-r*.12} ${y+r*.44} ${x-r*.74} ${y+r*.72} ${x-r*.4} ${y+r*.14} ${x-r} ${y-r*.18} ${x-r*.34} ${y-r*.27} Z`; + `M ${x-r*0.06} ${y-r} L ${x+r*0.22} ${y-r*0.34} ${x+r*0.91} ${y-r*0.58} ${x+r*0.46} ${y-r*0.05} ${x+r} ${y+r*0.39} ${x+r*0.31} ${y+r*0.28} ${x+r*0.2} ${y+r*0.92} ${x-r*0.12} ${y+r*0.44} ${x-r*0.74} ${y+r*0.72} ${x-r*0.4} ${y+r*0.14} ${x-r} ${y-r*0.18} ${x-r*0.34} ${y-r*0.27} Z`; export const ward = (x: number, y: number, r: number) => - `M ${x} ${y-r} Q ${x+r*.45} ${y-r*.63} ${x+r*.9} ${y-r*.68} L ${x+r*.82} ${y+r*.18} Q ${x+r*.7} ${y+r*.66} ${x} ${y+r} Q ${x-r*.75} ${y+r*.59} ${x-r*.81} ${y+r*.15} L ${x-r*.9} ${y-r*.68} Q ${x-r*.42} ${y-r*.65} ${x} ${y-r} Z`; + `M ${x} ${y-r} Q ${x+r*0.45} ${y-r*0.63} ${x+r*0.9} ${y-r*0.68} L ${x+r*0.82} ${y+r*0.18} Q ${x+r*0.7} ${y+r*0.66} ${x} ${y+r} Q ${x-r*0.75} ${y+r*0.59} ${x-r*0.81} ${y+r*0.15} L ${x-r*0.9} ${y-r*0.68} Q ${x-r*0.42} ${y-r*0.65} ${x} ${y-r} Z`; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 2469518..e949c8b 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -1,6 +1,6 @@ // Websocket client + reactive session state (Svelte 5 runes). -import type { Command, GameEvent, GameView } from "@wizwar/engine"; +import type { Command, GameEvent, GameView, Side } from "@wizwar/engine"; import { cardDef } from "@wizwar/engine"; const SERVER_URL = @@ -23,6 +23,9 @@ export function spellName(cardId: string): string { } } +/** The compass, for captions and buttons. */ +export const SIDE_NAMES: Record = { N: "North", E: "East", S: "South", W: "West" }; + export function humanize(e: GameEvent): string | null { switch (e.type) { case "gameStarted": { @@ -66,7 +69,7 @@ export function humanize(e: GameEvent): string | null { case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`; case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`; - case "pushChosen": return `${e.pushee} breaks ${e.direction === "N" ? "north" : e.direction === "S" ? "south" : e.direction === "E" ? "east" : "west"}.`; + case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`; case "treasureTorn": return e.toFloor ? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!` : `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`; @@ -246,9 +249,11 @@ const ACTING = new Set([ "climbedFromPit", "spellTrapped", ]); function actorOf(e: GameEvent, turnOwner: string | null): string | undefined { - const any = e as unknown as Record; - for (const k of ["caster", "attacker", "by", "owner"]) if (typeof any[k] === "string") return any[k] as string; - if (ACTING.has(e.type) && typeof any.player === "string") return any.player as string; + if ("caster" in e && typeof e.caster === "string") return e.caster; + if ("attacker" in e && typeof e.attacker === "string") return e.attacker; + if ("by" in e && typeof e.by === "string") return e.by; + if ("owner" in e && typeof e.owner === "string") return e.owner; + if (ACTING.has(e.type) && "player" in e && typeof e.player === "string") return e.player; return turnOwner ?? undefined; } @@ -620,12 +625,18 @@ class Net { setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000); break; } -} + } private send(message: unknown): void { if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); } + /** A toast the table shows for a moment, as it shows the server's refusals. */ + flash(message: string): void { + this.error = message; + setTimeout(() => { if (this.error === message) this.error = null; }, 5000); + } + create(name: string): void { this.you = name; this.spectating = false; diff --git a/packages/web/src/prefs.svelte.ts b/packages/web/src/prefs.svelte.ts index 756b77d..5842f38 100644 --- a/packages/web/src/prefs.svelte.ts +++ b/packages/web/src/prefs.svelte.ts @@ -59,8 +59,6 @@ function load(): Prefs { wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "", color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null, cautions: p.cautions !== false, - // A first game is against an apprentice; a browser that has finished - // one is promoted to adept unless the tier was picked by hand. botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice", instantReplay: p.instantReplay !== false, liveFp: p.liveFp === true,