HQ dashboard on Tab with a live advisor; fix left-column overlap

New 📊 HQ panel (Tab): gross money broken out per stream (slop ads,
API drip, hosting, SaaS, power), compute allocation and constraints
(VRAM/disk/uplink), model line status, and a rule-based WHAT'S NEXT
advisor that surfaces the top three moves for the current state —
the always-available answer to "how do I progress". Left column
restructured: chips flow two-per-row and the tutorial card docks
below the controls, so it can no longer cover the TRAIN slider it
was pointing at. Rates now track ad/API/SaaS revenue separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-02 10:43:44 +10:00
parent 83a97f7441
commit e245ebf85d
7 changed files with 108 additions and 6 deletions

View File

@ -77,6 +77,7 @@ async function boot() {
(hud.root.querySelector("#lab-btn") as HTMLButtonElement).onclick = () => panels.toggle("lab");
(hud.root.querySelector("#rnd-btn") as HTMLButtonElement).onclick = () => panels.toggle("rnd");
(hud.root.querySelector("#market-btn") as HTMLButtonElement).onclick = () => panels.toggle("market");
(hud.root.querySelector("#hq-btn") as HTMLButtonElement).onclick = () => panels.toggle("hq");
const cancel = () => {
shopSel = null; hud.selectedShop = null; hud.lockedCache = "";
@ -92,6 +93,7 @@ async function boot() {
if (e.key === "l" || e.key === "L") panels.toggle("lab");
if (e.key === "r" || e.key === "R") panels.toggle("rnd");
if (e.key === "m" || e.key === "M") panels.toggle("market");
if (e.key === "Tab") { e.preventDefault(); panels.toggle("hq"); }
});
scene.app.canvas.addEventListener("pointermove", (e) => {

View File

@ -632,6 +632,9 @@ export function step(s: SimState) {
const tokTrain = tokEff * trainShare * dt;
const tokHost = tokEff * hostShare; // tok/s
let income = tokInf * BAL.TOK_PRICE * tm; // baseline API drip
let apiRevTick = income; // dashboard stream tracking (gross, pre-equity)
let adRevTick = 0;
let saasRevTick = 0;
// training fills the research bank; the Lab decides what it buys.
// checkpoints for the version you're training toward need disk to live on.
@ -811,6 +814,7 @@ export function step(s: SimState) {
s.totalViews += v.rate * dt;
liveViewRate += v.rate;
income += (v.rate * dt * BAL.CPM) / 1000;
adRevTick += (v.rate * dt * BAL.CPM) / 1000;
}
// --- SaaS: the "Instant Girlfriend App" wrapper business ---
@ -824,6 +828,7 @@ export function step(s: SimState) {
s.subs = Math.max(0, s.subs + signups - s.subs * churn * dt);
subsNet = (s.subs - before) / dt;
income += s.subs * BAL.SAAS.SUB_REV_S * dt;
saasRevTick = s.subs * BAL.SAAS.SUB_REV_S * dt;
const stages = [100, 1_000, 10_000, 100_000];
while (s.subStage < stages.length && s.subs >= stages[s.subStage]) {
s.subStage++;
@ -891,6 +896,9 @@ export function step(s: SimState) {
r.trainStalled = trainStalled;
r.subsNet += (subsNet - r.subsNet) * sm;
r.saasLatency = saasLatency;
r.adRev += (adRevTick / dt - r.adRev) * sm;
r.apiRev += ((apiRevTick * priceFx) / dt - r.apiRev) * sm;
r.saasRev += (saasRevTick / dt - r.saasRev) * sm;
}
/** HUD helper: progress toward the next slop video + whether tokens flow at all */

View File

@ -101,6 +101,9 @@ export interface SimState {
trainStalled: boolean;
subsNet: number; // subscribers/s net growth
saasLatency: boolean;
adRev: number; // $/s from slop ad views
apiRev: number; // $/s API drip
saasRev: number; // $/s subscriptions
};
events: { msg: string; t: number; kind: string }[];
rng: number;
@ -163,6 +166,7 @@ export function createState(): SimState {
adapt: 0, hostIncome: 0, hostTok: 0,
vramHave: 0, vramNeed: 0, storageGB: 0, trainStalled: false,
subsNet: 0, saasLatency: false,
adRev: 0, apiRev: 0, saasRev: 0,
},
events: [],
rng: 0x5109a & 0xffffffff,

View File

@ -138,7 +138,13 @@ body { background: var(--bg); }
}
@keyframes flash { from { opacity: 1; } to { opacity: 0.55; } }
#controls { position: absolute; top: 10px; left: 10px; display: flex; flex-direction: column; gap: 6px; padding: 10px; }
#leftcol { position: absolute; top: 10px; left: 10px; width: 254px; display: flex;
flex-direction: column; gap: 8px; max-height: calc(100vh - 20px); pointer-events: none; }
#leftcol > * { pointer-events: auto; }
#controls { display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px; padding: 10px; }
#controls .chip { flex: 1 1 44%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#controls #alloc-row, #controls #mandate, #controls #clap { flex: 1 1 100%; }
#alloc, #allochost, #adspend { width: 100%; }
.chip {
background: var(--chip); border: 1px solid var(--edge); color: var(--dim); border-radius: 6px;
padding: 5px 9px; cursor: pointer; font: inherit; text-align: left;
@ -242,8 +248,8 @@ body { background: var(--bg); }
#devpanel .devbtns { display: flex; gap: 6px; margin-bottom: 4px; }
#devpanel h4 { margin: 8px 0 4px; font-size: 10px; color: var(--dim); letter-spacing: 0.15em; }
/* tutorial quest card */
#tutorial { position: absolute; left: 10px; top: 46vh; width: 250px; }
/* tutorial quest card — docked in #leftcol under the controls */
#tutorial { width: 254px; }
.tutcard { padding: 10px 12px; border-color: var(--purple) !important; pointer-events: auto; }
.tutcard.tutdone { border-color: var(--green) !important; }
.tuthead { display: flex; justify-content: space-between; align-items: center;

View File

@ -83,6 +83,7 @@ export class Hud {
<button id="breaker-reset">RESET BREAKER</button>
</div>
<div id="fxrow"></div>
<div id="leftcol">
<div id="controls" class="panel">
<button class="chip" data-ov="heat">🌡 Heat</button>
<button class="chip" data-ov="power"> Power</button>
@ -92,6 +93,7 @@ export class Hud {
<button class="chip" id="lab-btn">🧪 Model Lab (L)</button>
<button class="chip" id="rnd-btn">🏢 R&D (R)</button>
<button class="chip" id="market-btn">📡 Market (M)</button>
<button class="chip" id="hq-btn">📊 HQ (Tab)</button>
<button class="chip" id="mute">${isMuted() ? "🔇 Muted" : "🔊 Sound"}</button>
<button class="chip" id="theme">🎨 ${THEMES.find((t) => t.id === currentTheme())?.name ?? "Midnight"}</button>
<button class="chip" id="labels-btn">🏷 Labels</button>
@ -114,6 +116,7 @@ export class Hud {
<button class="chip" id="spam">📧 Spam Blast</button>
<button class="chip" id="move-hq" style="display:none">🚚 Move HQ</button>
</div>
</div>
<div id="shop" class="panel"><h3>HARDWARE STORE</h3></div>
<div id="feed" class="panel"><h3><b></b> SLOPTUBE STUDIO <span id="vidprog"></span></h3><div id="vids"></div></div>
<div id="hint">click store item, place in room · W: wire PCrouter · right-click: sell · Esc: cancel</div>

View File

@ -11,6 +11,7 @@ import {
shipGrade, switchHosting, unlockScrape, buyGpu, sellGpu, cardStats, deviceTokRate,
} from "../sim/sim";
import { pushEvent } from "../sim/state";
import { ROOMS, MOVES } from "../sim/rooms";
import { sfx } from "./sfx";
const fmt = (n: number) =>
@ -18,7 +19,7 @@ const fmt = (n: number) =>
export class Panels {
state: SimState;
open: "lab" | "rnd" | "market" | null = null;
open: "lab" | "rnd" | "market" | "hq" | null = null;
selectedDevice: number | null = null;
root: HTMLElement;
@ -30,7 +31,7 @@ export class Panels {
this.root.addEventListener("click", (e) => this.dispatch(e));
}
toggle(which: "lab" | "rnd" | "market") {
toggle(which: "lab" | "rnd" | "market" | "hq") {
this.open = this.open === which ? null : which;
sfx.click();
this.render();
@ -79,6 +80,7 @@ export class Panels {
if (this.open === "lab") html += this.labHtml(s);
if (this.open === "rnd") html += this.rndHtml(s);
if (this.open === "market") html += this.marketHtml(s);
if (this.open === "hq") html += this.hqHtml(s);
if (this.selectedDevice !== null) {
const d = s.devices.find((x) => x.uid === this.selectedDevice);
if (d) html += this.deviceHtml(s, d);
@ -197,6 +199,82 @@ export class Panels {
</div></div>`;
}
/** rule-based advisor: the top three moves for right now */
private suggest(s: SimState): string[] {
const out: string[] = [];
const r = s.rates;
const next = BAL.TIERS[s.tier + 1];
const anyWired = s.devices.some((d) => d.wiredTo !== null && isCompute(DEF[d.defId]));
const mv = MOVES.find((m) => m.from === s.room);
if (!anyWired) out.push("🔌 Nothing is producing — press W and wire a PC to a router.");
if (s.mandate) {
const got = s.totalViews - s.mandate.startViews;
out.push(`💰 Mandate: ${fmt(got)}/${fmt(s.mandate.target)} views, ${Math.max(0, Math.round((s.mandate.deadline - s.t) / 60))} min left — spend on compute NOW.`);
}
if (r.trainStalled) out.push(`💾 Training is STALLED — buy storage (need ${next?.checkpointGB}GB of disk).`);
if (r.vramNeed > 0 && r.vramHave < r.vramNeed)
out.push(`🧠 VRAM-starved (${Math.round(r.vramHave)}/${r.vramNeed}GB) — bigger cards or everything runs slow.`);
if (next && s.trainTokens >= next.tokens!)
out.push(`🧪 Bank is full — release ${s.modelName} ${VERSION_NAMES[s.tier + 1]} in the Lab (L) for x${next.mult} revenue and a filter reset.`);
if (r.maxTemp > 82) out.push(`🔥 ${Math.round(r.maxTemp)}°C — add cooling near the hot box before it ignites (>95°C burns).`);
if (r.watts > r.breakerWatts * 0.85) out.push("⚡ Breaker near its limit — a 20A Sub-Panel buys headroom.");
if (s.platformAdapt > 0.45) out.push(`🤖 ALGO HEAT ${Math.round(s.platformAdapt * 100)}% — push TRAIN and release a fresh version to slip the filters.`);
if (s.tier >= 1 && s.shippedGrades.length === 0)
out.push("🚀 Ship a grade in the Lab — shipped models earn on the API Market and unlock SaaS subscribers.");
if (s.shippedGrades.length > 0 && s.allocHosting === 0)
out.push("📡 HOST % is at zero — the API Market (M) pays for idle compute.");
if (r.dataMult < 1) out.push("📶 Uplink saturated — tokens are being dropped; fiber or switches will fix it.");
if (s.round === 0 && !s.mandate && s.earnedTotal > 60)
out.push("💰 Raise Seed when you can spend it fast — cash now, views mandate later.");
if (s.cash > 500 && !s.devices.some((d) => (DEF[d.defId].gpuSlots ?? 0) >= 4))
out.push("🛒 A Milk-Crate Rig Frame + cards beats any single PC — four GPU slots.");
if (mv && s.earnedTotal >= mv.unlockAt) out.push(`🚚 ${ROOMS[mv.to].name} is unlocked — more tiles, more power service.`);
if (out.length === 0) out.push("😎 Nothing on fire. Scale compute, keep ALGO HEAT low, stack subscribers.");
return out.slice(0, 3);
}
private hqHtml(s: SimState): string {
const r = s.rates;
const money = (v: number) => `$${v.toFixed(3)}/s`;
const net = r.income - r.powerCost;
const gen = BAL.TIERS[s.tier];
const trainShare = Math.round(s.allocTraining * 100);
const hostShare = Math.round(Math.min(s.allocHosting, 1 - s.allocTraining) * 100);
const grades = s.shippedGrades.map((g) =>
`${s.modelName} ${VERSION_NAMES[g.version]}${GRADES.find((x) => x.id === g.grade)!.suffix}${g.distilled ? " 🥷" : ""}`).join(" · ") || "none shipped";
return `<div class="modal panel" id="hq">
<div class="mhead"><h2>📊 ${s.company.toUpperCase()} HQ</h2>
<span class="bank">${ROOMS[s.room].name} · round ${s.round} · equity power ${Math.round(s.equityMult * 100)}%</span>
<button class="mclose" data-cmd="close"></button></div>
<div class="mcols">
<div class="mcol">
<h4>WHAT'S NEXT</h4>
${this.suggest(s).map((t) => `<div class="node owned">${t}</div>`).join("")}
<h4>MONEY (gross)</h4>
<div class="node">Slop ad views<small>${money(r.adRev)}</small></div>
<div class="node">API drip<small>${money(r.apiRev)}</small></div>
<div class="node">Hosting<small>${money(r.hostIncome)}</small></div>
<div class="node">SaaS (${fmt(s.subs)} subs)<small>${money(r.saasRev)}${r.saasLatency ? " · ⚠ LATENCY CHURN" : ""}</small></div>
<div class="node">Power bill<small>-${money(r.powerCost)}</small></div>
<div class="node ${net >= 0 ? "owned" : "dim"}">NET<small>${net >= 0 ? "+" : ""}${money(net)}</small></div>
</div>
<div class="mcol">
<h4>COMPUTE ${fmt(r.tokEff)} tok/s</h4>
<div class="node">Slop videos<small>${100 - trainShare - hostShare}% of tokens</small></div>
<div class="node">Training<small>${trainShare}% · bank ${fmt(s.trainTokens)} tok${r.trainStalled ? " · 💾 STALLED" : ""}</small></div>
<div class="node">Hosting<small>${hostShare}% · ${fmt(r.hostTok)} tok/s allocated</small></div>
<div class="node">VRAM<small>${Math.round(r.vramHave)}GB${r.vramNeed ? ` / ${r.vramNeed}GB needed` : ""}</small></div>
<div class="node">Disk<small>${fmt(r.storageGB)}GB</small></div>
<div class="node">Uplink<small>${fmt(r.demandMbps)}/${fmt(r.uplinkMbps)} Mbps${r.dataMult < 1 ? " · ⚠ SATURATED" : ""}</small></div>
<h4>MODEL LINE</h4>
<div class="node">${s.modelName} ${VERSION_NAMES[s.tier]}${s.collapsed ? " 🌀 COLLAPSED" : ""}<small>revenue x${gen.mult}${s.collapsed ? " halved by collapse" : ""} · ${s.spec ? SPECS.find((x) => x.id === s.spec)?.name : "no fine-tune"}</small></div>
<div class="node">Shipped<small>${grades}</small></div>
<div class="node">ALGO HEAT<small>${Math.round(s.platformAdapt * 100)}% views cut ${Math.round(s.platformAdapt * BAL.ADAPT_MAX_PENALTY * 100)}%</small></div>
<div class="node">Lifetime<small>${fmt(s.totalViews)} views · $${fmt(s.earnedTotal)} earned · ${s.perkPoints} perk pts</small></div>
</div>
</div></div>`;
}
private deviceHtml(s: SimState, d: (typeof s.devices)[number]): string {
const def = DEF[d.defId];
const compute = isCompute(def);

View File

@ -91,7 +91,8 @@ export class Tutorial {
constructor() {
this.root = document.createElement("div");
this.root.id = "tutorial";
document.getElementById("hud")!.appendChild(this.root);
// dock under the controls so the card can never cover the sliders it points at
(document.getElementById("leftcol") ?? document.getElementById("hud")!).appendChild(this.root);
this.root.addEventListener("click", (e) => {
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-tut]");
if (!el) return;