Shadows
muse spark 1.1 · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 95,181 bytes · SHA-256 361a5edffd9a
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shadows of the Abyss</title>
<style>
/* ── css: main ── */
:root{--bg:#0b0a12;--panel:#161424;--accent:#c94f3f;--accent2:#8a6cff;--gold:#ffc857;--text:#e8e2d6;--muted:#8b869a;--card-w:168px;--card-h:228px}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:radial-gradient(120% 80% at 50% -10%,#251a3a 0%,#0b0a12 55%);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;overflow-x:hidden}
#topbar{position:sticky;top:0;z-index:10;display:flex;justify-content:space-between;align-items:center;padding:10px 18px;background:rgba(13,11,22,0.92);backdrop-filter:blur(12px);border-bottom:1px solid #2a2540}
#tb-left{display:flex;align-items:center;gap:12px}#hp-bar{width:160px;height:14px;background:#1a1826;border-radius:8px;overflow:hidden;border:1px solid #342d55}#hp-fill{height:100%;background:linear-gradient(90deg,#ff4d4d,#ff9068);width:100%;transition:width .4s}#tb-block{color:#6ac7ff;font-weight:700}
#tb-center{display:flex;gap:18px;font-weight:600;letter-spacing:.04em}#tb-gold{color:var(--gold)}
#tb-right{display:flex;gap:16px;align-items:center}#tb-relics{display:flex;gap:6px}#tb-potions{display:flex;gap:6px}
.relic-icon,.pot-icon{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-size:14px;border:1px solid #3a3552;cursor:pointer}
.relic-icon{background:linear-gradient(180deg,#2d2646,#1b1830)}.pot-icon{background:linear-gradient(180deg,#3a2a1d,#221810)}
.screen{display:none;min-height:calc(100vh - 56px);padding:24px}#screen-start.active,#screen-map.active,#screen-combat.active,#screen-reward.active,#screen-rest.active,#screen-shop.active,#screen-event.active,#screen-gameover.active{display:flex;flex-direction:column}
#screen-start{align-items:center;justify-content:center;background:radial-gradient(60% 60% at 50% 30%,rgba(138,108,255,.18),transparent)}
.title-glow{font-size:56px;font-weight:900;letter-spacing:.12em;text-shadow:0 0 30px rgba(201,79,63,.6),0 0 80px rgba(138,108,255,.35);margin-bottom:8px;background:linear-gradient(180deg,#ffe9d6,#c94f3f 50%,#8a6cff);-webkit-background-clip:text;background-clip:text;color:transparent}
.subtitle{color:var(--muted);letter-spacing:.2em;text-transform:uppercase;margin-bottom:36px}
#class-select{display:flex;gap:20px}
.class-card{width:260px;padding:22px;background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.02));border:1px solid #332d4d;border-radius:16px;cursor:pointer;transition:.2s;backdrop-filter:blur(8px)}
.class-card:hover{transform:translateY(-4px);border-color:var(--accent2);box-shadow:0 12px 40px rgba(138,108,255,.25)}
.cc-name{font-weight:800;font-size:18px;letter-spacing:.08em;margin-bottom:10px}.cc-desc{color:var(--muted);font-size:13px;line-height:1.4;margin-bottom:14px}.cc-ability{font-size:12px;color:var(--gold);border-top:1px solid #2e2a47;padding-top:10px}
#map-path{display:flex;flex-direction:column-reverse;align-items:center;gap:14px;padding:20px 0;width:100%;max-width:720px;margin:0 auto}
.map-floor{display:flex;align-items:center;justify-content:center;gap:36px;width:100%;position:relative}
.map-floor::before{content:'';position:absolute;left:50%;top:-14px;width:2px;height:14px;background:#2c2842;transform:translateX(-50%)}
.floor-label{position:absolute;left:0;color:var(--muted);font-size:12px}
.map-node{width:72px;height:72px;border-radius:50%;display:grid;place-items:center;cursor:pointer;border:2px solid #3b3654;background:radial-gradient(120% 120% at 30% 20%,#2a2642,#171424);transition:.2s;position:relative}
.map-node .node-type{font-size:10px;letter-spacing:.1em;position:absolute;bottom:-16px;color:var(--muted)}
.map-node.current{border-color:var(--gold);box-shadow:0 0 18px rgba(255,200,87,.5)}
.map-node.completed{opacity:.4;pointer-events:none}
.map-node.boss{width:84px;height:84px;background:radial-gradient(80% 80% at 30% 20%,#4a2530,#1a1014);border-color:var(--accent)}
.map-node.elite{background:radial-gradient(80% 80% at 30% 20%,#3a2a50,#1c1630);border-color:#a78bfa}
.map-node.rest{border-color:#5eead4}.map-node.shop{border-color:var(--gold)}.map-node.event{border-color:#f472b6}
.map-node.available{transform:scale(1.08);box-shadow:0 0 16px rgba(138,108,255,.35)}
#map-legend{display:flex;gap:16px;justify-content:center;margin-top:20px;color:var(--muted);font-size:12px}
#combat-enemies{display:flex;gap:18px;justify-content:center;flex-wrap:wrap;min-height:220px;padding:16px}
.enemy-card{width:200px;background:linear-gradient(180deg,#1d182c 0%,#12101c 100%);border:1px solid #3a3552;border-radius:14px;padding:12px;position:relative;transition:.2s}
.enemy-card.targeted{border-color:var(--accent);box-shadow:0 0 20px rgba(201,79,63,.4);transform:translateY(-3px)}
.enemy-name{font-weight:700;font-size:13px;letter-spacing:.06em;display:flex;justify-content:space-between}
.enemy-hpbar{height:8px;background:#201c2f;border-radius:6px;margin:8px 0;overflow:hidden}.enemy-hpbar-fill{height:100%;background:linear-gradient(90deg,#ff5a5a,#ff9a5a);transition:width .3s}
.enemy-intent{margin:6px 0;padding:6px 8px;border-radius:8px;background:rgba(0,0,0,.4);font-size:12px;display:flex;align-items:center;gap:6px;border:1px solid #2f2a46}
.intent-icon{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;font-size:13px}
.intent-atk{background:#3a1e1e}.intent-def{background:#1e2e4a}.intent-buff{background:#2a2e1e}.intent-debuff{background:#3a1e3a}
.enemy-statuses{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.status-tag{font-size:10px;padding:2px 6px;border-radius:999px;background:#25213a;border:1px solid #3a3560}
.status-tag.bad{border-color:#5a2630;color:#ff8b8b}.status-tag.good{color:#8be9a0}
#combat-playerarea{display:flex;justify-content:space-between;align-items:center;padding:10px 20px;background:rgba(255,255,255,.03);border-radius:12px;margin:0 20px}
#player-statuses{display:flex;gap:6px;flex-wrap:wrap}
#combat-hand{display:flex;gap:10px;justify-content:center;align-items:flex-end;flex-wrap:wrap;min-height:250px;padding:20px 10px 10px}
.c-card{width:var(--card-w);height:var(--card-h);border-radius:14px;border:1.5px solid #3d3760;background:linear-gradient(180deg,#221e37 0%,#161326 70%,#0e0c1a 100%);cursor:pointer;position:relative;padding:10px 10px 8px;display:flex;flex-direction:column;transition:.18s;box-shadow:0 8px 24px rgba(0,0,0,.5)}
.c-card:hover{transform:translateY(-12px) rotate(-1deg);z-index:5;border-color:#8a6cff;box-shadow:0 16px 40px rgba(138,108,255,.35)}
.c-card.unplayable{opacity:.45;filter:grayscale(.3)}.c-card.selected{border-color:var(--gold);transform:translateY(-14px);box-shadow:0 0 28px rgba(255,200,87,.45)}
.c-top{display:flex;justify-content:space-between;align-items:center;font-size:11px;letter-spacing:.08em}.c-cost{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(#5a4a8a,#2a2450);border:1px solid #6b5fb0;font-weight:800}.c-name{font-weight:800;font-size:12px;text-align:center;margin:6px 0 4px;min-height:28px;line-height:1.2}
.c-type{font-size:9px;color:var(--muted);text-align:center;letter-spacing:.12em;text-transform:uppercase}
.c-desc{font-size:11px;line-height:1.35;color:#c5c0d8;margin-top:8px;flex:1}.c-desc b{color:#fff}
.c-card.attack{border-top-color:#c94f3f}.c-card.skill{border-top-color:#6ac7ff}.c-card.power{border-top-color:#ffc857}
.c-card.common{}.c-card.uncommon{box-shadow:inset 0 0 0 1px rgba(90,255,160,.25)}.c-card.rare{box-shadow:inset 0 0 0 1px rgba(255,200,87,.4),0 0 12px rgba(255,200,87,.15)}
#combat-controls{display:flex;gap:14px;justify-content:center;padding:14px}
#combat-controls button,#reward-actions button,#screen-rest button,#screen-shop button,#screen-event button,#screen-gameover button,#overlay-cardpick button,#deck-modal button{padding:10px 22px;border-radius:999px;border:1px solid #4a4468;background:linear-gradient(180deg,#2e2a4a,#1c1930);color:var(--text);cursor:pointer;font-weight:700;letter-spacing:.04em;transition:.15s}
#combat-controls button:hover{border-color:var(--accent2);transform:translateY(-1px)}
#btn-endturn{background:linear-gradient(180deg,#3a2a2a,#221717)!important;border-color:#6b3a3a!important;color:#ffb4a0!important}
#screen-reward{max-width:900px;margin:0 auto;align-items:center;gap:18px}
#reward-title{font-size:28px;font-weight:900;letter-spacing:.08em}#reward-cards,#shop-cards{display:flex;gap:16px;flex-wrap:wrap;justify-content:center}
#screen-rest{align-items:center;gap:24px}.rest-options{display:flex;gap:20px}.rest-btn{width:180px;height:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border-radius:14px!important}
#screen-shop{max-width:1000px;margin:0 auto;gap:18px}#shop-relics,#shop-pots{display:flex;gap:12px;flex-wrap:wrap;justify-content:center}
.shop-item{display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px;border:1px solid #3a3554;border-radius:12px;background:rgba(255,255,255,.03);min-width:150px}
#screen-event{max-width:640px;margin:0 auto;gap:16px}#event-choices{display:flex;flex-direction:column;gap:10px}
#log{position:fixed;left:12px;bottom:12px;width:300px;max-height:36vh;overflow:auto;display:flex;flex-direction:column;gap:4px;pointer-events:none;z-index:20}
.log-entry{font-size:11px;padding:4px 8px;border-radius:6px;background:rgba(10,8,18,.85);border:1px solid #2a2640}
#overlay-cardpick{position:fixed;inset:0;background:rgba(6,5,10,.88);backdrop-filter:blur(10px);display:none;flex-direction:column;align-items:center;justify-content:center;z-index:30;padding:20px}
#overlay-cardpick.active{display:flex}#cardpick-grid{display:flex;gap:16px;flex-wrap:wrap;justify-content:center;margin:20px 0;max-width:960px;max-height:70vh;overflow:auto}
#deck-modal{position:fixed;inset:0;background:rgba(5,4,10,.88);display:none;place-items:center;z-index:40}#deck-modal.active{display:grid}#deck-modal-inner{background:#171424;border:1px solid #3a3554;border-radius:16px;padding:20px;max-width:900px;width:90%;max-height:80vh;overflow:auto}#deck-list{display:flex;flex-wrap:wrap;gap:10px;margin:16px 0}
.enemy-shake{animation:shake .25s}.card-play-anim{animation:playpop .35s}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
@keyframes playpop{0%{transform:scale(1)}50%{transform:scale(1.12)}100%{transform:scale(1)}}
.pulse-gold{animation:goldpulse 1.2s infinite}
@keyframes goldpulse{0%,100%{box-shadow:0 0 0 rgba(255,200,87,0)}50%{box-shadow:0 0 20px rgba(255,200,87,.6)}}
</style>
</head>
<body>
<div id="topbar">
<div id="tb-left">
<span id="tb-hp">HP: 70/70</span>
<div id="hp-bar"><div id="hp-fill"></div></div>
<span id="tb-block"></span>
</div>
<div id="tb-center">
<span id="tb-act">Act 1</span><span id="tb-floor">Floor 1</span><span id="tb-gold">Gold: 99</span>
</div>
<div id="tb-right">
<div id="tb-relics"></div>
<div id="tb-potions"></div>
</div>
</div>
<div id="view-root">
<div id="screen-start" class="screen active">
<div class="title-glow">SHADOWS OF THE ABYSS</div>
<div class="subtitle">Choose thy cursed path</div>
<div id="class-select">
<div class="class-card" data-class="Blood Reaper">
<div class="cc-name">BLOOD REAPER</div><div class="cc-desc">Sacrifice HP for overwhelming power. Bleed & life steal.</div>
<div class="cc-ability">Sig: Sanguine Strike</div>
</div>
<div class="class-card" data-class="Shadowmancer">
<div class="cc-name">SHADOWMANCER</div><div class="cc-desc">Control with Weak, Vulnerable and Intangible.</div>
<div class="cc-ability">Sig: Shadow Bolt</div>
</div>
<div class="class-card" data-class="Hellforged">
<div class="cc-name">HELLFORGED</div><div class="cc-desc">Burn, summon Imps, warp energy itself.</div>
<div class="cc-ability">Sig: Firebolt</div>
</div>
</div>
</div>
<div id="screen-map" class="screen">
<div id="map-path"></div>
<div id="map-legend"><span>◉ Enemy</span><span>◎ Elite</span><span>⌾ Rest</span><span>◍ Shop</span><span>◐ Event</span><span>⬣ Boss</span></div>
</div>
<div id="screen-combat" class="screen">
<div id="combat-enemies"></div>
<div id="combat-playerarea">
<div id="player-statuses"></div>
<div id="player-stats"><span id="p-energy">E:3</span><span id="p-draw">Draw:0</span><span id="p-discard">Disc:0</span><span id="p-exhaust">Exh:0</span></div>
</div>
<div id="combat-hand"></div>
<div id="combat-controls"><button id="btn-endturn">End Turn [E]</button><button id="btn-viewdeck">Deck</button></div>
</div>
<div id="screen-reward" class="screen">
<div id="reward-title"></div><div id="reward-gold"></div><div id="reward-cards"></div><div id="reward-relics"></div><div id="reward-potions"></div><div id="reward-actions"><button id="btn-skip-reward">Skip / Continue</button></div>
</div>
<div id="screen-rest" class="screen">
<h2>The Resting Crypt</h2>
<div class="rest-options">
<button class="rest-btn" data-rest="heal"><b>Rest</b><span>Heal 25% HP</span></button>
<button class="rest-btn" data-rest="upgrade"><b>Forge</b><span>Upgrade a card</span></button>
<button class="rest-btn" data-rest="remove"><b>Meditate</b><span>Remove a card</span></button>
</div>
</div>
<div id="screen-shop" class="screen"><h2>Accursed Bazaar</h2><div id="shop-cards"></div><div id="shop-relics"></div><div id="shop-pots"></div><div id="shop-remove"><button id="btn-shop-remove">Remove Card (75g)</button><span id="shop-gold"></span><button id="btn-leave-shop">Leave</button></div></div>
<div id="screen-event" class="screen"><h2 id="event-title"></h2><p id="event-text"></p><div id="event-choices"></div></div>
<div id="screen-gameover" class="screen"><h1 id="go-title">Defeat</h1><p id="go-text"></p><button id="btn-restart">Return to Void</button></div>
</div>
<div id="overlay-cardpick"><div id="cardpick-title"></div><div id="cardpick-grid"></div><button id="btn-cardpick-cancel">Cancel</button></div>
<div id="log"></div>
<div id="deck-modal"><div id="deck-modal-inner"><h3>Your Deck <span id="deck-count"></span></h3><div id="deck-list"></div><button id="btn-close-deck">Close</button></div></div>
<script>
'use strict';
// ── const: BASIC_CARDS ──
const BASIC_CARDS = {
Strike: {id:'Strike', name:'Strike', type:'Attack', cost:1, rarity:'Basic', desc:'Deal 6 damage.', dmg:6, up:{dmg:8}},
Defend: {id:'Defend', name:'Defend', type:'Skill', cost:1, rarity:'Basic', desc:'Gain 5 Block.', block:5, up:{block:8}},
};
// ── const: CLASS_CARDS ──
const CLASS_CARDS = {
'Blood Reaper': {
'Sanguine Strike': {id:'Sanguine Strike', name:'Sanguine Strike', type:'Attack', cost:1, rarity:'Common', desc:'Deal 8 dmg. Heal 2 HP.', dmg:8, heal:2, up:{dmg:10, heal:3}},
'Hemorrhage': {id:'Hemorrhage', name:'Hemorrhage', type:'Attack', cost:1, rarity:'Common', desc:'Deal 5 dmg. Apply 3 Bleed.', dmg:5, bleed:3, up:{dmg:6, bleed:4}},
'Bloodletting': {id:'Bloodletting', name:'Bloodletting', type:'Attack', cost:1, rarity:'Uncommon', desc:'Lose 3 HP. Deal 12 dmg.', hpLoss:3, dmg:12, up:{hpLoss:2, dmg:15}},
'Crimson Eruption': {id:'Crimson Eruption', name:'Crimson Eruption', type:'Attack', cost:2, rarity:'Rare', desc:'Deal 8 dmg to ALL. Lose 2 HP.', dmg:8, aoe:true, hpLoss:2, up:{dmg:10}},
'Crimson Shield': {id:'Crimson Shield', name:'Crimson Shield', type:'Skill', cost:1, rarity:'Common', desc:'Gain 6 Block. Lose 2 HP.', block:6, hpLoss:2, up:{block:8}},
'Blood Ward': {id:'Blood Ward', name:'Blood Ward', type:'Skill', cost:1, rarity:'Common', desc:'Gain 3 Block. Gain 1 Regen.', block:3, regen:1, up:{block:4, regen:2}},
'Sacrificial Barrier': {id:'Sacrificial Barrier', name:'Sacrificial Barrier', type:'Skill', cost:2, rarity:'Uncommon', desc:'Gain 10 Block. Lose 5 HP.', block:10, hpLoss:5, up:{block:13}},
'Vital Surge': {id:'Vital Surge', name:'Vital Surge', type:'Skill', cost:-1, rarity:'Rare', desc:'Gain Block = missing HP. Exhaust.', special:'vital', exhaust:true, up:{bonus:3}},
'Life Tap': {id:'Life Tap', name:'Life Tap', type:'Skill', cost:0, rarity:'Common', desc:'Draw 2. Lose 3 HP.', draw:2, hpLoss:3, up:{draw:3}},
'Rend Flesh': {id:'Rend Flesh', name:'Rend Flesh', type:'Skill', cost:1, rarity:'Uncommon', desc:'Apply 5 Bleed to ALL. Lose 2 HP.', bleed:5, aoe:true, hpLoss:2, up:{bleed:7}},
'Soul Siphon': {id:'Soul Siphon', name:'Soul Siphon', type:'Power', cost:2, rarity:'Uncommon', desc:'Start turn gain 1 Energy, lose 1 HP.', power:'soulSiphon', up:{cost:1}},
'Blood Pact': {id:'Blood Pact', name:'Blood Pact', type:'Power', cost:1, rarity:'Rare', desc:'When lose HP from card, gain 1 Str.', power:'bloodPact', strPerHp:1, up:{strPerHp:2}},
},
'Shadowmancer': {
'Shadow Bolt': {id:'Shadow Bolt', name:'Shadow Bolt', type:'Attack', cost:1, rarity:'Common', desc:'Deal 6 dmg. Apply 1 Weak.', dmg:6, weak:1, up:{dmg:8, weak:2}},
'Grasp of Despair': {id:'Grasp of Despair', name:'Grasp of Despair', type:'Attack', cost:1, rarity:'Common', desc:'Deal 4 dmg. Apply 1 Vulnerable.', dmg:4, vuln:1, up:{dmg:5, vuln:2}},
'Nightmare Lash': {id:'Nightmare Lash', name:'Nightmare Lash', type:'Attack', cost:2, rarity:'Uncommon', desc:'Deal 7 dmg. If Weak, deal 7 more.', dmg:7, bonusWeak:7, up:{dmg:9, bonusWeak:9}},
'Eternal Darkness': {id:'Eternal Darkness', name:'Eternal Darkness', type:'Attack', cost:2, rarity:'Rare', desc:'Deal 3 dmg random 3 times.', dmg:3, hits:3, random:true, up:{dmg:4}},
'Cloak of Shadows': {id:'Cloak of Shadows', name:'Cloak of Shadows', type:'Skill', cost:1, rarity:'Common', desc:'Gain 5 Block. Draw 1.', block:5, draw:1, up:{block:7}},
'Haunting Mist': {id:'Haunting Mist', name:'Haunting Mist', type:'Skill', cost:1, rarity:'Common', desc:'Gain 7 Block. Apply 1 Weak ALL.', block:7, weak:1, aoe:true, up:{block:9}},
'Phantom Armor': {id:'Phantom Armor', name:'Phantom Armor', type:'Skill', cost:1, rarity:'Uncommon', desc:'Gain 8 Block. Next turn +2 Dex.', block:8, nextDex:2, up:{block:10, nextDex:3}},
'Whispering Shroud': {id:'Whispering Shroud', name:'Whispering Shroud', type:'Skill', cost:1, rarity:'Rare', desc:'Gain 4 Block. Next turn gain 4 Block.', block:4, nextBlock:4, up:{block:5, nextBlock:5}},
'Shadowform': {id:'Shadowform', name:'Shadowform', type:'Skill', cost:2, rarity:'Uncommon', desc:'Gain 2 Intangible. Exhaust.', intangible:2, exhaust:true, up:{intangible:3}},
'Curse of Misfortune': {id:'Curse of Misfortune', name:'Curse of Misfortune', type:'Skill', cost:1, rarity:'Common', desc:'Enemy loses 3 Str this turn. Apply 1 Vuln.', strDown:3, vuln:1, up:{strDown:4, vuln:2}},
'Dark Ritual': {id:'Dark Ritual', name:'Dark Ritual', type:'Skill', cost:0, rarity:'Uncommon', desc:'Gain 2 Energy. Shuffle Dazed into draw.', energy:2, shuffle:'Dazed', up:{shuffleTo:'discard'}},
'Umbral Mastery': {id:'Umbral Mastery', name:'Umbral Mastery', type:'Power', cost:2, rarity:'Rare', desc:'Start turn apply 1 Weak ALL.', power:'umbral', weak:1, up:{weak:2}},
},
'Hellforged': {
'Firebolt': {id:'Firebolt', name:'Firebolt', type:'Attack', cost:1, rarity:'Common', desc:'Deal 7 dmg. Apply 2 Burn.', dmg:7, burn:2, up:{dmg:9, burn:3}},
'Hellfire Blast': {id:'Hellfire Blast', name:'Hellfire Blast', type:'Attack', cost:2, rarity:'Uncommon', desc:'Deal 9 dmg to ALL.', dmg:9, aoe:true, up:{dmg:12}},
'Searing Strike': {id:'Searing Strike', name:'Searing Strike', type:'Attack', cost:1, rarity:'Common', desc:'Deal 6 dmg. Gain 1 Energy next turn.', dmg:6, nextEnergy:1, up:{dmg:8}},
'Immolate': {id:'Immolate', name:'Immolate', type:'Attack', cost:2, rarity:'Rare', desc:'Deal 12 dmg. Exhaust.', dmg:12, exhaust:true, up:{dmg:16}},
'Obsidian Skin': {id:'Obsidian Skin', name:'Obsidian Skin', type:'Skill', cost:1, rarity:'Common', desc:'Gain 7 Block.', block:7, up:{block:10}},
'Flame Ward': {id:'Flame Ward', name:'Flame Ward', type:'Skill', cost:1, rarity:'Common', desc:'Gain 5 Block. Apply 2 Burn random.', block:5, burn:2, burnRandom:true, up:{block:6, burn:3}},
'Demonic Resilience': {id:'Demonic Resilience', name:'Demonic Resilience', type:'Skill', cost:1, rarity:'Uncommon', desc:'Gain 4 Block. Heal 2 HP.', block:4, heal:2, up:{block:5, heal:3}},
'Brimstone Shield': {id:'Brimstone Shield', name:'Brimstone Shield', type:'Skill', cost:1, rarity:'Rare', desc:'Gain 3 Block. Gain 2 Str this turn.', block:3, tempStr:2, up:{block:4, tempStr:3}},
'Summon Imp': {id:'Summon Imp', name:'Summon Imp', type:'Skill', cost:1, rarity:'Common', desc:'Add Imp Claw to hand. Exhaust.', gen:'Imp Claw', exhaust:true, up:{gen:'Imp Claw+'}},
'Infernal Pact': {id:'Infernal Pact', name:'Infernal Pact', type:'Skill', cost:0, rarity:'Uncommon', desc:'Lose 2 HP. Gain 2 Energy. Add Imp Claw.', hpLoss:2, energy:2, gen:'Imp Claw', up:{hpLoss:1, gen:'Imp Claw+'}},
'Hellfire Aura': {id:'Hellfire Aura', name:'Hellfire Aura', type:'Power', cost:2, rarity:'Rare', desc:'Start turn deal 3 dmg ALL.', power:'hellAura', dmg:3, up:{dmg:5}},
'Soul Burn': {id:'Soul Burn', name:'Soul Burn', type:'Skill', cost:1, rarity:'Uncommon', desc:'Apply 8 Burn. Draw 1.', burn:8, draw:1, up:{burn:10}},
}
};
// ── const: COLORLESS ──
const COLORLESS = {
'Flash of Insight': {id:'Flash of Insight', name:'Flash of Insight', type:'Skill', cost:1, rarity:'Common', colorless:true, desc:'Draw 2.', draw:2, up:{draw:3}},
'Iron Will': {id:'Iron Will', name:'Iron Will', type:'Skill', cost:1, rarity:'Common', colorless:true, desc:'Gain 8 Block.', block:8, up:{block:11}},
'Quick Slash': {id:'Quick Slash', name:'Quick Slash', type:'Attack', cost:1, rarity:'Common', colorless:true, desc:'Deal 7 dmg.', dmg:7, up:{dmg:9}},
'Potent Brew': {id:'Potent Brew', name:'Potent Brew', type:'Skill', cost:0, rarity:'Uncommon', colorless:true, desc:'Gain 1 Energy. Exhaust.', energy:1, exhaust:true, up:{energy:2}},
};
// ── const: TOKENS ──
const TOKENS = {
'Imp Claw': {id:'Imp Claw', name:'Imp Claw', type:'Attack', cost:0, rarity:'Special', desc:'Deal 4 dmg. Exhaust.', dmg:4, exhaust:true, token:true},
'Imp Claw+': {id:'Imp Claw+', name:'Imp Claw+', type:'Attack', cost:0, rarity:'Special', desc:'Deal 6 dmg. Exhaust.', dmg:6, exhaust:true, token:true},
'Wound': {id:'Wound', name:'Wound', type:'Curse', cost:-1, rarity:'Curse', desc:'Unplayable. Ethereal. Exhaust.', unplayable:true, ethereal:true, exhaust:true},
'Dazed': {id:'Dazed', name:'Dazed', type:'Status', cost:-1, rarity:'Status', desc:'Unplayable. Ethereal. Exhaust.', unplayable:true, ethereal:true, exhaust:true},
'Doubt': {id:'Doubt', name:'Doubt', type:'Curse', cost:-1, rarity:'Curse', desc:'Unplayable. End turn lose 1 HP.', unplayable:true, doubt:true},
};
// ── const: ALL_CARD_MAP ──
const ALL_CARD_MAP = (()=>{ const m={}; Object.values(BASIC_CARDS).forEach(c=>m[c.id]=c);
Object.values(CLASS_CARDS).forEach(pool=>Object.values(pool).forEach(c=>m[c.id]=c));
Object.values(COLORLESS).forEach(c=>m[c.id]=c);
Object.values(TOKENS).forEach(c=>m[c.id]=c);
return m;})();
// ── const: ENEMY_DEFS ──
const ENEMY_DEFS = {
Skeleton:{name:'Skeleton Warrior',hp:22,act:1,type:'normal',pattern:[{type:'Attack',dmg:6},{type:'Attack',dmg:6},{type:'Defend',block:8}]},
ZombieBrute:{name:'Zombie Brute',hp:28,act:1,type:'normal',pattern:[{type:'Attack',dmg:8},{type:'Debuff',weak:2},{type:'Attack',dmg:8}]},
DarkCultist:{name:'Dark Cultist',hp:20,act:1,type:'normal',pattern:[{type:'Buff',str:3},{type:'Attack',dmg:5},{type:'Attack',dmg:5}]},
RatSwarm:{name:'Rat Swarm',hp:15,act:1,type:'normal',pattern:[{type:'Multi',dmg:3,hits:2},{type:'Multi',dmg:3,hits:2}]},
TombGuardian:{name:'Tomb Guardian',hp:42,act:1,type:'elite',pattern:[{type:'AtkDef',dmg:6,block:8},{type:'Multi',dmg:4,hits:3},{type:'Attack',dmg:12}]},
BloodWitch:{name:'Blood Witch',hp:36,act:1,type:'elite',pattern:[{type:'Lifesteal',dmg:5,heal:4},{type:'Debuff',vuln:1,weak:1},{type:'Lifesteal',dmg:7,heal:4}]},
LichKing:{name:'Lich King',hp:65,act:1,type:'boss',boss:true},
Wraith:{name:'Wraith',hp:30,act:2,type:'normal',pattern:[{type:'AtkDebuff',dmg:7,weak:1},{type:'Attack',dmg:7},{type:'Defend',block:10}]},
CorruptedBeast:{name:'CorruptedBeast',hp:38,act:2,type:'normal',pattern:[{type:'Attack',dmg:12},{type:'SelfDebuff',block:5,vulnSelf:1},{type:'Attack',dmg:12}]},
ThornedHorror:{name:'Thorned Horror',hp:25,act:2,type:'normal',pattern:[{type:'Multi',dmg:4,hits:3},{type:'Debuff',bleed:3},{type:'Attack',dmg:8}]},
WitchDoctor:{name:'Witch Doctor',hp:22,act:2,type:'normal',pattern:[{type:'BuffAlly',block:4,regen:2},{type:'AtkDebuff',dmg:6,poison:2},{type:'Attack',dmg:6}]},
TreantProtector:{name:'Treant Protector',hp:50,act:2,type:'elite',pattern:[{type:'AtkDef',dmg:9,block:6},{type:'Debuff',vuln:2},{type:'Multi',dmg:10,hits:2}]},
PlagueShaman:{name:'Plague Shaman',hp:42,act:2,type:'elite',pattern:[{type:'Debuff',poison:4},{type:'Summon',summon:'PlagueRat'},{type:'Attack',dmg:8}]},
PlagueRat:{name:'Plague Rat',hp:18,act:2,type:'minion',pattern:[{type:'Attack',dmg:5}]},
EldritchTreant:{name:'Eldritch Treant',hp:75,act:2,type:'boss',boss:true},
Hellhound:{name:'Hellhound',hp:26,act:3,type:'normal',pattern:[{type:'AtkDebuff',dmg:8,burn:2},{type:'Attack',dmg:8},{type:'Defend',block:9}]},
ShadowDemon:{name:'Shadow Demon',hp:30,act:3,type:'normal',pattern:[{type:'AtkDebuff',dmg:7,weak:1},{type:'Attack',dmg:7},{type:'Buff',intangible:1}]},
InfernalImp:{name:'Infernal Imp',hp:18,act:3,type:'normal',pattern:[{type:'Attack',dmg:5},{type:'Attack',dmg:5},{type:'Summon',summon:'InfernalImp'}]},
AbyssalCultist:{name:'Abyssal Cultist',hp:24,act:3,type:'normal',pattern:[{type:'BuffAll',str:2},{type:'Attack',dmg:7},{type:'Attack',dmg:7}]},
Flamespawn:{name:'Flamespawn',hp:48,act:3,type:'elite',pattern:[{type:'AtkDebuff',dmg:9,burn:3},{type:'AtkDefBuff',block:10,str:2},{type:'Multi',dmg:8,hits:2}]},
DreadKnight:{name:'Dread Knight',hp:55,act:3,type:'elite',pattern:[{type:'Attack',dmg:12},{type:'Debuff',bleed:3},{type:'HealBlock',block:12,heal:4}]},
Archdemon:{name:'Archdemon',hp:85,act:3,type:'boss',boss:true},
SkeletonMinion:{name:'Skeleton Minion',hp:15,act:1,type:'minion',pattern:[{type:'Attack',dmg:5}]},
};
// ── function: makeActFloorGroups ──
function makeActFloorGroups(act){
if(act===1){
return {2:[['Skeleton'],['ZombieBrute']],3:[['DarkCultist']],6:[['RatSwarm']],8:[['Skeleton','Skeleton'],['ZombieBrute']],9:{elite:['TombGuardian'],alt:['Skeleton','RatSwarm']},11:[['DarkCultist','RatSwarm']],12:{elite:['BloodWitch'],alt:['ZombieBrute','Skeleton']},13:[['DarkCultist','DarkCultist']]};
}
if(act===2){
return {2:[['Wraith'],['CorruptedBeast']],3:[['ThornedHorror']],6:[['WitchDoctor']],8:[['Wraith','Wraith'],['CorruptedBeast']],9:{elite:['TreantProtector'],alt:['Wraith','ThornedHorror']},11:[['WitchDoctor','PlagueRat']],12:{elite:['PlagueShaman'],alt:['ThornedHorror','Wraith']},13:[['Wraith','WitchDoctor']]};
}
return {2:[['Hellhound'],['ShadowDemon']],3:[['AbyssalCultist']],6:[['InfernalImp']],8:[['Hellhound','InfernalImp'],['ShadowDemon']],9:{elite:['Flamespawn'],alt:['AbyssalCultist','Hellhound']},11:[['ShadowDemon','Hellhound']],12:{elite:['DreadKnight'],alt:['InfernalImp','AbyssalCultist']},13:[['AbyssalCultist','AbyssalCultist']]};
}
// ── const: ACT_FLOOR_TYPES ──
const ACT_FLOOR_TYPES=[null,null,['Enemy','Enemy'],['Enemy'],['Event'],['Shop'],['Enemy'],['Rest'],['Enemy','Enemy'],['Elite','Enemy'],['Event'],['Enemy'],['Elite','Enemy'],['Enemy'],['Rest'],['Boss']];
// ── const: RELICS ──
const RELICS = {
Bloodstone:{name:'Bloodstone',desc:'When lose HP from card, heal 1.', rarity:'Common', effect:'bloodstone'},
DemonHorns:{name:'Demon Horns',desc:'Start combat: add random Imp Claw.', rarity:'Common', class:'Hellforged', effect:'demonHorns'},
ShadowCloak:{name:'Shadow Cloak',desc:'Start combat: gain 1 Intangible.', rarity:'Rare', effect:'shadowCloak'},
CursedAmulet:{name:'Cursed Amulet',desc:'Start combat: apply 1 Weak to ALL.', rarity:'Common', effect:'cursedAmulet'},
TomeDarkRituals:{name:'Tome of Dark Rituals',desc:'When play Power, draw 1.', rarity:'Uncommon', effect:'tome'},
BleedingEdge:{name:'Bleeding Edge',desc:'Start turn: apply 1 Bleed random.', rarity:'Common', effect:'bleedingEdge'},
SoulEmber:{name:'Soul Ember',desc:'When apply Burn, deal +2 dmg.', rarity:'Rare', effect:'soulEmber'},
EternalHourglass:{name:'Eternal Hourglass',desc:'If no cards in hand start turn, +1 Energy.', rarity:'Uncommon', effect:'hourglass'},
SacrificialDagger:{name:'Sacrificial Dagger',desc:'First HP-cost Attack each combat -1 HP.', rarity:'Rare', class:'Blood Reaper', effect:'sacDagger'},
VialOfShadows:{name:'Vial of Shadows',desc:'On pickup, remove a card.', rarity:'Rare', effect:'vial'},
AshenArmor:{name:'Ashen Armor',desc:'End combat heal 6 HP.', rarity:'Common', effect:'ashen'},
GreedyImp:{name:'Greedy Imp',desc:'Enemies drop +10 Gold.', rarity:'Common', effect:'greedy'},
};
// ── const: BOSS_RELICS ──
const BOSS_RELICS = {
CursedCrown:{name:'Cursed Crown',desc:'+1 Energy each turn. Curses cannot be removed.', energy:1, downside:'noRemoveCurses'},
Ectoplasm:{name:'Ectoplasm',desc:'+1 Energy. Lose all Gold and gain none.', energy:1, downside:'noGold'},
PhilosophersStone:{name:'Philosophers Stone',desc:'+1 Energy. Enemies start with 1 Str.', energy:1, downside:'enemiesStr'},
BlackBlood:{name:'Black Blood',desc:'Start combat lose 10% HP, Attack heals 1.', energy:0, effect:'blackBlood'},
ShadowAmulet:{name:'Shadow Amulet',desc:'Start turn 1 Intangible. -15 Max HP.', energy:0, effect:'shadowAmulet', maxHp:-15},
};
// ── const: POTIONS ──
const POTIONS = {
BloodVial:{name:'Blood Vial',desc:'Heal 15 HP.', effect:'heal', val:15},
Firebomb:{name:'Firebomb',desc:'Deal 10 dmg ALL.', effect:'firebomb', dmg:10},
DexTonic:{name:'Dex Tonic',desc:'Gain 3 Dex 1 turn.', effect:'dex', val:3},
EnergyElixir:{name:'Energy Elixir',desc:'Gain 2 Energy.', effect:'energy', val:2},
CursedBrew:{name:'Cursed Brew',desc:'Apply 3 Vulnerable ALL.', effect:'vulnAll', val:3},
BleedingFlask:{name:'Bleeding Flask',desc:'Apply 6 Bleed enemy.', effect:'bleed', val:6},
ImpInBottle:{name:'Imp in a Bottle',desc:'Add Imp Claw+ to hand.', effect:'imp'},
SmokeBomb:{name:'Smoke Bomb',desc:'Escape non-boss combat.', effect:'escape'},
};
// ── const: EVENTS ──
const EVENTS = [
{id:'altar',title:'The Forgotten Altar',text:'A blood-stained altar hums with power.', choices:[
{text:'[Offer 15 HP] Obtain random Rare relic.', act:(st)=>{ if(st.hp>15){ loseHp(15); addRandomRelic('Rare'); logMsg('Blood for power!'); return true;} else {logMsg('Too weak'); return false;}}},
{text:'[Pray] Heal 10 HP, gain Doubt.', act:(st)=>{ heal(10); addCardToDeck('Doubt'); logMsg('Prayed, doubt festers.'); return true;}},
{text:'[Leave] Nothing.', act:()=>{logMsg('Left altar.'); return true;}},
]},
{id:'peddler',title:'Shadowy Peddler',text:'A hooded figure offers a trade.', choices:[
{text:'[Trade a card] Remove card, gain 100 Gold.', act:(st)=>{ openCardPick('Remove a card', st.deck, (c)=>{ removeFromDeck(c); st.gold+=100; logMsg('Traded for 100g'); closeCardPick(); endEvent();}); return 'deferred';}},
{text:'[Buy his wares] Lose 50g, get potion + common relic.', act:(st)=>{ if(st.gold>=50){st.gold-=50; addRandomPotion(); addRandomRelic('Common'); logMsg('Bought wares'); return true;} logMsg('Not enough gold'); return false;}},
{text:'[Attack] Fight Shadow Thief (35 HP), +75g', act:(st)=>{ st.forcedCombat={ids:['Wraith'], rewardGold:75}; return true;}},
]},
{id:'spring',title:'Cursed Spring',text:'A spring bubbles with dark water.', choices:[
{text:'[Drink] Lose 10 HP, remove all Curses.', act:(st)=>{ loseHp(10); st.deck=st.deck.filter(id=>!['Wound','Dazed','Doubt'].includes(baseId(id))); logMsg('Curses purged'); return true;}},
{text:'[Bottle it] Obtain Cursed Brew potion.', act:(st)=>{ addSpecificPotion('CursedBrew'); return true;}},
{text:'[Leave]', act:()=>true},
]},
{id:'ghost',title:'Restless Dead',text:'Ghosts offer a bargain.', choices:[
{text:'[Accept] Lose 20 HP, upgrade 2 random cards.', act:(st)=>{ loseHp(20); upgradeRandomCards(2); return true;}},
{text:'[Refuse] Nothing.', act:()=>true},
]},
{id:'gambler',title:'Impish Gambler',text:'A grinning imp challenges you.', choices:[
{text:'[Bet 30 Gold] 50% double or lose.', act:(st)=>{ if(st.gold>=30){st.gold-=30; if(Math.random()<0.5){st.gold+=60; logMsg('Won bet!');} else logMsg('Lost bet'); return true;} logMsg('No gold'); return false;}},
{text:'[Wager card] Remove random, if Curse +100g.', act:(st)=>{ if(st.deck.length==0) return false; let idx=Math.floor(Math.random()*st.deck.length); let cid=st.deck[idx]; st.deck.splice(idx,1); if(['Wound','Doubt','Dazed'].some(x=>baseId(cid).includes(x))){st.gold+=100; logMsg('Cursed! Gold!');} else logMsg('Card lost'); return true;}},
{text:'Leave', act:()=>true},
]},
{id:'library',title:'Ancient Library',text:'Dusty tomes contain forgotten knowledge.', choices:[
{text:'[Study] +1 Energy next combat, lose 5 HP.', act:(st)=>{ st.nextCombatEnergy=1; loseHp(5); return true;}},
{text:'[Read forbidden] Random Power card + Doubt.', act:(st)=>{ addRandomPowerCard(); addCardToDeck('Doubt'); return true;}},
{text:'[Leave]', act:()=>true},
]},
];
// ── let: G ──
let G = {
playerClass:null, act:1, floor:1, maxHp:70, hp:70, gold:99,
deck:[], relics:[], potions:[], bossRelics:[],
floorMap:[], curNodeIdx:0, // index in floor array for current floor's choice?
inCombat:false, combat:null,
nextCombatEnergy:0,
noGoldFlag:false,
noRemoveCurses:false,
};
// ── function: baseId ──
function baseId(cardId){ return cardId.replace(/\+.*$|\+$/,'').replace(/\+\d+/,''); }
// ── function: randInt ──
function randInt(a,b){ return Math.floor(Math.random()*(b-a+1))+a; }
// ── function: shuffle ──
function shuffle(arr){ for(let i=arr.length-1;i>0;i--){let j=Math.floor(Math.random()*(i+1)); [arr[i],arr[j]]=[arr[j],arr[i]];} return arr; }
// ── function: clone ──
function clone(obj){ return JSON.parse(JSON.stringify(obj)); }
// ── function: makeCardInstance ──
function makeCardInstance(id, upgraded=false){
let base = ALL_CARD_MAP[id] || ALL_CARD_MAP[baseId(id)];
if(!base) return {id:id, instanceId: Math.random().toString(36).slice(2), upgraded:false};
let trueId = base.id + (upgraded?'+':'');
return {id:base.id, instanceId: Math.random().toString(36).slice(2), upgraded:upgraded, trueId:trueId};
}
// ── function: getCardDef ──
function getCardDef(inst){
let id = inst.id;
let def = ALL_CARD_MAP[id];
if(!def) return null;
if(inst.upgraded && def.up){
let up = def.up;
let merged = {...def};
// apply overrides
if(up.dmg!==undefined) merged.dmg = up.dmg;
if(up.block!==undefined) merged.block = up.block;
if(up.heal!==undefined) merged.heal = up.heal;
if(up.bleed!==undefined) merged.bleed = up.bleed;
if(up.burn!==undefined) merged.burn = up.burn;
if(up.weak!==undefined) merged.weak = up.weak;
if(up.vuln!==undefined) merged.vuln = up.vuln;
if(up.draw!==undefined) merged.draw = up.draw;
if(up.cost!==undefined) merged.cost = up.cost;
if(up.bonusWeak!==undefined) merged.bonusWeak = up.bonusWeak;
if(up.hits!==undefined) merged.hits = up.hits;
if(up.regen!==undefined) merged.regen = up.regen;
if(up.nextDex!==undefined) merged.nextDex = up.nextDex;
if(up.nextBlock!==undefined) merged.nextBlock = up.nextBlock;
if(up.intangible!==undefined) merged.intangible = up.intangible;
if(up.strDown!==undefined) merged.strDown = up.strDown;
if(up.bonus!==undefined) merged.bonus = up.bonus;
if(up.strPerHp!==undefined) merged.strPerHp = up.strPerHp;
if(up.gen!==undefined) merged.gen = up.gen;
if(up.hpLoss!==undefined) merged.hpLoss = up.hpLoss;
if(up.energy!==undefined) merged.energy = up.energy;
return merged;
}
return def;
}
// ── function: cardDisplayName ──
function cardDisplayName(inst){ return inst.upgraded? inst.id+'+' : inst.id; }
// ── function: logMsg ──
function logMsg(t){ const log=document.getElementById('log'); const d=document.createElement('div'); d.className='log-entry'; d.textContent=t; log.prepend(d); if(log.children.length>8) log.lastChild.remove(); }
// ── function: heal ──
function heal(amount){ G.hp=Math.min(G.maxHp, G.hp+amount); updateTopbar(); }
// ── function: loseHpRaw ──
function loseHpRaw(amount, bypass=false){ // returns true if lethal
let finalAmt = amount;
if(G.combat){
let pl = G.combat.player;
if(pl.statuses.Intangible && pl.statuses.Intangible>0) finalAmt=Math.min(finalAmt,1);
} else {
// outside combat intangible not considered
}
if(finalAmt<=0) finalAmt=1;
G.hp-=finalAmt;
updateTopbar();
if(G.hp<=0) return true;
return false;
}
// ── function: loseHp ──
function loseHp(amount){
if(loseHpRaw(amount)) gameOver(false);
}
// ── function: addCardToDeck ──
function addCardToDeck(id){ G.deck.push(makeCardInstance(id)); }
// ── function: addSpecificPotion ──
function addSpecificPotion(key){
if(G.potions.length>=3){ logMsg('Potion slots full'); return;}
G.potions.push(key);
updateTopbar();
}
// ── function: addRandomPotion ──
function addRandomPotion(){
const keys=Object.keys(POTIONS);
addSpecificPotion(keys[randInt(0,keys.length-1)]);
}
// ── function: addRandomRelic ──
function addRandomRelic(rarity){
let pool = Object.keys(RELICS).filter(k=>{
let r=RELICS[k];
if(rarity && r.rarity!==rarity) return false;
if(r.class && r.class!==G.playerClass) return false;
return !G.relics.includes(k);
});
if(pool.length===0) pool=Object.keys(RELICS).filter(k=>!G.relics.includes(k));
if(pool.length===0) return;
let pick=pool[randInt(0,pool.length-1)];
obtainRelic(pick);
}
// ── function: obtainRelic ──
function obtainRelic(key){
if(G.relics.includes(key)) return;
G.relics.push(key);
const r=RELICS[key];
if(!r){} else {
if(r.effect==='vial'){
// on pickup remove card
openCardPick('Vial of Shadows: Remove a card', G.deck, (cInst)=>{
if(G.noRemoveCurses && ['Wound','Doubt','Dazed'].includes(cInst.id)){ logMsg('Cannot remove Curses!'); return; }
G.deck = G.deck.filter(x=>x.instanceId!==cInst.instanceId);
closeCardPick(); logMsg('Removed '+cInst.id);
});
}
if(key==='AshenArmor'){} // handled end combat
if(key==='ShadowAmulet' || (BOSS_RELICS[key] && BOSS_RELICS[key].maxHp)){
let dec = BOSS_RELICS[key]?.maxHp||0;
if(RELICS[key]?.effect==='shadowAmulet'){ dec=-15; } // boss version
if(dec){ G.maxHp+=dec; if(G.maxHp<1) G.maxHp=1; if(G.hp>G.maxHp) G.hp=G.maxHp; }
}
}
updateTopbar();
logMsg('Obtained relic: '+(RELICS[key]?.name || BOSS_RELICS[key]?.name || key));
}
// ── function: addRandomPowerCard ──
function addRandomPowerCard(){
let pool = Object.keys(CLASS_CARDS[G.playerClass]).filter(id=>{
let d=CLASS_CARDS[G.playerClass][id]; return d.type==='Power';
});
let id=pool[randInt(0,pool.length-1)];
addCardToDeck(id);
}
// ── function: upgradeRandomCards ──
function upgradeRandomCards(n){
let candidates = G.deck.filter(c=>!c.upgraded);
shuffle(candidates);
for(let i=0;i<n && i<candidates.length;i++){ candidates[i].upgraded=true; }
logMsg('Upgraded '+n+' cards');
}
// ── function: removeFromDeck ──
function removeFromDeck(inst){ G.deck = G.deck.filter(x=>x.instanceId!==inst.instanceId); }
// ── function: openCardPick ──
function openCardPick(title, cardList, onPick){
const ov=document.getElementById('overlay-cardpick');
document.getElementById('cardpick-title').textContent=title;
const grid=document.getElementById('cardpick-grid'); grid.innerHTML='';
cardList.forEach(c=>{
const def = c.id ? getCardDef(c) : ALL_CARD_MAP[c];
const el = document.createElement('div'); el.className='c-card '+ (def?def.type.toLowerCase():'');
el.innerHTML=`<div class="c-top"><span class="c-cost">${def?def.cost:''}</span><span>${def?def.rarity:''}</span></div><div class="c-name">${cardDisplayName(c)}</div><div class="c-type">${def?def.type:''}</div><div class="c-desc">${def?def.desc:''}</div>`;
el.onclick=()=>onPick(c);
grid.appendChild(el);
});
ov.classList.add('active');
ov._onPick=onPick;
}
// ── function: closeCardPick ──
function closeCardPick(){ document.getElementById('overlay-cardpick').classList.remove('active'); }
// ── function: endEvent ──
function endEvent(){
closeCardPick();
proceedToNextFloor();
}
// ── function: updateTopbar ──
function updateTopbar(){
document.getElementById('tb-hp').textContent=`HP: ${G.hp}/${G.maxHp}`;
document.getElementById('hp-fill').style.width=(G.hp/G.maxHp*100)+'%';
document.getElementById('tb-act').textContent='Act '+G.act;
document.getElementById('tb-floor').textContent='Floor '+G.floor;
document.getElementById('tb-gold').textContent='Gold: '+G.gold;
document.getElementById('tb-block').textContent= G.combat? ('Block: '+ (G.combat.player.block||0)) : '';
const relicDiv=document.getElementById('tb-relics'); relicDiv.innerHTML='';
G.relics.forEach(k=>{ let el=document.createElement('div'); el.className='relic-icon'; el.textContent=(RELICS[k]?.name||BOSS_RELICS[k]?.name||k)[0]; el.title=(RELICS[k]?.name||BOSS_RELICS[k]?.name||k)+': '+(RELICS[k]?.desc||BOSS_RELICS[k]?.desc||''); relicDiv.appendChild(el); });
const potDiv=document.getElementById('tb-potions'); potDiv.innerHTML='';
G.potions.forEach((k,i)=>{ let p=POTIONS[k]; let el=document.createElement('div'); el.className='pot-icon'; el.textContent='⚗'; el.title=p.name+': '+p.desc; el.onclick=()=>{ if(G.inCombat) usePotion(i); }; potDiv.appendChild(el); });
}
// ── function: initializeRun ──
function initializeRun(cls){
G.playerClass=cls; G.act=1; G.floor=1; G.maxHp=70; G.hp=70; G.gold=99;
G.relics=[]; G.potions=[]; G.bossRelics=[]; G.noGoldFlag=false; G.noRemoveCurses=false;
G.deck=[];
for(let i=0;i<5;i++) G.deck.push(makeCardInstance('Strike'));
for(let i=0;i<4;i++) G.deck.push(makeCardInstance('Defend'));
let sig = cls==='Blood Reaper'?'Sanguine Strike': cls==='Shadowmancer'?'Shadow Bolt':'Firebolt';
G.deck.push(makeCardInstance(sig));
G.floor=2; // start at floor2 encounter? Floor1 start
generateMapForAct(G.act);
showScreen('map');
renderMap();
updateTopbar();
logMsg('Run started as '+cls);
}
// ── function: generateMapForAct ──
function generateMapForAct(act){
// build floor list 2..15 with node objects
let groups = makeActFloorGroups(act);
let map=[];
for(let f=2;f<=15;f++){
let types = ACT_FLOOR_TYPES[f];
let nodes=[];
for(let c=0;c<types.length;c++){
let t=types[c];
let node={floor:f, col:c, type:t, id:f+'-'+c};
if(t==='Enemy' || t==='Elite' || t==='Boss'){
if(groups[f]){
let g = groups[f];
if(Array.isArray(g) && Array.isArray(g[0])){ // list of groups
let idx=c < g.length ? c : 0;
node.enemyIds = g[idx];
} else if(g.elite && g.alt){
if(t==='Elite') node.enemyIds = g.elite;
else node.enemyIds = g.alt;
} else { node.enemyIds=g; }
} else {
if(t==='Boss'){
let bossName = act===1?'LichKing': act===2?'EldritchTreant':'Archdemon';
node.enemyIds=[bossName];
} else if(t==='Elite'){
let elitePool = act===1?['TombGuardian','BloodWitch']: act===2?['TreantProtector','PlagueShaman']:['Flamespawn','DreadKnight'];
node.enemyIds=[elitePool[randInt(0,elitePool.length-1)]];
} else {
let normPool = act===1?['Skeleton','ZombieBrute','DarkCultist','RatSwarm']: act===2?['Wraith','CorruptedBeast','ThornedHorror','WitchDoctor']:['Hellhound','ShadowDemon','InfernalImp','AbyssalCultist'];
node.enemyIds=[normPool[randInt(0,normPool.length-1)]];
}
}
}
nodes.push(node);
}
map.push({floor:f, nodes:nodes});
}
G.floorMap=map;
G.curFloorIdx=0; // index 0 corresponds floor 2
G.selectedNode=null; // for branching
}
// ── function: renderMap ──
function renderMap(){
const cont=document.getElementById('map-path'); cont.innerHTML='';
G.floorMap.forEach((floorObj, idx)=>{
const row=document.createElement('div'); row.className='map-floor';
let flLabel=document.createElement('div'); flLabel.className='floor-label'; flLabel.textContent='F'+floorObj.floor; row.appendChild(flLabel);
floorObj.nodes.forEach(node=>{
const el=document.createElement('div'); el.className='map-node '+node.type.toLowerCase();
if(node.type==='Boss') el.classList.add('boss');
if(node.type==='Elite') el.classList.add('elite');
if(node.type==='Rest') el.classList.add('rest');
if(node.type==='Shop') el.classList.add('shop');
if(node.type==='Event') el.classList.add('event');
let icon = node.type==='Enemy'?'◉': node.type==='Elite'?'◎': node.type==='Rest'?'⌾': node.type==='Shop'?'◍': node.type==='Event'?'◐':'⬣';
el.innerHTML=`${icon}<span class="node-type">${node.type}</span>`;
let currentFloor = G.floor;
if(floorObj.floor < currentFloor){ el.classList.add('completed'); }
else if(floorObj.floor===currentFloor){ el.classList.add('available'); el.onclick=()=>enterNode(node); }
else { el.style.opacity='.5'; el.style.pointerEvents='none'; }
row.appendChild(el);
});
cont.appendChild(row);
});
}
// ── function: enterNode ──
function enterNode(node){
G.selectedNode=node;
G.floor=node.floor;
updateTopbar();
logMsg('Entered '+node.type+' floor '+node.floor);
if(node.type==='Enemy' || node.type==='Elite' || node.type==='Boss'){
startCombat(node.enemyIds, node.type);
} else if(node.type==='Rest'){
showScreen('rest');
} else if(node.type==='Shop'){
openShop();
} else if(node.type==='Event'){
openEvent();
}
}
// ── function: proceedToNextFloor ──
function proceedToNextFloor(){
if(G.floor===15){ // boss defeated
if(G.act===3){ gameOver(true); return; }
else {
G.act++; G.floor=2;
generateMapForAct(G.act);
showScreen('map');
renderMap();
heal(G.maxHp); // full heal after boss per GDD
logMsg('Entered Act '+G.act);
updateTopbar();
return;
}
}
// move to next floor index
let nextFloorNum = G.floor+1;
G.floor=nextFloorNum;
showScreen('map');
renderMap();
updateTopbar();
}
// ── function: showScreen ──
function showScreen(id){
document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
document.getElementById('screen-'+id).classList.add('active');
}
// ── function: gameOver ──
function gameOver(victory){
showScreen('gameover');
document.getElementById('go-title').textContent=victory?'VICTORY':'DEFEAT';
document.getElementById('go-text').textContent=victory?'You vanquished the Archdemon and escaped the Abyss.': 'Your cursed journey ends here. HP fell to zero.';
G.inCombat=false;
}
// ── function: openShop ──
function openShop(){
showScreen('shop');
const cardCont=document.getElementById('shop-cards'); cardCont.innerHTML='';
// 3 class cards
let classPool = Object.keys(CLASS_CARDS[G.playerClass]);
shuffle(classPool);
for(let i=0;i<3;i++){
let cid=classPool[i % classPool.length];
let def=CLASS_CARDS[G.playerClass][cid];
let price = def.rarity==='Common'?50: def.rarity==='Uncommon'?75:150;
makeShopCardEl(cardCont, cid, def, price);
}
let colKeys = Object.keys(COLORLESS); let colId=colKeys[randInt(0,colKeys.length-1)];
makeShopCardEl(cardCont, colId, COLORLESS[colId], 60, true);
// relics
const relicCont=document.getElementById('shop-relics'); relicCont.innerHTML='';
for(let i=0;i<2;i++){
let pool=Object.keys(RELICS).filter(k=>!G.relics.includes(k) && (!RELICS[k].class || RELICS[k].class===G.playerClass));
if(pool.length===0) break;
let key=pool[randInt(0,pool.length-1)];
let div=document.createElement('div'); div.className='shop-item';
div.innerHTML=`<b>${RELICS[key].name}</b><span>${RELICS[key].desc}</span><span>150g</span><button>Buy</button>`;
let btn=div.querySelector('button');
btn.onclick=()=>{ if(G.gold>=150){ G.gold-=150; obtainRelic(key); div.remove(); updateTopbar(); } else logMsg('Not enough gold'); };
relicCont.appendChild(div);
}
// potions
const potCont=document.getElementById('shop-pots'); potCont.innerHTML='';
for(let i=0;i<3;i++){
let keys=Object.keys(POTIONS); let k=keys[randInt(0,keys.length-1)];
let div=document.createElement('div'); div.className='shop-item';
div.innerHTML=`<b>${POTIONS[k].name}</b><span>${POTIONS[k].desc}</span><span>50g</span><button>Buy</button>`;
div.querySelector('button').onclick=()=>{ if(G.gold>=50 && G.potions.length<3){ G.gold-=50; addSpecificPotion(k); div.remove(); updateTopbar(); } else logMsg('Cant buy'); };
potCont.appendChild(div);
}
document.getElementById('shop-gold').textContent='Gold: '+G.gold;
}
// ── function: makeShopCardEl ──
function makeShopCardEl(cont, cid, def, price, isColorless=false){
let div=document.createElement('div'); div.className='shop-item';
div.innerHTML=`<div class="c-card ${def.type.toLowerCase()} ${def.rarity.toLowerCase()}" style="width:140px;height:190px"><div class="c-top"><span class="c-cost">${def.cost}</span></div><div class="c-name">${def.name}</div><div class="c-desc">${def.desc}</div></div><span>${price}g</span><button>Buy</button>`;
div.querySelector('button').onclick=()=>{
if(G.gold>=price){ G.gold-=price; G.deck.push(makeCardInstance(cid)); div.remove(); updateTopbar(); logMsg('Bought '+cid); }
else logMsg('Not enough gold');
};
cont.appendChild(div);
}
// ── function: openEvent ──
function openEvent(){
showScreen('event');
let ev = EVENTS[randInt(0,EVENTS.length-1)];
G.currentEvent=ev;
document.getElementById('event-title').textContent=ev.title;
document.getElementById('event-text').textContent=ev.text;
const choicesDiv=document.getElementById('event-choices'); choicesDiv.innerHTML='';
ev.choices.forEach((ch,idx)=>{
let btn=document.createElement('button'); btn.textContent=ch.text;
btn.onclick=()=>{
let res = ch.act(G);
if(res==='deferred') return;
if(res){ showScreen('map'); proceedToNextFloor(); }
};
choicesDiv.appendChild(btn);
});
}
// ── function: startCombat ──
function startCombat(enemyIds, nodeType){
G.inCombat=true; showScreen('combat');
let enemies=[];
enemyIds.forEach((eid,idx)=>{
let def = ENEMY_DEFS[eid];
if(!def) return;
enemies.push({
eid:eid, name:def.name, maxHp:def.hp, hp:def.hp, block:0,
statuses:{},
pattern: def.pattern ? clone(def.pattern) : null,
patIdx:0, intent:null, idx:idx,
boss:!!def.boss, type:def.type,
enrage:0, str:0
});
});
// boss special logic
let actBoss = G.act===1?'LichKing': G.act===2?'EldritchTreant':'Archdemon';
if(nodeType==='Boss' && enemies.length===1 && enemies[0].eid!==actBoss){
// force correct boss if map gave wrong due to fallback
}
G.combat={
enemies:enemies,
player:{
block:0, statuses:{Strength:0, Dexterity:0, Regeneration:0, Intangible:0, Weak:0, Vulnerable:0, Bleed:0, Burn:0, Poison:0},
powers:[], // {id}
energy:3, maxEnergy:3,
drawPile:[], discardPile:[], exhaustPile:[], hand:[]
},
turn:1,
isPlayerTurn:true,
nodeType:nodeType,
sacDaggerUsed:false,
blackBloodActive: G.bossRelics.includes('BlackBlood'),
};
// apply relic start combat effects
let p = G.combat.player;
if(G.nextCombatEnergy>0){ p.maxEnergy+=G.nextCombatEnergy; G.nextCombatEnergy=0; }
G.relics.forEach(rk=>{
let r=RELICS[rk];
if(r.effect==='shadowCloak') p.statuses.Intangible=(p.statuses.Intangible||0)+1;
if(r.effect==='cursedAmulet'){
enemies.forEach(e=>{ e.statuses.Weak=(e.statuses.Weak||0)+1; });
}
if(r.effect==='sacDagger') G.combat.sacDaggerUsed=false;
});
// boss relics
if(G.bossRelics.includes('CursedCrown')) p.maxEnergy+=1;
if(G.bossRelics.includes('Ectoplasm')) p.maxEnergy+=1;
if(G.bossRelics.includes('PhilosophersStone')) p.maxEnergy+=1;
if(G.bossRelics.includes('BlackBlood')){
let loss=Math.floor(G.maxHp*0.1); if(loss<1) loss=1;
// apply with intangible check but start combat intangible may exist
G.hp=Math.max(1, G.hp-loss);
}
if(G.bossRelics.includes('ShadowAmulet')){
p.statuses.Intangible=(p.statuses.Intangible||0)+1;
}
if(G.bossRelics.includes('PhilosophersStone')){
enemies.forEach(e=>{ e.statuses.Strength=(e.statuses.Strength||0)+1; });
}
// Demon Horns
if(G.relics.includes('DemonHorns') || (G.playerClass==='Hellforged' && Math.random()<0.2)){
// add imp claw
let imp = makeCardInstance('Imp Claw');
G.combat.player.hand.push(imp); // will be overwritten by draw, so we queue to add after init draw? We'll push to draw then hand
}
initDeckForCombat();
startPlayerTurn();
updateCombatUI();
// generate intents
enemies.forEach(e=>generateIntent(e));
renderEnemies();
}
// ── function: initDeckForCombat ──
function initDeckForCombat(){
let c=G.combat;
c.player.drawPile = shuffle(G.deck.map(x=>({id:x.id, instanceId:x.instanceId, upgraded:x.upgraded})));
c.player.discardPile=[]; c.player.exhaustPile=[]; c.player.hand=[];
// Demon Horns effect: add imp
if(G.relics.includes('DemonHorns')){
let imp = makeCardInstance('Imp Claw');
c.player.drawPile.unshift(imp);
}
}
// ── function: startPlayerTurn ──
function startPlayerTurn(){
let combat=G.combat; if(!combat) return;
let p=combat.player;
combat.isPlayerTurn=true;
// handle statuses at start of player turn
// Bleed
if((p.statuses.Bleed||0)>0){
let dmg=p.statuses.Bleed;
logMsg('Bleed deals '+dmg+' to you');
if(applyDamageToPlayer(dmg, true)) return;
p.statuses.Bleed=Math.floor(p.statuses.Bleed/2);
if(p.statuses.Bleed===0) delete p.statuses.Bleed;
}
if((p.statuses.Poison||0)>0){
let dmg=p.statuses.Poison;
logMsg('Poison deals '+dmg);
if(applyDamageToPlayer(dmg,true)) return;
p.statuses.Poison=Math.max(0,p.statuses.Poison-1);
if(p.statuses.Poison===0) delete p.statuses.Poison;
}
// Weak, Vulnerable, etc decrement
['Weak','Vulnerable'].forEach(k=>{ if(p.statuses[k]){ p.statuses[k]--; if(p.statuses[k]<=0) delete p.statuses[k]; }});
if((p.statuses.Intangible||0)>0){ p.statuses.Intangible--; if(p.statuses.Intangible<=0) delete p.statuses.Intangible; }
if((p.statuses.Regeneration||0)>0){ heal(p.statuses.Regeneration); p.statuses.Regeneration--; if(p.statuses.Regeneration<=0) delete p.statuses.Regeneration; }
// block resets at start of player turn? GDD says block resets at end turn discard, and also block resets at start? Spec: All Block resets. We'll handle at end of player turn to reset player block. So here we keep zero? Actually block should be 0 at start, it was reset at end of previous player turn. But enemy block persists. So set player block 0? It was reset on endTurn. So ensure.
// Actually spec: End Turn: discard hand to discard pile. All Block resets. So player block resets at end turn. So here it's already 0.
p.energy=p.maxEnergy;
// relics start of turn
if(G.relics.includes('BleedingEdge')){
let alive=combat.enemies.filter(e=>e.hp>0);
if(alive.length){ let tgt=alive[randInt(0,alive.length-1)]; tgt.statuses.Bleed=(tgt.statuses.Bleed||0)+1; logMsg('Bleeding Edge applies Bleed'); }
}
if(G.relics.includes('EternalHourglass')){
// handled after draw check
}
// power start turn effects
p.powers.forEach(pow=>{
if(pow.id==='Soul Siphon' || pow.id==='Soul Siphon+'){
p.energy+=1; let died=loseHpRaw(1); updateTopbar(); if(died) gameOver(false);
// blood pact triggers will be handled in card HP loss? need handle here too
if(G.relics.includes('Bloodstone')) heal(1);
handleBloodPact(1);
}
if(pow.id==='Hellfire Aura' || pow.id==='Hellfire Aura+'){
let dmg=pow.def.dmg||3;
combat.enemies.forEach(e=>{ applyDamageToEnemy(e, dmg, false); });
}
if(pow.id==='Umbral Mastery' || pow.id==='Umbral Mastery+'){
let w=pow.def.weak||1;
combat.enemies.forEach(e=>{ e.statuses.Weak=(e.statuses.Weak||0)+w; });
}
});
// draw 5
drawCards(5);
// check hourglass
if(G.relics.includes('EternalHourglass') && p.hand.length===0){
p.energy+=1; logMsg('Hourglass +1 Energy');
}
// Phantom Armor / Whispering Shroud next turn delayed effects stored in combat.nextTurnBuffs?
if(combat.nextTurn){
if(combat.nextTurn.dex) p.statuses.Dexterity=(p.statuses.Dexterity||0)+combat.nextTurn.dex;
if(combat.nextTurn.block){ p.block+=combat.nextTurn.block; }
if(combat.nextTurn.energy){ p.energy+=combat.nextTurn.energy; }
combat.nextTurn=null;
}
combat.turn++;
updateCombatUI();
}
// ── function: drawCards ──
function drawCards(n){
let p=G.combat.player;
for(let i=0;i<n;i++){
if(p.drawPile.length===0){
if(p.discardPile.length===0) break;
p.drawPile=shuffle(p.discardPile); p.discardPile=[];
}
let c=p.drawPile.pop();
if(!c) break;
// Dazed etc no draw effect but still added
p.hand.push(c);
}
}
// ── function: generateIntent ──
function generateIntent(enemy){
if(enemy.hp<=0){ enemy.intent=null; return; }
// boss special handling
if(enemy.boss){
bossGenerateIntent(enemy);
return;
}
if(!enemy.pattern || enemy.pattern.length===0){ enemy.intent={type:'Attack', dmg:5}; return; }
// per pattern cycling
let pat = enemy.pattern[enemy.patIdx % enemy.pattern.length];
enemy.intent = clone(pat);
enemy.patIdx++;
// apply str to intent display later
}
// ── function: bossGenerateIntent ──
function bossGenerateIntent(enemy){
let act=G.act;
if(act===1){
let boss=enemy; // Lich King
let minions = G.combat.enemies.filter(e=>e.eid.includes('SkeletonMinion') && e.hp>0).length;
if(boss.hp>30){
let cycle=boss.patIdx % 3;
if(cycle===0){
if(minions>=2) boss.intent={type:'Attack', dmg:8};
else boss.intent={type:'Summon', summon:'SkeletonMinion'};
} else if(cycle===1) boss.intent={type:'Attack', dmg:8};
else boss.intent={type:'Defend', block:12};
} else {
// phase2 enrage
if(boss.patIdx===0 || boss.enrage===0){ boss.str=(boss.str||0)+2; boss.enrage=1; }
else boss.str=(boss.str||0)+2;
let cycle=boss.patIdx % 3;
if(cycle===0) boss.intent={type:'Attack', dmg:10, str:battleStr(boss)};
else if(cycle===1) boss.intent={type:'Attack', dmg:10, str:battleStr(boss)};
else boss.intent={type:'Defend', block:15};
}
} else if(act===2){
let boss=enemy;
if(boss.hp>40){
let c=boss.patIdx%3;
if(c===0) boss.intent={type:'AtkDebuff', dmg:10, weak:2};
else if(c===1) boss.intent={type:'Defend', block:15, regen:2};
else boss.intent={type:'Multi', dmg:8, hits:2};
} else {
boss.str=(boss.str||0)+1;
let c=boss.patIdx%3;
if(c===0) boss.intent={type:'AtkDebuff', dmg:14, vuln:1, str:boss.str};
else if(c===1) boss.intent={type:'HealBlock', block:20, heal:5};
else boss.intent={type:'AtkDebuff', dmg:14, vuln:1, str:boss.str};
}
} else {
let boss=enemy;
if(boss.hp>45){
let c=boss.patIdx%3;
if(c===0){
let hasDog=G.combat.enemies.some(e=>e.eid==='Hellhound' && e.hp>0);
if(hasDog) boss.intent={type:'Attack', dmg:12, burn:2};
else boss.intent={type:'Summon', summon:'Hellhound'};
} else if(c===1) boss.intent={type:'AtkDebuff', dmg:12, burn:2, str:boss.str||0};
else { boss.intent={type:'Buff', str:3, block:15}; }
} else {
boss.str=(boss.str||0)+2;
let c=boss.patIdx%3;
if(c===0) boss.intent={type:'Attack', dmg:16, str:boss.str};
else if(c===1) boss.intent={type:'Multi', dmg:7, hits:3, str:boss.str};
else boss.intent={type:'Attack', dmg:16, str:boss.str};
}
}
enemy.patIdx++;
}
// ── function: battleStr ──
function battleStr(enemy){ return (enemy.statuses.Strength||0)+(enemy.str||0); }
// ── function: updateCombatUI ──
function updateCombatUI(){
if(!G.combat) return;
const p=G.combat.player;
document.getElementById('p-energy').textContent='E:'+p.energy+'/'+p.maxEnergy;
document.getElementById('p-draw').textContent='Draw:'+p.drawPile.length;
document.getElementById('p-discard').textContent='Disc:'+p.discardPile.length;
document.getElementById('p-exhaust').textContent='Exh:'+p.exhaustPile.length;
document.getElementById('tb-block').textContent=p.block? ('Block: '+p.block):'';
let stDiv=document.getElementById('player-statuses'); stDiv.innerHTML='';
Object.entries(p.statuses).forEach(([k,v])=>{
if(!v) return;
let s=document.createElement('div'); s.className='status-tag '+( ['Weak','Vulnerable','Bleed','Burn','Poison'].includes(k) ? 'bad':'good');
s.textContent=k+':'+v; stDiv.appendChild(s);
});
p.powers.forEach(pw=>{
let s=document.createElement('div'); s.className='status-tag good'; s.textContent=pw.id; stDiv.appendChild(s);
});
renderEnemies();
renderHand();
updateTopbar();
}
// ── function: renderEnemies ──
function renderEnemies(){
const cont=document.getElementById('combat-enemies'); cont.innerHTML='';
if(!G.combat) return;
G.combat.enemies.forEach((e,i)=>{
if(e.hp<=0) return;
const card=document.createElement('div'); card.className='enemy-card'; card.dataset.idx=i;
if(G.combat.selectedEnemy===i) card.classList.add('targeted');
card.onclick=()=>{ G.combat.selectedEnemy=i; updateCombatUI(); };
let intentStr='';
if(e.intent){
let it=e.intent; let extraStr = (e.statuses.Strength||0)+(e.str||0);
let icon='⚔'; let cls='intent-atk';
if(it.type==='Defend'){ icon='🛡'; cls='intent-def'; intentStr=`Gain ${it.block} Block`; }
else if(it.type==='Attack'){ icon='⚔'; intentStr=`Attack ${ (it.dmg||0)+extraStr }`; }
else if(it.type==='Multi'){ icon='⚔'; intentStr=`${it.hits}x${(it.dmg||0)+extraStr}`; }
else if(it.type==='AtkDef'){ icon='⚔'; intentStr=`Atk ${it.dmg+extraStr} / Block ${it.block}`; }
else if(it.type==='Debuff' || it.type==='AtkDebuff'){ icon='☠'; cls='intent-debuff'; intentStr= (it.dmg?`Atk ${it.dmg+extraStr} + `:'' ) + (it.weak?`Weak ${it.weak} `:'' ) + (it.vuln?`Vuln ${it.vuln} `:'' ) + (it.bleed?`Bleed ${it.bleed} `:'' ) + (it.burn?`Burn ${it.burn} `:'' ) + (it.poison?`Poison ${it.poison} `:'' ); }
else if(it.type==='Summon'){ icon='✚'; cls='intent-buff'; intentStr=`Summon ${it.summon}`; }
else if(it.type==='Buff' || it.type==='BuffAll' || it.type==='BuffAlly'){ icon='✦'; cls='intent-buff'; intentStr=`Buff ${it.str? 'Str '+it.str:''} ${it.block? 'Block '+it.block:''} ${it.intangible? 'Intangible':''}`; }
else if(it.type==='AtkDefBuff'){ icon='⚔'; intentStr=`Block ${it.block} + Str ${it.str}`; }
else if(it.type==='Lifesteal'){ icon='❤'; intentStr=`Atk ${it.dmg+extraStr} Heal ${it.heal}`; }
else if(it.type==='HealBlock'){ icon='❤'; intentStr=`Block ${it.block} Heal ${it.heal}`; }
else intentStr=JSON.stringify(it);
card.innerHTML+=`<div class="enemy-intent"><span class="intent-icon ${cls}">${icon}</span><span>${intentStr}</span></div>`;
}
card.innerHTML = `<div class="enemy-name"><span>${e.name}</span><span>${e.hp}/${e.maxHp}${e.block? ' [B:'+e.block+']':''}</span></div><div class="enemy-hpbar"><div class="enemy-hpbar-fill" style="width:${e.hp/e.maxHp*100}%"></div></div>` + card.innerHTML;
let sDiv=document.createElement('div'); sDiv.className='enemy-statuses';
Object.entries(e.statuses).forEach(([k,v])=>{ if(v){ let el=document.createElement('span'); el.className='status-tag '+(k==='Strength'||k==='Regeneration'||k==='Intangible'?'good':'bad'); el.textContent=k+':'+v; sDiv.appendChild(el); }});
if((e.str||0)>0){ let el=document.createElement('span'); el.className='status-tag good'; el.textContent='Str+'+e.str; sDiv.appendChild(el); }
card.appendChild(sDiv);
cont.appendChild(card);
});
}
// ── function: renderHand ──
function renderHand(){
const cont=document.getElementById('combat-hand'); cont.innerHTML='';
if(!G.combat) return;
G.combat.player.hand.forEach((cInst, idx)=>{
let def=getCardDef(cInst);
if(!def) return;
let unplayable = (def.unplayable || (def.cost>0 && def.cost>G.combat.player.energy && def.cost!==-1));
let div=document.createElement('div'); div.className='c-card '+(def.type.toLowerCase())+' '+(def.rarity?def.rarity.toLowerCase():'common');
if(unplayable) div.classList.add('unplayable');
if(G.combat.handSelected===idx) div.classList.add('selected');
div.innerHTML=`<div class="c-top"><span class="c-cost">${def.cost===-1?'X':def.cost}</span><span style="font-size:9px">${def.rarity||''}</span></div><div class="c-name">${cardDisplayName(cInst)}</div><div class="c-type">${def.type}</div><div class="c-desc">${def.desc}</div>`;
div.onclick=()=>{
if(unplayable && !def.doubt) return;
// select enemy for attack if needed
if(def.dmg && !def.aoe && !def.random){
if(G.combat.selectedEnemy===undefined) { G.combat.selectedEnemy=0; updateCombatUI(); }
playCard(idx, G.combat.selectedEnemy);
} else {
playCard(idx, null);
}
};
cont.appendChild(div);
});
}
// ── function: calcAttackDamage ──
function calcAttackDamage(base, attackerStatuses, attackerStr, defenderStatuses, defenderBlock){
let dmg=base + (attackerStatuses.Strength||0) + (attackerStr||0);
if(attackerStatuses.Weak) dmg=Math.floor(dmg*0.75);
if(defenderStatuses.Vulnerable) dmg=Math.floor(dmg*1.5);
if(defenderStatuses.Intangible) dmg=Math.min(dmg,1);
// block absorption done elsewhere
return Math.max(0,dmg);
}
// ── function: applyDamageToEnemy ──
function applyDamageToEnemy(enemy, baseDmg, ignoreBlock, attackerStatuses){
if(enemy.hp<=0) return 0;
if(baseDmg<=0) return 0;
attackerStatuses = attackerStatuses || G.combat.player.statuses;
let dmg = baseDmg;
// strength etc already baked? We'll assume base passed already includes attacker str? We handle inside calc if calling from card?
// For card direct, base is final after calc
let defenderStatuses = enemy.statuses;
// apply vulnerable from defender
let finalDmg = dmg;
if(!ignoreBlock){
if((defenderStatuses.Vulnerable||0)>0) finalDmg = Math.floor(finalDmg*1.5);
if((defenderStatuses.Intangible||0)>0) finalDmg = Math.min(finalDmg,1);
if(enemy.block>0){
let blocked = Math.min(enemy.block, finalDmg);
enemy.block-=blocked;
finalDmg-=blocked;
}
}
enemy.hp-=finalDmg;
if(enemy.hp<0) enemy.hp=0;
// blood pact? not enemy
if(G.combat && G.combat.player.powers.some(p=>p.id.includes('Black Blood'))){
// Black Blood heals on attack cards
}
if(enemy.hp<=0){
logMsg(enemy.name+' defeated');
}
return finalDmg;
}
// ── function: applyDamageToPlayer ──
function applyDamageToPlayer(amount, isHpLoss){
let p=G.combat.player;
let dmg=amount;
if(p.statuses.Vulnerable) dmg=Math.floor(dmg*1.5);
if(p.statuses.Intangible) dmg=Math.min(dmg,1);
if(!isHpLoss){
if(p.block>0){
let bl=Math.min(p.block, dmg);
p.block-=bl; dmg-=bl;
}
}
// isHpLoss bypasses block already
if(dmg>0){
if(loseHpRaw(dmg)) { gameOver(false); return true; }
}
return false;
}
// ── function: handleBloodPact ──
function handleBloodPact(hpLost){
let p=G.combat.player;
let pact = p.powers.filter(pw=>pw.id.startsWith('Blood Pact'));
pact.forEach(pow=>{
let amt=pow.def.strPerHp||1;
// per HP lost? spec says whenever you lose HP from a card, gain 1 Str. Not per HP amount but per card event.
p.statuses.Strength=(p.statuses.Strength||0)+amt;
logMsg('Blood Pact +'+amt+' Str');
});
// Bloodstone relic
if(G.relics.includes('Bloodstone')){
heal(1); logMsg('Bloodstone heals 1');
}
// SoulEmber extra? irrelevant
}
// ── function: playCard ──
function playCard(handIdx, targetEnemyIdx){
let combat=G.combat; if(!combat || !combat.isPlayerTurn) return;
let p=combat.player;
let cInst=p.hand[handIdx];
let def=getCardDef(cInst);
if(!def) return;
let cost = def.cost;
let realCost = cost;
if(cost===-1){ // X
realCost = p.energy;
}
if(realCost > p.energy) { logMsg('Not enough energy'); return; }
// Sac dagger check
let hpCost = def.hpLoss||0;
if(hpCost>0 && G.relics.includes('SacrificialDagger') && !combat.sacDaggerUsed && (def.type==='Attack')){
hpCost=Math.max(0,hpCost-1); combat.sacDaggerUsed=true;
}
// hp loss from card
if(hpCost>0){
// Intangible reduces to 1 if player has it
let actualLoss=hpCost;
if((p.statuses.Intangible||0)>0) actualLoss=Math.min(actualLoss,1);
if(loseHpRaw(actualLoss)) { if(G.hp<=0) { gameOver(false); return; } }
handleBloodPact(actualLoss);
}
// spend energy
if(cost===-1){
p.energy=0;
} else {
p.energy-=realCost;
}
// remove from hand to played
p.hand.splice(handIdx,1);
// apply card effects
applyCardEffect(def, cInst, targetEnemyIdx, realCost);
// handle exhaust vs discard
if(def.exhaust){
p.exhaustPile.push(cInst);
} else if(def.type!=='Power'){
p.discardPile.push(cInst);
}
// check win
if(combat.enemies.every(e=>e.hp<=0)){
winCombat();
return;
}
updateCombatUI();
}
// ── function: applyCardEffect ──
function applyCardEffect(def, cInst, targetIdx, energySpent){
let p=G.combat.player;
let enemies=G.combat.enemies.filter(e=>e.hp>0);
let target = null;
if(targetIdx!==null && targetIdx!==undefined) {
target = G.combat.enemies[targetIdx];
if(target && target.hp<=0) target=null;
}
if(!target && enemies.length>0) target=enemies[0];
// Damage
if(def.dmg && def.dmg>0){
let base = def.dmg;
let aoe = def.aoe;
let randomHits = def.random;
let hits = def.hits||1;
let calc = ()=>{
let dmg = base + (p.statuses.Strength||0) + (p.statuses.tempStr||0);
if(p.statuses.Weak) dmg=Math.floor(dmg*0.75);
return dmg;
};
if(aoe){
for(let h=0;h<hits;h++){
G.combat.enemies.forEach(e=>{ if(e.hp>0){ let dmg=calc(); applyDamageToEnemy(e,dmg,false,p.statuses); } });
}
} else if(randomHits){
for(let i=0;i<hits;i++){
let alive=G.combat.enemies.filter(e=>e.hp>0);
if(alive.length===0) break;
let e=alive[randInt(0,alive.length-1)];
let dmg=calc(); applyDamageToEnemy(e,dmg,false,p.statuses);
}
} else {
if(def.bonusWeak){
// Nightmare Lash
let extra = (target && target.statuses.Weak)? def.bonusWeak:0;
let dmg=calc()+extra;
if(target) applyDamageToEnemy(target,dmg,false,p.statuses);
} else {
let dmg=calc();
// multi hits for normal?
for(let h=0;h<hits;h++){
if(target) { let d=applyDamageToEnemy(target,dmg,false,p.statuses); if(target.hp<=0) break; }
}
}
}
// Black Blood relic: heal 1 on Attack
if(G.bossRelics.includes('BlackBlood')){
heal(1);
}
}
// Block
if(def.block){
let b = def.block + (p.statuses.Dexterity||0);
if(def.cost===-1){ // Vital Surge
let missing = G.maxHp - G.hp;
b = missing + (def.bonus||0);
// if X spent? multiply? For now missing+bonus per spec, not scaled by energy, but consume all energy.
}
p.block+=b;
}
// Healing
if(def.heal){ heal(def.heal); }
// Draw
if(def.draw){ drawCards(def.draw); }
// Energy
if(def.energy){ p.energy+=def.energy; }
// Regen
if(def.regen){ p.statuses.Regeneration=(p.statuses.Regeneration||0)+def.regen; }
// Status applications to enemies
let applyEnemy = (e, field, val)=>{
if(!e) return;
if(field==='weak') e.statuses.Weak=(e.statuses.Weak||0)+val;
if(field==='vuln') e.statuses.Vulnerable=(e.statuses.Vulnerable||0)+val;
if(field==='bleed') e.statuses.Bleed=(e.statuses.Bleed||0)+val;
if(field==='burn'){
e.statuses.Burn=(e.statuses.Burn||0)+val;
if(G.relics.includes('SoulEmber')){
applyDamageToEnemy(e,2,true,p.statuses);
}
}
if(field==='poison') e.statuses.Poison=(e.statuses.Poison||0)+val;
};
if(def.weak){
if(def.aoe) G.combat.enemies.forEach(e=>applyEnemy(e,'weak',def.weak));
else if(target) applyEnemy(target,'weak',def.weak);
else if(def.burnRandom){ let alive=G.combat.enemies.filter(e=>e.hp>0); if(alive.length) applyEnemy(alive[randInt(0,alive.length-1)],'weak',def.weak); }
}
if(def.vuln){
if(def.aoe) G.combat.enemies.forEach(e=>applyEnemy(e,'vuln',def.vuln));
else if(target) applyEnemy(target,'vuln',def.vuln);
}
if(def.bleed){
if(def.aoe) G.combat.enemies.forEach(e=>applyEnemy(e,'bleed',def.bleed));
else if(target) applyEnemy(target,'bleed',def.bleed);
}
if(def.burn){
if(def.aoe && !def.burnRandom) G.combat.enemies.forEach(e=>applyEnemy(e,'burn',def.burn));
else if(def.burnRandom){
let alive=G.combat.enemies.filter(e=>e.hp>0);
if(alive.length) applyEnemy(alive[randInt(0,alive.length-1)],'burn',def.burn);
} else if(target) applyEnemy(target,'burn',def.burn);
}
if(def.strDown && target){
target.statuses.Strength=(target.statuses.Strength||0)-def.strDown;
}
if(def.tempStr){ p.statuses.tempStrength=(p.statuses.tempStrength||0)+def.tempStr; p.statuses.Strength=(p.statuses.Strength||0)+def.tempStr; // temporary for this turn only, we'll subtract at end turn
if(!G.combat.tempStrList) G.combat.tempStrList=[];
G.combat.tempStrList.push(def.tempStr);
}
if(def.nextDex){
if(!G.combat.nextTurn) G.combat.nextTurn={};
G.combat.nextTurn.dex=(G.combat.nextTurn.dex||0)+def.nextDex;
}
if(def.nextBlock){
if(!G.combat.nextTurn) G.combat.nextTurn={};
G.combat.nextTurn.block=(G.combat.nextTurn.block||0)+def.nextBlock;
}
if(def.nextEnergy){
if(!G.combat.nextTurn) G.combat.nextTurn={};
G.combat.nextTurn.energy=(G.combat.nextTurn.energy||0)+def.nextEnergy;
}
if(def.intangible){
p.statuses.Intangible=(p.statuses.Intangible||0)+def.intangible;
}
if(def.gen){
let genId=def.gen;
let inst=makeCardInstance(genId);
G.combat.player.hand.push(inst);
}
if(def.shuffle){
// shuffle Dazed into draw pile
let token=makeCardInstance(def.shuffle);
if(def.shuffleTo==='discard') G.combat.player.discardPile.push(token);
else G.combat.player.drawPile.push(token);
shuffle(G.combat.player.drawPile);
}
if(def.special==='vital'){
// already handled block
}
if(def.type==='Power'){
p.powers.push({id:def.name, def:def});
if(G.relics.includes('TomeDarkRituals')) drawCards(1);
}
logMsg('Played '+def.name);
}
// ── function: endPlayerTurn ──
function endPlayerTurn(){
let combat=G.combat; if(!combat || !combat.isPlayerTurn) return;
let p=combat.player;
// handle ethereal, doubt, temp str removal etc
let remainingHand=[...p.hand];
remainingHand.forEach(c=>{
let def=getCardDef(c);
if(!def) return;
if(def.ethereal){
// exhaust ethereal
p.exhaustPile.push(c);
} else {
if(def.doubt){
loseHpRaw(1);
logMsg('Doubt deals 1');
}
p.discardPile.push(c);
}
});
p.hand=[];
// discard temp strength
if(combat.tempStrList){
combat.tempStrList.forEach(v=>{ p.statuses.Strength-=v; });
combat.tempStrList=[];
}
p.block=0;
// trigger burn status on player? Actually Burn is enemy only per GDD, but we have player Burn as per design? We'll keep enemy Burn tick at end of their turn, Bleed tick at start
// Player Burn? Enemies can apply Burn to player: GDD says Burn applied to enemies only (player can be set on fire by enemies). So player Burn exists
if((p.statuses.Burn||0)>0){
let dmg=p.statuses.Burn;
logMsg('Burn deals '+dmg);
if(applyDamageToPlayer(dmg,true)){ return; }
p.statuses.Burn=Math.floor(p.statuses.Burn/2);
if(p.statuses.Burn===0) delete p.statuses.Burn;
}
combat.isPlayerTurn=false;
updateCombatUI();
setTimeout(enemyTurn, 500);
}
// ── function: enemyTurn ──
function enemyTurn(){
let combat=G.combat; if(!combat) return;
let idx=0;
function nextEnemy(){
if(combat.enemies.every(e=>e.hp<=0)){ winCombat(); return; }
if(idx>=combat.enemies.length){ // end of enemy turn, start new player turn
// generate new intents for next round
combat.enemies.forEach(e=>{
if(e.hp>0){
// regen for enemies
if((e.statuses.Regeneration||0)>0){ e.hp=Math.min(e.maxHp, e.hp+e.statuses.Regeneration); e.statuses.Regeneration--; if(e.statuses.Regeneration<=0) delete e.statuses.Regeneration; }
// decrement status durations
['Weak','Vulnerable'].forEach(k=>{ if(e.statuses[k]){ e.statuses[k]--; if(e.statuses[k]<=0) delete e.statuses[k]; }});
if((e.statuses.Intangible||0)>0){ e.statuses.Intangible--; if(e.statuses.Intangible<=0) delete e.statuses.Intangible; }
// Burn tick at end of enemy turn
if((e.statuses.Burn||0)>0){
let dmg=e.statuses.Burn;
// intangible?
if((e.statuses.Intangible||0)>0) dmg=Math.min(dmg,1);
e.hp-=dmg;
e.statuses.Burn=Math.floor(e.statuses.Burn/2);
if(e.statuses.Burn===0) delete e.statuses.Burn;
if(e.hp<=0) { e.hp=0; logMsg(e.name+' burned to death'); }
}
// bleed tick at start of enemy turn was supposed to be at start, but we already did? Actually bleed is at start of its turn. So we should handle at start of enemy individual turn, not end.
// Poison tick start
if((e.statuses.Poison||0)>0){
e.hp-=e.statuses.Poison;
e.statuses.Poison--;
if(e.statuses.Poison<=0) delete e.statuses.Poison;
if(e.hp<=0) logMsg(e.name+' poisoned');
}
// clear block? per spec block persists until next action then resets? We'll clear before next intent generation? Our generate will clear block at start of enemy action if they are about to act. We'll manage here: clear block now if enemy will act next turn and they didn't defend? Simpler: keep block until they act, then we will clear at start of their action in next cycle.
// For simplicity, clear enemy block at start of their next intent execution.
generateIntent(e);
}
});
startPlayerTurn();
updateCombatUI();
return;
}
let enemy=combat.enemies[idx];
idx++;
if(enemy.hp<=0){ nextEnemy(); return; }
// Bleed tick at start of this enemy's turn
if((enemy.statuses.Bleed||0)>0){
let dmg=enemy.statuses.Bleed;
if((enemy.statuses.Intangible||0)>0) dmg=Math.min(dmg,1);
enemy.hp-=dmg;
enemy.statuses.Bleed=Math.floor(enemy.statuses.Bleed/2);
if(enemy.statuses.Bleed===0) delete enemy.statuses.Bleed;
logMsg(enemy.name+' bleeds '+dmg);
if(enemy.hp<=0){ enemy.hp=0; updateCombatUI(); setTimeout(nextEnemy, 300); return; }
}
// clear block from previous turn? spec says block persists until next action. So we keep it until now? Actually at start of enemy turn, their old block should still be present to absorb player attacks (already absorbed during player turn). Now before they perform new action, they lose leftover block unless their intent is to gain block? Many games clear block at start of turn. We'll clear here after bleed.
// But if intent is Defend, they will gain new block on top of leftover? In original, block persists until next action. So we should not clear automatically if they intend to Defend? spec says persists until their next action OR until broken. So at moment before action, block still exists. If they defend, they add more. If they attack, the remaining block should be lost after they act? Let's implement: keep block during action execution, then after executing intent, set block to 0 if intent wasn't a block gain? Actually simpler: at start of enemy turn, keep block; after performing action, if action was not a block-type, clear remaining block. That matches "persists until their next action". So after they act, block goes away unless they just gained block.
// We'll execute intent then decide.
executeEnemyIntent(enemy);
updateCombatUI();
// after execution, handle block persistence rule
if(enemy.hp>0){
let wasBlockIntent = enemy.intent && (enemy.intent.type==='Defend' || enemy.intent.type==='AtkDef' || enemy.intent.type==='AtkDefBuff' || enemy.intent.type==='HealBlock' || enemy.intent.type==='BuffAlly' && enemy.intent.block);
if(!wasBlockIntent){
// if they didn't gain block this turn, clear remaining block after action (old block expires)
enemy.block=0;
}
}
setTimeout(nextEnemy, 600);
}
nextEnemy();
}
// ── function: executeEnemyIntent ──
function executeEnemyIntent(enemy){
let intent=enemy.intent;
if(!intent) return;
let extraStr = (enemy.statuses.Strength||0)+(enemy.str||0);
if(enemy.statuses.Weak) extraStr = Math.floor(extraStr*0.25) - ( (enemy.statuses.Strength||0)+(enemy.str||0) >0 ? Math.floor(((enemy.statuses.Strength||0)+(enemy.str||0))*0.25) : 0 ); // simplified: weak reduces damage 25%, we handle in damage calc rather than str adjust
function dealToPlayer(base){
let dmg=base + (enemy.statuses.Strength||0)+(enemy.str||0);
if((enemy.statuses.Weak||0)>0) dmg=Math.floor(dmg*0.75);
// player vulnerable etc handled in applyDamage
applyDamageToPlayer(dmg,false);
let el=document.getElementById('combat-enemies'); el.classList.add('enemy-shake'); setTimeout(()=>el.classList.remove('enemy-shake'),300);
}
switch(intent.type){
case 'Attack':
dealToPlayer(intent.dmg);
break;
case 'Multi':
for(let i=0;i<intent.hits;i++) dealToPlayer(intent.dmg);
break;
case 'AtkDef':
dealToPlayer(intent.dmg);
enemy.block+=intent.block||0;
break;
case 'Defend':
enemy.block+=intent.block||0;
break;
case 'Buff':
if(intent.str) enemy.statuses.Strength=(enemy.statuses.Strength||0)+intent.str;
if(intent.block) enemy.block+=intent.block;
if(intent.intangible) enemy.statuses.Intangible=(enemy.statuses.Intangible||0)+intent.intangible;
break;
case 'BuffAll':
G.combat.enemies.forEach(e=>{ if(e.hp>0){ if(intent.str) e.statuses.Strength=(e.statuses.Strength||0)+intent.str; }});
break;
case 'BuffAlly':
// buff self or ally
let ally = G.combat.enemies.filter(e=>e.hp>0 && e!==enemy)[0] || enemy;
if(intent.block) ally.block+=intent.block;
if(intent.regen) ally.statuses.Regeneration=(ally.statuses.Regeneration||0)+intent.regen;
break;
case 'Debuff':
if(intent.weak) G.combat.player.statuses.Weak=(G.combat.player.statuses.Weak||0)+(intent.weak||0);
if(intent.vuln) G.combat.player.statuses.Vulnerable=(G.combat.player.statuses.Vulnerable||0)+(intent.vuln||0);
if(intent.bleed) G.combat.player.statuses.Bleed=(G.combat.player.statuses.Bleed||0)+intent.bleed;
if(intent.burn) G.combat.player.statuses.Burn=(G.combat.player.statuses.Burn||0)+intent.burn;
if(intent.poison) G.combat.player.statuses.Poison=(G.combat.player.statuses.Poison||0)+intent.poison;
break;
case 'AtkDebuff':
if(intent.dmg) dealToPlayer(intent.dmg);
if(intent.weak) G.combat.player.statuses.Weak=(G.combat.player.statuses.Weak||0)+intent.weak;
if(intent.vuln) G.combat.player.statuses.Vulnerable=(G.combat.player.statuses.Vulnerable||0)+intent.vuln;
if(intent.burn) G.combat.player.statuses.Burn=(G.combat.player.statuses.Burn||0)+intent.burn;
if(intent.poison) G.combat.player.statuses.Poison=(G.combat.player.statuses.Poison||0)+intent.poison;
break;
case 'SelfDebuff':
if(intent.block) enemy.block+=intent.block;
if(intent.vulnSelf) enemy.statuses.Vulnerable=(enemy.statuses.Vulnerable||0)+intent.vulnSelf;
break;
case 'AtkDefBuff':
if(intent.block) enemy.block+=intent.block;
if(intent.str) enemy.statuses.Strength=(enemy.statuses.Strength||0)+intent.str;
break;
case 'HealBlock':
if(intent.block) enemy.block+=intent.block;
if(intent.heal) enemy.hp=Math.min(enemy.maxHp, enemy.hp+intent.heal);
break;
case 'Lifesteal':
dealToPlayer(intent.dmg);
enemy.hp=Math.min(enemy.maxHp, enemy.hp+intent.heal);
break;
case 'Summon':
// add new enemy if less than 4 total
if(G.combat.enemies.length<5){
let def=ENEMY_DEFS[intent.summon];
if(def){
G.combat.enemies.push({eid:intent.summon, name:def.name, maxHp:def.hp, hp:def.hp, block:0, statuses:{}, pattern:def.pattern?clone(def.pattern):null, patIdx:0, intent:null, boss:false, type:'minion', str:0});
let newE=G.combat.enemies[G.combat.enemies.length-1];
generateIntent(newE);
}
}
break;
default:
if(intent.dmg) dealToPlayer(intent.dmg);
break;
}
logMsg(enemy.name+' uses '+ (intent.type));
}
// ── function: winCombat ──
function winCombat(){
let combat=G.combat;
G.inCombat=false;
let nodeType=combat.nodeType;
let baseGold = nodeType==='Elite'? randInt(25,35) : nodeType==='Boss'? 50 : randInt(10,20);
if(G.relics.includes('GreedyImp')) baseGold+=10;
if(G.bossRelics.includes('Ectoplasm') || G.noGoldFlag) baseGold=0;
else G.gold+=baseGold;
// ashen armor heal
if(G.relics.includes('AshenArmor')) heal(6);
showScreen('reward');
document.getElementById('reward-title').textContent= nodeType==='Boss' ? 'Boss Vanquished!' : 'Victory!';
document.getElementById('reward-gold').textContent='Gained '+baseGold+' Gold';
const cardCont=document.getElementById('reward-cards'); cardCont.innerHTML='';
const relicCont=document.getElementById('reward-relics'); relicCont.innerHTML='';
const potCont=document.getElementById('reward-potions'); potCont.innerHTML='';
// card reward: 3 cards weighted
if(nodeType!=='Boss' || true){
let choices = generateCardChoices(nodeType);
choices.forEach(cid=>{
let def = CLASS_CARDS[G.playerClass][cid] || BASIC_CARDS[cid] || COLORLESS[cid] || ALL_CARD_MAP[cid];
if(!def) return;
let div=document.createElement('div'); div.className='c-card '+def.type.toLowerCase()+' '+def.rarity.toLowerCase();
div.innerHTML=`<div class="c-top"><span class="c-cost">${def.cost}</span></div><div class="c-name">${def.name}</div><div class="c-type">${def.type} • ${def.rarity}</div><div class="c-desc">${def.desc}</div>`;
div.onclick=()=>{ G.deck.push(makeCardInstance(cid)); cardCont.innerHTML='<i>Picked '+cid+'</i>'; logMsg('Picked '+cid); };
cardCont.appendChild(div);
});
}
// potion chance
let potChance = nodeType==='Elite'?0.7: nodeType==='Boss'?1:0.4;
if(Math.random()<potChance && G.potions.length<3){
let keys=Object.keys(POTIONS); let k=keys[randInt(0,keys.length-1)];
let btn=document.createElement('button'); btn.textContent='Take '+POTIONS[k].name; btn.onclick=()=>{ if(G.potions.length<3){ addSpecificPotion(k); potCont.innerHTML=''; } };
potCont.appendChild(btn);
if(nodeType==='Boss' && G.potions.length<3){
let keys2=Object.keys(POTIONS); let k2=keys2[randInt(0,keys2.length-1)];
let btn2=document.createElement('button'); btn2.textContent='Take '+POTIONS[k2].name; btn2.onclick=()=>{ if(G.potions.length<3){ addSpecificPotion(k2); btn2.remove(); } };
potCont.appendChild(btn2);
}
}
if(nodeType==='Elite'){
addRandomRelic(null);
relicCont.textContent='Relic obtained!';
} else if(nodeType==='Boss'){
// choose 1 of 3 boss relics
let bossKeys=Object.keys(BOSS_RELICS); shuffle(bossKeys);
for(let i=0;i<3;i++){
let key=bossKeys[i];
let br=BOSS_RELICS[key];
let div=document.createElement('div'); div.className='shop-item';
div.innerHTML=`<b>${br.name}</b><span>${br.desc}</span><button>Take</button>`;
div.querySelector('button').onclick=()=>{
// apply boss relic effect
if(br.energy) { /* extra energy already handled via flag */ }
if(br.maxHp) { G.maxHp+=br.maxHp; if(G.hp>G.maxHp) G.hp=G.maxHp; }
if(br.downside==='noRemoveCurses') G.noRemoveCurses=true;
if(br.downside==='noGold'){ G.noGoldFlag=true; G.gold=0; }
if(br.downside==='enemiesStr'){ /* flag applied at combat start */ }
if(key==='BlackBlood') { /* active via bossRelics includes */ }
G.bossRelics.push(key);
G.relics.push(key); // for topbar display
relicCont.innerHTML='Took '+br.name;
updateTopbar();
};
relicCont.appendChild(div);
}
}
document.getElementById('btn-skip-reward').onclick=()=>{
if(nodeType==='Boss'){
if(G.bossRelics.length===0 && G.act<3){
logMsg('Must choose a boss relic!');
return;
}
}
proceedToNextFloor();
};
G.combat=null;
updateTopbar();
}
// ── function: generateCardChoices ──
function generateCardChoices(nodeType){
let poolCommon=[], poolUncommon=[], poolRare=[];
let classPool = CLASS_CARDS[G.playerClass];
Object.keys(classPool).forEach(id=>{
let r=classPool[id].rarity;
if(r==='Common') poolCommon.push(id);
else if(r==='Uncommon') poolUncommon.push(id);
else if(r==='Rare') poolRare.push(id);
});
let rareChance = nodeType==='Elite'?0.2 : nodeType==='Boss'?1/3 : 0.1;
let choices=[];
for(let i=0;i<3;i++){
let r=Math.random();
if(i===0 && nodeType==='Boss'){
// guarantee at least one rare among three: force one rare
let rp=poolRare[randInt(0,poolRare.length-1)];
choices.push(rp);
continue;
}
if(r<rareChance) choices.push(poolRare[randInt(0,poolRare.length-1)]);
else if(r<0.6+rareChance) choices.push(poolCommon[randInt(0,poolCommon.length-1)]);
else choices.push(poolUncommon[randInt(0,poolUncommon.length-1)]);
}
return choices;
}
// ── function: usePotion ──
function usePotion(idx){
if(!G.combat) return;
let key=G.potions[idx];
let pot=POTIONS[key];
if(!pot) return;
let p=G.combat.player;
switch(pot.effect){
case 'heal': heal(pot.val); break;
case 'firebomb': G.combat.enemies.forEach(e=>{ if(e.hp>0) applyDamageToEnemy(e,pot.dmg,true); }); break;
case 'dex': p.statuses.Dexterity=(p.statuses.Dexterity||0)+pot.val; break;
case 'energy': p.energy+=pot.val; break;
case 'vulnAll': G.combat.enemies.forEach(e=>{ e.statuses.Vulnerable=(e.statuses.Vulnerable||0)+pot.val; }); break;
case 'bleed': {
let alive=G.combat.enemies.filter(e=>e.hp>0);
if(alive.length){ let e=G.combat.enemies[G.combat.selectedEnemy]||alive[0]; e.statuses.Bleed=(e.statuses.Bleed||0)+pot.val; }
break;
}
case 'imp': { p.hand.push(makeCardInstance('Imp Claw+')); break; }
case 'escape':
if(G.combat.nodeType!=='Boss'){ G.combat=null; G.inCombat=false; showScreen('map'); proceedToNextFloor(); logMsg('Escaped with smoke bomb'); }
break;
}
G.potions.splice(idx,1);
updateCombatUI();
logMsg('Used '+pot.name);
}
// ── main ──
document.querySelectorAll('.class-card').forEach(el=>{
el.addEventListener('click', ()=> initializeRun(el.dataset.class));
});
document.getElementById('btn-endturn').onclick=endPlayerTurn;
document.getElementById('btn-skip-reward').onclick=()=>proceedToNextFloor();
document.getElementById('btn-leave-shop').onclick=()=>proceedToNextFloor();
document.getElementById('btn-shop-remove').onclick=()=>{
if(G.gold<75){ logMsg('Not enough gold'); return; }
openCardPick('Choose card to remove (75g)', G.deck, (c)=>{
if(G.noRemoveCurses && ['Wound','Doubt','Dazed'].includes(c.id)){ logMsg('Curses cannot be removed!'); return; }
G.gold-=75; G.deck=G.deck.filter(x=>x.instanceId!==c.instanceId); closeCardPick(); logMsg('Removed '+c.id); updateTopbar();
});
};
document.getElementById('btn-cardpick-cancel').onclick=closeCardPick;
document.getElementById('btn-viewdeck').onclick=()=>{
const modal=document.getElementById('deck-modal');
const list=document.getElementById('deck-list');
list.innerHTML='';
G.deck.forEach(c=>{
let def=getCardDef(c);
let div=document.createElement('div'); div.className='c-card '+(def?def.type.toLowerCase():'');
div.style.width='140px'; div.style.height='190px';
div.innerHTML=`<div class="c-top"><span class="c-cost">${def?def.cost:''}</span></div><div class="c-name">${cardDisplayName(c)}</div><div class="c-desc">${def?def.desc:''}</div>`;
list.appendChild(div);
});
document.getElementById('deck-count').textContent='('+G.deck.length+')';
modal.classList.add('active');
};
document.getElementById('btn-close-deck').onclick=()=>document.getElementById('deck-modal').classList.remove('active');
document.getElementById('btn-restart').onclick=()=>{ document.getElementById('screen-gameover').classList.remove('active'); showScreen('start'); };
document.querySelectorAll('.rest-btn').forEach(btn=>{
btn.onclick=()=>{
let act=btn.dataset.rest;
if(act==='heal'){ heal(Math.floor(G.maxHp*0.25)); logMsg('Healed 25%'); proceedToNextFloor(); }
else if(act==='upgrade'){ openCardPick('Choose card to upgrade', G.deck.filter(c=>!c.upgraded), (c)=>{ c.upgraded=true; closeCardPick(); logMsg('Upgraded '+c.id); proceedToNextFloor(); }); }
else if(act==='remove'){ openCardPick('Choose card to remove', G.deck, (c)=>{ if(G.noRemoveCurses && ['Wound','Doubt','Dazed'].includes(c.id)){ logMsg('Cannot remove'); return; } G.deck=G.deck.filter(x=>x.instanceId!==c.instanceId); closeCardPick(); logMsg('Removed '+c.id); proceedToNextFloor(); }); }
};
});
window.addEventListener('keydown', (e)=>{
if(e.key==='e' || e.key==='E'){ if(G.inCombat) endPlayerTurn(); }
});
updateTopbar();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"04f43aa1-8671-4232-8627-c4917dae0843","tokensIn":804950,"tokensOut":37573,"tokensTotal":842523,"turns":22,"toolCalls":21,"failedToolCalls":3,"timestamp":"2026-07-17T03:33:16.200Z"} -->