Expansion wave 4: 27 combat, curse, and utility cards
Fortune: GIFT FROM ABOVE (+3, no ceiling), GIFT FROM BELOW (a trap in the deck — 3 damage on the draw, harmless in the opening deal). Modifiers: POWER ATTACK burns life into any damage spell. Curses: WEAKNESS (drop your treasure, take double, carry nothing), STRENGTH (double physical dealt; the two cancel), WALKING DEAD (half a point per space walked, forever), DISEASE (the victim becomes a carrier who infects everyone in squares they enter), IDIOT (the victim shambles toward their nearest own treasure, able only to counteract, until they stand on it and ask "What am I doing here?"). Defense: EMPATHY (attacks bite their caster too), FORCE FIELD (spell-stopping counteraction). Mischief: MENTAL SWAP (trade whole hands), MENTAL FORCE (march someone three spaces), BUTT-HEAD (become a goat, ram for the distance charged), HEAVE-HO (throw your carried treasure as a weapon), THIEF and SWAP-MEET (item larceny), CHAOS (all hands in a pile, shuffled, redealt), ILLUSIONARY ATTACK (a fake spell that hurts if believed), WARD (a trapped treasure bites its thief), REMOVE CURSE (strip any duration spell, rolling to hit the shrunken or invisible), SWARTHMORE'S ENCHANTMENT (+1 on an enchanted object). Space-time: DIMENSIONAL WARP (paired step-through tokens), REDIRECTION (swap two outer exits' wraparounds), BIG MAN (fills the corridor: no entry, no punches, no casting past him), FEAR (nobody approaches within 3), and the out-of-turn pair — INTERRUPT and OPPORTUNITY FIRE — which open a one-action window in another player's turn. Only THUMB OF GOD remains, awaiting its digital redesign. 117 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3a35922a40
commit
06eea72ded
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCommand,
|
||||
activePlayer,
|
||||
createGame,
|
||||
sustainedOn,
|
||||
type Command,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { cellKey } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
|
||||
function newGame(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
|
||||
}
|
||||
|
||||
function must(state: GameState, player: PlayerId, command: Command): GameState {
|
||||
const result = applyCommand(state, player, command);
|
||||
if (!result.ok) throw new Error(`command failed: ${result.error}`);
|
||||
return result.state;
|
||||
}
|
||||
|
||||
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
||||
const p = state.players.find((p) => p.id === playerId)!;
|
||||
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
||||
p.hand[slot] = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
function toRound2(state: GameState): GameState {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
return state;
|
||||
}
|
||||
|
||||
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
defender.position = { ...attacker.position };
|
||||
return { attacker: attacker.id, defender: defender.id };
|
||||
}
|
||||
|
||||
function castAt(
|
||||
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
|
||||
extra: Partial<Extract<Command, { type: "cast" }>> = {},
|
||||
): GameState {
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: card.instanceId,
|
||||
target: { kind: "player", playerId: defender }, ...extra,
|
||||
});
|
||||
return must(state, defender, { type: "pass" });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (steps === 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user