diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 6b1086d..843fb0f 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -148,6 +148,8 @@ export interface CastStack { /** Power/duration multiplier from AMPLIFY (and EXTEND for durations). */ amplifyFactor: number; extendFactor: number; + /** POWER ATTACK: extra damage bought with the caster's life. */ + powerAttackPoints: number; params: CastParams | null; kind: "spell" | "physical"; counters: { player: PlayerId; card: CardInstance; nullified: boolean }[]; @@ -203,6 +205,12 @@ export interface GameState { gluedCells: Record; /** SAFE cells unlocked until end of turn (lock cards / the creator). */ openSafes: string[]; + /** SWARTHMORE'S ENCHANTMENT: enchanted object instances (+1 magical). */ + enchantedObjects: Record; + /** DIMENSIONAL WARP token pairs. */ + dimWarps: { a: Cell; b: Cell }[]; + /** INTERRUPT / OPPORTUNITY FIRE: one out-of-turn action window. */ + outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null; players: PlayerState[]; treasures: TreasureState[]; sustained: SustainedEffect[]; @@ -233,6 +241,10 @@ export function gameLos(state: GameState, from: Cell, to: Cell): boolean { for (const [key, content] of Object.entries(state.squareContents)) { if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true; } + // BIG MAN: you cannot cast spells past him. + for (const p of state.players) { + if (p.alive && sustainedOn(state, p.id, "big-man").length > 0) blockers[cellKey(p.position)] = true; + } return hasLineOfSight(boardView(state), from, to, blockers); } @@ -459,6 +471,20 @@ export type GameEvent = | { type: "safeOpened"; player: PlayerId; at: Cell } | { type: "itemsTraded"; caster: PlayerId; a: Cell; b: Cell } | { type: "stoneTurnedToWater"; caster: PlayerId; at: Cell | null } + | { type: "handsSwapped"; a: PlayerId; b: PlayerId } + | { type: "handsScrambled"; caster: PlayerId } + | { type: "rammed"; attacker: PlayerId; target: PlayerId; distance: number } + | { type: "treasureThrown"; attacker: PlayerId; at: Cell; distance: number } + | { type: "illusionBelieved"; player: PlayerId; cardId: string; believed: boolean } + | { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string } + | { type: "itemsSwapped"; a: PlayerId; b: PlayerId } + | { type: "wardSprung"; owner: PlayerId; victim: PlayerId } + | { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string } + | { type: "objectEnchanted"; caster: PlayerId; cardId: string } + | { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell } + | { type: "warpStepped"; player: PlayerId; from: Cell; to: Cell } + | { type: "exitsRedirected"; caster: PlayerId } + | { type: "outOfTurnWindow"; player: PlayerId; kind: "interrupt" | "opportunity-fire" } | { 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 } @@ -499,6 +525,7 @@ export type Command = | { type: "move"; direction: Side } | { type: "playNumberForMovement"; instanceId: string } | { type: "punch"; targetId: PlayerId } + | { type: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } | { type: "creatureAttack"; creatureId: string; targetId: string } | { @@ -516,6 +543,9 @@ export type Command = extendInstanceId?: string; /** AROUND THE CORNER card attached (bends this cast's line of sight). */ aroundCornerInstanceId?: string; + /** POWER ATTACK card attached: burn life for extra damage. */ + powerAttackInstanceId?: string; + powerAttackPoints?: number; target?: CastTarget; params?: CastParams; } @@ -1646,6 +1676,392 @@ const CARD_EFFECTS: Record return "target a stone wall or a solid stone block"; }, }, + // --- Expansion #1: fortune and misfortune -------------------------------- + "gift-from-above": { + kind: "neutral", + // "Add three points to your total, now. You may go higher than fifteen." + resolve: (state, events, caster) => { + caster.life += 3; + events.push({ type: "lifeGained", player: caster.id, amount: 3, source: "gift from above", lifeAfter: caster.life }); + return null; + }, + }, + "power-attack": { + kind: "neutral", + // Handled as a cast modifier (powerAttackPoints); casting it alone is a + // usage error. + resolve: () => "attach Power Attack to a damage spell (choose life points to burn)", + }, + strength: { + kind: "neutral", + resolve: (state, events, caster, _cmd, magnitude) => { + attachSustained(state, events, "strength", caster.id, caster.id, magnitude.duration); + return null; + }, + }, + weakness: { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + sustains: true, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + // "Opponent drops any treasure carried." + if (ctx.defender.carriedTreasureId) { + const t = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId)!; + t.carriedBy = null; + t.position = ctx.defender.position; + ctx.defender.carriedTreasureId = null; + ctx.events.push({ + type: "treasureDropped", player: ctx.defender.id, treasureId: t.id, + at: ctx.defender.position, onHomeOf: homeOwnerAt(ctx.state, ctx.defender.position), + }); + } + }, + }, + "walking-dead": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + // "1/2 point of damage for every space moved. This spell is permanent." + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, 1_000_000_000); + }, + }, + disease: { + kind: "attack", + baseDamage: () => 0, + sameSquare: false, + validate: (state, cmd) => { + const target = state.players.find((p) => p.id === (cmd.target as { playerId?: PlayerId })?.playerId); + const caster = activePlayer(state); + if (!target) return null; + const d = Math.abs(target.position.x - caster.position.x) + Math.abs(target.position.y - caster.position.y); + return d <= 1 ? null : "disease spreads by touch — you must be adjacent"; + }, + sustains: true, + }, + empathy: { + kind: "neutral", + // Counteraction card used proactively: while it lasts, attacks against + // you act against the attacker too. + resolve: (state, events, caster, _cmd, magnitude) => { + attachSustained(state, events, "empathy", caster.id, caster.id, magnitude.duration); + return null; + }, + }, + "force-field": { + kind: "counter", + // Stops the spell attack outright (daggers and blades slip through). + apply: (p) => { + if (p.kind === "spell") { p.damage = 0; p.duration = 0; p.fullyStopped = true; } + }, + }, + "mental-swap": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; + const aHand = ctx.attacker.hand; + ctx.attacker.hand = ctx.defender.hand; + ctx.defender.hand = aHand; + const aDisp = ctx.attacker.displayed; + ctx.attacker.displayed = ctx.defender.displayed; + ctx.defender.displayed = aDisp; + ctx.events.push({ type: "handsSwapped", a: ctx.attacker.id, b: ctx.defender.id }); + const check = (p: PlayerState) => { + if (p.hand.length > handLimit(p)) ctx.state.pendingDiscard = p.id; + }; + check(ctx.attacker); + check(ctx.defender); + }, + }, + "mental-force": { + kind: "attack", + baseDamage: () => 0, // no LOS printed + validate: (state, cmd) => { + const cell = cmd.params?.cell; + if (!cell) return "say where they go (within three moved spaces)"; + if (!boardView(state).cells[cellKey(cell)]) return "off the board"; + if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; + return null; + }, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + if (isLockedInPlace(ctx.state, ctx.defender.id)) return; + const to = ctx.stack.params!.cell!; + if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return; + 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: "mental-force" }); + }, + }, + "butt-head": { + kind: "attack", + physical: true, + baseDamage: () => 0, // computed at resolution: distance rammed + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; + const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) + + Math.abs(ctx.defender.position.y - ctx.attacker.position.y); + if (d === 0) return; + ctx.attacker.position = { ...ctx.defender.position }; + ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d }); + applyDamage(ctx.state, ctx.events, ctx.defender, d, "goat ram", ctx.attacker.id, "physical"); + }, + }, + "heave-ho": { + kind: "attack", + requiresLos: true, + physical: true, + baseDamage: () => 0, + validate: (state) => { + const caster = activePlayer(state); + return caster.carriedTreasureId ? null : "you have no treasure to throw"; + }, + onResolved: (ctx) => { + if (!ctx.attacker.carriedTreasureId) return; + const t = ctx.state.treasures.find((t) => t.id === ctx.attacker.carriedTreasureId)!; + const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) + + Math.abs(ctx.defender.position.y - ctx.attacker.position.y); + t.carriedBy = null; + t.position = { ...ctx.defender.position }; + ctx.attacker.carriedTreasureId = null; + ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d }); + if (!ctx.fullyStopped && ctx.defender.alive && d > 0) { + applyDamage(ctx.state, ctx.events, ctx.defender, d, "hurled treasure", ctx.attacker.id, "physical"); + } + ctx.events.push({ + type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id, + at: t.position!, onHomeOf: homeOwnerAt(ctx.state, t.position!), + }); + checkVictory(ctx.state, ctx.events); + }, + }, + thief: { + kind: "attack", + sameSquare: true, + baseDamage: () => 0, + validate: (_s, cmd) => (cmd.params?.cardId ? null : "name the item to steal"), + onResolved: (ctx) => { + if (ctx.fullyStopped) return; + const wanted = ctx.stack.params!.cardId!; + if (wanted === "treasure") return; // "The item may not be a treasure." + const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted && cardDef(c.cardId).cardType === "object"); + if (idx === -1) return; + const [card] = ctx.defender.hand.splice(idx, 1); + ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId); + ctx.attacker.hand.push(card!); + ctx.events.push({ type: "itemStolen", from: ctx.defender.id, to: ctx.attacker.id, cardId: wanted }); + if (ctx.attacker.hand.length > handLimit(ctx.attacker)) ctx.state.pendingDiscard = ctx.attacker.id; + }, + }, + chaos: { + kind: "attack", + baseDamage: () => 0, + // Everyone's hands into one pile, shuffled, dealt back in equal counts. + // (Simplification: the FULL SHIELD opt-out and ABSORB SPELL interactions + // are not modeled — chaos resolves for all living players at once.) + onResolved: (ctx) => { + const players = ctx.state.players.filter((p) => p.alive); + const counts = players.map((p) => p.hand.length); + const pile = players.flatMap((p) => p.hand.splice(0)); + for (const p of players) p.displayed = []; + const [shuffled, rngNext] = shuffle(ctx.state.rng, pile); + ctx.state.rng = rngNext; + let i = 0; + players.forEach((p, pi) => { + p.hand = shuffled.slice(i, i + counts[pi]!); + i += counts[pi]!; + ctx.events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] }); + }); + ctx.events.push({ type: "handsScrambled", caster: ctx.attacker.id }); + }, + }, + "illusionary-attack": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + validate: (_s, cmd) => { + const chosen = cmd.params?.cardId; + if (!chosen) return "choose the attack spell to fake"; + const fx = CARD_EFFECTS[chosen]; + if (!fx || fx.kind !== "attack") return "that is not an attack spell"; + return null; + }, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + const chosen = ctx.stack.params!.cardId!; + const fx = CARD_EFFECTS[chosen] as AttackEffect; + const [roll, rngNext] = rollDie(ctx.state.rng); + ctx.state.rng = rngNext; + const believed = roll <= 2; + ctx.events.push({ type: "illusionBelieved", player: ctx.defender.id, cardId: chosen, believed }); + if (!believed) return; + const dmg = fx.baseDamage(ctx.stack.numberValue, ctx.stack.params ?? null); + if (dmg > 0) { + applyDamage(ctx.state, ctx.events, ctx.defender, dmg, `illusionary ${chosen}`, ctx.attacker.id); + } + }, + }, + "swap-meet": { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + validate: (_s, cmd) => (cmd.params?.cardId ? null : "name your item and theirs (yours;theirs)"), + onResolved: (ctx) => { + if (ctx.fullyStopped) return; + const [mineId, theirsId] = (ctx.stack.params!.cardId ?? "").split(";"); + const mine = ctx.attacker.hand.findIndex((c) => c.cardId === mineId && cardDef(c.cardId).cardType === "object"); + const theirs = ctx.defender.hand.findIndex((c) => c.cardId === theirsId && cardDef(c.cardId).cardType === "object"); + if (mine === -1 || theirs === -1) return; + const [a] = ctx.attacker.hand.splice(mine, 1); + const [b] = ctx.defender.hand.splice(theirs, 1); + ctx.attacker.hand.push(b!); + ctx.defender.hand.push(a!); + ctx.attacker.displayed = ctx.attacker.displayed.filter((id) => id !== a!.instanceId); + ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== b!.instanceId); + ctx.events.push({ type: "itemsSwapped", a: ctx.attacker.id, b: ctx.defender.id }); + }, + }, + "remove-curse": { + kind: "neutral", + // Counteraction used out of the stack: strip one duration spell. + resolve: (state, events, caster, cmd) => { + if (!cmd.target || cmd.target.kind !== "player") return "choose whose curse to remove"; + const wanted = cmd.params?.cardId; + if (!wanted) return "name the spell to remove"; + const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); + if (!target) return "no such player"; + const idx = state.sustained.findIndex((fx) => fx.targetId === target.id && fx.cardId === wanted); + if (idx === -1) return "no such spell on them"; + // "Has to hit to affect SHRINK and INVISIBLE." + if (wanted === "invisible" || wanted === "shrink") { + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + const needed = wanted === "invisible" ? 1 : 2; + if (roll > needed) { + events.push({ type: "attackMissed", attacker: caster.id, defender: target.id, attackCardId: "remove-curse", because: wanted as "invisible" | "shrink" }); + return null; + } + } + const [fx] = state.sustained.splice(idx, 1); + if (fx!.cardId === "glue" && fx!.edge) delete state.gluedCells[fx!.edge]; + events.push({ type: "curseRemoved", caster: caster.id, target: target.id, cardId: wanted }); + return null; + }, + }, + "swarthmores-enchantment": { + kind: "neutral", + resolve: (state, events, caster, cmd) => { + const wanted = cmd.params?.cardId; + if (!wanted) return "name the object to enchant"; + // Find the instance: your hand, a target player's hand, or the floor. + let instance: CardInstance | undefined = caster.hand.find((c) => c.cardId === wanted); + if (!instance && cmd.target?.kind === "player") { + const t = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); + instance = t?.hand.find((c) => c.cardId === wanted); + } + if (!instance) { + for (const objs of Object.values(state.groundObjects)) { + instance = objs.find((c) => c.cardId === wanted); + if (instance) break; + } + } + if (!instance) return "no such object in sight"; + state.enchantedObjects[instance.instanceId] = true; + events.push({ type: "objectEnchanted", caster: caster.id, cardId: wanted }); + return null; + }, + }, + ward: { + kind: "neutral", + // WARD is never cast from the hand — it springs automatically when your + // treasure is grabbed (see doPickUpTreasure). + resolve: () => "Ward waits in your hand and springs when your treasure is taken", + }, + idiot: { + kind: "attack", + requiresLos: true, + baseDamage: () => 0, + // "Opponent heads straight for the nearest of his own treasures ... This + // lasts until opponent is on his own treasure." + sustains: false, + onResolved: (ctx) => { + if (ctx.fullyStopped || !ctx.defender.alive) return; + const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position); + if (!hasTreasureOut) return; // "ends if both treasures are being carried" + attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, 1_000_000_000); + }, + }, + "big-man": { + kind: "neutral", + resolve: (state, events, caster, _cmd, magnitude) => { + attachSustained(state, events, "big-man", caster.id, caster.id, magnitude.duration); + return null; + }, + }, + fear: { + kind: "neutral", + resolve: (state, events, caster, _cmd, magnitude) => { + attachSustained(state, events, "fear", caster.id, caster.id, magnitude.duration); + return null; + }, + }, + "dimensional-warp": { + kind: "neutral", + // Two tokens anywhere (except home bases); stepping between them costs 1. + resolve: (state, events, caster, cmd) => { + const a = cmd.params?.cell; + const bT = cmd.target; + if (!a || !bT || bT.kind !== "cell") return "place the two warp tokens"; + const b = bT.cell; + const view = boardView(state); + for (const c of [a, b]) { + if (!view.cells[cellKey(c)]) return "off the board"; + if (view.homes.some((h) => cellKey(h) === cellKey(c))) return "not on a home base"; + if (state.squareContents[cellKey(c)]?.kind === "stone") return "inside solid stone"; + } + if (cellKey(a) === cellKey(b)) return "the tokens go on two different squares"; + state.dimWarps.push({ a: { ...a }, b: { ...b } }); + events.push({ type: "warpTokensPlaced", caster: caster.id, a, b }); + return null; + }, + }, + redirection: { + kind: "neutral", + // "Swap two external sector exits" — their wraparound destinations trade. + resolve: (state, events, caster, cmd) => { + const a = cmd.params?.cell; + const bT = cmd.target; + if (!a || !bT || bT.kind !== "cell") return "pick the two exits to swap"; + const b = bT.cell; + const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a)); + const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b)); + if (!wa || !wb || wa === wb) return "pick two different outer exits"; + // Swap destinations and fix the reciprocal warps to match. + const destA = { ...wa.to }; + const destB = { ...wb.to }; + wa.to = destB; + wb.to = destA; + for (const w of state.board.warps) { + if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side }; + if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.from.side }; + } + events.push({ type: "exitsRedirected", caster: caster.id }); + return null; + }, + }, + "opportunity-fire": { + kind: "neutral", + resolve: () => "played out of turn — wait for another player's turn, then use it", + }, + interrupt: { + kind: "neutral", + resolve: () => "played out of turn — use it during another player's turn", + }, "reuse-spell": { kind: "neutral", // "You may retrieve any spell you use immediately after you use it (but @@ -2253,6 +2669,29 @@ function attachSustained( }); } +/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */ +function walkingDistance(state: GameState, from: Cell, to: Cell): number { + if (cellKey(from) === cellKey(to)) return 0; + const view = boardView(state); + 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 >= 6) break; + for (const side of SIDES) { + const step = stepTarget(view, cur, side); + if (step.kind === "blocked") continue; + if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue; + if (seen.has(cellKey(step.to))) continue; + seen.set(cellKey(step.to), d + 1); + if (cellKey(step.to) === cellKey(to)) return d + 1; + queue.push(step.to); + } + } + return seen.get(cellKey(to)) ?? Infinity; +} + /** BFS steps between cells ignoring walls (teleport distance). */ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { if (cellKey(from) === cellKey(to)) return 0; @@ -2321,7 +2760,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game while (p.hand.length < HAND_LIMIT) { const card = deck.shift(); if (!card) throw new Error("deck exhausted during deal"); - if (isTrap(card.cardId)) { + if (isTrap(card.cardId) || card.cardId === "gift-from-below") { + // "Discard without any damage taken if this is dealt on the first turn." discard.push(card); events.push({ type: "trapRedrawnDuringDeal", player: p.id }); } else { @@ -2367,6 +2807,9 @@ export function createGame(config: GameConfig): { state: GameState; events: Game boobytraps: [], gluedCells: {}, openSafes: [], + enchantedObjects: {}, + dimWarps: [], + outOfTurnWindow: null, players, treasures, sustained: [], @@ -2423,12 +2866,82 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm return err("an attack is being resolved — counteract or pass"); } - if (activePlayer(state).id !== playerId) return err("not your turn"); + // INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window. + if (state.outOfTurnWindow) { + if (playerId !== state.outOfTurnWindow.playerId) { + return err("an interruption is being resolved"); + } + if (command.type === "pass") { + const st = clone(state); + st.outOfTurnWindow = null; + return { ok: true, state: st, events: [] }; + } + if (command.type !== "cast" && command.type !== "punch") { + return err("use your interruption (cast or punch) or pass"); + } + const st = clone(state); + const idx = st.players.findIndex((p) => p.id === playerId); + const saved = { ...st.turn }; + st.turn = { + ...st.turn, + activeIndex: idx, + attackUsed: false, + secondAttackUsed: false, + attackForbidden: false, + actionsEnded: false, + }; + const kind = st.outOfTurnWindow!.kind; + // OPPORTUNITY FIRE permits an attack; INTERRUPT any one spell. + if (kind === "opportunity-fire" && command.type === "cast") { + const p = st.players[idx]!; + const card = p.hand.find((c) => c.instanceId === command.instanceId); + const fx = card ? CARD_EFFECTS[card.cardId] : undefined; + if (!fx || fx.kind !== "attack") return err("opportunity fire permits an attack"); + } + if (kind === "interrupt" && command.type === "punch") { + return err("interrupt lets you cast a spell, not brawl"); + } + st.outOfTurnWindow = null; + const result = command.type === "cast" ? doCast(st, command) : doPunch(st, command.targetId); + if (!result.ok) return result; // the window stays open in `state` + const out = result.state; + out.turn = { + ...saved, + round: out.turn.round, + }; + return { ok: true, state: out, events: result.events }; + } + + if (activePlayer(state).id !== playerId) { + // Playing INTERRUPT or OPPORTUNITY FIRE out of turn opens a window. + if (command.type === "cast" && !state.stack) { + const p = state.players.find((q) => q.id === playerId && q.alive); + const card = p?.hand.find((c) => c.instanceId === command.instanceId); + if (p && card && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) { + if (state.turn.round === 1) return err("no combat during the first round of turns"); + const castBlock = castingBlocked(state, playerId); + if (castBlock) return err(castBlock); + const st = clone(state); + const pp = st.players.find((q) => q.id === playerId)!; + const taken = takeFromHand(pp, command.instanceId)!; + st.discard.push(taken); + st.outOfTurnWindow = { playerId, kind: card.cardId as "interrupt" | "opportunity-fire" }; + st.lastSpellUsed[playerId] = card.cardId; + return { + ok: true, + state: st, + events: [{ type: "outOfTurnWindow", player: playerId, kind: st.outOfTurnWindow!.kind }], + }; + } + } + 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 "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "cast": return doCast(state, command); @@ -2482,6 +2995,12 @@ function doMove(prev: GameState, direction: Side): CommandResult { const p = activePlayer(state); const events: GameEvent[] = []; + // IDIOT: every move heads for the nearest of their own treasures. + if (sustainedOn(state, p.id, "idiot").length > 0) { + const steered = idiotSteer(state, p); + if (steered) direction = steered; + } + // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // move ... bumping into a wall counts as one space of movement." if (isBlinded(state, p)) { @@ -2557,6 +3076,25 @@ function doMove(prev: GameState, direction: Side): CommandResult { // Square contents at the destination. let content = state.squareContents[cellKey(p.position)]; if (content?.kind === "stone") return err("that square is solid stone"); + // BIG MAN: nobody enters his square. + for (const other of state.players) { + if (other.id !== p.id && other.alive && cellKey(other.position) === cellKey(p.position) && + sustainedOn(state, other.id, "big-man").length > 0) { + p.position = from; + return err("a giant fills that corridor"); + } + } + // FEAR: no one moves within 3 spaces of the fearsome one. + for (const other of state.players) { + if (other.id === p.id || !other.alive) continue; + if (sustainedOn(state, other.id, "fear").length === 0) continue; + const d = Math.abs(other.position.x - p.position.x) + Math.abs(other.position.y - p.position.y); + const dBefore = Math.abs(other.position.x - from.x) + Math.abs(other.position.y - from.y); + if (d <= 3 && d < dBefore) { + p.position = from; + return err("an unnatural dread keeps you away"); + } + } // CREATE PIT: stepping onto a pit is a jump attempt — roll D4; on a 1 you // fall in (2 damage, movement over); otherwise you sail across to the far @@ -2595,6 +3133,24 @@ function doMove(prev: GameState, direction: Side): CommandResult { state.turn.movementUsed++; events.push({ type: "moved", player: p.id, from, to: p.position, direction, via }); + // WALKING DEAD: 1/2 point per space moved (a full point every two steps). + for (const fx of sustainedOn(state, p.id, "walking-dead")) { + fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1; + if (fx.data.halfSteps % 2 === 0) { + applyDamage(state, events, p, 1, "walking dead", null); + checkVictory(state, events); + } + } + // DISEASE: the carrier infects every other player in a square they enter. + if (p.alive && sustainedOn(state, p.id, "disease").length > 0) { + for (const other of state.players) { + if (!other.alive || other.id === p.id) continue; + if (cellKey(other.position) !== cellKey(p.position)) continue; + applyDamage(state, events, other, 3, "disease", null, "physical"); + } + checkVictory(state, events); + } + if (crossedFirewall) { events.push({ type: "firewallBurned", player: p.id }); const webbed = sustainedOn(state, p.id, "sticky-web").length > 0; @@ -2653,6 +3209,20 @@ function doMove(prev: GameState, direction: Side): CommandResult { events.push({ type: "stuckInSlime", player: p.id, at: p.position }); state.turn.actionsEnded = true; } + // IDIOT lifts when the victim reaches their own treasure. + if (sustainedOn(state, p.id, "idiot").length > 0) { + const onOwn = state.treasures.some( + (t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position), + ); + const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position); + if (onOwn || allCarried) { + for (const fx of sustainedOn(state, p.id, "idiot")) { + events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id }); + } + state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id)); + } + } + // BOOBYTRAP: the real token detonates under anyone but its caster. for (const trap of [...state.boobytraps]) { if (trap.casterId === p.id) continue; @@ -2667,6 +3237,24 @@ function doMove(prev: GameState, direction: Side): CommandResult { return { ok: true, state, events }; } +function doWarpStep(prev: GameState): CommandResult { + const blocked = requireActionsAvailable(prev); + if (blocked) return err(blocked); + if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left"); + const state = clone(prev); + const p = activePlayer(state); + if (isLockedInPlace(state, p.id)) return err("you are locked in place"); + const here = cellKey(p.position); + const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here); + if (!pair) return err("you are not standing on a warp token"); + const dest = cellKey(pair.a) === here ? pair.b : pair.a; + if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone"); + const from = p.position; + p.position = { ...dest }; + state.turn.movementUsed++; + return { ok: true, state, events: [{ type: "warpStepped", player: p.id, from, to: p.position }] }; +} + function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult { const blocked = requireActionsAvailable(prev); if (blocked) return err(blocked); @@ -2720,11 +3308,34 @@ function attackPreconditions(state: GameState): string | 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"; + if (sustainedOn(state, playerId, "idiot").length > 0) return "What am I doing here...? (you can do nothing but head for your treasure)"; return null; } +/** IDIOT: the victim's moves are steered toward their nearest own treasure. */ +function idiotSteer(state: GameState, p: PlayerState): Side | null { + const targets = state.treasures.filter((t) => t.owner === p.id && t.position); + if (targets.length === 0) return null; + const view = boardView(state); + let best: { side: Side; dist: number } | null = null; + for (const side of SIDES) { + const step = stepTarget(view, p.position, side); + if (step.kind === "blocked") continue; + if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue; + for (const t of targets) { + const d = walkingDistance(state, step.to, t.position!); + if (best === null || d < best.dist) best = { side, dist: d }; + } + } + return best?.side ?? null; +} + /** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */ function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null { + if (sustainedOn(state, target.id, "big-man").length > 0 && + cellKey(attacker.position) === cellKey(target.position)) { + return "he fills the corridor — there is no room to swing"; + } if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush"; if (inThornbush(state, target)) return "you cannot attack someone in a thornbush"; if (isMisted(state, attacker.id)) return "you are mist — you may not attack"; @@ -2782,6 +3393,7 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult { numberValue: null, amplifyFactor: 1, extendFactor: 1, + powerAttackPoints: 0, params: null, kind: "physical", counters: [], @@ -2800,6 +3412,8 @@ interface CastConsumables { add: CardInstance | null; extend: CardInstance | null; aroundCorner: CardInstance | null; + powerAttack: CardInstance | null; + powerAttackPoints: number; magnitude: Magnitude; } @@ -2855,6 +3469,18 @@ function gatherModifiers( aroundCorner = c; } + let powerAttack: CardInstance | null = null; + let powerAttackPoints = 0; + if (cmd.powerAttackInstanceId) { + const c = find(cmd.powerAttackInstanceId); + if (!c || c.cardId !== "power-attack") return "POWER ATTACK card not in hand"; + const pts = cmd.powerAttackPoints ?? 0; + if (!Number.isInteger(pts) || pts < 1) return "choose how many life points to burn"; + if (pts >= caster.life) return "that would kill you"; + powerAttack = c; + powerAttackPoints = pts; + } + // POWERSTONE: "Add 1 to any NUMBER card played." const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0; const sum = numbers.length > 0 @@ -2868,6 +3494,8 @@ function gatherModifiers( add, extend, aroundCorner, + powerAttack, + powerAttackPoints, magnitude: { numberValue: sum, power: (sum ?? 1) * amp, @@ -2894,7 +3522,7 @@ function consumeCast( takeFromHand(caster, card.instanceId); state.discard.push(card); } - for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner]) { + for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner, mods.powerAttack]) { if (!c) continue; takeFromHand(caster, c.instanceId); state.discard.push(c); @@ -3052,6 +3680,10 @@ function doCast(prev: GameState, cmd: Extract): Comma const werr = spendWandCharge(state, caster, wandEvents); if (werr) return err(werr); } + if (mods.powerAttackPoints > 0) { + caster.life -= mods.powerAttackPoints; + wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance }); + } // BLIND: casts at others fly in a rolled direction. "Misdirected spells // go intended distance" — if the die disagrees with the true direction, @@ -3091,6 +3723,7 @@ function doCast(prev: GameState, cmd: Extract): Comma numberValue: mods.magnitude.numberValue, amplifyFactor: 2 ** mods.amplifies.length, extendFactor: mods.extend ? 2 : 1, + powerAttackPoints: mods.powerAttackPoints, params: cmd.params ?? null, kind: effect.physical ? "physical" : "spell", counters: [], @@ -3116,6 +3749,7 @@ function doCast(prev: GameState, cmd: Extract): Comma numberValue: mods.magnitude.numberValue, amplifyFactor: 2 ** mods.amplifies.length, extendFactor: mods.extend ? 2 : 1, + powerAttackPoints: mods.powerAttackPoints, params: cmd.params ?? null, kind: effect.physical ? "physical" : "spell", counters: [], @@ -3334,6 +3968,22 @@ function resolveStack(state: GameState, events: GameEvent[]): void { base = (stack.numberValue ?? 1) * defender.hand.filter((c) => isMagicStone(c.cardId)).length; } base *= stack.amplifyFactor; + base += stack.powerAttackPoints; + // SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper. + if (stack.attackCard && state.enchantedObjects[stack.attackCard.instanceId]) { + base += 1; + } + // STRENGTH: "Doubles all physical damage you do to others." + if (stack.kind === "physical" && sustainedOn(state, attacker.id, "strength").length > 0) { + base *= 2; + } + // WEAKNESS: "takes two times normal damage from any point-type spells or + // physical attacks" (Strength and Weakness cancel each other). + { + const weak = sustainedOn(state, defender.id, "weakness").length; + const strong = sustainedOn(state, defender.id, "strength").length; + if (weak > 0 && strong === 0) base *= 2; + } const baseDuration = effect?.sustains ? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor : 0; @@ -3387,6 +4037,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void { if (pipe.reflectedDamage > 0) { applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id); } + // EMPATHY: "Any attack done in any form against you acts against both + // you and the caster of the spell." + if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) { + applyDamage(state, events, attacker, damageDealt, `${attackId ?? "punch"} (empathy)`, defender.id, pipe.kind); + } // SHADOWSTONE: physical damage you deal feeds your life total. if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) { attacker.life += damageDealt; @@ -3541,6 +4196,7 @@ function doPickUpTreasure(prev: GameState): CommandResult { const state = clone(prev); const p = activePlayer(state); if (p.carriedTreasureId) return err("you can only carry one treasure at a time"); + if (sustainedOn(state, p.id, "weakness").length > 0) return err("you are too weak to carry treasure"); const here = cellKey(p.position); if (state.gluedCells[here]) return err("it is glued fast to the floor"); const safe = state.squareContents[here]?.kind === "safe"; @@ -3556,11 +4212,24 @@ function doPickUpTreasure(prev: GameState): CommandResult { t.position = null; p.carriedTreasureId = t.id; state.turn.actionsEnded = true; - return { - ok: true, - state, - events: [{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }], - }; + const events: GameEvent[] = [ + { type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }, + ]; + // WARD: the treasure's owner may have trapped it. (Simplification: springs + // automatically whenever the owner holds the card.) + const owner = state.players.find((q) => q.id === t.owner); + if (owner && owner.alive && owner.id !== p.id) { + const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward"); + if (wardIdx !== -1) { + const [card] = owner.hand.splice(wardIdx, 1); + owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId); + state.discard.push(card!); + events.push({ type: "wardSprung", owner: owner.id, victim: p.id }); + applyDamage(state, events, p, 3, "warded treasure", null); + checkVictory(state, events); + } + } + return { ok: true, state, events }; } function doPickUpObject(prev: GameState, instanceId: string): CommandResult { @@ -3838,6 +4507,15 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { events.push({ type: "trapSprung", player: p.id }); continue; } + if (card.cardId === "gift-from-below") { + // "You lose 3 points to magical damage, now ... then discard and redraw." + state.discard.push(card); + events.push({ type: "trapSprung", player: p.id }); + applyDamage(state, events, p, 3, "gift from below", null); + checkVictory(state, events); + if (!p.alive) break; + continue; + } drawn.push(card); toDraw--; } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 9526113..4a6db57 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -58,6 +58,8 @@ export interface GameView { wandCharges: Record; /** Boobytrap tokens: everyone sees the four; only the caster sees which is real. */ boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[]; + dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[]; + outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null; } export function viewFor(state: GameState, playerId: PlayerId): GameView { @@ -111,6 +113,8 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { knownIllusionEdges, creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), wandCharges: { ...state.wandCharges }, + dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })), + outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null, boobytraps: state.boobytraps.map((t) => { const [rx, ry] = t.realKey.split(",").map(Number) as [number, number]; return { diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 1ba92d1..598e131 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, "chaos"); // expansion1, unimplemented + const card = giveCard(state, caster.id, "thumb-of-god"); // awaiting digital redesign 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/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts new file mode 100644 index 0000000..f75f7d4 --- /dev/null +++ b/packages/engine/test/expansion-combat.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { + applyCommand, + activePlayer, + createGame, + sustainedOn, + type Command, + type GameState, + type PlayerId, +} from "../src/game"; +import { cellKey } from "../src/board"; +import type { CardInstance } from "../src/cards"; + +function newGame(seed = 42) { + return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] }); +} + +function must(state: GameState, player: PlayerId, command: Command): GameState { + const result = applyCommand(state, player, command); + if (!result.ok) throw new Error(`command failed: ${result.error}`); + return result.state; +} + +function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance { + const p = state.players.find((p) => p.id === playerId)!; + const instance = { instanceId: `${cardId}#${tag}`, cardId }; + p.hand[slot] = instance; + return instance; +} + +function toRound2(state: GameState): GameState { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + return state; +} + +function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } { + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + defender.position = { ...attacker.position }; + return { attacker: attacker.id, defender: defender.id }; +} + +function castAt( + state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance, + extra: Partial> = {}, +): GameState { + state = must(state, attacker, { + type: "cast", instanceId: card.instanceId, + target: { kind: "player", playerId: defender }, ...extra, + }); + return must(state, defender, { type: "pass" }); +} + +describe("expansion combat cards", () => { + it("power attack burns life for extra damage", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const fb = giveCard(state, attacker, "fireball"); + giveCard(state, attacker, "power-attack", "PA", 1); + state = castAt(state, attacker, defender, fb, { + powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 3, + }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(7); // 5+3 + expect(state.players.find((p) => p.id === attacker)!.life).toBe(12); + }); + + it("weakness doubles damage taken and forbids carrying treasure", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const wk = giveCard(state, attacker, "weakness"); + giveCard(state, attacker, "number-3", "N", 1); + state = castAt(state, attacker, defender, wk, { numberInstanceIds: ["number-3#N"] }); + expect(sustainedOn(state, defender, "weakness").length).toBe(1); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + const t = state.treasures.find((t) => t.owner === attacker && t.position)!; + const d = state.players.find((p) => p.id === defender)!; + d.position = { ...t.position! }; + expect(applyCommand(state, defender, { type: "pickUpTreasure" }).ok).toBe(false); + state = must(state, defender, { type: "endTurn", draw: 0 }); + const fb = giveCard(state, attacker, "fireball", "F", 0); + const d2 = state.players.find((p) => p.id === defender)!; + d2.position = { ...state.players.find((p) => p.id === attacker)!.position }; + state = castAt(state, attacker, defender, fb); + expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5x2 + }); + + it("walking dead bleeds half a point per space walked", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const wd = giveCard(state, attacker, "walking-dead"); + state = castAt(state, attacker, defender, wd); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + // Defender walks: every second step costs a point. + let lifeStart = state.players.find((p) => p.id === defender)!.life; + let steps = 0; + for (const dir of ["N", "S", "E", "W", "N", "S"] as const) { + const r = applyCommand(state, defender, { type: "move", direction: dir }); + if (r.ok) { state = r.state; steps++; } + if (steps === 2) break; + } + if (steps === 2) { + expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1); + } + }); + + it("mental swap trades entire hands", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const ms = giveCard(state, attacker, "mental-swap"); + const aCards = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId); + const dCards = state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId); + state = castAt(state, attacker, defender, ms); + const aAfter = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId); + expect(aAfter).toEqual(dCards); + // (the swap card itself was consumed from the attacker's hand pre-swap) + expect(state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId)) + .toEqual(aCards.filter((id) => id !== ms.instanceId)); + }); + + it("butt-head rams for the distance charged", () => { + let { state } = newGame(); + state = toRound2(state); + const attacker = activePlayer(state); + const defender = state.players.find((p) => p.id !== attacker.id)!; + // Stand them 3 apart on the same column if possible; else same square +N. + defender.position = { x: attacker.position.x, y: attacker.position.y >= 3 ? attacker.position.y - 3 : attacker.position.y + 3 }; + const bh = giveCard(state, attacker.id, "butt-head"); + state = castAt(state, attacker.id, defender.id, bh); + const a = state.players.find((p) => p.id === attacker.id)!; + const d = state.players.find((p) => p.id === defender.id)!; + expect(cellKey(a.position)).toBe(cellKey(d.position)); + expect(d.life).toBe(12); // 3 spaces = 3 damage + }); + + it("empathy turns an attack back on its caster as well", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + // Defender raises empathy on their own turn. + state = must(state, attacker, { type: "endTurn", draw: 0 }); + const em = giveCard(state, defender, "empathy", "E", 0); + giveCard(state, defender, "number-3", "N", 1); + state = must(state, defender, { + type: "cast", instanceId: em.instanceId, numberInstanceIds: ["number-3#N"], + }); + state = must(state, defender, { type: "endTurn", draw: 0 }); + const fb = giveCard(state, attacker, "fireball", "F", 0); + state = castAt(state, attacker, defender, fb); + expect(state.players.find((p) => p.id === defender)!.life).toBe(10); + expect(state.players.find((p) => p.id === attacker)!.life).toBe(10); + }); + + it("ward springs when a trapped treasure is grabbed", () => { + let { state } = newGame(); + const me = activePlayer(state); + const enemy = state.players.find((p) => p.id !== me.id)!; + giveCard(state, enemy.id, "ward", "W", 0); + const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!; + me.position = { ...treasure.position! }; + state = must(state, me.id, { type: "pickUpTreasure" }); + expect(state.players.find((p) => p.id === me.id)!.life).toBe(12); + expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); + }); + + it("opportunity fire opens an out-of-turn attack window", () => { + let { state } = newGame(); + state = toRound2(state); + const active = activePlayer(state).id; + const lurker = state.players.find((p) => p.id !== active)!; + lurker.position = { ...state.players.find((p) => p.id === active)!.position }; + const of_ = giveCard(state, lurker.id, "opportunity-fire", "OF", 0); + const fb = giveCard(state, lurker.id, "fireball", "F", 1); + // Out of turn: play opportunity fire, then the attack. + state = must(state, lurker.id, { type: "cast", instanceId: of_.instanceId }); + expect(state.outOfTurnWindow?.playerId).toBe(lurker.id); + state = must(state, lurker.id, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: active }, + }); + state = must(state, active, { type: "pass" }); + expect(state.players.find((p) => p.id === active)!.life).toBe(10); + // Turn structure is intact: the original player is still active. + expect(activePlayer(state).id).toBe(active); + expect(state.outOfTurnWindow).toBeNull(); + }); + + it("idiot marches its victim toward their own treasure and forbids casting", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const id = giveCard(state, attacker, "idiot"); + state = castAt(state, attacker, defender, id); + expect(sustainedOn(state, defender, "idiot").length).toBe(1); + state = must(state, attacker, { type: "endTurn", draw: 0 }); + // Casting is refused; moving is steered (any direction request works). + const fb = giveCard(state, defender, "fireball", "F", 0); + expect(applyCommand(state, defender, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker }, + }).ok).toBe(false); + const before = state.players.find((p) => p.id === defender)!.position; + const r = applyCommand(state, defender, { type: "move", direction: "N" }); + if (r.ok) { + const after = r.state.players.find((p) => p.id === defender)!.position; + expect(cellKey(after)).not.toBe(cellKey(before)); + } + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 8162a18..a750c57 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -55,13 +55,15 @@ "troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow", "killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit", "handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap", + "dimensional-warp", "redirection", ]); + const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]); const CREATURE_TARGET_CARDS = new Set(["mega-monster"]); const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]); const SELF_CARDS = new Set([ - "invisible", "shrink", "mist-body", + "invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline", ]); - const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu"]); + const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)); const cellSelectMode = $derived( (selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null, @@ -142,6 +144,7 @@ // duration self-spells wait so a number card can be attached. const INSTANT = new Set([ "speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash", + "gift-from-above", "chaos", "interrupt", "opportunity-fire", "bloodstone", "brainstone", "powerstone", "shadowstone", "shieldstone", "soulstone", "speedstone", "visionstone", ]); @@ -197,7 +200,7 @@ } return; } - if (selectedCard?.cardId === "trader") { + if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) { if (!tradeFrom) { tradeFrom = cell; return; } net.command({ type: "cast", instanceId: selectedCard.instanceId, @@ -485,8 +488,11 @@ {#if selectedCard?.cardId === "boobytrap"} — place 4 tokens ({trapCells.length}/4; the FIRST is the real trap) {/if} - {#if selectedCard?.cardId === "trader"} - {tradeFrom ? "— now the second item square" : "— click the first item square"} + {#if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)} + {tradeFrom ? "— now the second square" : "— click the first square"} + {/if} + {#if selectedCard?.cardId === "power-attack"} + (select an attack first, then attach Power Attack via number input) {/if} {#if selectedCard?.cardId === "relocate-sector"} {#if pendingSectorFrom} @@ -543,6 +549,17 @@ {#if discardSelection.size > 0} {/if} + {#if isYourTurn && view.dimWarps.some((w) => { + const me = view.players.find((p) => p.id === view.you)!; + return (w.a.x === me.position.x && w.a.y === me.position.y) || + (w.b.x === me.position.x && w.b.y === me.position.y); + })} + + {/if} + {#if view.outOfTurnWindow?.playerId === view.you} + + + {/if} {#if isYourTurn} diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 6fee540..31433d7 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -128,6 +128,14 @@ {/if} {/each} + + {#each view.dimWarps as w, wi (wi)} + {#each [w.a, w.b] as tok, i (i)} + + {/each} + {/each} + {#each view.boobytraps as trap, ti (ti)} {#each trap.cells as tc, i (i)} @@ -274,6 +282,7 @@ .safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; } .trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; } .trap-real { stroke: #d3352b; stroke-width: 2.5; } + .dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; } .ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; } .illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; } .creature { cursor: pointer; } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 690885c..0dabc36 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -78,6 +78,42 @@ function humanize(e: GameEvent): string | null { case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`; case "impScorches": return `The fire imp scorches ${e.player}!`; case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`; + case "wandCharged": return `${e.player} charges a wand (${e.charges} charges).`; + case "wandUsed": return e.chargesLeft > 0 ? `The wand crackles (${e.chargesLeft} left).` : null; + case "wandExhausted": return `${e.player}'s wand crumbles to dust.`; + case "wallWarpedOpen": return `A section of wall shimmers out of existence!`; + case "wallsWarpedBack": return `The warped wall snaps back into place.`; + case "shoved": return `${e.player} is shoved bodily by ${e.by}!`; + case "webbed": return `${e.player} is tangled in sticky webs!`; + case "cardRetrieved": return `${e.player} plucks a card from the discard pile.`; + case "slippedInOoze": return `${e.player} slips flat on their face in the ooze!`; + case "struggledInOoze": return e.stood ? `${e.player} staggers upright.` : `${e.player} flounders in the ooze.`; + case "steppedOnTacks": return `${e.player} steps on tacks! OW OW OW.`; + case "jumpedPit": return `${e.player} leaps the pit!`; + case "fellInPit": return `${e.player} misjudges the jump and plummets in!`; + case "climbedFromPit": return e.success ? `${e.player} hauls themselves out of the pit.` : `${e.player} scrabbles at the pit walls in vain.`; + case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`; + case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`; + case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`; + case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`; + case "safeCreated": return `A massive safe slams down around the loot.`; + case "safeOpened": return null; + case "itemsTraded": return `Two items blink and trade places.`; + case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`; + case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`; + case "handsScrambled": return `CHAOS! Every hand is thrown in a pile and redealt!`; + case "rammed": return `BAAA! ${e.attacker} turns into a goat and rams ${e.target} (${e.distance} spaces)!`; + case "treasureThrown": return `${e.attacker} HURLS their treasure (${e.distance} spaces)!`; + case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`; + case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`; + case "itemsSwapped": return `${e.a} and ${e.b} swap items.`; + case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`; + case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`; + case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`; + case "warpTokensPlaced": return `Two dimensional warp tokens hum to life.`; + case "warpStepped": return `${e.player} steps through the dimensional warp!`; + case "exitsRedirected": return `The maze's outer exits twist and reconnect!`; + case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`; 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.`;