From 4f3083c490a5acaa0e501ac676eafb86de5b91ce Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 10:33:02 -0400 Subject: [PATCH] Targeting dims out-of-reach squares; Speedstone pays out immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an L.O.S. card now drops every square you cannot see into shadow, leaving eligible targets lit — computed by the engine's own hasLineOfSight over the player's VIEW, so illusion walls you still believe in block your aim, stone and bushes and the Big Man block everyone's, and Around The Corner widens the light one bend when attached. ADJACENT cards light just your square and its four neighbors. The shade paints over tokens and wizards alike, and switches off while arming an ambush, since ambushes aim at the future. A new sightedCellsFor lives in the engine view module so the client can never disagree with the rules. Speedstone, meanwhile, only took effect at next turn's allowance recompute — displaying it mid-turn granted nothing, as playtesting found. It now bumps the current allowance by one on display (a delta, so number-card movement already played survives), unless SLOW or a zeroed allowance says otherwise. Test covers the immediate grant and the persistence into later turns. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 14 ++++++++++-- packages/engine/src/view.ts | 30 +++++++++++++++++++++++++- packages/engine/test/casting.test.ts | 17 +++++++++++++++ packages/web/src/App.svelte | 32 +++++++++++++++++++++++++++- packages/web/src/Board.svelte | 23 +++++++++++++++++++- 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 26ff127..3b33bde 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -132,7 +132,7 @@ export interface SquareContent { } /** Which square contents block line of sight. */ -const LOS_BLOCKING_CONTENT: Record = { +export const LOS_BLOCKING_CONTENT: Record = { stone: true, thornbush: true, rosebush: true, dust: true, slime: true, ooze: false, tacks: false, pit: false, safe: false, }; @@ -1304,7 +1304,17 @@ const CARD_EFFECTS: Record powerstone: stoneEffect("powerstone"), shadowstone: stoneEffect("shadowstone"), soulstone: stoneEffect("soulstone"), - speedstone: stoneEffect("speedstone"), + speedstone: stoneEffect("speedstone", (state, _events, caster) => { + // "Your movement rate is increased by 1" — starting now, not next turn. + // A delta (not a recompute) so number cards already played stay counted. + // No bump while SLOW forces 1, or when this turn's movement is already + // forced to zero (pit struggle, sticky webs) — next turn recomputes. + const isActive = state.players[state.turn.activeIndex]?.id === caster.id; + const slowed = sustainedOn(state, caster.id, "slow").length > 0; + if (isActive && !slowed && state.turn.movementAllowance > 0) { + state.turn.movementAllowance += 1; + } + }), shieldstone: stoneEffect("shieldstone"), visionstone: stoneEffect("visionstone"), brainstone: stoneEffect("brainstone", (state, events, caster) => { diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index a7463b1..a73e3bb 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -2,10 +2,11 @@ // The server sends this after every state change; clients never see the // deck order or other players' hands. -import { type AssembledBoard } from "./board"; +import { hasLineOfSight, type AssembledBoard } from "./board"; import { type CardInstance } from "./cards"; import { boardView, + LOS_BLOCKING_CONTENT, type AmbushState, type CastStack, type CreatureState, @@ -134,3 +135,30 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { }), }; } + +/** + * Every cell the viewing player can see from where they stand, by the same + * line-of-sight rules the engine enforces — computed from the VIEW, so it + * reflects what this player knows (illusion walls they believe in block it). + * The basis for the client's "dim the ineligible squares" targeting aid. + */ +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 blockers: Record = {}; + for (const [key, content] of Object.entries(view.squareContents)) { + if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true; + } + // BIG MAN: you cannot cast spells past him. + for (const p of view.players) { + if (p.alive && view.sustained.some((s) => s.cardId === "big-man" && s.targetId === p.id)) { + blockers[`${p.position.x},${p.position.y}`] = true; + } + } + for (const key of Object.keys(view.board.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + if (hasLineOfSight(view.board, me.position, { x, y }, blockers)) out.add(key); + } + return out; +} diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index e8d1941..1311c64 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -298,3 +298,20 @@ describe("stack discipline", () => { if (!result.ok) expect(result.error).toMatch(/not implemented/); }); }); + +describe("speedstone", () => { + it("displaying it grants the extra movement point immediately", () => { + let { state } = newGame(); + state = toRound2(state); + const who = activePlayer(state).id; + giveCard(state, who, "speedstone"); + expect(state.turn.movementAllowance).toBe(3); + state = must(state, who, { type: "cast", instanceId: "speedstone#T" }); + expect(state.turn.movementAllowance).toBe(4); + // And it persists into later turns via the displayed stone. + state = must(state, who, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + expect(activePlayer(state).id).toBe(who); + expect(state.turn.movementAllowance).toBe(4); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index d2af0f4..f121778 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -5,7 +5,7 @@ import Help from "./Help.svelte"; import Replay from "./Replay.svelte"; import { local } from "./local.svelte"; - import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject } from "@wizwar/engine"; + import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine"; net.connect(); @@ -477,6 +477,35 @@ view.treasures.some((t) => t.position && t.position.x === me.position.x && t.position.y === me.position.y && !t.carriedBy), ); + /** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */ + const litCells = $derived.by(() => { + if (!view || !selectedDef || !isYourTurn) return null; + if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future + if (!me) return null; + if (selectedDef.adjacent === true) { + const { x, y } = me.position; + return new Set( + [[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] + .map(([cx, cy]) => `${cx},${cy}`) + .filter((k) => view.board.cells[k]), + ); + } + if (selectedDef.los !== true) return null; + const sighted = sightedCellsFor(view); + if (attachedMods.some((m) => m.cardId === "around-the-corner")) { + const widened = new Set(sighted); + for (const key of Object.keys(view.board.cells)) { + if (widened.has(key)) continue; + const [cx, cy] = key.split(",").map(Number); + if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) { + widened.add(key); + } + } + return widened; + } + return sighted; + }); + const objectsHere = $derived( view != null && me != null ? (view.groundObjects[`${me.position.x},${me.position.y}`] ?? []) @@ -806,6 +835,7 @@ onCreatureClick={clickCreature} onWarpClick={clickWarp} markedCell={tradeFrom ?? pendingSectorFrom} + {litCells} /> diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 5358349..62aab6c 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -15,6 +15,7 @@ onCreatureClick, onWarpClick, markedCell = null, + litCells = null, }: { view: GameView; edgeSelectMode?: boolean; @@ -26,6 +27,8 @@ onWarpClick?: (cell: { x: number; y: number }, side: Side) => void; /** First square of a two-square spell: marked so the click reads as taken. */ markedCell?: { x: number; y: number } | null; + /** When set, squares NOT in this set dim — the targeting aid. */ + litCells?: Set | null; } = $props(); const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"]; @@ -431,6 +434,16 @@ onkeydown={() => {}} /> {/each} + + {#if litCells} + {#each Object.keys(view.board.cells) as key (key)} + {#if !litCells.has(key)} + {@const dx = Number(key.split(",")[0])} + {@const dy = Number(key.split(",")[1])} + + {/if} + {/each} + {/if}