The great simplification: every vintage gate collapses, the count restarts at 1
All 50 rooms were retired (backed up on the droplet and in Spaces), so no ledger needs an old branch to replay: forty revisions of deckRev gates fold into one canonical ruleset — the newest behavior everywhere. The ward's arming, the idiot's steering, the lazy illusion roll, the legacy redirection swap, and the flat wave all leave with their gates; RULES_REV restarts at 1 for whenever the rules fork again. Old-vintage test pins go with them. The opening screen now quietly drops seats whose rooms the server no longer knows, instead of listing them "unreachable" forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e8df6efcde
commit
8a50ff7f36
@@ -784,7 +784,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
|
|||||||
}
|
}
|
||||||
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
|
||||||
const vanish = find("invisible");
|
const vanish = find("invisible");
|
||||||
if (vanish && (!stack.creatureId || view.deckRev >= 13) && incoming >= 3 - flinch) {
|
if (vanish && incoming >= 3 - flinch) {
|
||||||
const num = numbersInHand(view)[0];
|
const num = numbersInHand(view)[0];
|
||||||
return {
|
return {
|
||||||
type: "counteract", instanceId: vanish.instanceId,
|
type: "counteract", instanceId: vanish.instanceId,
|
||||||
@@ -957,11 +957,6 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
|
|||||||
// Three or more wizards: LIFESAVER takes elimination off the table.
|
// Three or more wizards: LIFESAVER takes elimination off the table.
|
||||||
const lifesaver = inHand(view, "lifesaver");
|
const lifesaver = inHand(view, "lifesaver");
|
||||||
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
|
||||||
// The ward guards the gold while its owner is away robbing yours
|
|
||||||
// (arming retired at rev 31: the window asks in the moment instead).
|
|
||||||
if (view.deckRev < 31 && inHand(view, "ward") && !view.yourWardArmed) {
|
|
||||||
return { type: "armWard", armed: true };
|
|
||||||
}
|
|
||||||
const enemyNear = livingEnemies(view).some(
|
const enemyNear = livingEnemies(view).some(
|
||||||
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
|
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
|
||||||
);
|
);
|
||||||
@@ -1323,12 +1318,12 @@ export function automatonCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDIOT's curse: the maze no longer forces the feet (rev 37), so the
|
// IDIOT's curse: the maze no longer forces the feet, so the
|
||||||
// clockwork honors the march to its own gold itself — and when a thief
|
// clockwork honors the march to its own gold itself — and when a thief
|
||||||
// carries that gold, DROP OBJECT (the rev-37 carve-out) shakes it onto
|
// carries that gold, DROP OBJECT (an IDIOT aid) shakes it onto the
|
||||||
// the floor where it can finally be stood upon.
|
// floor where it can finally be stood upon.
|
||||||
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
||||||
if (cursedIdiot && view.deckRev >= 37 && view.turn.round > 1 && !view.turn.attackUsed) {
|
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
|
||||||
const dropper = inHand(view, "drop-object");
|
const dropper = inHand(view, "drop-object");
|
||||||
if (dropper) {
|
if (dropper) {
|
||||||
const sighted = sightedCellsFor(view);
|
const sighted = sightedCellsFor(view);
|
||||||
@@ -1365,8 +1360,8 @@ export function automatonCommand(
|
|||||||
pathToward(view, self.position, objectives, { canUnlock }) ??
|
pathToward(view, self.position, objectives, { canUnlock }) ??
|
||||||
pathToward(view, self.position, enemyCells, { canUnlock });
|
pathToward(view, self.position, enemyCells, { canUnlock });
|
||||||
// A shimmering illusion on the best crossing costs nothing to doubt:
|
// A shimmering illusion on the best crossing costs nothing to doubt:
|
||||||
// roll the free test before spending any card on that wall (rev 29).
|
// roll the free test before spending any card on that wall.
|
||||||
if (view.deckRev >= 29) {
|
{
|
||||||
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
|
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
|
||||||
if (crossing && crossing.total < (path?.distance ?? Infinity)) {
|
if (crossing && crossing.total < (path?.distance ?? Infinity)) {
|
||||||
const k = edgeKey(crossing.cell, crossing.side);
|
const k = edgeKey(crossing.cell, crossing.side);
|
||||||
|
|||||||
+120
-281
@@ -191,14 +191,14 @@ export interface CastStack {
|
|||||||
params: CastParams | null;
|
params: CastParams | null;
|
||||||
kind: "spell" | "physical";
|
kind: "spell" | "physical";
|
||||||
counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell;
|
counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell;
|
||||||
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade (rev 27). */
|
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
|
||||||
cardId?: string }[];
|
cardId?: string }[];
|
||||||
waitingOn: PlayerId;
|
waitingOn: PlayerId;
|
||||||
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
||||||
defenderShielded?: boolean;
|
defenderShielded?: boolean;
|
||||||
/** Set when a creature, not a wizard, delivers the attack. */
|
/** Set when a creature, not a wizard, delivers the attack. */
|
||||||
creatureId?: string;
|
creatureId?: string;
|
||||||
/** A FULL REFLECTION turned the spell around (rules rev 25): this stack
|
/** A FULL REFLECTION turned the spell around: this stack
|
||||||
* is the returned blow, damage and duration already settled — the new
|
* is the returned blow, damage and duration already settled — the new
|
||||||
* defender (the original caster) answers with counteractions of their
|
* defender (the original caster) answers with counteractions of their
|
||||||
* own. Another full reflection here turns it around again. */
|
* own. Another full reflection here turns it around again. */
|
||||||
@@ -259,9 +259,8 @@ export interface GameState {
|
|||||||
wallDamage: Record<string, number>;
|
wallDamage: Record<string, number>;
|
||||||
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
|
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
|
||||||
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
|
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
|
||||||
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
|
/** Players whose WARD is set to spring. */
|
||||||
wardArmed: PlayerId[];
|
/** A treasure was just grabbed and its owner holds WARD:
|
||||||
/** A treasure was just grabbed and its owner holds WARD (rules rev 31):
|
|
||||||
* the table waits while they choose to play it "at that time" or not. */
|
* the table waits while they choose to play it "at that time" or not. */
|
||||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||||
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
|
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
|
||||||
@@ -349,11 +348,11 @@ export function losBlockers(state: GameState): Record<string, true> {
|
|||||||
return blockers;
|
return blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A held-open door is an open doorway to the eye (rules rev 15): REMOVE
|
/** A held-open door is an open doorway to the eye: REMOVE
|
||||||
* LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and
|
* LOCK's "still considered to block L.O.S." speaks of a CLOSED door, and
|
||||||
* the table holds doors open precisely to cast back through them. */
|
* the table holds doors open precisely to cast back through them. */
|
||||||
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
|
||||||
if ((state.config.deckRev ?? 1) < 15 || state.heldDoors.length === 0) return board;
|
if (state.heldDoors.length === 0) return board;
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
for (const h of state.heldDoors) delete edges[h.key];
|
for (const h of state.heldDoors) delete edges[h.key];
|
||||||
return { ...board, edges };
|
return { ...board, edges };
|
||||||
@@ -372,10 +371,9 @@ function parseEdgeKey(key: string): { cell: Cell; side: Side } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What does this player believe about an illusion wall? Before rules rev 29
|
* What does this player believe about an illusion wall? The test is an
|
||||||
* the 50% chance rolled lazily the first time it mattered; from rev 29 the
|
* explicit act (the testIllusion command); an untested wall simply IS a
|
||||||
* test is an explicit act (the testIllusion command) and an untested wall
|
* wall to this player until they choose to doubt it.
|
||||||
* simply IS a wall to this player until they choose to doubt it.
|
|
||||||
*/
|
*/
|
||||||
function illusionBelief(
|
function illusionBelief(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
@@ -385,15 +383,8 @@ function illusionBelief(
|
|||||||
): "believes" | "seesThrough" {
|
): "believes" | "seesThrough" {
|
||||||
const wall = state.illusionWalls[key]!;
|
const wall = state.illusionWalls[key]!;
|
||||||
if (wall.createdBy === playerId) return "seesThrough";
|
if (wall.createdBy === playerId) return "seesThrough";
|
||||||
const known = wall.belief[playerId];
|
// An untested illusion blocks; testing it is its own free action.
|
||||||
if (known) return known;
|
return wall.belief[playerId] ?? "believes";
|
||||||
if ((state.config.deckRev ?? 1) >= 29) return "believes"; // untested blocks; no roll
|
|
||||||
const [roll, rngNext] = rollDie(state.rng);
|
|
||||||
state.rng = rngNext;
|
|
||||||
const result = roll <= 2 ? "seesThrough" : "believes";
|
|
||||||
wall.belief[playerId] = result;
|
|
||||||
events.push({ type: "illusionTested", player: playerId, edge: key, result });
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The board as one player perceives it: believed illusions become walls. */
|
/** The board as one player perceives it: believed illusions become walls. */
|
||||||
@@ -786,7 +777,6 @@ function rollJunctionAlterations(
|
|||||||
origin: Cell,
|
origin: Cell,
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const travel = new Set<string>();
|
const travel = new Set<string>();
|
||||||
if ((state.config.deckRev ?? 1) < 20) return travel;
|
|
||||||
const inSector = (c: Cell) =>
|
const inSector = (c: Cell) =>
|
||||||
c.x >= origin.x && c.x < origin.x + 5 && c.y >= origin.y && c.y < origin.y + 5;
|
c.x >= origin.x && c.x < origin.x + 5 && c.y >= origin.y && c.y < origin.y + 5;
|
||||||
const altered = new Set<string>([
|
const altered = new Set<string>([
|
||||||
@@ -851,12 +841,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (ctx.damageDealt <= 0) return;
|
if (ctx.damageDealt <= 0) return;
|
||||||
// "Destroys all magical stones an opponent is CARRYING": a stone
|
// "Destroys all magical stones an opponent is CARRYING": a stone
|
||||||
// still hidden in the hand is a secret card, not a carried stone —
|
// still hidden in the hand is a secret card, not a carried stone —
|
||||||
// only displayed stones burn (rules rev 33; earlier games burned
|
// only displayed stones burn.
|
||||||
// the hand's hidden stones too and replay so).
|
|
||||||
const rev33 = (ctx.state.config.deckRev ?? 1) >= 33;
|
|
||||||
const stones = ctx.defender.hand.filter((c) =>
|
const stones = ctx.defender.hand.filter((c) =>
|
||||||
isMagicStone(c.cardId) &&
|
isMagicStone(c.cardId) && ctx.defender.displayed.includes(c.instanceId));
|
||||||
(!rev33 || ctx.defender.displayed.includes(c.instanceId)));
|
|
||||||
if (stones.length === 0) return;
|
if (stones.length === 0) return;
|
||||||
ctx.defender.hand = ctx.defender.hand.filter(
|
ctx.defender.hand = ctx.defender.hand.filter(
|
||||||
(c) => !stones.some((s) => s.instanceId === c.instanceId),
|
(c) => !stones.some((s) => s.instanceId === c.instanceId),
|
||||||
@@ -878,10 +865,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
|
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
|
||||||
// Stunned mid-own-turn (the bolt came back): FAQ — "If this gets FULLY
|
// Stunned mid-own-turn (the bolt came back): FAQ — "If this gets FULLY
|
||||||
// REFLECTED, you lose the rest of your turn and can't draw cards (in
|
// REFLECTED, you lose the rest of your turn and can't draw cards (in
|
||||||
// addition to damage)." Rules rev 28; older games played on and replay so.
|
// addition to damage)."
|
||||||
const st = ctx.state;
|
const st = ctx.state;
|
||||||
if ((st.config.deckRev ?? 1) >= 28 &&
|
if (st.players[st.turn.activeIndex]!.id === ctx.defender.id) {
|
||||||
st.players[st.turn.activeIndex]!.id === ctx.defender.id) {
|
|
||||||
st.turn.actionsEnded = true;
|
st.turn.actionsEnded = true;
|
||||||
st.turn.drawForbidden = true;
|
st.turn.drawForbidden = true;
|
||||||
}
|
}
|
||||||
@@ -940,11 +926,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
onResolved: (ctx) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
const n = ctx.stack.numberValue ?? 1;
|
const n = ctx.stack.numberValue ?? 1;
|
||||||
if ((ctx.state.config.deckRev ?? 1) >= 36) {
|
|
||||||
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n);
|
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n);
|
||||||
} else {
|
|
||||||
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, n);
|
|
||||||
}
|
|
||||||
ctx.defender.lostTurns++;
|
ctx.defender.lostTurns++;
|
||||||
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
|
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
|
||||||
},
|
},
|
||||||
@@ -1147,9 +1129,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
// Breaching the maze's outer rim breaks through BOTH sides — the
|
// Breaching the maze's outer rim breaks through BOTH sides — the
|
||||||
// default wraparound connects opposite edges, so a new warp opens
|
// default wraparound connects opposite edges, so a new warp opens
|
||||||
// (rules rev 19; sector moves recompute openings and forget it).
|
//.
|
||||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||||
if ((state.config.deckRev ?? 1) >= 19 && offBoard && !pair) {
|
if (offBoard && !pair) {
|
||||||
const far = oppositePerimeter(view, cell, side);
|
const far = oppositePerimeter(view, cell, side);
|
||||||
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
||||||
if (far && (boardView(state).edges[farKey] ?? "open") === "wall") {
|
if (far && (boardView(state).edges[farKey] ?? "open") === "wall") {
|
||||||
@@ -1161,13 +1143,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
|
applyDamage(state, events, p, 4, "collapsing wall", caster.id, "physical");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ((state.config.deckRev ?? 1) >= 21) {
|
|
||||||
for (const cr of [...state.creatures]) {
|
for (const cr of [...state.creatures]) {
|
||||||
if (cellKey(cr.position) === cellKey(far.cell)) {
|
if (cellKey(cr.position) === cellKey(far.cell)) {
|
||||||
damageCreature(state, events, cr, 4, "collapsing wall");
|
damageCreature(state, events, cr, 4, "collapsing wall");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
state.board.warps.push(
|
state.board.warps.push(
|
||||||
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
|
{ from: { cell, side }, to: { cell: far.cell, side: far.side } },
|
||||||
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
|
{ from: { cell: far.cell, side: far.side }, to: { cell, side } },
|
||||||
@@ -1182,15 +1162,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// "Anyone in either square" includes monsters — the expansion sheet
|
// "Anyone in either square" includes monsters — the expansion sheet
|
||||||
// says so in as many words (rules rev 21).
|
// says so in as many words.
|
||||||
if ((state.config.deckRev ?? 1) >= 21) {
|
|
||||||
for (const cr of [...state.creatures]) {
|
for (const cr of [...state.creatures]) {
|
||||||
if (cellKey(cr.position) === cellKey(c)) {
|
if (cellKey(cr.position) === cellKey(c)) {
|
||||||
damageCreature(state, events, cr, 4, "collapsing wall");
|
damageCreature(state, events, cr, 4, "collapsing wall");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -1257,8 +1235,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (wallIgnoringDistance(view, caster.position, to) > 4) {
|
if (wallIgnoringDistance(view, caster.position, to) > 4) {
|
||||||
return "teleport reaches at most four spaces";
|
return "teleport reaches at most four spaces";
|
||||||
}
|
}
|
||||||
// A teleport is a willing move: FEAR's bubble refuses it (rev 32).
|
// A teleport is a willing move: FEAR's bubble refuses it.
|
||||||
if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, caster.id, caster.position, to)) {
|
if (fearRepels(state, caster.id, caster.position, to)) {
|
||||||
return "an unnatural dread keeps you away";
|
return "an unnatural dread keeps you away";
|
||||||
}
|
}
|
||||||
const from = caster.position;
|
const from = caster.position;
|
||||||
@@ -1417,12 +1395,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const { cell, side } = cmd.target;
|
const { cell, side } = cmd.target;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
// A warp mouth at the maze's rim is a corridor too — the wraparound
|
// A warp mouth at the maze's rim is a corridor too — the wraparound
|
||||||
// connects it to the opposite edge (rev 39; older ledgers refused it
|
// connects it to the opposite edge. The fire takes BOTH mouths, so the crossing burns
|
||||||
// and replay so). The fire takes BOTH mouths, so the crossing burns
|
|
||||||
// no matter which rim the wizard steps in from.
|
// no matter which rim the wizard steps in from.
|
||||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||||
const warp = offBoard && (state.config.deckRev ?? 1) >= 39
|
const warp = offBoard ? findWarp(view, cell, side) : undefined;
|
||||||
? findWarp(view, cell, side) : undefined;
|
|
||||||
if (!view.cells[cellKey(cell)] || (offBoard && !warp)) {
|
if (!view.cells[cellKey(cell)] || (offBoard && !warp)) {
|
||||||
return "the fire must span a corridor between two spaces";
|
return "the fire must span a corridor between two spaces";
|
||||||
}
|
}
|
||||||
@@ -1461,10 +1437,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge";
|
if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge";
|
||||||
const { cell, side } = cmd.target;
|
const { cell, side } = cmd.target;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
// A rim warp mouth is a corridor too (rev 40): the collapse washes
|
// A rim warp mouth is a corridor too: the collapse washes
|
||||||
// both rims inward (waveFromEdge follows the tunnel).
|
// both rims inward (waveFromEdge follows the tunnel).
|
||||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||||
const warpMouth = offBoard && (state.config.deckRev ?? 1) >= 40 && findWarp(view, cell, side);
|
const warpMouth = offBoard && findWarp(view, cell, side);
|
||||||
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
||||||
return "the wave must span a corridor between two spaces";
|
return "the wave must span a corridor between two spaces";
|
||||||
}
|
}
|
||||||
@@ -1624,11 +1600,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge";
|
if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge";
|
||||||
const { cell, side } = cmd.target;
|
const { cell, side } = cmd.target;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
// A rim warp mouth takes the scrim too (rev 40) — the bluff of
|
// A rim warp mouth takes the scrim too — the bluff of
|
||||||
// create-wall's brick-over. The illusion hangs on the mouth it was
|
// create-wall's brick-over. The illusion hangs on the mouth it was
|
||||||
// painted on; the far mouth shows nothing (nothing physical exists).
|
// painted on; the far mouth shows nothing (nothing physical exists).
|
||||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||||
const warpMouth = offBoard && (state.config.deckRev ?? 1) >= 40 && findWarp(view, cell, side);
|
const warpMouth = offBoard && findWarp(view, cell, side);
|
||||||
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
||||||
return "the illusion must span two spaces on the board";
|
return "the illusion must span two spaces on the board";
|
||||||
}
|
}
|
||||||
@@ -1802,11 +1778,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
|
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
|
||||||
state.edgeOverrides[key] = "open";
|
state.edgeOverrides[key] = "open";
|
||||||
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
||||||
// A rim wall warped open bores a temporary wraparound (rev 40): the
|
// A rim wall warped open bores a temporary wraparound: the
|
||||||
// far rim wall vanishes with it and a warp runs until turn's end.
|
// far rim wall vanishes with it and a warp runs until turn's end.
|
||||||
// (Pre-40 rim casts opened a dead edge and replay so.)
|
// (Pre-40 rim casts opened a dead edge and replay so.)
|
||||||
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
const offBoard = !view.cells[cellKey(neighbor(cell, side))];
|
||||||
if ((state.config.deckRev ?? 1) >= 40 && offBoard && !findWarp(view, cell, side)) {
|
if (offBoard && !findWarp(view, cell, side)) {
|
||||||
const far = oppositePerimeter(view, cell, side);
|
const far = oppositePerimeter(view, cell, side);
|
||||||
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
const farKey = far ? edgeKey(far.cell, far.side) : "";
|
||||||
if (far && (view.edges[farKey] ?? "open") === "wall") {
|
if (far && (view.edges[farKey] ?? "open") === "wall") {
|
||||||
@@ -1876,9 +1852,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const { cell, side } = cmd.target;
|
const { cell, side } = cmd.target;
|
||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
// No doors through the maze's rim (rev 40): a rim door would open onto
|
// No doors through the maze's rim: a rim door would open onto
|
||||||
// nothing traversable — the wraparound belongs to breaches, not doors.
|
// nothing traversable — the wraparound belongs to breaches, not doors.
|
||||||
if ((state.config.deckRev ?? 1) >= 40 && !view.cells[cellKey(neighbor(cell, side))]) {
|
if (!view.cells[cellKey(neighbor(cell, side))]) {
|
||||||
return "the door must stand between two spaces";
|
return "the door must stand between two spaces";
|
||||||
}
|
}
|
||||||
if ((view.edges[key] ?? "open") === "open") {
|
if ((view.edges[key] ?? "open") === "open") {
|
||||||
@@ -1994,19 +1970,17 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const { cell, side } = cmd.target;
|
const { cell, side } = cmd.target;
|
||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
// Doors are "small entryways in a stone wall" — stone melts as
|
// Doors are "small entryways in a stone wall" — stone melts as
|
||||||
// stone (rev 38; older ledgers had refused doors and replay so).
|
// stone.
|
||||||
const current = view.edges[key];
|
const current = view.edges[key];
|
||||||
const meltable = current === "wall" ||
|
const meltable = current === "wall" || current === "door";
|
||||||
((state.config.deckRev ?? 1) >= 38 && current === "door");
|
|
||||||
if (!meltable) return "that is not a stone wall";
|
if (!meltable) return "that is not a stone wall";
|
||||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||||
state.edgeOverrides[key] = "open";
|
state.edgeOverrides[key] = "open";
|
||||||
delete state.doorStates[key];
|
delete state.doorStates[key];
|
||||||
delete state.createdEdges[key];
|
delete state.createdEdges[key];
|
||||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: { cell, side } });
|
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: null, edge: { cell, side } });
|
||||||
if ((state.config.deckRev ?? 1) >= 40) {
|
|
||||||
// A brick over a warp mouth melts at BOTH ends — the tunnel is one
|
// A brick over a warp mouth melts at BOTH ends — the tunnel is one
|
||||||
// wall line (rev 40, mirroring DESTROY WALL's pair rule).
|
// wall line.
|
||||||
const pair = pairedWarpMouth(view, cell, side);
|
const pair = pairedWarpMouth(view, cell, side);
|
||||||
const pairKey = pair ? edgeKey(pair.cell, pair.side) : "";
|
const pairKey = pair ? edgeKey(pair.cell, pair.side) : "";
|
||||||
if (pair && state.createdEdges[pairKey]) {
|
if (pair && state.createdEdges[pairKey]) {
|
||||||
@@ -2032,7 +2006,6 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
events.push({ type: "warpOpened", a: { cell, side }, b: far });
|
events.push({ type: "warpOpened", a: { cell, side }, b: far });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// "Wall turns into a WATERWALL with a range and damage of 2."
|
// "Wall turns into a WATERWALL with a range and damage of 2."
|
||||||
waveFromEdge(state, events, cell, side, 2);
|
waveFromEdge(state, events, cell, side, 2);
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
@@ -2239,13 +2212,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
kind: "attack",
|
kind: "attack",
|
||||||
baseDamage: () => 0,
|
baseDamage: () => 0,
|
||||||
// Everyone's hands into one pile, shuffled, dealt back in equal counts.
|
// Everyone's hands into one pile, shuffled, dealt back in equal counts.
|
||||||
// "FULL SHIELD removes a player from participation": under rules rev 3
|
// "FULL SHIELD removes a player from participation": every bystander
|
||||||
// every bystander gets a shield window before the pile forms; earlier
|
// gets a shield window before the pile forms.
|
||||||
// revisions scramble at once so stored games replay unchanged.
|
|
||||||
onResolved: (ctx) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped) return;
|
if (ctx.fullyStopped) return;
|
||||||
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
|
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
|
||||||
if ((ctx.state.config.deckRev ?? 1) >= 3) {
|
{
|
||||||
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
|
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
|
||||||
const queue = order.filter((id) =>
|
const queue = order.filter((id) =>
|
||||||
id !== ctx.attacker.id && id !== ctx.defender.id &&
|
id !== ctx.attacker.id && id !== ctx.defender.id &&
|
||||||
@@ -2292,13 +2264,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (ctx.fullyStopped) return;
|
if (ctx.fullyStopped) return;
|
||||||
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";");
|
||||||
// "Swap any two carried items": every movable object trades — daggers,
|
// "Swap any two carried items": every movable object trades — daggers,
|
||||||
// rocks, wands, stones (rules rev 26; earlier games matched only
|
// rocks, wands, stones — and a carried TREASURE is an
|
||||||
// object-typed cards and replay so) — and a carried TREASURE is an
|
|
||||||
// item too, named by the "treasure" token (never in older ledgers).
|
// item too, named by the "treasure" token (never in older ledgers).
|
||||||
const tradable = (id: string) =>
|
const tradable = (id: string) => isMovableObject(id);
|
||||||
(ctx.state.config.deckRev ?? 1) >= 26
|
|
||||||
? isMovableObject(id)
|
|
||||||
: cardDef(id).cardType === "object";
|
|
||||||
type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" };
|
type TradeSide = { kind: "card"; idx: number } | { kind: "treasure" };
|
||||||
const side = (p: PlayerState, id: string): TradeSide | null => {
|
const side = (p: PlayerState, id: string): TradeSide | null => {
|
||||||
if (id === "treasure") return p.carriedTreasureId ? { kind: "treasure" } : null;
|
if (id === "treasure") return p.carriedTreasureId ? { kind: "treasure" } : null;
|
||||||
@@ -2418,10 +2386,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
|
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
|
||||||
// FAQ: "If you happen to already be carrying one of your treasures
|
// FAQ: "If you happen to already be carrying one of your treasures
|
||||||
// when you are hit with this spell, it has no effect on you."
|
// when you are hit with this spell, it has no effect on you."
|
||||||
if ((ctx.state.config.deckRev ?? 1) >= 23) {
|
|
||||||
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
|
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
|
||||||
if (carried?.owner === ctx.defender.id) return;
|
if (carried?.owner === ctx.defender.id) return;
|
||||||
}
|
|
||||||
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2463,7 +2429,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
// "Signify their new directions by placing the A tokens on one set of
|
// "Signify their new directions by placing the A tokens on one set of
|
||||||
// exits & the B tokens on the other" — the two exits you choose become a
|
// exits & the B tokens on the other" — the two exits you choose become a
|
||||||
// pair (rules rev 9), and their former partners pair with each other.
|
// pair, and their former partners pair with each other.
|
||||||
// Earlier revisions swapped the two exits' destinations instead.
|
// Earlier revisions swapped the two exits' destinations instead.
|
||||||
resolve: (state, events, caster, cmd) => {
|
resolve: (state, events, caster, cmd) => {
|
||||||
const a = cmd.params?.cell;
|
const a = cmd.params?.cell;
|
||||||
@@ -2473,7 +2439,6 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a));
|
const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a));
|
||||||
const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b));
|
const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b));
|
||||||
if (!wa || !wb || wa === wb) return "pick two different outer exits";
|
if (!wa || !wb || wa === wb) return "pick two different outer exits";
|
||||||
if ((state.config.deckRev ?? 1) >= 9) {
|
|
||||||
if (cellKey(wa.to.cell) === cellKey(wb.from.cell)) return "those exits already connect";
|
if (cellKey(wa.to.cell) === cellKey(wb.from.cell)) return "those exits already connect";
|
||||||
const pa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wa.to.cell));
|
const pa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wa.to.cell));
|
||||||
const pb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wb.to.cell));
|
const pb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wb.to.cell));
|
||||||
@@ -2484,17 +2449,6 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
pa.to = { cell: { ...pb.from.cell }, side: pb.from.side };
|
pa.to = { cell: { ...pb.from.cell }, side: pb.from.side };
|
||||||
pb.to = { cell: { ...pa.from.cell }, side: pa.from.side };
|
pb.to = { cell: { ...pa.from.cell }, side: pa.from.side };
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Legacy: swap destinations and fix the reciprocal warps to match.
|
|
||||||
const destA = { ...wa.to };
|
|
||||||
const destB = { ...wb.to };
|
|
||||||
wa.to = destB;
|
|
||||||
wb.to = destA;
|
|
||||||
for (const w of state.board.warps) {
|
|
||||||
if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side };
|
|
||||||
if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.from.side };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
events.push({ type: "exitsRedirected", caster: caster.id });
|
events.push({ type: "exitsRedirected", caster: caster.id });
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -2667,21 +2621,19 @@ function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* How hard a wave still pushes someone it finds `dist` cells from its
|
* How hard a wave still pushes someone it finds `dist` cells from its
|
||||||
* source: the force spent reaching them is gone (rules rev 24), so a
|
* source: the force spent reaching them is gone, so a
|
||||||
* range-2 wave throws its adjacent victim 2 spaces but a victim at its far
|
* range-2 wave throws its adjacent victim 2 spaces but a victim at its far
|
||||||
* edge only 1 — and never converts spent force into crush damage.
|
* edge only 1 — and never converts spent force into crush damage.
|
||||||
*/
|
*/
|
||||||
function waveForce(state: GameState, range: number, dist: number): number {
|
function waveForce(state: GameState, range: number, dist: number): number {
|
||||||
return (state.config.deckRev ?? 1) >= 24 ? range - dist : range;
|
return range - dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A collapsing waterwall wave from an edge: wash players back `range`. */
|
/** A collapsing waterwall wave from an edge: wash players back `range`. */
|
||||||
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
|
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
|
||||||
// A warp mouth's "far side" is the opposite rim: the collapse washes both
|
// A warp mouth's "far side" is the opposite rim: the collapse washes both
|
||||||
// mouths inward (rev 40; older ledgers' rim waves pushed into the void
|
// mouths inward. A plain edge washes its two adjacent cells apart.
|
||||||
// and replay so). A plain edge washes its two adjacent cells apart.
|
const warp = !boardView(state).cells[cellKey(neighbor(cell, side))]
|
||||||
const warp = (state.config.deckRev ?? 1) >= 40 &&
|
|
||||||
!boardView(state).cells[cellKey(neighbor(cell, side))]
|
|
||||||
? findWarp(boardView(state), cell, side) : undefined;
|
? findWarp(boardView(state), cell, side) : undefined;
|
||||||
const pushes: { start: Cell; dir: Side }[] = warp
|
const pushes: { start: Cell; dir: Side }[] = warp
|
||||||
? [
|
? [
|
||||||
@@ -2703,7 +2655,7 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
|
|||||||
if (cellKey(c.position) !== cellKey(probe)) continue;
|
if (cellKey(c.position) !== cellKey(probe)) continue;
|
||||||
if (c.kind === "fire-imp") {
|
if (c.kind === "fire-imp") {
|
||||||
destroyCreature(state, events, c, reason);
|
destroyCreature(state, events, c, reason);
|
||||||
} else if ((state.config.deckRev ?? 1) >= 17) {
|
} else {
|
||||||
washBackCreature(state, events, c, dir, force);
|
washBackCreature(state, events, c, dir, force);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2731,7 +2683,7 @@ function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range
|
|||||||
if (cellKey(c.position) !== cellKey(probe)) continue;
|
if (cellKey(c.position) !== cellKey(probe)) continue;
|
||||||
if (c.kind === "fire-imp") {
|
if (c.kind === "fire-imp") {
|
||||||
destroyCreature(state, events, c, "rushing water");
|
destroyCreature(state, events, c, "rushing water");
|
||||||
} else if ((state.config.deckRev ?? 1) >= 17) {
|
} else {
|
||||||
washBackCreature(state, events, c, dir, force);
|
washBackCreature(state, events, c, dir, force);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2804,9 +2756,8 @@ function emptySquareTarget(
|
|||||||
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
|
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
|
||||||
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
|
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
|
||||||
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
|
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
|
||||||
// "You can't create an object on a DIMENSIONAL WARP" (rules rev 8).
|
// "You can't create an object on a DIMENSIONAL WARP".
|
||||||
if ((state.config.deckRev ?? 1) >= 8 &&
|
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||||
state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
|
||||||
return "the warp shimmers there — nothing can form on it";
|
return "the warp shimmers there — nothing can form on it";
|
||||||
}
|
}
|
||||||
if (!gameLos(state, caster.position, cell)) return "no line of sight";
|
if (!gameLos(state, caster.position, cell)) return "no line of sight";
|
||||||
@@ -2833,7 +2784,7 @@ function washBackN(state: GameState, events: GameEvent[], p: PlayerState, dir: S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The wave carries monsters as it carries wizards (rules rev 17): washed
|
/** The wave carries monsters as it carries wizards: washed
|
||||||
* back, and crushed a point per space the maze refuses them. Fire imps
|
* back, and crushed a point per space the maze refuses them. Fire imps
|
||||||
* never reach this — water destroys them outright. */
|
* never reach this — water destroys them outright. */
|
||||||
function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void {
|
function washBackCreature(state: GameState, events: GameEvent[], c: CreatureState, dir: Side, range: number): void {
|
||||||
@@ -2892,9 +2843,8 @@ function spawnCreature(
|
|||||||
movementUsed: 0, // "It may move on that turn." (Exp1 sheet)
|
movementUsed: 0, // "It may move on that turn." (Exp1 sheet)
|
||||||
// "Cannot attack the turn they are created" is justCreated's job; the
|
// "Cannot attack the turn they are created" is justCreated's job; the
|
||||||
// democratic monster's attackUsed budgets its ROUND, so spending it at
|
// democratic monster's attackUsed budgets its ROUND, so spending it at
|
||||||
// birth would mute a mid-round summon until the round rolled over
|
// birth would mute a mid-round summon until the round rolled over.
|
||||||
// (rules rev 18).
|
attackUsed: kind !== "democratic-monster",
|
||||||
attackUsed: !(kind === "democratic-monster" && (state.config.deckRev ?? 1) >= 18),
|
|
||||||
justCreated: true,
|
justCreated: true,
|
||||||
wallPassesPerTurn: stats.wallPasses,
|
wallPassesPerTurn: stats.wallPasses,
|
||||||
wallPassUsed: 0,
|
wallPassUsed: 0,
|
||||||
@@ -2983,15 +2933,14 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
|||||||
} else {
|
} else {
|
||||||
const content = state.squareContents[cellKey(target.to)];
|
const content = state.squareContents[cellKey(target.to)];
|
||||||
if (content?.kind === "stone") return err("that square is solid stone");
|
if (content?.kind === "stone") return err("that square is solid stone");
|
||||||
if ((state.config.deckRev ?? 1) >= 5 &&
|
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) &&
|
||||||
state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) &&
|
|
||||||
sustainedOn(state, o.id, "big-man").length > 0)) {
|
sustainedOn(state, o.id, "big-man").length > 0)) {
|
||||||
return err("a giant fills that corridor");
|
return err("a giant fills that corridor");
|
||||||
}
|
}
|
||||||
creature.position = target.to;
|
creature.position = target.to;
|
||||||
}
|
}
|
||||||
// FEAR holds monsters off too (rules rev 32): "no player or monster".
|
// FEAR holds monsters off too: "no player or monster".
|
||||||
if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, null, from, creature.position)) {
|
if (fearRepels(state, null, from, creature.position)) {
|
||||||
creature.position = from;
|
creature.position = from;
|
||||||
if (creature.wallPassUsed > 0) creature.wallPassUsed--;
|
if (creature.wallPassUsed > 0) creature.wallPassUsed--;
|
||||||
return err("an unnatural dread stops the beast");
|
return err("an unnatural dread stops the beast");
|
||||||
@@ -3000,38 +2949,23 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
|
|||||||
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
|
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
|
||||||
|
|
||||||
// Touch effects on entering a player's square.
|
// Touch effects on entering a player's square.
|
||||||
const rev4 = (state.config.deckRev ?? 1) >= 4;
|
|
||||||
for (const p of state.players) {
|
for (const p of state.players) {
|
||||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
||||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
||||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||||
creature.attackUsed = true;
|
creature.attackUsed = true;
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||||
if (rev4) {
|
|
||||||
// The victim may counteract ("REFLECTIONs used on the wraith's touch
|
// The victim may counteract ("REFLECTIONs used on the wraith's touch
|
||||||
// will damage the wraith" — the card assumes exactly this window).
|
// will damage the wraith" — the card assumes exactly this window).
|
||||||
openCreatureStack(state, creature, p, 2, "wraith");
|
openCreatureStack(state, creature, p, 2, "wraith");
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
applyDamage(state, events, p, 2, "wraith's touch", null);
|
|
||||||
if (p.alive && p.hand.length > 0) {
|
|
||||||
const [idx, rngNext] = nextInt(state.rng, p.hand.length);
|
|
||||||
state.rng = rngNext;
|
|
||||||
const [card] = p.hand.splice(idx, 1);
|
|
||||||
p.displayed = p.displayed.filter((id) => id !== card!.instanceId);
|
|
||||||
state.discard.push(card!);
|
|
||||||
events.push({ type: "cardsDiscarded", player: p.id, cards: [card!] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
|
||||||
creature.attackUsed = true; // "may attack only one player per round of turns"
|
creature.attackUsed = true; // "may attack only one player per round of turns"
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||||
if (rev4) {
|
|
||||||
openCreatureStack(state, creature, p, 2, "claw");
|
openCreatureStack(state, creature, p, 2, "claw");
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
applyDamage(state, events, p, 2, "clawing monster", null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
checkVictory(state, events);
|
checkVictory(state, events);
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
@@ -3063,7 +2997,7 @@ function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerSt
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rules rev 4: a creature's blow opens a counteraction window like any attack. */
|
/** A creature's blow opens a counteraction window like any attack. */
|
||||||
function openCreatureStack(
|
function openCreatureStack(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
creature: CreatureState,
|
creature: CreatureState,
|
||||||
@@ -3128,11 +3062,8 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
|
|||||||
}
|
}
|
||||||
events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: targetId, dieRoll: roll });
|
events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: targetId, dieRoll: roll });
|
||||||
if (targetPlayer) {
|
if (targetPlayer) {
|
||||||
if ((state.config.deckRev ?? 1) >= 4) {
|
|
||||||
openCreatureStack(state, creature, targetPlayer, amount);
|
openCreatureStack(state, creature, targetPlayer, amount);
|
||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
|
||||||
applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical");
|
|
||||||
} else {
|
} else {
|
||||||
damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`);
|
damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`);
|
||||||
}
|
}
|
||||||
@@ -3238,10 +3169,8 @@ function remapState(
|
|||||||
for (const t of state.treasures) {
|
for (const t of state.treasures) {
|
||||||
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
|
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
|
||||||
}
|
}
|
||||||
// A moving sector carries everything standing on it. Earlier revisions left
|
// A moving sector carries everything standing on it.
|
||||||
// creatures, traps, glue, safes, and warp tokens at their old coordinates;
|
{
|
||||||
// their stored games must replay with that flaw intact (rules rev 11).
|
|
||||||
if ((state.config.deckRev ?? 1) >= 11) {
|
|
||||||
for (const c of state.creatures) {
|
for (const c of state.creatures) {
|
||||||
if (inSector(c.position)) c.position = mapCell(c.position);
|
if (inSector(c.position)) c.position = mapCell(c.position);
|
||||||
}
|
}
|
||||||
@@ -3289,11 +3218,6 @@ function relocateSector(state: GameState, index: number, dest: Cell, travelKeys?
|
|||||||
const placements = state.board.placements;
|
const placements = state.board.placements;
|
||||||
const current = placements[index]!.origin;
|
const current = placements[index]!.origin;
|
||||||
if (dest.x === current.x && dest.y === current.y) return "the sector is already there";
|
if (dest.x === current.x && dest.y === current.y) return "the sector is already there";
|
||||||
// The grid has no wall at zero: a sector may land beyond the old origin and
|
|
||||||
// the whole maze renormalizes back into positive coordinates (rules rev 11).
|
|
||||||
if ((state.config.deckRev ?? 1) < 11 && (dest.x < 0 || dest.y < 0)) {
|
|
||||||
return "the sector cannot go there";
|
|
||||||
}
|
|
||||||
for (let i = 0; i < placements.length; i++) {
|
for (let i = 0; i < placements.length; i++) {
|
||||||
if (i === index) continue;
|
if (i === index) continue;
|
||||||
const o = placements[i]!.origin;
|
const o = placements[i]!.origin;
|
||||||
@@ -3317,13 +3241,11 @@ function relocateSector(state: GameState, index: number, dest: Cell, travelKeys?
|
|||||||
const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p));
|
const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p));
|
||||||
// Zero-anchor the maze after the move: negative landings shift back into
|
// Zero-anchor the maze after the move: negative landings shift back into
|
||||||
// positive coordinates, and vacating the origin corner pulls the maze snug
|
// positive coordinates, and vacating the origin corner pulls the maze snug
|
||||||
// instead of leaving a blank band (rules rev 11; older games never shift).
|
// instead of leaving a blank band.
|
||||||
const shift = (state.config.deckRev ?? 1) >= 11
|
const shift = {
|
||||||
? {
|
|
||||||
x: -Math.min(...newPlacements.map((p) => p.origin.x)),
|
x: -Math.min(...newPlacements.map((p) => p.origin.x)),
|
||||||
y: -Math.min(...newPlacements.map((p) => p.origin.y)),
|
y: -Math.min(...newPlacements.map((p) => p.origin.y)),
|
||||||
}
|
};
|
||||||
: { x: 0, y: 0 };
|
|
||||||
const shifted = shift.x || shift.y
|
const shifted = shift.x || shift.y
|
||||||
? newPlacements.map((p) => ({ ...p, origin: { x: p.origin.x + shift.x, y: p.origin.y + shift.y } }))
|
? newPlacements.map((p) => ({ ...p, origin: { x: p.origin.x + shift.x, y: p.origin.y + shift.y } }))
|
||||||
: newPlacements;
|
: newPlacements;
|
||||||
@@ -3534,7 +3456,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
);
|
);
|
||||||
|
|
||||||
let fullDeck = buildDeck(config.sets);
|
let fullDeck = buildDeck(config.sets);
|
||||||
if ((config.deckRev ?? 1) >= 2 && n === 2) {
|
if (n === 2) {
|
||||||
fullDeck = fullDeck.filter((c) => c.cardId !== "lifesaver");
|
fullDeck = fullDeck.filter((c) => c.cardId !== "lifesaver");
|
||||||
}
|
}
|
||||||
const [deckShuffled, rng3] = shuffle(rng, fullDeck);
|
const [deckShuffled, rng3] = shuffle(rng, fullDeck);
|
||||||
@@ -3580,7 +3502,6 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
|||||||
edgeOverrides: {},
|
edgeOverrides: {},
|
||||||
wallDamage: {},
|
wallDamage: {},
|
||||||
slimeTraps: {},
|
slimeTraps: {},
|
||||||
wardArmed: [],
|
|
||||||
wardPending: null,
|
wardPending: null,
|
||||||
chaosPending: null,
|
chaosPending: null,
|
||||||
doorStates: {},
|
doorStates: {},
|
||||||
@@ -3778,7 +3699,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
case "punch": return doPunch(state, command.targetId);
|
case "punch": return doPunch(state, command.targetId);
|
||||||
case "punchWall": return doPunchWall(state, command.cell, command.side);
|
case "punchWall": return doPunchWall(state, command.cell, command.side);
|
||||||
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
||||||
case "armWard": return doArmWard(state, command.armed);
|
case "armWard": return doArmWard();
|
||||||
case "wardChoice": return err("no grab is hanging on your Ward");
|
case "wardChoice": return err("no grab is hanging on your Ward");
|
||||||
case "warpStep": return doWarpStep(state);
|
case "warpStep": return doWarpStep(state);
|
||||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||||
@@ -3850,23 +3771,16 @@ export function dreadDistance(board: AssembledBoard, a: Cell, b: Cell): number {
|
|||||||
/**
|
/**
|
||||||
* FEAR: "no player or monster will move to within 3 spaces of you." True
|
* FEAR: "no player or monster will move to within 3 spaces of you." True
|
||||||
* for every WILLING move — walking, warp steps, self-teleports, commanded
|
* for every WILLING move — walking, warp steps, self-teleports, commanded
|
||||||
* monsters (rules rev 32 widened it beyond walking wizards; forced movement
|
* monsters. Forced movement such as knockback or a wave is not willing
|
||||||
* such as knockback or a wave is not willing and passes).
|
* and passes.
|
||||||
*/
|
*/
|
||||||
function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: Cell): boolean {
|
function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: Cell): boolean {
|
||||||
// The wrap joined the measure at rev 32; older ledgers were recorded
|
const board = boardView(state);
|
||||||
// under the flat diamond and replay so.
|
|
||||||
const wraps = (state.config.deckRev ?? 1) >= 32;
|
|
||||||
const board = wraps ? boardView(state) : null;
|
|
||||||
for (const other of state.players) {
|
for (const other of state.players) {
|
||||||
if (!other.alive || other.id === moverId) continue;
|
if (!other.alive || other.id === moverId) continue;
|
||||||
if (sustainedOn(state, other.id, "fear").length === 0) continue;
|
if (sustainedOn(state, other.id, "fear").length === 0) continue;
|
||||||
const d = board
|
const d = dreadDistance(board, other.position, to);
|
||||||
? dreadDistance(board, other.position, to)
|
const dBefore = dreadDistance(board, other.position, from);
|
||||||
: Math.abs(other.position.x - to.x) + Math.abs(other.position.y - to.y);
|
|
||||||
const dBefore = board
|
|
||||||
? dreadDistance(board, other.position, from)
|
|
||||||
: Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y);
|
|
||||||
if (d <= 3 && d < dBefore) return true;
|
if (d <= 3 && d < dBefore) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -3891,15 +3805,6 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
|
|
||||||
// IDIOT: every move heads for the nearest of their own treasures.
|
|
||||||
// Rev 37 retires the steering: a greedy per-step recalculation ping-pongs
|
|
||||||
// between routes that tie at a corner and can wall the victim off from
|
|
||||||
// the cure entirely. The march is the player's to honor (like FEAR's
|
|
||||||
// duty); the automatons steer themselves. Older ledgers replay steered.
|
|
||||||
if ((state.config.deckRev ?? 1) < 37 && sustainedOn(state, p.id, "idiot").length > 0) {
|
|
||||||
const steered = idiotSteer(state, p);
|
|
||||||
if (steered) direction = steered;
|
|
||||||
}
|
|
||||||
|
|
||||||
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
|
||||||
// move ... bumping into a wall counts as one space of movement."
|
// move ... bumping into a wall counts as one space of movement."
|
||||||
@@ -3911,8 +3816,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
{
|
{
|
||||||
const key = edgeKey(p.position, direction);
|
const key = edgeKey(p.position, direction);
|
||||||
const shimmer = state.illusionWalls[key];
|
const shimmer = state.illusionWalls[key];
|
||||||
if (shimmer && (state.config.deckRev ?? 1) >= 29 &&
|
if (shimmer && shimmer.createdBy !== p.id && !shimmer.belief[p.id]) {
|
||||||
shimmer.createdBy !== p.id && !shimmer.belief[p.id]) {
|
|
||||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||||
return err("the wall shimmers oddly — test your eyes before you can pass");
|
return err("the wall shimmers oddly — test your eyes before you can pass");
|
||||||
}
|
}
|
||||||
@@ -3924,11 +3828,11 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
|
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
// BIG MAN: "Using 2 movement points, you can step over a PIT, TACKS, or
|
// BIG MAN: "Using 2 movement points, you can step over a PIT, TACKS, or
|
||||||
// KILLER OOZE" (rules rev 5). The giant strides the hazard square without
|
// KILLER OOZE". The giant strides the hazard square without
|
||||||
// entering it — one point charged here, then the normal step below lands
|
// entering it — one point charged here, then the normal step below lands
|
||||||
// him beyond it with every usual arrival effect (and the second point).
|
// him beyond it with every usual arrival effect (and the second point).
|
||||||
if (over) {
|
if (over) {
|
||||||
const big = (state.config.deckRev ?? 1) >= 5 && sustainedOn(state, p.id, "big-man").length > 0;
|
const big = sustainedOn(state, p.id, "big-man").length > 0;
|
||||||
if (!big) return err("only a giant steps over hazards");
|
if (!big) return err("only a giant steps over hazards");
|
||||||
if (isBlinded(state, p)) return err("you cannot leap what you cannot see");
|
if (isBlinded(state, p)) return err("you cannot leap what you cannot see");
|
||||||
if (state.turn.movementAllowance - state.turn.movementUsed < 2) return err("stepping over costs 2 movement");
|
if (state.turn.movementAllowance - state.turn.movementUsed < 2) return err("stepping over costs 2 movement");
|
||||||
@@ -3952,8 +3856,8 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
const edge = view.edges[key] ?? "open";
|
const edge = view.edges[key] ?? "open";
|
||||||
const dest = neighbor(p.position, direction);
|
const dest = neighbor(p.position, direction);
|
||||||
if (!view.cells[cellKey(dest)]) {
|
if (!view.cells[cellKey(dest)]) {
|
||||||
// A fire on a warp mouth (rev 39 casts allow it): the wraparound
|
// A fire on a warp mouth: the wraparound corridor still runs —
|
||||||
// corridor still runs — through flame.
|
// through flame.
|
||||||
const warp = edge === "firewall" ? findWarp(view, p.position, direction) : undefined;
|
const warp = edge === "firewall" ? findWarp(view, p.position, direction) : undefined;
|
||||||
if (!warp) {
|
if (!warp) {
|
||||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||||
@@ -4002,10 +3906,9 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// BIG MAN pushes: "You can push monsters or other players (in an adjacent
|
// BIG MAN pushes: "You can push monsters or other players (in an adjacent
|
||||||
// square) down the corridor ahead of you as you move" (rules rev 5). No
|
// square) down the corridor ahead of you as you move". No
|
||||||
// room to shove them onward means no way forward for the giant either.
|
// room to shove them onward means no way forward for the giant either.
|
||||||
if ((state.config.deckRev ?? 1) >= 5 && via === "step" &&
|
if (via === "step" && sustainedOn(state, p.id, "big-man").length > 0) {
|
||||||
sustainedOn(state, p.id, "big-man").length > 0) {
|
|
||||||
const here = cellKey(p.position);
|
const here = cellKey(p.position);
|
||||||
const pushPlayers = state.players.filter((o) => o.id !== p.id && o.alive && cellKey(o.position) === here);
|
const pushPlayers = state.players.filter((o) => o.id !== p.id && o.alive && cellKey(o.position) === here);
|
||||||
const pushCreatures = state.creatures.filter((c) => cellKey(c.position) === here);
|
const pushCreatures = state.creatures.filter((c) => cellKey(c.position) === here);
|
||||||
@@ -4200,7 +4103,7 @@ function doWarpStep(prev: GameState): CommandResult {
|
|||||||
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
|
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.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
|
||||||
const from = p.position;
|
const from = p.position;
|
||||||
if ((state.config.deckRev ?? 1) >= 32 && fearRepels(state, p.id, from, dest)) {
|
if (fearRepels(state, p.id, from, dest)) {
|
||||||
return err("an unnatural dread keeps you away");
|
return err("an unnatural dread keeps you away");
|
||||||
}
|
}
|
||||||
p.position = { ...dest };
|
p.position = { ...dest };
|
||||||
@@ -4290,10 +4193,10 @@ function castingBlocked(
|
|||||||
// needed)" — and per the FAQ, spells that aid the march to the treasure.
|
// needed)" — and per the FAQ, spells that aid the march to the treasure.
|
||||||
if (sustainedOn(state, playerId, "idiot").length > 0 &&
|
if (sustainedOn(state, playerId, "idiot").length > 0 &&
|
||||||
!opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) {
|
!opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) {
|
||||||
// Rev 37: DROP OBJECT that shakes YOUR OWN treasure out of a thief's
|
// DROP OBJECT that shakes YOUR OWN treasure out of a thief's arms
|
||||||
// arms serves the march — while carried, that treasure cannot even be
|
// serves the march — while carried, that treasure cannot even be
|
||||||
// stood upon, so without this the curse can be unsatisfiable.
|
// stood upon, so without this the curse can be unsatisfiable.
|
||||||
const freesOwnGold = (state.config.deckRev ?? 1) >= 37 &&
|
const freesOwnGold =
|
||||||
opts?.cardId === "drop-object" && opts.pickedCardId === "treasure" &&
|
opts?.cardId === "drop-object" && opts.pickedCardId === "treasure" &&
|
||||||
opts.targetPlayerId != null &&
|
opts.targetPlayerId != null &&
|
||||||
state.treasures.some((t) => t.owner === playerId && t.carriedBy === opts.targetPlayerId);
|
state.treasures.some((t) => t.owner === playerId && t.carriedBy === opts.targetPlayerId);
|
||||||
@@ -4312,30 +4215,12 @@ function castingBlocked(
|
|||||||
* instant cure.
|
* instant cure.
|
||||||
*/
|
*/
|
||||||
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
|
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||||
if ((state.config.deckRev ?? 1) >= 23 && sustainedOn(state, playerId, "idiot").length > 0) {
|
if (sustainedOn(state, playerId, "idiot").length > 0) {
|
||||||
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IDIOT: the victim's moves are steered toward their nearest own treasure. */
|
|
||||||
function idiotSteer(state: GameState, p: PlayerState): Side | null {
|
|
||||||
const targets = state.treasures.filter((t) => t.owner === p.id && t.position);
|
|
||||||
if (targets.length === 0) return null;
|
|
||||||
const view = boardView(state);
|
|
||||||
let best: { side: Side; dist: number } | null = null;
|
|
||||||
for (const side of SIDES) {
|
|
||||||
const step = stepTarget(view, p.position, side);
|
|
||||||
if (step.kind === "blocked") continue;
|
|
||||||
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
|
|
||||||
for (const t of targets) {
|
|
||||||
const d = walkingDistance(state, step.to, t.position!);
|
|
||||||
if (best === null || d < best.dist) best = { side, dist: d };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.side ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
||||||
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
||||||
if (sustainedOn(state, target.id, "big-man").length > 0 &&
|
if (sustainedOn(state, target.id, "big-man").length > 0 &&
|
||||||
@@ -4415,24 +4300,11 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
|
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
|
||||||
function doArmWard(prev: GameState, armed: boolean): CommandResult {
|
function doArmWard(): CommandResult {
|
||||||
// Rev 31 reads the card literally: the Ward is played in the moment of
|
// The card is read literally: the Ward is played in the moment of the
|
||||||
// the grab, never set ahead. Older games keep their arming and replay so.
|
// grab, never set ahead.
|
||||||
if ((prev.config.deckRev ?? 1) >= 31) {
|
|
||||||
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
|
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
|
||||||
}
|
}
|
||||||
const state = clone(prev);
|
|
||||||
const p = activePlayer(state);
|
|
||||||
if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD");
|
|
||||||
const already = state.wardArmed.includes(p.id);
|
|
||||||
if (armed === already) return err(armed ? "your ward is already set" : "your ward is not set");
|
|
||||||
state.wardArmed = armed ? [...state.wardArmed, p.id] : state.wardArmed.filter((id) => id !== p.id);
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
state,
|
|
||||||
events: [{ type: "wardSet", player: p.id, armed, visibleTo: p.id }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Player ids in seat order, starting after `fromId`. */
|
/** Player ids in seat order, starting after `fromId`. */
|
||||||
function turnOrderFrom(state: GameState, fromId: PlayerId): PlayerId[] {
|
function turnOrderFrom(state: GameState, fromId: PlayerId): PlayerId[] {
|
||||||
@@ -4466,13 +4338,12 @@ function finishChaosIfReady(state: GameState, events: GameEvent[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TEST AN ILLUSION (rules rev 29): a free act on your turn. Standing beside
|
* TEST AN ILLUSION: a free act on your turn. Standing beside
|
||||||
* the shimmer or seeing it, you roll your 50% doubt — the wall then either
|
* the shimmer or seeing it, you roll your 50% doubt — the wall then either
|
||||||
* hardens (for you) or dissolves (for you). Nobody else's eyes are moved by
|
* hardens (for you) or dissolves (for you). Nobody else's eyes are moved by
|
||||||
* your verdict, or by anyone strolling through it.
|
* your verdict, or by anyone strolling through it.
|
||||||
*/
|
*/
|
||||||
function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult {
|
function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult {
|
||||||
if ((prev.config.deckRev ?? 1) < 29) return err("illusions test themselves in this vintage of the rules");
|
|
||||||
const state = clone(prev);
|
const state = clone(prev);
|
||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
@@ -5094,10 +4965,9 @@ function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush"
|
|||||||
numbers.push(c);
|
numbers.push(c);
|
||||||
}
|
}
|
||||||
if (numbers.length > 1) return err("one number card per action");
|
if (numbers.length > 1) return err("one number card per action");
|
||||||
// Cell-needing spells commit their destination when the trap is laid
|
// Cell-needing spells commit their destination when the trap is laid.
|
||||||
// (rules rev 35; older games armed them blind and their springs fizzle).
|
|
||||||
const needsCell = spell.cardId === "teleport-opponent" || spell.cardId === "mental-force";
|
const needsCell = spell.cardId === "teleport-opponent" || spell.cardId === "mental-force";
|
||||||
if (needsCell && (prev.config.deckRev ?? 1) >= 35) {
|
if (needsCell) {
|
||||||
if (!cmd.cell) return err("choose where the ambush will send them");
|
if (!cmd.cell) return err("choose where the ambush will send them");
|
||||||
const v = boardView(state);
|
const v = boardView(state);
|
||||||
if (!v.cells[cellKey(cmd.cell)]) return err("that destination is off the board");
|
if (!v.cells[cellKey(cmd.cell)]) return err("that destination is off the board");
|
||||||
@@ -5219,13 +5089,12 @@ function checkAmbushes(
|
|||||||
|
|
||||||
/** Damage dealt inside an attack's own resolution hook still honors
|
/** Damage dealt inside an attack's own resolution hook still honors
|
||||||
* EMPATHY — "Any attack done in ANY form against you acts against both you
|
* EMPATHY — "Any attack done in ANY form against you acts against both you
|
||||||
* and the caster" (rules rev 16; earlier ledgers resolved these blows
|
* and the caster". */
|
||||||
* unmirrored and replay so). */
|
|
||||||
function resolvedBlow(
|
function resolvedBlow(
|
||||||
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
|
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
|
||||||
): void {
|
): void {
|
||||||
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, source, ctx.attacker.id, kind);
|
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, source, ctx.attacker.id, kind);
|
||||||
if ((ctx.state.config.deckRev ?? 1) >= 16 && dmg > 0 && ctx.attacker.alive &&
|
if (dmg > 0 && ctx.attacker.alive &&
|
||||||
sustainedOn(ctx.state, ctx.defender.id, "empathy").length > 0) {
|
sustainedOn(ctx.state, ctx.defender.id, "empathy").length > 0) {
|
||||||
applyDamage(ctx.state, ctx.events, ctx.attacker, dmg, `${source} (empathy)`, ctx.defender.id, kind);
|
applyDamage(ctx.state, ctx.events, ctx.attacker, dmg, `${source} (empathy)`, ctx.defender.id, kind);
|
||||||
}
|
}
|
||||||
@@ -5259,8 +5128,8 @@ function doCounteract(
|
|||||||
const castBlock = castingBlocked(state, playerId, { counteraction: true });
|
const castBlock = castingBlocked(state, playerId, { counteraction: true });
|
||||||
if (castBlock) return err(castBlock);
|
if (castBlock) return err(castBlock);
|
||||||
|
|
||||||
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
|
// "REFLECTIONS have no effect" against CHAOS.
|
||||||
if (stack.attackCard?.cardId === "chaos" && (state.config.deckRev ?? 1) >= 3 &&
|
if (stack.attackCard?.cardId === "chaos" &&
|
||||||
(card.cardId === "reflection" || card.cardId === "full-reflection")) {
|
(card.cardId === "reflection" || card.cardId === "full-reflection")) {
|
||||||
return err("REFLECTIONS have no effect against CHAOS");
|
return err("REFLECTIONS have no effect against CHAOS");
|
||||||
}
|
}
|
||||||
@@ -5322,9 +5191,6 @@ function doCounteract(
|
|||||||
// The spell truly goes up — duration from an attached NUMBER card — and
|
// The spell truly goes up — duration from an attached NUMBER card — and
|
||||||
// the attacker's 1-in-4 hit roll happens at resolution.
|
// the attacker's 1-in-4 hit roll happens at resolution.
|
||||||
if (card.cardId === "invisible") {
|
if (card.cardId === "invisible") {
|
||||||
if (stack.creatureId && (state.config.deckRev ?? 1) < 13) {
|
|
||||||
return err("in this game's revision, creatures are not fooled by the vanishing");
|
|
||||||
}
|
|
||||||
const duration = counterDuration(state, player, numberInstanceIds);
|
const duration = counterDuration(state, player, numberInstanceIds);
|
||||||
takeFromHand(player, instanceId);
|
takeFromHand(player, instanceId);
|
||||||
state.discard.push(card);
|
state.discard.push(card);
|
||||||
@@ -5440,11 +5306,9 @@ function doCounteract(
|
|||||||
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
|
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
|
||||||
if (!targetCounter) return err("no counteraction to nullify");
|
if (!targetCounter) return err("no counteraction to nullify");
|
||||||
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
|
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
|
||||||
// (the card face). Teleport is rev-6-gated — earlier games replay their
|
// (the card face).
|
||||||
// old chains; invisible needs no gate, since no earlier engine ever
|
|
||||||
// accepted it as a counteraction for a ledger to contain.
|
|
||||||
if (targetCounter.card.cardId === "invisible" ||
|
if (targetCounter.card.cardId === "invisible" ||
|
||||||
((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport")) {
|
targetCounter.card.cardId === "teleport") {
|
||||||
return err("ANTI-ANTI does not work against escape");
|
return err("ANTI-ANTI does not work against escape");
|
||||||
}
|
}
|
||||||
takeFromHand(player, instanceId);
|
takeFromHand(player, instanceId);
|
||||||
@@ -5478,23 +5342,18 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult {
|
|||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
// A self-stack (a democratic monster touching its own creator) has one
|
// A self-stack (a democratic monster touching its own creator) has one
|
||||||
// party wearing both hats: their pass RESOLVES — bouncing it "to the
|
// party wearing both hats: their pass RESOLVES — bouncing it "to the
|
||||||
// defender" would hand the exchange back to themselves forever
|
// defender" would hand the exchange back to themselves forever.
|
||||||
// (rules rev 22; older ledgers hold those no-op bounces and replay so).
|
const selfStack = stack.attackerId === stack.defenderId;
|
||||||
const selfStack =
|
|
||||||
stack.attackerId === stack.defenderId && (state.config.deckRev ?? 1) >= 22;
|
|
||||||
if (playerId === stack.attackerId && !selfStack) {
|
if (playerId === stack.attackerId && !selfStack) {
|
||||||
// The attacker declining to answer bounces the exchange back so the
|
// The attacker declining to answer bounces the exchange back so the
|
||||||
// defender may stack further counters — but not past a total stop:
|
// defender may stack further counters — but not past a total stop:
|
||||||
// re-prompting then reads as "your shield failed" and goads the table
|
// re-prompting then reads as "your shield failed" and goads the table
|
||||||
// into burning cards against a spell that is already dead (rules rev 10).
|
// into burning cards against a spell that is already dead.
|
||||||
const rev = state.config.deckRev ?? 1;
|
const totalStop =
|
||||||
const totalStop = rev >= 10 &&
|
|
||||||
stack.counters.some((c) =>
|
stack.counters.some((c) =>
|
||||||
!c.nullified &&
|
!c.nullified &&
|
||||||
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
|
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
|
||||||
// Wall-of-fire counters pre-date the total-stop rule; their stored
|
c.card.cardId === "wall-of-fire") &&
|
||||||
// exchanges bounced, and replay so (rules rev 12).
|
|
||||||
(rev >= 12 && c.card.cardId === "wall-of-fire")) &&
|
|
||||||
(stack.kind === "spell" || c.card.cardId === "teleport"));
|
(stack.kind === "spell" || c.card.cardId === "teleport"));
|
||||||
if (!totalStop) {
|
if (!totalStop) {
|
||||||
stack.waitingOn = stack.defenderId;
|
stack.waitingOn = stack.defenderId;
|
||||||
@@ -5517,10 +5376,10 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
|
|
||||||
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
|
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
|
||||||
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
|
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
|
||||||
// Creature blows roll the same dice from rev 13 — the cards carve out no
|
// Creature blows roll the same dice — the cards carve out no exception
|
||||||
// exception for monsters; earlier games skipped the rolls and replay so.
|
// for monsters. A reflected spell returns to its sender unerringly — no
|
||||||
// A reflected spell returns to its sender unerringly — no fresh hit rolls.
|
// fresh hit rolls.
|
||||||
const missRolls = (!stack.creatureId || (state.config.deckRev ?? 1) >= 13) && !stack.reflectedBase;
|
const missRolls = !stack.reflectedBase;
|
||||||
if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) {
|
if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) {
|
||||||
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
|
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
|
||||||
if (roll !== 1) {
|
if (roll !== 1) {
|
||||||
@@ -5548,13 +5407,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
base += 2;
|
base += 2;
|
||||||
}
|
}
|
||||||
// STONE DEAD: number x the stones the defender carries — and "carrying"
|
// STONE DEAD: number x the stones the defender carries — and "carrying"
|
||||||
// means displayed, as with fireball's burning (rules rev 34): a hidden
|
// means displayed, as with fireball's burning: a hidden stone is a
|
||||||
// stone is a secret card, and counting it would broadcast the secret in
|
// secret card, and counting it would broadcast the secret in the
|
||||||
// the damage total. Earlier games counted the whole hand and replay so.
|
// damage total.
|
||||||
if (attackId === "stone-dead") {
|
if (attackId === "stone-dead") {
|
||||||
const inPlay = (state.config.deckRev ?? 1) >= 34
|
const inPlay = defender.hand.filter((c) =>
|
||||||
? defender.hand.filter((c) => isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId))
|
isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId));
|
||||||
: defender.hand.filter((c) => isMagicStone(c.cardId));
|
|
||||||
base = (stack.numberValue ?? 1) * inPlay.length;
|
base = (stack.numberValue ?? 1) * inPlay.length;
|
||||||
}
|
}
|
||||||
base *= stack.amplifyFactor;
|
base *= stack.amplifyFactor;
|
||||||
@@ -5597,7 +5455,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
reversed: false,
|
reversed: false,
|
||||||
kind: stack.kind,
|
kind: stack.kind,
|
||||||
};
|
};
|
||||||
if (attackId === "chaos" && (state.config.deckRev ?? 1) >= 3) {
|
if (attackId === "chaos") {
|
||||||
const shielded = stack.counters.some((c) => !c.nullified && c.card.cardId === "full-shield");
|
const shielded = stack.counters.some((c) => !c.nullified && c.card.cardId === "full-shield");
|
||||||
if (shielded) {
|
if (shielded) {
|
||||||
stack.defenderShielded = true;
|
stack.defenderShielded = true;
|
||||||
@@ -5658,9 +5516,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
: undefined;
|
: undefined;
|
||||||
if (pipe.redirected) {
|
if (pipe.redirected) {
|
||||||
// SWAP MEET turned: "FULL REFLECTION lets the other player decide which
|
// SWAP MEET turned: "FULL REFLECTION lets the other player decide which
|
||||||
// objects, if any, will be swapped" (rules rev 27). The reflector's
|
// objects, if any, will be swapped". The reflector's
|
||||||
// choice rode their counter — absent, or "none", nothing trades.
|
// choice rode their counter — absent, or "none", nothing trades.
|
||||||
if (attackId === "swap-meet" && (state.config.deckRev ?? 1) >= 27) {
|
if (attackId === "swap-meet") {
|
||||||
const fr = stack.counters.find((c) => !c.nullified && c.card.cardId === "full-reflection");
|
const fr = stack.counters.find((c) => !c.nullified && c.card.cardId === "full-reflection");
|
||||||
const chosen = fr?.cardId;
|
const chosen = fr?.cardId;
|
||||||
if (chosen && chosen !== "none" && effect?.onResolved && !attackingCreature) {
|
if (chosen && chosen !== "none" && effect?.onResolved && !attackingCreature) {
|
||||||
@@ -5684,9 +5542,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The returned spell is a fresh attack on its own caster — who gets a
|
// The returned spell is a fresh attack on its own caster — who gets a
|
||||||
// defender's counteraction window against it (rules rev 25). Earlier
|
// defender's counteraction window against it.
|
||||||
// revisions land the blow instantly and stored games replay so.
|
if (!stack.trapped && !attackingCreature &&
|
||||||
if ((state.config.deckRev ?? 1) >= 25 && !stack.trapped && !attackingCreature &&
|
|
||||||
attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) {
|
attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) {
|
||||||
state.stack = {
|
state.stack = {
|
||||||
attackerId: defender.id,
|
attackerId: defender.id,
|
||||||
@@ -5800,7 +5657,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GO AWAY's rout (rules rev 36): "must move away a number of spaces equal
|
* GO AWAY's rout: "must move away a number of spaces equal
|
||||||
* to the NUMBER card played, in as straight a line as possible. Roll D4
|
* to the NUMBER card played, in as straight a line as possible. Roll D4
|
||||||
* for random direction, if necessary." The victim keeps moving away,
|
* for random direction, if necessary." The victim keeps moving away,
|
||||||
* holding their line while it is open, bending around walls when it is
|
* holding their line while it is open, bending around walls when it is
|
||||||
@@ -6007,26 +5864,14 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
|
|||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
||||||
];
|
];
|
||||||
// WARD: "you may play at that time (out of turn) this card on him."
|
// WARD: "you may play at that time (out of turn) this card on him" —
|
||||||
// From rules rev 31 that is literal: the grab hangs while the owner
|
// literally: the grab hangs while the owner decides.
|
||||||
// decides. Rev 3-30 committed the choice ahead of time by arming; rev
|
|
||||||
// 1-2 sprang automatically. Stored games replay their own vintage.
|
|
||||||
const owner = state.players.find((q) => q.id === t.owner);
|
const owner = state.players.find((q) => q.id === t.owner);
|
||||||
const rev = state.config.deckRev ?? 1;
|
|
||||||
if (owner && owner.alive && owner.id !== p.id) {
|
if (owner && owner.alive && owner.id !== p.id) {
|
||||||
const holdsWard = owner.hand.some((c) => c.cardId === "ward");
|
const holdsWard = owner.hand.some((c) => c.cardId === "ward");
|
||||||
if (rev >= 31 && holdsWard) {
|
if (holdsWard) {
|
||||||
state.wardPending = { ownerId: owner.id, takerId: p.id };
|
state.wardPending = { ownerId: owner.id, takerId: p.id };
|
||||||
events.push({ type: "wardWindow", owner: owner.id, victim: p.id });
|
events.push({ type: "wardWindow", owner: owner.id, victim: p.id });
|
||||||
} else if (holdsWard && (rev >= 3 ? state.wardArmed.includes(owner.id) : true)) {
|
|
||||||
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
|
|
||||||
const [card] = owner.hand.splice(wardIdx, 1);
|
|
||||||
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
|
|
||||||
state.discard.push(card!);
|
|
||||||
state.wardArmed = state.wardArmed.filter((id) => id !== owner.id);
|
|
||||||
events.push({ type: "wardSprung", owner: owner.id, victim: p.id });
|
|
||||||
applyDamage(state, events, p, 3, "warded treasure", null);
|
|
||||||
checkVictory(state, events);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkAmbushes(state, events, p, { pickedUpTreasure: true });
|
checkAmbushes(state, events, p, { pickedUpTreasure: true });
|
||||||
@@ -6132,9 +5977,8 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
|
|||||||
p.displayed = [];
|
p.displayed = [];
|
||||||
events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" });
|
events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" });
|
||||||
// A treasure in the fallen wizard's arms lands where they stood, as
|
// A treasure in the fallen wizard's arms lands where they stood, as
|
||||||
// it does when a wizard is killed (rules rev 30; earlier games let
|
// it does when a wizard is killed.
|
||||||
// it vanish with them and replay so).
|
if (p.carriedTreasureId) {
|
||||||
if ((state.config.deckRev ?? 1) >= 30 && p.carriedTreasureId) {
|
|
||||||
const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!;
|
const t = state.treasures.find((t) => t.id === p.carriedTreasureId)!;
|
||||||
t.carriedBy = null;
|
t.carriedBy = null;
|
||||||
t.position = p.position;
|
t.position = p.position;
|
||||||
@@ -6147,7 +5991,7 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
|
|||||||
onHomeOf: homeOwnerAt(state, p.position),
|
onHomeOf: homeOwnerAt(state, p.position),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if ((state.config.deckRev ?? 1) >= 14) sweepEliminated(state, p.id);
|
sweepEliminated(state, p.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6226,9 +6070,6 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
|||||||
c.scorchedThisTurn = [];
|
c.scorchedThisTurn = [];
|
||||||
if (c.kind === "democratic-monster") {
|
if (c.kind === "democratic-monster") {
|
||||||
c.movementUsed = 0;
|
c.movementUsed = 0;
|
||||||
// Rev <18 refreshed the claw here — which never fires once the
|
|
||||||
// first-rolled wizard is dead, since only the living begin turns.
|
|
||||||
if ((state.config.deckRev ?? 1) < 18 && index === state.turn.firstIndex) c.attackUsed = false;
|
|
||||||
} else if (c.controllerId === player.id) {
|
} else if (c.controllerId === player.id) {
|
||||||
c.movementUsed = 0;
|
c.movementUsed = 0;
|
||||||
c.wallPassUsed = 0;
|
c.wallPassUsed = 0;
|
||||||
@@ -6412,9 +6253,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
|||||||
p.extraTurns--;
|
p.extraTurns--;
|
||||||
beginTurnFor(state, events, state.turn.activeIndex);
|
beginTurnFor(state, events, state.turn.activeIndex);
|
||||||
// "If you have SPEED on you while under the influence of a duration
|
// "If you have SPEED on you while under the influence of a duration
|
||||||
// spell, it uses up a turn of that duration" — recipient-counted (FAQ;
|
// spell, it uses up a turn of that duration" — recipient-counted (FAQ).
|
||||||
// rules rev 8). Self-cast durations already burned in beginTurnFor.
|
// Self-cast durations already burned in beginTurnFor.
|
||||||
if ((state.config.deckRev ?? 1) >= 8) {
|
{
|
||||||
const surviving: SustainedEffect[] = [];
|
const surviving: SustainedEffect[] = [];
|
||||||
for (const s of state.sustained) {
|
for (const s of state.sustained) {
|
||||||
if (s.targetId === p.id && s.casterId !== p.id) {
|
if (s.targetId === p.id && s.casterId !== p.id) {
|
||||||
@@ -6441,13 +6282,11 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
|
|||||||
state.turn.round++;
|
state.turn.round++;
|
||||||
// The democratic monster's one claw per round refreshes at the TRUE
|
// The democratic monster's one claw per round refreshes at the TRUE
|
||||||
// round boundary — even when the first-rolled wizard is dead or
|
// round boundary — even when the first-rolled wizard is dead or
|
||||||
// skipped and never begins a turn (rules rev 18).
|
// skipped and never begins a turn.
|
||||||
if ((state.config.deckRev ?? 1) >= 18) {
|
|
||||||
for (const c of state.creatures) {
|
for (const c of state.creatures) {
|
||||||
if (c.kind === "democratic-monster") c.attackUsed = false;
|
if (c.kind === "democratic-monster") c.attackUsed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const candidate = state.players[next]!;
|
const candidate = state.players[next]!;
|
||||||
if (!candidate.alive) continue;
|
if (!candidate.alive) continue;
|
||||||
if (candidate.lostTurns > 0) {
|
if (candidate.lostTurns > 0) {
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export interface GameView {
|
|||||||
winReason: "treasures" | "lastStanding" | null;
|
winReason: "treasures" | "lastStanding" | null;
|
||||||
turn: TurnState;
|
turn: TurnState;
|
||||||
activePlayerId: PlayerId;
|
activePlayerId: PlayerId;
|
||||||
/** The game's rules revision — the client mirrors rev-gated legality. */
|
/** The game's rules revision (a fresh count from 1; gates return if the
|
||||||
|
* rules ever fork again while games are live). */
|
||||||
deckRev: number;
|
deckRev: number;
|
||||||
/** Board with dynamic wall changes already merged in. */
|
/** Board with dynamic wall changes already merged in. */
|
||||||
board: AssembledBoard;
|
board: AssembledBoard;
|
||||||
@@ -70,7 +71,7 @@ export interface GameView {
|
|||||||
createdEdges: string[];
|
createdEdges: string[];
|
||||||
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
|
||||||
knownIllusionEdges: string[];
|
knownIllusionEdges: string[];
|
||||||
/** Every illusion edge and YOUR verdict on it (rules rev 29+): untested
|
/** Every illusion edge and YOUR verdict on it: untested
|
||||||
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
|
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
|
||||||
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
|
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
|
||||||
creatures: CreatureState[];
|
creatures: CreatureState[];
|
||||||
@@ -82,9 +83,7 @@ export interface GameView {
|
|||||||
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
||||||
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
||||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||||
/** Whether YOUR ward is set to spring. */
|
/** A grab hangs while the treasure's owner decides their Ward. */
|
||||||
yourWardArmed: boolean;
|
|
||||||
/** A grab hangs while the treasure's owner decides their Ward (rev 31). */
|
|
||||||
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
|
||||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||||
yourAmbushes: AmbushState[];
|
yourAmbushes: AmbushState[];
|
||||||
@@ -95,20 +94,17 @@ export interface GameView {
|
|||||||
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||||
const you = state.players.find((p) => p.id === playerId);
|
const you = state.players.find((p) => p.id === playerId);
|
||||||
// Illusion walls render as real walls unless this viewer knows better.
|
// Illusion walls render as real walls unless this viewer knows better.
|
||||||
// From rules rev 29 their locations are open knowledge (the cast is
|
// Their locations are open knowledge (the cast is public at a table) —
|
||||||
// public at a table) — what stays personal is each player's verdict.
|
// what stays personal is each player's verdict.
|
||||||
const base = boardView(state);
|
const base = boardView(state);
|
||||||
const rev29 = (state.config.deckRev ?? 1) >= 29;
|
|
||||||
const knownIllusionEdges: string[] = [];
|
const knownIllusionEdges: string[] = [];
|
||||||
const illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
|
const illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
|
||||||
let edges = base.edges;
|
let edges = base.edges;
|
||||||
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
for (const [key, wall] of Object.entries(state.illusionWalls)) {
|
||||||
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
|
||||||
if (rev29) {
|
|
||||||
illusionEdges[key] =
|
illusionEdges[key] =
|
||||||
wall.createdBy === playerId ? "mine"
|
wall.createdBy === playerId ? "mine"
|
||||||
: wall.belief[playerId] ?? "untested";
|
: wall.belief[playerId] ?? "untested";
|
||||||
}
|
|
||||||
if (knows) {
|
if (knows) {
|
||||||
knownIllusionEdges.push(key);
|
knownIllusionEdges.push(key);
|
||||||
} else {
|
} else {
|
||||||
@@ -169,7 +165,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
|||||||
chaosPending: state.chaosPending
|
chaosPending: state.chaosPending
|
||||||
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
||||||
: null,
|
: null,
|
||||||
yourWardArmed: state.wardArmed.includes(playerId),
|
|
||||||
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
wardPending: state.wardPending ? { ...state.wardPending } : null,
|
||||||
yourAmbushes: state.ambushes
|
yourAmbushes: state.ambushes
|
||||||
.filter((a) => a.ownerId === playerId)
|
.filter((a) => a.ownerId === playerId)
|
||||||
@@ -205,9 +200,9 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
|||||||
|
|
||||||
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
||||||
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||||
// Held-open doors are open doorways to the eye (rules rev 15).
|
// Held-open doors are open doorways to the eye.
|
||||||
let board = view.board;
|
let board = view.board;
|
||||||
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
|
if (view.heldDoorEdges.length > 0) {
|
||||||
const edges = { ...board.edges };
|
const edges = { ...board.edges };
|
||||||
for (const k of view.heldDoorEdges) delete edges[k];
|
for (const k of view.heldDoorEdges) delete edges[k];
|
||||||
board = { ...board, edges };
|
board = { ...board, edges };
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ function playOut(
|
|||||||
playerIds: ids,
|
playerIds: ids,
|
||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
deckRev: 8,
|
|
||||||
});
|
});
|
||||||
let commands = 0;
|
let commands = 0;
|
||||||
let stuck = 0;
|
let stuck = 0;
|
||||||
@@ -120,7 +119,7 @@ describe("automaton vs automaton", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
// burn round 1
|
// burn round 1
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
@@ -175,7 +174,7 @@ describe("the clockwork guards gold only within sight", () => {
|
|||||||
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
|
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
|
||||||
// so SAFE must only ever be offered where the engine's sight rule allows it.
|
// so SAFE must only ever be offered where the engine's sight rule allows it.
|
||||||
function goldOnTheFloor() {
|
function goldOnTheFloor() {
|
||||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 });
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||||
while (actingSeat(state) !== "bot") {
|
while (actingSeat(state) !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -227,7 +226,7 @@ describe("the clockwork guards gold only within sight", () => {
|
|||||||
describe("the clockwork honors IDIOT's march", () => {
|
describe("the clockwork honors IDIOT's march", () => {
|
||||||
// Rev 37 retired the engine's steering, so the duty falls to the brain.
|
// Rev 37 retired the engine's steering, so the duty falls to the brain.
|
||||||
function cursedBot() {
|
function cursedBot() {
|
||||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 37 });
|
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||||
// Past round 1 (no combat) and around to the bot's turn.
|
// Past round 1 (no combat) and around to the bot's turn.
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
@@ -285,7 +284,7 @@ describe("the clockwork honors IDIOT's march", () => {
|
|||||||
|
|
||||||
describe("a clogged hand gets shed, not hoarded", () => {
|
describe("a clogged hand gets shed, not hoarded", () => {
|
||||||
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
it("the bot discards dead weight so the end-of-turn draw has room", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||||
while (actingSeat(state) !== "bot") {
|
while (actingSeat(state) !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -331,7 +330,7 @@ describe("the clockwork honors absorb's fine print", () => {
|
|||||||
|
|
||||||
describe("the clockwork wields destroy wall", () => {
|
describe("the clockwork wields destroy wall", () => {
|
||||||
it("blasts its way out when no road leads to the gold", () => {
|
it("blasts its way out when no road leads to the gold", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||||
while (actingSeat(state) !== "bot") {
|
while (actingSeat(state) !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -353,7 +352,7 @@ describe("the clockwork wields destroy wall", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("values destroy wall above the chaff when forced to discard", () => {
|
it("values destroy wall above the chaff when forced to discard", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||||
const bot = state.players.find((p) => p.id === "bot")!;
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
bot.hand = [
|
bot.hand = [
|
||||||
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
|
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
|
||||||
@@ -376,7 +375,7 @@ describe("the clockwork wields destroy wall", () => {
|
|||||||
|
|
||||||
describe("the clockwork wields pass through wall", () => {
|
describe("the clockwork wields pass through wall", () => {
|
||||||
it("banks a crossing when bricked in, then steps through the wall", () => {
|
it("banks a crossing when bricked in, then steps through the wall", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||||
while (actingSeat(state) !== "bot") {
|
while (actingSeat(state) !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -408,7 +407,7 @@ describe("the clockwork wields pass through wall", () => {
|
|||||||
|
|
||||||
describe("the clockwork denies the road", () => {
|
describe("the clockwork denies the road", () => {
|
||||||
it("walls a thief's corridor when its treasure is being carried home", () => {
|
it("walls a thief's corridor when its treasure is being carried home", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
|
||||||
while (actingSeat(state) !== "bot") {
|
while (actingSeat(state) !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -446,7 +445,7 @@ describe("the clockwork denies the road", () => {
|
|||||||
|
|
||||||
describe("the widened spellbook", () => {
|
describe("the widened spellbook", () => {
|
||||||
it("dispels a conjured wall standing between it and the only road", () => {
|
it("dispels a conjured wall standing between it and the only road", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
while (actingSeat(state) !== "bot") {
|
while (actingSeat(state) !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -468,7 +467,7 @@ describe("the widened spellbook", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("offers a buddy pact to the hound at its heels", () => {
|
it("offers a buddy pact to the hound at its heels", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
while (actingSeat(state) !== "bot") {
|
while (actingSeat(state) !== "bot") {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -490,7 +489,7 @@ describe("the widened spellbook", () => {
|
|||||||
|
|
||||||
describe("finishing tactics: exile and adrenaline", () => {
|
describe("finishing tactics: exile and adrenaline", () => {
|
||||||
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
it("exiles a thief carrying its gold to the far end of nowhere", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
// burn round 1 and reach the bot's turn
|
// burn round 1 and reach the bot's turn
|
||||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
@@ -518,7 +517,7 @@ describe("finishing tactics: exile and adrenaline", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("casts adrenaline when two blows finish what one cannot", () => {
|
it("casts adrenaline when two blows finish what one cannot", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -543,7 +542,7 @@ describe("finishing tactics: exile and adrenaline", () => {
|
|||||||
|
|
||||||
describe("the fireball-then-buddy lockout", () => {
|
describe("the fireball-then-buddy lockout", () => {
|
||||||
it("burns the target, then signs the pact so they cannot hit back", () => {
|
it("burns the target, then signs the pact so they cannot hit back", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -584,7 +583,7 @@ describe("the fireball-then-buddy lockout", () => {
|
|||||||
|
|
||||||
describe("a pact once signed is honored", () => {
|
describe("a pact once signed is honored", () => {
|
||||||
it("will not attack the wizard it just buddied", () => {
|
it("will not attack the wizard it just buddied", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 25 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -610,7 +609,7 @@ describe("a pact once signed is honored", () => {
|
|||||||
|
|
||||||
describe("no number is wasted on a turn that ends at a grab", () => {
|
describe("no number is wasted on a turn that ends at a grab", () => {
|
||||||
it("gold two free steps away: the berserker charges without spending its 5", () => {
|
it("gold two free steps away: the berserker charges without spending its 5", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 29 });
|
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
@@ -636,7 +635,7 @@ describe("no number is wasted on a turn that ends at a grab", () => {
|
|||||||
|
|
||||||
describe("the clockwork flees the dread", () => {
|
describe("the clockwork flees the dread", () => {
|
||||||
it("caught in FEAR's bubble, it spends its legs moving away", () => {
|
it("caught in FEAR's bubble, it spends its legs moving away", () => {
|
||||||
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"], deckRev: 32 });
|
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"] });
|
||||||
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
|
||||||
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("relocation discards the aisle warp: only opposite edges connect after", () => {
|
it("relocation discards the aisle warp: only opposite edges connect after", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"] });
|
||||||
const bent = (warps: typeof state.board.warps) =>
|
const bent = (warps: typeof state.board.warps) =>
|
||||||
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
|
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
|
||||||
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
|
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
|
||||||
@@ -244,7 +244,7 @@ describe("the aisle warp treats its corner as adjacent — sight included", () =
|
|||||||
|
|
||||||
describe("bricking over a warp mouth", () => {
|
describe("bricking over a warp mouth", () => {
|
||||||
it("create wall seals a lettered opening: no passage, no sight", () => {
|
it("create wall seals a lettered opening: no passage, no sight", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 18 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
// round 1 passes
|
// round 1 passes
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
@@ -283,7 +283,7 @@ describe("bricking over a warp mouth", () => {
|
|||||||
|
|
||||||
describe("breaching the rim (rules rev 19)", () => {
|
describe("breaching the rim (rules rev 19)", () => {
|
||||||
it("destroying a perimeter wall opens both sides as a new warp", () => {
|
it("destroying a perimeter wall opens both sides as a new warp", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 19 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ describe("attack spells", () => {
|
|||||||
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
||||||
|
|
||||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||||
// Full shield stops everything -> stones survive.
|
// Full shield stops everything -> stones survive. (A total stop
|
||||||
|
// resolves on the attacker's pass; no bounce back.)
|
||||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
||||||
state = must(state, attacker, { type: "pass" });
|
state = must(state, attacker, { type: "pass" });
|
||||||
state = must(state, defender, { type: "pass" });
|
|
||||||
const after = state.players.find((p) => p.id === defender)!;
|
const after = state.players.find((p) => p.id === defender)!;
|
||||||
expect(after.life).toBe(15);
|
expect(after.life).toBe(15);
|
||||||
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
|
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
|
||||||
@@ -111,7 +111,8 @@ describe("attack spells", () => {
|
|||||||
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
||||||
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
|
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
|
||||||
state = must(state, attacker, { type: "pass" });
|
state = must(state, attacker, { type: "pass" });
|
||||||
state = must(state, defender, { type: "pass" });
|
// The returned spell is a fresh attack on its caster: their window.
|
||||||
|
state = must(state, attacker, { type: "pass" });
|
||||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
||||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||||
});
|
});
|
||||||
@@ -308,16 +309,14 @@ describe("stored-log compatibility", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("deck revisions", () => {
|
describe("deck revisions", () => {
|
||||||
it("revision 2 removes LIFESAVER at two players, and only there", () => {
|
it("two-player games shed LIFESAVER; bigger tables keep it", () => {
|
||||||
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
||||||
const inGame = (s: typeof two.state) =>
|
const inGame = (s: typeof two.state) =>
|
||||||
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
|
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
|
||||||
expect(inGame(two.state)).toBe(false);
|
expect(inGame(two.state)).toBe(false);
|
||||||
// Three players keep it; old two-player games (no deckRev) keep it too.
|
// Three players keep it.
|
||||||
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
|
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"] });
|
||||||
expect(inGame(three.state)).toBe(true);
|
expect(inGame(three.state)).toBe(true);
|
||||||
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
|
||||||
expect(inGame(legacy.state)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -380,42 +379,31 @@ describe("attacking walls and doors", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ward arming and chaos shields (rules rev 3)", () => {
|
describe("the ward window and chaos shields", () => {
|
||||||
function rev3Game(seed = 42) {
|
function threeGame(seed = 42) {
|
||||||
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
|
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
it("ward springs only when its owner armed it", () => {
|
it("the grab hangs while the ward's owner decides — and arming ahead is refused", () => {
|
||||||
let { state } = rev3Game();
|
let { state } = threeGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
const thief = activePlayer(state);
|
const thief = activePlayer(state);
|
||||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||||
giveCard(state, owner.id, "ward", "W", 0);
|
giveCard(state, owner.id, "ward", "W", 0);
|
||||||
|
expect(applyCommand(state, owner.id, { type: "armWard", armed: true }).ok).toBe(false);
|
||||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||||
|
|
||||||
// Unarmed: the grab goes unpunished.
|
|
||||||
thief.position = { ...treasure.position! };
|
thief.position = { ...treasure.position! };
|
||||||
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
|
state = must(state, thief.id, { type: "pickUpTreasure" });
|
||||||
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
|
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
|
||||||
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
|
// Spring it: the thief bleeds 3 and the ward is spent.
|
||||||
|
state = must(state, owner.id, { type: "wardChoice", play: true });
|
||||||
// Armed (on the owner's own turn): the trap bites for 3.
|
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
||||||
const ownerTurnState = (() => {
|
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||||
let s = state;
|
|
||||||
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
|
|
||||||
return s;
|
|
||||||
})();
|
|
||||||
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
|
|
||||||
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
|
|
||||||
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
|
|
||||||
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
|
|
||||||
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
|
||||||
expect(s3.wardArmed).not.toContain(owner.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("chaos: bystanders may shield out, reflections are refused", () => {
|
it("chaos: bystanders may shield out, reflections are refused", () => {
|
||||||
let { state } = rev3Game();
|
let { state } = threeGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
@@ -442,7 +430,7 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("chaos: the defender's full shield sits them out without stopping it", () => {
|
it("chaos: the defender's full shield sits them out without stopping it", () => {
|
||||||
let { state } = rev3Game();
|
let { state } = threeGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
@@ -458,7 +446,6 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
|
|||||||
});
|
});
|
||||||
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
|
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
|
||||||
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
|
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
|
||||||
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
|
|
||||||
// Bystander declines; the scramble happens without the defender.
|
// Bystander declines; the scramble happens without the defender.
|
||||||
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
|
||||||
state = must(state, bystander.id, { type: "pass" });
|
state = must(state, bystander.id, { type: "pass" });
|
||||||
@@ -466,17 +453,6 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
|
|||||||
expect(defenderHand()).toEqual(kept);
|
expect(defenderHand()).toEqual(kept);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("legacy games (rev < 3) keep the automatic ward", () => {
|
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
|
||||||
state = toRound2(state);
|
|
||||||
const thief = activePlayer(state);
|
|
||||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
|
||||||
giveCard(state, owner.id, "ward", "W", 0);
|
|
||||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
|
||||||
thief.position = { ...treasure.position! };
|
|
||||||
state = must(state, thief.id, { type: "pickUpTreasure" });
|
|
||||||
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("zero-damage utility attacks", () => {
|
describe("zero-damage utility attacks", () => {
|
||||||
@@ -517,8 +493,8 @@ describe("zero-damage utility attacks", () => {
|
|||||||
target: { kind: "player", playerId: defender },
|
target: { kind: "player", playerId: defender },
|
||||||
});
|
});
|
||||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
||||||
state = must(state, attacker, { type: "pass" });
|
// A total stop settles on the attacker's pass.
|
||||||
const result = applyCommand(state, defender, { type: "pass" });
|
const result = applyCommand(state, attacker, { type: "pass" });
|
||||||
if (!result.ok) throw new Error(result.error);
|
if (!result.ok) throw new Error(result.error);
|
||||||
const resolved = result.events.find((e) => e.type === "attackResolved");
|
const resolved = result.events.find((e) => e.type === "attackResolved");
|
||||||
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
|
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
|
||||||
@@ -539,33 +515,13 @@ describe("teleport as a counteraction", () => {
|
|||||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||||
});
|
});
|
||||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
||||||
|
// The escape is a total stop: the attacker's pass settles it.
|
||||||
state = must(state, attacker, { type: "pass" });
|
state = must(state, attacker, { type: "pass" });
|
||||||
state = must(state, defender, { type: "pass" });
|
|
||||||
const after = state.players.find((p) => p.id === defender)!;
|
const after = state.players.find((p) => p.id === defender)!;
|
||||||
expect(after.life).toBe(15);
|
expect(after.life).toBe(15);
|
||||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
|
|
||||||
let { state } = newGame();
|
|
||||||
state = toRound2(state);
|
|
||||||
const { attacker, defender } = faceOff(state);
|
|
||||||
const fb = giveCard(state, attacker, "fireball");
|
|
||||||
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
|
||||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
|
||||||
const d = state.players.find((p) => p.id === defender)!;
|
|
||||||
const home = { ...d.position };
|
|
||||||
state = must(state, attacker, {
|
|
||||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
||||||
});
|
|
||||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
|
|
||||||
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
|
||||||
state = must(state, defender, { type: "pass" });
|
|
||||||
const after = state.players.find((p) => p.id === defender)!;
|
|
||||||
expect(cellKey(after.position)).toBe(cellKey(home));
|
|
||||||
expect(after.life).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reaches at most four spaces", () => {
|
it("reaches at most four spaces", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
@@ -585,7 +541,7 @@ describe("teleport as a counteraction", () => {
|
|||||||
|
|
||||||
describe("slime holds spells", () => {
|
describe("slime holds spells", () => {
|
||||||
function slimeRig() {
|
function slimeRig() {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||||
@@ -638,7 +594,6 @@ describe("slime holds spells", () => {
|
|||||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||||
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||||
state = must(state, rig.caster, { type: "pass" });
|
state = must(state, rig.caster, { type: "pass" });
|
||||||
state = must(state, rig.victim, { type: "pass" });
|
|
||||||
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
||||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
||||||
});
|
});
|
||||||
@@ -664,7 +619,7 @@ describe("slime holds spells", () => {
|
|||||||
|
|
||||||
describe("answering counteractions (FAQ rulings)", () => {
|
describe("answering counteractions (FAQ rulings)", () => {
|
||||||
function rev6() {
|
function rev6() {
|
||||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
|
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
|
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
|
||||||
@@ -719,7 +674,6 @@ describe("answering counteractions (FAQ rulings)", () => {
|
|||||||
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
state = must(state, attacker, { type: "pass" });
|
state = must(state, attacker, { type: "pass" });
|
||||||
state = must(state, defender, { type: "pass" });
|
|
||||||
const after = state.players.find((p) => p.id === defender)!;
|
const after = state.players.find((p) => p.id === defender)!;
|
||||||
expect(after.life).toBe(15);
|
expect(after.life).toBe(15);
|
||||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||||
@@ -728,7 +682,7 @@ describe("answering counteractions (FAQ rulings)", () => {
|
|||||||
|
|
||||||
describe("speed and warp-token creation (rules rev 8)", () => {
|
describe("speed and warp-token creation (rules rev 8)", () => {
|
||||||
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
|
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const other = state.players.find((p) => p.id !== me.id)!;
|
const other = state.players.find((p) => p.id !== me.id)!;
|
||||||
@@ -747,7 +701,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("nothing can be created on a dimensional warp token", () => {
|
it("nothing can be created on a dimensional warp token", () => {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const spot = { x: me.position.x + 1, y: me.position.y };
|
const spot = { x: me.position.x + 1, y: me.position.y };
|
||||||
@@ -763,7 +717,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
|
|||||||
|
|
||||||
describe("redirection (rules rev 9)", () => {
|
describe("redirection (rules rev 9)", () => {
|
||||||
it("the two chosen exits connect; their old partners pair with each other", () => {
|
it("the two chosen exits connect; their old partners pair with each other", () => {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
// Pick two exits that are NOT already partners.
|
// Pick two exits that are NOT already partners.
|
||||||
@@ -788,7 +742,7 @@ describe("redirection (rules rev 9)", () => {
|
|||||||
|
|
||||||
describe("total stops end the exchange (rules rev 10)", () => {
|
describe("total stops end the exchange (rules rev 10)", () => {
|
||||||
function rev10Game() {
|
function rev10Game() {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
return { state, attacker, defender };
|
return { state, attacker, defender };
|
||||||
@@ -839,7 +793,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
|||||||
|
|
||||||
describe("escapes and elemental walls as counteractions", () => {
|
describe("escapes and elemental walls as counteractions", () => {
|
||||||
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
|
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const atk = giveCard(state, attacker, attackId);
|
const atk = giveCard(state, attacker, attackId);
|
||||||
@@ -901,7 +855,7 @@ describe("escapes and elemental walls as counteractions", () => {
|
|||||||
|
|
||||||
describe("empathy as a counteraction", () => {
|
describe("empathy as a counteraction", () => {
|
||||||
function empathyRig() {
|
function empathyRig() {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 15 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const fb = giveCard(state, attacker, "fireball");
|
const fb = giveCard(state, attacker, "fireball");
|
||||||
@@ -942,7 +896,7 @@ describe("empathy as a counteraction", () => {
|
|||||||
|
|
||||||
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
|
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
|
||||||
it("a believed illusion bites both wizards", () => {
|
it("a believed illusion bites both wizards", () => {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const ill = giveCard(state, attacker, "illusionary-attack");
|
const ill = giveCard(state, attacker, "illusionary-attack");
|
||||||
@@ -963,9 +917,9 @@ describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the reflected-attack window (rules rev 25)", () => {
|
describe("the reflected-attack window", () => {
|
||||||
function reflectRig() {
|
function reflectRig() {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const sd = giveCard(state, attacker, "sudden-death");
|
const sd = giveCard(state, attacker, "sudden-death");
|
||||||
@@ -1007,26 +961,11 @@ describe("the reflected-attack window (rules rev 25)", () => {
|
|||||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(5);
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(5);
|
||||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rev 24 lands the reflected blow instantly, as stored games replay", () => {
|
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 24 });
|
|
||||||
state = toRound2(state);
|
|
||||||
const { attacker, defender } = faceOff(state);
|
|
||||||
const sd = giveCard(state, attacker, "sudden-death");
|
|
||||||
giveCard(state, defender, "full-reflection", "FR", 0);
|
|
||||||
state = must(state, attacker, {
|
|
||||||
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
|
|
||||||
});
|
|
||||||
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
|
|
||||||
state = must(state, attacker, { type: "pass" });
|
|
||||||
expect(state.stack).toBeNull();
|
|
||||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(5);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () => {
|
describe("a reflected lightning blast ends the caster's turn", () => {
|
||||||
function boltRig(deckRev: number) {
|
function boltRig() {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const lb = giveCard(state, attacker, "lightning-blast");
|
const lb = giveCard(state, attacker, "lightning-blast");
|
||||||
@@ -1044,7 +983,7 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => {
|
it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => {
|
||||||
let { state, attacker } = boltRig(28);
|
let { state, attacker } = boltRig();
|
||||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
|
||||||
expect(state.turn.actionsEnded).toBe(true);
|
expect(state.turn.actionsEnded).toBe(true);
|
||||||
// No more marching, and the end-of-turn draw comes up empty.
|
// No more marching, and the end-of-turn draw comes up empty.
|
||||||
@@ -1056,19 +995,11 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", ()
|
|||||||
expect(state.players.find((p) => p.id === attacker)!.lostTurns
|
expect(state.players.find((p) => p.id === attacker)!.lostTurns
|
||||||
+ (activePlayer(state).id === attacker ? 1 : 0)).toBeGreaterThanOrEqual(1);
|
+ (activePlayer(state).id === attacker ? 1 : 0)).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rev 27 keeps the old behavior: the caster plays on and draws", () => {
|
|
||||||
let { state, attacker } = boltRig(27);
|
|
||||||
expect(state.turn.actionsEnded).toBe(false);
|
|
||||||
const before = state.players.find((p) => p.id === attacker)!.hand.length;
|
|
||||||
state = must(state, attacker, { type: "endTurn", draw: 2 });
|
|
||||||
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBeGreaterThan(before);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fireball burns only the stones in play (rules rev 33)", () => {
|
describe("fireball burns only the stones in play (rules rev 33)", () => {
|
||||||
it("a displayed stone dies; a hidden one stays secret and safe", () => {
|
it("a displayed stone dies; a hidden one stays secret and safe", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 33 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const fb = giveCard(state, attacker, "fireball");
|
const fb = giveCard(state, attacker, "fireball");
|
||||||
@@ -1086,7 +1017,7 @@ describe("fireball burns only the stones in play (rules rev 33)", () => {
|
|||||||
|
|
||||||
describe("stone dead counts only the stones in play (rules rev 34)", () => {
|
describe("stone dead counts only the stones in play (rules rev 34)", () => {
|
||||||
it("hidden stones neither add damage nor betray their count", () => {
|
it("hidden stones neither add damage nor betray their count", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 34 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const sd = giveCard(state, attacker, "stone-dead");
|
const sd = giveCard(state, attacker, "stone-dead");
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
|||||||
|
|
||||||
describe("expansion deck", () => {
|
describe("expansion deck", () => {
|
||||||
it("basic + expansion1 builds the full 200-card game", () => {
|
it("basic + expansion1 builds the full 200-card game", () => {
|
||||||
const { state } = newGame();
|
// Three seats: two-player games shed LIFESAVER from the build.
|
||||||
|
const { state } = newGame(42, ["a", "b", "c"]);
|
||||||
const total = state.deck.length + state.discard.length +
|
const total = state.deck.length + state.discard.length +
|
||||||
state.players.reduce((s, p) => s + p.hand.length, 0);
|
state.players.reduce((s, p) => s + p.hand.length, 0);
|
||||||
expect(total).toBe(200);
|
expect(total).toBe(200);
|
||||||
@@ -59,6 +60,8 @@ describe("monsters", () => {
|
|||||||
state = must(state, me, {
|
state = must(state, me, {
|
||||||
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
|
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
|
||||||
});
|
});
|
||||||
|
// The blow opens the victim's counteraction window; they take it raw.
|
||||||
|
state = must(state, victim.id, { type: "pass" });
|
||||||
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
|
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,6 +139,8 @@ describe("monsters", () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The touch opens the victim's counteraction window; they take it raw.
|
||||||
|
state = must(state, enemy2.id, { type: "pass" });
|
||||||
const bitten = state.players.find((p) => p.id !== me)!;
|
const bitten = state.players.find((p) => p.id !== me)!;
|
||||||
expect(bitten.life).toBe(13);
|
expect(bitten.life).toBe(13);
|
||||||
expect(bitten.hand.length).toBe(6);
|
expect(bitten.hand.length).toBe(6);
|
||||||
@@ -293,9 +298,9 @@ describe("expansion support cards", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("counteracting a creature's blow (rules rev 4)", () => {
|
describe("counteracting a creature's blow", () => {
|
||||||
function rev4() {
|
function rev4() {
|
||||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
|
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
}
|
}
|
||||||
function wraithOnVictim(state: GameState) {
|
function wraithOnVictim(state: GameState) {
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
@@ -341,20 +346,11 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
|||||||
const wraith = state.creatures.find((c) => c.id === "w1")!;
|
const wraith = state.creatures.find((c) => c.id === "w1")!;
|
||||||
expect(wraith.damage).toBe(2);
|
expect(wraith.damage).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("legacy games (rev < 4) keep the instant touch", () => {
|
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
|
||||||
const rig = wraithOnVictim(state);
|
|
||||||
state = rig.state;
|
|
||||||
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
|
||||||
expect(state.stack).toBeNull();
|
|
||||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("big man (rules rev 5)", () => {
|
describe("big man (rules rev 5)", () => {
|
||||||
function bigRig() {
|
function bigRig() {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const giant = activePlayer(state);
|
const giant = activePlayer(state);
|
||||||
state.sustained.push({
|
state.sustained.push({
|
||||||
@@ -450,9 +446,9 @@ describe("big man (rules rev 5)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
describe("monsters roll to hit the hidden", () => {
|
||||||
function creatureVsInvisible(deckRev: number) {
|
function creatureVsInvisible() {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const me = activePlayer(state).id;
|
const me = activePlayer(state).id;
|
||||||
const victim = state.players.find((p) => p.id !== me)!;
|
const victim = state.players.find((p) => p.id !== me)!;
|
||||||
@@ -470,29 +466,18 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||||
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
|
let { state, me, victim, skeletonId } = creatureVsInvisible();
|
||||||
const rngBefore = JSON.stringify(state.rng);
|
const rngBefore = JSON.stringify(state.rng);
|
||||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||||
state = must(state, victim.id, { type: "pass" });
|
state = must(state, victim.id, { type: "pass" });
|
||||||
// The die was consumed, whichever way it landed.
|
// The die was consumed, whichever way it landed.
|
||||||
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
|
||||||
let { state, me, victim, skeletonId } = creatureVsInvisible(12);
|
|
||||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
|
||||||
const rngBefore = JSON.stringify(state.rng);
|
|
||||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
|
||||||
state = must(state, victim.id, { type: "pass" });
|
|
||||||
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
|
||||||
// No die was consumed on the way.
|
|
||||||
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("elimination sweeps the board either way (rules rev 14)", () => {
|
describe("elimination sweeps the board either way (rules rev 14)", () => {
|
||||||
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
const victim = state.players.find((p) => p.id === "c")!;
|
const victim = state.players.find((p) => p.id === "c")!;
|
||||||
state.creatures.push({
|
state.creatures.push({
|
||||||
id: "imp1", kind: "fire-imp", controllerId: "c",
|
id: "imp1", kind: "fire-imp", controllerId: "c",
|
||||||
@@ -524,7 +509,7 @@ describe("elimination sweeps the board either way (rules rev 14)", () => {
|
|||||||
|
|
||||||
describe("creatures walk open doorways", () => {
|
describe("creatures walk open doorways", () => {
|
||||||
it("a skeleton passes a door whose lock was removed", () => {
|
it("a skeleton passes a door whose lock was removed", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
for (const [key, edge] of Object.entries(view.edges)) {
|
for (const [key, edge] of Object.entries(view.edges)) {
|
||||||
@@ -553,7 +538,7 @@ describe("creatures walk open doorways", () => {
|
|||||||
|
|
||||||
describe("the wave carries monsters (rules rev 17)", () => {
|
describe("the wave carries monsters (rules rev 17)", () => {
|
||||||
it("a cornered skeleton takes the waterwall crush", () => {
|
it("a cornered skeleton takes the waterwall crush", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 17 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
// A skeleton pinned against solid stone, one square from the wave's edge.
|
// A skeleton pinned against solid stone, one square from the wave's edge.
|
||||||
@@ -592,7 +577,7 @@ describe("the wave carries monsters (rules rev 17)", () => {
|
|||||||
|
|
||||||
describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => {
|
describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => {
|
||||||
it("refreshes each round even with the roll-off winner dead", () => {
|
it("refreshes each round even with the roll-off winner dead", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const firstId = state.players[state.turn.firstIndex]!.id;
|
const firstId = state.players[state.turn.firstIndex]!.id;
|
||||||
const dm = {
|
const dm = {
|
||||||
@@ -619,7 +604,7 @@ describe("the democratic monster's claw survives the first wizard's death (rules
|
|||||||
|
|
||||||
describe("a mid-round democratic monster claws on the next turn (rules rev 18)", () => {
|
describe("a mid-round democratic monster claws on the next turn (rules rev 18)", () => {
|
||||||
it("creation spends the turn, not the round", () => {
|
it("creation spends the turn, not the round", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const creator = activePlayer(state);
|
const creator = activePlayer(state);
|
||||||
const r0 = summon(state, creator.id, "democratic-monster");
|
const r0 = summon(state, creator.id, "democratic-monster");
|
||||||
@@ -641,9 +626,9 @@ describe("a mid-round democratic monster claws on the next turn (rules rev 18)",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("collapsing walls crush monsters too (rules rev 21)", () => {
|
describe("collapsing walls crush monsters too", () => {
|
||||||
function wallRig(deckRev: number) {
|
function wallRig() {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
for (const [key, edge] of Object.entries(view.edges)) {
|
for (const [key, edge] of Object.entries(view.edges)) {
|
||||||
@@ -672,21 +657,16 @@ describe("collapsing walls crush monsters too (rules rev 21)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("rev 21: the adjacent troll takes the four points", () => {
|
it("rev 21: the adjacent troll takes the four points", () => {
|
||||||
const state = wallRig(21);
|
const state = wallRig();
|
||||||
const troll = state.creatures.find((c) => c.id === "tr1");
|
const troll = state.creatures.find((c) => c.id === "tr1");
|
||||||
// Four points on a six-point troll: hurt but standing (or regenerating).
|
// Four points on a six-point troll: hurt but standing (or regenerating).
|
||||||
expect(troll?.damage).toBe(4);
|
expect(troll?.damage).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("earlier revisions leave the troll dusty but unharmed", () => {
|
|
||||||
const state = wallRig(20);
|
|
||||||
expect(state.creatures.find((c) => c.id === "tr1")!.damage).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the self-stack resolves on a pass (rules rev 22)", () => {
|
describe("the self-stack resolves on a pass", () => {
|
||||||
function selfTouch(deckRev: number) {
|
function selfTouch() {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const creator = activePlayer(state);
|
const creator = activePlayer(state);
|
||||||
state.creatures.push({
|
state.creatures.push({
|
||||||
@@ -702,7 +682,7 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("rev 22: passing your own monster's touch takes the claw and moves on", () => {
|
it("rev 22: passing your own monster's touch takes the claw and moves on", () => {
|
||||||
const { state, creator } = selfTouch(22);
|
const { state, creator } = selfTouch();
|
||||||
if (!state.stack) return; // a wall between: the touch never opened
|
if (!state.stack) return; // a wall between: the touch never opened
|
||||||
expect(state.stack.attackerId).toBe(creator);
|
expect(state.stack.attackerId).toBe(creator);
|
||||||
expect(state.stack.defenderId).toBe(creator);
|
expect(state.stack.defenderId).toBe(creator);
|
||||||
@@ -711,19 +691,11 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => {
|
|||||||
expect(r.state.stack).toBeNull();
|
expect(r.state.stack).toBeNull();
|
||||||
expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13);
|
expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rev 21 keeps its recorded bounce, no-op as it was", () => {
|
|
||||||
const { state, creator } = selfTouch(21);
|
|
||||||
if (!state.stack) return;
|
|
||||||
const r = applyCommand(state, creator, { type: "pass" });
|
|
||||||
if (!r.ok) throw new Error(r.error);
|
|
||||||
expect(r.state.stack).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => {
|
describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => {
|
||||||
it("a commanded monster cannot close within three of the fearsome", () => {
|
it("a commanded monster cannot close within three of the fearsome", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 32 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
const a = state.players.find((p) => p.id === "a")!;
|
const a = state.players.find((p) => p.id === "a")!;
|
||||||
const b = state.players.find((p) => p.id === "b")!;
|
const b = state.players.find((p) => p.id === "b")!;
|
||||||
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
|
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
|
||||||
|
|||||||
@@ -339,8 +339,8 @@ describe("cast modifiers", () => {
|
|||||||
target: { kind: "player", playerId: defender },
|
target: { kind: "player", playerId: defender },
|
||||||
});
|
});
|
||||||
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
|
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
|
||||||
|
// REVERSE is a total stop of the harm: the attacker's pass settles it.
|
||||||
state = must(state, attacker, { type: "pass" });
|
state = must(state, attacker, { type: "pass" });
|
||||||
state = must(state, defender, { type: "pass" });
|
|
||||||
const d = state.players.find((p) => p.id === defender)!;
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
expect(d.life).toBe(19); // gained 4 instead of losing it
|
expect(d.life).toBe(19); // gained 4 instead of losing it
|
||||||
expect(d.lostTurns).toBe(1); // the stun still applies
|
expect(d.lostTurns).toBe(1); // the stun still applies
|
||||||
@@ -349,7 +349,7 @@ describe("cast modifiers", () => {
|
|||||||
|
|
||||||
describe("holding the door open (Pick Lock / Master Key)", () => {
|
describe("holding the door open (Pick Lock / Master Key)", () => {
|
||||||
function doorRig() {
|
function doorRig() {
|
||||||
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"], deckRev: 14 });
|
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
// Find a door edge; stand the acting player beside it.
|
// Find a door edge; stand the acting player beside it.
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
@@ -410,9 +410,9 @@ describe("holding the door open (Pick Lock / Master Key)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("a held door is an open doorway to the eye (rules rev 15)", () => {
|
describe("a held door is an open doorway to the eye", () => {
|
||||||
function sightRig(deckRev: number) {
|
function sightRig() {
|
||||||
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev });
|
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
for (const [key, edge] of Object.entries(view.edges)) {
|
for (const [key, edge] of Object.entries(view.edges)) {
|
||||||
@@ -431,7 +431,7 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("rev 15: the holder blasts the pursuer through the doorway", () => {
|
it("rev 15: the holder blasts the pursuer through the doorway", () => {
|
||||||
let { state, cell, side, holder, pursuer } = sightRig(15);
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
const pick = giveCard(state, holder, "pick-lock");
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
state = must(state, holder, {
|
state = must(state, holder, {
|
||||||
type: "cast", instanceId: pick.instanceId,
|
type: "cast", instanceId: pick.instanceId,
|
||||||
@@ -447,7 +447,7 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("an unheld unlocked door still blocks sight", () => {
|
it("an unheld unlocked door still blocks sight", () => {
|
||||||
let { state, cell, side, holder, pursuer } = sightRig(15);
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
const pick = giveCard(state, holder, "pick-lock");
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
state = must(state, holder, {
|
state = must(state, holder, {
|
||||||
type: "cast", instanceId: pick.instanceId,
|
type: "cast", instanceId: pick.instanceId,
|
||||||
@@ -459,18 +459,4 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
|
|||||||
});
|
});
|
||||||
expect(refused.ok).toBe(false);
|
expect(refused.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("earlier revisions keep the blocked sightline, hold or no", () => {
|
|
||||||
let { state, cell, side, holder, pursuer } = sightRig(14);
|
|
||||||
const pick = giveCard(state, holder, "pick-lock");
|
|
||||||
state = must(state, holder, {
|
|
||||||
type: "cast", instanceId: pick.instanceId,
|
|
||||||
target: { kind: "edge", cell, side }, params: { hold: true },
|
|
||||||
});
|
|
||||||
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
|
||||||
const refused = applyCommand(state, holder, {
|
|
||||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
|
||||||
});
|
|
||||||
expect(refused.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ describe("expansion combat cards", () => {
|
|||||||
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ward springs when a trapped treasure is grabbed", () => {
|
it("ward springs in the moment: the grab hangs on the owner's choice", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const enemy = state.players.find((p) => p.id !== me.id)!;
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
||||||
@@ -114,6 +114,8 @@ describe("expansion combat cards", () => {
|
|||||||
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
|
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
|
||||||
me.position = { ...treasure.position! };
|
me.position = { ...treasure.position! };
|
||||||
state = must(state, me.id, { type: "pickUpTreasure" });
|
state = must(state, me.id, { type: "pickUpTreasure" });
|
||||||
|
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
|
||||||
|
state = must(state, enemy.id, { type: "wardChoice", play: true });
|
||||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
||||||
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -164,7 +166,7 @@ describe("expansion combat cards", () => {
|
|||||||
|
|
||||||
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
@@ -198,9 +200,9 @@ describe("expansion combat cards", () => {
|
|||||||
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
|
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("idiot at rev 37 no longer steers the march — the step goes where asked", () => {
|
it("idiot never steers the march — the step goes where asked", () => {
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 37,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
@@ -225,10 +227,10 @@ describe("expansion combat cards", () => {
|
|||||||
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to));
|
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("idiot at rev 37 permits DROP OBJECT that frees the victim's own treasure", () => {
|
it("idiot permits DROP OBJECT that frees the victim's own treasure", () => {
|
||||||
for (const deckRev of [36, 37]) {
|
{
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
@@ -252,13 +254,13 @@ describe("expansion combat cards", () => {
|
|||||||
type: "cast", instanceId: dr.instanceId,
|
type: "cast", instanceId: dr.instanceId,
|
||||||
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
|
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(deckRev >= 37);
|
expect(r.ok).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
|
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
@@ -394,9 +396,9 @@ describe("ambushes (async interrupts)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("swap meet trades carried items (rules rev 26)", () => {
|
describe("swap meet trades carried items", () => {
|
||||||
function rig(deckRev: number) {
|
function rig() {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const sm = giveCard(state, attacker, "swap-meet");
|
const sm = giveCard(state, attacker, "swap-meet");
|
||||||
@@ -412,21 +414,15 @@ describe("swap meet trades carried items (rules rev 26)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("a dagger trades for a wand — both are carried items", () => {
|
it("a dagger trades for a wand — both are carried items", () => {
|
||||||
const { state, attacker, defender } = rig(26);
|
const { state, attacker, defender } = rig();
|
||||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
|
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
|
||||||
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("older revisions matched only object-typed cards and replay so", () => {
|
|
||||||
const { state, attacker, defender } = rig(25);
|
|
||||||
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
|
||||||
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("swap meet trades treasures too", () => {
|
describe("swap meet trades treasures too", () => {
|
||||||
function treasureRig(theirsToken: string, mineToken: string) {
|
function treasureRig(theirsToken: string, mineToken: string) {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const a = state.players.find((p) => p.id === attacker)!;
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
@@ -459,7 +455,7 @@ describe("swap meet trades treasures too", () => {
|
|||||||
it("the trade refuses to overload full arms", () => {
|
it("the trade refuses to overload full arms", () => {
|
||||||
// The attacker also carries a treasure: claiming theirs with a dagger
|
// The attacker also carries a treasure: claiming theirs with a dagger
|
||||||
// would mean two in hand — the swap quietly cannot happen.
|
// would mean two in hand — the swap quietly cannot happen.
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const a = state.players.find((p) => p.id === attacker)!;
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
@@ -481,7 +477,7 @@ describe("swap meet trades treasures too", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("treasure for treasure trades both armfuls", () => {
|
it("treasure for treasure trades both armfuls", () => {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const a = state.players.find((p) => p.id === attacker)!;
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
@@ -506,7 +502,7 @@ describe("swap meet trades treasures too", () => {
|
|||||||
|
|
||||||
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => {
|
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => {
|
||||||
function reflectedSwapRig(reflectorChoice?: string) {
|
function reflectedSwapRig(reflectorChoice?: string) {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const a = state.players.find((p) => p.id === attacker)!;
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
@@ -553,7 +549,7 @@ describe("full reflection hands the swap meet choice to the reflector (rev 27)",
|
|||||||
|
|
||||||
describe("go away routs around walls (rules rev 36)", () => {
|
describe("go away routs around walls (rules rev 36)", () => {
|
||||||
it("a victim against a wall bends the line instead of standing still", () => {
|
it("a victim against a wall bends the line instead of standing still", () => {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 36 });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const { attacker, defender } = faceOff(state);
|
const { attacker, defender } = faceOff(state);
|
||||||
const a = state.players.find((p) => p.id === attacker)!;
|
const a = state.players.find((p) => p.id === attacker)!;
|
||||||
|
|||||||
@@ -162,10 +162,10 @@ describe("expansion terrain", () => {
|
|||||||
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wall of fire takes a rim warp from rev 39 — both mouths burn the crossing", () => {
|
it("wall of fire takes a rim warp — both mouths burn the crossing", () => {
|
||||||
for (const deckRev of [38, 39]) {
|
{
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const warp = state.board.warps[0]!;
|
const warp = state.board.warps[0]!;
|
||||||
@@ -175,8 +175,7 @@ describe("expansion terrain", () => {
|
|||||||
type: "cast", instanceId: wof.instanceId,
|
type: "cast", instanceId: wof.instanceId,
|
||||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(deckRev >= 39);
|
if (!r.ok) throw new Error(r.error);
|
||||||
if (!r.ok) continue;
|
|
||||||
state = r.state;
|
state = r.state;
|
||||||
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
||||||
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
||||||
@@ -191,10 +190,10 @@ describe("expansion terrain", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waterwall takes a rim warp from rev 40 — the collapse washes both rims", () => {
|
it("waterwall takes a rim warp — the collapse washes both rims", () => {
|
||||||
for (const deckRev of [39, 40]) {
|
{
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const other = state.players.find((p) => p.id !== me.id)!;
|
const other = state.players.find((p) => p.id !== me.id)!;
|
||||||
@@ -206,8 +205,7 @@ describe("expansion terrain", () => {
|
|||||||
type: "cast", instanceId: ww.instanceId,
|
type: "cast", instanceId: ww.instanceId,
|
||||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(deckRev >= 40);
|
if (!r.ok) throw new Error(r.error);
|
||||||
if (!r.ok) continue;
|
|
||||||
// Both wizards stood in the mouths; the collapse washed both inward.
|
// Both wizards stood in the mouths; the collapse washed both inward.
|
||||||
const meAfter = r.state.players.find((p) => p.id === me.id)!;
|
const meAfter = r.state.players.find((p) => p.id === me.id)!;
|
||||||
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
|
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
|
||||||
@@ -216,10 +214,10 @@ describe("expansion terrain", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("illusion wall hangs on a rim warp mouth from rev 40", () => {
|
it("illusion wall hangs on a rim warp mouth", () => {
|
||||||
for (const deckRev of [39, 40]) {
|
{
|
||||||
const { state } = createGame({
|
const { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const warp = state.board.warps[0]!;
|
const warp = state.board.warps[0]!;
|
||||||
@@ -229,17 +227,17 @@ describe("expansion terrain", () => {
|
|||||||
type: "cast", instanceId: il.instanceId,
|
type: "cast", instanceId: il.instanceId,
|
||||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(deckRev >= 40);
|
expect(r.ok).toBe(true);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
|
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stone to water breaches the outer rim from rev 40, opening a warp", () => {
|
it("stone to water breaches the outer rim, opening a warp", () => {
|
||||||
for (const deckRev of [39, 40]) {
|
{
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const board = boardView(state);
|
const board = boardView(state);
|
||||||
@@ -264,20 +262,15 @@ describe("expansion terrain", () => {
|
|||||||
type: "cast", instanceId: stw.instanceId,
|
type: "cast", instanceId: stw.instanceId,
|
||||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||||
});
|
});
|
||||||
if (deckRev >= 40) {
|
|
||||||
// The far rim melted with it and the wraparound now runs.
|
// The far rim melted with it and the wraparound now runs.
|
||||||
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
||||||
} else {
|
|
||||||
// Older vintages left a dangling opening and replay so.
|
|
||||||
expect(state.board.warps.length).toBe(warpsBefore);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no doors through the rim from rev 40", () => {
|
it("no doors through the rim", () => {
|
||||||
for (const deckRev of [39, 40]) {
|
{
|
||||||
const { state } = createGame({
|
const { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const board = boardView(state);
|
const board = boardView(state);
|
||||||
@@ -299,15 +292,14 @@ describe("expansion terrain", () => {
|
|||||||
type: "cast", instanceId: cd.instanceId,
|
type: "cast", instanceId: cd.instanceId,
|
||||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||||
});
|
});
|
||||||
// Rev 39 hung a dead door there (and replays so); rev 40 refuses.
|
expect(r.ok).toBe(false);
|
||||||
expect(r.ok).toBe(deckRev < 40);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stone to water melts a door from rev 38 — a small entryway in a stone wall", () => {
|
it("stone to water melts a door — a small entryway in a stone wall", () => {
|
||||||
for (const deckRev of [37, 38]) {
|
{
|
||||||
const { state } = createGame({
|
const { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const board = boardView(state);
|
const board = boardView(state);
|
||||||
@@ -329,7 +321,7 @@ describe("expansion terrain", () => {
|
|||||||
type: "cast", instanceId: stw.instanceId,
|
type: "cast", instanceId: stw.instanceId,
|
||||||
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
|
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(deckRev >= 38);
|
expect(r.ok).toBe(true);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const key = edgeKey(doorAt.cell, doorAt.side);
|
const key = edgeKey(doorAt.cell, doorAt.side);
|
||||||
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
|
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
|
||||||
@@ -389,11 +381,11 @@ describe("eligibility dimming mirrors the engine", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("a wave's force is spent as it travels (rules rev 24)", () => {
|
describe("a wave's force is spent as it travels", () => {
|
||||||
/** Caster at B, one cell above A; the target wall is A's south edge, so
|
/** Caster at B, one cell above A; the target wall is A's south edge, so
|
||||||
* the range-2 wave covers A (dist 0) and B (dist 1). */
|
* the range-2 wave covers A (dist 0) and B (dist 1). */
|
||||||
function rig(deckRev: number, behind: "open" | "wall") {
|
function rig(behind: "open" | "wall") {
|
||||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
|
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
|
||||||
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
|
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
|
||||||
@@ -412,20 +404,15 @@ describe("a wave's force is spent as it travels (rules rev 24)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("a victim at the wave's far edge is carried one space, unhurt", () => {
|
it("a victim at the wave's far edge is carried one space, unhurt", () => {
|
||||||
const { me, Bn } = rig(24, "open");
|
const { me, Bn } = rig("open");
|
||||||
expect(cellKey(me.position)).toBe(cellKey(Bn));
|
expect(cellKey(me.position)).toBe(cellKey(Bn));
|
||||||
expect(me.life).toBe(15);
|
expect(me.life).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only unspent force crushes: one space of push blocked is one damage", () => {
|
it("only unspent force crushes: one space of push blocked is one damage", () => {
|
||||||
const { me, B } = rig(24, "wall");
|
const { me, B } = rig("wall");
|
||||||
expect(cellKey(me.position)).toBe(cellKey(B));
|
expect(cellKey(me.position)).toBe(cellKey(B));
|
||||||
expect(me.life).toBe(14);
|
expect(me.life).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("older revisions keep the flat full-range wash for replay fidelity", () => {
|
|
||||||
const { me, Bnn } = rig(23, "open");
|
|
||||||
expect(cellKey(me.position)).toBe(cellKey(Bnn));
|
|
||||||
expect(me.life).toBe(15);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ describe("treasures and victory", () => {
|
|||||||
|
|
||||||
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => {
|
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => {
|
||||||
it("the carried treasure lands where the wizard stood", () => {
|
it("the carried treasure lands where the wizard stood", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 });
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
|
||||||
const A = state.players.find((p) => p.id === "a")!;
|
const A = state.players.find((p) => p.id === "a")!;
|
||||||
const B = state.players.find((p) => p.id === "b")!;
|
const B = state.players.find((p) => p.id === "b")!;
|
||||||
const C = state.players.find((p) => p.id === "c")!;
|
const C = state.players.find((p) => p.id === "c")!;
|
||||||
@@ -267,7 +267,7 @@ describe("elimination by lost treasures drops what the fallen carried (rev 30)",
|
|||||||
|
|
||||||
describe("the Ward is played in the moment (rules rev 31)", () => {
|
describe("the Ward is played in the moment (rules rev 31)", () => {
|
||||||
function grabRig() {
|
function grabRig() {
|
||||||
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 });
|
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
|
||||||
const thief = state.players.find((p) => p.id === "thief")!;
|
const thief = state.players.find((p) => p.id === "thief")!;
|
||||||
const owner = state.players.find((p) => p.id === "owner")!;
|
const owner = state.players.find((p) => p.id === "owner")!;
|
||||||
giveCard(state, "owner", "ward", "W", 0);
|
giveCard(state, "owner", "ward", "W", 0);
|
||||||
@@ -307,7 +307,7 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("arming is refused in this vintage", () => {
|
it("arming is refused in this vintage", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
|
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
|
||||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
|
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
@@ -316,7 +316,7 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
|
|||||||
|
|
||||||
describe("an ambushed teleport carries its destination (rules rev 35)", () => {
|
describe("an ambushed teleport carries its destination (rules rev 35)", () => {
|
||||||
it("the trap springs and the victim lands where the trapper said", () => {
|
it("the trap springs and the victim lands where the trapper said", () => {
|
||||||
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 });
|
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
|
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
|
||||||
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
||||||
if (!r.ok) throw new Error(r.error);
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
|||||||
@@ -143,13 +143,16 @@ describe("illusion wall", () => {
|
|||||||
expect(after.ok).toBe(true);
|
expect(after.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opponents test the illusion when they bump it — some see through, some believe", () => {
|
it("an untested illusion blocks the bump; a tested eye rolls its verdict", () => {
|
||||||
let believed = 0, sawThrough = 0;
|
let believed = 0, sawThrough = 0;
|
||||||
for (let seed = 1; seed <= 12; seed++) {
|
for (let seed = 1; seed <= 12; seed++) {
|
||||||
const { state, caster, side } = setupIllusion(seed);
|
const { state, caster, side } = setupIllusion(seed);
|
||||||
const other = state.players.find((p) => p.id !== caster)!;
|
const other = state.players.find((p) => p.id !== caster)!;
|
||||||
other.position = { ...state.players.find((p) => p.id === caster)!.position };
|
other.position = { ...state.players.find((p) => p.id === caster)!.position };
|
||||||
let s = must(state, caster, { type: "endTurn", draw: 0 });
|
let s = must(state, caster, { type: "endTurn", draw: 0 });
|
||||||
|
// Bumping blind is refused: eyes must be tested first.
|
||||||
|
expect(applyCommand(s, other.id, { type: "move", direction: side }).ok).toBe(false);
|
||||||
|
s = must(s, other.id, { type: "testIllusion", cell: other.position, side });
|
||||||
const result = applyCommand(s, other.id, { type: "move", direction: side });
|
const result = applyCommand(s, other.id, { type: "move", direction: side });
|
||||||
if (result.ok) sawThrough++;
|
if (result.ok) sawThrough++;
|
||||||
else believed++;
|
else believed++;
|
||||||
@@ -223,11 +226,16 @@ describe("sector manipulation", () => {
|
|||||||
type: "cast", instanceId: rel.instanceId,
|
type: "cast", instanceId: rel.instanceId,
|
||||||
target: { kind: "cell", cell: dest }, params: { cell: me.position },
|
target: { kind: "cell", cell: dest }, params: { cell: me.position },
|
||||||
});
|
});
|
||||||
|
// The maze zero-anchors after the move; measure against the sector's
|
||||||
|
// origin as it settled, not the requested landing.
|
||||||
const after = state.players.find((p) => p.id === me.id)!;
|
const after = state.players.find((p) => p.id === me.id)!;
|
||||||
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
|
const newOrigin = state.board.placements[idx]!.origin;
|
||||||
|
const dx = newOrigin.x - myOrigin.x, dy = newOrigin.y - myOrigin.y;
|
||||||
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
|
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
|
||||||
expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
|
expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
|
||||||
expect(state.board.placements[idx]!.origin).toEqual(dest);
|
// My sector sits east of the other, as asked.
|
||||||
|
const otherAfter = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
||||||
|
expect(newOrigin).toEqual({ x: otherAfter.x + 5, y: otherAfter.y });
|
||||||
// The map reassembled: every treasure/wizard cell exists on the new board.
|
// The map reassembled: every treasure/wizard cell exists on the new board.
|
||||||
for (const t of state.treasures) {
|
for (const t of state.treasures) {
|
||||||
if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true);
|
if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true);
|
||||||
@@ -319,8 +327,8 @@ describe("6e card-face corrections", () => {
|
|||||||
params: { damage: 4, knockback: 0 },
|
params: { damage: 4, knockback: 0 },
|
||||||
});
|
});
|
||||||
state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" });
|
state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" });
|
||||||
|
// The fire is a total stop: the attacker's pass settles it.
|
||||||
state = must(state, attacker.id, { type: "pass" });
|
state = must(state, attacker.id, { type: "pass" });
|
||||||
state = must(state, defender.id, { type: "pass" });
|
|
||||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
|
||||||
|
|
||||||
// But it cannot counter a fireball.
|
// But it cannot counter a fireball.
|
||||||
@@ -337,7 +345,7 @@ describe("6e card-face corrections", () => {
|
|||||||
|
|
||||||
describe("relocation past the origin (rules rev 11)", () => {
|
describe("relocation past the origin (rules rev 11)", () => {
|
||||||
function rev11Game() {
|
function rev11Game() {
|
||||||
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
|
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +446,7 @@ describe("relocation past the origin (rules rev 11)", () => {
|
|||||||
|
|
||||||
describe("junction alterations roll for their sector (rules rev 20)", () => {
|
describe("junction alterations roll for their sector (rules rev 20)", () => {
|
||||||
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
|
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
|
||||||
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 20 });
|
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
let state = toRound2(fresh);
|
let state = toRound2(fresh);
|
||||||
// The 2p board is two sectors stacked: the seam runs between them.
|
// The 2p board is two sectors stacked: the seam runs between them.
|
||||||
const [p0, p1] = state.board.placements;
|
const [p0, p1] = state.board.placements;
|
||||||
@@ -503,7 +511,7 @@ describe("sight tracing", () => {
|
|||||||
|
|
||||||
describe("illusions are tested by choice (rules rev 29)", () => {
|
describe("illusions are tested by choice (rules rev 29)", () => {
|
||||||
function shimmerRig() {
|
function shimmerRig() {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 29 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const caster = activePlayer(state);
|
const caster = activePlayer(state);
|
||||||
const mark = state.players.find((p) => p.id !== caster.id)!;
|
const mark = state.players.find((p) => p.id !== caster.id)!;
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ describe("control effects", () => {
|
|||||||
|
|
||||||
describe("picking one of two treasures", () => {
|
describe("picking one of two treasures", () => {
|
||||||
it("a named treasure is the one taken", () => {
|
it("a named treasure is the one taken", () => {
|
||||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 16 });
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const p = activePlayer(state);
|
const p = activePlayer(state);
|
||||||
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
|
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ describe("magic wands", () => {
|
|||||||
|
|
||||||
it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => {
|
it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => {
|
||||||
let { state } = createGame({
|
let { state } = createGame({
|
||||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 40,
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||||
});
|
});
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ export interface Room {
|
|||||||
|
|
||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
|
|
||||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
/** Rules revision new games are dealt under (stored games keep their own).
|
||||||
const RULES_REV = 40;
|
* Reset to 1 on 2026-08-21: every earlier room was retired and the engine's
|
||||||
|
* vintage gates collapsed into one canonical ruleset. */
|
||||||
|
const RULES_REV = 1;
|
||||||
|
|
||||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
@@ -227,8 +227,6 @@
|
|||||||
const k = `${c.x},${c.y}`;
|
const k = `${c.x},${c.y}`;
|
||||||
if (seen.has(k)) continue;
|
if (seen.has(k)) continue;
|
||||||
seen.add(k);
|
seen.add(k);
|
||||||
// Before rev 11 the grid stopped at zero; newer games renormalize.
|
|
||||||
if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue;
|
|
||||||
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
|
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
|
||||||
const next = origins.map((o, j) => (j === idx ? c : o));
|
const next = origins.map((o, j) => (j === idx ? c : o));
|
||||||
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
|
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
|
||||||
@@ -255,7 +253,6 @@
|
|||||||
});
|
});
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
|
|
||||||
/** A live moment to interrupt: another wizard acts, no attack is pending. */
|
/** A live moment to interrupt: another wizard acts, no attack is pending. */
|
||||||
const canInterruptNow = $derived(
|
const canInterruptNow = $derived(
|
||||||
view != null && !isYourTurn && !view.stack && !view.chaosPending &&
|
view != null && !isYourTurn && !view.stack && !view.chaosPending &&
|
||||||
@@ -400,7 +397,7 @@
|
|||||||
// Fully reflecting a SWAP MEET: "lets the other player decide which
|
// Fully reflecting a SWAP MEET: "lets the other player decide which
|
||||||
// objects, if any, will be swapped" — the choice rides the counter.
|
// objects, if any, will be swapped" — the choice rides the counter.
|
||||||
if (youMustRespond && card.cardId === "full-reflection" &&
|
if (youMustRespond && card.cardId === "full-reflection" &&
|
||||||
view.stack?.attackCard?.cardId === "swap-meet" && view.deckRev >= 27) {
|
view.stack?.attackCard?.cardId === "swap-meet") {
|
||||||
reflectSwapCard = card;
|
reflectSwapCard = card;
|
||||||
reflectSwapMine = null;
|
reflectSwapMine = null;
|
||||||
return;
|
return;
|
||||||
@@ -912,11 +909,8 @@
|
|||||||
reflectSwapCard = null;
|
reflectSwapCard = null;
|
||||||
reflectSwapMine = null;
|
reflectSwapMine = null;
|
||||||
}
|
}
|
||||||
// Older rooms' rules trade only object-typed cards (rev < 26): the picker
|
|
||||||
// must offer exactly what that room's engine will honor.
|
|
||||||
function tradableHere(cardId: string): boolean {
|
function tradableHere(cardId: string): boolean {
|
||||||
if (!view) return false;
|
return view != null && isMovableObject(cardId);
|
||||||
return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object";
|
|
||||||
}
|
}
|
||||||
/** What one side can put on a trade table: hand (yours) or displayed
|
/** What one side can put on a trade table: hand (yours) or displayed
|
||||||
* cards (theirs), plus a carried treasure. Their treasure is claimable
|
* cards (theirs), plus a carried treasure. Their treasure is claimable
|
||||||
@@ -1753,9 +1747,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if view.yourWardArmed}
|
|
||||||
<div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
|
|
||||||
{/if}
|
|
||||||
{#if youMustShield}
|
{#if youMustShield}
|
||||||
<div class="slip urgent">
|
<div class="slip urgent">
|
||||||
Chaos comes for your hand — tap your Full Shield to sit out, or
|
Chaos comes for your hand — tap your Full Shield to sit out, or
|
||||||
@@ -1808,20 +1799,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
|
{#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
|
||||||
<div class="slip urgent">
|
<div class="slip urgent">
|
||||||
{#if view.deckRev >= 37}
|
|
||||||
🥴 "What am I doing here…?" — IDIOT drives you to the nearest
|
🥴 "What am I doing here…?" — IDIOT drives you to the nearest
|
||||||
of your own treasures. Spend your movement marching toward it;
|
of your own treasures. Spend your movement marching toward it;
|
||||||
you may cast only counteractions, spells that speed the trip,
|
you may cast only counteractions, spells that speed the trip,
|
||||||
and Drop Object to shake your own treasure from a thief's
|
and Drop Object to shake your own treasure from a thief's
|
||||||
arms. It lifts when you stand on your treasure. (The marching
|
arms. It lifts when you stand on your treasure. (The marching
|
||||||
is yours to honor.)
|
is yours to honor.)
|
||||||
{:else}
|
|
||||||
🥴 "What am I doing here…?" — IDIOT drives you to the nearest
|
|
||||||
of your own treasures. Spend your movement marching (the maze
|
|
||||||
will steer your steps); you may cast only counteractions and
|
|
||||||
spells that speed the trip. It lifts when you stand on your
|
|
||||||
treasure.
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if actionsSpent}
|
{#if actionsSpent}
|
||||||
@@ -2136,10 +2119,6 @@
|
|||||||
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
|
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
|
||||||
Pick up {cardDef(obj.cardId).name}</button>
|
Pick up {cardDef(obj.cardId).name}</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if holdingWard && view.deckRev < 31}
|
|
||||||
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
|
|
||||||
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
|
|
||||||
{/if}
|
|
||||||
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
||||||
onclick={() => (punchWallMode = !punchWallMode)}>
|
onclick={() => (punchWallMode = !punchWallMode)}>
|
||||||
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
|
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ function describe(view: GameView, cmd: Command): string {
|
|||||||
return `set the monster on ${cmd.targetId}`;
|
return `set the monster on ${cmd.targetId}`;
|
||||||
case "setAmbush":
|
case "setAmbush":
|
||||||
return `lay an ambush with ${nameOf(view, cmd.instanceId)}`;
|
return `lay an ambush with ${nameOf(view, cmd.instanceId)}`;
|
||||||
case "armWard":
|
|
||||||
return cmd.armed ? "arm the Ward over the gold" : "stand the Ward down";
|
|
||||||
case "discard":
|
case "discard":
|
||||||
return `shed ${cmd.instanceIds.map((id) => nameOf(view, id)).join(", ")} to make room`;
|
return `shed ${cmd.instanceIds.map((id) => nameOf(view, id)).join(", ")} to make room`;
|
||||||
case "endTurn":
|
case "endTurn":
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ class LocalGame {
|
|||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
deckRev: 36,
|
|
||||||
};
|
};
|
||||||
const { state, events } = createGame(config);
|
const { state, events } = createGame(config);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
|
|||||||
@@ -418,6 +418,18 @@ class Net {
|
|||||||
if (g.yourTurn && !was) this.notifyTurn(g);
|
if (g.yourTurn && !was) this.notifyTurn(g);
|
||||||
this.lastYourTurn.set(key, g.yourTurn);
|
this.lastYourTurn.set(key, g.yourTurn);
|
||||||
}
|
}
|
||||||
|
// A seat the server answered for but did not list is gone — the
|
||||||
|
// room was deleted (or the seat's token voided). Quietly drop it
|
||||||
|
// from the ledger rather than showing "unreachable" forever.
|
||||||
|
// (The server checks at most 50 seats per ask; never prune blind.)
|
||||||
|
if (this.seats.length <= 50) {
|
||||||
|
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
|
||||||
|
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));
|
||||||
|
if (kept.length !== this.seats.length) {
|
||||||
|
this.seats = kept;
|
||||||
|
saveSeats(this.seats);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "error":
|
case "error":
|
||||||
|
|||||||
Reference in New Issue
Block a user