From 7c607ad5c7229ec85b7b44ad03ec506227935922 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 19:47:06 -0400 Subject: [PATCH] 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 --- packages/engine/src/cards.ts | 6 + packages/engine/src/game.ts | 709 ++++++++++++++++++++++++--- packages/engine/test/casting.test.ts | 300 ++++++++++++ packages/engine/test/game.test.ts | 29 +- 4 files changed, 974 insertions(+), 70 deletions(-) create mode 100644 packages/engine/test/casting.test.ts diff --git a/packages/engine/src/cards.ts b/packages/engine/src/cards.ts index d0d6294..1c56eb7 100644 --- a/packages/engine/src/cards.ts +++ b/packages/engine/src/cards.ts @@ -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; +} diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index f095d0f..07d8ee2 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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; 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) => 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 = { + // --- 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 = 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): 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, diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts new file mode 100644 index 0000000..a8daf00 --- /dev/null +++ b/packages/engine/test/casting.test.ts @@ -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/); + }); +}); diff --git a/packages/engine/test/game.test.ts b/packages/engine/test/game.test.ts index 31afdce..83bb304 100644 --- a/packages/engine/test/game.test.ts +++ b/packages/engine/test/game.test.ts @@ -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);