The card's line bends: a victim shoved against a wall moved zero squares and just lost the turn, because GO AWAY borrowed waterbolt's straight-line knockback. Its own rout now holds the heading while open, bends around walls, sidesteps when no ground can be gained, rolls the D4 only when options tie, and stops early only when truly cornered. Waterbolt's physical shove stays straight, as a shove should. Ledgers verified before deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
527 lines
26 KiB
TypeScript
527 lines
26 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
|
import { cellKey, edgeKey, stepTarget } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
|
|
|
describe("expansion combat cards", () => {
|
|
it("power attack burns life for extra damage", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
giveCard(state, attacker, "power-attack", "PA", 1);
|
|
state = castAt(state, attacker, defender, fb, {
|
|
powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 3,
|
|
});
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(7); // 5+3
|
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
|
|
});
|
|
|
|
it("weakness doubles damage taken and forbids carrying treasure", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const wk = giveCard(state, attacker, "weakness");
|
|
giveCard(state, attacker, "number-3", "N", 1);
|
|
state = castAt(state, attacker, defender, wk, { numberInstanceIds: ["number-3#N"] });
|
|
expect(sustainedOn(state, defender, "weakness").length).toBe(1);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
const t = state.treasures.find((t) => t.owner === attacker && t.position)!;
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.position = { ...t.position! };
|
|
expect(applyCommand(state, defender, { type: "pickUpTreasure" }).ok).toBe(false);
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
|
const d2 = state.players.find((p) => p.id === defender)!;
|
|
d2.position = { ...state.players.find((p) => p.id === attacker)!.position };
|
|
state = castAt(state, attacker, defender, fb);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5x2
|
|
});
|
|
|
|
it("walking dead bleeds half a point per space walked", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const wd = giveCard(state, attacker, "walking-dead");
|
|
state = castAt(state, attacker, defender, wd);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
// Defender walks: every second step costs a point.
|
|
let lifeStart = state.players.find((p) => p.id === defender)!.life;
|
|
let steps = 0;
|
|
for (const dir of ["N", "S", "E", "W", "N", "S"] as const) {
|
|
const r = applyCommand(state, defender, { type: "move", direction: dir });
|
|
if (r.ok) { state = r.state; steps++; }
|
|
if (steps === 2) break;
|
|
}
|
|
expect(steps).toBe(2);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
|
|
});
|
|
|
|
it("mental swap trades entire hands", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const ms = giveCard(state, attacker, "mental-swap");
|
|
const aCards = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
|
|
const dCards = state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId);
|
|
state = castAt(state, attacker, defender, ms);
|
|
const aAfter = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
|
|
expect(aAfter).toEqual(dCards);
|
|
// (the swap card itself was consumed from the attacker's hand pre-swap)
|
|
expect(state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId))
|
|
.toEqual(aCards.filter((id) => id !== ms.instanceId));
|
|
});
|
|
|
|
it("butt-head rams for the distance charged", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
// Stand them 3 apart on the same column if possible; else same square +N.
|
|
defender.position = { x: attacker.position.x, y: attacker.position.y >= 3 ? attacker.position.y - 3 : attacker.position.y + 3 };
|
|
const bh = giveCard(state, attacker.id, "butt-head");
|
|
state = castAt(state, attacker.id, defender.id, bh);
|
|
const a = state.players.find((p) => p.id === attacker.id)!;
|
|
const d = state.players.find((p) => p.id === defender.id)!;
|
|
expect(cellKey(a.position)).toBe(cellKey(d.position));
|
|
expect(d.life).toBe(12); // 3 spaces = 3 damage
|
|
});
|
|
|
|
it("empathy turns an attack back on its caster as well", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
// Defender raises empathy on their own turn.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
const em = giveCard(state, defender, "empathy", "E", 0);
|
|
giveCard(state, defender, "number-3", "N", 1);
|
|
state = must(state, defender, {
|
|
type: "cast", instanceId: em.instanceId, numberInstanceIds: ["number-3#N"],
|
|
});
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
|
state = castAt(state, attacker, defender, fb);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
|
});
|
|
|
|
it("ward springs when a trapped treasure is grabbed", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
|
giveCard(state, enemy.id, "ward", "W", 0);
|
|
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
|
|
me.position = { ...treasure.position! };
|
|
state = must(state, me.id, { type: "pickUpTreasure" });
|
|
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);
|
|
});
|
|
|
|
it("opportunity fire opens an out-of-turn attack window", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const active = activePlayer(state).id;
|
|
const lurker = state.players.find((p) => p.id !== active)!;
|
|
lurker.position = { ...state.players.find((p) => p.id === active)!.position };
|
|
const of_ = giveCard(state, lurker.id, "opportunity-fire", "OF", 0);
|
|
const fb = giveCard(state, lurker.id, "fireball", "F", 1);
|
|
// Out of turn: play opportunity fire, then the attack.
|
|
state = must(state, lurker.id, { type: "cast", instanceId: of_.instanceId });
|
|
expect(state.outOfTurnWindow?.playerId).toBe(lurker.id);
|
|
state = must(state, lurker.id, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: active },
|
|
});
|
|
state = must(state, active, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === active)!.life).toBe(10);
|
|
// Turn structure is intact: the original player is still active.
|
|
expect(activePlayer(state).id).toBe(active);
|
|
expect(state.outOfTurnWindow).toBeNull();
|
|
});
|
|
|
|
it("idiot marches its victim toward their own treasure and forbids casting", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const id = giveCard(state, attacker, "idiot");
|
|
state = castAt(state, attacker, defender, id);
|
|
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
// Casting is refused; moving is steered (any direction request works).
|
|
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);
|
|
const before = state.players.find((p) => p.id === defender)!.position;
|
|
const r = applyCommand(state, defender, { type: "move", direction: "N" });
|
|
if (r.ok) {
|
|
const after = r.state.players.find((p) => p.id === defender)!.position;
|
|
expect(cellKey(after)).not.toBe(cellKey(before));
|
|
} else {
|
|
expect(r.error).toMatch(/blocked/);
|
|
}
|
|
});
|
|
|
|
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
|
let { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
|
});
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
// The victim carries an OPPONENT'S treasure into the spell.
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
|
stolen.carriedBy = defender;
|
|
stolen.position = null;
|
|
d.carriedTreasureId = stolen.id;
|
|
const id = giveCard(state, attacker, "idiot");
|
|
state = castAt(state, attacker, defender, id);
|
|
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
|
|
// No dropping the carried treasure (no capturing it on your home, either).
|
|
const drop = applyCommand(state, defender, { type: "dropTreasure" });
|
|
expect(drop.ok).toBe(false);
|
|
if (!drop.ok) expect(drop.error).toMatch(/treasure/);
|
|
// No punching the tormentor.
|
|
expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false);
|
|
// Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall").
|
|
const sp = giveCard(state, defender, "speed", "SP", 0);
|
|
expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true);
|
|
// Counteractions are expressly allowed: absorb an incoming fireball.
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
|
});
|
|
giveCard(state, defender, "absorb-spell", "AB", 1);
|
|
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
|
|
});
|
|
|
|
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
|
|
let { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
|
});
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const own = state.treasures.find((t) => t.owner === defender)!;
|
|
own.carriedBy = defender;
|
|
own.position = null;
|
|
d.carriedTreasureId = own.id;
|
|
const id = giveCard(state, attacker, "idiot");
|
|
state = castAt(state, attacker, defender, id);
|
|
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("swap home bases", () => {
|
|
it("trades homes when both bases hold equal treasures", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const other = state.players.find((p) => p.id !== me.id)!;
|
|
other.position = { ...me.position }; // LOS guaranteed
|
|
const myHome = { ...me.home };
|
|
const theirHome = { ...other.home };
|
|
const shb = giveCard(state, me.id, "swap-home-bases");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: shb.instanceId, target: { kind: "player", playerId: other.id },
|
|
});
|
|
expect(cellKey(state.players.find((p) => p.id === me.id)!.home)).toBe(cellKey(theirHome));
|
|
expect(cellKey(state.players.find((p) => p.id === other.id)!.home)).toBe(cellKey(myHome));
|
|
});
|
|
});
|
|
|
|
describe("thumb of god (divine meteor)", () => {
|
|
it("scatters every token near where the die lands", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state);
|
|
// Sprinkle the blast zone: a ground object and the enemy nearby.
|
|
const enemy = state.players.find((p) => p.id !== me.id)!;
|
|
enemy.position = { ...me.position };
|
|
state.groundObjects[cellKey(me.position)] = [{ instanceId: "dagger#G", cardId: "dagger" }];
|
|
const tog = giveCard(state, me.id, "thumb-of-god");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: tog.instanceId, target: { kind: "cell", cell: me.position },
|
|
});
|
|
expect(state.turn.attackUsed).toBe(true);
|
|
// The dagger moved somewhere on the board.
|
|
const allObjects = Object.values(state.groundObjects).flat();
|
|
expect(allObjects.some((c) => c.cardId === "dagger")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("add for movement", () => {
|
|
it("an ADD permits a second number card on movement, once", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state).id;
|
|
giveCard(state, me, "number-3", "N1", 0);
|
|
giveCard(state, me, "number-2", "N2", 1);
|
|
giveCard(state, me, "add", "A", 2);
|
|
giveCard(state, me, "number-4", "N3", 3);
|
|
state = must(state, me, { type: "playNumberForMovement", instanceId: "number-3#N1" });
|
|
expect(state.turn.movementAllowance).toBe(6);
|
|
// Second without ADD: refused.
|
|
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2" }).ok).toBe(false);
|
|
// With ADD: allowed.
|
|
state = must(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2", addInstanceId: "add#A" });
|
|
expect(state.turn.movementAllowance).toBe(8);
|
|
// A third is refused even with another add wished for.
|
|
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-4#N3" }).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("ambushes (async interrupts)", () => {
|
|
it("an armed Opportunity Fire springs when prey walks into sight", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const owner = activePlayer(state);
|
|
const of_ = giveCard(state, owner.id, "opportunity-fire", "OF", 0);
|
|
const fb = giveCard(state, owner.id, "fireball", "FB", 1);
|
|
state = must(state, owner.id, {
|
|
type: "setAmbush", instanceId: of_.instanceId, trigger: { kind: "los" },
|
|
spellInstanceId: fb.instanceId,
|
|
});
|
|
const o = state.players.find((p) => p.id === owner.id)!;
|
|
expect(o.hand.some((c) => c.cardId === "fireball")).toBe(false);
|
|
expect(state.ambushes.length).toBe(1);
|
|
state = must(state, owner.id, { type: "endTurn", draw: 0 });
|
|
|
|
const preyNow = state.players.find((p) => p.id !== owner.id)!;
|
|
const ownerNow = state.players.find((p) => p.id === owner.id)!;
|
|
// Find a step that goes from a no-LOS cell into a LOS cell.
|
|
let found: { from: { x: number; y: number }; side: "N" | "S" | "E" | "W" } | null = null;
|
|
outer: for (const key of Object.keys(state.board.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
const cell = { x, y };
|
|
if (!gameLos(state, ownerNow.position, cell)) continue;
|
|
for (const side of ["N", "S", "E", "W"] as const) {
|
|
const dx = side === "E" ? 1 : side === "W" ? -1 : 0;
|
|
const dy = side === "S" ? 1 : side === "N" ? -1 : 0;
|
|
const from = { x: x - dx, y: y - dy };
|
|
if (!state.board.cells[cellKey(from)]) continue;
|
|
if (gameLos(state, ownerNow.position, from)) continue;
|
|
const st = stepTarget(boardView(state), from, side);
|
|
if (st.kind === "step" && cellKey(st.to) === key) { found = { from, side }; break outer; }
|
|
}
|
|
}
|
|
expect(found).not.toBeNull();
|
|
preyNow.position = found!.from;
|
|
state = must(state, preyNow.id, { type: "move", direction: found!.side });
|
|
expect(state.stack).not.toBeNull();
|
|
expect(state.stack!.attackerId).toBe(owner.id);
|
|
expect(state.stack!.attackCard!.cardId).toBe("fireball");
|
|
expect(state.ambushes.length).toBe(0);
|
|
state = must(state, preyNow.id, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === preyNow.id)!.life).toBe(10);
|
|
});
|
|
|
|
it("cancelling an ambush returns the committed cards", () => {
|
|
let { state } = newGame();
|
|
const owner = activePlayer(state);
|
|
const int_ = giveCard(state, owner.id, "interrupt", "I", 0);
|
|
const lb = giveCard(state, owner.id, "lightning-blast", "LB", 1);
|
|
giveCard(state, owner.id, "number-3", "N", 2);
|
|
state = must(state, owner.id, {
|
|
type: "setAmbush", instanceId: int_.instanceId, trigger: { kind: "near" },
|
|
spellInstanceId: lb.instanceId, numberInstanceIds: ["number-3#N"],
|
|
});
|
|
const id = state.ambushes[0]!.id;
|
|
state = must(state, owner.id, { type: "cancelAmbush", ambushId: id });
|
|
const o = state.players.find((p) => p.id === owner.id)!;
|
|
expect(o.hand.some((c) => c.cardId === "interrupt")).toBe(true);
|
|
expect(o.hand.some((c) => c.cardId === "lightning-blast")).toBe(true);
|
|
expect(state.ambushes.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("swap meet trades carried items (rules rev 26)", () => {
|
|
function rig(deckRev: number) {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const sm = giveCard(state, attacker, "swap-meet");
|
|
giveCard(state, attacker, "dagger", "D", 1);
|
|
giveCard(state, defender, "blaster-wand", "W", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: sm.instanceId,
|
|
target: { kind: "player", playerId: defender },
|
|
params: { cardId: "dagger;blaster-wand" },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
return { state, attacker, defender };
|
|
}
|
|
|
|
it("a dagger trades for a wand — both are carried items", () => {
|
|
const { state, attacker, defender } = rig(26);
|
|
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);
|
|
});
|
|
|
|
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", () => {
|
|
function treasureRig(theirsToken: string, mineToken: string) {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
// The defender hauls one of the attacker's own treasures.
|
|
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
|
stolen.position = null; stolen.carriedBy = defender; d.carriedTreasureId = stolen.id;
|
|
const sm = giveCard(state, attacker, "swap-meet");
|
|
giveCard(state, attacker, "dagger", "D", 1);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: sm.instanceId,
|
|
target: { kind: "player", playerId: defender },
|
|
params: { cardId: `${mineToken};${theirsToken}` },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
return { state, attacker, defender, a, d, stolen };
|
|
}
|
|
|
|
it("a dagger buys back the treasure in the thief's arms", () => {
|
|
const { state, attacker, defender } = treasureRig("treasure", "dagger");
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const t = state.treasures.find((tr) => tr.owner === attacker && tr.carriedBy)!;
|
|
expect(a.carriedTreasureId).toBe(t.id);
|
|
expect(t.carriedBy).toBe(attacker);
|
|
expect(d.carriedTreasureId).toBeNull();
|
|
expect(d.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
|
});
|
|
|
|
it("the trade refuses to overload full arms", () => {
|
|
// The attacker also carries a treasure: claiming theirs with a dagger
|
|
// would mean two in hand — the swap quietly cannot happen.
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const t1 = state.treasures.find((t) => t.owner === attacker)!;
|
|
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
|
|
const t2 = state.treasures.find((t) => t.owner === defender)!;
|
|
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
|
|
const sm = giveCard(state, attacker, "swap-meet");
|
|
giveCard(state, attacker, "dagger", "D", 1);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: sm.instanceId,
|
|
target: { kind: "player", playerId: defender },
|
|
params: { cardId: "dagger;treasure" },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t1.id);
|
|
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t2.id);
|
|
});
|
|
|
|
it("treasure for treasure trades both armfuls", () => {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
const t1 = state.treasures.find((t) => t.owner === attacker)!;
|
|
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
|
|
const t2 = state.treasures.find((t) => t.owner === defender)!;
|
|
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
|
|
const sm = giveCard(state, attacker, "swap-meet");
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: sm.instanceId,
|
|
target: { kind: "player", playerId: defender },
|
|
params: { cardId: "treasure;treasure" },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t2.id);
|
|
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t1.id);
|
|
expect(state.treasures.find((t) => t.id === t1.id)!.carriedBy).toBe(defender);
|
|
expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker);
|
|
});
|
|
});
|
|
|
|
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => {
|
|
function reflectedSwapRig(reflectorChoice?: string) {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 });
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
// The caster carries a treasure and offers a dagger; the reflector may
|
|
// instead demand the trade of their own choosing.
|
|
const t = state.treasures.find((t) => t.owner === attacker)!;
|
|
t.position = null; t.carriedBy = attacker; a.carriedTreasureId = t.id;
|
|
const sm = giveCard(state, attacker, "swap-meet");
|
|
giveCard(state, attacker, "dagger", "D", 1);
|
|
giveCard(state, defender, "full-reflection", "FR", 0);
|
|
giveCard(state, defender, "large-rock", "R", 1);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: sm.instanceId,
|
|
target: { kind: "player", playerId: defender },
|
|
params: { cardId: "dagger;large-rock" },
|
|
});
|
|
state = must(state, defender, {
|
|
type: "counteract", instanceId: "full-reflection#FR",
|
|
...(reflectorChoice ? { params: { cardId: reflectorChoice } } : {}),
|
|
});
|
|
state = must(state, attacker, { type: "pass" });
|
|
return { state, attacker, defender, treasureId: t.id };
|
|
}
|
|
|
|
it("the reflector rewrites the trade: their rock for the caster's treasure", () => {
|
|
const { state, attacker, defender, treasureId } = reflectedSwapRig("large-rock;treasure");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(d.carriedTreasureId).toBe(treasureId);
|
|
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
|
|
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBeNull();
|
|
});
|
|
|
|
it("the reflector may decline: 'none' trades nothing", () => {
|
|
const { state, attacker, defender, treasureId } = reflectedSwapRig("none");
|
|
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(treasureId);
|
|
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
|
|
});
|
|
|
|
it("a bare full reflection with no choice trades nothing", () => {
|
|
const { state, attacker } = reflectedSwapRig(undefined);
|
|
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("go away routs around walls (rules rev 36)", () => {
|
|
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 });
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
// Attacker west of the victim, a wall hard against the victim's east:
|
|
// the straight line away is blocked from the first step.
|
|
a.position = { x: 1, y: 5 };
|
|
d.position = { x: 2, y: 5 };
|
|
state.edgeOverrides[edgeKey({ x: 1, y: 5 }, "E")] = "open";
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "E")] = "wall";
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "N")] = "open";
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "S")] = "open";
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "N")] = "open";
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 6 }, "S")] = "open";
|
|
const ga = giveCard(state, attacker, "go-away");
|
|
giveCard(state, attacker, "number-3", "N3", 1);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: ga.instanceId, numberInstanceIds: ["number-3#N3"],
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
const dist = Math.abs(after.position.x - 1) + Math.abs(after.position.y - 5);
|
|
// Three routed spaces: strictly farther than where they stood.
|
|
expect(dist).toBeGreaterThanOrEqual(3);
|
|
expect(after.lostTurns).toBe(1);
|
|
});
|
|
});
|