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}