From cdeb3d510395a71eb221430a1a5e858a7fa76a8d Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 19:03:09 -0400 Subject: [PATCH] Idiot enforces its card (rev 23) + attack sight-line tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules rev 23 — IDIOT, per the card and FAQ: - No handling items: pick up / drop of treasures and objects refused (dropping was the exploit: capturing a stolen treasure on your own home, or dropping your own treasure underfoot for an instant cure) - No punching, no thrown dagger / large rock (attacks on players) - No effect on a victim already carrying one of their own treasures Ungated (permissive): counteractions are now castable while idiotized (the one thing the card expressly allows — the gate wrongly blocked them), and goal-aiding spells (IDIOT_AIDS: destroy-wall, teleport, mad-dash, ...) per the FAQ's 'you could, however, destroy a wall'. Sight tracing: while an LOS attack sits on the stack, the board draws the line it traveled — straight when direct, leg by leg through both warp mouths (with pulsing rings) when the maze's wraparound carried it. Engine traceSight/traceSightFor/stackSightTrace; overlay in Board.svelte; shown live and in replays. Verified against H3PC's cross-board Idiot cast. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/board.ts | 45 ++++++++++++- packages/engine/src/game.ts | 65 ++++++++++++++++--- packages/engine/src/view.ts | 51 +++++++++++++-- packages/engine/test/expansion-combat.test.ts | 54 ++++++++++++++- .../test/sight-illusions-sectors.test.ts | 39 ++++++++++- packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 7 +- packages/web/src/Board.svelte | 42 +++++++++++- packages/web/src/Replay.svelte | 7 +- packages/web/src/local.svelte.ts | 2 +- 10 files changed, 290 insertions(+), 24 deletions(-) diff --git a/packages/engine/src/board.ts b/packages/engine/src/board.ts index e3f89c1..78cda55 100644 --- a/packages/engine/src/board.ts +++ b/packages/engine/src/board.ts @@ -360,6 +360,41 @@ export function hasWarpLineOfSight( to: Cell, blockedCells?: Record, ): boolean { + return warpSightTrace(board, from, to, blockedCells) !== null; +} + +/** + * How a sight line reached its target — the material for drawing it. A warp + * trace carries the two mouths and the exact rim-crossing points (in board + * coordinates, where cell (x,y) spans [x,x+1]) so a renderer can draw the + * near leg to `entry` and the far leg from `exit`. + */ +export type SightTrace = + | { kind: "direct" } + | { + kind: "warp"; + mouthA: { cell: Cell; side: Side }; + mouthB: { cell: Cell; side: Side }; + entry: { x: number; y: number }; + exit: { x: number; y: number }; + }; + +export function traceSight( + board: AssembledBoard, + from: Cell, + to: Cell, + blockedCells?: Record, +): SightTrace | null { + if (hasLineOfSight(board, from, to, blockedCells)) return { kind: "direct" }; + return warpSightTrace(board, from, to, blockedCells); +} + +function warpSightTrace( + board: AssembledBoard, + from: Cell, + to: Cell, + blockedCells?: Record, +): Extract | null { const DIR: Record = { N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 }, }; @@ -423,10 +458,16 @@ export function hasWarpLineOfSight( segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells, [cellKey(to), cellKey(mouthB)]) ) { - return true; + return { + kind: "warp", + mouthA: { cell: mouthA, side: sideA }, + mouthB: { cell: mouthB, side: w.to.side }, + entry: P, + exit: Pfar, + }; } } - return false; + return null; } /** The game's full line-of-sight check: direct, or through a wraparound opening. */ diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 3062cf4..386d606 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -2227,6 +2227,12 @@ const CARD_EFFECTS: Record if (ctx.fullyStopped || !ctx.defender.alive) return; const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position); if (!hasTreasureOut) return; // "ends if both treasures are being carried" + // FAQ: "If you happen to already be carrying one of your treasures + // when you are hit with this spell, it has no effect on you." + if ((ctx.state.config.deckRev ?? 1) >= 23) { + const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId); + if (carried?.owner === ctx.defender.id) return; + } attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); }, }, @@ -3987,10 +3993,43 @@ function attackPreconditions(state: GameState): string | null { return null; } -function castingBlocked(state: GameState, playerId: PlayerId): string | null { +/** + * IDIOT's FAQ carves out spells that "help you in your goal" of reaching the + * treasure — barrier-removal, passage, and movement boosts. Attacks and + * everything aimed at other players stay forbidden. + */ +const IDIOT_AIDS = new Set([ + "destroy-wall", "pass-through-wall", "teleport", "power-run", "mad-dash", + "speed", "mist-body", "flight", "exploding-door", "stone-to-water", + "create-door", "vampire", "werewolf", +]); + +function castingBlocked( + state: GameState, playerId: PlayerId, + opts?: { counteraction?: boolean; cardId?: string }, +): string | null { if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa"; if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast"; - if (sustainedOn(state, playerId, "idiot").length > 0) return "What am I doing here...? (you can do nothing but head for your treasure)"; + // IDIOT: "He can do nothing else but cast COUNTERACTION spells (if + // needed)" — and per the FAQ, spells that aid the march to the treasure. + if (sustainedOn(state, playerId, "idiot").length > 0 && + !opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) { + return "What am I doing here...? (you can do nothing but head for your treasure)"; + } + return null; +} + +/** + * IDIOT forbids handling items and attacking players, not just casting: + * "it does not allow you to attack players or pick up other items". + * Dropping is the sharp edge — an idiot could otherwise capture a stolen + * treasure on their own home, or drop their own treasure underfoot for an + * instant cure. + */ +function idiotBlocked(state: GameState, playerId: PlayerId): string | null { + if ((state.config.deckRev ?? 1) >= 23 && sustainedOn(state, playerId, "idiot").length > 0) { + return "What am I doing here...? (you can do nothing but head for your treasure)"; + } return null; } @@ -4131,7 +4170,7 @@ function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult { } function doPunch(prev: GameState, targetId: PlayerId): CommandResult { - const pre = attackPreconditions(prev); + const pre = attackPreconditions(prev) ?? idiotBlocked(prev, activePlayer(prev).id); if (pre) return err(pre); const state = clone(prev); @@ -4348,11 +4387,16 @@ function doCast(prev: GameState, cmd: Extract): Comma // NO SPELL cast on you." const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand; if (isSpell) { - const castBlock = castingBlocked(state, caster.id); + const castBlock = castingBlocked(state, caster.id, { cardId: inHand.cardId }); if (castBlock) return err(castBlock); } else if (sustainedOn(state, caster.id, "medusa").length > 0) { return err("you are paralyzed by Medusa"); } + // Thrown objects are attacks on players — not among IDIOT's permitted aids. + if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") { + const ib = idiotBlocked(state, caster.id); + if (ib) return err(ib); + } const mods = gatherModifiers(caster, cmd); if (typeof mods === "string") return err(mods); @@ -4855,8 +4899,9 @@ function doCounteract( const def = cardDef(card.cardId); // "Opponent cannot move or cast spells, including COUNTERACTIONs" (MEDUSA); - // NO SPELL blocks all spells too. - const castBlock = castingBlocked(state, playerId); + // NO SPELL blocks all spells too. IDIOT does not — counteractions are the + // one thing its victim is expressly allowed. + const castBlock = castingBlocked(state, playerId, { counteraction: true }); if (castBlock) return err(castBlock); // "REFLECTIONS have no effect" against CHAOS (rules rev 3). @@ -5431,7 +5476,7 @@ function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null { // --- Treasures --------------------------------------------------------------- function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult { - const blocked = requireActionsAvailable(prev); + const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id); if (blocked) return err(blocked); const state = clone(prev); @@ -5480,7 +5525,7 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult { } function doPickUpObject(prev: GameState, instanceId: string): CommandResult { - const blocked = requireActionsAvailable(prev); + const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id); if (blocked) return err(blocked); const state = clone(prev); @@ -5523,6 +5568,8 @@ export function isMovableObject(cardId: string): boolean { } function doDropObject(prev: GameState, instanceId: string): CommandResult { + const ib = idiotBlocked(prev, activePlayer(prev).id); + if (ib) return err(ib); const state = clone(prev); const p = activePlayer(state); const card = p.hand.find((c) => c.instanceId === instanceId); @@ -5539,6 +5586,8 @@ function doDropObject(prev: GameState, instanceId: string): CommandResult { } function doDropTreasure(prev: GameState): CommandResult { + const ib = idiotBlocked(prev, activePlayer(prev).id); + if (ib) return err(ib); const state = clone(prev); const p = activePlayer(state); if (!p.carriedTreasureId) return err("you are not carrying a treasure"); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 1f53e3a..3ce34fd 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -2,8 +2,8 @@ // The server sends this after every state change; clients never see the // deck order or other players' hands. -import { sightBetween, type AssembledBoard } from "./board"; -import { type CardInstance } from "./cards"; +import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; +import { cardDef, type CardInstance } from "./cards"; import { boardView, LOS_BLOCKING_CONTENT, @@ -173,6 +173,16 @@ export function sightedCellsFor(view: GameView): Set { const out = new Set(); const me = view.players.find((p) => p.id === view.you); if (!me) return out; + const { board, blockers } = sightBasis(view); + for (const key of Object.keys(board.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); + } + return out; +} + +/** The board-as-seen and sight blockers this view's sight rules run against. */ +function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record } { // Held-open doors are open doorways to the eye (rules rev 15). let board = view.board; if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) { @@ -189,11 +199,38 @@ export function sightedCellsFor(view: GameView): Set { blockers[`${p.position.x},${p.position.y}`] = true; } } - for (const key of Object.keys(board.cells)) { - const [x, y] = key.split(",").map(Number) as [number, number]; - if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); - } - return out; + return { board, blockers }; +} + +/** + * How one square sees another under this viewer's knowledge of the board + * (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 { + const { board, blockers } = sightBasis(view); + return traceSight(board, from, to, blockers); +} + +/** + * The sight line behind the attack currently on the stack — the board's + * answer to "how can he even see me?". Null when nothing should draw: + * no stack, a creature's or physical attack, a non-LOS card, attacker and + * 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, +): { from: Cell; to: Cell; trace: SightTrace } | 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); + return trace ? { from: a.position, to: d.position, trace } : null; } const CREATION_CARD_IDS = new Set([ diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 0adfa0b..fc13231 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game"; +import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game"; import { cellKey, stepTarget } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; @@ -161,6 +161,58 @@ describe("expansion combat cards", () => { expect(r.error).toMatch(/blocked/); } }); + + it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => { + let { state } = createGame({ + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, + }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + // The victim carries an OPPONENT'S treasure into the spell. + const d = state.players.find((p) => p.id === defender)!; + const stolen = state.treasures.find((t) => t.owner === attacker)!; + stolen.carriedBy = defender; + stolen.position = null; + d.carriedTreasureId = stolen.id; + const id = giveCard(state, attacker, "idiot"); + state = castAt(state, attacker, defender, id); + expect(sustainedOn(state, defender, "idiot").length).toBe(1); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + + // No dropping the carried treasure (no capturing it on your home, either). + const drop = applyCommand(state, defender, { type: "dropTreasure" }); + expect(drop.ok).toBe(false); + if (!drop.ok) expect(drop.error).toMatch(/treasure/); + // No punching the tormentor. + expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false); + // Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall"). + const sp = giveCard(state, defender, "speed", "SP", 0); + expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true); + // Counteractions are expressly allowed: absorb an incoming fireball. + state = must(state, defender, { type: "endTurn", draw: 0 }); + const fb = giveCard(state, attacker, "fireball", "F", 0); + state = must(state, attacker, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, + }); + giveCard(state, defender, "absorb-spell", "AB", 1); + expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true); + }); + + it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => { + let { state } = createGame({ + playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23, + }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const d = state.players.find((p) => p.id === defender)!; + const own = state.treasures.find((t) => t.owner === defender)!; + own.carriedBy = defender; + own.position = null; + d.carriedTreasureId = own.id; + const id = giveCard(state, attacker, "idiot"); + state = castAt(state, attacker, defender, id); + expect(sustainedOn(state, defender, "idiot").length).toBe(0); + }); }); describe("swap home bases", () => { diff --git a/packages/engine/test/sight-illusions-sectors.test.ts b/packages/engine/test/sight-illusions-sectors.test.ts index 9c7fc51..4a178da 100644 --- a/packages/engine/test/sight-illusions-sectors.test.ts +++ b/packages/engine/test/sight-illusions-sectors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src"; -import { cellKey, edgeKey, type Cell } from "../src/board"; +import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; @@ -463,3 +463,40 @@ describe("junction alterations roll for their sector (rules rev 20)", () => { expect(createdKeys.length).toBe(1); }); }); + +describe("sight tracing", () => { + it("agrees with sightBetween on every pair and pins entry/exit to the mouths", () => { + const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] }); + const board = boardView(state); + const cells = Object.keys(board.cells).map((k) => { + const [x, y] = k.split(",").map(Number) as [number, number]; + return { x, y }; + }); + let warped = 0; + for (const from of cells) { + for (const to of cells) { + const t = traceSight(board, from, to); + expect(t !== null).toBe(sightBetween(board, from, to)); + if (!t) continue; + if (hasLineOfSight(board, from, to)) { + expect(t.kind).toBe("direct"); + } else { + expect(t.kind).toBe("warp"); + if (t.kind === "warp") { + warped++; + // Each crossing point lies on its mouth's one-cell rim segment. + const onRim = (p: { x: number; y: number }, m: { cell: Cell; side: string }) => { + if (m.side === "N") return p.y === m.cell.y && p.x > m.cell.x && p.x < m.cell.x + 1; + if (m.side === "S") return p.y === m.cell.y + 1 && p.x > m.cell.x && p.x < m.cell.x + 1; + if (m.side === "W") return p.x === m.cell.x && p.y > m.cell.y && p.y < m.cell.y + 1; + return p.x === m.cell.x + 1 && p.y > m.cell.y && p.y < m.cell.y + 1; + }; + expect(onRim(t.entry, t.mouthA)).toBe(true); + expect(onRim(t.exit, t.mouthB)).toBe(true); + } + } + } + } + expect(warped).toBeGreaterThan(0); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 104dcc7..4fd4c07 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 22; +const RULES_REV = 23; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index dbfa8a5..de386ec 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -10,7 +10,7 @@ import { scheduleFx, type BoardFx } from "./fx"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { local } from "./local.svelte"; - import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine"; + import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine"; net.connect(); @@ -854,6 +854,10 @@ t.position.y === me.position.y && !t.carriedBy), ); /** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */ + // While an LOS attack sits on the stack, draw the line it traveled — the + // answer to "how can he even see me?" when sight ran through a warp mouth. + const sightTrace = $derived(view ? stackSightTrace(view) : null); + const litCells = $derived.by(() => { if (!view || !selectedDef || !yourMoment) return null; if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future @@ -1386,6 +1390,7 @@ onGhostClick={clickGhostSlot} onCellPeek={(c) => peekAt(c)} {litCells} + {sightTrace} /> diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 851059d..ec70458 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -1,6 +1,6 @@