The first minute, for a stranger: what the game is, what to do, and how to join
The landing says the pitch before it asks for a name — steal treasure, blast through walls, outwit your friends — and states the goal. The coach says "select a card, then choose its target," which is what the cards actually want. The turn banner counts treasures home beside the moves left. The council speaks once when its three voices agree. A friend arriving on an invite link meets "Join Eric's game," a name field, and a seat, with the gallery an afterthought; the masthead says "invite friends" beside the room code. The first-person view has a toggle beside the board instead of a checkbox in preferences. How to play opens with winning. And a shared moment's card is titled by the turn's story — the treasure carried home, the blow that landed, the spell cast — instead of whose turn it was. 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
a071494f4a
commit
4cc0423846
@@ -36,6 +36,7 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
|
||||||
import { extname, join, normalize, sep } from "node:path";
|
import { extname, join, normalize, sep } from "node:path";
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import { cardDef } from "@wizwar/engine";
|
||||||
import type { Command, PlayerId } from "@wizwar/engine";
|
import type { Command, PlayerId } from "@wizwar/engine";
|
||||||
import {
|
import {
|
||||||
catchUpSteps,
|
catchUpSteps,
|
||||||
@@ -198,11 +199,43 @@ function safeBase(rawHost: string, rawProto: string): string {
|
|||||||
return `${proto}://${escapeHtml(rawHost)}`;
|
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 {
|
||||||
|
const name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } };
|
||||||
|
let best: { rank: number; text: string } | null = null;
|
||||||
|
const offer = (rank: number, text: string) => { if (!best || rank > best.rank) best = { rank, text }; };
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? (best as { text: string }).text : null;
|
||||||
|
}
|
||||||
|
|
||||||
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
||||||
const base = safeBase(rawHost, rawProto);
|
const base = safeBase(rawHost, rawProto);
|
||||||
|
const headline = data.whole ? null : headlineOf(data);
|
||||||
const title = data.whole
|
const title = data.whole
|
||||||
? "The whole tale — a game of Wiz-War, replayed"
|
? "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
|
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.`
|
? `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. ` +
|
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
|
||||||
|
|||||||
+59
-24
@@ -1492,6 +1492,11 @@
|
|||||||
function pass() { dispatch({ type: "pass" }); }
|
function pass() { dispatch({ type: "pass" }); }
|
||||||
|
|
||||||
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
|
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
|
||||||
|
/** Enemy treasures resting on your home square: two wins the game. */
|
||||||
|
const treasuresHome = $derived(
|
||||||
|
view && me ? view.treasures.filter((t) => t.owner !== me!.id && t.position &&
|
||||||
|
t.position.x === me!.home.x && t.position.y === me!.home.y).length : 0,
|
||||||
|
);
|
||||||
const paBurnable = $derived(me ? Array.from({ length: Math.max(0, me.life - 1) }, (_, i) => i + 1) : []);
|
const paBurnable = $derived(me ? Array.from({ length: Math.max(0, me.life - 1) }, (_, i) => i + 1) : []);
|
||||||
/** Two treasures can share a square: one dispatches, more ask whose. */
|
/** Two treasures can share a square: one dispatches, more ask whose. */
|
||||||
const treasuresHere = $derived(
|
const treasuresHere = $derived(
|
||||||
@@ -1660,7 +1665,7 @@
|
|||||||
{:else if net.roomId}
|
{:else if net.roomId}
|
||||||
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
|
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
|
||||||
onclick={copyInvite}>
|
onclick={copyInvite}>
|
||||||
{net.spectating ? "watching" : "room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
|
{net.spectating ? "watching" : "invite friends · room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
|
||||||
</button>
|
</button>
|
||||||
{#if net.audience > 0}
|
{#if net.audience > 0}
|
||||||
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
||||||
@@ -2232,7 +2237,9 @@
|
|||||||
<section class="boxlid">
|
<section class="boxlid">
|
||||||
<div class="boxlid-inner">
|
<div class="boxlid-inner">
|
||||||
<div class="boxlid-title">Wiz-War</div>
|
<div class="boxlid-title">Wiz-War</div>
|
||||||
<div class="boxlid-tag">A game of magical combat in a stone labyrinth</div>
|
<div class="boxlid-pitch">Steal treasure. Blast through walls. Outwit your friends.</div>
|
||||||
|
<div class="boxlid-tag">A turn-based wizard board game for two to six, free in your browser, with
|
||||||
|
friends or clockwork opponents. Carry home two treasures that aren't yours, or be the last wizard standing.</div>
|
||||||
<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>
|
||||||
@@ -2375,12 +2382,29 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if net.spectating}
|
||||||
|
{#if net.players.length < 6}
|
||||||
|
<!-- A friend's link lands here: the seat is the invitation, the gallery the afterthought. -->
|
||||||
|
<div class="lobby-head">Join {net.hostId ?? "the"}'s game</div>
|
||||||
|
<div class="boxlid-tag">Choose your wizard's name and take a seat; the boards flip when the host starts.</div>
|
||||||
|
<div class="gallery-join">
|
||||||
|
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
|
||||||
|
<button class="stamp primary" disabled={!name.trim()}
|
||||||
|
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
|
||||||
|
Take a seat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="waiting">👁 Just watching? You're in the Peanut Gallery until you sit.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="waiting">👁 The table is full — you watch from the Peanut Gallery. Waiting for the boards to flip…</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<div class="lobby-head">Fill the table</div>
|
<div class="lobby-head">Fill the table</div>
|
||||||
<div class="boxlid-tag">Two to six wizards enter the maze — friends by code or link,
|
<div class="boxlid-tag">Two to six wizards enter the maze — friends by code or link,
|
||||||
computer wizards seated below, or a mix of both.</div>
|
computer wizards seated below, or a mix of both.</div>
|
||||||
<div class="invite-row">
|
<div class="invite-row">
|
||||||
<code class="invite-link">{inviteLink}</code>
|
<code class="invite-link">{inviteLink}</code>
|
||||||
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy link"}</button>
|
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy invite link"}</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="roster">
|
<ul class="roster">
|
||||||
{#each net.players as p (p)}
|
{#each net.players as p (p)}
|
||||||
@@ -2399,18 +2423,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{#if net.spectating}
|
{#if net.you === net.hostId}
|
||||||
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
|
|
||||||
{#if net.players.length < 6}
|
|
||||||
<div class="gallery-join">
|
|
||||||
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
|
|
||||||
<button class="stamp" disabled={!name.trim()}
|
|
||||||
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
|
|
||||||
…or take a seat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if net.you === net.hostId}
|
|
||||||
{#if net.players.length < 6}
|
{#if net.players.length < 6}
|
||||||
<div class="lobby-head">Seat a computer wizard</div>
|
<div class="lobby-head">Seat a computer wizard</div>
|
||||||
<div class="boxlid-tag">An automaton plays a full seat by the rules — a hunter goes for treasure,
|
<div class="boxlid-tag">An automaton plays a full seat by the rules — a hunter goes for treasure,
|
||||||
@@ -2645,6 +2658,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<section class="board-zone">
|
<section class="board-zone">
|
||||||
|
{#if view.you && !net.spectating}
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="stamp tiny" class:lit={prefs.liveFp} onclick={() => setPref("liveFp", !prefs.liveFp)}
|
||||||
|
title="a first-person pane above the board, through your wizard's eyes">
|
||||||
|
{prefs.liveFp ? "▦ the board only" : "👁 through your eyes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
||||||
{#if prefs.liveFp && view.you && !net.spectating}
|
{#if prefs.liveFp && view.you && !net.spectating}
|
||||||
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
||||||
@@ -2768,7 +2789,7 @@
|
|||||||
picking up ended your actions for this turn — <strong>End turn</strong> when ready.
|
picking up ended your actions for this turn — <strong>End turn</strong> when ready.
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""}
|
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} · treasures home {treasuresHome} of 2
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !prefs.coached && !view.turn.actionsEnded}
|
{#if !prefs.coached && !view.turn.actionsEnded}
|
||||||
@@ -2777,7 +2798,8 @@
|
|||||||
<div class="slip coach">
|
<div class="slip coach">
|
||||||
<strong>How a turn works.</strong> Walk up to {view.turn.movementAllowance} squares by tapping
|
<strong>How a turn works.</strong> Walk up to {view.turn.movementAllowance} squares by tapping
|
||||||
the square beside your wizard, one step at a time; play a NUMBER card first to walk farther.
|
the square beside your wizard, one step at a time; play a NUMBER card first to walk farther.
|
||||||
Tap a card to cast it. When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
|
Select a card, then choose its target — a wizard, a square, or a wall, as the hint bar says.
|
||||||
|
When you're done, <strong>End turn</strong>. Stuck? Ask the automatons for hints.
|
||||||
<button class="stamp tiny" onclick={() => setPref("coached", true)}>got it</button>
|
<button class="stamp tiny" onclick={() => setPref("coached", true)}>got it</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -2832,13 +2854,17 @@
|
|||||||
<div class="council-head">The council considers your position…
|
<div class="council-head">The council considers your position…
|
||||||
<button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button>
|
<button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button>
|
||||||
</div>
|
</div>
|
||||||
{#each councilHints as h (h.name)}
|
{#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)}
|
||||||
<div class="council-row">
|
<div class="council-row"><span class="council-name">The council agrees:</span> {councilHints[0]!.advice}</div>
|
||||||
<span class="council-name">{h.glyph} {h.name}</span>
|
{:else}
|
||||||
<span class="council-mood">({h.mood})</span>
|
{#each councilHints as h (h.name)}
|
||||||
{h.advice}
|
<div class="council-row">
|
||||||
</div>
|
<span class="council-name">{h.glyph} {h.name}</span>
|
||||||
{/each}
|
<span class="council-mood">({h.mood})</span>
|
||||||
|
{h.advice}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
<button class="stamp tiny" onclick={askCouncil}>ask again</button>
|
<button class="stamp tiny" onclick={askCouncil}>ask again</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -3445,6 +3471,15 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.boxlid-title.small { font-size: 1.7rem; }
|
.boxlid-title.small { font-size: 1.7rem; }
|
||||||
|
.boxlid-pitch {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #3a2f1f;
|
||||||
|
margin: 0.2rem 0 0.1rem;
|
||||||
|
}
|
||||||
|
.view-toggle { display: flex; justify-content: flex-end; margin: 0 0 0.3rem; }
|
||||||
.boxlid-tag {
|
.boxlid-tag {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #6b5a41;
|
color: #6b5a41;
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ export const RULES_SECTIONS: RulesSection[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const HOW_TO_PLAY: 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.",
|
||||||
|
"A turn is: walk, then act. Step up to three squares, select a card and choose its target, and End turn. The banner above the board keeps count of your moves and your treasures home.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Moving",
|
title: "Moving",
|
||||||
body: [
|
body: [
|
||||||
|
|||||||
Reference in New Issue
Block a user