Safes finally open to keys — and the clockwork stops clawing at the lid
'All LOCK-type cards will work on it' was never wired: openSafes was reset every turn and filled by nothing. A PICK LOCK or MASTER KEY aimed at a safe's square (underfoot or beside) now opens it until turn's end, with its own chronicle line; the dead never-emitted safeOpened variant and its null humanize go. The automaton — H4EN's Automaton II spent six turns grabbing at a locked lid saying 'I meant to do that' — now cracks the box first (key or DISPEL CREATION, which it held the whole time) and drops unopenable safes from its goals rather than marching to one forever. 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
12ece6065a
commit
eabc10f18c
@@ -652,6 +652,17 @@ function treasureGoals(view: GameView): Set<string> {
|
|||||||
}
|
}
|
||||||
for (const t of view.treasures) {
|
for (const t of view.treasures) {
|
||||||
if (!t.position || t.carriedBy) continue;
|
if (!t.position || t.carriedBy) continue;
|
||||||
|
// A chest inside someone else's SAFE is no goal without a way in — a
|
||||||
|
// lock card turns the combination, DISPEL CREATION removes the box.
|
||||||
|
// Marching to an unopenable safe stalls the clockwork on it forever.
|
||||||
|
const boxKey = cellKey(t.position);
|
||||||
|
if (view.squareContents[boxKey]?.kind === "safe" &&
|
||||||
|
view.squareContents[boxKey]!.createdBy !== view.you &&
|
||||||
|
!view.openSafes.includes(boxKey) &&
|
||||||
|
!inHand(view, "pick-lock") && !inHand(view, "master-key") &&
|
||||||
|
!inHand(view, "dispel-creation")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (t.owner === view.you) {
|
if (t.owner === view.you) {
|
||||||
// REPOSSESSION: my own gold banked at an enemy's home is a point on
|
// REPOSSESSION: my own gold banked at an enemy's home is a point on
|
||||||
// THEIR scoreboard. Marching to take it back is worth the detour
|
// THEIR scoreboard. Marching to take it back is worth the detour
|
||||||
@@ -1302,7 +1313,17 @@ export function automatonCommand(
|
|||||||
// Repossessing my own gold off an enemy's home square.
|
// Repossessing my own gold off an enemy's home square.
|
||||||
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
|
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
|
||||||
);
|
);
|
||||||
if (prize) return { type: "pickUpTreasure", treasureId: prize.id };
|
if (prize) {
|
||||||
|
// A SAFE over the prize: turn the combination (or dispel the box)
|
||||||
|
// before reaching for the gold — the grab itself would be refused.
|
||||||
|
const boxed = view.squareContents[here]?.kind === "safe" &&
|
||||||
|
view.squareContents[here]!.createdBy !== you && !view.openSafes.includes(here);
|
||||||
|
if (!boxed) return { type: "pickUpTreasure", treasureId: prize.id };
|
||||||
|
const key = inHand(view, "pick-lock") ?? inHand(view, "master-key");
|
||||||
|
if (key) return { type: "cast", instanceId: key.instanceId, target: { kind: "cell", cell: self.position } };
|
||||||
|
const dispel = inHand(view, "dispel-creation");
|
||||||
|
if (dispel) return { type: "cast", instanceId: dispel.instanceId, target: { kind: "cell", cell: self.position } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wounded clockwork teleports clear of visible hunters.
|
// Wounded clockwork teleports clear of visible hunters.
|
||||||
|
|||||||
@@ -703,7 +703,6 @@ export type GameEvent =
|
|||||||
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
|
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
|
||||||
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
|
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
|
||||||
| { type: "safeCreated"; caster: PlayerId; at: Cell }
|
| { type: "safeCreated"; caster: PlayerId; at: Cell }
|
||||||
| { type: "safeOpened"; player: PlayerId; at: Cell }
|
|
||||||
| { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell }
|
| { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell }
|
||||||
| { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null; edge?: { cell: Cell; side: Side } }
|
| { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null; edge?: { cell: Cell; side: Side } }
|
||||||
| { type: "handsSwapped"; a: PlayerId; b: PlayerId }
|
| { type: "handsSwapped"; a: PlayerId; b: PlayerId }
|
||||||
@@ -745,6 +744,7 @@ export type GameEvent =
|
|||||||
| { type: "extraTurnGranted"; player: PlayerId }
|
| { type: "extraTurnGranted"; player: PlayerId }
|
||||||
| { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number }
|
| { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number }
|
||||||
| { type: "madDash"; player: PlayerId; newAllowance: number }
|
| { type: "madDash"; player: PlayerId; newAllowance: number }
|
||||||
|
| { type: "safeOpened"; player: PlayerId; cell: Cell; withCardId: string }
|
||||||
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
| { type: "trapSprung"; player: PlayerId; cardId?: string }
|
||||||
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
|
||||||
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
||||||
@@ -3588,6 +3588,17 @@ function unlockDoor(
|
|||||||
cardId: string,
|
cardId: string,
|
||||||
opts: { requireAdjacent: boolean },
|
opts: { requireAdjacent: boolean },
|
||||||
): string | null {
|
): string | null {
|
||||||
|
// "All LOCK-type cards will work on it" — aimed at a SAFE's square
|
||||||
|
// (underfoot or beside), the key opens it until the turn ends.
|
||||||
|
const cell = cmd.target?.kind === "cell" ? cmd.target.cell : null;
|
||||||
|
if (cell && state.squareContents[cellKey(cell)]?.kind === "safe") {
|
||||||
|
const d = Math.abs(caster.position.x - cell.x) + Math.abs(caster.position.y - cell.y);
|
||||||
|
if (d > 1) return "you must be at or beside the safe";
|
||||||
|
const key = cellKey(cell);
|
||||||
|
if (!state.openSafes.includes(key)) state.openSafes.push(key);
|
||||||
|
events.push({ type: "safeOpened", player: caster.id, cell: { ...cell }, withCardId: cardId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const found = doorTarget(state, cmd);
|
const found = doorTarget(state, cmd);
|
||||||
if (typeof found === "string") return found;
|
if (typeof found === "string") return found;
|
||||||
const key = edgeKey(found.cell, found.side);
|
const key = edgeKey(found.cell, found.side);
|
||||||
|
|||||||
@@ -1008,6 +1008,42 @@ describe("interception, escape, and hazard sense", () => {
|
|||||||
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cracks a safe over the prize instead of grabbing at the lid forever", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
state = toBot(state);
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const chest = state.treasures.find((t) => t.owner === "foe" && t.position)!;
|
||||||
|
state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" };
|
||||||
|
bot.position = { ...chest.position! };
|
||||||
|
bot.hand = [{ cardId: "dispel-creation", instanceId: "DC" }];
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } });
|
||||||
|
const r = applyCommand(state, "bot", cmd!);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
// The box gone, the very next thought is the grab.
|
||||||
|
const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
||||||
|
expect(next).toMatchObject({ type: "pickUpTreasure" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never proposes the doomed grab on a safe it cannot open", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
state = toBot(state);
|
||||||
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
|
const chest = state.treasures.find((t) => t.owner === "foe" && t.position)!;
|
||||||
|
state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" };
|
||||||
|
bot.position = { ...chest.position! };
|
||||||
|
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
if (!cmd) break;
|
||||||
|
expect(cmd.type).not.toBe("pickUpTreasure");
|
||||||
|
const r = applyCommand(state, "bot", cmd);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
state = r.state;
|
||||||
|
if (cmd.type === "endTurn") break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("a pressed carrier of any temperament turns to mist", () => {
|
it("a pressed carrier of any temperament turns to mist", () => {
|
||||||
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toBot(state);
|
state = toBot(state);
|
||||||
|
|||||||
@@ -109,6 +109,47 @@ describe("terrain", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("safes and lock cards", () => {
|
||||||
|
function safeRig() {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
state = toRound2(state);
|
||||||
|
const raider = activePlayer(state);
|
||||||
|
const owner = state.players.find((p) => p.id !== raider.id)!;
|
||||||
|
const chest = state.treasures.find((t) => t.owner === owner.id && t.position)!;
|
||||||
|
state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: owner.id };
|
||||||
|
raider.position = { ...chest.position! };
|
||||||
|
return { state, raider: raider.id, chest };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a pick lock aimed at the safe's square opens it until turn's end", () => {
|
||||||
|
let { state, raider, chest } = safeRig();
|
||||||
|
expect(applyCommand(state, raider, { type: "pickUpTreasure" }).ok).toBe(false);
|
||||||
|
const pl = giveCard(state, raider, "pick-lock", "PL", 0);
|
||||||
|
const here = state.players.find((p) => p.id === raider)!.position;
|
||||||
|
state = must(state, raider, {
|
||||||
|
type: "cast", instanceId: pl.instanceId, target: { kind: "cell", cell: { ...here } },
|
||||||
|
});
|
||||||
|
expect(state.openSafes).toContain(cellKey(here));
|
||||||
|
state = must(state, raider, { type: "pickUpTreasure" });
|
||||||
|
expect(state.treasures.find((t) => t.id === chest.id)!.carriedBy).toBe(raider);
|
||||||
|
state = must(state, raider, { type: "endTurn", draw: 0 });
|
||||||
|
expect(state.openSafes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the key must be at or beside the safe", () => {
|
||||||
|
let { state, raider } = safeRig();
|
||||||
|
const p = state.players.find((q) => q.id === raider)!;
|
||||||
|
const box = { ...p.position };
|
||||||
|
p.position = { x: (box.x + 3) % 10, y: (box.y + 3) % 10 };
|
||||||
|
const pl = giveCard(state, raider, "pick-lock", "PL", 0);
|
||||||
|
const r = applyCommand(state, raider, {
|
||||||
|
type: "cast", instanceId: pl.instanceId, target: { kind: "cell", cell: box },
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toMatch(/beside the safe/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("objects", () => {
|
describe("objects", () => {
|
||||||
it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => {
|
it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
|
|||||||
@@ -786,6 +786,13 @@
|
|||||||
confirmTeleport();
|
confirmTeleport();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedCard && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key") &&
|
||||||
|
view.squareContents[cellKey(cell)]?.kind === "safe") {
|
||||||
|
// A key aimed at a SAFE's square opens the box, not a door.
|
||||||
|
dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } }));
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selectedCard?.cardId === "relocate-sector") {
|
if (selectedCard?.cardId === "relocate-sector") {
|
||||||
// Any board click (re-)picks the sector; the landing is chosen from the
|
// Any board click (re-)picks the sector; the landing is chosen from the
|
||||||
// dashed ghost slots beyond the maze, since every on-board slot is taken.
|
// dashed ghost slots beyond the maze, since every on-board slot is taken.
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
: `${e.player} is stone — the damage has no effect.`;
|
: `${e.player} is stone — the damage has no effect.`;
|
||||||
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
||||||
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
|
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
|
||||||
|
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
||||||
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||||
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
|
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
|
||||||
case "teleported": return e.by === e.player
|
case "teleported": return e.by === e.player
|
||||||
@@ -162,7 +163,6 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`;
|
case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`;
|
||||||
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
|
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
|
||||||
case "safeCreated": return `A massive safe slams down around the loot.`;
|
case "safeCreated": return `A massive safe slams down around the loot.`;
|
||||||
case "safeOpened": return null;
|
|
||||||
case "itemsTraded": return `Two items blink and trade places.`;
|
case "itemsTraded": return `Two items blink and trade places.`;
|
||||||
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
|
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
|
||||||
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
|
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
|
||||||
|
|||||||
Reference in New Issue
Block a user