Monsters ride the wormhole; mental force fails loudly (rules rev 5)
Two findings from the first human-vs-Claude duel (room 9UA6). A creature standing on a DIMENSIONAL WARP token had no way through it — warpStep obeys only wizards. The new creatureWarpStep command steps a commanded creature through the tokens under the walker's rules: one move spent, solid stone refuses it, FEAR holds the beast, touch effects greet whoever shares the landing; the board taps the paired token like a rim mouth. Replay-safe ungated — a new command widens nothing that was recorded. MENTAL FORCE ate its card silently when the destination lay beyond the victim's three walked spaces — the caster spent an attack and learned nothing (ask me how I know). Rev 5 refuses the impossible destination up front with the card kept; when the victim slips out of range between cast and resolution, a mentalForceFizzled event tells the table what happened instead of nothing. All 20 production ledgers verified, the duel's own among them; 283 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
df2913f366
commit
dd87f1d843
@@ -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<Command, { type: "cast" }>) => string | null;
|
||||
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
|
||||
onResolved?: (ctx: ResolutionContext) => void;
|
||||
};
|
||||
|
||||
@@ -2163,11 +2168,19 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
"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<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
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<Command, { type: "cast" }>): 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") {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user