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.
@@ -2375,12 +2382,29 @@ {/each}
{/if} + {#if net.spectating} + {#if net.players.length < 6} + +
Join {net.hostId ?? "the"}'s game
+
Choose your wizard's name and take a seat; the boards flip when the host starts.
+ +

👁 Just watching? You're in the Peanut Gallery until you sit.

+ {:else} +

👁 The table is full — you watch from the Peanut Gallery. Waiting for the boards to flip…

+ {/if} + {/if}
Fill the table
Two to six wizards enter the maze — friends by code or link, computer wizards seated below, or a mix of both.
{inviteLink} - +
- {#if net.spectating} -

👁 You watch from the Peanut Gallery. Waiting for the boards to flip…

- {#if net.players.length < 6} - - {/if} - {:else if net.you === net.hostId} + {#if net.you === net.hostId} {#if net.players.length < 6}
Seat a computer wizard
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}
{#if !prefs.coached && !view.turn.actionsEnded} @@ -2777,7 +2798,8 @@
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} @@ -2832,13 +2854,17 @@
The council considers your position…
- {#each councilHints as h (h.name)} -
- {h.glyph} {h.name} - ({h.mood}) - {h.advice} -
- {/each} + {#if councilHints.length > 1 && councilHints.every((h) => h.advice === councilHints![0]!.advice)} +
The council agrees: {councilHints[0]!.advice}
+ {:else} + {#each councilHints as h (h.name)} +
+ {h.glyph} {h.name} + ({h.mood}) + {h.advice} +
+ {/each} + {/if}
{/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: [