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:
co-authored by
Claude Fable 5
parent
a8884592a4
commit
7c607ad5c7
@@ -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
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user