diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 6cd8443..1fa6150 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -1234,6 +1234,10 @@ const CARD_EFFECTS: Record if (wallIgnoringDistance(view, caster.position, to) > 4) { return "teleport reaches at most four spaces"; } + // A teleport is a willing move: FEAR's bubble refuses it (rev 32). + if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, caster.id, caster.position, to)) { + return "an unnatural dread keeps you away"; + } const from = caster.position; caster.position = to; state.turn.movementUsed = state.turn.movementAllowance; // movement ends @@ -2867,6 +2871,12 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C } creature.position = target.to; } + // FEAR holds monsters off too (rules rev 32): "no player or monster". + if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, null, from, creature.position)) { + creature.position = from; + if (creature.wallPassUsed > 0) creature.wallPassUsed--; + return err("an unnatural dread stops the beast"); + } creature.movementUsed++; events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id }); @@ -3706,6 +3716,23 @@ function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null { // --- Movement --------------------------------------------------------------- +/** + * FEAR: "no player or monster will move to within 3 spaces of you." True + * for every WILLING move — walking, warp steps, self-teleports, commanded + * monsters (rules rev 32 widened it beyond walking wizards; forced movement + * such as knockback or a wave is not willing and passes). + */ +function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: Cell): boolean { + for (const other of state.players) { + if (!other.alive || other.id === moverId) continue; + if (sustainedOn(state, other.id, "fear").length === 0) continue; + const d = Math.abs(other.position.x - to.x) + Math.abs(other.position.y - to.y); + const dBefore = Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y); + if (d <= 3 && d < dBefore) return true; + } + return false; +} + /** A blind wizard's wasted lurch into a wall still costs a movement point. */ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult { state.turn.movementUsed++; @@ -3860,15 +3887,9 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { } } // FEAR: no one moves within 3 spaces of the fearsome one. - for (const other of state.players) { - if (other.id === p.id || !other.alive) continue; - if (sustainedOn(state, other.id, "fear").length === 0) continue; - const d = Math.abs(other.position.x - p.position.x) + Math.abs(other.position.y - p.position.y); - const dBefore = Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y); - if (d <= 3 && d < dBefore) { - p.position = from; - return err("an unnatural dread keeps you away"); - } + if (fearRepels(state, p.id, from, p.position)) { + p.position = from; + return err("an unnatural dread keeps you away"); } // CREATE PIT: stepping onto a pit is a jump attempt — roll D4; on a 1 you @@ -4029,6 +4050,9 @@ function doWarpStep(prev: GameState): CommandResult { const dest = cellKey(pair.a) === here ? pair.b : pair.a; if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone"); const from = p.position; + if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, p.id, from, dest)) { + return err("an unnatural dread keeps you away"); + } p.position = { ...dest }; state.turn.movementUsed++; return { ok: true, state, events: [{ type: "warpStepped", player: p.id, from, to: p.position }] }; diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 9057945..e7808ff 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; -import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; +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 } from "./helpers"; @@ -720,3 +720,33 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => { expect(r.state.stack).not.toBeNull(); }); }); + +describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => { + it("a commanded monster cannot close within three of the fearsome", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 32 }); + const a = state.players.find((p) => p.id === "a")!; + const b = state.players.find((p) => p.id === "b")!; + // b radiates fear; a's troll stands exactly four away, aimed straight at b. + state.sustained.push({ + id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b", + remainingTurns: 5, data: {}, + } as never); + b.position = { x: 2, y: 5 }; + a.position = { x: 0, y: 0 }; + state.creatures.push({ + id: "troll-1", kind: "troll", controllerId: "a", position: { x: 2, y: 9 }, + damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0, + wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, + scorchedThisTurn: [], + } as never); + state.edgeOverrides[edgeKey({ x: 2, y: 8 }, "S")] = "open"; + while (state.players[state.turn.activeIndex]!.id !== "a") { + const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const r = applyCommand(state, "a", { type: "moveCreature", creatureId: "troll-1", direction: "N" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("dread"); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 86db7b3..7b6e978 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 = 31; +const RULES_REV = 32; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index d90c437..c02a272 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 31, + deckRev: 32, }; const { state, events } = createGame(config); for (const e of events) {