diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 8444dab..4237fe7 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -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,11 +199,43 @@ 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"
- : `${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. ` +
diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index 897d698..427beab 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -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}
{#if net.audience > 0}
👁 {net.audience}
@@ -2232,7 +2237,9 @@
Wiz-War
-
A game of magical combat in a stone labyrinth
+
Steal treasure. Blast through walls. Outwit your friends.
+
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.
An automaton plays a full seat by the rules — a hunter goes for treasure,
@@ -2645,6 +2658,14 @@
{/if}
{/snippet}
+ {#if view.you && !net.spectating}
+
+
+
+ {/if}
{#if prefs.liveFp && view.you && !net.spectating}
setPref("liveFp", false)}
@@ -2768,7 +2789,7 @@
picking up ended your actions for this turn — End turn 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}
How a turn works. 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, End turn. 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, End turn. Stuck? Ask the automatons for hints.
{/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;
diff --git a/packages/web/src/rules.ts b/packages/web/src/rules.ts
index efa4804..2cde904 100644
--- a/packages/web/src/rules.ts
+++ b/packages/web/src/rules.ts
@@ -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: [