Claude Opus 4.8 (max)
AnthropicMay 2026
What it wrote
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#070912">
<title>Astral Foundry — Forge the Cosmos</title>
<meta name="description" content="An incremental game. Draw aether from the void, forge cosmic structures, and collapse universes for dark matter.">
<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=Cinzel:wght@400;500;600;700&family=Jost:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Atmosphere layers -->
<canvas id="starfield" aria-hidden="true"></canvas>
<div class="atmos-vignette" aria-hidden="true"></div>
<div class="atmos-grain" aria-hidden="true"></div>
<!-- Top bar -->
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32"><path d="M16 1 L19 13 L31 16 L19 19 L16 31 L13 19 L1 16 L13 13 Z"/></svg>
</span>
<span class="brand-name">Astral Foundry</span>
</div>
<div class="topbar-stats">
<div class="tb-stat" id="tb-darkmatter" title="Dark Matter — earned by collapsing universes">
<span class="tb-icon dm" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 2 L20 12 L12 22 L4 12 Z"/></svg>
</span>
<span class="tb-val" data-dm-have>0</span>
<span class="tb-label">Dark Matter</span>
</div>
</div>
<div class="topbar-actions">
<button class="icon-btn" id="btn-help" title="How to play" aria-label="How to play">?</button>
<button class="icon-btn" id="btn-settings" title="Settings" aria-label="Settings">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 8.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7zM12 2l1.3 2.4 2.7-.5.6 2.7 2.4 1.3-.9 2.6.9 2.6-2.4 1.3-.6 2.7-2.7-.5L12 22l-1.3-2.4-2.7.5-.6-2.7L5 16.1l.9-2.6L5 10.9l2.4-1.3.6-2.7 2.7.5z"/></svg>
</button>
</div>
</header>
<!-- Main stage -->
<main class="stage">
<!-- LEFT: the Forge -->
<section class="forge-panel deco-frame" aria-label="The Forge">
<div class="forge-title">
<h1>Astral Foundry</h1>
<p class="tagline">Draw aether from the endless void</p>
</div>
<div class="readout">
<div class="aether-amount">
<span class="aether-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 1 L14 10 L23 12 L14 14 L12 23 L10 14 L1 12 L10 10 Z"/></svg>
</span>
<span class="aether-value" data-aether>0</span>
<span class="aether-unit">aether</span>
</div>
<div class="rate-line">
<span data-cps>0</span> per second
<span class="rate-sep">·</span>
<span data-click>+1</span> per draw
</div>
</div>
<!-- The clickable forge emblem -->
<div class="forge-wrap">
<button class="forge" id="forge" aria-label="Draw aether">
<span class="forge-burst" aria-hidden="true"></span>
<span class="forge-ring ring-1" aria-hidden="true"></span>
<span class="forge-ring ring-2" aria-hidden="true"><i class="orbiter"></i></span>
<span class="forge-rose" aria-hidden="true">
<svg viewBox="0 0 200 200">
<g class="rose-lines">
<circle cx="100" cy="100" r="72"/>
<circle cx="100" cy="100" r="54"/>
<path d="M100 22 L112 88 L178 100 L112 112 L100 178 L88 112 L22 100 L88 88 Z"/>
<path d="M100 40 L100 160 M40 100 L160 100 M58 58 L142 142 M142 58 L58 142"/>
</g>
</svg>
</span>
<span class="forge-core" aria-hidden="true"></span>
</button>
<div class="fx-layer" id="fx-layer" aria-hidden="true"></div>
</div>
<p class="forge-hint" data-hint>Click the core to draw your first aether.</p>
<!-- Collapse / prestige -->
<div class="collapse-block" id="collapse-block" hidden>
<div class="collapse-info">
Collapse the cosmos to claim
<strong><span data-dm-pending>0</span> ◆ Dark Matter</strong>
</div>
<button class="btn-collapse" id="btn-collapse">Collapse Universe</button>
</div>
</section>
<!-- RIGHT: operations -->
<section class="ops-panel" aria-label="Operations">
<nav class="tabs" role="tablist">
<button class="tab is-active" role="tab" data-tab="structures" aria-selected="true">Structures</button>
<button class="tab" role="tab" data-tab="upgrades">Upgrades <span class="tab-badge" data-badge="upgrades" hidden>0</span></button>
<button class="tab" role="tab" data-tab="ascension">Ascension</button>
<button class="tab" role="tab" data-tab="codex">Codex</button>
</nav>
<div class="tab-body">
<!-- STRUCTURES -->
<div class="tab-pane is-active" id="pane-structures" role="tabpanel">
<div class="buy-controls">
<span class="buy-label">Forge quantity</span>
<div class="seg" role="group" aria-label="Buy quantity">
<button class="seg-btn is-active" data-qty="1">×1</button>
<button class="seg-btn" data-qty="10">×10</button>
<button class="seg-btn" data-qty="100">×100</button>
<button class="seg-btn" data-qty="max">Max</button>
</div>
</div>
<div class="building-list" id="building-list"></div>
</div>
<!-- UPGRADES -->
<div class="tab-pane" id="pane-upgrades" role="tabpanel">
<p class="pane-intro">Permanent enhancements. Each is forged once.</p>
<div class="upgrade-grid" id="upgrade-grid"></div>
<p class="empty-note" id="upgrades-empty">No enhancements available yet. Keep forging.</p>
</div>
<!-- ASCENSION -->
<div class="tab-pane" id="pane-ascension" role="tabpanel">
<div class="ascend-head">
<div class="ascend-stat">
<span class="ascend-num" data-dm-have>0</span>
<span class="ascend-cap">Dark Matter held</span>
</div>
<div class="ascend-stat">
<span class="ascend-num" data-collapses>0</span>
<span class="ascend-cap">Collapses</span>
</div>
</div>
<p class="pane-intro">Dark Matter persists through every collapse. Spend it on eternal works.</p>
<div class="ascend-grid" id="ascend-grid"></div>
</div>
<!-- CODEX -->
<div class="tab-pane" id="pane-codex" role="tabpanel">
<div class="stats-grid" id="stats-grid"></div>
<h3 class="codex-h">Achievements <span class="codex-count" data-ach-count>0 / 0</span></h3>
<div class="ach-grid" id="ach-grid"></div>
</div>
</div>
</section>
</main>
<!-- Toasts -->
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<!-- Modal host -->
<div class="modal-host" id="modal-host" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal deco-frame" role="dialog" aria-modal="true">
<button class="modal-x" data-close aria-label="Close">✕</button>
<div class="modal-content" id="modal-content"></div>
</div>
</div>
<script src="game.js"></script>
</body>
</html>/* ============================================================
ASTRAL FOUNDRY
Art-deco celestial incremental game
============================================================ */
:root{
/* Void / surfaces */
--void-0:#05060f;
--void-1:#080b18;
--void-2:#0d1124;
--panel:rgba(16,21,42,.72);
--panel-2:rgba(22,28,54,.55);
--edge:rgba(232,196,122,.22);
--edge-soft:rgba(232,196,122,.12);
/* Metals & light */
--gold:#e8c47a;
--gold-bright:#f8e3ac;
--gold-deep:#b88f44;
--gold-glow:rgba(232,196,122,.55);
--astral:#6fe3d2;
--astral-deep:#2c8f86;
--violet:#b69cff;
--violet-deep:#6b54c4;
/* Ink */
--ink:#f3ead6;
--ink-dim:#b3a888;
--ink-faint:#766c56;
--ok:#94dca6;
--danger:#e88a8a;
--font-display:'Cinzel',Georgia,serif;
--font-ui:'Jost','Century Gothic',sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,monospace;
--r:14px;
--ease:cubic-bezier(.2,.7,.2,1);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:var(--font-ui);
color:var(--ink);
background:
radial-gradient(120% 90% at 50% -10%, #16203f 0%, var(--void-1) 42%, var(--void-0) 100%);
background-attachment:fixed;
overflow-x:hidden;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
letter-spacing:.01em;
}
button{font-family:inherit;color:inherit;cursor:pointer;border:none;background:none}
::selection{background:var(--gold);color:var(--void-0)}
/* Scrollbars */
*{scrollbar-width:thin;scrollbar-color:var(--gold-deep) transparent}
*::-webkit-scrollbar{width:9px;height:9px}
*::-webkit-scrollbar-thumb{background:linear-gradient(var(--gold-deep),var(--gold-deep));border-radius:9px;border:2px solid transparent;background-clip:content-box}
*::-webkit-scrollbar-track{background:transparent}
/* ---------- Atmosphere ---------- */
#starfield{position:fixed;inset:0;width:100%;height:100%;z-index:0;pointer-events:none}
.atmos-vignette{
position:fixed;inset:0;z-index:1;pointer-events:none;
background:radial-gradient(120% 100% at 50% 38%, transparent 52%, rgba(3,4,10,.72) 100%);
}
.atmos-grain{
position:fixed;inset:-50%;z-index:2;pointer-events:none;opacity:.05;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation:grain 6s steps(6) infinite;
}
@keyframes grain{
0%,100%{transform:translate(0,0)} 20%{transform:translate(-4%,2%)}
40%{transform:translate(3%,-3%)} 60%{transform:translate(-2%,4%)} 80%{transform:translate(4%,1%)}
}
/* ---------- Deco frame ---------- */
.deco-frame{position:relative}
.deco-frame::before,.deco-frame::after{
content:"";position:absolute;width:26px;height:26px;pointer-events:none;z-index:3;
}
.deco-frame::before{top:9px;left:9px;border-top:1px solid var(--gold);border-left:1px solid var(--gold)}
.deco-frame::after{bottom:9px;right:9px;border-bottom:1px solid var(--gold);border-right:1px solid var(--gold)}
/* ---------- Top bar ---------- */
.topbar{
position:relative;z-index:5;
display:flex;align-items:center;gap:18px;
padding:14px clamp(14px,3vw,34px);
border-bottom:1px solid var(--edge-soft);
background:linear-gradient(180deg, rgba(7,9,18,.85), rgba(7,9,18,.25));
backdrop-filter:blur(6px);
}
.brand{display:flex;align-items:center;gap:11px}
.brand-mark svg{width:26px;height:26px;fill:var(--gold);filter:drop-shadow(0 0 8px var(--gold-glow));animation:spin 36s linear infinite}
.brand-name{
font-family:var(--font-display);font-weight:600;letter-spacing:.32em;
text-transform:uppercase;font-size:.92rem;
color:var(--gold-bright);text-shadow:0 0 18px var(--gold-glow);
}
.topbar-stats{margin-left:auto}
.tb-stat{display:flex;align-items:center;gap:8px;
padding:6px 14px;border:1px solid var(--edge);border-radius:40px;
background:var(--panel-2)}
.tb-icon svg{width:16px;height:16px;display:block}
.tb-icon.dm svg{fill:var(--violet);filter:drop-shadow(0 0 6px rgba(182,156,255,.6))}
.tb-val{font-family:var(--font-mono);font-weight:700;color:var(--violet);font-size:.95rem}
.tb-label{font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-faint)}
.topbar-actions{display:flex;gap:9px}
.icon-btn{
width:38px;height:38px;border-radius:50%;
border:1px solid var(--edge);background:var(--panel-2);
color:var(--gold);font-size:1rem;font-family:var(--font-display);
display:grid;place-items:center;transition:.2s var(--ease)}
.icon-btn svg{fill:var(--gold)}
.icon-btn:hover{border-color:var(--gold);box-shadow:0 0 16px var(--gold-glow);transform:translateY(-1px)}
/* ---------- Stage ---------- */
.stage{
position:relative;z-index:4;
display:grid;grid-template-columns:minmax(360px,1.05fr) minmax(420px,1.25fr);
gap:clamp(16px,2.4vw,34px);
align-items:start;
padding:clamp(16px,2.6vw,38px);
max-width:1480px;margin:0 auto;
}
/* ---------- Forge panel ---------- */
.forge-panel{
position:sticky;top:22px;
display:flex;flex-direction:column;align-items:center;
padding:30px 26px 26px;
border:1px solid var(--edge);border-radius:var(--r);
background:
radial-gradient(120% 70% at 50% 0%, rgba(232,196,122,.06), transparent 60%),
var(--panel);
backdrop-filter:blur(10px);
box-shadow:0 30px 80px -30px rgba(0,0,0,.8), inset 0 1px 0 rgba(255,255,255,.03);
}
.forge-title{text-align:center;margin-bottom:6px}
.forge-title h1{
font-family:var(--font-display);font-weight:700;
font-size:clamp(1.5rem,2.6vw,2.15rem);letter-spacing:.12em;
text-transform:uppercase;line-height:1;
background:linear-gradient(180deg,var(--gold-bright),var(--gold-deep));
-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 2px 14px var(--gold-glow));
}
.tagline{margin-top:9px;font-size:.72rem;letter-spacing:.34em;text-transform:uppercase;color:var(--ink-faint)}
/* readout */
.readout{text-align:center;margin:20px 0 6px}
.aether-amount{display:flex;align-items:center;justify-content:center;gap:10px}
.aether-glyph svg{width:24px;height:24px;fill:var(--gold);filter:drop-shadow(0 0 10px var(--gold-glow));animation:spin 14s linear infinite}
.aether-value{
font-family:var(--font-mono);font-weight:700;
font-size:clamp(2.1rem,4.6vw,3.1rem);line-height:1;
color:var(--gold-bright);
text-shadow:0 0 26px rgba(232,196,122,.35);
font-variant-numeric:tabular-nums}
.aether-unit{font-size:.7rem;letter-spacing:.3em;text-transform:uppercase;color:var(--ink-faint);align-self:flex-end;margin-bottom:6px}
.rate-line{margin-top:10px;font-size:.82rem;color:var(--ink-dim);font-family:var(--font-mono)}
.rate-line span{color:var(--astral)}
.rate-sep{color:var(--ink-faint)!important;margin:0 8px}
/* forge emblem */
.forge-wrap{position:relative;margin:18px 0 6px;width:min(340px,72vw);aspect-ratio:1}
.forge{
position:absolute;inset:0;width:100%;height:100%;border-radius:50%;
display:grid;place-items:center;background:transparent;
-webkit-tap-highlight-color:transparent;touch-action:manipulation;
}
.forge>span{position:absolute;border-radius:50%;pointer-events:none}
.forge-burst{
inset:-6%;
background:repeating-conic-gradient(from 0deg, var(--gold-glow) 0deg .5deg, transparent .5deg 7.5deg);
-webkit-mask:radial-gradient(closest-side, transparent 56%, #000 60%, #000 86%, transparent 92%);
mask:radial-gradient(closest-side, transparent 56%, #000 60%, #000 86%, transparent 92%);
opacity:.5;animation:spin 60s linear infinite;
}
.forge-ring{border:1px solid var(--edge)}
.ring-1{inset:12%;box-shadow:inset 0 0 30px rgba(232,196,122,.07)}
.ring-2{inset:6%;border-color:var(--edge-soft);animation:spin 24s linear infinite}
.orbiter{position:absolute;top:-4px;left:50%;width:8px;height:8px;border-radius:50%;
background:var(--astral);box-shadow:0 0 12px var(--astral);transform:translateX(-50%)}
.forge-rose{inset:18%;display:grid;place-items:center}
.forge-rose svg{width:100%;height:100%}
.rose-lines circle,.rose-lines path{fill:none;stroke:var(--gold);stroke-width:1.1;opacity:.5;
vector-effect:non-scaling-stroke}
.forge-rose{animation:spin 90s linear infinite reverse}
.forge-core{
inset:34%;
background:
radial-gradient(circle at 50% 42%, #fff 0%, var(--gold-bright) 20%, var(--gold) 42%, var(--gold-deep) 70%, #5a4118 100%);
box-shadow:
0 0 40px var(--gold-glow), 0 0 90px rgba(232,196,122,.35),
inset 0 0 30px rgba(255,240,200,.5);
animation:pulse 3.4s var(--ease) infinite;
}
.forge:hover .forge-core{box-shadow:0 0 60px var(--gold-glow),0 0 130px rgba(232,196,122,.5),inset 0 0 30px rgba(255,240,200,.6)}
.forge:active .forge-core{transform:scale(.92)}
@keyframes pulse{0%,100%{transform:scale(1);filter:brightness(1)}50%{transform:scale(1.04);filter:brightness(1.12)}}
@keyframes spin{to{transform:rotate(360deg)}}
.fx-layer{position:absolute;inset:0;overflow:visible;pointer-events:none}
.float-num{
position:absolute;font-family:var(--font-mono);font-weight:700;font-size:1.05rem;
color:var(--gold-bright);text-shadow:0 0 12px var(--gold-glow);will-change:transform,opacity;white-space:nowrap}
.spark{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--gold-bright);
box-shadow:0 0 8px var(--gold-glow);will-change:transform,opacity}
.forge-hint{margin-top:14px;font-size:.74rem;color:var(--ink-faint);letter-spacing:.04em;text-align:center;min-height:1.1em}
/* collapse */
.collapse-block{margin-top:18px;width:100%;text-align:center;
padding-top:18px;border-top:1px solid var(--edge-soft)}
.collapse-info{font-size:.8rem;color:var(--ink-dim);margin-bottom:12px}
.collapse-info strong{color:var(--violet);font-family:var(--font-mono)}
.btn-collapse{
padding:11px 26px;border-radius:40px;font-family:var(--font-display);
letter-spacing:.18em;text-transform:uppercase;font-size:.78rem;font-weight:600;
color:var(--violet);border:1px solid var(--violet-deep);
background:radial-gradient(120% 120% at 50% 0%, rgba(182,156,255,.22), rgba(107,84,196,.08));
box-shadow:0 0 24px rgba(107,84,196,.3);transition:.2s var(--ease)}
.btn-collapse:hover{color:#fff;box-shadow:0 0 36px rgba(182,156,255,.55);transform:translateY(-1px)}
/* ---------- Ops panel ---------- */
.ops-panel{min-width:0}
.tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}
.tab{
position:relative;padding:10px 18px;border-radius:8px 8px 0 0;
font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;font-weight:500;
color:var(--ink-faint);border-bottom:1px solid transparent;transition:.2s var(--ease)}
.tab:hover{color:var(--ink-dim)}
.tab.is-active{color:var(--gold-bright);border-bottom-color:var(--gold)}
.tab.is-active::after{content:"";position:absolute;left:18px;right:18px;bottom:-1px;height:1px;background:var(--gold);box-shadow:0 0 12px var(--gold-glow)}
.tab-badge{display:inline-grid;place-items:center;min-width:17px;height:17px;padding:0 4px;border-radius:9px;
background:var(--astral);color:var(--void-0);font-family:var(--font-mono);font-size:.62rem;font-weight:700;margin-left:6px;vertical-align:middle}
.tab-pane{display:none;animation:fadein .35s var(--ease)}
.tab-pane.is-active{display:block}
@keyframes fadein{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.pane-intro{font-size:.78rem;color:var(--ink-faint);margin-bottom:14px;letter-spacing:.02em}
/* buy controls */
.buy-controls{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
.buy-label{font-size:.66rem;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-faint)}
.seg{display:flex;border:1px solid var(--edge);border-radius:8px;overflow:hidden;background:var(--panel-2)}
.seg-btn{padding:7px 13px;font-family:var(--font-mono);font-size:.74rem;color:var(--ink-dim);
border-right:1px solid var(--edge-soft);transition:.15s var(--ease)}
.seg-btn:last-child{border-right:none}
.seg-btn:hover{color:var(--ink);background:rgba(232,196,122,.06)}
.seg-btn.is-active{background:var(--gold);color:var(--void-0);font-weight:700}
/* ---------- Building rows ---------- */
.building-list{display:flex;flex-direction:column;gap:10px}
.building{
position:relative;display:grid;grid-template-columns:54px 1fr auto;align-items:center;gap:14px;
padding:13px 16px;border:1px solid var(--edge-soft);border-radius:12px;
background:linear-gradient(100deg,var(--panel),var(--panel-2));
text-align:left;width:100%;overflow:hidden;transition:.18s var(--ease)}
.building::before{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:transparent;transition:.2s}
.building.affordable{border-color:var(--edge);box-shadow:0 0 0 1px var(--edge-soft),0 8px 26px -16px var(--gold-glow)}
.building.affordable::before{background:var(--gold);box-shadow:0 0 14px var(--gold-glow)}
.building.affordable:hover{transform:translateY(-2px);border-color:var(--gold)}
.building:disabled,.building.locked{cursor:default}
.building.maxed{opacity:.85}
.b-icon{width:54px;height:54px;display:grid;place-items:center;border-radius:10px;
border:1px solid var(--edge-soft);background:radial-gradient(circle at 50% 30%,rgba(232,196,122,.1),transparent 70%)}
.b-icon svg{width:30px;height:30px;fill:none;stroke:var(--gold);stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}
.b-icon .fill{fill:var(--gold);stroke:none}
.affordable .b-icon{border-color:var(--edge)}
.b-main{min-width:0}
.b-name{font-family:var(--font-display);font-weight:600;font-size:.98rem;letter-spacing:.03em;color:var(--ink);
display:flex;align-items:baseline;gap:8px}
.b-count{font-family:var(--font-mono);font-size:.74rem;color:var(--gold-deep);font-weight:700}
.b-blurb{font-size:.72rem;color:var(--ink-faint);margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.b-each{font-size:.68rem;color:var(--astral-deep);margin-top:4px;font-family:var(--font-mono)}
.b-each b{color:var(--astral)}
.b-buy{text-align:right;display:flex;flex-direction:column;align-items:flex-end;gap:3px}
.b-cost{font-family:var(--font-mono);font-weight:700;font-size:.92rem;color:var(--gold);display:flex;align-items:center;gap:5px}
.b-cost.cant{color:var(--ink-faint)}
.b-cost svg{width:13px;height:13px;fill:currentColor}
.b-qty-tag{font-size:.62rem;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint)}
/* locked building teaser */
.building.locked{border-style:dashed;opacity:.6}
.building.locked .b-name{color:var(--ink-faint)}
.building.locked .b-icon svg{stroke:var(--ink-faint);opacity:.5}
/* ---------- Upgrades ---------- */
.upgrade-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(86px,1fr));gap:11px}
.upgrade{
position:relative;aspect-ratio:1;border-radius:12px;
border:1px solid var(--edge-soft);background:linear-gradient(160deg,var(--panel),var(--panel-2));
display:grid;place-items:center;padding:8px;transition:.16s var(--ease);overflow:visible}
.upgrade .u-glyph{width:34px;height:34px;fill:none;stroke:var(--gold);stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}
.upgrade.affordable{border-color:var(--gold);box-shadow:0 0 20px -6px var(--gold-glow)}
.upgrade.affordable:hover{transform:translateY(-3px) scale(1.03);box-shadow:0 0 26px -2px var(--gold-glow)}
.upgrade:not(.affordable){opacity:.55;filter:saturate(.6)}
.upgrade .u-cost{position:absolute;bottom:5px;left:0;right:0;text-align:center;
font-family:var(--font-mono);font-size:.6rem;color:var(--gold);font-weight:700}
.upgrade[data-kind="click"] .u-glyph{stroke:var(--astral)}
.upgrade[data-kind="global"] .u-glyph{stroke:var(--violet)}
/* tooltip */
.upgrade .u-tip{
position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(6px);
width:200px;padding:11px 13px;border-radius:10px;border:1px solid var(--edge);
background:rgba(8,11,24,.97);box-shadow:0 16px 40px -10px #000;
opacity:0;visibility:hidden;transition:.16s var(--ease);z-index:20;pointer-events:none}
.upgrade:hover .u-tip{opacity:1;visibility:visible;transform:translateX(-50%) translateY(0)}
.u-tip h4{font-family:var(--font-display);font-size:.82rem;color:var(--gold-bright);margin-bottom:4px;letter-spacing:.03em}
.u-tip p{font-size:.72rem;color:var(--ink-dim);line-height:1.45}
.u-tip .u-tip-cost{margin-top:7px;font-family:var(--font-mono);font-size:.72rem;color:var(--gold);font-weight:700}
.u-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
border:6px solid transparent;border-top-color:var(--edge)}
.empty-note{font-size:.78rem;color:var(--ink-faint);text-align:center;padding:30px 0}
/* ---------- Ascension ---------- */
.ascend-head{display:flex;gap:14px;margin-bottom:18px}
.ascend-stat{flex:1;padding:14px;border:1px solid var(--edge-soft);border-radius:12px;text-align:center;
background:radial-gradient(120% 120% at 50% 0%,rgba(182,156,255,.08),transparent)}
.ascend-num{display:block;font-family:var(--font-mono);font-weight:700;font-size:1.5rem;color:var(--violet)}
.ascend-cap{font-size:.62rem;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-faint)}
.ascend-grid{display:flex;flex-direction:column;gap:11px}
.ascend-card{
display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center;
padding:15px 17px;border:1px solid var(--edge-soft);border-radius:12px;
background:linear-gradient(100deg,var(--panel),var(--panel-2));transition:.16s var(--ease)}
.ascend-card.affordable{border-color:var(--violet-deep);box-shadow:0 0 22px -8px rgba(182,156,255,.5)}
.ascend-card.owned{border-color:var(--violet-deep);background:linear-gradient(100deg,rgba(107,84,196,.14),rgba(107,84,196,.04))}
.ascend-card h4{font-family:var(--font-display);font-size:.96rem;color:var(--ink);letter-spacing:.03em;margin-bottom:4px}
.ascend-card.owned h4{color:var(--violet)}
.ascend-card p{font-size:.74rem;color:var(--ink-dim);line-height:1.4}
.ascend-buy{padding:9px 16px;border-radius:8px;font-family:var(--font-mono);font-weight:700;font-size:.82rem;
white-space:nowrap;border:1px solid var(--violet-deep);color:var(--violet);
background:rgba(107,84,196,.12);transition:.16s var(--ease)}
.ascend-card.affordable .ascend-buy:hover{background:var(--violet-deep);color:#fff}
.ascend-buy:disabled{opacity:.4;cursor:default}
.ascend-owned-tag{font-family:var(--font-display);font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;color:var(--violet)}
/* ---------- Codex ---------- */
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:11px;margin-bottom:26px}
.stat-cell{padding:13px 15px;border:1px solid var(--edge-soft);border-radius:10px;background:var(--panel-2)}
.stat-cell .s-val{display:block;font-family:var(--font-mono);font-weight:700;font-size:1.18rem;color:var(--gold-bright)}
.stat-cell .s-lab{font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint);margin-top:3px}
.codex-h{font-family:var(--font-display);font-size:1rem;letter-spacing:.06em;color:var(--gold-bright);
margin-bottom:13px;display:flex;align-items:baseline;gap:10px}
.codex-count{font-family:var(--font-mono);font-size:.74rem;color:var(--ink-faint);font-weight:400}
.ach-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:9px}
.ach{position:relative;aspect-ratio:1;border-radius:10px;border:1px solid var(--edge-soft);
display:grid;place-items:center;background:var(--panel-2);transition:.16s var(--ease)}
.ach svg{width:26px;height:26px;fill:none;stroke:var(--ink-faint);stroke-width:1.4;opacity:.4}
.ach.unlocked{border-color:var(--gold);background:radial-gradient(circle at 50% 35%,rgba(232,196,122,.16),var(--panel-2))}
.ach.unlocked svg{stroke:var(--gold);opacity:1;filter:drop-shadow(0 0 6px var(--gold-glow))}
.ach:hover{transform:translateY(-2px);z-index:5}
.ach .a-tip{position:absolute;bottom:calc(100% + 9px);left:50%;transform:translateX(-50%) translateY(6px);
width:170px;padding:10px;border-radius:9px;border:1px solid var(--edge);background:rgba(8,11,24,.97);
opacity:0;visibility:hidden;transition:.15s var(--ease);z-index:30;text-align:center;pointer-events:none;box-shadow:0 14px 36px -10px #000}
.ach:hover .a-tip{opacity:1;visibility:visible;transform:translateX(-50%)}
.a-tip h5{font-family:var(--font-display);font-size:.78rem;color:var(--gold-bright);margin-bottom:3px}
.a-tip p{font-size:.68rem;color:var(--ink-dim);line-height:1.4}
/* ---------- Toasts ---------- */
.toast-stack{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:60;
display:flex;flex-direction:column;gap:9px;align-items:center;pointer-events:none;width:min(92vw,420px)}
.toast{
display:flex;align-items:center;gap:12px;width:100%;
padding:12px 18px;border-radius:12px;border:1px solid var(--edge);
background:linear-gradient(100deg,rgba(13,17,36,.97),rgba(20,26,52,.97));
box-shadow:0 16px 44px -12px #000,0 0 24px -8px var(--gold-glow);
animation:toastIn .4s var(--ease)}
.toast.out{animation:toastOut .4s var(--ease) forwards}
@keyframes toastIn{from{opacity:0;transform:translateY(16px) scale(.96)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateY(10px) scale(.97)}}
.toast .t-ico{width:30px;height:30px;flex:none;display:grid;place-items:center;border-radius:8px;border:1px solid var(--edge-soft)}
.toast .t-ico svg{width:18px;height:18px;fill:var(--gold);stroke:var(--gold)}
.toast.dm .t-ico svg{fill:var(--violet);stroke:var(--violet)}
.toast .t-body{min-width:0}
.toast .t-title{font-family:var(--font-display);font-size:.82rem;color:var(--gold-bright);letter-spacing:.04em}
.toast.dm .t-title{color:var(--violet)}
.toast .t-sub{font-size:.7rem;color:var(--ink-dim);margin-top:1px}
/* ---------- Modal ---------- */
.modal-host{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px}
.modal-host[hidden]{display:none}
.modal-backdrop{position:absolute;inset:0;background:rgba(3,4,10,.78);backdrop-filter:blur(4px);animation:fadein .25s}
.modal{position:relative;width:min(560px,94vw);max-height:88vh;overflow:auto;
padding:30px clamp(20px,4vw,40px);border:1px solid var(--edge);border-radius:16px;
background:var(--panel);backdrop-filter:blur(16px);box-shadow:0 40px 100px -20px #000;
animation:modalIn .3s var(--ease)}
@keyframes modalIn{from{opacity:0;transform:translateY(20px) scale(.97)}to{opacity:1;transform:none}}
.modal-x{position:absolute;top:14px;right:16px;width:32px;height:32px;border-radius:50%;
border:1px solid var(--edge-soft);color:var(--ink-dim);font-size:.85rem;display:grid;place-items:center;transition:.15s}
.modal-x:hover{border-color:var(--gold);color:var(--gold)}
.modal-content h2{font-family:var(--font-display);font-size:1.4rem;letter-spacing:.06em;color:var(--gold-bright);
text-transform:uppercase;margin-bottom:4px}
.modal-content h2.dm{color:var(--violet)}
.modal-content .sub{color:var(--ink-faint);font-size:.78rem;letter-spacing:.16em;text-transform:uppercase;margin-bottom:20px}
.modal-content p{color:var(--ink-dim);font-size:.9rem;line-height:1.6;margin-bottom:13px}
.modal-content p strong{color:var(--gold);font-family:var(--font-mono)}
.modal-content ul{list-style:none;margin:0 0 16px}
.modal-content li{position:relative;padding-left:22px;color:var(--ink-dim);font-size:.86rem;line-height:1.55;margin-bottom:8px}
.modal-content li::before{content:"✦";position:absolute;left:0;color:var(--gold);font-size:.8rem}
.modal-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.m-btn{padding:11px 22px;border-radius:10px;font-family:var(--font-display);letter-spacing:.12em;text-transform:uppercase;
font-size:.74rem;font-weight:600;border:1px solid var(--edge);color:var(--gold);background:var(--panel-2);transition:.16s var(--ease)}
.m-btn:hover{border-color:var(--gold);box-shadow:0 0 18px -4px var(--gold-glow)}
.m-btn.primary{background:var(--gold);color:var(--void-0)}
.m-btn.danger{color:var(--danger);border-color:rgba(232,138,138,.4)}
.m-btn.danger:hover{background:rgba(232,138,138,.12);box-shadow:0 0 18px -4px rgba(232,138,138,.5)}
.modal textarea{width:100%;height:120px;margin:6px 0 14px;padding:12px;border-radius:10px;resize:vertical;
border:1px solid var(--edge-soft);background:var(--void-1);color:var(--ink);font-family:var(--font-mono);font-size:.74rem}
.set-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--edge-soft)}
.set-row:last-child{border-bottom:none}
.set-row .set-label{font-size:.86rem;color:var(--ink)}
.set-row .set-desc{font-size:.72rem;color:var(--ink-faint);margin-top:2px}
.switch{position:relative;width:46px;height:25px;border-radius:25px;border:1px solid var(--edge);background:var(--void-1);flex:none;transition:.2s}
.switch::after{content:"";position:absolute;top:2px;left:2px;width:19px;height:19px;border-radius:50%;background:var(--ink-faint);transition:.2s var(--ease)}
.switch.on{background:rgba(232,196,122,.2);border-color:var(--gold)}
.switch.on::after{left:23px;background:var(--gold);box-shadow:0 0 10px var(--gold-glow)}
/* number ticking flash */
.flash{animation:flash .4s var(--ease)}
@keyframes flash{0%{color:#fff;text-shadow:0 0 30px var(--gold-bright)}100%{}}
/* ---------- Responsive ---------- */
@media (max-width:880px){
.stage{grid-template-columns:1fr;padding:14px}
.forge-panel{position:static;top:auto}
.topbar{flex-wrap:wrap;gap:10px}
.brand-name{font-size:.8rem;letter-spacing:.24em}
.forge-wrap{width:min(300px,76vw)}
}
@media (max-width:520px){
.building{grid-template-columns:46px 1fr;gap:11px}
.b-buy{grid-column:2;flex-direction:row;justify-content:space-between;align-items:center;width:100%;margin-top:6px}
.b-icon{width:46px;height:46px}
.tab{padding:9px 12px;letter-spacing:.1em}
}
@media (prefers-reduced-motion:reduce){
*{animation-duration:.001ms!important;animation-iteration-count:1!important}
.forge-core{animation:none}
}/* ============================================================
ASTRAL FOUNDRY — game engine
Pure vanilla JS. No dependencies.
============================================================ */
(() => {
'use strict';
/* ---------------------------------------------------------- *
* Small helpers
* ---------------------------------------------------------- */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const now = () => Date.now();
const SUFFIX = ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc',
'UDc', 'DDc', 'TDc', 'QaDc', 'QiDc', 'SxDc', 'SpDc', 'OcDc', 'NoDc', 'Vg'];
function fmt(n) {
if (n === Infinity) return '∞';
if (n == null || isNaN(n)) return '0';
const neg = n < 0; n = Math.abs(n);
if (n < 1000) {
if (n === 0) return '0';
if (n < 1) return (neg ? '-' : '') + n.toFixed(2).replace(/\.?0+$/, '');
if (n < 100 && n % 1 !== 0) return (neg ? '-' : '') + n.toFixed(1).replace(/\.0$/, '');
return (neg ? '-' : '') + Math.floor(n).toString();
}
const tier = Math.floor(Math.log10(n) / 3);
if (tier < SUFFIX.length) {
const scaled = n / Math.pow(1000, tier);
const d = scaled < 10 ? 2 : scaled < 100 ? 1 : 0;
return (neg ? '-' : '') + scaled.toFixed(d) + SUFFIX[tier];
}
return (neg ? '-' : '') + n.toExponential(2).replace('e+', 'e');
}
const fmtInt = (n) => (n < 1000 ? Math.floor(n).toString() : fmt(n));
/* ---------------------------------------------------------- *
* Icon library (inline SVG inner markup, viewBox 0 0 24 24)
* ---------------------------------------------------------- */
const I = {
mote:'<path d="M12 3 L13.4 10.6 L21 12 L13.4 13.4 L12 21 L10.6 13.4 L3 12 L10.6 10.6 Z"/><path d="M18.5 5.2 l.6 1.7 1.7.6-1.7.6-.6 1.7-.6-1.7-1.7-.6 1.7-.6z"/>',
comet:'<circle cx="8.5" cy="15.5" r="3.4"/><path d="M11 13 L20.5 3.5 M12.6 16.4 L18 17.6 M9.4 11.9 L8.2 6.5"/>',
asteroid:'<path d="M8 5.5 L14 4 L19 8 L20 14 L16 19 L9 19.5 L4.5 15 L4 9 Z"/><circle cx="10" cy="11" r="1.3"/><circle cx="15" cy="14" r="1"/>',
moon:'<path d="M15.5 3.2 a9 9 0 1 0 5 15.6 A7.2 7.2 0 1 1 15.5 3.2 Z"/>',
planet:'<circle cx="12" cy="11.5" r="6.4"/><ellipse cx="12" cy="12.5" rx="11" ry="3.4" transform="rotate(-22 12 12)"/>',
star:'<circle cx="12" cy="12" r="4.4"/><path d="M12 1.5 V5 M12 19 V22.5 M1.5 12 H5 M19 12 H22.5 M4.2 4.2 l2.3 2.3 M17.5 17.5 l2.3 2.3 M19.8 4.2 l-2.3 2.3 M6.5 17.5 l-2.3 2.3"/>',
nebula:'<path d="M6 15.5 a3.6 3.6 0 0 1 .6-6.7 a4.8 4.8 0 0 1 8.6-1.2 a3.8 3.8 0 0 1 2.4 7 a4 4 0 0 1-6 .8 Z"/><circle cx="9.5" cy="11" r=".9"/><circle cx="14" cy="13" r=".7"/>',
galaxy:'<path d="M12 12 c2.8-1.2 5 .6 4.4 3.4 c-.7 3.3-5.3 4.6-8.8 2.4 c-4.2-2.6-4.6-8.6-1-12 c3.7-3.4 9.8-2.6 12.4 1.6"/><circle class="fill" cx="12" cy="12" r="1.4"/>',
quasar:'<circle cx="12" cy="12" r="2.8"/><path d="M12 9 V1.5 M12 15 V22.5 M9.6 9.6 L7 3.5 M14.4 9.6 L17 3.5 M9.6 14.4 L7 20.5 M14.4 14.4 L17 20.5"/>',
singularity:'<circle cx="12" cy="12" r="8.2"/><ellipse cx="12" cy="12" rx="8.2" ry="2.6"/><circle class="fill" cx="12" cy="12" r="3.1"/>',
bolt:'<path d="M13.5 2 L5.5 13 H11 L9.5 22 L18.5 10 H12.5 Z"/>',
rays:'<circle cx="12" cy="12" r="3"/><path d="M12 1.5 V6 M12 18 V22.5 M1.5 12 H6 M18 12 H22.5 M4.5 4.5 L7.6 7.6 M16.4 16.4 L19.5 19.5 M19.5 4.5 L16.4 7.6 M7.6 16.4 L4.5 19.5"/>',
dm:'<path d="M12 2 L20 12 L12 22 L4 12 Z"/>',
trophy:'<path d="M7 4 H17 V9 a5 5 0 0 1-10 0 Z M7 6 H4 a3 3 0 0 0 3 3 M17 6 H20 a3 3 0 0 1-3 3 M12 14 V18 M8.5 21 H15.5 M10 18 H14 V21 H10 Z"/>',
flame:'<path d="M12 2 C13 6 17 7 16 12 C18 11 18 14 17 16 a5 5 0 0 1-10 0 C6 13 8 11 8.5 9 C9.5 11 10 11 10.5 10 C11 8 10 6 12 2 Z"/>',
hand:'<path d="M9 11 V5.5 a1.4 1.4 0 0 1 2.8 0 V10 M11.8 10 V4 a1.4 1.4 0 0 1 2.8 0 V10 M14.6 10 V5.5 a1.4 1.4 0 0 1 2.8 0 V13 c0 4-2.5 7-6.2 7 c-2.5 0-4-1-5.3-3 L6 14 l-1.6-2.4 a1.4 1.4 0 0 1 2.2-1.7 L9 12.5 Z"/>',
infinity:'<path d="M8 8 a4 4 0 1 0 0 8 c2.5 0 3.5-2 4-4 c.5-2 1.5-4 4-4 a4 4 0 1 1 0 8 c-2.5 0-3.5-2-4-4 c-.5-2-1.5-4-4-4 Z"/>',
orbit:'<circle cx="12" cy="12" r="3"/><ellipse cx="12" cy="12" rx="10" ry="4.4" transform="rotate(35 12 12)"/><circle class="fill" cx="20.5" cy="9" r="1.4"/>'
};
const svg = (inner, cls = '') => `<svg viewBox="0 0 24 24" class="${cls}">${inner}</svg>`;
/* ---------------------------------------------------------- *
* Definitions
* ---------------------------------------------------------- */
const GROWTH = 1.15;
const BUILDINGS = [
{ id:'mote', name:'Stardust Mote', icon:'mote', baseCost:15, baseCps:0.1, blurb:'A fleck of luminous dust coaxed into being.' },
{ id:'comet', name:'Wandering Comet',icon:'comet', baseCost:110, baseCps:1, blurb:'Ice and fire on an endless silver ellipse.' },
{ id:'asteroid', name:'Asteroid', icon:'asteroid', baseCost:1300, baseCps:8, blurb:'A tumbling shard of the early cosmos.' },
{ id:'moon', name:'Silver Moon', icon:'moon', baseCost:14000, baseCps:47, blurb:'Pale tides answer to its quiet pull.' },
{ id:'planet', name:'Living Planet', icon:'planet', baseCost:200000, baseCps:260, blurb:'Continents bloom across its turning skin.' },
{ id:'star', name:'Burning Star', icon:'star', baseCost:3.3e6, baseCps:1400, blurb:'A furnace fusing light from raw hydrogen.' },
{ id:'nebula', name:'Nebula', icon:'nebula', baseCost:5.1e7, baseCps:7800, blurb:'A glowing nursery where new stars are spun.' },
{ id:'galaxy', name:'Spiral Galaxy', icon:'galaxy', baseCost:7.5e8, baseCps:44000, blurb:'A hundred billion suns in slow rotation.' },
{ id:'quasar', name:'Quasar', icon:'quasar', baseCost:1e10, baseCps:260000, blurb:'A feeding void outshining whole galaxies.' },
{ id:'singularity', name:'Singularity', icon:'singularity', baseCost:1.4e11, baseCps:1.6e6, blurb:'Where space, time and aether converge as one.' },
];
const B_BY_ID = Object.fromEntries(BUILDINGS.map(b => [b.id, b]));
/* ---- Upgrades: generated tiered building doublers + specials ---- */
const TIER = [
{ own:1, mul:10, rn:'I' },
{ own:10, mul:100, rn:'II' },
{ own:25, mul:550, rn:'III' },
{ own:50, mul:3200, rn:'IV' },
{ own:100, mul:18000, rn:'V' },
{ own:150, mul:90000, rn:'VI' },
];
const UPGRADES = [];
BUILDINGS.forEach(b => {
TIER.forEach((t, i) => {
UPGRADES.push({
id:`${b.id}_t${i}`, kind:'building', target:b.id, icon:b.icon,
name:`${b.name} ${t.rn}`,
desc:`${b.name}s produce twice as much aether.`,
cost:b.baseCost * t.mul,
reqOwn:t.own,
effect:{ buildingMul:2 },
});
});
});
// Click power doublers
[ ['Gilded Touch', 2.0e2], ['Astral Sigil', 6.0e3], ['Hand of the Maker', 1.5e5],
['Starwright’s Grip', 4.0e6], ['The First Gesture', 9.0e7], ['Demiurge’s Will', 2.0e9] ]
.forEach(([name, cost], i) => UPGRADES.push({
id:`click${i}`, kind:'click', icon:'bolt', name,
desc:'Drawing aether by hand is twice as effective.',
cost, reqEarned:cost * 0.45, effect:{ clickMul:2 },
}));
// Click <-> economy synergy
[ ['Resonant Draw', 4.0e4, 0.01], ['Aetheric Conduit', 5.0e7, 0.02], ['Singular Focus', 8.0e9, 0.05] ]
.forEach(([name, cost, pct], i) => UPGRADES.push({
id:`syn${i}`, kind:'click', icon:'hand', name,
desc:`Each draw also yields ${(pct*100).toFixed(0)}% of your aether-per-second.`,
cost, reqEarned:cost * 0.4, effect:{ clickPct:pct },
}));
// Global production multipliers
[ ['Aetheric Lattice', 1.2e6, 1.05], ['Cosmic Filament', 2.0e8, 1.07],
['Universal Weave', 4.0e10, 1.10], ['Law of Attraction', 5.0e12, 1.12] ]
.forEach(([name, cost, mul], i) => UPGRADES.push({
id:`glob${i}`, kind:'global', icon:'rays', name,
desc:`Every structure produces ${Math.round((mul-1)*100)}% more aether.`,
cost, reqEarned:cost * 0.4, effect:{ globalMul:mul },
}));
const U_BY_ID = Object.fromEntries(UPGRADES.map(u => [u.id, u]));
/* ---- Ascension (Dark Matter) — permanent, persist across collapses ---- */
const ASCENSION = [
{ id:'voidhands', name:'Void Hands', cost:3, desc:'Drawing aether by hand is ×5 stronger, forever.' },
{ id:'echo', name:'Aetheric Echo', cost:8, desc:'All structures permanently produce +50% aether.' },
{ id:'primordial',name:'Primordial Spark', cost:15, desc:'Begin every new cosmos with 25 Motes and 10 Comets already forged.' },
{ id:'catalyst', name:'Dark Catalyst', cost:25, desc:'Every structure costs 8% less to forge.' },
{ id:'memory', name:'Stellar Memory', cost:40, desc:'Carry 2% of your aether through each collapse.' },
{ id:'compound', name:'Cosmic Compounding', cost:60, desc:'+1% production for every Dark Matter you have ever earned.' },
{ id:'eternal', name:'Eternal Engine', cost:100, desc:'+3% production for every collapse you have ever performed.' },
{ id:'seed', name:'Seed of Singularity', cost:175, desc:'Collapsing yields ×3 as much Dark Matter.' },
];
const A_BY_ID = Object.fromEntries(ASCENSION.map(a => [a.id, a]));
/* ---- Achievements (generated) ---- */
const ACH = [];
const addAch = (id, name, desc, glyph, test) => ACH.push({ id, name, desc, glyph, test });
// per-building counts
BUILDINGS.forEach(b => {
[25, 100].forEach(n => addAch(
`${b.id}_${n}`, `${b.name} ×${n}`,
`Forge ${n} ${b.name}s.`, b.icon,
s => (s.owned[b.id] || 0) >= n));
});
// total aether earned
[ ['Spark',1e4],['Kindling',1e6],['Bonfire',1e9],['Inferno',1e12],['Conflagration',1e15],['Heat Death',1e18] ]
.forEach(([nm, amt]) => addAch(
`tot_${amt}`, nm, `Forge ${fmt(amt)} aether in total.`, 'flame',
s => s.totalAllTime >= amt));
// clicks
[ ['Restless Hands',100],['Ten Thousand Draws',5000],['Carpal Cosmos',50000] ]
.forEach(([nm, n]) => addAch(`clk_${n}`, nm, `Draw aether ${fmtInt(n)} times by hand.`, 'hand',
s => s.clicks >= n));
// collapses
[ ['First Collapse',1],['Cycle of Rebirth',5],['Eternal Return',20] ]
.forEach(([nm, n]) => addAch(`col_${n}`, nm, `Collapse the cosmos ${n} time${n>1?'s':''}.`, 'infinity',
s => s.collapses >= n));
// dark matter
[ ['Touch of the Dark',10],['Matter Made Manifest',100] ]
.forEach(([nm, n]) => addAch(`dm_${n}`, nm, `Earn ${n} Dark Matter in total.`, 'dm',
s => s.earnedDM >= n));
const A_TOTAL = ACH.length;
/* ---------------------------------------------------------- *
* State
* ---------------------------------------------------------- */
const SAVE_KEY = 'astralFoundry.v1';
const PRESTIGE_DIV = 1e8; // aether-per-DM scaling base
const OFFLINE_CAP = 8 * 3600; // seconds
function freshRun(state) {
// reset per-run progress, keep meta (DM, ascension, collapses, achievements, stats)
state.aether = 0;
state.totalRun = 0;
state.owned = {};
state.upgrades = {};
state.discovered = {};
BUILDINGS.forEach(b => state.owned[b.id] = 0);
if (state.ascension.primordial) { state.owned.mote = 25; state.owned.comet = 10; }
}
function defaultState() {
const s = {
aether:0, totalRun:0, totalAllTime:0,
owned:{}, upgrades:{}, discovered:{},
clicks:0, handAether:0,
earnedDM:0, spentDM:0, collapses:0,
ascension:{}, achievements:{},
started:now(), lastSave:now(),
settings:{ particles:true, starfield:true },
};
BUILDINGS.forEach(b => s.owned[b.id] = 0);
return s;
}
let S = defaultState();
/* ---------------------------------------------------------- *
* Derived values (recomputed each tick into `D`)
* ---------------------------------------------------------- */
const D = { cps:0, click:0, global:1, perOne:{} };
function buildingMul(id) {
let m = 1;
const b = B_BY_ID[id];
TIER.forEach((t, i) => { if (S.upgrades[`${id}_t${i}`]) m *= 2; });
return m;
}
function globalMultiplier() {
let g = 1;
// achievements: +1% each
g *= 1 + 0.01 * achCount();
// global upgrades
UPGRADES.forEach(u => { if (u.kind === 'global' && S.upgrades[u.id]) g *= u.effect.globalMul; });
// ascension
if (S.ascension.echo) g *= 1.5;
if (S.ascension.compound) g *= 1 + 0.01 * S.earnedDM;
if (S.ascension.eternal) g *= 1 + 0.03 * S.collapses;
return g;
}
function costMultiplier() { return S.ascension.catalyst ? 0.92 : 1; }
function recompute() {
const g = globalMultiplier();
let cps = 0;
BUILDINGS.forEach(b => {
const per = b.baseCps * buildingMul(b.id) * g;
D.perOne[b.id] = per;
cps += per * (S.owned[b.id] || 0);
});
// click power
let clickMul = 1, clickPct = 0;
UPGRADES.forEach(u => {
if (!S.upgrades[u.id]) return;
if (u.effect.clickMul) clickMul *= u.effect.clickMul;
if (u.effect.clickPct) clickPct += u.effect.clickPct;
});
if (S.ascension.voidhands) clickMul *= 5;
D.global = g;
D.cps = cps;
D.click = clickMul * g + clickPct * cps; // base hand-power 1 * mult * global, plus % of cps
}
const achCount = () => { let n = 0; for (const k in S.achievements) if (S.achievements[k]) n++; return n; };
const heldDM = () => S.earnedDM - S.spentDM;
/* cost helpers ------------------------------------------------ */
function unitCost(b, n) { return b.baseCost * costMultiplier() * Math.pow(GROWTH, n); }
function bulkCost(b, count) {
const n = S.owned[b.id] || 0;
// geometric sum
return b.baseCost * costMultiplier() * Math.pow(GROWTH, n) * (Math.pow(GROWTH, count) - 1) / (GROWTH - 1);
}
function maxAffordable(b) {
const n = S.owned[b.id] || 0;
const base = b.baseCost * costMultiplier() * Math.pow(GROWTH, n);
const q = Math.floor(Math.log(1 + S.aether * (GROWTH - 1) / base) / Math.log(GROWTH));
return Math.max(0, isFinite(q) ? q : 0);
}
function buyPlan(b) {
if (UI.buyQty === 'max') {
const q = maxAffordable(b);
const disp = q > 0 ? q : 1;
return { qty:q, dispQty:disp, cost:bulkCost(b, disp), can:q > 0 };
}
const q = UI.buyQty;
const cost = bulkCost(b, q);
return { qty:q, dispQty:q, cost, can:S.aether >= cost };
}
/* prestige ---------------------------------------------------- */
function potentialDM() {
const raw = Math.floor(Math.cbrt(S.totalAllTime / PRESTIGE_DIV));
const mult = S.ascension.seed ? 3 : 1;
return Math.max(0, raw * mult);
}
const pendingDM = () => Math.max(0, potentialDM() - S.earnedDM);
/* ---------------------------------------------------------- *
* UI state + element caches
* ---------------------------------------------------------- */
const UI = {
buyQty: 1,
buildingEls: {}, // id -> {row, cost, count, each, plan}
ascendEls: {},
achEls: {},
upgSig: '', // signature of current upgrade grid
tab: 'structures',
};
/* ===== build static lists ================================= */
function buildBuildings() {
const list = $('#building-list');
list.innerHTML = '';
BUILDINGS.forEach(b => {
const row = document.createElement('button');
row.className = 'building locked';
row.innerHTML = `
<span class="b-icon">${svg(I[b.icon])}</span>
<span class="b-main">
<span class="b-name"><span class="bn">${b.name}</span> <span class="b-count" data-count>0</span></span>
<span class="b-blurb">${b.blurb}</span>
<span class="b-each" data-each></span>
</span>
<span class="b-buy">
<span class="b-cost" data-cost><span class="cv">0</span></span>
<span class="b-qty-tag" data-qtytag></span>
</span>`;
row.addEventListener('click', () => buyBuilding(b.id));
list.appendChild(row);
UI.buildingEls[b.id] = {
row,
count: $('[data-count]', row),
each: $('[data-each]', row),
cost: $('[data-cost]', row),
costV: $('.cv', row),
qtytag:$('[data-qtytag]', row),
name: $('.bn', row),
};
});
}
function buildAscension() {
const grid = $('#ascend-grid');
grid.innerHTML = '';
ASCENSION.forEach(a => {
const card = document.createElement('div');
card.className = 'ascend-card';
card.innerHTML = `
<div class="ascend-text">
<h4>${a.name}</h4>
<p>${a.desc}</p>
</div>
<button class="ascend-buy" data-buy>${a.cost} ◆</button>`;
$('[data-buy]', card).addEventListener('click', () => buyAscension(a.id));
grid.appendChild(card);
UI.ascendEls[a.id] = { card, btn:$('[data-buy]', card) };
});
}
function buildCodex() {
// stats
const sg = $('#stats-grid');
sg.innerHTML = `
<div class="stat-cell"><span class="s-val" data-s="alltime">0</span><span class="s-lab">Aether all-time</span></div>
<div class="stat-cell"><span class="s-val" data-s="run">0</span><span class="s-lab">Aether this cosmos</span></div>
<div class="stat-cell"><span class="s-val" data-s="cps">0</span><span class="s-lab">Aether / second</span></div>
<div class="stat-cell"><span class="s-val" data-s="hand">0</span><span class="s-lab">Forged by hand</span></div>
<div class="stat-cell"><span class="s-val" data-s="clicks">0</span><span class="s-lab">Total draws</span></div>
<div class="stat-cell"><span class="s-val" data-s="structures">0</span><span class="s-lab">Structures forged</span></div>
<div class="stat-cell"><span class="s-val" data-s="dm">0</span><span class="s-lab">Dark Matter held</span></div>
<div class="stat-cell"><span class="s-val" data-s="collapses">0</span><span class="s-lab">Collapses</span></div>
<div class="stat-cell"><span class="s-val" data-s="global">×1</span><span class="s-lab">Production mult.</span></div>
<div class="stat-cell"><span class="s-val" data-s="played">0m</span><span class="s-lab">Time in the void</span></div>`;
// achievements
const ag = $('#ach-grid');
ag.innerHTML = '';
ACH.forEach(a => {
const cell = document.createElement('div');
cell.className = 'ach';
cell.innerHTML = `${svg(I[a.glyph])}
<span class="a-tip"><h5>${a.name}</h5><p>${a.desc}</p></span>`;
ag.appendChild(cell);
UI.achEls[a.id] = cell;
});
}
/* ===== dynamic: upgrades grid ============================= */
function availableUpgrades() {
return UPGRADES.filter(u => {
if (S.upgrades[u.id]) return false;
if (u.kind === 'building') return (S.owned[u.target] || 0) >= u.reqOwn;
return S.totalRun >= (u.reqEarned || 0);
});
}
function renderUpgrades() {
const avail = availableUpgrades();
const sig = avail.map(u => u.id).join(',');
const badge = $('[data-badge="upgrades"]');
const affordCount = avail.filter(u => S.aether >= u.cost).length;
if (affordCount > 0) { badge.hidden = false; badge.textContent = affordCount; }
else badge.hidden = true;
if (sig === UI.upgSig) { // just refresh affordability
avail.forEach(u => {
const el = $(`#upg-${u.id}`);
if (el) el.classList.toggle('affordable', S.aether >= u.cost);
});
return;
}
UI.upgSig = sig;
const grid = $('#upgrade-grid');
grid.innerHTML = '';
$('#upgrades-empty').style.display = avail.length ? 'none' : 'block';
avail.sort((a, b) => a.cost - b.cost).forEach(u => {
const el = document.createElement('button');
el.className = 'upgrade';
el.id = `upg-${u.id}`;
el.dataset.kind = u.kind;
el.classList.toggle('affordable', S.aether >= u.cost);
el.innerHTML = `${svg(I[u.icon], 'u-glyph')}
<span class="u-cost">${fmt(u.cost)}</span>
<span class="u-tip"><h4>${u.name}</h4><p>${u.desc}</p>
<span class="u-tip-cost">${fmt(u.cost)} ✦ aether</span></span>`;
el.addEventListener('click', () => buyUpgrade(u.id));
grid.appendChild(el);
});
}
/* ---------------------------------------------------------- *
* Actions
* ---------------------------------------------------------- */
function gainAether(amount, fromHand) {
S.aether += amount;
S.totalRun += amount;
S.totalAllTime += amount;
if (fromHand) S.handAether += amount;
}
function buyBuilding(id) {
const b = B_BY_ID[id];
const plan = buyPlan(b);
if (!plan.can || S.aether < plan.cost) return;
S.aether -= plan.cost;
S.owned[id] = (S.owned[id] || 0) + plan.qty;
flashEl(UI.buildingEls[id].count);
recompute();
updateBuildings();
renderUpgrades();
refreshHUD();
checkUnlocks();
}
function buyUpgrade(id) {
const u = U_BY_ID[id];
if (S.upgrades[id] || S.aether < u.cost) return;
S.aether -= u.cost;
S.upgrades[id] = true;
recompute();
renderUpgrades();
updateBuildings();
refreshHUD();
toast('upg', u.name, 'Enhancement forged');
}
function buyAscension(id) {
const a = A_BY_ID[id];
if (S.ascension[id] || heldDM() < a.cost) return;
S.spentDM += a.cost;
S.ascension[id] = true;
if (id === 'primordial') { // apply immediately to current run if at start-ish
S.owned.mote = Math.max(S.owned.mote, 25);
S.owned.comet = Math.max(S.owned.comet, 10);
}
recompute();
updateAscension();
updateBuildings();
refreshHUD();
toast('dm', a.name, 'Eternal work completed');
}
let clickUiThrottle = 0;
function manualClick(ev) {
const gain = D.click;
gainAether(gain, true);
S.clicks++;
spawnClickFX(ev, gain);
flashEl($('.aether-value'));
refreshHUD();
// keep buy buttons / upgrade affordability responsive between loop ticks,
// but throttle so rapid clicking stays cheap
const t = now();
if (t - clickUiThrottle > 60) {
clickUiThrottle = t;
updateBuildings();
renderUpgrades();
}
if (S.clicks % 10 === 0) checkUnlocks();
}
function doCollapse() {
const dm = pendingDM();
if (dm < 1) return;
openModal(`
<h2 class="dm">Collapse?</h2>
<div class="sub">The cosmos begins anew</div>
<p>Collapsing dissolves every structure, upgrade and mote you have forged in
<strong>this</strong> cosmos.</p>
<p>In return you crystallise <strong>${fmt(dm)} ◆ Dark Matter</strong>, which is
<em>yours forever</em> — spent on eternal works that carry across all cosmos to come.</p>
<div class="modal-actions">
<button class="m-btn" data-close>Not yet</button>
<button class="m-btn primary" id="confirm-collapse">Collapse the Universe</button>
</div>`);
$('#confirm-collapse').addEventListener('click', () => {
const keep = S.ascension.memory ? S.aether * 0.02 : 0;
S.earnedDM += dm;
S.collapses++;
freshRun(S);
S.aether = keep; S.totalRun = keep;
recompute();
closeModal();
rebuildDynamic();
toast('dm', `+${fmt(dm)} Dark Matter`, 'A new cosmos awakens', true);
burstScreen();
save();
});
}
/* ---------------------------------------------------------- *
* HUD / list refresh
* ---------------------------------------------------------- */
function refreshHUD() {
$('.aether-value').textContent = fmt(S.aether);
$('[data-cps]').textContent = fmt(D.cps);
$('[data-click]').textContent = '+' + fmt(D.click);
$$('[data-dm-have]').forEach(e => e.textContent = fmt(heldDM()));
// hint
updateHint();
}
let lastNextTeaser = -1;
function updateBuildings() {
// determine discovery & next teaser
let teaserShown = false;
BUILDINGS.forEach((b, idx) => {
const el = UI.buildingEls[b.id];
const owned = S.owned[b.id] || 0;
// discovery
if (!S.discovered[b.id]) {
const prevOwned = idx === 0 ? 1 : (S.owned[BUILDINGS[idx-1].id] || 0);
if (owned > 0 || S.totalAllTime >= b.baseCost * 0.5 || prevOwned >= 1)
S.discovered[b.id] = true;
}
if (S.discovered[b.id]) {
el.row.style.display = '';
el.row.classList.remove('locked');
el.name.textContent = b.name;
el.count.textContent = owned ? fmtInt(owned) : '';
const plan = buyPlan(b);
el.costV.textContent = fmt(plan.cost);
el.cost.classList.toggle('cant', !plan.can);
el.row.classList.toggle('affordable', plan.can);
el.row.disabled = !plan.can;
el.qtytag.textContent = UI.buyQty === 'max'
? (plan.qty > 0 ? `buy ${plan.qty}` : 'max')
: `×${plan.qty}`;
el.each.innerHTML = owned
? `each makes <b>${fmt(D.perOne[b.id])}</b>/s · ${b.id} total <b>${fmt(D.perOne[b.id]*owned)}</b>/s`
: `makes <b>${fmt(D.perOne[b.id] || (b.baseCps*D.global))}</b>/s each`;
} else if (!teaserShown) {
teaserShown = true;
el.row.style.display = '';
el.row.classList.add('locked');
el.row.disabled = true;
el.name.textContent = 'Unknown Structure';
el.count.textContent = '';
el.each.innerHTML = '';
el.costV.textContent = '???';
el.qtytag.textContent = `near ${fmt(b.baseCost * 0.5)} ✦`;
} else {
el.row.style.display = 'none';
}
});
}
function updateAscension() {
$('[data-collapses]').textContent = fmtInt(S.collapses);
ASCENSION.forEach(a => {
const el = UI.ascendEls[a.id];
const owned = !!S.ascension[a.id];
el.card.classList.toggle('owned', owned);
el.card.classList.toggle('affordable', !owned && heldDM() >= a.cost);
if (owned) {
el.btn.outerHTML = `<span class="ascend-owned-tag">Eternal</span>`;
UI.ascendEls[a.id].btn = $('.ascend-owned-tag', el.card);
} else {
el.btn.disabled = heldDM() < a.cost;
}
});
}
function updateCodex() {
const g = $('#stats-grid');
if (!g) return;
const set = (k, v) => { const e = $(`[data-s="${k}"]`, g); if (e) e.textContent = v; };
let structures = 0; BUILDINGS.forEach(b => structures += (S.owned[b.id] || 0));
set('alltime', fmt(S.totalAllTime));
set('run', fmt(S.totalRun));
set('cps', fmt(D.cps));
set('hand', fmt(S.handAether));
set('clicks', fmtInt(S.clicks));
set('structures', fmtInt(structures));
set('dm', fmt(heldDM()));
set('collapses', fmtInt(S.collapses));
set('global', '×' + fmt(D.global));
const mins = Math.floor((now() - S.started) / 60000);
set('played', mins < 60 ? mins + 'm' : Math.floor(mins/60) + 'h ' + (mins%60) + 'm');
// ach count
$('[data-ach-count]').textContent = `${achCount()} / ${A_TOTAL}`;
}
/* ---------------------------------------------------------- *
* Unlocks: achievements + collapse availability
* ---------------------------------------------------------- */
function checkUnlocks() {
ACH.forEach(a => {
if (S.achievements[a.id]) return;
if (a.test(S)) {
S.achievements[a.id] = true;
UI.achEls[a.id]?.classList.add('unlocked');
toast('upg', a.name, 'Achievement unlocked');
recompute(); // achievements boost production
}
});
// collapse block
const dm = pendingDM();
const block = $('#collapse-block');
if (dm >= 1) {
block.hidden = false;
$('[data-dm-pending]').textContent = fmt(dm);
} else {
block.hidden = true;
}
}
function updateHint() {
const h = $('[data-hint]');
let msg = '';
if (S.clicks === 0 && S.totalAllTime === 0) msg = 'Click the molten core to draw your first aether.';
else if ((S.owned.mote || 0) === 0 && S.aether < 15) msg = `Forge a Stardust Mote once you hold 15 ✦.`;
else if ((S.owned.mote || 0) === 0) msg = 'You can afford a Stardust Mote — forge it on the right.';
else if (pendingDM() >= 1 && S.collapses === 0) msg = 'A collapse is now possible — claim Dark Matter when ready.';
else msg = '';
if (h.textContent !== msg) h.textContent = msg;
h.style.display = msg ? '' : 'none';
}
/* ---------------------------------------------------------- *
* Effects (floating numbers, sparks, toasts, screen burst)
* ---------------------------------------------------------- */
const fxLayer = () => $('#fx-layer');
function spawnClickFX(ev, gain) {
const wrap = $('.forge-wrap');
const rect = wrap.getBoundingClientRect();
let x, y;
if (ev && ev.clientX != null) { x = ev.clientX - rect.left; y = ev.clientY - rect.top; }
else { x = rect.width / 2; y = rect.height / 2; }
// floating number
const fn = document.createElement('div');
fn.className = 'float-num';
fn.textContent = '+' + fmt(gain);
fn.style.left = x + 'px'; fn.style.top = y + 'px';
fxLayer().appendChild(fn);
const dx = (Math.random() - 0.5) * 50;
fn.animate(
[ { transform:'translate(-50%,-50%) scale(.8)', opacity:0 },
{ transform:'translate(-50%,-70%) scale(1.05)', opacity:1, offset:.18 },
{ transform:`translate(calc(-50% + ${dx}px),-160%) scale(1)`, opacity:0 } ],
{ duration:1000, easing:'cubic-bezier(.2,.7,.2,1)' }
).onfinish = () => fn.remove();
// core punch
const core = $('.forge-core');
core.animate(
[ { transform:'scale(.9)' }, { transform:'scale(1.06)' }, { transform:'scale(1)' } ],
{ duration:260, easing:'ease-out' });
if (!S.settings.particles) return;
// sparks
const n = 7;
for (let i = 0; i < n; i++) {
const sp = document.createElement('span');
sp.className = 'spark';
sp.style.left = x + 'px'; sp.style.top = y + 'px';
fxLayer().appendChild(sp);
const ang = Math.random() * Math.PI * 2;
const dist = 36 + Math.random() * 60;
sp.animate(
[ { transform:'translate(-50%,-50%) scale(1)', opacity:1 },
{ transform:`translate(calc(-50% + ${Math.cos(ang)*dist}px), calc(-50% + ${Math.sin(ang)*dist}px)) scale(0)`, opacity:0 } ],
{ duration:560 + Math.random()*260, easing:'cubic-bezier(.1,.6,.3,1)' }
).onfinish = () => sp.remove();
}
}
function flashEl(el) {
if (!el) return;
el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');
}
let toastTimer;
function toast(type, title, sub, dm) {
const stack = $('#toast-stack');
const t = document.createElement('div');
t.className = 'toast' + (dm ? ' dm' : '');
const glyph = dm ? I.dm : (type === 'dm' ? I.dm : I.trophy);
t.innerHTML = `<span class="t-ico">${svg(glyph)}</span>
<span class="t-body"><span class="t-title">${title}</span><span class="t-sub">${sub}</span></span>`;
stack.appendChild(t);
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 420); }, 4200);
// cap stack
while (stack.children.length > 4) stack.firstChild.remove();
}
function burstScreen() {
const flash = document.createElement('div');
Object.assign(flash.style, {
position:'fixed', inset:0, zIndex:70, pointerEvents:'none',
background:'radial-gradient(circle at 50% 45%, rgba(182,156,255,.5), transparent 60%)',
});
document.body.appendChild(flash);
flash.animate([{ opacity:0 }, { opacity:1, offset:.2 }, { opacity:0 }],
{ duration:900, easing:'ease-out' }).onfinish = () => flash.remove();
}
/* ---------------------------------------------------------- *
* Modal
* ---------------------------------------------------------- */
function openModal(html) {
$('#modal-content').innerHTML = html;
$('#modal-host').hidden = false;
$$('[data-close]', $('#modal-host')).forEach(el => el.addEventListener('click', closeModal));
}
function closeModal() { $('#modal-host').hidden = true; }
function openHelp() {
openModal(`
<h2>How to Play</h2>
<div class="sub">A foundry for universes</div>
<p>You are a Starwright, drawing raw <strong>aether</strong> from the void and forging it into the cosmos.</p>
<ul>
<li>Click the glowing <strong>core</strong> to draw aether by hand.</li>
<li>Spend aether on <strong>Structures</strong> — from motes to singularities — that generate aether every second.</li>
<li>Forge one-time <strong>Upgrades</strong> to multiply your output and your draw.</li>
<li>When a cosmos grows vast, <strong>Collapse</strong> it for <strong>Dark Matter ◆</strong>.</li>
<li>Dark Matter is eternal — spend it in <strong>Ascension</strong> on works that persist across every cosmos.</li>
</ul>
<p>Your progress saves automatically. The void keeps working while you are away.</p>
<div class="modal-actions"><button class="m-btn primary" data-close>Begin</button></div>`);
}
function openSettings() {
const sw = (on) => `<span class="switch${on ? ' on' : ''}"></span>`;
openModal(`
<h2>Settings</h2>
<div class="sub">Foundry controls</div>
<div class="set-row" data-toggle="particles">
<div><div class="set-label">Particle effects</div><div class="set-desc">Sparks when drawing aether</div></div>
${sw(S.settings.particles)}
</div>
<div class="set-row" data-toggle="starfield">
<div><div class="set-label">Animated starfield</div><div class="set-desc">The drifting cosmic background</div></div>
${sw(S.settings.starfield)}
</div>
<div class="set-row">
<div><div class="set-label">Save data</div><div class="set-desc">Export or import your cosmos</div></div>
<div style="display:flex;gap:8px">
<button class="m-btn" id="exp">Export</button>
<button class="m-btn" id="imp">Import</button>
</div>
</div>
<div class="set-row">
<div><div class="set-label">Hard reset</div><div class="set-desc">Erase everything, permanently</div></div>
<button class="m-btn danger" id="reset">Reset</button>
</div>`);
$$('[data-toggle]').forEach(row => row.addEventListener('click', () => {
const k = row.dataset.toggle;
S.settings[k] = !S.settings[k];
$('.switch', row).classList.toggle('on', S.settings[k]);
if (k === 'starfield' && !S.settings[k]) clearStars();
}));
$('#exp').addEventListener('click', exportSave);
$('#imp').addEventListener('click', importSave);
$('#reset').addEventListener('click', confirmReset);
}
function exportSave() {
save();
const code = btoa(unescape(encodeURIComponent(localStorage.getItem(SAVE_KEY) || '')));
openModal(`
<h2>Export</h2><div class="sub">Copy this code somewhere safe</div>
<textarea readonly id="exp-area">${code}</textarea>
<div class="modal-actions">
<button class="m-btn primary" id="copy">Copy to clipboard</button>
<button class="m-btn" data-close>Done</button>
</div>`);
$('#exp-area').select();
$('#copy').addEventListener('click', () => {
navigator.clipboard?.writeText(code).then(() => toast('upg', 'Copied', 'Save code on clipboard'));
});
}
function importSave() {
openModal(`
<h2>Import</h2><div class="sub">Paste a save code</div>
<textarea id="imp-area" placeholder="Paste your exported code here…"></textarea>
<div class="modal-actions">
<button class="m-btn primary" id="do-import">Load cosmos</button>
<button class="m-btn" data-close>Cancel</button>
</div>`);
$('#do-import').addEventListener('click', () => {
try {
const raw = decodeURIComponent(escape(atob($('#imp-area').value.trim())));
const data = JSON.parse(raw);
suppressSave = true;
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
location.reload();
} catch (e) { toast('upg', 'Invalid code', 'Could not read that save'); }
});
}
function confirmReset() {
openModal(`
<h2 class="dm">Hard Reset</h2><div class="sub">There is no undo</div>
<p>This erases <strong>everything</strong> — all aether, structures, Dark Matter and eternal works.
The void will not remember you.</p>
<div class="modal-actions">
<button class="m-btn" data-close>Keep my cosmos</button>
<button class="m-btn danger" id="really-reset">Erase everything</button>
</div>`);
$('#really-reset').addEventListener('click', () => {
suppressSave = true;
localStorage.removeItem(SAVE_KEY);
location.reload();
});
}
/* ---------------------------------------------------------- *
* Save / load / offline
* ---------------------------------------------------------- */
let suppressSave = false; // blocks the beforeunload save during reset/import reloads
function save() {
if (suppressSave) return;
S.lastSave = now();
try { localStorage.setItem(SAVE_KEY, JSON.stringify(S)); } catch (e) {}
}
function load() {
let raw;
try { raw = localStorage.getItem(SAVE_KEY); } catch (e) { return false; }
if (!raw) return false;
try {
const data = JSON.parse(raw);
S = Object.assign(defaultState(), data);
S.owned = Object.assign({}, ...BUILDINGS.map(b => ({ [b.id]: 0 })), data.owned || {});
S.settings = Object.assign({ particles:true, starfield:true }, data.settings || {});
S.upgrades = data.upgrades || {};
S.ascension = data.ascension || {};
S.achievements = data.achievements || {};
S.discovered = data.discovered || {};
return true;
} catch (e) { return false; }
}
function offlineProgress() {
const dt = clamp((now() - (S.lastSave || now())) / 1000, 0, OFFLINE_CAP);
if (dt < 5) return;
recompute();
const gain = D.cps * dt;
if (gain <= 0) return;
gainAether(gain, false);
const h = Math.floor(dt / 3600), m = Math.floor((dt % 3600) / 60);
const away = h ? `${h}h ${m}m` : `${m}m ${Math.floor(dt%60)}s`;
setTimeout(() => openModal(`
<h2>Welcome Back</h2><div class="sub">The void kept working</div>
<p>You were away for <strong>${away}</strong>.</p>
<p>In your absence the cosmos forged <strong>${fmt(gain)} ✦ aether</strong>.</p>
<div class="modal-actions"><button class="m-btn primary" data-close>Resume</button></div>`), 400);
}
/* ---------------------------------------------------------- *
* Starfield
* ---------------------------------------------------------- */
let cv, cx, stars = [], shootings = [], dpr = 1, sw = 0, sh = 0;
function initStars() {
cv = $('#starfield'); cx = cv.getContext('2d');
resizeStars();
addEventListener('resize', resizeStars);
}
function resizeStars() {
dpr = Math.min(devicePixelRatio || 1, 2);
sw = innerWidth; sh = innerHeight;
cv.width = sw * dpr; cv.height = sh * dpr;
cv.style.width = sw + 'px'; cv.style.height = sh + 'px';
cx.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.round(clamp(sw * sh / 9000, 90, 220));
stars = Array.from({ length:count }, () => ({
x:Math.random()*sw, y:Math.random()*sh,
z:Math.random()*Math.random(), // depth 0..1 (most are far)
tw:Math.random()*Math.PI*2,
hue:Math.random() < .12 ? 'a' : (Math.random() < .15 ? 'v' : 'g'),
}));
}
function clearStars() { if (cx) cx.clearRect(0, 0, sw, sh); }
let shootTimer = 0;
function drawStars(dt, t) {
if (!S.settings.starfield) return;
cx.clearRect(0, 0, sw, sh);
for (const s of stars) {
s.y += (0.02 + s.z * 0.16) * dt * 0.06; // gentle parallax drift
if (s.y > sh + 2) { s.y = -2; s.x = Math.random() * sw; }
const tw = 0.55 + 0.45 * Math.sin(t * 0.0013 + s.tw);
const r = 0.4 + s.z * 1.6;
const col = s.hue === 'a' ? '111,227,210' : s.hue === 'v' ? '182,156,255' : '243,234,214';
cx.beginPath();
cx.fillStyle = `rgba(${col},${(0.25 + s.z * 0.6) * tw})`;
cx.arc(s.x, s.y, r, 0, 7);
cx.fill();
}
// occasional shooting star
shootTimer -= dt;
if (shootTimer <= 0 && shootings.length < 2) {
shootTimer = 4200 + Math.random() * 8000;
const edge = Math.random() * sw;
shootings.push({ x:edge, y:-20, vx:(Math.random()*1.6+1.4)* (Math.random()<.5?-1:1), vy:Math.random()*2+3.4, life:1 });
}
for (let i = shootings.length - 1; i >= 0; i--) {
const s = shootings[i];
s.x += s.vx * dt * 0.12; s.y += s.vy * dt * 0.12; s.life -= dt * 0.0009;
if (s.life <= 0 || s.y > sh + 40) { shootings.splice(i, 1); continue; }
const len = 60;
const g = cx.createLinearGradient(s.x, s.y, s.x - s.vx*len*0.12, s.y - s.vy*len*0.12);
g.addColorStop(0, `rgba(248,227,172,${0.9*s.life})`);
g.addColorStop(1, 'rgba(248,227,172,0)');
cx.strokeStyle = g; cx.lineWidth = 1.6; cx.lineCap = 'round';
cx.beginPath(); cx.moveTo(s.x, s.y);
cx.lineTo(s.x - s.vx*len*0.12, s.y - s.vy*len*0.12); cx.stroke();
}
}
/* ---------------------------------------------------------- *
* Main loop
* ---------------------------------------------------------- */
let lastT = performance.now();
let accUI = 0, accUnlock = 0, accSave = 0;
function loop(t) {
const dt = Math.min(t - lastT, 250); // ms, cap to avoid huge jumps
lastT = t;
const sec = dt / 1000;
// production
if (D.cps > 0) gainAether(D.cps * sec, false);
// background
drawStars(dt, t);
// throttled UI
accUI += dt; accUnlock += dt; accSave += dt;
// live counters every frame (cheap)
$('.aether-value').textContent = fmt(S.aether);
$('[data-cps]').textContent = fmt(D.cps);
if (accUI >= 110) {
accUI = 0;
recompute();
$('[data-click]').textContent = '+' + fmt(D.click);
$$('[data-dm-have]').forEach(e => e.textContent = fmt(heldDM()));
updateBuildings();
renderUpgrades();
if (UI.tab === 'ascension') updateAscension();
if (UI.tab === 'codex') updateCodex();
}
if (accUnlock >= 500) { accUnlock = 0; checkUnlocks(); updateHint(); }
if (accSave >= 15000) { accSave = 0; save(); }
requestAnimationFrame(loop);
}
/* ---------------------------------------------------------- *
* Wiring
* ---------------------------------------------------------- */
function rebuildDynamic() {
UI.upgSig = '';
updateBuildings();
renderUpgrades();
updateAscension();
updateCodex();
// refresh achievement cells
ACH.forEach(a => UI.achEls[a.id]?.classList.toggle('unlocked', !!S.achievements[a.id]));
refreshHUD();
checkUnlocks();
}
function wire() {
// forge click
const forge = $('#forge');
forge.addEventListener('click', manualClick);
forge.addEventListener('keydown', e => { if (e.code === 'Space' || e.code === 'Enter') { e.preventDefault(); manualClick(); } });
// tabs
$$('.tab').forEach(tab => tab.addEventListener('click', () => {
$$('.tab').forEach(t => { t.classList.remove('is-active'); t.setAttribute('aria-selected', 'false'); });
tab.classList.add('is-active'); tab.setAttribute('aria-selected', 'true');
$$('.tab-pane').forEach(p => p.classList.remove('is-active'));
const id = tab.dataset.tab;
$(`#pane-${id}`).classList.add('is-active');
UI.tab = id;
if (id === 'ascension') updateAscension();
if (id === 'codex') updateCodex();
}));
// buy quantity
$$('.seg-btn').forEach(btn => btn.addEventListener('click', () => {
$$('.seg-btn').forEach(b => b.classList.remove('is-active'));
btn.classList.add('is-active');
const q = btn.dataset.qty;
UI.buyQty = q === 'max' ? 'max' : parseInt(q, 10);
updateBuildings();
}));
// collapse
$('#btn-collapse').addEventListener('click', doCollapse);
// topbar
$('#btn-help').addEventListener('click', openHelp);
$('#btn-settings').addEventListener('click', openSettings);
// esc closes modal
addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// persistence safety
addEventListener('visibilitychange', () => { if (document.hidden) save(); });
addEventListener('beforeunload', save);
}
/* ---------------------------------------------------------- *
* Boot
* ---------------------------------------------------------- */
function init() {
buildBuildings();
buildAscension();
buildCodex();
wire();
initStars();
const had = load();
recompute();
if (had) offlineProgress();
rebuildDynamic();
recompute();
refreshHUD();
if (!had) setTimeout(openHelp, 500);
requestAnimationFrame(t => { lastT = t; loop(t); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();