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 { 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 {
|
||||
catchUpSteps,
|
||||
@@ -198,10 +199,42 @@ 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 {
|
||||
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 {
|
||||
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"
|
||||
: 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.`
|
||||
|
||||
+52
-17
@@ -1492,6 +1492,11 @@
|
||||
function pass() { dispatch({ type: "pass" }); }
|
||||
|
||||
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) : []);
|
||||
/** Two treasures can share a square: one dispatches, more ask whose. */
|
||||
const treasuresHere = $derived(
|
||||
@@ -1660,7 +1665,7 @@
|
||||
{:else if net.roomId}
|
||||
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
|
||||
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>
|
||||
{#if net.audience > 0}
|
||||
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
||||
@@ -2232,7 +2237,9 @@
|
||||
<section class="boxlid">
|
||||
<div class="boxlid-inner">
|
||||
<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; }}>
|
||||
about this game — a labor of love
|
||||
</button>
|
||||
@@ -2375,12 +2382,29 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/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="boxlid-tag">Two to six wizards enter the maze — friends by code or link,
|
||||
computer wizards seated below, or a mix of both.</div>
|
||||
<div class="invite-row">
|
||||
<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>
|
||||
<ul class="roster">
|
||||
{#each net.players as p (p)}
|
||||
@@ -2399,18 +2423,7 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if net.spectating}
|
||||
<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.you === net.hostId}
|
||||
{#if net.players.length < 6}
|
||||
<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,
|
||||
@@ -2645,6 +2658,14 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
<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}>
|
||||
{#if prefs.liveFp && view.you && !net.spectating}
|
||||
<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.
|
||||
{/if}
|
||||
{: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}
|
||||
</div>
|
||||
{#if !prefs.coached && !view.turn.actionsEnded}
|
||||
@@ -2777,7 +2798,8 @@
|
||||
<div class="slip coach">
|
||||
<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.
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2832,6 +2854,9 @@
|
||||
<div class="council-head">The council considers your position…
|
||||
<button class="hint-cancel" onclick={() => (councilHints = null)}>enough</button>
|
||||
</div>
|
||||
{#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)}
|
||||
<div class="council-row"><span class="council-name">The council agrees:</span> {councilHints[0]!.advice}</div>
|
||||
{:else}
|
||||
{#each councilHints as h (h.name)}
|
||||
<div class="council-row">
|
||||
<span class="council-name">{h.glyph} {h.name}</span>
|
||||
@@ -2839,6 +2864,7 @@
|
||||
{h.advice}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<button class="stamp tiny" onclick={askCouncil}>ask again</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -3445,6 +3471,15 @@
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.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 {
|
||||
font-size: 0.95rem;
|
||||
color: #6b5a41;
|
||||
|
||||
@@ -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.",
|
||||
"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",
|
||||
body: [
|
||||
|
||||
Reference in New Issue
Block a user