diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 07d8ee2..6860c20 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -6,16 +6,17 @@ // where, via what) so that replays — including the planned first-person // wizard's-eye renderings — can reconstruct scenes without re-deriving them. // -// 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. +// Rules sources: the owner's 6e rulebook (research/official-6e-card-list.md), +// verbatim card text in data/cards.json, and Jolly's 2002 FAQ. Card effects +// register in CARD_EFFECTS as they are implemented; unimplemented cards +// refuse to cast with a clear error. import { type AssembledBoard, type Cell, type EdgeState, type Side, + SIDES, cellKey, edgeKey, hasLineOfSight, @@ -32,7 +33,7 @@ import { type CardInstance, type CardSet, } from "./cards"; -import { createRng, rollDie, shuffle, type RngState } from "./rng"; +import { createRng, nextInt, rollDie, shuffle, type RngState } from "./rng"; import { setupBoard } from "./setups"; export type PlayerId = string; @@ -44,9 +45,7 @@ export const DRAW_PER_TURN = 2; export interface TreasureState { id: string; - /** The player whose home this treasure belongs to (who "protects" it). */ owner: PlayerId; - /** Board position, or null while carried. */ position: Cell | null; carriedBy: PlayerId | null; } @@ -57,46 +56,65 @@ export interface PlayerState { home: Cell; position: Cell; life: number; - /** false once killed OR eliminated by losing both treasures. */ alive: boolean; hand: CardInstance[]; + /** Instance ids of cards displayed face-up (Master Key, Wizardblade, stones). */ + displayed: string[]; carriedTreasureId: string | null; - /** Turns to skip (Lightning Blast stun, TRAP!, ...). */ lostTurns: number; - /** Extra turns granted (SPEED). */ extraTurns: number; + /** PASS THROUGH WALL charges (each lets one step through a wall). */ + passWallCharges: number; +} + +/** A duration spell in play. Expires at the START of the caster's turns. */ +export interface SustainedEffect { + id: string; + cardId: string; + casterId: PlayerId; + targetId: PlayerId; + remainingTurns: number; + /** Per-card scratch (e.g. SLOW's turn parity counter). */ + data: Record; } export interface TurnState { - /** 1-based round counter; combat is forbidden during round 1. */ round: number; - /** Seat of the die-roll winner; rounds advance when play wraps past it. */ firstIndex: number; activeIndex: number; movementAllowance: number; movementUsed: number; numberPlayedForMovement: boolean; attackUsed: boolean; - /** Picking up any object ends your actions for the turn. */ + /** SLOW: "his attacks [reduce] to every other turn". */ + attackForbidden: boolean; 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. */ + /** Combined number value (ADD may join two number cards); null = none played. */ numberValue: number | null; - /** Waterbolt's caster-chosen split; damage + knockback = number value. */ - params: { damage?: number; knockback?: number } | null; + /** Power/duration multiplier from AMPLIFY (and EXTEND for durations). */ + amplifyFactor: number; + extendFactor: number; + params: CastParams | 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 CastParams { + damage?: number; + knockback?: number; + cell?: Cell; + cardId?: string; + points?: number; +} + export interface GameConfig { playerIds: PlayerId[]; seed: number; @@ -109,16 +127,22 @@ export interface GameState { board: AssembledBoard; /** Dynamic wall changes (Create Wall, Destroy Wall) layered over the board. */ edgeOverrides: Record; + /** Permanent door-lock changes, by edge key. */ + doorStates: Record; + /** Door edges unlocked until the end of the current turn. */ + openDoorEdges: string[]; players: PlayerState[]; treasures: TreasureState[]; + sustained: SustainedEffect[]; 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; + /** Monotonic counter for sustained-effect ids. */ + nextEffectId: number; } /** The board with dynamic wall changes applied — use for movement and LOS. */ @@ -127,6 +151,10 @@ export function boardView(state: GameState): AssembledBoard { return { ...state.board, edges: { ...state.board.edges, ...state.edgeOverrides } }; } +export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: string): SustainedEffect[] { + return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId)); +} + // --------------------------------------------------------------------------- // Events @@ -138,21 +166,39 @@ export type GameEvent = | { 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: "moved"; player: PlayerId; from: Cell; to: Cell; direction: Side; via: "step" | "warp" | "passWall" } | { 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: "spellCast"; caster: PlayerId; card: CardInstance; cardId: string; numberCards: CardInstance[]; 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: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" } | { 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: "damageImmune"; player: PlayerId; source: string; because: "medusa" } + | { type: "lifeGained"; 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: "spellSustained"; effectId: string; cardId: string; caster: PlayerId; target: PlayerId; turns: number } + | { type: "spellExpired"; effectId: string; cardId: string; target: PlayerId } + | { type: "teleported"; player: PlayerId; from: Cell; to: Cell; by: PlayerId; cardId: string } + | { type: "positionsSwapped"; a: PlayerId; b: PlayerId; aTo: Cell; bTo: Cell } + | { type: "cardErased"; player: PlayerId; cardId: string | null; found: boolean } + | { type: "cardsStolen"; from: PlayerId; to: PlayerId; count: number } + | { type: "cardsStolenPrivate"; visibleTo: PlayerId; cards: CardInstance[] } + | { type: "handRevealed"; player: PlayerId; to: PlayerId } + | { type: "handRevealedPrivate"; visibleTo: PlayerId; 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: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } + | { type: "doorsRelocked"; count: number } + | { type: "doorJammed"; player: PlayerId; edge: { cell: Cell; side: Side } } + | { type: "lockRemoved"; player: PlayerId; edge: { cell: Cell; side: Side } } + | { type: "cardDisplayed"; player: PlayerId; card: CardInstance } | { type: "extraTurnGranted"; player: PlayerId } + | { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number } | { type: "trapSprung"; player: PlayerId } | { type: "died"; player: PlayerId; killedBy: PlayerId | null } | { type: "handTaken"; from: PlayerId; to: PlayerId; count: number } @@ -167,7 +213,6 @@ export type GameEvent = | { type: "turnEnded"; player: PlayerId } | { type: "gameWon"; player: PlayerId; reason: "treasures" | "lastStanding" }; -/** Strip private card knowledge from an event unless `viewer` may see it. */ export function redactEvent(event: GameEvent, viewer: PlayerId): GameEvent | null { if ("visibleTo" in event && event.visibleTo !== viewer) return null; return event; @@ -178,13 +223,29 @@ export function redactEvent(event: GameEvent, viewer: PlayerId): GameEvent | nul export type CastTarget = | { kind: "player"; playerId: PlayerId } - | { kind: "edge"; cell: Cell; side: Side }; + | { kind: "edge"; cell: Cell; side: Side } + | { kind: "cell"; cell: Cell }; 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: "cast"; + instanceId: string; + /** Number cards powering the cast (two allowed when an ADD is attached). */ + numberInstanceIds?: string[]; + /** Legacy single-number field; merged into numberInstanceIds. */ + numberInstanceId?: string; + /** AMPLIFY cards attached (each doubles power/duration). */ + amplifyInstanceIds?: string[]; + /** ADD card attached (permits a second number card). */ + addInstanceId?: string; + /** EXTEND card attached (doubles duration). */ + extendInstanceId?: string; + target?: CastTarget; + params?: CastParams; + } | { type: "counteract"; instanceId: string } | { type: "pass" } | { type: "pickUpTreasure" } @@ -197,27 +258,47 @@ export type CommandResult = | { ok: false; error: string }; // --------------------------------------------------------------------------- -// Card effects registry (first wave) +// Card effects registry 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; + requiresLos?: boolean; + /** Attacker must share the target's square (WIZARDBLADE). */ + sameSquare?: boolean; + baseDamage: (numberValue: number | null, params: CastParams | null) => number; + /** A duration spell: attach a sustained effect on resolution. */ + sustains?: boolean; + /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ + keepInHand?: boolean; + validate?: (state: GameState, cmd: Extract) => string | null; onResolved?: (ctx: ResolutionContext) => void; }; type NeutralEffect = { kind: "neutral"; - resolve: (state: GameState, events: GameEvent[], caster: PlayerState, cmd: Extract) => string | null; + /** Card stays in hand and is displayed (MASTER KEY). */ + keepInHand?: boolean; + resolve: ( + state: GameState, + events: GameEvent[], + caster: PlayerState, + cmd: Extract, + magnitude: Magnitude, + ) => string | null; }; type CounterEffect = { kind: "counter"; - /** Applies this counter inside the damage pipeline. */ apply: (pipe: DamagePipeline) => void; }; +/** Computed power/duration for a cast, after ADD/AMPLIFY/EXTEND. */ +interface Magnitude { + numberValue: number | null; + power: number; + duration: number; +} + interface ResolutionContext { state: GameState; events: GameEvent[]; @@ -225,30 +306,36 @@ interface ResolutionContext { defender: PlayerState; damageDealt: number; fullyStopped: boolean; + duration: number; stack: CastStack; } interface DamagePipeline { damage: number; + duration: number; reflectedDamage: number; + /** Reflection splits a duration spell onto both parties. */ + splitDuration: boolean; redirected: boolean; fullyStopped: boolean; + reversed: boolean; kind: "spell" | "physical"; } const CARD_EFFECTS: Record = { - // --- Attacks ------------------------------------------------------------- + // --- Attacks: damage ------------------------------------------------------ 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.defender.displayed = ctx.defender.displayed.filter( + (id) => !stones.some((s) => s.instanceId === id), + ); ctx.state.discard.push(...stones); ctx.events.push({ type: "stonesDestroyed", player: ctx.defender.id, cards: stones }); }, @@ -256,25 +343,17 @@ const CARD_EFFECTS: Record "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), - }, + powerthrust: { kind: "attack", requiresLos: true, 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; @@ -282,19 +361,168 @@ const CARD_EFFECTS: Record knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock); }, }, + "sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 }, + "power-drain": { + kind: "attack", + requiresLos: true, + baseDamage: (n) => n ?? 1, + onResolved: (ctx) => { + if (ctx.damageDealt <= 0 || !ctx.attacker.alive) return; + ctx.attacker.life += ctx.damageDealt; + ctx.events.push({ + type: "lifeGained", + player: ctx.attacker.id, + amount: ctx.damageDealt, + source: "power drain", + lifeAfter: ctx.attacker.life, + }); + }, + }, + "stone-dead": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, // computed at resolution: number x stones carried + }, + wizardblade: { + kind: "attack", + sameSquare: true, + keepInHand: true, + // "Does magical damage equal to the NUMBER card played. Does NO damage + // without a NUMBER card." + baseDamage: (n) => n ?? 0, + }, - // --- 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." + // --- Attacks: control ----------------------------------------------------- + slow: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true }, + "no-spell": { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true }, + medusa: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true }, + "go-away": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + const n = ctx.stack.numberValue ?? 1; + knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); + ctx.defender.lostTurns++; + ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); + }, + }, + "teleport-opponent": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + validate: (state, cmd) => { + const cell = cmd.params?.cell; + if (!cell) return "teleport opponent needs a destination cell"; + if (!boardView(state).cells[cellKey(cell)]) return "destination is off the board"; + return null; + }, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + const to = ctx.stack.params!.cell!; + const from = ctx.defender.position; + ctx.defender.position = to; + ctx.events.push({ + type: "teleported", player: ctx.defender.id, from, to, + by: ctx.attacker.id, cardId: "teleport-opponent", + }); + }, + }, + swap: { + kind: "attack", + // "Swap places with any other character during your turn." No LOS printed. + baseDamage: () => 0, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + const a = ctx.attacker.position; + ctx.attacker.position = ctx.defender.position; + ctx.defender.position = a; + // "Counts as your movement." + ctx.state.turn.movementUsed = ctx.state.turn.movementAllowance; + ctx.events.push({ + type: "positionsSwapped", + a: ctx.attacker.id, b: ctx.defender.id, + aTo: ctx.attacker.position, bTo: ctx.defender.position, + }); + }, + }, + + // --- Attacks: cards ------------------------------------------------------- + "card-erasure": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + validate: (_state, cmd) => (cmd.params?.cardId ? null : "name the card to erase"), + onResolved: (ctx) => { + if (ctx.fullyStopped) return; + const wanted = ctx.stack.params!.cardId!; + const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted); + if (idx === -1) { + ctx.events.push({ type: "cardErased", player: ctx.defender.id, cardId: wanted, found: false }); + return; + } + const [card] = ctx.defender.hand.splice(idx, 1); + ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId); + ctx.state.discard.push(card!); + ctx.events.push({ type: "cardErased", player: ctx.defender.id, cardId: wanted, found: true }); + }, + }, + "thought-steal": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.attacker.alive) return; + const stolen: CardInstance[] = []; + for (let i = 0; i < 2 && ctx.defender.hand.length > 0; i++) { + const [idx, rngNext] = nextInt(ctx.state.rng, ctx.defender.hand.length); + ctx.state.rng = rngNext; + const [card] = ctx.defender.hand.splice(idx, 1); + ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId); + stolen.push(card!); + } + ctx.attacker.hand.push(...stolen); + ctx.events.push({ type: "cardsStolen", from: ctx.defender.id, to: ctx.attacker.id, count: stolen.length }); + ctx.events.push({ type: "cardsStolenPrivate", visibleTo: ctx.attacker.id, cards: stolen }); + if (ctx.attacker.hand.length > HAND_LIMIT) ctx.state.pendingDiscard = ctx.attacker.id; + }, + }, + telepath: { + kind: "attack", + baseDamage: () => 0, // "lets you see any one person's cards" — no LOS printed + onResolved: (ctx) => { + if (ctx.fullyStopped) return; + ctx.events.push({ type: "handRevealed", player: ctx.defender.id, to: ctx.attacker.id }); + ctx.events.push({ + type: "handRevealedPrivate", + visibleTo: ctx.attacker.id, + player: ctx.defender.id, + cards: [...ctx.defender.hand], + }); + }, + }, + + // --- Counteractions ------------------------------------------------------- + absorb: { + kind: "counter", + // "Has no effect on duration-based spells." + apply: (p) => { p.damage = Math.max(0, p.damage - 3); }, + }, + blunt: { + kind: "counter", + // "Works on point-based or duration-based spells, or physical damage." + apply: (p) => { + p.damage = Math.ceil(p.damage / 2); + p.duration = Math.ceil(p.duration / 2); + }, + }, "full-shield": { kind: "counter", - apply: (p) => { if (p.kind === "spell") { p.damage = 0; p.fullyStopped = true; } }, + apply: (p) => { + if (p.kind === "spell") { p.damage = 0; p.duration = 0; p.fullyStopped = true; } + }, }, - // "A spell cast against you works 50% for both parties. Round fractions up." reflection: { kind: "counter", apply: (p) => { @@ -302,15 +530,24 @@ const CARD_EFFECTS: Record const half = Math.ceil(p.damage / 2); p.reflectedDamage += half; p.damage = half; + if (p.duration > 0) { + p.duration = Math.ceil(p.duration / 2); + p.splitDuration = true; + } }, }, - // "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; } }, + apply: (p) => { if (p.kind === "spell") p.redirected = true; }, + }, + reverse: { + kind: "counter", + // "Instead of losing points in a magical attack, you gain them ... Any + // remaining effect of a spell still takes effect." + apply: (p) => { if (p.kind === "spell") p.reversed = true; }, }, - // --- Neutrals ------------------------------------------------------------ + // --- Neutrals: walls and doors ------------------------------------------- "create-wall": { kind: "neutral", resolve: (state, events, caster, cmd) => { @@ -321,8 +558,7 @@ const CARD_EFFECTS: Record 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 ((view.edges[key] ?? "open") !== "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 } }); @@ -340,9 +576,8 @@ const CARD_EFFECTS: Record 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"; + delete state.doorStates[key]; 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)) { @@ -354,32 +589,210 @@ const CARD_EFFECTS: Record return null; }, }, + "pick-lock": { + kind: "neutral", + // "Unlock any door (but the door will relock behind you). Can only use + // when adjacent to door. You may 'hold the door open' for others." + resolve: (state, events, caster, cmd) => + unlockDoor(state, events, caster, cmd, "pick-lock", { requireAdjacent: true }), + }, + "master-key": { + kind: "neutral", + keepInHand: true, + // "Unlocks any door (door relocks behind you). Do not discard when used. + // Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK." + resolve: (state, events, caster, cmd) => + unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }), + }, + "remove-lock": { + kind: "neutral", + resolve: (state, events, caster, cmd) => { + const found = doorTarget(state, cmd); + if (typeof found === "string") return found; + if (!isAdjacentToEdge(caster.position, found.cell, found.side)) { + return "you must be adjacent to the door"; + } + const key = edgeKey(found.cell, found.side); + if (state.doorStates[key] === "jammed") return "the lock is jammed solid"; + state.doorStates[key] = "removed"; + events.push({ type: "lockRemoved", player: caster.id, edge: found }); + return null; + }, + }, + "jam-lock": { + kind: "neutral", + resolve: (state, events, caster, cmd) => { + const found = doorTarget(state, cmd); + if (typeof found === "string") return found; + if (!losToEdge(boardView(state), caster.position, found.cell, found.side)) { + return "no line of sight to the door"; + } + const key = edgeKey(found.cell, found.side); + if (state.doorStates[key] === "removed") return "there is no lock left to jam"; + state.doorStates[key] = "jammed"; + state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key); + events.push({ type: "doorJammed", player: caster.id, edge: found }); + return null; + }, + }, + + // --- Neutrals: movement --------------------------------------------------- + teleport: { + kind: "neutral", + // "Move up to four spaces (not diagonally), ignoring walls and objects. + // ... your movement ends after you play it." + resolve: (state, events, caster, cmd) => { + if (!cmd.target || cmd.target.kind !== "cell") return "teleport needs a destination cell"; + const to = cmd.target.cell; + const view = boardView(state); + if (!view.cells[cellKey(to)]) return "destination is off the board"; + if (wallIgnoringDistance(view, caster.position, to) > 4) { + return "teleport reaches at most four spaces"; + } + const from = caster.position; + caster.position = to; + state.turn.movementUsed = state.turn.movementAllowance; // movement ends + events.push({ type: "teleported", player: caster.id, from, to, by: caster.id, cardId: "teleport" }); + return null; + }, + }, + "pass-through-wall": { + kind: "neutral", + resolve: (_state, _events, caster) => { + caster.passWallCharges++; + return null; + }, + }, + "power-run": { + kind: "neutral", + // "Trade your life-points for extra movement, one point per space." + resolve: (state, events, caster, cmd) => { + const points = cmd.params?.points ?? 0; + if (!Number.isInteger(points) || points < 1) return "choose how many life points to trade"; + if (points >= caster.life) return "that trade would kill you"; + caster.life -= points; + state.turn.movementAllowance += points; + events.push({ type: "lifeTraded", player: caster.id, points, newAllowance: state.turn.movementAllowance }); + return null; + }, + }, + + // --- Neutrals: self-buffs ------------------------------------------------- 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; }, }, + invisible: { + kind: "neutral", + resolve: (state, events, caster, _cmd, magnitude) => { + attachSustained(state, events, "invisible", caster.id, caster.id, magnitude.duration); + return null; + }, + }, + shrink: { + kind: "neutral", + resolve: (state, events, caster, _cmd, magnitude) => { + attachSustained(state, events, "shrink", caster.id, caster.id, magnitude.duration); + return null; + }, + }, }; -/** LOS to the midpoint of a wall edge (Create Wall: "the center of the wall"). */ +// --------------------------------------------------------------------------- +// Effect helpers + 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); } +function isAdjacentToEdge(pos: Cell, cell: Cell, side: Side): boolean { + return cellKey(pos) === cellKey(cell) || cellKey(pos) === cellKey(neighbor(cell, side)); +} + +function doorTarget( + state: GameState, + cmd: Extract, +): { cell: Cell; side: Side } | string { + if (!cmd.target || cmd.target.kind !== "edge") return "target a door"; + const { cell, side } = cmd.target; + const current = boardView(state).edges[edgeKey(cell, side)] ?? "open"; + if (current !== "door") return "that is not a door"; + return { cell, side }; +} + +function unlockDoor( + state: GameState, + events: GameEvent[], + caster: PlayerState, + cmd: Extract, + cardId: string, + opts: { requireAdjacent: boolean }, +): string | null { + const found = doorTarget(state, cmd); + if (typeof found === "string") return found; + const key = edgeKey(found.cell, found.side); + if (state.doorStates[key] === "jammed") return "the lock is jammed solid"; + if (state.doorStates[key] === "removed") return "that door has no lock"; + if (opts.requireAdjacent && !isAdjacentToEdge(caster.position, found.cell, found.side)) { + return "you must be adjacent to the door"; + } + if (!state.openDoorEdges.includes(key)) state.openDoorEdges.push(key); + events.push({ type: "doorUnlocked", player: caster.id, edge: found, withCardId: cardId }); + return null; +} + +function attachSustained( + state: GameState, + events: GameEvent[], + cardId: string, + casterId: PlayerId, + targetId: PlayerId, + turns: number, +): void { + const effect: SustainedEffect = { + id: `fx-${state.nextEffectId++}`, + cardId, + casterId, + targetId, + remainingTurns: Math.max(1, turns), + data: {}, + }; + state.sustained.push(effect); + events.push({ + type: "spellSustained", + effectId: effect.id, + cardId, + caster: casterId, + target: targetId, + turns: effect.remainingTurns, + }); +} + +/** BFS steps between cells ignoring walls (teleport distance). */ +function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { + if (cellKey(from) === cellKey(to)) return 0; + const seen = new Map([[cellKey(from), 0]]); + const queue: Cell[] = [from]; + while (queue.length > 0) { + const cur = queue.shift()!; + const d = seen.get(cellKey(cur))!; + if (d >= 8) break; // teleport range is 4; stop early + for (const side of SIDES) { + const n = neighbor(cur, side); + if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue; + seen.set(cellKey(n), d + 1); + if (cellKey(n) === cellKey(to)) return d + 1; + queue.push(n); + } + } + return seen.get(cellKey(to)) ?? Infinity; +} + // --------------------------------------------------------------------------- // Setup @@ -402,9 +815,11 @@ export function createGame(config: GameConfig): { state: GameState; events: Game life: STARTING_LIFE, alive: true, hand: [], + displayed: [], carriedTreasureId: null, lostTurns: 0, extraTurns: 0, + passWallCharges: 0, })); const treasures: TreasureState[] = players.flatMap((p, i) => @@ -456,8 +871,11 @@ export function createGame(config: GameConfig): { state: GameState; events: Game phase: "playing", board, edgeOverrides: {}, + doorStates: {}, + openDoorEdges: [], players, treasures, + sustained: [], deck, discard, turn: { @@ -468,12 +886,14 @@ export function createGame(config: GameConfig): { state: GameState; events: Game movementUsed: 0, numberPlayedForMovement: false, attackUsed: false, + attackForbidden: false, actionsEnded: false, }, stack: null, rng, winner: null, pendingDiscard: null, + nextEffectId: 1, }; events.unshift({ @@ -500,8 +920,6 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm 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); @@ -545,7 +963,9 @@ function requireActionsAvailable(state: GameState): string | 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]!; + const card = p.hand.splice(idx, 1)[0]!; + p.displayed = p.displayed.filter((id) => id !== instanceId); + return card; } // --- Movement --------------------------------------------------------------- @@ -554,19 +974,43 @@ function doMove(prev: GameState, direction: Side): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left"); + const mover = activePlayer(prev); + if (sustainedOn(prev, mover.id, "medusa").length > 0) return err("you are paralyzed by Medusa"); const state = clone(prev); const p = activePlayer(state); - const target = stepTarget(boardView(state), p.position, direction); - if (target.kind === "blocked") return err(`blocked by ${target.by}`); + const view = boardView(state); + const target = stepTarget(view, p.position, direction); const from = p.position; - p.position = target.to; + let via: "step" | "warp" | "passWall"; + if (target.kind === "blocked") { + const key = edgeKey(p.position, direction); + const edge = view.edges[key] ?? "open"; + const dest = neighbor(p.position, direction); + // A locked door that has been unlocked or de-locked is passable. + if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) { + if (!view.cells[cellKey(dest)]) return err("blocked"); + p.position = dest; + via = "step"; + } else if (edge === "wall" && p.passWallCharges > 0 && view.cells[cellKey(dest)]) { + // PASS THROUGH WALL: one charge, one wall. + p.passWallCharges--; + p.position = dest; + via = "passWall"; + } else { + return err(`blocked by ${target.by}`); + } + } else { + p.position = target.to; + via = target.kind; + } + state.turn.movementUsed++; return { ok: true, state, - events: [{ type: "moved", player: p.id, from, to: p.position, direction, via: target.kind }], + events: [{ type: "moved", player: p.id, from, to: p.position, direction, via }], }; } @@ -574,6 +1018,10 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandRe const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); if (prev.turn.numberPlayedForMovement) return err("only one number card may boost movement per turn"); + const mover = activePlayer(prev); + if (sustainedOn(prev, mover.id, "slow").length > 0) { + return err("you are slowed — no number cards for movement"); + } const state = clone(prev); const p = activePlayer(state); @@ -605,6 +1053,13 @@ function attackPreconditions(state: GameState): string | null { 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"; + if (state.turn.attackForbidden) return "you are slowed — no attack this turn"; + return null; +} + +function castingBlocked(state: GameState, playerId: PlayerId): string | null { + if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa"; + if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast"; return null; } @@ -627,6 +1082,8 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult { defenderId: target.id, attackCard: null, numberValue: null, + amplifyFactor: 1, + extendFactor: 1, params: null, kind: "physical", counters: [], @@ -639,6 +1096,95 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult { }; } +interface CastConsumables { + numbers: CardInstance[]; + amplifies: CardInstance[]; + add: CardInstance | null; + extend: CardInstance | null; + magnitude: Magnitude; +} + +/** Validate and gather the number/modifier cards attached to a cast. */ +function gatherModifiers( + caster: PlayerState, + cmd: Extract, +): CastConsumables | string { + const numberIds = [...(cmd.numberInstanceIds ?? [])]; + if (cmd.numberInstanceId && !numberIds.includes(cmd.numberInstanceId)) { + numberIds.push(cmd.numberInstanceId); + } + const find = (id: string) => caster.hand.find((c) => c.instanceId === id); + + const numbers: CardInstance[] = []; + for (const id of numberIds) { + const c = find(id); + if (!c) return "number card not in hand"; + if (!isNumberCard(c.cardId)) return "that is not a number card"; + numbers.push(c); + } + + let add: CardInstance | null = null; + if (cmd.addInstanceId) { + const c = find(cmd.addInstanceId); + if (!c || c.cardId !== "add") return "ADD card not in hand"; + add = c; + } + // "Only one NUMBER card can be played per action" — ADD permits two. + if (numbers.length > (add ? 2 : 1)) { + return add ? "ADD permits at most two number cards" : "only one number card per action (use ADD for two)"; + } + + const amplifies: CardInstance[] = []; + for (const id of cmd.amplifyInstanceIds ?? []) { + const c = find(id); + if (!c || c.cardId !== "amplify") return "AMPLIFY card not in hand"; + amplifies.push(c); + } + if (amplifies.length > 2) return "at most two AMPLIFY cards may be combined"; + + let extend: CardInstance | null = null; + if (cmd.extendInstanceId) { + const c = find(cmd.extendInstanceId); + if (!c || c.cardId !== "extend") return "EXTEND card not in hand"; + extend = c; + } + + const sum = numbers.length > 0 ? numbers.reduce((t, c) => t + numberValue(c.cardId), 0) : null; + const amp = 2 ** amplifies.length; + const ext = extend ? 2 : 1; + return { + numbers, + amplifies, + add, + extend, + magnitude: { + numberValue: sum, + power: (sum ?? 1) * amp, + duration: (sum ?? 1) * amp * ext, + }, + }; +} + +function consumeCast( + state: GameState, + caster: PlayerState, + card: CardInstance, + mods: CastConsumables, + keepInHand: boolean, +): void { + if (keepInHand) { + if (!caster.displayed.includes(card.instanceId)) caster.displayed.push(card.instanceId); + } else { + takeFromHand(caster, card.instanceId); + state.discard.push(card); + } + for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend]) { + if (!c) continue; + takeFromHand(caster, c.instanceId); + state.discard.push(c); + } +} + function doCast(prev: GameState, cmd: Extract): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); @@ -651,18 +1197,23 @@ function doCast(prev: GameState, cmd: Extract): Comma 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 === "counter") { + return err(`${def.name} is a counteraction — play it in response to an attack`); } + // Physical actions (objects like MASTER KEY) are not spells; spells are + // blocked by NO SPELL / MEDUSA. + const isSpell = def.cardType !== "object"; + if (isSpell) { + const castBlock = castingBlocked(state, caster.id); + if (castBlock) return err(castBlock); + } else if (sustainedOn(state, caster.id, "medusa").length > 0) { + return err("you are paralyzed by Medusa"); + } + + const mods = gatherModifiers(caster, cmd); + if (typeof mods === "string") return err(mods); + if (effect.kind === "attack") { const pre = attackPreconditions(state); if (pre) return err(pre); @@ -670,12 +1221,18 @@ function doCast(prev: GameState, cmd: Extract): Comma 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.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { + return err("you must be in the same square"); + } 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 (effect.validate) { + const problem = effect.validate(state, cmd); + if (problem) return err(problem); + } if (inHand.cardId === "waterbolt") { - const total = numValue ?? 1; + const total = mods.magnitude.power; const d = cmd.params?.damage ?? total; const k = cmd.params?.knockback ?? 0; if (d < 0 || k < 0 || d + k !== total) { @@ -683,70 +1240,62 @@ function doCast(prev: GameState, cmd: Extract): Comma } } - takeFromHand(caster, cmd.instanceId); - state.discard.push(inHand); - if (numberCard) { - takeFromHand(caster, numberCard.instanceId); - state.discard.push(numberCard); - } + consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); state.turn.attackUsed = true; state.stack = { attackerId: caster.id, defenderId: target.id, attackCard: inHand, - numberValue: numValue, + numberValue: mods.magnitude.numberValue, + amplifyFactor: 2 ** mods.amplifies.length, + extendFactor: mods.extend ? 2 : 1, 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, + numberCards: mods.numbers, + numberValue: mods.magnitude.numberValue, from: caster.position, - target: null, - targetCell: cmd.target?.kind === "edge" ? cmd.target.cell : null, + target: target.id, + targetCell: target.position, }]; - // 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); + if (effect.keepInHand) { + events.push({ type: "cardDisplayed", player: caster.id, card: inHand }); } - 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`); + // Neutral: validate on a preview clone before consuming any cards. + const events: GameEvent[] = [{ + type: "spellCast", + caster: caster.id, + card: inHand, + cardId: inHand.cardId, + numberCards: mods.numbers, + numberValue: mods.magnitude.numberValue, + from: caster.position, + target: cmd.target?.kind === "player" ? cmd.target.playerId : null, + targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null, + }]; + const preview = clone(state); + const problem = (effect as NeutralEffect).resolve( + preview, [], activePlayer(preview), cmd, mods.magnitude, + ); + if (problem) return err(problem); + + consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); + if (effect.keepInHand) { + events.push({ type: "cardDisplayed", player: caster.id, card: inHand }); + } + const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude); + if (result) return err(result); // unreachable after preview + return { ok: true, state, events }; } function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): CommandResult { @@ -757,16 +1306,25 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): if (!card) return err("card not in hand"); const def = cardDef(card.cardId); + // "Opponent cannot move or cast spells, including COUNTERACTIONs" (MEDUSA); + // NO SPELL blocks all spells too. + const castBlock = castingBlocked(state, playerId); + if (castBlock) return err(castBlock); + 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); + if (di !== -1) { + state.discard.splice(di, 1); + } else { + // Displayed attack (WIZARDBLADE): take it from the attacker's hand. + const attacker = state.players.find((p) => p.id === stack.attackerId)!; + takeFromHand(attacker, attackCard.instanceId); + } player.hand.push(attackCard); const events: GameEvent[] = [ { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }, @@ -779,13 +1337,13 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): } const isCounter = def.cardType === "counteraction" || def.cardType === "neutral/counteraction"; - if (!isCounter || !(card.cardId in CARD_EFFECTS)) { + if (!isCounter || !(card.cardId in CARD_EFFECTS) || CARD_EFFECTS[card.cardId]!.kind !== "counter") { 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) + stack.waitingOn = stack.attackerId; return { ok: true, state, @@ -793,9 +1351,8 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): }; } - // 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)"); + if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction (for now)"); const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified); if (!targetCounter) return err("no counteraction to nullify"); takeFromHand(player, instanceId); @@ -816,11 +1373,9 @@ 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); @@ -835,16 +1390,48 @@ function resolveStack(state: GameState, events: GameEvent[]): void { 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 + + // Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and + // SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." + if (sustainedOn(state, defender.id, "invisible").length > 0) { + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + if (roll !== 1) { + events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" }); + events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false }); + return; + } + } + if (sustainedOn(state, defender.id, "shrink").length > 0) { + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + if (roll > 2) { + events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" }); + events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false }); + return; + } + } + + let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1 + // STONE DEAD: number x the stones the defender carries. + if (attackId === "stone-dead") { + base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).length; + } + base *= stack.amplifyFactor; + const baseDuration = effect?.sustains + ? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor + : 0; const pipe: DamagePipeline = { damage: base, + duration: baseDuration, reflectedDamage: 0, + splitDuration: false, redirected: false, fullyStopped: false, + reversed: 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]; @@ -853,19 +1440,30 @@ function resolveStack(state: GameState, events: GameEvent[]): void { 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); } + if (effect?.sustains && pipe.duration > 0) { + attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration); + } } else { - if (pipe.damage > 0) { + if (pipe.reversed && pipe.damage > 0 && pipe.kind === "spell") { + defender.life += pipe.damage; + events.push({ type: "lifeGained", player: defender.id, amount: pipe.damage, source: `${attackId} (reversed)`, lifeAfter: defender.life }); + damageDealt = pipe.damage; // secondary effects still take effect + } 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); } + if (effect?.sustains && pipe.duration > 0 && !pipe.fullyStopped) { + attachSustained(state, events, attackId!, attacker.id, defender.id, pipe.duration); + if (pipe.splitDuration) { + attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration); + } + } } events.push({ @@ -875,12 +1473,10 @@ function resolveStack(state: GameState, events: GameEvent[]): void { attackCardId: attackId, damageDealt, reflectedDamage: pipe.redirected ? pipe.damage : pipe.reflectedDamage, - fullyStopped: pipe.fullyStopped || (damageDealt === 0 && !pipe.redirected), + fullyStopped: pipe.fullyStopped || (damageDealt === 0 && !pipe.redirected && !effect?.sustains), 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, @@ -889,12 +1485,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void { defender, damageDealt, fullyStopped: pipe.fullyStopped, + duration: pipe.duration, stack, }); } } -/** Push the defender directly away from the attacker, stopping at walls. */ function knockBack( state: GameState, events: GameEvent[], @@ -904,11 +1500,16 @@ function knockBack( ): 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; + if (!dir) { + // Same square (e.g. GO AWAY point blank): random direction, per the die's + // "random direction" use. + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + dir = SIDES[roll - 1]!; + } const from = defender.position; let moved = 0; @@ -924,7 +1525,6 @@ function knockBack( } } -/** Damage, death, killer-takes-cards, elimination — shared by all sources. */ function applyDamage( state: GameState, events: GameEvent[], @@ -933,6 +1533,12 @@ function applyDamage( source: string, attackerId: PlayerId | null, ): void { + // MEDUSA: "opponent is also immune to any damage." + if (sustainedOn(state, target.id, "medusa").length > 0) { + events.push({ type: "damageImmune", player: target.id, source, because: "medusa" }); + return; + } + target.life -= amount; events.push({ type: "damaged", player: target.id, amount, source, lifeAfter: target.life }); if (target.life > 0) return; @@ -940,6 +1546,7 @@ function applyDamage( target.alive = false; events.push({ type: "died", player: target.id, killedBy: attackerId }); events.push({ type: "playerEliminated", player: target.id, reason: "killed" }); + state.sustained = state.sustained.filter((s) => s.targetId !== target.id && s.casterId !== target.id); if (target.carriedTreasureId) { const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!; @@ -958,12 +1565,14 @@ function applyDamage( 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); + target.displayed = []; killer.hand.push(...taken); events.push({ type: "handTaken", from: target.id, to: killer.id, count: taken.length }); events.push({ type: "handTakenPrivate", visibleTo: killer.id, cards: taken }); if (killer.hand.length > HAND_LIMIT) state.pendingDiscard = killer.id; } else if (target.hand.length > 0) { state.discard.push(...target.hand.splice(0)); + target.displayed = []; } } @@ -1017,7 +1626,6 @@ function doDropTreasure(prev: GameState): CommandResult { return { ok: true, state, events }; } -/** Both win conditions plus treasure-loss elimination. */ function checkVictory(state: GameState, events: GameEvent[]): void { if (state.phase !== "playing") return; @@ -1033,6 +1641,7 @@ function checkVictory(state: GameState, events: GameEvent[]): void { if (lost) { p.alive = false; state.discard.push(...p.hand.splice(0)); + p.displayed = []; events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" }); } } @@ -1078,8 +1687,6 @@ function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]): 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; @@ -1090,6 +1697,51 @@ function drawOne(state: GameState, events: GameEvent[]): CardInstance | null { return state.deck.shift()!; } +/** Sustained-effect upkeep + turn flags when `player` begins a turn. */ +function beginTurnFor(state: GameState, events: GameEvent[], index: number): void { + const player = state.players[index]!; + + // Duration spells expire at the start of their CASTER's turns. + const surviving: SustainedEffect[] = []; + for (const s of state.sustained) { + if (s.casterId === player.id) { + s.remainingTurns--; + if (s.remainingTurns <= 0) { + events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId }); + continue; + } + } + surviving.push(s); + } + state.sustained = surviving; + + // Movement allowance: SLOW forces 1, SHRINK forces 2, else base 3. + let allowance = BASE_MOVEMENT; + if (sustainedOn(state, player.id, "shrink").length > 0) allowance = Math.min(allowance, 2); + const slows = sustainedOn(state, player.id, "slow"); + if (slows.length > 0) allowance = 1; + + // SLOW: "his attacks [reduce] to every other turn, starting on his next + // turn" — forbidden on the 1st, 3rd, ... slowed turns. + let attackForbidden = false; + for (const s of slows) { + s.data.turnCount = (s.data.turnCount ?? 0) + 1; + if (s.data.turnCount % 2 === 1) attackForbidden = true; + } + + state.turn = { + round: state.turn.round, + firstIndex: state.turn.firstIndex, + activeIndex: index, + movementAllowance: allowance, + movementUsed: 0, + numberPlayedForMovement: false, + attackUsed: false, + attackForbidden, + actionsEnded: false, + }; +} + function doEndTurn(prev: GameState, draw: number): CommandResult { if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`); @@ -1105,13 +1757,11 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { 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 + continue; } drawn.push(card); toDraw--; @@ -1121,25 +1771,22 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { events.push({ type: "cardsDrawnPrivate", visibleTo: p.id, cards: drawn }); } + // Doors unlocked this turn relock ("the door will relock behind you"). + if (state.openDoorEdges.length > 0) { + events.push({ type: "doorsRelocked", count: state.openDoorEdges.length }); + state.openDoorEdges = []; + } + events.push({ type: "turnEnded", player: p.id }); - // 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, - }; + beginTurnFor(state, events, state.turn.activeIndex); 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; for (;;) { @@ -1155,16 +1802,7 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { break; } - state.turn = { - round: state.turn.round, - firstIndex: state.turn.firstIndex, - activeIndex: next, - movementAllowance: BASE_MOVEMENT, - movementUsed: 0, - numberPlayedForMovement: false, - attackUsed: false, - actionsEnded: false, - }; + beginTurnFor(state, events, next); events.push({ type: "turnStarted", player: state.players[next]!.id, round: state.turn.round }); return { ok: true, state, events }; } diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index a8daf00..25336f2 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -292,7 +292,7 @@ describe("stack discipline", () => { 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 card = giveCard(state, caster.id, "ugly"); 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/wave2.test.ts b/packages/engine/test/wave2.test.ts new file mode 100644 index 0000000..2821b03 --- /dev/null +++ b/packages/engine/test/wave2.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + activePlayer, + createGame, + boardView, + sustainedOn, + type Command, + type GameState, + type PlayerId, +} from "../src/game"; +import { cellKey, edgeKey, 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; +} + +function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance { + const p = state.players.find((p) => p.id === playerId)!; + const instance = { instanceId: `${cardId}#${tag}`, cardId }; + p.hand[slot] = instance; + return instance; +} + +function toRound2(state: GameState): GameState { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + return state; +} + +function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } { + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + defender.position = { ...attacker.position }; + return { attacker: attacker.id, defender: defender.id }; +} + +function castAt( + state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance, + extra: Partial> = {}, +): GameState { + state = must(state, attacker, { + type: "cast", instanceId: card.instanceId, + target: { kind: "player", playerId: defender }, ...extra, + }); + return must(state, defender, { type: "pass" }); +} + +describe("duration spells", () => { + it("slow reduces movement to 1, blocks number cards, and halves attacks", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const slow = giveCard(state, attacker, "slow"); + giveCard(state, attacker, "number-4", "N", 1); + state = castAt(state, attacker, defender, slow, { numberInstanceIds: ["number-4#N"] }); + expect(sustainedOn(state, defender, "slow").length).toBe(1); + + // Defender's turn: 1 movement, no number cards, no attack (1st slowed turn). + state = must(state, attacker, { type: "endTurn", draw: 0 }); + expect(activePlayer(state).id).toBe(defender); + expect(state.turn.movementAllowance).toBe(1); + expect(state.turn.attackForbidden).toBe(true); + const num = giveCard(state, defender, "number-3", "M", 0); + expect(applyCommand(state, defender, { type: "playNumberForMovement", instanceId: num.instanceId }).ok).toBe(false); + + // Second slowed turn: attack allowed again. + state = must(state, defender, { type: "endTurn", draw: 0 }); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + expect(activePlayer(state).id).toBe(defender); + expect(state.turn.attackForbidden).toBe(false); + }); + + it("duration spells expire at the start of the caster's turn", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const ns = giveCard(state, attacker, "no-spell"); + // Duration 1 (no number card). + state = castAt(state, attacker, defender, ns); + expect(sustainedOn(state, defender, "no-spell").length).toBe(1); + + // Defender cannot cast while it lasts. + const fb = giveCard(state, defender, "fireball", "F", 1); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + const refused = applyCommand(state, defender, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker }, + }); + expect(refused.ok).toBe(false); + + // Back to the caster: the spell expires at their turn start. + state = must(state, defender, { type: "endTurn", draw: 0 }); + expect(sustainedOn(state, defender, "no-spell").length).toBe(0); + }); + + it("medusa paralyzes and grants damage immunity", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const med = giveCard(state, attacker, "medusa"); + giveCard(state, attacker, "number-2", "N", 1); + // Duration 2 so it survives past the caster's next turn start. + state = castAt(state, attacker, defender, med, { numberInstanceIds: ["number-2#N"] }); + + // Defender is immune to damage while paralyzed. + state = must(state, attacker, { type: "endTurn", draw: 0 }); + expect(activePlayer(state).id).toBe(defender); + expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false); + const counter = giveCard(state, defender, "blunt", "B", 0); + void counter; + state = must(state, defender, { type: "endTurn", draw: 0 }); + + // Attacker punches the frozen defender: no damage. + state = must(state, attacker, { type: "punch", targetId: defender }); + // Defender cannot counteract under medusa; they pass. + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(15); + }); + + it("invisible makes attacks miss 3 times out of 4 (deterministic per seed)", () => { + let hits = 0, misses = 0; + for (let seed = 1; seed <= 12; seed++) { + let { state } = newGame(seed); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const inv = giveCard(state, defender, "invisible", "I", 0); + // Defender casts invisible on their own turn first — rearrange: give + // attacker the attack, let defender cast invisible when active. + // Simpler: attach directly via a cast from the defender's turn. + state.players.find((p) => p.id === defender)!.hand[0] = inv; + // Attacker ends turn; defender casts invisible; attacker attacks. + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = must(state, defender, { type: "cast", instanceId: inv.instanceId }); + state = must(state, defender, { type: "endTurn", draw: 0 }); + const fb = giveCard(state, attacker, "fireball", "F", 0); + state = castAt(state, attacker, defender, fb); + const life = state.players.find((p) => p.id === defender)!.life; + if (life < 15) hits++; + else misses++; + } + expect(hits + misses).toBe(12); + expect(misses).toBeGreaterThan(hits); // 75% miss rate over 12 seeds + }); +}); + +describe("doors", () => { + function findDoor(state: GameState): { cell: { x: number; y: number }; side: Side } { + const view = boardView(state); + for (const [key, edgeState] of Object.entries(view.edges)) { + if (edgeState !== "door") continue; + const [kind, coords] = key.split(":") as [string, string]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + return kind === "V" ? { cell: { x, y }, side: "E" } : { cell: { x, y }, side: "S" }; + } + throw new Error("no door on this board"); + } + + it("pick lock opens an adjacent door until end of turn", () => { + let { state } = newGame(); + const me = activePlayer(state); + const door = findDoor(state); + me.position = { ...door.cell }; + const other = neighbor(door.cell, door.side); + const dir = door.side; + + // Locked: blocked. + expect(applyCommand(state, me.id, { type: "move", direction: dir }).ok).toBe(false); + + const pl = giveCard(state, me.id, "pick-lock"); + state = must(state, me.id, { + type: "cast", instanceId: pl.instanceId, + target: { kind: "edge", cell: door.cell, side: door.side }, + }); + state = must(state, me.id, { type: "move", direction: dir }); + expect(cellKey(activePlayer(state).position)).toBe(cellKey(other)); + + // Relocks when the turn ends. + state = must(state, me.id, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + expect(state.openDoorEdges.length).toBe(0); + expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false); + }); + + it("remove lock is permanent; jam lock seals the door for everyone", () => { + let { state } = newGame(); + const me = activePlayer(state); + const door = findDoor(state); + me.position = { ...door.cell }; + const key = edgeKey(door.cell, door.side); + + const rl = giveCard(state, me.id, "remove-lock"); + state = must(state, me.id, { + type: "cast", instanceId: rl.instanceId, + target: { kind: "edge", cell: door.cell, side: door.side }, + }); + expect(state.doorStates[key]).toBe("removed"); + state = must(state, me.id, { type: "move", direction: door.side }); + + // Jamming a removed lock is refused. + const jl = giveCard(state, me.id, "jam-lock"); + const refused = applyCommand(state, me.id, { + type: "cast", instanceId: jl.instanceId, + target: { kind: "edge", cell: door.cell, side: door.side }, + }); + expect(refused.ok).toBe(false); + }); +}); + +describe("movement spells", () => { + it("teleport jumps up to four spaces through walls and ends movement", () => { + let { state } = newGame(); + const me = activePlayer(state); + const from = me.position; + const tp = giveCard(state, me.id, "teleport"); + const far = { x: from.x, y: from.y >= 4 ? from.y - 4 : from.y + 4 }; + state = must(state, me.id, { + type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: far }, + }); + expect(cellKey(activePlayer(state).position)).toBe(cellKey(far)); + expect(applyCommand(state, me.id, { type: "move", direction: "N" }).ok).toBe(false); + }); + + it("teleport refuses jumps beyond four spaces", () => { + const { state } = newGame(); + const me = activePlayer(state); + const tp = giveCard(state, me.id, "teleport"); + const tooFar = { x: me.position.x, y: me.position.y >= 5 ? me.position.y - 5 : me.position.y + 5 }; + const result = applyCommand(state, me.id, { + type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: tooFar }, + }); + expect(result.ok).toBe(false); + }); + + it("swap trades places and consumes movement", () => { + let { state } = newGame(); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + const aPos = { ...attacker.position }; + const bPos = { ...defender.position }; + const sw = giveCard(state, attacker.id, "swap"); + state = castAt(state, attacker.id, defender.id, sw); + expect(cellKey(state.players.find((p) => p.id === attacker.id)!.position)).toBe(cellKey(bPos)); + expect(cellKey(state.players.find((p) => p.id === defender.id)!.position)).toBe(cellKey(aPos)); + expect(state.turn.movementUsed).toBe(state.turn.movementAllowance); + }); + + it("power run trades life for movement", () => { + let { state } = newGame(); + const me = activePlayer(state); + const pr = giveCard(state, me.id, "power-run"); + state = must(state, me.id, { + type: "cast", instanceId: pr.instanceId, params: { points: 3 }, + }); + expect(state.players.find((p) => p.id === me.id)!.life).toBe(12); + expect(state.turn.movementAllowance).toBe(6); + }); + + it("pass through wall grants a one-wall step", () => { + let { state } = newGame(); + const me = activePlayer(state); + // Find a direction blocked by a wall with a real cell behind it. + const view = boardView(state); + let dir: Side | null = null; + for (const side of ["N", "S", "E", "W"] as Side[]) { + const k = edgeKey(me.position, side); + if (view.edges[k] === "wall" && view.cells[cellKey(neighbor(me.position, side))]) { + dir = side; + break; + } + } + if (!dir) return; // no adjacent wall on this seed's home; fine + const ptw = giveCard(state, me.id, "pass-through-wall"); + state = must(state, me.id, { type: "cast", instanceId: ptw.instanceId }); + state = must(state, me.id, { type: "move", direction: dir }); + expect(state.players.find((p) => p.id === me.id)!.passWallCharges).toBe(0); + }); +}); + +describe("card warfare", () => { + it("card erasure discards a named card; thought steal takes two at random", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + giveCard(state, defender, "fireball", "V", 0); + const ce = giveCard(state, attacker, "card-erasure"); + state = castAt(state, attacker, defender, ce, { params: { cardId: "fireball" } }); + const d = state.players.find((p) => p.id === defender)!; + expect(d.hand.some((c) => c.cardId === "fireball")).toBe(false); + expect(d.hand.length).toBe(6); + + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = must(state, defender, { type: "endTurn", draw: 0 }); + const ts = giveCard(state, attacker, "thought-steal"); + const handBefore = state.players.find((p) => p.id === attacker)!.hand.length; + state = castAt(state, attacker, defender, ts); + // -1 (thought steal cast) +2 stolen + expect(state.players.find((p) => p.id === attacker)!.hand.length).toBe(handBefore + 1); + expect(state.players.find((p) => p.id === defender)!.hand.length).toBe(4); + }); + + it("power drain transfers life; sudden death does 10", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const pd = giveCard(state, attacker, "power-drain"); + giveCard(state, attacker, "number-4", "N", 1); + state = castAt(state, attacker, defender, pd, { numberInstanceIds: ["number-4#N"] }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(11); + expect(state.players.find((p) => p.id === attacker)!.life).toBe(19); + + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = must(state, defender, { type: "endTurn", draw: 0 }); + const sd = giveCard(state, attacker, "sudden-death"); + state = castAt(state, attacker, defender, sd); + expect(state.players.find((p) => p.id === defender)!.life).toBe(1); + }); + + it("wizardblade needs the same square, uses a number card, and stays displayed", () => { + let { state } = newGame(); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + const wb = giveCard(state, attacker.id, "wizardblade"); + giveCard(state, attacker.id, "number-3", "N", 1); + + // Not same square: refused. + defender.position = { x: attacker.position.x, y: attacker.position.y === 0 ? 1 : attacker.position.y - 1 }; + const refused = applyCommand(state, attacker.id, { + type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-3#N"], + target: { kind: "player", playerId: defender.id }, + }); + expect(refused.ok).toBe(false); + + defender.position = { ...attacker.position }; + state = castAt(state, attacker.id, defender.id, wb, { numberInstanceIds: ["number-3#N"] }); + expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12); + const a = state.players.find((p) => p.id === attacker.id)!; + expect(a.hand.some((c) => c.cardId === "wizardblade")).toBe(true); + expect(a.displayed).toContain(wb.instanceId); + }); +}); + +describe("cast modifiers", () => { + it("amplify doubles fireball; add joins two number cards", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const fb = giveCard(state, attacker, "fireball"); + giveCard(state, attacker, "amplify", "A", 1); + state = castAt(state, attacker, defender, fb, { amplifyInstanceIds: ["amplify#A"] }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5 x2 + + state = must(state, attacker, { type: "endTurn", draw: 0 }); + state = must(state, defender, { type: "endTurn", draw: 0 }); + const lb = giveCard(state, attacker, "lightning-blast"); + giveCard(state, attacker, "number-2", "N1", 1); + giveCard(state, attacker, "number-3", "N2", 2); + giveCard(state, attacker, "add", "AD", 3); + // Two numbers without ADD: refused. + const refused = applyCommand(state, attacker, { + type: "cast", instanceId: lb.instanceId, + numberInstanceIds: ["number-2#N1", "number-3#N2"], + target: { kind: "player", playerId: defender }, + }); + expect(refused.ok).toBe(false); + state = castAt(state, attacker, defender, lb, { + numberInstanceIds: ["number-2#N1", "number-3#N2"], + addInstanceId: "add#AD", + }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(0); // 5 dmg on 5 life + }); + + it("reverse turns damage into healing but keeps secondary effects", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const lb = giveCard(state, attacker, "lightning-blast"); + giveCard(state, attacker, "number-4", "N", 1); + giveCard(state, defender, "reverse", "R", 0); + state = must(state, attacker, { + type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-4#N"], + target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "counteract", instanceId: "reverse#R" }); + state = must(state, attacker, { type: "pass" }); + state = must(state, defender, { type: "pass" }); + const d = state.players.find((p) => p.id === defender)!; + expect(d.life).toBe(19); // gained 4 instead of losing it + expect(d.lostTurns).toBe(1); // the stun still applies + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9db61dd..d864f46 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -84,9 +84,10 @@ wss.on("connection", (socket) => { case "create": { const name = String(msg.name ?? "").trim(); if (!name) return send(socket, { type: "error", message: "name required" }); - const room = createRoom(name); + const { room, token } = createRoom(name); session.playerId = name; session.roomId = room.id; + send(socket, { type: "seat", playerId: name, token }); broadcastRoomState(room); break; } @@ -96,10 +97,11 @@ wss.on("connection", (socket) => { if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" }); const room = getRoom(roomId); if (!room) return send(socket, { type: "error", message: "no such room" }); - const problem = joinRoom(room, name); - if (problem) return send(socket, { type: "error", message: problem }); + const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null); + if ("error" in result) return send(socket, { type: "error", message: result.error }); session.playerId = name; session.roomId = room.id; + send(socket, { type: "seat", playerId: name, token: result.token }); broadcastRoomState(room); break; } diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index a3e5813..c1bcf15 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -2,6 +2,7 @@ // the append-only command log (the seed + log IS the game — the basis for // replays and async play). Clients get per-player redacted views and events. +import { randomBytes, randomInt } from "node:crypto"; import { applyCommand, createGame, @@ -25,6 +26,8 @@ export interface Room { id: string; hostId: PlayerId; players: PlayerId[]; // join order + /** Per-player secrets: reclaiming a seat requires the matching token. */ + tokens: Map; seed: number; state: GameState | null; // null until started log: LoggedCommand[]; @@ -38,35 +41,47 @@ const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; function makeRoomCode(): string { let code = ""; for (let i = 0; i < 4; i++) { - code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)]; + code += ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)]; } return rooms.has(code) ? makeRoomCode() : code; } -export function createRoom(hostId: PlayerId): Room { +export function createRoom(hostId: PlayerId): { room: Room; token: string } { + const token = randomBytes(16).toString("hex"); const room: Room = { id: makeRoomCode(), hostId, players: [hostId], - seed: Math.floor(Math.random() * 0xffffffff), + tokens: new Map([[hostId, token]]), + seed: randomInt(0, 0xffffffff), state: null, log: [], events: [], }; rooms.set(room.id, room); - return room; + return { room, token }; } export function getRoom(id: string): Room | undefined { return rooms.get(id.toUpperCase()); } -export function joinRoom(room: Room, playerId: PlayerId): string | null { - if (room.state) return "game already started"; - if (room.players.includes(playerId)) return null; // rejoin is fine - if (room.players.length >= 4) return "room is full"; +export function joinRoom( + room: Room, + playerId: PlayerId, + token: string | null, +): { token: string } | { error: string } { + if (room.players.includes(playerId)) { + // Reclaiming an existing seat requires that seat's secret. + if (token && room.tokens.get(playerId) === token) return { token }; + return { error: "that wizard name is taken in this room" }; + } + if (room.state) return { error: "game already started" }; + if (room.players.length >= 4) return { error: "room is full" }; + const fresh = randomBytes(16).toString("hex"); room.players.push(playerId); - return null; + room.tokens.set(playerId, fresh); + return { token: fresh }; } export function startGame(room: Room): { events: GameEvent[] } | { error: string } { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index db95b0d..a1545b7 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -34,6 +34,28 @@ function humanize(e: GameEvent): string | null { case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`; case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`; + case "attackMissed": return e.because === "invisible" + ? `The attack passes through empty air — ${e.defender} is invisible!` + : `${e.defender} is too small to hit — the attack misses!`; + case "damageImmune": return `${e.player} is stone — the damage has no effect.`; + case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`; + case "spellSustained": return `${cardDef(e.cardId).name} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; + case "spellExpired": return `${cardDef(e.cardId).name} wears off ${e.target}.`; + case "teleported": return e.by === e.player + ? `${e.player} teleports across the maze!` + : `${e.player} is teleported away by ${e.by}!`; + case "positionsSwapped": return `${e.a} and ${e.b} swap places!`; + case "cardErased": return e.found + ? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!` + : `${e.player} wasn't holding that card — the erasure fizzles.`; + case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`; + case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`; + case "doorUnlocked": return `${e.player} unlocks a door.`; + case "doorsRelocked": return `The door swings shut and relocks.`; + case "doorJammed": return `${e.player} jams a door's lock solid.`; + case "lockRemoved": return `${e.player} removes a door's lock for good.`; + case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`; + case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`; case "trapRedrawnDuringDeal": return null; case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`; case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`; @@ -61,6 +83,7 @@ class Net { error = $state(null); private ws: WebSocket | null = null; + private token: string | null = null; connect(): void { if (this.ws) return; @@ -75,6 +98,9 @@ class Net { ws.onmessage = (raw) => { const msg = JSON.parse(raw.data as string); switch (msg.type) { + case "seat": + this.token = msg.token; + break; case "room": this.roomId = msg.roomId; this.players = msg.players; @@ -109,7 +135,7 @@ class Net { join(roomId: string, name: string): void { this.you = name; - this.send({ type: "join", roomId, name }); + this.send({ type: "join", roomId, name, token: this.token }); } start(): void {