Compare commits
32
Commits
65e88df523
...
main
@@ -22,7 +22,11 @@ quiet, say so in one line before the details.
|
||||
protocol error"` — anything nonzero deserves a look (Sentry has
|
||||
the stack traces)
|
||||
2. **Sentry** (MCP connector, org `locallygrownnet`, project `wizwar`):
|
||||
- `search_issues` for `is:unresolved` — new issues are the headline
|
||||
- `search_issues` for `is:unresolved` — new issues are the headline.
|
||||
An issue titled "<name> challenges Kestrel to a game — <link>" is
|
||||
not an error: it is the keeper's bell (a lobby called Eric to a
|
||||
seat; the room link is in the title). Report it as a caller waiting,
|
||||
with the room code; Eric resolves it himself once answered.
|
||||
- `get_monitor_details` for cron monitor slug `new-monitor` (named
|
||||
wizwar-backup) — last check-in must be ok and recent; the uptime
|
||||
monitor alerts on its own but note any incidents
|
||||
@@ -34,26 +38,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=<name>` — 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=<name>`, 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 +72,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-10
@@ -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=<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).
|
||||
# (Reddit and BGG send none); campaigns are the tags a link itself carries
|
||||
# — ?ref=<name> 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=<name> 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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`
|
||||
|
||||
+294
-84
@@ -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;
|
||||
@@ -212,6 +218,9 @@ export interface CastStack {
|
||||
reflectedBase?: { damage: number; duration: number };
|
||||
/** The wraith's touch also steals a random card if damage lands. */
|
||||
creatureTouch?: "wraith" | "claw";
|
||||
/** Where the spell left from when not the attacker's own square: an
|
||||
* ALTER EGO's. Knockbacks and routs push away from here. */
|
||||
origin?: Cell;
|
||||
/** A spell freed from slime: counteractions cannot touch its caster. */
|
||||
trapped?: boolean;
|
||||
/** STRENGTH's tear: "this would be an attack" — the grab rides the stack
|
||||
@@ -238,7 +247,7 @@ export interface CastParams {
|
||||
}
|
||||
|
||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||
export const CURRENT_RULES_REV = 20;
|
||||
export const CURRENT_RULES_REV = 24;
|
||||
|
||||
/** 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
|
||||
@@ -263,6 +272,10 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
|
||||
{ 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." },
|
||||
{ rev: 21, note: "Two cards keep their whole promise. LIFESAVER's holder is not eliminated for losing both treasures. FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them — on every side, where the card says one." },
|
||||
{ rev: 22, note: "TELEPORT ignores the maze's outer edge as it ignores any wall: a teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on. Older games crossed the edge only at the lettered openings." },
|
||||
{ rev: 23, note: "POWER DRAIN drains the number played: the caster gains it whether the blow is BLUNTed or ABSORBed (FAQ: the counter blunts the damage done, not the drain), and a wall drained for its points gives them up too. Older games gave the caster only what the opponent lost, and nothing from a wall." },
|
||||
{ rev: 24, note: "A REFLECTION against a permanent curse afflicts both wizards: WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties. Older games left the caster untouched." },
|
||||
];
|
||||
|
||||
export interface GameConfig {
|
||||
@@ -294,11 +307,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). */
|
||||
@@ -450,10 +459,24 @@ function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean {
|
||||
return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust";
|
||||
}
|
||||
|
||||
/** A FORCE FIELD raised against `id` on the wizard standing at `cell`,
|
||||
* if any (rev 21): they may not enter it, nor cast on or past it. */
|
||||
function fieldAgainst(state: GameState, id: PlayerId, cell: Cell): boolean {
|
||||
if ((state.config.deckRev ?? 1) < 21) return false;
|
||||
const idx = state.players.findIndex((q) => q.id === id);
|
||||
return state.players.some((h) => h.alive && h.id !== id && cellKey(h.position) === cellKey(cell) &&
|
||||
sustainedOn(state, h.id, "force-field").some((f) => f.data.against === idx));
|
||||
}
|
||||
|
||||
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 blockers = losBlockers(state);
|
||||
if (viewerId) {
|
||||
for (const h of state.players) {
|
||||
if (h.alive && h.id !== viewerId && fieldAgainst(state, viewerId, h.position)) blockers[cellKey(h.position)] = true;
|
||||
}
|
||||
}
|
||||
if (sightBetween(board, from, to, blockers)) return true;
|
||||
if (!stone) return false;
|
||||
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
|
||||
@@ -590,6 +613,9 @@ function castSightEdge(
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The attacks that burn: what the FAQ lets hurt a KILLER OOZE. */
|
||||
const FIRE_ATTACKS = new Set(["fireball"]);
|
||||
|
||||
function inThornbush(state: GameState, p: PlayerState): boolean {
|
||||
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
||||
}
|
||||
@@ -638,7 +664,10 @@ export type GameEvent =
|
||||
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
|
||||
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
|
||||
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" }
|
||||
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
|
||||
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean;
|
||||
/** The blow before any counter, and what each counter left of it, in
|
||||
* the order they were weighed — the receipt the table reads. */
|
||||
incoming?: number; incomingDuration?: number; trail?: CounterStep[] }
|
||||
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
|
||||
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
|
||||
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" }
|
||||
@@ -766,6 +795,8 @@ export type GameEvent =
|
||||
| { type: "slowDeathCountered"; player: PlayerId; cardId: string; remaining: number }
|
||||
| { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number }
|
||||
| { type: "safeSmashed"; attacker: PlayerId; cell: Cell }
|
||||
| { type: "squareContentDamaged"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze"; amount: number; total: number; needed: number }
|
||||
| { type: "squareContentDestroyed"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze" }
|
||||
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
||||
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
||||
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
||||
@@ -816,6 +847,7 @@ export type Command =
|
||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||
| { type: "creatureWarpStep"; creatureId: string }
|
||||
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
||||
| { type: "creatureAttackWall"; creatureId: string; cell: Cell; side: Side }
|
||||
| {
|
||||
type: "cast";
|
||||
instanceId: string;
|
||||
@@ -836,6 +868,10 @@ export type Command =
|
||||
powerAttackPoints?: number;
|
||||
target?: CastTarget;
|
||||
params?: CastParams;
|
||||
/** ALTER EGO: the double's creature id, when the spell leaves from
|
||||
* its square — "it may use any of the spells in your hand, and you
|
||||
* need not be in its L.O.S. to do so." */
|
||||
via?: string;
|
||||
}
|
||||
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell }
|
||||
| { type: "cancelAmbush"; ambushId: string }
|
||||
@@ -903,6 +939,8 @@ interface Magnitude {
|
||||
}
|
||||
|
||||
interface ResolutionContext {
|
||||
/** The square the blow came from: the attacker's, or their double's. */
|
||||
origin: Cell;
|
||||
state: GameState;
|
||||
events: GameEvent[];
|
||||
attacker: PlayerState;
|
||||
@@ -915,6 +953,17 @@ interface ResolutionContext {
|
||||
stack: CastStack;
|
||||
}
|
||||
|
||||
/** One counter's mark on the blow: the damage, the half sent back, and
|
||||
* the duration left after it was weighed (unchanged when nullified). */
|
||||
export interface CounterStep {
|
||||
cardId: string;
|
||||
player: PlayerId;
|
||||
nullified: boolean;
|
||||
damage: number;
|
||||
reflected: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface DamagePipeline {
|
||||
damage: number;
|
||||
duration: number;
|
||||
@@ -1069,7 +1118,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
const knock = ctx.stack.params?.knockback ?? 0;
|
||||
if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock);
|
||||
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock, ctx.origin);
|
||||
},
|
||||
},
|
||||
"sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 },
|
||||
@@ -1078,12 +1127,21 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
requiresLos: true,
|
||||
baseDamage: (n) => n ?? 1,
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.damageDealt <= 0 || !ctx.attacker.alive) return;
|
||||
ctx.attacker.life += ctx.damageDealt;
|
||||
if (!ctx.attacker.alive) return;
|
||||
// Rev 23: the drain is the number played, counters or no — "he is
|
||||
// blunting damage done, not acting upon the attack spell itself"
|
||||
// (FAQ). A returned drain carries its settled amount. Older games
|
||||
// drained only what the opponent lost.
|
||||
const modern = (ctx.state.config.deckRev ?? 1) >= 23 && !ctx.stack.reflectedBase;
|
||||
const gain = modern
|
||||
? (ctx.fullyStopped || ctx.reversed ? 0 : (ctx.stack.numberValue ?? 1) * ctx.stack.amplifyFactor)
|
||||
: ctx.damageDealt;
|
||||
if (gain <= 0) return;
|
||||
ctx.attacker.life += gain;
|
||||
ctx.events.push({
|
||||
type: "lifeGained",
|
||||
player: ctx.attacker.id,
|
||||
amount: ctx.damageDealt,
|
||||
amount: gain,
|
||||
source: "power drain",
|
||||
lifeAfter: ctx.attacker.life,
|
||||
});
|
||||
@@ -1114,7 +1172,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
const n = ctx.stack.numberValue ?? 1;
|
||||
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n);
|
||||
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n, ctx.origin);
|
||||
ctx.defender.lostTurns++;
|
||||
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
|
||||
},
|
||||
@@ -1415,7 +1473,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(to)]) return "destination is off the board";
|
||||
if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone";
|
||||
if (wallIgnoringDistance(view, caster.position, to) > 4) {
|
||||
if (wallIgnoringDistance(view, caster.position, to, (state.config.deckRev ?? 1) >= 22) > 4) {
|
||||
return "teleport reaches at most four spaces";
|
||||
}
|
||||
// A teleport is a willing move: FEAR's bubble refuses it.
|
||||
@@ -2377,11 +2435,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
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;
|
||||
},
|
||||
@@ -2389,8 +2443,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
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):
|
||||
@@ -2879,10 +2932,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 {
|
||||
@@ -3389,6 +3440,31 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
/** "This rock-hard beast can punch a player (or a wall, etc.)": the troll's
|
||||
* fist on a wall line beside it, a D4 of damage toward the wall's fall. */
|
||||
function doCreatureAttackWall(prev: GameState, creatureId: string, cell: Cell, side: Side): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
if (prev.turn.round === 1) return err("no combat during the first round of turns");
|
||||
const state = clone(prev);
|
||||
const active = activePlayer(state);
|
||||
const creature = creatureById(state, creatureId);
|
||||
if (!creature) return err("no such creature");
|
||||
if (creature.controllerId !== active.id) return err("that creature does not obey you");
|
||||
if (creature.kind !== "troll") return err("only the troll punches walls");
|
||||
if (creature.justCreated) return err("it cannot attack the turn it was created");
|
||||
if (creature.attackUsed) return err("that creature has already attacked this turn");
|
||||
if (!touchesEdge(creature.position, cell, side)) return err("the troll must stand beside the wall");
|
||||
const events: GameEvent[] = [];
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
state.rng = rngNext;
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: "wall", dieRoll: roll });
|
||||
const problem = damageWall(state, events, active, cell, side, roll, "troll");
|
||||
if (problem) return err(problem);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */
|
||||
function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void {
|
||||
const view = boardView(state);
|
||||
@@ -3700,9 +3776,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);
|
||||
@@ -3725,8 +3808,25 @@ export function walkingDistance(state: GameState, from: Cell, to: Cell, limit =
|
||||
return seen.get(cellKey(to)) ?? Infinity;
|
||||
}
|
||||
|
||||
/** BFS steps between cells ignoring walls (teleport distance). */
|
||||
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
||||
/** Where a line leaving the maze at `cur` through `side` comes back in:
|
||||
* the first square of the maze scanning from the opposite edge along the
|
||||
* same row or column — the maze's edges as one continuous field. */
|
||||
export function edgeReentry(board: AssembledBoard, cur: Cell, side: Side): Cell | null {
|
||||
const cells = Object.keys(board.cells).map((k) => k.split(",").map(Number) as [number, number]);
|
||||
const line = side === "N" || side === "S"
|
||||
? cells.filter(([x]) => x === cur.x).map(([, y]) => y)
|
||||
: cells.filter(([, y]) => y === cur.y).map(([x]) => x);
|
||||
if (line.length === 0) return null;
|
||||
const far = side === "N" || side === "W" ? Math.max(...line) : Math.min(...line);
|
||||
const cell = side === "N" || side === "S" ? { x: cur.x, y: far } : { x: far, y: cur.y };
|
||||
return cellKey(cell) === cellKey(cur) ? null : cell;
|
||||
}
|
||||
|
||||
/** BFS steps between cells ignoring walls (teleport distance). With
|
||||
* `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any
|
||||
* wall: a line leaving the maze re-enters at the opposite edge, one space
|
||||
* on. Without it, only the lettered openings carry a teleporter across. */
|
||||
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number {
|
||||
if (cellKey(from) === cellKey(to)) return 0;
|
||||
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
||||
const queue: Cell[] = [from];
|
||||
@@ -3742,8 +3842,12 @@ export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell
|
||||
const w = board.warps.find(
|
||||
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
|
||||
);
|
||||
if (!w) continue;
|
||||
n = w.to.cell;
|
||||
if (w) n = w.to.cell;
|
||||
else if (wrapEdges) {
|
||||
const back = edgeReentry(board, cur, side);
|
||||
if (!back) continue;
|
||||
n = back;
|
||||
} else continue;
|
||||
}
|
||||
if (seen.has(cellKey(n))) continue;
|
||||
seen.set(cellKey(n), d + 1);
|
||||
@@ -4064,6 +4168,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
|
||||
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
||||
case "creatureAttackWall": return doCreatureAttackWall(state, command.creatureId, command.cell, command.side);
|
||||
case "cast": return doCast(state, command);
|
||||
case "setAmbush": return doSetAmbush(state, command);
|
||||
case "cancelAmbush": return doCancelAmbush(state, command.ambushId);
|
||||
@@ -4230,6 +4335,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]!;
|
||||
}
|
||||
@@ -4337,6 +4443,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
// Square contents at the destination.
|
||||
let content = state.squareContents[cellKey(p.position)];
|
||||
if (content?.kind === "stone") return err("that square is solid stone");
|
||||
if (fieldAgainst(state, p.id, p.position)) return err("a force field bars the way");
|
||||
// BIG MAN: nobody enters his square — except a SHRUNK wizard, small
|
||||
// enough to slip between the giant's boots (table ruling).
|
||||
if (sustainedOn(state, p.id, "shrink").length === 0) {
|
||||
@@ -4372,10 +4479,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);
|
||||
@@ -4436,19 +4542,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) {
|
||||
@@ -4464,7 +4563,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";
|
||||
}
|
||||
@@ -5243,6 +5342,19 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
|
||||
if (!inHand) return err("card not in hand");
|
||||
const def = cardDef(inHand.cardId);
|
||||
// ALTER EGO: "It may use any of the spells in your hand, and you need
|
||||
// not be in its L.O.S. to do so." The double's square is where the
|
||||
// spell leaves from — sight, reach, and the blow's direction are its —
|
||||
// while the hand, the attack, and any answer to it stay the caster's.
|
||||
// Spells that move the caster's own body do not travel through it.
|
||||
let origin: Cell = caster.position;
|
||||
if (cmd.via) {
|
||||
const ego = state.creatures.find((c) => c.id === cmd.via);
|
||||
if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double");
|
||||
if (!cmd.target) return err("your double casts only at a target");
|
||||
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`);
|
||||
origin = ego.position;
|
||||
}
|
||||
const effect = CARD_EFFECTS[inHand.cardId];
|
||||
if (!effect) return err(`${def.name} is not implemented yet`);
|
||||
if (effect.kind === "counter") {
|
||||
@@ -5328,7 +5440,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: null, targetCell: null,
|
||||
from: origin, target: null, targetCell: null,
|
||||
}];
|
||||
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
|
||||
return { ok: true, state, events };
|
||||
@@ -5342,12 +5454,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (cmd.target?.kind === "creature") {
|
||||
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||
if (!creature) return err("no such creature");
|
||||
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
|
||||
if (effect.sameSquare && cellKey(creature.position) !== cellKey(origin)) {
|
||||
return err("you must be in the same square");
|
||||
}
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, creature.position)
|
||||
: casterLos(state, caster, caster.position, creature.position))) {
|
||||
? bentLos(state, caster, origin, creature.position)
|
||||
: casterLos(state, caster, origin, creature.position))) {
|
||||
return err("no line of sight to the creature");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -5362,7 +5474,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [...wandEvents, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: null, targetCell: creature.position,
|
||||
from: origin, target: null, targetCell: creature.position,
|
||||
}];
|
||||
// "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP).
|
||||
if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") {
|
||||
@@ -5383,7 +5495,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (cmd.target?.kind === "cell" &&
|
||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
|
||||
const cell = cmd.target.cell;
|
||||
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) {
|
||||
if (effect.sameSquare && cellKey(cell) !== cellKey(origin)) {
|
||||
return err("you must be in the same square");
|
||||
}
|
||||
// castSight carries the whole sight law: straight lines, the
|
||||
@@ -5405,7 +5517,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [...wandEvents, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: null, targetCell: { ...cell },
|
||||
from: origin, target: null, targetCell: { ...cell },
|
||||
}];
|
||||
const box = state.squareContents[cellKey(cell)]!;
|
||||
box.damage += dmg;
|
||||
@@ -5417,6 +5529,53 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
// A THORNBUSH or ROSEBUSH: "Five points of damage will destroy it." A
|
||||
// KILLER OOZE: "Only fire ... will hurt the ooze" (FAQ), and five points
|
||||
// burn it away. Damage accumulates across attackers and turns. A
|
||||
// same-square attack (WIZARDBLADE) is swung from a square beside it:
|
||||
// "If target fills an entire square, you must be in an adjacent square."
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const cell = cmd.target.cell;
|
||||
const content = state.squareContents[cellKey(cell)];
|
||||
if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) {
|
||||
const what = content.kind === "ooze" ? "ooze" : content.kind;
|
||||
if (effect.sameSquare) {
|
||||
const dx = cell.x - origin.x, dy = cell.y - origin.y;
|
||||
const side: Side | null = dx === 1 && dy === 0 ? "E" : dx === -1 && dy === 0 ? "W" : dy === 1 && dx === 0 ? "S" : dy === -1 && dx === 0 ? "N" : null;
|
||||
if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") {
|
||||
return err(`you must stand beside the ${what} to use that`);
|
||||
}
|
||||
} else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
|
||||
return err(`no line of sight to the ${what}`);
|
||||
}
|
||||
if (content.kind === "ooze" && !FIRE_ATTACKS.has(inHand.cardId)) {
|
||||
return err("only fire hurts the ooze");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
{
|
||||
const werr = spendWandCharge(state, caster, wandEvents);
|
||||
if (werr) return err(werr);
|
||||
}
|
||||
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
|
||||
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
state.turn.attackUsed = true;
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [...wandEvents, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: origin, target: null, targetCell: { ...cell },
|
||||
}];
|
||||
content.damage += dmg;
|
||||
events.push({ type: "squareContentDamaged", attacker: caster.id, cell: { ...cell }, kind: content.kind, amount: dmg, total: content.damage, needed: 5 });
|
||||
if (content.damage >= 5) {
|
||||
delete state.squareContents[cellKey(cell)];
|
||||
events.push({ type: "squareContentDestroyed", attacker: caster.id, cell: { ...cell }, kind: content.kind });
|
||||
}
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
}
|
||||
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
|
||||
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
|
||||
// washes the slime away instead.
|
||||
@@ -5424,8 +5583,8 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
|
||||
const cell = cmd.target.cell;
|
||||
if (effect.requiresLos && !(mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, cell)
|
||||
: casterLos(state, caster, caster.position, cell))) {
|
||||
? bentLos(state, caster, origin, cell)
|
||||
: casterLos(state, caster, origin, cell))) {
|
||||
return err("no line of sight to the slime");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
@@ -5469,7 +5628,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (current !== "wall" && current !== "door") {
|
||||
return err("there is no wall or door on that edge to attack");
|
||||
}
|
||||
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
|
||||
if (effect.sameSquare && !touchesEdge(origin, cell, side)) {
|
||||
return err("you must stand beside the wall");
|
||||
}
|
||||
if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
|
||||
@@ -5495,10 +5654,15 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events2: GameEvent[] = [...wandEvents, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: null, targetCell: cell,
|
||||
from: origin, target: null, targetCell: cell,
|
||||
}];
|
||||
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
|
||||
if (problem) return err(problem);
|
||||
// Rev 23: a wall drained for its points gives them up like anyone.
|
||||
if (inHand.cardId === "power-drain" && (state.config.deckRev ?? 1) >= 23 && caster.alive) {
|
||||
caster.life += dmg;
|
||||
events2.push({ type: "lifeGained", player: caster.id, amount: dmg, source: "power drain", lifeAfter: caster.life });
|
||||
}
|
||||
// Thrown weapons clatter to the floor at the foot of the wall.
|
||||
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
|
||||
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
|
||||
@@ -5524,7 +5688,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: null, targetCell: null,
|
||||
from: origin, target: null, targetCell: null,
|
||||
}];
|
||||
const queue = turnOrderFrom(state, caster.id).filter((id) =>
|
||||
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
|
||||
@@ -5536,16 +5700,17 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return err("no such living player");
|
||||
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) {
|
||||
if (effect.sameSquare && cellKey(target.position) !== cellKey(origin)) {
|
||||
return err("you must be in the same square");
|
||||
}
|
||||
const statusBlock = attackBlockedByStatus(state, caster, target);
|
||||
if (statusBlock) return err(statusBlock);
|
||||
if (fieldAgainst(state, caster.id, target.position)) return err("their force field turns your spell aside");
|
||||
const preEvents: GameEvent[] = [];
|
||||
if (effect.requiresLos) {
|
||||
const sighted = mods.aroundCorner
|
||||
? bentLos(state, caster, caster.position, target.position)
|
||||
: casterLos(state, caster, caster.position, target.position);
|
||||
? bentLos(state, caster, origin, target.position)
|
||||
: casterLos(state, caster, origin, target.position);
|
||||
if (!sighted) return err("no line of sight to the target");
|
||||
}
|
||||
// Attacking someone breaks any BUDDY pact you swore to them.
|
||||
@@ -5579,9 +5744,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
// go intended distance" — if the die disagrees with the true direction,
|
||||
// the spell hits whoever lies that way, or dissipates.
|
||||
if (isBlinded(state, caster) &&
|
||||
cellKey(target.position) !== cellKey(caster.position)) {
|
||||
const dx = target.position.x - caster.position.x;
|
||||
const dy = target.position.y - caster.position.y;
|
||||
cellKey(target.position) !== cellKey(origin)) {
|
||||
const dx = target.position.x - origin.x;
|
||||
const dy = target.position.y - origin.y;
|
||||
const intended: Side =
|
||||
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
@@ -5590,11 +5755,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (rolled !== intended) {
|
||||
const along = state.players.find((p) => {
|
||||
if (!p.alive || p.id === caster.id) return false;
|
||||
const px = p.position.x - caster.position.x;
|
||||
const py = p.position.y - caster.position.y;
|
||||
const px = p.position.x - origin.x;
|
||||
const py = p.position.y - origin.y;
|
||||
const dirOf: Side | null =
|
||||
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
|
||||
return dirOf === rolled && casterLos(state, caster, caster.position, p.position);
|
||||
return dirOf === rolled && casterLos(state, caster, origin, p.position);
|
||||
});
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
state.turn.attackUsed = true;
|
||||
@@ -5616,11 +5781,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: along.id,
|
||||
...(cmd.via ? { origin } : {}),
|
||||
};
|
||||
missEvents.push({
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position, target: along.id, targetCell: along.position,
|
||||
from: origin, target: along.id, targetCell: along.position,
|
||||
});
|
||||
return { ok: true, state, events: missEvents };
|
||||
}
|
||||
@@ -5642,6 +5808,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
counters: [],
|
||||
waitingOn: target.id,
|
||||
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
|
||||
...(cmd.via ? { origin } : {}),
|
||||
};
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [...wandEvents, ...preEvents];
|
||||
@@ -5654,7 +5821,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
numberCards: mods.numbers,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
amplifies: mods.amplifies.length,
|
||||
from: caster.position,
|
||||
from: origin,
|
||||
target: target.id,
|
||||
targetCell: target.position,
|
||||
});
|
||||
@@ -5674,7 +5841,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
cardId: inHand.cardId,
|
||||
numberCards: mods.numbers,
|
||||
numberValue: mods.magnitude.numberValue,
|
||||
from: caster.position,
|
||||
from: origin,
|
||||
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
|
||||
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
|
||||
});
|
||||
@@ -5934,7 +6101,7 @@ function doCounteract(
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(to)]) return err("destination is off the board");
|
||||
if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone");
|
||||
if (wallIgnoringDistance(view, player.position, to) > 4) {
|
||||
if (wallIgnoringDistance(view, player.position, to, (state.config.deckRev ?? 1) >= 22) > 4) {
|
||||
return err("teleport reaches at most four spaces");
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
@@ -6029,12 +6196,17 @@ function doCounteract(
|
||||
// any, will be swapped" — the reflector's choice rides the counter.
|
||||
...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}),
|
||||
});
|
||||
// Rev 21: FORCE FIELD stands after it stops the spell — "preventing an
|
||||
// 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 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) {
|
||||
@@ -6211,6 +6383,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
// Whether this attack even tries to wound: utility attacks (DROP OBJECT,
|
||||
// TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped.
|
||||
const dealsDamage = base > 0;
|
||||
const trail: CounterStep[] = [];
|
||||
const receipt = { incoming: base, incomingDuration: baseDuration, trail };
|
||||
const pipe: DamagePipeline = {
|
||||
damage: base,
|
||||
duration: baseDuration,
|
||||
@@ -6229,18 +6403,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
}
|
||||
for (const counter of stack.counters) {
|
||||
trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration });
|
||||
const mark = trail[trail.length - 1]!;
|
||||
const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; };
|
||||
if (counter.nullified) continue;
|
||||
if (isNumberCard(counter.card.cardId)) {
|
||||
// SHIELDSTONE number counter: reduce point AND duration effects.
|
||||
const v = numberValue(counter.card.cardId);
|
||||
pipe.damage = Math.max(0, pipe.damage - v);
|
||||
pipe.duration = Math.max(0, pipe.duration - v);
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
|
||||
// Fire meets water, whichever was thrown first: entirely stopped.
|
||||
pipe.damage = 0;
|
||||
pipe.fullyStopped = true;
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
|
||||
@@ -6250,6 +6429,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
pipe.damage = 0;
|
||||
pipe.duration = 0;
|
||||
pipe.fullyStopped = true;
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (stack.creatureId &&
|
||||
@@ -6262,10 +6442,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
} else {
|
||||
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
|
||||
}
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
const ce = CARD_EFFECTS[counter.card.cardId];
|
||||
if (ce && ce.kind === "counter") ce.apply(pipe);
|
||||
weigh();
|
||||
}
|
||||
|
||||
// A surviving teleport counter whisks the defender away before anything lands.
|
||||
@@ -6291,12 +6473,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
effect.onResolved({
|
||||
state, events,
|
||||
attacker: defender, defender: attacker,
|
||||
origin: defender.position,
|
||||
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
|
||||
stack: { ...stack, params: { ...stack.params, cardId: chosen } },
|
||||
});
|
||||
}
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6328,7 +6511,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
reflectedBase: { damage: pipe.damage, duration: pipe.duration },
|
||||
};
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6418,7 +6601,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6428,12 +6611,30 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
redirected: pipe.redirected,
|
||||
});
|
||||
|
||||
// Rev 24: "If this spell is REFLECTed, both players take 1/2 point of
|
||||
// damage per space moved" (WALKING DEAD); "In case of REFLECTION, both
|
||||
// players are affected" (IDIOT). A permanent curse has no duration for
|
||||
// REFLECTION's split to halve, so the curse is laid on the caster as
|
||||
// well, as if the defender had cast it back.
|
||||
const curseShared = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 24 &&
|
||||
!pipe.redirected && !pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive &&
|
||||
stack.counters.some((c) => !c.nullified && c.card.cardId === "reflection");
|
||||
if (effect?.onResolved && curseShared) {
|
||||
effect.onResolved({
|
||||
state, events,
|
||||
attacker: defender, defender: attacker,
|
||||
origin: defender.position,
|
||||
damageDealt: 0, fullyStopped: false, reversed: pipe.reversed, duration: pipe.duration,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
if (effect?.onResolved && !pipe.redirected) {
|
||||
effect.onResolved({
|
||||
state,
|
||||
events,
|
||||
attacker,
|
||||
defender,
|
||||
origin: stack.origin ?? attacker.position,
|
||||
damageDealt,
|
||||
fullyStopped: pipe.fullyStopped,
|
||||
reversed: pipe.reversed,
|
||||
@@ -6472,11 +6673,12 @@ function goAwayRout(
|
||||
attacker: PlayerState,
|
||||
defender: PlayerState,
|
||||
squares: number,
|
||||
origin: Cell = attacker.position,
|
||||
): void {
|
||||
if (isLockedInPlace(state, defender.id)) return;
|
||||
const from = defender.position;
|
||||
const dist = (c: Cell) =>
|
||||
Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y);
|
||||
Math.abs(origin.x - c.x) + Math.abs(origin.y - c.y);
|
||||
let heading: Side | null = null;
|
||||
let moved = 0;
|
||||
for (let i = 0; i < squares; i++) {
|
||||
@@ -6527,9 +6729,10 @@ function knockBack(
|
||||
attacker: PlayerState,
|
||||
defender: PlayerState,
|
||||
squares: number,
|
||||
origin: Cell = attacker.position,
|
||||
): void {
|
||||
const dx = defender.position.x - attacker.position.x;
|
||||
const dy = defender.position.y - attacker.position.y;
|
||||
const dx = defender.position.x - origin.x;
|
||||
const dy = defender.position.y - origin.y;
|
||||
let dir: Side | null = null;
|
||||
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
|
||||
else if (dy !== 0) dir = dy > 0 ? "S" : "N";
|
||||
@@ -6790,7 +6993,11 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
|
||||
const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null;
|
||||
return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
|
||||
});
|
||||
if (lost) {
|
||||
// Rev 21: LIFESAVER — "immune to the effects of losing both of your
|
||||
// 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;
|
||||
p.finalHand = [...p.hand];
|
||||
state.discard.push(...p.hand.splice(0));
|
||||
@@ -7120,6 +7327,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
||||
}
|
||||
state.openSafes = [];
|
||||
|
||||
// A FORCE FIELD raised against this wizard lasts only until their turn ends.
|
||||
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) {
|
||||
|
||||
+122
-62
@@ -2,13 +2,16 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
edgeReentry,
|
||||
LOS_BLOCKING_CONTENT,
|
||||
type AmbushState,
|
||||
type CastStack,
|
||||
type GameEvent,
|
||||
type PushPending,
|
||||
type CreatureState,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
@@ -93,7 +96,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[];
|
||||
@@ -220,20 +223,23 @@ export function fearCells(view: GameView): Set<string> {
|
||||
* 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, from?: Cell): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return out;
|
||||
// Sight is taken from the viewer's square unless a cast leaves from
|
||||
// elsewhere — an ALTER EGO's — in which case it is the double's.
|
||||
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, me.position, me.position, true)) {
|
||||
out.add(`${me.position.x},${me.position.y}`);
|
||||
if (inDust(view, eye)) {
|
||||
out.add(`${eye.x},${eye.y}`);
|
||||
return out;
|
||||
}
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
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 (dustAtEnd(view, eye, { x, y })) continue;
|
||||
if (sightBetween(board, eye, { x, y }, blockers)) out.add(key);
|
||||
}
|
||||
// VISIONSTONE lets its bearer see through exactly one wall or door —
|
||||
// any one — so a square is also sighted if removing a single edge
|
||||
@@ -248,7 +254,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
const opened = { ...board, edges };
|
||||
for (let i = unseen.length - 1; i >= 0; i--) {
|
||||
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
|
||||
if (sightBetween(opened, me.position, { x, y }, blockers)) {
|
||||
if (sightBetween(opened, eye, { x, y }, blockers)) {
|
||||
out.add(unseen[i]!);
|
||||
unseen.splice(i, 1);
|
||||
}
|
||||
@@ -263,8 +269,8 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
* plus every square visible from one of those — the caster looks to a middle
|
||||
* cell and the spell turns there, mirroring the engine's bentLos.
|
||||
*/
|
||||
export function bentSightedCellsFor(view: GameView): Set<string> {
|
||||
const out = sightedCellsFor(view);
|
||||
export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
|
||||
const out = sightedCellsFor(view, from);
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return out;
|
||||
const mids = [...out].map((k) => {
|
||||
@@ -324,21 +330,21 @@ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Recor
|
||||
* (believed illusion walls block; held doors admit). Null when no sight
|
||||
* exists — the renderer's material for drawing the line an attack traveled.
|
||||
*/
|
||||
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
|
||||
if (dustAtEnd(view, from, to)) return null;
|
||||
export function traceSightFor(view: GameView, from: Cell, to: Cell, ignoreDust = false): SightTrace | null {
|
||||
if (!ignoreDust && dustAtEnd(view, from, to)) return null;
|
||||
const { board, blockers } = sightBasis(view);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,54 +374,96 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||
* defender sharing a square, or no sight under this viewer's knowledge
|
||||
* (a believed illusion wall can honestly hide the line).
|
||||
*/
|
||||
export function stackSightTrace(
|
||||
view: GameView,
|
||||
): {
|
||||
/** A sight line as the board draws it: the legs, the corner bend, and the
|
||||
* one wall a VISIONSTONE dissolved. */
|
||||
export interface SightLine {
|
||||
from: Cell;
|
||||
to: Cell;
|
||||
trace: SightTrace;
|
||||
bend?: { mid: Cell; trace: SightTrace };
|
||||
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
|
||||
pierced?: string;
|
||||
} | null {
|
||||
}
|
||||
|
||||
/** The sight line from one square to another under the sight law the
|
||||
* engine applies to a cast: a plain line (warps included), else the
|
||||
* VISIONSTONE's look through one wall, else AROUND THE CORNER's two legs.
|
||||
* `afterTheFact` traces a cast already resolved, whose own dust cloud
|
||||
* would otherwise blind the line to it. */
|
||||
export function sightTraceBetween(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
opts: { visionstone?: boolean; bentCorner?: boolean; afterTheFact?: boolean } = {},
|
||||
): SightLine | null {
|
||||
if (from.x === to.x && from.y === to.y) return null;
|
||||
const trace = traceSightFor(view, from, to, opts.afterTheFact);
|
||||
if (trace) return { from, to, trace };
|
||||
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
|
||||
// plain line exists, find the single edge whose removal opens one and draw
|
||||
// the ray straight through it, marking the pierced wall for the table.
|
||||
if (opts.visionstone) {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const [key, edge] of Object.entries(board.edges)) {
|
||||
if (edge === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
const t = traceSight({ ...board, edges }, from, to, blockers);
|
||||
if (t) return { from, to, trace: t, pierced: key };
|
||||
}
|
||||
}
|
||||
// AROUND THE CORNER: no straight line exists — find a middle square both
|
||||
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||
// needle instead of doubting it.
|
||||
if (opts.bentCorner) {
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if ((mid.x === from.x && mid.y === from.y) || (mid.x === to.x && mid.y === to.y)) continue;
|
||||
const leg1 = traceSightFor(view, from, mid, opts.afterTheFact);
|
||||
if (!leg1) continue;
|
||||
const leg2 = traceSightFor(view, mid, to, opts.afterTheFact);
|
||||
if (leg2) return { from, to, trace: leg1, bend: { mid, trace: leg2 } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The sight line of the attack on the stack, for the board to draw. */
|
||||
export function stackSightTrace(view: GameView): SightLine | null {
|
||||
const stack = view.stack;
|
||||
if (!stack || stack.creatureId) return null;
|
||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||
const a = view.players.find((p) => p.id === stack.attackerId);
|
||||
const d = view.players.find((p) => p.id === stack.defenderId);
|
||||
if (!a || !d) return null;
|
||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||
const trace = traceSightFor(view, a.position, d.position);
|
||||
if (trace) return { from: a.position, to: d.position, trace };
|
||||
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
|
||||
// plain line exists, find the single edge whose removal opens one and draw
|
||||
// the ray straight through it, marking the pierced wall for the table.
|
||||
if (a.displayed.some((c) => c.cardId === "visionstone")) {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const [key, edge] of Object.entries(board.edges)) {
|
||||
if (edge === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
const t = traceSight({ ...board, edges }, a.position, d.position, blockers);
|
||||
if (t) return { from: a.position, to: d.position, trace: t, pierced: key };
|
||||
}
|
||||
}
|
||||
// AROUND THE CORNER: no straight line exists — find a middle square both
|
||||
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||
// needle instead of doubting it.
|
||||
if (stack.bentCorner) {
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if ((mid.x === a.position.x && mid.y === a.position.y) ||
|
||||
(mid.x === d.position.x && mid.y === d.position.y)) continue;
|
||||
const leg1 = traceSightFor(view, a.position, mid);
|
||||
if (!leg1) continue;
|
||||
const leg2 = traceSightFor(view, mid, d.position);
|
||||
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return sightTraceBetween(view, a.position, d.position, {
|
||||
visionstone: a.displayed.some((c) => c.cardId === "visionstone"),
|
||||
bentCorner: stack.bentCorner === true,
|
||||
});
|
||||
}
|
||||
|
||||
/** The sight line a resolved cast was accepted on — a creation, a curse,
|
||||
* anything aimed by line of sight that never waited on the stack — so the
|
||||
* table can see how the aim was legal. `events` is the cast's own batch,
|
||||
* which names an AROUND THE CORNER cast. */
|
||||
export function castSightTrace(
|
||||
view: GameView,
|
||||
cast: Extract<GameEvent, { type: "spellCast" }>,
|
||||
events: readonly GameEvent[],
|
||||
afterTheFact = false,
|
||||
): SightLine | null {
|
||||
let def;
|
||||
try { def = cardDef(cast.cardId); } catch { return null; }
|
||||
if (def.los !== true) return null;
|
||||
const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
|
||||
if (!to) return null;
|
||||
const caster = view.players.find((p) => p.id === cast.caster);
|
||||
return sightTraceBetween(view, cast.from, to, {
|
||||
visionstone: caster?.displayed.some((c) => c.cardId === "visionstone") ?? false,
|
||||
bentCorner: events.some((e) => e.type === "castAroundCorner" && e.caster === cast.caster),
|
||||
afterTheFact,
|
||||
});
|
||||
}
|
||||
|
||||
const CREATION_CARD_IDS = new Set([
|
||||
@@ -431,12 +479,12 @@ const SUMMON_CARD_IDS = new Set([
|
||||
* aid — mirroring the engine's own validation from the viewer's knowledge.
|
||||
* Null = this card's eligibility is not modeled; light everything.
|
||||
*/
|
||||
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null {
|
||||
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false, from?: Cell): Set<string> | null {
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return null;
|
||||
const cells = Object.keys(view.board.cells);
|
||||
const key = (x: number, y: number) => `${x},${y}`;
|
||||
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view);
|
||||
const sighted = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from);
|
||||
|
||||
if (CREATION_CARD_IDS.has(cardId)) {
|
||||
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
||||
@@ -469,7 +517,9 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
||||
|
||||
if (cardId === "teleport") {
|
||||
// Up to four spaces, walls and objects ignored; not into solid stone.
|
||||
// BFS over existing cells, matching the engine's wallIgnoringDistance.
|
||||
// BFS over existing cells, matching the engine's wallIgnoringDistance —
|
||||
// the maze wraps for teleporters as it does for walkers, a warp mouth
|
||||
// being one step like any doorway.
|
||||
const out = new Set<string>();
|
||||
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
||||
const queue = [me.position];
|
||||
@@ -477,10 +527,20 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
||||
const cur = queue.shift()!;
|
||||
const d = dist.get(key(cur.x, cur.y))!;
|
||||
if (d >= 4) continue;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||
const n = { x: cur.x + dx, y: cur.y + dy };
|
||||
for (const side of SIDES) {
|
||||
let n = neighbor(cur, side);
|
||||
if (!view.board.cells[key(n.x, n.y)]) {
|
||||
const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side);
|
||||
if (w) n = w.to.cell;
|
||||
else if (view.deckRev >= 22) {
|
||||
// Rev 22: the outer edge is no more than a wall to a teleporter.
|
||||
const back = edgeReentry(view.board, cur, side);
|
||||
if (!back) continue;
|
||||
n = back;
|
||||
} else continue;
|
||||
}
|
||||
const nk = key(n.x, n.y);
|
||||
if (!view.board.cells[nk] || dist.has(nk)) continue;
|
||||
if (dist.has(nk)) continue;
|
||||
dist.set(nk, d + 1);
|
||||
queue.push(n);
|
||||
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1394,3 +1394,28 @@ describe("disease makes the caster the carrier (rev 7)", () => {
|
||||
throw new Error("no adjacent step for the return walk");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a REFLECTION against a permanent curse afflicts both (rev 24)", () => {
|
||||
const cursed = (s: GameState, id: string) => s.sustained.some((e) => e.cardId === "walking-dead" && e.targetId === id);
|
||||
const play = (deckRev: number) => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const wd = giveCard(state, attacker, "walking-dead");
|
||||
state.players.find((p) => p.id === defender)!.hand[0] = { instanceId: "reflection#T", cardId: "reflection" };
|
||||
state = must(state, attacker, { type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "reflection#T" });
|
||||
state = drain(state);
|
||||
return { state, attacker, defender };
|
||||
};
|
||||
it("lays WALKING DEAD on the caster too", () => {
|
||||
const { state, attacker, defender } = play(24);
|
||||
expect(cursed(state, defender)).toBe(true);
|
||||
expect(cursed(state, attacker)).toBe(true);
|
||||
});
|
||||
it("older games left the caster untouched", () => {
|
||||
const { state, attacker, defender } = play(23);
|
||||
expect(cursed(state, defender)).toBe(true);
|
||||
expect(cursed(state, attacker)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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, gameLos,
|
||||
} from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
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, faceOff } from "./helpers";
|
||||
|
||||
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
||||
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||
@@ -395,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];
|
||||
@@ -406,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`);
|
||||
@@ -452,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 });
|
||||
@@ -961,3 +963,62 @@ describe("the imp's fire is a spell (rev 8)", () => {
|
||||
expect(after.alive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the alter ego casts from its own square", () => {
|
||||
it("a fireball leaves from the double, by the double's sight, and answers to the caster", () => {
|
||||
let { state } = newGame(42);
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const ego = giveCard(state, attacker, "alter-ego");
|
||||
state = must(state, attacker, { type: "cast", instanceId: ego.instanceId });
|
||||
const double = state.creatures.find((c) => c.kind === "alter-ego" && c.controllerId === attacker)!;
|
||||
expect(cellKey(double.position)).toBe(cellKey(a.position));
|
||||
// The caster steps out of the defender's sight; the double keeps it.
|
||||
const view = boardView(state);
|
||||
const hidden = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
|
||||
.find((c) => !gameLos(state, c, d.position) && !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
|
||||
expect(hidden).toBeDefined();
|
||||
const me = state.players.find((p) => p.id === attacker)!;
|
||||
me.position = { ...hidden! };
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const blind = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
expect(blind.ok).toBe(false);
|
||||
const wrong = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: "no-such-double" });
|
||||
expect(wrong.ok).toBe(false);
|
||||
const viaEgo = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: double.id });
|
||||
expect(viaEgo.ok).toBe(true);
|
||||
if (!viaEgo.ok) return;
|
||||
expect(viaEgo.state.stack?.attackerId).toBe(attacker);
|
||||
expect(cellKey(viaEgo.state.stack!.origin!)).toBe(cellKey(double.position));
|
||||
const cast = viaEgo.events.find((e) => e.type === "spellCast");
|
||||
expect(cast && cast.type === "spellCast" ? cellKey(cast.from) : null).toBe(cellKey(double.position));
|
||||
const done = must(viaEgo.state, defender, { type: "pass" });
|
||||
expect(done.players.find((p) => p.id === defender)!.life).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the troll's fist on a wall", () => {
|
||||
it("punches a wall line beside it for a D4, once a turn", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state).id;
|
||||
const r = summon(state, me, "troll");
|
||||
state = r.state;
|
||||
const other = state.players.find((p) => p.id !== me)!.id;
|
||||
state = must(state, me, { type: "endTurn", draw: 0 });
|
||||
state = must(state, other, { type: "endTurn", draw: 0 });
|
||||
const troll = state.creatures[0]!;
|
||||
const board = boardView(state);
|
||||
const side = SIDES.find((s) => board.edges[edgeKey(troll.position, s)] === "wall")!;
|
||||
const res = applyCommand(state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
const hit = res.events.find((e) => e.type === "wallDamaged");
|
||||
expect(hit && hit.type === "wallDamaged" ? hit.amount : 0).toBeGreaterThanOrEqual(1);
|
||||
expect(res.state.creatures[0]!.attackUsed).toBe(true);
|
||||
const again = applyCommand(res.state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
|
||||
expect(again.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, neighbor, opposite, SIDES, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt, drain } from "./helpers";
|
||||
|
||||
describe("duration spells", () => {
|
||||
it("slow reduces movement to 1, blocks number cards, and halves attacks", () => {
|
||||
@@ -567,3 +567,44 @@ describe("the displayed MASTER KEY", () => {
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POWER DRAIN drains the number played (rev 23)", () => {
|
||||
it("gains the number even when the blow is blunted", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const pd = giveCard(state, attacker, "power-drain");
|
||||
giveCard(state, attacker, "number-4", "N", 1);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
||||
state = must(state, attacker, { type: "cast", instanceId: pd.instanceId, target: { kind: "player", playerId: defender }, numberInstanceIds: ["number-4#N"] });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
|
||||
state = drain(state);
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(19);
|
||||
});
|
||||
|
||||
it("drains a wall for its points", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
|
||||
const pd = giveCard(state, me.id, "power-drain");
|
||||
giveCard(state, me.id, "number-3", "N", 1);
|
||||
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
|
||||
expect(state.wallDamage[edgeKey(me.position, side)]).toBe(3);
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(18);
|
||||
});
|
||||
|
||||
it("older games gave only what the opponent lost, and nothing from a wall", () => {
|
||||
let state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 22 }).state);
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
|
||||
const pd = giveCard(state, me.id, "power-drain");
|
||||
giveCard(state, me.id, "number-3", "N", 1);
|
||||
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board";
|
||||
import { cellKey, edgeKey, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
|
||||
@@ -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);
|
||||
@@ -843,3 +843,55 @@ describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("cards that keep their whole promise (rev 21)", () => {
|
||||
it("LIFESAVER's holder survives losing both treasures", () => {
|
||||
for (const [deckRev, alive] of [[21, true], [20, false]] as const) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
const me = activePlayer(state);
|
||||
const others = state.players.filter((p) => p.id !== me.id);
|
||||
const card = giveCard(state, me.id, "lifesaver");
|
||||
state = must(state, me.id, { type: "cast", instanceId: card.instanceId });
|
||||
// Both of the holder's treasures rest on two different enemy homes: nobody wins, the holder is done for.
|
||||
const mine = state.treasures.filter((t) => t.owner === me.id);
|
||||
mine[0]!.position = { ...others[0]!.home };
|
||||
mine[1]!.position = { ...others[1]!.home };
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
expect(state.players.find((p) => p.id === me.id)!.alive).toBe(alive);
|
||||
}
|
||||
});
|
||||
|
||||
it("FORCE FIELD stands until the opponent's turn ends: no entering, no casting on its caster", () => {
|
||||
for (const deckRev of [21, 20]) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
// Stand the attacker beside the defender, an open edge between.
|
||||
const view = boardView(state);
|
||||
const side = SIDES.find((s: Side) => { const t = stepTarget(view, d.position, s); return t.kind === "step" && !state.squareContents[cellKey(t.to)]; })!;
|
||||
const beside = stepTarget(view, d.position, side);
|
||||
if (beside.kind === "blocked") throw new Error("no open side");
|
||||
a.position = { ...beside.to };
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" };
|
||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "force-field#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
if (state.stack) state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
||||
const step = applyCommand(state, attacker, { type: "move", direction: opposite(side) });
|
||||
if (deckRev >= 21) {
|
||||
expect(step.ok).toBe(false);
|
||||
if (!step.ok) expect(step.error).toMatch(/force field/);
|
||||
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(true);
|
||||
const after = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
expect(after.sustained.some((f) => f.cardId === "force-field")).toBe(false);
|
||||
} else {
|
||||
expect(step.ok).toBe(true);
|
||||
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, wallIgnoringDistance, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||
@@ -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 });
|
||||
@@ -706,3 +705,119 @@ describe("spells cast at a slime wait in the gel", () => {
|
||||
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attacks aimed at a bush or the ooze", () => {
|
||||
function withContent(kind: "thornbush" | "rosebush" | "ooze") {
|
||||
const state = toRound2(newGame().state);
|
||||
const me = activePlayer(state);
|
||||
const { cell } = emptyNeighborCell(state, me.position);
|
||||
state.squareContents[cellKey(cell)] = { kind, damage: 0, createdBy: "bob" };
|
||||
return { state, me, cell };
|
||||
}
|
||||
|
||||
it("a FIREBALL tears a thornbush apart at five points", () => {
|
||||
const { state, me, cell } = withContent("thornbush");
|
||||
const fireball = giveCard(state, me.id, "fireball");
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDamaged", kind: "thornbush", amount: 5, total: 5 }));
|
||||
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDestroyed", kind: "thornbush" }));
|
||||
expect(r.state.squareContents[cellKey(cell)]).toBeUndefined();
|
||||
expect(r.state.turn.attackUsed).toBe(true);
|
||||
});
|
||||
|
||||
it("a thrown dagger scratches a rosebush, and the scratch stays", () => {
|
||||
const { state, me, cell } = withContent("rosebush");
|
||||
const dagger = giveCard(state, me.id, "dagger");
|
||||
const next = must(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
||||
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 3 }));
|
||||
});
|
||||
|
||||
it("only fire hurts the ooze", () => {
|
||||
const { state, me, cell } = withContent("ooze");
|
||||
const dagger = giveCard(state, me.id, "dagger");
|
||||
const refused = applyCommand(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toMatch(/only fire/);
|
||||
const fireball = giveCard(state, me.id, "fireball");
|
||||
const burned = must(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
||||
expect(burned.squareContents[cellKey(cell)]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a WIZARDBLADE is swung at a bush from the square beside it, never from afar", () => {
|
||||
const { state, me, cell } = withContent("thornbush");
|
||||
const blade = giveCard(state, me.id, "wizardblade");
|
||||
const three = giveCard(state, me.id, "number-3", "N", 1);
|
||||
const near = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell },
|
||||
});
|
||||
expect(near.ok).toBe(true);
|
||||
if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 }));
|
||||
|
||||
const far = sightedCellsFor(viewFor(state, me.id));
|
||||
const farKey = [...far].find((k) => {
|
||||
const [x, y] = k.split(",").map(Number);
|
||||
return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k];
|
||||
});
|
||||
if (!farKey) return;
|
||||
const [fx, fy] = farKey.split(",").map(Number);
|
||||
state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" };
|
||||
const refused = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell: { x: fx!, y: fy! } },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toMatch(/beside/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport's reach wraps through the board's openings", () => {
|
||||
it("offers the square beyond a warp mouth, as the engine allows it", () => {
|
||||
const state = toRound2(newGame().state);
|
||||
const me = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
const warp = view.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
giveCard(state, me.id, "teleport");
|
||||
const cells = eligibleCellsFor(viewFor(state, me.id), "teleport")!;
|
||||
const beyond = cellKey(warp.to.cell);
|
||||
expect(cells.has(beyond)).toBe(true);
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: warp.to.cell } });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport across the maze's outer edge (rev 22)", () => {
|
||||
function atTheTopEdge(deckRev?: number) {
|
||||
const config = { playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] as ("basic" | "expansion1")[], ...(deckRev ? { deckRev } : {}) };
|
||||
const state = toRound2(createGame(config).state);
|
||||
const me = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
// A square on the top row whose north side is plain edge, not a lettered mouth.
|
||||
const top = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number])
|
||||
.filter(([x, y]) => y === 0 && !view.warps.some((w) => w.from.cell.x === x && w.from.cell.y === y && w.from.side === "N"))
|
||||
.find(([x]) => !state.squareContents[`${x},0`])!;
|
||||
me.position = { x: top[0], y: 0 };
|
||||
const column = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number]).filter(([x]) => x === top[0]).map(([, y]) => y);
|
||||
const bottom = { x: top[0], y: Math.max(...column) };
|
||||
giveCard(state, me.id, "teleport");
|
||||
return { state, me, bottom };
|
||||
}
|
||||
|
||||
it("re-enters at the bottom of the same column, one space on", () => {
|
||||
const { state, me, bottom } = atTheTopEdge();
|
||||
expect(wallIgnoringDistance(boardView(state), me.position, bottom, true)).toBe(1);
|
||||
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(true);
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.state.players.find((p) => p.id === me.id)!.position).toEqual(bottom);
|
||||
});
|
||||
|
||||
it("older games cross only at the lettered openings", () => {
|
||||
const { state, me, bottom } = atTheTopEdge(21);
|
||||
expect(wallIgnoringDistance(boardView(state), me.position, bottom)).toBeGreaterThan(4);
|
||||
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(false);
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,8 +36,9 @@ import { randomBytes } from "node:crypto";
|
||||
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
||||
import { extname, join, normalize, sep } from "node:path";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
import type { Command, PlayerId } from "@wizwar/engine";
|
||||
import {
|
||||
import { callKeeper, callRematch,
|
||||
catchUpSteps,
|
||||
momentSteps,
|
||||
claimTransferCode,
|
||||
@@ -85,6 +86,11 @@ 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);
|
||||
/** Calls to the keeper: a real person's phone rings for each. */
|
||||
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
|
||||
/** The keeper of this table: the wizard a lobby may challenge. */
|
||||
const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
||||
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
|
||||
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
||||
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
||||
@@ -198,11 +204,46 @@ function safeBase(rawHost: string, rawProto: string): string {
|
||||
return `${proto}://${escapeHtml(rawHost)}`;
|
||||
}
|
||||
|
||||
/** The turn's story in one line, for the share card: the deed a
|
||||
* stranger would click to see, chosen by weight — a crown, a treasure
|
||||
* 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; } };
|
||||
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) {
|
||||
case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two treasures home` : `${e.player} is the last wizard standing`); break;
|
||||
case "playerEliminated": offer(8, e.reason === "killed" ? `${e.player} falls in the maze` : `${e.player} loses both treasures`); break;
|
||||
case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a stolen treasure home`); break;
|
||||
case "treasurePickedUp": if (e.owner !== e.player) offer(5, `${e.player} snatches ${e.owner}'s treasure`); break;
|
||||
case "attackResolved": if (e.damageDealt > 0) offer(6, `${e.attacker} hits ${e.defender} with ${name(e.attackCardId)} for ${e.damageDealt}`); else if (e.fullyStopped) offer(4, `${e.defender} stops ${e.attacker}'s ${name(e.attackCardId)} cold`); break;
|
||||
case "punched": offer(3, `${e.attacker} punches ${e.target}`); break;
|
||||
case "spellCast": offer(2, e.target ? `${e.caster} casts ${name(e.cardId)} at ${e.target}` : `${e.caster} casts ${name(e.cardId)}`); break;
|
||||
case "jumpedPit": offer(3, `${e.player} leaps the pit`); break;
|
||||
case "fellInPit": offer(3, `${e.player} falls into the pit`); break;
|
||||
case "wardSprung": offer(4, `${e.owner}'s warded treasure bites ${e.victim}`); break;
|
||||
case "slimeTrapSprung": offer(4, `the slime springs ${name(e.cardId)} on ${e.victim}`); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
const base = safeBase(rawHost, rawProto);
|
||||
const headline = data.whole ? null : headlineOf(data);
|
||||
const title = data.whole
|
||||
? "The whole tale — a game of Wiz-War, replayed"
|
||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
||||
: headline
|
||||
? `${escapeHtml(headline)} — a Wiz-War instant replay`
|
||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
||||
const desc = data.whole
|
||||
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
|
||||
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
|
||||
@@ -289,7 +330,10 @@ const httpServer = createServer((req, res) => {
|
||||
"cache-control": "public, max-age=60",
|
||||
"access-control-allow-origin": "*",
|
||||
});
|
||||
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true }));
|
||||
res.end(JSON.stringify({
|
||||
steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true,
|
||||
headline: data.whole ? null : headlineOf(data),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
|
||||
@@ -482,6 +526,11 @@ function roomInfo(room: Room) {
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
audience: audienceCount(room),
|
||||
rematch: room.rematch ?? null,
|
||||
expected: room.expected ?? [],
|
||||
expansion: room.expansion,
|
||||
challenge: room.challenge ?? null,
|
||||
keeper: KEEPER,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
// A mystery machine keeps its mood only while the game lives: once it
|
||||
@@ -668,7 +717,12 @@ wss.on("connection", (socket, req) => {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
if ("error" in result) {
|
||||
// A refused seat is worth a line: a lost seat mid-game is the
|
||||
// costliest quiet failure this table has.
|
||||
console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)} — ${result.error}`);
|
||||
return send(socket, { type: "error", message: result.error });
|
||||
}
|
||||
leaveGallery(session);
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
@@ -679,11 +733,40 @@ wss.on("connection", (socket, req) => {
|
||||
// from firing again on every return to the room.
|
||||
if (room.state) {
|
||||
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
|
||||
} else if (room.chat.length > 0) {
|
||||
// The lobby's talk so far, for whoever just sat down.
|
||||
send(socket, { type: "events", events: room.chat.map((c) => ({ type: "tableTalk", player: c.player, text: c.text })), replayed: true });
|
||||
}
|
||||
broadcastRoomState(room);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "challengeKeeper": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
if (!challengesPerAddress.allow(session.address)) {
|
||||
return send(socket, { type: "error", message: "the keeper has been called enough from here for one hour" });
|
||||
}
|
||||
const called = callKeeper(room, session.playerId, KEEPER);
|
||||
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
||||
// The alarm the keeper listens for: one issue per room, so each
|
||||
// call rings once, with the door in the message. Error level,
|
||||
// because Sentry's alerts ring for high-priority issues and a
|
||||
// warning is filed as medium — a bell nobody hears.
|
||||
const link = `${PUBLIC_URL}/join/${room.id}`;
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
|
||||
level: "error",
|
||||
fingerprint: ["challenge", room.id],
|
||||
tags: { room: room.id, challenger: session.playerId },
|
||||
extra: { link, players: room.players.join(", ") },
|
||||
});
|
||||
}
|
||||
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
|
||||
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "watch": {
|
||||
// The Peanut Gallery: no name, no seat, no ledger line — a pure
|
||||
// reader of the public broadcast, counted but never identified.
|
||||
@@ -801,6 +884,30 @@ wss.on("connection", (socket, req) => {
|
||||
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
|
||||
break;
|
||||
}
|
||||
case "rematch": {
|
||||
// Anyone at a finished table may call; the caller lands in the
|
||||
// new lobby, and the old table hears where it went.
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const called = callRematch(room, session.playerId);
|
||||
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
||||
const next = getRoom(called.roomId);
|
||||
if (!next) return send(socket, { type: "error", message: "the rematch room is gone" });
|
||||
let token = called.token;
|
||||
if (!token) {
|
||||
const joined = joinRoom(next, session.playerId, null);
|
||||
if ("error" in joined) return send(socket, { type: "error", message: joined.error });
|
||||
token = joined.token;
|
||||
}
|
||||
if (called.created) broadcast(room, () => ({ type: "rematch", roomId: room.id, to: next.id, by: session.playerId }));
|
||||
leaveGallery(session);
|
||||
send(socket, { type: "rematched", roomId: next.id });
|
||||
session.roomId = next.id;
|
||||
session.token = token;
|
||||
send(socket, { type: "seat", playerId: session.playerId, token });
|
||||
broadcastRoomState(next);
|
||||
break;
|
||||
}
|
||||
case "chat": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -963,6 +1070,7 @@ wss.on("connection", (socket, req) => {
|
||||
const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null);
|
||||
if (result === null) continue;
|
||||
if (result === "badToken") {
|
||||
console.warn(`seat voided: room ${roomId} name ${JSON.stringify(name)} — the token did not match`);
|
||||
voided.push(`${roomId}:${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface Room {
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
/** Last time anything looked at this room (memory eviction clock). */
|
||||
touchedAt?: number;
|
||||
/** A finished table that called for a rematch: where it went, and who called. */
|
||||
rematch?: { roomId: string; by: PlayerId };
|
||||
/** A rematch lobby: the wizards of the last table who have not yet sat. */
|
||||
expected?: PlayerId[];
|
||||
/** The table called the keeper of this site to a seat, and by whom. */
|
||||
challenge?: { by: PlayerId; at: string };
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -91,7 +97,10 @@ export function runningRooms(): Room[] {
|
||||
return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing");
|
||||
}
|
||||
|
||||
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
export function createRoom(
|
||||
hostId: PlayerId,
|
||||
rematch?: { of: string; expected: PlayerId[]; expansion: boolean },
|
||||
): { room: Room; token: string } {
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const room: Room = {
|
||||
id: makeRoomCode(),
|
||||
@@ -99,7 +108,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
players: [hostId],
|
||||
tokens: new Map([[hostId, hashToken(token)]]),
|
||||
seed: randomInt(0, 0xffffffff),
|
||||
expansion: false,
|
||||
expansion: rematch?.expansion ?? false,
|
||||
colorChoices: new Map(),
|
||||
createdAt: new Date().toISOString(),
|
||||
state: null,
|
||||
@@ -108,6 +117,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(rematch ? { expected: [...rematch.expected] } : {}),
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -118,10 +128,45 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
hostTokenHash: hashToken(token),
|
||||
seed: room.seed,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(rematch ? { rematchOf: rematch.of, expected: rematch.expected, expansion: rematch.expansion } : {}),
|
||||
});
|
||||
return { room, token };
|
||||
}
|
||||
|
||||
/** A lobby calls the keeper to the table: a seat is held under their
|
||||
* name, the call is written to the ledger, and the caller (index.ts)
|
||||
* raises the alarm the keeper listens for. Once per room. */
|
||||
export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at: string } | { error: string } {
|
||||
if (room.state) return { error: "the game has started" };
|
||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" };
|
||||
if (room.challenge) return { error: `${keeper} has already been called to this table` };
|
||||
if (room.players.includes(keeper)) return { error: `${keeper} is already here` };
|
||||
if (room.players.length + (room.expected?.length ?? 0) >= 6) return { error: "the table is full" };
|
||||
const at = new Date().toISOString();
|
||||
room.challenge = { by: byId, at };
|
||||
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
||||
appendLine(room.id, { kind: "challenge", by: byId, at });
|
||||
return { at };
|
||||
}
|
||||
|
||||
/** A finished table calls for a rematch: a new room with the same
|
||||
* clockwork at the same tiers and the same expansion setting, waiting for
|
||||
* the same humans. The first caller hosts it; anyone at the old table may
|
||||
* call, and a second call finds the room already made. */
|
||||
export function callRematch(
|
||||
room: Room, byId: PlayerId,
|
||||
): { roomId: string; token: string | null; created: boolean } | { error: string } {
|
||||
if (room.state?.phase !== "finished") return { error: "the game is not over yet" };
|
||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" };
|
||||
if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false };
|
||||
const humans = room.players.filter((p) => !room.bots.has(p) && p !== byId);
|
||||
const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion });
|
||||
for (const [, b] of room.bots) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
|
||||
room.rematch = { roomId: next.id, by: byId };
|
||||
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
|
||||
return { roomId: next.id, token, created: true };
|
||||
}
|
||||
|
||||
export function getRoom(id: string): Room | undefined {
|
||||
const code = id.toUpperCase();
|
||||
const live = rooms.get(code);
|
||||
@@ -165,7 +210,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 +230,8 @@ interface 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> };
|
||||
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);
|
||||
@@ -209,6 +256,7 @@ function baseSummary(room: Room, active: PlayerId | null): Omit<GameSummary, "na
|
||||
round: room.state?.turn.round ?? null,
|
||||
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
||||
chatCount: room.chat.length,
|
||||
rematch: room.rematch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,6 +294,8 @@ export function joinRoom(
|
||||
const fresh = randomBytes(16).toString("hex");
|
||||
room.players.push(playerId);
|
||||
room.tokens.set(playerId, hashToken(fresh));
|
||||
// An expected wizard who sits is expected no longer.
|
||||
if (room.expected?.includes(playerId)) room.expected = room.expected.filter((n) => n !== playerId);
|
||||
appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) });
|
||||
recordRoom(room);
|
||||
return { token: fresh };
|
||||
@@ -465,6 +515,8 @@ export interface GameSummary {
|
||||
lastMoveAt: string | null;
|
||||
/** Total table-talk messages; the client tracks which it has seen. */
|
||||
chatCount: number;
|
||||
/** A finished table that moved on: the rematch room and who called it. */
|
||||
rematch?: { roomId: string; by: PlayerId } | null;
|
||||
}
|
||||
|
||||
/** Who holds the table's attention, and why — the summary's turn facts.
|
||||
@@ -741,6 +793,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(meta.expected ? { expected: [...meta.expected] } : {}),
|
||||
...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}),
|
||||
};
|
||||
if (lines.some((l) => l.kind === "abandon")) return null;
|
||||
for (const line of lines.slice(1)) {
|
||||
@@ -764,10 +818,17 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
if (!joinHash) throw new Error("join line has no token");
|
||||
room.players.push(line.name);
|
||||
room.tokens.set(line.name, joinHash);
|
||||
if (room.expected?.includes(line.name)) room.expected = room.expected.filter((n) => n !== line.name);
|
||||
}
|
||||
} else if (line.kind === "start") {
|
||||
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||
} else if (line.kind === "rematch") {
|
||||
room.rematch = { roomId: line.to, by: line.by };
|
||||
} else if (line.kind === "challenge") {
|
||||
room.challenge = { by: line.by, at: line.at };
|
||||
const keeper = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
||||
if (!room.players.includes(keeper)) room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
||||
} else if (line.kind === "chat") {
|
||||
// File order preserves the interleaving with commands.
|
||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||
@@ -797,7 +858,7 @@ export function loadPersistedRooms(): void {
|
||||
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)) });
|
||||
sleepingStubs.set(id, fromStored(stored.stub));
|
||||
asleep++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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<string, Share>();
|
||||
|
||||
function sharesFile(): string {
|
||||
return join(statsDir(), "shares.jsonl");
|
||||
return join(dataRoot(), "shares.jsonl");
|
||||
}
|
||||
|
||||
export function loadShares(): void {
|
||||
|
||||
@@ -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<EngagementStats, "wizardsSeated"> {
|
||||
roomStages: Record<string, "created" | "started" | "finished">;
|
||||
}
|
||||
|
||||
const FILE = () => join(statsDir(), "stats.json");
|
||||
const FILE = () => join(dataRoot(), "stats.json");
|
||||
|
||||
let data: StatsFile = {
|
||||
gamesCreated: 0, gamesStarted: 0, gamesFinished: 0,
|
||||
|
||||
@@ -16,6 +16,26 @@ export interface RoomMetaLine {
|
||||
hostToken?: string;
|
||||
seed: number;
|
||||
createdAt: string;
|
||||
/** A rematch: the finished room it follows, and the wizards it waits for. */
|
||||
rematchOf?: string;
|
||||
expected?: string[];
|
||||
/** The lobby's expansion default, carried over from the last table. */
|
||||
expansion?: boolean;
|
||||
}
|
||||
|
||||
/** A table called the keeper: the seat is held and the keeper told. */
|
||||
export interface ChallengeLine {
|
||||
kind: "challenge";
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** The finished table called for a rematch: the new room it moved to. */
|
||||
export interface RematchLine {
|
||||
kind: "rematch";
|
||||
to: string;
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface JoinLine {
|
||||
@@ -71,7 +91,7 @@ export interface AbandonLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
@@ -86,8 +106,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 +171,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 +190,7 @@ export function readStubFile<T>(roomId: string): { bytes: number; stub: T } | nu
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStubFile(roomId: string, bytes: number, stub: unknown): void {
|
||||
export function writeStubFile<T>(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 +204,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<string, unknown>): void {
|
||||
ensureDataDir();
|
||||
@@ -239,7 +261,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 +282,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. */
|
||||
|
||||
+984
-298
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,6 @@
|
||||
markedCells = null,
|
||||
litCells = null,
|
||||
spotCells = [],
|
||||
spotCaption = null,
|
||||
markedSector = null,
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
@@ -70,8 +69,6 @@
|
||||
litCells?: Set<string> | null;
|
||||
/** The step light's squares: a pulsing rim on each. */
|
||||
spotCells?: string[];
|
||||
/** One line hung above a square — the first stride's cue. */
|
||||
spotCaption?: { cell: { x: number; y: number }; text: string } | null;
|
||||
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
|
||||
markedSector?: { x: number; y: number } | null;
|
||||
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
|
||||
@@ -464,15 +461,11 @@
|
||||
{@const wx = Number(key.split(":")[1]?.split(",")[0])}
|
||||
{@const wy = Number(key.split(":")[1]?.split(",")[1])}
|
||||
{@const frac = Math.min(1, dmg / 20)}
|
||||
{#if kind === "V"}
|
||||
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)}
|
||||
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{:else}
|
||||
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)}
|
||||
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{/if}
|
||||
{@const scar = kind === "V"
|
||||
? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
|
||||
: edgeScar(wx * CELL + 3, (wy + 1) * CELL, (wx + 1) * CELL - 3, (wy + 1) * CELL, frac)}
|
||||
<path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
{/each}
|
||||
|
||||
<!-- illusions YOU know are fake: ghostly dashed lines -->
|
||||
@@ -697,17 +690,6 @@
|
||||
{@const sy = Number(key.split(",")[1])}
|
||||
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
|
||||
{/each}
|
||||
{#if spotCaption}
|
||||
{@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
|
||||
{@const cw = spotCaption.text.length * 6.6 + 20}
|
||||
{@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
|
||||
{@const above = spotCaption.cell.y > 0}
|
||||
{@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
|
||||
<g class="spot-caption" transform={`translate(${cx}, ${cy})`}>
|
||||
<rect x={-cw / 2} y={-12} width={cw} height={24} rx="12" />
|
||||
<text x="0" y="4">{spotCaption.text}</text>
|
||||
</g>
|
||||
{/if}
|
||||
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
|
||||
{#each fearAura as key (key)}
|
||||
{@const bx = Number(key.split(",")[0])}
|
||||
@@ -865,15 +847,6 @@
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.spot-caption { pointer-events: none; }
|
||||
.spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
|
||||
.spot-caption text {
|
||||
fill: #f7ecc8;
|
||||
font-size: 12px;
|
||||
font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
text-anchor: middle;
|
||||
}
|
||||
.marked-cell {
|
||||
fill: rgba(211, 133, 43, 0.18);
|
||||
stroke: #d3852b;
|
||||
@@ -933,7 +906,6 @@
|
||||
stroke: rgba(240, 231, 209, 0.9);
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
.warp-dest {
|
||||
fill: none;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
><title>{tip}</title></rect>
|
||||
{/if}
|
||||
|
||||
<!-- Pigment and hardware sit over the original wall/door hit target. -->
|
||||
<!-- stone highlights or wood grain and lock hardware; the rect beneath takes the taps -->
|
||||
<g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true">
|
||||
{#if state === "wall"}
|
||||
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" />
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
fill="#bd3b1b" class="fw-bar"
|
||||
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
|
||||
{/if}
|
||||
<!-- Drawn flame poses share the original bases and hit area. -->
|
||||
<!-- tongues of flame, each on its own beat; the bar beneath is the hit target -->
|
||||
{#each TONGUES as f (f.t)}
|
||||
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
|
||||
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}>
|
||||
|
||||
+462
-222
@@ -1,130 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { allCardDefs, RULES_REVISIONS } from "@wizwar/engine";
|
||||
// The reference desk: how to play, the rules and FAQ as one searchable
|
||||
// body, the card library with proper entries, the house rulings, the
|
||||
// story of the table, and the tally. Every section and card has a link
|
||||
// a player can send; the desk remembers where the reader was.
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
import { tick } from "svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Faq from "./Faq.svelte";
|
||||
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
|
||||
import { FAQ_GENERAL } from "./faq-general";
|
||||
import { RULEBOOK_BASE, RULEBOOK_EXPANSION, RULEBOOK_COPYRIGHT } from "./rulebook";
|
||||
import { HOW_TO_PLAY } from "./rules";
|
||||
import { RULEBOOK_COPYRIGHT } from "./rulebook";
|
||||
import { helpState, type HelpTab } from "./help-state";
|
||||
import {
|
||||
REFERENCE, SOURCE_LABELS, searchReference, renderInline,
|
||||
CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel,
|
||||
HOUSE_RULINGS, rulingsFor, searchRulings,
|
||||
type RefSource,
|
||||
} from "./reference";
|
||||
|
||||
let {
|
||||
onclose,
|
||||
initialTab = "play",
|
||||
initialTab = null,
|
||||
initialAnchor = null,
|
||||
initialCard = null,
|
||||
stats = null,
|
||||
onstats,
|
||||
onnavigate,
|
||||
}: {
|
||||
onclose: () => void;
|
||||
initialTab?: "play" | "rules" | "cards" | "rulings" | "about" | "tally";
|
||||
/** A tab to open on; null reopens where the reader left off. */
|
||||
initialTab?: HelpTab | null;
|
||||
/** A section to scroll to on the tab. */
|
||||
initialAnchor?: string | null;
|
||||
/** A card entry to open. */
|
||||
initialCard?: string | null;
|
||||
stats?: Record<string, number | string | null> | null;
|
||||
onstats?: () => void;
|
||||
/** The address bar follows the reader, so the link is always a copy away. */
|
||||
onnavigate?: (tab: HelpTab, anchor: string | null, card: string | null) => void;
|
||||
} = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
|
||||
let tab = $state<"play" | "rules" | "cards" | "rulings" | "about" | "tally">(initialTab);
|
||||
/** Cards the FAQ has spoken on, in the library's order. */
|
||||
const ruledCards = allCardDefs().filter((c) => c.faqRulings.length > 0);
|
||||
let faqCardId = $state<string | null>(null);
|
||||
let libPeek = $state<string | null>(null);
|
||||
// svelte-ignore state_referenced_locally -- the opening tab is a one-time value
|
||||
let tab = $state<HelpTab>(initialCard ? "cards" : (initialTab ?? helpState.tab));
|
||||
let rulesSearch = $state(helpState.rulesSearch);
|
||||
let rulingsSearch = $state(helpState.rulingsSearch);
|
||||
let cardText = $state(helpState.cards.text);
|
||||
let cardSet = $state(helpState.cards.set);
|
||||
let cardType = $state(helpState.cards.type);
|
||||
let cardSight = $state(helpState.cards.sight);
|
||||
// svelte-ignore state_referenced_locally -- the opening card is a one-time value
|
||||
let entry = $state<string | null>(initialCard);
|
||||
let entryOpener: HTMLElement | null = null;
|
||||
let bodyEl = $state<HTMLElement | null>(null);
|
||||
let copied = $state<string | null>(null);
|
||||
|
||||
function openTally() {
|
||||
tab = "tally";
|
||||
onstats?.();
|
||||
const refResults = $derived(searchReference(rulesSearch));
|
||||
const rulingResults = $derived(searchRulings(rulingsSearch));
|
||||
const cardResults = $derived(searchCards({ text: cardText, set: cardSet, type: cardType, sight: cardSight }));
|
||||
const entryDef = $derived(entry ? cardDef(entry) : null);
|
||||
const entryRulings = $derived(entry ? rulingsFor(entry) : []);
|
||||
const entryMentions = $derived(entry ? mentionedCards(entry) : []);
|
||||
const refBySource = $derived(
|
||||
(["summary", "rulebook", "expansion", "faq"] as RefSource[])
|
||||
.map((source) => ({ source, sections: REFERENCE.filter((s) => s.source === source) })),
|
||||
);
|
||||
|
||||
$effect(() => { helpState.tab = tab; });
|
||||
$effect(() => { helpState.rulesSearch = rulesSearch; });
|
||||
$effect(() => { helpState.rulingsSearch = rulingsSearch; });
|
||||
$effect(() => { helpState.cards = { text: cardText, set: cardSet, type: cardType, sight: cardSight }; });
|
||||
$effect(() => { onnavigate?.(tab, null, entry); });
|
||||
|
||||
function saveScroll() {
|
||||
if (bodyEl) helpState.scroll[tab] = bodyEl.scrollTop;
|
||||
}
|
||||
async function goTab(t: HelpTab) {
|
||||
if (t === tab) return;
|
||||
saveScroll();
|
||||
tab = t;
|
||||
if (t === "tally") onstats?.();
|
||||
await tick();
|
||||
if (bodyEl) bodyEl.scrollTop = helpState.scroll[t] ?? 0;
|
||||
}
|
||||
async function jumpTo(anchor: string) {
|
||||
await tick();
|
||||
const el = bodyEl?.querySelector(`#${CSS.escape(anchor)}`);
|
||||
el?.scrollIntoView({ block: "start" });
|
||||
onnavigate?.(tab, anchor, null);
|
||||
}
|
||||
function linkFor(t: HelpTab, anchor: string | null, card: string | null): string {
|
||||
const q = card ? `card=${card}` : `help=${t}${anchor ? `/${anchor}` : ""}`;
|
||||
return `${location.origin}/?${q}`;
|
||||
}
|
||||
function copyLink(t: HelpTab, anchor: string | null, card: string | null) {
|
||||
const key = card ?? `${t}/${anchor ?? ""}`;
|
||||
navigator.clipboard?.writeText(linkFor(t, anchor, card)).catch(() => {});
|
||||
onnavigate?.(t, anchor, card);
|
||||
copied = key;
|
||||
setTimeout(() => { if (copied === key) copied = null; }, 1500);
|
||||
}
|
||||
function openEntry(id: string, opener?: EventTarget | null) {
|
||||
entryOpener = (opener instanceof HTMLElement ? opener : document.activeElement) as HTMLElement | null;
|
||||
entry = id;
|
||||
}
|
||||
function closeEntry() {
|
||||
entry = null;
|
||||
entryOpener?.focus();
|
||||
entryOpener = null;
|
||||
}
|
||||
function close() {
|
||||
saveScroll();
|
||||
onclose();
|
||||
}
|
||||
/** Escape closes the foremost layer only: the card entry before the desk. */
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
if (entry) { closeEntry(); return; }
|
||||
close();
|
||||
}
|
||||
function n(v: unknown): string {
|
||||
return Number(v ?? 0).toLocaleString();
|
||||
}
|
||||
function hours(mins: number): string {
|
||||
return mins < 90 ? `${mins} minutes` : `${Math.round(mins / 6) / 10} hours`;
|
||||
return mins < 90 ? `${n(mins)} minutes` : `${n(Math.round(mins / 6) / 10)} hours`;
|
||||
}
|
||||
let search = $state("");
|
||||
|
||||
// The playable pool: every card actually in the 6e game (base + Exp1).
|
||||
const pool = allCardDefs().filter(
|
||||
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
|
||||
);
|
||||
const filtered = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const list = q
|
||||
? pool.filter(
|
||||
(d) => d.name.toLowerCase().includes(q) || (d.text ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
: pool;
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||
$effect(() => {
|
||||
if (initialAnchor) void jumpTo(initialAnchor);
|
||||
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
|
||||
});
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
{#if libPeek}
|
||||
<div class="lib-peek-scrim" role="button" tabindex="-1" onclick={() => (libPeek = null)} onkeydown={() => {}}>
|
||||
<div class="lib-peek">
|
||||
<Card card={{ instanceId: `peek-${libPeek}`, cardId: libPeek }} onfaq={(id) => (faqCardId = id)} />
|
||||
{#if entryDef}
|
||||
<div class="layer-scrim" role="presentation" onclick={closeEntry}>
|
||||
<div class="entry" role="dialog" aria-modal="true" aria-label="{entryDef.name}, card entry" tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
|
||||
<header class="entry-head">
|
||||
<span class="entry-title">{entryDef.name}</span>
|
||||
<button class="link-btn" onclick={() => copyLink("cards", null, entryDef!.id)} title="copy a link to this card">
|
||||
{copied === entryDef.id ? "link copied" : "🔗 link"}</button>
|
||||
<button class="close" onclick={closeEntry} aria-label="close card entry">×</button>
|
||||
</header>
|
||||
<div class="entry-body">
|
||||
<div class="entry-card">
|
||||
<Card card={{ instanceId: `entry-${entryDef.id}`, cardId: entryDef.id }} />
|
||||
</div>
|
||||
<div class="entry-text reading">
|
||||
<p class="entry-meta">
|
||||
{typeLabel(entryDef)} · {sightOf(entryDef)} · {setLabel(entryDef)}
|
||||
</p>
|
||||
<h3>As printed</h3>
|
||||
<p class="printed">{entryDef.text ?? (entryDef.cardType === "number" ? `A NUMBER card worth ${entryDef.value}: add it to your movement, or set a spell's duration or power.` : "")}</p>
|
||||
{#if entryDef.faqRulings.length > 0}
|
||||
<h3>Official FAQ</h3>
|
||||
{#each entryDef.faqRulings as q, i (i)}<p>{q}</p>{/each}
|
||||
<p class="colophon">Tom Jolly's own rulings (wizwar.com, September 2002). The cards overrule the rules, and the designer overrules the table.</p>
|
||||
{/if}
|
||||
{#if entryRulings.length > 0}
|
||||
<h3>At this table</h3>
|
||||
{#each entryRulings as r (r.id)}
|
||||
{#each r.body as p, i (i)}<p>{p}</p>{/each}
|
||||
<p class="colophon"><button class="inline-link" onclick={() => { closeEntry(); void goTab("rulings"); void jumpTo(`ruling-${r.id}`); }}>{r.title}, in the house rulings</button></p>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if entryMentions.length > 0}
|
||||
<h3>Mentions</h3>
|
||||
<p class="chips">
|
||||
{#each entryMentions as m (m.id)}
|
||||
<button class="chip" onclick={(e) => openEntry(m.id, e.currentTarget)}>{m.name}</button>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if faqCardId}
|
||||
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
|
||||
{/if}
|
||||
|
||||
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
|
||||
<div
|
||||
class="booklet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="help"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<div class="scrim" role="presentation" onclick={close}>
|
||||
<div class="booklet" role="dialog" aria-modal="true" aria-label="help" tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
|
||||
<header class="booklet-head">
|
||||
<span class="booklet-title">Wiz-War</span>
|
||||
<nav class="tabs">
|
||||
<button class:current={tab === "play"} onclick={() => (tab = "play")}>How to play</button>
|
||||
<button class:current={tab === "rules"} onclick={() => (tab = "rules")}>The rules</button>
|
||||
<button class:current={tab === "cards"} onclick={() => (tab = "cards")}>Card library</button>
|
||||
<button class:current={tab === "rulings"} onclick={() => (tab = "rulings")}>House rulings</button>
|
||||
<button class:current={tab === "about"} onclick={() => (tab = "about")}>About</button>
|
||||
<button class:current={tab === "tally"} onclick={openTally}>The tally</button>
|
||||
<nav class="tabs" aria-label="help sections">
|
||||
<button class:current={tab === "play"} onclick={() => goTab("play")}>How to play</button>
|
||||
<button class:current={tab === "rules"} onclick={() => goTab("rules")}>The rules</button>
|
||||
<button class:current={tab === "cards"} onclick={() => goTab("cards")}>Card library</button>
|
||||
<button class:current={tab === "rulings"} onclick={() => goTab("rulings")}>House rulings</button>
|
||||
<button class:current={tab === "about"} onclick={() => goTab("about")}>About</button>
|
||||
<button class:current={tab === "tally"} onclick={() => goTab("tally")}>The tally</button>
|
||||
</nav>
|
||||
<button class="close" onclick={onclose} aria-label="close help">×</button>
|
||||
<button class="close" onclick={close} aria-label="close help">×</button>
|
||||
</header>
|
||||
|
||||
<div class="booklet-body">
|
||||
<div class="booklet-body" bind:this={bodyEl}>
|
||||
{#if tab === "tally"}
|
||||
<div class="tally">
|
||||
<div class="reading">
|
||||
<h3>How much love the maze is getting</h3>
|
||||
{#if stats}
|
||||
<div class="stories">
|
||||
<div class="story"><span class="big">{n(stats.gamesFinished)}</span><span>games fought to a finish</span></div>
|
||||
<div class="story"><span class="big">{n(stats.winsByTreasure)}</span><span>won by carrying treasure home</span></div>
|
||||
<div class="story"><span class="big">{n(stats.winsByLastStanding)}</span><span>won as the last wizard standing</span></div>
|
||||
<div class="story"><span class="big">{hours(Number(stats.minutesAtTable))}</span><span>spent at the table</span></div>
|
||||
</div>
|
||||
<h3>The ledgers in detail</h3>
|
||||
<dl class="tally-list">
|
||||
<dt>{stats.gamesCreated}</dt><dd>games chronicled — {stats.gamesStarted} begun, {stats.gamesFinished} fought to a finish</dd>
|
||||
<dt>{stats.wizardsSeated}</dt><dd>distinct wizards have taken a seat</dd>
|
||||
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd>
|
||||
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd>
|
||||
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd>
|
||||
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
|
||||
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
|
||||
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
|
||||
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
|
||||
<dt>{n(stats.gamesCreated)}</dt><dd>games chronicled — {n(stats.gamesStarted)} begun</dd>
|
||||
<dt>{n(stats.wizardsSeated)}</dt><dd>distinct wizard names have taken a seat</dd>
|
||||
<dt>{n(stats.commandsPlayed)}</dt><dd>spells, steps, and punches recorded</dd>
|
||||
<dt>{n(stats.longestGameCommands)}</dt><dd>actions in the longest game yet played</dd>
|
||||
<dt>{n(stats.fullestTable)}</dt><dd>wizards at the fullest table</dd>
|
||||
<dt>{n(stats.hotseatGames)}</dt><dd>games played on one device, passed around</dd>
|
||||
<dt>{n(stats.automatonGames ?? 0)}</dt><dd>games fought against the clockwork</dd>
|
||||
</dl>
|
||||
{#if stats.firstGameAt}
|
||||
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
|
||||
{/if}
|
||||
<p class="colophon">
|
||||
{#if stats.firstGameAt}The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}.{/if}
|
||||
Wizards are counted by name, not by person: one player under two names is two wizards here.
|
||||
Table time is estimated from the clock between moves, so a game left open overnight counts only the minutes it was actually played.
|
||||
Games on one device send only their counts — names and moves stay on the device.
|
||||
</p>
|
||||
{:else}
|
||||
<p>Counting the ledgers…</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === "about"}
|
||||
<div class="about">
|
||||
<h3>What this is</h3>
|
||||
<p>
|
||||
Wiz-War is a game of magical combat in a stone labyrinth: two to six
|
||||
wizards prowl a maze, hurling fireballs, walking through walls,
|
||||
summoning trolls, and stealing each other's treasure. It was created
|
||||
by <strong>Tom Jolly</strong> in 1983 and first published under his
|
||||
own Jolly Games label; the edition reproduced here is the
|
||||
<strong>sixth edition</strong>, published by Chessex in 1993, together
|
||||
with its one expansion — monsters and magic wands included.
|
||||
</p>
|
||||
<div class="reading about">
|
||||
<h3>Why it exists</h3>
|
||||
<p>
|
||||
In the early nineties, a group of friends played this exact edition
|
||||
@@ -138,6 +239,16 @@
|
||||
it was played at that table, made so the same friends — and their
|
||||
friends — can keep playing it.
|
||||
</p>
|
||||
<h3>What this is</h3>
|
||||
<p>
|
||||
Wiz-War is a game of magical combat in a stone labyrinth: two to six
|
||||
wizards prowl a maze, hurling fireballs, walking through walls,
|
||||
summoning trolls, and stealing each other's treasure. It was created
|
||||
by <strong>Tom Jolly</strong> in 1983 and first published under his
|
||||
own Jolly Games label; the edition reproduced here is the
|
||||
<strong>sixth edition</strong>, published by Chessex in 1993, together
|
||||
with its one expansion — monsters and magic wands included.
|
||||
</p>
|
||||
<h3>Whose game it is</h3>
|
||||
<p>
|
||||
Wiz-War is Tom Jolly's design, and the rights to it now rest with
|
||||
@@ -151,22 +262,35 @@
|
||||
somebody at a real table.
|
||||
</p>
|
||||
<h3>Behind the curtain</h3>
|
||||
<p>
|
||||
The workshops where this table's pieces are made are open to
|
||||
visitors:
|
||||
the <a href="/?tokens">token workshop</a> shows every token in both
|
||||
arts beside the wall textures and spell sprites;
|
||||
the <a href="/?fx">flourish workshop</a> plays each board effect on
|
||||
demand; and
|
||||
the <a href="/?fpv">first-person workshop</a> walks the maze through
|
||||
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
|
||||
</p>
|
||||
<p>The workshops where this table's pieces are made are open to visitors.</p>
|
||||
<div class="gallery">
|
||||
<a class="tile" href="/?tokens">
|
||||
<img src="/tokens/alter-ego.png" alt="" />
|
||||
<span class="tile-name">The token workshop</span>
|
||||
<span class="tile-note">every token in both arts, beside the wall textures and spell sprites</span>
|
||||
</a>
|
||||
<a class="tile" href="/?fx">
|
||||
<img src="/fx3d/fireball.png" alt="" />
|
||||
<span class="tile-name">The flourish workshop</span>
|
||||
<span class="tile-note">each board effect, played on demand</span>
|
||||
</a>
|
||||
<a class="tile" href="/?fpv">
|
||||
<img src="/hero.jpg" alt="" />
|
||||
<span class="tile-name">The first-person workshop</span>
|
||||
<span class="tile-note">the maze through a wizard's own eyes — or <span class="tile-link">watch the demo reel</span></span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
|
||||
<h3>What the table remembers</h3>
|
||||
<p>
|
||||
Every game here is recorded permanently, move by move — table talk
|
||||
included — so finished games can be replayed, shared, and studied.
|
||||
Play under whatever name you like, but know that what you say and
|
||||
do at the table becomes part of its lasting record.
|
||||
An online game is written to this table's ledger move by move, table
|
||||
talk included, and kept: a finished game can be replayed from above
|
||||
or through a wizard's own eyes, and any turn can be shared by link.
|
||||
A shared replay shows that turn's moves and the sharing wizard's view
|
||||
of the board — never anyone's hand. A game passed around one device
|
||||
stays on that device; only its counts reach the tally. Play under
|
||||
whatever name you like, and know that what you say and do at an
|
||||
online table is part of its record.
|
||||
</p>
|
||||
<h3>Send word</h3>
|
||||
<p>
|
||||
@@ -183,113 +307,139 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else if tab === "play"}
|
||||
{#each HOW_TO_PLAY as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
{:else if tab === "rulings"}
|
||||
<p class="colophon">
|
||||
For players who know the box. Where this table departs from the
|
||||
cardboard, and the calls it makes where the rulebook is silent.
|
||||
Every game is frozen at the revision it was dealt under, so an old
|
||||
game replays exactly as it was played. If the table rules against
|
||||
your memory of the rules, the 🐞 report button pins the moment
|
||||
for review — the reply lands in your lobby.
|
||||
</p>
|
||||
<h3>What the digital table does differently</h3>
|
||||
<p>THE THUMB OF GOD is a divine meteor. Aim it at a square; the die
|
||||
drifts up to two squares in a random direction, then every token in
|
||||
and around the landing square — objects, treasures, creatures, even
|
||||
wizards — is flung to a random nearby square. Walls mean nothing to
|
||||
falling cardboard, and there is no counteraction.</p>
|
||||
<p>An ILLUSION WALL is real only to those who believe it. Its creator
|
||||
sees through it from the start; everyone else sees stone until they
|
||||
walk into it or see through it, and the maze remembers each
|
||||
wizard's verdict separately. An untested illusion shimmers faintly.</p>
|
||||
<p>An ambush (OPPORTUNITY FIRE) is set with the attack card it will
|
||||
fire and a trigger of your choice: an opponent entering your line of
|
||||
sight, coming within one square, or picking up any treasure. It
|
||||
springs on their turn, out of yours.</p>
|
||||
<p>BUTT-HEAD's ram is measured as the shortest walk through the
|
||||
corridors from where you cast it to your victim's square, on the
|
||||
legs you have this turn; you cannot pad the blow by taking the
|
||||
long way round, and the goat lands on the victim's square. There
|
||||
is no ceiling. MAD DASH doubles the whole allowance, NUMBER cards
|
||||
included, so a well-placed goat can ram for sixteen.</p>
|
||||
<p>Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a
|
||||
slime may hold several. They go off one at a time, oldest first:
|
||||
each wizard who pushes in springs one spell, the next wizard the
|
||||
next. The card says only that each spell goes off once; the queue
|
||||
is this table's reading. A slime shows how many it holds, and a
|
||||
peek names them, since every cast into it was seen.</p>
|
||||
<p>Clockwork wizards — the automatons — play from the same redacted
|
||||
view a human seat gets and obey every rule; a tier changes what
|
||||
they draw and know, never how correctly they play.</p>
|
||||
<p>A turn need not be taken at once. Games wait on the lobby ledger
|
||||
for days; the browser holds your seat; a replay of any game can be
|
||||
watched from above or through a wizard's eyes and shared by link.</p>
|
||||
<h3>Rulings by revision</h3>
|
||||
<p class="colophon">Each revision below changed how something resolves; games dealt before it keep the old reading.</p>
|
||||
<dl class="revisions">
|
||||
{#each RULES_REVISIONS as r (r.rev)}
|
||||
<dt>Rev {r.rev}</dt><dd>{r.note}</dd>
|
||||
<div class="reading">
|
||||
{#each HOW_TO_PLAY as section (section.title)}
|
||||
<h3 id="play-{section.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}">{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
</dl>
|
||||
<h3>Card rulings from the FAQ</h3>
|
||||
<p class="colophon">{ruledCards.length} cards carry rulings from the official FAQ; the engine follows them.</p>
|
||||
{#each ruledCards as c (c.id)}
|
||||
<details class="ruling">
|
||||
<summary>{c.name}</summary>
|
||||
{#each c.faqRulings as q, i (i)}<p>{q}</p>{/each}
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tab === "rulings"}
|
||||
<div class="reading">
|
||||
<p class="colophon">
|
||||
For players who know the box: how this table resolves what the
|
||||
cardboard leaves to the players, by card. If the table rules
|
||||
against your memory of the rules, the 🐞 report button pins the
|
||||
moment for review — the reply lands in your lobby.
|
||||
</p>
|
||||
<div class="search-row">
|
||||
<input bind:value={rulingsSearch} placeholder="search the rulings by card or topic…" aria-label="search house rulings" />
|
||||
</div>
|
||||
{#if rulingsSearch.trim()}
|
||||
<p class="count">{rulingResults.length === 0 ? `Nothing here mentions "${rulingsSearch.trim()}".` : `${rulingResults.length} of ${HOUSE_RULINGS.length} rulings`}</p>
|
||||
{/if}
|
||||
{#each rulingResults as r (r.id)}
|
||||
<h3 id="ruling-{r.id}" class="linked">
|
||||
{r.title}
|
||||
<button class="link-btn" onclick={() => copyLink("rulings", `ruling-${r.id}`, null)} title="copy a link to this ruling">
|
||||
{copied === `rulings/ruling-${r.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each r.body as p, i (i)}<p>{p}</p>{/each}
|
||||
<p class="chips">
|
||||
{#each r.cards as id (id)}
|
||||
<button class="chip" onclick={(e) => openEntry(id, e.currentTarget)}>{cardDef(id).name}</button>
|
||||
{/each}
|
||||
</p>
|
||||
{/each}
|
||||
{#if !rulingsSearch.trim()}
|
||||
<h3 id="ruling-the-table">The table itself</h3>
|
||||
<p>Clockwork wizards — the automatons — play from the same redacted
|
||||
view a human seat gets and play by the rules the engine enforces on
|
||||
everyone; a tier changes what they draw and know.</p>
|
||||
<p>A turn need not be taken at once. Games wait on the lobby ledger
|
||||
for days; the browser holds your seat; a replay of any game can be
|
||||
watched from above or through a wizard's eyes and shared by link.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === "rules"}
|
||||
<p class="colophon">
|
||||
Sixth edition rules, from the original rulebook. © 1985 Jolly Games —
|
||||
this digital adaptation is a fan project.
|
||||
</p>
|
||||
{#each RULES_SECTIONS as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">The rulebook, verbatim</h2>
|
||||
<p class="colophon">
|
||||
The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT}
|
||||
</p>
|
||||
{#each RULEBOOK_BASE as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">Expansion Set 1 — verbatim</h2>
|
||||
{#each RULEBOOK_EXPANSION as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">Official FAQ — general rulings</h2>
|
||||
<p class="colophon">
|
||||
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings
|
||||
occasionally reference other editions. Card-specific rulings sit on the
|
||||
cards themselves — look for the FAQ seal.
|
||||
</p>
|
||||
{#each FAQ_GENERAL as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<div class="reading">
|
||||
<div class="search-row">
|
||||
<input bind:value={rulesSearch} placeholder="search the rules, the rulebook, the expansion, and the FAQ…" aria-label="search the rules" />
|
||||
</div>
|
||||
{#if rulesSearch.trim()}
|
||||
<p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
|
||||
{#each refResults as s (s.id)}
|
||||
<h3 id={s.id} class="linked">
|
||||
{s.title}
|
||||
<span class="source">{SOURCE_LABELS[s.source]}</span>
|
||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<nav class="contents" aria-label="contents">
|
||||
{#each refBySource as group (group.source)}
|
||||
<div class="contents-group">
|
||||
<span class="contents-head">{SOURCE_LABELS[group.source]}</span>
|
||||
{#each group.sections as s (s.id)}
|
||||
<button class="contents-link" onclick={() => jumpTo(s.id)}>{s.title}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
{#each refBySource as group (group.source)}
|
||||
<h2 class="divider" id="source-{group.source}">{SOURCE_LABELS[group.source]}</h2>
|
||||
{#if group.source === "summary"}
|
||||
<p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games — this digital adaptation is a fan project.</p>
|
||||
{:else if group.source === "rulebook"}
|
||||
<p class="colophon">The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT} Notes marked <span class="ed-note">(6E: …)</span> are this table's, on where the sixth edition differs.</p>
|
||||
{:else if group.source === "faq"}
|
||||
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
|
||||
{/if}
|
||||
{#each group.sections as s (s.id)}
|
||||
<h3 id={s.id} class="linked">
|
||||
{s.title}
|
||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="search-row">
|
||||
<input
|
||||
bind:value={search}
|
||||
placeholder="search {pool.length} cards…"
|
||||
aria-label="search cards"
|
||||
/>
|
||||
<input bind:value={cardText} placeholder="search {CARD_POOL.length} cards by name or text…" aria-label="search cards" />
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>set
|
||||
<select bind:value={cardSet}>
|
||||
<option value="all">all</option>
|
||||
<option value="basic">base deck</option>
|
||||
<option value="expansion1">expansion</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>type
|
||||
<select bind:value={cardType}>
|
||||
<option value="all">all</option>
|
||||
<option value="attack">attack</option>
|
||||
<option value="neutral">neutral</option>
|
||||
<option value="counteraction">counteraction</option>
|
||||
<option value="number">number</option>
|
||||
<option value="object">object</option>
|
||||
<option value="trap">trap</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>target
|
||||
<select bind:value={cardSight}>
|
||||
<option value="all">any</option>
|
||||
<option value="los">line of sight</option>
|
||||
<option value="adjacent">adjacent</option>
|
||||
<option value="none">no target</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="count">{cardResults.length === 0 ? "No cards found." : `${cardResults.length} card${cardResults.length === 1 ? "" : "s"}`}</span>
|
||||
</div>
|
||||
{#if cardResults.length === 0}
|
||||
<p class="colophon">No card matches {cardText.trim() ? `"${cardText.trim()}"` : "those filters"}. Try fewer words, or widen the filters.</p>
|
||||
{/if}
|
||||
<div class="card-grid">
|
||||
{#each filtered as def (def.id)}
|
||||
{#each cardResults as def (def.id)}
|
||||
<div class="card-slot">
|
||||
<Card
|
||||
card={{ instanceId: `lib-${def.id}`, cardId: def.id }}
|
||||
onclick={() => (libPeek = def.id)}
|
||||
onfaq={(id) => (faqCardId = id)}
|
||||
onclick={() => openEntry(def.id)}
|
||||
onfaq={() => openEntry(def.id)}
|
||||
/>
|
||||
<span class="card-meta">
|
||||
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
|
||||
@@ -340,7 +490,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tabs { display: flex; gap: 0.3rem; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.tabs button {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.72rem;
|
||||
@@ -368,6 +518,12 @@
|
||||
line-height: 1;
|
||||
}
|
||||
.close:hover { color: #b3372b; }
|
||||
/* A narrow screen wraps the tabs to their own rows under the title and the close. */
|
||||
@media (max-width: 640px) {
|
||||
.booklet-head { gap: 0.5rem 1rem; }
|
||||
.tabs { order: 3; flex-basis: 100%; }
|
||||
.close { order: 2; }
|
||||
}
|
||||
|
||||
.booklet-body {
|
||||
overflow-y: auto;
|
||||
@@ -376,7 +532,9 @@
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.faq-divider {
|
||||
/* Long reading gets a book's column: narrower, a touch larger. */
|
||||
.reading { max-width: 44rem; margin: 0 auto; font-size: 1.04rem; line-height: 1.55; }
|
||||
.divider {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.1em;
|
||||
@@ -385,7 +543,7 @@
|
||||
padding-top: 0.8rem;
|
||||
margin: 1.6rem 0 0.2rem;
|
||||
}
|
||||
.booklet-body h3 {
|
||||
.booklet-body h3, .entry-text h3 {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
@@ -394,18 +552,63 @@
|
||||
padding-bottom: 0.15rem;
|
||||
margin: 1.2rem 0 0.4rem;
|
||||
}
|
||||
.booklet-body h3:first-child { margin-top: 0; }
|
||||
.revisions { margin: 0.4rem 0 1rem; }
|
||||
.revisions dt { font-family: "Oswald", sans-serif; letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.75rem; margin-top: 0.6rem; }
|
||||
.revisions dd { margin: 0.1rem 0 0; }
|
||||
.revisions dd::first-letter { text-transform: uppercase; }
|
||||
.ruling { margin: 0.3rem 0; }
|
||||
.ruling summary { cursor: pointer; font-weight: 600; }
|
||||
.ruling p { margin: 0.3rem 0 0.5rem 1rem; }
|
||||
.reading > h3:first-child, .entry-text > h3:first-child { margin-top: 0; }
|
||||
h3.linked { display: flex; align-items: baseline; gap: 0.5rem; scroll-margin-top: 0.5rem; }
|
||||
.source { font-size: 0.65rem; letter-spacing: 0.08em; color: #8a7a5e; border: 1px solid #c9bd9f; border-radius: 3px; padding: 0 0.3rem; text-transform: none; }
|
||||
.link-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: #8a7a5e;
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.35rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
h3:hover .link-btn, .link-btn:focus-visible, .entry-head .link-btn { opacity: 1; }
|
||||
.link-btn:hover { border-color: #b3a687; color: #43331f; }
|
||||
.booklet-body p { margin: 0.35rem 0; }
|
||||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; }
|
||||
.booklet-body a { color: #8a4a1f; text-decoration-style: dotted; }
|
||||
.booklet-body a:hover { color: #43331f; }
|
||||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.88rem; }
|
||||
.count { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; }
|
||||
.booklet-body a, .inline-link { color: #8a4a1f; text-decoration: underline; text-decoration-style: dotted; }
|
||||
.booklet-body a:hover, .inline-link:hover { color: #43331f; }
|
||||
.inline-link { background: none; border: none; padding: 0; font: inherit; cursor: pointer; }
|
||||
.booklet-body :global(.ed-note) {
|
||||
font-style: italic;
|
||||
color: #5e4d33;
|
||||
background: rgba(179, 166, 135, 0.22);
|
||||
border-left: 2px solid #b3a687;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.contents { columns: 2; column-gap: 2rem; margin: 0.6rem 0 0.4rem; font-size: 0.92rem; }
|
||||
.contents-group { break-inside: avoid; margin-bottom: 0.7rem; }
|
||||
.contents-head { display: block; font-family: "Oswald", sans-serif; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a7a5e; margin-bottom: 0.15rem; }
|
||||
.contents-link { display: block; background: none; border: none; padding: 0.05rem 0; font: inherit; color: #8a4a1f; cursor: pointer; text-align: left; }
|
||||
.contents-link:hover { color: #43331f; text-decoration: underline; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.chip {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: #e4dbc2;
|
||||
border: 1px solid #b3a687;
|
||||
border-radius: 3px;
|
||||
color: #43331f;
|
||||
padding: 0.15rem 0.45rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover { background: #d8cdae; }
|
||||
|
||||
.stories { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0 1rem; }
|
||||
.story { display: flex; flex-direction: column; align-items: center; text-align: center; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.6rem 0.5rem; font-size: 0.9rem; }
|
||||
.big { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.7rem; color: #b3372b; line-height: 1.1; }
|
||||
.tally-list { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.8rem; margin: 0.6rem 0 1rem; }
|
||||
.tally-list dt {
|
||||
font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem;
|
||||
@@ -413,6 +616,14 @@
|
||||
}
|
||||
.tally-list dd { margin: 0; align-self: center; }
|
||||
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0; }
|
||||
.tile { display: flex; flex-direction: column; gap: 0.25rem; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.5rem; text-decoration: none; color: inherit; }
|
||||
.tile:hover { background: #d8cdae; }
|
||||
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
|
||||
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.tile-note { font-size: 0.85rem; color: #6b5a41; }
|
||||
.tile-link { text-decoration: underline; text-decoration-style: dotted; }
|
||||
|
||||
.search-row { margin-bottom: 0.8rem; }
|
||||
.search-row input {
|
||||
width: 100%;
|
||||
@@ -425,6 +636,9 @@
|
||||
font-size: 0.95rem;
|
||||
color: #43331f;
|
||||
}
|
||||
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; margin: -0.3rem 0 0.8rem; font-family: "Oswald", sans-serif; font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase; color: #6b5a41; }
|
||||
.filters select { font-family: "Archivo Narrow", sans-serif; font-size: 0.9rem; text-transform: none; letter-spacing: 0; margin-left: 0.3rem; background: #f6f0df; border: 1px solid #b3a687; border-radius: 3px; color: #43331f; padding: 0.15rem 0.3rem; }
|
||||
.filters .count { margin-left: auto; text-transform: none; letter-spacing: 0; }
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
|
||||
@@ -432,25 +646,51 @@
|
||||
justify-items: center;
|
||||
}
|
||||
.card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
|
||||
.card-slot :global(.card) { cursor: default; }
|
||||
.card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
|
||||
.lib-peek-scrim {
|
||||
.card-slot :global(.card:hover) { transform: translateY(-0.2rem); }
|
||||
.card-meta {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.68rem;
|
||||
color: #6b5a41;
|
||||
}
|
||||
|
||||
/* The card entry: the familiar card beside reading-size text. */
|
||||
.layer-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 12, 16, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 55;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
.lib-peek :global(.card) {
|
||||
transform: scale(2.1);
|
||||
cursor: default;
|
||||
.entry {
|
||||
background: #efe8d4;
|
||||
color: #43331f;
|
||||
width: min(46rem, 100%);
|
||||
max-height: calc(100vh - 3rem);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b3a687;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lib-peek :global(.card:hover) { transform: scale(2.1); }
|
||||
.card-meta {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.68rem;
|
||||
color: #6b5a41;
|
||||
.entry-head { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 1rem; border-bottom: 2px solid #43331f; }
|
||||
.entry-title { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.entry-head .link-btn { margin-left: 0; }
|
||||
.entry-body { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.2rem; padding: 1rem 1.2rem 1.2rem; overflow-y: auto; font-family: "Archivo Narrow", sans-serif; }
|
||||
.entry-card { align-self: start; position: sticky; top: 0; }
|
||||
.entry-card :global(.card) { transform: scale(1.35); transform-origin: top left; margin: 0 2.9rem 4rem 0; cursor: default; }
|
||||
.entry-card :global(.card:hover) { transform: scale(1.35); box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
|
||||
.entry-text { min-width: 0; }
|
||||
.entry-text p { margin: 0.35rem 0; }
|
||||
.entry-meta { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; margin: 0 0 0.4rem; }
|
||||
.printed { font-size: 1.05rem; }
|
||||
@media (max-width: 640px) {
|
||||
.entry-body { grid-template-columns: 1fr; }
|
||||
.entry-card { position: static; display: flex; justify-content: center; }
|
||||
.entry-card :global(.card) { transform: none; margin: 0; }
|
||||
.entry-card :global(.card:hover) { transform: none; }
|
||||
.contents { columns: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
stroke: #9d8cc1;
|
||||
stroke-width: 0.4;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
animation: shimmer-drift 1.4s ease-in-out infinite;
|
||||
}
|
||||
.illusion-art { pointer-events: none; }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Every live event batch plays through the same fx pipeline the
|
||||
// instant replay uses, so spells, doors, and conjurations perform
|
||||
// here first.
|
||||
import { motion } from "./motion";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import type { FpvTarget } from "./fpv/raycast";
|
||||
import { objectArt, tokenArt } from "./art";
|
||||
@@ -161,6 +162,8 @@
|
||||
* still fire — your own magic always turns your head. */
|
||||
let manualUntil = 0;
|
||||
let turning = false;
|
||||
let turnRaf = 0;
|
||||
onDestroy(() => cancelAnimationFrame(turnRaf));
|
||||
|
||||
/** Manual steering holds the director's idle aims off this long. */
|
||||
const STEER_HOLD_MS = 4000;
|
||||
@@ -173,14 +176,15 @@
|
||||
manualUntil = performance.now() + STEER_HOLD_MS;
|
||||
const from = cam.facing;
|
||||
const to = from + dir * (Math.PI / 2);
|
||||
if (motion.reduced) { cam.facing = to; turning = false; return; }
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const w = Math.min(1, (now - t0) / 180);
|
||||
cam.facing = from + (to - from) * (smoothstep(w));
|
||||
if (w < 1) requestAnimationFrame(tick);
|
||||
if (w < 1) turnRaf = requestAnimationFrame(tick);
|
||||
else turning = false;
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
turnRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
/** Pane clicks: a possessed mount claims them first — adjacent floor
|
||||
* steps it, a same-square enemy takes its blow — and only the rider's
|
||||
@@ -225,14 +229,23 @@
|
||||
let actorPos = $state<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// --- The fx: each batch plays once, on its own beat. -----------------
|
||||
// Track ONLY the batch: the server sends `events` then `state` back to
|
||||
// Track the batch and motion policy: the server sends `events` then `state` back to
|
||||
// back, so a wider dependency set re-runs this within the same frame,
|
||||
// before a zero-delay timer can fire. No per-run cleanup for the same
|
||||
// reason — timers die only with the component.
|
||||
// reason — timers are cleared when effects are disabled or the pane closes.
|
||||
let playedBatch = 0;
|
||||
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
$effect(() => {
|
||||
const enabled = motion.effects;
|
||||
const b = batch;
|
||||
if (!enabled) {
|
||||
fxTimers.forEach(clearTimeout);
|
||||
fxTimers.clear();
|
||||
fpFx = [];
|
||||
// Consume suppressed batches: enabling effects must not replay them.
|
||||
if (b) playedBatch = b.n;
|
||||
return;
|
||||
}
|
||||
if (!b) return;
|
||||
untrack(() => {
|
||||
if (b.n === playedBatch || !me) return;
|
||||
@@ -240,6 +253,7 @@
|
||||
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
|
||||
const starter = setTimeout(() => {
|
||||
fxTimers.delete(starter);
|
||||
if (!motion.effects) return;
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
const ender = setTimeout(() => {
|
||||
fxTimers.delete(ender);
|
||||
@@ -251,7 +265,7 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
onDestroy(() => fxTimers.forEach(clearTimeout));
|
||||
onDestroy(() => { fxTimers.forEach(clearTimeout); fxTimers.clear(); });
|
||||
|
||||
// --- Other bodies glide between views. -------------------------------
|
||||
let prevView: GameView | null = null;
|
||||
@@ -259,6 +273,7 @@
|
||||
const v = view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (motion.reduced) { actorPos = {}; return; }
|
||||
if (!before || before === v || !me) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
@@ -283,6 +298,7 @@
|
||||
// --- The camera: the director's ladder, live. ------------------------
|
||||
let directedBatch = 0;
|
||||
$effect(() => {
|
||||
const reduced = motion.reduced;
|
||||
const b = batch;
|
||||
const m = me;
|
||||
if (!m) { camReady = false; return; }
|
||||
@@ -385,7 +401,9 @@
|
||||
// pointed your own head is where it stays, brick or no brick.
|
||||
targetFacing = deepestFacing(v, tx, ty);
|
||||
}
|
||||
if (willCut) {
|
||||
// Under reduced motion every move is a cut: the director still picks
|
||||
// where to look, and the eye lands there without the pan or the walk.
|
||||
if (willCut || reduced) {
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
|
||||
camReady = true;
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { motion } from "./motion";
|
||||
import Board from "./Board.svelte";
|
||||
import { wizardColor } from "./colors";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { humanize, spellName } from "./net.svelte";
|
||||
@@ -8,8 +10,7 @@
|
||||
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay, edgeMid } from "./fpv/raycast";
|
||||
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
|
||||
import { cardDef, isPermanentDuration, stackSightTrace, castSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
@@ -19,6 +20,7 @@
|
||||
pov = null,
|
||||
onshare = null,
|
||||
endLabel = null,
|
||||
startAt = 0,
|
||||
}: {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||
onclose: () => void;
|
||||
@@ -32,6 +34,8 @@
|
||||
onshare?: (() => Promise<string>) | null;
|
||||
/** What the leave button says once the reel has run out. */
|
||||
endLabel?: string | null;
|
||||
/** The step the reel opens on. */
|
||||
startAt?: number;
|
||||
} = $props();
|
||||
|
||||
/** The share button's little life: offer, mint, report. */
|
||||
@@ -49,11 +53,24 @@
|
||||
setTimeout(() => (shareState = "idle"), 4000);
|
||||
}
|
||||
|
||||
let idx = $state(0);
|
||||
let idx = $state(untrack(() => Math.min(Math.max(0, startAt), Math.max(0, steps.length - 1))));
|
||||
/** The timeline: one tick a step, a turn's first step marked, and the
|
||||
* steps where blows landed or gold changed hands flagged, so a reel of
|
||||
* a hundred moves can be read at a glance and jumped into. */
|
||||
const ticks = $derived(steps.map((s, i) => {
|
||||
let flag: "turn" | "hit" | "gold" | "end" | null = null;
|
||||
for (const e of s.events) {
|
||||
if (e.type === "gameWon" || e.type === "playerEliminated") { flag = "end"; break; }
|
||||
if (e.type === "treasurePickedUp" || (e.type === "treasureDropped" && e.onHomeOf != null)) flag = "gold";
|
||||
else if (!flag && (e.type === "damaged" || e.type === "attackResolved" || e.type === "punched")) flag = "hit";
|
||||
else if (!flag && e.type === "turnStarted") flag = "turn";
|
||||
}
|
||||
return { i, actor: s.actor, flag, color: wizardColor(s.view, s.actor) };
|
||||
}));
|
||||
let playing = $state(true);
|
||||
let speed = $state(1);
|
||||
/** Watch the board from above, or relive it through your own eyes. */
|
||||
let fp = $state(moment);
|
||||
let fp = $state(untrack(() => moment));
|
||||
|
||||
/** Whose eyes the first-person camera wears: the wizard whose turn it
|
||||
* is, ALWAYS — their position seen with the viewer's knowledge of the
|
||||
@@ -90,12 +107,12 @@
|
||||
const dieOf = (st: { events: GameEvent[] } | undefined) =>
|
||||
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
|
||||
/** How long this step's interlude holds the world before it moves. */
|
||||
const dieMs = $derived(dieOf(step) && prefs.flourishes ? DIE_MS / speed : 0);
|
||||
const dieMs = $derived(dieOf(step) && motion.effects ? DIE_MS / speed : 0);
|
||||
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
|
||||
$effect(() => {
|
||||
const i = Math.min(idx, steps.length - 1);
|
||||
const d = dieOf(steps[i]);
|
||||
if (!d || !prefs.flourishes) { die = null; return; }
|
||||
if (!d || !motion.effects) { die = null; return; }
|
||||
const t0 = performance.now();
|
||||
const dur = DIE_MS / speed;
|
||||
const who = d.player ?? "The maze";
|
||||
@@ -169,6 +186,7 @@
|
||||
$effect(() => {
|
||||
const who = povId;
|
||||
if (!fp) { lastPov = null; return; }
|
||||
if (!motion.effects) { lastPov = who; return; }
|
||||
if (lastPov !== null && lastPov !== who) {
|
||||
const p = step.view.players.find((x) => x.id === who);
|
||||
const fx: FpFx = {
|
||||
@@ -179,14 +197,26 @@
|
||||
fpFx = [...untrack(() => fpFx), fx];
|
||||
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
|
||||
lastPov = who;
|
||||
return () => clearTimeout(t);
|
||||
return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
|
||||
}
|
||||
lastPov = who;
|
||||
});
|
||||
|
||||
// The reel draws the same sight line the live table shows for an LOS
|
||||
// attack in progress, so a replay-watcher can see how a spell reached them.
|
||||
const sightTrace = $derived(stackSightTrace(step.view));
|
||||
/** The attack's line, or the line a cast in this step was accepted on —
|
||||
* traced after the fact, so the cast's own dust cloud does not blind it. */
|
||||
const sightTrace = $derived.by(() => {
|
||||
const onStack = stackSightTrace(step.view);
|
||||
if (onStack) return onStack;
|
||||
for (let i = step.events.length - 1; i >= 0; i--) {
|
||||
const e = step.events[i]!;
|
||||
if (e.type !== "spellCast") continue;
|
||||
const line = castSightTrace(step.view, e, step.events, true);
|
||||
if (line) return line;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/** Every wall destroyed so far in the reel leaves a mound of rubble on
|
||||
* the first-person floor for the rest of it. */
|
||||
@@ -210,7 +240,7 @@
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
$effect(() => {
|
||||
const step = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!step || !prefs.flourishes) return;
|
||||
if (!step || !motion.effects) return;
|
||||
let cancel = () => {};
|
||||
const wait = setTimeout(() => {
|
||||
cancel = scheduleFx(
|
||||
@@ -237,7 +267,7 @@
|
||||
$effect(() => {
|
||||
const i = Math.min(idx, steps.length - 1);
|
||||
const st = steps[i];
|
||||
if (!fp || !st || !prefs.flourishes) return;
|
||||
if (!fp || !st || !motion.effects) return;
|
||||
const plan = camPlan;
|
||||
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
@@ -280,6 +310,7 @@
|
||||
const v = st.view;
|
||||
const before = prevView;
|
||||
prevView = v;
|
||||
if (motion.reduced) { actorPos = {}; return; }
|
||||
if (!before || before === v) return;
|
||||
const moves = gatherGlides(before, v, povId);
|
||||
if (moves.length === 0) { actorPos = {}; return; }
|
||||
@@ -316,6 +347,7 @@
|
||||
"treasurePickedUp", "treasureDropped", "objectDropped"]);
|
||||
let camReady = false;
|
||||
$effect(() => {
|
||||
const reduced = motion.reduced;
|
||||
if (!fp) { camReady = false; return; }
|
||||
const v = step.view;
|
||||
const me = v.players.find((p) => p.id === povId);
|
||||
@@ -360,6 +392,7 @@
|
||||
cutawayShot = true;
|
||||
const i2 = Math.min(idx, steps.length - 1);
|
||||
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
|
||||
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
|
||||
heldView = pv;
|
||||
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
|
||||
if (pv) {
|
||||
@@ -483,6 +516,13 @@
|
||||
}
|
||||
return () => {};
|
||||
};
|
||||
if (reduced) {
|
||||
cam.x = tx; cam.y = ty; cam.facing = targetFacing + closingArc; cam.pitch = targetPitch;
|
||||
camReady = true;
|
||||
heldView = null;
|
||||
camPlan = { idx: stepIdx, holdMs: 0 };
|
||||
return;
|
||||
}
|
||||
if (willCut) {
|
||||
// First frame, or a leap the legs cannot explain: cut. An aimed
|
||||
// cut still holds a beat of the before-world once the reel is
|
||||
@@ -725,6 +765,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if steps.length > 3}
|
||||
<div class="timeline" role="group" aria-label="the moves, a tick each">
|
||||
{#each ticks as t (t.i)}
|
||||
<button class="tick" class:current={t.i === idx} class:turn={t.flag === "turn"} class:hit={t.flag === "hit"} class:gold={t.flag === "gold"} class:end={t.flag === "end"}
|
||||
style:--who={t.color} title={`move ${t.i + 1} — ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
|
||||
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="replay-controls">
|
||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
|
||||
@@ -749,10 +798,12 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
.replay {
|
||||
box-sizing: border-box;
|
||||
background: #171a20;
|
||||
border: 1px solid rgba(233, 225, 203, 0.25);
|
||||
border-radius: 8px;
|
||||
width: min(46rem, 100%);
|
||||
max-width: 100%;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -908,6 +959,29 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 14px;
|
||||
margin: 0.4rem 0.2rem 0.2rem;
|
||||
}
|
||||
.tick {
|
||||
flex: 1 1 0;
|
||||
min-width: 2px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 1px;
|
||||
background: var(--who, #8d8672);
|
||||
opacity: 0.45;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tick.turn { height: 9px; opacity: 0.7; }
|
||||
.tick.hit { height: 12px; opacity: 0.9; }
|
||||
.tick.gold { height: 14px; opacity: 1; box-shadow: 0 0 0 1px #e0b34a; }
|
||||
.tick.end { height: 14px; opacity: 1; background: #e9e1cb; }
|
||||
.tick.current { opacity: 1; outline: 1px solid #fff; outline-offset: 1px; }
|
||||
.replay-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -925,4 +999,18 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.playpause { min-width: 3.4rem; }
|
||||
/* A phone: the title takes its own line, the count and the buttons
|
||||
share the next, the caption reads smaller, and the controls fit. */
|
||||
@media (max-width: 640px) {
|
||||
.replay { padding: 0.6rem 0.6rem 0.8rem; }
|
||||
.replay-head { flex-wrap: wrap; gap: 0.4rem 0.6rem; }
|
||||
.replay-title { flex-basis: 100%; font-size: 0.72rem; letter-spacing: 0.1em; }
|
||||
.replay-count { font-size: 0.72rem; }
|
||||
.replay-eyes { white-space: nowrap; }
|
||||
.replay-caption { font-size: 0.72rem; padding: 0.45rem 0.55rem; gap: 0.45rem; }
|
||||
.caption-face { width: 36px; height: 36px; }
|
||||
.caption-status { max-width: 45%; }
|
||||
.replay-controls { gap: 0.35rem; flex-wrap: wrap; }
|
||||
.replay-controls button { min-width: 2.3rem; padding: 0.3rem 0.45rem; font-size: 0.85rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
actor: string;
|
||||
round: number;
|
||||
whole?: boolean;
|
||||
/** The turn's deed in one line, as the share card says it. */
|
||||
headline?: string | null;
|
||||
}
|
||||
let data = $state<ShareSteps | null>(null);
|
||||
let failed = $state(false);
|
||||
@@ -32,7 +34,14 @@
|
||||
<a class="share-brand" href="/">WIZ-WAR</a>
|
||||
{#if data}
|
||||
<div class="share-title">
|
||||
{#if data.whole}The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}{:else}Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}{/if}
|
||||
{#if data.whole}
|
||||
<span class="share-deed">The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}</span>
|
||||
{:else if data.headline}
|
||||
<span class="share-deed">{data.headline}</span>
|
||||
<span class="share-sub">an instant replay of {data.actor}'s turn{data.round ? `, round ${data.round}` : ""}</span>
|
||||
{:else}
|
||||
<span class="share-deed">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -51,6 +60,9 @@
|
||||
onclose={() => (reelKey += 1)} />
|
||||
{/key}
|
||||
</div>
|
||||
<div class="share-play">
|
||||
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="share-foot">
|
||||
@@ -61,12 +73,15 @@
|
||||
transcribed, every wall verified, replays rebuilt move by move from
|
||||
the game's own ledger.
|
||||
</p>
|
||||
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.share-page {
|
||||
/* Safari's text inflation has no business here: the page is laid
|
||||
out for the phone already. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
min-height: 100vh;
|
||||
background: #171a20;
|
||||
color: #d8d2c0;
|
||||
@@ -79,6 +94,7 @@
|
||||
.share-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
width: min(46rem, 100%);
|
||||
margin-bottom: 0.6rem;
|
||||
@@ -97,6 +113,9 @@
|
||||
font-size: 0.85rem;
|
||||
color: #e9e1cb;
|
||||
}
|
||||
.share-deed { display: block; }
|
||||
.share-sub { display: block; text-transform: none; letter-spacing: 0.02em; color: #8d8672; font-size: 0.8rem; }
|
||||
.share-play { width: min(46rem, 100%); margin-top: 0.8rem; text-align: center; }
|
||||
/* The reel is a modal everywhere else; here it stands in the page. */
|
||||
.share-stage { width: min(46rem, 100%); }
|
||||
.share-stage :global(.replay-scrim) {
|
||||
|
||||
@@ -9,23 +9,23 @@ export const edgeWisps = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
|
||||
return [0.06, 0.38, 0.71].map((t, i) => {
|
||||
const sign = i === 1 ? -1 : 1;
|
||||
return `M ${p(t)} C ${p(t+.07,-3*sign)} ${p(t+.2,-2.5*sign)} ${p(t+.23,.5*sign)} Q ${p(t+.18,-.6*sign)} ${p(t+.15,1.2*sign)} Q ${p(t+.12,-1.6*sign)} ${p(t)}`;
|
||||
}).join(' ');
|
||||
return `M ${p(t)} C ${p(t+0.07,-3*sign)} ${p(t+0.2,-2.5*sign)} ${p(t+0.23,0.5*sign)} Q ${p(t+0.18,-0.6*sign)} ${p(t+0.15,1.2*sign)} Q ${p(t+0.12,-1.6*sign)} ${p(t)}`;
|
||||
}).join(" ");
|
||||
};
|
||||
|
||||
/** An irregular fracture with branches that become longer as damage increases. */
|
||||
export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => {
|
||||
const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4;
|
||||
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
|
||||
return `M ${p(.03)} L ${p(.18,-width)} ${p(.34,.6*width)} ${p(.51,-.5*width)} ${p(.7,width)} ${p(.86,-.6*width)} ${p(.98)} M ${p(.34,.6*width)} L ${p(.4,1.25*width)} ${p(.47,1.4*width)} M ${p(.7,width)} L ${p(.64,-1.1*width)} ${p(.57,-1.35*width)}`;
|
||||
return `M ${p(0.03)} L ${p(0.18,-width)} ${p(0.34,0.6*width)} ${p(0.51,-0.5*width)} ${p(0.7,width)} ${p(0.86,-0.6*width)} ${p(0.98)} M ${p(0.34,0.6*width)} L ${p(0.4,1.25*width)} ${p(0.47,1.4*width)} M ${p(0.7,width)} L ${p(0.64,-1.1*width)} ${p(0.57,-1.35*width)}`;
|
||||
};
|
||||
|
||||
/** Silk spokes and two scalloped cross-threads, with a clear center for the token. */
|
||||
export const webThreads = (r: number) => {
|
||||
const p = (x: number, y: number) => `${x*r} ${y*r}`;
|
||||
return `M ${p(-1,-.6)} L ${p(1,.65)} M ${p(-.75,1)} L ${p(.65,-1)} M ${p(-1,.6)} L ${p(1,-.4)} M ${p(-.15,-1)} L ${p(.18,1)} M ${p(-.75,-.45)} Q ${p(-.15,-.2)} ${p(.4,-.62)} Q ${p(.3,-.15)} ${p(.72,-.28)} M ${p(-.65,.39)} Q ${p(-.25,.1)} ${p(-.47,.63)} Q ${p(0,.4)} ${p(.12,.7)} Q ${p(.35,.35)} ${p(.72,.46)}`;
|
||||
return `M ${p(-1,-0.6)} L ${p(1,0.65)} M ${p(-0.75,1)} L ${p(0.65,-1)} M ${p(-1,0.6)} L ${p(1,-0.4)} M ${p(-0.15,-1)} L ${p(0.18,1)} M ${p(-0.75,-0.45)} Q ${p(-0.15,-0.2)} ${p(0.4,-0.62)} Q ${p(0.3,-0.15)} ${p(0.72,-0.28)} M ${p(-0.65,0.39)} Q ${p(-0.25,0.1)} ${p(-0.47,0.63)} Q ${p(0,0.4)} ${p(0.12,0.7)} Q ${p(0.35,0.35)} ${p(0.72,0.46)}`;
|
||||
};
|
||||
|
||||
/** Open painted curls around a warp's precise center. */
|
||||
export const warpCurls = (x: number, y: number, r: number) =>
|
||||
`M ${x-r} ${y+r*.15} C ${x-r*1.1} ${y-r*.75} ${x+r*.4} ${y-r*1.2} ${x+r*.72} ${y-r*.4} Q ${x+r*.05} ${y-r*.85} ${x-r*.58} ${y-r*.25} M ${x+r} ${y-r*.1} C ${x+r*.95} ${y+r*.8} ${x-r*.3} ${y+r*1.18} ${x-r*.7} ${y+r*.45} Q ${x-r*.1} ${y+r*.85} ${x+r*.6} ${y+r*.25}`;
|
||||
`M ${x-r} ${y+r*0.15} C ${x-r*1.1} ${y-r*0.75} ${x+r*0.4} ${y-r*1.2} ${x+r*0.72} ${y-r*0.4} Q ${x+r*0.05} ${y-r*0.85} ${x-r*0.58} ${y-r*0.25} M ${x+r} ${y-r*0.1} C ${x+r*0.95} ${y+r*0.8} ${x-r*0.3} ${y+r*1.18} ${x-r*0.7} ${y+r*0.45} Q ${x-r*0.1} ${y+r*0.85} ${x+r*0.6} ${y+r*0.25}`;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// What happened while you were away, as a few facts before the reel:
|
||||
// read from the missed steps as the server redacted them for you, so it
|
||||
// tells you only what you would have seen at the table.
|
||||
|
||||
import { cardDef, type GameEvent, type GameView } from "@wizwar/engine";
|
||||
|
||||
export interface MissedStep {
|
||||
seq: number;
|
||||
actor: string;
|
||||
events: GameEvent[];
|
||||
view: GameView;
|
||||
chat?: { player: string; text: string }[];
|
||||
}
|
||||
|
||||
export interface MissedFact {
|
||||
text: string;
|
||||
/** The step of the reel this fact belongs to. */
|
||||
step: number;
|
||||
}
|
||||
|
||||
function spell(id: string | null): string {
|
||||
if (!id) return "a punch";
|
||||
try { return cardDef(id).name; } catch { return id; }
|
||||
}
|
||||
|
||||
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
|
||||
const me = view.players.find((p) => p.id === you);
|
||||
if (!me) return false;
|
||||
return Math.abs(me.home.x - cell.x) + Math.abs(me.home.y - cell.y) <= 2;
|
||||
}
|
||||
|
||||
/** The facts of the missed steps that touch you, in order, capped so the
|
||||
* slip stays a slip. Each fact points at its step for the reel. */
|
||||
export function summarizeMissed(steps: readonly MissedStep[], you: string, now: GameView | null): MissedFact[] {
|
||||
const facts: MissedFact[] = [];
|
||||
let lifeLost = 0;
|
||||
let lifeLostStep = -1;
|
||||
let talk = 0;
|
||||
steps.forEach((s, i) => {
|
||||
talk += s.chat?.length ?? 0;
|
||||
for (const e of s.events) {
|
||||
switch (e.type) {
|
||||
case "treasurePickedUp":
|
||||
if (e.owner === you && e.player !== you) facts.push({ text: `${e.player} took your treasure.`, step: i });
|
||||
else if (e.player !== you && e.owner !== e.player) facts.push({ text: `${e.player} picked up ${e.owner}'s treasure.`, step: i });
|
||||
break;
|
||||
case "treasureDropped": {
|
||||
const owner = s.view.treasures.find((t) => t.id === e.treasureId)?.owner;
|
||||
if (e.onHomeOf != null && owner && e.onHomeOf !== owner) {
|
||||
facts.push({ text: owner === you ? `Your treasure was carried home by ${e.player}.` : `${e.player} carried ${owner}'s treasure home.`, step: i });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "damaged":
|
||||
if (e.player === you && e.amount > 0) { lifeLost += e.amount; if (lifeLostStep < 0) lifeLostStep = i; }
|
||||
break;
|
||||
case "spellCast":
|
||||
if (e.target === you) facts.push({ text: `${e.caster} cast ${spell(e.cardId)} at you.`, step: i });
|
||||
break;
|
||||
case "punched":
|
||||
if (e.target === you) facts.push({ text: `${e.attacker} punched you.`, step: i });
|
||||
break;
|
||||
case "wallDestroyed":
|
||||
if (nearHome(s.view, you, e.edge.cell)) facts.push({ text: "A wall near your home was destroyed.", step: i });
|
||||
break;
|
||||
case "playerEliminated":
|
||||
facts.push({ text: e.player === you ? "You were eliminated." : `${e.player} is out of the game.`, step: i });
|
||||
break;
|
||||
case "gameWon":
|
||||
facts.push({ text: e.player === you ? "You won." : `${e.player} won the game.`, step: i });
|
||||
break;
|
||||
case "tableTalk":
|
||||
talk++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
|
||||
if (facts.length === 0) {
|
||||
// Nothing touched you: say what each wizard was up to, all the same.
|
||||
const doings = new Map<string, { moves: number; casts: number; first: number }>();
|
||||
steps.forEach((s, i) => {
|
||||
if (s.actor === you) return;
|
||||
const d = doings.get(s.actor) ?? { moves: 0, casts: 0, first: i };
|
||||
for (const e of s.events) {
|
||||
if (e.type === "moved" && e.player === s.actor) d.moves++;
|
||||
if (e.type === "spellCast" && e.caster === s.actor) d.casts++;
|
||||
}
|
||||
doings.set(s.actor, d);
|
||||
});
|
||||
for (const [who, d] of doings) {
|
||||
const bits = [d.moves > 0 ? `walked ${d.moves} square${d.moves === 1 ? "" : "s"}` : "", d.casts > 0 ? `cast ${d.casts} spell${d.casts === 1 ? "" : "s"}` : ""].filter(Boolean);
|
||||
facts.push({ text: `${who} ${bits.length ? bits.join(" and ") : "passed the time"}; nothing touched you.`, step: d.first });
|
||||
}
|
||||
}
|
||||
if (talk > 0) facts.push({ text: `${talk} line${talk === 1 ? "" : "s"} of table talk.`, step: -1 });
|
||||
const out = facts.slice(0, 6);
|
||||
if (facts.length > 6) out.push({ text: `…and ${facts.length - 6} more.`, step: -1 });
|
||||
if (now) {
|
||||
const me = now.players.find((p) => p.id === you);
|
||||
if (now.phase === "playing" && me?.alive) {
|
||||
const mine = now.activePlayerId === you || now.stack?.waitingOn === you || now.pendingDiscard === you;
|
||||
out.push({ text: mine ? "It's your turn." : `It's ${now.activePlayerId}'s turn.`, step: -1 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
// GameView. Columns of wall shaded by distance and facing; token art
|
||||
// billboarded for whatever stands in the corridors, occluded per column
|
||||
// by the same depth buffer the walls wrote.
|
||||
import { motion } from "../motion";
|
||||
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
|
||||
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx";
|
||||
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing as floorRing } from "./paintedFx";
|
||||
import { materialTextures } from "./textures";
|
||||
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
|
||||
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||
@@ -197,6 +198,10 @@
|
||||
}
|
||||
|
||||
function draw(time: number) {
|
||||
// With effects off the frame still draws — walls, bodies, hazards —
|
||||
// but nothing moves: no fx, and the ambient pulses freeze at t=0.
|
||||
const activeFx = motion.effects ? fx : [];
|
||||
const ambientTime = motion.effects ? time : 0;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
|
||||
@@ -207,7 +212,7 @@
|
||||
|
||||
// Active shakes wobble the eye a hair off true, fading as they run.
|
||||
let ex = x, ey = y;
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind !== "shake") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -221,7 +226,7 @@
|
||||
let growing: Record<string, number> | undefined;
|
||||
const spawn: Record<string, number> = {};
|
||||
const surging: Record<number, number> = {};
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind === "door") {
|
||||
const a = doorOpenness((time - f.t0) / f.dur);
|
||||
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
|
||||
@@ -321,7 +326,7 @@
|
||||
// FEAR's aura stains the forbidden ground itself, as the board draws
|
||||
// its dashed diamond — the same warp-walking yardstick behind both.
|
||||
const dreadGrid = dreadGridOf(view);
|
||||
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(time / 400) : 0;
|
||||
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(ambientTime / 400) : 0;
|
||||
for (let row = 0; row < H; row++) {
|
||||
const below = row > half;
|
||||
const dz = below ? row - half : half - row;
|
||||
@@ -440,7 +445,7 @@
|
||||
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
|
||||
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
|
||||
if (hit.kind === "firewall") {
|
||||
const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.worldU * 17 + col * 0.15);
|
||||
const flicker = 0.15 + 0.15 * Math.sin(ambientTime / 90 + hit.worldU * 17 + col * 0.15);
|
||||
ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
dark *= 0.5; // the fire lights itself
|
||||
@@ -448,13 +453,13 @@
|
||||
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
||||
// The same tell the board gives: an untested illusion's face
|
||||
// shimmers faintly — maybe stone, maybe not.
|
||||
const tell = 0.06 + 0.05 * Math.sin(time / 260 + hit.worldU * 13);
|
||||
const tell = 0.06 + 0.05 * Math.sin(ambientTime / 260 + hit.worldU * 13);
|
||||
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
|
||||
// A jammed lock seethes: a rusty seal pulsing across the wood.
|
||||
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
|
||||
const seethe = 0.10 + 0.08 * Math.sin(ambientTime / 160 + hit.worldU * 22);
|
||||
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
|
||||
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
|
||||
}
|
||||
@@ -464,13 +469,13 @@
|
||||
}
|
||||
if (hit.warped) {
|
||||
// Seen through a warp: the far side swims in violet haze.
|
||||
const haze = 0.16 + 0.05 * Math.sin(time / 300 + col * 0.05);
|
||||
const haze = 0.16 + 0.05 * Math.sin(ambientTime / 300 + col * 0.05);
|
||||
ctx.fillStyle = `rgba(120,70,220,${haze})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
|
||||
// The raised edge-card's invitation: faces breathe gold.
|
||||
const offer = 0.10 + 0.05 * Math.sin(time / 420 + hit.worldU * 3);
|
||||
const offer = 0.10 + 0.05 * Math.sin(ambientTime / 420 + hit.worldU * 3);
|
||||
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
}
|
||||
@@ -487,7 +492,7 @@
|
||||
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
|
||||
if (s) sprites.push({ ...s, fallback: "rubble" });
|
||||
}
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind !== "projectile" && f.kind !== "impact") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -545,13 +550,13 @@
|
||||
const w = s.right - s.left;
|
||||
if (seen && s.dread && s.hit) {
|
||||
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155),
|
||||
"160,30,30", 0.45 + 0.25 * Math.sin(time / 300), 0.12);
|
||||
"160,30,30", 0.45 + 0.25 * Math.sin(ambientTime / 300), 0.12);
|
||||
}
|
||||
if (seen && ontarget && aimBeings && litCells && s.hit && s.cell &&
|
||||
litCells.has(`${s.cell.x},${s.cell.y}`) &&
|
||||
!(s.hit.kind === "player" && s.hit.id === view.you)) {
|
||||
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13),
|
||||
"232,160,60", 0.55 + 0.25 * Math.sin(time / 220), 0.16);
|
||||
"232,160,60", 0.55 + 0.25 * Math.sin(ambientTime / 220), 0.16);
|
||||
}
|
||||
// Painted art composites normally (inks stay true); light glows
|
||||
// additively. Either way translucency applies.
|
||||
@@ -607,7 +612,7 @@
|
||||
const vTop = half - vh / 2;
|
||||
const wt = textures.warp!;
|
||||
const s = surging[vl.warp] ?? 0;
|
||||
const swirl = 0.1 + 0.1 * Math.sin(time / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
|
||||
const swirl = 0.1 + 0.1 * Math.sin(ambientTime / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
|
||||
// The ripple: a ring of light widening from the veil's center over
|
||||
// the surge, one full sweep to the edges; ~a fifth of the veil wide.
|
||||
const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0;
|
||||
@@ -636,7 +641,7 @@
|
||||
const top0 = half + full / 2 - grown;
|
||||
const tex = textures[ri.kind] ?? textures.wall!;
|
||||
const swirl = (0.35 * (1 - ri.g) + 0.08) *
|
||||
(0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21));
|
||||
(0.7 + 0.3 * Math.sin(ambientTime / 90 + ri.worldU * 21));
|
||||
for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
|
||||
ctx.globalAlpha = 0.55 + 0.45 * ri.g;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width),
|
||||
@@ -674,7 +679,7 @@
|
||||
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
|
||||
const gTop = half - gHft / 2;
|
||||
const tex = textures.wall!;
|
||||
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01);
|
||||
const ripple = 0.10 + 0.07 * Math.sin(ambientTime / 200 + gh.worldU * 11 + gHft * 0.01);
|
||||
for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
|
||||
@@ -695,7 +700,7 @@
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Being hit is felt: a whole-screen wash that fades as it goes.
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind !== "flash") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -709,7 +714,7 @@
|
||||
// as it goes, and snaps back; one thrown at you swells from the
|
||||
// middle of the pane until it fills it. Either way the blow lands
|
||||
// with a comic starburst.
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind !== "fist") continue;
|
||||
const p = (time - f.t0) / f.dur;
|
||||
if (p < 0 || p >= 1) continue;
|
||||
@@ -720,7 +725,7 @@
|
||||
ctx.rotate(pose.rotation);
|
||||
paintFist(ctx, pose.size, f.swing);
|
||||
ctx.restore();
|
||||
if (pose.radius > H * 0.19 * 0.01) {
|
||||
if (pose.radius > 0.5) {
|
||||
ctx.save();
|
||||
ctx.translate(pose.impactX, pose.impactY);
|
||||
ctx.rotate(-0.14);
|
||||
@@ -731,7 +736,7 @@
|
||||
|
||||
// A change of eyes: the slate wipes across, names the wizard whose
|
||||
// sight this now is, and wipes away to reveal it.
|
||||
for (const f of fx) {
|
||||
for (const f of activeFx) {
|
||||
if (f.kind !== "slate") continue;
|
||||
const [l, r] = slateBand((time - f.t0) / f.dur);
|
||||
if (r <= l) continue;
|
||||
@@ -772,12 +777,6 @@
|
||||
if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half);
|
||||
}
|
||||
|
||||
/** An ellipse of light on the floor at a body's feet: `rgb` as "r,g,b",
|
||||
* the stroke at `alpha`, the fill fainter. */
|
||||
function floorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number,
|
||||
rgb: string, alpha: number, fill: number) {
|
||||
paintFloorRing(ctx, cx, cy, rx, ry, rgb, alpha, fill);
|
||||
}
|
||||
|
||||
/** Paint the hover cue for whatever stands under the crosshair. */
|
||||
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { motion } from "../motion";
|
||||
import { smoothstep } from "./fx3d";
|
||||
// The first-person workshop (/?fpv): a real dealt game, one wizard's
|
||||
// eyes, free-fly controls. No rules run here — the camera walks where
|
||||
@@ -145,7 +146,7 @@
|
||||
const tick = (now: number) => {
|
||||
if (!anim) startNext(now);
|
||||
if (anim) {
|
||||
const w = Math.min(1, (now - anim.t0) / anim.dur);
|
||||
const w = motion.reduced ? 1 : Math.min(1, (now - anim.t0) / anim.dur);
|
||||
if (anim.kind === "glide") {
|
||||
x = anim.fx + (anim.tx - anim.fx) * ease(w);
|
||||
y = anim.fy + (anim.ty - anim.fy) * ease(w);
|
||||
@@ -205,17 +206,17 @@
|
||||
// a refused move reports itself instead of a blank stage.
|
||||
const scriptName = q.get("script");
|
||||
const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
|
||||
let scriptSteps: ScreenplayStep[] = [];
|
||||
let scriptError: string | null = null;
|
||||
if (scriptName && !scriptPlay) {
|
||||
scriptError = `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}`;
|
||||
} else if (scriptPlay) {
|
||||
function loadScript(): { scriptSteps: ScreenplayStep[]; scriptError: string | null } {
|
||||
if (scriptName && !scriptPlay) {
|
||||
return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
|
||||
}
|
||||
try {
|
||||
scriptSteps = buildScreenplaySteps(scriptPlay);
|
||||
return { scriptSteps: scriptPlay ? buildScreenplaySteps(scriptPlay) : [], scriptError: null };
|
||||
} catch (e) {
|
||||
scriptError = e instanceof Error ? e.message : String(e);
|
||||
return { scriptSteps: [], scriptError: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
const { scriptSteps, scriptError } = loadScript();
|
||||
|
||||
// Minimap geometry (top-down, one small square per cell).
|
||||
const MM = 9;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// camera shakes for the raycaster to draw. What happens ACROSS the room
|
||||
// is watched; what happens to YOU is felt.
|
||||
|
||||
import { fallbackCels, paintCels } from "./paintedFx";
|
||||
import { fallbackCels, paintCels, smoothstep } from "./paintedFx";
|
||||
export { smoothstep };
|
||||
import { fxForEvents } from "../fx";
|
||||
import { CELL } from "../fx-sprites/geom";
|
||||
import { castRay, edgeMid, warpMotion } from "./raycast";
|
||||
@@ -55,9 +56,6 @@ export function slateBand(p: number): [number, number] {
|
||||
return [smoothstep((p - 0.68) / 0.32), 1];
|
||||
}
|
||||
|
||||
/** The one easing every tween here shares. */
|
||||
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
|
||||
|
||||
/** Eased growth 0..1 for a conjuration's rise. */
|
||||
export function growProgress(p: number): number {
|
||||
if (p <= 0) return 0.01;
|
||||
@@ -306,7 +304,7 @@ export function fpFxForEvents(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Inked stand-ins in each original palette, replaced when the PNG loads. */
|
||||
/** Core and rim colors for each conjuration's inked stand-in. */
|
||||
const FX_COLORS: Record<string, [string, string]> = {
|
||||
fireball: ["#ffe27a", "#e0431a"],
|
||||
bolt: ["#ffffff", "#7ab0ff"],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Inked canvas cels. Paths are shared by the renderer and artwork previews. */
|
||||
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
|
||||
export type Cel = { d: string; fill?: string; stroke?: string; width?: number };
|
||||
const INK = "#2b2218";
|
||||
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
|
||||
|
||||
/** Poses modeled on the original right-facing and oncoming fist emoji:
|
||||
* folded fingers face the viewer; the thumb stays outside their curl. */
|
||||
/** Two fist poses: a punch thrown (wrist left, knuckles right) and one
|
||||
* arriving (knuckles to the viewer). The thumb stays outside the curl. */
|
||||
export const FISTS: Record<"out" | "in", Cel[]> = {
|
||||
out: [
|
||||
// Wrist at left, back of hand above, bent index at the right edge.
|
||||
@@ -33,7 +34,7 @@ export const FISTS: Record<"out" | "in", Cel[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Deliberately uneven impact silhouette and hand-cut lettering; no font dependency. */
|
||||
/** The POW starburst and its lettering, as paths: the canvas has no font to wait on. */
|
||||
export const POW: Cel[] = [
|
||||
{ d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 },
|
||||
{ d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 },
|
||||
@@ -57,58 +58,97 @@ export function paintCels(ctx: CanvasRenderingContext2D, cels: readonly Cel[]) {
|
||||
ctx.restore();
|
||||
}
|
||||
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
|
||||
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore();
|
||||
}
|
||||
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
|
||||
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore();
|
||||
ctx.save();
|
||||
ctx.scale(size / 100, size / 100);
|
||||
paintCels(ctx, FISTS[swing]);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Same endpoints and landing beats as the original animation. */
|
||||
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
|
||||
ctx.save();
|
||||
ctx.scale(radius / 100, radius / 100);
|
||||
paintCels(ctx, POW);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Where the fist is at progress `p`: thrown, it lunges to 0.42 and
|
||||
* retracts; arriving, it closes to 0.45, holds, and fades. The POW pops
|
||||
* at the landing beat and shrinks away. */
|
||||
export function punchPose(p: number, out: boolean, W: number, H: number) {
|
||||
const ease = (v: number) => v * v * (3 - 2 * v);
|
||||
// Clamp before easing: otherwise the incoming fist reverses past p=.45.
|
||||
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45));
|
||||
const sx = out ? W * .86 : W * .5, sy = out ? H * 1.15 : H * .55;
|
||||
const tx = W * .56, ty = H * .5;
|
||||
const land = out ? .36 : .4, q = (p - land) / (1 - land);
|
||||
const pop = p < land ? 0 : q < .18 ? ease(q / .18) * 1.15 : q < .7 ? 1.15 - .15 * ease((q - .18) / .52) : 1 - ease((q - .7) / .3);
|
||||
const ease = smoothstep;
|
||||
// Clamp before easing: past 0.45 the arriving fist would reverse.
|
||||
const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : ease(Math.min(1, p / 0.45));
|
||||
const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
|
||||
const tx = W * 0.56, ty = H * 0.5;
|
||||
const land = out ? 0.36 : 0.4, q = (p - land) / (1 - land);
|
||||
const pop = p < land ? 0 : q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3);
|
||||
return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a,
|
||||
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a),
|
||||
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1,
|
||||
rotation: out ? -.6 : .15, radius: H * .19 * pop,
|
||||
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 };
|
||||
size: out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a),
|
||||
alpha: !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1,
|
||||
rotation: out ? -0.6 : 0.15, radius: H * 0.19 * pop,
|
||||
impactX: out ? tx - W * 0.02 : W * 0.5, impactY: H * 0.34 };
|
||||
}
|
||||
|
||||
export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) {
|
||||
const hub = [left + w * .5, top + h * .45];
|
||||
const rim = [[.02,.06],[.5,0],[.98,.08],[1,.5],[.96,.94],[.5,1],[.04,.92],[0,.5]].map(([u,v]) => [left + u! * w, top + v! * h]);
|
||||
const at = (i: number, t: number) => { const r = rim[i % 8]!; return [hub[0]! + (r[0]! - hub[0]!) * t, hub[1]! + (r[1]! - hub[1]!) * t]; };
|
||||
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round";
|
||||
type Pt = [number, number];
|
||||
const hub: Pt = [left + w * 0.5, top + h * 0.45];
|
||||
const rim: Pt[] = ([[0.02, 0.06], [0.5, 0], [0.98, 0.08], [1, 0.5], [0.96, 0.94], [0.5, 1], [0.04, 0.92], [0, 0.5]] as Pt[])
|
||||
.map(([u, v]) => [left + u * w, top + v * h]);
|
||||
const at = (i: number, t: number): Pt => {
|
||||
const r = rim[i % 8] as Pt;
|
||||
return [hub[0] + (r[0] - hub[0]) * t, hub[1] + (r[1] - hub[1]) * t];
|
||||
};
|
||||
ctx.save();
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); }
|
||||
for (const t of [.27,.53,.8]) for (let i = 0; i < 8; i++) {
|
||||
const a = at(i,t), b = at(i+1,t);
|
||||
ctx.moveTo(a[0]!,a[1]!);
|
||||
ctx.quadraticCurveTo((a[0]!+b[0]!)*.36+hub[0]!*.28,(a[1]!+b[1]!)*.36+hub[1]!*.28,b[0]!,b[1]!);
|
||||
for (const r of rim) {
|
||||
ctx.moveTo(hub[0], hub[1]);
|
||||
ctx.lineTo(r[0], r[1]);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(25,25,30,0.65)"; ctx.lineWidth = 3; ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(245,245,240,0.9)"; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore();
|
||||
// Three sagging rings, each strand bowed toward the hub.
|
||||
for (const t of [0.27, 0.53, 0.8]) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = at(i, t), b = at(i + 1, t);
|
||||
ctx.moveTo(a[0], a[1]);
|
||||
ctx.quadraticCurveTo((a[0] + b[0]) * 0.36 + hub[0] * 0.28, (a[1] + b[1]) * 0.36 + hub[1] * 0.28, b[0], b[1]);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = "rgba(25,25,30,0.65)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(245,245,240,0.9)";
|
||||
ctx.lineWidth = 1.3;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function paintFloorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number) {
|
||||
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill();
|
||||
export function paintFloorRing(
|
||||
ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(${rgb},${fill})`;
|
||||
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const segments = [[.06,1.75],[1.87,3.48],[3.62,5.04],[5.18,6.22]];
|
||||
for (const [start,end] of segments) for (let i=0; i<=20; i++) {
|
||||
const t = start! + (end!-start!) * i/20, k = 1 - .018 * (1 + Math.sin(t*5));
|
||||
const x = cx + Math.cos(t)*rx*k, y = cy + Math.sin(t)*ry*k;
|
||||
if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// The rim is inked in four strokes with a slight tremor, not one perfect ellipse.
|
||||
ctx.beginPath();
|
||||
const segments: [number, number][] = [[0.06, 1.75], [1.87, 3.48], [3.62, 5.04], [5.18, 6.22]];
|
||||
for (const [start, end] of segments) {
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
const t = start + (end - start) * i / 20;
|
||||
const k = 1 - 0.018 * (1 + Math.sin(t * 5));
|
||||
const x = cx + Math.cos(t) * rx * k, y = cy + Math.sin(t) * ry * k;
|
||||
if (!i) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke(); ctx.restore();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Small, legible loading/error sprites using the original fallback palettes. */
|
||||
/** The inked silhouette drawn in a conjuration's palette until its PNG loads. */
|
||||
export function fallbackCels(name: string, core: string, rim: string): Cel[] {
|
||||
const outer: Record<string, string> = {
|
||||
fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z",
|
||||
@@ -135,5 +175,5 @@ export function fallbackCels(name: string, core: string, rim: string): Cel[] {
|
||||
rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z",
|
||||
};
|
||||
const kind = outer[name] ? name : "spark";
|
||||
return [{d:outer[kind]!,fill:rim,width:3},{d:inside[kind]!,fill:core,width:1.5}];
|
||||
return [{ d: outer[kind]!, fill: rim, width: 3 }, { d: inside[kind]!, fill: core, width: 1.5 }];
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
</script>
|
||||
|
||||
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
|
||||
<path d={puff(c.x - 6, c.y, (7) * 1)} fill="#b7a786" class="puff" />
|
||||
<path d={puff(c.x + 5, c.y - 2, (5) * 1)} fill="#b7a786" class="puff back late" />
|
||||
<path d={puff(c.x, c.y + 3, (6) * 1)} fill="#b7a786" class="puff later" />
|
||||
<path d={puff(c.x - 6, c.y, 7)} fill="#b7a786" class="puff" />
|
||||
<path d={puff(c.x + 5, c.y - 2, 5)} fill="#b7a786" class="puff back late" />
|
||||
<path d={puff(c.x, c.y + 3, 6)} fill="#b7a786" class="puff later" />
|
||||
<g class="grit">
|
||||
<path d={puff(c.x - 4, c.y - 5, (1.3) * 1)} />
|
||||
<path d={puff(c.x + 6, c.y + 2, (1.6) * 1)} />
|
||||
<path d={puff(c.x + 1, c.y - 2, (0.9) * 1)} />
|
||||
<path d={puff(c.x - 4, c.y - 5, 1.3)} />
|
||||
<path d={puff(c.x + 6, c.y + 2, 1.6)} />
|
||||
<path d={puff(c.x + 1, c.y - 2, 0.9)} />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
style={`transform-origin: ${c.x}px ${c.y}px`}
|
||||
/>
|
||||
{/each}
|
||||
<path d={glint(c.x, c.y, (3.5) * 1.6)} class="heart" />
|
||||
<path d={glint(c.x, c.y, 5.6)} class="heart" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -47,7 +47,7 @@
|
||||
.spark { animation: spark-out 1.05s ease-out forwards; }
|
||||
.trail.late, .spark.late { animation-delay: 0.07s; }
|
||||
.trail.later, .spark.later { animation-delay: 0.14s; }
|
||||
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; }
|
||||
.heart { fill: #fffdf0; transform-box: fill-box; transform-origin: center; animation: heart-pop 0.4s ease-out forwards; }
|
||||
@keyframes trail-out {
|
||||
0% { opacity: 0; transform: scale(0.15); }
|
||||
25% { opacity: 1; }
|
||||
@@ -63,5 +63,4 @@
|
||||
0% { opacity: 1; transform: scale(0.5); }
|
||||
100% { opacity: 0; transform: scale(3); }
|
||||
}
|
||||
.heart { transform-box: fill-box; transform-origin: center; }
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
<path d={`M ${c.x-14} ${c.y+7} q -6 -6 1 -7 q 4 1 4 5 q 7 -9 12 -5 q -3 4 0 5 q 12 -7 14 -1 q 0 8 -17 9 q -15 1 -14 -6 Z`} fill="#6ea03c" stroke="#46662c" stroke-width="1" class="splat" />
|
||||
<g class="blobs">
|
||||
<path d={drop(c.x - 12, c.y + 3, (2.5) * 1)} class="blob" />
|
||||
<path d={drop(c.x + 11, c.y + 2, (2) * 1)} class="blob late" />
|
||||
<path d={drop(c.x + 3, c.y - 3, (1.7) * 1)} class="blob later" />
|
||||
<path d={drop(c.x - 12, c.y + 3, 2.5)} class="blob" />
|
||||
<path d={drop(c.x + 11, c.y + 2, 2)} class="blob late" />
|
||||
<path d={drop(c.x + 3, c.y - 3, 1.7)} class="blob later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -19,7 +19,7 @@
|
||||
transform-origin: center;
|
||||
animation: splat-wobble 0.7s ease-out forwards;
|
||||
}
|
||||
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; }
|
||||
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out both; }
|
||||
.blob.late { animation-delay: 0.06s; }
|
||||
.blob.later { animation-delay: 0.12s; }
|
||||
@keyframes splat-wobble {
|
||||
@@ -33,5 +33,4 @@
|
||||
50% { transform: translateY(-7px); }
|
||||
100% { opacity: 0; transform: translateY(2px); }
|
||||
}
|
||||
.blob { animation-fill-mode: both; }
|
||||
</style>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
|
||||
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
|
||||
<g class="rim-dust">
|
||||
<path d={puff(c.x - 11, c.y - 6, (3) * 1)} class="dust" />
|
||||
<path d={puff(c.x + 10, c.y - 7, (2.5) * 1)} class="dust late" />
|
||||
<path d={puff(c.x + 1, c.y - 11, (2) * 1)} class="dust later" />
|
||||
<path d={puff(c.x - 11, c.y - 6, 3)} class="dust" />
|
||||
<path d={puff(c.x + 10, c.y - 7, 2.5)} class="dust late" />
|
||||
<path d={puff(c.x + 1, c.y - 11, 2)} class="dust later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
@@ -32,8 +32,9 @@
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
animation: hole-open 0.75s ease-out forwards;
|
||||
transform-box: fill-box; transform-origin: center;
|
||||
}
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; }
|
||||
.dust { fill: rgba(160, 150, 130, 0.75); opacity: 0; transform-box: fill-box; transform-origin: center; animation: dust-drift 0.7s ease-out 0.15s forwards; }
|
||||
.dust.late { animation-delay: 0.24s; }
|
||||
.dust.later { animation-delay: 0.32s; }
|
||||
@keyframes hole-open {
|
||||
@@ -51,6 +52,4 @@
|
||||
30% { opacity: 0.8; }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
|
||||
}
|
||||
.lip { transform-box: fill-box; transform-origin: center; }
|
||||
.dust { transform-box: fill-box; transform-origin: center; }
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{/if}
|
||||
<g class="corner-dust">
|
||||
{#each CORNERS as p, i (i)}
|
||||
<path d={puff(p.x, p.y, (3.5) * 1)} class={`dust d${i}`} />
|
||||
<path d={puff(p.x, p.y, 3.5)} class={`dust d${i}`} />
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
|
||||
</g>
|
||||
<path d={glint(c.x, c.y, (3) * 1.7)} class="core" />
|
||||
<path d={glint(c.x, c.y, 5.1)} class="core" />
|
||||
<g class="motes">
|
||||
<path d={glint(c.x - 10, c.y - 9, (1.3) * 1.7)} />
|
||||
<path d={glint(c.x + 11, c.y - 5, (1.1) * 1.7)} class="late" />
|
||||
<path d={glint(c.x + 2, c.y + 11, (1.2) * 1.7)} class="later" />
|
||||
<path d={glint(c.x - 10, c.y - 9, 2.21)} />
|
||||
<path d={glint(c.x + 11, c.y - 5, 1.87)} class="late" />
|
||||
<path d={glint(c.x + 2, c.y + 11, 2.04)} class="later" />
|
||||
</g>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
stroke-width: 3.2;
|
||||
animation: ring-out 0.55s ease-out forwards;
|
||||
}
|
||||
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in forwards; }
|
||||
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in both; }
|
||||
.drop.late { animation-delay: 0.07s; }
|
||||
.drop.later { animation-delay: 0.13s; }
|
||||
@keyframes pool-spread {
|
||||
@@ -48,5 +48,4 @@
|
||||
45% { transform: translateY(-8px); }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
.drop { animation-fill-mode: both; }
|
||||
</style>
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// What the help remembers between openings: the tab, each tab's search,
|
||||
// the library's filters, and where the reader was on each page. Module
|
||||
// state, so it lasts the session and costs nothing to restore.
|
||||
|
||||
import type { CardQuery } from "./reference";
|
||||
|
||||
export type HelpTab = "play" | "rules" | "cards" | "rulings" | "about" | "tally";
|
||||
|
||||
export const helpState: {
|
||||
tab: HelpTab;
|
||||
rulesSearch: string;
|
||||
rulingsSearch: string;
|
||||
cards: CardQuery;
|
||||
scroll: Partial<Record<HelpTab, number>>;
|
||||
} = {
|
||||
tab: "play",
|
||||
rulesSearch: "",
|
||||
rulingsSearch: "",
|
||||
cards: { text: "", set: "all", type: "all", sight: "all" },
|
||||
scroll: {},
|
||||
};
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
type GameEvent,
|
||||
redactEvent,
|
||||
} from "@wizwar/engine";
|
||||
import { humanize, net } from "./net.svelte";
|
||||
import { type LogLine, humanize, net } from "./net.svelte";
|
||||
import { receiptFor } from "./receipt";
|
||||
|
||||
const SAVE_KEY = "wizwar-hotseat";
|
||||
|
||||
@@ -47,7 +48,7 @@ class LocalGame {
|
||||
viewerId = $state<PlayerId | null>(null);
|
||||
/** Set while the device should be handed to the named player. */
|
||||
handoffTo = $state<PlayerId | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
log = $state<LogLine[]>([]);
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate command results. */
|
||||
@@ -64,11 +65,19 @@ class LocalGame {
|
||||
private activeMs = 0;
|
||||
private lastMoveAt = 0;
|
||||
|
||||
/** A line of the chronicle for an event, and the receipt under a resolved attack. */
|
||||
private chronicle(e: GameEvent, batch: readonly GameEvent[]): void {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, { text: line, turn: null, notable: false }];
|
||||
const receipt = receiptFor(batch, e);
|
||||
if (receipt) this.log = [...this.log, { text: receipt.title, turn: null, notable: false, receipt: receipt.lines }];
|
||||
}
|
||||
|
||||
/** The tabletop D4 for house calls: chronicle only, no game state. */
|
||||
rollTableDie(): void {
|
||||
if (!this.viewerId) return;
|
||||
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
|
||||
this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`];
|
||||
this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`, turn: null, notable: false }];
|
||||
}
|
||||
|
||||
/** Rebuild the whole game as replay steps (finished games only). */
|
||||
@@ -148,8 +157,7 @@ class LocalGame {
|
||||
this.gameState = state;
|
||||
this.log = [];
|
||||
for (const e of events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, events);
|
||||
}
|
||||
this.active = true;
|
||||
this.viewerId = null;
|
||||
@@ -174,16 +182,14 @@ class LocalGame {
|
||||
let current = state;
|
||||
this.log = [];
|
||||
for (const e of events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, events);
|
||||
}
|
||||
for (const c of saved.commands) {
|
||||
const result = applyCommand(current, c.playerId, c.command);
|
||||
if (!result.ok) throw new Error(`replay failed: ${result.error}`);
|
||||
current = result.state;
|
||||
for (const e of result.events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, result.events);
|
||||
}
|
||||
}
|
||||
this.config = saved.config;
|
||||
@@ -218,7 +224,7 @@ class LocalGame {
|
||||
const plain = $state.snapshot(this.gameState) as GameState;
|
||||
const result = applyCommand(plain, this.viewerId, command);
|
||||
if (!result.ok) {
|
||||
this.log = [...this.log, `— ${result.error} —`];
|
||||
this.log = [...this.log, { text: `— ${result.error} —`, turn: null, notable: false }];
|
||||
return;
|
||||
}
|
||||
this.onFx?.(result.events);
|
||||
@@ -237,8 +243,7 @@ class LocalGame {
|
||||
});
|
||||
}
|
||||
for (const e of result.events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, result.events);
|
||||
}
|
||||
this.persist();
|
||||
if (this.gameState.phase === "playing") {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { prefersReducedMotion } from "svelte/motion";
|
||||
import { prefs } from "./prefs.svelte";
|
||||
|
||||
// Shared by the board, the reel, and the canvas. Svelte's own media query
|
||||
// tracks the OS setting live and releases its listener when no reactive
|
||||
// consumer remains.
|
||||
export const motion = {
|
||||
get reduced(): boolean { return prefersReducedMotion.current; },
|
||||
/** Flourishes play only when the player wants them AND the OS allows motion. */
|
||||
get effects(): boolean { return prefs.flourishes && !prefersReducedMotion.current; },
|
||||
};
|
||||
+187
-22
@@ -1,7 +1,8 @@
|
||||
// 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";
|
||||
import { receiptFor } from "./receipt";
|
||||
|
||||
const SERVER_URL =
|
||||
import.meta.env.VITE_WIZWAR_SERVER ??
|
||||
@@ -23,6 +24,9 @@ export function spellName(cardId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** The compass, for captions and buttons. */
|
||||
export const SIDE_NAMES: Record<Side, string> = { N: "North", E: "East", S: "South", W: "West" };
|
||||
|
||||
export function humanize(e: GameEvent): string | null {
|
||||
switch (e.type) {
|
||||
case "gameStarted": {
|
||||
@@ -66,7 +70,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!`;
|
||||
@@ -87,6 +91,8 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
||||
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
|
||||
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
|
||||
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind} — ${e.amount} damage (${e.total}/${e.needed}).`;
|
||||
case "squareContentDestroyed": return e.kind === "ooze" ? `🔥 The ooze burns away under ${e.attacker}'s fire!` : `🌿 The ${e.kind} is torn apart by ${e.attacker}'s attack!`;
|
||||
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`;
|
||||
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
|
||||
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||
@@ -146,7 +152,9 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
|
||||
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
|
||||
case "creatureMoved": return null;
|
||||
case "creatureAttacked": return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
|
||||
case "creatureAttacked":
|
||||
if (e.target === "wall") return `The ${spellName(e.kind)} punches the wall (rolled ${e.dieRoll ?? "?"})!`;
|
||||
return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
|
||||
case "creatureTouched": return `The ${spellName(e.kind)} falls upon ${e.player}!`;
|
||||
case "creatureDamaged": return e.amount > 0 ? `The ${spellName(e.kind)} takes ${e.amount} damage.` : `The attack has no effect on the ${spellName(e.kind)}.`;
|
||||
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
|
||||
@@ -213,7 +221,8 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "slimeWashed": return `The wave washes the slime away.`;
|
||||
case "wallDamaged": {
|
||||
const what = e.needed === 15 ? "door" : "wall";
|
||||
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name} — ${e.total}/${e.needed}.`;
|
||||
const weapon = e.source === "punch" ? "bare fists" : e.source === "troll" ? "the troll's fist" : cardDef(e.source).name;
|
||||
return `${e.player} batters the ${what} with ${weapon} — ${e.total}/${e.needed}.`;
|
||||
}
|
||||
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
|
||||
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
|
||||
@@ -237,6 +246,8 @@ export interface LogLine {
|
||||
/** The wizard acting, for the color mark beside the line: whoever
|
||||
* cast, struck, walked, or countered — otherwise the turn's owner. */
|
||||
actor?: string;
|
||||
/** A resolved attack's account, folded under its line. */
|
||||
receipt?: string[];
|
||||
}
|
||||
|
||||
/** Events whose `player` is the one acting rather than the one acted on. */
|
||||
@@ -246,9 +257,11 @@ const ACTING = new Set([
|
||||
"climbedFromPit", "spellTrapped",
|
||||
]);
|
||||
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
|
||||
const any = e as unknown as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -296,6 +309,8 @@ export interface GameSummary {
|
||||
round: number | null;
|
||||
lastMoveAt: string | null;
|
||||
chatCount: number;
|
||||
/** A finished table that moved on: the rematch room and who called it. */
|
||||
rematch?: { roomId: string; by: string } | null;
|
||||
}
|
||||
|
||||
export function attentionLabel(a: GameSummary["attention"]): string {
|
||||
@@ -336,6 +351,28 @@ class Net {
|
||||
spectating = $state(false);
|
||||
/** How many watch from the gallery (0 hides the count). */
|
||||
audience = $state(0);
|
||||
/** A command on its way: sent, and the table has not answered yet. */
|
||||
pending = $state<{ label: string; at: number } | null>(null);
|
||||
/** The last command the table answered, shown for a moment. */
|
||||
confirmed = $state<string | null>(null);
|
||||
/** A command that never left: the socket was down when it was tried. */
|
||||
unsent = $state<string | null>(null);
|
||||
/** A command that left, then the socket dropped before the table answered. */
|
||||
unconfirmed = $state<string | null>(null);
|
||||
/** A line of table talk on its way, until the table echoes it. */
|
||||
chatPending = $state<string | null>(null);
|
||||
/** Table talk that never arrived: handed back to the composer. */
|
||||
chatUnsent = $state<string | null>(null);
|
||||
/** Who spoke last at the table: your own words are never unread. */
|
||||
lastTalkBy = $state<string | null>(null);
|
||||
/** A finished table's call for a rematch: where it went, and who called. */
|
||||
rematchCall = $state<{ roomId: string; by: string } | null>(null);
|
||||
/** A rematch lobby: the last table's wizards not yet seated. */
|
||||
expected = $state<string[]>([]);
|
||||
/** The table called the keeper of this site, and by whom. */
|
||||
challenge = $state<{ by: string; at: string } | null>(null);
|
||||
/** The keeper's name, as the server knows it. */
|
||||
keeper = $state("Kestrel");
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<LogLine[]>([]);
|
||||
error = $state<string | null>(null);
|
||||
@@ -343,11 +380,13 @@ class Net {
|
||||
feedbackReports = $state<FeedbackReportView[]>([]);
|
||||
/** Every seat this browser holds, across rooms. */
|
||||
seats = $state<Seat[]>(loadSeats());
|
||||
/** Seats the last games answer refused: dropped only if refused again. */
|
||||
private voidedOnce = new Set<string>();
|
||||
/** Lobby ledger: one summary per live seat. */
|
||||
stats = $state<Record<string, number | string | null> | null>(null);
|
||||
chatSeen = $state<Record<string, number>>(loadChatSeen());
|
||||
/** Messages in the current room this session (history + live). */
|
||||
chatCount = 0;
|
||||
chatCount = $state(0);
|
||||
games = $state<GameSummary[]>([]);
|
||||
notificationsEnabled = $state(
|
||||
typeof Notification !== "undefined" && Notification.permission === "granted",
|
||||
@@ -362,6 +401,11 @@ class Net {
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
/** A catch-up reel delivered by the server. */
|
||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||
/** The missed steps, fetched for the summary slip before any reel is opened. */
|
||||
missedSteps = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||
/** Where the catch-up reel opens: the step a summary line pointed at. */
|
||||
catchUpStart = $state(0);
|
||||
private catchUpWanted: "summary" | "reel" = "reel";
|
||||
/** One turn's reel, summoned from a chronicle line's instant-replay
|
||||
* eye — steps plus the wizard whose eyes the camera wears. */
|
||||
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
|
||||
@@ -421,6 +465,10 @@ class Net {
|
||||
ws.onclose = () => {
|
||||
this.status = "disconnected";
|
||||
this.ws = null;
|
||||
// Whatever was in flight is now in doubt: the board will say what
|
||||
// landed when the connection returns, and the talk goes back in the box.
|
||||
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
|
||||
if (this.chatPending) { this.chatUnsent = this.chatPending; this.chatPending = null; }
|
||||
setTimeout(() => this.connect(), 1500);
|
||||
};
|
||||
ws.onmessage = (raw) => {
|
||||
@@ -479,6 +527,10 @@ class Net {
|
||||
this.roomColors = msg.colors ?? {};
|
||||
this.roomBots = msg.bots ?? {};
|
||||
this.audience = msg.audience ?? 0;
|
||||
this.rematchCall = msg.rematch ?? null;
|
||||
this.expected = msg.expected ?? [];
|
||||
this.challenge = msg.challenge ?? null;
|
||||
if (typeof msg.keeper === "string") this.keeper = msg.keeper;
|
||||
if (this.you && this.token) {
|
||||
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||
@@ -490,6 +542,15 @@ class Net {
|
||||
break;
|
||||
case "state": {
|
||||
this.view = msg.view;
|
||||
// The table answered: the last command landed, and any doubt from
|
||||
// a dropped socket is settled by the board itself.
|
||||
if (this.pending) {
|
||||
const label = this.pending.label;
|
||||
this.pending = null;
|
||||
this.confirmed = label;
|
||||
setTimeout(() => { if (this.confirmed === label) this.confirmed = null; }, 1500);
|
||||
}
|
||||
this.unconfirmed = null;
|
||||
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
|
||||
this.currentSeq = msg.seq;
|
||||
// Only the FIRST state after arriving carries a gap worth
|
||||
@@ -503,13 +564,17 @@ class Net {
|
||||
const last = this.seen[this.roomId] ?? 0;
|
||||
this.missedMoves = Math.max(0, msg.seq - last);
|
||||
if (this.missedMoves === 0) this.markSeen();
|
||||
// The missed steps come now, for the facts on the slip; the
|
||||
// reel waits for a tap.
|
||||
else if (!this.missedSteps) this.requestCatchUp(true);
|
||||
}
|
||||
if (document.visibilityState === "visible") this.watching = this.roomId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "catchUp":
|
||||
this.catchUp = msg.steps;
|
||||
if (this.catchUpWanted === "summary") this.missedSteps = msg.steps;
|
||||
else this.catchUp = msg.steps;
|
||||
break;
|
||||
case "moment":
|
||||
this.moment = { steps: msg.steps, owner: msg.owner };
|
||||
@@ -523,7 +588,7 @@ class Net {
|
||||
let talk = 0;
|
||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||
for (const e of msg.events as GameEvent[]) {
|
||||
if (e.type === "tableTalk") talk++;
|
||||
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
|
||||
if (e.type === "gameStarted" && !msg.replayed) {
|
||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||
}
|
||||
@@ -538,6 +603,10 @@ class Net {
|
||||
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
|
||||
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable, actor: actorOf(e, this.turnOwner) }];
|
||||
}
|
||||
const receipt = receiptFor(msg.events as GameEvent[], e);
|
||||
if (receipt) {
|
||||
this.log = [...this.log, { text: receipt.title, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable: false, actor: actorOf(e, this.turnOwner), receipt: receipt.lines }];
|
||||
}
|
||||
}
|
||||
if (talk > 0) {
|
||||
this.chatCount += talk;
|
||||
@@ -563,7 +632,21 @@ class Net {
|
||||
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
||||
break;
|
||||
}
|
||||
case "rematch":
|
||||
// The old table hears where the rematch went.
|
||||
if (msg.roomId === this.roomId) this.rematchCall = { roomId: msg.to, by: msg.by };
|
||||
break;
|
||||
case "rematched":
|
||||
// The caller's own move: the seat and room for the new table follow.
|
||||
this.resetChronicle();
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.roomId = null;
|
||||
this.roomIdPending = msg.roomId;
|
||||
break;
|
||||
case "chat": {
|
||||
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
|
||||
this.lastTalkBy = msg.player;
|
||||
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
|
||||
this.chatCount += 1;
|
||||
if (this.roomId) this.markChatSeen();
|
||||
@@ -592,8 +675,13 @@ class Net {
|
||||
// stays — a restarting server, a stale restore, or the wrong
|
||||
// backend all answer with ignorance, and ignorance is not
|
||||
// deletion. The wallet is capped by age instead of by trust.
|
||||
// And a seat is dropped only on the SECOND refusal in a row: one
|
||||
// refusal around a restart or a stale answer must not cost a seat.
|
||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
||||
const doubted = new Set<string>();
|
||||
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
||||
this.voidedOnce = voided;
|
||||
let kept = this.seats.filter((s) => !doubted.has(`${s.roomId}:${s.name}`));
|
||||
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
||||
if (kept.length !== this.seats.length) {
|
||||
this.seats = kept;
|
||||
@@ -614,22 +702,32 @@ class Net {
|
||||
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
||||
this.roomIdPending = null;
|
||||
}
|
||||
this.pending = null;
|
||||
this.error = msg.message;
|
||||
// The toast fades; the chronicle remembers why nothing happened.
|
||||
this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
|
||||
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));
|
||||
/** True when the message left; false when the socket was not open to carry it. */
|
||||
private send(message: unknown): boolean {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return false;
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
this.send({ type: "create", name });
|
||||
if (!this.send({ type: "create", name })) this.flash("Still reaching the table — try again in a moment");
|
||||
}
|
||||
|
||||
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
||||
@@ -646,7 +744,9 @@ class Net {
|
||||
const existing = this.seats.find(
|
||||
(s) => s.roomId === this.roomIdPending && s.name === name,
|
||||
);
|
||||
this.send({ type: "join", roomId, name, token: existing?.token ?? this.token });
|
||||
if (!this.send({ type: "join", roomId, name, token: existing?.token ?? this.token })) {
|
||||
this.flash("Still reaching the table — try again in a moment");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sit back down at a remembered seat. */
|
||||
@@ -704,8 +804,30 @@ class Net {
|
||||
this.send({ type: "rollDie" });
|
||||
}
|
||||
|
||||
sendChat(text: string): void {
|
||||
this.send({ type: "chat", text });
|
||||
/** True when the line left; the composer keeps it otherwise. */
|
||||
sendChat(text: string): boolean {
|
||||
if (!this.send({ type: "chat", text })) return false;
|
||||
this.chatPending = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Call the keeper of this site to the table: a seat is held, and their phone rings. */
|
||||
challengeKeeper(): void {
|
||||
this.send({ type: "challengeKeeper" });
|
||||
}
|
||||
|
||||
/** Call for a rematch from a finished table, or join the one already called. */
|
||||
callRematch(): void {
|
||||
this.send({ type: "rematch" });
|
||||
}
|
||||
|
||||
/** Take the seat kept for you at the rematch table. */
|
||||
acceptRematch(roomId: string): void {
|
||||
if (!this.you) return;
|
||||
this.resetChronicle();
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.join(roomId, this.you);
|
||||
}
|
||||
|
||||
/** Watching the table counts as reading the talk. */
|
||||
@@ -776,6 +898,15 @@ class Net {
|
||||
* line, and wiping it would erase the only notice of why. */
|
||||
leaveLocal(): void {
|
||||
localStorage.removeItem(SEAT_KEY);
|
||||
this.missedSteps = null;
|
||||
this.catchUp = null;
|
||||
this.pending = null;
|
||||
this.unsent = null;
|
||||
this.unconfirmed = null;
|
||||
this.chatPending = null;
|
||||
this.rematchCall = null;
|
||||
this.expected = [];
|
||||
this.challenge = null;
|
||||
this.roomId = null;
|
||||
this.roomIdPending = null;
|
||||
this.view = null;
|
||||
@@ -808,18 +939,27 @@ class Net {
|
||||
this.send({ type: "catchUp", sinceSeq: 0, full: true });
|
||||
}
|
||||
|
||||
/** Ask for the reel of everything since we last watched. */
|
||||
requestCatchUp(): void {
|
||||
/** Ask for the steps since we last watched: for the summary slip, or for the reel. */
|
||||
requestCatchUp(summaryOnly = false): void {
|
||||
if (!this.roomId) return;
|
||||
this.catchUpWanted = summaryOnly ? "summary" : "reel";
|
||||
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
|
||||
}
|
||||
|
||||
/** Open the reel of the missed steps, at the step a fact pointed to. */
|
||||
watchCatchUp(startAt = 0): void {
|
||||
this.catchUpStart = Math.max(0, startAt);
|
||||
if (this.missedSteps) this.catchUp = this.missedSteps;
|
||||
else this.requestCatchUp(false);
|
||||
}
|
||||
|
||||
/** All caught up: remember it and clear the banner. */
|
||||
markSeen(): void {
|
||||
if (!this.roomId) return;
|
||||
this.seen[this.roomId] = this.currentSeq;
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen));
|
||||
this.missedMoves = 0;
|
||||
this.missedSteps = null;
|
||||
}
|
||||
|
||||
closeCatchUp(): void {
|
||||
@@ -864,8 +1004,33 @@ class Net {
|
||||
this.momentTurn = null;
|
||||
}
|
||||
|
||||
command(command: Command): void {
|
||||
this.send({ type: "command", command });
|
||||
/** Send a command, or say plainly that it did not go. */
|
||||
command(command: Command): boolean {
|
||||
const label = describeCommand(command);
|
||||
if (!this.send({ type: "command", command })) {
|
||||
this.unsent = label;
|
||||
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent — the table is out of reach`);
|
||||
return false;
|
||||
}
|
||||
this.unsent = null;
|
||||
this.pending = { label, at: Date.now() };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** A command as the player would name it, for the sending and not-sent notes. */
|
||||
function describeCommand(c: Command): string {
|
||||
switch (c.type) {
|
||||
case "move": return "your step";
|
||||
case "cast": return "your spell";
|
||||
case "counteract": return "your counter";
|
||||
case "pass": return "your pass";
|
||||
case "punch": case "punchWall": return "your punch";
|
||||
case "endTurn": return "ending your turn";
|
||||
case "pickUpTreasure": case "pickUpObject": return "the pickup";
|
||||
case "dropTreasure": case "dropObject": return "the drop";
|
||||
case "playNumberForMovement": return "your number";
|
||||
default: return "your action";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface Prefs {
|
||||
/** The hand of cards stands in a column at the board's left instead of
|
||||
* lying along the bottom (wide screens only). */
|
||||
handLeft: boolean;
|
||||
/** The hand's side was picked by hand; until then a wide, short screen
|
||||
* stands the cards beside the board on its own. */
|
||||
handLeftChosen: boolean;
|
||||
/** The first-turn coaching slip has been read and dismissed. */
|
||||
coached: boolean;
|
||||
/** The automaton tier was picked by hand (so a finished game leaves it alone). */
|
||||
@@ -42,7 +45,7 @@ const KEY = "wizwar-prefs";
|
||||
function load(): Prefs {
|
||||
const fallback: Prefs = {
|
||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false,
|
||||
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false, handLeftChosen: false,
|
||||
coached: false, tierChosen: false, finishedGames: 0, stepLightAlways: false,
|
||||
};
|
||||
try {
|
||||
@@ -56,12 +59,11 @@ 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,
|
||||
handLeft: p.handLeft === true,
|
||||
handLeftChosen: p.handLeftChosen === true || p.handLeft === true,
|
||||
coached: p.coached === true,
|
||||
tierChosen: p.tierChosen === true,
|
||||
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// The receipt under a resolved attack: the blow as it came in, what each
|
||||
// counter left of it, what landed, and whose life moved — read off the
|
||||
// engine's own events, so it explains what happened rather than what the
|
||||
// cards promise.
|
||||
|
||||
import { cardDef, type GameEvent } from "@wizwar/engine";
|
||||
|
||||
export interface Receipt {
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function nameOf(id: string | null): string {
|
||||
if (!id) return "The punch";
|
||||
try { return cardDef(id).name; } catch { return id; }
|
||||
}
|
||||
|
||||
/** The receipt for a resolution event, or null when there is nothing to
|
||||
* account for. `batch` is the event list the resolution arrived in: the
|
||||
* damage and life bookkeeping of the same exchange sits beside it. */
|
||||
export function receiptFor(batch: readonly GameEvent[], e: GameEvent): Receipt | null {
|
||||
if (e.type === "attackMissed") {
|
||||
const why =
|
||||
e.because === "invisible" ? `${e.defender} is invisible; the die sent it astray.`
|
||||
: e.because === "shrink" ? `${e.defender} is shrunk; the die said miss.`
|
||||
: `${e.defender} outran it.`;
|
||||
return { title: `${nameOf(e.attackCardId)} misses ${e.defender}`, lines: [why] };
|
||||
}
|
||||
if (e.type !== "attackResolved") return null;
|
||||
const at = batch.indexOf(e);
|
||||
let start = 0;
|
||||
for (let i = at - 1; i >= 0; i--) {
|
||||
if (batch[i]!.type === "attackResolved") { start = i + 1; break; }
|
||||
}
|
||||
const exchange = batch.slice(start, at + 1);
|
||||
const returned = exchange.some((x) => x.type === "damaged" && /\(reflected\)/.test(x.source));
|
||||
const lines: string[] = [];
|
||||
|
||||
const healed = exchange.find((x) => x.type === "lifeGained" && /\(reversed\)/.test(x.source));
|
||||
const tookHold = exchange.find((x) => x.type === "spellSustained");
|
||||
if (e.incoming != null) {
|
||||
const card = nameOf(e.attackCardId);
|
||||
const dur0 = e.incomingDuration ?? 0;
|
||||
const parts = [
|
||||
e.incoming > 0 ? `${e.incoming} incoming`
|
||||
: dur0 > 0 ? `${dur0} turn${dur0 === 1 ? "" : "s"} of ${card} incoming`
|
||||
: `${card} incoming`,
|
||||
];
|
||||
let damage = e.incoming;
|
||||
let duration = dur0;
|
||||
let reflected = 0;
|
||||
for (const t of e.trail ?? []) {
|
||||
const c = nameOf(t.cardId);
|
||||
if (t.nullified) { parts.push(`${t.player}'s ${c} is nullified`); continue; }
|
||||
const off = damage - t.damage;
|
||||
const back = t.reflected - reflected;
|
||||
const turnsOff = duration - t.duration;
|
||||
let verb: string;
|
||||
if (t.cardId === "full-reflection") verb = "turns it around";
|
||||
else if (t.cardId === "reverse") verb = healed ? "turns it into healing" : "reverses it";
|
||||
else if (t.cardId === "empathy") verb = "shares it with the caster";
|
||||
else if (t.cardId === "anti-anti") verb = "cancels the counter before it";
|
||||
else if (back > 0) verb = t.damage > 0 ? `sends ${back} back and lets ${t.damage} through` : `sends ${back} back`;
|
||||
else if ((t.damage === 0 && damage > 0) || (t.duration === 0 && duration > 0 && damage === 0)) verb = "stops it cold";
|
||||
else if (off > 0) verb = `takes ${off} off`;
|
||||
else if (turnsOff > 0) verb = `takes ${turnsOff} turn${turnsOff === 1 ? "" : "s"} off`;
|
||||
else verb = "changes nothing";
|
||||
parts.push(`${t.player}'s ${c} ${verb}`);
|
||||
damage = t.damage;
|
||||
duration = t.duration;
|
||||
reflected = t.reflected;
|
||||
}
|
||||
parts.push(
|
||||
e.redirected ? `the whole blow turns back on ${e.attacker}`
|
||||
: e.fullyStopped ? "nothing lands"
|
||||
: healed && healed.type === "lifeGained" ? `${healed.amount} heals ${e.defender} instead`
|
||||
: e.damageDealt > 0 ? `${e.damageDealt} lands on ${e.defender}`
|
||||
: tookHold && tookHold.type === "spellSustained" ? `${card} takes hold of ${e.defender} for ${tookHold.turns} turn${tookHold.turns === 1 ? "" : "s"}`
|
||||
: "nothing to land",
|
||||
);
|
||||
lines.push(parts.join(" → "));
|
||||
} else if (e.redirected) {
|
||||
lines.push(`the whole blow turns back on ${e.attacker}`);
|
||||
} else if (e.fullyStopped) {
|
||||
lines.push("nothing lands");
|
||||
}
|
||||
|
||||
for (const x of exchange) {
|
||||
if (x.type === "damaged" && x.amount > 0) {
|
||||
const soak = x.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
|
||||
lines.push(`${x.player}: ${x.lifeAfter + x.amount} → ${x.lifeAfter} life${soak ? ` (${soak})` : ""}`);
|
||||
} else if (x.type === "damageImmune") {
|
||||
lines.push(`${x.player} takes nothing — ${x.because}`);
|
||||
} else if (x.type === "lifeGained") {
|
||||
lines.push(`${x.player}: ${x.lifeAfter - x.amount} → ${x.lifeAfter} life, reversed`);
|
||||
} else if (x.type === "stonesDestroyed") {
|
||||
lines.push(`${x.player}'s magic stones are destroyed`);
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
return { title: `${nameOf(e.attackCardId)}${returned ? ", returned," : ""} resolved`, lines };
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// The reference desk behind the help: the rules and FAQ as one searchable,
|
||||
// linkable body; the house rulings keyed to the cards they touch; and the
|
||||
// small text services (inline emphasis, cross-references) the help renders
|
||||
// with. Everything here is data and pure functions; Help.svelte presents it.
|
||||
|
||||
import { allCardDefs, cardDef, type CardDef } from "@wizwar/engine";
|
||||
import { RULES_SECTIONS } from "./rules";
|
||||
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
|
||||
import { FAQ_GENERAL } from "./faq-general";
|
||||
|
||||
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
|
||||
export function slug(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export type RefSource = "summary" | "rulebook" | "expansion" | "faq";
|
||||
|
||||
export const SOURCE_LABELS: Record<RefSource, string> = {
|
||||
summary: "the rules, condensed",
|
||||
rulebook: "the rulebook, verbatim",
|
||||
expansion: "Expansion Set 1, verbatim",
|
||||
faq: "official FAQ",
|
||||
};
|
||||
|
||||
export interface RefSection {
|
||||
/** Unique across all sources: the source and the title's slug. */
|
||||
id: string;
|
||||
source: RefSource;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
function sections(source: RefSource, list: { title: string; body?: string[]; paragraphs?: string[] }[]): RefSection[] {
|
||||
return list.map((s) => ({
|
||||
id: `${source}-${slug(s.title)}`,
|
||||
source,
|
||||
title: s.title,
|
||||
paragraphs: s.body ?? s.paragraphs ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** Every section of the rules tab, in reading order. */
|
||||
export const REFERENCE: RefSection[] = [
|
||||
...sections("summary", RULES_SECTIONS),
|
||||
...sections("rulebook", RULEBOOK_BASE),
|
||||
...sections("expansion", RULEBOOK_EXPANSION),
|
||||
...sections("faq", FAQ_GENERAL),
|
||||
];
|
||||
|
||||
/** The sections whose title or text mention every word of the query. */
|
||||
export function searchReference(query: string): RefSection[] {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return REFERENCE;
|
||||
return REFERENCE.filter((s) => {
|
||||
const hay = `${s.title}\n${s.paragraphs.join("\n")}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** The transcriptions carry light Markdown: **bold** for the rulebook's own
|
||||
* run-in headings, and *(6E: …)* for this table's editorial notes on where
|
||||
* the sixth edition differs from the text it shipped with. Rendered, with
|
||||
* the notes set apart from the original words. */
|
||||
export function renderInline(text: string): string {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\((6E:[\s\S]*?)\)\*/g, '<span class="ed-note">($1)</span>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||
}
|
||||
|
||||
/** The playable pool: every card in the 6e box and its expansion. */
|
||||
export const CARD_POOL: CardDef[] = allCardDefs().filter(
|
||||
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
export type CardTypeFilter = "all" | "attack" | "neutral" | "counteraction" | "number" | "object" | "trap";
|
||||
export type SetFilter = "all" | "basic" | "expansion1";
|
||||
export type SightFilter = "all" | "los" | "adjacent" | "none";
|
||||
|
||||
export interface CardQuery {
|
||||
text: string;
|
||||
set: SetFilter;
|
||||
type: CardTypeFilter;
|
||||
sight: SightFilter;
|
||||
}
|
||||
|
||||
function typeMatches(d: CardDef, t: CardTypeFilter): boolean {
|
||||
if (t === "all") return true;
|
||||
const kind = d.cardType ?? "";
|
||||
if (t === "neutral" || t === "counteraction") return kind === t || kind === "neutral/counteraction";
|
||||
return kind === t;
|
||||
}
|
||||
|
||||
function sightMatches(d: CardDef, s: SightFilter): boolean {
|
||||
if (s === "all") return true;
|
||||
if (s === "los") return d.los === true;
|
||||
if (s === "adjacent") return d.adjacent === true;
|
||||
return d.los !== true && d.adjacent !== true;
|
||||
}
|
||||
|
||||
/** Cards matching the query, ranked: an exact name first, then names that
|
||||
* start with the words, then names that contain them, then texts that do.
|
||||
* Alphabetical within each rank. */
|
||||
export function searchCards(q: CardQuery): CardDef[] {
|
||||
const text = q.text.trim().toLowerCase();
|
||||
const rank = (d: CardDef): number => {
|
||||
if (!text) return 2;
|
||||
const name = d.name.toLowerCase();
|
||||
if (name === text) return 0;
|
||||
if (name.startsWith(text)) return 1;
|
||||
if (name.includes(text)) return 2;
|
||||
if ((d.text ?? "").toLowerCase().includes(text)) return 3;
|
||||
return -1;
|
||||
};
|
||||
return CARD_POOL
|
||||
.filter((d) => typeMatches(d, q.type) && sightMatches(d, q.sight))
|
||||
.filter((d) => q.set === "all" || d.set === q.set || d.alsoIn?.some((a) => a.set === q.set))
|
||||
.map((d) => ({ d, r: rank(d) }))
|
||||
.filter((x) => x.r >= 0)
|
||||
.sort((a, b) => a.r - b.r || a.d.name.localeCompare(b.d.name))
|
||||
.map((x) => x.d);
|
||||
}
|
||||
|
||||
/** Other cards a card's text names in capitals — "a NUMBER card" is a
|
||||
* kind, "MAD DASH" is a card. Longest names first so SLOW DEATH is not
|
||||
* also read as SLOW. */
|
||||
export function mentionedCards(cardId: string): CardDef[] {
|
||||
const def = cardDef(cardId);
|
||||
let text = def.text ?? "";
|
||||
if (!text) return [];
|
||||
text = text.replace(new RegExp(`\\b${def.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ");
|
||||
const found: CardDef[] = [];
|
||||
const candidates = CARD_POOL
|
||||
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
|
||||
.sort((a, b) => b.name.length - a.name.length);
|
||||
for (const d of candidates) {
|
||||
const re = new RegExp(`\\b${d.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
if (re.test(text)) {
|
||||
found.push(d);
|
||||
text = text.replace(new RegExp(re.source, "g"), " ");
|
||||
}
|
||||
}
|
||||
return found.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** How a card's corner reads: where it must be aimed. */
|
||||
export function sightOf(d: CardDef): string {
|
||||
if (d.adjacent) return "adjacent";
|
||||
if (d.los) return "line of sight";
|
||||
return "no target";
|
||||
}
|
||||
|
||||
export function typeLabel(d: CardDef): string {
|
||||
switch (d.cardType) {
|
||||
case "neutral/counteraction": return "neutral or counteraction";
|
||||
case null: return "card";
|
||||
default: return d.cardType;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLabel(d: CardDef): string {
|
||||
const also = d.alsoIn?.length ? ` and ×${d.alsoIn[0]!.quantity} in the expansion` : "";
|
||||
return `×${d.quantity} in the ${d.set === "basic" ? "base deck" : "expansion"}${also}`;
|
||||
}
|
||||
|
||||
/** A house ruling: how this table resolves something the cardboard leaves
|
||||
* to the players, in the present tense, keyed to the cards it touches. */
|
||||
export interface HouseRuling {
|
||||
id: string;
|
||||
title: string;
|
||||
cards: string[];
|
||||
body: string[];
|
||||
}
|
||||
|
||||
export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
{
|
||||
id: "thumb-of-god", title: "The Thumb of God", cards: ["thumb-of-god"],
|
||||
body: ["THE THUMB OF GOD is a divine meteor. Aim it at a square; the die drifts up to two squares in a random direction, then every token in and around the landing square — objects, treasures, creatures, even wizards — is flung to a random nearby square. Walls mean nothing to falling cardboard, and there is no counteraction."],
|
||||
},
|
||||
{
|
||||
id: "illusion-wall", title: "Illusion walls", cards: ["illusion-wall"],
|
||||
body: ["An ILLUSION WALL is real only to those who believe it. Its creator sees through it from the start; everyone else sees stone until they walk into it or see through it, and the maze remembers each wizard's verdict separately. An untested illusion shimmers faintly, and doubting it is free."],
|
||||
},
|
||||
{
|
||||
id: "ambush", title: "Ambushes", cards: ["opportunity-fire"],
|
||||
body: ["An ambush (OPPORTUNITY FIRE) is set with the attack card it will fire and a trigger of your choice: an opponent entering your line of sight, coming within one square, or picking up any treasure. It springs on their turn, out of yours."],
|
||||
},
|
||||
{
|
||||
id: "butt-head", title: "The goat's ram", cards: ["butt-head", "mad-dash", "power-run"],
|
||||
body: [
|
||||
"BUTT-HEAD's ram is movement. The charge is measured as the shortest walk through the corridors from where you cast it to your victim's square, on the legs you have this turn — three, plus any NUMBER played for movement — and it spends them. You cannot pad the blow by taking the long way round, and the goat lands on the victim's square. There is no ceiling on the damage.",
|
||||
"MAD DASH doubles the whole allowance, NUMBER cards and POWER RUN points included, so a well-placed goat can ram for sixteen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "slime", title: "Spells cast into slime", cards: ["fill-square-with-slime"],
|
||||
body: ["Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a slime may hold several. They go off one at a time, oldest first: each wizard who pushes in springs one spell, the next wizard the next. The card says only that each spell goes off once; the queue is this table's reading. A slime shows how many it holds, and a peek names them, since every cast into it was seen."],
|
||||
},
|
||||
{
|
||||
id: "doors-and-sight", title: "Doors and sight", cards: ["pick-lock", "master-key", "remove-lock"],
|
||||
body: [
|
||||
"A wizard beside a door they can open — its lock removed, unlocked this turn, or PICK LOCK or MASTER KEY in hand — sees through the doorway. The hallway behind them still cannot.",
|
||||
"\"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 open. A door unlocked and not passed relocks at the turn's end.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "visionstone", title: "Visionstone", cards: ["visionstone", "dust-cloud"],
|
||||
body: ["VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included, not only direct attacks. It does not see through a DUST CLOUD."],
|
||||
},
|
||||
{
|
||||
id: "dust-cloud", title: "Dust clouds", cards: ["dust-cloud"],
|
||||
body: ["A DUST CLOUD blinds whoever stands in it. No line-of-sight spell may be cast from inside a cloud, nor at anyone standing in one, and sight lines that pass through it are blocked. Spells a wizard casts on themself still work."],
|
||||
},
|
||||
{
|
||||
id: "mental-force", title: "Mental Force", cards: ["mental-force"],
|
||||
body: ["MENTAL FORCE refuses a destination the victim cannot walk to in three spaces, rather than spending the card on nothing."],
|
||||
},
|
||||
{
|
||||
id: "strength", title: "Tearing a treasure away", cards: ["strength"],
|
||||
body: ["STRENGTH's treasure-tear is an attack: it opens a counteraction window (\"this would be an attack\") rather than resolving on the spot."],
|
||||
},
|
||||
{
|
||||
id: "disease", title: "Disease", cards: ["disease"],
|
||||
body: ["DISEASE is a plague cast on yourself (\"You're the carrier!\"). The caster carries it; sharing a square bites in both directions; there is no counteraction."],
|
||||
},
|
||||
{
|
||||
id: "fire-imp", title: "The fire imp's scorch", cards: ["fire-imp"],
|
||||
body: ["The fire imp's scorch is a spell, as the FAQ has it: magical, and counteractable."],
|
||||
},
|
||||
{
|
||||
id: "soulstone", title: "Soulstone", cards: ["soulstone"],
|
||||
body: ["SOULSTONE's floor holds even when its bearer is at three life-points or below."],
|
||||
},
|
||||
{
|
||||
id: "ward", title: "The ward's bite", cards: ["ward"],
|
||||
body: ["A WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does — a reversal, a reflection — touches the ward's caster."],
|
||||
},
|
||||
{
|
||||
id: "curses", title: "Reversing and reflecting a curse", cards: ["reverse", "full-reflection", "reflection", "slow-death", "walking-dead", "idiot"],
|
||||
body: [
|
||||
"A REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse, as the FAQ rules: a point gained per card drawn, half a point per space walked, permanently.",
|
||||
"A FULL REFLECTION returns a permanent curse — SLOW DEATH, WALKING DEAD, IDIOT — onto its caster instead of letting it evaporate.",
|
||||
"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.",
|
||||
"A REFLECTION against a permanent curse afflicts both wizards. WALKING DEAD and IDIOT say so on the card; SLOW DEATH follows REFLECTION's own rule that a spell works for both parties.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "slow-death", title: "Slow Death's bites", cards: ["slow-death", "absorb", "blunt"],
|
||||
body: ["SLOW DEATH's per-draw bites land as one blow, which pauses for a victim holding ABSORB or BLUNT. The counter is played against the total: ABSORB soaks up to three, BLUNT halves it rounding up."],
|
||||
},
|
||||
{
|
||||
id: "mad-dash", title: "Mad Dash", cards: ["mad-dash", "power-run"],
|
||||
body: ["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 are doubled as well."],
|
||||
},
|
||||
{
|
||||
id: "teleport", title: "Teleporting across the maze's edge", cards: ["teleport"],
|
||||
body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on — the lettered openings are not needed. Four spaces straight up from two squares below the top edge lands two squares up from the bottom."],
|
||||
},
|
||||
{
|
||||
id: "power-drain", title: "Power Drain", cards: ["power-drain", "blunt", "absorb"],
|
||||
body: ["POWER DRAIN drains the number played. The caster gains it whether the blow is BLUNTed or ABSORBed — the FAQ has the counter blunting the damage done, not the drain — and a wall drained for its points gives them up as a wizard would. A FULL SHIELD stops the drain with the spell."],
|
||||
},
|
||||
{
|
||||
id: "troll", title: "The troll's fist", cards: ["troll"],
|
||||
body: ["A commanded TROLL punches a wall line beside it as it punches a wizard: a D4 of damage toward the wall's fall, once a turn."],
|
||||
},
|
||||
{
|
||||
id: "pits", title: "Crossing a pit", cards: ["create-pit"],
|
||||
body: [
|
||||
"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. A warp mouth on the rim is a way off like any square, and is taken when no square offers. A pit with no way off cannot be entered.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "big-man", title: "The giant's shove at a fork", cards: ["big-man"],
|
||||
body: ["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. The FAQ gives the choice to the other player; so does this table."],
|
||||
},
|
||||
{
|
||||
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
|
||||
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
|
||||
},
|
||||
{
|
||||
id: "lifesaver", title: "Lifesaver", cards: ["lifesaver"],
|
||||
body: ["A wizard holding LIFESAVER is not eliminated for losing both treasures, exactly as the card promises."],
|
||||
},
|
||||
{
|
||||
id: "force-field", title: "Force Field", cards: ["force-field"],
|
||||
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
|
||||
},
|
||||
];
|
||||
|
||||
/** The rulings that touch a card. */
|
||||
export function rulingsFor(cardId: string): HouseRuling[] {
|
||||
return HOUSE_RULINGS.filter((r) => r.cards.includes(cardId));
|
||||
}
|
||||
|
||||
/** Rulings whose title, text, or cards mention every word of the query. */
|
||||
export function searchRulings(query: string): HouseRuling[] {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return HOUSE_RULINGS;
|
||||
return HOUSE_RULINGS.filter((r) => {
|
||||
const names = r.cards.map((id) => { try { return cardDef(id).name; } catch { return id; } });
|
||||
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
}
|
||||
@@ -91,6 +91,13 @@ export const RULES_SECTIONS: RulesSection[] = [
|
||||
];
|
||||
|
||||
export const HOW_TO_PLAY: RulesSection[] = [
|
||||
{
|
||||
title: "Winning",
|
||||
body: [
|
||||
"Carry two treasures that aren't yours home — one at a time, to your home square, and drop them there — or be the last wizard standing. Lose both of your own treasures to enemy homes and you're out.",
|
||||
"Move and play cards in any order, then end your turn. You have three steps and one attack a turn — a step, a spell, two more steps is a fine turn. The banner above the board keeps count of your moves and your treasures home.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Moving",
|
||||
body: [
|
||||
|
||||
Reference in New Issue
Block a user