diff --git a/deploy/film-clips.mjs b/deploy/film-clips.mjs index 0acc4be..4046d88 100644 --- a/deploy/film-clips.mjs +++ b/deploy/film-clips.mjs @@ -22,7 +22,7 @@ if (!outDir) { console.error("usage: film-clips.mjs [scene ...]"); pro const PORT = 8809; const FPS = 25; const TAIL_MS = 2500; // hold on the last move -const POSTER_SETTLE_MS = 1200; // the poster beat's tween and fx, done +const POSTER_SETTLE_MS = 2000; // the poster beat's tween, fx, and any change-of-eyes wipe, done const EPOCH = new Date("2026-01-01T00:00:00Z").getTime(); const server = startServer(PORT, "wizwar-film"); diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 1d95b97..a3cd94d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -240,6 +240,24 @@ export interface CastParams { /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ export const CURRENT_RULES_REV = 13; +/** Every rulings revision since the baseline, newest last — the entries a + * game's deckRev freezes it before or after. Shown to players as the house + * rulings; new entries go here alongside the CURRENT_RULES_REV bump. */ +export const RULES_REVISIONS: { rev: number; note: string }[] = [ + { rev: 2, note: "a wizard beside a door they can open (lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through the doorway; the hallway behind them still cannot." }, + { rev: 3, note: "VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included — not only direct attacks." }, + { rev: 4, note: "\"the door will relock behind you\" means it — passing through an unlocked door shuts it at the walker's back unless a hand holds it; unpassed, it relocks at turn's end as before." }, + { rev: 5, note: "MENTAL FORCE refuses a destination the victim cannot walk to in three spaces, instead of eating the card silently at resolution." }, + { rev: 6, note: "STRENGTH's treasure-tear opens a counteraction window (\"this would be an attack\") instead of resolving instantly." }, + { rev: 7, note: "DISEASE is a self-cast plague (\"You're the carrier!\") — the caster carries it, sharing a square bites both directions, no counteraction." }, + { rev: 8, note: "the fire imp's scorch is a SPELL (FAQ) — counteractable, magical — and SOULSTONE's floor holds even at 3 life or below." }, + { rev: 9, note: "the WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does touches its caster." }, + { rev: 10, note: "BUTT-HEAD's ram is MOVEMENT — the charge walks real corridors on the turn's legal legs (three plus numbers) and spends them." }, + { rev: 11, note: "a REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse (FAQ) — a point gained per card drawn, half a point per space walked, permanently — and a FULL REFLECTION returns a permanent curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of letting it evaporate." }, + { rev: 12, note: "MAD DASH doubles \"NUMBER cards and other add-ons\" too — a number riding the cast fuels it, and numbers or POWER RUN points played under the dash double; older games doubled only the base allowance and consumed the rider without effect." }, + { rev: 13, note: "SLOW DEATH's per-draw bites land as ONE blow that pauses for a victim holding ABSORB or BLUNT — countered against the total (absorb soaks up to 3, blunt halves rounding up); older games bit instantly." }, +]; + export interface GameConfig { playerIds: PlayerId[]; seed: number; @@ -251,43 +269,7 @@ export interface GameConfig { /** * Rules revision, frozen per game so stored games replay unchanged. * Absent = rev 1, the baseline all earlier revisions collapsed into. - * Rev 2: a wizard beside a door they can open (lock removed, door - * unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through - * the doorway; the hallway behind them still cannot. - * Rev 3: VISIONSTONE pierces its one wall for every sight the game - * asks of its bearer — creations and utility spells included — not - * only direct attacks. - * Rev 4: "the door will relock behind you" means it — passing through - * an unlocked door shuts it at the walker's back unless a hand holds - * it; unpassed, it relocks at turn's end as before. - * Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to - * in three spaces, instead of eating the card silently at resolution. - * Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this - * would be an attack") instead of resolving instantly. - * Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the - * caster carries it, sharing a square bites both directions, no - * counteraction. - * Rev 8: the fire imp's scorch is a SPELL (FAQ) — counteractable, - * magical — and SOULSTONE's floor holds even at 3 life or below. - * Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ... - * otherwise work as written"), though nothing a counter does touches - * its caster. - * Rev 10: BUTT-HEAD's ram is MOVEMENT — the charge walks real - * corridors on the turn's legal legs (three plus numbers) and spends - * them. - * Rev 11: a REVERSE against SLOW DEATH or WALKING DEAD turns the whole - * curse (FAQ) — a point gained per card drawn, half a point per space - * walked, permanently — and a FULL REFLECTION returns a permanent - * curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of - * letting it evaporate. - * Rev 12: MAD DASH doubles "NUMBER cards and other add-ons" too — a - * number riding the cast fuels it, and numbers or POWER RUN points - * played under the dash double; older games doubled only the base - * allowance and consumed the rider without effect. - * Rev 13: SLOW DEATH's per-draw bites land as ONE blow that pauses - * for a victim holding ABSORB or BLUNT — countered against the total - * (absorb soaks up to 3, blunt halves rounding up); older games bit - * instantly. + * What each revision changed is RULES_REVISIONS, above. */ deckRev?: number; } diff --git a/packages/web/public/hero.jpg b/packages/web/public/hero.jpg new file mode 100644 index 0000000..f3eae45 Binary files /dev/null and b/packages/web/public/hero.jpg differ diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 59f04c4..2937082 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -244,7 +244,7 @@ let faqCardId = $state(null); /** A discard-pile card enlarged above the pile. */ let discardPeek = $state(null); - let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play"); + let helpTab = $state<"play" | "rules" | "cards" | "rulings" | "about" | "tally">("play"); let hotseatCount = $state(2); let setupName = $state(""); let setupColor = $state(0); @@ -1316,6 +1316,94 @@ councilHints = tableHints(view); } + /** "Play now": one tap from the landing to a game against a clockwork + * wizard. The room is created, a bot seated, the boards flipped, each + * step as the server confirms the last; the same chain re-seats the + * bots of a finished game for "play again". */ + let playNowPending = $state<{ stage: "create" | "seating" | "starting"; bots: { style?: string; tier: string }[]; seated: number } | null>(null); + function playNow() { + setPref("wizardName", name.trim()); + playNowPending = { stage: "create", bots: [{ tier: prefs.botTier }], seated: 0 }; + net.create(name.trim()); + } + function playAgain() { + // The bots of the table just finished, as the roster names them ("adept hunter"). + const bots = Object.values(net.roomBots).map((label) => { + const [tier, style] = String(label).split(" "); + return { tier: tier ?? prefs.botTier, style: style && style !== "mystery" ? style : undefined }; + }); + const me = net.you ?? name.trim(); + net.leave(); + playNowPending = { stage: "create", bots: bots.length ? bots : [{ tier: prefs.botTier }], seated: 0 }; + setTimeout(() => net.create(me), 300); + } + $effect(() => { + const p = playNowPending; + if (!p || !net.roomId || net.you !== net.hostId) return; + if (net.started) { playNowPending = null; return; } + const botsNow = Object.keys(net.roomBots).length; + if (p.stage !== "starting" && botsNow < p.bots.length && botsNow === p.seated) { + const b = p.bots[botsNow]!; + net.addBot(b.style, b.tier); + playNowPending = { ...p, stage: "seating", seated: botsNow + 1 }; + } else if (p.stage !== "starting" && botsNow >= p.bots.length && net.players.length >= 2) { + net.start(withExpansion); + playNowPending = { ...p, stage: "starting" }; + } + }); + + /** The whole tale, as a link to hand around. */ + let taleState = $state<"idle" | "minting" | "copied" | "failed">("idle"); + async function shareTale() { + if (taleState === "minting") return; + taleState = "minting"; + try { + const url = await net.requestShare(-1); + await navigator.clipboard.writeText(url); + taleState = "copied"; + } catch { + taleState = "failed"; + } + setTimeout(() => (taleState = "idle"), 4000); + } + + /** A finished game counts toward this browser's experience: after the + * first, the automatons step up from apprentice unless the tier was + * picked by hand. */ + let finishedRoom: string | null = null; + $effect(() => { + if (view?.phase !== "finished" || !view.winner || local.active) return; + const room = net.roomId; + if (!room || finishedRoom === room) return; + finishedRoom = room; + setPref("finishedGames", prefs.finishedGames + 1); + if (!prefs.tierChosen && prefs.botTier === "apprentice") setPref("botTier", "adept"); + }); + + /** On a first turn the council speaks unasked, once — a newcomer will + * not know to ask, and a minute's silence is how they leave. */ + let counciled = false; + $effect(() => { + if (!view || !isYourTurn || prefs.coached || view.turn.round !== 1 || counciled || councilHints !== null) return; + counciled = true; + void askCouncil(); + }); + + /** Arrow keys and WASD walk on the board, as they do in the first-person + * pane — that pane takes the keys itself while it is up. */ + function onBoardKey(e: KeyboardEvent) { + if (!view || !isYourTurn || prefs.liveFp || net.spectating || showHelp || caution || youMustRespond) return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return; + const k = e.key.toLowerCase(); + const side: Side | null = + k === "arrowup" || k === "w" ? "N" : k === "arrowdown" || k === "s" ? "S" : + k === "arrowleft" || k === "a" ? "W" : k === "arrowright" || k === "d" ? "E" : null; + if (!side) return; + e.preventDefault(); + tryMove(side); + } + let pickChoice = $state(false); function pickUp() { if (treasuresHere.length > 1) { pickChoice = true; return; } @@ -1442,6 +1530,8 @@ } + + {#if fpvWorkshop} {:else if tokenWorkshop} @@ -1984,11 +2074,17 @@ + + Through a wizard's eyes: an ambush watches its corridor through a wormhole + watch clips from the labyrinth +
+
+ More ways to play — pass one device around, or claim a transferred seat
or play here, passing the device:
@@ -2032,6 +2130,7 @@ Claim a transferred seat
+
{#if net.seats.length > 0}
@@ -2157,7 +2256,7 @@ ⚙ seat a