Shadows
Deepseek v4.1 Flash · full

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 116,445 bytes · SHA-256 0f6e59fcfda0
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;800&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root{
--ink:#07060a; --ink2:#0d0a13;
--panel:#150f1e; --panel2:#1d1428; --panel3:#251a33;
--gold:#d8b34a; --gold2:#f0d78c; --golddim:#7a6224;
--blood:#b3242f; --blood2:#e3494f;
--shade:#7b52d6; --shade2:#a98bff;
--ember:#e2622a; --ember2:#ffa05a;
--text:#e9e2d2; --muted:#a093b3; --dim:#6d6180;
--line:#3a2b4f;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%;margin:0;padding:0;overflow:hidden}
body{
background:#050408;color:var(--text);
font-family:'EB Garamond',Georgia,'Times New Roman',serif;
font-size:17px;line-height:1.35;
user-select:none;position:relative;
}
h1,h2,h3,h4,.cin{font-family:'Cinzel',Georgia,serif;letter-spacing:.06em;font-weight:600}
button{font-family:inherit;color:inherit;cursor:pointer}
#fog{position:fixed;inset:-20%;pointer-events:none;z-index:0;
background:
radial-gradient(ellipse 60% 50% at 20% 10%, rgba(90,40,140,.28), transparent 60%),
radial-gradient(ellipse 50% 60% at 85% 80%, rgba(120,20,40,.25), transparent 60%),
radial-gradient(ellipse 70% 50% at 50% 110%, rgba(30,20,60,.5), transparent 60%);
animation:fog 26s ease-in-out infinite alternate;
filter:blur(30px);
}
@keyframes fog{0%{transform:translate3d(0,0,0) scale(1)}100%{transform:translate3d(3%,-2%,0) scale(1.12)}}
#vign{position:fixed;inset:0;pointer-events:none;z-index:50;
box-shadow:inset 0 0 220px 60px rgba(0,0,0,.85);}
.screen{position:absolute;inset:0;display:none;flex-direction:column;z-index:2}
.screen.active{display:flex}
::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-track{background:#0c0812}
::-webkit-scrollbar-thumb{background:#3d2c55;border-radius:6px}
::-webkit-scrollbar-thumb:hover{background:#5b4380}
/* ============ TITLE ============ */
#screen-title{align-items:center;justify-content:center;text-align:center;gap:6px}
#titleWrap{display:flex;flex-direction:column;align-items:center;gap:8px;animation:rise .9s ease both}
@keyframes rise{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
#gameTitle{font-size:clamp(34px,6vw,72px);margin:0;line-height:1;
background:linear-gradient(180deg,#f6e6b4,#d8b34a 55%,#6d4c12);
-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 0 60px rgba(216,179,74,.25);}
#subTitle{color:var(--muted);font-style:italic;letter-spacing:.24em;font-size:13px;text-transform:uppercase;margin-bottom:6px}
.rule{width:340px;height:1px;background:linear-gradient(90deg,transparent,var(--golddim),transparent);margin:6px 0 14px}
#classRow{display:flex;gap:18px;flex-wrap:wrap;justify-content:center;padding:8px}
.classCard{
width:236px;padding:20px 16px 18px;border-radius:14px;cursor:pointer;
background:linear-gradient(180deg,rgba(35,25,52,.95),rgba(16,11,24,.95));
border:1.5px solid var(--line);transition:.22s;position:relative;overflow:hidden;
text-align:center;
}
.classCard:hover{transform:translateY(-7px);border-color:var(--c1);box-shadow:0 14px 40px -12px var(--c1),0 0 0 1px var(--c1) inset}
.classCard .cico{width:76px;height:76px;margin:0 auto 8px;color:var(--c1);filter:drop-shadow(0 0 12px var(--c1))}
.classCard h3{margin:2px 0 4px;font-size:21px;color:var(--c1)}
.classCard .ctag{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
.classCard p{font-size:14.5px;color:#c8bcd6;margin:10px 0 4px;min-height:62px;font-style:italic}
.classCard .clist{font-size:13px;color:var(--dim);border-top:1px solid var(--line);padding-top:8px;margin-top:8px}
.btn{
background:linear-gradient(180deg,#2b1f3d,#170f22);border:1px solid var(--line);
padding:9px 20px;border-radius:8px;letter-spacing:.08em;transition:.16s;
font-family:'Cinzel',serif;font-size:14px;color:#dcd2c0;
}
.btn:hover{background:linear-gradient(180deg,#3b2a53,#221630);border-color:var(--golddim);color:var(--gold2)}
.btn:disabled{opacity:.35;cursor:not-allowed}
.btn.gold{border-color:var(--golddim);color:var(--gold2);background:linear-gradient(180deg,#2e2415,#1a1208)}
.btn.gold:hover{box-shadow:0 0 22px -4px var(--gold)}
.btn.danger{border-color:#5c1f27;color:#f0a0a6}
.btn.sm{padding:5px 12px;font-size:12px}
/* ============ TOPBAR ============ */
#topbar{
display:flex;align-items:center;gap:14px;padding:7px 14px;z-index:20;
background:linear-gradient(180deg,rgba(10,7,16,.97),rgba(10,7,16,.72));
border-bottom:1px solid var(--line);min-height:52px;position:relative;
}
.pill{display:flex;align-items:center;gap:7px;background:rgba(0,0,0,.45);border:1px solid var(--line);border-radius:20px;padding:3px 13px;font-size:15px}
.pill .lbl{color:var(--dim);font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-family:'Cinzel',serif}
.hpmini{width:170px;height:15px;background:#2a0c12;border-radius:8px;position:relative;overflow:hidden;border:1px solid #4a1a22}
.hpmini .fill{position:absolute;inset:0;width:100%;background:linear-gradient(180deg,#e3494f,#8b1b24);transition:width .35s cubic-bezier(.2,.9,.3,1)}
.hpmini .txt{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:11.5px;letter-spacing:.06em;text-shadow:0 1px 2px #000}
.goldico{color:var(--gold2)}
#topbar .spacer{flex:1}
#relicStrip{display:flex;gap:5px;flex-wrap:nowrap;overflow-x:auto;max-width:44vw;padding:2px}
.relic{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;
background:linear-gradient(180deg,#2a2038,#171021);border:1px solid var(--golddim);
color:var(--gold2);cursor:help;transition:.15s;flex:0 0 auto}
.relic svg{width:20px;height:20px}
.relic:hover{box-shadow:0 0 16px -2px var(--gold);transform:translateY(-2px)}
.relic.new{animation:pop .6s ease}
@keyframes pop{0%{transform:scale(.2) rotate(-40deg);opacity:0}70%{transform:scale(1.15)}100%{transform:scale(1)}}
/* ============ MAP ============ */
#screen-map{overflow:hidden}
#mapWrap{flex:1;position:relative;display:grid;place-items:center;padding:8px;min-height:0}
#mapSvg{width:100%;height:100%;max-height:100%;display:block}
.mnode{cursor:default}
.mnode.enabled{cursor:pointer}
.mnode circle.hit{fill:transparent}
.mnode .ring{fill:#150f1e;stroke:#3a2b4f;stroke-width:1.4;transition:.2s}
.mnode.enabled .ring{stroke:var(--gold);animation:nodePulse 1.9s ease-in-out infinite}
@keyframes nodePulse{0%,100%{opacity:1}50%{opacity:.45}}
.mnode.done .ring{fill:#0e0a15;stroke:#2b2138}
.mnode .ico{font-size:19px;text-anchor:middle;dominant-baseline:central;fill:#8d81a3}
.mnode.enabled .ico{fill:#fff}
.mnode.done .ico{fill:#40344f}
.mnode .tag{font-family:'Cinzel',serif;font-size:9.5px;letter-spacing:.1em;text-anchor:middle;fill:#7b6f91;text-transform:uppercase}
.mnode.enabled .tag{fill:var(--gold2)}
.mpath{stroke:#2e2340;stroke-width:2.5;fill:none;stroke-dasharray:5 6}
.mpath.hot{stroke:#6a4d8f;stroke-dasharray:none}
#heroMark{fill:var(--gold2);filter:drop-shadow(0 0 10px var(--gold))}
.tcol{--t:0}
.actBanner{font-family:'Cinzel',serif;text-align:center;letter-spacing:.3em;text-transform:uppercase;color:var(--muted);font-size:12px;padding:5px}
/* ============ MODALS ============ */
#overlay{position:fixed;inset:0;z-index:100;display:none;align-items:center;justify-content:center;
background:radial-gradient(ellipse at center,rgba(8,5,14,.82),rgba(2,1,4,.96));backdrop-filter:blur(3px);padding:18px}
#overlay.on{display:flex;animation:fadeIn .25s ease}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal{max-width:1080px;width:100%;max-height:94vh;overflow-y:auto;overflow-x:hidden;
background:linear-gradient(180deg,rgba(28,20,40,.98),rgba(12,8,20,.99));
border:1px solid var(--line);border-radius:16px;padding:22px 24px;position:relative;
box-shadow:0 30px 90px -20px #000,0 0 0 1px rgba(216,179,74,.09) inset;animation:rise .3s ease both}
.modal h2{margin:0 0 4px;font-size:25px;color:var(--gold2);text-align:center}
.modal .msub{text-align:center;color:var(--muted);font-style:italic;margin-bottom:16px;font-size:14.5px}
.mrow{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin:10px 0}
.mfoot{display:flex;gap:10px;justify-content:center;margin-top:16px;flex-wrap:wrap}
/* ============ CARDS ============ */
.card{
width:150px;height:212px;border-radius:11px;position:relative;flex:0 0 auto;
background:linear-gradient(180deg,#241a33 0%,#1a1226 46%,#120c1c 100%);
border:2px solid #4a3a63;box-shadow:0 6px 16px -6px #000;
transition:transform .16s cubic-bezier(.2,.9,.3,1),box-shadow .16s;
padding:6px;display:flex;flex-direction:column;overflow:hidden;
}
.card:before{content:'';position:absolute;inset:2px;border-radius:8px;pointer-events:none;
background:radial-gradient(ellipse at 50% 0%,rgba(255,255,255,.09),transparent 62%)}
.card .cost{position:absolute;top:4px;left:4px;width:27px;height:27px;border-radius:50%;
display:grid;place-items:center;font-family:'Cinzel',serif;font-weight:800;font-size:14px;
background:radial-gradient(circle at 35% 30%,#4b6cf0,#14235e);border:2px solid #9db6ff;color:#fff;
box-shadow:0 0 12px -2px #5b7cff;z-index:3}
.card .cname{font-family:'Cinzel',serif;font-size:11.4px;text-align:center;letter-spacing:.02em;
padding:3px 6px 3px 33px;margin:0 0 3px;border-radius:5px;font-weight:600;line-height:1.15;min-height:28px;
display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.35)}
.card .art{height:62px;border-radius:6px;display:grid;place-items:center;margin-bottom:4px;
background:radial-gradient(ellipse at 50% 40%,rgba(255,255,255,.11),rgba(0,0,0,.42));border:1px solid rgba(255,255,255,.07)}
.card .art svg{width:44px;height:44px}
.card .ctext{font-size:12.2px;line-height:1.28;text-align:center;color:#ded4c4;flex:1;padding:0 2px}
.card .ctype{font-family:'Cinzel',serif;font-size:8.6px;letter-spacing:.2em;text-align:center;color:#8e82a5;text-transform:uppercase;padding-top:2px;border-top:1px solid rgba(255,255,255,.09);margin-top:3px}
.card .plus{color:#8ff08a}
.card.up .cname{background:linear-gradient(90deg,rgba(60,120,60,.5),rgba(30,60,30,.3))}
.card.attack{border-color:#7a3b39}
.card.skill{border-color:#3d5f7a}
.card.power{border-color:#7a5f2a}
.card.curse,.card.status{border-color:#4e3a63}
.card.rar-common{box-shadow:0 0 0 1px rgba(255,255,255,.06) inset,0 6px 16px -6px #000}
.card.rar-uncommon{border-color:#2f8f8f!important;box-shadow:0 0 16px -6px #37c6c6,0 6px 16px -6px #000}
.card.rar-rare{border-color:#c9a227!important;box-shadow:0 0 20px -5px #d8b34a,0 6px 16px -6px #000}
.card.rar-basic{border-color:#5b5140!important}
.card.rar-curse{border-color:#6d2f6d!important;box-shadow:0 0 16px -6px #a03aa0}
.card.rar-special{border-color:#9a6bd8!important;box-shadow:0 0 20px -5px #9a6bd8}
.card.playable:hover{transform:translateY(-16px) scale(1.06);box-shadow:0 20px 40px -14px #000,0 0 24px -6px var(--gold);z-index:20}
.card.disabled{filter:grayscale(.75) brightness(.6)}
.card.selected{transform:translateY(-20px) scale(1.07);box-shadow:0 0 0 3px var(--gold2),0 20px 40px -12px #000;z-index:21}
.card.sel-target{cursor:crosshair}
.card.mini{width:112px;height:162px;padding:4px}
.card.mini .cname{font-size:10px;min-height:24px}
.card.mini .art{height:44px}.card.mini .art svg{width:32px;height:32px}
.card.mini .ctext{font-size:10.4px}
.card.mini .cost{width:25px;height:25px;font-size:13px}
#handArea{display:flex;justify-content:center;align-items:flex-end;gap:0;flex:0 0 auto;height:266px;padding:36px 0 0;min-height:0}
#handArea .card{margin:0 -8px}
#handArea .card:first-child{margin-left:0}#handArea .card:last-child{margin-right:0}
#handArea .slot{width:150px;margin:0 -8px}
/* ============ COMBAT ============ */
#screen-combat{padding:0;overflow:hidden}
#cbTop{flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:8px 14px;border-bottom:1px solid rgba(74,58,99,.5);background:linear-gradient(180deg,rgba(0,0,0,.5),transparent)}
#cbEnemies{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:clamp(10px,4vw,74px);padding:52px 20px 6px;min-height:170px;position:relative}
.enemy{display:flex;flex-direction:column;align-items:center;gap:5px;position:relative;transition:.15s;cursor:default;width:170px}
.enemy.targetable{cursor:crosshair}
.enemy.targetable:hover .sprite{filter:brightness(1.35) drop-shadow(0 0 16px var(--blood2));transform:translateY(-6px) scale(1.05)}
.enemy.dead{opacity:.18;filter:grayscale(1);pointer-events:none;transform:translateY(14px) scale(.9)}
.enemy.hit .sprite{animation:ehit .34s ease}
@keyframes ehit{0%,100%{transform:none}20%{transform:translateX(-9px) rotate(-3deg)}45%{transform:translateX(7px) rotate(2deg)}70%{transform:translateX(-4px)}}
.sprite{width:clamp(88px,10vw,124px);height:clamp(88px,10vw,124px);color:#c8bcd6;transition:.15s;filter:drop-shadow(0 6px 14px rgba(0,0,0,.7))}
.sprite svg{width:100%;height:100%}
.enemy .ename{font-family:'Cinzel',serif;font-size:12.5px;letter-spacing:.04em;color:#e0d6c4;text-shadow:0 2px 4px #000;text-align:center}
.hpbar{width:150px;height:13px;background:#2a0c12;border:1px solid #5a2028;border-radius:7px;position:relative;overflow:hidden}
.hpbar .fill{position:absolute;inset:0;background:linear-gradient(180deg,#e3494f,#8b1b24);transition:width .3s cubic-bezier(.2,.9,.3,1)}
.hpbar .num{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10.5px;text-shadow:0 1px 3px #000;letter-spacing:.04em}
.blockpill{position:absolute;left:-46px;top:34%;display:flex;align-items:center;gap:3px;
background:rgba(20,30,60,.92);border:1.5px solid #6f8fd8;border-radius:8px;padding:2px 7px;font-size:12.5px;color:#cfe0ff;
box-shadow:0 0 14px -3px #5b7cff}
.blockpill svg{width:13px;height:13px;color:#8fb0ff}
.intent{position:absolute;top:-46px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:2px;
background:rgba(10,6,16,.9);border:1px solid var(--line);border-radius:10px;padding:3px 9px;min-width:52px;z-index:5;
box-shadow:0 4px 14px -4px #000}
.intent .iv{font-family:'Cinzel',serif;font-size:14.5px;font-weight:700;color:#ffd9a0;display:flex;align-items:center;gap:4px}
.intent .ilbl{font-size:9.5px;color:var(--muted);letter-spacing:.06em;text-transform:uppercase;font-family:'Cinzel',serif}
.intent svg{width:17px;height:17px}
.intent.atk{color:#ff8b8b;border-color:#5c2027}
.intent.def{color:#9fc4ff;border-color:#2c3f66}
.intent.buf{color:#c4a4ff;border-color:#463063}
.intent.deb{color:#b8e08a;border-color:#3d4d26}
.intent.sum{color:#ffb056;border-color:#66452c}
.statuses{display:flex;gap:4px;flex-wrap:wrap;justify-content:center;min-height:22px}
.st{display:flex;align-items:center;gap:3px;background:rgba(0,0,0,.55);border:1px solid var(--line);border-radius:6px;padding:1px 6px;font-size:12px;cursor:help}
.st svg{width:13px;height:13px}
.st.buff{border-color:#2f6b3a;color:#a8e6a8}
.st.debuff{border-color:#71302a;color:#f0a48d}
#cbPlayer{display:flex;align-items:center;gap:14px;padding:6px 18px 2px;border-top:1px solid rgba(74,58,99,.35)}
.psprite{width:66px;height:66px;color:#d8cfe6;flex:0 0 auto;filter:drop-shadow(0 0 12px rgba(120,90,200,.5))}
.psprite svg{width:100%;height:100%}
#playerPanel{display:flex;flex-direction:column;gap:4px;flex:1;max-width:420px}
.prow{display:flex;align-items:center;gap:9px}
#cbBottom{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;padding:0 18px 8px}
.pile{display:flex;flex-direction:column;align-items:center;gap:2px;width:74px;cursor:help;color:var(--muted);font-size:11.5px;font-family:'Cinzel',serif;letter-spacing:.06em}
.pile .pbox{width:52px;height:64px;border-radius:8px;background:linear-gradient(180deg,#221a30,#120c1c);border:1px solid var(--line);display:grid;place-items:center;font-size:19px;color:#a293c0;font-family:'Cinzel',serif}
.orb{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;position:relative;
background:radial-gradient(circle at 35% 30%,#4b6cf0,#101a45 70%);border:2px solid #8fa9ff;
box-shadow:0 0 34px -6px #5b7cff,inset 0 0 22px rgba(150,180,255,.4);font-family:'Cinzel',serif;font-weight:800;font-size:23px;color:#fff}
.orb.pulse{animation:orbP .5s ease}
@keyframes orbP{0%{transform:scale(1)}45%{transform:scale(1.16)}100%{transform:scale(1)}}
#endTurn{font-size:15px;padding:12px 26px}
.float{position:fixed;font-family:'Cinzel',serif;font-weight:800;font-size:24px;pointer-events:none;z-index:99;
text-shadow:0 2px 8px #000,0 0 16px currentColor;animation:floatUp 1.05s ease-out forwards}
@keyframes floatUp{0%{opacity:0;transform:translate(-50%,0) scale(.7)}18%{opacity:1;transform:translate(-50%,-18px) scale(1.15)}100%{opacity:0;transform:translate(-50%,-72px) scale(1)}}
.dmg{color:#ff6b6b}.blk{color:#9fc4ff}.heal{color:#7ee08a}.ene{color:#ffd76b}
#toastWrap{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:120;display:flex;flex-direction:column;gap:6px;align-items:center;pointer-events:none}
.toast{background:rgba(12,8,20,.94);border:1px solid var(--golddim);border-radius:20px;padding:5px 16px;font-size:14px;color:var(--gold2);animation:tst 2.4s ease forwards}
@keyframes tst{0%{opacity:0;transform:translateY(-10px)}12%{opacity:1;transform:none}80%{opacity:1}100%{opacity:0}}
/* shop */
.shopGrid{display:flex;gap:16px;flex-wrap:wrap;justify-content:center;align-items:flex-start}
.shelf{background:rgba(0,0,0,.28);border:1px solid var(--line);border-radius:12px;padding:12px;display:flex;flex-wrap:wrap;gap:14px;justify-content:center;align-items:flex-start;max-width:100%}
.shelf h4{width:100%;margin:0 0 2px;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);text-align:center}
.shelf .divider{width:100%}
.priceTag{margin-top:8px;text-align:center;font-family:'Cinzel',serif;color:var(--gold2);font-size:14px}
.priceTag.poor{color:#8b6b6b}
.item{width:150px;display:flex;flex-direction:column;align-items:center;cursor:pointer}
.item:hover{filter:brightness(1.15)}
.item.sold{opacity:.3;pointer-events:none;filter:grayscale(1)}
.potIco{width:64px;height:64px;color:#9be0c0;filter:drop-shadow(0 0 10px rgba(120,255,200,.25))}
.potIco svg{width:100%;height:100%}
.evText{font-size:16.5px;line-height:1.5;color:#d6cde2;max-width:640px;margin:0 auto 18px;text-align:center;font-style:italic}
.evChoice{display:block;width:100%;max-width:620px;margin:8px auto;text-align:left;padding:12px 18px;
background:linear-gradient(90deg,rgba(45,32,66,.7),rgba(20,13,32,.6));border:1px solid var(--line);border-radius:10px;transition:.16s;font-size:15.5px}
.evChoice:hover{border-color:var(--gold);background:linear-gradient(90deg,rgba(70,50,100,.8),rgba(28,18,42,.7));transform:translateX(5px)}
.evChoice small{display:block;color:var(--muted);font-size:13px;margin-top:3px;font-style:italic}
.tagline{font-family:'Cinzel',serif;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--dim);text-align:center;margin:14px 0 8px}
#deckGrid{display:flex;flex-wrap:wrap;gap:11px;justify-content:center;max-height:56vh;overflow-y:auto;padding:6px}
.scroller{max-height:56vh;overflow-y:auto;padding:4px}
.optGrid{display:flex;flex-wrap:wrap;gap:12px;justify-content:center}
.divider{height:1px;background:linear-gradient(90deg,transparent,var(--line),transparent);margin:14px 0}
.center{text-align:center}
.muted{color:var(--muted)}
.gold{color:var(--gold2)}
.small{font-size:13px}
#log{position:absolute;left:10px;bottom:118px;width:220px;max-height:120px;overflow:hidden;font-size:12.5px;color:#8f83a6;pointer-events:none;z-index:4;line-height:1.35}
#log div{opacity:.65;animation:fadeIn .3s}
#log div:last-child{opacity:1;color:#cfc2dd}
#bossIntro{position:fixed;inset:0;z-index:110;display:grid;place-items:center;pointer-events:none}
#bossIntro .bi{text-align:center;animation:biIn 2.4s ease forwards}
@keyframes biIn{0%{opacity:0;transform:scale(1.5)}25%{opacity:1;transform:scale(1)}75%{opacity:1}100%{opacity:0}}
#bossIntro h1{font-size:56px;color:#c0283a;text-shadow:0 0 50px #7a0f1c,0 4px 20px #000;margin:0}
@media (max-width:860px){
.card{width:104px;height:156px}
.card .ctext{font-size:9px;line-height:1.22}
.card .cname{font-size:8.6px;padding:2px 4px 2px 22px;min-height:22px;letter-spacing:0}
.card .ctype{font-size:7px;letter-spacing:.1em}
.card .art{height:34px}
.card .cost{width:21px;height:21px;font-size:10.5px;top:3px;left:3px}
#handArea{height:212px;padding-top:28px}
#handArea .card{margin:0 -18px}
.sprite{width:70px;height:70px}
.enemy{width:96px}.hpbar{width:92px}
.intent{top:-36px}
.psprite{width:44px;height:44px}
.orb{width:54px;height:54px;font-size:16px}
.classCard{width:min(90vw,330px)}
#screen-title{overflow-y:auto;padding:18px 6px;justify-content:flex-start;align-items:center}
#titleWrap{padding:14px 0 30px}
.modal{padding:16px 12px}
.shelf{padding:8px;gap:8px}
.item{width:104px!important}
.potIco{width:46px;height:46px}
#log{display:none}
.mnode .tag{font-size:8px}
#cbTop{padding:6px 8px;gap:6px}
.pill{padding:2px 8px;font-size:12px}
.hpmini{width:110px}
#cbPlayer{padding:4px 8px 0}
#cbBottom{padding:0 8px 6px}
.pile{width:56px}
.pile .pbox{width:42px;height:48px;font-size:15px}
#combatHint{display:none}
.blockpill{left:-34px}
}
</style>
</head>
<body>
<div id="fog"></div>
<div id="vign"></div>
<div id="toastWrap"></div>
<!-- ================= TITLE ================= -->
<div id="screen-title" class="screen active">
<div id="titleWrap">
<div id="gameTitle" class="cin">Shadows of the Abyss</div>
<div id="subTitle">A Cursed Descent · Roguelike Deckbuilder</div>
<div class="rule"></div>
<div class="ctag muted" style="font-size:12px;letter-spacing:.22em;text-transform:uppercase">Choose your cursed archetype</div>
<div id="classRow"></div>
<div class="muted small" style="margin-top:10px;font-style:italic">Click a class to begin your descent. Death is permanent.</div>
</div>
</div>
<!-- ================= MAP ================= -->
<div id="screen-map" class="screen">
<div id="topbar"></div>
<div id="mapWrap"><svg id="mapSvg" viewBox="0 0 460 980" preserveAspectRatio="xMidYMid meet"></svg></div>
</div>
<!-- ================= COMBAT ================= -->
<div id="screen-combat" class="screen">
<div id="cbTop">
<div class="pill"><span class="lbl">Act</span><span id="cbAct">1</span></div>
<div class="pill"><span class="lbl">Floor</span><span id="cbFloor">1</span></div>
<div class="pill"><span class="lbl">Turn</span><span id="cbTurn">1</span></div>
<div style="flex:1"></div>
<div id="cbRelics" style="display:flex;gap:5px"></div>
<div style="width:10px"></div>
<div id="cbPotions" style="display:flex;gap:6px"></div>
</div>
<div id="cbEnemies"></div>
<div id="log"></div>
<div id="cbPlayer">
<div class="psprite" id="playerSprite"></div>
<div id="playerPanel">
<div class="prow">
<div class="psprite" id="playerPortrait" style="width:30px;height:30px"></div>
<div id="playerName" class="cin" style="font-size:14px;color:#e2d6c0"></div>
<div class="blockpill" id="playerBlock" style="position:static;display:none"></div>
</div>
<div class="hpmini" style="width:100%;height:19px"><div class="fill" id="playerHpFill"></div><div class="txt" id="playerHpTxt"></div></div>
<div class="statuses" id="playerStatuses"></div>
</div>
<div style="flex:1"></div>
<div id="combatHint" class="muted small" style="text-align:right;max-width:250px;font-style:italic"></div>
</div>
<div id="handArea"></div>
<div id="cbBottom">
<div class="pile" id="drawPile" title="Draw Pile"><div class="pbox" id="drawCount">0</div>Draw</div>
<div style="flex:1"></div>
<div class="orb" id="energyOrb">3/3</div>
<div style="width:26px"></div>
<button class="btn gold" id="endTurn">End Turn</button>
<div style="flex:1"></div>
<div class="pile" id="discardPile" title="Discard Pile"><div class="pbox" id="discardCount">0</div>Discard</div>
<div class="pile" id="exhaustPile" title="Exhaust Pile"><div class="pbox" id="exhaustCount">0</div>Exhaust</div>
</div>
</div>
<div id="overlay"><div class="modal" id="modal"></div></div>
<div id="bossIntro"></div>
<div id="tooltip" style="position:fixed;z-index:200;display:none;pointer-events:none;max-width:280px"></div>
<script>
/* ============================================================
SHADOWS OF THE ABYSS
============================================================ */
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const rnd=(a,b)=>a+Math.floor(Math.random()*(b-a+1));
const pick=a=>a[Math.floor(Math.random()*a.length)];
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;}
/* ---------------- SVG GLYPHS ---------------- */
const GL={
sword:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 2l3 3-7.2 7.2 1.4 1.4L18 6.4V4l2 2v5.6l-6.9 6.9 1.4 1.4L9 21.5 2.5 15 4 13.5l1.4 1.4L12.3 8 13.5 2z" opacity=".95"/><path d="M5.6 16.6l1.8 1.8-2.5 2.5-1.8-1.8z"/></svg>',
shield:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1.6l8 3v7.2c0 5-3.5 9.2-8 10.6-4.5-1.4-8-5.6-8-10.6V4.6l8-3zm0 2.2L6 6.1v5.7c0 3.9 2.5 7.2 6 8.5 3.5-1.3 6-4.6 6-8.5V6.1l-6-2.3z"/></svg>',
flame:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1.5c3.5 4.2 6.6 7.2 6.6 11.4 0 4-2.9 7.1-6.6 7.1s-6.6-3.1-6.6-7.1c0-2 .8-3.7 2-5.4.3 1.4 1.1 2.4 2.2 2.9-.6-3 .4-6.2 2.4-8.9z"/><path d="M12 20.5c1.9 0 3.4-1.6 3.4-3.6 0-1.6-1-2.9-2-4.2-.4 1-1 1.7-1.9 2.2.2-1.6-.3-3.1-1.1-4.3-.4 1.4-1.4 2.5-1.4 4.2 0 2.2 1.1 3.7 3 5.7z" fill="#000" opacity=".35"/></svg>',
blood:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1.8C12 1.8 5 10 5 14.6 5 18.5 8.1 21.6 12 21.6s7-3.1 7-7c0-4.6-7-12.8-7-12.8z"/><ellipse cx="9.6" cy="14.4" rx="1.9" ry="2.6" fill="#fff" opacity=".28"/></svg>',
eye:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 5C6.6 5 2.3 8.7 1 12c1.3 3.3 5.6 7 11 7s9.7-3.7 11-7c-1.3-3.3-5.6-7-11-7zm0 11.6A4.6 4.6 0 1116.6 12 4.6 4.6 0 0112 16.6z"/><circle cx="12" cy="12" r="2.2" fill="#fff" opacity=".85"/></svg>',
moon:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5 15.6A9 9 0 019.4 3.2 9.6 9.6 0 1020.5 15.6z"/></svg>',
skull:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1.6C7.4 1.6 4 5.2 4 9.8c0 2.7 1.1 4.4 2.2 5.5v3c0 1.6 1.2 2.7 2.8 2.7h6c1.6 0 2.8-1.1 2.8-2.7v-3c1.1-1.1 2.2-2.8 2.2-5.5 0-4.6-3.4-8.2-8-8.2zm-3.4 11a2.4 2.4 0 112.4-2.4 2.4 2.4 0 01-2.4 2.4zm6.8 0a2.4 2.4 0 112.4-2.4 2.4 2.4 0 01-2.4 2.4zM11 15.6h2l-1 2.2z"/></svg>',
heart:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-8.5-5.1-8.5-11A4.9 4.9 0 018.4 5c1.6 0 2.9.8 3.6 2 .7-1.2 2-2 3.6-2a4.9 4.9 0 014.9 5C20.5 15.9 12 21 12 21z"/></svg>',
hourglass:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 2h12v3l-4 7 4 7v3H6v-3l4-7-4-7V2zm2 2v1l4 7-4 7v1h8v-1l-4-7 4-7V4H8z"/></svg>',
coin:'<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9.5"/><circle cx="12" cy="12" r="7" fill="#000" opacity=".35"/><path d="M12 6.5l1.5 3.4 3.6.3-2.7 2.4.8 3.5-3.2-1.9-3.2 1.9.8-3.5-2.7-2.4 3.6-.3z" fill="#fff" opacity=".7"/></svg>',
flask:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 2h6v2h-1v4.2l4.4 8.4A3 3 0 0115.7 21H8.3a3 3 0 01-2.7-4.4L10 8.2V4H9V2z"/><path d="M8.4 14h7.2l1.4 2.7a1.4 1.4 0 01-1.3 2.1H8.3a1.4 1.4 0 01-1.3-2.1z" fill="#fff" opacity=".45"/></svg>',
bolt:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13.5 1L4 14h6l-1.5 9L19 10h-6z"/></svg>',
star:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1.5l3.1 6.6 7.2.9-5.3 5 1.4 7.1L12 17.6 5.6 21l1.4-7.1-5.3-5 7.2-.9z"/></svg>',
chain:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 7h8v2H8a3 3 0 000 6h8v2H8A5 5 0 018 7zm-3 5h2v0h2v2H5zm10 0h4v2h-4z"/></svg>',
drop:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2c3 5 6 8.2 6 12a6 6 0 11-12 0c0-3.8 3-7 6-12z"/></svg>',
book:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 3h7a2 2 0 012 2v15a3 3 0 00-2-1H4V3zm16 0h-7a2 2 0 00-2 2v15a3 3 0 012-1h7V3z" opacity=".95"/></svg>',
fang:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 3l5 9-4 9 6-6 6 6-4-9 5-9-7 4z"/></svg>',
crown:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 6l4 4 6-7 6 7 4-4v13H2V6z"/></svg>',
bone:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.5 3a3 3 0 00-2 5.2L9 12l-4.5 3.8A3 3 0 106.5 21a3 3 0 002.6-4.4L11 15l1.9 1.6A3 3 0 1015.5 21a3 3 0 102-5.2L13 12l4.5-3.8A3 3 0 1015.5 3a3 3 0 00-2.6 4.4L11 9 9.1 7.4A3 3 0 006.5 3z"/></svg>'
};
/* Monster silhouettes (procedural-ish SVG) */
function monster(kind,color,color2){
const c=color||'#b9adc9', c2=color2||'rgba(0,0,0,.55)';
const P={
skull:`<svg viewBox="0 0 100 100"><path d="M50 8C31 8 19 21 19 38c0 11 5 19 11 23v12c0 5 4 9 9 9h22c5 0 9-4 9-9V61c6-4 11-12 11-23C81 21 69 8 50 8z" fill="${c}"/><circle cx="37" cy="40" r="8" fill="${c2}"/><circle cx="63" cy="40" r="8" fill="${c2}"/><path d="M46 55h8l-4 9z" fill="${c2}"/><path d="M28 74v10M36 76v10M44 77v10M56 77v10M64 76v10M72 74v10" stroke="${c2}" stroke-width="2.6" fill="none"/></svg>`,
brute:`<svg viewBox="0 0 100 100"><path d="M50 12c-11 0-18 8-18 18 0 7 3 12 6 15-13 5-21 15-23 30l-3 19h76l-3-19c-2-15-10-25-23-30 3-3 6-8 6-15 0-10-7-18-18-18z" fill="${c}"/><circle cx="43" cy="34" r="3.4" fill="${c2}"/><circle cx="57" cy="34" r="3.4" fill="${c2}"/><path d="M31 74c6 5 13 8 19 8s13-3 19-8" stroke="${c2}" stroke-width="3" fill="none"/><path d="M20 62l12 4-12 6zM80 62l-12 4 12 6z" fill="${c}" opacity=".8"/></svg>`,
hooded:`<svg viewBox="0 0 100 100"><path d="M50 8C34 8 23 22 23 39c0 9 3 16 4 22l-6 33h58l-6-33c1-6 4-13 4-22C77 22 66 8 50 8z" fill="${c}"/><ellipse cx="50" cy="40" rx="11" ry="14" fill="${c2}"/><circle cx="45" cy="38" r="2.6" fill="#ff6b6b"/><circle cx="55" cy="38" r="2.6" fill="#ff6b6b"/><path d="M23 92h54l-4-14H27z" fill="${c}" opacity=".85"/></svg>`,
swarm:`<svg viewBox="0 0 100 100"><g fill="${c}"><ellipse cx="30" cy="66" rx="15" ry="11"/><ellipse cx="70" cy="70" rx="13" ry="10"/><ellipse cx="50" cy="44" rx="18" ry="13"/></g><g fill="${c2}"><circle cx="24" cy="64" r="2.4"/><circle cx="36" cy="68" r="2.4"/><circle cx="64" cy="69" r="2.2"/><circle cx="76" cy="71" r="2.2"/><circle cx="43" cy="42" r="2.8"/><circle cx="57" cy="46" r="2.8"/></g><path d="M50 30l-4-14M50 30l6-13M30 55l-9-9M70 60l10-8" stroke="${c}" stroke-width="3" fill="none"/></svg>`,
demon:`<svg viewBox="0 0 100 100"><path d="M50 14c-12 0-20 9-20 20 0 6 2 11 5 14-12 6-19 16-21 30l-3 18h78l-3-18c-2-14-9-24-21-30 3-3 5-8 5-14 0-11-8-20-20-20z" fill="${c}"/><path d="M31 22c-7-8-11-16-9-19 3-2 12 4 17 12zM69 22c7-8 11-16 9-19-3-2-12 4-17 12z" fill="${c}"/><circle cx="42" cy="34" r="3.4" fill="#ff9b3d"/><circle cx="58" cy="34" r="3.4" fill="#ff9b3d"/><path d="M36 78c4 6 8 9 14 9s10-3 14-9" stroke="${c2}" stroke-width="3" fill="none"/><path d="M40 70l3 7M50 71v8M60 70l-3 7" stroke="#fff" stroke-width="2.6"/></svg>`,
knight:`<svg viewBox="0 0 100 100"><path d="M50 6l22 8v22c0 8-4 14-9 18l9 40H28l9-40c-5-4-9-10-9-18V14z" fill="${c}"/><path d="M35 30h30v6H35zm0 10h30v6H35z" fill="${c2}"/><circle cx="43" cy="27" r="2.6" fill="#ff5a5a"/><circle cx="57" cy="27" r="2.6" fill="#ff5a5a"/><path d="M50 6l-3-5h6z" fill="${c}"/></svg>`,
treant:`<svg viewBox="0 0 100 100"><path d="M50 4c-16 0-28 10-28 24 0 8 4 14 9 18-6 6-9 14-9 24l-2 20h60l-2-20c0-10-3-18-9-24 5-4 9-10 9-18C78 14 66 4 50 4z" fill="${c}"/><circle cx="40" cy="28" r="4" fill="${c2}"/><circle cx="60" cy="28" r="4" fill="${c2}"/><path d="M38 44c4 4 8 6 12 6s8-2 12-6" stroke="${c2}" stroke-width="3.4" fill="none"/><path d="M50 56v30M28 50l-8-8M72 50l8-8" stroke="${c2}" stroke-width="4" fill="none"/></svg>`,
hound:`<svg viewBox="0 0 100 100"><path d="M22 60c-4-10-2-20 6-26 6-5 12-6 20-5l16-14 4 6-8 12h14c8 4 12 12 10 22-2 12-12 20-26 22-16 2-30-4-36-17z" fill="${c}"/><path d="M28 42l-14-8 4-8 12 6zM30 50l-16-2 2-8 14 2z" fill="${c}"/><circle cx="62" cy="46" r="3.6" fill="#ff9b3d"/><path d="M72 62l6 6-8 2z" fill="${c2}"/><path d="M32 70l-6 20M46 74l-2 18M58 74l2 18" stroke="${c}" stroke-width="5" fill="none"/></svg>`,
imp:`<svg viewBox="0 0 100 100"><path d="M50 22c-10 0-17 8-17 17 0 5 2 9 4 11-8 5-13 13-14 24l-2 14h58l-2-14c-1-11-6-19-14-24 2-2 4-6 4-11 0-9-7-17-17-17z" fill="${c}"/><path d="M34 30c-6-7-9-14-7-17 3-2 10 3 14 10zM66 30c6-7 9-14 7-17-3-2-10 3-14 10z" fill="${c}"/><circle cx="43" cy="40" r="3" fill="#ffcf5a"/><circle cx="57" cy="40" r="3" fill="#ffcf5a"/><path d="M40 78c3 4 6 6 10 6s7-2 10-6" stroke="${c2}" stroke-width="2.6" fill="none"/></svg>`
};
return P[kind]||P.hooded;
}
/* ============================================================
CARD REGISTRY
============================================================ */
const CARDS={};
function CD(id,o){o.id=id;CARDS[id]=o;return o;}
function V(c,k){const d=CARDS[c.id];if(!d)return 0;
if(c.up&&d[k+'Up']!=null)return d[k+'Up'];
return d[k]!==undefined?d[k]:0;}
const txt=(c,key)=>V(c,key);
/* ---- BASIC / TOKEN ---- */
CD('strike',{name:'Strike',cls:'basic',type:'attack',cost:1,rarity:'basic',ico:'sword',target:1,
dmg:6,dmgUp:8,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.`,
play:(g,c,t)=>g.dmg(t,V(c,'dmg'))});
CD('defend',{name:'Defend',cls:'basic',type:'skill',cost:1,rarity:'basic',ico:'shield',
blk:5,blkUp:8,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.`,
play:(g,c)=>g.block(V(c,'blk'))});
CD('impclaw',{name:'Imp Claw',cls:'basic',type:'attack',cost:0,rarity:'special',ico:'fang',target:1,exhaust:1,
dmg:4,dmgUp:6,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br><i>Exhaust.</i>`,
play:(g,c,t)=>g.dmg(t,V(c,'dmg'))});
/* ================= BLOOD REAPER ================= */
CD('sanguine',{name:'Sanguine Strike',cls:'blood',type:'attack',cost:1,rarity:'common',ico:'blood',target:1,
dmg:8,dmgUp:10,heal:2,healUp:3,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br>Heal <b>${V(c,'heal')}</b> HP.`,
play:(g,c,t)=>{g.dmg(t,V(c,'dmg'));g.heal(V(c,'heal'));}});
CD('hemorrhage',{name:'Hemorrhage',cls:'blood',type:'attack',cost:1,rarity:'common',ico:'blood',target:1,
dmg:5,dmgUp:6,bleed:3,bleedUp:4,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br>Apply <b>${V(c,'bleed')}</b> Bleed.`,
play:(g,c,t)=>{g.dmg(t,V(c,'dmg'));g.st(t,'bleed',V(c,'bleed'));}});
CD('bloodletting',{name:'Bloodletting',cls:'blood',type:'attack',cost:1,rarity:'uncommon',ico:'drop',target:1,
hp:3,hpUp:2,dmg:12,dmgUp:15,
text:c=>`Lose <b>${V(c,'hp')}</b> HP.<br>Deal <b>${V(c,'dmg')}</b> damage.`,
play:(g,c,t)=>{g.hpLoss(V(c,'hp'),1);g.dmg(t,V(c,'dmg'));}});
CD('crimsoneruption',{name:'Crimson Eruption',cls:'blood',type:'attack',cost:2,rarity:'rare',ico:'blood',
dmg:8,dmgUp:10,hp:2,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage to ALL enemies.<br>Lose <b>${V(c,'hp')}</b> HP.`,
play:(g,c)=>{g.dmgAll(V(c,'dmg'));g.hpLoss(V(c,'hp'),1);}});
CD('crimsonshield',{name:'Crimson Shield',cls:'blood',type:'skill',cost:1,rarity:'common',ico:'shield',
blk:6,blkUp:8,hp:2,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Lose <b>${V(c,'hp')}</b> HP.`,
play:(g,c)=>{g.block(V(c,'blk'));g.hpLoss(V(c,'hp'),1);}});
CD('bloodward',{name:'Blood Ward',cls:'blood',type:'skill',cost:1,rarity:'common',ico:'heart',
blk:3,blkUp:4,regen:1,regenUp:2,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Gain <b>${V(c,'regen')}</b> Regeneration.`,
play:(g,c)=>{g.block(V(c,'blk'));g.self('regen',V(c,'regen'));}});
CD('sacrificial',{name:'Sacrificial Barrier',cls:'blood',type:'skill',cost:2,rarity:'uncommon',ico:'shield',
blk:10,blkUp:13,hp:5,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Lose <b>${V(c,'hp')}</b> HP.`,
play:(g,c)=>{g.block(V(c,'blk'));g.hpLoss(V(c,'hp'),1);}});
CD('vitalsurge',{name:'Vital Surge',cls:'blood',type:'skill',cost:-1,rarity:'rare',ico:'heart',exhaust:1,
bonus:0,bonusUp:3,
text:c=>`Gain Block equal to your missing HP${V(c,'bonus')?' + '+V(c,'bonus'):''}.<br><i>Exhaust.</i>`,
play:(g,c)=>{const miss=g.player.maxHp-g.player.hp;g.block(miss+V(c,'bonus'));g.spentAll=1;}});
CD('lifetap',{name:'Life Tap',cls:'blood',type:'skill',cost:0,rarity:'common',ico:'drop',
draw:2,drawUp:3,hp:3,
text:c=>`Draw <b>${V(c,'draw')}</b> cards.<br>Lose <b>${V(c,'hp')}</b> HP.`,
play:(g,c)=>{g.draw(V(c,'draw'));g.hpLoss(V(c,'hp'),1);}});
CD('rendflesh',{name:'Rend Flesh',cls:'blood',type:'skill',cost:1,rarity:'uncommon',ico:'fang',
bleed:5,bleedUp:7,hp:2,
text:c=>`Apply <b>${V(c,'bleed')}</b> Bleed to ALL enemies.<br>Lose <b>${V(c,'hp')}</b> HP.`,
play:(g,c)=>{g.stAll('bleed',V(c,'bleed'));g.hpLoss(V(c,'hp'),1);}});
CD('soulsiphon',{name:'Soul Siphon',cls:'blood',type:'power',cost:2,costUp:1,rarity:'uncommon',ico:'eye',
text:c=>`At the start of your turn, gain <b>1</b> Energy and lose <b>1</b> HP.`,
play:(g,c)=>g.power({id:'soulsiphon',name:'Soul Siphon',ico:'eye',desc:'Start of turn: +1 Energy, -1 HP',
onStart:(g)=>{g.addEnergy(1);g.hpLoss(1,1);}})});
CD('bloodpact',{name:'Blood Pact',cls:'blood',type:'power',cost:1,rarity:'rare',ico:'heart',
amt:1,amtUp:2,
text:c=>`Whenever you lose HP from a card, gain <b>${V(c,'amt')}</b> Strength.`,
play:(g,c)=>g.power({id:'bloodpact',amt:V(c,'amt'),name:'Blood Pact',ico:'heart',desc:'HP loss from cards grants Strength',
onCardHpLoss:(g,n)=>{g.self('str',g.pw.amt)}})});
/* ================= SHADOWMANCER ================= */
CD('shadowbolt',{name:'Shadow Bolt',cls:'shadow',type:'attack',cost:1,rarity:'common',ico:'bolt',target:1,
dmg:6,dmgUp:8,weak:1,weakUp:2,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br>Apply <b>${V(c,'weak')}</b> Weak.`,
play:(g,c,t)=>{g.dmg(t,V(c,'dmg'));g.st(t,'weak',V(c,'weak'));}});
CD('grasp',{name:'Grasp of Despair',cls:'shadow',type:'attack',cost:1,rarity:'common',ico:'chain',target:1,
dmg:4,dmgUp:5,vuln:1,vulnUp:2,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br>Apply <b>${V(c,'vuln')}</b> Vulnerable.`,
play:(g,c,t)=>{g.dmg(t,V(c,'dmg'));g.st(t,'vuln',V(c,'vuln'));}});
CD('nightmare',{name:'Nightmare Lash',cls:'shadow',type:'attack',cost:2,rarity:'uncommon',ico:'eye',target:1,
dmg:7,dmgUp:9,bonus:7,bonusUp:9,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br>If the enemy has Weak, deal <b>${V(c,'bonus')}</b> more damage.`,
play:(g,c,t)=>{g.dmg(t,V(c,'dmg'));if(t.weak>0){g.dmg(t,V(c,'bonus'));}}});
CD('eternal',{name:'Eternal Darkness',cls:'shadow',type:'attack',cost:2,rarity:'rare',ico:'moon',
dmg:3,dmgUp:4,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage to a random enemy <b>3</b> times.`,
play:(g,c)=>{for(let i=0;i<3;i++){const e=g.randomEnemy();if(!e)break;g.dmg(e,V(c,'dmg'));}}});
CD('cloak',{name:'Cloak of Shadows',cls:'shadow',type:'skill',cost:1,rarity:'common',ico:'shield',
blk:5,blkUp:7,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Draw <b>1</b> card.`,
play:(g,c)=>{g.block(V(c,'blk'));g.draw(1);}});
CD('mist',{name:'Haunting Mist',cls:'shadow',type:'skill',cost:1,rarity:'common',ico:'moon',
blk:7,blkUp:9,weak:1,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Apply <b>1</b> Weak to ALL enemies.`,
play:(g,c)=>{g.block(V(c,'blk'));g.stAll('weak',1);}});
CD('phantom',{name:'Phantom Armor',cls:'shadow',type:'skill',cost:1,rarity:'uncommon',ico:'shield',
blk:8,blkUp:10,dex:2,dexUp:3,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Next turn, gain <b>${V(c,'dex')}</b> Dexterity.`,
play:(g,c)=>{g.block(V(c,'blk'));g.pend.dex+=V(c,'dex');}});
CD('shroud',{name:'Whispering Shroud',cls:'shadow',type:'skill',cost:1,rarity:'rare',ico:'moon',
blk:4,blkUp:5,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Next turn, gain <b>${V(c,'blk')}</b> Block.`,
play:(g,c)=>{g.block(V(c,'blk'));g.pend.block+=V(c,'blk');}});
CD('shadowform',{name:'Shadowform',cls:'shadow',type:'skill',cost:2,rarity:'uncommon',ico:'eye',exhaust:1,
intang:2,intangUp:3,
text:c=>`Gain <b>${V(c,'intang')}</b> Intangible.<br><i>Exhaust.</i>`,
play:(g,c)=>{g.self('intang',V(c,'intang'));}});
CD('misfortune',{name:'Curse of Misfortune',cls:'shadow',type:'skill',cost:1,rarity:'common',ico:'skull',target:1,
str:3,strUp:4,vuln:1,vulnUp:2,
text:c=>`Enemy loses <b>${V(c,'str')}</b> Strength this turn.<br>Apply <b>${V(c,'vuln')}</b> Vulnerable.`,
play:(g,c,t)=>{g.st(t,'strTmp',-V(c,'str'));g.st(t,'vuln',V(c,'vuln'));}});
CD('darkritual',{name:'Dark Ritual',cls:'shadow',type:'skill',cost:0,rarity:'uncommon',ico:'book',
text:c=>`Gain <b>2</b> Energy.<br>Shuffle a Dazed into your ${c.up?'discard pile':'draw pile'}.`,
play:(g,c)=>{g.addEnergy(2);g.addPile(c.up?'discard':'draw','dazed');}});
CD('umbral',{name:'Umbral Mastery',cls:'shadow',type:'power',cost:2,rarity:'rare',ico:'moon',
amt:1,amtUp:2,
text:c=>`At the start of your turn, apply <b>${V(c,'amt')}</b> Weak to ALL enemies.`,
play:(g,c)=>g.power({id:'umbral',amt:V(c,'amt'),name:'Umbral Mastery',ico:'moon',desc:'Start of turn: Weak to all enemies',
onStart:(g)=>{g.stAll('weak',g.pw.amt)}})});
/* ================= HELLFORGED ================= */
CD('firebolt',{name:'Firebolt',cls:'hell',type:'attack',cost:1,rarity:'common',ico:'flame',target:1,
dmg:7,dmgUp:9,burn:2,burnUp:3,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br>Apply <b>${V(c,'burn')}</b> Burn.`,
play:(g,c,t)=>{g.dmg(t,V(c,'dmg'));g.st(t,'burn',V(c,'burn'));}});
CD('hellfireblast',{name:'Hellfire Blast',cls:'hell',type:'attack',cost:2,rarity:'uncommon',ico:'flame',
dmg:9,dmgUp:12,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage to ALL enemies.`,
play:(g,c)=>g.dmgAll(V(c,'dmg'))});
CD('searing',{name:'Searing Strike',cls:'hell',type:'attack',cost:1,rarity:'common',ico:'bolt',target:1,
dmg:6,dmgUp:8,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br>Gain <b>1</b> Energy next turn.`,
play:(g,c,t)=>{g.dmg(t,V(c,'dmg'));g.pend.energy+=1;}});
CD('immolate',{name:'Immolate',cls:'hell',type:'attack',cost:2,rarity:'rare',ico:'flame',target:1,exhaust:1,
dmg:12,dmgUp:16,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.<br><i>Exhaust.</i>`,
play:(g,c,t)=>g.dmg(t,V(c,'dmg'))});
CD('obsidian',{name:'Obsidian Skin',cls:'hell',type:'skill',cost:1,rarity:'common',ico:'shield',
blk:7,blkUp:10,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.`,
play:(g,c)=>g.block(V(c,'blk'))});
CD('flameward',{name:'Flame Ward',cls:'hell',type:'skill',cost:1,rarity:'common',ico:'flame',
blk:5,blkUp:6,burn:2,burnUp:3,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Apply <b>${V(c,'burn')}</b> Burn to a random enemy.`,
play:(g,c)=>{g.block(V(c,'blk'));const e=g.randomEnemy();if(e)g.st(e,'burn',V(c,'burn'));}});
CD('demonicres',{name:'Demonic Resilience',cls:'hell',type:'skill',cost:1,rarity:'uncommon',ico:'heart',
blk:4,blkUp:5,heal:2,healUp:3,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Heal <b>${V(c,'heal')}</b> HP.`,
play:(g,c)=>{g.block(V(c,'blk'));g.heal(V(c,'heal'));}});
CD('brimstone',{name:'Brimstone Shield',cls:'hell',type:'skill',cost:1,rarity:'rare',ico:'shield',
blk:3,blkUp:4,str:2,strUp:3,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.<br>Gain <b>${V(c,'str')}</b> Strength this turn.`,
play:(g,c)=>{g.block(V(c,'blk'));g.self('strTmp',V(c,'str'));}});
CD('summonimp',{name:'Summon Imp',cls:'hell',type:'skill',cost:1,rarity:'common',ico:'fang',exhaust:1,
text:c=>`Add ${c.up?'an <b>Imp Claw+</b>':'an <b>Imp Claw</b>'} to your hand.<br><i>Exhaust.</i>`,
play:(g,c)=>{g.addHand('impclaw',c.up?1:0);}});
CD('infernalpact',{name:'Infernal Pact',cls:'hell',type:'skill',cost:0,rarity:'uncommon',ico:'crown',
hp:2,hpUp:1,
text:c=>`Lose <b>${V(c,'hp')}</b> HP.<br>Gain <b>2</b> Energy.<br>Add ${c.up?'an <b>Imp Claw+</b>':'an <b>Imp Claw</b>'} to your hand.`,
play:(g,c)=>{g.hpLoss(V(c,'hp'),1);g.addEnergy(2);g.addHand('impclaw',c.up?1:0);}});
CD('hellfireaura',{name:'Hellfire Aura',cls:'hell',type:'power',cost:2,rarity:'rare',ico:'flame',
amt:3,amtUp:5,
text:c=>`At the start of your turn, deal <b>${V(c,'amt')}</b> damage to ALL enemies.`,
play:(g,c)=>g.power({id:'hellfireaura',amt:V(c,'amt'),name:'Hellfire Aura',ico:'flame',desc:'Start of turn: damage all enemies',
onStart:(g)=>{g.dmgAll(g.pw.amt)}})});
CD('soulburn',{name:'Soul Burn',cls:'hell',type:'skill',cost:1,rarity:'uncommon',ico:'flame',target:1,
burn:8,burnUp:10,
text:c=>`Apply <b>${V(c,'burn')}</b> Burn to an enemy.<br>Draw <b>1</b> card.`,
play:(g,c,t)=>{g.st(t,'burn',V(c,'burn'));g.draw(1);}});
/* ================= COLORLESS ================= */
CD('flash',{name:'Flash of Insight',cls:'colorless',type:'skill',cost:1,rarity:'common',ico:'star',
draw:2,drawUp:3,
text:c=>`Draw <b>${V(c,'draw')}</b> cards.`,
play:(g,c)=>g.draw(V(c,'draw'))});
CD('ironwill',{name:'Iron Will',cls:'colorless',type:'skill',cost:1,rarity:'common',ico:'shield',
blk:8,blkUp:11,
text:c=>`Gain <b>${V(c,'blk')}</b> Block.`,
play:(g,c)=>g.block(V(c,'blk'))});
CD('quickslash',{name:'Quick Slash',cls:'colorless',type:'attack',cost:1,rarity:'common',ico:'sword',target:1,
dmg:7,dmgUp:9,
text:c=>`Deal <b>${V(c,'dmg')}</b> damage.`,
play:(g,c,t)=>g.dmg(t,V(c,'dmg'))});
CD('potentbrew',{name:'Potent Brew',cls:'colorless',type:'skill',cost:0,rarity:'uncommon',ico:'flask',exhaust:1,
en:1,enUp:2,
text:c=>`Gain <b>${V(c,'en')}</b> Energy.<br><i>Exhaust.</i>`,
play:(g,c)=>g.addEnergy(V(c,'en'))});
/* ================= CURSES / STATUS ================= */
CD('wound',{name:'Wound',cls:'curse',type:'curse',cost:-2,rarity:'curse',ico:'bone',unplayable:1,ethereal:1,exhaust:1,
text:c=>`<i>Unplayable.<br>Ethereal. Exhaust.</i>`,play:()=>{}});
CD('doubt',{name:'Doubt',cls:'curse',type:'curse',cost:-2,rarity:'curse',ico:'chain',unplayable:1,
text:c=>`<i>Unplayable.</i><br>At the end of your turn, if this is in your hand, lose <b>1</b> HP.`,play:()=>{}});
CD('dazed',{name:'Dazed',cls:'curse',type:'status',cost:-2,rarity:'special',ico:'star',unplayable:1,ethereal:1,exhaust:1,
text:c=>`<i>Unplayable.<br>Ethereal. Exhaust.</i>`,play:()=>{}});
const CLASS_POOLS={blood:[],shadow:[],hell:[]};
for(const k in CARDS){const d=CARDS[k];if(CLASS_POOLS[d.cls])CLASS_POOLS[d.cls].push(k);}
const COLORLESS=['flash','ironwill','quickslash','potentbrew'];
const BASICS=['strike','defend'];
/* ============================================================
RELICS / POTIONS / CLASSES
============================================================ */
const RELICS={
bloodstone:{name:'Bloodstone',rar:'common',ico:'blood',desc:'Whenever you lose HP from a card, heal 1 HP.',
onCardHpLoss:g=>g.heal(1)},
demonhorns:{name:'Demon Horns',rar:'uncommon',ico:'fang',cls:'hell',desc:'At the start of each combat, add a random Imp Claw to your hand.',
onCombatStart:g=>g.addHand('impclaw',Math.random()<.5?1:0)},
shadowcloak:{name:'Shadow Cloak',rar:'rare',ico:'moon',desc:'At the start of each combat, gain 1 Intangible.',
onCombatStart:g=>g.self('intang',1)},
cursedamulet:{name:'Cursed Amulet',rar:'common',ico:'chain',desc:'At the start of each combat, apply 1 Weak to ALL enemies.',
onCombatStart:g=>g.stAll('weak',1)},
tome:{name:'Tome of Dark Rituals',rar:'uncommon',ico:'book',desc:'Whenever you play a Power card, draw 1 card.',
onPowerPlayed:g=>g.draw(1)},
bleedingedge:{name:'Bleeding Edge',rar:'common',ico:'drop',desc:'At the start of your turn, apply 1 Bleed to a random enemy.',
onTurnStart:g=>{const e=g.randomEnemy();if(e)g.st(e,'bleed',1);}},
soulember:{name:'Soul Ember',rar:'rare',ico:'flame',desc:'Whenever you apply Burn to an enemy, deal 2 additional damage.',
onBurnApplied:(g,e,n)=>g.dmg(e,2,{truth:1})},
hourglass:{name:'Eternal Hourglass',rar:'uncommon',ico:'hourglass',desc:'At the start of your turn, if you have no cards in hand, gain 1 Energy.',
onTurnStart:g=>{if(g.hand.length===0)g.addEnergy(1);}},
sacdagger:{name:'Sacrificial Dagger',rar:'uncommon',ico:'drop',cls:'blood',desc:'Your first Attack that costs HP each combat costs 1 less HP.',
onCombatStart:g=>{g.flags.dagger=1;}},
vialofshadows:{name:'Vial of Shadows',rar:'rare',ico:'moon',unique:1,desc:'Upon pickup, remove a card from your deck. Then this relic does nothing.'},
ashenarmor:{name:'Ashen Armor',rar:'common',ico:'shield',desc:'At the end of combat, heal 6 HP.',
onCombatEnd:g=>g.heal(6)},
greedyimp:{name:'Greedy Imp',rar:'common',ico:'coin',desc:'Enemies drop 10 extra Gold.',
gold:10}
};
const RELIC_IDS=Object.keys(RELICS);
const POTIONS={
bloodvial:{name:'Blood Vial',ico:'flask',desc:'Heal 15 HP.',combat:1,use:g=>g.heal(15)},
firebomb:{name:'Firebomb',ico:'flame',desc:'Deal 10 damage to ALL enemies.',combat:1,use:g=>g.dmgAll(10)},
dextonic:{name:'Dex Tonic',ico:'shield',desc:'Gain 3 Dexterity for 1 turn.',combat:1,use:g=>{g.self('dexTmp',3);g.player.dexTmpTurns=2;}},
energyelixir:{name:'Energy Elixir',ico:'bolt',desc:'Gain 2 Energy this turn.',combat:1,use:g=>g.addEnergy(2)},
cursedbrew:{name:'Cursed Brew',ico:'skull',desc:'Apply 3 Vulnerable to all enemies.',combat:1,use:g=>g.stAll('vuln',3)},
bleedingflask:{name:'Bleeding Flask',ico:'blood',target:1,desc:'Apply 6 Bleed to an enemy.',combat:1,use:(g,t)=>g.st(t,'bleed',6)},
impbottle:{name:'Imp-in-a-Bottle',ico:'fang',desc:'Add an Imp Claw+ to your hand.',combat:1,use:g=>g.addHand('impclaw',1)},
smokebomb:{name:'Smoke Bomb',ico:'moon',desc:'Escape a non-boss combat, no rewards.',combat:1,use:g=>g.escape()}
};
const POTION_IDS=Object.keys(POTIONS);
const BOSS_RELICS=[
{id:'cursedcrown',name:'Cursed Crown',desc:'Gain 1 Energy at the start of each turn. Curses can no longer be removed.',ico:'crown'},
{id:'ectoplasm',name:'Ectoplasm',desc:'Gain 1 Energy. Lose all Gold and gain no more Gold this run.',ico:'drop'},
{id:'philosophers',name:'Philosopher\u2019s Stone',desc:'Gain 1 Energy. All enemies start combat with 1 Strength.',ico:'star'},
{id:'blackblood',name:'Black Blood',desc:'At the start of combat, lose 10% max HP, but whenever you play an Attack, heal 1 HP.',ico:'heart'},
{id:'shadowamulet',name:'Shadow Amulet',desc:'Start each turn with 1 Intangible. Reduce max HP by 15.',ico:'moon'}
];
const BOSS_RELIC_HOOKS={
cursedcrown:{onTurnStart:g=>g.addEnergy(1),noCurseRemove:1},
ectoplasm:{onTurnStart:g=>g.addEnergy(1),noGold:1},
philosophers:{onTurnStart:g=>g.addEnergy(1),onCombatStart:g=>g.enemies.forEach(e=>g.st(e,'str',1))},
blackblood:{onPlayerAttack:g=>g.heal(1),onCombatStart:g=>{const l=Math.floor(g.player.maxHp*.1);g.hpLossDirect(l)}},
shadowamulet:{onTurnStart:g=>g.self('intang',1)}
};
const CLASSES={
blood:{id:'blood',name:'Blood Reaper',color:'#c0283a',ico:'blood',art:'hooded',
tag:'Sacrifice & Bleed',
blurb:'A butcher of the old covenant. Pays in blood for strength, drinking life back from the wounds of others.',
deck:[['strike',5],['defend',4],['sanguine',1]],
list:'Bleed · Lifesteal · HP costs'},
shadow:{id:'shadow',name:'Shadowmancer',color:'#8b6fe0',ico:'moon',art:'hooded',
tag:'Debuff & Evasion',
blurb:'Weaver of the dark between stars. Weakens and unmakes, slipping damage away into the void.',
deck:[['strike',5],['defend',4],['shadowbolt',1]],
list:'Weak · Vulnerable · Intangible'},
hell:{id:'hell',name:'Hellforged',color:'#e2622a',ico:'flame',art:'demon',
tag:'Burn & Summoning',
blurb:'Armored in infernal brass, chained to a fire that never asked permission. Commands imps and cinders.',
deck:[['strike',5],['defend',4],['firebolt',1]],
list:'Burn · Energy · Imps'}
};
/* ============================================================
BESTIARY
============================================================ */
const ENEMIES={};
function E(id,o){o.id=id;ENEMIES[id]=o;return o;}
/* Move keys: dmg,hits,block,heal,pWeak,pVuln,pBleed,pBurn,pPoison,selfStr,selfStrTmp,selfRegen,selfDex,selfIntang,
selfVuln,summon,maxMinions,altAll,special,name */
/* --- ACT 1 : THE CRYPTS --- */
E('skeleton',{name:'Skeleton Warrior',hp:22,art:'skull',color:'#cfc6b0',
moves:[{dmg:6},{dmg:6},{block:8}]});
E('zombie',{name:'Zombie Brute',hp:28,art:'brute',color:'#8fa07a',
moves:[{dmg:8},{pWeak:2},{dmg:8}]});
E('cultist',{name:'Dark Cultist',hp:20,art:'hooded',color:'#8b6fb0',
moves:[{selfStr:3},{dmg:5},{dmg:5}]});
E('ratswarm',{name:'Rat Swarm',hp:15,art:'swarm',color:'#9a8f7a',
moves:[{dmg:2,hits:3},{dmg:2,hits:3}]});
E('skeleton_minion',{name:'Skeleton Minion',minion:1,hp:15,art:'skull',color:'#b0a890',
moves:[{dmg:5}]});
E('shadowthief',{name:'Shadow Thief',hp:30,art:'hooded',color:'#6a5a90',fromEvent:1,
moves:[{dmg:6},{dmg:6},{block:8}]});
/* --- ACT 2 : BLIGHTED WOODS --- */
E('wraith',{name:'Wraith',hp:30,art:'hooded',color:'#a8c8e8',
moves:[{dmg:7,pWeak:1},{dmg:7},{block:10}]});
E('beast',{name:'Corrupted Beast',hp:38,art:'brute',color:'#8a7a55',
moves:[{dmg:12},{block:5,selfVuln:1},{dmg:12}]});
E('thorned',{name:'Thorned Horror',hp:25,art:'brute',color:'#6f8f4a',
moves:[{dmg:3,hits:4},{pBleed:3},{dmg:8}]});
E('witchdoctor',{name:'Witch Doctor',hp:22,art:'hooded',color:'#b08a4a',
moves:[{buffAlly:1},{dmg:6,pPoison:2},{dmg:6}]});
E('plaguerat',{name:'Plague Rat',minion:1,hp:18,art:'swarm',color:'#8f9a5a',moves:[{dmg:5}]});
/* --- ACT 3 : ABYSSAL SPIRE --- */
E('hellhound',{name:'Hellhound',hp:26,art:'hound',color:'#e2622a',
moves:[{dmg:8,pBurn:2},{dmg:8},{block:9}]});
E('shadowdemon',{name:'Shadow Demon',hp:30,art:'demon',color:'#7b52d6',
moves:[{dmg:7,pWeak:1},{dmg:7},{selfIntang:1}]});
E('infernalimp',{name:'Infernal Imp',hp:18,art:'imp',color:'#e08a3a',
moves:[{dmg:5},{dmg:5},{summon:'infernalimp',maxMinions:3}]});
E('abyssalcultist',{name:'Abyssal Cultist',hp:24,art:'hooded',color:'#6a4a9a',
moves:[{allStr:2},{dmg:7},{dmg:7}]});
E('infernalimp_minion',{name:'Infernal Imp',minion:1,hp:18,art:'imp',color:'#e08a3a',
moves:[{dmg:5},{dmg:5}]});
/* --- ELITES --- */
E('tombguardian',{name:'Tomb Guardian',elite:1,hp:42,art:'knight',color:'#b8a882',
moves:[{dmg:6,block:8,name:'Shield Bash'},{dmg:3,hits:4,name:'Flurry'},{dmg:12,name:'Heavy Slam'}]});
E('bloodwitch',{name:'Blood Witch',elite:1,hp:36,art:'hooded',color:'#c04a5a',
moves:[{dmg:5,heal:4,name:'Drain Life'},{pWeak:1,pVuln:1,name:'Curse'},{dmg:7,heal:4,name:'Blood Ritual'}]});
E('treantp',{name:'Treant Protector',elite:1,hp:50,art:'treant',color:'#5f8a45',
moves:[{dmg:9,block:6,name:'Root Slam'},{pVuln:2,name:'Grasping Vines'},{dmg:10,hits:2,name:'Uproot'}]});
E('plagueshaman',{name:'Plague Shaman',elite:1,hp:42,art:'hooded',color:'#7a8f5a',
moves:[{pPoison:4,name:'Fester'},{summon:'plaguerat',maxMinions:2,name:'Summon Rats'},{dmg:8,name:'Hex'}]});
E('flamespawn',{name:'Flamespawn',elite:1,hp:48,art:'demon',color:'#ff8a3a',
moves:[{dmg:9,pBurn:3,name:'Cinder Claw'},{block:10,selfStr:2,name:'Molten Hide'},{dmg:8,hits:2,name:'Double Rend'}]});
E('dreadknight',{name:'Dread Knight',elite:1,hp:55,art:'knight',color:'#9a9aa8',
moves:[{dmg:12,name:'Cleave'},{pBleed:3,name:'Wicked Cut'},{block:12,heal:4,name:'Dark Mend'}]});
/* --- BOSSES --- */
E('lichking',{name:'Lich King',boss:1,hp:65,art:'skull',color:'#9dd0c8',
moves:[{summon:'skeleton_minion',maxMinions:2,altAll:{dmg:8},name:'Summon'},{dmg:8},{block:12}],
phase2:{at:30,perTurnStr:2,moves:[{dmg:10},{dmg:10},{block:15}]},
title:'Sovereign of the Crypts'});
E('eldritch',{name:'Eldritch Treant',boss:1,hp:75,art:'treant',color:'#8a5fbf',
moves:[{dmg:10,pWeak:2,name:'Blighted Boughs'},{block:15,selfRegen:2,name:'Barkskin'},{dmg:8,hits:2,name:'Sweeping Roots'}],
phase2:{at:40,perTurnStr:1,moves:[{dmg:14,pVuln:1,name:'Ravenous Roots'},{block:20,heal:5,name:'Regrowth'},{dmg:14,pVuln:1,name:'Ravenous Roots'}]},
title:'Warden of the Rotting Wood'});
E('archdemon',{name:'Archdemon',boss:1,hp:85,art:'demon',color:'#c0283a',
moves:[{summon:'hellhound',maxMinions:1,name:'Summon Hellhound'},{dmg:12,pBurn:2,name:'Hellfire'},{block:15,selfStr:3,name:'Infernal Bulwark'}],
phase2:{at:45,perTurnStr:2,moves:[{dmg:16,name:'Cataclysm'},{dmg:7,hits:3,name:'Trident Flurry'},{dmg:16,name:'Cataclysm'}]},
title:'Lord of the Abyss'});
E('hellhound_minion',{name:'Hellhound',minion:1,hp:26,art:'hound',color:'#e2622a',moves:[{dmg:8},{dmg:8}]});
/* ============================================================
ACTS + MAP
============================================================ */
const ACTS=[
{n:1,name:'The Crypts',intro:'Damp stone swallows the torchlight. Something below remembers your name.',
tint:'#7b52d6'},
{n:2,name:'Blighted Woods',intro:'The trees here breathe. Every trunk bears a scar shaped like a mouth.',
tint:'#4a8f5a'},
{n:3,name:'Abyssal Spire',intro:'A tower of bone and brass rises past the clouds. The stairs are warm.',
tint:'#c0283a'}
];
function mkNode(col,type,enc,icon){return{col,type,enc:enc||null,icon:icon||type,cleared:false};}
function makeMap(act){
const F=[];
F.push([mkNode(1,'start',null,'camp')]);
const en=(c,enc)=>mkNode(c,'enemy',enc);
if(act===1){
F.push([en(0,['skeleton']),en(2,['zombie'])]); // 2
F.push([en(0,['skeleton']),en(2,['cultist'])]); // 3
F.push([mkNode(1,'event')]); // 4
F.push([mkNode(1,'shop')]); // 5
F.push([en(0,['ratswarm']),en(2,['zombie'])]); // 6
F.push([mkNode(1,'rest')]); // 7
F.push([en(0,['skeleton','skeleton']),en(2,['zombie'])]); // 8
F.push([mkNode(0,'elite',['tombguardian']),en(2,['skeleton','ratswarm'])]); // 9
F.push([mkNode(1,'event')]); // 10
F.push([en(0,['cultist','ratswarm']),en(2,['zombie'])]); // 11
F.push([mkNode(0,'elite',['bloodwitch']),en(2,['zombie','skeleton'])]); // 12
F.push([en(0,['cultist','cultist']),en(2,['ratswarm','ratswarm'])]); // 13
F.push([mkNode(1,'rest')]); // 14
F.push([mkNode(1,'boss',['lichking'])]); // 15
} else if(act===2){
F.push([en(0,['wraith']),en(2,['beast'])]); // 2
F.push([en(0,['thorned']),en(2,['witchdoctor'])]); // 3
F.push([mkNode(1,'event')]);
F.push([mkNode(1,'shop')]);
F.push([en(0,['wraith']),en(2,['beast'])]);
F.push([mkNode(1,'rest')]);
F.push([en(0,['thorned','thorned']),en(2,['witchdoctor'])]);
F.push([mkNode(0,'elite',['treantp']),en(2,['wraith','thorned'])]);
F.push([mkNode(1,'event')]);
F.push([en(0,['witchdoctor','beast']),en(2,['thorned'])]);
F.push([mkNode(0,'elite',['plagueshaman']),en(2,['beast','wraith'])]);
F.push([en(0,['wraith','wraith']),en(2,['thorned','witchdoctor'])]);
F.push([mkNode(1,'rest')]);
F.push([mkNode(1,'boss',['eldritch'])]);
} else {
F.push([en(0,['hellhound']),en(2,['shadowdemon'])]); // 2
F.push([en(0,['infernalimp','infernalimp']),en(2,['abyssalcultist'])]); // 3
F.push([mkNode(1,'event')]);
F.push([mkNode(1,'shop')]);
F.push([en(0,['hellhound']),en(2,['shadowdemon'])]);
F.push([mkNode(1,'rest')]);
F.push([en(0,['infernalimp','abyssalcultist']),en(2,['hellhound'])]);
F.push([mkNode(0,'elite',['flamespawn']),en(2,['shadowdemon','infernalimp'])]);
F.push([mkNode(1,'event')]);
F.push([en(0,['abyssalcultist','hellhound']),en(2,['shadowdemon'])]);
F.push([mkNode(0,'elite',['dreadknight']),en(2,['hellhound','abyssalcultist'])]);
F.push([en(0,['shadowdemon','shadowdemon']),en(2,['hellhound','infernalimp'])]);
F.push([mkNode(1,'rest')]);
F.push([mkNode(1,'boss',['archdemon'])]);
}
return F;
}
/* ============================================================
GAME STATE
============================================================ */
let UID=1;
function mk(id,up){return{id,up:up?1:0,uid:UID++};}
const G={
cls:null, gold:99, deck:[], relics:[], bossRelics:[], potions:[],
act:1, floor:1, node:null, map:null, combat:null,
nextCombatEnergy:0, noGold:false, noCurseRemove:false, victory:false, dead:false,
player:{},
seenEvents:[]
};
function newPlayer(clsId){
const cl=CLASSES[clsId];
const deck=[];
cl.deck.forEach(([id,n])=>{for(let i=0;i<n;i++)deck.push(mk(id,0));});
G.player={cls:clsId,hp:70,maxHp:70,block:0,str:0,strTmp:0,dex:0,dexTmp:0,dexTmpTurns:0,
regen:0,intang:0,weak:0,vuln:0,bleed:0,burn:0,poison:0,pend:{energy:0,dex:0,block:0},powers:[]};
G.deck=deck;G.gold=99;G.relics=[];G.bossRelics=[];G.potions=[];G.act=1;G.floor=1;
G.nextCombatEnergy=0;G.noGold=false;G.noCurseRemove=false;G.dead=false;G.victory=false;G.seenEvents=[];
}
function deckCard(id,up){return CARDS[id]?{id,up:up?1:0}:null;}
function addCardToDeck(id,up){
if(!CARDS[id])return;
if(id==='doubt'||id==='wound'){/*curse*/}
G.deck.push(mk(id,up?1:0));
}
function removeCardFromDeck(uid){const i=G.deck.findIndex(c=>c.uid===uid);if(i>=0)G.deck.splice(i,1);}
function upgradeCardInDeck(uid){
const c=G.deck.find(c=>c.uid===uid);if(c&&!c.up&&!CARDS[c.id].noUp){c.up=1;return c;}
return null;
}
function hasRelic(id){return G.relics.includes(id)||G.bossRelics.includes(id);}
function capHP(){G.player.hp=clamp(G.player.hp,1,G.player.maxHp);}
function maxHpDown(n){G.player.maxHp=Math.max(1,G.player.maxHp-n);G.player.hp=Math.min(G.player.hp,G.player.maxHp);}
/* ============================================================
COMBAT ENGINE
============================================================ */
function C(){return G.combat;}
function wait(ms){return new Promise(r=>setTimeout(r,ms));}
function newEnemy(id,slotIdx){
const d=ENEMIES[id];
return{def:d,id:id,name:d.name,hp:d.hp,maxHp:d.hp,block:0,str:0,strTmp:0,dex:0,dexTmp:0,regen:0,moves:d.moves,
intang:0,weak:0,vuln:0,bleed:0,burn:0,poison:0,idx:0,alive:true,minion:!!d.minion,
elite:!!d.elite,boss:!!d.boss,enraged:false,uuid:'e'+slotIdx+'x'+Math.floor(Math.random()*9999),
flash:0};
}
function startCombat(enemyIds,kind,opts){
opts=opts||{};
const p=G.player;
p.block=0;p.str=0;p.strTmp=0;p.dex=0;p.dexTmp=0;p.dexTmpTurns=0;p.regen=0;p.intang=0;
p.weak=0;p.vuln=0;p.bleed=0;p.burn=0;p.poison=0;p.pend={energy:0,dex:0,block:0};p.powers=[];
const c={
kind:kind||'normal', enemies:[], drawPile:[], hand:[], discard:[], exhaust:[], powers:[],
turn:1, energy:3, maxEnergy:3, flags:{}, busy:false, over:false, escaped:false,
logs:[], toShow:[], rewards:opts.rewards||null, eventGold:opts.gold||0
};
G.combat=c;
bindCtx(c);
enemyIds.forEach((id,i)=>c.enemies.push(newEnemy(id,i)));
c.drawPile=shuffle(G.deck.map(x=>({id:x.id,up:x.up,ruid:UID++})));
showScreen('combat');
// start-of-combat relic hooks
relicHooks('onCombatStart',c);
if(p.bleed||p.burn){} // no
c.turn=1;
if(G.nextCombatEnergy>0){c.energy+=G.nextCombatEnergy;G.nextCombatEnergy=0;}
if(opts.boss){showBossIntro(c.enemies[0]);}
startPlayerTurn(true);
}
/* ---- ctx methods (passed as `g`) ---- */
function bindCtx(c){
c.pw=null;
c.player=G.player;
c.log=function(m){c.logs.push(m);if(c.logs.length>40)c.logs.shift();renderLog();};
c.pulseEnergy=function(){const o=$('#energyOrb');if(o){o.classList.remove('pulse');void o.offsetWidth;o.classList.add('pulse');}};
c.addEnergy=function(n){c.energy+=n;c.pulseEnergy();render();};
c.block=function(n){const amt=Math.max(0,n+G.player.dex+G.player.dexTmp);G.player.block+=amt;c.log('Gained '+amt+' Block.');render();};
c.heal=function(n){if(n<=0)return;G.player.hp=Math.min(G.player.maxHp,G.player.hp+n);
c.log('Healed '+n+' HP.');floatText($('#playerHpFill'),'+'+n,'heal',true);render();};
c.draw=function(n){drawCards(n);};
c.hpLoss=function(n,fromCard){
const p=G.player;let amt=n;
if(p.intang>0)amt=1;
p.hp-=amt;c.log('Lost '+amt+' HP.');
floatText($('#playerHpFill'),'-'+amt,'dmg',true);
if(fromCard){relicHooks('onCardHpLoss',c);G.player.powers.forEach(pw=>{if(pw.onCardHpLoss){c.pw=pw;pw.onCardHpLoss(c,amt);c.pw=null;}});}
render();checkPlayerDeath();
};
c.hpLossDirect=function(n){const p=G.player;let a=p.intang>0?1:n;p.hp-=a;render();checkPlayerDeath();};
c.self=function(k,n){
const p=G.player;p[k]=(p[k]||0)+n;
render();
};
c.st=function(t,k,n){
if(!t||t.alive===false)return;
t[k]=(t[k]||0)+n;
if(t!==G.player&&k==='burn'&&n>0){relicHooks('onBurnApplied',c,t,n);}
render();
};
c.stAll=function(k,n){c.enemies.forEach(e=>{if(e.alive)c.st(e,k,n);});};
c.randomEnemy=function(){const a=c.enemies.filter(e=>e.alive);return a.length?pick(a):null;};
c.dmg=function(t,base,o){o=o||{};if(!t)return;return hit(t,base,o,o.from||G.player);};
c.dmgAll=function(base,o){o=o||{};c.enemies.forEach(e=>{if(e.alive)hit(e,base,o,o.from||G.player);});};
c.addHand=function(id,up){const inst={id,up:up?1:0,ruid:UID++};c.hand.push(inst);c.log('Added '+CARDS[id].name+(up?'+':'')+' to hand.');render();};
c.addPile=function(pile,id,up){const inst={id,up:up?1:0,ruid:UID++};if(pile==='draw'){c.drawPile.push(inst);shuffle(c.drawPile);}else c.discard.push(inst);render();};
c.power=function(o){o.amt=o.amt||0;c.powers.push(o);G.player.powers=c.powers;relicHooks('onPowerPlayed',c);render();};
c.escape=function(){c.escaped=true;c.over=true;endCombat(false,true);};
return c;
}
function hit(target,base,o,attacker){
const c=C();o=o||{};
let amt=base;
if(!o.raw&&attacker){
amt+= (attacker.str||0)+(attacker.strTmp||0);
if(attacker.weak>0)amt=Math.floor(amt*0.75);
if(target.vuln>0)amt=Math.floor(amt*1.5);
}
amt=Math.max(0,Math.round(amt));
if(target.intang>0)amt=Math.min(amt,1);
const blocked=Math.min(target.block,amt);
target.block-=blocked;
const hp=amt-blocked;
if(hp>0){
target.hp-=hp;
floatText(elFor(target),'-'+hp,'dmg',target===G.player);
if(target!==G.player){target.flash=1;const n=enemyEl(target);if(n){n.classList.remove('hit');void n.offsetWidth;n.classList.add('hit');}}
} else if(blocked>0){
floatText(elFor(target),'Block','blk',target===G.player);
}
if(target===G.player){checkPlayerDeath();}
else if(target.hp<=0){killOrDie(target);}
render();
return {blocked,hp};
}
function elFor(t){return t===G.player?$('#playerHpFill'):enemyEl(t);}
function killOrDie(t){
if(t===G.player){checkPlayerDeath();return;}
t.alive=false;t.hp=0;
C().log(t.name+' is destroyed.');
if(t.def.phase2){/*boss dead*/}
const alive=C().enemies.filter(e=>e.alive);
if(!alive.length&&!C().over){
C().over=true;
setTimeout(()=>endCombat(true,false),760);
}
}
function checkPlayerDeath(){
const p=G.player;
if(p.hp<=0&&!G.dead&&!C().over){
p.hp=0;G.dead=true;C().over=true;
render();
setTimeout(()=>showGameOver(),900);
}
}
function drawCards(n){
const c=C();
for(let i=0;i<n;i++){
if(!c.drawPile.length){
if(c.discard.length){c.drawPile=shuffle(c.discard);c.discard=[];c.log('Discard reshuffled into draw pile.');}
else break;
}
const card=c.drawPile.pop();
c.hand.push(card);
}
render();
}
function relicHooks(evt){
const args=[].slice.call(arguments,1);
G.relics.forEach(id=>{const r=RELICS[id];if(r&&typeof r[evt]==='function')r[evt].apply(null,args);});
G.bossRelics.forEach(id=>{const r=BOSS_RELIC_HOOKS[id];if(r&&typeof r[evt]==='function')r[evt].apply(null,args);});
}
function cardEffectiveCost(inst){
const d=CARDS[inst.id];
let cost=(inst.up&&d.costUp!=null)?d.costUp:d.cost;
if(cost===-1)return C().energy; // X
return cost;
}
function canPlay(inst){
const d=CARDS[inst.id];
if(d.unplayable)return false;
if(C().busy||C().over)return false;
const cost=(inst.up&&d.costUp!=null)?d.costUp:d.cost;
if(cost===-1)return true;
return C().energy>=cost;
}
function playCard(inst,target){
const c=C(),d=CARDS[inst.id];
if(!canPlay(inst))return false;
const idx=c.hand.indexOf(inst);
if(idx<0)return false;
if(d.target&&!target){const a=c.enemies.filter(e=>e.alive);if(a.length===1)target=a[0];}
if(d.target&&!target)return false;
let cost=(inst.up&&d.costUp!=null)?d.costUp:d.cost;
if(cost===-1)cost=c.energy;
c.hand.splice(idx,1);
c.energy=Math.max(0,c.energy-cost);
c.pw=null;
if(d.type==='power'){
d.play(c,inst,target);
} else {
d.play(c,inst,target);
}
c.log('Played '+cardName(inst)+'.');
if(d.type==='attack')relicHooks('onPlayerAttack',c);
// discard / exhaust
if(d.exhaust||inst.exhaust){c.exhaust.push(inst);}
else if(d.type!=='power'){c.discard.push(inst);}
render();
return true;
}
function cardName(inst){return CARDS[inst.id].name+(inst.up?'+':'');}
function endPlayerTurn(){
const c=C();
if(c.busy||c.over)return;
c.busy=true;
const p=G.player;
// Doubt triggers
c.hand.forEach(h=>{if(h.id==='doubt'){p.hp-=1;floatText($('#playerHpFill'),'-1','dmg',true);}});
// discard hand; ethereal exhausts
const hand=c.hand.slice();
c.hand=[];
hand.forEach(h=>{
const d=CARDS[h.id];
if(d.ethereal)c.exhaust.push(h); else c.discard.push(h);
});
p.strTmp=0;
if(p.dexTmpTurns>0){p.dexTmpTurns--;if(p.dexTmpTurns<=0)p.dexTmp=0;}
render();
if(p.hp<=0){checkPlayerDeath();return;}
enemyTurn();
}
async function enemyTurn(){
const c=C(),p=G.player;
c.log('— Enemy turn —');
setPhase('enemy');
await wait(420);
for(const e of c.enemies){
if(c.over||p.hp<=0)break;
if(!e.alive)continue;
e.block=0;
// status ticks at start of enemy turn
tickEntity(e,true);
if(!e.alive)continue;
if(e.def.phase2&&!e.enraged&&e.hp<=e.def.phase2.at){
e.enraged=true;e.moves=e.def.phase2.moves;e.idx=0;
c.log('✦ '+e.name+' ENRAGES!');
toast(e.name+' enrages!');
render();
await wait(650);
}
if(e.enraged&&e.def.phase2.perTurnStr){e.str+=e.def.phase2.perTurnStr;}
const mv=e.moves[e.idx%e.moves.length];
await execMove(e,mv);
e.idx++;
await wait(340);
if(!e.alive)continue;
// end-of-turn effects
if(e.burn>0){const b=e.burn;e.hp-=b;floatText(enemyEl(e),'-'+b,'dmg');e.burn=Math.floor(e.burn/2);c.log(e.name+' burns for '+b+'.');
if(e.hp<=0){killOrDie(e);}await wait(240);}
if(e.alive&&e.regen>0){const r=e.regen;e.hp=Math.min(e.maxHp,e.hp+r);floatText(enemyEl(e),'+'+r,'heal');e.regen--;}
e.strTmp=0;
render();
await wait(200);
}
if(c.over||p.hp<=0)return;
startPlayerTurn(false);
}
function tickEntity(e,isEnemyTurn){
const c=C();
if(e.weak>0)e.weak--;
if(e.vuln>0)e.vuln--;
if(e.intang>0)e.intang--;
if(e.bleed>0){
const b=e.bleed;e.hp-=b;floatText(elFor(e),'-'+b,'dmg',e===G.player);
e.bleed=Math.floor(e.bleed/2);
c.log(e===G.player?('You bleed for '+b+'.'):(e.name+' bleeds for '+b+'.'));
if(e.hp<=0){killOrDie(e);return;}
}
if(e.poison>0){
const b=e.poison;e.hp-=b;floatText(elFor(e),'-'+b,'dmg',e===G.player);e.poison--;
if(e.hp<=0){killOrDie(e);return;}
}
}
async function execMove(e,mv){
const c=C(),p=G.player;
let m=mv;
if(m.summon){
const minions=c.enemies.filter(x=>x.alive&&x.minion).length;
if(minions>= (m.maxMinions||2)){m=Object.assign({},m,m.altAll||{dmg:6});delete m.summon;}
else{
const ne=newEnemy(m.summon,c.enemies.length);
c.enemies.push(ne);
c.log(e.name+' summons '+ne.name+'!');
toast(e.name+' summons '+ne.name);
render();await wait(420);
return;
}
}
if(m.allStr){c.enemies.forEach(x=>{if(x.alive)x.str+=m.allStr;});c.log(e.name+' empowers its allies.');render();await wait(300);return;}
if(m.buffAlly){
const alive=c.enemies.filter(x=>x.alive);
const t=alive.length>1?pick(alive):e;
t.block+=4;t.regen+=2;c.log(e.name+' wards '+(t===e?'itself':t.name)+'.');
floatText(enemyEl(t),'+4 Block','blk');render();await wait(320);return;
}
if(m.block){e.block+=m.block;floatText(enemyEl(e),'+'+m.block,'blk');c.log(e.name+' gains '+m.block+' Block.');}
if(m.selfStr){e.str+=m.selfStr;floatText(enemyEl(e),'+'+m.selfStr+' Str','ene');}
if(m.selfRegen){e.regen+=m.selfRegen;floatText(enemyEl(e),'+'+m.selfRegen+' Regen','heal');}
if(m.selfDex){e.dex+=m.selfDex;}
if(m.selfIntang){e.intang+=m.selfIntang;floatText(enemyEl(e),'Intangible','ene');}
if(m.selfVuln){e.vuln+=m.selfVuln;floatText(enemyEl(e),'Vulnerable','dmg');}
if(m.heal){e.hp=Math.min(e.maxHp,e.hp+m.heal);floatText(enemyEl(e),'+'+m.heal,'heal');}
if(m.pWeak){p.weak+=m.pWeak;}
if(m.pVuln){p.vuln+=m.pVuln;}
if(m.pBleed){p.bleed+=m.pBleed;}
if(m.pBurn){p.burn+=m.pBurn;}
if(m.pPoison){p.poison+=m.pPoison;}
if(m.pWeak||m.pVuln||m.pBleed||m.pBurn||m.pPoison){floatText($('#playerHpFill'),'Cursed!','ene',true);}
render();
if(m.dmg){
const hits=m.hits||1;
for(let i=0;i<hits;i++){
if(c.over)break;
hit(p,m.dmg,{},e);
await wait(hits>1?230:300);
}
c.log(e.name+' attacks for '+m.dmg+(hits>1?('×'+hits):'')+'.');
}
render();
await wait(220);
}
function startPlayerTurn(first){
const c=C(),p=G.player;
if(c.over)return;
if(!first)c.turn++;
c.pw=null;
p.block=0;
if(p.bleed>0||p.poison>0||p.intang>0||p.weak>0||p.vuln>0)tickEntity(p,false);
if(p.hp<=0){checkPlayerDeath();return;}
// pending resources
c.energy=c.maxEnergy+p.pend.energy;
p.pend.energy=0;
p.block+=p.pend.block;p.pend.block=0;
if(p.pend.dex){p.dex+=p.pend.dex;p.pend.dex=0;}
// relic + power start of turn
relicHooks('onTurnStart',c);
c.powers.forEach(pw=>{c.pw=pw;if(pw.onStart)pw.onStart(c);c.pw=null;});
setPhase('player');
drawCards(5);
render();
}
function setPhase(ph){C().phase=ph;render();}
function endCombat(won,escaped){
const c=C();
c.busy=true;
if(won)relicHooks('onCombatEnd',c);
setTimeout(()=>{
if(escaped){G.combat=null;showMap();toast('You vanish in smoke.');return;}
if(won)showRewards();
else showGameOver();
},340);
}
/* ============================================================
RENDER HELPERS
============================================================ */
const STATUS_META={
str:{n:'Strength',ico:'sword',buff:1,d:'Increases Attack damage dealt by X.'},
strTmp:{n:'Strength (this turn)',ico:'sword',buff:1,d:'Temporary Strength. Expires at the end of this turn.'},
dex:{n:'Dexterity',ico:'shield',buff:1,d:'Increases Block gained from cards by X.'},
dexTmp:{n:'Dexterity (temp)',ico:'shield',buff:1,d:'Temporary Dexterity.'},
regen:{n:'Regeneration',ico:'heart',buff:1,d:'Heal X HP at the end of turn, then reduce by 1.'},
intang:{n:'Intangible',ico:'moon',buff:1,d:'All damage and HP loss reduced to 1.'},
weak:{n:'Weak',ico:'chain',debuff:1,d:'Deals 25% less Attack damage.'},
vuln:{n:'Vulnerable',ico:'bolt',debuff:1,d:'Takes 50% more damage from Attacks.'},
bleed:{n:'Bleed',ico:'blood',debuff:1,d:'At start of turn, lose HP equal to stacks, then halve.'},
burn:{n:'Burn',ico:'flame',debuff:1,d:'At end of turn, lose HP equal to stacks, then halve.'},
poison:{n:'Poison',ico:'drop',debuff:1,d:'At start of turn, lose HP equal to stacks, then reduce by 1.'}
};
function statusChips(e){
let out='';
const order=['str','strTmp','dex','dexTmp','regen','intang','weak','vuln','bleed','burn','poison'];
order.forEach(k=>{
let v=e[k]||0;
if(!v)return;
const m=STATUS_META[k];
out+=`<span class="st ${m.buff?'buff':'debuff'}" title="${m.n}: ${m.d}">${GL[m.ico]}<b>${v}</b></span>`;
});
return out;
}
function floatText(el,txt,cls,center){
if(!el)return;
const r=el.getBoundingClientRect();
const d=document.createElement('div');
d.className='float '+cls;
d.textContent=txt;
d.style.left=(r.left+r.width/2)+'px';
d.style.top=(r.top+(center?r.height/2-20:6))+'px';
document.body.appendChild(d);
setTimeout(()=>d.remove(),1100);
}
function toast(msg,ms){
const w=$('#toastWrap');
const d=document.createElement('div');d.className='toast';d.textContent=msg;
w.appendChild(d);setTimeout(()=>d.remove(),ms||2450);
}
function renderLog(){
const c=C();if(!c)return;
const el=$('#log');
el.innerHTML=c.logs.slice(-5).map(l=>`<div>${l}</div>`).join('');
}
function cardEl(inst,opts){
opts=opts||{};
const d=CARDS[inst.id];
const el=document.createElement('div');
let rar=d.rarity;
el.className='card '+d.type+' rar-'+rar+(inst.up?' up':'');
if(opts.mini)el.classList.add('mini');
let costText;
const cost=(inst.up&&d.costUp!=null)?d.costUp:d.cost;
if(cost===-2)costText='✕';
else if(cost===-1)costText='X';
else costText=cost;
el.innerHTML=
(cost!==-2||!d.unplayable?`<div class="cost">${costText}</div>`:``)+
`<div class="cname">${d.name}${inst.up?'<span class="plus">+</span>':''}</div>`+
`<div class="art">${GL[d.ico]||GL.star}</div>`+
`<div class="ctext">${d.text(inst)}</div>`+
`<div class="ctype">${d.type==='curse'?'Curse':d.type==='status'?'Status':d.type}</div>`;
if(inst.up)el.querySelector('.art').style.color='#9be08a';
return el;
}
function potionEl(pid,opts){
opts=opts||{};
const p=POTIONS[pid];
const el=document.createElement('div');
el.className='relic';el.title=p.name+' — '+p.desc;
el.innerHTML=GL[p.ico];
el.style.color='#9be0c0';
if(opts.big){el.style.width='64px';el.style.height='64px';el.classList.add('potIco');el.style.border='none';el.style.background='none';}
return el;
}
function relicEl(rid,opts){
opts=opts||{};
const r=RELICS[rid]||BOSS_RELICS.find(b=>b.id===rid);
const el=document.createElement('div');
el.className='relic'+(opts.big?'':'');
el.title=r.name+' — '+r.desc;
el.innerHTML=GL[r.ico]||GL.star;
if(opts.big){el.style.width='46px';el.style.height='46px';}
return el;
}
function bRelicInfo(id){return BOSS_RELICS.find(b=>b.id===id);}
/* ============================================================
TOPBAR / MAP
============================================================ */
const NODE_ICON={enemy:GL.sword,elite:GL.skull,rest:GL.flame,shop:GL.coin,event:GL.book,boss:GL.crown,start:GL.star};
const NODE_LABEL={enemy:'Enemy',elite:'Elite',rest:'Rest',shop:'Shop',event:'Event',boss:'Boss',start:'Start'};
function nodeColor(t){
return t==='enemy'?'#c07a6a':t==='elite'?'#d8b34a':t==='rest'?'#7fd0a0':t==='shop'?'#e0c060':
t==='event'?'#a08ce0':t==='boss'?'#e04040':'#8d81a3';
}
function renderTopbar(){
const p=G.player;
const hpPct=Math.max(0,p.hp/p.maxHp*100);
$('#topbar').innerHTML=
`<div class="pill"><span class="lbl">HP</span><div class="hpmini"><div class="fill" style="width:${hpPct}%"></div><div class="txt">${p.hp} / ${p.maxHp}</div></div></div>`+
`<div class="pill goldico">${GL.coin}<span style="color:#f0d78c">${G.gold}</span> <span class="lbl">Gold</span></div>`+
`<div class="pill"><span class="lbl">Act</span>${G.act} <span class="muted small">${ACTS[G.act-1].name}</span></div>`+
`<div class="spacer"></div>`+
`<div id="relicStrip">${relicStripHTML()}</div>`+
`<button class="btn sm" onclick="showDeck()">Deck (${G.deck.length})</button>`+
`<div style="display:flex;gap:5px">${potionsHTML()}</div>`;
}
function relicStripHTML(){
return G.relics.map(id=>`<span class="relic" title="${RELICS[id].name} — ${RELICS[id].desc}">${GL[RELICS[id].ico]||GL.star}</span>`).join('')+
G.bossRelics.map(id=>{const b=bRelicInfo(id);return `<span class="relic" style="border-color:#c0283a;color:#ff8b8b" title="${b.name} — ${b.desc}">${GL[b.ico]||GL.crown}</span>`;}).join('');
}
function potionsHTML(){
return G.potions.map((pid,i)=>`<span class="relic" style="color:#9be0c0" title="${POTIONS[pid].name} — ${POTIONS[pid].desc}">${GL[POTIONS[pid].ico]}</span>`).join('')+`<span style="color:#4a3d5e;font-size:11px;align-self:center">${G.potions.length}/3</span>`;
}
function showScreen(n){
G.screen=n;
['title','map','combat'].forEach(s=>$('#screen-'+s).classList.toggle('active',s===n));
}
function renderMap(){
const p=G.player;
renderTopbar();
const svg=$('#mapSvg');
const F=G.map;
const narrow=window.innerWidth<820;
const VBW=narrow?420:980,VBH=narrow?620:560;
const xOf=c=>narrow?(90+c*120):(140+c*350), yOf=f=>(narrow?1250:1400)-f*78;
let vbY=yOf(G.cur.f)-0.30*VBH;
vbY=clamp(vbY, yOf(14)-50, yOf(0)+50-VBH);
svg.setAttribute('viewBox',`0 ${vbY} ${VBW} ${VBH}`);
let s='';
for(let i=0;i<F.length-1;i++){
F[i].forEach(n=>{
(n.children||[]).forEach(m=>{
const hot=(G.cur.f===i&&G.cur.c===n.col);
s+=`<line class="mpath${hot?' hot':''}" x1="${xOf(n.col)}" y1="${yOf(i)}" x2="${xOf(m.col)}" y2="${yOf(i+1)}"/>`;
});
});
}
const opts=currentOptions();
F.forEach((floor,fi)=>{
floor.forEach(n=>{
const isOpt=opts.includes(n);
const isCur=(G.cur.f===fi&&G.cur.c===n.col);
const done=fi<G.cur.f||(n.cleared&&!isCur);
const r=n.type==='boss'?20:15;
const col=isOpt?nodeColor(n.type):(done?'#4a3d5e':'#6a5c80');
let cls='mnode'+(isOpt?' enabled':'')+(done?' done':'');
const X=xOf(n.col),Y=yOf(fi);
s+=`<g class="${cls}" data-f="${fi}" data-c="${n.col}" style="cursor:${isOpt?'pointer':'default'}">`;
if(isOpt)s+=`<circle cx="${X}" cy="${Y}" r="${r+9}" fill="${nodeColor(n.type)}" opacity=".15"/>`;
s+=`<circle class="ring" cx="${X}" cy="${Y}" r="${r}" ${isOpt?`style="stroke:${nodeColor(n.type)}"`:''}/>`;
if(n.type==='boss'||n.type==='elite')
s+=`<circle cx="${X}" cy="${Y}" r="${r+6}" fill="none" stroke="${isOpt?nodeColor(n.type):'#3a2b4f'}" stroke-width="1" stroke-dasharray="3 3"/>`;
s+=`<g transform="translate(${X-11},${Y-11})" style="color:${col}"><g fill="${col}">${NODE_ICON[n.type]}</g></g>`;
s+=`</g>`;
s+=`<text class="tag" x="${X}" y="${Y+r+15}" fill="${isOpt?nodeColor(n.type):'#5d5170'}">${NODE_LABEL[n.type]}</text>`;
});
});
const hx=xOf(G.cur.c),hy=yOf(G.cur.f);
s+=`<circle id="heroMark" cx="${hx}" cy="${hy}" r="6"><animate attributeName="r" values="5;8;5" dur="1.8s" repeatCount="indefinite"/></circle>`;
svg.innerHTML=s;
svg.querySelectorAll('.mnode svg').forEach(el=>{el.setAttribute('width','22');el.setAttribute('height','22');});
svg.querySelectorAll('.mnode').forEach(g=>{
g.addEventListener('click',()=>{
const f=+g.dataset.f,c=+g.dataset.c;
const target=G.map[f].find(n=>n.col===c);
if(target&&opts.includes(target))travel(target,f,c);
});
});
}
function currentOptions(){
const F=G.map;
if(G.cur.f>=F.length-1)return[];
const n=F[G.cur.f].find(x=>x.col===G.cur.c);
return n&&n.children?n.children:[];
}
function showMap(){
showScreen('map');
closeModal();
renderMap();
}
function linkMap(F){
for(let i=0;i<F.length-1;i++){
F[i].forEach(n=>{
n.children=[];
F[i+1].forEach(m=>{if(Math.abs(m.col-n.col)<=1)n.children.push(m);});
if(!n.children.length){
let best=null,bd=9;
F[i+1].forEach(m=>{const dd=Math.abs(m.col-n.col);if(dd<bd){bd=dd;best=m;}});
if(best)n.children.push(best);
}
});
}
F[F.length-1].forEach(n=>n.children=[]);
}
function travel(node,fi,ci){
node.cleared=true;
G.cur={f:fi,c:ci};
G.floor=fi+1;
const t=node.type;
if(t==='enemy')startCombat(node.enc,'normal');
else if(t==='elite')startCombat(node.enc,'elite');
else if(t==='boss')startCombat(node.enc,'boss',{boss:true});
else if(t==='rest')showRest();
else if(t==='shop')showShop();
else if(t==='event')showEvent();
else showMap();
}
/* ============================================================
COMBAT UI
============================================================ */
function enemyEl(e){return document.querySelector(`.enemy[data-uuid="${e.uuid}"]`);}
function renderAll(){
if(G.screen==='combat')renderCombat();
else if(G.screen==='map')showMap();
}
function render(){
if(G.screen==='combat')renderCombat();
renderLog();
}
function intentParts(e){
let mv=e.moves[e.idx%e.moves.length];
const parts=[];
if(mv.summon){
const minions=G.combat.enemies.filter(x=>x.alive&&x.minion).length;
if(minions>=(mv.maxMinions||2)&&mv.altAll)mv=Object.assign({},mv,mv.altAll);
}
const p=G.player;
if(mv.dmg){
let d=mv.dmg+(e.str||0)+(e.strTmp||0);
if(e.weak>0)d=Math.floor(d*0.75);
if(p.vuln>0)d=Math.floor(d*1.5);
parts.push({c:'atk',ico:GL.sword,val:d+(mv.hits>1?'×'+mv.hits:'')});
if(mv.hits>1)parts[parts.length-1].val=d+'<span style="font-size:11px">×'+mv.hits+'</span>';
}
if(mv.block)parts.push({c:'def',ico:GL.shield,val:mv.block});
if(mv.summon)parts.push({c:'sum',ico:GL.crown,val:'Summon'});
if(mv.selfStr||mv.selfRegen||mv.selfDex||mv.selfIntang||mv.allStr||mv.buffAlly)
parts.push({c:'buf',ico:GL.star,val:'Buff'});
if(mv.pWeak||mv.pVuln||mv.pBleed||mv.pBurn||mv.pPoison||mv.selfVuln)
parts.push({c:'deb',ico:GL.chain,val:'Debuff'});
if(!parts.length)parts.push({c:'buf',ico:GL.star,val:'?'});
return parts;
}
function renderCombat(){
const c=C();if(!c)return;
$('#cbAct').textContent=G.act;
$('#cbFloor').textContent=G.floor;
$('#cbTurn').textContent=c.turn;
$('#cbRelics').innerHTML=G.relics.map(id=>`<span class="relic" title="${RELICS[id].name} — ${RELICS[id].desc}">${GL[RELICS[id].ico]||GL.star}</span>`).join('')+
G.bossRelics.map(id=>{const b=bRelicInfo(id);return `<span class="relic" style="border-color:#c0283a;color:#ff8b8b" title="${b.name} — ${b.desc}">${GL[b.ico]||GL.crown}</span>`;}).join('');
$('#cbPotions').innerHTML=G.potions.map((pid,i)=>{
const p=POTIONS[pid];
return `<span class="relic" style="color:#9be0c0;cursor:${c.phase==='player'&&!c.busy?'pointer':'default'}" data-pot="${i}" title="${p.name} — ${p.desc}">${GL[p.ico]}</span>`;
}).join('')+`<span style="color:#4a3d5e;font-size:11px;align-self:center">${G.potions.length}/3</span>`;
// enemies
const cont=$('#cbEnemies');
cont.innerHTML=c.enemies.map((e,i)=>{
const alive=e.alive;
const pct=Math.max(0,e.hp/e.maxHp*100);
const parts=alive?intentParts(e):[];
const targeting=!!G.sel&&G.selNeedsTarget&&alive;
return `<div class="enemy${alive?'':' dead'}${targeting?' targetable':''}" data-uuid="${e.uuid}">
${alive?`<div class="intent ${parts[0]?parts[0].c:''}">${parts.map(p=>`<div class="iv ${p.c}">${p.ico}<span>${p.val}</span></div>`).join('')}</div>`:''}
<div class="sprite" style="color:${e.def.color}">${monster(e.def.art,e.def.color)}</div>
<div class="ename">${e.name}${e.elite?' <span style="color:#d8b34a">◆</span>':''}${e.boss?' <span style="color:#e04040">✦</span>':''}</div>
<div class="hpbar"><div class="fill" style="width:${pct}%"></div><div class="num">${Math.max(0,e.hp)} / ${e.maxHp}</div></div>
${e.block>0?`<div class="blockpill">${GL.shield}${e.block}</div>`:''}
<div class="statuses" style="margin-top:4px">${statusChips(e)}</div>
</div>`;
}).join('');
// player
const p=G.player;
$('#playerSprite').innerHTML=monster(CLASSES[p.cls].art,CLASSES[p.cls].color);
$('#playerPortrait').innerHTML=GL[CLASSES[p.cls].ico];
$('#playerPortrait').style.color=CLASSES[p.cls].color;
$('#playerName').textContent=CLASSES[p.cls].name;
const ppct=Math.max(0,p.hp/p.maxHp*100);
$('#playerHpFill').style.width=ppct+'%';
$('#playerHpTxt').textContent=p.hp+' / '+p.maxHp;
$('#playerBlock').style.display=p.block>0?'flex':'none';
$('#playerBlock').innerHTML=GL.shield+p.block;
$('#playerStatuses').innerHTML=statusChips(p)+(c.powers.map(pw=>`<span class="st buff" title="${pw.desc}">${GL[pw.ico]||GL.star}<b>${pw.name}</b></span>`).join(''));
// hand
const h=$('#handArea');
h.innerHTML='';
c.hand.forEach(inst=>{
const ok=canPlay(inst)&&c.phase==='player'&&!c.busy;
const el=cardEl(inst,{});
el.dataset.ruid=inst.ruid;
if(!ok)el.classList.add('disabled');else el.classList.add('playable');
if(G.sel===inst)el.classList.add('selected');
h.appendChild(el);
});
$('#drawCount').textContent=c.drawPile.length;
$('#discardCount').textContent=c.discard.length;
$('#exhaustCount').textContent=c.exhaust.length;
$('#energyOrb').textContent=c.energy+'/'+c.maxEnergy;
$('#endTurn').disabled=!!c.busy||c.over;
$('#endTurn').style.opacity=(c.busy||c.over)?.4:1;
$('#combatHint').innerHTML=c.phase==='player'
? (c.busy?'Enemies act...':'Choose a card. Attacks need a target — click card, then enemy.')
: 'Enemy turn...';
renderLog();
}
function showBossIntro(e){
const el=$('#bossIntro');
el.innerHTML=`<div class="bi"><div class="cin" style="letter-spacing:.4em;color:#c0283a;font-size:14px">${e.def.title||'BOSS'}</div><h1 class="cin">${e.name}</h1></div>`;
setTimeout(()=>{el.innerHTML='';},2400);
}
/* ============================================================
MODAL SYSTEM
============================================================ */
function modal(html){const m=$('#modal');m.style.maxWidth='';m.innerHTML=html;$('#overlay').classList.add('on');m.scrollTop=0;}
function closeModal(){$('#overlay').classList.remove('on');}
function canUpgrade(c){const d=CARDS[c.id];if(!d||d.unplayable)return false;return Object.keys(d).some(k=>k.endsWith('Up'));}
function rollRarity(boost){const r=Math.random()*100;return boost?(r<60?'common':r<80?'uncommon':'rare'):(r<60?'common':r<90?'uncommon':'rare');}
function pickCards(cls,n,forceRare,boost){
const pool=CLASS_POOLS[cls],out=[];
for(let i=0;i<n;i++){
const rar=(forceRare&&i===0)?'rare':rollRarity(boost);
let cands=pool.filter(id=>CARDS[id].rarity===rar&&!out.includes(id));
if(!cands.length)cands=pool.filter(id=>!out.includes(id));
if(!cands.length)cands=pool;
out.push(pick(cands));
}
return out;
}
function randomRelic(rar){
let pool=RELIC_IDS.filter(id=>{const r=RELICS[id];
if(r.cls&&r.cls!==G.player.cls)return false;
if(rar&&r.rar!==rar)return false;
if(r.unique&&hasRelic(id))return false;
return true;});
if(!pool.length)pool=RELIC_IDS.filter(id=>!RELICS[id].cls||RELICS[id].cls===G.player.cls);
return pick(pool);
}
function randomPotion(){
const pid=pick(POTION_IDS);
if(G.potions.length>=3){return null;}
return pid;
}
function grantPotion(pid){if(!pid||G.potions.length>=3)return false;G.potions.push(pid);return true;}
function grantRelic(id){
if(!id)return;
G.relics.push(id);
toast('Obtained relic: '+RELICS[id].name);
renderTopbar();
if(id==='vialofshadows'){
setTimeout(()=>{
chooseCard('Vial of Shadows','Choose a card to remove from your deck permanently.',c=>true,()=>{});
},450);
}
}
/* deck picker */
let pickCB=null,pickFilter=null;
function chooseCard(title,sub,filter,cb){
pickCB=cb;pickFilter=filter||(()=>true);
const list=G.deck.filter(pickFilter);
modal(`<h2>${title}</h2><div class="msub">${sub}</div>
<div id="deckGrid">${list.map(c=>`<div class="optWrap" data-uid="${c.uid}">${cardOuter(c)}</div>`).join('')}</div>`);
$('#deckGrid').onclick=ev=>{
const w=ev.target.closest('.optWrap');if(!w)return;
const c=G.deck.find(x=>x.uid===+w.dataset.uid);
if(c){const f=pickCB;pickCB=null;closeModal();if(f)f(c);}
};
}
function cardOuter(c,mini){return cardEl(c,{mini:!!mini}).outerHTML;}
/* ============================================================
COMBAT REWARDS
============================================================ */
function showRewards(){
const c=G.combat;if(!c)return;
const kind=c.kind;
const boost=kind==='elite'?1:0;
let goldBase=kind==='elite'?rnd(25,35):kind==='boss'?50:rnd(10,20);
if(hasRelic('greedyimp'))goldBase+=10;
if(G.noGold)goldBase=0;
goldBase+=(c.eventGold||0);
G.gold+=goldBase;
const cards=pickCards(G.player.cls,3,kind==='boss',kind==='elite');
let pot;
if(kind==='boss')pot=[randomPotion(),randomPotion()].filter(Boolean);
else pot=Math.random()<(kind==='elite'?.7:.4)?[randomPotion()]:[];
const relic=kind==='elite'?randomRelic():null;
const bossChoices=kind==='boss'?shuffle(BOSS_RELICS.slice()).slice(0,3):null;
if(kind==='boss'){G.player.hp=G.player.maxHp;toast('The Abyss grants you breath. Fully healed.');}
const R={kind,gold:goldBase,cards,takenCard:false,potions:pot.filter(Boolean),potTaken:false,
relic,takenRelic:false,bossChoices,takenBoss:false};
G.reward=R;
drawRewards();
}
function drawRewards(){
const R=G.reward;if(!R)return;
const kind=R.kind;
let html=`<h2>${kind==='boss'?'Boss Vanquished':'Victory'}</h2>
<div class="msub">${kind==='boss'?'The way onward opens.':kind==='elite'?'The elite falls. Spoils are yours.':'The last breath leaves it.'}</div>`;
html+=`<div class="center gold" style="font-size:19px;margin-bottom:8px">${GL.coinGlyph||''}+ ${R.gold} Gold <span class="muted small">(total ${G.gold})</span></div>`;
if(!R.takenCard){
html+=`<div class="tagline">Choose a card</div><div class="mrow">`+
R.cards.map((id,i)=>`<div class="optWrap" data-card="${i}">${cardOuter({id,up:0})}</div>`).join('')+`</div>
<div class="center"><button class="btn sm" onclick="rewardSkipCard()">Skip</button></div>`;
} else html+=`<div class="center muted" style="margin:10px">Card chosen.</div>`;
if(R.potions.length&&!R.potTaken){
html+=`<div class="tagline">Potions</div><div class="mrow">`+
R.potions.map((p,i)=>`<div class="item" data-pot="${i}" style="width:auto"><div class="potIco">${GL[POTIONS[p].ico]}</div><div class="small">${POTIONS[p].name}</div><div class="muted small">${POTIONS[p].desc}</div></div>`).join('')+`</div>`;
} else if(R.potTaken)html+=`<div class="center muted" style="margin:6px">Potion taken.</div>`;
if(R.relic&&!R.takenRelic){
html+=`<div class="tagline">Relic</div><div class="mrow"><div class="item" id="relReward"><div style="width:56px;height:56px;color:#f0d78c">${GL[RELICS[R.relic].ico]}</div><div class="gold">${RELICS[R.relic].name}</div><div class="muted small center">${RELICS[R.relic].desc}</div></div></div>`;
}
if(R.bossChoices&&!R.takenBoss){
html+=`<div class="tagline">Claim a Boss Relic</div><div class="mrow">`+
R.bossChoices.map((b,i)=>`<div class="item" data-bboss="${i}" style="width:180px"><div style="width:48px;height:48px;color:#ff8b8b">${GL[b.ico]}</div><div class="gold">${b.name}</div><div class="muted small">${b.desc}</div></div>`).join('')+`</div>`;
}
html+=`<div class="mfoot"><button class="btn gold" onclick="leaveCombat()">Continue →</button></div>`;
modal(html);
const m=$('#modal');
m.onclick=ev=>{
const cw=ev.target.closest('[data-card]');
if(cw&&!R.takenCard){const id=R.cards[+cw.dataset.card];G.deck.push(mk(id,0));R.takenCard=true;toast('Added '+CARDS[id].name+' to your deck.');drawRewards();return;}
const pw=ev.target.closest('[data-pot]');
if(pw&&!R.potTaken){const pid=R.potions[+pw.dataset.pot];
if(G.potions.length>=3){toast('Potion slots full — it slips away.');}else{G.potions.push(pid);toast('Obtained '+POTIONS[pid].name+'.');}
R.potTaken=true;drawRewards();return;}
const rw=ev.target.closest('#relReward');
if(rw&&!R.takenRelic){R.takenRelic=true;grantRelic(R.relic);drawRewards();return;}
const bw=ev.target.closest('[data-bboss]');
if(bw&&!R.takenBoss){R.takenBoss=true;const b=R.bossChoices[+bw.dataset.bboss];takeBossRelic(b);drawRewards();return;}
};
}
function takeBossRelic(b){
G.bossRelics.push(b.id);
if(b.id==='ectoplasm'){G.gold=0;G.noGold=true;}
if(b.id==='cursedcrown'){G.noCurseRemove=true;}
if(b.id==='shadowamulet')maxHpDown(15);
toast('Boss Relic: '+b.name);
renderTopbar();
}
function rewardSkipCard(){G.reward.takenCard=true;drawRewards();}
function leaveCombat(){
const c=G.combat;
const kind=c?c.kind:'normal';
G.combat=null;G.reward=null;closeModal();
if(kind==='boss'){
if(G.act>=3){showVictory();return;}
G.act++;G.floor=1;G.map=makeMap(G.act);linkMap(G.map);G.cur={f:0,c:0};
G.player.hp=G.player.maxHp;
toast('Act '+G.act+': '+ACTS[G.act-1].name);
showMap();
setTimeout(()=>{modal(`<h2>Act ${G.act}: ${ACTS[G.act-1].name}</h2><div class="msub">${ACTS[G.act-1].intro}</div><div class="mfoot"><button class="btn gold" onclick="closeModal()">Descend</button></div>`);},260);
return;
}
showMap();
}
/* ============================================================
SHOP
============================================================ */
function showShop(){
const cls=G.player.cls;
const ids=pickCards(cls,3,false,1);
const colorId=pick(COLORLESS);
const relics=[randomRelic(),randomRelic()].filter((v,i,a)=>a.indexOf(v)===i);
const pots=[pick(POTION_IDS),pick(POTION_IDS),pick(POTION_IDS)];
const priceOf=id=>({common:50,uncommon:75,rare:150}[CARDS[id].rarity]||50);
G.shop={ids,colorId,relics,pots,sold:{},removed:false,cards:[...ids.map(id=>({id,up:0,price:priceOf(id),t:'class'})),
{id:colorId,up:0,price:60,t:'color'}]};
G.shop.relicItems=relics.map(id=>({id,price:150}));
G.shop.potItems=pots.map(id=>({id,price:50}));
drawShop();
}
function drawShop(){
const S=G.shop;
const itemCards=S.cards.map((it,i)=>`<div class="item ${S.sold['c'+i]?'sold':''}" data-buy="c${i}">
${cardOuter({id:it.id,up:it.up},true)}<div class="priceTag ${G.gold<it.price?'poor':''}">${it.price}g${it.t==='color'?' · colorless':''}</div></div>`).join('');
const itemRelics=S.relicItems.map((it,i)=>`<div class="item ${S.sold['r'+i]?'sold':''}" data-buy="r${i}" style="width:150px">
<div style="width:52px;height:52px;color:#f0d78c">${GL[RELICS[it.id].ico]}</div><div class="gold center">${RELICS[it.id].name}</div>
<div class="muted small center">${RELICS[it.id].desc}</div><div class="priceTag ${G.gold<it.price?'poor':''}">${it.price}g</div></div>`).join('');
const itemPots=S.potItems.map((it,i)=>`<div class="item ${S.sold['p'+i]?'sold':''}" data-buy="p${i}" style="width:150px">
<div class="potIco">${GL[POTIONS[it.id].ico]}</div><div class="center">${POTIONS[it.id].name}</div>
<div class="muted small center">${POTIONS[it.id].desc}</div><div class="priceTag ${G.gold<it.price?'poor':''}">${it.price}g</div></div>`).join('');
modal(`<h2>The Peddler's Wares</h2>
<div class="msub">"Bones for gold, gold for power. A fair trade, yes?" — Gold: <span class="gold">${G.gold}</span></div>
<div class="divider"></div>
<div class="shopGrid">
<div class="shelf"><h4>Cards</h4>${itemCards}</div>
<div class="shelf"><h4>Relics & Potions</h4>${itemRelics}${itemPots}</div>
</div>
<div class="divider"></div>
<div class="mrow">
<button class="btn ${S.removed?'':'danger'}" ${S.removed?'disabled':''} onclick="shopRemove()">${S.removed?'Card removed':'Remove a card — 75g'}</button>
</div>
<div class="mfoot"><button class="btn gold" onclick="closeModal();showMap()">Leave Shop</button></div>`);
$('#modal').style.maxWidth='1260px';
$('#modal').addEventListener('click',shopClick);
}
function shopClick(ev){
const el=ev.target.closest('[data-buy]');if(!el)return;
const S=G.shop;if(!S)return;
const key=el.dataset.buy;
if(S.sold[key])return;
const i=+key.slice(1);
let item,price;
if(key[0]==='c'){item=S.cards[i];price=item.price;}
else if(key[0]==='r'){item=S.relicItems[i];price=item.price;}
else{item=S.potItems[i];price=item.price;}
if(G.gold<price){toast('Not enough Gold.');return;}
if(key[0]==='p'&&G.potions.length>=3){toast('Potion slots full.');return;}
G.gold-=price;S.sold[key]=1;
if(key[0]==='c'){G.deck.push(mk(item.id,0));toast('Bought '+CARDS[item.id].name+'.');}
else if(key[0]==='r'){S.sold[key]=1;grantRelic(item.id);}
else{G.potions.push(item.id);toast('Bought '+POTIONS[item.id].name+'.');}
renderTopbar();drawShop();
}
function shopRemove(){
const S=G.shop;if(S.removed)return;
if(G.noCurseRemove){toast('The Cursed Crown forbids it.');return;}
if(G.gold<75){toast('Not enough Gold.');return;}
chooseCard('Card Removal','Choose a card to remove from your deck — 75 Gold.',()=>true,null);
pickCB=()=>{};
const g=$('#deckGrid');
g.onclick=ev=>{
const w=ev.target.closest('.optWrap');if(!w)return;
const c=G.deck.find(x=>x.uid===+w.dataset.uid);if(!c)return;
G.gold-=75;S.removed=true;removeCardFromDeck(c.uid);
closeModal();toast('Removed '+CARDS[c.id].name+'.');renderTopbar();drawShop();
};
}
/* ============================================================
REST
============================================================ */
function showRest(){
const heal=Math.floor(G.player.maxHp*.25);
modal(`<h2>A Rest Site</h2>
<div class="msub">Dim embers, a dead fire, and time enough to breathe.</div>
<div class="evChoice" onclick="restHeal(${heal})"><b>Rest</b><small>Heal ${heal} HP (25% of max).</small></div>
<div class="evChoice" onclick="restForge()"><b>Forge</b><small>Upgrade one card permanently.</small></div>
<div class="evChoice" onclick="restMeditate()"><b>Meditate</b><small>Remove one card from your deck permanently.</small></div>
<div class="mfoot"><button class="btn" onclick="closeModal();showMap()">Move on</button></div>`);
}
function restHeal(n){
G.player.hp=Math.min(G.player.maxHp,G.player.hp+n);
toast('Healed '+n+' HP.');
closeModal();showMap();
}
function restForge(){
const up=G.deck.filter(canUpgrade);
if(!up.length){toast('Nothing to upgrade.');return;}
modal(`<h2>Forge</h2><div class="msub">Choose a card to upgrade permanently.</div>
<div id="deckGrid">${up.map(c=>`<div class="optWrap" data-uid="${c.uid}">${cardOuter(c)}</div>`).join('')}</div>`);
$('#deckGrid').onclick=ev=>{
const w=ev.target.closest('.optWrap');if(!w)return;
const c=G.deck.find(x=>x.uid===+w.dataset.uid);if(!c)return;
if(upgradeCardInDeck(c.uid))toast(CARDS[c.id].name+' upgraded!');
closeModal();showMap();
};
}
function restMeditate(){
if(G.noCurseRemove){toast('The Cursed Crown forbids it.');return;}
chooseCard('Meditate','Choose a card to remove from your deck permanently.',()=>true,c=>{
removeCardFromDeck(c.uid);toast('Removed '+CARDS[c.id].name+'.');showMap();
});
}
/* ============================================================
EVENTS
============================================================ */
const EVENTS=[
{id:'altar',title:'The Forgotten Altar',ico:'blood',
text:'A blood-stained altar hums with old power. The stone is warm. Something beneath it is thirsty.',
choices:[
{l:'Offer 15 HP',s:'Lose 15 HP. Obtain a random Rare relic.',run:h=>{h.hp(15);const r=randomRelic('rare');h.relic(r);toast('Relic: '+RELICS[r].name);}},
{l:'Pray',s:'Heal 10 HP. Gain a Curse: Doubt.',run:h=>{h.heal(10);h.curse('doubt');toast('Gained Doubt.');}},
{l:'Leave',s:'Nothing happens.',run:h=>{}}
]},
{id:'peddler',title:'Shadowy Peddler',ico:'coin',
text:'A hooded figure unfolds a cloth of trinkets that were not there a moment ago. "Trade? Trade."',
choices:[
{l:'Trade a card',s:'Remove a card from your deck. Gain 100 Gold.',run:h=>{h.chooseCard('Trade a card','Choose a card to sell.',c=>{removeCardFromDeck(c.uid);h.gold(100);toast('Sold '+CARDS[c.id].name+' for 100 Gold.');});}},
{l:'Buy his wares',s:'Lose 50 Gold. Obtain a random Potion and a random Common relic.',run:h=>{if(!h.spend(50)){toast('Not enough Gold.');return;}const p=pick(POTION_IDS);h.potion(p);const r=randomRelic('common');h.relic(r);toast('Relic: '+RELICS[r].name);}},
{l:'Attack him',s:'Fight the Shadow Thief. Victory pays 75 Gold.',run:h=>{h.combat(['shadowthief'],75);}}
]},
{id:'spring',title:'Cursed Spring',ico:'drop',
text:'Dark water bubbles up from a broken statue. It smells of iron and rain that fell centuries ago.',
choices:[
{l:'Drink deep',s:'Lose 10 HP. Remove ALL Curses from your deck.',run:h=>{h.hp(10);const n=G.deck.filter(c=>CARDS[c.id].type==='curse').length;G.deck=G.deck.filter(c=>CARDS[c.id].type!=='curse');toast(n?('Purged '+n+' curse(s).'):'Your deck was already clean.');}},
{l:'Bottle it',s:'Obtain a Cursed Brew potion.',run:h=>{h.potion('cursedbrew');}},
{l:'Leave',s:'Nothing happens.',run:h=>{}}
]},
{id:'dead',title:'Restless Dead',ico:'skull',
text:'Ghosts drift in a slow ring, whispering a bargain in a language your bones understand.',
choices:[
{l:'Accept',s:'Lose 20 HP. Upgrade 2 random cards in your deck.',run:h=>{h.hp(20);h.upgradeRandom(2);}},
{l:'Refuse',s:'Nothing happens.',run:h=>{h.hp(0);}}
]},
{id:'gambler',title:'Impish Gambler',ico:'star',
text:'A grinning imp rattles a cup of knucklebones. "Double or nothing, friend. Nothing is so easy to double."',
choices:[
{l:'Bet 30 Gold',s:'50% chance to win 60 Gold, or lose 30.',run:h=>{if(!h.spend(30)){toast('Not enough Gold.');return;}if(Math.random()<.5){h.gold(60);toast('The imp pays 60 Gold, sulking.');}else toast('The imp pockets your 30 Gold.');}},
{l:'Wager a card',s:'Remove a random card. If it was a Curse, gain 100 Gold.',run:h=>{if(!G.deck.length){toast('No cards to wager.');return;}const c=pick(G.deck);removeCardFromDeck(c.uid);if(CARDS[c.id].type==='curse'){h.gold(100);toast('A curse! The imp pays 100 Gold.');}else toast('Lost '+CARDS[c.id].name+' for nothing.');}},
{l:'Leave',s:'Nothing happens.',run:h=>{}}
]},
{id:'library',title:'Ancient Library',ico:'book',
text:'Dusty tomes lean against each other like exhausted soldiers. One spine bears no title at all.',
choices:[
{l:'Study',s:'Gain 1 Energy in your next combat only. Lose 5 HP.',run:h=>{h.hp(5);h.energyNext(1);}},
{l:'Read the forbidden page',s:'Add a random Power card of your class to your deck. Gain a Curse: Doubt.',run:h=>{h.powerCard();h.curse('doubt');}},
{l:'Leave',s:'Nothing happens.',run:h=>{}}
]}
];
function showEvent(){
let pool=EVENTS.filter(e=>!G.seenEvents.includes(e.id));
if(!pool.length){pool=EVENTS;G.seenEvents=[];}
const ev=pick(pool);G.seenEvents.push(ev.id);
const art=`<div style="width:74px;height:74px;margin:0 auto 10px;color:#a08ce0">${GL[ev.ico]}</div>`;
modal(`${art}<h2>${ev.title}</h2><div class="evText">${ev.text}</div>
${ev.choices.map((c,i)=>`<button class="evChoice" data-ev="${i}"><b>${c.l}</b><small>${c.s}</small></button>`).join('')}`);
const h=eventAPI();
$('#modal').onclick=evx=>{
const b=evx.target.closest('[data-ev]');if(!b)return;
const ch=ev.choices[+b.dataset.ev];
$('#modal').onclick=null;
G.evHandled=false;
ch.run(h);
if(!G.evHandled){
renderTopbar();closeModal();
setTimeout(()=>{if(!G.combat&&G.screen!=='combat')showMap();},360);
}
};
}
function eventAPI(){
return{
hp:n=>{if(n>0){G.player.hp=Math.max(1,G.player.hp-n);floatText($('#playerHpFill'),'-'+n,'dmg',true);renderTopbar();}},
heal:n=>{G.player.hp=Math.min(G.player.maxHp,G.player.hp+n);toast('Healed '+n+' HP.');renderTopbar();},
gold:n=>{if(G.noGold){toast('No Gold for the damned.');return;}G.gold+=n;renderTopbar();},
spend:n=>{if(G.noGold||G.gold<n)return false;G.gold-=n;renderTopbar();return true;},
relic:id=>{if(id)grantRelic(id);},
potion:id=>{if(id&&!grantPotion(id))toast('Potion slots full.');},
curse:id=>{G.deck.push(mk(id,0));},
powerCard:()=>{const p=CLASS_POOLS[G.player.cls].filter(id=>CARDS[id].type==='power');const id=p.length?pick(p):'flash';G.deck.push(mk(id,0));toast('Learned '+CARDS[id].name+'.');},
upgradeRandom:n=>{const ups=G.deck.filter(canUpgrade);shuffle(ups).slice(0,n).forEach(c=>upgradeCardInDeck(c.uid));toast('Upgraded '+Math.min(n,ups.length)+' card(s).');},
energyNext:n=>{G.nextCombatEnergy+=n;toast('+'+n+' Energy in the next combat.');},
chooseCard:(t,s,cb)=>{G.evHandled=true;
chooseCard(t,s,()=>true,(c)=>{closeModal();cb(c);renderTopbar();
setTimeout(()=>{if(!G.combat&&G.screen!=='combat')showMap();},360);});},
combat:(ids,gold)=>{G.evHandled=true;closeModal();setTimeout(()=>startCombat(ids,'event',{gold:gold}),340);}
};
}
/* ============================================================
POTIONS / DECK / ENDINGS
============================================================ */
function usePotion(i){
const c=C();
if(!c||c.phase!=='player'||c.busy||c.over){toast('Potions may only be drunk during your turn.');return;}
const pid=G.potions[i];if(!pid)return;
const p=POTIONS[pid];
const alive=c.enemies.filter(e=>e.alive);
if(!alive.length)return;
if(p.target){
if(alive.length===1)applyPotion(i,alive[0]);
else{G.pendPotion=i;toast('Choose a target for '+p.name+'.');render();}
} else applyPotion(i,null);
}
function applyPotion(i,t){
const c=C();const pid=G.potions[i];if(!pid)return;
const p=POTIONS[pid];
G.potions.splice(i,1);
p.use(c,t);
c.log('Used '+p.name+'.');
render();
}
function showDeck(){
const order={attack:0,skill:1,power:2,status:3,curse:4};
const list=G.deck.slice().sort((a,b)=>{
const da=CARDS[a.id],db=CARDS[b.id];
if(a.up!==b.up)return a.up?-1:1;
if(order[da.type]!==order[db.type])return order[da.type]-order[db.type];
return (da.cost)-(db.cost);
});
modal(`<h2>Your Deck</h2>
<div class="msub">${G.deck.length} cards · ${G.relics.length+G.bossRelics.length} relics · ${G.potions.length}/3 potions</div>
<div id="deckGrid">${list.map(c=>`<div>${cardOuter(c)}</div>`).join('')}</div>
<div class="divider"></div>
<div class="tagline">Relics</div>
<div class="mrow">${G.relics.map(id=>`<div class="item" style="width:130px"><div style="width:40px;height:40px;color:#f0d78c;margin:0 auto">${GL[RELICS[id].ico]}</div><div class="gold center small">${RELICS[id].name}</div><div class="muted small center">${RELICS[id].desc}</div></div>`).join('')||'<span class="muted">None</span>'}
${G.bossRelics.map(id=>{const b=bRelicInfo(id);return `<div class="item" style="width:130px"><div style="width:40px;height:40px;color:#ff8b8b;margin:0 auto">${GL[b.ico]}</div><div class="center small" style="color:#ff8b8b">${b.name}</div><div class="muted small center">${b.desc}</div></div>`;}).join('')}</div>
<div class="tagline">Potions</div>
<div class="mrow">${G.potions.map((pid,i)=>`<div class="item" style="width:130px"><div class="potIco" style="width:38px;height:38px">${GL[POTIONS[pid].ico]}</div><div class="center small">${POTIONS[pid].name}</div></div>`).join('')||'<span class="muted">None</span>'}</div>
<div class="mfoot"><button class="btn gold" onclick="closeModal()">Close</button></div>`);
}
function showGameOver(){
showScreen('combat');
modal(`<h2 style="color:#c0283a;font-size:34px">You Have Fallen</h2>
<div class="msub" style="font-size:16px">The Abyss keeps what it takes.</div>
<div class="center" style="font-size:16px;margin:14px 0">
Act <b>${G.act}</b> · Floor <b>${G.floor}</b> · ${CLASSES[G.player.cls].name}<br>
<span class="muted">Deck ${G.deck.length} · Relics ${G.relics.length+G.bossRelics.length} · ${G.gold} Gold</span>
</div>
<div class="mfoot">
<button class="btn gold" onclick="location.reload()">Begin Anew</button>
</div>`);
}
function showVictory(){
G.victory=true;
modal(`<h2 style="color:#f0d78c;font-size:36px">The Abyss Is Silent</h2>
<div class="msub" style="font-size:16px">The Archdemon's crown cools in the ash. For a moment, the world exhales.</div>
<div class="center" style="font-size:16px;margin:16px 0">
<b>${CLASSES[G.player.cls].name}</b> conquered all three acts.<br>
<span class="muted">HP ${G.player.hp}/${G.player.maxHp} · ${G.deck.length} cards · ${G.relics.length+G.bossRelics.length} relics · ${G.gold} Gold</span>
</div>
<div class="rule" style="margin:14px auto"></div>
<div class="center muted" style="font-style:italic">You have escaped the shadows. For now.</div>
<div class="mfoot"><button class="btn gold" onclick="location.reload()">Descend Again</button></div>`);
}
/* ============================================================
TITLE
============================================================ */
function buildTitle(){
$('#classRow').innerHTML=Object.values(CLASSES).map(cl=>`
<div class="classCard" style="--c1:${cl.color}" data-cls="${cl.id}">
<div class="cico">${monster(cl.art,cl.color)}</div>
<h3>${cl.name}</h3>
<div class="ctag">${cl.tag}</div>
<p>${cl.blurb}</p>
<div class="clist">${cl.list}</div>
</div>`).join('');
$('#classRow').onclick=ev=>{
const c=ev.target.closest('[data-cls]');if(!c)return;
startRun(c.dataset.cls);
};
}
function startRun(clsId){
newPlayer(clsId);
G.map=makeMap(1);linkMap(G.map);G.cur={f:0,c:1};
showScreen('map');
renderMap();
const cl=CLASSES[clsId];
modal(`<h2>${cl.name}</h2><div class="msub">${cl.tag}</div>
<div class="evText">${cl.blurb}</div>
<div class="center muted">Starting deck: 5× Strike, 4× Defend, 1× ${CARDS[cl.deck[2][0]].name} · 99 Gold</div>
<div class="mfoot"><button class="btn gold" onclick="closeModal();enterAct(1)">Begin the Descent</button></div>`);
}
function enterAct(n){
modal(`<h2>Act ${n}: ${ACTS[n-1].name}</h2><div class="msub">${ACTS[n-1].intro}</div>
<div class="mfoot"><button class="btn gold" onclick="closeModal()">Continue</button></div>`);
}
/* ============================================================
INPUT
============================================================ */
function handClick(ev){
const el=ev.target.closest('.card');if(!el)return;
const c=C();if(!c||c.phase!=='player'||c.busy||c.over)return;
const inst=c.hand.find(h=>h.ruid===+el.dataset.ruid);if(!inst)return;
if(!canPlay(inst)){toast('Not enough Energy.');return;}
const d=CARDS[inst.id];
if(d.target){
const alive=c.enemies.filter(e=>e.alive);
if(alive.length===1)doPlay(inst,alive[0]);
else{G.sel=inst;G.selNeedsTarget=true;render();}
} else doPlay(inst,null);
}
function doPlay(inst,t){
G.sel=null;G.selNeedsTarget=false;
playCard(inst,t);
render();
}
function enemyClick(ev){
const el=ev.target.closest('.enemy');if(!el)return;
const c=C();if(!c)return;
const e=c.enemies.find(x=>x.uuid===el.dataset.uuid);
if(!e||!e.alive)return;
if(G.pendPotion!=null){const i=G.pendPotion;G.pendPotion=null;applyPotion(i,e);return;}
if(G.sel&&G.selNeedsTarget&&c.phase==='player'&&!c.busy)doPlay(G.sel,e);
}
function init(){
buildTitle();
$('#endTurn').addEventListener('click',endPlayerTurn);
$('#handArea').addEventListener('click',handClick);
$('#cbEnemies').addEventListener('click',enemyClick);
$('#cbPotions').addEventListener('click',ev=>{
const el=ev.target.closest('[data-pot]');if(el)usePotion(+el.dataset.pot);
});
document.addEventListener('keydown',ev=>{
const c=C();
if(ev.key==='Escape'){G.sel=null;G.selNeedsTarget=false;G.pendPotion=null;render();return;}
if(!c||c.phase!=='player'||c.busy||c.over||G.screen!=='combat')return;
if(ev.key===' '||ev.key==='Enter'){ev.preventDefault();endPlayerTurn();return;}
if(/^[1-9]$/.test(ev.key)){
const inst=c.hand[+ev.key-1];
if(inst&&canPlay(inst)){
const d=CARDS[inst.id];
if(d.target){
const alive=c.enemies.filter(e=>e.alive);
if(alive.length===1)doPlay(inst,alive[0]);
else{G.sel=inst;G.selNeedsTarget=true;render();}
} else doPlay(inst,null);
}
}
});
document.addEventListener('mousemove',ev=>{
const el=ev.target.closest('[title]');
const tip=$('#tooltip');
if(el){tip.innerHTML=`<div class="st" style="display:block;padding:7px 11px;font-size:13px;line-height:1.35">${el.getAttribute('title')}</div>`;
const r=el.getBoundingClientRect();
tip.style.display='block';
tip.style.left=clamp(r.left+r.width/2-140,8,window.innerWidth-288)+'px';
tip.style.top=Math.max(8,r.top-tip.offsetHeight-10)+'px';
el.removeAttribute('title');el.dataset.tip=el.title||'';
} else tip.style.display='none';
});
}
window.addEventListener('load',init);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"581ece0d-0418-4cd8-a3f0-85eb2fcda16d","tokensIn":16740693,"tokensOut":126067,"tokensTotal":16866760,"cost":0.14610197400000005,"turns":116,"toolCalls":115,"failedToolCalls":5,"timestamp":"2026-09-10T21:48:05.344Z"} -->