gigaslop/src/main.ts
type-two e245ebf85d 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>
2026-08-02 10:43:44 +10:00

248 lines
9.9 KiB
TypeScript

import { BAL } from "./sim/balance";
import { createState, pushEvent } from "./sim/state";
import { step, place, canPlace, wire, sell, grant, raiseRound, clapBack, shipGrade, switchHosting, unlockScrape, buyGpu, sellGpu, extinguishFire, moveRoom, spamBlast } from "./sim/sim";
import { saveState, loadState, clearSave } from "./sim/save";
import { DEF, isCompute } from "./sim/catalog";
import { Scene } from "./render/scene";
import { Hud, applyTheme, currentTheme } from "./ui/hud";
import { Panels } from "./ui/panels";
import { Tutorial } from "./ui/tutorial";
import { sfx } from "./ui/sfx";
import { unlockGen, unlockModality, setSpec, setDataSource, unlockRnd, buyUpgrade } from "./sim/sim";
import { DataSource } from "./sim/research";
async function boot() {
applyTheme(currentTheme());
const el = document.getElementById("app")!;
const loaded = loadState();
const state = loaded ?? createState();
let wiped = false;
const scene = new Scene();
await scene.init(el, state.room);
// --- input state ---
let shopSel: string | null = null;
let wireMode = false;
let wireSource: number | null = null;
let mouse = { x: 0, y: 0 };
const hud = new Hud({
onSelectShop: (id) => { shopSel = id; wireMode = false; wireSource = null; hud.setWireActive(false); },
onToggleOverlay: (w) => { scene.overlays[w] = !scene.overlays[w]; },
onWireTool: () => {
wireMode = !wireMode;
wireSource = null;
shopSel = null;
hud.selectedShop = null;
hud.setWireActive(wireMode);
},
onAlloc: (v) => { state.allocTraining = v; },
onAllocHost: (v) => { state.allocHosting = v; },
onNewGame: () => { wiped = true; clearSave(); tutorial.reset(); location.reload(); },
onRaise: () => {
const err = raiseRound(state);
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.money();
},
onClapback: () => {
const err = clapBack(state);
if (err) { pushEvent(state, err, "info"); sfx.error(); }
},
onAdSpend: (v) => { state.adSpend = v; },
onSpam: () => {
const err = spamBlast(state);
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.money();
},
onMove: (to) => {
const err = moveRoom(state, to);
if (err) { pushEvent(state, err, "info"); sfx.error(); }
else { scene.setRoom(state.room); sfx.tierUp(); saveState(state); }
},
});
hud.bindBreaker(state);
const panels = new Panels(state);
const tutorial = new Tutorial();
const tutChip = hud.root.querySelector("#tut-toggle") as HTMLButtonElement;
const syncTutChip = () => tutChip.classList.toggle("on", tutorial.enabled);
tutChip.onclick = () => { tutorial.setEnabled(!tutorial.enabled); syncTutChip(); };
syncTutChip();
const labelsChip = hud.root.querySelector("#labels-btn") as HTMLButtonElement;
const syncLabels = () => labelsChip.classList.toggle("on", scene.showLabels);
labelsChip.onclick = () => {
scene.showLabels = !scene.showLabels;
localStorage.setItem("gigaslop-labels", scene.showLabels ? "1" : "0");
syncLabels();
sfx.click();
};
syncLabels();
(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 = "";
wireMode = false; wireSource = null; hud.setWireActive(false);
};
window.addEventListener("keydown", (e) => {
if ((e.target as HTMLElement)?.tagName === "INPUT") return;
if (e.key === "Escape") { cancel(); panels.closeAll(); }
if (e.key === "w" || e.key === "W") {
wireMode = !wireMode; wireSource = null; shopSel = null; hud.setWireActive(wireMode);
}
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) => {
mouse = { x: e.clientX, y: e.clientY };
});
const deviceAt = (tx: number, ty: number) =>
state.devices.find((d) => {
const def = DEF[d.defId];
return tx >= d.x && tx < d.x + def.footprint.w && ty >= d.y && ty < d.y + def.footprint.h;
});
scene.app.canvas.addEventListener("pointerdown", (e) => {
const t = scene.tileAt(e.clientX, e.clientY);
if (e.button === 2) {
if (shopSel || wireMode) { cancel(); return; }
if (t.in) {
const d = deviceAt(t.x, t.y);
if (d) { sell(state, d.uid); sfx.sell(); }
}
return;
}
if (!t.in) return;
if (extinguishFire(state, t.x, t.y)) { sfx.sell(); return; }
if (shopSel) {
const err = canPlace(state, shopSel, t.x, t.y);
if (err) { pushEvent(state, err, "info"); sfx.error(); return; }
place(state, shopSel, t.x, t.y);
sfx.place();
if (state.cash < DEF[shopSel].cost) { shopSel = null; hud.selectedShop = null; hud.lockedCache = ""; }
return;
}
if (wireMode) {
const d = deviceAt(t.x, t.y);
if (!d) return;
const def = DEF[d.defId];
if (wireSource === null) {
if (isCompute(def) || def.switchMbps) {
wireSource = d.uid;
sfx.click();
pushEvent(state, `${def.name} picked — now click a router or switch (green)`, "info");
} else if (def.uplinkMbps) {
pushEvent(state, "that's the internet side — pick the PC/rig first (cyan), then this", "info");
} else {
pushEvent(state, `${def.name} can't be wired — pick a PC, rig, rack, or switch`, "info");
}
} else {
const err = wire(state, wireSource, d.uid);
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.wire();
wireSource = null;
}
return;
}
// no tool active: click inspects a device, empty floor deselects
const d = deviceAt(t.x, t.y);
panels.selectedDevice = d ? d.uid : null;
panels.render();
if (d) sfx.click();
});
scene.app.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
// --- fixed-tick sim pump ---
// setInterval (not RAF) so the economy keeps running when the tab is hidden;
// background tabs clamp timers to ~1 Hz, the accumulator catches up (max 2 s).
let acc = 0;
let last = performance.now();
setInterval(() => {
const now = performance.now();
acc += Math.min(2, (now - last) / 1000);
last = now;
if (document.getElementById("name-modal")) { acc = 0; return; } // clock frozen until incorporated
while (acc >= BAL.SIM_DT) { step(state); acc -= BAL.SIM_DT; }
}, 50);
// --- render loop ---
const loop = () => {
const t = scene.tileAt(mouse.x, mouse.y);
const ghost = shopSel && t.in
? { defId: shopSel, x: t.x, y: t.y, ok: !canPlace(state, shopSel, t.x, t.y) }
: null;
scene.render(state, ghost, 1 / 60, {
wireActive: wireMode,
wireSource,
ringDefs: tutorial.ringDefs(),
});
if (wireMode && wireSource !== null) {
const src = state.devices.find((d) => d.uid === wireSource);
scene.highlightWireSource(src ?? null, state);
}
requestAnimationFrame(loop);
};
// HUD at 5 Hz; open panels refresh at 1 Hz (innerHTML rebuild resets scroll)
let hudTicks = 0;
setInterval(() => {
hud.update(state);
tutorial.update(state);
if (++hudTicks % 5 === 0 && (panels.open || panels.selectedDevice !== null)) panels.render();
}, 200);
hud.update(state);
if (!loaded) {
// starter bedroom: your old PC (Dumpster inside) and router, not yet wired
grant(state, "desk", 5, 7);
grant(state, "desk_pc", 7, 7, ["gfx1060"]);
grant(state, "router", 2, 1);
grant(state, "ssd_shoebox", 0, 7);
const names = await hud.showNameModal();
state.company = names.company;
state.modelName = names.model;
document.title = `${state.company} — GigaSlop`;
pushEvent(state, `${state.company} is live. ${state.modelName} 1 awaits its slop. Wire the PC to the router (W).`, "info");
saveState(state);
} else {
document.title = `${state.company} — GigaSlop`;
}
requestAnimationFrame(loop);
// autosave (suppressed after a New Game wipe so the wipe actually sticks)
setInterval(() => { if (!wiped) saveState(state); }, 10_000);
window.addEventListener("beforeunload", () => { if (!wiped) saveState(state); });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && !wiped) saveState(state);
});
// debug/automation handle
(window as never as Record<string, unknown>).__giga = {
state, scene, place: (id: string, x: number, y: number) => place(state, id, x, y),
wire: (a: number, b: number) => wire(state, a, b),
stepN: (n: number) => { for (let i = 0; i < n; i++) step(state); },
newGame: () => { wiped = true; clearSave(); location.reload(); },
raise: () => raiseRound(state),
clap: () => clapBack(state),
panels,
gen: () => unlockGen(state),
modality: (id: string) => unlockModality(state, id),
spec: (id: string) => setSpec(state, id),
data: (k: DataSource) => setDataSource(state, k),
rnd: (id: string) => unlockRnd(state, id),
upg: (uid: number, id: string) => buyUpgrade(state, uid, id),
ship: (g: "mini" | "pro" | "max") => shipGrade(state, g),
host: (id: number | null) => switchHosting(state, id),
scrape: (id: string) => unlockScrape(state, id),
gpu: (uid: number, id: string) => buyGpu(state, uid, id),
sellGpu: (uid: number, slot: number) => sellGpu(state, uid, slot),
move: (to: string) => { const e = moveRoom(state, to); if (!e) scene.setRoom(state.room); return e; },
spam: () => spamBlast(state),
};
}
boot();