Compare commits
25
Commits
6e49fc9020
...
main
@@ -22,7 +22,11 @@ quiet, say so in one line before the details.
|
||||
protocol error"` — anything nonzero deserves a look (Sentry has
|
||||
the stack traces)
|
||||
2. **Sentry** (MCP connector, org `locallygrownnet`, project `wizwar`):
|
||||
- `search_issues` for `is:unresolved` — new issues are the headline
|
||||
- `search_issues` for `is:unresolved` — new issues are the headline.
|
||||
An issue titled "<name> challenges Kestrel to a game — <link>" is
|
||||
not an error: it is the keeper's bell (a lobby called Eric to a
|
||||
seat; the room link is in the title). Report it as a caller waiting,
|
||||
with the room code; Eric resolves it himself once answered.
|
||||
- `get_monitor_details` for cron monitor slug `new-monitor` (named
|
||||
wizwar-backup) — last check-in must be ok and recent; the uptime
|
||||
monitor alerts on its own but note any incidents
|
||||
|
||||
+172
-14
@@ -247,7 +247,7 @@ export interface CastParams {
|
||||
}
|
||||
|
||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||
export const CURRENT_RULES_REV = 21;
|
||||
export const CURRENT_RULES_REV = 24;
|
||||
|
||||
/** 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
|
||||
@@ -273,6 +273,9 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
|
||||
{ rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." },
|
||||
{ rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." },
|
||||
{ rev: 21, note: "Two cards keep their whole promise. LIFESAVER's holder is not eliminated for losing both treasures. FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them — on every side, where the card says one." },
|
||||
{ rev: 22, note: "TELEPORT ignores the maze's outer edge as it ignores any wall: a teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on. Older games crossed the edge only at the lettered openings." },
|
||||
{ rev: 23, note: "POWER DRAIN drains the number played: the caster gains it whether the blow is BLUNTed or ABSORBed (FAQ: the counter blunts the damage done, not the drain), and a wall drained for its points gives them up too. Older games gave the caster only what the opponent lost, and nothing from a wall." },
|
||||
{ rev: 24, note: "A REFLECTION against a permanent curse afflicts both wizards: WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties. Older games left the caster untouched." },
|
||||
];
|
||||
|
||||
export interface GameConfig {
|
||||
@@ -610,6 +613,9 @@ function castSightEdge(
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The attacks that burn: what the FAQ lets hurt a KILLER OOZE. */
|
||||
const FIRE_ATTACKS = new Set(["fireball"]);
|
||||
|
||||
function inThornbush(state: GameState, p: PlayerState): boolean {
|
||||
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
||||
}
|
||||
@@ -658,7 +664,10 @@ export type GameEvent =
|
||||
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
|
||||
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
|
||||
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" }
|
||||
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
|
||||
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean;
|
||||
/** The blow before any counter, and what each counter left of it, in
|
||||
* the order they were weighed — the receipt the table reads. */
|
||||
incoming?: number; incomingDuration?: number; trail?: CounterStep[] }
|
||||
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
|
||||
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
|
||||
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" }
|
||||
@@ -786,6 +795,8 @@ export type GameEvent =
|
||||
| { type: "slowDeathCountered"; player: PlayerId; cardId: string; remaining: number }
|
||||
| { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number }
|
||||
| { type: "safeSmashed"; attacker: PlayerId; cell: Cell }
|
||||
| { type: "squareContentDamaged"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze"; amount: number; total: number; needed: number }
|
||||
| { type: "squareContentDestroyed"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze" }
|
||||
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
||||
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
||||
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
||||
@@ -836,6 +847,7 @@ export type Command =
|
||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||
| { type: "creatureWarpStep"; creatureId: string }
|
||||
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
||||
| { type: "creatureAttackWall"; creatureId: string; cell: Cell; side: Side }
|
||||
| {
|
||||
type: "cast";
|
||||
instanceId: string;
|
||||
@@ -941,6 +953,17 @@ interface ResolutionContext {
|
||||
stack: CastStack;
|
||||
}
|
||||
|
||||
/** One counter's mark on the blow: the damage, the half sent back, and
|
||||
* the duration left after it was weighed (unchanged when nullified). */
|
||||
export interface CounterStep {
|
||||
cardId: string;
|
||||
player: PlayerId;
|
||||
nullified: boolean;
|
||||
damage: number;
|
||||
reflected: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface DamagePipeline {
|
||||
damage: number;
|
||||
duration: number;
|
||||
@@ -1104,12 +1127,21 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
requiresLos: true,
|
||||
baseDamage: (n) => n ?? 1,
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.damageDealt <= 0 || !ctx.attacker.alive) return;
|
||||
ctx.attacker.life += ctx.damageDealt;
|
||||
if (!ctx.attacker.alive) return;
|
||||
// Rev 23: the drain is the number played, counters or no — "he is
|
||||
// blunting damage done, not acting upon the attack spell itself"
|
||||
// (FAQ). A returned drain carries its settled amount. Older games
|
||||
// drained only what the opponent lost.
|
||||
const modern = (ctx.state.config.deckRev ?? 1) >= 23 && !ctx.stack.reflectedBase;
|
||||
const gain = modern
|
||||
? (ctx.fullyStopped || ctx.reversed ? 0 : (ctx.stack.numberValue ?? 1) * ctx.stack.amplifyFactor)
|
||||
: ctx.damageDealt;
|
||||
if (gain <= 0) return;
|
||||
ctx.attacker.life += gain;
|
||||
ctx.events.push({
|
||||
type: "lifeGained",
|
||||
player: ctx.attacker.id,
|
||||
amount: ctx.damageDealt,
|
||||
amount: gain,
|
||||
source: "power drain",
|
||||
lifeAfter: ctx.attacker.life,
|
||||
});
|
||||
@@ -1441,7 +1473,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(to)]) return "destination is off the board";
|
||||
if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone";
|
||||
if (wallIgnoringDistance(view, caster.position, to) > 4) {
|
||||
if (wallIgnoringDistance(view, caster.position, to, (state.config.deckRev ?? 1) >= 22) > 4) {
|
||||
return "teleport reaches at most four spaces";
|
||||
}
|
||||
// A teleport is a willing move: FEAR's bubble refuses it.
|
||||
@@ -3408,6 +3440,31 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
/** "This rock-hard beast can punch a player (or a wall, etc.)": the troll's
|
||||
* fist on a wall line beside it, a D4 of damage toward the wall's fall. */
|
||||
function doCreatureAttackWall(prev: GameState, creatureId: string, cell: Cell, side: Side): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
if (prev.turn.round === 1) return err("no combat during the first round of turns");
|
||||
const state = clone(prev);
|
||||
const active = activePlayer(state);
|
||||
const creature = creatureById(state, creatureId);
|
||||
if (!creature) return err("no such creature");
|
||||
if (creature.controllerId !== active.id) return err("that creature does not obey you");
|
||||
if (creature.kind !== "troll") return err("only the troll punches walls");
|
||||
if (creature.justCreated) return err("it cannot attack the turn it was created");
|
||||
if (creature.attackUsed) return err("that creature has already attacked this turn");
|
||||
if (!touchesEdge(creature.position, cell, side)) return err("the troll must stand beside the wall");
|
||||
const events: GameEvent[] = [];
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
state.rng = rngNext;
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: "wall", dieRoll: roll });
|
||||
const problem = damageWall(state, events, active, cell, side, roll, "troll");
|
||||
if (problem) return err(problem);
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */
|
||||
function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void {
|
||||
const view = boardView(state);
|
||||
@@ -3751,8 +3808,25 @@ export function walkingDistance(state: GameState, from: Cell, to: Cell, limit =
|
||||
return seen.get(cellKey(to)) ?? Infinity;
|
||||
}
|
||||
|
||||
/** BFS steps between cells ignoring walls (teleport distance). */
|
||||
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
|
||||
/** Where a line leaving the maze at `cur` through `side` comes back in:
|
||||
* the first square of the maze scanning from the opposite edge along the
|
||||
* same row or column — the maze's edges as one continuous field. */
|
||||
export function edgeReentry(board: AssembledBoard, cur: Cell, side: Side): Cell | null {
|
||||
const cells = Object.keys(board.cells).map((k) => k.split(",").map(Number) as [number, number]);
|
||||
const line = side === "N" || side === "S"
|
||||
? cells.filter(([x]) => x === cur.x).map(([, y]) => y)
|
||||
: cells.filter(([, y]) => y === cur.y).map(([x]) => x);
|
||||
if (line.length === 0) return null;
|
||||
const far = side === "N" || side === "W" ? Math.max(...line) : Math.min(...line);
|
||||
const cell = side === "N" || side === "S" ? { x: cur.x, y: far } : { x: far, y: cur.y };
|
||||
return cellKey(cell) === cellKey(cur) ? null : cell;
|
||||
}
|
||||
|
||||
/** BFS steps between cells ignoring walls (teleport distance). With
|
||||
* `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any
|
||||
* wall: a line leaving the maze re-enters at the opposite edge, one space
|
||||
* on. Without it, only the lettered openings carry a teleporter across. */
|
||||
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number {
|
||||
if (cellKey(from) === cellKey(to)) return 0;
|
||||
const seen = new Map<string, number>([[cellKey(from), 0]]);
|
||||
const queue: Cell[] = [from];
|
||||
@@ -3768,8 +3842,12 @@ export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell
|
||||
const w = board.warps.find(
|
||||
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
|
||||
);
|
||||
if (!w) continue;
|
||||
n = w.to.cell;
|
||||
if (w) n = w.to.cell;
|
||||
else if (wrapEdges) {
|
||||
const back = edgeReentry(board, cur, side);
|
||||
if (!back) continue;
|
||||
n = back;
|
||||
} else continue;
|
||||
}
|
||||
if (seen.has(cellKey(n))) continue;
|
||||
seen.set(cellKey(n), d + 1);
|
||||
@@ -4090,6 +4168,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
|
||||
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
||||
case "creatureAttackWall": return doCreatureAttackWall(state, command.creatureId, command.cell, command.side);
|
||||
case "cast": return doCast(state, command);
|
||||
case "setAmbush": return doSetAmbush(state, command);
|
||||
case "cancelAmbush": return doCancelAmbush(state, command.ambushId);
|
||||
@@ -5450,6 +5529,53 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
// A THORNBUSH or ROSEBUSH: "Five points of damage will destroy it." A
|
||||
// KILLER OOZE: "Only fire ... will hurt the ooze" (FAQ), and five points
|
||||
// burn it away. Damage accumulates across attackers and turns. A
|
||||
// same-square attack (WIZARDBLADE) is swung from a square beside it:
|
||||
// "If target fills an entire square, you must be in an adjacent square."
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const cell = cmd.target.cell;
|
||||
const content = state.squareContents[cellKey(cell)];
|
||||
if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) {
|
||||
const what = content.kind === "ooze" ? "ooze" : content.kind;
|
||||
if (effect.sameSquare) {
|
||||
const dx = cell.x - origin.x, dy = cell.y - origin.y;
|
||||
const side: Side | null = dx === 1 && dy === 0 ? "E" : dx === -1 && dy === 0 ? "W" : dy === 1 && dx === 0 ? "S" : dy === -1 && dx === 0 ? "N" : null;
|
||||
if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") {
|
||||
return err(`you must stand beside the ${what} to use that`);
|
||||
}
|
||||
} else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
|
||||
return err(`no line of sight to the ${what}`);
|
||||
}
|
||||
if (content.kind === "ooze" && !FIRE_ATTACKS.has(inHand.cardId)) {
|
||||
return err("only fire hurts the ooze");
|
||||
}
|
||||
const wandEvents: GameEvent[] = [];
|
||||
{
|
||||
const werr = spendWandCharge(state, caster, wandEvents);
|
||||
if (werr) return err(werr);
|
||||
}
|
||||
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
|
||||
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
state.turn.attackUsed = true;
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [...wandEvents, {
|
||||
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
|
||||
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
|
||||
from: origin, target: null, targetCell: { ...cell },
|
||||
}];
|
||||
content.damage += dmg;
|
||||
events.push({ type: "squareContentDamaged", attacker: caster.id, cell: { ...cell }, kind: content.kind, amount: dmg, total: content.damage, needed: 5 });
|
||||
if (content.damage >= 5) {
|
||||
delete state.squareContents[cellKey(cell)];
|
||||
events.push({ type: "squareContentDestroyed", attacker: caster.id, cell: { ...cell }, kind: content.kind });
|
||||
}
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
}
|
||||
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
|
||||
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
|
||||
// washes the slime away instead.
|
||||
@@ -5532,6 +5658,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
}];
|
||||
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
|
||||
if (problem) return err(problem);
|
||||
// Rev 23: a wall drained for its points gives them up like anyone.
|
||||
if (inHand.cardId === "power-drain" && (state.config.deckRev ?? 1) >= 23 && caster.alive) {
|
||||
caster.life += dmg;
|
||||
events2.push({ type: "lifeGained", player: caster.id, amount: dmg, source: "power drain", lifeAfter: caster.life });
|
||||
}
|
||||
// Thrown weapons clatter to the floor at the foot of the wall.
|
||||
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
|
||||
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
|
||||
@@ -5970,7 +6101,7 @@ function doCounteract(
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(to)]) return err("destination is off the board");
|
||||
if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone");
|
||||
if (wallIgnoringDistance(view, player.position, to) > 4) {
|
||||
if (wallIgnoringDistance(view, player.position, to, (state.config.deckRev ?? 1) >= 22) > 4) {
|
||||
return err("teleport reaches at most four spaces");
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
@@ -6252,6 +6383,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
// Whether this attack even tries to wound: utility attacks (DROP OBJECT,
|
||||
// TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped.
|
||||
const dealsDamage = base > 0;
|
||||
const trail: CounterStep[] = [];
|
||||
const receipt = { incoming: base, incomingDuration: baseDuration, trail };
|
||||
const pipe: DamagePipeline = {
|
||||
damage: base,
|
||||
duration: baseDuration,
|
||||
@@ -6270,18 +6403,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
}
|
||||
for (const counter of stack.counters) {
|
||||
trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration });
|
||||
const mark = trail[trail.length - 1]!;
|
||||
const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; };
|
||||
if (counter.nullified) continue;
|
||||
if (isNumberCard(counter.card.cardId)) {
|
||||
// SHIELDSTONE number counter: reduce point AND duration effects.
|
||||
const v = numberValue(counter.card.cardId);
|
||||
pipe.damage = Math.max(0, pipe.damage - v);
|
||||
pipe.duration = Math.max(0, pipe.duration - v);
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
|
||||
// Fire meets water, whichever was thrown first: entirely stopped.
|
||||
pipe.damage = 0;
|
||||
pipe.fullyStopped = true;
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
|
||||
@@ -6291,6 +6429,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
pipe.damage = 0;
|
||||
pipe.duration = 0;
|
||||
pipe.fullyStopped = true;
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
if (stack.creatureId &&
|
||||
@@ -6303,10 +6442,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
} else {
|
||||
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
|
||||
}
|
||||
weigh();
|
||||
continue;
|
||||
}
|
||||
const ce = CARD_EFFECTS[counter.card.cardId];
|
||||
if (ce && ce.kind === "counter") ce.apply(pipe);
|
||||
weigh();
|
||||
}
|
||||
|
||||
// A surviving teleport counter whisks the defender away before anything lands.
|
||||
@@ -6338,7 +6479,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
});
|
||||
}
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6370,7 +6511,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
reflectedBase: { damage: pipe.damage, duration: pipe.duration },
|
||||
};
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6460,7 +6601,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: "attackResolved",
|
||||
type: "attackResolved", ...receipt,
|
||||
attacker: attacker.id,
|
||||
defender: defender.id,
|
||||
attackCardId: attackId,
|
||||
@@ -6470,6 +6611,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
redirected: pipe.redirected,
|
||||
});
|
||||
|
||||
// Rev 24: "If this spell is REFLECTed, both players take 1/2 point of
|
||||
// damage per space moved" (WALKING DEAD); "In case of REFLECTION, both
|
||||
// players are affected" (IDIOT). A permanent curse has no duration for
|
||||
// REFLECTION's split to halve, so the curse is laid on the caster as
|
||||
// well, as if the defender had cast it back.
|
||||
const curseShared = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 24 &&
|
||||
!pipe.redirected && !pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive &&
|
||||
stack.counters.some((c) => !c.nullified && c.card.cardId === "reflection");
|
||||
if (effect?.onResolved && curseShared) {
|
||||
effect.onResolved({
|
||||
state, events,
|
||||
attacker: defender, defender: attacker,
|
||||
origin: defender.position,
|
||||
damageDealt: 0, fullyStopped: false, reversed: pipe.reversed, duration: pipe.duration,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
if (effect?.onResolved && !pipe.redirected) {
|
||||
effect.onResolved({
|
||||
state,
|
||||
|
||||
+99
-43
@@ -2,13 +2,15 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
edgeReentry,
|
||||
LOS_BLOCKING_CONTENT,
|
||||
type AmbushState,
|
||||
type CastStack,
|
||||
type GameEvent,
|
||||
type PushPending,
|
||||
type CreatureState,
|
||||
type GameState,
|
||||
@@ -328,8 +330,8 @@ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Recor
|
||||
* (believed illusion walls block; held doors admit). Null when no sight
|
||||
* exists — the renderer's material for drawing the line an attack traveled.
|
||||
*/
|
||||
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
|
||||
if (dustAtEnd(view, from, to)) return null;
|
||||
export function traceSightFor(view: GameView, from: Cell, to: Cell, ignoreDust = false): SightTrace | null {
|
||||
if (!ignoreDust && dustAtEnd(view, from, to)) return null;
|
||||
const { board, blockers } = sightBasis(view);
|
||||
return traceSight(board, from, to, blockers);
|
||||
}
|
||||
@@ -372,54 +374,96 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
|
||||
* defender sharing a square, or no sight under this viewer's knowledge
|
||||
* (a believed illusion wall can honestly hide the line).
|
||||
*/
|
||||
export function stackSightTrace(
|
||||
view: GameView,
|
||||
): {
|
||||
/** A sight line as the board draws it: the legs, the corner bend, and the
|
||||
* one wall a VISIONSTONE dissolved. */
|
||||
export interface SightLine {
|
||||
from: Cell;
|
||||
to: Cell;
|
||||
trace: SightTrace;
|
||||
bend?: { mid: Cell; trace: SightTrace };
|
||||
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
|
||||
pierced?: string;
|
||||
} | null {
|
||||
}
|
||||
|
||||
/** The sight line from one square to another under the sight law the
|
||||
* engine applies to a cast: a plain line (warps included), else the
|
||||
* VISIONSTONE's look through one wall, else AROUND THE CORNER's two legs.
|
||||
* `afterTheFact` traces a cast already resolved, whose own dust cloud
|
||||
* would otherwise blind the line to it. */
|
||||
export function sightTraceBetween(
|
||||
view: GameView,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
opts: { visionstone?: boolean; bentCorner?: boolean; afterTheFact?: boolean } = {},
|
||||
): SightLine | null {
|
||||
if (from.x === to.x && from.y === to.y) return null;
|
||||
const trace = traceSightFor(view, from, to, opts.afterTheFact);
|
||||
if (trace) return { from, to, trace };
|
||||
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
|
||||
// plain line exists, find the single edge whose removal opens one and draw
|
||||
// the ray straight through it, marking the pierced wall for the table.
|
||||
if (opts.visionstone) {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const [key, edge] of Object.entries(board.edges)) {
|
||||
if (edge === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
const t = traceSight({ ...board, edges }, from, to, blockers);
|
||||
if (t) return { from, to, trace: t, pierced: key };
|
||||
}
|
||||
}
|
||||
// AROUND THE CORNER: no straight line exists — find a middle square both
|
||||
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||
// needle instead of doubting it.
|
||||
if (opts.bentCorner) {
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if ((mid.x === from.x && mid.y === from.y) || (mid.x === to.x && mid.y === to.y)) continue;
|
||||
const leg1 = traceSightFor(view, from, mid, opts.afterTheFact);
|
||||
if (!leg1) continue;
|
||||
const leg2 = traceSightFor(view, mid, to, opts.afterTheFact);
|
||||
if (leg2) return { from, to, trace: leg1, bend: { mid, trace: leg2 } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The sight line of the attack on the stack, for the board to draw. */
|
||||
export function stackSightTrace(view: GameView): SightLine | null {
|
||||
const stack = view.stack;
|
||||
if (!stack || stack.creatureId) return null;
|
||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||
const a = view.players.find((p) => p.id === stack.attackerId);
|
||||
const d = view.players.find((p) => p.id === stack.defenderId);
|
||||
if (!a || !d) return null;
|
||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||
const trace = traceSightFor(view, a.position, d.position);
|
||||
if (trace) return { from: a.position, to: d.position, trace };
|
||||
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
|
||||
// plain line exists, find the single edge whose removal opens one and draw
|
||||
// the ray straight through it, marking the pierced wall for the table.
|
||||
if (a.displayed.some((c) => c.cardId === "visionstone")) {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const [key, edge] of Object.entries(board.edges)) {
|
||||
if (edge === "open") continue;
|
||||
const edges = { ...board.edges };
|
||||
delete edges[key];
|
||||
const t = traceSight({ ...board, edges }, a.position, d.position, blockers);
|
||||
if (t) return { from: a.position, to: d.position, trace: t, pierced: key };
|
||||
}
|
||||
}
|
||||
// AROUND THE CORNER: no straight line exists — find a middle square both
|
||||
// ends can see and draw the sight leg by leg, so the table can audit the
|
||||
// needle instead of doubting it.
|
||||
if (stack.bentCorner) {
|
||||
for (const key of Object.keys(view.board.cells)) {
|
||||
const [mx, my] = key.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if ((mid.x === a.position.x && mid.y === a.position.y) ||
|
||||
(mid.x === d.position.x && mid.y === d.position.y)) continue;
|
||||
const leg1 = traceSightFor(view, a.position, mid);
|
||||
if (!leg1) continue;
|
||||
const leg2 = traceSightFor(view, mid, d.position);
|
||||
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return sightTraceBetween(view, a.position, d.position, {
|
||||
visionstone: a.displayed.some((c) => c.cardId === "visionstone"),
|
||||
bentCorner: stack.bentCorner === true,
|
||||
});
|
||||
}
|
||||
|
||||
/** The sight line a resolved cast was accepted on — a creation, a curse,
|
||||
* anything aimed by line of sight that never waited on the stack — so the
|
||||
* table can see how the aim was legal. `events` is the cast's own batch,
|
||||
* which names an AROUND THE CORNER cast. */
|
||||
export function castSightTrace(
|
||||
view: GameView,
|
||||
cast: Extract<GameEvent, { type: "spellCast" }>,
|
||||
events: readonly GameEvent[],
|
||||
afterTheFact = false,
|
||||
): SightLine | null {
|
||||
let def;
|
||||
try { def = cardDef(cast.cardId); } catch { return null; }
|
||||
if (def.los !== true) return null;
|
||||
const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
|
||||
if (!to) return null;
|
||||
const caster = view.players.find((p) => p.id === cast.caster);
|
||||
return sightTraceBetween(view, cast.from, to, {
|
||||
visionstone: caster?.displayed.some((c) => c.cardId === "visionstone") ?? false,
|
||||
bentCorner: events.some((e) => e.type === "castAroundCorner" && e.caster === cast.caster),
|
||||
afterTheFact,
|
||||
});
|
||||
}
|
||||
|
||||
const CREATION_CARD_IDS = new Set([
|
||||
@@ -473,7 +517,9 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
||||
|
||||
if (cardId === "teleport") {
|
||||
// Up to four spaces, walls and objects ignored; not into solid stone.
|
||||
// BFS over existing cells, matching the engine's wallIgnoringDistance.
|
||||
// BFS over existing cells, matching the engine's wallIgnoringDistance —
|
||||
// the maze wraps for teleporters as it does for walkers, a warp mouth
|
||||
// being one step like any doorway.
|
||||
const out = new Set<string>();
|
||||
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
|
||||
const queue = [me.position];
|
||||
@@ -481,10 +527,20 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
|
||||
const cur = queue.shift()!;
|
||||
const d = dist.get(key(cur.x, cur.y))!;
|
||||
if (d >= 4) continue;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||
const n = { x: cur.x + dx, y: cur.y + dy };
|
||||
for (const side of SIDES) {
|
||||
let n = neighbor(cur, side);
|
||||
if (!view.board.cells[key(n.x, n.y)]) {
|
||||
const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side);
|
||||
if (w) n = w.to.cell;
|
||||
else if (view.deckRev >= 22) {
|
||||
// Rev 22: the outer edge is no more than a wall to a teleporter.
|
||||
const back = edgeReentry(view.board, cur, side);
|
||||
if (!back) continue;
|
||||
n = back;
|
||||
} else continue;
|
||||
}
|
||||
const nk = key(n.x, n.y);
|
||||
if (!view.board.cells[nk] || dist.has(nk)) continue;
|
||||
if (dist.has(nk)) continue;
|
||||
dist.set(nk, d + 1);
|
||||
queue.push(n);
|
||||
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
|
||||
|
||||
@@ -1394,3 +1394,28 @@ describe("disease makes the caster the carrier (rev 7)", () => {
|
||||
throw new Error("no adjacent step for the return walk");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a REFLECTION against a permanent curse afflicts both (rev 24)", () => {
|
||||
const cursed = (s: GameState, id: string) => s.sustained.some((e) => e.cardId === "walking-dead" && e.targetId === id);
|
||||
const play = (deckRev: number) => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const wd = giveCard(state, attacker, "walking-dead");
|
||||
state.players.find((p) => p.id === defender)!.hand[0] = { instanceId: "reflection#T", cardId: "reflection" };
|
||||
state = must(state, attacker, { type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender } });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "reflection#T" });
|
||||
state = drain(state);
|
||||
return { state, attacker, defender };
|
||||
};
|
||||
it("lays WALKING DEAD on the caster too", () => {
|
||||
const { state, attacker, defender } = play(24);
|
||||
expect(cursed(state, defender)).toBe(true);
|
||||
expect(cursed(state, attacker)).toBe(true);
|
||||
});
|
||||
it("older games left the caster untouched", () => {
|
||||
const { state, attacker, defender } = play(23);
|
||||
expect(cursed(state, defender)).toBe(true);
|
||||
expect(cursed(state, attacker)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -998,3 +998,27 @@ describe("the alter ego casts from its own square", () => {
|
||||
expect(done.players.find((p) => p.id === defender)!.life).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the troll's fist on a wall", () => {
|
||||
it("punches a wall line beside it for a D4, once a turn", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state).id;
|
||||
const r = summon(state, me, "troll");
|
||||
state = r.state;
|
||||
const other = state.players.find((p) => p.id !== me)!.id;
|
||||
state = must(state, me, { type: "endTurn", draw: 0 });
|
||||
state = must(state, other, { type: "endTurn", draw: 0 });
|
||||
const troll = state.creatures[0]!;
|
||||
const board = boardView(state);
|
||||
const side = SIDES.find((s) => board.edges[edgeKey(troll.position, s)] === "wall")!;
|
||||
const res = applyCommand(state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
const hit = res.events.find((e) => e.type === "wallDamaged");
|
||||
expect(hit && hit.type === "wallDamaged" ? hit.amount : 0).toBeGreaterThanOrEqual(1);
|
||||
expect(res.state.creatures[0]!.attackUsed).toBe(true);
|
||||
const again = applyCommand(res.state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
|
||||
expect(again.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, neighbor, opposite, SIDES, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
import { newGame, must, giveCard, toRound2, faceOff, castAt, drain } from "./helpers";
|
||||
|
||||
describe("duration spells", () => {
|
||||
it("slow reduces movement to 1, blocks number cards, and halves attacks", () => {
|
||||
@@ -567,3 +567,44 @@ describe("the displayed MASTER KEY", () => {
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POWER DRAIN drains the number played (rev 23)", () => {
|
||||
it("gains the number even when the blow is blunted", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const pd = giveCard(state, attacker, "power-drain");
|
||||
giveCard(state, attacker, "number-4", "N", 1);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
||||
state = must(state, attacker, { type: "cast", instanceId: pd.instanceId, target: { kind: "player", playerId: defender }, numberInstanceIds: ["number-4#N"] });
|
||||
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
|
||||
state = drain(state);
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
|
||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(19);
|
||||
});
|
||||
|
||||
it("drains a wall for its points", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
|
||||
const pd = giveCard(state, me.id, "power-drain");
|
||||
giveCard(state, me.id, "number-3", "N", 1);
|
||||
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
|
||||
expect(state.wallDamage[edgeKey(me.position, side)]).toBe(3);
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(18);
|
||||
});
|
||||
|
||||
it("older games gave only what the opponent lost, and nothing from a wall", () => {
|
||||
let state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 22 }).state);
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
|
||||
const pd = giveCard(state, me.id, "power-drain");
|
||||
giveCard(state, me.id, "number-3", "N", 1);
|
||||
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, wallIgnoringDistance, type GameState } from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||
@@ -705,3 +705,119 @@ describe("spells cast at a slime wait in the gel", () => {
|
||||
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attacks aimed at a bush or the ooze", () => {
|
||||
function withContent(kind: "thornbush" | "rosebush" | "ooze") {
|
||||
const state = toRound2(newGame().state);
|
||||
const me = activePlayer(state);
|
||||
const { cell } = emptyNeighborCell(state, me.position);
|
||||
state.squareContents[cellKey(cell)] = { kind, damage: 0, createdBy: "bob" };
|
||||
return { state, me, cell };
|
||||
}
|
||||
|
||||
it("a FIREBALL tears a thornbush apart at five points", () => {
|
||||
const { state, me, cell } = withContent("thornbush");
|
||||
const fireball = giveCard(state, me.id, "fireball");
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDamaged", kind: "thornbush", amount: 5, total: 5 }));
|
||||
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDestroyed", kind: "thornbush" }));
|
||||
expect(r.state.squareContents[cellKey(cell)]).toBeUndefined();
|
||||
expect(r.state.turn.attackUsed).toBe(true);
|
||||
});
|
||||
|
||||
it("a thrown dagger scratches a rosebush, and the scratch stays", () => {
|
||||
const { state, me, cell } = withContent("rosebush");
|
||||
const dagger = giveCard(state, me.id, "dagger");
|
||||
const next = must(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
||||
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 3 }));
|
||||
});
|
||||
|
||||
it("only fire hurts the ooze", () => {
|
||||
const { state, me, cell } = withContent("ooze");
|
||||
const dagger = giveCard(state, me.id, "dagger");
|
||||
const refused = applyCommand(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toMatch(/only fire/);
|
||||
const fireball = giveCard(state, me.id, "fireball");
|
||||
const burned = must(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
|
||||
expect(burned.squareContents[cellKey(cell)]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a WIZARDBLADE is swung at a bush from the square beside it, never from afar", () => {
|
||||
const { state, me, cell } = withContent("thornbush");
|
||||
const blade = giveCard(state, me.id, "wizardblade");
|
||||
const three = giveCard(state, me.id, "number-3", "N", 1);
|
||||
const near = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell },
|
||||
});
|
||||
expect(near.ok).toBe(true);
|
||||
if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 }));
|
||||
|
||||
const far = sightedCellsFor(viewFor(state, me.id));
|
||||
const farKey = [...far].find((k) => {
|
||||
const [x, y] = k.split(",").map(Number);
|
||||
return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k];
|
||||
});
|
||||
if (!farKey) return;
|
||||
const [fx, fy] = farKey.split(",").map(Number);
|
||||
state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" };
|
||||
const refused = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell: { x: fx!, y: fy! } },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
if (!refused.ok) expect(refused.error).toMatch(/beside/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport's reach wraps through the board's openings", () => {
|
||||
it("offers the square beyond a warp mouth, as the engine allows it", () => {
|
||||
const state = toRound2(newGame().state);
|
||||
const me = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
const warp = view.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
giveCard(state, me.id, "teleport");
|
||||
const cells = eligibleCellsFor(viewFor(state, me.id), "teleport")!;
|
||||
const beyond = cellKey(warp.to.cell);
|
||||
expect(cells.has(beyond)).toBe(true);
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: warp.to.cell } });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport across the maze's outer edge (rev 22)", () => {
|
||||
function atTheTopEdge(deckRev?: number) {
|
||||
const config = { playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] as ("basic" | "expansion1")[], ...(deckRev ? { deckRev } : {}) };
|
||||
const state = toRound2(createGame(config).state);
|
||||
const me = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
// A square on the top row whose north side is plain edge, not a lettered mouth.
|
||||
const top = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number])
|
||||
.filter(([x, y]) => y === 0 && !view.warps.some((w) => w.from.cell.x === x && w.from.cell.y === y && w.from.side === "N"))
|
||||
.find(([x]) => !state.squareContents[`${x},0`])!;
|
||||
me.position = { x: top[0], y: 0 };
|
||||
const column = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number]).filter(([x]) => x === top[0]).map(([, y]) => y);
|
||||
const bottom = { x: top[0], y: Math.max(...column) };
|
||||
giveCard(state, me.id, "teleport");
|
||||
return { state, me, bottom };
|
||||
}
|
||||
|
||||
it("re-enters at the bottom of the same column, one space on", () => {
|
||||
const { state, me, bottom } = atTheTopEdge();
|
||||
expect(wallIgnoringDistance(boardView(state), me.position, bottom, true)).toBe(1);
|
||||
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(true);
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.state.players.find((p) => p.id === me.id)!.position).toEqual(bottom);
|
||||
});
|
||||
|
||||
it("older games cross only at the lettered openings", () => {
|
||||
const { state, me, bottom } = atTheTopEdge(21);
|
||||
expect(wallIgnoringDistance(boardView(state), me.position, bottom)).toBeGreaterThan(4);
|
||||
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(false);
|
||||
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ 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 {
|
||||
import { callKeeper, callRematch,
|
||||
catchUpSteps,
|
||||
momentSteps,
|
||||
claimTransferCode,
|
||||
@@ -86,6 +86,11 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
|
||||
// Per-address limits, held across reconnects: a table of friends never
|
||||
// nears them; a script filling the vault or the reports desk does.
|
||||
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
|
||||
/** Calls to the keeper: a real person's phone rings for each. */
|
||||
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
|
||||
/** The keeper of this table: the wizard a lobby may challenge. */
|
||||
const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
||||
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
|
||||
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
||||
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
||||
@@ -325,7 +330,10 @@ const httpServer = createServer((req, res) => {
|
||||
"cache-control": "public, max-age=60",
|
||||
"access-control-allow-origin": "*",
|
||||
});
|
||||
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true }));
|
||||
res.end(JSON.stringify({
|
||||
steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true,
|
||||
headline: data.whole ? null : headlineOf(data),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
|
||||
@@ -518,6 +526,11 @@ function roomInfo(room: Room) {
|
||||
hostId: room.hostId,
|
||||
started: room.state !== null,
|
||||
audience: audienceCount(room),
|
||||
rematch: room.rematch ?? null,
|
||||
expected: room.expected ?? [],
|
||||
expansion: room.expansion,
|
||||
challenge: room.challenge ?? null,
|
||||
keeper: KEEPER,
|
||||
colors: Object.fromEntries(room.colorChoices),
|
||||
bots: Object.fromEntries(
|
||||
// A mystery machine keeps its mood only while the game lives: once it
|
||||
@@ -704,7 +717,12 @@ wss.on("connection", (socket, req) => {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||
if ("error" in result) {
|
||||
// A refused seat is worth a line: a lost seat mid-game is the
|
||||
// costliest quiet failure this table has.
|
||||
console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)} — ${result.error}`);
|
||||
return send(socket, { type: "error", message: result.error });
|
||||
}
|
||||
leaveGallery(session);
|
||||
session.playerId = name;
|
||||
session.roomId = room.id;
|
||||
@@ -715,11 +733,40 @@ wss.on("connection", (socket, req) => {
|
||||
// from firing again on every return to the room.
|
||||
if (room.state) {
|
||||
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
|
||||
} else if (room.chat.length > 0) {
|
||||
// The lobby's talk so far, for whoever just sat down.
|
||||
send(socket, { type: "events", events: room.chat.map((c) => ({ type: "tableTalk", player: c.player, text: c.text })), replayed: true });
|
||||
}
|
||||
broadcastRoomState(room);
|
||||
runBots(room);
|
||||
break;
|
||||
}
|
||||
case "challengeKeeper": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
if (!challengesPerAddress.allow(session.address)) {
|
||||
return send(socket, { type: "error", message: "the keeper has been called enough from here for one hour" });
|
||||
}
|
||||
const called = callKeeper(room, session.playerId, KEEPER);
|
||||
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
||||
// The alarm the keeper listens for: one issue per room, so each
|
||||
// call rings once, with the door in the message. Error level,
|
||||
// because Sentry's alerts ring for high-priority issues and a
|
||||
// warning is filed as medium — a bell nobody hears.
|
||||
const link = `${PUBLIC_URL}/join/${room.id}`;
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
|
||||
level: "error",
|
||||
fingerprint: ["challenge", room.id],
|
||||
tags: { room: room.id, challenger: session.playerId },
|
||||
extra: { link, players: room.players.join(", ") },
|
||||
});
|
||||
}
|
||||
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
|
||||
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
|
||||
broadcastRoomState(room);
|
||||
break;
|
||||
}
|
||||
case "watch": {
|
||||
// The Peanut Gallery: no name, no seat, no ledger line — a pure
|
||||
// reader of the public broadcast, counted but never identified.
|
||||
@@ -837,6 +884,30 @@ wss.on("connection", (socket, req) => {
|
||||
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
|
||||
break;
|
||||
}
|
||||
case "rematch": {
|
||||
// Anyone at a finished table may call; the caller lands in the
|
||||
// new lobby, and the old table hears where it went.
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const called = callRematch(room, session.playerId);
|
||||
if ("error" in called) return send(socket, { type: "error", message: called.error });
|
||||
const next = getRoom(called.roomId);
|
||||
if (!next) return send(socket, { type: "error", message: "the rematch room is gone" });
|
||||
let token = called.token;
|
||||
if (!token) {
|
||||
const joined = joinRoom(next, session.playerId, null);
|
||||
if ("error" in joined) return send(socket, { type: "error", message: joined.error });
|
||||
token = joined.token;
|
||||
}
|
||||
if (called.created) broadcast(room, () => ({ type: "rematch", roomId: room.id, to: next.id, by: session.playerId }));
|
||||
leaveGallery(session);
|
||||
send(socket, { type: "rematched", roomId: next.id });
|
||||
session.roomId = next.id;
|
||||
session.token = token;
|
||||
send(socket, { type: "seat", playerId: session.playerId, token });
|
||||
broadcastRoomState(next);
|
||||
break;
|
||||
}
|
||||
case "chat": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -999,6 +1070,7 @@ wss.on("connection", (socket, req) => {
|
||||
const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null);
|
||||
if (result === null) continue;
|
||||
if (result === "badToken") {
|
||||
console.warn(`seat voided: room ${roomId} name ${JSON.stringify(name)} — the token did not match`);
|
||||
voided.push(`${roomId}:${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface Room {
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
/** Last time anything looked at this room (memory eviction clock). */
|
||||
touchedAt?: number;
|
||||
/** A finished table that called for a rematch: where it went, and who called. */
|
||||
rematch?: { roomId: string; by: PlayerId };
|
||||
/** A rematch lobby: the wizards of the last table who have not yet sat. */
|
||||
expected?: PlayerId[];
|
||||
/** The table called the keeper of this site to a seat, and by whom. */
|
||||
challenge?: { by: PlayerId; at: string };
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
@@ -91,7 +97,10 @@ export function runningRooms(): Room[] {
|
||||
return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing");
|
||||
}
|
||||
|
||||
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
export function createRoom(
|
||||
hostId: PlayerId,
|
||||
rematch?: { of: string; expected: PlayerId[]; expansion: boolean },
|
||||
): { room: Room; token: string } {
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const room: Room = {
|
||||
id: makeRoomCode(),
|
||||
@@ -99,7 +108,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
players: [hostId],
|
||||
tokens: new Map([[hostId, hashToken(token)]]),
|
||||
seed: randomInt(0, 0xffffffff),
|
||||
expansion: false,
|
||||
expansion: rematch?.expansion ?? false,
|
||||
colorChoices: new Map(),
|
||||
createdAt: new Date().toISOString(),
|
||||
state: null,
|
||||
@@ -108,6 +117,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(rematch ? { expected: [...rematch.expected] } : {}),
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
recordRoom(room);
|
||||
@@ -118,10 +128,45 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
hostTokenHash: hashToken(token),
|
||||
seed: room.seed,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(rematch ? { rematchOf: rematch.of, expected: rematch.expected, expansion: rematch.expansion } : {}),
|
||||
});
|
||||
return { room, token };
|
||||
}
|
||||
|
||||
/** A lobby calls the keeper to the table: a seat is held under their
|
||||
* name, the call is written to the ledger, and the caller (index.ts)
|
||||
* raises the alarm the keeper listens for. Once per room. */
|
||||
export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at: string } | { error: string } {
|
||||
if (room.state) return { error: "the game has started" };
|
||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" };
|
||||
if (room.challenge) return { error: `${keeper} has already been called to this table` };
|
||||
if (room.players.includes(keeper)) return { error: `${keeper} is already here` };
|
||||
if (room.players.length + (room.expected?.length ?? 0) >= 6) return { error: "the table is full" };
|
||||
const at = new Date().toISOString();
|
||||
room.challenge = { by: byId, at };
|
||||
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
||||
appendLine(room.id, { kind: "challenge", by: byId, at });
|
||||
return { at };
|
||||
}
|
||||
|
||||
/** A finished table calls for a rematch: a new room with the same
|
||||
* clockwork at the same tiers and the same expansion setting, waiting for
|
||||
* the same humans. The first caller hosts it; anyone at the old table may
|
||||
* call, and a second call finds the room already made. */
|
||||
export function callRematch(
|
||||
room: Room, byId: PlayerId,
|
||||
): { roomId: string; token: string | null; created: boolean } | { error: string } {
|
||||
if (room.state?.phase !== "finished") return { error: "the game is not over yet" };
|
||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" };
|
||||
if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false };
|
||||
const humans = room.players.filter((p) => !room.bots.has(p) && p !== byId);
|
||||
const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion });
|
||||
for (const [, b] of room.bots) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
|
||||
room.rematch = { roomId: next.id, by: byId };
|
||||
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
|
||||
return { roomId: next.id, token, created: true };
|
||||
}
|
||||
|
||||
export function getRoom(id: string): Room | undefined {
|
||||
const code = id.toUpperCase();
|
||||
const live = rooms.get(code);
|
||||
@@ -211,6 +256,7 @@ function baseSummary(room: Room, active: PlayerId | null): Omit<GameSummary, "na
|
||||
round: room.state?.turn.round ?? null,
|
||||
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
||||
chatCount: room.chat.length,
|
||||
rematch: room.rematch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,6 +294,8 @@ export function joinRoom(
|
||||
const fresh = randomBytes(16).toString("hex");
|
||||
room.players.push(playerId);
|
||||
room.tokens.set(playerId, hashToken(fresh));
|
||||
// An expected wizard who sits is expected no longer.
|
||||
if (room.expected?.includes(playerId)) room.expected = room.expected.filter((n) => n !== playerId);
|
||||
appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) });
|
||||
recordRoom(room);
|
||||
return { token: fresh };
|
||||
@@ -467,6 +515,8 @@ export interface GameSummary {
|
||||
lastMoveAt: string | null;
|
||||
/** Total table-talk messages; the client tracks which it has seen. */
|
||||
chatCount: number;
|
||||
/** A finished table that moved on: the rematch room and who called it. */
|
||||
rematch?: { roomId: string; by: PlayerId } | null;
|
||||
}
|
||||
|
||||
/** Who holds the table's attention, and why — the summary's turn facts.
|
||||
@@ -743,6 +793,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
chat: [],
|
||||
bots: new Map(),
|
||||
touchedAt: Date.now(),
|
||||
...(meta.expected ? { expected: [...meta.expected] } : {}),
|
||||
...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}),
|
||||
};
|
||||
if (lines.some((l) => l.kind === "abandon")) return null;
|
||||
for (const line of lines.slice(1)) {
|
||||
@@ -766,10 +818,17 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
if (!joinHash) throw new Error("join line has no token");
|
||||
room.players.push(line.name);
|
||||
room.tokens.set(line.name, joinHash);
|
||||
if (room.expected?.includes(line.name)) room.expected = room.expected.filter((n) => n !== line.name);
|
||||
}
|
||||
} else if (line.kind === "start") {
|
||||
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
|
||||
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
|
||||
} else if (line.kind === "rematch") {
|
||||
room.rematch = { roomId: line.to, by: line.by };
|
||||
} else if (line.kind === "challenge") {
|
||||
room.challenge = { by: line.by, at: line.at };
|
||||
const keeper = process.env.WIZWAR_KEEPER ?? "Kestrel";
|
||||
if (!room.players.includes(keeper)) room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
|
||||
} else if (line.kind === "chat") {
|
||||
// File order preserves the interleaving with commands.
|
||||
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
||||
|
||||
@@ -16,6 +16,26 @@ export interface RoomMetaLine {
|
||||
hostToken?: string;
|
||||
seed: number;
|
||||
createdAt: string;
|
||||
/** A rematch: the finished room it follows, and the wizards it waits for. */
|
||||
rematchOf?: string;
|
||||
expected?: string[];
|
||||
/** The lobby's expansion default, carried over from the last table. */
|
||||
expansion?: boolean;
|
||||
}
|
||||
|
||||
/** A table called the keeper: the seat is held and the keeper told. */
|
||||
export interface ChallengeLine {
|
||||
kind: "challenge";
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** The finished table called for a rematch: the new room it moved to. */
|
||||
export interface RematchLine {
|
||||
kind: "rematch";
|
||||
to: string;
|
||||
by: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface JoinLine {
|
||||
@@ -71,7 +91,7 @@ export interface AbandonLine {
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
|
||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine;
|
||||
|
||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||
|
||||
|
||||
+822
-244
File diff suppressed because it is too large
Load Diff
+462
-222
@@ -1,130 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { allCardDefs, RULES_REVISIONS } from "@wizwar/engine";
|
||||
// The reference desk: how to play, the rules and FAQ as one searchable
|
||||
// body, the card library with proper entries, the house rulings, the
|
||||
// story of the table, and the tally. Every section and card has a link
|
||||
// a player can send; the desk remembers where the reader was.
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
import { tick } from "svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Faq from "./Faq.svelte";
|
||||
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
|
||||
import { FAQ_GENERAL } from "./faq-general";
|
||||
import { RULEBOOK_BASE, RULEBOOK_EXPANSION, RULEBOOK_COPYRIGHT } from "./rulebook";
|
||||
import { HOW_TO_PLAY } from "./rules";
|
||||
import { RULEBOOK_COPYRIGHT } from "./rulebook";
|
||||
import { helpState, type HelpTab } from "./help-state";
|
||||
import {
|
||||
REFERENCE, SOURCE_LABELS, searchReference, renderInline,
|
||||
CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel,
|
||||
HOUSE_RULINGS, rulingsFor, searchRulings,
|
||||
type RefSource,
|
||||
} from "./reference";
|
||||
|
||||
let {
|
||||
onclose,
|
||||
initialTab = "play",
|
||||
initialTab = null,
|
||||
initialAnchor = null,
|
||||
initialCard = null,
|
||||
stats = null,
|
||||
onstats,
|
||||
onnavigate,
|
||||
}: {
|
||||
onclose: () => void;
|
||||
initialTab?: "play" | "rules" | "cards" | "rulings" | "about" | "tally";
|
||||
/** A tab to open on; null reopens where the reader left off. */
|
||||
initialTab?: HelpTab | null;
|
||||
/** A section to scroll to on the tab. */
|
||||
initialAnchor?: string | null;
|
||||
/** A card entry to open. */
|
||||
initialCard?: string | null;
|
||||
stats?: Record<string, number | string | null> | null;
|
||||
onstats?: () => void;
|
||||
/** The address bar follows the reader, so the link is always a copy away. */
|
||||
onnavigate?: (tab: HelpTab, anchor: string | null, card: string | null) => void;
|
||||
} = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
|
||||
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 libPeek = $state<string | null>(null);
|
||||
// svelte-ignore state_referenced_locally -- the opening tab is a one-time value
|
||||
let tab = $state<HelpTab>(initialCard ? "cards" : (initialTab ?? helpState.tab));
|
||||
let rulesSearch = $state(helpState.rulesSearch);
|
||||
let rulingsSearch = $state(helpState.rulingsSearch);
|
||||
let cardText = $state(helpState.cards.text);
|
||||
let cardSet = $state(helpState.cards.set);
|
||||
let cardType = $state(helpState.cards.type);
|
||||
let cardSight = $state(helpState.cards.sight);
|
||||
// svelte-ignore state_referenced_locally -- the opening card is a one-time value
|
||||
let entry = $state<string | null>(initialCard);
|
||||
let entryOpener: HTMLElement | null = null;
|
||||
let bodyEl = $state<HTMLElement | null>(null);
|
||||
let copied = $state<string | null>(null);
|
||||
|
||||
function openTally() {
|
||||
tab = "tally";
|
||||
onstats?.();
|
||||
const refResults = $derived(searchReference(rulesSearch));
|
||||
const rulingResults = $derived(searchRulings(rulingsSearch));
|
||||
const cardResults = $derived(searchCards({ text: cardText, set: cardSet, type: cardType, sight: cardSight }));
|
||||
const entryDef = $derived(entry ? cardDef(entry) : null);
|
||||
const entryRulings = $derived(entry ? rulingsFor(entry) : []);
|
||||
const entryMentions = $derived(entry ? mentionedCards(entry) : []);
|
||||
const refBySource = $derived(
|
||||
(["summary", "rulebook", "expansion", "faq"] as RefSource[])
|
||||
.map((source) => ({ source, sections: REFERENCE.filter((s) => s.source === source) })),
|
||||
);
|
||||
|
||||
$effect(() => { helpState.tab = tab; });
|
||||
$effect(() => { helpState.rulesSearch = rulesSearch; });
|
||||
$effect(() => { helpState.rulingsSearch = rulingsSearch; });
|
||||
$effect(() => { helpState.cards = { text: cardText, set: cardSet, type: cardType, sight: cardSight }; });
|
||||
$effect(() => { onnavigate?.(tab, null, entry); });
|
||||
|
||||
function saveScroll() {
|
||||
if (bodyEl) helpState.scroll[tab] = bodyEl.scrollTop;
|
||||
}
|
||||
async function goTab(t: HelpTab) {
|
||||
if (t === tab) return;
|
||||
saveScroll();
|
||||
tab = t;
|
||||
if (t === "tally") onstats?.();
|
||||
await tick();
|
||||
if (bodyEl) bodyEl.scrollTop = helpState.scroll[t] ?? 0;
|
||||
}
|
||||
async function jumpTo(anchor: string) {
|
||||
await tick();
|
||||
const el = bodyEl?.querySelector(`#${CSS.escape(anchor)}`);
|
||||
el?.scrollIntoView({ block: "start" });
|
||||
onnavigate?.(tab, anchor, null);
|
||||
}
|
||||
function linkFor(t: HelpTab, anchor: string | null, card: string | null): string {
|
||||
const q = card ? `card=${card}` : `help=${t}${anchor ? `/${anchor}` : ""}`;
|
||||
return `${location.origin}/?${q}`;
|
||||
}
|
||||
function copyLink(t: HelpTab, anchor: string | null, card: string | null) {
|
||||
const key = card ?? `${t}/${anchor ?? ""}`;
|
||||
navigator.clipboard?.writeText(linkFor(t, anchor, card)).catch(() => {});
|
||||
onnavigate?.(t, anchor, card);
|
||||
copied = key;
|
||||
setTimeout(() => { if (copied === key) copied = null; }, 1500);
|
||||
}
|
||||
function openEntry(id: string, opener?: EventTarget | null) {
|
||||
entryOpener = (opener instanceof HTMLElement ? opener : document.activeElement) as HTMLElement | null;
|
||||
entry = id;
|
||||
}
|
||||
function closeEntry() {
|
||||
entry = null;
|
||||
entryOpener?.focus();
|
||||
entryOpener = null;
|
||||
}
|
||||
function close() {
|
||||
saveScroll();
|
||||
onclose();
|
||||
}
|
||||
/** Escape closes the foremost layer only: the card entry before the desk. */
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
if (entry) { closeEntry(); return; }
|
||||
close();
|
||||
}
|
||||
function n(v: unknown): string {
|
||||
return Number(v ?? 0).toLocaleString();
|
||||
}
|
||||
function hours(mins: number): string {
|
||||
return mins < 90 ? `${mins} minutes` : `${Math.round(mins / 6) / 10} hours`;
|
||||
return mins < 90 ? `${n(mins)} minutes` : `${n(Math.round(mins / 6) / 10)} hours`;
|
||||
}
|
||||
let search = $state("");
|
||||
|
||||
// The playable pool: every card actually in the 6e game (base + Exp1).
|
||||
const pool = allCardDefs().filter(
|
||||
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
|
||||
);
|
||||
const filtered = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const list = q
|
||||
? pool.filter(
|
||||
(d) => d.name.toLowerCase().includes(q) || (d.text ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
: pool;
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||
$effect(() => {
|
||||
if (initialAnchor) void jumpTo(initialAnchor);
|
||||
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
|
||||
});
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
{#if libPeek}
|
||||
<div class="lib-peek-scrim" role="button" tabindex="-1" onclick={() => (libPeek = null)} onkeydown={() => {}}>
|
||||
<div class="lib-peek">
|
||||
<Card card={{ instanceId: `peek-${libPeek}`, cardId: libPeek }} onfaq={(id) => (faqCardId = id)} />
|
||||
{#if entryDef}
|
||||
<div class="layer-scrim" role="presentation" onclick={closeEntry}>
|
||||
<div class="entry" role="dialog" aria-modal="true" aria-label="{entryDef.name}, card entry" tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
|
||||
<header class="entry-head">
|
||||
<span class="entry-title">{entryDef.name}</span>
|
||||
<button class="link-btn" onclick={() => copyLink("cards", null, entryDef!.id)} title="copy a link to this card">
|
||||
{copied === entryDef.id ? "link copied" : "🔗 link"}</button>
|
||||
<button class="close" onclick={closeEntry} aria-label="close card entry">×</button>
|
||||
</header>
|
||||
<div class="entry-body">
|
||||
<div class="entry-card">
|
||||
<Card card={{ instanceId: `entry-${entryDef.id}`, cardId: entryDef.id }} />
|
||||
</div>
|
||||
<div class="entry-text reading">
|
||||
<p class="entry-meta">
|
||||
{typeLabel(entryDef)} · {sightOf(entryDef)} · {setLabel(entryDef)}
|
||||
</p>
|
||||
<h3>As printed</h3>
|
||||
<p class="printed">{entryDef.text ?? (entryDef.cardType === "number" ? `A NUMBER card worth ${entryDef.value}: add it to your movement, or set a spell's duration or power.` : "")}</p>
|
||||
{#if entryDef.faqRulings.length > 0}
|
||||
<h3>Official FAQ</h3>
|
||||
{#each entryDef.faqRulings as q, i (i)}<p>{q}</p>{/each}
|
||||
<p class="colophon">Tom Jolly's own rulings (wizwar.com, September 2002). The cards overrule the rules, and the designer overrules the table.</p>
|
||||
{/if}
|
||||
{#if entryRulings.length > 0}
|
||||
<h3>At this table</h3>
|
||||
{#each entryRulings as r (r.id)}
|
||||
{#each r.body as p, i (i)}<p>{p}</p>{/each}
|
||||
<p class="colophon"><button class="inline-link" onclick={() => { closeEntry(); void goTab("rulings"); void jumpTo(`ruling-${r.id}`); }}>{r.title}, in the house rulings</button></p>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if entryMentions.length > 0}
|
||||
<h3>Mentions</h3>
|
||||
<p class="chips">
|
||||
{#each entryMentions as m (m.id)}
|
||||
<button class="chip" onclick={(e) => openEntry(m.id, e.currentTarget)}>{m.name}</button>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if faqCardId}
|
||||
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
|
||||
{/if}
|
||||
|
||||
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
|
||||
<div
|
||||
class="booklet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="help"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<div class="scrim" role="presentation" onclick={close}>
|
||||
<div class="booklet" role="dialog" aria-modal="true" aria-label="help" tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
|
||||
<header class="booklet-head">
|
||||
<span class="booklet-title">Wiz-War</span>
|
||||
<nav class="tabs">
|
||||
<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 === "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 === "tally"} onclick={openTally}>The tally</button>
|
||||
<nav class="tabs" aria-label="help sections">
|
||||
<button class:current={tab === "play"} onclick={() => goTab("play")}>How to play</button>
|
||||
<button class:current={tab === "rules"} onclick={() => goTab("rules")}>The rules</button>
|
||||
<button class:current={tab === "cards"} onclick={() => goTab("cards")}>Card library</button>
|
||||
<button class:current={tab === "rulings"} onclick={() => goTab("rulings")}>House rulings</button>
|
||||
<button class:current={tab === "about"} onclick={() => goTab("about")}>About</button>
|
||||
<button class:current={tab === "tally"} onclick={() => goTab("tally")}>The tally</button>
|
||||
</nav>
|
||||
<button class="close" onclick={onclose} aria-label="close help">×</button>
|
||||
<button class="close" onclick={close} aria-label="close help">×</button>
|
||||
</header>
|
||||
|
||||
<div class="booklet-body">
|
||||
<div class="booklet-body" bind:this={bodyEl}>
|
||||
{#if tab === "tally"}
|
||||
<div class="tally">
|
||||
<div class="reading">
|
||||
<h3>How much love the maze is getting</h3>
|
||||
{#if stats}
|
||||
<div class="stories">
|
||||
<div class="story"><span class="big">{n(stats.gamesFinished)}</span><span>games fought to a finish</span></div>
|
||||
<div class="story"><span class="big">{n(stats.winsByTreasure)}</span><span>won by carrying treasure home</span></div>
|
||||
<div class="story"><span class="big">{n(stats.winsByLastStanding)}</span><span>won as the last wizard standing</span></div>
|
||||
<div class="story"><span class="big">{hours(Number(stats.minutesAtTable))}</span><span>spent at the table</span></div>
|
||||
</div>
|
||||
<h3>The ledgers in detail</h3>
|
||||
<dl class="tally-list">
|
||||
<dt>{stats.gamesCreated}</dt><dd>games chronicled — {stats.gamesStarted} begun, {stats.gamesFinished} fought to a finish</dd>
|
||||
<dt>{stats.wizardsSeated}</dt><dd>distinct wizards have taken a seat</dd>
|
||||
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd>
|
||||
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd>
|
||||
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd>
|
||||
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
|
||||
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
|
||||
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
|
||||
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
|
||||
<dt>{n(stats.gamesCreated)}</dt><dd>games chronicled — {n(stats.gamesStarted)} begun</dd>
|
||||
<dt>{n(stats.wizardsSeated)}</dt><dd>distinct wizard names have taken a seat</dd>
|
||||
<dt>{n(stats.commandsPlayed)}</dt><dd>spells, steps, and punches recorded</dd>
|
||||
<dt>{n(stats.longestGameCommands)}</dt><dd>actions in the longest game yet played</dd>
|
||||
<dt>{n(stats.fullestTable)}</dt><dd>wizards at the fullest table</dd>
|
||||
<dt>{n(stats.hotseatGames)}</dt><dd>games played on one device, passed around</dd>
|
||||
<dt>{n(stats.automatonGames ?? 0)}</dt><dd>games fought against the clockwork</dd>
|
||||
</dl>
|
||||
{#if stats.firstGameAt}
|
||||
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
|
||||
{/if}
|
||||
<p class="colophon">
|
||||
{#if stats.firstGameAt}The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}.{/if}
|
||||
Wizards are counted by name, not by person: one player under two names is two wizards here.
|
||||
Table time is estimated from the clock between moves, so a game left open overnight counts only the minutes it was actually played.
|
||||
Games on one device send only their counts — names and moves stay on the device.
|
||||
</p>
|
||||
{:else}
|
||||
<p>Counting the ledgers…</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === "about"}
|
||||
<div class="about">
|
||||
<h3>What this is</h3>
|
||||
<p>
|
||||
Wiz-War is a game of magical combat in a stone labyrinth: two to six
|
||||
wizards prowl a maze, hurling fireballs, walking through walls,
|
||||
summoning trolls, and stealing each other's treasure. It was created
|
||||
by <strong>Tom Jolly</strong> in 1983 and first published under his
|
||||
own Jolly Games label; the edition reproduced here is the
|
||||
<strong>sixth edition</strong>, published by Chessex in 1993, together
|
||||
with its one expansion — monsters and magic wands included.
|
||||
</p>
|
||||
<div class="reading about">
|
||||
<h3>Why it exists</h3>
|
||||
<p>
|
||||
In the early nineties, a group of friends played this exact edition
|
||||
@@ -138,6 +239,16 @@
|
||||
it was played at that table, made so the same friends — and their
|
||||
friends — can keep playing it.
|
||||
</p>
|
||||
<h3>What this is</h3>
|
||||
<p>
|
||||
Wiz-War is a game of magical combat in a stone labyrinth: two to six
|
||||
wizards prowl a maze, hurling fireballs, walking through walls,
|
||||
summoning trolls, and stealing each other's treasure. It was created
|
||||
by <strong>Tom Jolly</strong> in 1983 and first published under his
|
||||
own Jolly Games label; the edition reproduced here is the
|
||||
<strong>sixth edition</strong>, published by Chessex in 1993, together
|
||||
with its one expansion — monsters and magic wands included.
|
||||
</p>
|
||||
<h3>Whose game it is</h3>
|
||||
<p>
|
||||
Wiz-War is Tom Jolly's design, and the rights to it now rest with
|
||||
@@ -151,22 +262,35 @@
|
||||
somebody at a real table.
|
||||
</p>
|
||||
<h3>Behind the curtain</h3>
|
||||
<p>
|
||||
The workshops where this table's pieces are made are open to
|
||||
visitors:
|
||||
the <a href="/?tokens">token workshop</a> shows every token in both
|
||||
arts beside the wall textures and spell sprites;
|
||||
the <a href="/?fx">flourish workshop</a> plays each board effect on
|
||||
demand; and
|
||||
the <a href="/?fpv">first-person workshop</a> walks the maze through
|
||||
a wizard's own eyes (add <code>&demo=1</code> to watch a reel).
|
||||
</p>
|
||||
<p>The workshops where this table's pieces are made are open to visitors.</p>
|
||||
<div class="gallery">
|
||||
<a class="tile" href="/?tokens">
|
||||
<img src="/tokens/alter-ego.png" alt="" />
|
||||
<span class="tile-name">The token workshop</span>
|
||||
<span class="tile-note">every token in both arts, beside the wall textures and spell sprites</span>
|
||||
</a>
|
||||
<a class="tile" href="/?fx">
|
||||
<img src="/fx3d/fireball.png" alt="" />
|
||||
<span class="tile-name">The flourish workshop</span>
|
||||
<span class="tile-note">each board effect, played on demand</span>
|
||||
</a>
|
||||
<a class="tile" href="/?fpv">
|
||||
<img src="/hero.jpg" alt="" />
|
||||
<span class="tile-name">The first-person workshop</span>
|
||||
<span class="tile-note">the maze through a wizard's own eyes — or <span class="tile-link">watch the demo reel</span></span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
|
||||
<h3>What the table remembers</h3>
|
||||
<p>
|
||||
Every game here is recorded permanently, move by move — table talk
|
||||
included — so finished games can be replayed, shared, and studied.
|
||||
Play under whatever name you like, but know that what you say and
|
||||
do at the table becomes part of its lasting record.
|
||||
An online game is written to this table's ledger move by move, table
|
||||
talk included, and kept: a finished game can be replayed from above
|
||||
or through a wizard's own eyes, and any turn can be shared by link.
|
||||
A shared replay shows that turn's moves and the sharing wizard's view
|
||||
of the board — never anyone's hand. A game passed around one device
|
||||
stays on that device; only its counts reach the tally. Play under
|
||||
whatever name you like, and know that what you say and do at an
|
||||
online table is part of its record.
|
||||
</p>
|
||||
<h3>Send word</h3>
|
||||
<p>
|
||||
@@ -183,113 +307,139 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else if tab === "play"}
|
||||
{#each HOW_TO_PLAY as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/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>BUTT-HEAD's ram is measured as the shortest walk through the
|
||||
corridors from where you cast it to your victim's square, on the
|
||||
legs you have this turn; you cannot pad the blow by taking the
|
||||
long way round, and the goat lands on the victim's square. There
|
||||
is no ceiling. MAD DASH doubles the whole allowance, NUMBER cards
|
||||
included, so a well-placed goat can ram for sixteen.</p>
|
||||
<p>Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a
|
||||
slime may hold several. They go off one at a time, oldest first:
|
||||
each wizard who pushes in springs one spell, the next wizard the
|
||||
next. The card says only that each spell goes off once; the queue
|
||||
is this table's reading. A slime shows how many it holds, and a
|
||||
peek names them, since every cast into it was seen.</p>
|
||||
<p>Clockwork wizards — the automatons — play from the same redacted
|
||||
view a human seat gets and play by the rules the engine enforces on
|
||||
everyone; a tier changes what they draw and know.</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>
|
||||
<div class="reading">
|
||||
{#each HOW_TO_PLAY as section (section.title)}
|
||||
<h3 id="play-{section.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}">{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/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}
|
||||
</div>
|
||||
{:else if tab === "rulings"}
|
||||
<div class="reading">
|
||||
<p class="colophon">
|
||||
For players who know the box: how this table resolves what the
|
||||
cardboard leaves to the players, by card. 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>
|
||||
<div class="search-row">
|
||||
<input bind:value={rulingsSearch} placeholder="search the rulings by card or topic…" aria-label="search house rulings" />
|
||||
</div>
|
||||
{#if rulingsSearch.trim()}
|
||||
<p class="count">{rulingResults.length === 0 ? `Nothing here mentions "${rulingsSearch.trim()}".` : `${rulingResults.length} of ${HOUSE_RULINGS.length} rulings`}</p>
|
||||
{/if}
|
||||
{#each rulingResults as r (r.id)}
|
||||
<h3 id="ruling-{r.id}" class="linked">
|
||||
{r.title}
|
||||
<button class="link-btn" onclick={() => copyLink("rulings", `ruling-${r.id}`, null)} title="copy a link to this ruling">
|
||||
{copied === `rulings/ruling-${r.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each r.body as p, i (i)}<p>{p}</p>{/each}
|
||||
<p class="chips">
|
||||
{#each r.cards as id (id)}
|
||||
<button class="chip" onclick={(e) => openEntry(id, e.currentTarget)}>{cardDef(id).name}</button>
|
||||
{/each}
|
||||
</p>
|
||||
{/each}
|
||||
{#if !rulingsSearch.trim()}
|
||||
<h3 id="ruling-the-table">The table itself</h3>
|
||||
<p>Clockwork wizards — the automatons — play from the same redacted
|
||||
view a human seat gets and play by the rules the engine enforces on
|
||||
everyone; a tier changes what they draw and know.</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>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === "rules"}
|
||||
<p class="colophon">
|
||||
Sixth edition rules, from the original rulebook. © 1985 Jolly Games —
|
||||
this digital adaptation is a fan project.
|
||||
</p>
|
||||
{#each RULES_SECTIONS as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">The rulebook, verbatim</h2>
|
||||
<p class="colophon">
|
||||
The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT}
|
||||
</p>
|
||||
{#each RULEBOOK_BASE as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">Expansion Set 1 — verbatim</h2>
|
||||
{#each RULEBOOK_EXPANSION as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<h2 class="faq-divider">Official FAQ — general rulings</h2>
|
||||
<p class="colophon">
|
||||
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings
|
||||
occasionally reference other editions. Card-specific rulings sit on the
|
||||
cards themselves — look for the FAQ seal.
|
||||
</p>
|
||||
{#each FAQ_GENERAL as section (section.title)}
|
||||
<h3>{section.title}</h3>
|
||||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||||
{/each}
|
||||
<div class="reading">
|
||||
<div class="search-row">
|
||||
<input bind:value={rulesSearch} placeholder="search the rules, the rulebook, the expansion, and the FAQ…" aria-label="search the rules" />
|
||||
</div>
|
||||
{#if rulesSearch.trim()}
|
||||
<p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
|
||||
{#each refResults as s (s.id)}
|
||||
<h3 id={s.id} class="linked">
|
||||
{s.title}
|
||||
<span class="source">{SOURCE_LABELS[s.source]}</span>
|
||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<nav class="contents" aria-label="contents">
|
||||
{#each refBySource as group (group.source)}
|
||||
<div class="contents-group">
|
||||
<span class="contents-head">{SOURCE_LABELS[group.source]}</span>
|
||||
{#each group.sections as s (s.id)}
|
||||
<button class="contents-link" onclick={() => jumpTo(s.id)}>{s.title}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
{#each refBySource as group (group.source)}
|
||||
<h2 class="divider" id="source-{group.source}">{SOURCE_LABELS[group.source]}</h2>
|
||||
{#if group.source === "summary"}
|
||||
<p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games — this digital adaptation is a fan project.</p>
|
||||
{:else if group.source === "rulebook"}
|
||||
<p class="colophon">The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT} Notes marked <span class="ed-note">(6E: …)</span> are this table's, on where the sixth edition differs.</p>
|
||||
{:else if group.source === "faq"}
|
||||
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
|
||||
{/if}
|
||||
{#each group.sections as s (s.id)}
|
||||
<h3 id={s.id} class="linked">
|
||||
{s.title}
|
||||
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
|
||||
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
|
||||
</h3>
|
||||
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="search-row">
|
||||
<input
|
||||
bind:value={search}
|
||||
placeholder="search {pool.length} cards…"
|
||||
aria-label="search cards"
|
||||
/>
|
||||
<input bind:value={cardText} placeholder="search {CARD_POOL.length} cards by name or text…" aria-label="search cards" />
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>set
|
||||
<select bind:value={cardSet}>
|
||||
<option value="all">all</option>
|
||||
<option value="basic">base deck</option>
|
||||
<option value="expansion1">expansion</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>type
|
||||
<select bind:value={cardType}>
|
||||
<option value="all">all</option>
|
||||
<option value="attack">attack</option>
|
||||
<option value="neutral">neutral</option>
|
||||
<option value="counteraction">counteraction</option>
|
||||
<option value="number">number</option>
|
||||
<option value="object">object</option>
|
||||
<option value="trap">trap</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>target
|
||||
<select bind:value={cardSight}>
|
||||
<option value="all">any</option>
|
||||
<option value="los">line of sight</option>
|
||||
<option value="adjacent">adjacent</option>
|
||||
<option value="none">no target</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="count">{cardResults.length === 0 ? "No cards found." : `${cardResults.length} card${cardResults.length === 1 ? "" : "s"}`}</span>
|
||||
</div>
|
||||
{#if cardResults.length === 0}
|
||||
<p class="colophon">No card matches {cardText.trim() ? `"${cardText.trim()}"` : "those filters"}. Try fewer words, or widen the filters.</p>
|
||||
{/if}
|
||||
<div class="card-grid">
|
||||
{#each filtered as def (def.id)}
|
||||
{#each cardResults as def (def.id)}
|
||||
<div class="card-slot">
|
||||
<Card
|
||||
card={{ instanceId: `lib-${def.id}`, cardId: def.id }}
|
||||
onclick={() => (libPeek = def.id)}
|
||||
onfaq={(id) => (faqCardId = id)}
|
||||
onclick={() => openEntry(def.id)}
|
||||
onfaq={() => openEntry(def.id)}
|
||||
/>
|
||||
<span class="card-meta">
|
||||
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
|
||||
@@ -340,7 +490,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tabs { display: flex; gap: 0.3rem; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.tabs button {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.72rem;
|
||||
@@ -368,6 +518,12 @@
|
||||
line-height: 1;
|
||||
}
|
||||
.close:hover { color: #b3372b; }
|
||||
/* A narrow screen wraps the tabs to their own rows under the title and the close. */
|
||||
@media (max-width: 640px) {
|
||||
.booklet-head { gap: 0.5rem 1rem; }
|
||||
.tabs { order: 3; flex-basis: 100%; }
|
||||
.close { order: 2; }
|
||||
}
|
||||
|
||||
.booklet-body {
|
||||
overflow-y: auto;
|
||||
@@ -376,7 +532,9 @@
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.faq-divider {
|
||||
/* Long reading gets a book's column: narrower, a touch larger. */
|
||||
.reading { max-width: 44rem; margin: 0 auto; font-size: 1.04rem; line-height: 1.55; }
|
||||
.divider {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.1em;
|
||||
@@ -385,7 +543,7 @@
|
||||
padding-top: 0.8rem;
|
||||
margin: 1.6rem 0 0.2rem;
|
||||
}
|
||||
.booklet-body h3 {
|
||||
.booklet-body h3, .entry-text h3 {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
@@ -394,18 +552,63 @@
|
||||
padding-bottom: 0.15rem;
|
||||
margin: 1.2rem 0 0.4rem;
|
||||
}
|
||||
.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; }
|
||||
.reading > h3:first-child, .entry-text > h3:first-child { margin-top: 0; }
|
||||
h3.linked { display: flex; align-items: baseline; gap: 0.5rem; scroll-margin-top: 0.5rem; }
|
||||
.source { font-size: 0.65rem; letter-spacing: 0.08em; color: #8a7a5e; border: 1px solid #c9bd9f; border-radius: 3px; padding: 0 0.3rem; text-transform: none; }
|
||||
.link-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: #8a7a5e;
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.35rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
h3:hover .link-btn, .link-btn:focus-visible, .entry-head .link-btn { opacity: 1; }
|
||||
.link-btn:hover { border-color: #b3a687; color: #43331f; }
|
||||
.booklet-body p { margin: 0.35rem 0; }
|
||||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; }
|
||||
.booklet-body a { color: #8a4a1f; text-decoration-style: dotted; }
|
||||
.booklet-body a:hover { color: #43331f; }
|
||||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.88rem; }
|
||||
.count { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; }
|
||||
.booklet-body a, .inline-link { color: #8a4a1f; text-decoration: underline; text-decoration-style: dotted; }
|
||||
.booklet-body a:hover, .inline-link:hover { color: #43331f; }
|
||||
.inline-link { background: none; border: none; padding: 0; font: inherit; cursor: pointer; }
|
||||
.booklet-body :global(.ed-note) {
|
||||
font-style: italic;
|
||||
color: #5e4d33;
|
||||
background: rgba(179, 166, 135, 0.22);
|
||||
border-left: 2px solid #b3a687;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.contents { columns: 2; column-gap: 2rem; margin: 0.6rem 0 0.4rem; font-size: 0.92rem; }
|
||||
.contents-group { break-inside: avoid; margin-bottom: 0.7rem; }
|
||||
.contents-head { display: block; font-family: "Oswald", sans-serif; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a7a5e; margin-bottom: 0.15rem; }
|
||||
.contents-link { display: block; background: none; border: none; padding: 0.05rem 0; font: inherit; color: #8a4a1f; cursor: pointer; text-align: left; }
|
||||
.contents-link:hover { color: #43331f; text-decoration: underline; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.chip {
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: #e4dbc2;
|
||||
border: 1px solid #b3a687;
|
||||
border-radius: 3px;
|
||||
color: #43331f;
|
||||
padding: 0.15rem 0.45rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover { background: #d8cdae; }
|
||||
|
||||
.stories { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0 1rem; }
|
||||
.story { display: flex; flex-direction: column; align-items: center; text-align: center; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.6rem 0.5rem; font-size: 0.9rem; }
|
||||
.big { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.7rem; color: #b3372b; line-height: 1.1; }
|
||||
.tally-list { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.8rem; margin: 0.6rem 0 1rem; }
|
||||
.tally-list dt {
|
||||
font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem;
|
||||
@@ -413,6 +616,14 @@
|
||||
}
|
||||
.tally-list dd { margin: 0; align-self: center; }
|
||||
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0; }
|
||||
.tile { display: flex; flex-direction: column; gap: 0.25rem; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.5rem; text-decoration: none; color: inherit; }
|
||||
.tile:hover { background: #d8cdae; }
|
||||
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
|
||||
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.tile-note { font-size: 0.85rem; color: #6b5a41; }
|
||||
.tile-link { text-decoration: underline; text-decoration-style: dotted; }
|
||||
|
||||
.search-row { margin-bottom: 0.8rem; }
|
||||
.search-row input {
|
||||
width: 100%;
|
||||
@@ -425,6 +636,9 @@
|
||||
font-size: 0.95rem;
|
||||
color: #43331f;
|
||||
}
|
||||
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; margin: -0.3rem 0 0.8rem; font-family: "Oswald", sans-serif; font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase; color: #6b5a41; }
|
||||
.filters select { font-family: "Archivo Narrow", sans-serif; font-size: 0.9rem; text-transform: none; letter-spacing: 0; margin-left: 0.3rem; background: #f6f0df; border: 1px solid #b3a687; border-radius: 3px; color: #43331f; padding: 0.15rem 0.3rem; }
|
||||
.filters .count { margin-left: auto; text-transform: none; letter-spacing: 0; }
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
|
||||
@@ -432,25 +646,51 @@
|
||||
justify-items: center;
|
||||
}
|
||||
.card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
|
||||
.card-slot :global(.card) { cursor: default; }
|
||||
.card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
|
||||
.lib-peek-scrim {
|
||||
.card-slot :global(.card:hover) { transform: translateY(-0.2rem); }
|
||||
.card-meta {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.68rem;
|
||||
color: #6b5a41;
|
||||
}
|
||||
|
||||
/* The card entry: the familiar card beside reading-size text. */
|
||||
.layer-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 12, 16, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 55;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
.lib-peek :global(.card) {
|
||||
transform: scale(2.1);
|
||||
cursor: default;
|
||||
.entry {
|
||||
background: #efe8d4;
|
||||
color: #43331f;
|
||||
width: min(46rem, 100%);
|
||||
max-height: calc(100vh - 3rem);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b3a687;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lib-peek :global(.card:hover) { transform: scale(2.1); }
|
||||
.card-meta {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.68rem;
|
||||
color: #6b5a41;
|
||||
.entry-head { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 1rem; border-bottom: 2px solid #43331f; }
|
||||
.entry-title { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.entry-head .link-btn { margin-left: 0; }
|
||||
.entry-body { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.2rem; padding: 1rem 1.2rem 1.2rem; overflow-y: auto; font-family: "Archivo Narrow", sans-serif; }
|
||||
.entry-card { align-self: start; position: sticky; top: 0; }
|
||||
.entry-card :global(.card) { transform: scale(1.35); transform-origin: top left; margin: 0 2.9rem 4rem 0; cursor: default; }
|
||||
.entry-card :global(.card:hover) { transform: scale(1.35); box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
|
||||
.entry-text { min-width: 0; }
|
||||
.entry-text p { margin: 0.35rem 0; }
|
||||
.entry-meta { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; margin: 0 0 0.4rem; }
|
||||
.printed { font-size: 1.05rem; }
|
||||
@media (max-width: 640px) {
|
||||
.entry-body { grid-template-columns: 1fr; }
|
||||
.entry-card { position: static; display: flex; justify-content: center; }
|
||||
.entry-card :global(.card) { transform: none; margin: 0; }
|
||||
.entry-card :global(.card:hover) { transform: none; }
|
||||
.contents { columns: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { motion } from "./motion";
|
||||
import Board from "./Board.svelte";
|
||||
import { wizardColor } from "./colors";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { humanize, spellName } from "./net.svelte";
|
||||
@@ -9,7 +10,7 @@
|
||||
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
|
||||
import { castRay, edgeMid } from "./fpv/raycast";
|
||||
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
|
||||
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
|
||||
import { cardDef, isPermanentDuration, stackSightTrace, castSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
@@ -19,6 +20,7 @@
|
||||
pov = null,
|
||||
onshare = null,
|
||||
endLabel = null,
|
||||
startAt = 0,
|
||||
}: {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||
onclose: () => void;
|
||||
@@ -32,6 +34,8 @@
|
||||
onshare?: (() => Promise<string>) | null;
|
||||
/** What the leave button says once the reel has run out. */
|
||||
endLabel?: string | null;
|
||||
/** The step the reel opens on. */
|
||||
startAt?: number;
|
||||
} = $props();
|
||||
|
||||
/** The share button's little life: offer, mint, report. */
|
||||
@@ -49,7 +53,20 @@
|
||||
setTimeout(() => (shareState = "idle"), 4000);
|
||||
}
|
||||
|
||||
let idx = $state(0);
|
||||
let idx = $state(untrack(() => Math.min(Math.max(0, startAt), Math.max(0, steps.length - 1))));
|
||||
/** The timeline: one tick a step, a turn's first step marked, and the
|
||||
* steps where blows landed or gold changed hands flagged, so a reel of
|
||||
* a hundred moves can be read at a glance and jumped into. */
|
||||
const ticks = $derived(steps.map((s, i) => {
|
||||
let flag: "turn" | "hit" | "gold" | "end" | null = null;
|
||||
for (const e of s.events) {
|
||||
if (e.type === "gameWon" || e.type === "playerEliminated") { flag = "end"; break; }
|
||||
if (e.type === "treasurePickedUp" || (e.type === "treasureDropped" && e.onHomeOf != null)) flag = "gold";
|
||||
else if (!flag && (e.type === "damaged" || e.type === "attackResolved" || e.type === "punched")) flag = "hit";
|
||||
else if (!flag && e.type === "turnStarted") flag = "turn";
|
||||
}
|
||||
return { i, actor: s.actor, flag, color: wizardColor(s.view, s.actor) };
|
||||
}));
|
||||
let playing = $state(true);
|
||||
let speed = $state(1);
|
||||
/** Watch the board from above, or relive it through your own eyes. */
|
||||
@@ -187,7 +204,19 @@
|
||||
|
||||
// The reel draws the same sight line the live table shows for an LOS
|
||||
// attack in progress, so a replay-watcher can see how a spell reached them.
|
||||
const sightTrace = $derived(stackSightTrace(step.view));
|
||||
/** The attack's line, or the line a cast in this step was accepted on —
|
||||
* traced after the fact, so the cast's own dust cloud does not blind it. */
|
||||
const sightTrace = $derived.by(() => {
|
||||
const onStack = stackSightTrace(step.view);
|
||||
if (onStack) return onStack;
|
||||
for (let i = step.events.length - 1; i >= 0; i--) {
|
||||
const e = step.events[i]!;
|
||||
if (e.type !== "spellCast") continue;
|
||||
const line = castSightTrace(step.view, e, step.events, true);
|
||||
if (line) return line;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/** Every wall destroyed so far in the reel leaves a mound of rubble on
|
||||
* the first-person floor for the rest of it. */
|
||||
@@ -736,6 +765,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if steps.length > 3}
|
||||
<div class="timeline" role="group" aria-label="the moves, a tick each">
|
||||
{#each ticks as t (t.i)}
|
||||
<button class="tick" class:current={t.i === idx} class:turn={t.flag === "turn"} class:hit={t.flag === "hit"} class:gold={t.flag === "gold"} class:end={t.flag === "end"}
|
||||
style:--who={t.color} title={`move ${t.i + 1} — ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
|
||||
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="replay-controls">
|
||||
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move">◀</button>
|
||||
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
|
||||
@@ -760,10 +798,12 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
.replay {
|
||||
box-sizing: border-box;
|
||||
background: #171a20;
|
||||
border: 1px solid rgba(233, 225, 203, 0.25);
|
||||
border-radius: 8px;
|
||||
width: min(46rem, 100%);
|
||||
max-width: 100%;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -919,6 +959,29 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 14px;
|
||||
margin: 0.4rem 0.2rem 0.2rem;
|
||||
}
|
||||
.tick {
|
||||
flex: 1 1 0;
|
||||
min-width: 2px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 1px;
|
||||
background: var(--who, #8d8672);
|
||||
opacity: 0.45;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tick.turn { height: 9px; opacity: 0.7; }
|
||||
.tick.hit { height: 12px; opacity: 0.9; }
|
||||
.tick.gold { height: 14px; opacity: 1; box-shadow: 0 0 0 1px #e0b34a; }
|
||||
.tick.end { height: 14px; opacity: 1; background: #e9e1cb; }
|
||||
.tick.current { opacity: 1; outline: 1px solid #fff; outline-offset: 1px; }
|
||||
.replay-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -936,4 +999,18 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.playpause { min-width: 3.4rem; }
|
||||
/* A phone: the title takes its own line, the count and the buttons
|
||||
share the next, the caption reads smaller, and the controls fit. */
|
||||
@media (max-width: 640px) {
|
||||
.replay { padding: 0.6rem 0.6rem 0.8rem; }
|
||||
.replay-head { flex-wrap: wrap; gap: 0.4rem 0.6rem; }
|
||||
.replay-title { flex-basis: 100%; font-size: 0.72rem; letter-spacing: 0.1em; }
|
||||
.replay-count { font-size: 0.72rem; }
|
||||
.replay-eyes { white-space: nowrap; }
|
||||
.replay-caption { font-size: 0.72rem; padding: 0.45rem 0.55rem; gap: 0.45rem; }
|
||||
.caption-face { width: 36px; height: 36px; }
|
||||
.caption-status { max-width: 45%; }
|
||||
.replay-controls { gap: 0.35rem; flex-wrap: wrap; }
|
||||
.replay-controls button { min-width: 2.3rem; padding: 0.3rem 0.45rem; font-size: 0.85rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
actor: string;
|
||||
round: number;
|
||||
whole?: boolean;
|
||||
/** The turn's deed in one line, as the share card says it. */
|
||||
headline?: string | null;
|
||||
}
|
||||
let data = $state<ShareSteps | null>(null);
|
||||
let failed = $state(false);
|
||||
@@ -32,7 +34,14 @@
|
||||
<a class="share-brand" href="/">WIZ-WAR</a>
|
||||
{#if data}
|
||||
<div class="share-title">
|
||||
{#if data.whole}The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}{:else}Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}{/if}
|
||||
{#if data.whole}
|
||||
<span class="share-deed">The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}</span>
|
||||
{:else if data.headline}
|
||||
<span class="share-deed">{data.headline}</span>
|
||||
<span class="share-sub">an instant replay of {data.actor}'s turn{data.round ? `, round ${data.round}` : ""}</span>
|
||||
{:else}
|
||||
<span class="share-deed">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -51,6 +60,9 @@
|
||||
onclose={() => (reelKey += 1)} />
|
||||
{/key}
|
||||
</div>
|
||||
<div class="share-play">
|
||||
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="share-foot">
|
||||
@@ -61,12 +73,15 @@
|
||||
transcribed, every wall verified, replays rebuilt move by move from
|
||||
the game's own ledger.
|
||||
</p>
|
||||
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.share-page {
|
||||
/* Safari's text inflation has no business here: the page is laid
|
||||
out for the phone already. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
min-height: 100vh;
|
||||
background: #171a20;
|
||||
color: #d8d2c0;
|
||||
@@ -79,6 +94,7 @@
|
||||
.share-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
width: min(46rem, 100%);
|
||||
margin-bottom: 0.6rem;
|
||||
@@ -97,6 +113,9 @@
|
||||
font-size: 0.85rem;
|
||||
color: #e9e1cb;
|
||||
}
|
||||
.share-deed { display: block; }
|
||||
.share-sub { display: block; text-transform: none; letter-spacing: 0.02em; color: #8d8672; font-size: 0.8rem; }
|
||||
.share-play { width: min(46rem, 100%); margin-top: 0.8rem; text-align: center; }
|
||||
/* The reel is a modal everywhere else; here it stands in the page. */
|
||||
.share-stage { width: min(46rem, 100%); }
|
||||
.share-stage :global(.replay-scrim) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// What happened while you were away, as a few facts before the reel:
|
||||
// read from the missed steps as the server redacted them for you, so it
|
||||
// tells you only what you would have seen at the table.
|
||||
|
||||
import { cardDef, type GameEvent, type GameView } from "@wizwar/engine";
|
||||
|
||||
export interface MissedStep {
|
||||
seq: number;
|
||||
actor: string;
|
||||
events: GameEvent[];
|
||||
view: GameView;
|
||||
chat?: { player: string; text: string }[];
|
||||
}
|
||||
|
||||
export interface MissedFact {
|
||||
text: string;
|
||||
/** The step of the reel this fact belongs to. */
|
||||
step: number;
|
||||
}
|
||||
|
||||
function spell(id: string | null): string {
|
||||
if (!id) return "a punch";
|
||||
try { return cardDef(id).name; } catch { return id; }
|
||||
}
|
||||
|
||||
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
|
||||
const me = view.players.find((p) => p.id === you);
|
||||
if (!me) return false;
|
||||
return Math.abs(me.home.x - cell.x) + Math.abs(me.home.y - cell.y) <= 2;
|
||||
}
|
||||
|
||||
/** The facts of the missed steps that touch you, in order, capped so the
|
||||
* slip stays a slip. Each fact points at its step for the reel. */
|
||||
export function summarizeMissed(steps: readonly MissedStep[], you: string, now: GameView | null): MissedFact[] {
|
||||
const facts: MissedFact[] = [];
|
||||
let lifeLost = 0;
|
||||
let lifeLostStep = -1;
|
||||
let talk = 0;
|
||||
steps.forEach((s, i) => {
|
||||
talk += s.chat?.length ?? 0;
|
||||
for (const e of s.events) {
|
||||
switch (e.type) {
|
||||
case "treasurePickedUp":
|
||||
if (e.owner === you && e.player !== you) facts.push({ text: `${e.player} took your treasure.`, step: i });
|
||||
else if (e.player !== you && e.owner !== e.player) facts.push({ text: `${e.player} picked up ${e.owner}'s treasure.`, step: i });
|
||||
break;
|
||||
case "treasureDropped": {
|
||||
const owner = s.view.treasures.find((t) => t.id === e.treasureId)?.owner;
|
||||
if (e.onHomeOf != null && owner && e.onHomeOf !== owner) {
|
||||
facts.push({ text: owner === you ? `Your treasure was carried home by ${e.player}.` : `${e.player} carried ${owner}'s treasure home.`, step: i });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "damaged":
|
||||
if (e.player === you && e.amount > 0) { lifeLost += e.amount; if (lifeLostStep < 0) lifeLostStep = i; }
|
||||
break;
|
||||
case "spellCast":
|
||||
if (e.target === you) facts.push({ text: `${e.caster} cast ${spell(e.cardId)} at you.`, step: i });
|
||||
break;
|
||||
case "punched":
|
||||
if (e.target === you) facts.push({ text: `${e.attacker} punched you.`, step: i });
|
||||
break;
|
||||
case "wallDestroyed":
|
||||
if (nearHome(s.view, you, e.edge.cell)) facts.push({ text: "A wall near your home was destroyed.", step: i });
|
||||
break;
|
||||
case "playerEliminated":
|
||||
facts.push({ text: e.player === you ? "You were eliminated." : `${e.player} is out of the game.`, step: i });
|
||||
break;
|
||||
case "gameWon":
|
||||
facts.push({ text: e.player === you ? "You won." : `${e.player} won the game.`, step: i });
|
||||
break;
|
||||
case "tableTalk":
|
||||
talk++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
|
||||
if (facts.length === 0) {
|
||||
// Nothing touched you: say what each wizard was up to, all the same.
|
||||
const doings = new Map<string, { moves: number; casts: number; first: number }>();
|
||||
steps.forEach((s, i) => {
|
||||
if (s.actor === you) return;
|
||||
const d = doings.get(s.actor) ?? { moves: 0, casts: 0, first: i };
|
||||
for (const e of s.events) {
|
||||
if (e.type === "moved" && e.player === s.actor) d.moves++;
|
||||
if (e.type === "spellCast" && e.caster === s.actor) d.casts++;
|
||||
}
|
||||
doings.set(s.actor, d);
|
||||
});
|
||||
for (const [who, d] of doings) {
|
||||
const bits = [d.moves > 0 ? `walked ${d.moves} square${d.moves === 1 ? "" : "s"}` : "", d.casts > 0 ? `cast ${d.casts} spell${d.casts === 1 ? "" : "s"}` : ""].filter(Boolean);
|
||||
facts.push({ text: `${who} ${bits.length ? bits.join(" and ") : "passed the time"}; nothing touched you.`, step: d.first });
|
||||
}
|
||||
}
|
||||
if (talk > 0) facts.push({ text: `${talk} line${talk === 1 ? "" : "s"} of table talk.`, step: -1 });
|
||||
const out = facts.slice(0, 6);
|
||||
if (facts.length > 6) out.push({ text: `…and ${facts.length - 6} more.`, step: -1 });
|
||||
if (now) {
|
||||
const me = now.players.find((p) => p.id === you);
|
||||
if (now.phase === "playing" && me?.alive) {
|
||||
const mine = now.activePlayerId === you || now.stack?.waitingOn === you || now.pendingDiscard === you;
|
||||
out.push({ text: mine ? "It's your turn." : `It's ${now.activePlayerId}'s turn.`, step: -1 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// What the help remembers between openings: the tab, each tab's search,
|
||||
// the library's filters, and where the reader was on each page. Module
|
||||
// state, so it lasts the session and costs nothing to restore.
|
||||
|
||||
import type { CardQuery } from "./reference";
|
||||
|
||||
export type HelpTab = "play" | "rules" | "cards" | "rulings" | "about" | "tally";
|
||||
|
||||
export const helpState: {
|
||||
tab: HelpTab;
|
||||
rulesSearch: string;
|
||||
rulingsSearch: string;
|
||||
cards: CardQuery;
|
||||
scroll: Partial<Record<HelpTab, number>>;
|
||||
} = {
|
||||
tab: "play",
|
||||
rulesSearch: "",
|
||||
rulingsSearch: "",
|
||||
cards: { text: "", set: "all", type: "all", sight: "all" },
|
||||
scroll: {},
|
||||
};
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
type GameEvent,
|
||||
redactEvent,
|
||||
} from "@wizwar/engine";
|
||||
import { humanize, net } from "./net.svelte";
|
||||
import { type LogLine, humanize, net } from "./net.svelte";
|
||||
import { receiptFor } from "./receipt";
|
||||
|
||||
const SAVE_KEY = "wizwar-hotseat";
|
||||
|
||||
@@ -47,7 +48,7 @@ class LocalGame {
|
||||
viewerId = $state<PlayerId | null>(null);
|
||||
/** Set while the device should be handed to the named player. */
|
||||
handoffTo = $state<PlayerId | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
log = $state<LogLine[]>([]);
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate command results. */
|
||||
@@ -64,11 +65,19 @@ class LocalGame {
|
||||
private activeMs = 0;
|
||||
private lastMoveAt = 0;
|
||||
|
||||
/** A line of the chronicle for an event, and the receipt under a resolved attack. */
|
||||
private chronicle(e: GameEvent, batch: readonly GameEvent[]): void {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, { text: line, turn: null, notable: false }];
|
||||
const receipt = receiptFor(batch, e);
|
||||
if (receipt) this.log = [...this.log, { text: receipt.title, turn: null, notable: false, receipt: receipt.lines }];
|
||||
}
|
||||
|
||||
/** The tabletop D4 for house calls: chronicle only, no game state. */
|
||||
rollTableDie(): void {
|
||||
if (!this.viewerId) return;
|
||||
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
|
||||
this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`];
|
||||
this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`, turn: null, notable: false }];
|
||||
}
|
||||
|
||||
/** Rebuild the whole game as replay steps (finished games only). */
|
||||
@@ -148,8 +157,7 @@ class LocalGame {
|
||||
this.gameState = state;
|
||||
this.log = [];
|
||||
for (const e of events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, events);
|
||||
}
|
||||
this.active = true;
|
||||
this.viewerId = null;
|
||||
@@ -174,16 +182,14 @@ class LocalGame {
|
||||
let current = state;
|
||||
this.log = [];
|
||||
for (const e of events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, events);
|
||||
}
|
||||
for (const c of saved.commands) {
|
||||
const result = applyCommand(current, c.playerId, c.command);
|
||||
if (!result.ok) throw new Error(`replay failed: ${result.error}`);
|
||||
current = result.state;
|
||||
for (const e of result.events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, result.events);
|
||||
}
|
||||
}
|
||||
this.config = saved.config;
|
||||
@@ -218,7 +224,7 @@ class LocalGame {
|
||||
const plain = $state.snapshot(this.gameState) as GameState;
|
||||
const result = applyCommand(plain, this.viewerId, command);
|
||||
if (!result.ok) {
|
||||
this.log = [...this.log, `— ${result.error} —`];
|
||||
this.log = [...this.log, { text: `— ${result.error} —`, turn: null, notable: false }];
|
||||
return;
|
||||
}
|
||||
this.onFx?.(result.events);
|
||||
@@ -237,8 +243,7 @@ class LocalGame {
|
||||
});
|
||||
}
|
||||
for (const e of result.events) {
|
||||
const line = humanize(e);
|
||||
if (line) this.log = [...this.log, line];
|
||||
this.chronicle(e, result.events);
|
||||
}
|
||||
this.persist();
|
||||
if (this.gameState.phase === "playing") {
|
||||
|
||||
+170
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
import { receiptFor } from "./receipt";
|
||||
|
||||
const SERVER_URL =
|
||||
import.meta.env.VITE_WIZWAR_SERVER ??
|
||||
@@ -90,6 +91,8 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
||||
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
|
||||
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
|
||||
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind} — ${e.amount} damage (${e.total}/${e.needed}).`;
|
||||
case "squareContentDestroyed": return e.kind === "ooze" ? `🔥 The ooze burns away under ${e.attacker}'s fire!` : `🌿 The ${e.kind} is torn apart by ${e.attacker}'s attack!`;
|
||||
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`;
|
||||
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
|
||||
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||
@@ -149,7 +152,9 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
|
||||
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
|
||||
case "creatureMoved": return null;
|
||||
case "creatureAttacked": return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
|
||||
case "creatureAttacked":
|
||||
if (e.target === "wall") return `The ${spellName(e.kind)} punches the wall (rolled ${e.dieRoll ?? "?"})!`;
|
||||
return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
|
||||
case "creatureTouched": return `The ${spellName(e.kind)} falls upon ${e.player}!`;
|
||||
case "creatureDamaged": return e.amount > 0 ? `The ${spellName(e.kind)} takes ${e.amount} damage.` : `The attack has no effect on the ${spellName(e.kind)}.`;
|
||||
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
|
||||
@@ -216,7 +221,8 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "slimeWashed": return `The wave washes the slime away.`;
|
||||
case "wallDamaged": {
|
||||
const what = e.needed === 15 ? "door" : "wall";
|
||||
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name} — ${e.total}/${e.needed}.`;
|
||||
const weapon = e.source === "punch" ? "bare fists" : e.source === "troll" ? "the troll's fist" : cardDef(e.source).name;
|
||||
return `${e.player} batters the ${what} with ${weapon} — ${e.total}/${e.needed}.`;
|
||||
}
|
||||
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
|
||||
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
|
||||
@@ -240,6 +246,8 @@ export interface LogLine {
|
||||
/** The wizard acting, for the color mark beside the line: whoever
|
||||
* cast, struck, walked, or countered — otherwise the turn's owner. */
|
||||
actor?: string;
|
||||
/** A resolved attack's account, folded under its line. */
|
||||
receipt?: string[];
|
||||
}
|
||||
|
||||
/** Events whose `player` is the one acting rather than the one acted on. */
|
||||
@@ -301,6 +309,8 @@ export interface GameSummary {
|
||||
round: number | null;
|
||||
lastMoveAt: string | null;
|
||||
chatCount: number;
|
||||
/** A finished table that moved on: the rematch room and who called it. */
|
||||
rematch?: { roomId: string; by: string } | null;
|
||||
}
|
||||
|
||||
export function attentionLabel(a: GameSummary["attention"]): string {
|
||||
@@ -341,6 +351,28 @@ class Net {
|
||||
spectating = $state(false);
|
||||
/** How many watch from the gallery (0 hides the count). */
|
||||
audience = $state(0);
|
||||
/** A command on its way: sent, and the table has not answered yet. */
|
||||
pending = $state<{ label: string; at: number } | null>(null);
|
||||
/** The last command the table answered, shown for a moment. */
|
||||
confirmed = $state<string | null>(null);
|
||||
/** A command that never left: the socket was down when it was tried. */
|
||||
unsent = $state<string | null>(null);
|
||||
/** A command that left, then the socket dropped before the table answered. */
|
||||
unconfirmed = $state<string | null>(null);
|
||||
/** A line of table talk on its way, until the table echoes it. */
|
||||
chatPending = $state<string | null>(null);
|
||||
/** Table talk that never arrived: handed back to the composer. */
|
||||
chatUnsent = $state<string | null>(null);
|
||||
/** Who spoke last at the table: your own words are never unread. */
|
||||
lastTalkBy = $state<string | null>(null);
|
||||
/** A finished table's call for a rematch: where it went, and who called. */
|
||||
rematchCall = $state<{ roomId: string; by: string } | null>(null);
|
||||
/** A rematch lobby: the last table's wizards not yet seated. */
|
||||
expected = $state<string[]>([]);
|
||||
/** The table called the keeper of this site, and by whom. */
|
||||
challenge = $state<{ by: string; at: string } | null>(null);
|
||||
/** The keeper's name, as the server knows it. */
|
||||
keeper = $state("Kestrel");
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<LogLine[]>([]);
|
||||
error = $state<string | null>(null);
|
||||
@@ -348,11 +380,13 @@ class Net {
|
||||
feedbackReports = $state<FeedbackReportView[]>([]);
|
||||
/** Every seat this browser holds, across rooms. */
|
||||
seats = $state<Seat[]>(loadSeats());
|
||||
/** Seats the last games answer refused: dropped only if refused again. */
|
||||
private voidedOnce = new Set<string>();
|
||||
/** Lobby ledger: one summary per live seat. */
|
||||
stats = $state<Record<string, number | string | null> | null>(null);
|
||||
chatSeen = $state<Record<string, number>>(loadChatSeen());
|
||||
/** Messages in the current room this session (history + live). */
|
||||
chatCount = 0;
|
||||
chatCount = $state(0);
|
||||
games = $state<GameSummary[]>([]);
|
||||
notificationsEnabled = $state(
|
||||
typeof Notification !== "undefined" && Notification.permission === "granted",
|
||||
@@ -367,6 +401,11 @@ class Net {
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
/** A catch-up reel delivered by the server. */
|
||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||
/** The missed steps, fetched for the summary slip before any reel is opened. */
|
||||
missedSteps = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||
/** Where the catch-up reel opens: the step a summary line pointed at. */
|
||||
catchUpStart = $state(0);
|
||||
private catchUpWanted: "summary" | "reel" = "reel";
|
||||
/** One turn's reel, summoned from a chronicle line's instant-replay
|
||||
* eye — steps plus the wizard whose eyes the camera wears. */
|
||||
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
|
||||
@@ -426,6 +465,10 @@ class Net {
|
||||
ws.onclose = () => {
|
||||
this.status = "disconnected";
|
||||
this.ws = null;
|
||||
// Whatever was in flight is now in doubt: the board will say what
|
||||
// landed when the connection returns, and the talk goes back in the box.
|
||||
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
|
||||
if (this.chatPending) { this.chatUnsent = this.chatPending; this.chatPending = null; }
|
||||
setTimeout(() => this.connect(), 1500);
|
||||
};
|
||||
ws.onmessage = (raw) => {
|
||||
@@ -484,6 +527,10 @@ class Net {
|
||||
this.roomColors = msg.colors ?? {};
|
||||
this.roomBots = msg.bots ?? {};
|
||||
this.audience = msg.audience ?? 0;
|
||||
this.rematchCall = msg.rematch ?? null;
|
||||
this.expected = msg.expected ?? [];
|
||||
this.challenge = msg.challenge ?? null;
|
||||
if (typeof msg.keeper === "string") this.keeper = msg.keeper;
|
||||
if (this.you && this.token) {
|
||||
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
|
||||
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
|
||||
@@ -495,6 +542,15 @@ class Net {
|
||||
break;
|
||||
case "state": {
|
||||
this.view = msg.view;
|
||||
// The table answered: the last command landed, and any doubt from
|
||||
// a dropped socket is settled by the board itself.
|
||||
if (this.pending) {
|
||||
const label = this.pending.label;
|
||||
this.pending = null;
|
||||
this.confirmed = label;
|
||||
setTimeout(() => { if (this.confirmed === label) this.confirmed = null; }, 1500);
|
||||
}
|
||||
this.unconfirmed = null;
|
||||
if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
|
||||
this.currentSeq = msg.seq;
|
||||
// Only the FIRST state after arriving carries a gap worth
|
||||
@@ -508,13 +564,17 @@ class Net {
|
||||
const last = this.seen[this.roomId] ?? 0;
|
||||
this.missedMoves = Math.max(0, msg.seq - last);
|
||||
if (this.missedMoves === 0) this.markSeen();
|
||||
// The missed steps come now, for the facts on the slip; the
|
||||
// reel waits for a tap.
|
||||
else if (!this.missedSteps) this.requestCatchUp(true);
|
||||
}
|
||||
if (document.visibilityState === "visible") this.watching = this.roomId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "catchUp":
|
||||
this.catchUp = msg.steps;
|
||||
if (this.catchUpWanted === "summary") this.missedSteps = msg.steps;
|
||||
else this.catchUp = msg.steps;
|
||||
break;
|
||||
case "moment":
|
||||
this.moment = { steps: msg.steps, owner: msg.owner };
|
||||
@@ -528,7 +588,7 @@ class Net {
|
||||
let talk = 0;
|
||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||
for (const e of msg.events as GameEvent[]) {
|
||||
if (e.type === "tableTalk") talk++;
|
||||
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
|
||||
if (e.type === "gameStarted" && !msg.replayed) {
|
||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||
}
|
||||
@@ -543,6 +603,10 @@ class Net {
|
||||
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
|
||||
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable, actor: actorOf(e, this.turnOwner) }];
|
||||
}
|
||||
const receipt = receiptFor(msg.events as GameEvent[], e);
|
||||
if (receipt) {
|
||||
this.log = [...this.log, { text: receipt.title, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable: false, actor: actorOf(e, this.turnOwner), receipt: receipt.lines }];
|
||||
}
|
||||
}
|
||||
if (talk > 0) {
|
||||
this.chatCount += talk;
|
||||
@@ -568,7 +632,21 @@ class Net {
|
||||
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
||||
break;
|
||||
}
|
||||
case "rematch":
|
||||
// The old table hears where the rematch went.
|
||||
if (msg.roomId === this.roomId) this.rematchCall = { roomId: msg.to, by: msg.by };
|
||||
break;
|
||||
case "rematched":
|
||||
// The caller's own move: the seat and room for the new table follow.
|
||||
this.resetChronicle();
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.roomId = null;
|
||||
this.roomIdPending = msg.roomId;
|
||||
break;
|
||||
case "chat": {
|
||||
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
|
||||
this.lastTalkBy = msg.player;
|
||||
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
|
||||
this.chatCount += 1;
|
||||
if (this.roomId) this.markChatSeen();
|
||||
@@ -597,8 +675,13 @@ class Net {
|
||||
// stays — a restarting server, a stale restore, or the wrong
|
||||
// backend all answer with ignorance, and ignorance is not
|
||||
// deletion. The wallet is capped by age instead of by trust.
|
||||
// And a seat is dropped only on the SECOND refusal in a row: one
|
||||
// refusal around a restart or a stale answer must not cost a seat.
|
||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
||||
const doubted = new Set<string>();
|
||||
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
||||
this.voidedOnce = voided;
|
||||
let kept = this.seats.filter((s) => !doubted.has(`${s.roomId}:${s.name}`));
|
||||
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
||||
if (kept.length !== this.seats.length) {
|
||||
this.seats = kept;
|
||||
@@ -619,6 +702,7 @@ class Net {
|
||||
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
|
||||
this.roomIdPending = null;
|
||||
}
|
||||
this.pending = null;
|
||||
this.error = msg.message;
|
||||
// The toast fades; the chronicle remembers why nothing happened.
|
||||
this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
|
||||
@@ -627,8 +711,11 @@ class Net {
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
|
||||
/** True when the message left; false when the socket was not open to carry it. */
|
||||
private send(message: unknown): boolean {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return false;
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A toast the table shows for a moment, as it shows the server's refusals. */
|
||||
@@ -640,7 +727,7 @@ class Net {
|
||||
create(name: string): void {
|
||||
this.you = name;
|
||||
this.spectating = false;
|
||||
this.send({ type: "create", name });
|
||||
if (!this.send({ type: "create", name })) this.flash("Still reaching the table — try again in a moment");
|
||||
}
|
||||
|
||||
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
||||
@@ -657,7 +744,9 @@ class Net {
|
||||
const existing = this.seats.find(
|
||||
(s) => s.roomId === this.roomIdPending && s.name === name,
|
||||
);
|
||||
this.send({ type: "join", roomId, name, token: existing?.token ?? this.token });
|
||||
if (!this.send({ type: "join", roomId, name, token: existing?.token ?? this.token })) {
|
||||
this.flash("Still reaching the table — try again in a moment");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sit back down at a remembered seat. */
|
||||
@@ -715,8 +804,30 @@ class Net {
|
||||
this.send({ type: "rollDie" });
|
||||
}
|
||||
|
||||
sendChat(text: string): void {
|
||||
this.send({ type: "chat", text });
|
||||
/** True when the line left; the composer keeps it otherwise. */
|
||||
sendChat(text: string): boolean {
|
||||
if (!this.send({ type: "chat", text })) return false;
|
||||
this.chatPending = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Call the keeper of this site to the table: a seat is held, and their phone rings. */
|
||||
challengeKeeper(): void {
|
||||
this.send({ type: "challengeKeeper" });
|
||||
}
|
||||
|
||||
/** Call for a rematch from a finished table, or join the one already called. */
|
||||
callRematch(): void {
|
||||
this.send({ type: "rematch" });
|
||||
}
|
||||
|
||||
/** Take the seat kept for you at the rematch table. */
|
||||
acceptRematch(roomId: string): void {
|
||||
if (!this.you) return;
|
||||
this.resetChronicle();
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.join(roomId, this.you);
|
||||
}
|
||||
|
||||
/** Watching the table counts as reading the talk. */
|
||||
@@ -787,6 +898,15 @@ class Net {
|
||||
* line, and wiping it would erase the only notice of why. */
|
||||
leaveLocal(): void {
|
||||
localStorage.removeItem(SEAT_KEY);
|
||||
this.missedSteps = null;
|
||||
this.catchUp = null;
|
||||
this.pending = null;
|
||||
this.unsent = null;
|
||||
this.unconfirmed = null;
|
||||
this.chatPending = null;
|
||||
this.rematchCall = null;
|
||||
this.expected = [];
|
||||
this.challenge = null;
|
||||
this.roomId = null;
|
||||
this.roomIdPending = null;
|
||||
this.view = null;
|
||||
@@ -819,18 +939,27 @@ class Net {
|
||||
this.send({ type: "catchUp", sinceSeq: 0, full: true });
|
||||
}
|
||||
|
||||
/** Ask for the reel of everything since we last watched. */
|
||||
requestCatchUp(): void {
|
||||
/** Ask for the steps since we last watched: for the summary slip, or for the reel. */
|
||||
requestCatchUp(summaryOnly = false): void {
|
||||
if (!this.roomId) return;
|
||||
this.catchUpWanted = summaryOnly ? "summary" : "reel";
|
||||
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
|
||||
}
|
||||
|
||||
/** Open the reel of the missed steps, at the step a fact pointed to. */
|
||||
watchCatchUp(startAt = 0): void {
|
||||
this.catchUpStart = Math.max(0, startAt);
|
||||
if (this.missedSteps) this.catchUp = this.missedSteps;
|
||||
else this.requestCatchUp(false);
|
||||
}
|
||||
|
||||
/** All caught up: remember it and clear the banner. */
|
||||
markSeen(): void {
|
||||
if (!this.roomId) return;
|
||||
this.seen[this.roomId] = this.currentSeq;
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen));
|
||||
this.missedMoves = 0;
|
||||
this.missedSteps = null;
|
||||
}
|
||||
|
||||
closeCatchUp(): void {
|
||||
@@ -875,8 +1004,33 @@ class Net {
|
||||
this.momentTurn = null;
|
||||
}
|
||||
|
||||
command(command: Command): void {
|
||||
this.send({ type: "command", command });
|
||||
/** Send a command, or say plainly that it did not go. */
|
||||
command(command: Command): boolean {
|
||||
const label = describeCommand(command);
|
||||
if (!this.send({ type: "command", command })) {
|
||||
this.unsent = label;
|
||||
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent — the table is out of reach`);
|
||||
return false;
|
||||
}
|
||||
this.unsent = null;
|
||||
this.pending = { label, at: Date.now() };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** A command as the player would name it, for the sending and not-sent notes. */
|
||||
function describeCommand(c: Command): string {
|
||||
switch (c.type) {
|
||||
case "move": return "your step";
|
||||
case "cast": return "your spell";
|
||||
case "counteract": return "your counter";
|
||||
case "pass": return "your pass";
|
||||
case "punch": case "punchWall": return "your punch";
|
||||
case "endTurn": return "ending your turn";
|
||||
case "pickUpTreasure": case "pickUpObject": return "the pickup";
|
||||
case "dropTreasure": case "dropObject": return "the drop";
|
||||
case "playNumberForMovement": return "your number";
|
||||
default: return "your action";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// The receipt under a resolved attack: the blow as it came in, what each
|
||||
// counter left of it, what landed, and whose life moved — read off the
|
||||
// engine's own events, so it explains what happened rather than what the
|
||||
// cards promise.
|
||||
|
||||
import { cardDef, type GameEvent } from "@wizwar/engine";
|
||||
|
||||
export interface Receipt {
|
||||
title: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
function nameOf(id: string | null): string {
|
||||
if (!id) return "The punch";
|
||||
try { return cardDef(id).name; } catch { return id; }
|
||||
}
|
||||
|
||||
/** The receipt for a resolution event, or null when there is nothing to
|
||||
* account for. `batch` is the event list the resolution arrived in: the
|
||||
* damage and life bookkeeping of the same exchange sits beside it. */
|
||||
export function receiptFor(batch: readonly GameEvent[], e: GameEvent): Receipt | null {
|
||||
if (e.type === "attackMissed") {
|
||||
const why =
|
||||
e.because === "invisible" ? `${e.defender} is invisible; the die sent it astray.`
|
||||
: e.because === "shrink" ? `${e.defender} is shrunk; the die said miss.`
|
||||
: `${e.defender} outran it.`;
|
||||
return { title: `${nameOf(e.attackCardId)} misses ${e.defender}`, lines: [why] };
|
||||
}
|
||||
if (e.type !== "attackResolved") return null;
|
||||
const at = batch.indexOf(e);
|
||||
let start = 0;
|
||||
for (let i = at - 1; i >= 0; i--) {
|
||||
if (batch[i]!.type === "attackResolved") { start = i + 1; break; }
|
||||
}
|
||||
const exchange = batch.slice(start, at + 1);
|
||||
const returned = exchange.some((x) => x.type === "damaged" && /\(reflected\)/.test(x.source));
|
||||
const lines: string[] = [];
|
||||
|
||||
const healed = exchange.find((x) => x.type === "lifeGained" && /\(reversed\)/.test(x.source));
|
||||
const tookHold = exchange.find((x) => x.type === "spellSustained");
|
||||
if (e.incoming != null) {
|
||||
const card = nameOf(e.attackCardId);
|
||||
const dur0 = e.incomingDuration ?? 0;
|
||||
const parts = [
|
||||
e.incoming > 0 ? `${e.incoming} incoming`
|
||||
: dur0 > 0 ? `${dur0} turn${dur0 === 1 ? "" : "s"} of ${card} incoming`
|
||||
: `${card} incoming`,
|
||||
];
|
||||
let damage = e.incoming;
|
||||
let duration = dur0;
|
||||
let reflected = 0;
|
||||
for (const t of e.trail ?? []) {
|
||||
const c = nameOf(t.cardId);
|
||||
if (t.nullified) { parts.push(`${t.player}'s ${c} is nullified`); continue; }
|
||||
const off = damage - t.damage;
|
||||
const back = t.reflected - reflected;
|
||||
const turnsOff = duration - t.duration;
|
||||
let verb: string;
|
||||
if (t.cardId === "full-reflection") verb = "turns it around";
|
||||
else if (t.cardId === "reverse") verb = healed ? "turns it into healing" : "reverses it";
|
||||
else if (t.cardId === "empathy") verb = "shares it with the caster";
|
||||
else if (t.cardId === "anti-anti") verb = "cancels the counter before it";
|
||||
else if (back > 0) verb = t.damage > 0 ? `sends ${back} back and lets ${t.damage} through` : `sends ${back} back`;
|
||||
else if ((t.damage === 0 && damage > 0) || (t.duration === 0 && duration > 0 && damage === 0)) verb = "stops it cold";
|
||||
else if (off > 0) verb = `takes ${off} off`;
|
||||
else if (turnsOff > 0) verb = `takes ${turnsOff} turn${turnsOff === 1 ? "" : "s"} off`;
|
||||
else verb = "changes nothing";
|
||||
parts.push(`${t.player}'s ${c} ${verb}`);
|
||||
damage = t.damage;
|
||||
duration = t.duration;
|
||||
reflected = t.reflected;
|
||||
}
|
||||
parts.push(
|
||||
e.redirected ? `the whole blow turns back on ${e.attacker}`
|
||||
: e.fullyStopped ? "nothing lands"
|
||||
: healed && healed.type === "lifeGained" ? `${healed.amount} heals ${e.defender} instead`
|
||||
: e.damageDealt > 0 ? `${e.damageDealt} lands on ${e.defender}`
|
||||
: tookHold && tookHold.type === "spellSustained" ? `${card} takes hold of ${e.defender} for ${tookHold.turns} turn${tookHold.turns === 1 ? "" : "s"}`
|
||||
: "nothing to land",
|
||||
);
|
||||
lines.push(parts.join(" → "));
|
||||
} else if (e.redirected) {
|
||||
lines.push(`the whole blow turns back on ${e.attacker}`);
|
||||
} else if (e.fullyStopped) {
|
||||
lines.push("nothing lands");
|
||||
}
|
||||
|
||||
for (const x of exchange) {
|
||||
if (x.type === "damaged" && x.amount > 0) {
|
||||
const soak = x.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
|
||||
lines.push(`${x.player}: ${x.lifeAfter + x.amount} → ${x.lifeAfter} life${soak ? ` (${soak})` : ""}`);
|
||||
} else if (x.type === "damageImmune") {
|
||||
lines.push(`${x.player} takes nothing — ${x.because}`);
|
||||
} else if (x.type === "lifeGained") {
|
||||
lines.push(`${x.player}: ${x.lifeAfter - x.amount} → ${x.lifeAfter} life, reversed`);
|
||||
} else if (x.type === "stonesDestroyed") {
|
||||
lines.push(`${x.player}'s magic stones are destroyed`);
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
return { title: `${nameOf(e.attackCardId)}${returned ? ", returned," : ""} resolved`, lines };
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// The reference desk behind the help: the rules and FAQ as one searchable,
|
||||
// linkable body; the house rulings keyed to the cards they touch; and the
|
||||
// small text services (inline emphasis, cross-references) the help renders
|
||||
// with. Everything here is data and pure functions; Help.svelte presents it.
|
||||
|
||||
import { allCardDefs, cardDef, type CardDef } from "@wizwar/engine";
|
||||
import { RULES_SECTIONS } from "./rules";
|
||||
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
|
||||
import { FAQ_GENERAL } from "./faq-general";
|
||||
|
||||
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
|
||||
export function slug(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export type RefSource = "summary" | "rulebook" | "expansion" | "faq";
|
||||
|
||||
export const SOURCE_LABELS: Record<RefSource, string> = {
|
||||
summary: "the rules, condensed",
|
||||
rulebook: "the rulebook, verbatim",
|
||||
expansion: "Expansion Set 1, verbatim",
|
||||
faq: "official FAQ",
|
||||
};
|
||||
|
||||
export interface RefSection {
|
||||
/** Unique across all sources: the source and the title's slug. */
|
||||
id: string;
|
||||
source: RefSource;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
function sections(source: RefSource, list: { title: string; body?: string[]; paragraphs?: string[] }[]): RefSection[] {
|
||||
return list.map((s) => ({
|
||||
id: `${source}-${slug(s.title)}`,
|
||||
source,
|
||||
title: s.title,
|
||||
paragraphs: s.body ?? s.paragraphs ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** Every section of the rules tab, in reading order. */
|
||||
export const REFERENCE: RefSection[] = [
|
||||
...sections("summary", RULES_SECTIONS),
|
||||
...sections("rulebook", RULEBOOK_BASE),
|
||||
...sections("expansion", RULEBOOK_EXPANSION),
|
||||
...sections("faq", FAQ_GENERAL),
|
||||
];
|
||||
|
||||
/** The sections whose title or text mention every word of the query. */
|
||||
export function searchReference(query: string): RefSection[] {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return REFERENCE;
|
||||
return REFERENCE.filter((s) => {
|
||||
const hay = `${s.title}\n${s.paragraphs.join("\n")}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** The transcriptions carry light Markdown: **bold** for the rulebook's own
|
||||
* run-in headings, and *(6E: …)* for this table's editorial notes on where
|
||||
* the sixth edition differs from the text it shipped with. Rendered, with
|
||||
* the notes set apart from the original words. */
|
||||
export function renderInline(text: string): string {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\((6E:[\s\S]*?)\)\*/g, '<span class="ed-note">($1)</span>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||
}
|
||||
|
||||
/** The playable pool: every card in the 6e box and its expansion. */
|
||||
export const CARD_POOL: CardDef[] = allCardDefs().filter(
|
||||
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
|
||||
);
|
||||
|
||||
export type CardTypeFilter = "all" | "attack" | "neutral" | "counteraction" | "number" | "object" | "trap";
|
||||
export type SetFilter = "all" | "basic" | "expansion1";
|
||||
export type SightFilter = "all" | "los" | "adjacent" | "none";
|
||||
|
||||
export interface CardQuery {
|
||||
text: string;
|
||||
set: SetFilter;
|
||||
type: CardTypeFilter;
|
||||
sight: SightFilter;
|
||||
}
|
||||
|
||||
function typeMatches(d: CardDef, t: CardTypeFilter): boolean {
|
||||
if (t === "all") return true;
|
||||
const kind = d.cardType ?? "";
|
||||
if (t === "neutral" || t === "counteraction") return kind === t || kind === "neutral/counteraction";
|
||||
return kind === t;
|
||||
}
|
||||
|
||||
function sightMatches(d: CardDef, s: SightFilter): boolean {
|
||||
if (s === "all") return true;
|
||||
if (s === "los") return d.los === true;
|
||||
if (s === "adjacent") return d.adjacent === true;
|
||||
return d.los !== true && d.adjacent !== true;
|
||||
}
|
||||
|
||||
/** Cards matching the query, ranked: an exact name first, then names that
|
||||
* start with the words, then names that contain them, then texts that do.
|
||||
* Alphabetical within each rank. */
|
||||
export function searchCards(q: CardQuery): CardDef[] {
|
||||
const text = q.text.trim().toLowerCase();
|
||||
const rank = (d: CardDef): number => {
|
||||
if (!text) return 2;
|
||||
const name = d.name.toLowerCase();
|
||||
if (name === text) return 0;
|
||||
if (name.startsWith(text)) return 1;
|
||||
if (name.includes(text)) return 2;
|
||||
if ((d.text ?? "").toLowerCase().includes(text)) return 3;
|
||||
return -1;
|
||||
};
|
||||
return CARD_POOL
|
||||
.filter((d) => typeMatches(d, q.type) && sightMatches(d, q.sight))
|
||||
.filter((d) => q.set === "all" || d.set === q.set || d.alsoIn?.some((a) => a.set === q.set))
|
||||
.map((d) => ({ d, r: rank(d) }))
|
||||
.filter((x) => x.r >= 0)
|
||||
.sort((a, b) => a.r - b.r || a.d.name.localeCompare(b.d.name))
|
||||
.map((x) => x.d);
|
||||
}
|
||||
|
||||
/** Other cards a card's text names in capitals — "a NUMBER card" is a
|
||||
* kind, "MAD DASH" is a card. Longest names first so SLOW DEATH is not
|
||||
* also read as SLOW. */
|
||||
export function mentionedCards(cardId: string): CardDef[] {
|
||||
const def = cardDef(cardId);
|
||||
let text = def.text ?? "";
|
||||
if (!text) return [];
|
||||
text = text.replace(new RegExp(`\\b${def.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ");
|
||||
const found: CardDef[] = [];
|
||||
const candidates = CARD_POOL
|
||||
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
|
||||
.sort((a, b) => b.name.length - a.name.length);
|
||||
for (const d of candidates) {
|
||||
const re = new RegExp(`\\b${d.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
if (re.test(text)) {
|
||||
found.push(d);
|
||||
text = text.replace(new RegExp(re.source, "g"), " ");
|
||||
}
|
||||
}
|
||||
return found.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** How a card's corner reads: where it must be aimed. */
|
||||
export function sightOf(d: CardDef): string {
|
||||
if (d.adjacent) return "adjacent";
|
||||
if (d.los) return "line of sight";
|
||||
return "no target";
|
||||
}
|
||||
|
||||
export function typeLabel(d: CardDef): string {
|
||||
switch (d.cardType) {
|
||||
case "neutral/counteraction": return "neutral or counteraction";
|
||||
case null: return "card";
|
||||
default: return d.cardType;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLabel(d: CardDef): string {
|
||||
const also = d.alsoIn?.length ? ` and ×${d.alsoIn[0]!.quantity} in the expansion` : "";
|
||||
return `×${d.quantity} in the ${d.set === "basic" ? "base deck" : "expansion"}${also}`;
|
||||
}
|
||||
|
||||
/** A house ruling: how this table resolves something the cardboard leaves
|
||||
* to the players, in the present tense, keyed to the cards it touches. */
|
||||
export interface HouseRuling {
|
||||
id: string;
|
||||
title: string;
|
||||
cards: string[];
|
||||
body: string[];
|
||||
}
|
||||
|
||||
export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
{
|
||||
id: "thumb-of-god", title: "The Thumb of God", cards: ["thumb-of-god"],
|
||||
body: ["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."],
|
||||
},
|
||||
{
|
||||
id: "illusion-wall", title: "Illusion walls", cards: ["illusion-wall"],
|
||||
body: ["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, and doubting it is free."],
|
||||
},
|
||||
{
|
||||
id: "ambush", title: "Ambushes", cards: ["opportunity-fire"],
|
||||
body: ["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."],
|
||||
},
|
||||
{
|
||||
id: "butt-head", title: "The goat's ram", cards: ["butt-head", "mad-dash", "power-run"],
|
||||
body: [
|
||||
"BUTT-HEAD's ram is movement. The charge is measured as the shortest walk through the corridors from where you cast it to your victim's square, on the legs you have this turn — three, plus any NUMBER played for movement — and it spends them. You cannot pad the blow by taking the long way round, and the goat lands on the victim's square. There is no ceiling on the damage.",
|
||||
"MAD DASH doubles the whole allowance, NUMBER cards and POWER RUN points included, so a well-placed goat can ram for sixteen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "slime", title: "Spells cast into slime", cards: ["fill-square-with-slime"],
|
||||
body: ["Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a slime may hold several. They go off one at a time, oldest first: each wizard who pushes in springs one spell, the next wizard the next. The card says only that each spell goes off once; the queue is this table's reading. A slime shows how many it holds, and a peek names them, since every cast into it was seen."],
|
||||
},
|
||||
{
|
||||
id: "doors-and-sight", title: "Doors and sight", cards: ["pick-lock", "master-key", "remove-lock"],
|
||||
body: [
|
||||
"A wizard beside a door they can open — its lock removed, unlocked this turn, or PICK LOCK or MASTER KEY in hand — sees through the doorway. The hallway behind them still cannot.",
|
||||
"\"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 open. A door unlocked and not passed relocks at the turn's end.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "visionstone", title: "Visionstone", cards: ["visionstone", "dust-cloud"],
|
||||
body: ["VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included, not only direct attacks. It does not see through a DUST CLOUD."],
|
||||
},
|
||||
{
|
||||
id: "dust-cloud", title: "Dust clouds", cards: ["dust-cloud"],
|
||||
body: ["A DUST CLOUD blinds whoever stands in it. No line-of-sight spell may be cast from inside a cloud, nor at anyone standing in one, and sight lines that pass through it are blocked. Spells a wizard casts on themself still work."],
|
||||
},
|
||||
{
|
||||
id: "mental-force", title: "Mental Force", cards: ["mental-force"],
|
||||
body: ["MENTAL FORCE refuses a destination the victim cannot walk to in three spaces, rather than spending the card on nothing."],
|
||||
},
|
||||
{
|
||||
id: "strength", title: "Tearing a treasure away", cards: ["strength"],
|
||||
body: ["STRENGTH's treasure-tear is an attack: it opens a counteraction window (\"this would be an attack\") rather than resolving on the spot."],
|
||||
},
|
||||
{
|
||||
id: "disease", title: "Disease", cards: ["disease"],
|
||||
body: ["DISEASE is a plague cast on yourself (\"You're the carrier!\"). The caster carries it; sharing a square bites in both directions; there is no counteraction."],
|
||||
},
|
||||
{
|
||||
id: "fire-imp", title: "The fire imp's scorch", cards: ["fire-imp"],
|
||||
body: ["The fire imp's scorch is a spell, as the FAQ has it: magical, and counteractable."],
|
||||
},
|
||||
{
|
||||
id: "soulstone", title: "Soulstone", cards: ["soulstone"],
|
||||
body: ["SOULSTONE's floor holds even when its bearer is at three life-points or below."],
|
||||
},
|
||||
{
|
||||
id: "ward", title: "The ward's bite", cards: ["ward"],
|
||||
body: ["A WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does — a reversal, a reflection — touches the ward's caster."],
|
||||
},
|
||||
{
|
||||
id: "curses", title: "Reversing and reflecting a curse", cards: ["reverse", "full-reflection", "reflection", "slow-death", "walking-dead", "idiot"],
|
||||
body: [
|
||||
"A REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse, as the FAQ rules: a point gained per card drawn, half a point per space walked, permanently.",
|
||||
"A FULL REFLECTION returns a permanent curse — SLOW DEATH, WALKING DEAD, IDIOT — onto its caster instead of letting it evaporate.",
|
||||
"REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window: an ABSORB or a BLUNT meets it as it would any blow.",
|
||||
"A REFLECTION against a permanent curse afflicts both wizards. WALKING DEAD and IDIOT say so on the card; SLOW DEATH follows REFLECTION's own rule that a spell works for both parties.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "slow-death", title: "Slow Death's bites", cards: ["slow-death", "absorb", "blunt"],
|
||||
body: ["SLOW DEATH's per-draw bites land as one blow, which pauses for a victim holding ABSORB or BLUNT. The counter is played against the total: ABSORB soaks up to three, BLUNT halves it rounding up."],
|
||||
},
|
||||
{
|
||||
id: "mad-dash", title: "Mad Dash", cards: ["mad-dash", "power-run"],
|
||||
body: ["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 are doubled as well."],
|
||||
},
|
||||
{
|
||||
id: "teleport", title: "Teleporting across the maze's edge", cards: ["teleport"],
|
||||
body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on — the lettered openings are not needed. Four spaces straight up from two squares below the top edge lands two squares up from the bottom."],
|
||||
},
|
||||
{
|
||||
id: "power-drain", title: "Power Drain", cards: ["power-drain", "blunt", "absorb"],
|
||||
body: ["POWER DRAIN drains the number played. The caster gains it whether the blow is BLUNTed or ABSORBed — the FAQ has the counter blunting the damage done, not the drain — and a wall drained for its points gives them up as a wizard would. A FULL SHIELD stops the drain with the spell."],
|
||||
},
|
||||
{
|
||||
id: "troll", title: "The troll's fist", cards: ["troll"],
|
||||
body: ["A commanded TROLL punches a wall line beside it as it punches a wizard: a D4 of damage toward the wall's fall, once a turn."],
|
||||
},
|
||||
{
|
||||
id: "pits", title: "Crossing a pit", cards: ["create-pit"],
|
||||
body: [
|
||||
"Crossing a pit on a 2, 3, or 4 means edging around its rim. The walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it. A warp mouth on the rim is a way off like any square, and is taken when no square offers. A pit with no way off cannot be entered.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "big-man", title: "The giant's shove at a fork", cards: ["big-man"],
|
||||
body: ["BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do. The FAQ gives the choice to the other player; so does this table."],
|
||||
},
|
||||
{
|
||||
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
|
||||
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
|
||||
},
|
||||
{
|
||||
id: "lifesaver", title: "Lifesaver", cards: ["lifesaver"],
|
||||
body: ["A wizard holding LIFESAVER is not eliminated for losing both treasures, exactly as the card promises."],
|
||||
},
|
||||
{
|
||||
id: "force-field", title: "Force Field", cards: ["force-field"],
|
||||
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
|
||||
},
|
||||
];
|
||||
|
||||
/** The rulings that touch a card. */
|
||||
export function rulingsFor(cardId: string): HouseRuling[] {
|
||||
return HOUSE_RULINGS.filter((r) => r.cards.includes(cardId));
|
||||
}
|
||||
|
||||
/** Rulings whose title, text, or cards mention every word of the query. */
|
||||
export function searchRulings(query: string): HouseRuling[] {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return HOUSE_RULINGS;
|
||||
return HOUSE_RULINGS.filter((r) => {
|
||||
const names = r.cards.map((id) => { try { return cardDef(id).name; } catch { return id; } });
|
||||
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
}
|
||||
@@ -95,7 +95,7 @@ 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.",
|
||||
"Move and play cards in any order, then end your turn. You have three steps and one attack a turn — a step, a spell, two more steps is a fine turn. The banner above the board keeps count of your moves and your treasures home.",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user