// SpireClimbLogic.js // Pure (no Phaser) engine for Spire Climb: seeded RNG, map generation, the // turn-based combat state machine (energy / draw-discard-exhaust / block / // statuses / enemy intents), relic hooks, and run-meta helpers (rewards, rest, // shop, events). SpireClimbGame renders state and forwards player input here; // verifySpireClimb.js drives it headless. import { CLASSES, CARDS, RELICS, POTIONS, POTION_IDS, ENEMIES, ENCOUNTERS, EVENTS, ACT, TOTAL_ACTS, cardPoolFor, relicPool, } from './SpireClimbData.js'; // ── RNG (mulberry32, seedable & serializable) ──────────────────────────────── export function makeRng(seed) { let s = (seed >>> 0) || 1; const fn = () => { s |= 0; s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; fn.int = (n) => Math.floor(fn() * n); fn.pick = (arr) => arr[fn.int(arr.length)]; fn.range = (lo, hi) => lo + fn.int(hi - lo + 1); fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; }; return fn; } let _uid = 1; export function cardInstance(cardId, upgraded = false) { return { uid: _uid++, id: cardId, upgraded }; } // Returns the card definition with upgrade overrides folded in. export function resolvedCard(inst) { const base = CARDS[inst.id]; if (!base) return null; if (!inst.upgraded || !base.upgrade) return base; return { ...base, ...base.upgrade, name: `${base.name}+`, id: base.id, type: base.type, cls: base.cls, rarity: base.rarity, target: base.target }; } export function cardCost(inst) { const c = resolvedCard(inst); return c.cost; } // ── RUN / MAP ──────────────────────────────────────────────────────────────── // A run holds persistent player state + the generated map. Combat is a separate // transient object created from a run + an encounter. export function newRun(className, seed = (Math.random() * 1e9) | 0) { const cls = CLASSES[className] || CLASSES.warrior; const rng = makeRng(seed); const deck = cls.startingDeck.map((id) => cardInstance(id)); const run = { seed, className: cls.id, maxHp: cls.maxHp, hp: cls.maxHp, gold: ACT.startGold, deck, relics: [cls.startRelic], potions: [], maxPotions: 3, map: null, currentNodeId: null, // null until first row picked floor: 0, visited: [], act: 1, finished: false, victory: false, }; run.map = generateMap(rng); return run; } // Build a layered branching map: rows of nodes with edges to the next row. // Row 0 = entry combats; last row = boss; rest forced one row below boss. export function generateMap(rng) { const rows = ACT.rows; const grid = []; for (let r = 0; r < rows; r++) { let width; if (r === 0) width = rng.range(2, 3); else if (r === rows - 1) width = 1; // boss else if (r === rows - 2) width = rng.range(1, 2); else width = rng.range(ACT.minWidth + 1, ACT.maxWidth); const nodes = []; for (let i = 0; i < width; i++) { nodes.push({ id: `n${r}_${i}`, row: r, col: i, type: assignType(r, rows, rng), edges: [], }); } grid.push(nodes); } // boss grid[rows - 1][0].type = 'boss'; // guarantee a rest right before the boss grid[rows - 2].forEach((n) => { n.type = 'rest'; }); // first row are always combats; treasure node mid-act guarantee grid[0].forEach((n) => { n.type = 'combat'; }); const midRow = Math.floor(rows / 2); if (grid[midRow][0]) grid[midRow][0].type = 'treasure'; // connect each node to 1–2 nodes on the next row (nearest cols), ensure // every next-row node has at least one parent. for (let r = 0; r < rows - 1; r++) { const cur = grid[r]; const nxt = grid[r + 1]; const parented = new Set(); cur.forEach((node) => { const links = rng.range(1, 2); const order = nxt.map((_, i) => i).sort((a, b) => Math.abs(a - node.col) - Math.abs(b - node.col)); for (let k = 0; k < links && k < order.length; k++) { const t = nxt[order[k]]; if (!node.edges.includes(t.id)) node.edges.push(t.id); parented.add(order[k]); } }); nxt.forEach((t, i) => { if (!parented.has(i)) { // attach orphan to nearest current node let best = cur[0]; for (const c of cur) if (Math.abs(c.col - t.col) < Math.abs(best.col - t.col)) best = c; if (!best.edges.includes(t.id)) best.edges.push(t.id); } }); } return { rows, grid }; } function assignType(r, rows, rng) { if (r === 0) return 'combat'; if (r === rows - 1) return 'boss'; // weighted pick; no elites/rests too early const w = { ...ACT.weights }; if (r <= 1) { w.elite = 0; w.rest = 0; w.shop = 0; } const total = Object.values(w).reduce((a, b) => a + b, 0); let roll = rng() * total; for (const [type, weight] of Object.entries(w)) { roll -= weight; if (roll <= 0) return type; } return 'combat'; } export function nodeById(run, id) { for (const row of run.map.grid) for (const n of row) if (n.id === id) return n; return null; } // Nodes the player may move to next. export function availableNodes(run) { if (!run.currentNodeId) return run.map.grid[0].slice(); const cur = nodeById(run, run.currentNodeId); if (!cur) return []; return cur.edges.map((id) => nodeById(run, id)).filter(Boolean); } export function enterNode(run, nodeId) { const node = nodeById(run, nodeId); if (!node) return null; run.currentNodeId = nodeId; run.floor = node.row + 1; run.visited.push(nodeId); return node; } // ── COMBAT ─────────────────────────────────────────────────────────────────── export function encounterForNode(run, node, rng) { const act = ENCOUNTERS[run.act] || ENCOUNTERS[1]; let table; if (node.type === 'boss') table = act.boss; else if (node.type === 'elite') table = act.elite; else table = act.normal; return rng.pick(table); } export function startCombat(run, enemyIds, seed) { const rng = makeRng(seed); const player = { hp: run.hp, maxHp: run.maxHp, block: 0, alive: true, // so enemy attacks treat the player as a valid target energy: 0, maxEnergy: 3, statuses: {}, // {strength, dexterity, vulnerable, weak, frail, poison, ritual, regen, metallicize} relics: run.relics.slice(), attacksThisTurn: 0, attacksTotal: 0, }; const enemies = enemyIds.map((id, idx) => { const def = ENEMIES[id]; const hp = rng.range(def.hp[0], def.hp[1]); return { slot: idx, defId: id, name: def.name, color: def.color, placeholderFrame: def.placeholderFrame, hp, maxHp: hp, block: 0, alive: true, statuses: {}, moveIdx: 0, moveCount: 0, lastMoveId: null, intent: null, }; }); const combat = { seed, rng, turn: 0, phase: 'start', player, enemies, draw: rng.shuffle(run.deck.map((c) => cardInstance(c.id, c.upgraded))), hand: [], discard: [], exhaust: [], log: [], handSize: 5, isBoss: false, isElite: false, run, // back-reference for persistence on combat end }; applyCombatStartRelics(combat); // choose initial intents combat.enemies.forEach((e) => chooseEnemyMove(combat, e)); startPlayerTurn(combat, true); return combat; } function applyCombatStartRelics(combat) { const p = combat.player; for (const rid of p.relics) { const r = RELICS[rid]; if (!r) continue; switch (r.hook) { case 'combatStartBlock': p.block += r.value; break; case 'combatStartStrength': addStatus(p, 'strength', r.value); break; case 'combatStartDexterity': addStatus(p, 'dexterity', r.value); break; case 'combatStartHeal': p.hp = Math.min(p.maxHp, p.hp + r.value); break; case 'combatStartVulnAll': combat.enemies.forEach((e) => addStatus(e, 'vulnerable', r.value)); break; default: break; } } } function hasRelic(combat, id) { return combat.player.relics.includes(id); } function relicVal(combat, id) { return hasRelic(combat, id) ? RELICS[id].value : 0; } function startPlayerTurn(combat, first = false) { const p = combat.player; combat.turn += 1; combat.phase = 'player'; p.block = 0; p.attacksThisTurn = 0; p.energy = p.maxEnergy + relicVal(combat, 'energy-core'); // ritual / demon form, regen handled at relevant ends; strength-from-ritual is enemy-only here // turn-start poison on player tickPoison(combat, p, 'You'); if (combat.phase === 'lost') return; // draw let drawN = combat.handSize; if (first) drawN += relicVal(combat, 'ring-of-the-snake'); drawCards(combat, drawN); } export function drawCards(combat, n) { for (let i = 0; i < n; i++) { if (combat.draw.length === 0) { if (combat.discard.length === 0) break; combat.draw = combat.rng.shuffle(combat.discard); combat.discard = []; } combat.hand.push(combat.draw.shift()); } } // ── status helpers ── export function addStatus(unit, key, amount) { unit.statuses[key] = (unit.statuses[key] || 0) + amount; if (unit.statuses[key] === 0) delete unit.statuses[key]; } export function statusOf(unit, key) { return unit.statuses[key] || 0; } function tickPoison(combat, unit, label) { const p = statusOf(unit, 'poison'); if (p > 0) { dealRaw(combat, unit, p, label + ' poison'); unit.statuses.poison = p - 1; if (unit.statuses.poison <= 0) delete unit.statuses.poison; } } // raw damage straight to HP through block function dealRaw(combat, unit, amount, source) { let dmg = amount; if (unit.block > 0) { const absorbed = Math.min(unit.block, dmg); unit.block -= absorbed; dmg -= absorbed; } if (dmg > 0) unit.hp -= dmg; combat.log.push({ t: 'damage', target: unit === combat.player ? 'player' : unit.slot, amount: dmg, source }); if (unit === combat.player) { if (unit.hp <= 0) loseCombat(combat); } else if (unit.hp <= 0) killEnemy(combat, unit); return dmg; } // attack damage with modifiers (strength already added by caller for attacker) function dealAttack(combat, attacker, target, baseAmount, opts = {}) { let dmg = baseAmount; // attacker strength if (attacker) dmg += statusOf(attacker, 'strength'); // weak on attacker if (attacker && statusOf(attacker, 'weak') > 0) dmg = Math.floor(dmg * 0.75); // vulnerable on target if (statusOf(target, 'vulnerable') > 0) dmg = Math.floor(dmg * 1.5); // pen nib relic (player only) if (opts.penNibDouble) dmg *= 2; if (dmg < 0) dmg = 0; const dealt = dealRaw(combat, target, dmg, opts.source || 'attack'); // thorns / bronze scales when player is the target if (target === combat.player && dealt > 0 && hasRelic(combat, 'bronze-scales') && attacker && attacker !== combat.player && attacker.alive) { dealRaw(combat, attacker, relicVal(combat, 'bronze-scales'), 'thorns'); } return dealt; } function gainBlock(unit, amount, isPlayer) { let b = amount + statusOf(unit, 'dexterity'); if (isPlayer && statusOf(unit, 'frail') > 0) b = Math.floor(b * 0.75); if (b < 0) b = 0; unit.block += b; return b; } function killEnemy(combat, enemy) { if (!enemy.alive) return; enemy.alive = false; enemy.hp = 0; combat.log.push({ t: 'death', target: enemy.slot }); if (combat.enemies.every((e) => !e.alive)) winCombat(combat); } function loseCombat(combat) { combat.phase = 'lost'; combat.player.alive = false; } function winCombat(combat) { combat.phase = 'won'; applyCombatEndRelics(combat); } function applyCombatEndRelics(combat) { const p = combat.player; if (hasRelic(combat, 'burning-blood')) p.hp = Math.min(p.maxHp, p.hp + RELICS['burning-blood'].value); if (hasRelic(combat, 'meat-on-the-bone') && p.hp < p.maxHp / 2) p.hp = Math.min(p.maxHp, p.hp + RELICS['meat-on-the-bone'].value); } export function isCombatOver(combat) { if (combat.phase === 'won') return 'won'; if (combat.phase === 'lost') return 'lost'; return null; } // Cards that can legally be played right now (cost affordable, playable). export function playableHand(combat) { return combat.hand.filter((inst) => { const c = resolvedCard(inst); if (c.unplayable) return false; return combat.player.energy >= Math.max(0, c.cost); }); } export function canPlay(combat, inst) { const c = resolvedCard(inst); if (!c || c.unplayable) return false; if (combat.phase !== 'player') return false; return combat.player.energy >= Math.max(0, c.cost); } // Play a card from hand. targetSlot = enemy slot index (for enemy-target cards). export function playCard(combat, inst, targetSlot = null) { if (!canPlay(combat, inst)) return false; const c = resolvedCard(inst); const hi = combat.hand.indexOf(inst); if (hi < 0) return false; combat.hand.splice(hi, 1); combat.player.energy -= Math.max(0, c.cost); const target = targetSlot != null ? combat.enemies[targetSlot] : pickDefaultTarget(combat); resolveEffects(combat, c.effects || [], { isCard: true, cardType: c.type, target }); // exhaust vs discard if (c.exhaust) combat.exhaust.push(inst); else combat.discard.push(inst); // post-resolution death checks already handled inside deal* if (combat.phase !== 'lost' && combat.enemies.every((e) => !e.alive) && combat.phase !== 'won') winCombat(combat); return true; } // Multi-hit animation support: applies energy/hand/non-damage effects up front, // then returns helpers so the scene can apply and animate each hit individually. export function setupMultiHitCard(combat, inst, targetSlot = null) { if (!canPlay(combat, inst)) return null; const c = resolvedCard(inst); const hi = combat.hand.indexOf(inst); if (hi < 0) return null; combat.hand.splice(hi, 1); combat.player.energy -= Math.max(0, c.cost); const target = targetSlot != null ? combat.enemies[targetSlot] : pickDefaultTarget(combat); // Non-damage effects (block, buffs, debuffs…) fire immediately. const setupEffects = (c.effects || []).filter((e) => e.op !== 'damage' && e.op !== 'damageAll'); resolveEffects(combat, setupEffects, { isCard: true, cardType: c.type, target }); const dmgEff = (c.effects || []).find((e) => (e.op === 'damage' || e.op === 'damageAll') && (e.times || 1) > 1); const isAll = dmgEff.op === 'damageAll'; const { amount, times } = dmgEff; const p = combat.player; return { times, applyHit() { const targets = isAll ? combat.enemies.filter((en) => en.alive) : [target && target.alive ? target : pickDefaultTarget(combat)]; for (const tgt of targets) { if (!tgt || !tgt.alive) continue; countAttack(combat, p, true); dealAttack(combat, p, tgt, amount, { source: 'card', penNibDouble: penNibTriggers(combat, p) }); } }, conclude() { if (c.exhaust) combat.exhaust.push(inst); else combat.discard.push(inst); if (combat.phase !== 'lost' && combat.enemies.every((e) => !e.alive) && combat.phase !== 'won') winCombat(combat); }, }; } function pickDefaultTarget(combat) { return combat.enemies.find((e) => e.alive) || combat.enemies[0]; } // Shared effect resolver for cards, potions, and (mostly) enemy moves. // ctx.target is the chosen enemy for enemy-target ops; attacker is the player // for cards and the enemy for enemy moves. function resolveEffects(combat, effects, ctx) { const p = combat.player; const attacker = ctx.attacker || p; const isPlayerSource = attacker === p; for (const e of effects) { switch (e.op) { case 'damage': { const times = e.times || 1; const tgt = ctx.target && ctx.target.alive ? ctx.target : pickDefaultTarget(combat); for (let i = 0; i < times; i++) { if (!tgt || !tgt.alive) break; countAttack(combat, p, isPlayerSource); dealAttack(combat, attacker, tgt, e.amount, { source: 'card', penNibDouble: isPlayerSource && penNibTriggers(combat, p) }); } break; } case 'damageAll': { const times = e.times || 1; for (let i = 0; i < times; i++) { combat.enemies.filter((en) => en.alive).forEach((en) => { countAttack(combat, p, isPlayerSource); dealAttack(combat, attacker, en, e.amount, { source: 'card', penNibDouble: isPlayerSource && penNibTriggers(combat, p) }); }); } break; } case 'damageEqualBlock': if (ctx.target && ctx.target.alive) { countAttack(combat, p, isPlayerSource); dealAttack(combat, attacker, ctx.target, p.block, { source: 'card' }); } break; case 'block': gainBlock(p, e.amount, true); break; case 'blockSelf': attacker.block += Math.max(0, e.amount); break; // enemy block (no frail/dex) case 'buffSelf': addStatus(p, e.status, e.amount); break; case 'buffSelfEnemy': addStatus(attacker, e.status, e.amount); break; case 'debuff': if (ctx.target && ctx.target.alive) addStatus(ctx.target, e.status, e.amount); break; case 'debuffAll': combat.enemies.filter((en) => en.alive).forEach((en) => addStatus(en, e.status, e.amount)); break; case 'debuffPlayer': addStatus(p, e.status, e.amount); break; case 'poison': if (ctx.target && ctx.target.alive) addStatus(ctx.target, 'poison', e.amount); break; case 'poisonAll': combat.enemies.filter((en) => en.alive).forEach((en) => addStatus(en, 'poison', e.amount)); break; case 'multiplyPoison': if (ctx.target && ctx.target.alive) { const cur = statusOf(ctx.target, 'poison'); ctx.target.statuses.poison = cur * e.factor; } break; case 'draw': drawCards(combat, e.amount); break; case 'discard': /* UI-driven; auto-discard random for AI/headless */ autoDiscard(combat, e.amount); break; case 'energy': p.energy += e.amount; break; case 'heal': p.hp = Math.min(p.maxHp, p.hp + e.amount); break; case 'healPct': p.hp = Math.min(p.maxHp, p.hp + Math.floor(p.maxHp * e.amount)); break; case 'loseHp': dealRaw(combat, p, e.amount, 'self'); break; case 'doubleStrength': p.statuses.strength = statusOf(p, 'strength') * 2; if (!p.statuses.strength) delete p.statuses.strength; break; case 'addCardToDiscard': for (let i = 0; i < (e.amount || 1); i++) combat.discard.push(cardInstance(e.card)); break; default: break; } if (combat.phase === 'lost' || combat.phase === 'won') break; } } function autoDiscard(combat, n) { for (let i = 0; i < n && combat.hand.length; i++) { const idx = combat.rng.int(combat.hand.length); combat.discard.push(combat.hand.splice(idx, 1)[0]); } } // ── relic attack counters (player) ── function countAttack(combat, p, isPlayerSource) { if (!isPlayerSource) return; p.attacksThisTurn += 1; p.attacksTotal += 1; if (hasRelic(combat, 'kunai') && p.attacksThisTurn % 3 === 0) addStatus(p, 'dexterity', RELICS.kunai.value); } function penNibTriggers(combat, p) { if (!hasRelic(combat, 'pen-nib')) return false; // double on every 10th attack (next attack count is attacksTotal+1) return (p.attacksTotal + 1) % RELICS['pen-nib'].value === 0; } // ── END PLAYER TURN → ENEMY TURN ── export function endTurn(combat) { if (combat.phase !== 'player') return; const p = combat.player; // end-of-turn: metallicize, regen, burn cards in hand if (statusOf(p, 'metallicize') > 0) p.block += statusOf(p, 'metallicize'); if (statusOf(p, 'regen') > 0) { p.hp = Math.min(p.maxHp, p.hp + statusOf(p, 'regen')); addStatus(p, 'regen', -1); } for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.endTurnSelfDamage) dealRaw(combat, p, c.endTurnSelfDamage, 'burn'); } if (combat.phase === 'lost') return; // ethereal cards exhaust at end of turn; the rest discard for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.ethereal) combat.exhaust.push(inst); else combat.discard.push(inst); } combat.hand = []; combat.phase = 'enemy'; runEnemyTurn(combat); if (combat.phase === 'enemy') startPlayerTurn(combat); } function runEnemyTurn(combat) { for (const e of combat.enemies) { if (!e.alive) continue; e.block = 0; // ritual: gain strength at start of its turn const ritual = statusOf(e, 'ritual'); if (ritual > 0) addStatus(e, 'strength', ritual); tickPoison(combat, e, e.name); if (!e.alive) continue; executeEnemyMove(combat, e); if (combat.phase === 'lost') return; // metallicize on enemies (e.g. Guardian shell) if (statusOf(e, 'metallicize') > 0) e.block += statusOf(e, 'metallicize'); } // pick next intents combat.enemies.forEach((e) => { if (e.alive) chooseEnemyMove(combat, e); }); } function executeEnemyMove(combat, enemy) { const move = enemy.intent && enemy.intent.move; if (!move) return; resolveEffects(combat, move.effects || [], { attacker: enemy, target: combat.player }); } // ── Granular enemy-phase API ── // endTurn() above resolves the whole enemy turn at once (used by the headless // verifier). The animated scene instead drives the turn one beat at a time with // these so it can play per-enemy lunge/buff animations and time the player's // health loss to the moment an attack lands. // Player end-of-turn upkeep, discard/exhaust the hand, hand off to the enemies. export function beginEnemyPhase(combat) { if (combat.phase !== 'player') return; const p = combat.player; if (statusOf(p, 'metallicize') > 0) p.block += statusOf(p, 'metallicize'); if (statusOf(p, 'regen') > 0) { p.hp = Math.min(p.maxHp, p.hp + statusOf(p, 'regen')); addStatus(p, 'regen', -1); } for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.endTurnSelfDamage) dealRaw(combat, p, c.endTurnSelfDamage, 'burn'); } if (combat.phase === 'lost') return; for (const inst of combat.hand) { const c = resolvedCard(inst); if (c.ethereal) combat.exhaust.push(inst); else combat.discard.push(inst); } combat.hand = []; combat.phase = 'enemy'; } // One enemy's start-of-turn upkeep (block reset, ritual, poison). Returns whether // the enemy is still alive afterward. export function enemyUpkeep(combat, enemy) { if (!enemy.alive) return false; enemy.block = 0; const ritual = statusOf(enemy, 'ritual'); if (ritual > 0) addStatus(enemy, 'strength', ritual); tickPoison(combat, enemy, enemy.name); return enemy.alive; } // Resolve a single enemy's chosen move (apply its effects now). export function resolveEnemyMove(combat, enemy) { if (!enemy.alive || combat.phase === 'lost') return; executeEnemyMove(combat, enemy); if (enemy.alive && statusOf(enemy, 'metallicize') > 0) enemy.block += statusOf(enemy, 'metallicize'); } // After every enemy has acted: pick next intents and start the player's turn. export function finishEnemyPhase(combat) { if (combat.phase !== 'enemy') return; combat.enemies.forEach((e) => { if (e.alive) chooseEnemyMove(combat, e); }); startPlayerTurn(combat); } // ── ENEMY AI (intent selection) ── export function chooseEnemyMove(combat, enemy) { const def = ENEMIES[enemy.defId]; const rng = combat.rng; let move; switch (def.ai) { case 'jawworm': { if (enemy.moveCount === 0) move = byId(def, 'chomp'); else { const r = rng(); move = r < 0.45 ? byId(def, 'bellow') : (r < 0.75 ? byId(def, 'thrash') : byId(def, 'chomp')); } break; } case 'cultist': move = enemy.moveCount === 0 ? byId(def, 'incantation') : byId(def, 'darkstrike'); break; case 'gremlinnob': { if (enemy.moveCount === 0) move = byId(def, 'bellow'); else { move = rng() < 0.33 ? byId(def, 'skullbash') : byId(def, 'rush'); } break; } case 'lagavulin': { if (enemy.moveCount < 3) move = byId(def, 'sleep'); else if (enemy.moveCount % 3 === 0) move = byId(def, 'siphon'); else move = byId(def, 'attack'); break; } case 'alternate': move = def.moves[enemy.moveCount % def.moves.length]; break; case 'sequence': move = def.moves[enemy.moveCount % def.moves.length]; break; default: { // avoid repeating the same move 3x do { move = rng.pick(def.moves); } while (def.moves.length > 1 && move.id === enemy.lastMoveId && rng() < 0.6); break; } } enemy.lastMoveId = move.id; enemy.moveCount += 1; enemy.intent = { move, type: move.intent, value: scaledIntentValue(enemy, move), times: move.times || 1, block: move.block || 0 }; } function byId(def, id) { return def.moves.find((m) => m.id === id); } function scaledIntentValue(enemy, move) { if (move.value == null) return null; let v = move.value + statusOf(enemy, 'strength'); if (statusOf(enemy, 'weak') > 0) v = Math.floor(v * 0.75); return Math.max(0, v); } // Per-hit damage the enemy's CURRENT move would deal to the player right now. // Mirrors dealAttack exactly (strength → enemy Weak → player Vulnerable) so the // displayed intent matches what actually lands. Multiply by intent.times for // multi-hit attacks. Recompute each render — the player's Vulnerable can change. export function intentDamage(combat, enemy) { const move = enemy.intent && enemy.intent.move; if (!move || move.value == null) return null; let dmg = move.value + statusOf(enemy, 'strength'); if (statusOf(enemy, 'weak') > 0) dmg = Math.floor(dmg * 0.75); if (statusOf(combat.player, 'vulnerable') > 0) dmg = Math.floor(dmg * 1.5); return Math.max(0, dmg); } // ── A simple greedy/heuristic auto-player for headless verification ── export function autoPlayTurn(combat) { // play affordable cards greedily: powers > attacks on lowest-hp enemy > block let safety = 40; while (combat.phase === 'player' && safety-- > 0) { const playable = playableHand(combat); if (!playable.length) break; // prefer cheapest power, then attack, then skill const score = (inst) => { const c = resolvedCard(inst); if (c.type === 'power') return 0; if (c.type === 'attack') return 1; return 2; }; playable.sort((a, b) => score(a) - score(b) || cardCost(a) - cardCost(b)); const inst = playable[0]; const c = resolvedCard(inst); let target = null; if (c.target === 'enemy') { const alive = combat.enemies.filter((e) => e.alive); alive.sort((a, b) => a.hp - b.hp); target = alive.length ? alive[0].slot : null; } const ok = playCard(combat, inst, target); if (!ok) break; if (combat.phase !== 'player') break; } if (combat.phase === 'player') endTurn(combat); } // ── COMBAT RESULT → RUN ── // Persist combat outcome back to the run (hp). Returns {result, rewards|null}. export function settleCombat(combat, node, rng) { const run = combat.run; run.hp = Math.max(0, combat.player.hp); if (combat.phase === 'lost' || run.hp <= 0) { run.finished = true; run.victory = false; return { result: 'lost' }; } // won const rewards = rollRewards(run, node, rng); let actCleared = false; if (node && node.type === 'boss') { if (run.act >= TOTAL_ACTS) { run.finished = true; run.victory = true; } // final boss → run won else { advanceAct(run, rng); actCleared = true; } // climb to the next act } return { result: 'won', rewards, actCleared }; } // Move the run into its next act: fresh map, back to floor 0. Keeps hp, deck, // gold, relics, potions. export function advanceAct(run, rng) { run.act += 1; run.map = generateMap(rng); run.currentNodeId = null; run.floor = 0; run.visited = []; } export function rollRewards(run, node, rng) { const isElite = node && node.type === 'elite'; const isBoss = node && node.type === 'boss'; const gold = isBoss ? rng.range(95, 105) : isElite ? rng.range(25, 35) : rng.range(10, 20); const cards = rollCardChoices(run, rng, ACT.cardRewardChoices); const reward = { gold, cards, relic: null, potion: null }; if (isElite || isBoss) reward.relic = rollRelic(run, rng, isBoss ? 'uncommon' : 'common'); if (!isBoss && rng() < 0.4) reward.potion = rng.pick(POTION_IDS); return reward; } // 3 distinct card choices weighted by rarity. export function rollCardChoices(run, rng, n) { const pool = cardPoolFor(run.className); const out = []; const used = new Set(); let guard = 100; while (out.length < n && guard-- > 0) { const roll = rng(); const rarity = roll < 0.62 ? 'common' : roll < 0.92 ? 'uncommon' : 'rare'; const choices = pool.filter((c) => c.rarity === rarity && !used.has(c.id)); const pickFrom = choices.length ? choices : pool.filter((c) => !used.has(c.id)); if (!pickFrom.length) break; const card = rng.pick(pickFrom); used.add(card.id); out.push({ id: card.id, upgraded: false }); } return out; } export function rollRelic(run, rng, rarity = 'common') { const owned = new Set(run.relics); let pool = relicPool(rarity).filter((r) => !owned.has(r.id)); if (!pool.length) pool = relicPool('common').filter((r) => !owned.has(r.id)); if (!pool.length) pool = Object.values(RELICS).filter((r) => !owned.has(r.id) && r.rarity !== 'starter'); return pool.length ? rng.pick(pool).id : null; } // ── RUN-META MUTATORS ── export function addCardToDeck(run, cardId, upgraded = false) { run.deck.push(cardInstance(cardId, upgraded)); } export function removeCardFromDeck(run, uid) { const i = run.deck.findIndex((c) => c.uid === uid); if (i >= 0) run.deck.splice(i, 1); } export function upgradeCardInDeck(run, uid) { const c = run.deck.find((x) => x.uid === uid); if (c && CARDS[c.id]?.upgrade && !c.upgraded) c.upgraded = true; } export function addRelic(run, relicId) { if (!run.relics.includes(relicId)) run.relics.push(relicId); } export function addPotion(run, potionId) { if (run.potions.length < run.maxPotions) { run.potions.push(potionId); return true; } return false; } export function removePotion(run, index) { run.potions.splice(index, 1); } export function restHeal(run) { run.hp = Math.min(run.maxHp, run.hp + Math.floor(run.maxHp * 0.3)); } // ── SHOP ── export function generateShop(run, rng) { const pool = cardPoolFor(run.className); const cards = []; const used = new Set(); for (let i = 0; i < 5; i++) { const roll = rng(); const rarity = roll < 0.5 ? 'common' : roll < 0.85 ? 'uncommon' : 'rare'; const choices = pool.filter((c) => c.rarity === rarity && !used.has(c.id)); const pickFrom = choices.length ? choices : pool.filter((c) => !used.has(c.id)); if (!pickFrom.length) break; const card = rng.pick(pickFrom); used.add(card.id); cards.push({ id: card.id, price: Math.round(ACT.shopPrices[card.rarity] * (0.85 + rng() * 0.3)) }); } const relics = []; const rid = rollRelic(run, rng, rng() < 0.7 ? 'common' : 'uncommon'); if (rid) relics.push({ id: rid, price: ACT.shopPrices.relic }); const potions = []; for (let i = 0; i < 2; i++) potions.push({ id: rng.pick(POTION_IDS), price: ACT.shopPrices.potion }); return { cards, relics, potions, removalPrice: ACT.shopPrices.removal, removalUsed: false }; } // ── potions used in combat ── export function usePotion(combat, potionId, targetSlot = null) { const pot = POTIONS[potionId]; if (!pot) return false; const target = targetSlot != null ? combat.enemies[targetSlot] : pickDefaultTarget(combat); resolveEffects(combat, pot.effects, { isCard: false, target, attacker: combat.player }); if (combat.phase !== 'lost' && combat.enemies.every((e) => !e.alive) && combat.phase !== 'won') winCombat(combat); return true; } export { EVENTS };