diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 231a63c..6ed9ab5 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -226,7 +226,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 4; +export const CURRENT_RULES_REV = 5; export interface GameConfig { playerIds: PlayerId[]; @@ -248,6 +248,8 @@ export interface GameConfig { * Rev 4: "the door will relock behind you" means it — passing through * an unlocked door shuts it at the walker's back unless a hand holds * it; unpassed, it relocks at turn's end as before. + * Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to + * in three spaces, instead of eating the card silently at resolution. */ deckRev?: number; } @@ -579,6 +581,8 @@ export type GameEvent = | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } | { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean } + | { type: "creatureWarpStepped"; creatureId: string; from: Cell; to: Cell; by: PlayerId } + | { type: "mentalForceFizzled"; attacker: PlayerId; defender: PlayerId; cell: Cell } | { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell; /** Origins in FINAL coordinates (the maze may renormalize after the * landing); from/to record the pre-shift request. */ @@ -695,6 +699,7 @@ export type Command = | { type: "wardChoice"; play: boolean } | { type: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } + | { type: "creatureWarpStep"; creatureId: string } | { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "cast"; @@ -747,7 +752,7 @@ type AttackEffect = { sustains?: boolean; /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ keepInHand?: boolean; - validate?: (state: GameState, cmd: Extract) => string | null; + validate?: (state: GameState, cmd: Extract, caster?: PlayerState, target?: PlayerState) => string | null; onResolved?: (ctx: ResolutionContext) => void; }; @@ -2163,11 +2168,19 @@ const CARD_EFFECTS: Record "mental-force": { kind: "attack", baseDamage: () => 0, // no LOS printed - validate: (state, cmd) => { + validate: (state, cmd, caster, target) => { const cell = cmd.params?.cell; if (!cell) return "say where they go (within three moved spaces)"; if (!boardView(state).cells[cellKey(cell)]) return "off the board"; if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; + // The victim WALKS those three spaces — a destination the maze's + // walls put out of reach is refused up front, not swallowed at + // resolution with the card already spent. (Rev 5; the victim may + // still slip out of range before it resolves — see onResolved.) + if ((state.config.deckRev ?? 1) >= 5 && target && + walkingDistance(state, target.position, cell) > 3) { + return "the walls put that square beyond three moved spaces"; + } return null; }, onResolved: (ctx) => { @@ -2175,7 +2188,12 @@ const CARD_EFFECTS: Record if (isLockedInPlace(ctx.state, ctx.defender.id)) return; const to = ctx.stack.params?.cell; if (!to) return; // an ambush armed without a destination fizzles - if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return; + if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) { + // The victim slipped beyond reach: the force strains and fails, + // and the table sees it fail rather than wondering. + ctx.events.push({ type: "mentalForceFizzled", attacker: ctx.attacker.id, defender: ctx.defender.id, cell: to }); + return; + } const from = ctx.defender.position; ctx.defender.position = to; ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" }); @@ -2920,6 +2938,57 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId): checkVictory(state, events); } +/** A creature standing on a DIMENSIONAL WARP token steps through it like + * any walker: its commander spends one of its moves, solid stone on the + * far side refuses it, and a monster is no braver than a wizard about + * FEAR. (Monsters obey the same maze the wizards do.) */ +function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult { + const blocked = requireActionsAvailable(prev); + if (blocked) return err(blocked); + const state = clone(prev); + const active = activePlayer(state); + const creature = creatureById(state, creatureId); + if (!creature) return err("no such creature"); + if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) { + return err("that creature does not obey you"); + } + if (creature.movesPerTurn === 0) return err("that creature cannot move"); + if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left"); + const here = cellKey(creature.position); + const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here); + if (!pair) return err("it is not standing on a warp token"); + 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"); + if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) && + sustainedOn(state, o.id, "big-man").length > 0)) { + return err("a giant fills that square"); + } + const from = creature.position; + // FEAR holds monsters off too: "no player or monster". + if (fearRepels(state, null, from, dest)) return err("an unnatural dread stops the beast"); + creature.position = { ...dest }; + creature.movementUsed++; + const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }]; + // Touch effects on arriving in a player's square, as any step has. + for (const p of state.players) { + if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue; + if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; + if (creature.kind === "wraith" && !creature.attackUsed) { + creature.attackUsed = true; + events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); + openCreatureStack(state, creature, p, 2, "wraith"); + return { ok: true, state, events }; + } + if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) { + creature.attackUsed = true; + events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id }); + openCreatureStack(state, creature, p, 2, "claw"); + return { ok: true, state, events }; + } + } + return { ok: true, state, events }; +} + function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); @@ -3739,6 +3808,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman case "wardChoice": return err("no grab is hanging on your Ward"); case "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); + case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "cast": return doCast(state, command); case "setAmbush": return doSetAmbush(state, command); @@ -5006,7 +5076,7 @@ function doCast(prev: GameState, cmd: Extract): Comma (s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id), ); if (effect.validate) { - const problem = effect.validate(state, cmd); + const problem = effect.validate(state, cmd, caster, target); if (problem) return err(problem); } if (inHand.cardId === "waterbolt") { diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index ecc9f38..5f728bc 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -1140,3 +1140,29 @@ describe("stone dead counts only the stones in play", () => { expect(state.players.find((p) => p.id === defender)!.life).toBe(12); }); }); + +describe("MENTAL FORCE respects the victim's three walked spaces", () => { + it("a destination beyond the walls is refused up front, card kept", () => { + let { state } = newGame(); + state = toRound2(state); + const caster = activePlayer(state); + const victim = state.players.find((p) => p.id !== caster.id)!; + caster.position = { x: 0, y: 0 }; + victim.position = { x: 2, y: 4 }; + // Seal the victim into their square: every walked space is out of + // reach, so ANY other destination is beyond three moved spaces. + for (const side of SIDES) { + state.edgeOverrides[edgeKey(victim.position, side)] = "wall"; + } + const mf = giveCard(state, caster.id, "mental-force", "MF"); + const far = { x: 4, y: 9 }; + const refused = applyCommand(state, caster.id, { + type: "cast", instanceId: mf.instanceId, + target: { kind: "player", playerId: victim.id }, params: { cell: far }, + }); + expect(refused.ok).toBe(false); + if (!refused.ok) expect(refused.error).toContain("three moved spaces"); + // The card is still in hand — nothing was spent on the refusal. + expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true); + }); +}); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index dd0656e..2b17ece 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -752,3 +752,38 @@ describe("fear holds off monsters and unwilling feet alike", () => { expect(r.ok).toBe(true); }); }); + +describe("creatures and the dimensional warp", () => { + it("a commanded troll steps through the warp tokens", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); + const you = state.players[state.turn.activeIndex]!.id; + state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } }); + state.creatures.push({ + id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, + life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, + justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, + } as never); + const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); + expect(r.ok).toBe(true); + if (r.ok) { + const troll = r.state.creatures.find((c) => c.id === "troll-w")!; + expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 })); + expect(troll.movementUsed).toBe(1); + } + }); + + it("solid stone on the far side refuses the beast", () => { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] }); + const you = state.players[state.turn.activeIndex]!.id; + state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } }); + state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" }; + state.creatures.push({ + id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 }, + life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false, + justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, + } as never); + const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("stone"); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 5e7eb15..126a735 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -716,6 +716,15 @@ dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side }); return; } + // Standing on a DIMENSIONAL WARP token: clicking the paired token + // steps it through. + const dw = view.dimWarps.find((d) => + (cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) || + (cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell))); + if (dw) { + dispatch({ type: "creatureWarpStep", creatureId: selectedCreature }); + return; + } } selectedCreature = null; return; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index a8d3861..2f07bf7 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -103,6 +103,8 @@ export function humanize(e: GameEvent): string | null { case "doorHeld": return `${e.player} holds the door open.`; case "doorReleased": return `The held door swings shut.`; case "doorsRelocked": return `The door swings shut and relocks.`; + case "creatureWarpStepped": return `The creature slips through the dimensional warp!`; + case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`; case "doorJammed": return `${e.player} jams a door's lock solid.`; case "lockRemoved": return `${e.player} removes a door's lock for good.`; case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;