Implement casting layer: attack stack, counteraction chain, first effects

The attack/counteraction stack: casting an attack (or punching) opens a
stack; the defender counteracts or passes, the attacker may respond
(ANTI-ANTI nullifies a counter), and resolution runs the damage
pipeline in play order. First effect wave, encoded from verified card
text: Fireball (flat 5 + destroys carried magic stones if damage gets
through), Lightning Blast (number damage + stun unless fully stopped),
Powerthrust (2 + optional number), Waterbolt (caster-chosen
damage/knockback split with push-away movement), Blunt (halve, round
result up), Absorb (-3), Full Shield (spell-only stop), Reflection
(half to both), Full Reflection (redirect), Absorb Spell (nullify and
steal the attack card), Create/Destroy Wall (dynamic edge overrides
layered over the board; collapse deals 4 to adjacent squares), Speed
(extra turn), and TRAP! on draw (lose next turn, redraw). Lost turns
skip on advance; unimplemented cards refuse to cast with a clear
error. 35 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 19:47:06 -04:00
co-authored by Claude Fable 5
parent a8884592a4
commit 7c607ad5c7
4 changed files with 974 additions and 70 deletions
+6
View File
@@ -21,6 +21,7 @@ export interface CardDef {
name: string;
set: CardSet;
cardType: CardType | null;
subtypes?: string[];
los: boolean | null;
text: string | null;
quantity: number | null;
@@ -84,3 +85,8 @@ export function numberValue(cardId: string): number {
export function isTrap(cardId: string): boolean {
return cardId === "trap";
}
/** Magic stones (Powerstone, Shieldstone, ...) — destroyed by Fireball. */
export function isMagicStone(cardId: string): boolean {
return cardDef(cardId).subtypes?.includes("stone") ?? false;
}
+654 -55
View File
@@ -6,20 +6,32 @@
// where, via what) so that replays — including the planned first-person
// wizard's-eye renderings — can reconstruct scenes without re-deriving them.
//
// Implemented in this core: setup/deal, movement (3 + one number card, warps),
// punching, damage/death, treasure stealing and both victory conditions, hand
// management (draw up to 2 at end of turn, 7-card limit, dead player's cards
// to the killer). Spell casting and the counteraction stack are the next
// layer and hook in at the marked extension points.
// Layers implemented: setup/deal, movement, punching, the casting stack
// (attack -> counteraction chain -> resolution), a first wave of card
// effects, dynamic walls, treasures, and both victory conditions. Remaining
// card effects register in CARD_EFFECTS as they are implemented.
import {
type AssembledBoard,
type Cell,
type EdgeState,
type Side,
cellKey,
edgeKey,
hasLineOfSight,
neighbor,
stepTarget,
} from "./board";
import { buildDeck, isNumberCard, isTrap, numberValue, type CardInstance, type CardSet } from "./cards";
import {
buildDeck,
cardDef,
isMagicStone,
isNumberCard,
isTrap,
numberValue,
type CardInstance,
type CardSet,
} from "./cards";
import { createRng, rollDie, shuffle, type RngState } from "./rng";
import { setupBoard } from "./setups";
@@ -49,6 +61,10 @@ export interface PlayerState {
alive: boolean;
hand: CardInstance[];
carriedTreasureId: string | null;
/** Turns to skip (Lightning Blast stun, TRAP!, ...). */
lostTurns: number;
/** Extra turns granted (SPEED). */
extraTurns: number;
}
export interface TurnState {
@@ -65,6 +81,22 @@ export interface TurnState {
actionsEnded: boolean;
}
/** The attack-in-flight: attacker declared, defender may counteract. */
export interface CastStack {
attackerId: PlayerId;
defenderId: PlayerId;
/** null = a punch (physical attack with no card). */
attackCard: CardInstance | null;
/** Value of the number card played with the attack, if any. */
numberValue: number | null;
/** Waterbolt's caster-chosen split; damage + knockback = number value. */
params: { damage?: number; knockback?: number } | null;
kind: "spell" | "physical";
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
/** Whose response we await. Resolution happens when the defender passes. */
waitingOn: PlayerId;
}
export interface GameConfig {
playerIds: PlayerId[];
seed: number;
@@ -75,17 +107,26 @@ export interface GameState {
config: GameConfig;
phase: "playing" | "finished";
board: AssembledBoard;
/** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */
edgeOverrides: Record<string, EdgeState>;
players: PlayerState[];
treasures: TreasureState[];
deck: CardInstance[];
discard: CardInstance[];
turn: TurnState;
stack: CastStack | null;
rng: RngState;
winner: PlayerId | null;
/** Set when a player must discard down to HAND_LIMIT before play continues. */
pendingDiscard: PlayerId | null;
}
/** The board with dynamic wall changes applied — use for movement and LOS. */
export function boardView(state: GameState): AssembledBoard {
if (Object.keys(state.edgeOverrides).length === 0) return state.board;
return { ...state.board, edges: { ...state.board.edges, ...state.edgeOverrides } };
}
// ---------------------------------------------------------------------------
// Events
@@ -95,10 +136,24 @@ export type GameEvent =
| { type: "cardsDealtPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
| { type: "trapRedrawnDuringDeal"; player: PlayerId }
| { type: "turnStarted"; player: PlayerId; round: number }
| { type: "turnSkipped"; player: PlayerId; reason: "lostTurn" }
| { type: "extraTurnStarted"; player: PlayerId }
| { type: "moved"; player: PlayerId; from: Cell; to: Cell; direction: Side; via: "step" | "warp" }
| { type: "numberPlayedForMovement"; player: PlayerId; card: CardInstance; value: number; newAllowance: number }
| { type: "punched"; attacker: PlayerId; target: PlayerId; at: Cell }
| { type: "spellCast"; caster: PlayerId; card: CardInstance; cardId: string; numberCard: CardInstance | null; numberValue: number | null; from: Cell; target: PlayerId | null; targetCell: Cell | null }
| { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string }
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number }
| { type: "stunned"; player: PlayerId; turnsLost: number }
| { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number }
| { type: "stonesDestroyed"; player: PlayerId; cards: CardInstance[] }
| { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "extraTurnGranted"; player: PlayerId }
| { type: "trapSprung"; player: PlayerId }
| { type: "died"; player: PlayerId; killedBy: PlayerId | null }
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
| { type: "handTakenPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
@@ -121,10 +176,17 @@ export function redactEvent(event: GameEvent, viewer: PlayerId): GameEvent | nul
// ---------------------------------------------------------------------------
// Commands
export type CastTarget =
| { kind: "player"; playerId: PlayerId }
| { kind: "edge"; cell: Cell; side: Side };
export type Command =
| { type: "move"; direction: Side }
| { type: "playNumberForMovement"; instanceId: string }
| { type: "punch"; targetId: PlayerId }
| { type: "cast"; instanceId: string; numberInstanceId?: string; target?: CastTarget; params?: { damage?: number; knockback?: number } }
| { type: "counteract"; instanceId: string }
| { type: "pass" }
| { type: "pickUpTreasure" }
| { type: "dropTreasure" }
| { type: "discard"; instanceIds: string[] }
@@ -134,6 +196,190 @@ export type CommandResult =
| { ok: true; state: GameState; events: GameEvent[] }
| { ok: false; error: string };
// ---------------------------------------------------------------------------
// Card effects registry (first wave)
type AttackEffect = {
kind: "attack";
requiresLos: boolean;
/** Base damage from the played number value (null = no number card). */
baseDamage: (numberValue: number | null, params: CastStack["params"]) => number;
onResolved?: (ctx: ResolutionContext) => void;
};
type NeutralEffect = {
kind: "neutral";
resolve: (state: GameState, events: GameEvent[], caster: PlayerState, cmd: Extract<Command, { type: "cast" }>) => string | null;
};
type CounterEffect = {
kind: "counter";
/** Applies this counter inside the damage pipeline. */
apply: (pipe: DamagePipeline) => void;
};
interface ResolutionContext {
state: GameState;
events: GameEvent[];
attacker: PlayerState;
defender: PlayerState;
damageDealt: number;
fullyStopped: boolean;
stack: CastStack;
}
interface DamagePipeline {
damage: number;
reflectedDamage: number;
redirected: boolean;
fullyStopped: boolean;
kind: "spell" | "physical";
}
const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect> = {
// --- Attacks -------------------------------------------------------------
fireball: {
kind: "attack",
requiresLos: true,
// "Does five points of magical damage and destroys all magical stones an
// opponent is carrying, if any of the points get through."
baseDamage: () => 5,
onResolved: (ctx) => {
if (ctx.damageDealt <= 0) return;
const stones = ctx.defender.hand.filter((c) => isMagicStone(c.cardId));
if (stones.length === 0) return;
ctx.defender.hand = ctx.defender.hand.filter((c) => !isMagicStone(c.cardId));
ctx.state.discard.push(...stones);
ctx.events.push({ type: "stonesDestroyed", player: ctx.defender.id, cards: stones });
},
},
"lightning-blast": {
kind: "attack",
requiresLos: true,
// "Does magical damage equal to the accompanying NUMBER card, and stuns."
baseDamage: (n) => n ?? 1,
onResolved: (ctx) => {
// "If all damage gets counteracted, opponent does not lose turn."
if (ctx.damageDealt <= 0 || !ctx.defender.alive) return;
ctx.defender.lostTurns++;
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
},
},
powerthrust: {
kind: "attack",
requiresLos: true,
// "two points plus an accompanying NUMBER card (optional)"
baseDamage: (n) => 2 + (n ?? 0),
},
waterbolt: {
kind: "attack",
requiresLos: true,
// Damage and/or knockback split as chosen by the caster.
baseDamage: (n, params) => params?.damage ?? n ?? 1,
onResolved: (ctx) => {
const knock = ctx.stack.params?.knockback ?? 0;
if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return;
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock);
},
},
// --- Counteractions ------------------------------------------------------
// "Reduces any point damage done to you, up to three points."
absorb: { kind: "counter", apply: (p) => { p.damage = Math.max(0, p.damage - 3); } },
// "Reduces any damage done to you by 1/2. Round fractions up." (The damage
// that gets THROUGH is rounded up, per the rulebook's counteraction rule.)
blunt: { kind: "counter", apply: (p) => { p.damage = Math.ceil(p.damage / 2); } },
// "Stops any spell attack. Does not stop any physical attack."
"full-shield": {
kind: "counter",
apply: (p) => { if (p.kind === "spell") { p.damage = 0; p.fullyStopped = true; } },
},
// "A spell cast against you works 50% for both parties. Round fractions up."
reflection: {
kind: "counter",
apply: (p) => {
if (p.kind !== "spell") return;
const half = Math.ceil(p.damage / 2);
p.reflectedDamage += half;
p.damage = half;
},
},
// "Opponent's spell, if cast upon you, is reflected back on him."
"full-reflection": {
kind: "counter",
apply: (p) => { if (p.kind === "spell") { p.redirected = true; } },
},
// --- Neutrals ------------------------------------------------------------
"create-wall": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "create-wall targets a wall edge";
const { cell, side } = cmd.target;
const view = boardView(state);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "walls must be created between two spaces on the board";
}
const key = edgeKey(cell, side);
const current = view.edges[key] ?? "open";
if (current !== "open") return "there is already something in that wall line";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall line";
state.edgeOverrides[key] = "wall";
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
return null;
},
},
"destroy-wall": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "destroy-wall targets a wall edge";
const { cell, side } = cmd.target;
const view = boardView(state);
const key = edgeKey(cell, side);
const current = view.edges[key] ?? "open";
if (current === "open") return "there is no wall there";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall";
state.edgeOverrides[key] = "open";
events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" });
// "Anyone in either square next to the wall takes 4 points of physical
// damage (not considered an attack)."
for (const c of [cell, neighbor(cell, side)]) {
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(c)) {
applyDamage(state, events, p, 4, "collapsing wall", caster.id);
}
}
}
checkVictory(state, events);
return null;
},
},
speed: {
kind: "neutral",
// "Allows one extra turn." (Timing nuance — "must be played before new
// cards are drawn" — is satisfied because casting is only possible before
// endTurn.)
resolve: (state, events, caster) => {
caster.extraTurns++;
events.push({ type: "extraTurnGranted", player: caster.id });
return null;
},
},
};
/** LOS to the midpoint of a wall edge (Create Wall: "the center of the wall"). */
function losToEdge(board: AssembledBoard, from: Cell, cell: Cell, side: Side): boolean {
// The shared edge midpoint between `cell` and its neighbor.
const n = neighbor(cell, side);
const mx = (cell.x + n.x) / 2 + 0.5;
const my = (cell.y + n.y) / 2 + 0.5;
// Reuse cell-to-cell LOS to both adjacent cells as a practical proxy: the
// caster must see at least one face of the wall line. (TODO: exact
// midpoint-based check per FAQ if disputes arise.)
void mx; void my;
return hasLineOfSight(board, from, cell) || hasLineOfSight(board, from, n);
}
// ---------------------------------------------------------------------------
// Setup
@@ -145,8 +391,6 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
const { board, rng: rng1 } = setupBoard(n, rng);
rng = rng1;
// Each player is randomly assigned one sector ("chooses one of the four
// sectors, face down, at random").
const [sectorOrder, rng2] = shuffle(rng, board.homes.map((_, i) => i).slice(0, n));
rng = rng2;
@@ -159,6 +403,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
alive: true,
hand: [],
carriedTreasureId: null,
lostTurns: 0,
extraTurns: 0,
}));
const treasures: TreasureState[] = players.flatMap((p, i) =>
@@ -170,7 +416,6 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
})),
);
// Shuffle and deal 7 each; a TRAP! drawn on the deal is discarded and redrawn.
const [deckShuffled, rng3] = shuffle(rng, buildDeck(config.sets));
rng = rng3;
const deck = [...deckShuffled];
@@ -190,7 +435,6 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
}
// First player: highest die roll, rerolling ties among the leaders.
const dieRolls: Record<PlayerId, number[]> = Object.fromEntries(players.map((p) => [p.id, []]));
let contenders = players.map((_, i) => i);
let firstIndex = contenders[0]!;
@@ -211,6 +455,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
config,
phase: "playing",
board,
edgeOverrides: {},
players,
treasures,
deck,
@@ -225,6 +470,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
attackUsed: false,
actionsEnded: false,
},
stack: null,
rng,
winner: null,
pendingDiscard: null,
@@ -251,15 +497,27 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
if (state.pendingDiscard) {
if (playerId !== state.pendingDiscard) return err("waiting for another player to discard");
if (command.type !== "discard") return err("you must discard down to the hand limit first");
} else if (activePlayer(state).id !== playerId) {
// Out-of-turn play is only for counteractions (future casting layer).
return err("not your turn");
return doDiscard(state, playerId, command.instanceIds);
}
// While an attack is on the stack, only the awaited player may act, and
// only with counteract/pass.
if (state.stack) {
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId);
if (command.type === "pass") return doPass(state, playerId);
return err("an attack is being resolved — counteract or pass");
}
if (activePlayer(state).id !== playerId) return err("not your turn");
switch (command.type) {
case "move": return doMove(state, command.direction);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId);
case "punch": return doPunch(state, command.targetId);
case "cast": return doCast(state, command);
case "counteract": return err("nothing to counteract");
case "pass": return err("nothing to pass on");
case "pickUpTreasure": return doPickUpTreasure(state);
case "dropTreasure": return doDropTreasure(state);
case "discard": return doDiscard(state, playerId, command.instanceIds);
@@ -284,6 +542,14 @@ function requireActionsAvailable(state: GameState): string | null {
return null;
}
function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null {
const idx = p.hand.findIndex((c) => c.instanceId === instanceId);
if (idx === -1) return null;
return p.hand.splice(idx, 1)[0]!;
}
// --- Movement ---------------------------------------------------------------
function doMove(prev: GameState, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
@@ -291,7 +557,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
const state = clone(prev);
const p = activePlayer(state);
const target = stepTarget(state.board, p.position, direction);
const target = stepTarget(boardView(state), p.position, direction);
if (target.kind === "blocked") return err(`blocked by ${target.by}`);
const from = p.position;
@@ -311,13 +577,11 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandRe
const state = clone(prev);
const p = activePlayer(state);
const idx = p.hand.findIndex((c) => c.instanceId === instanceId);
if (idx === -1) return err("card not in hand");
const card = p.hand[idx]!;
const card = takeFromHand(p, instanceId);
if (!card) return err("card not in hand");
if (!isNumberCard(card.cardId)) return err("not a number card");
const value = numberValue(card.cardId);
p.hand.splice(idx, 1);
state.discard.push(card);
state.turn.movementAllowance += value;
state.turn.numberPlayedForMovement = true;
@@ -334,11 +598,19 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandRe
};
}
// --- Attacks and the casting stack ------------------------------------------
function attackPreconditions(state: GameState): string | null {
const blocked = requireActionsAvailable(state);
if (blocked) return blocked;
if (state.turn.round === 1) return "no combat during the first round of turns";
if (state.turn.attackUsed) return "you may attack only once per turn";
return null;
}
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.round === 1) return err("no combat during the first round of turns");
if (prev.turn.attackUsed) return err("you may attack only once per turn");
const pre = attackPreconditions(prev);
if (pre) return err(pre);
const state = clone(prev);
const attacker = activePlayer(state);
@@ -350,15 +622,309 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
}
state.turn.attackUsed = true;
const events: GameEvent[] = [
{ type: "punched", attacker: attacker.id, target: target.id, at: attacker.position },
];
applyDamage(state, events, target, 1, `punch from ${attacker.id}`, attacker.id);
state.stack = {
attackerId: attacker.id,
defenderId: target.id,
attackCard: null,
numberValue: null,
params: null,
kind: "physical",
counters: [],
waitingOn: target.id,
};
return {
ok: true,
state,
events: [{ type: "punched", attacker: attacker.id, target: target.id, at: attacker.position }],
};
}
function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const caster = activePlayer(state);
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
if (!inHand) return err("card not in hand");
const def = cardDef(inHand.cardId);
const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`);
// Optional number card: one per action.
let numberCard: CardInstance | null = null;
let numValue: number | null = null;
if (cmd.numberInstanceId) {
const nc = caster.hand.find((c) => c.instanceId === cmd.numberInstanceId);
if (!nc) return err("number card not in hand");
if (!isNumberCard(nc.cardId)) return err("that is not a number card");
numberCard = nc;
numValue = numberValue(nc.cardId);
}
if (effect.kind === "attack") {
const pre = attackPreconditions(state);
if (pre) return err(pre);
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return err("no such living player");
if (effect.requiresLos && !hasLineOfSight(boardView(state), caster.position, target.position)) {
return err("no line of sight to the target");
}
// Waterbolt split must account for the full number value.
if (inHand.cardId === "waterbolt") {
const total = numValue ?? 1;
const d = cmd.params?.damage ?? total;
const k = cmd.params?.knockback ?? 0;
if (d < 0 || k < 0 || d + k !== total) {
return err(`waterbolt damage + knockback must total ${total}`);
}
}
takeFromHand(caster, cmd.instanceId);
state.discard.push(inHand);
if (numberCard) {
takeFromHand(caster, numberCard.instanceId);
state.discard.push(numberCard);
}
state.turn.attackUsed = true;
state.stack = {
attackerId: caster.id,
defenderId: target.id,
attackCard: inHand,
numberValue: numValue,
params: cmd.params ?? null,
kind: "spell",
counters: [],
waitingOn: target.id,
};
return {
ok: true,
state,
events: [{
type: "spellCast",
caster: caster.id,
card: inHand,
cardId: inHand.cardId,
numberCard,
numberValue: numValue,
from: caster.position,
target: target.id,
targetCell: target.position,
}],
};
}
if (effect.kind === "neutral") {
const events: GameEvent[] = [{
type: "spellCast",
caster: caster.id,
card: inHand,
cardId: inHand.cardId,
numberCard,
numberValue: numValue,
from: caster.position,
target: null,
targetCell: cmd.target?.kind === "edge" ? cmd.target.cell : null,
}];
// Validate the effect BEFORE consuming cards.
const preview = clone(state);
const previewCaster = activePlayer(preview);
const problem = (CARD_EFFECTS[inHand.cardId] as NeutralEffect).resolve(preview, [], previewCaster, cmd);
if (problem) return err(problem);
takeFromHand(caster, cmd.instanceId);
state.discard.push(inHand);
if (numberCard) {
takeFromHand(caster, numberCard.instanceId);
state.discard.push(numberCard);
}
const result = (CARD_EFFECTS[inHand.cardId] as NeutralEffect).resolve(state, events, caster, cmd);
if (result) return err(result); // should not happen after preview
return { ok: true, state, events };
}
return err(`${def.name} is a counteraction — play it in response to an attack`);
}
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): CommandResult {
const state = clone(prev);
const stack = state.stack!;
const player = state.players.find((p) => p.id === playerId)!;
const card = player.hand.find((c) => c.instanceId === instanceId);
if (!card) return err("card not in hand");
const def = cardDef(card.cardId);
if (playerId === stack.defenderId) {
// ABSORB SPELL: nullify the whole attack and take the card into hand.
if (card.cardId === "absorb-spell") {
if (stack.kind !== "spell") return err("absorb spell only works against spells");
takeFromHand(player, instanceId);
state.discard.push(card);
const attackCard = stack.attackCard!;
// Remove the attack card from the discard pile into the defender's hand.
const di = state.discard.findIndex((c) => c.instanceId === attackCard.instanceId);
if (di !== -1) state.discard.splice(di, 1);
player.hand.push(attackCard);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
{ type: "attackAbsorbedIntoHand", player: playerId, attackCard },
{ type: "attackResolved", attacker: stack.attackerId, defender: stack.defenderId, attackCardId: attackCard.cardId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false },
];
state.stack = null;
if (player.hand.length > HAND_LIMIT) state.pendingDiscard = player.id;
return { ok: true, state, events };
}
const isCounter = def.cardType === "counteraction" || def.cardType === "neutral/counteraction";
if (!isCounter || !(card.cardId in CARD_EFFECTS)) {
return err(`${def.name} cannot counteract (or is not implemented yet)`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId; // attacker may respond (e.g. ANTI-ANTI)
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
// Attacker responding to a counteraction: ANTI-ANTI nullifies it.
if (playerId === stack.attackerId) {
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction (in this wave)");
const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified);
if (!targetCounter) return err("no counteraction to nullify");
takeFromHand(player, instanceId);
state.discard.push(card);
targetCounter.nullified = true;
stack.waitingOn = stack.defenderId;
return {
ok: true,
state,
events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }],
};
}
return err("you are not part of this exchange");
}
function doPass(prev: GameState, playerId: PlayerId): CommandResult {
const state = clone(prev);
const stack = state.stack!;
if (playerId === stack.attackerId) {
// Attacker declines to respond; back to the defender for more counters.
stack.waitingOn = stack.defenderId;
return { ok: true, state, events: [] };
}
// Defender passes: resolve.
const events: GameEvent[] = [];
resolveStack(state, events);
checkVictory(state, events);
return { ok: true, state, events };
}
/** Damage, death, killer-takes-cards, elimination — shared with future spells. */
function resolveStack(state: GameState, events: GameEvent[]): void {
const stack = state.stack!;
state.stack = null;
const attacker = state.players.find((p) => p.id === stack.attackerId)!;
const defender = state.players.find((p) => p.id === stack.defenderId)!;
const attackId = stack.attackCard?.cardId ?? null;
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
const base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1
const pipe: DamagePipeline = {
damage: base,
reflectedDamage: 0,
redirected: false,
fullyStopped: false,
kind: stack.kind,
};
// "COUNTERACTIONs occur before ATTACKs" — apply in the order played.
for (const counter of stack.counters) {
if (counter.nullified) continue;
const ce = CARD_EFFECTS[counter.card.cardId];
if (ce && ce.kind === "counter") ce.apply(pipe);
}
let damageDealt = 0;
if (pipe.redirected) {
// FULL REFLECTION: the whole spell comes back at the attacker.
damageDealt = 0;
if (pipe.damage > 0) {
applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id);
}
} else {
if (pipe.damage > 0) {
applyDamage(state, events, defender, pipe.damage, attackId ?? `punch from ${attacker.id}`, attacker.id);
damageDealt = pipe.damage;
}
if (pipe.reflectedDamage > 0) {
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
}
}
events.push({
type: "attackResolved",
attacker: attacker.id,
defender: defender.id,
attackCardId: attackId,
damageDealt,
reflectedDamage: pipe.redirected ? pipe.damage : pipe.reflectedDamage,
fullyStopped: pipe.fullyStopped || (damageDealt === 0 && !pipe.redirected),
redirected: pipe.redirected,
});
// Secondary effects ("If all damage from a spell is stopped, any secondary
// effects, such as a lost turn, are also stopped" — modeled per card).
if (effect?.onResolved && !pipe.redirected) {
effect.onResolved({
state,
events,
attacker,
defender,
damageDealt,
fullyStopped: pipe.fullyStopped,
stack,
});
}
}
/** Push the defender directly away from the attacker, stopping at walls. */
function knockBack(
state: GameState,
events: GameEvent[],
attacker: PlayerState,
defender: PlayerState,
squares: number,
): void {
const dx = defender.position.x - attacker.position.x;
const dy = defender.position.y - attacker.position.y;
// Dominant axis away from the attacker; same-square defaults to no push.
let dir: Side | null = null;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else if (dy !== 0) dir = dy > 0 ? "S" : "N";
if (!dir) return;
const from = defender.position;
let moved = 0;
const view = boardView(state);
for (let i = 0; i < squares; i++) {
const step = stepTarget(view, defender.position, dir);
if (step.kind === "blocked") break;
defender.position = step.to;
moved++;
}
if (moved > 0) {
events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved });
}
}
/** Damage, death, killer-takes-cards, elimination — shared by all sources. */
function applyDamage(
state: GameState,
events: GameEvent[],
@@ -375,7 +941,6 @@ function applyDamage(
events.push({ type: "died", player: target.id, killedBy: attackerId });
events.push({ type: "playerEliminated", player: target.id, reason: "killed" });
// A carried treasure drops where they fell.
if (target.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
t.carriedBy = null;
@@ -390,8 +955,6 @@ function applyDamage(
});
}
// "If you kill an opponent, you get all his cards, but you must immediately
// discard enough to bring your hand down to seven cards."
const killer = attackerId ? state.players.find((p) => p.id === attackerId) : undefined;
if (killer && killer.alive && target.hand.length > 0) {
const taken = target.hand.splice(0);
@@ -409,6 +972,8 @@ function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null {
return p ? p.id : null;
}
// --- Treasures ---------------------------------------------------------------
function doPickUpTreasure(prev: GameState): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
@@ -424,7 +989,6 @@ function doPickUpTreasure(prev: GameState): CommandResult {
t.carriedBy = p.id;
t.position = null;
p.carriedTreasureId = t.id;
// Picking up any object ends the turn's actions (drawing is still allowed).
state.turn.actionsEnded = true;
return {
ok: true,
@@ -457,7 +1021,6 @@ function doDropTreasure(prev: GameState): CommandResult {
function checkVictory(state: GameState, events: GameEvent[]): void {
if (state.phase !== "playing") return;
// Elimination: both of a player's treasures sit on OTHER living players' homes.
for (const p of state.players) {
if (!p.alive) continue;
const mine = state.treasures.filter((t) => t.owner === p.id);
@@ -474,7 +1037,6 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
}
}
// Win by treasures: two enemy treasures resting on your home base.
for (const p of state.players) {
if (!p.alive) continue;
const stolenAtHome = state.treasures.filter(
@@ -488,7 +1050,6 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
}
}
// Win by elimination: last wizard standing.
const alive = state.players.filter((p) => p.alive);
if (alive.length === 1) {
state.phase = "finished";
@@ -497,14 +1058,16 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
}
}
// --- Hand management and turn flow -------------------------------------------
function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]): CommandResult {
const state = clone(prev);
const p = state.players.find((p) => p.id === playerId)!;
const cards: CardInstance[] = [];
for (const id of instanceIds) {
const idx = p.hand.findIndex((c) => c.instanceId === id);
if (idx === -1) return err(`card not in hand: ${id}`);
cards.push(...p.hand.splice(idx, 1));
const card = takeFromHand(p, id);
if (!card) return err(`card not in hand: ${id}`);
cards.push(card);
}
state.discard.push(...cards);
if (state.pendingDiscard === playerId && p.hand.length <= HAND_LIMIT) {
@@ -513,32 +1076,45 @@ function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]):
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
}
function drawOne(state: GameState, events: GameEvent[]): CardInstance | null {
if (state.deck.length === 0) {
// Reshuffle the discard pile into a fresh deck. (TODO: confirm the
// official rule for deck exhaustion — not covered in the 6e rulebook.)
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
state.rng = rngNext;
state.deck = reshuffled;
state.discard = [];
events.push({ type: "deckReshuffled", size: state.deck.length });
if (state.deck.length === 0) return null;
}
return state.deck.shift()!;
}
function doEndTurn(prev: GameState, draw: number): CommandResult {
if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`);
if (prev.pendingDiscard) return err("a discard is pending");
const state = clone(prev);
const p = activePlayer(state);
const events: GameEvent[] = [];
// "Draw new cards only at the end of YOUR turn ... may never have more than
// seven cards in your hand."
const room = HAND_LIMIT - p.hand.length;
const count = Math.min(draw, room);
if (count > 0) {
const drawn: CardInstance[] = [];
for (let i = 0; i < count; i++) {
if (state.deck.length === 0) {
// Reshuffle the discard pile into a fresh deck. (TODO: confirm the
// official rule for deck exhaustion — not covered in the 6e rulebook.)
const [reshuffled, rngNext] = shuffle(state.rng, state.discard);
state.rng = rngNext;
state.deck = reshuffled;
state.discard = [];
events.push({ type: "deckReshuffled", size: state.deck.length });
if (state.deck.length === 0) break;
let toDraw = count;
while (toDraw > 0) {
const card = drawOne(state, events);
if (!card) break;
// "TRAP! You fool! ... Display immediately, lose your next turn and
// draw another card."
if (isTrap(card.cardId)) {
state.discard.push(card);
p.lostTurns++;
events.push({ type: "trapSprung", player: p.id });
continue; // the replacement draw
}
drawn.push(state.deck.shift()!);
drawn.push(card);
toDraw--;
}
p.hand.push(...drawn);
events.push({ type: "cardsDrawn", player: p.id, count: drawn.length });
@@ -547,14 +1123,37 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
events.push({ type: "turnEnded", player: p.id });
// Advance to the next living player; a full cycle back past the first
// player of the round increments the round counter.
// SPEED: an extra turn for the same player before play passes on.
if (p.extraTurns > 0) {
p.extraTurns--;
state.turn = {
...state.turn,
movementAllowance: BASE_MOVEMENT,
movementUsed: 0,
numberPlayedForMovement: false,
attackUsed: false,
actionsEnded: false,
};
events.push({ type: "extraTurnStarted", player: p.id });
events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
return { ok: true, state, events };
}
// Advance to the next living player, consuming lost turns along the way.
const n = state.players.length;
let next = state.turn.activeIndex;
do {
for (;;) {
next = (next + 1) % n;
if (next === state.turn.firstIndex) state.turn.round++;
} while (!state.players[next]!.alive);
const candidate = state.players[next]!;
if (!candidate.alive) continue;
if (candidate.lostTurns > 0) {
candidate.lostTurns--;
events.push({ type: "turnSkipped", player: candidate.id, reason: "lostTurn" });
continue;
}
break;
}
state.turn = {
round: state.turn.round,
+300
View File
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
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;
}
/** Test surgery: put a specific card into a player's hand (swapping one out). */
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T"): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[0] = instance;
return instance;
}
/** Advance past round 1 (both players just end their turns). */
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 });
expect(state.turn.round).toBe(2);
return state;
}
/** Put attacker and defender in mutual LOS (same square works for spells too). */
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 };
}
describe("attack spells", () => {
it("fireball does 5 flat damage when unopposed", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(state.stack).not.toBeNull();
state = must(state, defender, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
});
it("fireball destroys carried magic stones only if damage gets through", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
// Full shield stops everything -> stones survive.
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
});
it("lightning blast deals number-card damage and stuns", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lb = giveCard(state, attacker, "lightning-blast");
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
state = must(state, attacker, {
type: "cast", instanceId: lb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
const d = state.players.find((p) => p.id === defender)!;
expect(d.life).toBe(11);
expect(d.lostTurns).toBe(1);
// The stunned player's next turn is skipped.
state = must(state, attacker, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(attacker); // defender was skipped
});
it("blunt halves damage rounding the result up; absorb takes 3 off", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
d.hand[1] = { instanceId: "absorb#T", cardId: "absorb" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "counteract", instanceId: "absorb#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
// 5 -> blunt -> ceil(2.5)=3 -> absorb -> 0
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
// All damage stopped -> no stones logic, no stun, attack fully spent.
});
it("anti-anti nullifies a counteraction", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
// Shield nullified: full 5 damage lands.
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
});
it("full reflection sends the whole spell back at the caster", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "full-reflection#T", cardId: "full-reflection" };
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, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("absorb spell nullifies the attack and takes the card into hand", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "absorb-spell#T", cardId: "absorb-spell" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "absorb-spell#T" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(after.hand.some((c) => c.cardId === "fireball")).toBe(true);
expect(state.stack).toBeNull();
});
it("waterbolt splits number value between damage and knockback", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Stand defender 1 east of attacker with open space behind (find a spot):
// use same square then nudge — simplest robust arrangement: same square,
// knockback direction defaults to none, so instead place east if open.
defender.position = { ...attacker.position };
const wb = giveCard(state, attacker.id, "waterbolt");
const a = state.players.find((p) => p.id === attacker.id)!;
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
const bad = applyCommand(state, attacker.id, {
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender.id },
params: { damage: 1, knockback: 1 },
});
expect(bad.ok).toBe(false); // must total 4
state = must(state, attacker.id, {
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender.id },
params: { damage: 3, knockback: 1 },
});
state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12);
});
it("requires line of sight for LOS attacks", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Find any cell without LOS from the attacker.
const view = boardView(state);
let hidden = null;
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number);
if (!hasLineOfSight(view, attacker.position, { x: x!, y: y! })) { hidden = { x: x!, y: y! }; break; }
}
expect(hidden).not.toBeNull();
defender.position = hidden!;
const fb = giveCard(state, attacker.id, "fireball");
const result = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(result.ok).toBe(false);
});
});
describe("neutral spells", () => {
it("create wall then destroy wall, with collapse damage", () => {
let { state } = newGame();
const caster = activePlayer(state);
// Find an open edge adjacent to the caster.
const view = boardView(state);
let edge: { cell: typeof caster.position; side: Side } | null = null;
for (const side of ["N", "S", "E", "W"] as Side[]) {
const n = neighbor(caster.position, side);
if (view.cells[cellKey(n)] && !(edgeKey(caster.position, side) in view.edges)) {
edge = { cell: caster.position, side };
break;
}
}
expect(edge).not.toBeNull();
const cw = giveCard(state, caster.id, "create-wall");
state = must(state, caster.id, {
type: "cast", instanceId: cw.instanceId,
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
});
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("wall");
// Destroying it hurts anyone standing beside it — the caster is adjacent.
const dw = giveCard(state, caster.id, "destroy-wall");
state = must(state, caster.id, {
type: "cast", instanceId: dw.instanceId,
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
});
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("open");
expect(state.players.find((p) => p.id === caster.id)!.life).toBe(11);
});
it("neutrals do not consume the attack and multiple can be cast", () => {
let { state } = newGame();
const caster = activePlayer(state);
const s1 = giveCard(state, caster.id, "speed", "T1");
state = must(state, caster.id, { type: "cast", instanceId: s1.instanceId });
expect(state.turn.attackUsed).toBe(false);
const s2 = giveCard(state, caster.id, "speed", "T2");
state = must(state, caster.id, { type: "cast", instanceId: s2.instanceId });
expect(state.players.find((p) => p.id === caster.id)!.extraTurns).toBe(2);
});
it("speed grants an extra turn before play passes", () => {
let { state } = newGame();
const caster = activePlayer(state);
const sp = giveCard(state, caster.id, "speed");
state = must(state, caster.id, { type: "cast", instanceId: sp.instanceId });
state = must(state, caster.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(caster.id); // extra turn, same player
state = must(state, caster.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).not.toBe(caster.id);
});
});
describe("stack discipline", () => {
it("locks other commands while an attack is unresolved", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
// Attacker cannot move while awaiting the defender.
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
// Defender cannot move either — only counteract or pass.
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
});
it("unimplemented cards refuse to cast with a clear error", () => {
let { state } = newGame();
const caster = activePlayer(state);
const card = giveCard(state, caster.id, "medusa");
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/not implemented/);
});
});
+14 -15
View File
@@ -115,17 +115,17 @@ describe("turns and combat", () => {
const attacker = activePlayer(state);
const victim = state.players.find((p) => p.id !== attacker.id)!;
victim.position = { ...attacker.position };
const afterPunch = applyCommand(state, attacker.id, { type: "punch", targetId: victim.id });
expect(afterPunch.ok).toBe(true);
if (afterPunch.ok) {
const v = afterPunch.state.players.find((p) => p.id === victim.id)!;
expect(v.life).toBe(STARTING_LIFE - 1);
const secondPunch = applyCommand(afterPunch.state, attacker.id, {
type: "punch",
targetId: victim.id,
});
expect(secondPunch.ok).toBe(false);
}
state = must(state, attacker.id, { type: "punch", targetId: victim.id });
// Punches enter the counteraction stack (BLUNT/ABSORB work on physical
// damage); the defender passes and the punch resolves.
state = must(state, victim.id, { type: "pass" });
const v = state.players.find((p) => p.id === victim.id)!;
expect(v.life).toBe(STARTING_LIFE - 1);
const secondPunch = applyCommand(state, attacker.id, {
type: "punch",
targetId: victim.id,
});
expect(secondPunch.ok).toBe(false);
});
it("cannot punch yourself", () => {
@@ -145,11 +145,10 @@ describe("turns and combat", () => {
const victim = state.players.find((p) => p.id !== attacker.id)!;
victim.position = { ...attacker.position };
victim.life = 1; // test surgery: one punch kills
const result = applyCommand(state, attacker.id, { type: "punch", targetId: victim.id });
expect(result.ok).toBe(true);
if (!result.ok) return;
state = must(state, attacker.id, { type: "punch", targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
const s = result.state;
const s = state;
expect(s.players.find((p) => p.id === victim.id)!.alive).toBe(false);
const a = s.players.find((p) => p.id === attacker.id)!;
expect(a.hand.length).toBe(14);