← Aethelgard results

Aethelgard

Deepseek v4.1 Flash · full

Initial view of Aethelgard

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 158,123 bytes · SHA-256 518c04b20f4d
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aethelgard — Resonance Chronicles</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Press+Start+2P&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
:root{
  --bg:#07060d;
  --panel:rgba(20,18,34,.62);
  --panel2:rgba(30,26,50,.72);
  --stroke:rgba(255,255,255,.09);
  --txt:#eceaf8;
  --dim:#9c98b8;
  --acc:#8b5cf6;
  --acc2:#22d3ee;
  --fh:'Press Start 2P','Courier New',monospace;
  --fb:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
}
html,body{height:100%;width:100%}
body{
  margin:0;background:var(--bg);color:var(--txt);font-family:var(--fb);
  overflow:hidden;user-select:none;
}
#bgfx{
  position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;
  background:
    radial-gradient(1100px 780px at 12% 8%, rgba(139,92,246,.26), transparent 62%),
    radial-gradient(900px 700px at 92% 84%, rgba(34,211,238,.17), transparent 62%),
    radial-gradient(760px 620px at 62% 26%, rgba(255,122,41,.11), transparent 64%),
    linear-gradient(158deg,#08070f,#0e0c1a 46%,#08070f);
}
#bgfx .orb{position:absolute;border-radius:50%;filter:blur(70px);opacity:.42;animation:orbFloat 22s ease-in-out infinite}
#bgfx .orb.o1{width:420px;height:420px;left:-90px;top:-80px;background:#7c3aed}
#bgfx .orb.o2{width:380px;height:380px;right:-70px;bottom:-90px;background:#06b6d4;animation-delay:-7s}
#bgfx .orb.o3{width:300px;height:300px;left:52%;top:58%;background:#f97316;opacity:.22;animation-delay:-13s}
@keyframes orbFloat{0%,100%{transform:translate3d(0,0,0) scale(1)}33%{transform:translate3d(46px,-38px,0) scale(1.12)}66%{transform:translate3d(-34px,32px,0) scale(.94)}}
#bgfx .grid{position:absolute;inset:-2px;opacity:.16;
  background-image:linear-gradient(rgba(255,255,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.08) 1px,transparent 1px);
  background-size:52px 52px;
  mask-image:radial-gradient(circle at 50% 45%, #000 0%, transparent 78%);
  -webkit-mask-image:radial-gradient(circle at 50% 45%, #000 0%, transparent 78%);}
#fxflash{position:fixed;inset:0;z-index:60;pointer-events:none;background:#fff;opacity:0;transition:opacity .12s}
#vignette{position:fixed;inset:0;z-index:55;pointer-events:none;box-shadow:inset 0 0 160px 30px rgba(0,0,0,.75)}

#app{position:relative;z-index:10;width:100vw;height:100vh;overflow:hidden}
.screen{
  position:absolute;inset:0;opacity:0;transform:translateX(46px) scale(.985);
  pointer-events:none;transition:opacity .38s cubic-bezier(.4,0,.2,1),transform .38s cubic-bezier(.4,0,.2,1);
  display:flex;flex-direction:column;
}
.screen.active{opacity:1;transform:none;pointer-events:auto;z-index:20}
.screen.leaving{opacity:0;transform:translateX(-46px) scale(.985);pointer-events:none;z-index:10}

.glass{
  background:var(--panel);
  backdrop-filter:blur(16px) saturate(140%);
  -webkit-backdrop-filter:blur(16px) saturate(140%);
  border:1px solid var(--stroke);
  border-radius:18px;
  box-shadow:0 18px 44px -22px rgba(0,0,0,.95), inset 0 1px 0 rgba(255,255,255,.05);
}
.glass-strong{background:var(--panel2)}
.hd{font-family:var(--fh);letter-spacing:.5px}

/* ---------- Type theming ---------- */
.t-Pyro{--el:#ff7a29;--el2:#ffc247;--elrgb:255,122,41}
.t-Hydro{--el:#38bdf8;--el2:#3b82f6;--elrgb:56,189,248}
.t-Flora{--el:#4ade80;--el2:#15803d;--elrgb:74,222,128}
.t-Zephyr{--el:#a5f3fc;--el2:#38bdf8;--elrgb:165,243,252}
.t-Earth{--el:#f59e0b;--el2:#92400e;--elrgb:245,158,11}
.t-Volt{--el:#fde047;--el2:#eab308;--elrgb:253,224,71}
.t-Cosmos{--el:#c084fc;--el2:#7c3aed;--elrgb:192,132,252}

.type-badge{
  --el:#8b5cf6;--el2:#6d28d9;--elrgb:139,92,246;
  display:inline-flex;align-items:center;gap:5px;
  font-size:9px;font-weight:800;letter-spacing:.13em;text-transform:uppercase;
  padding:3px 9px;border-radius:999px;
  color:var(--el);
  background:rgba(var(--elrgb),.13);
  border:1px solid rgba(var(--elrgb),.5);
  box-shadow:0 0 14px -2px rgba(var(--elrgb),.55), inset 0 0 12px -6px rgba(var(--elrgb),.7);
  white-space:nowrap;
}
.type-badge.t-Pyro{--el:#ff7a29;--el2:#ffc247;--elrgb:255,122,41}
.type-badge.t-Hydro{--el:#38bdf8;--el2:#3b82f6;--elrgb:56,189,248}
.type-badge.t-Flora{--el:#4ade80;--el2:#15803d;--elrgb:74,222,128}
.type-badge.t-Zephyr{--el:#a5f3fc;--el2:#38bdf8;--elrgb:165,243,252}
.type-badge.t-Earth{--el:#f59e0b;--el2:#92400e;--elrgb:245,158,11}
.type-badge.t-Volt{--el:#fde047;--el2:#eab308;--elrgb:253,224,71}
.type-badge.t-Cosmos{--el:#c084fc;--el2:#7c3aed;--elrgb:192,132,252}
.type-badge.sm{font-size:8px;padding:2px 7px}

/* ---------- Buttons ---------- */
.btn{
  position:relative;font-family:var(--fb);font-weight:700;font-size:13px;
  color:var(--txt);background:rgba(255,255,255,.05);
  border:1px solid var(--stroke);border-radius:12px;
  padding:11px 18px;cursor:pointer;
  transition:transform .12s ease, background .18s ease, box-shadow .18s ease, border-color .18s;
  display:inline-flex;align-items:center;justify-content:center;gap:8px;
  overflow:hidden;
}
.btn:hover{background:rgba(139,92,246,.18);border-color:rgba(139,92,246,.6);transform:translateY(-2px);box-shadow:0 10px 26px -12px rgba(139,92,246,.9)}
.btn:active{transform:translateY(0) scale(.98)}
.btn:disabled{opacity:.38;cursor:not-allowed;transform:none!important;box-shadow:none}
.btn.primary{background:linear-gradient(135deg,rgba(139,92,246,.85),rgba(34,211,238,.65));border-color:rgba(180,160,255,.6);box-shadow:0 14px 34px -16px rgba(139,92,246,1)}
.btn.primary:hover{background:linear-gradient(135deg,rgba(160,118,255,.95),rgba(56,220,250,.8))}
.btn.ghost{background:transparent}
.btn.danger:hover{background:rgba(239,68,68,.2);border-color:rgba(239,68,68,.65);box-shadow:0 10px 26px -12px rgba(239,68,68,.9)}
.btn.xl{font-size:15px;padding:15px 40px;border-radius:14px}
.btn.sm{font-size:11px;padding:7px 12px;border-radius:9px}
.btn.wide{width:100%}
.btn.el{border-color:rgba(var(--elrgb,.139,92,246),.55);background:rgba(var(--elrgb),.10)}
.btn.el:hover{background:rgba(var(--elrgb),.22);box-shadow:0 10px 26px -12px rgba(var(--elrgb),.95)}

.icon-btn{width:40px;height:40px;border-radius:12px;display:grid;place-items:center;cursor:pointer;border:1px solid var(--stroke);background:rgba(255,255,255,.045);transition:all .16s}
.icon-btn:hover{background:rgba(139,92,246,.22);border-color:rgba(139,92,246,.6);transform:translateY(-2px)}

/* ---------- Bars ---------- */
.bar{position:relative;height:9px;border-radius:99px;background:rgba(0,0,0,.55);overflow:hidden;border:1px solid rgba(255,255,255,.07);box-shadow:inset 0 1px 3px rgba(0,0,0,.7)}
.bar>i{display:block;height:100%;width:100%;border-radius:99px;transition:width .55s cubic-bezier(.3,1.4,.5,1), background .4s}
.bar.hp>i{background:linear-gradient(90deg,#22c55e,#86efac)}
.bar.hp.mid>i{background:linear-gradient(90deg,#eab308,#fde047)}
.bar.hp.low>i{background:linear-gradient(90deg,#dc2626,#f87171)}
.bar.hp.low>i{animation:lowPulse 1s infinite}
@keyframes lowPulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.55)}}
.bar.xp{height:6px}
.bar.xp>i{background:linear-gradient(90deg,#22d3ee,#8b5cf6)}
.bar.thin{height:5px}

/* ---------- Title ---------- */
.title-wrap{margin:auto;text-align:center;padding:26px;max-width:920px;width:100%}
.game-title{
  font-family:var(--fh);font-size:clamp(28px,7.2vw,72px);line-height:1.28;
  background:linear-gradient(180deg,#fff 8%,#c8b6ff 46%,#22d3ee 100%);
  -webkit-background-clip:text;background-clip:text;color:transparent;
  filter:drop-shadow(0 0 34px rgba(139,92,246,.75));
  animation:titleGlow 4.5s ease-in-out infinite;
}
.game-title span{display:inline-block;animation:letterPop .7s cubic-bezier(.2,1.5,.4,1) backwards}
@keyframes letterPop{from{opacity:0;transform:translateY(26px) rotate(-8deg) scale(.7)}to{opacity:1;transform:none}}
@keyframes titleGlow{0%,100%{filter:drop-shadow(0 0 26px rgba(139,92,246,.6))}50%{filter:drop-shadow(0 0 48px rgba(34,211,238,.7))}}
.subtitle{font-size:clamp(10px,1.7vw,13px);letter-spacing:.42em;text-transform:uppercase;color:#b3a9e0;margin-top:20px}
.tagline{margin:22px auto 0;color:var(--dim);font-size:14px;max-width:560px;line-height:1.75}
.title-orb{width:132px;height:132px;margin:0 auto 26px;position:relative}
.title-orb .ring{position:absolute;inset:0;border-radius:50%;border:2px solid rgba(139,92,246,.45);animation:spin 9s linear infinite}
.title-orb .ring:nth-child(2){inset:16px;border-color:rgba(34,211,238,.5);animation-duration:6s;animation-direction:reverse;border-style:dashed}
.title-orb .ring:nth-child(3){inset:32px;border-color:rgba(255,122,41,.5);animation-duration:13s}
.title-orb .core{position:absolute;inset:46px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fff,#a78bfa 45%,#4c1d95);box-shadow:0 0 44px 10px rgba(139,92,246,.75);animation:corePulse 2.6s ease-in-out infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes corePulse{0%,100%{transform:scale(1)}50%{transform:scale(1.14)}}
.menu-col{display:flex;flex-direction:column;gap:12px;width:min(330px,88vw);margin:36px auto 0}

/* ---------- Layout shells ---------- */
.page{display:flex;flex-direction:column;height:100%;padding:14px;gap:12px}
.topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.brand{font-family:var(--fh);font-size:13px;background:linear-gradient(90deg,#c4b5fd,#67e8f9);-webkit-background-clip:text;background-clip:text;color:transparent}
.hud-right{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.pill{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:99px;font-size:12px;font-weight:700;background:rgba(255,255,255,.05);border:1px solid var(--stroke)}
.pill.shard{border-color:rgba(34,211,238,.45);color:#7de3f7;box-shadow:0 0 18px -6px rgba(34,211,238,.85)}

/* ---------- Map ---------- */
.map-layout{flex:1;display:grid;grid-template-columns:284px 1fr;gap:12px;min-height:0}
.zone-info{padding:16px;display:flex;flex-direction:column;gap:12px;overflow:auto}
.zone-info h2{font-family:var(--fh);font-size:14px;margin:0;line-height:1.7}
.zone-info p{margin:0;font-size:12.5px;color:var(--dim);line-height:1.72}
.zone-item{display:flex;gap:10px;align-items:center;padding:9px 11px;border-radius:12px;border:1px solid var(--stroke);background:rgba(255,255,255,.035);cursor:pointer;transition:all .16s}
.zone-item:hover{background:rgba(139,92,246,.16);transform:translateX(3px)}
.zone-item.locked{opacity:.42;cursor:not-allowed;filter:grayscale(.6)}
.zone-item.curr{border-color:rgba(var(--elrgb,139,92,246),.7);background:rgba(var(--elrgb,139,92,246),.14);box-shadow:0 0 22px -8px rgba(var(--elrgb,139,92,246),.9)}
.zone-dot{width:11px;height:11px;border-radius:50%;background:var(--el,#8b5cf6);box-shadow:0 0 12px 2px rgba(var(--elrgb,139,92,246),.85);flex:0 0 auto}
.zone-item small{display:block;font-size:10.5px;color:var(--dim);margin-top:2px}

.map-canvas{position:relative;overflow:hidden;min-height:0;background:linear-gradient(180deg,rgba(10,9,20,.5),rgba(14,12,26,.72))}
#mapPaths{position:absolute;inset:0;width:100%;height:100%}
.node{
  position:absolute;transform:translate(-50%,-50%);
  width:76px;height:76px;border-radius:50%;display:grid;place-items:center;cursor:pointer;
  background:radial-gradient(circle at 34% 28%, rgba(255,255,255,.14), rgba(255,255,255,.03) 65%);
  border:1.5px solid rgba(255,255,255,.16);
  transition:transform .18s cubic-bezier(.3,1.6,.5,1),box-shadow .2s,border-color .2s;
  animation:none;
}
.node:hover{transform:translate(-50%,-50%) scale(1.13)}
.node .nicon{font-size:26px;filter:drop-shadow(0 2px 5px rgba(0,0,0,.8))}
.node .nlabel{position:absolute;bottom:-19px;font-size:9.5px;font-weight:700;letter-spacing:.05em;color:#cfc9ea;white-space:nowrap;text-shadow:0 2px 6px #000}
.node.locked{opacity:.34;cursor:not-allowed;filter:grayscale(.9)}
.node.locked:hover{transform:translate(-50%,-50%)}
.node.avail{border-color:rgba(var(--elrgb,139,92,246),.75);box-shadow:0 0 0 5px rgba(var(--elrgb,139,92,246),.13),0 0 30px -6px rgba(var(--elrgb,139,92,246),.95);animation:nodePulse 2.2s ease-in-out infinite}
@keyframes nodePulse{0%,100%{box-shadow:0 0 0 5px rgba(var(--elrgb,139,92,246),.13),0 0 26px -8px rgba(var(--elrgb,139,92,246),.9)}50%{box-shadow:0 0 0 11px rgba(var(--elrgb,139,92,246),.05),0 0 42px -4px rgba(var(--elrgb,139,92,246),1)}}
.node.done{opacity:.72;border-color:rgba(74,222,128,.5)}
.node.boss{border-width:2.5px}
.node.boss::after{content:'';position:absolute;inset:-11px;border-radius:50%;border:1px dashed rgba(239,68,68,.55);animation:spin 14s linear infinite}
.node.here::before{content:'';position:absolute;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 0 18px 6px rgba(255,255,255,.9);animation:corePulse 1.6s infinite}

.map-info{position:absolute;left:12px;bottom:12px;max-width:330px;padding:13px 15px;border-radius:14px;font-size:12.5px;line-height:1.6;color:var(--dim)}

/* ---------- Party chips / bottom bar ---------- */
.bottombar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.party-strip{display:flex;gap:10px;flex-wrap:wrap}
.pchip{display:flex;align-items:center;gap:9px;padding:7px 12px 7px 7px;border-radius:14px;border:1px solid var(--stroke);background:rgba(255,255,255,.04);min-width:172px;transition:all .16s}
.pchip:hover{border-color:rgba(var(--elrgb,139,92,246),.6);box-shadow:0 0 20px -8px rgba(var(--elrgb,139,92,246),.9)}
.pchip.fainted{opacity:.5;filter:grayscale(.5)}
.pchip svg{width:38px;height:38px;flex:0 0 auto}
.pchip .pmeta{flex:1;min-width:0}
.pchip .pname{font-size:11.5px;font-weight:700;display:flex;justify-content:space-between;gap:6px}
.pchip .pname span{color:var(--dim);font-weight:600;font-size:10px}

/* ---------- Battle ---------- */
#screen-battle{padding:0}
.battle-wrap{position:relative;flex:1;display:flex;flex-direction:column;min-height:0}
.arena{
  position:relative;flex:1;min-height:0;overflow:hidden;
  background:
    radial-gradient(760px 440px at 70% 22%, rgba(var(--aelrgb,139,92,246),.24), transparent 66%),
    radial-gradient(700px 500px at 22% 88%, rgba(34,211,238,.14), transparent 68%),
    linear-gradient(180deg,#0b0a16,#120f22 55%,#0a0914);
}
.arena::after{content:'';position:absolute;left:0;right:0;bottom:0;height:46%;background:linear-gradient(180deg,transparent,rgba(0,0,0,.45))}
.arena-grid{position:absolute;inset:0;opacity:.13;background-image:linear-gradient(rgba(255,255,255,.16) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.16) 1px,transparent 1px);background-size:46px 46px;mask-image:linear-gradient(180deg,transparent,#000 40%,#000);-webkit-mask-image:linear-gradient(180deg,transparent,#000 40%,#000)}
.platform{position:absolute;border-radius:50%;background:radial-gradient(ellipse at center, rgba(var(--elrgb,139,92,246),.4), transparent 70%);filter:blur(1px)}
.slot{position:absolute;display:flex;flex-direction:column;gap:10px}
.slot.enemy{right:8%;top:6%;align-items:flex-end}
.slot.player{left:8%;bottom:7%;align-items:flex-start}
.sprite-wrap{position:relative;filter:drop-shadow(0 16px 26px rgba(0,0,0,.8))}
.sprite-wrap svg{display:block}
.sprite-wrap.bob{animation:bobA 2.6s ease-in-out infinite}
.sprite-wrap.enemy-bob{animation:bobB 2.9s ease-in-out infinite}
@keyframes bobA{0%,100%{transform:translateY(0)}50%{transform:translateY(-11px)}}
@keyframes bobB{0%,100%{transform:translateY(0) scaleX(-1)}50%{transform:translateY(-13px) scaleX(-1)}}
.sprite-wrap.hitflash{animation:hitFlash .34s ease-out}
@keyframes hitFlash{0%{filter:brightness(4) saturate(0) drop-shadow(0 0 26px #fff);transform:translateX(-9px) scale(1.06)}60%{filter:brightness(1.6);transform:translateX(6px)}100%{filter:none;transform:none}}
.sprite-wrap.fainted{animation:faintDrop .7s cubic-bezier(.6,0,.9,.4) forwards}
@keyframes faintDrop{to{transform:translateY(76px) rotate(16deg) scale(.72);opacity:0}}
.sprite-wrap.charge{animation:chargeUp .6s ease-in-out}
@keyframes chargeUp{0%,100%{transform:none}50%{transform:translateY(-14px) scale(1.13);filter:brightness(1.7) drop-shadow(0 0 30px #fff)}}

.info-panel{min-width:266px;padding:12px 14px;border-radius:15px}
.info-panel .row1{display:flex;justify-content:space-between;align-items:center;gap:10px}
.info-panel .nm{font-weight:800;font-size:14px;letter-spacing:.02em}
.info-panel .lv{font-size:10px;font-weight:800;color:#cfc9ea;background:rgba(255,255,255,.08);padding:2px 8px;border-radius:99px;border:1px solid var(--stroke)}
.info-panel .hpline{display:flex;align-items:center;gap:8px;margin-top:9px}
.info-panel .hpnum{font-size:10.5px;color:var(--dim);font-weight:700;min-width:52px;text-align:right;font-variant-numeric:tabular-nums}
.info-panel .xpline{margin-top:7px}
.status-chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px;min-height:0}
.schip{font-size:8.5px;font-weight:800;letter-spacing:.09em;text-transform:uppercase;padding:2.5px 7px;border-radius:99px;border:1px solid currentColor}
.schip.burn{color:#fb923c;background:rgba(251,146,60,.13)}
.schip.venom{color:#a3e635;background:rgba(163,230,53,.13)}
.schip.chill{color:#7dd3fc;background:rgba(125,211,252,.13)}
.schip.stun{color:#fde047;background:rgba(253,224,71,.13)}
.schip.buff{color:#c4b5fd;background:rgba(196,181,253,.13)}
.party-dots{display:flex;gap:5px;margin-top:8px}
.pdot{width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.22)}
.pdot.alive{background:#22c55e;box-shadow:0 0 9px rgba(34,197,94,.9)}
.pdot.dead{background:#7f1d1d}
.pdot.act{outline:2px solid #fff;outline-offset:1px}

.battle-bottom{display:grid;grid-template-columns:1fr minmax(300px,432px);gap:12px;padding:12px;height:238px;flex:0 0 auto}
.logbox{padding:15px 18px;font-size:14.5px;line-height:1.7;overflow:auto;position:relative}
.logbox .cursor{display:inline-block;width:9px;height:16px;background:#c4b5fd;vertical-align:-2px;animation:blink .8s steps(1) infinite}
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
.logbox .past{color:var(--dim);font-size:12.5px;line-height:1.65}
.menu-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;padding:12px;align-content:start;overflow:auto}
.movebtn{text-align:left;display:flex;flex-direction:column;gap:3px;padding:10px 13px;border-radius:12px;border:1px solid rgba(var(--elrgb,139,92,246),.42);background:rgba(var(--elrgb,139,92,246),.09);cursor:pointer;transition:all .16s;color:var(--txt)}
.movebtn:hover{background:rgba(var(--elrgb,139,92,246),.22);transform:translateY(-2px);box-shadow:0 12px 26px -14px rgba(var(--elrgb,139,92,246),1)}
.movebtn:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
.movebtn .mn{font-weight:800;font-size:12.5px;display:flex;align-items:center;gap:6px;justify-content:space-between}
.movebtn .ms{font-size:10px;color:var(--dim);display:flex;gap:9px;font-weight:600;font-variant-numeric:tabular-nums}
.battle-hint{grid-column:1/-1;font-size:11px;color:var(--dim);text-align:center;padding-top:2px}

.float-dmg{position:absolute;font-family:var(--fh);font-size:19px;pointer-events:none;z-index:40;animation:dmgFloat 1.15s cubic-bezier(.2,.9,.3,1) forwards;text-shadow:0 3px 10px rgba(0,0,0,.9), 0 0 18px currentColor}
@keyframes dmgFloat{0%{opacity:0;transform:translate(-50%,10px) scale(.5)}22%{opacity:1;transform:translate(-50%,-14px) scale(1.22)}100%{opacity:0;transform:translate(-50%,-72px) scale(.95)}}
.shake{animation:shakeAll .5s cubic-bezier(.36,.07,.19,.97)}
@keyframes shakeAll{10%,90%{transform:translate3d(-7px,2px,0)}20%,80%{transform:translate3d(9px,-3px,0)}30%,50%,70%{transform:translate3d(-13px,4px,0)}40%,60%{transform:translate3d(13px,-4px,0)}}
.sparks{position:absolute;inset:0;pointer-events:none;z-index:39;overflow:hidden}
.spark{position:absolute;width:8px;height:8px;border-radius:50%;animation:sparkFly .7s ease-out forwards}
@keyframes sparkFly{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--dx),var(--dy)) scale(.2)}}
.orb-proj{position:absolute;width:30px;height:30px;border-radius:50%;z-index:38;pointer-events:none;box-shadow:0 0 26px 6px currentColor}
.drift{position:absolute;width:6px;height:6px;border-radius:50%;pointer-events:none;animation:driftUp 2.4s linear infinite;opacity:.75}
@keyframes driftUp{0%{transform:translateY(0) scale(.6);opacity:0}18%{opacity:.9}100%{transform:translateY(-72px) scale(1.15);opacity:0}}

/* prism capture animation */
.prism-anim{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:45;pointer-events:none}
.prism-anim svg{animation:prismThrow .8s cubic-bezier(.2,1.4,.4,1) forwards}
@keyframes prismThrow{0%{transform:translate(230px,190px) scale(.4) rotate(-160deg);opacity:0}55%{opacity:1}100%{transform:translate(0,0) scale(1) rotate(0)}}
.prism-anim.shakeit svg{animation:prismShake .42s ease-in-out infinite}
@keyframes prismShake{0%,100%{transform:rotate(-13deg)}50%{transform:rotate(13deg)}}
.prism-anim.caught svg{animation:prismCaught .9s ease-out forwards}
@keyframes prismCaught{0%{filter:brightness(1)}30%{filter:brightness(3.2) drop-shadow(0 0 34px #fff)}100%{filter:brightness(1) drop-shadow(0 0 26px rgba(255,255,255,.9))}}
.burst{position:absolute;left:50%;top:50%;width:20px;height:20px;border-radius:50%;transform:translate(-50%,-50%);border:3px solid rgba(255,255,255,.9);animation:burstOut .85s ease-out forwards;z-index:44}
@keyframes burstOut{0%{width:20px;height:20px;opacity:1}100%{width:460px;height:460px;opacity:0}}

/* ---------- Dex ---------- */
.scroll-area{flex:1;overflow:auto;min-height:0}
.dex-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(158px,1fr));gap:12px;padding:2px}
.dex-card{padding:12px;border-radius:16px;cursor:pointer;transition:transform .18s,box-shadow .2s,border-color .2s;position:relative;overflow:hidden;text-align:center;border:1px solid var(--stroke);background:var(--panel)}
.dex-card:hover{transform:translateY(-5px);border-color:rgba(var(--elrgb,139,92,246),.75);box-shadow:0 20px 40px -20px rgba(var(--elrgb,139,92,246),1)}
.dex-card.unknown{filter:grayscale(1);opacity:.42}
.dex-card .nm{font-size:12.5px;font-weight:800;margin-top:4px}
.dex-card .no{position:absolute;top:8px;left:10px;font-size:9.5px;font-weight:800;color:var(--dim);letter-spacing:.06em}
.dex-card .tick{position:absolute;top:7px;right:8px;font-size:11px}
.dex-card svg{width:96px;height:96px;margin:4px auto 0}
.detail-split{display:grid;grid-template-columns:1fr 316px;gap:12px;flex:1;min-height:0}
.stat-row{display:flex;align-items:center;gap:10px;font-size:11.5px;font-weight:700;padding:3.5px 0}
.stat-row .lbl{width:44px;color:var(--dim);font-size:10.5px;letter-spacing:.06em}
.stat-row .val{width:34px;text-align:right;font-variant-numeric:tabular-nums}
.stat-row .track{flex:1;height:6px;border-radius:99px;background:rgba(0,0,0,.5);overflow:hidden}
.stat-row .track i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,var(--el,#8b5cf6),var(--el2,#22d3ee))}
.movelist{display:flex;flex-direction:column;gap:7px}
.moverow{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11.5px;padding:8px 11px;border-radius:10px;background:rgba(255,255,255,.04);border:1px solid var(--stroke)}
.moverow.locked{opacity:.4}

/* ---------- Shop / Party ---------- */
.shop-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
.shopcard{padding:15px;border-radius:16px;display:flex;flex-direction:column;gap:9px}
.shopcard .ic{width:48px;height:48px;border-radius:13px;display:grid;place-items:center;font-size:24px;background:rgba(139,92,246,.16);border:1px solid rgba(139,92,246,.4)}
.shopcard h3{margin:0;font-size:14px;font-weight:800}
.shopcard p{margin:0;font-size:11.5px;color:var(--dim);line-height:1.6;flex:1}
.party-slot{padding:14px;border-radius:16px;display:flex;gap:14px;align-items:center}
.party-slot svg{width:88px;height:88px;flex:0 0 auto}
.party-slot .body{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
.big-empty{display:grid;place-items:center;height:100%;color:var(--dim);text-align:center;font-size:13px;line-height:2}

/* ---------- Modal / toast ---------- */
#modal-root{position:fixed;inset:0;z-index:70;display:none;place-items:center;background:rgba(4,3,10,.72);backdrop-filter:blur(7px);padding:18px}
#modal-root.on{display:grid}
.modal{width:min(600px,94vw);max-height:88vh;overflow:auto;padding:22px;border-radius:20px;animation:popIn .32s cubic-bezier(.2,1.4,.4,1)}
@keyframes popIn{from{opacity:0;transform:translateY(24px) scale(.94)}to{opacity:1;transform:none}}
.modal h2{font-family:var(--fh);font-size:13px;margin:0 0 14px;line-height:1.8}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;flex-wrap:wrap}
.dialog-line{font-size:14.5px;line-height:1.75;margin:0 0 11px;color:#ded9f5}
.dialog-line b{color:#c4b5fd}
#toast-root{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:80;display:flex;flex-direction:column;gap:9px;align-items:center;pointer-events:none}
.toast{padding:11px 20px;border-radius:99px;font-size:12.5px;font-weight:700;background:rgba(20,18,34,.94);border:1px solid rgba(139,92,246,.55);box-shadow:0 14px 34px -12px rgba(139,92,246,1);animation:toastIn .35s cubic-bezier(.2,1.5,.4,1),toastOut .4s ease-in 2.3s forwards}
@keyframes toastIn{from{opacity:0;transform:translateY(20px) scale(.9)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateY(-14px) scale(.94)}}

.lvlup-banner{position:absolute;left:50%;top:34%;transform:translateX(-50%);z-index:50;font-family:var(--fh);font-size:15px;padding:14px 26px;border-radius:14px;background:rgba(20,18,34,.9);border:1px solid rgba(253,224,71,.7);color:#fde047;box-shadow:0 0 44px -6px rgba(253,224,71,.8);animation:lvlIn .45s cubic-bezier(.2,1.5,.4,1),lvlOut .5s ease-in 1.7s forwards;text-align:center;line-height:1.9}
@keyframes lvlIn{from{opacity:0;transform:translate(-50%,22px) scale(.85)}to{opacity:1;transform:translate(-50%,0) scale(1)}}
@keyframes lvlOut{to{opacity:0;transform:translate(-50%,-20px)}}

::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-track{background:rgba(255,255,255,.03)}
::-webkit-scrollbar-thumb{background:rgba(139,92,246,.42);border-radius:99px}
::-webkit-scrollbar-thumb:hover{background:rgba(139,92,246,.7)}

.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.flex{display:flex}.between{justify-content:space-between}.center{align-items:center}.gap8{gap:8px}.gap12{gap:12px}
.wrap{flex-wrap:wrap}.mt8{margin-top:8px}.mt14{margin-top:14px}
.muted{color:var(--dim)}
.small{font-size:11.5px}
.grow{flex:1;min-height:0}
h3.sect{font-family:var(--fh);font-size:11px;margin:0;color:#c4b5fd;letter-spacing:.05em}

@media (max-width:1020px){
  .map-layout{grid-template-columns:1fr;grid-template-rows:auto 1fr}
  .zone-info{max-height:180px}
  .battle-bottom{grid-template-columns:1fr;height:auto}
  .detail-split{grid-template-columns:1fr}
}
@media (max-width:640px){
  .page{padding:9px;gap:9px}
  .node{width:60px;height:60px}.node .nicon{font-size:21px}
  .info-panel{min-width:200px;padding:9px 11px}
  .slot.enemy{top:3%;right:2%}.slot.player{left:2%;bottom:3%}
  .logbox{font-size:13px;padding:11px}
  .battle-bottom{height:200px}
  .brand{font-size:10px}
  .pchip{min-width:0;flex:1;padding:5px 8px 5px 5px;gap:6px}
  .pchip svg{width:30px;height:30px}
  .pchip .pname{font-size:10.5px}
  .pchip .pname span{font-size:9px}
  .party-strip{gap:6px;width:100%}
  .bottombar{gap:6px}
  .menu-grid{grid-template-columns:1fr 1fr;padding:9px;gap:8px}
  .battle-bottom{grid-template-columns:1fr;height:auto;flex:0 0 auto;max-height:46vh;padding:9px;overflow:auto}
  .logbox{min-height:76px;max-height:120px;font-size:12.5px}
  .arena{flex:1;min-height:150px}
  .movebtn{padding:8px 10px}
  .movebtn .ms{font-size:9px;gap:6px}
  .map-info{max-width:58%;font-size:11px;padding:9px 11px}
  .zone-info h2{font-size:12px}
}
</style>
</head>
<body>
<div id="bgfx"><div class="orb o1"></div><div class="orb o2"></div><div class="orb o3"></div><div class="grid"></div></div>
<div id="vignette"></div>
<div id="fxflash"></div>

<div id="app">

  <!-- ================= TITLE ================= -->
  <section id="screen-title" class="screen active">
    <div class="title-wrap">
      <div class="title-orb"><div class="ring"></div><div class="ring"></div><div class="ring"></div><div class="core"></div></div>
      <h1 class="game-title hd" id="titleText">AETHELGARD</h1>
      <div class="subtitle">Resonance Chronicles</div>
      <p class="tagline">The frequencies of the world tremble. Capture the Resonants, tune your harmony, and climb toward the Apex Pillar.</p>
      <div class="menu-col">
        <button class="btn primary xl" id="btnContinue">Continue</button>
        <button class="btn xl" id="btnNewGame">New Game</button>
        <div class="grid-2">
          <button class="btn" id="btnTitleDex">Conductor's Dex</button>
          <button class="btn" id="btnTitleHelp">How to Play</button>
        </div>
        <button class="btn danger ghost sm" id="btnReset">Reset Progress</button>
      </div>
      <div class="muted small" style="margin-top:26px" id="titleSaveInfo"></div>
    </div>
  </section>

  <!-- ================= STARTER ================= -->
  <section id="screen-starter" class="screen">
    <div class="page">
      <div class="topbar">
        <div>
          <div class="brand">CHOOSE YOUR FIRST RESONANT</div>
          <div class="muted small" style="margin-top:6px">Professor Harmonia hands you a single Aether Prism. Which frequency answers your call?</div>
        </div>
        <button class="btn sm" id="btnStarterBack">Back</button>
      </div>
      <div class="scroll-area">
        <div class="shop-grid" id="starterGrid" style="grid-template-columns:repeat(auto-fill,minmax(232px,1fr))"></div>
      </div>
    </div>
  </section>

  <!-- ================= MAP ================= -->
  <section id="screen-map" class="screen">
    <div class="page">
      <div class="topbar">
        <div class="brand">AETHELGARD · <span id="zoneNameTop">THE VERDANT CANOPY</span></div>
        <div class="hud-right">
          <div class="pill shard"><span style="font-size:14px">◆</span><span id="hudShards">0</span></div>
          <div class="pill"><span style="color:#c4b5fd">⬢</span> <span id="hudBadges">0</span>/4 Sigils</div>
          <button class="icon-btn" id="btnOpenDex" title="Conductor's Dex">📖</button>
          <button class="icon-btn" id="btnOpenParty" title="Team">⚗</button>
          <button class="icon-btn" id="btnOpenShop" title="Camp Shop">🛒</button>
          <button class="icon-btn" id="btnOpenMenu" title="Menu">☰</button>
        </div>
      </div>

      <div class="map-layout">
        <aside class="zone-info glass">
          <h3 class="sect">ZONE SELECT</h3>
          <div id="zoneList" style="display:flex;flex-direction:column;gap:8px"></div>
          <div style="height:1px;background:rgba(255,255,255,.07)"></div>
          <h3 class="sect">ZONE BRIEF</h3>
          <h2 id="zoneName"></h2>
          <p id="zoneDesc"></p>
          <div id="zoneMeta" class="small muted" style="line-height:1.9"></div>
        </aside>

        <div class="map-canvas glass">
          <svg id="mapPaths" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
          <div id="mapNodes" style="position:absolute;inset:0"></div>
          <div class="map-info glass" id="mapInfo">Select a node on the trail. Reach the Gatekeeper to unlock the next zone.</div>
        </div>
      </div>

      <div class="bottombar">
        <div class="party-strip" id="mapParty"></div>
        <div class="flex gap8 wrap">
          <button class="btn sm" id="btnHealHint">⛲ Springs heal your team</button>
        </div>
      </div>
    </div>
  </section>

  <!-- ================= BATTLE ================= -->
  <section id="screen-battle" class="screen">
    <div class="battle-wrap">
      <div class="arena" id="arena">
        <div class="arena-grid"></div>
        <div class="platform" id="platEnemy" style="right:2%;top:44%;width:270px;height:70px"></div>
        <div class="platform" id="platPlayer" style="left:2%;bottom:1%;width:300px;height:78px"></div>

        <div class="slot enemy" id="slotEnemy">
          <div class="info-panel glass" id="enemyPanel"></div>
          <div class="sprite-wrap enemy-bob" id="enemySprite"></div>
        </div>

        <div class="slot player" id="slotPlayer">
          <div class="sprite-wrap bob" id="playerSprite"></div>
          <div class="info-panel glass" id="playerPanel"></div>
        </div>

        <div class="sparks" id="sparks"></div>
      </div>

      <div class="battle-bottom">
        <div class="logbox glass" id="logbox"></div>
        <div class="glass menu-grid" id="battleMenu"></div>
      </div>
    </div>
  </section>

  <!-- ================= DEX ================= -->
  <section id="screen-dex" class="screen">
    <div class="page">
      <div class="topbar">
        <div>
          <div class="brand">CONDUCTOR'S DEX</div>
          <div class="muted small" style="margin-top:6px" id="dexCount">0 / 18 recorded</div>
        </div>
        <div class="hud-right">
          <input id="dexSearch" placeholder="Search Resonants…" style="background:rgba(255,255,255,.05);border:1px solid var(--stroke);border-radius:11px;padding:10px 14px;color:var(--txt);font-family:var(--fb);font-size:12.5px;outline:none;width:190px">
          <select id="dexFilter" style="background:rgba(255,255,255,.05);border:1px solid var(--stroke);border-radius:11px;padding:10px 12px;color:var(--txt);font-family:var(--fb);font-size:12.5px;outline:none">
            <option value="all">All Types</option>
            <option value="Pyro">Pyro</option><option value="Hydro">Hydro</option><option value="Flora">Flora</option>
            <option value="Zephyr">Zephyr</option><option value="Earth">Earth</option><option value="Volt">Volt</option>
          </select>
          <button class="btn sm" id="btnDexBack">Back</button>
        </div>
      </div>
      <div class="scroll-area">
        <div class="dex-grid" id="dexGrid"></div>
      </div>
    </div>
  </section>

  <!-- ================= PARTY ================= -->
  <section id="screen-party" class="screen">
    <div class="page">
      <div class="topbar">
        <div>
          <div class="brand">HARMONIC TEAM</div>
          <div class="muted small" style="margin-top:6px">Up to 3 Resonants resonate at once. Extra captures rest in the prism vault.</div>
        </div>
        <div class="hud-right">
          <div class="pill shard"><span style="font-size:13px">◆</span><span id="partyShards">0</span></div>
          <button class="btn sm" id="btnPartyBack">Back</button>
        </div>
      </div>
      <div class="scroll-area">
        <div style="display:flex;flex-direction:column;gap:12px" id="partyList"></div>
        <h3 class="sect" style="margin:22px 0 10px">PRISM VAULT</h3>
        <div style="display:flex;flex-direction:column;gap:12px" id="boxList"></div>
      </div>
    </div>
  </section>

  <!-- ================= SHOP ================= -->
  <section id="screen-shop" class="screen">
    <div class="page">
      <div class="topbar">
        <div>
          <div class="brand">CAMP SHOP</div>
          <div class="muted small" style="margin-top:6px">Field Quartermaster Orrin trades in Aether Shards.</div>
        </div>
        <div class="hud-right">
          <div class="pill shard"><span style="font-size:13px">◆</span><span id="shopShards">0</span></div>
          <button class="btn sm" id="btnShopBack">Back</button>
        </div>
      </div>
      <div class="scroll-area">
        <div class="shop-grid" id="shopGrid"></div>
        <h3 class="sect" style="margin:24px 0 10px">YOUR SATCHEL</h3>
        <div class="glass" style="padding:16px;display:flex;gap:20px;flex-wrap:wrap" id="satchelList"></div>
      </div>
    </div>
  </section>

  <div id="modal-root"><div class="modal glass" id="modalBox"></div></div>
  <div id="toast-root"></div>
</div>

<script>
/* =========================================================================
   AETHELGARD — RESONANCE CHRONICLES
   Single-file monster-collector RPG. All systems authored inline below.
   ========================================================================= */
'use strict';

/* ------------------------------ UTILITIES ------------------------------ */
const $ = (s, r) => (r || document).querySelector(s);
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
const rnd = (a, b) => Math.random() * (b - a) + a;
const rndInt = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a;
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const sleep = ms => new Promise(r => setTimeout(r, ms));
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmt = n => n.toLocaleString('en-US');

/* ============================ AUDIO SYNTHESIZER ========================= */
class SynthManager {
  constructor() {
    this.ctx = null; this.master = null; this.enabled = true; this.volume = 0.35; this.noiseBuf = null;
  }
  init() {
    if (this.ctx) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) { this.enabled = false; return; }
    this.ctx = new AC();
    this.master = this.ctx.createGain();
    this.master.gain.value = this.volume;
    const comp = this.ctx.createDynamicsCompressor();
    this.master.connect(comp); comp.connect(this.ctx.destination);
    // pre-baked white noise buffer for percussive hits
    const len = Math.floor(this.ctx.sampleRate * 0.8);
    const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
    this.noiseBuf = buf;
  }
  resume() {
    this.init();
    if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
  }
  setVolume(v) { this.volume = v; if (this.master) this.master.gain.value = v; }
  /* single oscillator blip */
  tone(o) {
    if (!this.enabled) return; this.init(); if (!this.ctx) return;
    const t0 = this.ctx.currentTime + (o.delay || 0);
    const osc = this.ctx.createOscillator();
    const g = this.ctx.createGain();
    osc.type = o.type || 'square';
    osc.frequency.setValueAtTime(o.freq, t0);
    if (o.slideTo) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.slideTo), t0 + (o.dur || 0.1));
    if (o.detune) osc.detune.value = o.detune;
    const atk = o.attack != null ? o.attack : 0.006;
    const vol = o.vol != null ? o.vol : 0.3;
    g.gain.setValueAtTime(0.0001, t0);
    g.gain.exponentialRampToValueAtTime(vol, t0 + atk);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + (o.dur || 0.1));
    let node = osc;
    if (o.filter) {
      const f = this.ctx.createBiquadFilter();
      f.type = o.filterType || 'lowpass'; f.frequency.value = o.filter;
      node.connect(f); node = f;
    }
    node.connect(g); g.connect(this.master);
    osc.start(t0); osc.stop(t0 + (o.dur || 0.1) + 0.05);
  }
  /* pitched noise burst */
  noise(o) {
    if (!this.enabled) return; this.init(); if (!this.ctx) return;
    const t0 = this.ctx.currentTime + (o.delay || 0);
    const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuf;
    const f = this.ctx.createBiquadFilter();
    f.type = o.type || 'bandpass'; f.frequency.value = o.freq || 900; f.Q.value = o.q || 1.2;
    const g = this.ctx.createGain();
    const dur = o.dur || 0.16, vol = o.vol != null ? o.vol : 0.28;
    g.gain.setValueAtTime(vol, t0);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    if (o.sweep) f.frequency.exponentialRampToValueAtTime(Math.max(60, o.sweep), t0 + dur);
    src.connect(f); f.connect(g); g.connect(this.master);
    src.start(t0); src.stop(t0 + dur + 0.04);
  }
  seq(notes, base) {
    notes.forEach((n, i) => {
      if (n === null) return;
      this.tone({
        freq: typeof n === 'number' ? n : n[0], dur: (n[1] != null ? n[1] : 0.09),
        type: base.type || 'square', vol: base.vol != null ? base.vol : 0.26, delay: i * (base.step || 0.08)
      });
    });
  }
  hover() { this.tone({ freq: 1180, dur: 0.035, type: 'triangle', vol: 0.10 }); }
  select() { this.tone({ freq: 780, slideTo: 1560, dur: 0.09, type: 'square', vol: 0.2 }); }
  back() { this.tone({ freq: 620, slideTo: 300, dur: 0.09, type: 'square', vol: 0.18 }); }
  open() { this.seq([[520, 0.05], [780, 0.05], [1040, 0.09]], { type: 'triangle', step: 0.05, vol: 0.18 }); }
  hit() { this.noise({ freq: 700, sweep: 180, dur: 0.16, vol: 0.3 }); this.tone({ freq: 220, slideTo: 90, dur: 0.13, type: 'square', vol: 0.24 }); }
  superHit() {
    this.noise({ freq: 1800, sweep: 220, dur: 0.34, vol: 0.36 });
    this.tone({ freq: 420, slideTo: 120, dur: 0.32, type: 'sawtooth', vol: 0.26 });
    this.tone({ freq: 840, slideTo: 240, dur: 0.3, type: 'square', vol: 0.16, delay: 0.05 });
  }
  crit() { this.noise({ freq: 2400, sweep: 300, dur: 0.4, vol: 0.4 }); this.tone({ freq: 1400, slideTo: 200, dur: 0.34, type: 'sawtooth', vol: 0.24 }); }
  weak() { this.tone({ freq: 260, slideTo: 200, dur: 0.14, type: 'triangle', vol: 0.18 }); }
  levelUp() { this.seq([659, 880, 1046, 1318], { type: 'square', step: 0.075, vol: 0.24 }); }
  evolve() { this.seq([440, 554, 659, 880, 1108, 1318], { type: 'triangle', step: 0.09, vol: 0.22 }); this.tone({ freq: 1760, dur: 0.6, type: 'sine', vol: 0.14, delay: 0.5 }); }
  captureShake() { this.tone({ freq: 300, dur: 0.06, type: 'square', vol: 0.16 }); }
  captureSuccess() { this.seq([784, 988, 1318, 1568, 2093], { type: 'square', step: 0.085, vol: 0.24 }); }
  captureFail() { this.tone({ freq: 400, slideTo: 150, dur: 0.3, type: 'sawtooth', vol: 0.2 }); }
  heal() { this.seq([[880, 0.1], [1108, 0.1], [1318, 0.18]], { type: 'sine', step: 0.08, vol: 0.2 }); }
  faint() { this.tone({ freq: 520, slideTo: 60, dur: 0.65, type: 'sawtooth', vol: 0.22 }); }
  shard() { this.seq([[1568, 0.05], [2093, 0.09]], { type: 'triangle', step: 0.05, vol: 0.16 }); }
  error() { this.tone({ freq: 180, dur: 0.14, type: 'square', vol: 0.2 }); }
  bossFanfare() {
    this.seq([392, 392, 523, 659, 587, 698], { type: 'square', step: 0.12, vol: 0.24 });
  }
}
const Synth = new SynthManager();

/* ============================ ELEMENT CHART ============================= */
const TYPES = ['Pyro', 'Hydro', 'Flora', 'Zephyr', 'Earth', 'Volt', 'Cosmos'];
/* strong[attacker] = list of types it hits hard; weak[attacker] = types it is weak against */
const STRONG = { Pyro: ['Flora'], Hydro: ['Pyro'], Flora: ['Hydro'], Zephyr: ['Earth'], Earth: ['Volt'], Volt: ['Zephyr'], Cosmos: [] };
const WEAK = { Pyro: ['Hydro'], Hydro: ['Flora'], Flora: ['Pyro'], Zephyr: ['Volt'], Earth: ['Zephyr'], Volt: ['Earth'], Cosmos: [] };
function typeMod(attackType, defendType) {
  if (attackType === 'Cosmos' || defendType === 'Cosmos') return 1;
  if ((STRONG[attackType] || []).includes(defendType)) return 1.6;
  if ((WEAK[attackType] || []).includes(defendType)) return 0.6;
  return 1;
}
function typeMultText(m) { return m > 1.05 ? "It's super effective!" : (m < 0.95 ? "It's not very effective…" : ''); }

/* ============================== MOVE DATA =============================== */
/* effect: {status, chance} | {buff:{stat,mult}} | {heal:frac} | {drain:frac} | {recoil:frac}
   All 18 roster species draw from their family move-line. */
const MOVES = {
  /* --- PYRO LINE --- */
  ember_tap:      { name: 'Ember Tap',      power: 38,  acc: 96, element: 'Pyro', unlock: 1,  desc: 'A snapping spark of heat.' },
  flare_fang:     { name: 'Flare Fang',     power: 56,  acc: 92, element: 'Pyro', unlock: 4,  desc: 'Burning bite. May inflict Burn.', effect: { status: 'burn', chance: 0.25 } },
  cinder_rush:    { name: 'Cinder Rush',    power: 74,  acc: 88, element: 'Pyro', unlock: 9,  desc: 'A scorching full-body charge.' },
  scorch_howl:    { name: 'Scorch Howl',    power: 0,   acc: 100, element: 'Pyro', unlock: 12, desc: 'Howl to sharpen Attack sharply.', effect: { buff: { stat: 'atk', mult: 1.55 } } },
  inferno_lance:  { name: 'Inferno Lance',  power: 96,  acc: 82, element: 'Pyro', unlock: 15, desc: 'A piercing spear of white flame.' },
  phoenix_wrath:  { name: 'Phoenix Wrath',  power: 124, acc: 76, element: 'Pyro', unlock: 20, desc: 'Reborn fury of the sun. Often burns.', effect: { status: 'burn', chance: 0.35 } },
  /* --- HYDRO LINE --- */
  aqua_jet:       { name: 'Aqua Jet',       power: 38,  acc: 96, element: 'Hydro', unlock: 1,  desc: 'A quick pressurized squirt.' },
  bubble_burst:   { name: 'Bubble Burst',   power: 56,  acc: 92, element: 'Hydro', unlock: 4,  desc: 'Bursting bubbles. May Chill.', effect: { status: 'chill', chance: 0.25 } },
  tidal_slam:     { name: 'Tidal Slam',     power: 76,  acc: 88, element: 'Hydro', unlock: 9,  desc: 'A crashing wall of water.' },
  mist_veil:      { name: 'Mist Veil',      power: 0,   acc: 100, element: 'Hydro', unlock: 12, desc: 'Cloak in mist; Defense rises.', effect: { buff: { stat: 'def', mult: 1.55 } } },
  abyssal_crush:  { name: 'Abyssal Crush',  power: 97,  acc: 82, element: 'Hydro', unlock: 15, desc: 'Pressure of the deep dark.' },
  leviathan_roar: { name: "Leviathan Roar", power: 124, acc: 76, element: 'Hydro', unlock: 20, desc: 'A world-shaking depth-cry.' },
  /* --- FLORA LINE --- */
  vine_whip:      { name: 'Vine Whip',      power: 38,  acc: 96, element: 'Flora', unlock: 1,  desc: 'A lashing green tendril.' },
  seed_bomb:      { name: 'Seed Bomb',      power: 56,  acc: 92, element: 'Flora', unlock: 4,  desc: 'Explosive hardened seeds.' },
  thorn_barrage:  { name: 'Thorn Barrage',  power: 76,  acc: 88, element: 'Flora', unlock: 9,  desc: 'A rain of barbs. May poison.', effect: { status: 'venom', chance: 0.3 } },
  photosynth:     { name: 'Photosynth',     power: 0,   acc: 100, element: 'Flora', unlock: 12, desc: 'Absorb light to restore 30% HP.', effect: { heal: 0.3 } },
  bloom_crash:    { name: 'Bloom Crash',    power: 97,  acc: 82, element: 'Flora', unlock: 15, desc: 'A colossal blossom slams down.' },
  worldroot:      { name: 'Worldroot Eruption', power: 126, acc: 76, element: 'Flora', unlock: 20, desc: 'Roots of the world tear upward.', effect: { drain: 0.3 } },
  /* --- VOLT LINE --- */
  static_zap:     { name: 'Static Zap',     power: 38,  acc: 96, element: 'Volt', unlock: 1,  desc: 'A jabbing arc of static.' },
  volt_fang:      { name: 'Volt Fang',      power: 56,  acc: 92, element: 'Volt', unlock: 4,  desc: 'Electrified bite. May Stun.', effect: { status: 'stun', chance: 0.2 } },
  arc_bolt:       { name: 'Arc Bolt',       power: 77,  acc: 88, element: 'Volt', unlock: 9,  desc: 'A searing split of lightning.' },
  overcharge:     { name: 'Overcharge',     power: 0,   acc: 100, element: 'Volt', unlock: 12, desc: 'Supercharge: Attack up sharply.', effect: { buff: { stat: 'atk', mult: 1.6 } } },
  thunder_crash:  { name: 'Thunder Crash',  power: 98,  acc: 82, element: 'Volt', unlock: 15, desc: 'A hammer-blow of thunder.' },
  storm_cataclysm:{ name: 'Storm Cataclysm',power: 126, acc: 76, element: 'Volt', unlock: 20, desc: 'The sky splits wide open.', effect: { status: 'stun', chance: 0.25 } },
  /* --- ZEPHYR LINE --- */
  gust_slash:     { name: 'Gust Slash',     power: 38,  acc: 96, element: 'Zephyr', unlock: 1,  desc: 'A razor crescent of wind.' },
  feather_dart:   { name: 'Feather Dart',   power: 56,  acc: 92, element: 'Zephyr', unlock: 4,  desc: 'Blades of hardened feather.' },
  cyclone_kick:   { name: 'Cyclone Kick',   power: 77,  acc: 88, element: 'Zephyr', unlock: 9,  desc: 'A spinning gale-assisted kick.' },
  tailwind:       { name: 'Tailwind',       power: 0,   acc: 100, element: 'Zephyr', unlock: 12, desc: 'Ride the current: Speed rises.', effect: { buff: { stat: 'spd', mult: 1.6 } } },
  sky_sovereign:  { name: 'Sky Sovereign',  power: 98,  acc: 82, element: 'Zephyr', unlock: 15, desc: 'Dive from beyond the clouds.' },
  tempest_requiem:{ name: 'Tempest Requiem',power: 126, acc: 76, element: 'Zephyr', unlock: 20, desc: 'A mournful, unstoppable hurricane.' },
  /* --- EARTH LINE --- */
  pebble_toss:    { name: 'Pebble Toss',    power: 38,  acc: 96, element: 'Earth', unlock: 1,  desc: 'A hurtling stone.' },
  stone_edge:     { name: 'Stone Edge',     power: 57,  acc: 92, element: 'Earth', unlock: 4,  desc: 'A jagged stone blade.' },
  quake_stomp:    { name: 'Quake Stomp',    power: 78,  acc: 88, element: 'Earth', unlock: 9,  desc: 'The ground buckles beneath.' },
  iron_hide:      { name: 'Iron Hide',      power: 0,   acc: 100, element: 'Earth', unlock: 12, desc: 'Harden the shell; Defense up.', effect: { buff: { stat: 'def', mult: 1.6 } } },
  tectonic_slam:  { name: 'Tectonic Slam',  power: 99,  acc: 82, element: 'Earth', unlock: 15, desc: 'A continental-weight impact.' },
  continental_rupture: { name: 'Continental Rupture', power: 128, acc: 76, element: 'Earth', unlock: 20, desc: 'Fault lines answer the call.' },
  /* --- COSMOS (final encounter only) --- */
  void_pulse:     { name: 'Void Pulse',     power: 44,  acc: 96, element: 'Cosmos', unlock: 1,  desc: 'A ripple of hollow space.' },
  starfall:       { name: 'Starfall',       power: 78,  acc: 90, element: 'Cosmos', unlock: 1,  desc: 'Burnished stars rain down.' },
  entropy_wave:   { name: 'Entropy Wave',   power: 104, acc: 84, element: 'Cosmos', unlock: 1,  desc: 'Order unspools where it passes.' },
  cosmic_collapse:{ name: 'Cosmic Collapse',power: 132, acc: 76, element: 'Cosmos', unlock: 1,  desc: 'The space between moments folds.' },
  /* fallback */
  struggle:       { name: 'Struggle',       power: 32,  acc: 100, element: 'Cosmos', unlock: 1,  desc: 'A desperate flailing strike.' }
};
const MOVE_LINES = {
  Pyro:   ['ember_tap', 'flare_fang', 'cinder_rush', 'scorch_howl', 'inferno_lance', 'phoenix_wrath'],
  Hydro:  ['aqua_jet', 'bubble_burst', 'tidal_slam', 'mist_veil', 'abyssal_crush', 'leviathan_roar'],
  Flora:  ['vine_whip', 'seed_bomb', 'thorn_barrage', 'photosynth', 'bloom_crash', 'worldroot'],
  Volt:   ['static_zap', 'volt_fang', 'arc_bolt', 'overcharge', 'thunder_crash', 'storm_cataclysm'],
  Zephyr: ['gust_slash', 'feather_dart', 'cyclone_kick', 'tailwind', 'sky_sovereign', 'tempest_requiem'],
  Earth:  ['pebble_toss', 'stone_edge', 'quake_stomp', 'iron_hide', 'tectonic_slam', 'continental_rupture'],
  Cosmos: ['void_pulse', 'starfall', 'entropy_wave', 'cosmic_collapse']
};

/* ========================= RESONANT DATABASE (18) ======================== */
const DEX = [
  /* ---- PYRO FAMILY ---- */
  { key:'emberkit', num:1, name:'Emberkit', type:'Pyro', tier:1, evoLevel:8,  evolvesTo:'pyrowolf',
    base:{hp:44,atk:54,def:38,spd:56}, xpYield:62, shape:'pyro', flavor:'A cinder-furred pup that naps in hearth ash. Its tail flickers when it is happy.',
    lore:'Born from the first spark struck against flint, Emberkits travel in yipping packs through warm grass.' },
  { key:'pyrowolf', num:2, name:'Pyrowolf', type:'Pyro', tier:2, evoLevel:15, evolvesTo:'ignisaur',
    base:{hp:64,atk:76,def:56,spd:78}, xpYield:148, shape:'pyro', flavor:'Its mane burns hotter the angrier it gets. Hunters follow its glow at dusk.',
    lore:'A pack-leader frequency. Pyrowolves can ignite their own pawprints to mark territory.' },
  { key:'ignisaur', num:3, name:'Ignisaur', type:'Pyro', tier:3, evoLevel:null, evolvesTo:null,
    base:{hp:88,atk:106,def:78,spd:96}, xpYield:330, shape:'pyro', flavor:'A living forge. The plates along its spine cool into obsidian glass as it walks.',
    lore:'Ancient texts call it the Walking Sun. Its roar has been recorded melting the snow off peaks.' },
  /* ---- HYDRO FAMILY ---- */
  { key:'droplet', num:4, name:'Droplet', type:'Hydro', tier:1, evoLevel:8, evolvesTo:'geoxus',
    base:{hp:48,atk:48,def:44,spd:50}, xpYield:62, shape:'hydro', flavor:'A palm-sized water spirit that hums when it rains.',
    lore:'Droplets are said to be tears of the first river, given curious little legs.' },
  { key:'geoxus', num:5, name:'Geoxus', type:'Hydro', tier:2, evoLevel:15, evolvesTo:'neptunian',
    base:{hp:70,atk:68,def:66,spd:66}, xpYield:150, shape:'hydro', flavor:'Its shell is a living tide-pool, home to tiny luminous shrimp.',
    lore:'Geoxus migrates with the moon, hauling entire ecosystems in its carapace.' },
  { key:'neptunian', num:6, name:'Neptunian', type:'Hydro', tier:3, evoLevel:null, evolvesTo:null,
    base:{hp:98,atk:94,def:92,spd:86}, xpYield:334, shape:'hydro', flavor:'Its body is translucent deep-sea glass; you can watch its heart glow.',
    lore:'Sovereign of trench and current. Sailors speak of it calming storms with a single note.' },
  /* ---- FLORA FAMILY ---- */
  { key:'seedling', num:7, name:'Seedling', type:'Flora', tier:1, evoLevel:8, evolvesTo:'thornback',
    base:{hp:50,atk:50,def:46,spd:44}, xpYield:62, shape:'flora', flavor:'A restless sprout that follows sunlight like a compass needle.',
    lore:'Gardeners keep Seedlings as living barometers — they droop before rain.' },
  { key:'thornback', num:8, name:'Thornback', type:'Flora', tier:2, evoLevel:15, evolvesTo:'bloomgoliath',
    base:{hp:74,atk:72,def:70,spd:58}, xpYield:152, shape:'flora', flavor:'Each thorn carries a different pollen. Its smell is dizzying up close.',
    lore:'Thornbacks wall off wild groves, growing denser rings with every season.' },
  { key:'bloomgoliath', num:9, name:'Bloomgoliath', type:'Flora', tier:3, evoLevel:null, evolvesTo:null,
    base:{hp:106,atk:96,def:94,spd:70}, xpYield:338, shape:'flora', flavor:'Its crown blooms once every hundred years — for exactly one night.',
    lore:'A walking forest. Entire villages shelter beneath a resting Bloomgoliath.' },
  /* ---- VOLT FAMILY ---- */
  { key:'sparky', num:10, name:'Sparky', type:'Volt', tier:1, evoLevel:8, evolvesTo:'boltsaber',
    base:{hp:42,atk:56,def:36,spd:62}, xpYield:66, shape:'volt', flavor:'It stores static in its cheek pouches and discharges when startled.',
    lore:'Sparkies nest inside old transmission towers, feeding on residual current.' },
  { key:'boltsaber', num:11, name:'Boltsaber', type:'Volt', tier:2, evoLevel:15, evolvesTo:'stormlord',
    base:{hp:60,atk:80,def:52,spd:88}, xpYield:154, shape:'volt', flavor:'It sharpens its fangs on lightning rods. The taste of copper excites it.',
    lore:'Boltsabers duel above storm clouds, their arcs visible from neighboring valleys.' },
  { key:'stormlord', num:12, name:'Stormlord', type:'Volt', tier:3, evoLevel:null, evolvesTo:null,
    base:{hp:82,atk:112,def:72,spd:120}, xpYield:342, shape:'volt', flavor:'Thunderstorms gather where it sleeps, waiting for its command.',
    lore:'The Obsidian Crags were scorched into glass by a single Stormlord argument.' },
  /* ---- ZEPHYR FAMILY ---- */
  { key:'gusty', num:13, name:'Gusty', type:'Zephyr', tier:1, evoLevel:8, evolvesTo:'slipstream',
    base:{hp:41,atk:52,def:36,spd:66}, xpYield:66, shape:'zephyr', flavor:'A feather-light spirit that refuses to touch the ground.',
    lore:'Gusties ride thermals for weeks, sleeping with wings half-spread.' },
  { key:'slipstream', num:14, name:'Slipstream', type:'Zephyr', tier:2, evoLevel:15, evolvesTo:'zephyrus',
    base:{hp:58,atk:74,def:52,spd:96}, xpYield:154, shape:'zephyr', flavor:'It leaves a faint silver trail wherever it dives.',
    lore:'Conductors say a Slipstream can outrun its own echo.' },
  { key:'zephyrus', num:15, name:'Zephyrus', type:'Zephyr', tier:3, evoLevel:null, evolvesTo:null,
    base:{hp:80,atk:102,def:70,spd:134}, xpYield:342, shape:'zephyr', flavor:'Four wings, no sound. It moves in the pause between breaths.',
    lore:'The Apex Pillar was chosen as its roost — and no Conductor has ever disputed it.' },
  /* ---- EARTH FAMILY ---- */
  { key:'pebble', num:16, name:'Pebble', type:'Earth', tier:1, evoLevel:8, evolvesTo:'obsidian',
    base:{hp:54,atk:50,def:58,spd:34}, xpYield:66, shape:'earth', flavor:'It curls into a perfect sphere and pretends to be scenery.',
    lore:'Pebbles are patient. Some have waited in the same riverbed for decades.' },
  { key:'obsidian', num:17, name:'Obsidian', type:'Earth', tier:2, evoLevel:15, evolvesTo:'terrasunder',
    base:{hp:76,atk:72,def:84,spd:44}, xpYield:156, shape:'earth', flavor:'Volcanic glass fused with stone. Its edges never dull.',
    lore:'Forged in the Obsidian Crags, these Resonants carry a shard of the mountain itself.' },
  { key:'terrasunder', num:18, name:'Terrasunder', type:'Earth', tier:3, evoLevel:null, evolvesTo:null,
    base:{hp:110,atk:98,def:118,spd:56}, xpYield:346, shape:'earth', flavor:'Its footfalls register on seismographs two valleys away.',
    lore:'Terrasunders walk alone, and mountains are said to make way for them.' }
];
const DEX_BY_KEY = {};
DEX.forEach(s => DEX_BY_KEY[s.key] = s);

/* Final boss signature (Cosmos - not part of the 18-record roster dex) */
const ASTRALON = { key:'astralon', num:19, name:'Astralon', type:'Cosmos', tier:3, evoLevel:null, evolvesTo:null,
  base:{hp:132,atk:118,def:104,spd:112}, xpYield:600, shape:'cosmos', boss:true,
  flavor:'A Resonant that predates the elements — it is the silence the frequencies sing into.',
  lore:'Astralon has no family line. It simply is.' };
const ALL_SPECIES = DEX.concat([ASTRALON]);
ALL_SPECIES.forEach(s => DEX_BY_KEY[s.key] = s);

/* ============================== ITEMS ================================== */
const ITEMS = {
  prism_basic: { name:'Harmonizer Prism', icon:'🔷', price:120, mult:1.0,  kind:'prism', desc:'Standard catch matrix. Reliable, unremarkable.' },
  prism_hyper: { name:'Hyper-Conductive Prism', icon:'💠', price:420, mult:2.0, kind:'prism', desc:'Tuned to open-closed frequency gaps. Doubles catch rate.' },
  prism_master:{ name:'Apex Prism', icon:'🔶', price:1400, mult:4.2, kind:'prism', desc:'Lab-grade matrix. Nearly always harmonizes.' },
  restorer:    { name:'Core Restorer', icon:'🧪', price:150, kind:'heal', frac:0.5, desc:'Restores 50% of a Resonant\'s maximum HP.' },
  restorer_max:{ name:'Full Core Restorer', icon:'⚗️', price:520, kind:'heal', frac:1.0, desc:'Restores a Resonant to full HP.' },
  elixir:      { name:'Resonance Elixir', icon:'🫧', price:200, kind:'status', desc:'Clears all status conditions from a Resonant.' },
  reviver:     { name:'Resonant Requiem', icon:'🕯️', price:800, kind:'revive', desc:'Revives a fainted Resonant to half HP.' },
  charm:       { name:'Lucky Resonance Charm', icon:'🍀', price:340, kind:'buff', desc:'A one-battle charm: +1 permanent tweak to your active Resonant\'s Speed.', once:true }
};
const SHOP_ORDER = ['prism_basic', 'prism_hyper', 'prism_master', 'restorer', 'restorer_max', 'elixir', 'reviver', 'charm'];

/* ============================== ZONES =================================== */
const ZONES = [
  {
    id:0, name:'The Verdant Canopy', short:'VERDANT CANOPY', accent:'#4ade80', rgb:'74,222,128',
    type:'Flora', types:['Flora','Zephyr'], minLv:2, maxLv:5,
    desc:'A cathedral of roots where sunlight falls in slow green curtains. Zephyr currents thread the branches.',
    nodes:[
      { t:'wild',   x:14, y:80, label:'Whispering Grass' },
      { t:'cache',  x:29, y:64, label:'Root Cache' },
      { t:'wild',   x:45, y:74, label:'Mossy Trail' },
      { t:'trainer',x:60, y:58, label:'Conductor Bram' },
      { t:'spring', x:74, y:70, label:'Dew Spring' },
      { t:'wild',   x:86, y:46, label:'High Branches' },
      { t:'wild',   x:62, y:30, label:'Fern Hollow' },
      { t:'boss',   x:32, y:20, label:'Gatekeeper Sylva' }
    ],
    pool:[['seedling',2,5],['gusty',2,5],['emberkit',2,4],['droplet',2,5],['pebble',3,5],['thornback',5,5]]
  },
  {
    id:1, name:'The Sunken Grotto', short:'SUNKEN GROTTO', accent:'#38bdf8', rgb:'56,189,248',
    type:'Hydro', types:['Hydro','Earth'], minLv:5, maxLv:10,
    desc:'Cold chambers of drowned stone. Every drip echoes twice, and something large is always breathing.',
    nodes:[
      { t:'wild',   x:16, y:74, label:'Tidal Mouth' },
      { t:'wild',   x:30, y:58, label:'Dripstone Hall' },
      { t:'cache',  x:44, y:68, label:'Drowned Satchel' },
      { t:'trainer',x:58, y:52, label:'Diver Kel' },
      { t:'spring', x:72, y:64, label:'Warm Vent' },
      { t:'wild',   x:86, y:44, label:'Echo Basin' },
      { t:'camp',   x:64, y:26, label:'Dry Camp' },
      { t:'boss',   x:34, y:18, label:'Gatekeeper Marin' }
    ],
    pool:[['droplet',5,9],['geoxus',8,10],['pebble',5,9],['obsidian',9,10],['seedling',5,8],['gusty',6,9]]
  },
  {
    id:2, name:'The Obsidian Crags', short:'OBSIDIAN CRAGS', accent:'#f59e0b', rgb:'245,158,11',
    type:'Pyro', types:['Pyro','Volt'], minLv:10, maxLv:16,
    desc:'Shattered black glass under a hot ash sky. Lightning walks the ridgelines like a caged animal.',
    nodes:[
      { t:'wild',   x:15, y:78, label:'Ash Slope' },
      { t:'cache',  x:31, y:62, label:'Fused Coffer' },
      { t:'wild',   x:47, y:72, label:'Glass Ridge' },
      { t:'trainer',x:63, y:56, label:'Conductor Rask' },
      { t:'spring', x:78, y:66, label:'Steam Fissure' },
      { t:'wild',   x:88, y:42, label:'Thunder Shelf' },
      { t:'wild',   x:60, y:28, label:'Caldera Edge' },
      { t:'boss',   x:30, y:18, label:'Gatekeeper Volkan' }
    ],
    pool:[['emberkit',10,14],['pyrowolf',12,16],['sparky',10,14],['boltsaber',13,16],['obsidian',10,14],['terrasunder',16,16]]
  },
  {
    id:3, name:'The Apex Pillar', short:'APEX PILLAR', accent:'#c084fc', rgb:'192,132,252',
    type:'Cosmos', types:['Cosmos'], minLv:16, maxLv:24,
    desc:'A monolith of humming crystal that pierces the clouds. Here the frequencies stop being separate things.',
    nodes:[
      { t:'wild',   x:18, y:76, label:'Windward Steps' },
      { t:'trainer',x:36, y:62, label:'Conductor Vell' },
      { t:'cache',  x:54, y:72, label:'Star Cache' },
      { t:'spring', x:72, y:60, label:'Resonance Well' },
      { t:'trainer',x:86, y:42, label:'Conductor Nyx' },
      { t:'wild',   x:64, y:30, label:'Crown Ledge' },
      { t:'cache',  x:42, y:26, label:'Vault of Echoes' },
      { t:'boss',   x:22, y:16, label:'Sovereign Aetherion' }
    ],
    pool:[['ignisaur',18,22],['neptunian',18,22],['bloomgoliath',18,22],['stormlord',18,22],['zephyrus',18,22],['terrasunder',18,22]]
  }
];

/* =============================== BOSSES ================================ */
const BOSSES = {
  0: {
    name:'Warden Sylva', title:'Gatekeeper of the Verdant Canopy', sprite:'flora', accent:'#4ade80',
    team:[['thornback',9],['gusty',9]],
    intro:[
      'The canopy folds aside. A Conductor in bark-armoured robes plants her staff in the moss.',
      '"Halt, wanderer. This grove grows only for those who can hear it."',
      '"Show me a frequency worth listening to. <b>Resonate with me!</b>"'
    ],
    mid:['"Your harmony is brittle! Deepen it!"','"The roots remember every beat you strike."'],
    win:[
      'Sylva lowers her staff, and the vines uncoil from the path ahead.',
      '"…You heard it. The low note under the wind. Go — the Sunken Grotto waits below."',
      'She presses a humming sigil into your palm. <b>Verdant Sigil obtained!</b>'
    ],
    lose:['"Your rhythm scattered the moment I pushed. Breathe, then try again."'],
    reward:{ shards:600, item:'prism_hyper', qty:3 }
  },
  1: {
    name:'Tidecaller Marin', title:'Gatekeeper of the Sunken Grotto', sprite:'hydro', accent:'#38bdf8',
    team:[['geoxus',14],['obsidian',14],['droplet',13]],
    intro:[
      'Water shears apart like a curtain. Marin stands on a stone that should not be above the waterline.',
      '"You carry a green sigil. Good. That means Sylva let you pass — she is stingy."',
      '"Down here the pressure decides everything. <b>Let us see what you are made of.</b>"'
    ],
    mid:['"The deep does not negotiate!"','"Feel that? The whole grotto is listening to you."'],
    win:[
      'Marin surfaces with a grin, hair plastered to her forehead.',
      '"Beautiful. Salt and lightning in the same chord. You will need it up on the Crags."',
      'The water parts, revealing a stair of dry stone. <b>Tidal Sigil obtained!</b>'
    ],
    lose:['"Crushed flat. Go dry off and come back with a better arrangement."'],
    reward:{ shards:1200, item:'restorer_max', qty:4 }
  },
  2: {
    name:'Forgemaster Volkan', title:'Gatekeeper of the Obsidian Crags', sprite:'volt', accent:'#f59e0b',
    team:[['boltsaber',20],['pyrowolf',20],['obsidian',19]],
    intro:[
      'Heat rolls off a figure hammering a rod of lightning into shape on a bare anvil of glass.',
      '"Another one climbing. Everyone climbs. Nobody comes back down the same."',
      '"I am the last thing standing between you and the Pillar. <b>Strike, or be struck.</b>"'
    ],
    mid:['"HA! That had teeth! Again!"','"Every scar is a bar of music, child."'],
    win:[
      'Volkan laughs — a sound like a rockslide — and tosses you the glowing rod.',
      '"You have a spine of forged steel. The Pillar will test more than that."',
      '"Aetherion does not fight to win. He fights to <i>know</i> you." <b>Ember Sigil obtained!</b>'
    ],
    lose:['"Not hot enough. Cool down, tune up, come back."'],
    reward:{ shards:2400, item:'prism_master', qty:2 }
  },
  3: {
    name:'Sovereign Aetherion', title:'Sovereign of the Apex Pillar', sprite:'cosmos', accent:'#c084fc',
    team:[['stormlord',25],['zephyrus',25],['astralon',28]],
    intro:[
      'The summit is silent in a way that has weight. Aetherion stands with his back to you, watching the stars move.',
      '"Four frequencies. Six sigils. A long road for a single question."',
      '"I have waited a very long time for a Conductor who collects silence as well as sound."',
      '"Do not be nervous. <b>Astralon, greet them.</b>"'
    ],
    mid:['"Closer. Listen past the noise."','"You are almost holding the whole chord at once."'],
    win:[
      'Astralon dissolves into quiet motes of light, and Aetherion finally turns around.',
      '"There it is. The note underneath all six."',
      '"You did not defeat me — you <i>harmonized</i> with me. That is the whole secret. It always was."',
      'He hands you a prism with no facets, and the sky answers. <b>You are the Apex Conductor.</b>'
    ],
    lose:['"Not yet. But the shape of it is in you. Return when the chord is whole."'],
    reward:{ shards:6000, item:'prism_master', qty:5 }
  }
};

/* =========================== STARTER OPTIONS ============================ */
const STARTERS = ['emberkit', 'droplet', 'seedling', 'sparky', 'gusty', 'pebble'];

/* ========================= NAME / FLAVOR TABLES ======================== */
const TRAINER_LINES = {
  win:['"Good resonance. Cleaner than mine."','"Ha! You tuned that perfectly."','"Take the shards — you earned the tempo."'],
  lose:['"Not bad. But not yet."','"Your second note wobbled."','"Come back when your team breathes together."'],
  intro:['"A fellow Conductor!"','"Let us tune against each other."','"Three Resonants, three frequencies — let\'s hear yours."']
};

/* ============================ SPRITE ENGINE ============================= */
const TYPE_COLORS = {
  Pyro:   { a:'#ffa04d', b:'#c2410c', c:'#ffe0a3', glow:'#ff7a29', dark:'#2a0f06' },
  Hydro:  { a:'#7dd3fc', b:'#1d4ed8', c:'#e0f2fe', glow:'#38bdf8', dark:'#062036' },
  Flora:  { a:'#a3e635', b:'#15803d', c:'#ecfccb', glow:'#4ade80', dark:'#08240f' },
  Zephyr: { a:'#e0fbff', b:'#0284c7', c:'#ffffff', glow:'#a5f3fc', dark:'#06283a' },
  Earth:  { a:'#e0b072', b:'#78350f', c:'#fde8c4', glow:'#f59e0b', dark:'#2a1607' },
  Volt:   { a:'#fef08a', b:'#a16207', c:'#fffef2', glow:'#fde047', dark:'#2c2103' },
  Cosmos: { a:'#e2c9ff', b:'#6d28d9', c:'#fdf4ff', glow:'#c084fc', dark:'#1e0b33' }
};
let spriteUid = 0;
function eyePair(cx, cy, dx, r, dark, glow, ang) {
  const a = ang || 0;
  return `<g transform="rotate(${a} ${cx} ${cy})">
    <circle cx="${cx - dx}" cy="${cy}" r="${(r * 2.1).toFixed(1)}" fill="${glow}" opacity=".22"/>
    <circle cx="${cx + dx}" cy="${cy}" r="${(r * 2.1).toFixed(1)}" fill="${glow}" opacity=".22"/>
    <ellipse cx="${cx - dx}" cy="${cy}" rx="${r.toFixed(1)}" ry="${(r * 1.18).toFixed(1)}" fill="${dark}"/>
    <ellipse cx="${cx + dx}" cy="${cy}" rx="${r.toFixed(1)}" ry="${(r * 1.18).toFixed(1)}" fill="${dark}"/>
    <circle cx="${cx - dx + r * 0.34}" cy="${cy - r * 0.4}" r="${(r * 0.4).toFixed(1)}" fill="#fff"/>
    <circle cx="${cx + dx + r * 0.34}" cy="${cy - r * 0.4}" r="${(r * 0.4).toFixed(1)}" fill="#fff"/>
    <circle cx="${cx - dx + r * 0.34}" cy="${cy - r * 0.4}" r="${(r * 0.18).toFixed(1)}" fill="#fff" opacity=".9"/>
  </g>`;
}

const SHAPES = {
  /* -------------------------- PYRO -------------------------- */
  pyro(p, t, id) {
    const B = `url(#${id}b)`, C = `url(#${id}c)`;
    let s = '';
    s += `<path d="M72,74 C94,72 100,44 80,38 C92,58 86,70 70,66 Z" fill="${B}"/>`;
    s += `<path d="M78,68 C92,64 94,50 84,48 C88,58 84,64 76,62 Z" fill="${p.c}" opacity=".75"/>`;
    s += `<ellipse cx="34" cy="86" rx="9" ry="6" fill="${p.b}"/><ellipse cx="68" cy="86" rx="9" ry="6" fill="${p.b}"/>`;
    s += `<ellipse cx="50" cy="66" rx="27" ry="20" fill="${B}"/>`;
    s += `<ellipse cx="50" cy="75" rx="16" ry="10" fill="${C}" opacity=".5"/>`;
    s += `<path d="M33,28 L26,3 L48,20 Z" fill="${B}"/><path d="M67,28 L74,3 L52,20 Z" fill="${B}"/>`;
    s += `<path d="M34,26 L30,10 L44,21 Z" fill="${p.c}" opacity=".55"/><path d="M66,26 L70,10 L56,21 Z" fill="${p.c}" opacity=".55"/>`;
    s += `<circle cx="50" cy="38" r="21" fill="${B}"/>`;
    s += `<ellipse cx="50" cy="47" rx="11" ry="7.5" fill="${C}" opacity=".72"/>`;
    s += eyePair(50, 35, 8.5, 4.2, p.dark, p.glow);
    s += `<path d="M46.5,42.5 L53.5,42.5 L50,47 Z" fill="${p.dark}"/>`;
    s += `<path d="M50,47 L50,51 M50,51 L46,53 M50,51 L54,53" stroke="${p.dark}" stroke-width="1.5" stroke-linecap="round" fill="none" opacity=".8"/>`;
    if (t >= 2) {
      s += `<path d="M28,52 L18,44 L30,45 L22,33 L34,41 Z" fill="${p.c}" opacity=".92"/>`;
      s += `<path d="M72,52 L82,44 L70,45 L78,33 L66,41 Z" fill="${p.c}" opacity=".92"/>`;
    }
    if (t >= 3) {
      s += `<path d="M44,19 L50,2 L56,19 Z" fill="${p.c}"/>`;
      s += `<path d="M62,54 L78,44 L70,58 Z" fill="${p.c}" opacity=".9"/>`;
      s += `<path d="M33,58 L19,50 L29,64 Z" fill="${p.c}" opacity=".9"/>`;
      s += `<circle cx="50" cy="6" r="2.6" fill="#fff" opacity=".9"/>`;
    }
    return s;
  },
  /* -------------------------- HYDRO -------------------------- */
  hydro(p, t, id) {
    const B = `url(#${id}b)`, C = `url(#${id}c)`;
    let s = '';
    s += `<path d="M78,62 C95,50 99,74 84,86 C89,72 84,64 73,68 Z" fill="${B}" opacity=".96"/>`;
    s += `<path d="M50,30 C58,12 72,14 76,28 C65,21 57,26 55,40 Z" fill="${p.c}" opacity=".8"/>`;
    s += `<path d="M50,26 C74,44 85,58 85,69 A35,27 0 0 1 15,69 C15,58 26,44 50,26 Z" fill="${B}"/>`;
    s += `<path d="M30,73 A20,15 0 0 0 70,73 A20,17 0 0 1 30,73 Z" fill="${C}" opacity=".5"/>`;
    s += `<path d="M17,70 C5,66 3,78 15,82 Z" fill="${B}"/>`;
    s += `<path d="M83,70 C95,66 97,78 85,82 Z" fill="${B}"/>`;
    s += eyePair(50, 57, 14, 4.8, p.dark, p.glow);
    s += `<path d="M40,68 Q50,76 60,68" fill="none" stroke="${p.dark}" stroke-width="1.9" stroke-linecap="round" opacity=".72"/>`;
    s += `<ellipse cx="42" cy="34" rx="5" ry="3" fill="#fff" opacity=".38"/>`;
    if (t >= 2) {
      s += `<circle cx="22" cy="42" r="3.6" fill="${p.c}" opacity=".8"/><circle cx="30" cy="26" r="2.3" fill="${p.c}" opacity=".7"/><circle cx="80" cy="36" r="2.8" fill="${p.c}" opacity=".7"/>`;
      s += `<path d="M50,28 L44,38 L56,38 Z" fill="${p.c}" opacity=".8"/>`;
    }
    if (t >= 3) {
      s += `<path d="M50,24 C40,6 62,0 70,10 C56,11 50,17 50,24 Z" fill="${p.c}" opacity=".9"/>`;
      s += `<circle cx="50" cy="20" r="5" fill="${p.c}"/><circle cx="50" cy="20" r="2.4" fill="#fff" opacity=".9"/>`;
      s += `<circle cx="34" cy="50" r="2.4" fill="${p.c}" opacity=".6"/><circle cx="68" cy="52" r="2.2" fill="${p.c}" opacity=".6"/>`;
    }
    return s;
  },
  /* -------------------------- FLORA -------------------------- */
  flora(p, t, id) {
    const B = `url(#${id}b)`, C = `url(#${id}c)`;
    let s = '';
    s += `<path d="M50,26 C42,8 58,-2 70,6 C56,10 50,16 50,26 Z" fill="#4ade80"/>`;
    s += `<path d="M50,26 C58,8 42,-2 30,6 C44,10 50,16 50,26 Z" fill="#22c55e"/>`;
    s += `<ellipse cx="34" cy="86" rx="10" ry="6" fill="${p.b}"/><ellipse cx="66" cy="86" rx="10" ry="6" fill="${p.b}"/>`;
    s += `<ellipse cx="50" cy="66" rx="28" ry="21" fill="${B}"/>`;
    s += `<ellipse cx="50" cy="74" rx="18" ry="12" fill="${C}" opacity=".45"/>`;
    s += `<circle cx="50" cy="38" r="21" fill="${B}"/>`;
    s += `<circle cx="50" cy="22" r="7" fill="#4ade80"/><circle cx="50" cy="22" r="3" fill="#d9f99d"/>`;
    s += eyePair(50, 38, 8.8, 4.4, p.dark, p.glow);
    s += `<path d="M44,49 Q50,54 56,49" fill="none" stroke="${p.dark}" stroke-width="1.9" stroke-linecap="round" opacity=".75"/>`;
    s += `<circle cx="34" cy="49" r="3.2" fill="#86efac" opacity=".75"/><circle cx="66" cy="49" r="3.2" fill="#86efac" opacity=".75"/>`;
    if (t >= 2) {
      s += `<path d="M22,62 C10,56 8,68 20,74 Z" fill="#22c55e"/><path d="M78,62 C90,56 92,68 80,74 Z" fill="#22c55e"/>`;
      s += `<path d="M28,44 L18,38 L30,40 Z" fill="#4ade80" opacity=".85"/><path d="M72,44 L82,38 L70,40 Z" fill="#4ade80" opacity=".85"/>`;
    }
    if (t >= 3) {
      for (let i = 0; i < 7; i++) {
        const a = -160 + i * 26;
        s += `<g transform="rotate(${a} 50 20)"><ellipse cx="50" cy="4" rx="5" ry="9" fill="#fda4af" opacity=".92"/></g>`;
      }
      s += `<circle cx="50" cy="20" r="6" fill="#fde047"/><circle cx="50" cy="20" r="3" fill="#fff8dc"/>`;
      s += `<path d="M30,70 C20,66 18,78 30,84 Z" fill="#22c55e"/><path d="M70,70 C80,66 82,78 70,84 Z" fill="#22c55e"/>`;
    }
    return s;
  },
  /* -------------------------- VOLT -------------------------- */
  volt(p, t, id) {
    const B = `url(#${id}b)`, C = `url(#${id}c)`;
    let s = '';
    s += `<path d="M74,74 L96,60 L84,74 L99,79 L74,84 Z" fill="${p.c}" opacity=".95"/>`;
    s += `<ellipse cx="34" cy="86" rx="9" ry="6" fill="${p.b}"/><ellipse cx="68" cy="86" rx="9" ry="6" fill="${p.b}"/>`;
    s += `<ellipse cx="50" cy="66" rx="26" ry="19" fill="${B}"/>`;
    s += `<ellipse cx="50" cy="74" rx="15" ry="9" fill="${C}" opacity=".45"/>`;
    s += `<path d="M33,29 L21,6 L30,25 L42,14 Z" fill="${B}"/>`;
    s += `<path d="M67,29 L79,6 L70,25 L58,14 Z" fill="${B}"/>`;
    s += `<circle cx="50" cy="38" r="20" fill="${B}"/>`;
    s += `<path d="M50,26 L45,37 L52,36 L46,48" fill="none" stroke="${p.c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" opacity=".95"/>`;
    s += eyePair(50, 39, 8.2, 4.1, p.dark, p.glow);
    s += `<circle cx="30" cy="50" r="5.2" fill="${p.c}" opacity=".82"/><circle cx="70" cy="50" r="5.2" fill="${p.c}" opacity=".82"/>`;
    s += `<path d="M43,50 Q50,55 57,50" fill="none" stroke="${p.dark}" stroke-width="1.8" stroke-linecap="round" opacity=".7"/>`;
    if (t >= 2) {
      s += `<path d="M26,58 L14,52 L24,64 Z" fill="${p.c}" opacity=".9"/><path d="M74,58 L86,52 L76,64 Z" fill="${p.c}" opacity=".9"/>`;
      s += `<path d="M64,44 L72,38 L66,46 Z" fill="${p.c}"/>`;
    }
    if (t >= 3) {
      s += `<path d="M50,16 L44,4 L52,12 L58,2" fill="none" stroke="${p.c}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>`;
      s += `<path d="M62,54 L78,44 L68,58 Z" fill="${p.c}" opacity=".95"/>`;
      s += `<path d="M34,60 L18,52 L30,66 Z" fill="${p.c}" opacity=".95"/>`;
    }
    return s;
  },
  /* -------------------------- ZEPHYR -------------------------- */
  zephyr(p, t, id) {
    const B = `url(#${id}b)`, C = `url(#${id}c)`;
    const wing = t >= 3 ? 1.16 : 1;
    let s = '';
    s += `<g transform="translate(50 62) scale(${wing}) translate(-50 -62)">`;
    s += `<path d="M30,58 C6,50 -2,26 12,20 C16,42 26,52 40,56 Z" fill="${B}"/>`;
    s += `<path d="M70,58 C94,50 102,26 88,20 C84,42 74,52 60,56 Z" fill="${B}"/>`;
    s += `<path d="M14,26 C20,44 28,51 40,55" fill="none" stroke="${p.c}" stroke-width="1.4" opacity=".6"/>`;
    s += `<path d="M86,26 C80,44 72,51 60,55" fill="none" stroke="${p.c}" stroke-width="1.4" opacity=".6"/>`;
    s += `</g>`;
    s += `<path d="M44,78 L40,94 L50,84 L60,94 L56,78 Z" fill="${p.c}" opacity=".85"/>`;
    s += `<ellipse cx="50" cy="64" rx="21" ry="20" fill="${B}"/>`;
    s += `<ellipse cx="50" cy="72" rx="12" ry="10" fill="${C}" opacity=".5"/>`;
    s += `<circle cx="50" cy="38" r="18" fill="${B}"/>`;
    s += `<path d="M36,30 C30,18 40,12 50,16 C42,20 38,25 36,30 Z" fill="${p.c}" opacity=".8"/>`;
    s += `<path d="M64,30 C70,18 60,12 50,16 C58,20 62,25 64,30 Z" fill="${p.c}" opacity=".8"/>`;
    s += eyePair(50, 35, 7.6, 4, p.dark, p.glow);
    s += `<path d="M50,41 L62,46 L50,50 Z" fill="#fbbf24"/>`;
    s += `<path d="M50,44 L58,46 L50,47.5 Z" fill="#f59e0b"/>`;
    if (t >= 2) {
      s += `<path d="M28,54 C18,48 16,58 26,62 Z" fill="${p.c}" opacity=".8"/><path d="M72,54 C82,48 84,58 74,62 Z" fill="${p.c}" opacity=".8"/>`;
    }
    if (t >= 3) {
      s += `<path d="M50,20 L46,6 L50,12 L54,6 Z" fill="${p.c}"/>`;
      s += `<circle cx="50" cy="8" r="2.6" fill="#fff" opacity=".9"/>`;
      s += `<path d="M22,70 C12,74 14,84 26,80 Z" fill="${p.c}" opacity=".7"/><path d="M78,70 C88,74 86,84 74,80 Z" fill="${p.c}" opacity=".7"/>`;
    }
    return s;
  },
  /* -------------------------- EARTH -------------------------- */
  earth(p, t, id) {
    const B = `url(#${id}b)`, C = `url(#${id}c)`;
    let s = '';
    s += `<ellipse cx="24" cy="70" rx="9" ry="16" fill="${p.b}" transform="rotate(-14 24 70)"/>`;
    s += `<ellipse cx="76" cy="70" rx="9" ry="16" fill="${p.b}" transform="rotate(14 76 70)"/>`;
    s += `<Path d="M0,0" fill="none"/>`;
    s += `<path d="M26,88 L20,54 L32,38 L50,32 L68,38 L80,54 L74,88 Z" fill="${B}"/>`;
    s += `<path d="M50,32 L68,38 L80,54 L74,88 L50,88 Z" fill="#000" opacity=".14"/>`;
    s += `<path d="M34,54 L50,46 L66,54 L60,80 L40,80 Z" fill="${C}" opacity=".28"/>`;
    s += `<path d="M24,52 L12,32 L34,42 Z" fill="${p.c}"/><path d="M76,52 L88,32 L66,42 Z" fill="${p.c}"/>`;
    s += `<path d="M36,42 L33,18 L50,10 L67,18 L64,42 Z" fill="${B}"/>`;
    s += `<path d="M50,10 L67,18 L64,42 L50,42 Z" fill="#000" opacity=".13"/>`;
    s += `<path d="M38,30 L34,20 L48,15 Z" fill="${p.c}" opacity=".8"/>`;
    s += `<rect x="40" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}"/><rect x="52.5" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}"/>`;
    s += `<rect x="40" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}" opacity=".5" filter="url(#${id}f)"/><rect x="52.5" y="26" width="7.5" height="5" rx="1.6" fill="${p.glow}" opacity=".5" filter="url(#${id}f)"/>`;
    s += `<path d="M40,38 L50,38 L50,44" fill="none" stroke="${p.dark}" stroke-width="1.7" stroke-linecap="round" opacity=".6"/>`;
    if (t >= 2) {
      s += `<path d="M50,20 L54,28 L46,28 Z" fill="${p.c}"/>`;
      s += `<path d="M30,64 L38,72 L30,80" fill="none" stroke="${p.dark}" stroke-width="1.6" opacity=".5"/>`;
    }
    if (t >= 3) {
      s += `<path d="M50,2 L56,14 L44,14 Z" fill="${p.c}"/>`;
      s += `<path d="M62,34 L72,22 L70,38 Z" fill="${p.c}"/><path d="M38,34 L28,22 L30,38 Z" fill="${p.c}"/>`;
      s += `<circle cx="50" cy="66" r="7" fill="${p.glow}" opacity=".85"/><circle cx="50" cy="66" r="3.4" fill="#fff" opacity=".9"/>`;
    }
    return s;
  },
  /* -------------------------- COSMOS -------------------------- */
  cosmos(p, t, id) {
    const B = `url(#${id}b)`, C = `url(#${id}c)`;
    let s = '';
    s += `<ellipse cx="50" cy="52" rx="44" ry="16" fill="none" stroke="${p.glow}" stroke-width="1.6" opacity=".65" transform="rotate(-18 50 52)"/>`;
    s += `<ellipse cx="50" cy="52" rx="36" ry="12" fill="none" stroke="#fff" stroke-width="1" opacity=".4" transform="rotate(22 50 52)"/>`;
    s += `<path d="M50,8 L78,30 L78,72 L50,94 L22,72 L22,30 Z" fill="${B}"/>`;
    s += `<path d="M50,8 L78,30 L78,72 L50,94 Z" fill="#000" opacity=".17"/>`;
    s += `<path d="M50,20 L68,34 L68,68 L50,82 L32,68 L32,34 Z" fill="${C}" opacity=".22"/>`;
    s += `<circle cx="50" cy="52" r="14" fill="${p.glow}" opacity=".9"/><circle cx="50" cy="52" r="7" fill="#fff"/>`;
    s += `<circle cx="50" cy="52" r="22" fill="none" stroke="${p.c}" stroke-width="1.2" opacity=".7"/>`;
    s += eyePair(50, 40, 11, 4.4, '#1a0530', p.glow);
    for (let i = 0; i < 6; i++) {
      const a = (i * 60) * Math.PI / 180;
      s += `<circle cx="${(50 + Math.cos(a) * 40).toFixed(1)}" cy="${(52 + Math.sin(a) * 30).toFixed(1)}" r="2.6" fill="#fff" opacity=".85"/>`;
    }
    return s;
  }
};

function spriteSVG(sp, size, opts) {
  size = size || 110; opts = opts || {};
  const p = TYPE_COLORS[sp.type] || TYPE_COLORS.Cosmos;
  const t = sp.tier || 1;
  const id = 'sp' + (++spriteUid);
  const inner = SHAPES[sp.shape] ? SHAPES[sp.shape](p, t, id) : SHAPES.cosmos(p, t, id);
  const sc = t === 1 ? 0.9 : (t === 2 ? 1.0 : 1.09);
  const wrap = `<g transform="translate(50,64) scale(${sc}) translate(-50,-64)">${inner}</g>`;
  return `<svg viewBox="0 0 100 100" width="${size}" height="${size}" style="overflow:visible" class="${opts.cls || ''}">
    <defs>
      <linearGradient id="${id}b" x1="0" y1="0" x2="0.35" y2="1">
        <stop offset="0" stop-color="${p.a}"/><stop offset="1" stop-color="${p.b}"/>
      </linearGradient>
      <linearGradient id="${id}c" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0" stop-color="#ffffff" stop-opacity=".95"/><stop offset="1" stop-color="${p.c}" stop-opacity=".55"/>
      </linearGradient>
      <radialGradient id="${id}g">
        <stop offset="0" stop-color="${p.glow}" stop-opacity=".45"/>
        <stop offset="0.6" stop-color="${p.glow}" stop-opacity=".14"/>
        <stop offset="1" stop-color="${p.glow}" stop-opacity="0"/>
      </radialGradient>
      <filter id="${id}f" x="-50%" y="-50%" width="200%" height="200%">
        <feGaussianBlur stdDeviation="2.4"/>
      </filter>
    </defs>
    <ellipse cx="50" cy="52" rx="48" ry="46" fill="url(#${id}g)"/>
    <ellipse cx="50" cy="92" rx="24" ry="5.5" fill="#000" opacity=".38"/>
    ${wrap}
  </svg>`;
}
function prismSVG(size, color, rgb) {
  size = size || 78; color = color || '#a78bfa'; rgb = rgb || '167,139,250';
  return `<svg viewBox="0 0 100 100" width="${size}" height="${size}">
    <defs>
      <linearGradient id="prg" x1="0" y1="0" x2="1" y2="1">
        <stop offset="0" stop-color="#fff" stop-opacity=".95"/><stop offset=".5" stop-color="${color}"/><stop offset="1" stop-color="#1e1b4b"/>
      </linearGradient>
      <filter id="prf" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="3.4"/></filter>
    </defs>
    <circle cx="50" cy="50" r="34" fill="${color}" opacity=".35" filter="url(#prf)"/>
    <path d="M50,4 L86,50 L50,96 L14,50 Z" fill="url(#prg)" stroke="rgba(255,255,255,.85)" stroke-width="2"/>
    <path d="M50,4 L50,96 M14,50 L86,50" stroke="rgba(255,255,255,.5)" stroke-width="1.4"/>
    <path d="M50,4 L86,50 L50,50 Z" fill="#fff" opacity=".28"/>
    <circle cx="50" cy="50" r="9" fill="#fff" opacity=".85"/>
  </svg>`;
}
function shardSVG(size, color, rgb) {
  size = size || 30; color = color || '#38bdf8';
  return `<svg viewBox="0 0 100 100" width="${size}" height="${size}">
    <circle cx="50" cy="52" r="30" fill="${color}" opacity=".35" filter="blur(4px)"/>
    <path d="M50,6 L84,44 L62,94 L38,94 L16,44 Z" fill="${color}" stroke="rgba(255,255,255,.8)" stroke-width="3"/>
    <path d="M50,6 L50,94 M16,44 L84,44" stroke="rgba(255,255,255,.55)" stroke-width="2.4"/>
    <path d="M50,6 L84,44 L50,44 Z" fill="#fff" opacity=".32"/>
  </svg>`;
}

/* =========================== GAME STATE ================================= */
const SAVE_KEY = 'aethelgard.resonance.save.v1';
let S = null;
let uidCounter = 0;
let activeZone = 0;

function defaultState() {
  return {
    version: 1,
    started: Date.now(),
    lastPlayed: Date.now(),
    shards: 320,
    items: { prism_basic: 6, prism_hyper: 1, prism_master: 0, restorer: 3, restorer_max: 0, elixir: 1, reviver: 0 },
    party: [],
    vault: [],
    zonesUnlocked: 1,
    cleared: {},
    visited: { 'z0-n0': true },
    seen: {},
    caught: {},
    sigils: 0,
    steps: 0,
    wildWins: 0,
    trainerWins: 0,
    playtime: 0,
    settings: { sfx: true, speed: 1 }
  };
}
function saveGame() {
  if (!S) return;
  S.lastPlayed = Date.now();
  try { localStorage.setItem(SAVE_KEY, JSON.stringify(S)); } catch (e) { /* storage unavailable */ }
}
function loadGame() {
  try {
    const raw = localStorage.getItem(SAVE_KEY);
    if (!raw) return null;
    const o = JSON.parse(raw);
    if (!o || !Array.isArray(o.party) || o.party.length === 0) return null;
    return o;
  } catch (e) { return null; }
}
function hasSave() { return !!loadGame(); }

/* ------------------------- MONSTER INSTANCES --------------------------- */
function statsFor(sp, level) {
  return {
    maxHP: Math.floor(sp.base.hp * 2 * level / 100) + level * 2 + 10,
    atk: Math.floor(sp.base.atk * 2 * level / 100) + 5,
    def: Math.floor(sp.base.def * 2 * level / 100) + 5,
    spd: Math.floor(sp.base.spd * 2 * level / 100) + 5
  };
}
function movesFor(sp, level) {
  const line = MOVE_LINES[sp.type] || MOVE_LINES.Cosmos;
  const avail = line.filter(k => MOVES[k].unlock <= level);
  const list = avail.slice(-4);
  return list.length ? list : ['struggle'];
}
function makeMon(key, level) {
  const sp = DEX_BY_KEY[key];
  level = clamp(level, 1, 60);
  const st = statsFor(sp, level);
  const m = {
    uid: 'm' + (++uidCounter) + '_' + Math.random().toString(36).slice(2, 6),
    key: key, level: level, xp: 0,
    status: null, curHP: st.maxHP, atk: st.atk, def: st.def, spd: st.spd, maxHP: st.maxHP,
    moveCache: movesFor(sp, level)
  };
  return m;
}
function recalcMon(m) {
  const sp = DEX_BY_KEY[m.key];
  const st = statsFor(sp, m.level);
  const oldMax = m.maxHP || st.maxHP;
  m.maxHP = st.maxHP; m.atk = st.atk; m.def = st.def; m.spd = st.spd;
  m.curHP = clamp(m.curHP + (m.maxHP - oldMax), 0, m.maxHP);
  if (m.curHP <= 0) m.curHP = 0;
  m.moveCache = movesFor(sp, m.level);
  return m;
}
function spOf(m) { return DEX_BY_KEY[m.key]; }
function xpNeeded(level) { return Math.floor(6 * level * level + 16 * level); }
function isFainted(m) { return m.curHP <= 0; }
function healMon(m, frac) {
  const amount = frac >= 1 ? m.maxHP : Math.ceil(m.maxHP * frac);
  const before = m.curHP;
  m.curHP = clamp(m.curHP + amount, 0, m.maxHP);
  return m.curHP - before;
}
function teamAlive(team) { return team.some(m => !isFainted(m)); }

/* ============================== ITEM LOGIC ============================= */
function giveItem(key, qty) {
  qty = qty || 1;
  S.items[key] = (S.items[key] || 0) + qty;
  saveGame();
}
function takeItem(key, qty) {
  qty = qty || 1;
  if ((S.items[key] || 0) < qty) return false;
  S.items[key] -= qty;
  if (S.items[key] <= 0) delete S.items[key];
  saveGame();
  return true;
}
function itemCount(key) { return S.items[key] || 0; }

/* ============================= UI HELPERS ============================== */
let currentScreen = 'screen-title';
function showScreen(id, keepHistory) {
  if (id === currentScreen) return;
  const cur = document.getElementById(currentScreen);
  const next = document.getElementById(id);
  if (!next) return;
  if (cur) { cur.classList.remove('active'); cur.classList.add('leaving'); setTimeout(() => cur.classList.remove('leaving'), 400); }
  next.classList.add('active');
  currentScreen = id;
  Synth.open();
}
function toast(msg, color) {
  const el = document.createElement('div');
  el.className = 'toast';
  if (color) { el.style.borderColor = color; el.style.boxShadow = '0 14px 34px -12px ' + color; }
  el.innerHTML = msg;
  $('#toast-root').appendChild(el);
  setTimeout(() => el.remove(), 2750);
}
function openModal(html) {
  $('#modalBox').innerHTML = html;
  $('#modal-root').classList.add('on');
  Synth.select();
}
function closeModal() {
  $('#modal-root').classList.remove('on');
  Synth.back();
}
function flashScreen(color, dur) {
  const f = $('#fxflash');
  f.style.background = color || '#fff';
  f.style.transition = 'none'; f.style.opacity = '0.85';
  requestAnimationFrame(() => { f.style.transition = 'opacity ' + (dur || 300) + 'ms ease-out'; f.style.opacity = '0'; });
}
function statusChipsHTML(m) {
  if (!m.status) return '';
  const labels = { burn: 'Burned', venom: 'Venomed', chill: 'Chilled', stun: 'Stunned', atkUp: 'ATK+', defUp: 'DEF+', spdUp: 'SPD+' };
  return Object.keys(m.status).filter(k => m.status[k]).map(k =>
    `<span class="schip ${k.replace('Up', '').toLowerCase()}">${k.includes('Up') ? labels[k] : labels[k]}</span>`).join('');
}
function hpClass(m) {
  const r = m.curHP / m.maxHP;
  return r <= 0.22 ? 'low' : (r <= 0.5 ? 'mid' : '');
}
function barHTML(m) {
  return `<div class="bar hp ${hpClass(m)}"><i style="width:${(100 * m.curHP / m.maxHP).toFixed(1)}%"></i></div>`;
}
function typeBadge(t, sm) { return `<span class="type-badge t-${t} ${sm ? 'sm' : ''}">${t}</span>`; }

/* ============================== XP / LEVELS ============================ */
function applyXP(mon, amount) {
  const events = [];
  mon.xp += amount;
  let guard = 0;
  while (mon.xp >= xpNeeded(mon.level) && mon.level < 60 && guard++ < 30) {
    mon.xp -= xpNeeded(mon.level);
    mon.level++;
    recalcMon(mon);
    events.push({ type: 'level', level: mon.level });
    const sp = spOf(mon);
    if (sp.evoLevel && mon.level >= sp.evoLevel && sp.evolvesTo) {
      mon.key = sp.evolvesTo;
      recalcMon(mon);
      events.push({ type: 'evolve', into: DEX_BY_KEY[mon.key].name, key: mon.key });
    }
  }
  if (mon.level >= 60) mon.xp = 0;
  saveGame();
  return events;
}
function xpGainFor(enemyMon, isTrainer) {
  const sp = spOf(enemyMon);
  const g = Math.floor(sp.xpYield * enemyMon.level / 4.5);
  return Math.max(4, Math.floor(g * (isTrainer ? 1.45 : 1)));
}
function markSeen(key) { if (!S.seen[key]) { S.seen[key] = 1; saveGame(); } }
function markCaught(key) { S.seen[key] = 1; S.caught[key] = 1; saveGame(); }

/* ============================ HUD UPDATES ============================== */
function updateHUD() {
  if (!S) return;
  $('#hudShards').textContent = fmt(S.shards);
  $('#hudBadges').textContent = S.sigils;
  $('#partyShards').textContent = fmt(S.shards);
  $('#shopShards').textContent = fmt(S.shards);
}
function statusLabel(m) {
  if (!m.status) return '';
  const k = Object.keys(m.status).filter(x => m.status[x] && typeof m.status[x] === 'number')[0];
  return k ? ' · ' + k.toUpperCase() : '';
}
function partyStripHTML() {
  return S.party.map(m => {
    const sp = spOf(m);
    return `<div class="pchip glass t-${sp.type} ${isFainted(m) ? 'fainted' : ''}" data-mid="${m.uid}">
      ${spriteSVG(sp, 38)}
      <div class="pmeta">
        <div class="pname"><b>${esc(sp.name)}</b><span>Lv ${m.level}</span></div>
        <div style="margin-top:4px">${barHTML(m)}</div>
        <div class="small muted" style="margin-top:3px;font-size:10px">${m.curHP}/${m.maxHP}${statusLabel(m)}</div>
      </div>
    </div>`;
  }).join('');
}

/* =========================== ZONE / MAP VIEW ============================ */
function nodeKey(z, i) { return 'z' + z + '-n' + i; }
function zoneUnlocked(z) { return z < S.zonesUnlocked; }
function nodeState(z, i) {
  const k = nodeKey(z, i);
  if (S.cleared[k]) return 'done';
  if (i === 0) return 'avail';
  if (S.cleared[nodeKey(z, i - 1)]) return 'avail';
  return 'locked';
}
function isRepeatable(node) { return node.t === 'wild'; }
function nodeIcon(t) {
  return ({ wild: '🌿', cache: '📦', trainer: '⚔️', spring: '⛲', boss: '👑', camp: '🏕️' })[t] || '❔';
}
function nodeKind(t) {
  return ({ wild: 'Wild Encounter', cache: 'Mystery Cache', trainer: 'Wandering Conductor', spring: 'Healing Spring', boss: 'Zone Gatekeeper', camp: 'Camp' })[t] || 'Unknown';
}

function renderZoneList() {
  $('#zoneList').innerHTML = ZONES.map((z, i) => {
    const un = zoneUnlocked(i);
    const done = !!S.cleared[nodeKey(i, z.nodes.length - 1)];
    return `<div class="zone-item t-${z.type} ${un ? '' : 'locked'} ${i === activeZone ? 'curr' : ''}" data-zone="${i}">
      <div class="zone-dot"></div>
      <div style="flex:1;min-width:0">
        <div style="font-size:12px;font-weight:700">${esc(z.name)}</div>
        <small>Lv ${z.minLv}–${z.maxLv} · ${un ? (done ? '★ Sigil earned' : 'Explored') : '🔒 Locked'}</small>
      </div>
    </div>`;
  }).join('');
  $$('#zoneList .zone-item').forEach(el => {
    el.onmouseenter = () => Synth.hover();
    el.onclick = () => {
      const z = +el.dataset.zone;
      if (!zoneUnlocked(z)) { Synth.error(); toast('That zone is sealed. Defeat the previous Gatekeeper.'); return; }
      activeZone = z; saveGame(); renderMap(); Synth.select();
    };
  });
}

function renderMap() {
  const z = ZONES[activeZone];
  document.documentElement.style.setProperty('--elrgb', z.rgb);
  $('#zoneNameTop').textContent = z.short;
  $('#zoneName').textContent = z.name;
  $('#zoneDesc').textContent = z.desc;
  const cleared = z.nodes.filter((n, i) => S.cleared[nodeKey(activeZone, i)]).length;
  $('#zoneMeta').innerHTML = `
    Dominant Frequencies: <b style="color:${z.accent}">${z.types.join(' / ')}</b><br>
    Recommended Level: <b>${z.minLv} – ${z.maxLv}</b><br>
    Trail Progress: <b>${cleared}/${z.nodes.length} nodes</b><br>
    Team Ready: <b>${S.party.filter(m => !isFainted(m)).length}/${S.party.length}</b>`;

  renderZoneList();
  updateHUD();
  $('#mapParty').innerHTML = partyStripHTML() || '<div class="muted small">No Resonants yet.</div>';
  $$('#mapParty .pchip').forEach(el => {
    el.onmouseenter = () => Synth.hover();
    el.onclick = () => { openPartyScreen(); };
  });

  /* trail lines */
  const pts = z.nodes.map(n => [n.x, n.y]);
  let d = '';
  pts.forEach((p, i) => { d += (i === 0 ? 'M' : 'L') + p[0] + ',' + p[1] + ' '; });
  let svg = `<path d="${d}" fill="none" stroke="rgba(255,255,255,.10)" stroke-width="1.1" stroke-dasharray="1.6 1.9" stroke-linecap="round" vector-effect="non-scaling-stroke"/>`;
  const reached = pts.length;
  let done = '';
  for (let i = 0; i < reached - 1; i++) {
    if (S.cleared[nodeKey(activeZone, i)]) done += `M${pts[i][0]},${pts[i][1]} L${pts[i + 1][0]},${pts[i + 1][1]} `;
  }
  if (done) svg += `<path d="${done}" fill="none" stroke="${z.accent}" stroke-width="2.2" stroke-linecap="round" opacity=".85" vector-effect="non-scaling-stroke" style="filter:drop-shadow(0 0 6px ${z.accent})"/>`;
  $('#mapPaths').innerHTML = svg;

  /* nodes */
  $('#mapNodes').innerHTML = z.nodes.map((n, i) => {
    const st = nodeState(activeZone, i);
    return `<div class="node t-${z.type} ${st} ${n.t === 'boss' ? 'boss' : ''}" data-i="${i}" style="left:${n.x}%;top:${n.y}%">
      <span class="nicon">${nodeIcon(n.t)}</span>
      <span class="nlabel">${esc(n.label)}</span>
    </div>`;
  }).join('');

  $$('#mapNodes .node').forEach(el => {
    const i = +el.dataset.i;
    const n = z.nodes[i];
    el.onmouseenter = () => {
      Synth.hover();
      const st = nodeState(activeZone, i);
      $('#mapInfo').innerHTML = `<b style="color:${z.accent}">${esc(n.label)}</b> · <span class="muted">${nodeKind(n.t)}</span><br>
        ${st === 'locked' ? 'Sealed until the previous node is cleared.' : st === 'done' ? 'Cleared. ' + (isRepeatable(n) ? 'You may search again anytime.' : 'Nothing left here but echoes.') : 'Ready to approach.'}`;
    };
    el.onclick = () => enterNode(i);
  });
  $('#mapInfo').innerHTML = `<b style="color:${z.accent}">${esc(z.name)}</b> — ${z.nodes.length} nodes on the trail. Hover a node for details, click to approach.`;
}
function currentZoneNodeIndex() {
  const z = ZONES[activeZone];
  for (let i = 0; i < z.nodes.length; i++) {
    const st = nodeState(activeZone, i);
    if (st === 'avail' && !S.cleared[nodeKey(activeZone, i)]) return i;
  }
  return -1;
}

/* ============================== NODE FLOW ============================== */
function enterNode(i) {
  const z = ZONES[activeZone];
  const n = z.nodes[i];
  const st = nodeState(activeZone, i);
  if (st === 'locked') { Synth.error(); toast('That path is sealed. Clear the node before it.'); return; }
  S.visited[nodeKey(activeZone, i)] = true;
  Synth.select();
  if (n.t === 'wild') {
    const lvl = clamp(rndInt(z.minLv, z.maxLv), 2, 60);
    startWildEncounter(activeZone, lvl, i);
  } else if (n.t === 'cache') {
    if (S.cleared[nodeKey(activeZone, i)]) { Synth.error(); toast('This cache is already empty.'); return; }
    openCache(activeZone, i, n);
  } else if (n.t === 'spring') {
    if (S.cleared[nodeKey(activeZone, i)]) { Synth.error(); toast('The spring already gave what it had.'); return; }
    healSpring(activeZone, i);
  } else if (n.t === 'trainer') {
    if (S.cleared[nodeKey(activeZone, i)]) { Synth.error(); toast('They have moved on down the trail.'); return; }
    startTrainerBattle(activeZone, i);
  } else if (n.t === 'camp') {
    openShopScreen();
  } else if (n.t === 'boss') {
    if (S.cleared[nodeKey(activeZone, i)]) { toast('The Gatekeeper has already yielded this zone.'); return; }
    startBossBattle(activeZone);
  }
}

function randomWild(zoneIdx, lvlOverride) {
  const z = ZONES[zoneIdx];
  const entry = pick(z.pool);
  const key = entry[0];
  let lv = lvlOverride != null ? lvlOverride : rndInt(entry[1], entry[2]);
  lv = clamp(lv, z.minLv, z.maxLv + (zoneIdx === 3 ? 6 : 1));
  return makeMon(key, lv);
}
function randomTrainerTeam(zoneIdx) {
  const z = ZONES[zoneIdx];
  const n = zoneIdx === 0 ? 2 : (zoneIdx === 3 ? 3 : 3);
  const team = [];
  const used = [];
  for (let i = 0; i < n; i++) {
    let entry = pick(z.pool);
    let guard = 0;
    while (used.includes(entry[0]) && guard++ < 12) entry = pick(z.pool);
    used.push(entry[0]);
    team.push(makeMon(entry[0], clamp(rndInt(z.minLv + 1, z.maxLv), 2, 60)));
  }
  return team;
}
function markCleared(zoneIdx, i) {
  S.cleared[nodeKey(zoneIdx, i)] = true;
  saveGame();
}

function openCache(zoneIdx, i, n) {
  Synth.select();
  const roll = Math.random();
  let rewardHTML = '', give = () => { };
  if (roll < 0.42) {
    const amt = rndInt(120, 320) * (zoneIdx + 1);
    S.shards += amt;
    give = () => { };
    rewardHTML = `<div style="text-align:center">${shardSVG(70)}<h2 style="margin-top:10px">◆ ${fmt(amt)} AETHER SHARDS</h2></div>`;
    Synth.shard();
  } else if (roll < 0.72) {
    const it = pick(['prism_basic', 'restorer', 'elixir', 'prism_hyper']);
    const qty = rndInt(1, 3);
    giveItem(it, qty);
    rewardHTML = `<div style="text-align:center"><div style="font-size:56px">${ITEMS[it].icon}</div><h2 style="margin-top:10px">${qty}× ${ITEMS[it].name}</h2></div>`;
    Synth.shard();
  } else if (roll < 0.9) {
    const it = pick(['prism_hyper', 'restorer_max', 'reviver']);
    giveItem(it, 1);
    rewardHTML = `<div style="text-align:center"><div style="font-size:56px">${ITEMS[it].icon}</div><h2 style="margin-top:10px">1× ${ITEMS[it].name}</h2><p class="muted small" style="margin-top:8px">A rare find, still humming.</p></div>`;
    Synth.captureSuccess();
  } else {
    const amt = rndInt(500, 900) * (zoneIdx + 1);
    S.shards += amt;
    rewardHTML = `<div style="text-align:center">${shardSVG(80)}<h2 style="margin-top:10px">RICH VEIN! ◆ ${fmt(amt)}</h2><p class="muted small" style="margin-top:8px">You found a motherlode of Aether Shards.</p></div>`;
    Synth.captureSuccess();
  }
  markCleared(zoneIdx, i);
  updateHUD();
  openModal(`<h2>MYSTERY CACHE</h2>
    <p class="dialog-line">You pry open the sealed container marked <b>${esc(n.label)}</b>…</p>
    ${rewardHTML}
    <div class="modal-actions"><button class="btn primary" onclick="closeModal();renderMap();">Collect</button></div>`);
}

function healSpring(zoneIdx, i) {
  let healed = 0, cured = 0;
  S.party.concat(S.vault).forEach(m => {
    if (m.curHP < m.maxHP) { healMon(m, 1); healed++; }
    if (m.status) { m.status = null; cured++; }
  });
  markCleared(zoneIdx, i);
  saveGame(); updateHUD(); renderMap();
  Synth.heal();
  openModal(`<h2>HEALING SPRING</h2>
    <div style="text-align:center;font-size:52px">⛲</div>
    <p class="dialog-line">Warm resonance-soaked water spills over your Resonants. Their frequencies steady.</p>
    <p class="dialog-line">All Resonants restored to <b>full HP</b>. ${cured ? cured + ' status condition(s) cleansed.' : ''}</p>
    <div class="modal-actions"><button class="btn primary" onclick="closeModal();">Continue</button></div>`);
}

async function startTrainerBattle(zoneIdx, i) {
  const z = ZONES[zoneIdx];
  const team = randomTrainerTeam(zoneIdx);
  const name = pick(['Bram', 'Kel', 'Rask', 'Vell', 'Nyx', 'Ondine', 'Perrin', 'Ilyra']) + ' of ' + z.short.split(' ').map(w => w[0] + w.slice(1).toLowerCase()).join(' ');
  const intro = [pick(TRAINER_LINES.intro), `"${name}" — they offer a hand, then step back three paces.`];
  await runBattle({
    kind: 'trainer',
    title: name,
    trainerName: name,
    team: team,
    accent: z.accent,
    intro: intro,
    loseLines: [pick(TRAINER_LINES.lose)],
    winLines: [pick(TRAINER_LINES.win)],
    reward: { shards: Math.floor(rndInt(180, 300) * (zoneIdx + 1) * 0.9) + 60 },
    onVictory: () => { S.trainerWins++; markCleared(zoneIdx, i); }
  });
}

async function startBossBattle(zoneIdx) {
  const b = BOSSES[zoneIdx];
  Synth.bossFanfare();
  const team = b.team.map(([k, lv]) => makeMon(k, lv));
  await runBattle({
    kind: 'boss',
    title: b.name + ' — ' + b.title,
    trainerName: b.name,
    team: team,
    accent: b.accent,
    intro: b.intro,
    midLines: b.mid,
    winLines: b.win,
    loseLines: b.lose,
    reward: b.reward,
    bossIndex: zoneIdx,
    onVictory: () => {
      if (!S.cleared[nodeKey(zoneIdx, ZONES[zoneIdx].nodes.length - 1)]) {
        S.sigils++;
      }
      markCleared(zoneIdx, ZONES[zoneIdx].nodes.length - 1);
      S.zonesUnlocked = Math.max(S.zonesUnlocked, Math.min(4, zoneIdx + 2));
    }
  });
}
async function startWildEncounter(zoneIdx, lvl, nodeIdx) {
  const mon = randomWild(zoneIdx, lvl);
  const z = ZONES[zoneIdx];
  await runBattle({
    kind: 'wild',
    title: 'A wild ' + spOf(mon).name + ' appeared!',
    team: [mon],
    accent: z.accent,
    intro: [],
    onVictory: () => { S.wildWins++; if (nodeIdx != null && nodeState(zoneIdx, nodeIdx) === 'avail') markCleared(zoneIdx, nodeIdx); }
  });
}

/* ============================== PARTY VIEW ============================= */
function monCardHTML(m, opts) {
  opts = opts || {};
  const sp = spOf(m);
  const st = statsFor(sp, m.level);
  const need = xpNeeded(m.level);
  return `<div class="party-slot glass t-${sp.type}" data-uid="${m.uid}">
    ${spriteSVG(sp, 88)}
    <div class="body">
      <div class="flex between center gap8">
        <div>
          <div style="font-weight:800;font-size:15px">${esc(sp.name)} <span class="muted small">#${String(sp.num).padStart(2, '0')}</span></div>
          <div class="flex gap8 center mt8" style="margin-top:5px">${typeBadge(sp.type)}<span class="small muted">Lv ${m.level}</span>${m.status ? statusChipsHTML(m) : ''}</div>
        </div>
        <div class="bar hp ${hpClass(m)}" style="width:132px">${''}<i style="width:${(100 * m.curHP / m.maxHP).toFixed(1)}%"></i></div>
      </div>
      <div class="small muted">HP ${m.curHP} / ${m.maxHP}</div>
      <div class="bar xp"><i style="width:${(100 * m.xp / need).toFixed(1)}%"></i></div>
      <div class="small muted" style="display:flex;gap:12px;flex-wrap:wrap;font-variant-numeric:tabular-nums">
        <span>ATK <b style="color:#fca5a5">${st.atk}</b></span>
        <span>DEF <b style="color:#93c5fd">${st.def}</b></span>
        <span>SPD <b style="color:#86efac">${st.spd}</b></span>
        <span>XP ${m.xp}/${need}</span>
      </div>
      <div class="flex gap8 wrap">${m.moveCache.map(k => `<span class="type-badge t-${MOVES[k].element} sm">${MOVES[k].name}</span>`).join('')}</div>
      <div class="flex gap8 wrap">${opts.actions || ''}</div>
    </div>
  </div>`;
}

let partyReturn = 'screen-map';
function openPartyScreen(from) {
  partyReturn = from || currentScreen;
  renderPartyScreen();
  showScreen('screen-party');
}
function renderPartyScreen() {
  updateHUD();
  const actions = (idx) => {
    if (S.party.length <= 1) return '';
    return `<button class="btn sm" data-act="promote" data-idx="${idx}">Set as Lead</button>`;
  };
  $('#partyList').innerHTML = S.party.length ? S.party.map((m, i) => monCardHTML(m, { actions: actions(i) })).join('')
    : '<div class="big-empty">Your harmonic team is empty. Return to the world and capture a Resonant.</div>';
  $('#boxList').innerHTML = S.vault.length ? S.vault.map(m => monCardHTML(m)).join('')
    : '<div class="muted small">The prism vault is empty. Captures beyond three team members are stored here.</div>';

  $$('#partyList [data-act]').forEach(b => b.onclick = () => {
    const idx = +b.dataset.idx;
    const [m] = S.party.splice(idx, 1);
    S.party.unshift(m);
    saveGame(); renderPartyScreen(); Synth.select(); toast('Lead Resonant updated.');
  });
}

/* =============================== DEX VIEW ============================== */
function openDexScreen(from) {
  partyReturn = from || currentScreen;
  renderDex();
  showScreen('screen-dex');
}
function renderDex() {
  const q = ($('#dexSearch').value || '').toLowerCase();
  const f = $('#dexFilter').value;
  const list = DEX.filter(s => {
    if (f !== 'all' && s.type !== f) return false;
    if (q && !s.name.toLowerCase().includes(q)) return false;
    return true;
  });
  const caught = Object.keys(S.caught).length;
  $('#dexCount').textContent = caught + ' / 18 recorded';
  $('#dexGrid').innerHTML = list.map(s => {
    const seen = !!S.seen[s.key], got = !!S.caught[s.key];
    return `<div class="dex-card t-${s.type} ${seen ? '' : 'unknown'}" data-key="${s.key}">
      <div class="no">#${String(s.num).padStart(2, '0')}</div>
      <div class="tick">${got ? '✅' : (seen ? '👁' : '')}</div>
      ${spriteSVG(s, 96)}
      <div class="nm">${seen ? esc(s.name) : '?????'}</div>
      <div class="mt8">${typeBadge(s.type, true)}</div>
      <div class="small muted" style="margin-top:6px">${seen ? (s.tier === 1 ? 'Base form' : s.tier === 2 ? 'Evolves at Lv ' + s.evoLevel : 'Final form') : 'Undiscovered'}</div>
    </div>`;
  }).join('');
  $$('#dexGrid .dex-card').forEach(c => {
    c.onmouseenter = () => Synth.hover();
    c.onclick = () => showDexDetail(c.dataset.key);
  });
}
function showDexDetail(key) {
  const s = DEX_BY_KEY[key];
  const seen = !!S.seen[key];
  Synth.select();
  if (!seen) {
    openModal(`<h2>RESONANT #${String(s.num).padStart(2, '0')}</h2>
      <div style="text-align:center;font-size:60px">🔒</div>
      <p class="dialog-line">No record yet. Encounter this Resonant in the wild to fill the entry.</p>
      <div class="modal-actions"><button class="btn" onclick="closeModal()">Close</button></div>`);
    return;
  }
  const maxStat = 145;
  const row = (lbl, v, cap) => `<div class="stat-row"><span class="lbl">${lbl}</span>
    <div class="track"><i style="width:${clamp(100 * v / (cap || maxStat), 4, 100)}%"></i></div><span class="val">${v}</span></div>`;
  const line = MOVE_LINES[s.type];
  openModal(`<h2>RESONANT #${String(s.num).padStart(2, '0')} · ${esc(s.name).toUpperCase()}</h2>
    <div class="flex gap12 center wrap" style="align-items:flex-start">
      <div class="t-${s.type}">${spriteSVG(s, 140)}</div>
      <div style="flex:1;min-width:220px">
        <div class="flex gap8 wrap center">${typeBadge(s.type)}<span class="small muted">${s.tier === 1 ? 'Base form' : s.tier === 2 ? 'Stage 2' : 'Final form'}</span>${S.caught[key] ? '<span class="small" style="color:#4ade80">✅ Captured</span>' : ''}</div>
        <p class="dialog-line" style="margin-top:10px">${esc(s.flavor)}</p>
        <p class="muted small" style="line-height:1.7">${esc(s.lore)}</p>
      </div>
    </div>
    <h3 class="sect" style="margin:16px 0 6px">BASE STATS</h3>
    ${row('HP', s.base.hp, 140)}${row('ATK', s.base.atk, 130)}${row('DEF', s.base.def, 130)}${row('SPD', s.base.spd, 140)}
    <h3 class="sect" style="margin:16px 0 6px">MOVE LINE</h3>
    <div class="movelist">${line.map(k => {
    const mv = MOVES[k];
    return `<div class="moverow"><div><b>${mv.name}</b> ${typeBadge(mv.element, true)}</div>
        <div class="small muted" style="display:flex;gap:12px;font-variant-numeric:tabular-nums"><span>PWR ${mv.power || '—'}</span><span>ACC ${mv.acc}</span><span>Lv ${mv.unlock}</span></div></div>`;
  }).join('')}</div>
    <p class="muted small" style="margin-top:12px">${s.evoLevel ? `Evolves into <b>${esc(DEX_BY_KEY[s.evolvesTo].name)}</b> at level ${s.evoLevel}.` : 'Final evolutionary stage.'}</p>
    <div class="modal-actions"><button class="btn primary" onclick="closeModal()">Close</button></div>`);
}

/* ============================== SHOP VIEW ============================== */
function openShopScreen(from) {
  partyReturn = from || currentScreen;
  renderShop();
  showScreen('screen-shop');
}
function renderShop() {
  updateHUD();
  $('#shopGrid').innerHTML = SHOP_ORDER.map(key => {
    const it = ITEMS[key];
    return `<div class="shopcard glass">
      <div class="flex between center">
        <div class="ic">${it.icon}</div>
        <div class="pill shard"><span style="font-size:12px">◆</span>${fmt(it.price)}</div>
      </div>
      <h3>${it.name}</h3>
      <p>${it.desc}</p>
      <div class="flex between center gap8">
        <span class="small muted">Owned: <b style="color:#fff">${itemCount(key)}</b></span>
        <button class="btn sm primary" data-buy="${key}">Buy 1</button>
      </div>
      <button class="btn sm" data-buy5="${key}">Buy 5</button>
    </div>`;
  }).join('');
  $$('[data-buy]').forEach(b => b.onclick = () => buy(b.dataset.buy, 1));
  $$('[data-buy5]').forEach(b => b.onclick = () => buy(b.dataset.buy5, 5));
  $('#satchelList').innerHTML = SHOP_ORDER.filter(k => itemCount(k) > 0).map(k =>
    `<div class="flex gap8 center"><span style="font-size:22px">${ITEMS[k].icon}</span>
      <div><div style="font-weight:700;font-size:12.5px">${ITEMS[k].name}</div>
      <div class="small muted">×${itemCount(k)}</div></div></div>`).join('') ||
    '<div class="muted small">Your satchel is empty. Buy supplies above.</div>';
}
function buy(key, qty) {
  const it = ITEMS[key];
  const cost = it.price * qty;
  if (S.shards < cost) { Synth.error(); toast('Not enough Aether Shards. You need ◆' + fmt(cost - S.shards) + ' more.'); return; }
  S.shards -= cost;
  giveItem(key, qty);
  updateHUD(); renderShop();
  Synth.shard();
  toast(`Purchased ${qty}× ${it.name}`);
}

/* ============================= BATTLE ENGINE =========================== */
const B = {
  on: false, kind: 'wild', cfg: null, team: [], eIdx: 0, pIdx: 0,
  result: null, busy: false, resolveChoice: null, turn: 0,
  midSaid: false, accent: '#8b5cf6'
};
function hexToRgb(hex) {
  const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '#8b5cf6');
  return m ? `${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)}` : '139,92,246';
}
const pActive = () => S.party[B.pIdx];
const eActive = () => B.team[B.eIdx];
function buffOf(m, stat) { return (m.buffs && m.buffs[stat]) || 1; }
function chipsOf(m) {
  let out = '';
  const names = { burn: 'Burned', venom: 'Venomed', chill: 'Chilled', stun: 'Stunned' };
  ['burn', 'venom', 'chill', 'stun'].forEach(k => {
    if (m.status && m.status[k]) out += `<span class="schip ${k}">${names[k]}</span>`;
  });
  if (m.buffs) {
    if (m.buffs.atk > 1.01) out += '<span class="schip buff">ATK ▲</span>';
    if (m.buffs.def > 1.01) out += '<span class="schip buff">DEF ▲</span>';
    if (m.buffs.spd > 1.01) out += '<span class="schip buff">SPD ▲</span>';
  }
  return out;
}
function resetBattleTemps(m) {
  m.buffs = { atk: 1, def: 1, spd: 1 };
  m.status = m.status || {};
}

/* --------------------------- rendering --------------------------------- */
function infoPanelHTML(m, isEnemy) {
  const sp = spOf(m);
  const need = xpNeeded(m.level);
  return `<div class="row1">
      <div class="nm t-${sp.type}" style="color:#fff">${esc(sp.name)}</div>
      <div class="lv">Lv ${m.level}</div>
    </div>
    <div class="hpline">${barHTML(m)}<span class="hpnum">${m.curHP}/${m.maxHP}</span></div>
    ${isEnemy ? '' : `<div class="xpline">${`<div class="bar xp"><i style="width:${(100 * m.xp / need).toFixed(1)}%"></i></div>`}
      <div class="small muted" style="margin-top:4px;font-size:10px">XP ${m.xp} / ${need}</div></div>`}
    <div class="status-chips">${chipsOf(m)}</div>
    <div class="party-dots">${B.team.map((t, i) => `<div class="pdot ${isEnemy ? (i === B.eIdx ? 'act' : '') + (t.curHP > 0 ? ' alive' : ' dead') : ''}"></div>`).join('')}</div>`;
}
function playerPanelHTML(m) {
  const sp = spOf(m);
  const need = xpNeeded(m.level);
  return `<div class="row1">
      <div class="nm">${esc(sp.name)}</div>
      <div class="lv">Lv ${m.level}</div>
    </div>
    <div class="hpline">${barHTML(m)}<span class="hpnum">${m.curHP}/${m.maxHP}</span></div>
    <div class="xpline"><div class="bar xp"><i style="width:${(100 * m.xp / need).toFixed(1)}%"></i></div></div>
    <div class="status-chips">${chipsOf(m)}</div>
    <div class="party-dots">${S.party.map((t, i) => `<div class="pdot ${i === B.pIdx ? 'act' : ''} ${t.curHP > 0 ? 'alive' : 'dead'}"></div>`).join('')}</div>`;
}
function renderBattleUI(full) {
  const e = eActive(), p = pActive();
  const eSP = spOf(e), pSP = spOf(p);
  document.documentElement.style.setProperty('--aelrgb', hexToRgb(B.accent));
  $('#enemyPanel').className = 'info-panel glass t-' + eSP.type;
  $('#enemyPanel').innerHTML = `<div class="row1">
      <div class="nm">${esc(eSP.name)}</div><div class="lv">Lv ${e.level}</div></div>
    <div class="hpline">${barHTML(e)}<span class="hpnum">${e.curHP}/${e.maxHP}</span></div>
    <div class="status-chips">${chipsOf(e)}</div>
    <div class="party-dots">${B.team.map((t, i) => `<div class="pdot ${i === B.eIdx ? 'act' : ''} ${t.curHP > 0 ? 'alive' : 'dead'}"></div>`).join('')}</div>`;
  $('#playerPanel').className = 'info-panel glass t-' + pSP.type;
  $('#playerPanel').innerHTML = playerPanelHTML(p);
  if (full) {
    $('#enemySprite').innerHTML = spriteSVG(eSP, 158);
    $('#enemySprite').className = 'sprite-wrap enemy-bob t-' + eSP.type;
    $('#playerSprite').innerHTML = spriteSVG(pSP, 178);
    $('#playerSprite').className = 'sprite-wrap bob t-' + pSP.type;
    const eC = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[eSP.type].glow)},.45), transparent 70%)`;
    const pC = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[pSP.type].glow)},.45), transparent 70%)`;
    $('#platEnemy').style.background = eC;
    $('#platPlayer').style.background = pC;
    spawnDrift($('#enemySprite'), TYPE_COLORS[eSP.type].glow);
    spawnDrift($('#playerSprite'), TYPE_COLORS[pSP.type].glow);
  }
}
function swapSprites() {
  const e = eActive(), p = pActive();
  $('#enemySprite').innerHTML = spriteSVG(spOf(e), 158);
  $('#playerSprite').innerHTML = spriteSVG(spOf(p), 178);
  $('#platEnemy').style.background = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[spOf(e).type].glow)},.45), transparent 70%)`;
  $('#platPlayer').style.background = `radial-gradient(ellipse at center, rgba(${hexToRgb(TYPE_COLORS[spOf(p).type].glow)},.45), transparent 70%)`;
}
function spawnDrift(el, color) {
  const wrap = el;
  if (!wrap) return;
  for (let i = 0; i < 7; i++) {
    const d = document.createElement('div');
    d.className = 'drift';
    d.style.background = color;
    d.style.left = (10 + Math.random() * 80) + '%';
    d.style.top = (40 + Math.random() * 55) + '%';
    d.style.animationDelay = (Math.random() * 1.6).toFixed(2) + 's';
    d.style.animationDuration = (1.8 + Math.random() * 1.4).toFixed(2) + 's';
    d.style.boxShadow = '0 0 10px 3px ' + color;
    wrap.appendChild(d);
  }
}
function floatText(el, text, color, size) {
  const arena = $('#arena'); if (!arena || !el) return;
  const a = arena.getBoundingClientRect(), r = el.getBoundingClientRect();
  const d = document.createElement('div');
  d.className = 'float-dmg';
  d.textContent = text;
  d.style.color = color || '#fff';
  if (size) d.style.fontSize = size + 'px';
  d.style.left = (r.left - a.left + r.width / 2) + 'px';
  d.style.top = (r.top - a.top + r.height * 0.14) + 'px';
  arena.appendChild(d);
  setTimeout(() => d.remove(), 1250);
}
function sparkBurst(el, color, count) {
  const arena = $('#arena'); if (!arena || !el) return;
  const a = arena.getBoundingClientRect(), r = el.getBoundingClientRect();
  const cx = r.left - a.left + r.width / 2, cy = r.top - a.top + r.height / 2;
  for (let i = 0; i < (count || 14); i++) {
    const s = document.createElement('div');
    s.className = 'spark';
    const ang = Math.random() * Math.PI * 2, dist = 40 + Math.random() * 110;
    s.style.setProperty('--dx', (Math.cos(ang) * dist).toFixed(0) + 'px');
    s.style.setProperty('--dy', (Math.sin(ang) * dist).toFixed(0) + 'px');
    s.style.left = cx + 'px'; s.style.top = cy + 'px';
    s.style.background = color || '#fff';
    s.style.boxShadow = '0 0 12px 4px ' + (color || '#fff');
    s.style.width = s.style.height = (4 + Math.random() * 7).toFixed(1) + 'px';
    arena.appendChild(s);
    setTimeout(() => s.remove(), 800);
  }
}
function shakeArena() {
  const a = $('#arena');
  a.classList.remove('shake');
  void a.offsetWidth;
  a.classList.add('shake');
  setTimeout(() => a.classList.remove('shake'), 520);
}
function hitFlash(el, cls) {
  if (!el) return;
  el.classList.remove('hitflash', 'charge', 'fainted');
  void el.offsetWidth;
  el.classList.add(cls || 'hitflash');
}

/* ------------------------------ message -------------------------------- */
async function say(text, cls) {
  const el = $('#logbox');
  const past = el.querySelector('.past');
  const pastHTML = past ? past.outerHTML : '';
  el.innerHTML = pastHTML + `<div class="line ${cls || ''}">${text}<span class="cursor"></span></div>`;
  el.scrollTop = 0;
  const spd = (S.settings && S.settings.speed) || 1;
  const dur = spd >= 3 ? 60 : Math.min(1500, (240 + String(text).length * 13)) / spd;
  await sleep(dur);
}
function archive(line) {
  const el = $('#logbox');
  el.innerHTML = `<div class="past">${line}</div>`;
}

/* ------------------------------- menu ---------------------------------- */
function setMenu(html) { $('#battleMenu').innerHTML = html; }
function choose(x) {
  if (!B.resolveChoice || B.busy) return;
  const r = B.resolveChoice;
  B.resolveChoice = null;
  B.busy = true;
  setMenu('');
  r(x);
}
function waitChoice() {
  B.busy = false;
  return new Promise(res => { B.resolveChoice = res; });
}
function mainMenu() {
  const p = pActive();
  setMenu(`
    <button class="movebtn" data-act="attack"><span class="mn">⚔️ Attack</span><span class="ms">Choose one of ${p.moveCache.length} moves</span></button>
    <button class="movebtn" data-act="prisms"><span class="mn">🔷 Aether Prisms</span><span class="ms">${B.kind === 'wild' ? 'Attempt to capture' : 'Wild Resonants only'}</span></button>
    <button class="movebtn" data-act="exchange"><span class="mn">🔁 Exchange</span><span class="ms">Swap active Resonant</span></button>
    <button class="movebtn" data-act="inventory"><span class="mn">🎒 Inventory</span><span class="ms">Use a satchel item</span></button>
    ${B.kind === 'wild' ? '<button class="movebtn" data-act="flee" style="grid-column:1/-1"><span class="mn">🏃 Retreat</span><span class="ms">Attempt to leave the encounter</span></button>' : ''}
    <div class="battle-hint">Turn ${B.turn} · ${spOf(p).name} Lv ${p.level}</div>`);
  $$('#battleMenu [data-act]').forEach(b => {
    b.onmouseenter = () => Synth.hover();
    b.onclick = () => {
      const a = b.dataset.act;
      Synth.select();
      if (a === 'attack') moveMenu();
      else if (a === 'prisms') prismMenu();
      else if (a === 'exchange') exchangeMenu();
      else if (a === 'inventory') inventoryMenu();
      else if (a === 'flee') choose({ kind: 'flee' });
    };
  });
}
function backButton() { return `<button class="movebtn" data-back="1" style="grid-column:1/-1"><span class="mn">↩ Back</span><span class="ms">Return to the action menu</span></button>`; }
function wireBack() { $$('#battleMenu [data-back]').forEach(b => b.onmouseenter = () => Synth.hover(), b.onclick = () => { Synth.back(); mainMenu(); }); }
function moveMenu() {
  const p = pActive();
  setMenu(p.moveCache.map(k => {
    const m = MOVES[k];
    const eff = typeMod(m.element, spOf(eActive()).type);
    const tag = eff > 1.05 ? '<span style="color:#4ade80">▲</span>' : eff < 0.95 ? '<span style="color:#f87171">▼</span>' : '';
    return `<button class="movebtn t-${m.element}" data-move="${k}">
      <span class="mn"><span class="t-${m.element}">${m.name}</span>${tag}</span>
      <span class="ms"><span>PWR ${m.power || '—'}</span><span>ACC ${m.acc}</span><span>${m.element}</span></span>
    </button>`;
  }).join('') + backButton());
  $$('#battleMenu [data-move]').forEach(b => {
    b.onmouseenter = () => Synth.hover();
    b.onclick = () => { Synth.select(); choose({ kind: 'attack', move: b.dataset.move }); };
  });
  wireBack();
}
function prismMenu() {
  const keys = ['prism_basic', 'prism_hyper', 'prism_master'].filter(k => itemCount(k) > 0);
  if (!keys.length) {
    setMenu(`<div class="battle-hint" style="font-size:13px;color:#fca5a5">You have no Aether Prisms left. Visit the Camp Shop.</div>` + backButton());
    wireBack(); return;
  }
  setMenu(keys.map(k => {
    const it = ITEMS[k];
    return `<button class="movebtn" data-prism="${k}">
      <span class="mn">${it.icon} ${it.name}</span>
      <span class="ms"><span>Owned ${itemCount(k)}</span><span>×${it.mult.toFixed(1)} rate</span></span></button>`;
  }).join('') + backButton());
  $$('#battleMenu [data-prism]').forEach(b => {
    b.onmouseenter = () => Synth.hover();
    b.onclick = () => { Synth.select(); choose({ kind: 'capture', prism: b.dataset.prism }); };
  });
  wireBack();
}
function exchangeMenu() {
  const list = S.party.map((m, i) => ({ m, i })).filter(o => o.i !== B.pIdx && !isFainted(o.m));
  if (!list.length) {
    setMenu(`<div class="battle-hint" style="font-size:13px;color:#fca5a5">No conscious Resonants to exchange with.</div>` + backButton());
    wireBack(); return;
  }
  setMenu(list.map(o => {
    const sp = spOf(o.m);
    return `<button class="movebtn t-${sp.type}" data-sw="${o.i}">
      <span class="mn">${esc(sp.name)}</span>
      <span class="ms"><span>Lv ${o.m.level}</span><span>HP ${o.m.curHP}/${o.m.maxHP}</span></span></button>`;
  }).join('') + backButton());
  $$('#battleMenu [data-sw]').forEach(b => {
    b.onmouseenter = () => Synth.hover();
    b.onclick = () => { Synth.select(); choose({ kind: 'switch', idx: +b.dataset.sw }); };
  });
  wireBack();
}
function inventoryMenu() {
  const keys = ['restorer', 'restorer_max', 'elixir', 'reviver', 'charm'].filter(k => itemCount(k) > 0);
  if (!keys.length) {
    setMenu(`<div class="battle-hint" style="font-size:13px;color:#fca5a5">Your satchel is empty.</div>` + backButton());
    wireBack(); return;
  }
  setMenu(keys.map(k => {
    const it = ITEMS[k];
    return `<button class="movebtn" data-item="${k}">
      <span class="mn">${it.icon} ${it.name}</span>
      <span class="ms"><span>Owned ${itemCount(k)}</span><span>${it.kind}</span></span></button>`;
  }).join('') + backButton());
  $$('#battleMenu [data-item]').forEach(b => {
    b.onmouseenter = () => Synth.hover();
    b.onclick = () => { Synth.select(); choose({ kind: 'item', item: b.dataset.item }); };
  });
  wireBack();
}
function targetMenu(itemKey) {
  const alive = S.party.some(m => !isFainted(m));
  const list = (ITEMS[itemKey].kind === 'revive')
    ? S.party.map((m, i) => ({ m, i })).filter(o => isFainted(o.m))
    : S.party.map((m, i) => ({ m, i })).filter(o => !isFainted(o.m));
  if (!list.length) { Synth.error(); toast('No valid target for that item.'); mainMenu(); return; }
  setMenu(list.map(o => {
    const sp = spOf(o.m);
    return `<button class="movebtn t-${sp.type}" data-target="${o.i}">
      <span class="mn">${esc(sp.name)}</span>
      <span class="ms"><span>Lv ${o.m.level}</span><span>HP ${o.m.curHP}/${o.m.maxHP}</span></span></button>`;
  }).join('') + backButton());
  $$('#battleMenu [data-target]').forEach(b => {
    b.onmouseenter = () => Synth.hover();
    b.onclick = () => { Synth.select(); choose({ kind: 'item', item: itemKey, targetIdx: +b.dataset.target }); };
  });
  $$('#battleMenu [data-back]').forEach(b => b.onclick = () => { Synth.back(); inventoryMenu(); });
  $$('#battleMenu [data-back]').forEach(b => b.onmouseenter = () => Synth.hover());
}

/* ---------------------------- combat math ------------------------------ */
function calcDamage(att, def, move) {
  const atkStat = att.atk * buffOf(att, 'atk');
  const defStat = Math.max(1, def.def * buffOf(def, 'def'));
  const base = ((2 * att.level / 5 + 2) * move.power * (atkStat / defStat)) / 50 + 2;
  const tm = typeMod(move.element, spOf(def).type);
  const crit = Math.random() < 0.1;
  const variance = rnd(0.85, 1.0);
  const dmg = Math.max(1, Math.floor(base * tm * variance * (crit ? 1.5 : 1)));
  return { dmg, tm, crit };
}
async function dealDamage(att, def, move, attIsPlayer) {
  const attEl = attIsPlayer ? $('#playerSprite') : $('#enemySprite');
  const defEl = attIsPlayer ? $('#enemySprite') : $('#playerSprite');
  const moveColor = TYPE_COLORS[move.element].glow;
  await say(`<b>${esc(spOf(att).name)}</b> used <b>${move.name}</b>!`);
  hitFlash(attEl, 'charge');
  await sleep(230);
  const { dmg, tm, crit } = calcDamage(att, def, move);
  const missed = Math.random() * 100 > move.acc;
  if (missed) {
    Synth.weak();
    await say(`…but the resonance scattered — it missed!`);
    return { missed: true };
  }
  sparkBurst(defEl, moveColor, tm > 1.05 ? 24 : 13);
  hitFlash(defEl, 'hitflash');
  if (tm > 1.05) Synth.superHit(); else if (tm < 0.95) Synth.weak(); else Synth.hit();
  if (crit) Synth.crit();
  if (crit || tm > 1.05) { shakeArena(); flashScreen(moveColor, crit ? 380 : 240); }
  floatText(defEl, String(dmg), crit ? '#fde047' : (tm > 1.05 ? '#4ade80' : '#fff'), crit ? 26 : 19);
  def.curHP = Math.max(0, def.curHP - dmg);
  if (crit) await say(`A critical resonance! <b>${dmg}</b> damage!`, 'crit');
  else await say(`It dealt <b>${dmg}</b> damage.`);
  const mult = typeMultText(tm);
  if (mult) await say(mult);
  /* effects */
  if (move.effect) await applyMoveEffect(att, def, move, attIsPlayer);
  if (def.curHP <= 0) {
    Synth.faint();
    hitFlash(defEl, 'fainted');
    await sleep(560);
    await say(`<b>${esc(spOf(def).name)}</b> fainted!`);
  }
  renderBattleUI(false);
  return { missed: false, dmg };
}
async function applyMoveEffect(att, def, move, attIsPlayer) {
  const e = move.effect;
  if (e.status && def.curHP > 0 && Math.random() < (e.chance || 0.2)) {
    if (!def.status) def.status = {};
    def.status[e.status] = 3 + rndInt(0, 2);
    Synth.noise({ freq: 1400, dur: 0.24, vol: 0.2 });
    await say(`<b>${esc(spOf(def).name)}</b> is now <b>${({ burn: 'burned', venom: 'venomed', chill: 'chilled', stun: 'stunned' })[e.status]}</b>!`);
    spawnDrift(attIsPlayer ? $('#enemySprite') : $('#playerSprite'), e.status === 'burn' ? '#fb923c' : e.status === 'venom' ? '#a3e635' : e.status === 'stun' ? '#fde047' : '#7dd3fc');
  }
  if (e.buff) {
    att.buffs = att.buffs || { atk: 1, def: 1, spd: 1 };
    att.buffs[e.buff.stat] = Math.max(att.buffs[e.buff.stat], e.buff.mult);
    Synth.levelUp();
    floatText(attIsPlayer ? $('#playerSprite') : $('#enemySprite'), e.buff.stat.toUpperCase() + ' ▲', '#c4b5fd', 15);
    await say(`<b>${esc(spOf(att).name)}</b>'s ${e.buff.stat.toUpperCase()} rose sharply!`);
  }
  if (e.heal) {
    const amt = Math.ceil(att.maxHP * e.heal);
    const before = att.curHP;
    att.curHP = clamp(att.curHP + amt, 0, att.maxHP);
    Synth.heal();
    floatText(attIsPlayer ? $('#playerSprite') : $('#enemySprite'), '+' + (att.curHP - before), '#86efac', 18);
    await say(`<b>${esc(spOf(att).name)}</b> restored <b>${att.curHP - before}</b> HP.`);
  }
  if (e.drain && def.curHP >= 0) {
    const amt = Math.ceil(att.maxHP * e.drain);
    const before = att.curHP;
    att.curHP = clamp(att.curHP + amt, 0, att.maxHP);
    Synth.heal();
    if (att.curHP > before) {
      floatText(attIsPlayer ? $('#playerSprite') : $('#enemySprite'), '+' + (att.curHP - before), '#86efac', 18);
      await say(`<b>${esc(spOf(att).name)}</b> drained the resonance, restoring <b>${att.curHP - before}</b> HP.`);
    }
  }
}

/* --------------------------- enemy behaviour --------------------------- */
function enemyBestMove(e, p) {
  const options = e.moveCache.map(k => MOVES[k]);
  const offensive = options.filter(m => m.power > 0);
  const support = options.filter(m => m.power === 0);
  const hurt = e.curHP / e.maxHP < 0.35;
  const healMove = support.find(m => m.effect && m.effect.heal);
  if (hurt && healMove && Math.random() < 0.6) return options.indexOf(healMove) >= 0 ? options[options.indexOf(healMove)] : healMove;
  const buffMove = support.find(m => m.effect && m.effect.buff);
  if (!hurt && buffMove && Math.random() < 0.28) return buffMove;
  if (!offensive.length) return options[0] || MOVES.struggle;
  if (Math.random() < 0.2) return pick(offensive);
  let best = offensive[0], bestScore = -1;
  offensive.forEach(m => {
    const tm = typeMod(m.element, spOf(p).type);
    const score = m.power * m.acc / 100 * tm * m.power / 50;
    if (score > bestScore) { bestScore = score; best = m; }
  });
  return best;
}
async function enemyTurn() {
  const e = eActive(), p = pActive();
  if (isFainted(e) || isFainted(p)) return;
  if (e.status && e.status.stun && Math.random() < 0.3) {
    Synth.weak();
    await say(`<b>${esc(spOf(e).name)}</b> is paralysed and cannot move!`);
    return;
  }
  const move = enemyBestMove(e, p);
  await dealDamage(e, p, move, false);
}
async function runStatusTicks() {
  const list = [{ m: pActive(), isP: true }, { m: eActive(), isP: false }];
  for (const { m, isP } of list) {
    if (isFainted(m) || !m.status) continue;
    if (m.status.burn) {
      const d = Math.max(1, Math.floor(m.maxHP / 16));
      m.curHP = Math.max(0, m.curHP - d);
      Synth.noise({ freq: 400, dur: 0.2, vol: 0.18 });
      floatText(isP ? $('#playerSprite') : $('#enemySprite'), '-' + d, '#fb923c', 16);
      await say(`<b>${esc(spOf(m).name)}</b> is hurt by its burn! (-${d} HP)`);
    }
    if (m.status.venom) {
      const d = Math.max(1, Math.floor(m.maxHP / 12));
      m.curHP = Math.max(0, m.curHP - d);
      Synth.noise({ freq: 300, dur: 0.2, vol: 0.18 });
      floatText(isP ? $('#playerSprite') : $('#enemySprite'), '-' + d, '#a3e635', 16);
      await say(`<b>${esc(spOf(m).name)}</b> is seared by venom! (-${d} HP)`);
    }
    ['burn', 'venom', 'chill', 'stun'].forEach(k => {
      if (m.status && m.status[k] != null) {
        m.status[k] = (typeof m.status[k] === 'number' ? m.status[k] - 1 : m.status[k]);
        if (m.status[k] <= 0) delete m.status[k];
      }
    });
    if (m.curHP <= 0) {
      Synth.faint();
      hitFlash(isP ? $('#playerSprite') : $('#enemySprite'), 'fainted');
      await sleep(480);
      await say(`<b>${esc(spOf(m).name)}</b> succumbed to its condition!`);
    }
    renderBattleUI(false);
  }
}

/* ------------------------- capture sequence ---------------------------- */
async function attemptCapture(prismKey) {
  if (B.kind !== 'wild') {
    Synth.error();
    await say(`A Conductor's Resonant cannot be captured. The prism rejects the foreign harmony.`);
    return false;
  }
  if (!takeItem(prismKey, 1)) { Synth.error(); return false; }
  const e = eActive();
  const sp = spOf(e);
  const prism = prismSVG(74, TYPE_COLORS[sp.type].glow, hexToRgb(TYPE_COLORS[sp.type].glow));
  let rate = 0.15 + (1 - (2 * e.curHP) / (3 * e.maxHP)) * 0.75;
  rate *= ITEMS[prismKey].mult;
  if (e.status && Object.keys(e.status).length) rate *= 1.35;
  rate *= [1, 0.85, 0.62][sp.tier - 1] || 1;
  rate = clamp(rate, 0.03, 0.96);
  await say(`You hurl the <b>${ITEMS[prismKey].name}</b>!`);
  const arena = $('#arena');
  const holder = document.createElement('div');
  holder.className = 'prism-anim';
  holder.innerHTML = prism;
  holder.style.left = '76%';
  holder.style.top = '38%';
  arena.appendChild(holder);
  Synth.tone({ freq: 900, slideTo: 1500, dur: 0.2, type: 'triangle', vol: 0.2 });
  await sleep(820);
  holder.style.animation = 'none';
  const burst = document.createElement('div');
  burst.className = 'burst';
  burst.style.left = '76%'; burst.style.top = '38%';
  arena.appendChild(burst);
  hitFlash($('#enemySprite'), 'charge');
  e._captureIfCatch = true;
  $('#enemySprite').style.opacity = '0';
  setTimeout(() => burst.remove(), 900);
  await sleep(320);
  const caught = Math.random() < rate;
  const shakes = caught ? 3 : rndInt(1, 3);
  holder.classList.add('shakeit');
  for (let i = 0; i < shakes; i++) {
    Synth.captureShake();
    await sleep(430);
  }
  holder.classList.remove('shakeit');
  if (caught) {
    holder.classList.add('caught');
    for (let i = 0; i < 3; i++) {
      const d = document.createElement('div');
      d.className = 'burst';
      d.style.left = '76%'; d.style.top = '38%';
      d.style.borderColor = TYPE_COLORS[sp.type].glow;
      arena.appendChild(d);
      setTimeout(() => d.remove(), 900);
      await sleep(150);
    }
    Synth.captureSuccess();
    flashScreen(TYPE_COLORS[sp.type].glow, 420);
    await say(`<b>${esc(sp.name)}</b> was harmonized! The prism holds its frequency safely.`, 'crit');
    markCaught(e.key);
    e.curHP = Math.max(1, e.curHP);
    addToRoster(e);
    B.result = 'caught';
    setTimeout(() => holder.remove(), 900);
    return true;
  } else {
    Synth.captureFail();
    $('#enemySprite').style.opacity = '';
    hitFlash($('#enemySprite'), 'hitflash');
    await say(`The prism shattered — <b>${esc(sp.name)}</b> broke free!`);
    holder.remove();
    return false;
  }
}
function addToRoster(mon) {
  mon.buffs = { atk: 1, def: 1, spd: 1 };
  if (S.party.length < 3) {
    S.party.push(mon);
    toast(`<b>${esc(spOf(mon).name)}</b> joined your harmonic team!`, TYPE_COLORS[spOf(mon).type].glow);
  } else {
    S.vault.push(mon);
    toast(`<b>${esc(spOf(mon).name)}</b> was sent to the prism vault.`, TYPE_COLORS[spOf(mon).type].glow);
  }
  saveGame();
}

/* ---------------------------- turn resolution -------------------------- */
async function playerAttack(moveKey) {
  const p = pActive(), e = eActive();
  if (p.status && p.status.stun && Math.random() < 0.3) {
    Synth.weak();
    await say(`<b>${esc(spOf(p).name)}</b> is paralysed and cannot move!`);
    return;
  }
  await dealDamage(p, e, MOVES[moveKey], true);
}
async function doSwitch(idx) {
  const old = pActive();
  B.pIdx = idx;
  const now = pActive();
  Synth.open();
  floatText($('#playerSprite'), 'EXCHANGE!', '#c4b5fd', 14);
  await say(`<b>${esc(spOf(old).name)}</b>, fall back! <b>${esc(spOf(now).name)}</b>, resonate!`);
  resetBattleTemps(now);
  spawnDrift($('#playerSprite'), TYPE_COLORS[spOf(now).type].glow);
  renderBattleUI(true);
  $('#playerSprite').style.opacity = '';
}
async function useItemOn(itemKey, idx) {
  const it = ITEMS[itemKey];
  const target = S.party[idx] || pActive();
  if (!takeItem(itemKey, 1)) { Synth.error(); await say(`You have no ${it.name} left.`); return; }
  Synth.heal();
  if (it.kind === 'heal') {
    const healed = healMon(target, it.frac);
    floatText(target === pActive() ? $('#playerSprite') : $('#playerSprite'), '+' + healed, '#86efac', 18);
    await say(`You used <b>${it.name}</b>. <b>${esc(spOf(target).name)}</b> recovered <b>${healed}</b> HP.`);
  } else if (it.kind === 'status') {
    target.status = {};
    await say(`You used <b>${it.name}</b>. <b>${esc(spOf(target).name)}</b>'s frequency is clean again.`);
  } else if (it.kind === 'revive') {
    target.curHP = Math.max(1, Math.floor(target.maxHP / 2));
    if (target.status) target.status = {};
    await say(`You used <b>${it.name}</b>. <b>${esc(spOf(target).name)}</b> resonates once more!`);
  } else if (it.kind === 'buff') {
    const t = pActive();
    t.spd += 1;
    await say(`You used <b>${it.name}</b>. <b>${esc(spOf(t).name)}</b>'s Speed permanently rose by 1!`);
  }
  renderBattleUI(true);
}
async function fleeAttempt() {
  const p = pActive(), e = eActive();
  const chance = clamp(0.45 + (p.spd - e.spd) / 120, 0.2, 0.95);
  if (Math.random() < chance) {
    Synth.back();
    await say('You slipped away on a backdraft of resonance.');
    B.result = 'flee';
  } else {
    Synth.error();
    await say('You could not break the resonance lock!');
  }
}
async function forceSwitchMenu() {
  const list = S.party.map((m, i) => ({ m, i })).filter(o => !isFainted(o.m));
  if (!list.length) return null;
  setMenu(`<div class="battle-hint" style="grid-column:1/-1;font-size:13px;color:#fca5a5">Your active Resonant has fainted — send out another!</div>` +
    list.map(o => {
      const sp = spOf(o.m);
      return `<button class="movebtn t-${sp.type}" data-forcesw="${o.i}">
        <span class="mn">${esc(sp.name)}</span>
        <span class="ms"><span>Lv ${o.m.level}</span><span>HP ${o.m.curHP}/${o.m.maxHP}</span></span></button>`;
    }).join(''));
  const res = await new Promise(r => {
    $$('#battleMenu [data-forcesw]').forEach(b => {
      b.onmouseenter = () => Synth.hover();
      b.onclick = () => { Synth.select(); r(+b.dataset.forcesw); };
    });
  });
  setMenu('');
  return res;
}
async function resolveTurn(choice) {
  B.turn++;
  const p = pActive(), e = eActive();
  const priority = k => (k === 'attack' ? 1 : 2);
  const order = [];
  order.push({ who: 'player', prio: priority(choice.kind) });
  order.push({ who: 'enemy', prio: 1 });
  if (order[0].prio === order[1].prio) {
    const pSpd = p.spd * buffOf(p, 'spd') * (p.status && p.status.chill ? 0.5 : 1);
    const eSpd = e.spd * buffOf(e, 'spd') * (e.status && e.status.chill ? 0.5 : 1);
    if (eSpd > pSpd) order.reverse();
  }
  for (const step of order) {
    if (B.result) break;
    if (step.who === 'player') {
      if (isFainted(pActive())) continue;
      if (choice.kind === 'attack') await playerAttack(choice.move);
      else if (choice.kind === 'capture') await attemptCapture(choice.prism);
      else if (choice.kind === 'switch') await doSwitch(choice.idx);
      else if (choice.kind === 'item') await useItemOn(choice.item, choice.targetIdx);
      else if (choice.kind === 'flee') await fleeAttempt();
    } else {
      if (!isFainted(eActive()) && !isFainted(pActive())) {
        if (B.kind === 'boss' && !B.midSaid && eActive().curHP / eActive().maxHP < 0.5 && B.cfg.midLines) {
          B.midSaid = true;
          await say(pick(B.cfg.midLines));
        }
        await enemyTurn();
      }
    }
    if (B.result) break;
    /* faint resolution */
    if (isFainted(eActive())) {
      await handleEnemyFaint();
      if (B.result) break;
    }
    if (isFainted(pActive())) {
      const ok = await handlePlayerFaint();
      if (!ok) break;
    }
  }
  if (B.result) return;
  await runStatusTicks();
  if (isFainted(eActive())) { await handleEnemyFaint(); if (B.result) return; }
  if (isFainted(pActive())) { await handlePlayerFaint(); if (B.result) return; }
}
async function handleEnemyFaint() {
  const e = eActive();
  markSeen(e.key);
  await awardXP(e);
  if (B.eIdx + 1 < B.team.length) {
    B.eIdx++;
    resetBattleTemps(eActive());
    renderBattleUI(true);
    const nm = B.cfg && B.cfg.trainerName;
    if (nm) await say(`<b>${esc(nm)}</b> sends out <b>${esc(spOf(eActive()).name)}</b>!`);
    else await say(`<b>${esc(spOf(eActive()).name)}</b> enters the field!`);
    Synth.open();
  } else {
    B.result = 'win';
  }
}
async function handlePlayerFaint() {
  if (!teamAlive(S.party)) {
    B.result = 'lose';
    return false;
  }
  const idx = await forceSwitchMenu();
  if (idx == null) { B.result = 'lose'; return false; }
  B.pIdx = idx;
  resetBattleTemps(pActive());
  renderBattleUI(true);
  await say(`Go, <b>${esc(spOf(pActive()).name)}</b>!`);
  return true;
}
async function awardXP(enemyMon) {
  const p = pActive();
  if (isFainted(p)) return;
  const gain = xpGainFor(enemyMon, B.kind !== 'wild');
  const evts = applyXP(p, gain);
  await say(`<b>${esc(spOf(p).name)}</b> gained <b>${gain}</b> XP.`);
  S.party.forEach(m => { if (m !== p && !isFainted(m)) applyXP(m, Math.floor(gain * 0.45)); });
  for (const ev of evts) {
    if (ev.type === 'level') {
      Synth.levelUp();
      banner(`LEVEL UP!  Lv ${ev.level}`);
      await say(`<b>${esc(spOf(p).name)}</b> reached <b>level ${ev.level}</b>!`, 'crit');
      await sleep(700);
    } else if (ev.type === 'evolve') {
      Synth.evolve();
      flashScreen('#fff', 700);
      banner('EVOLUTION!');
      await say(`What? <b>${esc(spOf(p).name)}</b> is evolving!`);
      await sleep(500);
      hitFlash($('#playerSprite'), 'charge');
      renderBattleUI(true);
      await sleep(900);
      await say(`It evolved into <b>${esc(ev.into)}</b>!`, 'crit');
      markSeen(p.key);
      markCaught(p.key);
    }
  }
  renderBattleUI(false);
}
function banner(text) {
  const d = document.createElement('div');
  d.className = 'lvlup-banner';
  d.innerHTML = text;
  $('#arena').appendChild(d);
  setTimeout(() => d.remove(), 2300);
}

/* ------------------------------ main loop ------------------------------ */
async function runBattle(cfg) {
  if (B.on) return;
  if (!teamAlive(S.party)) {
    S.party.forEach(m => { healMon(m, 1); m.status = {}; });
    toast('Your exhausted team nursed itself back at camp.');
  }
  B.on = true; B.cfg = cfg; B.kind = cfg.kind; B.team = cfg.team;
  B.eIdx = 0; B.result = null; B.busy = true; B.turn = 0; B.midSaid = false;
  B.accent = cfg.accent || '#8b5cf6';
  B.pIdx = Math.max(0, S.party.findIndex(m => !isFainted(m)));
  S.party.forEach(resetBattleTemps);
  B.team.forEach(resetBattleTemps);
  markSeen(eActive().key);
  $('#sparks').innerHTML = '';
  showScreen('screen-battle');
  $('#logbox').innerHTML = '<div class="past">The air thickens with resonance…</div>';
  setMenu('<div class="battle-hint" style="font-size:13px">⌁ Tuning frequencies… ⌁</div>');
  renderBattleUI(true);

  if (cfg.kind === 'wild') {
    Synth.noise({ freq: 1200, sweep: 300, dur: 0.4, vol: 0.3 });
    await say(`A wild <b>${esc(spOf(eActive()).name)}</b> (Lv ${eActive().level}) appeared!`, 'crit');
  } else {
    Synth.bossFanfare();
    for (const line of (cfg.intro || [])) await say(line);
    await say(`<b>${esc(cfg.trainerName)}</b> challenges you to a ${cfg.team.length}-on-3 resonance duel!`);
  }
  await say(`Go, <b>${esc(spOf(pActive()).name)}</b>!`);
  spawnDrift($('#playerSprite'), TYPE_COLORS[spOf(pActive()).type].glow);
  renderBattleUI(false);

  while (!B.result) {
    mainMenu();
    const choice = await waitChoice();
    if (choice.kind === 'prisms' || choice.kind === 'exchange' || choice.kind === 'inventory') continue;
    if (choice.kind === 'item') {
      if (!ITEMS[choice.item] || itemCount(choice.item) <= 0) { continue; }
      if (choice.targetIdx == null) { targetMenu(choice.item); continue; }
    }
    B.busy = true;
    archive(`Turn ${B.turn + 1} — ${esc(spOf(pActive()).name)} Lv ${pActive().level} vs ${esc(spOf(eActive()).name)} Lv ${eActive().level}`);
    await resolveTurn(choice);
    B.busy = false;
  }
  await finishBattle();
}
async function finishBattle() {
  const cfg = B.cfg;
  setMenu('');
  const r = B.result;
  if (r === 'lose') {
    Synth.faint();
    for (const l of (cfg.loseLines || [])) await say(l);
    await say('Your team is carried back to camp. The trail will still be here.');
    const lost = Math.floor(S.shards * 0.1);
    S.shards = Math.max(0, S.shards - lost);
    S.party.forEach(m => { m.curHP = Math.max(1, Math.floor(m.maxHP * 0.6)); if (m.status) m.status = {}; });
    saveGame();
    toast('You blacked out. Lost ◆' + fmt(lost) + ' in the retreat.');
  } else if (r === 'win') {
    Synth.levelUp();
    if (cfg.kind !== 'wild') {
      for (const l of (cfg.winLines || [])) await say(l);
    } else {
      await say('The wild resonance settles. You won!');
    }
    if (cfg.reward) {
      if (cfg.reward.shards) { S.shards += cfg.reward.shards; Synth.shard(); toast('Earned ◆' + fmt(cfg.reward.shards) + '!'); }
      if (cfg.reward.item) { giveItem(cfg.reward.item, cfg.reward.qty || 1); toast('Received ' + (cfg.reward.qty || 1) + '× ' + ITEMS[cfg.reward.item].name + '!'); }
    }
    if (cfg.onVictory) cfg.onVictory();
    saveGame();
  } else if (r === 'caught') {
    await say('The new Resonant hums softly inside its prism.');
    if (cfg.onVictory) cfg.onVictory();
    saveGame();
  } else if (r === 'flee') {
    await say('You retreated safely.');
  }
  await sleep(320);
  const finale = cfg.kind === 'boss' && B.cfg.bossIndex === 3 && r === 'win';
  B.on = false; B.result = null; B.team = []; B.cfg = null;
  S.party.forEach(m => { if (m.status) m.status = {}; });
  saveGame();
  if (finale) { showScreen('screen-map'); renderMap(); showVictory(); return; }
  showScreen('screen-map');
  renderMap();
}

/* ============================ TITLE / MENU ============================= */
function playtimeText() {
  const t = (S && S.playtime) || 0;
  const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
  return (h ? h + 'h ' : '') + m + 'm ' + s + 's';
}
function refreshTitle() {
  const save = loadGame();
  const btn = $('#btnContinue');
  if (save) {
    btn.classList.add('primary');
    btn.disabled = false;
    const lead = save.party[0] ? DEX_BY_KEY[save.party[0].key].name : '—';
    $('#titleSaveInfo').innerHTML = `Saved run · Lead <b style="color:#c4b5fd">${esc(lead)}</b> Lv ${save.party[0] ? save.party[0].level : '?'} · <b style="color:#7de3f7">◆${fmt(save.shards)}</b> · <b>${save.sigils}/4</b> sigils · ${Math.floor(save.playtime / 60)}m played`;
  } else {
    btn.classList.remove('primary');
    btn.disabled = true;
    $('#titleSaveInfo').textContent = 'No saved run found — begin a new journey.';
  }
}
function renderStarters() {
  $('#starterGrid').innerHTML = STARTERS.map(k => {
    const s = DEX_BY_KEY[k];
    const st = statsFor(s, 5);
    return `<div class="shopcard glass t-${s.type}">
      <div style="text-align:center">${spriteSVG(s, 108)}</div>
      <div style="text-align:center"><h3>${esc(s.name)}</h3>
        <div class="mt8">${typeBadge(s.type)}</div></div>
      <p>${esc(s.flavor)}</p>
      <div class="small muted" style="font-variant-numeric:tabular-nums">HP ${st.maxHP} · ATK ${st.atk} · DEF ${st.def} · SPD ${st.spd}<br>
        Evolves at Lv ${s.evoLevel} into ${esc(DEX_BY_KEY[s.evolvesTo].name)}</div>
      <button class="btn primary wide" data-starter="${k}">Choose ${esc(s.name)}</button>
    </div>`;
  }).join('');
  $$('[data-starter]').forEach(b => {
    b.onmouseenter = () => Synth.hover();
    b.onclick = () => beginJourney(b.dataset.starter);
  });
}
function beginJourney(key) {
  S = defaultState();
  const starter = makeMon(key, 5);
  S.party.push(starter);
  markCaught(key);
  S.seen[key] = 1;
  saveGame();
  Synth.captureSuccess();
  activeZone = 0;
  renderMap();
  showScreen('screen-map');
  const sp = DEX_BY_KEY[key];
  openModal(`<h2>PROFESSOR HARMONIA</h2>
    <p class="dialog-line">"Ah — <b>${esc(sp.name)}</b>. A warm frequency. I can hear it from here."</p>
    <p class="dialog-line">"Aethelgard is stitched together by six elemental frequencies. When they clash, spirits form — we call them <b>Resonants</b>."</p>
    <p class="dialog-line">"You carry three Aether Prisms and a satchel of supplies. Walk the trail, capture what answers you, and climb toward the <b>Apex Pillar</b>."</p>
    <p class="dialog-line">"Clear each zone's <b>Gatekeeper</b> to open the next. And mind the springs — they mend everything."</p>
    <div class="modal-actions">
      <button class="btn" onclick="closeModal();showHelp();">How to Play</button>
      <button class="btn primary" onclick="closeModal();">Set off</button>
    </div>`);
}
function showHelp() {
  openModal(`<h2>HOW TO PLAY</h2>
    <p class="dialog-line"><b>Explore.</b> Click nodes on a zone trail. 🌿 Wild encounters, 📦 Mystery caches, ⚔️ Wandering Conductors, ⛲ Healing springs, 👑 Zone Gatekeepers.</p>
    <p class="dialog-line"><b>Battle.</b> Turn-based 1v1. Speed decides who moves first; switching and items always resolve first.</p>
    <p class="dialog-line"><b>Type chart.</b> Pyro ▸ Flora ▸ Hydro ▸ Pyro · Zephyr ▸ Earth ▸ Volt ▸ Zephyr · Cosmos is neutral.</p>
    <p class="dialog-line"><b>Capture.</b> Weaken a wild Resonant first — the lower its HP and the rarer the prism, the better your odds. Status conditions help too.</p>
    <p class="dialog-line"><b>Grow.</b> Resonants evolve at level 8 and level 15, unlocking stronger moves as they level.</p>
    <p class="dialog-line"><b>Save.</b> Progress is stored automatically on every change.</p>
    <div class="modal-actions"><button class="btn primary" onclick="closeModal()">Understood</button></div>`);
}
function showMenuModal() {
  Synth.open();
  openModal(`<h2>CONDUCTOR MENU</h2>
    <div class="grid-2">
      <div class="glass" style="padding:14px">
        <div class="small muted">SIGILS</div><div style="font-size:22px;font-weight:800;color:#c4b5fd">${S.sigils}/4</div>
        <div class="small muted" style="margin-top:10px">AETHER SHARDS</div><div style="font-size:22px;font-weight:800;color:#7de3f7">◆ ${fmt(S.shards)}</div>
      </div>
      <div class="glass" style="padding:14px">
        <div class="small muted">WILD WINS</div><div style="font-size:22px;font-weight:800">${S.wildWins}</div>
        <div class="small muted" style="margin-top:10px">CONDUCTOR WINS</div><div style="font-size:22px;font-weight:800">${S.trainerWins}</div>
      </div>
    </div>
    <div class="small muted" style="margin:14px 0 4px">TIME PLAYED · ${playtimeText()}  ·  DEX ${Object.keys(S.caught).length}/18</div>
    <div class="modal-actions" style="justify-content:flex-start">
      <button class="btn" onclick="closeModal();openPartyScreen('screen-map')">⚗ Team</button>
      <button class="btn" onclick="closeModal();openDexScreen('screen-map')">📖 Dex</button>
      <button class="btn" onclick="closeModal();openShopScreen('screen-map')">🛒 Shop</button>
      <button class="btn" id="btnToggleSfx">🔊 Sound: ${S.settings.sfx ? 'On' : 'Off'}</button>
      <button class="btn" id="btnToggleSpeed">⏩ Text speed: ${S.settings.speed === 1 ? 'Normal' : S.settings.speed === 2 ? 'Fast' : 'Instant'}</button>
      <button class="btn" onclick="closeModal();saveGame();refreshTitle();showScreen('screen-title')">🏠 Save &amp; Title</button>
      <button class="btn danger" onclick="confirmReset()">⚠ Reset Progress</button>
    </div>`);
  $('#btnToggleSfx').onclick = () => {
    S.settings.sfx = !S.settings.sfx;
    Synth.enabled = S.settings.sfx;
    Synth.setVolume(S.settings.sfx ? 0.35 : 0);
    saveGame();
    showMenuModal();
  };
  $('#btnToggleSpeed').onclick = () => {
    S.settings.speed = S.settings.speed === 1 ? 2 : (S.settings.speed === 2 ? 3 : 1);
    saveGame();
    showMenuModal();
  };
}
function confirmReset() {
  openModal(`<h2>RESET PROGRESS</h2>
    <p class="dialog-line">This erases your team, your shards, your sigils and every dex entry. <b>This cannot be undone.</b></p>
    <div class="modal-actions">
      <button class="btn" onclick="closeModal()">Cancel</button>
      <button class="btn danger" id="btnDoReset">Erase everything</button>
    </div>`);
  $('#btnDoReset').onclick = () => {
    try { localStorage.removeItem(SAVE_KEY); } catch (e) { }
    S = null;
    B.on = false;
    closeModal();
    refreshTitle();
    showScreen('screen-title');
    toast('Progress erased. A clean frequency.');
  };
}
function showVictory() {
  Synth.captureSuccess();
  openModal(`<h2>APEX CONDUCTOR</h2>
    <div style="text-align:center">${prismSVG(96, '#c084fc', '192,132,252')}</div>
    <p class="dialog-line">The six frequencies settle into a single, quiet chord. Below you, Aethelgard hums in tune.</p>
    <p class="dialog-line">You defeated every Gatekeeper, harmonized with the Apex Sovereign, and recorded <b>${Object.keys(S.caught).length}/18</b> Resonants in the Conductor's Dex.</p>
    <p class="dialog-line">The trail is still open. Wilds still wander, and prisms still fill.</p>
    <div class="small muted" style="margin:10px 0">Time played · ${playtimeText()} · Wild wins · ${S.wildWins} · Conductor wins · ${S.trainerWins}</div>
    <div class="modal-actions">
      <button class="btn" onclick="closeModal();showMenuModal()">Conductor Menu</button>
      <button class="btn primary" onclick="closeModal()">Keep exploring</button>
    </div>`);
}

/* ================================ INIT ================================= */
let lastHoverTarget = null;
function wireGlobal() {
  document.addEventListener('mouseover', e => {
    const t = e.target.closest ? e.target.closest('.btn,.icon-btn,.node,.dex-card,.movebtn,.zone-item,.shopcard,.party-slot') : null;
    if (t && t !== lastHoverTarget && !t.disabled) { lastHoverTarget = t; Synth.resume(); Synth.hover(); }
    if (!t) lastHoverTarget = null;
  });
  const unlock = () => { Synth.resume(); };
  window.addEventListener('pointerdown', unlock, { once: true });
  window.addEventListener('keydown', unlock, { once: true });
  document.addEventListener('keydown', e => {
    if (e.key === 'Escape') { if ($('#modal-root').classList.contains('on')) closeModal(); }
    if (e.key === 'm' || e.key === 'M') {
      S.settings.sfx = !S.settings.sfx;
      Synth.enabled = S.settings.sfx;
      Synth.setVolume(S.settings.sfx ? 0.35 : 0);
      saveGame(); toast('Sound ' + (S.settings.sfx ? 'on' : 'off'));
    }
  });
  /* title screen */
  $('#btnNewGame').onclick = () => { Synth.select(); renderStarters(); showScreen('screen-starter'); };
  $('#btnContinue').onclick = () => {
    const data = loadGame();
    if (!data) { Synth.error(); toast('No saved run found.'); return; }
    S = data;
    S.items = S.items || {};
    S.settings = S.settings || { sfx: true, speed: 1 };
    S.seen = S.seen || {}; S.caught = S.caught || {}; S.vault = S.vault || [];
    S.cleared = S.cleared || {}; S.visited = S.visited || {};
    S.party.forEach(m => { if (!m.moveCache) recalcMon(m); m.buffs = { atk: 1, def: 1, spd: 1 }; });
    Synth.enabled = !!S.settings.sfx; Synth.setVolume(S.settings.sfx ? 0.35 : 0);
    activeZone = clamp(S.zonesUnlocked - 1, 0, 3);
    renderMap();
    showScreen('screen-map');
    Synth.captureSuccess();
    toast('Welcome back, Conductor.');
  };
  $('#btnTitleDex').onclick = () => {
    if (!S) { S = defaultState(); S.__temp = true; }
    renderDex(); showScreen('screen-dex');
  };
  $('#btnTitleHelp').onclick = () => { Synth.select(); showHelp(); };
  $('#btnReset').onclick = () => { Synth.select(); confirmReset(); };
  $('#btnStarterBack').onclick = () => { Synth.back(); refreshTitle(); showScreen('screen-title'); };
  /* map nav */
  $('#btnOpenDex').onclick = () => openDexScreen('screen-map');
  $('#btnOpenParty').onclick = () => openPartyScreen('screen-map');
  $('#btnOpenShop').onclick = () => openShopScreen('screen-map');
  $('#btnOpenMenu').onclick = () => showMenuModal();
  $('#btnHealHint').onclick = () => { Synth.select(); toast('⛲ Springs fully heal and cleanse your whole roster.'); };
  /* sub screens */
  $('#btnDexBack').onclick = () => { Synth.back(); if (S && S.__temp) { S = null; showScreen('screen-title'); } else showScreen(partyReturn || 'screen-map'); };
  $('#btnPartyBack').onclick = () => { Synth.back(); showScreen(partyReturn || 'screen-map'); };
  $('#btnShopBack').onclick = () => { Synth.back(); showScreen(partyReturn || 'screen-map'); };
  $('#dexSearch').oninput = () => renderDex();
  $('#dexFilter').onchange = () => renderDex();
  $('#modal-root').onclick = e => { if (e.target.id === 'modal-root') closeModal(); };
  /* title letters animation */
  const tt = $('#titleText');
  tt.innerHTML = tt.textContent.split('').map((c, i) => `<span style="animation-delay:${(i * 0.055).toFixed(2)}s">${c}</span>`).join('');
}
function boot() {
  wireGlobal();
  refreshTitle();
  /* periodic autosave + playtime */
  setInterval(() => {
    if (S && !S.__temp && currentScreen !== 'screen-title') {
      S.playtime++;
      if (S.playtime % 15 === 0) saveGame();
    }
  }, 1000);
  window.addEventListener('beforeunload', () => { if (S && !S.__temp) saveGame(); });
  /* pre-render idle screens so they are ready */
  console.log('%cAethelgard ready', 'color:#c084fc;font-weight:bold', '— 18 Resonants, 4 zones, 4 Gatekeepers.');
}
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"631a6372-e266-4af9-afc1-bf1d2d596ccb","tokensIn":5721196,"tokensOut":98422,"tokensTotal":5819618,"cost":0.08921570399999999,"turns":45,"toolCalls":44,"failedToolCalls":0,"timestamp":"2026-09-11T00:51:22.487Z"} -->