Fear repels every willing step (rules rev 32)
Room QD3S: Kestrel cast Fear and a Democratic Monster warp-stepped from eight squares away to ONE — moveCreature never knew the card existed. 'No player or monster will move to within 3 spaces of you' now guards every willing move: walking wizards (as before), commanded monsters (walks, wall-passes, and warp mouths alike), dimensional warp-steps, and self-teleports. Forced movement — knockback, waves, Mental Force, Teleport Opponent — is not willing, and still throws you anywhere. The shared fearRepels helper replaces the inline wizard-only check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
172fbc6ff8
commit
0e9d482495
@@ -1234,6 +1234,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
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 }] };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface Room {
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user