Credibility pass: the collapse loses its scaffolding
Blind reviews over the rev-37..40 features and the gate collapse. The scripted surgery's scars come out: bare brace blocks unwrapped (with one hiding an unreachable scrambleHands), the amputated comment healed, the dead events parameter unthreaded from the illusion-sight chain, the laziness probe in perceivedBoard collapsed to the rule it always produced, GameView.deckRev retired with its last reader, the ward's arming stub told honestly, and every comment or test title still citing the retired revision numbering reworded to the rule it guards. Rim mechanics unify on rimWarpMouth and breachRim; the test suite gains plainRimWall and pushSustained and loses its loop-shaped scars and rev-named helpers. No behavior changes; 255 tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8a50ff7f36
commit
4a91d56d24
@@ -9,6 +9,7 @@ import {
|
||||
import { cellKey, edgeKey } from "../src/board";
|
||||
import { sightedCellsFor, viewFor } from "../src/view";
|
||||
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
|
||||
import { pushSustained } from "./helpers";
|
||||
|
||||
/** Whose input does the maze want right now? */
|
||||
function actingSeat(state: GameState): PlayerId {
|
||||
@@ -224,7 +225,7 @@ describe("the clockwork guards gold only within sight", () => {
|
||||
});
|
||||
|
||||
describe("the clockwork honors IDIOT's march", () => {
|
||||
// Rev 37 retired the engine's steering, so the duty falls to the brain.
|
||||
// The engine never steers a cursed wizard's feet; the duty is the brain's.
|
||||
function cursedBot() {
|
||||
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
|
||||
// Past round 1 (no combat) and around to the bot's turn.
|
||||
@@ -238,10 +239,10 @@ describe("the clockwork honors IDIOT's march", () => {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
}
|
||||
state.sustained.push({
|
||||
pushSustained(state, {
|
||||
id: "fx-idiot", cardId: "idiot", casterId: "foe", targetId: "bot",
|
||||
remainingTurns: 9999, data: {},
|
||||
} as never);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -593,10 +594,10 @@ describe("a pact once signed is honored", () => {
|
||||
const prey = state.players.find((p) => p.id === "other")!;
|
||||
prey.position = { ...bot.position };
|
||||
// The pact already stands; a fireball waits in hand as temptation.
|
||||
state.sustained.push({
|
||||
pushSustained(state, {
|
||||
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
|
||||
remainingTurns: 1000, data: {},
|
||||
} as never);
|
||||
});
|
||||
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
// Anything but an attack on the pacted wizard: no cast at them, no punch.
|
||||
@@ -645,10 +646,10 @@ describe("the clockwork flees the dread", () => {
|
||||
const grim = state.players.find((p) => p.id === "grim")!;
|
||||
grim.position = { x: 2, y: 5 };
|
||||
bot.position = { x: 2, y: 7 }; // two spaces inside the dread
|
||||
state.sustained.push({
|
||||
pushSustained(state, {
|
||||
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
|
||||
remainingTurns: 5, data: {},
|
||||
} as never);
|
||||
});
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
|
||||
expect(cmd?.type).toBe("move");
|
||||
if (cmd?.type === "move") {
|
||||
|
||||
@@ -281,7 +281,7 @@ describe("bricking over a warp mouth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("breaching the rim (rules rev 19)", () => {
|
||||
describe("breaching the rim", () => {
|
||||
it("destroying a perimeter wall opens both sides as a new warp", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
|
||||
@@ -308,7 +308,7 @@ describe("stored-log compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deck revisions", () => {
|
||||
describe("the two-player deck", () => {
|
||||
it("two-player games shed LIFESAVER; bigger tables keep it", () => {
|
||||
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
|
||||
const inGame = (s: typeof two.state) =>
|
||||
@@ -391,7 +391,7 @@ describe("the ward window and chaos shields", () => {
|
||||
const thief = activePlayer(state);
|
||||
const owner = state.players.find((p) => p.id !== thief.id)!;
|
||||
giveCard(state, owner.id, "ward", "W", 0);
|
||||
expect(applyCommand(state, owner.id, { type: "armWard", armed: true }).ok).toBe(false);
|
||||
expect(applyCommand(state, owner.id, { type: "armWard" }).ok).toBe(false);
|
||||
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
|
||||
thief.position = { ...treasure.position! };
|
||||
state = must(state, thief.id, { type: "pickUpTreasure" });
|
||||
@@ -452,7 +452,6 @@ describe("the ward window and chaos shields", () => {
|
||||
expect(state.chaosPending).toBeNull();
|
||||
expect(defenderHand()).toEqual(kept);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("zero-damage utility attacks", () => {
|
||||
@@ -493,7 +492,6 @@ describe("zero-damage utility attacks", () => {
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
||||
// A total stop settles on the attacker's pass.
|
||||
const result = applyCommand(state, attacker, { type: "pass" });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const resolved = result.events.find((e) => e.type === "attackResolved");
|
||||
@@ -515,7 +513,6 @@ describe("teleport as a counteraction", () => {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
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" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
@@ -618,12 +615,12 @@ describe("slime holds spells", () => {
|
||||
});
|
||||
|
||||
describe("answering counteractions (FAQ rulings)", () => {
|
||||
function rev6() {
|
||||
function freshGame() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
}
|
||||
|
||||
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
|
||||
let { state } = rev6();
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -643,7 +640,7 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
});
|
||||
|
||||
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
|
||||
let { state } = rev6();
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -658,8 +655,8 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
|
||||
});
|
||||
|
||||
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => {
|
||||
let { state } = rev6();
|
||||
it("ANTI-ANTI does not work against a teleport escape", () => {
|
||||
let { state } = freshGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
@@ -680,7 +677,7 @@ describe("answering counteractions (FAQ rulings)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
describe("speed and warp-token creation", () => {
|
||||
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
@@ -715,7 +712,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirection (rules rev 9)", () => {
|
||||
describe("redirection", () => {
|
||||
it("the two chosen exits connect; their old partners pair with each other", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
@@ -740,8 +737,8 @@ describe("redirection (rules rev 9)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("total stops end the exchange (rules rev 10)", () => {
|
||||
function rev10Game() {
|
||||
describe("total stops end the exchange", () => {
|
||||
function faceOffRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
@@ -749,7 +746,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
}
|
||||
|
||||
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const lifeBefore = d.life;
|
||||
@@ -763,7 +760,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
});
|
||||
|
||||
it("a partial counter still invites the defender to stack more", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
||||
@@ -776,7 +773,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
|
||||
});
|
||||
|
||||
it("a nullified shield is no stop — the exchange returns to the defender", () => {
|
||||
let { state, attacker, defender } = rev10Game();
|
||||
let { state, attacker, defender } = faceOffRig();
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const a = state.players.find((p) => p.id === attacker)!;
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
@@ -839,7 +836,7 @@ describe("escapes and elemental walls as counteractions", () => {
|
||||
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
|
||||
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
// A total stop: the attacker's declined answer resolves at once (rev 10+).
|
||||
// A total stop: the attacker's declined answer resolves at once.
|
||||
expect(state.stack).toBeNull();
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
|
||||
});
|
||||
@@ -894,7 +891,7 @@ describe("empathy as a counteraction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
|
||||
describe("empathy mirrors resolution-hook blows", () => {
|
||||
it("a believed illusion bites both wizards", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
@@ -997,7 +994,7 @@ describe("a reflected lightning blast ends the caster's turn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fireball burns only the stones in play (rules rev 33)", () => {
|
||||
describe("fireball burns only the stones in play", () => {
|
||||
it("a displayed stone dies; a hidden one stays secret and safe", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
@@ -1015,7 +1012,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", () => {
|
||||
it("hidden stones neither add damage nor betray their count", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
|
||||
|
||||
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
||||
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
||||
@@ -139,7 +139,6 @@ describe("monsters", () => {
|
||||
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)!;
|
||||
expect(bitten.life).toBe(13);
|
||||
@@ -299,7 +298,7 @@ describe("expansion support cards", () => {
|
||||
});
|
||||
|
||||
describe("counteracting a creature's blow", () => {
|
||||
function rev4() {
|
||||
function freshGame() {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
}
|
||||
function wraithOnVictim(state: GameState) {
|
||||
@@ -317,7 +316,7 @@ describe("counteracting a creature's blow", () => {
|
||||
}
|
||||
|
||||
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
|
||||
let { state } = rev4();
|
||||
let { state } = freshGame();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "blunt", "B", 0);
|
||||
@@ -333,7 +332,7 @@ describe("counteracting a creature's blow", () => {
|
||||
});
|
||||
|
||||
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
|
||||
let { state } = rev4();
|
||||
let { state } = freshGame();
|
||||
const rig = wraithOnVictim(state);
|
||||
state = rig.state;
|
||||
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
||||
@@ -348,7 +347,7 @@ describe("counteracting a creature's blow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("big man (rules rev 5)", () => {
|
||||
describe("big man", () => {
|
||||
function bigRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
@@ -465,7 +464,7 @@ describe("monsters roll to hit the hidden", () => {
|
||||
return { state, me, victim, skeletonId: bones.id };
|
||||
}
|
||||
|
||||
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
it("the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible();
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||
@@ -475,7 +474,7 @@ describe("monsters roll to hit the hidden", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("elimination sweeps the board either way (rules rev 14)", () => {
|
||||
describe("elimination sweeps the board either way", () => {
|
||||
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const victim = state.players.find((p) => p.id === "c")!;
|
||||
@@ -536,7 +535,7 @@ describe("creatures walk open doorways", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wave carries monsters (rules rev 17)", () => {
|
||||
describe("the wave carries monsters", () => {
|
||||
it("a cornered skeleton takes the waterwall crush", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
@@ -575,7 +574,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", () => {
|
||||
it("refreshes each round even with the roll-off winner dead", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
@@ -602,7 +601,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", () => {
|
||||
it("creation spends the turn, not the round", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
@@ -656,7 +655,7 @@ describe("collapsing walls crush monsters too", () => {
|
||||
throw new Error("setup: no interior wall found");
|
||||
}
|
||||
|
||||
it("rev 21: the adjacent troll takes the four points", () => {
|
||||
it("the adjacent troll takes the four points", () => {
|
||||
const state = wallRig();
|
||||
const troll = state.creatures.find((c) => c.id === "tr1");
|
||||
// Four points on a six-point troll: hurt but standing (or regenerating).
|
||||
@@ -681,7 +680,7 @@ describe("the self-stack resolves on a pass", () => {
|
||||
return { state: r.state, creator: creator.id };
|
||||
}
|
||||
|
||||
it("rev 22: passing your own monster's touch takes the claw and moves on", () => {
|
||||
it("passing your own monster's touch takes the claw and moves on", () => {
|
||||
const { state, creator } = selfTouch();
|
||||
if (!state.stack) return; // a wall between: the touch never opened
|
||||
expect(state.stack.attackerId).toBe(creator);
|
||||
@@ -693,16 +692,16 @@ describe("the self-stack resolves on a pass", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => {
|
||||
describe("fear holds off monsters and unwilling feet alike", () => {
|
||||
it("a commanded monster cannot close within three of the fearsome", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
const a = state.players.find((p) => p.id === "a")!;
|
||||
const b = state.players.find((p) => p.id === "b")!;
|
||||
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
|
||||
state.sustained.push({
|
||||
pushSustained(state, {
|
||||
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
|
||||
remainingTurns: 5, data: {},
|
||||
} as never);
|
||||
});
|
||||
b.position = { x: 2, y: 5 };
|
||||
a.position = { x: 0, y: 0 };
|
||||
state.creatures.push({
|
||||
|
||||
@@ -339,7 +339,6 @@ describe("cast modifiers", () => {
|
||||
target: { kind: "player", playerId: defender },
|
||||
});
|
||||
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" });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(d.life).toBe(19); // gained 4 instead of losing it
|
||||
@@ -430,7 +429,7 @@ describe("a held door is an open doorway to the eye", () => {
|
||||
throw new Error("setup: seed 42 grew a maze with no doors");
|
||||
}
|
||||
|
||||
it("rev 15: the holder blasts the pursuer through the doorway", () => {
|
||||
it("the holder blasts the pursuer through the held doorway", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
const pick = giveCard(state, holder, "pick-lock");
|
||||
state = must(state, holder, {
|
||||
|
||||
@@ -164,7 +164,7 @@ describe("expansion combat cards", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
||||
it("idiot forbids item handling and punches but allows counteractions", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe("expansion combat cards", () => {
|
||||
.filter((s) => stepTarget(board, d.position, s).kind === "step");
|
||||
expect(open.length).toBeGreaterThanOrEqual(2);
|
||||
// Plant the victim's own gold one step out one way; walk the other way.
|
||||
// Pre-37 steering would have overridden the request and taken the gold.
|
||||
|
||||
const toward = stepTarget(board, d.position, open[0]!);
|
||||
const away = stepTarget(board, d.position, open[1]!);
|
||||
if (toward.kind === "blocked" || away.kind === "blocked") throw new Error("unreachable");
|
||||
@@ -228,37 +228,35 @@ describe("expansion combat cards", () => {
|
||||
});
|
||||
|
||||
it("idiot permits DROP OBJECT that frees the victim's own treasure", () => {
|
||||
{
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const thief = state.players.find((p) => p.id === attacker)!;
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = attacker;
|
||||
own.position = null;
|
||||
thief.carriedTreasureId = own.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
// An attack for its own sake stays forbidden at every revision.
|
||||
const fb = giveCard(state, defender, "fireball", "F", 0);
|
||||
expect(applyCommand(state, defender, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
||||
}).ok).toBe(false);
|
||||
// Shaking YOUR OWN gold out of the thief's arms serves the march —
|
||||
// while carried it cannot be stood upon — but only from rev 37 on.
|
||||
const dr = giveCard(state, defender, "drop-object", "D", 1);
|
||||
const r = applyCommand(state, defender, {
|
||||
type: "cast", instanceId: dr.instanceId,
|
||||
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
}
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const thief = state.players.find((p) => p.id === attacker)!;
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = attacker;
|
||||
own.position = null;
|
||||
thief.carriedTreasureId = own.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
// An attack for its own sake stays forbidden.
|
||||
const fb = giveCard(state, defender, "fireball", "F", 0);
|
||||
expect(applyCommand(state, defender, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
||||
}).ok).toBe(false);
|
||||
// Shaking YOUR OWN gold out of the thief's arms serves the march —
|
||||
// while carried it cannot be stood upon.
|
||||
const dr = giveCard(state, defender, "drop-object", "D", 1);
|
||||
const r = applyCommand(state, defender, {
|
||||
type: "cast", instanceId: dr.instanceId,
|
||||
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
|
||||
it("idiot has no effect on a victim carrying their own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
@@ -500,7 +498,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", () => {
|
||||
function reflectedSwapRig(reflectorChoice?: string) {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
@@ -547,7 +545,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", () => {
|
||||
it("a victim against a wall bends the line instead of standing still", () => {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||
state = toRound2(state);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../s
|
||||
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
|
||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
|
||||
|
||||
describe("expansion terrain", () => {
|
||||
it("killer ooze burns on entry and can drop you on your face", () => {
|
||||
@@ -163,170 +163,132 @@ describe("expansion terrain", () => {
|
||||
});
|
||||
|
||||
it("wall of fire takes a rim warp — both mouths burn the crossing", () => {
|
||||
{
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const wof = giveCard(state, me.id, "wall-of-fire", "WF", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: wof.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
||||
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
||||
expect(boardView(state).edges[nearKey]).toBe("firewall");
|
||||
expect(boardView(state).edges[farKey]).toBe("firewall");
|
||||
// The corridor still runs — through flame: the crossing lands on the
|
||||
// far rim and burns for 4.
|
||||
state = must(state, me.id, { type: "move", direction: warp.from.side });
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
||||
expect(after.life).toBe(11);
|
||||
}
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const wof = giveCard(state, me.id, "wall-of-fire", "WF", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: wof.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
const nearKey = edgeKey(warp.from.cell, warp.from.side);
|
||||
const farKey = edgeKey(warp.to.cell, warp.to.side);
|
||||
expect(boardView(state).edges[nearKey]).toBe("firewall");
|
||||
expect(boardView(state).edges[farKey]).toBe("firewall");
|
||||
// The corridor still runs — through flame: the crossing lands on the
|
||||
// far rim and burns for 4.
|
||||
state = must(state, me.id, { type: "move", direction: warp.from.side });
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
|
||||
expect(after.life).toBe(11);
|
||||
});
|
||||
|
||||
it("waterwall takes a rim warp — the collapse washes both rims", () => {
|
||||
{
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
other.position = { ...warp.to.cell };
|
||||
const ww = giveCard(state, me.id, "waterwall", "WW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: ww.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Both wizards stood in the mouths; the collapse washed both inward.
|
||||
const meAfter = r.state.players.find((p) => p.id === me.id)!;
|
||||
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
|
||||
expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell));
|
||||
expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell));
|
||||
}
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
other.position = { ...warp.to.cell };
|
||||
const ww = giveCard(state, me.id, "waterwall", "WW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: ww.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
// Both wizards stood in the mouths; the collapse washed both inward.
|
||||
const meAfter = r.state.players.find((p) => p.id === me.id)!;
|
||||
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
|
||||
expect(cellKey(meAfter.position)).not.toBe(cellKey(warp.from.cell));
|
||||
expect(cellKey(otherAfter.position)).not.toBe(cellKey(warp.to.cell));
|
||||
});
|
||||
|
||||
it("illusion wall hangs on a rim warp mouth", () => {
|
||||
{
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const il = giveCard(state, me.id, "illusion-wall", "IL", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: il.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
|
||||
}
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const warp = state.board.warps[0]!;
|
||||
me.position = { ...warp.from.cell };
|
||||
const il = giveCard(state, me.id, "illusion-wall", "IL", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: il.instanceId,
|
||||
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("stone to water breaches the outer rim, opening a warp", () => {
|
||||
{
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
// Stand at a plain rim wall (an off-board edge that is no warp mouth).
|
||||
let rim: { cell: Cell; side: Side } | null = null;
|
||||
outer: for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (board.cells[cellKey(n)]) continue;
|
||||
if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue;
|
||||
if (state.board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
|
||||
rim = { cell: { x, y }, side };
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
if (!rim) throw new Error("setup: no plain rim wall found");
|
||||
me.position = { ...rim.cell };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
// The far rim melted with it and the wraparound now runs.
|
||||
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
||||
}
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
// The far rim melted with it and the wraparound now runs.
|
||||
expect(state.board.warps.length).toBe(warpsBefore + 2);
|
||||
});
|
||||
|
||||
it("no doors through the rim", () => {
|
||||
{
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
let rim: { cell: Cell; side: Side } | null = null;
|
||||
outer: for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (!board.cells[cellKey(n)] && board.edges[edgeKey({ x, y }, side)] === "wall") {
|
||||
rim = { cell: { x, y }, side };
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!rim) throw new Error("setup: no rim wall found");
|
||||
me.position = { ...rim.cell };
|
||||
const cd = giveCard(state, me.id, "create-door", "CD", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: cd.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
}
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const cd = giveCard(state, me.id, "create-door", "CD", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: cd.instanceId,
|
||||
target: { kind: "edge", cell: rim.cell, side: rim.side },
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("stone to water melts a door — a small entryway in a stone wall", () => {
|
||||
{
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
// Stand the caster at some door and melt it point-blank.
|
||||
let doorAt: { cell: Cell; side: Side } | null = null;
|
||||
outer: for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
if (board.edges[edgeKey({ x, y }, side)] === "door") {
|
||||
doorAt = { cell: { x, y }, side };
|
||||
break outer;
|
||||
}
|
||||
const { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const board = boardView(state);
|
||||
// Stand the caster at some door and melt it point-blank.
|
||||
let doorAt: { cell: Cell; side: Side } | null = null;
|
||||
outer: for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
if (board.edges[edgeKey({ x, y }, side)] === "door") {
|
||||
doorAt = { cell: { x, y }, side };
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
if (!doorAt) throw new Error("setup: no door on this board");
|
||||
me.position = { ...doorAt.cell };
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const key = edgeKey(doorAt.cell, doorAt.side);
|
||||
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
|
||||
expect(r.state.doorStates[key]).toBeUndefined();
|
||||
}
|
||||
}
|
||||
if (!doorAt) throw new Error("setup: no door on this board");
|
||||
me.position = { ...doorAt.cell };
|
||||
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: stw.instanceId,
|
||||
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const key = edgeKey(doorAt.cell, doorAt.side);
|
||||
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
|
||||
expect(r.state.doorStates[key]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,7 +227,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", () => {
|
||||
it("the carried treasure lands where the wizard stood", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
|
||||
const A = state.players.find((p) => p.id === "a")!;
|
||||
@@ -265,7 +265,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", () => {
|
||||
function grabRig() {
|
||||
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
|
||||
const thief = state.players.find((p) => p.id === "thief")!;
|
||||
@@ -306,15 +306,15 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
|
||||
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("arming is refused in this vintage", () => {
|
||||
it("arming ahead is refused — the ward waits for the grab", () => {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
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" });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an ambushed teleport carries its destination (rules rev 35)", () => {
|
||||
describe("an ambushed teleport carries its destination", () => {
|
||||
it("the trap springs and the victim lands where the trapper said", () => {
|
||||
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++) {
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import type { SustainedEffect } from "../src/game";
|
||||
|
||||
export function newGame(seed = 42, players: PlayerId[] = ["alice", "bob"]) {
|
||||
return createGame({ playerIds: players, seed, sets: ["basic"] });
|
||||
@@ -76,3 +77,26 @@ export function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; sid
|
||||
}
|
||||
throw new Error("no empty neighbor");
|
||||
}
|
||||
|
||||
/** Some off-board edge that is a bare stone wall — no warp behind it. */
|
||||
export function plainRimWall(state: GameState): { cell: Cell; side: Side } {
|
||||
const board = boardView(state);
|
||||
for (const k of Object.keys(board.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
if (board.cells[cellKey(neighbor({ x, y }, side))]) continue;
|
||||
if (board.edges[edgeKey({ x, y }, side)] !== "wall") continue;
|
||||
if (board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
|
||||
return { cell: { x, y }, side };
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no plain rim wall on this board");
|
||||
}
|
||||
|
||||
/** Rig a duration spell directly, sparing the cast ceremony. */
|
||||
export function pushSustained(
|
||||
state: GameState,
|
||||
fx: Omit<SustainedEffect, "data"> & { data?: SustainedEffect["data"] },
|
||||
): void {
|
||||
state.sustained.push({ data: {}, ...fx });
|
||||
}
|
||||
|
||||
@@ -327,7 +327,6 @@ describe("6e card-face corrections", () => {
|
||||
params: { damage: 4, knockback: 0 },
|
||||
});
|
||||
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" });
|
||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
|
||||
|
||||
@@ -343,14 +342,14 @@ describe("6e card-face corrections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("relocation past the origin (rules rev 11)", () => {
|
||||
function rev11Game() {
|
||||
describe("relocation past the origin", () => {
|
||||
function freshGame() {
|
||||
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
||||
return state;
|
||||
}
|
||||
|
||||
it("a sector may land at negative coordinates — the maze renormalizes", () => {
|
||||
let state = rev11Game();
|
||||
let state = freshGame();
|
||||
const me = activePlayer(state);
|
||||
const other = state.players.find((p) => p.id !== me.id)!;
|
||||
const idx = state.board.placements.findIndex(
|
||||
@@ -384,8 +383,8 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("older revisions still refuse the negative landing", () => {
|
||||
let { state } = newGame(); // helper default: rev-ungated (1)
|
||||
it("a diagonal landing breaks adjacency and is refused", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
@@ -401,7 +400,7 @@ describe("relocation past the origin (rules rev 11)", () => {
|
||||
});
|
||||
|
||||
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
|
||||
let state = rev11Game();
|
||||
let state = freshGame();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
@@ -444,7 +443,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", () => {
|
||||
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"] });
|
||||
let state = toRound2(fresh);
|
||||
@@ -509,7 +508,7 @@ describe("sight tracing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("illusions are tested by choice (rules rev 29)", () => {
|
||||
describe("illusions are tested by choice", () => {
|
||||
function shimmerRig() {
|
||||
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
state = toRound2(state);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, plainRimWall } from "./helpers";
|
||||
|
||||
describe("magic wands", () => {
|
||||
it("blaster wand: charges on first use, once per turn, discards when spent", () => {
|
||||
@@ -143,27 +143,12 @@ describe("magic wands", () => {
|
||||
expect(boardView(state).edges[key]).toBe("wall");
|
||||
});
|
||||
|
||||
it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => {
|
||||
it("warp wand bores the rim: a wraparound that closes at turn's end", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
||||
});
|
||||
const me = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
// A plain rim wall: off-board neighbor, no existing warp mouth.
|
||||
let rim: { cell: Cell; side: Side } | null = null;
|
||||
outer: for (const k of Object.keys(view.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
for (const side of SIDES) {
|
||||
const dest = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||
y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (view.cells[cellKey(dest)]) continue;
|
||||
if (view.edges[edgeKey({ x, y }, side)] !== "wall") continue;
|
||||
if (state.board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
|
||||
rim = { cell: { x, y }, side };
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
if (!rim) throw new Error("setup: no plain rim wall found");
|
||||
const rim = plainRimWall(state);
|
||||
me.position = { ...rim.cell };
|
||||
const warpsBefore = state.board.warps.length;
|
||||
const wand = giveCard(state, me.id, "warp-wand");
|
||||
|
||||
Reference in New Issue
Block a user