feat: add Spire Climb single-player roguelike deckbuilder

Implement a complete Slay-the-Spire inspired game with:

- **Core engine** (`SpireClimbLogic.js`): Turn-based combat state machine,
  seeded RNG, map generation, enemy AI, relic hooks, and a headless verifier
  (`verifySpireClimb.js`) that auto-plays 1,000+ combats across both classes.

- **Game scene** (`SpireClimbGame.js`): Phaser UI for class select, branching
  map, animated combat (card fly-in, health-bar drain, enemy lunge/buff FX),
  rewards, shop, events, and treasure nodes.

- **Content** (`SpireClimbData.js`): Two classes (Warrior, Rogue), 30+ cards
  with upgrades, relics, potions, 10 enemies/elites/bosses, events, and map
  tuning.

- **Art pipeline**: Procedural rendering by default with drop-in sprite sheets
  (`spireclimb-creatures.png`, `spireclimb-cards.png`) wired through
  `spireclimb-artwork.json`. Includes a Node script to regenerate the creature
  spritesheet and a full art spec (`sprites.md`).
This commit is contained in:
Brian Fertig 2026-06-26 20:30:36 -06:00
parent 63ffa35aa4
commit 5f9f7c8bd8
14 changed files with 3084 additions and 2 deletions

176
genSpireClimbCreatures.js Normal file
View File

@ -0,0 +1,176 @@
// genSpireClimbCreatures.js — generates a PLACEHOLDER creature spritesheet for
// Spire Climb so the game ships with dedicated (non-shared) creature art that an
// artist can paint over. Pure Node (zlib only); no image libraries.
//
// node genSpireClimbCreatures.js
//
// Output: public/assets/images/spireclimb-creatures.png
// 10 frames, 300×300 each, laid out 5 columns × 2 rows (Phaser frame order is
// row-major, so frame 0 = top-left … frame 9 = bottom-right). The frame index
// under each creature matches /data/spireclimb-artwork.json "creatures".
//
// To replace with real art: keep the same 300×300 / 5×2 layout and frame order,
// drop your PNG at the same path. No code or JSON changes needed.
import zlib from 'node:zlib';
import { writeFileSync } from 'node:fs';
const FW = 300, FH = 300, COLS = 5, ROWS = 2;
const W = FW * COLS, H = FH * ROWS;
// id → { frame, color, shape, tier } (mirrors SpireClimbData ENEMIES placeholders)
const CREATURES = [
{ id: 'jawworm', frame: 0, color: [0x9c, 0x6b, 0x3a], shape: 'worm', tier: 1 },
{ id: 'cultist', frame: 1, color: [0x6a, 0x4d, 0x8a], shape: 'robed', tier: 1 },
{ id: 'louse', frame: 2, color: [0xc0, 0x53, 0x3a], shape: 'bug', tier: 1 },
{ id: 'fungi', frame: 3, color: [0x4b, 0x8c, 0x5a], shape: 'shroom',tier: 1 },
{ id: 'spikeslime', frame: 4, color: [0x4a, 0x6b, 0xbf], shape: 'spiky', tier: 1 },
{ id: 'sentry', frame: 5, color: [0x9a, 0xa7, 0xb5], shape: 'orb', tier: 2 },
{ id: 'gremlinnob', frame: 6, color: [0xb5, 0x50, 0x3a], shape: 'horns', tier: 2 },
{ id: 'lagavulin', frame: 7, color: [0x3a, 0x7a, 0x6b], shape: 'slime', tier: 2 },
{ id: 'guardian', frame: 8, color: [0x8a, 0x6b, 0x3a], shape: 'golem', tier: 3 },
{ id: 'slimeboss', frame: 9, color: [0x4a, 0x6b, 0xbf], shape: 'slime', tier: 3 },
];
// RGBA pixel buffer
const buf = new Uint8Array(W * H * 4); // transparent by default
const px = (x, y, r, g, b, a = 255) => {
if (x < 0 || y < 0 || x >= W || y >= H) return;
const i = (y * W + x) * 4;
const ia = a / 255;
buf[i] = Math.round(r * ia + buf[i] * (1 - ia));
buf[i + 1] = Math.round(g * ia + buf[i + 1] * (1 - ia));
buf[i + 2] = Math.round(b * ia + buf[i + 2] * (1 - ia));
buf[i + 3] = Math.max(buf[i + 3], a);
};
const rect = (x0, y0, w, h, c, a = 255) => { for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) px(x, y, c[0], c[1], c[2], a); };
const disc = (cx, cy, rad, c, a = 255) => { for (let y = cy - rad; y <= cy + rad; y++) for (let x = cx - rad; x <= cx + rad; x++) { const dx = x - cx, dy = y - cy; if (dx * dx + dy * dy <= rad * rad) px(x, y, c[0], c[1], c[2], a); } };
const ellipse = (cx, cy, rx, ry, c, a = 255) => { for (let y = cy - ry; y <= cy + ry; y++) for (let x = cx - rx; x <= cx + rx; x++) { const dx = (x - cx) / rx, dy = (y - cy) / ry; if (dx * dx + dy * dy <= 1) px(x, y, c[0], c[1], c[2], a); } };
const tri = (x0, y0, x1, y1, x2, y2, c, a = 255) => {
const minx = Math.min(x0, x1, x2), maxx = Math.max(x0, x1, x2), miny = Math.min(y0, y1, y2), maxy = Math.max(y0, y1, y2);
const area = (ax, ay, bx, by, cx, cy) => (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
const A = area(x0, y0, x1, y1, x2, y2);
for (let y = miny; y <= maxy; y++) for (let x = minx; x <= maxx; x++) {
const w0 = area(x1, y1, x2, y2, x, y), w1 = area(x2, y2, x0, y0, x, y), w2 = area(x0, y0, x1, y1, x, y);
if (A === 0) continue;
if ((w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0)) px(x, y, c[0], c[1], c[2], a);
}
};
const dark = (c, f = 0.55) => c.map((v) => Math.round(v * f));
const light = (c, f = 0.4) => c.map((v) => Math.round(v + (255 - v) * f));
// tiny 3×5 digit font for the frame-index badge
const FONT = {
'0': ['111', '101', '101', '101', '111'], '1': ['010', '110', '010', '010', '111'],
'2': ['111', '001', '111', '100', '111'], '3': ['111', '001', '111', '001', '111'],
'4': ['101', '101', '111', '001', '001'], '5': ['111', '100', '111', '001', '111'],
'6': ['111', '100', '111', '101', '111'], '7': ['111', '001', '010', '010', '010'],
'8': ['111', '101', '111', '101', '111'], '9': ['111', '101', '111', '001', '111'],
};
const digit = (ch, x, y, s, c) => { const g = FONT[ch]; if (!g) return; for (let r = 0; r < 5; r++) for (let col = 0; col < 3; col++) if (g[r][col] === '1') rect(x + col * s, y + r * s, s, s, c); };
const number = (n, x, y, s, c) => { const str = String(n); str.split('').forEach((ch, i) => digit(ch, x + i * (4 * s), y, s, c)); };
function drawFrame(cr) {
const ox = (cr.frame % COLS) * FW;
const oy = Math.floor(cr.frame / COLS) * FH;
const cx = ox + FW / 2, cy = oy + FH / 2;
const base = cr.color, bg = dark(base, 0.28), edge = light(base, 0.5);
// cell background + border + corner tier accent
rect(ox, oy, FW, FH, bg);
// vignette glow
disc(cx, cy + 10, 130, dark(base, 0.42), 110);
// border
rect(ox, oy, FW, 6, edge); rect(ox, oy + FH - 6, FW, 6, edge);
rect(ox, oy, 6, FH, edge); rect(ox + FW - 6, oy, 6, FH, edge);
const sz = cr.tier === 3 ? 1.25 : cr.tier === 2 ? 1.08 : 0.92; // boss bigger
const body = base, belly = light(base, 0.35), shade = dark(base, 0.6);
const r = Math.round(78 * sz);
// shape-specific silhouette
switch (cr.shape) {
case 'worm':
for (let s = 0; s < 4; s++) disc(cx - 60 + s * 40, cy + 20 - s * 6, Math.round(34 * sz), s % 2 ? body : light(body, 0.15));
ellipse(cx + 78, cy - 4, Math.round(40 * sz), Math.round(34 * sz), body);
break;
case 'shroom':
rect(cx - 26, cy, 52, Math.round(70 * sz), belly); // stalk
ellipse(cx, cy - 6, Math.round(86 * sz), Math.round(54 * sz), body); // cap
for (let i = 0; i < 5; i++) disc(cx - 60 + i * 30, cy - 16, 10, light(body, 0.5));
break;
case 'slime':
ellipse(cx, cy + 20, Math.round(96 * sz), Math.round(70 * sz), body);
ellipse(cx, cy + 44, Math.round(96 * sz), Math.round(30 * sz), shade); // base shadow
ellipse(cx - 26, cy - 6, 22, 16, belly, 160);
break;
case 'spiky':
for (let a = 0; a < 12; a++) { const an = (a / 12) * Math.PI * 2; tri(cx + Math.cos(an) * r, cy + Math.sin(an) * r, cx + Math.cos(an + 0.25) * (r + 34), cy + Math.sin(an + 0.25) * (r + 34), cx + Math.cos(an + 0.5) * r, cy + Math.sin(an + 0.5) * r, shade); }
disc(cx, cy, r, body);
break;
case 'orb':
disc(cx, cy, r, shade); disc(cx, cy, Math.round(r * 0.8), body); disc(cx, cy, Math.round(r * 0.32), light(body, 0.6));
break;
case 'horns':
tri(cx - r + 4, cy - 40, cx - r - 34, cy - 96, cx - r + 30, cy - 70, light(body, 0.2));
tri(cx + r - 4, cy - 40, cx + r + 34, cy - 96, cx + r - 30, cy - 70, light(body, 0.2));
disc(cx, cy + 6, r, body);
break;
case 'golem':
rect(cx - r, cy - r, r * 2, Math.round(r * 2.1), body); // blocky body
rect(cx - r, cy - r, r * 2, 10, light(body, 0.3));
rect(cx - r + 14, cy + 30, 28, r, shade); rect(cx + r - 42, cy + 30, 28, r, shade); // leg gap
break;
case 'robed':
tri(cx, cy - r - 10, cx - Math.round(r * 0.95), cy + r, cx + Math.round(r * 0.95), cy + r, body); // hood/robe
disc(cx, cy - Math.round(r * 0.4), Math.round(r * 0.42), shade); // face shadow
break;
case 'bug':
default:
ellipse(cx, cy + 6, Math.round(r * 0.95), Math.round(r * 1.05), body);
for (let i = 0; i < 3; i++) { rect(cx - r, cy - 20 + i * 26, 26, 6, shade); rect(cx + r - 26, cy - 20 + i * 26, 26, 6, shade); } // legs
break;
}
// eyes (skip for golem/orb which read as constructs but give them a core eye)
const ey = cy - Math.round(r * 0.2), espread = Math.round(r * 0.42), eR = Math.max(9, Math.round(r * 0.2));
if (cr.shape === 'orb' || cr.shape === 'golem') {
disc(cx, cy - (cr.shape === 'golem' ? Math.round(r * 0.4) : 0), eR, [0xff, 0x66, 0x44]);
disc(cx, cy - (cr.shape === 'golem' ? Math.round(r * 0.4) : 0), Math.round(eR * 0.5), [0xff, 0xdd, 0xaa]);
} else {
for (const sx of [cx - espread, cx + espread]) {
disc(sx, ey, eR, [0xf6, 0xf2, 0xe8]);
disc(sx + 2, ey + 2, Math.round(eR * 0.5), [0x18, 0x12, 0x1e]);
}
}
// frame-index badge (top-left of cell)
rect(ox + 12, oy + 12, 4 * 6 * String(cr.frame).length + 8, 30, [0, 0, 0], 150);
number(cr.frame, ox + 16, oy + 17, 4, light(base, 0.7));
}
for (const cr of CREATURES) drawFrame(cr);
// ── encode PNG (truecolour + alpha, 8-bit) ──
function crc32(b) { let c = ~0; for (let i = 0; i < b.length; i++) { c ^= b[i]; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; }
function chunk(type, data) {
const t = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const body = Buffer.concat([t, data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 6; // RGBA
// filtered scanlines (filter 0)
const raw = Buffer.alloc((W * 4 + 1) * H);
for (let y = 0; y < H; y++) { raw[y * (W * 4 + 1)] = 0; Buffer.from(buf.buffer, y * W * 4, W * 4).copy(raw, y * (W * 4 + 1) + 1); }
const idat = zlib.deflateSync(raw, { level: 9 });
const png = Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)),
]);
const out = 'public/assets/images/spireclimb-creatures.png';
writeFileSync(out, png);
console.log(`Wrote ${out} (${W}×${H}, ${CREATURES.length} frames)`);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,36 @@
{
"_readme": [
"Drop-in art for Spire Climb.",
"CREATURES: a dedicated placeholder sheet ships at the creatureSheet.path below",
" (spireclimb-creatures.png, 1500x600 = 10 frames of 300x300, 5 cols x 2 rows,",
" row-major frame order). Regenerate it with `node genSpireClimbCreatures.js`, or",
" paint over it keeping the same layout/frame order. The 'creatures' map ties each",
" enemy id to its frame index.",
"CARDS: still procedural until you add a sheet. To add card art:",
" 1. Drop spireclimb-cards.png into /assets/images/.",
" 2. Set cardSheet.path below + frameWidth/frameHeight.",
" 3. Map each card id to its 0-based frame index in 'cards'.",
"Cards show the illustration inside the procedural card frame's art window (no",
"border/text needed). Recommended card frame 250x160 (art-window aspect). See",
"src/games/spireclimb/sprites.md for the full sprite spec + frame maps."
],
"cardSheet": { "key": "spireclimb-cards", "path": "/assets/images/spireclimb-cards.png", "frameWidth": 250, "frameHeight": 160 },
"creatureSheet": { "key": "spireclimb-creatures", "path": "/assets/images/spireclimb-creatures.png", "frameWidth": 300, "frameHeight": 300 },
"cards": {
"strike": 0, "defend": 1, "bash": 2, "neutralize": 3, "survivor": 4,
"ironwave": 5, "pommelstrike": 6, "cleave": 7, "shrugitoff": 8, "clothesline": 9,
"inflame": 10, "uppercut": 11, "ghostlyarmor": 12, "bodyslam": 13, "metallicize": 14,
"whirlwind": 15, "limitbreak": 16, "demonform": 17,
"daggerthrow": 18, "poisonedstab": 19, "deadlypoison": 20, "backflip": 21, "sneakyhit": 22,
"footwork": 23, "bladedance": 24, "caltrops": 25, "bouncingblade": 26, "crippling": 27, "catalyst": 28,
"bandage": 29, "flashofsteel": 30,
"wound": 31, "dazed": 32, "burn": 33
},
"creatures": {
"jawworm": 0, "cultist": 1, "louse": 2, "fungi": 3, "spikeslime": 4,
"sentry": 5, "gremlinnob": 6, "lagavulin": 7,
"guardian": 8, "slimeboss": 9
}
}

View File

@ -0,0 +1,469 @@
// SpireClimbData.js
// Static content for Spire Climb: card pools (Warrior + Rogue + neutral/status/curse),
// relics, potions, enemy/elite/boss definitions, and act/map tuning.
//
// All card art is procedural by default. To drop in real art later, populate
// /data/spireclimb-artwork.json mapping a card `id` (or enemy `id`) to a
// { key, frame } in a loaded spritesheet — SpireClimbGame resolves art through
// that map and falls back to procedural drawing / the shared `opponents` sheet.
export const CLASSES = {
warrior: {
id: 'warrior',
name: 'Warrior',
blurb: 'Strength & block. Trade blows, stack armor, and hit like a truck.',
color: 0xb5392f,
colorHex: '#b5392f',
maxHp: 80,
startRelic: 'burning-blood',
// starter deck: 5 Strike, 4 Defend, 1 Bash
startingDeck: ['strike', 'strike', 'strike', 'strike', 'strike', 'defend', 'defend', 'defend', 'defend', 'bash'],
},
rogue: {
id: 'rogue',
name: 'Rogue',
blurb: 'Poison & dexterity. Death by a thousand cuts; dodge what you can.',
color: 0x4b9c5a,
colorHex: '#4b9c5a',
maxHp: 70,
startRelic: 'ring-of-the-snake',
// starter deck: 5 Strike, 4 Defend, 1 Neutralize, 1 Survivor (draws extra at start via relic)
startingDeck: ['strike', 'strike', 'strike', 'strike', 'strike', 'defend', 'defend', 'defend', 'defend', 'neutralize', 'survivor'],
},
};
// ── Status / buff definitions (display + behavior live in Logic) ──────────────
export const STATUS = {
strength: { name: 'Strength', kind: 'buff', color: 0xe0533a, desc: 'Attacks deal +X damage.' },
dexterity: { name: 'Dexterity', kind: 'buff', color: 0x4bce6b, desc: 'Block cards grant +X block.' },
vulnerable: { name: 'Vulnerable', kind: 'debuff', color: 0xd23b6a, desc: 'Takes 50% more attack damage.' },
weak: { name: 'Weak', kind: 'debuff', color: 0x8a6bd1, desc: 'Deals 25% less attack damage.' },
frail: { name: 'Frail', kind: 'debuff', color: 0xb88a3a, desc: 'Gains 25% less block.' },
poison: { name: 'Poison', kind: 'debuff', color: 0x6fae3a, desc: 'Lose X HP at turn start, then X drops by 1.' },
ritual: { name: 'Ritual', kind: 'buff', color: 0xc94f8a, desc: 'Gains X strength each turn.' },
regen: { name: 'Regen', kind: 'buff', color: 0x4bce6b, desc: 'Heal X at turn end, then X drops by 1.' },
metallicize:{ name: 'Metallicize',kind: 'buff', color: 0x9aa7b5, desc: 'Gain X block at turn end.' },
};
// ── CARD POOL ─────────────────────────────────────────────────────────────────
// type: attack | skill | power | status | curse
// target: enemy | self | all | none
// effects: ordered list of structured ops resolved by SpireClimbLogic.resolveCard
// upgrade: partial overrides applied when a card is upgraded (name suffixed with +)
export const CARDS = {
// —— Basic / starter ——
strike: {
id: 'strike', name: 'Strike', cls: 'neutral', type: 'attack', rarity: 'starter',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 6 }],
text: 'Deal 6 damage.',
upgrade: { effects: [{ op: 'damage', amount: 9 }], text: 'Deal 9 damage.' },
},
defend: {
id: 'defend', name: 'Defend', cls: 'neutral', type: 'skill', rarity: 'starter',
cost: 1, target: 'self', effects: [{ op: 'block', amount: 5 }],
text: 'Gain 5 block.',
upgrade: { effects: [{ op: 'block', amount: 8 }], text: 'Gain 8 block.' },
},
bash: {
id: 'bash', name: 'Bash', cls: 'warrior', type: 'attack', rarity: 'starter',
cost: 2, target: 'enemy',
effects: [{ op: 'damage', amount: 8 }, { op: 'debuff', status: 'vulnerable', amount: 2 }],
text: 'Deal 8 damage. Apply 2 Vulnerable.',
upgrade: { effects: [{ op: 'damage', amount: 10 }, { op: 'debuff', status: 'vulnerable', amount: 3 }], text: 'Deal 10 damage. Apply 3 Vulnerable.' },
},
neutralize: {
id: 'neutralize', name: 'Neutralize', cls: 'rogue', type: 'attack', rarity: 'starter',
cost: 0, target: 'enemy',
effects: [{ op: 'damage', amount: 3 }, { op: 'debuff', status: 'weak', amount: 1 }],
text: 'Deal 3 damage. Apply 1 Weak.',
upgrade: { effects: [{ op: 'damage', amount: 4 }, { op: 'debuff', status: 'weak', amount: 2 }], text: 'Deal 4 damage. Apply 2 Weak.' },
},
survivor: {
id: 'survivor', name: 'Survivor', cls: 'rogue', type: 'skill', rarity: 'starter',
cost: 1, target: 'self', effects: [{ op: 'block', amount: 8 }, { op: 'discard', amount: 1 }],
text: 'Gain 8 block. Discard 1 card.',
upgrade: { effects: [{ op: 'block', amount: 11 }, { op: 'discard', amount: 1 }], text: 'Gain 11 block. Discard 1 card.' },
},
// —— Warrior common ——
ironwave: {
id: 'ironwave', name: 'Iron Wave', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'block', amount: 5 }, { op: 'damage', amount: 5 }],
text: 'Gain 5 block. Deal 5 damage.',
upgrade: { effects: [{ op: 'block', amount: 7 }, { op: 'damage', amount: 7 }], text: 'Gain 7 block. Deal 7 damage.' },
},
pommelstrike: {
id: 'pommelstrike', name: 'Pommel Strike', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 9 }, { op: 'draw', amount: 1 }],
text: 'Deal 9 damage. Draw 1 card.',
upgrade: { effects: [{ op: 'damage', amount: 10 }, { op: 'draw', amount: 2 }], text: 'Deal 10 damage. Draw 2 cards.' },
},
cleave: {
id: 'cleave', name: 'Cleave', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 1, target: 'all', effects: [{ op: 'damageAll', amount: 8 }],
text: 'Deal 8 damage to ALL enemies.',
upgrade: { effects: [{ op: 'damageAll', amount: 11 }], text: 'Deal 11 damage to ALL enemies.' },
},
shrugitoff: {
id: 'shrugitoff', name: 'Shrug It Off', cls: 'warrior', type: 'skill', rarity: 'common',
cost: 1, target: 'self', effects: [{ op: 'block', amount: 8 }, { op: 'draw', amount: 1 }],
text: 'Gain 8 block. Draw 1 card.',
upgrade: { effects: [{ op: 'block', amount: 11 }, { op: 'draw', amount: 1 }], text: 'Gain 11 block. Draw 1 card.' },
},
clothesline: {
id: 'clothesline', name: 'Clothesline', cls: 'warrior', type: 'attack', rarity: 'common',
cost: 2, target: 'enemy', effects: [{ op: 'damage', amount: 12 }, { op: 'debuff', status: 'weak', amount: 2 }],
text: 'Deal 12 damage. Apply 2 Weak.',
upgrade: { effects: [{ op: 'damage', amount: 14 }, { op: 'debuff', status: 'weak', amount: 3 }], text: 'Deal 14 damage. Apply 3 Weak.' },
},
// —— Warrior uncommon/rare ——
inflame: {
id: 'inflame', name: 'Inflame', cls: 'warrior', type: 'power', rarity: 'uncommon',
cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'strength', amount: 2 }],
text: 'Gain 2 Strength.',
upgrade: { effects: [{ op: 'buffSelf', status: 'strength', amount: 3 }], text: 'Gain 3 Strength.' },
},
uppercut: {
id: 'uppercut', name: 'Uppercut', cls: 'warrior', type: 'attack', rarity: 'uncommon',
cost: 2, target: 'enemy',
effects: [{ op: 'damage', amount: 13 }, { op: 'debuff', status: 'weak', amount: 1 }, { op: 'debuff', status: 'vulnerable', amount: 1 }],
text: 'Deal 13 damage. Apply 1 Weak. Apply 1 Vulnerable.',
upgrade: { effects: [{ op: 'damage', amount: 13 }, { op: 'debuff', status: 'weak', amount: 2 }, { op: 'debuff', status: 'vulnerable', amount: 2 }], text: 'Deal 13 damage. Apply 2 Weak. Apply 2 Vulnerable.' },
},
ghostlyarmor: {
id: 'ghostlyarmor', name: 'Ghostly Armor', cls: 'warrior', type: 'skill', rarity: 'uncommon',
cost: 1, target: 'self', effects: [{ op: 'block', amount: 10 }],
text: 'Gain 10 block.',
upgrade: { effects: [{ op: 'block', amount: 13 }], text: 'Gain 13 block.' },
},
bodyslam: {
id: 'bodyslam', name: 'Body Slam', cls: 'warrior', type: 'attack', rarity: 'uncommon',
cost: 1, target: 'enemy', effects: [{ op: 'damageEqualBlock' }],
text: 'Deal damage equal to your current block.',
upgrade: { cost: 0, text: 'Deal damage equal to your current block.' },
},
metallicize: {
id: 'metallicize', name: 'Metallicize', cls: 'warrior', type: 'power', rarity: 'uncommon',
cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'metallicize', amount: 3 }],
text: 'At the end of your turn, gain 3 block.',
upgrade: { effects: [{ op: 'buffSelf', status: 'metallicize', amount: 4 }], text: 'At the end of your turn, gain 4 block.' },
},
whirlwind: {
id: 'whirlwind', name: 'Whirlwind', cls: 'warrior', type: 'attack', rarity: 'uncommon',
cost: 2, target: 'all', effects: [{ op: 'damageAll', amount: 5, times: 3 }],
text: 'Deal 5 damage to ALL enemies 3 times.',
upgrade: { effects: [{ op: 'damageAll', amount: 8, times: 3 }], text: 'Deal 8 damage to ALL enemies 3 times.' },
},
limitbreak: {
id: 'limitbreak', name: 'Limit Break', cls: 'warrior', type: 'skill', rarity: 'rare',
cost: 1, target: 'self', exhaust: true, effects: [{ op: 'doubleStrength' }],
text: 'Double your Strength. Exhaust.',
upgrade: { exhaust: false, text: 'Double your Strength.' },
},
demonform: {
id: 'demonform', name: 'Demon Form', cls: 'warrior', type: 'power', rarity: 'rare',
cost: 3, target: 'self', effects: [{ op: 'buffSelf', status: 'ritual', amount: 2 }],
text: 'At the start of each turn, gain 2 Strength.',
upgrade: { effects: [{ op: 'buffSelf', status: 'ritual', amount: 3 }], text: 'At the start of each turn, gain 3 Strength.' },
},
// —— Rogue common ——
daggerthrow: {
id: 'daggerthrow', name: 'Dagger Throw', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 9 }, { op: 'draw', amount: 1 }, { op: 'discard', amount: 1 }],
text: 'Deal 9 damage. Draw 1, discard 1.',
upgrade: { effects: [{ op: 'damage', amount: 12 }, { op: 'draw', amount: 1 }, { op: 'discard', amount: 1 }], text: 'Deal 12 damage. Draw 1, discard 1.' },
},
poisonedstab: {
id: 'poisonedstab', name: 'Poisoned Stab', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 6 }, { op: 'poison', amount: 3 }],
text: 'Deal 6 damage. Apply 3 Poison.',
upgrade: { effects: [{ op: 'damage', amount: 8 }, { op: 'poison', amount: 4 }], text: 'Deal 8 damage. Apply 4 Poison.' },
},
deadlypoison: {
id: 'deadlypoison', name: 'Deadly Poison', cls: 'rogue', type: 'skill', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'poison', amount: 5 }],
text: 'Apply 5 Poison.',
upgrade: { effects: [{ op: 'poison', amount: 7 }], text: 'Apply 7 Poison.' },
},
backflip: {
id: 'backflip', name: 'Backflip', cls: 'rogue', type: 'skill', rarity: 'common',
cost: 1, target: 'self', effects: [{ op: 'block', amount: 5 }, { op: 'draw', amount: 2 }],
text: 'Gain 5 block. Draw 2 cards.',
upgrade: { effects: [{ op: 'block', amount: 8 }, { op: 'draw', amount: 2 }], text: 'Gain 8 block. Draw 2 cards.' },
},
sneakyhit: {
id: 'sneakyhit', name: 'Sneaky Hit', cls: 'rogue', type: 'attack', rarity: 'common',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 12 }],
text: 'Deal 12 damage.',
upgrade: { effects: [{ op: 'damage', amount: 14 }], text: 'Deal 14 damage.' },
},
// —— Rogue uncommon/rare ——
footwork: {
id: 'footwork', name: 'Footwork', cls: 'rogue', type: 'power', rarity: 'uncommon',
cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'dexterity', amount: 2 }],
text: 'Gain 2 Dexterity.',
upgrade: { effects: [{ op: 'buffSelf', status: 'dexterity', amount: 3 }], text: 'Gain 3 Dexterity.' },
},
bladedance: {
id: 'bladedance', name: 'Blade Dance', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 1, target: 'enemy', effects: [{ op: 'damage', amount: 4, times: 3 }],
text: 'Deal 4 damage 3 times.',
upgrade: { effects: [{ op: 'damage', amount: 6, times: 3 }], text: 'Deal 6 damage 3 times.' },
},
caltrops: {
id: 'caltrops', name: 'Caltrops', cls: 'rogue', type: 'power', rarity: 'uncommon',
cost: 1, target: 'self', effects: [{ op: 'buffSelf', status: 'metallicize', amount: 2 }],
text: 'At the end of your turn, gain 2 block.',
upgrade: { effects: [{ op: 'buffSelf', status: 'metallicize', amount: 3 }], text: 'At the end of your turn, gain 3 block.' },
},
bouncingblade: {
id: 'bouncingblade', name: 'Bouncing Blade', cls: 'rogue', type: 'attack', rarity: 'uncommon',
cost: 1, target: 'all', effects: [{ op: 'damageAll', amount: 3, times: 3 }],
text: 'Deal 3 damage to ALL enemies 3 times.',
upgrade: { effects: [{ op: 'damageAll', amount: 4, times: 3 }], text: 'Deal 4 damage to ALL enemies 3 times.' },
},
crippling: {
id: 'crippling', name: 'Crippling Cloud', cls: 'rogue', type: 'skill', rarity: 'rare',
cost: 2, target: 'all', exhaust: true,
effects: [{ op: 'poisonAll', amount: 4 }, { op: 'debuffAll', status: 'weak', amount: 2 }],
text: 'Apply 4 Poison and 2 Weak to ALL enemies. Exhaust.',
upgrade: { effects: [{ op: 'poisonAll', amount: 7 }, { op: 'debuffAll', status: 'weak', amount: 2 }], text: 'Apply 7 Poison and 2 Weak to ALL enemies. Exhaust.' },
},
catalyst: {
id: 'catalyst', name: 'Catalyst', cls: 'rogue', type: 'skill', rarity: 'rare',
cost: 1, target: 'enemy', exhaust: true, effects: [{ op: 'multiplyPoison', factor: 2 }],
text: "Double an enemy's Poison. Exhaust.",
upgrade: { effects: [{ op: 'multiplyPoison', factor: 3 }], text: "Triple an enemy's Poison. Exhaust." },
},
// —— Neutral / colorless ——
bandage: {
id: 'bandage', name: 'Bandage Up', cls: 'neutral', type: 'skill', rarity: 'uncommon',
cost: 0, target: 'self', exhaust: true, effects: [{ op: 'heal', amount: 4 }],
text: 'Heal 4 HP. Exhaust.',
upgrade: { effects: [{ op: 'heal', amount: 6 }], text: 'Heal 6 HP. Exhaust.' },
},
flashofsteel: {
id: 'flashofsteel', name: 'Flash of Steel', cls: 'neutral', type: 'attack', rarity: 'common',
cost: 0, target: 'enemy', effects: [{ op: 'damage', amount: 3 }, { op: 'draw', amount: 1 }],
text: 'Deal 3 damage. Draw 1 card.',
upgrade: { effects: [{ op: 'damage', amount: 6 }, { op: 'draw', amount: 1 }], text: 'Deal 6 damage. Draw 1 card.' },
},
// —— Status / curse (non-removable in battle; clutter the deck) ——
wound: { id: 'wound', name: 'Wound', cls: 'status', type: 'status', rarity: 'special', cost: -1, unplayable: true, target: 'none', effects: [], text: 'Unplayable.' },
dazed: { id: 'dazed', name: 'Dazed', cls: 'status', type: 'status', rarity: 'special', cost: -1, unplayable: true, ethereal: true, target: 'none', effects: [], text: 'Unplayable. Ethereal.' },
burn: { id: 'burn', name: 'Burn', cls: 'status', type: 'status', rarity: 'special', cost: -1, unplayable: true, target: 'none', effects: [], text: 'Unplayable. At end of turn, take 2 damage.', endTurnSelfDamage: 2 },
};
// Reward pools by class (excludes starters/status/curse)
export function cardPoolFor(className) {
return Object.values(CARDS).filter(
(c) => (c.cls === className || c.cls === 'neutral') &&
c.rarity !== 'starter' && c.rarity !== 'special'
);
}
// ── RELICS ──────────────────────────────────────────────────────────────────
// hooks are interpreted by Logic at the noted trigger points.
export const RELICS = {
'burning-blood': { id: 'burning-blood', name: 'Burning Blood', rarity: 'starter', desc: 'At the end of combat, heal 6 HP.', hook: 'combatEndHeal', value: 6 },
'ring-of-the-snake': { id: 'ring-of-the-snake', name: 'Ring of the Snake', rarity: 'starter', desc: 'At the start of each combat, draw 2 extra cards.', hook: 'combatStartDraw', value: 2 },
'bag-of-marbles': { id: 'bag-of-marbles', name: 'Bag of Marbles', rarity: 'common', desc: 'At combat start, apply 1 Vulnerable to ALL enemies.', hook: 'combatStartVulnAll', value: 1 },
'anchor': { id: 'anchor', name: 'Anchor', rarity: 'common', desc: 'At combat start, gain 10 block.', hook: 'combatStartBlock', value: 10 },
'vajra': { id: 'vajra', name: 'Vajra', rarity: 'common', desc: 'At combat start, gain 1 Strength.', hook: 'combatStartStrength', value: 1 },
'oddly-smooth': { id: 'oddly-smooth', name: 'Oddly Smooth Stone', rarity: 'common', desc: 'At combat start, gain 1 Dexterity.', hook: 'combatStartDexterity', value: 1 },
'blood-vial': { id: 'blood-vial', name: 'Blood Vial', rarity: 'common', desc: 'At combat start, heal 2 HP.', hook: 'combatStartHeal', value: 2 },
'bronze-scales': { id: 'bronze-scales', name: 'Bronze Scales', rarity: 'common', desc: 'Whenever you take attack damage, deal 3 back.', hook: 'thorns', value: 3 },
'kunai': { id: 'kunai', name: 'Kunai', rarity: 'uncommon', desc: 'Every 3rd attack played each turn, gain 1 Dexterity.', hook: 'kunai', value: 1 },
'pen-nib': { id: 'pen-nib', name: 'Pen Nib', rarity: 'uncommon', desc: 'Every 10th attack deals double damage.', hook: 'penNib', value: 10 },
'energy-core': { id: 'energy-core', name: 'Energy Core', rarity: 'uncommon', desc: 'Gain 1 extra energy each turn.', hook: 'extraEnergy', value: 1 },
'meat-on-the-bone': { id: 'meat-on-the-bone', name: 'Meat on the Bone', rarity: 'uncommon', desc: 'If you end combat below 50% HP, heal 12.', hook: 'meatOnBone', value: 12 },
};
export function relicPool(rarity) {
return Object.values(RELICS).filter((r) => r.rarity === rarity);
}
// ── POTIONS ───────────────────────────────────────────────────────────────────
export const POTIONS = {
'fire-potion': { id: 'fire-potion', name: 'Fire Potion', desc: 'Deal 20 damage to an enemy.', target: 'enemy', effects: [{ op: 'damage', amount: 20 }] },
'block-potion': { id: 'block-potion', name: 'Block Potion', desc: 'Gain 12 block.', target: 'self', effects: [{ op: 'block', amount: 12 }] },
'strength-potion': { id: 'strength-potion', name: 'Strength Potion', desc: 'Gain 2 Strength.', target: 'self', effects: [{ op: 'buffSelf', status: 'strength', amount: 2 }] },
'dexterity-potion':{ id: 'dexterity-potion', name: 'Dexterity Potion', desc: 'Gain 2 Dexterity.', target: 'self', effects: [{ op: 'buffSelf', status: 'dexterity', amount: 2 }] },
'weak-potion': { id: 'weak-potion', name: 'Weak Potion', desc: 'Apply 3 Weak to an enemy.', target: 'enemy', effects: [{ op: 'debuff', status: 'weak', amount: 3 }] },
'poison-potion': { id: 'poison-potion', name: 'Poison Potion', desc: 'Apply 6 Poison to an enemy.', target: 'enemy', effects: [{ op: 'poison', amount: 6 }] },
'energy-potion': { id: 'energy-potion', name: 'Energy Potion', desc: 'Gain 2 energy this turn.', target: 'self', effects: [{ op: 'energy', amount: 2 }] },
'heal-potion': { id: 'heal-potion', name: 'Healing Potion', desc: 'Heal 20% of max HP.', target: 'self', effects: [{ op: 'healPct', amount: 0.2 }] },
};
export const POTION_IDS = Object.keys(POTIONS);
// ── ENEMIES ───────────────────────────────────────────────────────────────────
// placeholderFrame indexes the shared `opponents` spritesheet (300×300) for
// placeholder art. moves[] drive intents; ai picks a move each enemy turn.
// effect ops on enemy moves mirror the card resolver but originate from the enemy.
export const ENEMIES = {
// —— normal ——
jawworm: {
id: 'jawworm', name: 'Jaw Worm', tier: 'normal', placeholderFrame: 3,
hp: [40, 44], color: 0x9c6b3a,
moves: [
{ id: 'chomp', intent: 'attack', value: 11, effects: [{ op: 'damage', amount: 11 }] },
{ id: 'thrash', intent: 'attackdefend', value: 7, block: 5, effects: [{ op: 'damage', amount: 7 }, { op: 'blockSelf', amount: 5 }] },
{ id: 'bellow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 3 }, { op: 'blockSelf', amount: 6 }] },
],
ai: 'jawworm',
},
cultist: {
id: 'cultist', name: 'Cultist', tier: 'normal', placeholderFrame: 12, hp: [48, 54], color: 0x6a4d8a,
moves: [
{ id: 'incantation', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'ritual', amount: 3 }] },
{ id: 'darkstrike', intent: 'attack', value: 6, effects: [{ op: 'damage', amount: 6 }] },
],
ai: 'cultist',
},
louse: {
id: 'louse', name: 'Red Louse', tier: 'normal', placeholderFrame: 9, hp: [11, 16], color: 0xc0533a,
moves: [
{ id: 'bite', intent: 'attack', value: 6, effects: [{ op: 'damage', amount: 6 }] },
{ id: 'grow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 3 }] },
],
ai: 'random',
},
fungi: {
id: 'fungi', name: 'Fungi Beast', tier: 'normal', placeholderFrame: 5, hp: [22, 28], color: 0x4b8c5a,
moves: [
{ id: 'bite', intent: 'attack', value: 6, effects: [{ op: 'damage', amount: 6 }] },
{ id: 'grow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 4 }] },
],
ai: 'random',
dies: { op: 'debuffPlayer', status: 'frail', amount: 0 }, // spore handled in logic on death
},
spikeslime: {
id: 'spikeslime', name: 'Spike Slime', tier: 'normal', placeholderFrame: 21, hp: [28, 32], color: 0x4a6bbf,
moves: [
{ id: 'flame', intent: 'attackdebuff', value: 8, effects: [{ op: 'damage', amount: 8 }, { op: 'addCardToDiscard', card: 'wound', amount: 1 }] },
{ id: 'lick', intent: 'debuff', effects: [{ op: 'debuffPlayer', status: 'frail', amount: 1 }] },
],
ai: 'alternate',
},
// —— elites ——
sentry: {
id: 'sentry', name: 'Sentry', tier: 'elite', placeholderFrame: 22, hp: [38, 42], color: 0x9aa7b5,
moves: [
{ id: 'beam', intent: 'attack', value: 9, effects: [{ op: 'damage', amount: 9 }] },
{ id: 'bolt', intent: 'debuff', effects: [{ op: 'addCardToDiscard', card: 'dazed', amount: 2 }] },
],
ai: 'alternate',
},
gremlinnob: {
id: 'gremlinnob', name: 'Gremlin Nob', tier: 'elite', placeholderFrame: 23, hp: [82, 86], color: 0xb5503a,
moves: [
{ id: 'bellow', intent: 'buff', effects: [{ op: 'buffSelfEnemy', status: 'strength', amount: 2 }] },
{ id: 'rush', intent: 'attack', value: 14, effects: [{ op: 'damage', amount: 14 }] },
{ id: 'skullbash', intent: 'attackdebuff', value: 6, effects: [{ op: 'damage', amount: 6 }, { op: 'debuffPlayer', status: 'vulnerable', amount: 2 }] },
],
ai: 'gremlinnob',
},
lagavulin: {
id: 'lagavulin', name: 'Lagavulin', tier: 'elite', placeholderFrame: 19, hp: [105, 112], color: 0x3a7a6b,
moves: [
{ id: 'sleep', intent: 'sleep', effects: [{ op: 'blockSelf', amount: 8 }] },
{ id: 'attack', intent: 'attack', value: 18, effects: [{ op: 'damage', amount: 18 }] },
{ id: 'siphon', intent: 'debuff', effects: [{ op: 'debuffPlayer', status: 'strength', amount: -1 }, { op: 'debuffPlayer', status: 'dexterity', amount: -1 }] },
],
ai: 'lagavulin',
},
// —— bosses ——
guardian: {
id: 'guardian', name: 'The Guardian', tier: 'boss', placeholderFrame: 13, hp: [180, 180], color: 0x8a6b3a,
moves: [
{ id: 'slam', intent: 'attack', value: 16, effects: [{ op: 'damage', amount: 16 }] },
{ id: 'whirl', intent: 'attack', value: 5, times: 4, effects: [{ op: 'damage', amount: 5, times: 4 }] },
{ id: 'shell', intent: 'defend', block: 20, effects: [{ op: 'blockSelf', amount: 20 }, { op: 'buffSelfEnemy', status: 'metallicize', amount: 4 }] },
{ id: 'roll', intent: 'attackdebuff', value: 10, effects: [{ op: 'damage', amount: 10 }, { op: 'addCardToDiscard', card: 'dazed', amount: 2 }] },
],
ai: 'sequence',
},
slimeboss: {
id: 'slimeboss', name: 'Slime Boss', tier: 'boss', placeholderFrame: 27, hp: [150, 150], color: 0x4a6bbf,
moves: [
{ id: 'goop', intent: 'debuff', effects: [{ op: 'addCardToDiscard', card: 'dazed', amount: 3 }] },
{ id: 'prep', intent: 'unknown', effects: [] },
{ id: 'slam', intent: 'attack', value: 22, effects: [{ op: 'damage', amount: 22 }] },
],
ai: 'sequence',
},
};
export function enemiesByTier(tier) {
return Object.values(ENEMIES).filter((e) => e.tier === tier);
}
// ── ENCOUNTER TABLES (which enemy groups appear) ──────────────────────────────
export const ENCOUNTERS = {
normal: [
['jawworm'],
['cultist'],
['louse', 'louse'],
['fungi', 'fungi'],
['spikeslime'],
['louse', 'spikeslime'],
['cultist', 'louse'],
],
elite: [
['gremlinnob'],
['lagavulin'],
['sentry', 'sentry', 'sentry'],
],
boss: [
['guardian'],
['slimeboss'],
],
};
// ── MAP / ACT TUNING ──────────────────────────────────────────────────────────
export const ACT = {
rows: 11, // floors, last is boss
minWidth: 1,
maxWidth: 4,
// node-type weights for the middle floors (start/rest/boss are forced)
weights: { combat: 0.45, event: 0.22, elite: 0.16, rest: 0.10, shop: 0.07 },
startGold: 99,
cardRewardChoices: 3,
shopPrices: { common: 50, uncommon: 75, rare: 150, relic: 150, potion: 50, removal: 75 },
};
// non-combat event deck (simple branching choices resolved in Logic)
export const EVENTS = [
{
id: 'bonfire', title: 'Dying Embers',
text: 'A bonfire of forgotten cards crackles before you.',
options: [
{ label: 'Rest (heal 25% HP)', effect: { op: 'healPct', amount: 0.25 } },
{ label: 'Smith (upgrade a card)', effect: { op: 'upgrade' } },
{ label: 'Toss a card in (remove + gain 30 gold)', effect: { op: 'removeForGold', gold: 30 } },
],
},
{
id: 'shrine', title: 'Golden Shrine',
text: 'An idol promises power for a price.',
options: [
{ label: 'Pray (gain a relic, take 1 Curse)', effect: { op: 'relicAndCurse' } },
{ label: 'Desecrate (gain 75 gold, lose 8 HP)', effect: { op: 'goldForHp', gold: 75, hp: 8 } },
{ label: 'Leave', effect: { op: 'nothing' } },
],
},
{
id: 'wanderer', title: 'Wounded Wanderer',
text: 'A traveler offers a trade.',
options: [
{ label: 'Heal them (lose 6 HP, gain a potion)', effect: { op: 'hpForPotion', hp: 6 } },
{ label: 'Rob them (gain 60 gold, take 1 Curse)', effect: { op: 'goldAndCurse', gold: 60 } },
{ label: 'Leave', effect: { op: 'nothing' } },
],
},
];
export const NODE_TYPES = ['start', 'combat', 'elite', 'event', 'rest', 'shop', 'treasure', 'boss'];

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,732 @@
// 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, 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 12 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) {
let table;
if (node.type === 'boss') table = ENCOUNTERS.boss;
else if (node.type === 'elite') table = ENCOUNTERS.elite;
else table = ENCOUNTERS.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;
}
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
if (node && node.type === 'boss') { run.finished = true; run.victory = true; }
return { result: 'won', rewards: rollRewards(run, node, rng) };
}
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 };

View File

@ -0,0 +1,146 @@
# Spire Climb — Sprite / Art Spec
Everything in Spire Climb renders procedurally out of the box, so the game is
fully playable with **no** art. This document lists the optional sprite sheets
you can drop in to replace the placeholders, with exact dimensions, layout, and
frame-by-frame maps.
All art is wired through **`public/data/spireclimb-artwork.json`**. The scene
checks whether each sheet's texture is loaded; if it is, it uses your frame,
otherwise it falls back to procedural drawing (or the shared `opponents` sheet
for creatures). You never need to touch code — just add a PNG and, if needed,
set its `path` in that JSON.
Frame numbering everywhere is **row-major, 0-based**: frame 0 is top-left, count
left-to-right then down to the next row.
---
## 1. Creature sheet — `spireclimb-creatures.png`
| | |
|---|---|
| **Path** | `public/assets/images/spireclimb-creatures.png` |
| **Sheet size** | **1500 × 600 px** |
| **Frame size** | **300 × 300 px** |
| **Layout** | 5 columns × 2 rows = 10 frames |
| **Status** | ✅ Placeholder ships now (regenerate with `node genSpireClimbCreatures.js`) |
| **JSON** | `creatureSheet` (path already set) + `creatures` map |
Frames are square; the creature is drawn centered and auto-scaled to ~200 px
tall in combat. Transparent background recommended (PNG alpha) so creatures sit
on the battlefield cleanly — but an opaque square also works.
### Frame map
| Frame | id | Name | Tier | Notes |
|---:|---|---|---|---|
| 0 | `jawworm` | Jaw Worm | normal | basic bruiser |
| 1 | `cultist` | Cultist | normal | buffs itself (Ritual) |
| 2 | `louse` | Red Louse | normal | small, appears in pairs |
| 3 | `fungi` | Fungi Beast | normal | mushroom |
| 4 | `spikeslime` | Spike Slime | normal | spiky blue blob |
| 5 | `sentry` | Sentry | elite | mechanical orb |
| 6 | `gremlinnob` | Gremlin Nob | elite | big horned brute |
| 7 | `lagavulin` | Lagavulin | elite | armored sleeper |
| 8 | `guardian` | The Guardian | **boss** | huge construct |
| 9 | `slimeboss` | Slime Boss | **boss** | giant slime |
> Tip: bosses read better a little bigger/more detailed since they fill more of
> the screen. Keep the same 300×300 cell — the scale-to-fit handles the rest.
---
## 2. Card art sheet — `spireclimb-cards.png` *(not yet created)*
| | |
|---|---|
| **Path** | `public/assets/images/spireclimb-cards.png` |
| **Frame size** | **250 × 160 px** (aspect ≈ 1.565 : 1, matches the card art window) |
| **Sheet size** | your choice — e.g. **a 6 × 6 grid = 1500 × 960 px** holds all 34 frames |
| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) |
| **JSON** | `cardSheet` (set `path`) + `cards` map |
**Only paint the illustration.** The card frame, border, energy-cost orb, name,
type label, and rules text are all drawn procedurally on top — your art shows
inside the card's "art window" (an inset rounded rectangle near the top of the
card). Art is scaled to **fit** that window (no cropping), so anything at the
~1.565:1 aspect fills it edge-to-edge; other aspects just letterbox. Opaque
rectangular art is fine; design it to read at small size (the window is ~144×92
on a resting hand card, ~266×170 during the play-to-center zoom).
You don't have to fill all 34 frames at once — any card without art (or any
frame index missing from the `cards` map) simply stays procedural.
### Frame map
Class legend: **N** = neutral, **W** = Warrior, **R** = Rogue, **S** = status.
| Frame | id | Name | Class | Type |
|---:|---|---|---|---|
| 0 | `strike` | Strike | N | attack |
| 1 | `defend` | Defend | N | skill |
| 2 | `bash` | Bash | W | attack |
| 3 | `neutralize` | Neutralize | R | attack |
| 4 | `survivor` | Survivor | R | skill |
| 5 | `ironwave` | Iron Wave | W | attack |
| 6 | `pommelstrike` | Pommel Strike | W | attack |
| 7 | `cleave` | Cleave | W | attack |
| 8 | `shrugitoff` | Shrug It Off | W | skill |
| 9 | `clothesline` | Clothesline | W | attack |
| 10 | `inflame` | Inflame | W | power |
| 11 | `uppercut` | Uppercut | W | attack |
| 12 | `ghostlyarmor` | Ghostly Armor | W | skill |
| 13 | `bodyslam` | Body Slam | W | attack |
| 14 | `metallicize` | Metallicize | W | power |
| 15 | `whirlwind` | Whirlwind | W | attack |
| 16 | `limitbreak` | Limit Break | W | skill |
| 17 | `demonform` | Demon Form | W | power |
| 18 | `daggerthrow` | Dagger Throw | R | attack |
| 19 | `poisonedstab` | Poisoned Stab | R | attack |
| 20 | `deadlypoison` | Deadly Poison | R | skill |
| 21 | `backflip` | Backflip | R | skill |
| 22 | `sneakyhit` | Sneaky Hit | R | attack |
| 23 | `footwork` | Footwork | R | power |
| 24 | `bladedance` | Blade Dance | R | attack |
| 25 | `caltrops` | Caltrops | R | power |
| 26 | `bouncingblade` | Bouncing Blade | R | attack |
| 27 | `crippling` | Crippling Cloud | R | skill |
| 28 | `catalyst` | Catalyst | R | skill |
| 29 | `bandage` | Bandage Up | N | skill |
| 30 | `flashofsteel` | Flash of Steel | N | attack |
| 31 | `wound` | Wound | S | status (clutter) |
| 32 | `dazed` | Dazed | S | status (clutter) |
| 33 | `burn` | Burn | S | status (clutter) |
> The three status cards (3133) are unplayable junk cards enemies shuffle into
> your deck — give them a grimy/cursed look. Lowest priority.
---
## 3. Menu icon — `game-icons.png` frame **74** *(shared sheet)*
| | |
|---|---|
| **File** | `public/assets/images/game-icons.png` (existing shared sheet) |
| **Sheet size** | 660 × 660 px |
| **Frame size** | **44 × 44 px** |
| **Layout** | 15 columns × 15 rows |
| **Spire Climb frame** | **74** → row 4, col 14 → pixel box **x 616660, y 176220** |
Add a 44×44 icon for Spire Climb into cell 74 of the existing sheet (the menu
tile pulls `game-icons` frame `74`). Keep it readable at tiny size — e.g. a
stylized spire/tower or a single hero card. This is the only sprite that goes
into an **existing** sheet rather than a new file.
---
## Quick checklist
- [ ] `spireclimb-creatures.png` — 1500×600, 10 × (300×300). *(placeholder exists; replace to taste)*
- [ ] `spireclimb-cards.png` — 250×160 frames, 34 cards; then set `cardSheet.path` in `spireclimb-artwork.json`.
- [ ] `game-icons.png` frame 74 — 44×44 menu icon.
After dropping in `spireclimb-cards.png`, set its `path` (and confirm
`frameWidth`/`frameHeight`) in `public/data/spireclimb-artwork.json`. The
creature sheet path is already set; the icon needs no config.

View File

@ -84,6 +84,7 @@ import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js';
import KataminoGame from './games/katamino/KataminoGame.js';
import BookworkGame from './games/bookwork/BookworkGame.js';
import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js';
import SpireClimbGame from './games/spireclimb/SpireClimbGame.js';
const config = {
type: Phaser.AUTO,
@ -181,6 +182,7 @@ const config = {
KataminoGame,
BookworkGame,
PaiGowPokerGame,
SpireClimbGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -72,6 +72,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('dotlink', '/data/dotlink.json');
this.load.json('katamino', '/data/katamino.json');
this.load.json('bookwork', '/data/bookwork.json');
this.load.json('spireclimb-artwork', '/data/spireclimb-artwork.json');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
@ -210,8 +211,16 @@ export default class PreloadScene extends Phaser.Scene {
...(slotsArt?.artwork ?? []).filter((a) => a.path && a.key && !this.textures.exists(a.key)),
];
if (toLoad.length > 0) {
// Spire Climb drop-in spritesheets (card art + creature art). Only loaded
// once the artist sets a `path` in /data/spireclimb-artwork.json; until then
// the game renders cards/creatures procedurally.
const scArt = this.cache.json.get('spireclimb-artwork');
const scSheets = [scArt?.cardSheet, scArt?.creatureSheet]
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
if (toLoad.length > 0 || scSheets.length > 0) {
for (const asset of toLoad) this.load.image(asset.key, asset.path);
for (const s of scSheets) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight });
await new Promise((resolve) => {
this.load.once('complete', resolve);
this.load.start();

View File

@ -100,3 +100,4 @@ registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', m
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, minOpponents: 0, maxOpponents: 5, defaultOpponents: 5, iconFrame: 73 });
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 74 });

138
verifySpireClimb.js Normal file
View File

@ -0,0 +1,138 @@
// verifySpireClimb.js — headless engine verification for Spire Climb.
// Runs map generation, full auto-played combats for both classes, reward/shop
// rolls, and a handful of invariant checks. Pure-logic (no Phaser/browser).
//
// node verifySpireClimb.js
import {
newRun, generateMap, availableNodes, enterNode, encounterForNode,
startCombat, autoPlayTurn, isCombatOver, settleCombat, makeRng,
rollRewards, generateShop, rollCardChoices, rollRelic, addCardToDeck,
upgradeCardInDeck, addRelic, resolvedCard, playableHand, nodeById,
} from './public/src/games/spireclimb/SpireClimbLogic.js';
import { CLASSES, CARDS, ENEMIES, ENCOUNTERS, ACT } from './public/src/games/spireclimb/SpireClimbData.js';
let pass = 0, fail = 0;
const fails = [];
function ok(cond, msg) { if (cond) pass++; else { fail++; fails.push(msg); } }
// ── 1. content sanity ──
for (const [cid, c] of Object.entries(CARDS)) {
ok(typeof c.name === 'string' && c.name.length > 0, `card ${cid} has name`);
ok(c.type && ['attack', 'skill', 'power', 'status', 'curse'].includes(c.type), `card ${cid} valid type`);
if (c.upgrade) {
const inst = { uid: 1, id: cid, upgraded: true };
const up = resolvedCard(inst);
ok(up.name.endsWith('+'), `card ${cid} upgrade name suffix`);
}
}
for (const [eid, e] of Object.entries(ENEMIES)) {
ok(Array.isArray(e.moves) && e.moves.length > 0, `enemy ${eid} has moves`);
ok(e.hp[0] <= e.hp[1], `enemy ${eid} hp range ordered`);
ok(Number.isInteger(e.placeholderFrame), `enemy ${eid} has placeholderFrame`);
}
for (const tier of ['normal', 'elite', 'boss']) {
ok(ENCOUNTERS[tier].length > 0, `encounter table ${tier} non-empty`);
for (const grp of ENCOUNTERS[tier]) for (const id of grp) ok(!!ENEMIES[id], `encounter enemy ${id} exists`);
}
// ── 2. map generation ──
for (let s = 0; s < 50; s++) {
const rng = makeRng(s + 1);
const map = generateMap(rng);
ok(map.grid.length === ACT.rows, `map seed ${s} rows`);
ok(map.grid[map.rows - 1][0].type === 'boss', `map seed ${s} boss at top`);
ok(map.grid[0].every((n) => n.type === 'combat'), `map seed ${s} row0 combat`);
// connectivity: every non-top node has an edge; every non-row0 node has a parent
const hasParent = new Set();
for (let r = 0; r < map.rows - 1; r++) for (const n of map.grid[r]) {
ok(n.edges.length > 0, `map seed ${s} node ${n.id} has out-edge`);
n.edges.forEach((id) => hasParent.add(id));
}
for (let r = 1; r < map.rows; r++) for (const n of map.grid[r]) ok(hasParent.has(n.id), `map seed ${s} node ${n.id} reachable`);
// a full path exists from row0 to boss
let frontier = map.grid[0].map((n) => n.id);
for (let r = 0; r < map.rows - 1; r++) {
const next = new Set();
for (const id of frontier) nodeById({ map }, id).edges.forEach((e) => next.add(e));
frontier = [...next];
}
ok(frontier.includes('n' + (map.rows - 1) + '_0'), `map seed ${s} boss reachable from start`);
}
// ── 3. full auto-played combats, both classes, many seeds ──
function runCombat(className, enemyIds, seed) {
const run = newRun(className, seed);
const combat = startCombat(run, enemyIds, seed * 7 + 3);
let guard = 200;
while (!isCombatOver(combat) && guard-- > 0) autoPlayTurn(combat);
return { run, combat, looped: guard <= 0 };
}
let playerWins = 0, total = 0, neverLooped = true;
for (const className of ['warrior', 'rogue']) {
for (const tier of ['normal', 'elite', 'boss']) {
for (const grp of ENCOUNTERS[tier]) {
for (let s = 0; s < 12; s++) {
const { combat, looped } = runCombat(className, grp, s + 1);
if (looped) neverLooped = false;
const res = isCombatOver(combat);
ok(res === 'won' || res === 'lost', `${className} vs ${grp.join('+')} seed ${s} resolved (${res})`);
// hp invariants
ok(combat.player.hp <= combat.player.maxHp, `${className} hp <= max (${grp.join('+')})`);
combat.enemies.forEach((e) => ok(e.hp <= e.maxHp, `enemy hp<=max ${e.name}`));
if (res === 'won') {
combat.enemies.forEach((e) => ok(!e.alive, `won => all dead ${e.name}`));
playerWins++;
}
total++;
}
}
}
}
ok(neverLooped, 'no combat hit the turn-guard (possible infinite loop)');
ok(playerWins > 0, 'player wins at least some auto-played combats');
// ── 4. rewards / shop / deck ops ──
{
const run = newRun('warrior', 123);
const rng = makeRng(99);
const node = { type: 'elite' };
const reward = rollRewards(run, node, rng);
ok(reward.gold > 0, 'reward has gold');
ok(reward.cards.length === ACT.cardRewardChoices, 'reward card choices count');
ok(reward.cards.every((c) => CARDS[c.id]), 'reward cards valid');
ok(reward.relic && true, 'elite reward has relic');
const before = run.deck.length;
addCardToDeck(run, reward.cards[0].id);
ok(run.deck.length === before + 1, 'addCardToDeck grows deck');
const upTarget = run.deck.find((c) => CARDS[c.id].upgrade && !c.upgraded);
if (upTarget) { upgradeCardInDeck(run, upTarget.uid); ok(upTarget.upgraded, 'upgradeCardInDeck flips flag'); }
const shop = generateShop(run, rng);
ok(shop.cards.length > 0, 'shop has cards');
ok(shop.cards.every((c) => c.price > 0), 'shop card prices positive');
}
// ── 5. determinism: same seed => same map & same combat trace ──
{
const a = generateMap(makeRng(7));
const b = generateMap(makeRng(7));
ok(JSON.stringify(a) === JSON.stringify(b), 'map generation deterministic for fixed seed');
const r1 = runCombat('rogue', ['jawworm'], 5);
const r2 = runCombat('rogue', ['jawworm'], 5);
ok(r1.combat.player.hp === r2.combat.player.hp && isCombatOver(r1.combat) === isCombatOver(r2.combat), 'combat deterministic for fixed seed');
}
// ── 6. starter decks well-formed ──
for (const cls of Object.values(CLASSES)) {
ok(cls.startingDeck.every((id) => CARDS[id]), `${cls.id} starter deck valid card ids`);
ok(cls.startingDeck.length >= 10, `${cls.id} starter deck size`);
}
// ── report ──
console.log(`\nSpire Climb verification`);
console.log(` combats auto-played: ${total} (player win rate ${(100 * playerWins / total).toFixed(0)}%)`);
console.log(` checks: ${pass} passed, ${fail} failed`);
if (fail) { console.log('\nFAILURES:'); fails.slice(0, 30).forEach((m) => console.log(' ✗ ' + m)); process.exit(1); }
console.log(' ✓ all checks passed');