fertig-classic-games/public/src/games/spireclimb/SpireClimbLogic.js

790 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };