diff --git a/.claude/skills/wizwar-duel/SKILL.md b/.claude/skills/wizwar-duel/SKILL.md index 5141d5f..b673d5a 100644 --- a/.claude/skills/wizwar-duel/SKILL.md +++ b/.claude/skills/wizwar-duel/SKILL.md @@ -95,8 +95,9 @@ probing legality is free, so when unsure, try it and read the error. ## Game-three scars (lost in four rounds) -- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel has won two games - with quietly-placed wormholes ending beside his home. The client now +- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has + won two games + with quietly-placed wormholes ending beside his home. The client prints a !! line for them; treat any new token pair as a five-alarm fire and recompute both players' delivery distances through it. - **Never lead the attack against Kestrel.** Three games, three saved diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index c6ef254..30f49be 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -296,13 +296,13 @@ function pathDenial(view: GameView): Command | null { } // Enemy gold banked at MY home: my score, snatchable by anyone. A // raider closing on the bank gets the same road-lengthening treatment. - const me2 = me(view); + const self = me(view); const banked = view.treasures.find( - (t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home)); + (t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home)); if (banked) { for (const e of livingEnemies(view)) { - const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y); - if (d <= 6) threats.push({ enemy: e.position, goal: me2.home }); + const d = Math.abs(e.position.x - self.home.x) + Math.abs(e.position.y - self.home.y); + if (d <= 6) threats.push({ enemy: e.position, goal: self.home }); } } if (threats.length === 0) return null; @@ -758,9 +758,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma } // The LAST counter in hand is a treasure of its own: at a healthy life - // total it waits for something big rather than answering every jab — - // patience the clockwork's best opponent taught it, three counters at - // a time. (Lethal blows override this below.) + // total it waits for something big rather than answering every jab. + // (Lethal blows override this below.) const counterCount = view.yourHand.filter((c) => { const t = cardDef(c.cardId).cardType; return t === "counteraction" || t === "neutral/counteraction"; @@ -797,6 +796,8 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma // when the incoming points reach the clockwork's life, any counter // that can save it is cheap at the price. const lethal = !affliction && incoming >= me(view).life; + // 9 clears every thrift threshold below: a killing blow is answered + // with whatever can answer it, price be damned. const weight = lethal ? Math.max(incoming, 9) : incoming; if (stack.kind === "spell") { // REVERSE eats points; a pure duration offers it nothing. @@ -943,6 +944,7 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd if (sd && biggest) { const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length; const dmg = biggestValue * shown; + // `best` is assigned only inside offer(), so TS narrows it to null here. const cur = best as BestPick | null; if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) { best = { @@ -1132,9 +1134,8 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm } } // INFRASTRUCTURE: a wormhole anchored beside home turns every future - // delivery into a three-move stroll — the pattern that won its best - // opponent two games. Placed early, from home's doorstep, far mouth - // by the richest distant gold. + // delivery into a three-move stroll. Placed early, from home's + // doorstep, far mouth by the richest distant gold. { const dwarp = inHand(view, "dimensional-warp"); const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1; @@ -1547,7 +1548,7 @@ export function automatonCommand( const bankedCount = view.treasures.filter( (t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length; // The predicate reads only ENEMY positions — my own steps must not - // flip it mid-march or the walker shuttles (the thief-chase lesson). + // flip it mid-march or the walker shuttles between goals. const bankThreatened = bankedCount > 0 && !self.carriedTreasureId && livingEnemies(view).some((p) => !p.carriedTreasureId && diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index c65dc80..d2e6dca 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -389,13 +389,11 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse } /** LOS including square-filling blockers. */ -function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { - const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); - const blockers = losBlockers(state); - if (sightBetween(board, from, to, blockers)) return true; - if (!stone) return false; - const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined; - if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false; +/** Sight granted by removing any ONE closed edge — the VISIONSTONE's + * whole power, shared by every path that honors it. */ +function sightThroughOneEdge( + board: AssembledBoard, from: Cell, to: Cell, blockers: Record, +): boolean { for (const key of Object.keys(board.edges)) { if ((board.edges[key] ?? "open") === "open") continue; const edges = { ...board.edges }; @@ -405,11 +403,21 @@ function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | un return false; } +function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { + const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); + const blockers = losBlockers(state); + if (sightBetween(board, from, to, blockers)) return true; + if (!stone) return false; + const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined; + if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false; + return sightThroughOneEdge(board, from, to, blockers); +} + /** LOS including square-filling blockers. VISIONSTONE is the bearer's * sight wherever sight is asked of them — creations, dispels, utility * spells — not just attacks: one wall or door, any type, falls away. - * Safe at every revision: widening what a cast may target never changes - * how a recorded command replays. */ + * Ungated by design: validation may widen without a rev bump, because + * refused commands never entered any ledger. */ export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean { return losWith(state, from, to, viewerId, true); } @@ -472,13 +480,7 @@ function casterLos( const blockers = losBlockers(state); if (sightBetween(board, from, to, blockers)) return true; if (!displays(caster, "visionstone")) return false; - for (const key of Object.keys(board.edges)) { - if ((board.edges[key] ?? "open") === "open") continue; - const edges = { ...board.edges }; - delete edges[key]; - if (sightBetween({ ...board, edges }, from, to, blockers)) return true; - } - return false; + return sightThroughOneEdge(board, from, to, blockers); } /** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */ @@ -2941,6 +2943,31 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId): checkVictory(state, events); } +/** A creature arriving in a player's square touches: the wraith's chill + * and the democratic monster's claw open a counteraction window ("REFLECTIONs + * used on the wraith's touch will damage the wraith" — the card assumes + * exactly this window). True = a stack opened and the turn should return. + * "May attack only one player per round of turns" spends attackUsed. */ +function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature: CreatureState): boolean { + for (const p of state.players) { + if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue; + if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator + if (creature.kind === "wraith" && !creature.attackUsed) { + creature.attackUsed = true; + events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); + openCreatureStack(state, creature, p, 2, "wraith"); + return true; + } + if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) { + creature.attackUsed = true; + events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); + openCreatureStack(state, creature, p, 2, "claw"); + return true; + } + } + return false; +} + /** A creature standing on a DIMENSIONAL WARP token steps through it like * any walker: its commander spends one of its moves, solid stone on the * far side refuses it, and a monster is no braver than a wizard about @@ -2964,7 +2991,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone"); if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) && sustainedOn(state, o.id, "big-man").length > 0)) { - return err("a giant fills that square"); + return err("a giant fills that corridor"); } const from = creature.position; // FEAR holds monsters off too: "no player or monster". @@ -2972,23 +2999,7 @@ function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult creature.position = { ...dest }; creature.movementUsed++; const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }]; - // Touch effects on arriving in a player's square, as any step has. - for (const p of state.players) { - if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue; - if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; - if (creature.kind === "wraith" && !creature.attackUsed) { - creature.attackUsed = true; - events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); - openCreatureStack(state, creature, p, 2, "wraith"); - return { ok: true, state, events }; - } - if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) { - creature.attackUsed = true; - events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); - openCreatureStack(state, creature, p, 2, "claw"); - return { ok: true, state, events }; - } - } + creatureTouchOnArrival(state, events, creature); return { ok: true, state, events }; } @@ -3046,26 +3057,7 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C } creature.movementUsed++; events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id }); - - // Touch effects on entering a player's square. - for (const p of state.players) { - if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue; - if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator - if (creature.kind === "wraith" && !creature.attackUsed) { - creature.attackUsed = true; - events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); - // The victim may counteract ("REFLECTIONs used on the wraith's touch - // will damage the wraith" — the card assumes exactly this window). - openCreatureStack(state, creature, p, 2, "wraith"); - return { ok: true, state, events }; - } - if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) { - creature.attackUsed = true; // "may attack only one player per round of turns" - events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); - openCreatureStack(state, creature, p, 2, "claw"); - return { ok: true, state, events }; - } - } + if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events }; checkVictory(state, events); return { ok: true, state, events }; } diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 43dfc10..b77bd96 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -757,7 +757,7 @@ describe("the thief-chase holds one goal per turn", () => { state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open"; // A number card and no attacks: the blow the chase serves cannot land. bot.hand = []; - bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never); + bot.hand.push({ cardId: "number-2", instanceId: "N2" }); const visited = [cellKey(bot.position)]; for (let guard = 0; guard < 40; guard++) { const view = viewFor(state, "bot"); @@ -776,8 +776,9 @@ describe("the thief-chase holds one goal per turn", () => { }); }); -describe("the archmage's sharpened instincts", () => { - function botTurn(seed = 42) { +describe("the archmage's key, shield, and race discipline", () => { + function botTurn() { + const seed = 42; let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] }); while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); @@ -790,7 +791,7 @@ describe("the archmage's sharpened instincts", () => { it("a MASTER KEY drawn goes straight on display", () => { const state = botTurn(); const bot = state.players.find((p) => p.id === "bot")!; - bot.hand.push({ cardId: "master-key", instanceId: "MK" } as never); + bot.hand.push({ cardId: "master-key", instanceId: "MK" }); const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); expect(cmd).toEqual({ type: "cast", instanceId: "MK" }); }); @@ -806,8 +807,8 @@ describe("the archmage's sharpened instincts", () => { const bot = state.players.find((p) => p.id === "bot")!; bot.position = { ...foe.position }; bot.life = 2; - bot.hand = [{ cardId: "full-shield", instanceId: "FS" } as never]; - foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never); + bot.hand = [{ cardId: "full-shield", instanceId: "FS" }]; + foe.hand.push({ cardId: "fireball", instanceId: "FB" }); const r = applyCommand(state, "foe", { type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" }, }); @@ -833,15 +834,20 @@ describe("the archmage's sharpened instincts", () => { gold[1]!.position = null; bot.carriedTreasureId = gold[1]!.id; foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard - bot.hand = [ - { cardId: "fireball", instanceId: "FB" } as never, - { cardId: "number-3", instanceId: "N3" } as never, - ]; - // Stand the bot a straight, open four squares from home. + // Legs only: an attack in hand would fire at the foe in sight and + // open a stack this single-seat loop cannot answer. + bot.hand = [{ cardId: "number-3", instanceId: "N3" }]; + // Carve a straight, open four-square run to home; the rig owns its + // geometry rather than praying the seed provides it. const home = bot.home; - const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d })); - if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return; - bot.position = { ...column[4]! }; + const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d })); + for (const c of column) { + if (!viewFor(state, "bot").board.cells[cellKey(c)]) { + throw new Error("setup: the column north of home runs off the board"); + } + delete state.squareContents[cellKey(c)]; + } + bot.position = { ...column[0]! }; for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open"; for (let guard = 0; guard < 12; guard++) { const view = viewFor(state, "bot"); @@ -857,8 +863,9 @@ describe("the archmage's sharpened instincts", () => { }); }); -describe("lessons from the human duels", () => { - function rig(players = ["foe", "bot"]) { +describe("bank guarding, teleport delivery, and turn theft", () => { + function rig() { + const players = ["foe", "bot"]; let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] }); while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); @@ -878,9 +885,28 @@ describe("lessons from the human duels", () => { foe.position = { x: bot.home.x, y: bot.home.y + 1 }; bot.position = { ...foe.home }; bot.hand = []; - const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); - // Whatever step it picks, the march must be TOWARD home, not the gold map. - expect(cmd?.type).toBe("move"); + // The march may wind through the maze; what matters is that it ENDS + // at the threatened bank, not out on the gold map. + let cur = state; + for (let guard = 0; guard < 60; guard++) { + const seat = actingSeat(cur); + if (seat !== "bot") { + // The foe answers stacks with a pass and burns its turns. + let r2 = applyCommand(cur, seat, { type: "pass" }); + if (!r2.ok) r2 = applyCommand(cur, seat, { type: "endTurn", draw: 0 }); + if (!r2.ok) throw new Error(`foe stuck: ${r2.error}`); + cur = r2.state; + continue; + } + const view = viewFor(cur, "bot"); + const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage"); + const r = applyCommand(cur, "bot", cmd); + if (!r.ok) throw new Error(`refused: ${JSON.stringify(cmd)} — ${r.error}`); + cur = r.state; + const at = cur.players.find((p) => p.id === "bot")!; + if (cellKey(at.position) === cellKey(bot.home)) return; + } + throw new Error("the clockwork never came home to its threatened bank"); }); it("carrying with home a blink away, it teleports the delivery", () => { @@ -895,7 +921,7 @@ describe("lessons from the human duels", () => { for (const side of ["N", "S", "E", "W"] as const) { state.edgeOverrides[edgeKey(bot.position, side)] = "wall"; } - bot.hand = [{ cardId: "teleport", instanceId: "TP" } as never]; + bot.hand = [{ cardId: "teleport", instanceId: "TP" }]; const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } }); }); @@ -908,15 +934,15 @@ describe("lessons from the human duels", () => { const banked = state.treasures.find((t) => t.owner === "bot")!; banked.position = { ...foe.home }; const carried = state.treasures.filter((t) => t.owner === "bot")[1]; - if (!carried) return; + if (!carried) throw new Error("setup: the bot owns fewer than two treasures"); carried.carriedBy = "foe"; carried.position = null; foe.carriedTreasureId = carried.id; foe.position = { ...bot.position }; bot.hand = [ - { cardId: "fireball", instanceId: "FB" } as never, - { cardId: "lightning-blast", instanceId: "LB" } as never, - { cardId: "number-4", instanceId: "N4" } as never, + { cardId: "fireball", instanceId: "FB" }, + { cardId: "lightning-blast", instanceId: "LB" }, + { cardId: "number-4", instanceId: "N4" }, ]; const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" }); @@ -924,10 +950,10 @@ describe("lessons from the human duels", () => { }); describe("the denial planner offers only castable blocks", () => { - it("tacks at range are never proposed — the RNRX coma", () => { - // Room RNRX froze Automaton II for three turns: pathDenial proposed - // scattering tacks four squares away, the engine refused ("you must - // be adjacent"), and the refusal-fallback loop read as a coma. + it("tacks are offered only at the caster's feet", () => { + // TACKS demand adjacency; a planner that proposes them at range has + // its cast refused every turn, and the refusal-fallback loop reads + // as an idle bot. let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] }); while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") { const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 }); @@ -941,14 +967,13 @@ describe("the denial planner offers only castable blocks", () => { const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!; foe.position = { ...gold.position! }; bot.position = { ...bot.home }; - bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" } as never]; + bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }]; for (let guard = 0; guard < 6; guard++) { const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); if (!cmd) break; const r = applyCommand(state, "bot", cmd); // Whatever the brain proposes, the engine must accept it. - expect(r.ok, `refused: ${JSON.stringify(cmd)} — ${!r.ok ? r.error : ""}`).toBe(true); - if (!r.ok) break; + if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)} — ${r.error}`); state = r.state; if (cmd.type === "endTurn") break; } diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index fcf1be4..1bec1ee 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -1186,12 +1186,13 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => { powerAttackPoints: 0, params: null, kind: "spell", counters: [], waitingOn: d.id, bentCorner: true, }; + // Wall the straight diagonal shut so only the bend can answer. + state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "S")] = "wall"; + state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "E")] = "wall"; const traced = stackSightTrace(viewFor(state, d.id)); expect(traced).not.toBeNull(); - if (traced) { - // Whether sight ran straight (free-angle found a gap) or bent, the - // overlay has something to draw; a bend names its middle square. - if (traced.bend) expect(traced.bend.mid).toBeDefined(); - } + // The engine promises A workable corner, not which one. + expect(traced?.bend).toBeDefined(); + expect(traced?.bend?.mid).toBeDefined(); }); }); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 2b17ece..2754c77 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; +import { + type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; @@ -709,7 +710,7 @@ describe("fear holds off monsters and unwilling feet alike", () => { damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0, wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [], - } as never); + }); for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open"; while (state.players[state.turn.activeIndex]!.id !== "a") { const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); @@ -749,7 +750,6 @@ describe("fear holds off monsters and unwilling feet alike", () => { // The closer step is the only way out: permitted. const r = applyCommand(state, "a", { type: "move", direction: "S" }); if (!r.ok) throw new Error("escape step refused: " + r.error); - expect(r.ok).toBe(true); }); }); @@ -760,9 +760,9 @@ describe("creatures and the dimensional warp", () => { state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } }); state.creatures.push({ id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, - life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, - justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, - } as never); + damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, + justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [], + }); const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); expect(r.ok).toBe(true); if (r.ok) { @@ -779,9 +779,9 @@ describe("creatures and the dimensional warp", () => { state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" }; state.creatures.push({ id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, - life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, - justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, - } as never); + damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, + justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [], + }); const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain("stone"); diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index 0157608..2334c72 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -488,7 +488,9 @@ describe("a held door is an open doorway to the eye", () => { // no longer on their threshold; clear their path to the doorway. const near = neighbor(cell, side); const far = neighbor(near, side); - if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable + if (!boardView(state).cells[cellKey(far)]) { + throw new Error("setup: seed 42 lost the hallway this rig stands in"); + } state.players.find((p) => p.id === pursuer)!.position = far; state.edgeOverrides[edgeKey(near, side)] = "open"; const rm = giveCard(state, holder, "remove-lock"); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9e417e8..0d36076 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -396,6 +396,7 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string } } } + /** Spoken when a clockwork's chosen command was refused by the engine — * the table sees a stumble instead of an unexplained idle turn. */ const HESITATION_LINES = [ @@ -549,11 +550,12 @@ wss.on("connection", (socket) => { case "kickSeat": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); - const problem = kickSeat(room, session.playerId, String(msg.name ?? "")); + const kickName = String(msg.name ?? ""); + const problem = kickSeat(room, session.playerId, kickName); if (problem) return send(socket, { type: "error", message: problem }); // A kicked live socket is set adrift so it cannot act on a seat it lost. for (const other of sessions) { - if (other.roomId === room.id && other.playerId === msg.name) { + if (other.roomId === room.id && other.playerId === kickName) { other.playerId = null; other.roomId = null; other.token = null; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index e3e0e23..39f2bc0 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,11 +54,6 @@ export interface Room { const rooms = new Map(); -/** Rules revision new games are dealt under (stored games keep their own). - * A rules change while games are live must bump this and gate the engine; - * local hotseat games ride the engine's default and follow in lockstep. */ -const RULES_REV = CURRENT_RULES_REV; - const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; /** Tokens live hashed at rest (memory and disk); clients hold the raw form. */ @@ -164,7 +159,7 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n room.tokens.delete(name); room.bots.delete(name); room.colorChoices.delete(name); - appendLine(room.id, { kind: "kick", name }); + appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() }); recordRoom(room); return null; } @@ -228,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } { if (room.state) return { error: "already started" }; const colors = resolveColors(room); - const result = startInMemory(room, expansion, colors, RULES_REV); + const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV); if ("error" in result) return result; - appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV }); + appendLine(room.id, { kind: "start", expansion, colors, deckRev: CURRENT_RULES_REV }); recordRoom(room); return result; } @@ -320,22 +315,22 @@ function actingSeat(room: Room): PlayerId | null { */ export function driveOneAutomaton( room: Room, -): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null { +): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null { const seat = actingSeat(room); if (!seat || !room.bots.has(seat)) return null; const view = viewFor(room.state!, seat); const bot = room.bots.get(seat); const chosen = automatonCommand(view, bot?.style, bot?.tier); - let hesitated: { refused: Command; error: string } | undefined; + let hesitated: true | undefined; let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier)); if ("error" in r) { // A refused choice retries with the fallback — unless the fallback IS // what just failed — then burns down the ladder to endTurn and pass. // The refusal is remembered: a brain whose choice the engine rejects // is a bug in the brain, and the table deserves to see the stumble - // rather than an unexplained idle turn (the RNRX coma lesson). + // rather than an unexplained idle turn. if (chosen) { - hesitated = { refused: chosen, error: r.error }; + hesitated = true; console.warn( `automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`); r = runCommand(room, seat, automatonFallback(view, bot?.tier)); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 1ccf171..3a2ddb7 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -62,6 +62,7 @@ export interface KickLine { kind: "kick"; /** The seat the host removed from an unstarted room. */ name: string; + at: string; } export interface AbandonLine { @@ -119,7 +120,7 @@ export function readAllRooms(): Map { /** Retire an abandoned room's ledger to the graveyard — never deleted, * only moved out of the living rooms directory. */ export function archiveRoomFile(roomId: string): void { - const src = join(DATA_DIR, `${roomId}.jsonl`); + const src = fileFor(roomId); if (!existsSync(src)) return; const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); mkdirSync(graveyard, { recursive: true }); diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 77fd284..e75dfcd 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -120,6 +120,18 @@ } // BIG MAN towers; SHRINK dwindles. The token says so at a glance. + /** The facing ribbon: a shallow golden triangle hugging a token's + * leading edge — base the token's width, rising a quarter of it. */ + function facingWedge(side: "N" | "E" | "S" | "W", half: number): string { + const a = side === "E" ? 0 : side === "S" ? Math.PI / 2 : side === "W" ? Math.PI : -Math.PI / 2; + const fx = Math.cos(a), fy = Math.sin(a); + const px = -fy, py = fx; + const rise = half * 0.5; + return `${fx * half + px * half},${fy * half + py * half} ` + + `${fx * half - px * half},${fy * half - py * half} ` + + `${fx * (half + rise)},${fy * (half + rise)}`; + } + function wizardScale(id: string): number { if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5; if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62; @@ -596,19 +608,7 @@ {/if} {#if povFacing && !povCreatureId && p.id === view.you} - - {@const fa = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2} - {@const fh = CELL * 0.3 * wizardScale(p.id)} - {@const fxv = Math.cos(fa)} - {@const fyv = Math.sin(fa)} - {@const px = -fyv} - {@const py = fxv} - {@const rise = fh * 0.5} - + {/if} {/each} @@ -658,16 +658,7 @@ {#if povFacing && povCreatureId === c.id} - {@const fa2 = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2} - {@const ch = CELL * 0.26} - {@const cfx = Math.cos(fa2)} - {@const cfy = Math.sin(fa2)} - {@const cpx = -cfy} - {@const cpy = cfx} - + {/if} {/each} diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index c19db2c..b877357 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -9,10 +9,10 @@ // here first. import FirstPerson from "./fpv/FirstPerson.svelte"; import type { FpvTarget } from "./fpv/raycast"; - import { tokenArt } from "./art"; - import { objectArt } from "./art"; + import { objectArt, tokenArt } from "./art"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast"; + import { deepestFacing } from "./fpv/director"; import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { untrack } from "svelte"; import type { GameEvent, GameView, Side } from "@wizwar/engine"; @@ -108,12 +108,7 @@ seatedMount = possessedId; preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing })); const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5; - let deepest = -1, face = 0; - for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { - const h = castRay(view, mx, my, a); - if (h.dist > deepest) { deepest = h.dist; face = a; } - } - cam.x = mx; cam.y = my; cam.facing = face; + cam.x = mx; cam.y = my; cam.facing = deepestFacing(view, mx, my); }); /** What the ACTIVE body stands in or on: hazards it cannot see below @@ -164,11 +159,15 @@ let manualUntil = 0; let turning = false; + /** Manual steering holds the director's idle aims off this long. */ + const STEER_HOLD_MS = 4000; + /** Mounting holds them off for the whole ride. */ + const RIDE_HOLD_MS = 60000; const SIDES4 = ["E", "S", "W", "N"] as const; function manualTurn(dir: -1 | 1) { if (turning) return; turning = true; - manualUntil = performance.now() + 4000; + manualUntil = performance.now() + STEER_HOLD_MS; const from = cam.facing; const to = from + dir * (Math.PI / 2); const t0 = performance.now(); @@ -203,7 +202,7 @@ function manualStride(back: boolean) { if (!canStride) return; - manualUntil = performance.now() + 4000; + manualUntil = performance.now() + STEER_HOLD_MS; const q = Math.round(cam.facing / (Math.PI / 2)); const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!; if (possessed) onCreatureMove?.(possessed.id, side); @@ -321,15 +320,12 @@ const willCut = !camReady || (dist > 1.6 && !hurled); cutawayShot = false; const takeCutaway = (ax: number, ay: number) => { - let best = { d: -1, a: 0 }; - for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { - const h = castRay(v, ax, ay, a); - if (h.dist > best.d) best = { d: h.dist, a }; - } - const back = Math.min(1.6, Math.max(0.35, best.d - 0.4)); - cam.x = ax + Math.cos(best.a) * back; - cam.y = ay + Math.sin(best.a) * back; - cam.facing = best.a + Math.PI; + const a = deepestFacing(v, ax, ay); + const d = castRay(v, ax, ay, a).dist; + const back = Math.min(1.6, Math.max(0.35, d - 0.4)); + cam.x = ax + Math.cos(a) * back; + cam.y = ay + Math.sin(a) * back; + cam.facing = a + Math.PI; camReady = true; cutawayShot = true; }; @@ -372,11 +368,7 @@ // A fresh CUT with nothing asking to be watched faces the deepest // corridor — but a STANDING camera is never "rescued": where you // pointed your own head is where it stays, brick or no brick. - let deepest = -1; - for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { - const h = castRay(v, tx, ty, a); - if (h.dist > deepest) { deepest = h.dist; targetFacing = a; } - } + targetFacing = deepestFacing(v, tx, ty); } if (willCut) { cam.x = tx; cam.y = ty; cam.facing = targetFacing; @@ -424,8 +416,8 @@ {#if me}
- +
{#if possessed} riding the {possessed.kind.replace(/-/g, " ")} @@ -433,7 +425,7 @@ {:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)} {#each rideable as c (c.id)} - {/each} @@ -459,10 +451,7 @@
- {#if canStride} - - - {/if} + {#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)} at your feet {#if !possessed && onpickup} @@ -484,8 +473,16 @@ {/if} {/if} - - ← → turn · ↑ ↓ walk + + + {#if canStride} + + + {/if} + + + ← → turn · ↑ ↓ walk +
{/if} @@ -508,6 +505,9 @@ .bezel-top { border-bottom: 1px solid #2a2620; } .bezel-bottom { border-top: 1px solid #2a2620; } .bezel-spacer { flex: 1; } + .bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; } + .bezel-center { justify-content: center; } + .bezel-right { justify-content: flex-end; } .bezel-mid { display: flex; align-items: stretch; diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 8e48dda..a3307d8 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -7,7 +7,7 @@ import { scheduleFx, type BoardFx } from "./fx"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { castRay, edgeMid } from "./fpv/raycast"; - import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; + import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { prefs } from "./prefs.svelte"; import { stackSightTrace } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine"; @@ -249,15 +249,12 @@ * corridor, facing it, holding a beat of the world-before so the * deed happens ON screen — with the reel's own wizard visible. */ const takeCutaway = (ax: number, ay: number) => { - let best = { d: -1, a: 0 }; - for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { - const h = castRay(v, ax, ay, a); - if (h.dist > best.d) best = { d: h.dist, a }; - } - const back = Math.min(1.6, Math.max(0.35, best.d - 0.4)); - cam.x = ax + Math.cos(best.a) * back; - cam.y = ay + Math.sin(best.a) * back; - cam.facing = best.a + Math.PI; + const a = deepestFacing(v, ax, ay); + const d = castRay(v, ax, ay, a).dist; + const back = Math.min(1.6, Math.max(0.35, d - 0.4)); + cam.x = ax + Math.cos(a) * back; + cam.y = ay + Math.sin(a) * back; + cam.facing = a + Math.PI; camReady = true; cutawayShot = true; const i2 = Math.min(idx, steps.length - 1); diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 2cf2528..531f4d4 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -3,9 +3,7 @@ // GameView. Columns of wall shaded by distance and facing; token art // billboarded for whatever stands in the corridors, occluded per column // by the same depth buffer the walls wrote. - import { - warpMotion, - type FpvTarget, castRay, billboards } from "./raycast"; + import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast"; import { materialTextures } from "./textures"; import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d"; @@ -59,8 +57,23 @@ W: number; H: number; ex: number; ey: number; facing: number; zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array; cols: ({ edge?: string; kind: string; top: number; h: number } | null)[]; + /** Depth-sorted nearest-first, ready for hover hit-testing. */ sprites: Projected[]; } | null = null; + + /** The one visibility rule a sprite obeys in a column — shared by the + * draw pass and the hover hit test so they can never disagree. */ + function spriteVisibleInCol( + sp: Projected, col: number, + zbuf: Float64Array, warpIdCol: Int32Array, warpDistCol: Float64Array, + ): boolean { + if (sp.depth >= zbuf[col]!) return false; + if (sp.warped) { + if (warpIdCol[col] !== sp.warpId || sp.depth <= warpDistCol[col]!) return false; + } else if (sp.depth >= warpDistCol[col]!) return false; + if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) return false; + return true; + } /** Crosshair position in canvas pixels, while the pointer is over the * pane and the pane is an instrument. */ let mouse: { x: number; y: number } | null = null; @@ -403,7 +416,10 @@ if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); } sprites.sort((a, b) => b.sort - a.sort); - hitFrame = { W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, sprites }; + hitFrame = { + W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, + sprites: [...sprites].sort((a, b) => a.depth - b.depth), + }; // The nearest sprite each column carries — depth AND vertical span — // so the overlay passes (veils, ghosts, lintels, risings) can paint // around a body standing in front of them instead of over it. @@ -433,11 +449,7 @@ if (s.glow) ctx.globalCompositeOperation = "lighter"; if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1; for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { - if (s.depth >= zbuf[col]!) continue; - if (s.warped) { - if (warpIdCol[col] !== s.warpId || s.depth <= warpDistCol[col]!) continue; - } else if (s.depth >= warpDistCol[col]!) continue; - if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue; + if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue; if (s.depth < spriteZ[col]!) { spriteZ[col] = s.depth; sprTop[col] = s.top; @@ -590,6 +602,8 @@ ctx.fillRect(col, c.top, 1, c.h); } label = found.label; + } else if (found.shape.kind === "none") { + label = found.label; } else { // A ground square: its four corners projected onto the floor plane. const flen = (f.W / 2) / Math.tan(FOV / 2); @@ -716,7 +730,7 @@ * (or vault) square the pixel lies on — warp-bent columns mapping * their virtual ground back to real cells through the warp's own * rigid motion. */ - export function hitTest(px: number, py: number): FpvTarget | null { + function hitTest(px: number, py: number): FpvTarget | null { return resolveHover(px, py)?.target ?? null; } @@ -728,7 +742,8 @@ shape: | { kind: "rect"; left: number; right: number; top: number; bottom: number } | { kind: "face"; edge: string } - | { kind: "ground"; cell: { x: number; y: number } }; + | { kind: "ground"; cell: { x: number; y: number } } + | { kind: "none" }; } | null { const f = hitFrame; if (!f) return null; @@ -737,15 +752,10 @@ // Sprites first, nearest first, honoring the draw pass's own // visibility rules for this column. - const byDepth = [...f.sprites].sort((a, b) => a.depth - b.depth); - for (const sp of byDepth) { + for (const sp of f.sprites) { if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble) if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue; - if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) continue; - if (sp.depth >= f.zbuf[col]!) continue; - if (sp.warped) { - if (f.warpIdCol[col] !== sp.warpId || sp.depth <= f.warpDistCol[col]!) continue; - } else if (sp.depth >= f.warpDistCol[col]!) continue; + if (!spriteVisibleInCol(sp, col, f.zbuf, f.warpIdCol, f.warpDistCol)) continue; const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom }; const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp); if (sp.hit) return { target: sp.hit, label, shape }; @@ -783,7 +793,7 @@ // Warp-bent ground still TARGETS truly, but the highlight quad // cannot be drawn in this frame's geometry: label it instead. const pt = groundPoint(f, col, d); - return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "face", edge: "\u0000never" } } : null; + return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "none" } } : null; } const pt = groundPoint(f, col, d); return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null; diff --git a/packages/web/src/fpv/director.ts b/packages/web/src/fpv/director.ts index 283c345..03b9202 100644 --- a/packages/web/src/fpv/director.ts +++ b/packages/web/src/fpv/director.ts @@ -6,6 +6,7 @@ import { edgeMid } from "./raycast"; import { fpFxForEvents } from "./fx3d"; import type { GameEvent, GameView } from "@wizwar/engine"; +import { castRay } from "./raycast"; export function shortestArc(from: number, to: number): number { let d = (to - from) % (2 * Math.PI); @@ -131,3 +132,14 @@ export function gatherGlides(before: GameView, after: GameView, povId: string): } return moves; } + +/** The facing that stares down the longest corridor from a square — the + * default view wherever nothing asks to be watched. */ +export function deepestFacing(view: GameView, x: number, y: number): number { + let deepest = -1, face = 0; + for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { + const h = castRay(view, x, y, a); + if (h.dist > deepest) { deepest = h.dist; face = a; } + } + return face; +} diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 1c1785f..ba0f8f4 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -225,7 +225,6 @@ export interface LogLine { * momentSteps, so a chronicle turn number addresses the same commands. */ const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]); - const SEAT_KEY = "wizwar-seat"; const SEATS_KEY = "wizwar-seats"; const CHAT_SEEN_KEY = "wizwar-chat-seen"; @@ -318,7 +317,6 @@ class Net { /** Turns witnessed so far: counts the same boundary events the server * counts, so a chronicle line can name the turn it belongs to. */ private turnCounter = -1; - /** The turn that already carries an instant-replay eye (one per turn). */ /** The turn whose moment reel is open (share links point at it). */ private momentTurn: number | null = null; private shareResolve: ((url: string) => void) | null = null; @@ -494,9 +492,8 @@ class Net { // Prune ONLY seats the server proved dead: the room exists and // refused this exact token. A seat merely ABSENT from the reply // stays — a restarting server, a stale restore, or the wrong - // backend all answer with ignorance, and treating ignorance as - // deletion once cost a live player his seat mid-game. The - // wallet is capped by age instead of by trust. + // backend all answer with ignorance, and ignorance is not + // deletion. The wallet is capped by age instead of by trust. const voided = new Set((msg.voided as string[] | undefined) ?? []); let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`)); if (kept.length > 50) kept = kept.slice(kept.length - 50); @@ -510,10 +507,9 @@ class Net { // "Name is taken" means THIS token was tried and refused: the // credential itself is dead, and holding it helps nobody. But // "no such room" proves nothing about the seat — a restarting - // server, a stale restore, or the wrong backend all say it — - // and burning credentials on a transient locked a real player - // out of a live game. The seat stays; the error shows; a later - // reconnect against the right server walks back in. + // server, a stale restore, or the wrong backend all say it. + // The seat stays; the error shows; a later reconnect against + // the right server walks back in. if (this.roomIdPending && /name is taken/.test(msg.message)) { localStorage.removeItem(SEAT_KEY); } @@ -668,8 +664,9 @@ class Net { } catch { /* blocked at the OS level; the tab title still shows it */ } } - /** Forget the remembered seat and return to the lobby. */ - /** Client-side teardown when the SERVER detached us (kick, abandon). */ + /** Client-side teardown when the SERVER detached us (kick, abandon). + * The chronicle survives: the detachment appended its own explanatory + * line, and wiping it would erase the only notice of why. */ leaveLocal(): void { localStorage.removeItem(SEAT_KEY); this.roomId = null; @@ -682,18 +679,11 @@ class Net { this.audience = 0; } + /** Walk away from the table (or gallery) by choice. */ leave(): void { this.send({ type: "leave" }); // detach server-side too (frees a gallery seat) - localStorage.removeItem(SEAT_KEY); - this.roomId = null; - this.roomIdPending = null; - this.view = null; - this.started = false; - this.players = []; + this.leaveLocal(); this.resetChronicle(); - this.token = null; - this.spectating = false; - this.audience = 0; } start(expansion: boolean): void { diff --git a/tools/claude-seat.mjs b/tools/claude-seat.mjs index 289ea65..12217c5 100644 --- a/tools/claude-seat.mjs +++ b/tools/claude-seat.mjs @@ -42,7 +42,7 @@ function renderView(v) { for (let x = 0; x < B.width; x++) { const k = `${x},${y}`; if (!B.cells[k]) { top += " "; mid += " "; continue; } - const nEdge = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (B.edges[`H:${x},${y - 1}`] ?? "open"); + const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open"; const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " "); const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");