Eight quality-of-life turns for strangers, newcomers, and veterans
For visitors: the landing shows a clip card of the maze before asking for a name; "Play now against a clockwork wizard" is one tap from the landing to the first turn (the room, the seat, and the flip each follow as the server confirms the last); pass-the-device and seat transfer fold behind "More ways to play". For a first game: automatons default to apprentice until this browser has seen a game through, then step up to adept unless the tier was picked by hand; the council speaks once unasked on a first turn; a finished game offers "play again" (the same bots re-seated in a fresh room) and "share the tale" (the whole replay as a link). For players who know the box: a House rulings tab in help — what the digital table does differently, every rulings revision (now exported from the engine as RULES_REVISIONS, which the deckRev doc block points at), and the 76 cards the FAQ has spoken on. And the arrow keys and WASD walk on the board as they already did in the first-person pane. The recorder's poster settle lengthens to outlast a change-of-eyes wipe; two cards had caught the title card instead of the maze. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2b5c2c4884
commit
53fba77a55
@@ -22,7 +22,7 @@ if (!outDir) { console.error("usage: film-clips.mjs <out-dir> [scene ...]"); pro
|
|||||||
const PORT = 8809;
|
const PORT = 8809;
|
||||||
const FPS = 25;
|
const FPS = 25;
|
||||||
const TAIL_MS = 2500; // hold on the last move
|
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 EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
|
||||||
|
|
||||||
const server = startServer(PORT, "wizwar-film");
|
const server = startServer(PORT, "wizwar-film");
|
||||||
|
|||||||
+19
-37
@@ -240,6 +240,24 @@ export interface CastParams {
|
|||||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||||
export const CURRENT_RULES_REV = 13;
|
export const CURRENT_RULES_REV = 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 {
|
export interface GameConfig {
|
||||||
playerIds: PlayerId[];
|
playerIds: PlayerId[];
|
||||||
seed: number;
|
seed: number;
|
||||||
@@ -251,43 +269,7 @@ export interface GameConfig {
|
|||||||
/**
|
/**
|
||||||
* Rules revision, frozen per game so stored games replay unchanged.
|
* Rules revision, frozen per game so stored games replay unchanged.
|
||||||
* Absent = rev 1, the baseline all earlier revisions collapsed into.
|
* Absent = rev 1, the baseline all earlier revisions collapsed into.
|
||||||
* Rev 2: a wizard beside a door they can open (lock removed, door
|
* What each revision changed is RULES_REVISIONS, above.
|
||||||
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
|
|
||||||
* the doorway; the hallway behind them still cannot.
|
|
||||||
* Rev 3: VISIONSTONE pierces its one wall for every sight the game
|
|
||||||
* asks of its bearer — creations and utility spells included — not
|
|
||||||
* only direct attacks.
|
|
||||||
* Rev 4: "the door will relock behind you" means it — passing through
|
|
||||||
* an unlocked door shuts it at the walker's back unless a hand holds
|
|
||||||
* it; unpassed, it relocks at turn's end as before.
|
|
||||||
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
|
|
||||||
* in three spaces, instead of eating the card silently at resolution.
|
|
||||||
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this
|
|
||||||
* would be an attack") instead of resolving instantly.
|
|
||||||
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the
|
|
||||||
* caster carries it, sharing a square bites both directions, no
|
|
||||||
* counteraction.
|
|
||||||
* Rev 8: the fire imp's scorch is a SPELL (FAQ) — counteractable,
|
|
||||||
* magical — and SOULSTONE's floor holds even at 3 life or below.
|
|
||||||
* Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ...
|
|
||||||
* otherwise work as written"), though nothing a counter does touches
|
|
||||||
* its caster.
|
|
||||||
* Rev 10: BUTT-HEAD's ram is MOVEMENT — the charge walks real
|
|
||||||
* corridors on the turn's legal legs (three plus numbers) and spends
|
|
||||||
* them.
|
|
||||||
* Rev 11: a REVERSE against SLOW DEATH or WALKING DEAD turns the whole
|
|
||||||
* curse (FAQ) — a point gained per card drawn, half a point per space
|
|
||||||
* walked, permanently — and a FULL REFLECTION returns a permanent
|
|
||||||
* curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of
|
|
||||||
* letting it evaporate.
|
|
||||||
* Rev 12: MAD DASH doubles "NUMBER cards and other add-ons" too — a
|
|
||||||
* number riding the cast fuels it, and numbers or POWER RUN points
|
|
||||||
* played under the dash double; older games doubled only the base
|
|
||||||
* allowance and consumed the rider without effect.
|
|
||||||
* Rev 13: SLOW DEATH's per-draw bites land as ONE blow that pauses
|
|
||||||
* for a victim holding ABSORB or BLUNT — countered against the total
|
|
||||||
* (absorb soaks up to 3, blunt halves rounding up); older games bit
|
|
||||||
* instantly.
|
|
||||||
*/
|
*/
|
||||||
deckRev?: number;
|
deckRev?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
+118
-3
@@ -244,7 +244,7 @@
|
|||||||
let faqCardId = $state<string | null>(null);
|
let faqCardId = $state<string | null>(null);
|
||||||
/** A discard-pile card enlarged above the pile. */
|
/** A discard-pile card enlarged above the pile. */
|
||||||
let discardPeek = $state<CardInstance | null>(null);
|
let discardPeek = $state<CardInstance | null>(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 hotseatCount = $state(2);
|
||||||
let setupName = $state("");
|
let setupName = $state("");
|
||||||
let setupColor = $state(0);
|
let setupColor = $state(0);
|
||||||
@@ -1316,6 +1316,94 @@
|
|||||||
councilHints = tableHints(view);
|
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);
|
let pickChoice = $state(false);
|
||||||
function pickUp() {
|
function pickUp() {
|
||||||
if (treasuresHere.length > 1) { pickChoice = true; return; }
|
if (treasuresHere.length > 1) { pickChoice = true; return; }
|
||||||
@@ -1442,6 +1530,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onBoardKey} />
|
||||||
|
|
||||||
{#if fpvWorkshop}
|
{#if fpvWorkshop}
|
||||||
<FpvWorkshop />
|
<FpvWorkshop />
|
||||||
{:else if tokenWorkshop}
|
{:else if tokenWorkshop}
|
||||||
@@ -1984,11 +2074,17 @@
|
|||||||
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
|
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
|
||||||
about this game — a labor of love
|
about this game — a labor of love
|
||||||
</button>
|
</button>
|
||||||
|
<a class="hero" href="/clips" aria-label="watch clips from the labyrinth">
|
||||||
|
<img src="/hero.jpg" alt="Through a wizard's eyes: an ambush watches its corridor through a wormhole" />
|
||||||
|
</a>
|
||||||
<a class="about-link clips-link" href="/clips">watch clips from the labyrinth</a>
|
<a class="about-link clips-link" href="/clips">watch clips from the labyrinth</a>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Your wizard's name</span>
|
<span>Your wizard's name</span>
|
||||||
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" />
|
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" />
|
||||||
</label>
|
</label>
|
||||||
|
<button class="stamp primary play-now" disabled={!name.trim() || playNowPending !== null} onclick={playNow}>
|
||||||
|
{playNowPending ? "Flipping the boards…" : "Play now against a clockwork wizard"}
|
||||||
|
</button>
|
||||||
<div class="boxlid-actions">
|
<div class="boxlid-actions">
|
||||||
<button class="stamp" disabled={!name.trim()} onclick={() => { setPref("wizardName", name.trim()); net.create(name); }}>
|
<button class="stamp" disabled={!name.trim()} onclick={() => { setPref("wizardName", name.trim()); net.create(name); }}>
|
||||||
Create a game
|
Create a game
|
||||||
@@ -2005,6 +2101,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="more-ways" open={local.hasSave()}>
|
||||||
|
<summary>More ways to play — pass one device around, or claim a transferred seat</summary>
|
||||||
<div class="hotseat-row">
|
<div class="hotseat-row">
|
||||||
<span class="hotseat-label">or play here, passing the device:</span>
|
<span class="hotseat-label">or play here, passing the device:</span>
|
||||||
<div class="count-picker" role="group" aria-label="number of wizards">
|
<div class="count-picker" role="group" aria-label="number of wizards">
|
||||||
@@ -2032,6 +2130,7 @@
|
|||||||
Claim a transferred seat
|
Claim a transferred seat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
{#if net.seats.length > 0}
|
{#if net.seats.length > 0}
|
||||||
<div class="ledger">
|
<div class="ledger">
|
||||||
@@ -2157,7 +2256,7 @@
|
|||||||
<span class="bot-row">
|
<span class="bot-row">
|
||||||
<span class="bot-label">⚙ seat a</span>
|
<span class="bot-label">⚙ seat a</span>
|
||||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
||||||
onchange={() => setPref("botTier", botTier)}>
|
onchange={() => { setPref("botTier", botTier); setPref("tierChosen", true); }}>
|
||||||
<option value="apprentice">apprentice</option>
|
<option value="apprentice">apprentice</option>
|
||||||
<option value="adept">adept</option>
|
<option value="adept">adept</option>
|
||||||
<option value="archmage">archmage</option>
|
<option value="archmage">archmage</option>
|
||||||
@@ -2449,7 +2548,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if view.phase === "finished"}
|
{#if view.phase === "finished"}
|
||||||
<div class="slip winner">🏆 {view.winner} wins!</div>
|
<div class="slip winner">🏆 {view.winner} wins!
|
||||||
|
{#if !local.active}
|
||||||
|
{#if net.you === net.hostId}
|
||||||
|
<button class="stamp tiny" onclick={playAgain}>play again</button>
|
||||||
|
{/if}
|
||||||
|
<button class="stamp tiny" onclick={shareTale}>
|
||||||
|
{taleState === "idle" ? "share the tale" : taleState === "minting" ? "…" : taleState === "copied" ? "link copied" : "share failed"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if youMustRespond}
|
{:else if youMustRespond}
|
||||||
<div class="slip urgent">
|
<div class="slip urgent">
|
||||||
{#if reflectSwapCard}
|
{#if reflectSwapCard}
|
||||||
@@ -3175,6 +3283,13 @@
|
|||||||
color: #6b5a41;
|
color: #6b5a41;
|
||||||
margin: 0.5rem 0 0.4rem;
|
margin: 0.5rem 0 0.4rem;
|
||||||
}
|
}
|
||||||
|
.hero { display: block; margin: 0.6rem auto 0; max-width: 24rem; border-radius: 6px; overflow: hidden;
|
||||||
|
border: 1.5px solid #43331f; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); }
|
||||||
|
.hero img { display: block; width: 100%; }
|
||||||
|
.play-now { display: block; margin: 0.2rem auto 0.8rem; font-size: 0.95rem; padding: 0.6rem 1.2rem; }
|
||||||
|
.more-ways { margin-top: 1.2rem; }
|
||||||
|
.more-ways summary { cursor: pointer; font-family: "Caveat", cursive; font-size: 1.05rem; color: #8a6d3f; }
|
||||||
|
.more-ways summary:hover { color: #43331f; }
|
||||||
.lobby-head {
|
.lobby-head {
|
||||||
font-family: "Oswald", sans-serif;
|
font-family: "Oswald", sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { allCardDefs } from "@wizwar/engine";
|
import { allCardDefs, RULES_REVISIONS } from "@wizwar/engine";
|
||||||
import Card from "./Card.svelte";
|
import Card from "./Card.svelte";
|
||||||
import Faq from "./Faq.svelte";
|
import Faq from "./Faq.svelte";
|
||||||
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
|
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
|
||||||
@@ -13,13 +13,15 @@
|
|||||||
onstats,
|
onstats,
|
||||||
}: {
|
}: {
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
initialTab?: "play" | "rules" | "cards" | "about" | "tally";
|
initialTab?: "play" | "rules" | "cards" | "rulings" | "about" | "tally";
|
||||||
stats?: Record<string, number | string | null> | null;
|
stats?: Record<string, number | string | null> | null;
|
||||||
onstats?: () => void;
|
onstats?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
|
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
|
||||||
let tab = $state<"play" | "rules" | "cards" | "about" | "tally">(initialTab);
|
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 faqCardId = $state<string | null>(null);
|
||||||
let libPeek = $state<string | null>(null);
|
let libPeek = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
<button class:current={tab === "play"} onclick={() => (tab = "play")}>How to play</button>
|
<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 === "rules"} onclick={() => (tab = "rules")}>The rules</button>
|
||||||
<button class:current={tab === "cards"} onclick={() => (tab = "cards")}>Card library</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 === "about"} onclick={() => (tab = "about")}>About</button>
|
||||||
<button class:current={tab === "tally"} onclick={openTally}>The tally</button>
|
<button class:current={tab === "tally"} onclick={openTally}>The tally</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -184,6 +187,50 @@
|
|||||||
<h3>{section.title}</h3>
|
<h3>{section.title}</h3>
|
||||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||||
{/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>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>
|
||||||
|
{/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}
|
||||||
{:else if tab === "rules"}
|
{:else if tab === "rules"}
|
||||||
<p class="colophon">
|
<p class="colophon">
|
||||||
Sixth edition rules, from the original rulebook. © 1985 Jolly Games —
|
Sixth edition rules, from the original rulebook. © 1985 Jolly Games —
|
||||||
@@ -336,6 +383,13 @@
|
|||||||
margin: 1.2rem 0 0.4rem;
|
margin: 1.2rem 0 0.4rem;
|
||||||
}
|
}
|
||||||
.booklet-body h3:first-child { margin-top: 0; }
|
.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; }
|
||||||
.booklet-body p { margin: 0.35rem 0; }
|
.booklet-body p { margin: 0.35rem 0; }
|
||||||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; }
|
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; }
|
||||||
.booklet-body a { color: #8a4a1f; text-decoration-style: dotted; }
|
.booklet-body a { color: #8a4a1f; text-decoration-style: dotted; }
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export interface Prefs {
|
|||||||
handLeft: boolean;
|
handLeft: boolean;
|
||||||
/** The first-turn coaching slip has been read and dismissed. */
|
/** The first-turn coaching slip has been read and dismissed. */
|
||||||
coached: boolean;
|
coached: boolean;
|
||||||
|
/** The automaton tier was picked by hand (so a finished game leaves it alone). */
|
||||||
|
tierChosen: boolean;
|
||||||
|
/** Games this browser has seen through to a winner. */
|
||||||
|
finishedGames: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "wizwar-prefs";
|
const KEY = "wizwar-prefs";
|
||||||
@@ -34,8 +38,8 @@ const KEY = "wizwar-prefs";
|
|||||||
function load(): Prefs {
|
function load(): Prefs {
|
||||||
const fallback: Prefs = {
|
const fallback: Prefs = {
|
||||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||||
cautions: true, botTier: "adept", instantReplay: true, liveFp: false, handLeft: false,
|
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false,
|
||||||
coached: false,
|
coached: false, tierChosen: false, finishedGames: 0,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(KEY);
|
const raw = localStorage.getItem(KEY);
|
||||||
@@ -48,11 +52,15 @@ function load(): Prefs {
|
|||||||
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
|
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
|
||||||
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
||||||
cautions: p.cautions !== false,
|
cautions: p.cautions !== false,
|
||||||
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
// 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,
|
instantReplay: p.instantReplay !== false,
|
||||||
liveFp: p.liveFp === true,
|
liveFp: p.liveFp === true,
|
||||||
handLeft: p.handLeft === true,
|
handLeft: p.handLeft === true,
|
||||||
coached: p.coached === true,
|
coached: p.coached === true,
|
||||||
|
tierChosen: p.tierChosen === true,
|
||||||
|
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
Reference in New Issue
Block a user