790 lines
31 KiB
JavaScript
790 lines
31 KiB
JavaScript
// 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 };
|