gigaslop/src/sim/sim.ts
type-two 3b5e965ae7 Onboarding clarity: labels, tutorial pointing, wire affordances, heartbeat
New players couldn't tell which sprite was which or what to click.
Now: name tags under every device (🏷 chip toggles, persisted); the
tutorial physically points — active step pulses its UI targets (wire
chip, shop items via data-def, sliders, Lab/Market buttons) and draws
amber rings around the actual devices in the room; wire mode outlines
pickable sources in cyan and valid targets in green; and the SlopTube
header shows a live "next slop N%" render progress — or an explicit
"nothing flowing — wire a PC" warning instead of silent dead air.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 00:44:57 +10:00

909 lines
38 KiB
TypeScript

import { BAL } from "./balance";
import { CATALOG, DEF, DeviceDef, GPUS, GPU_DEF, isCompute } from "./catalog";
import { Device, SimState, pushEvent, rand } from "./state";
import {
computeMods, deviceMods, MODALITIES, SPECS, SPEC_COST_BY_GEN,
DATA_SOURCES, COLLAPSE_CHANCE, RND, SOCKETS, SOCKET_SLOTS, DataSource,
GRADES, VERSION_NAMES, DISTILL_MARGIN_CUT, SCRAPE_OPS, rollMarketModel,
} from "./research";
import { ROOMS, MOVES } from "./rooms";
import SLOP_TITLE_COUNT_JSON from "../data/sloptitles.json";
const TITLE_COUNT = (SLOP_TITLE_COUNT_JSON as string[]).length;
// Titles 0..COHERENT_TITLES-1 are readable; the rest are deranged small-LLM output.
// Low model tiers mostly publish the deranged ones — training buys coherence.
const COHERENT_TITLES = Math.min(60, TITLE_COUNT);
const THUMB_COUNT = 10;
function rollTitle(s: SimState): number {
const brokenCount = TITLE_COUNT - COHERENT_TITLES;
// a collapsed model outputs pure gibberish regardless of generation
const brokenChance = s.collapsed ? 1 : s.tier === 0 ? 0.75 : s.tier === 1 ? 0.35 : 0;
if (brokenCount > 0 && rand(s) < brokenChance)
return COHERENT_TITLES + Math.floor(rand(s) * brokenCount);
return Math.floor(rand(s) * COHERENT_TITLES);
}
export function footprintTiles(def: DeviceDef, x: number, y: number): [number, number][] {
const tiles: [number, number][] = [];
for (let dx = 0; dx < def.footprint.w; dx++)
for (let dy = 0; dy < def.footprint.h; dy++) tiles.push([x + dx, y + dy]);
return tiles;
}
export function canPlace(s: SimState, defId: string, x: number, y: number): string | null {
const def = DEF[defId];
if (!def) return "unknown device";
if (s.cash < def.cost) return "can't afford";
const room = ROOMS[s.room];
for (const [tx, ty] of footprintTiles(def, x, y)) {
if (tx < 0 || ty < 0 || tx >= room.w || ty >= room.h) return "out of bounds";
if (def.wallOnly && ty !== 0 && tx !== 0) return "must go against a wall";
for (const d of s.devices)
for (const [ox, oy] of footprintTiles(DEF[d.defId], d.x, d.y))
if (ox === tx && oy === ty) return "occupied";
}
return null;
}
export function place(s: SimState, defId: string, x: number, y: number): Device | null {
if (canPlace(s, defId, x, y)) return null;
const def = DEF[defId];
s.cash -= def.cost;
const d: Device = {
uid: s.nextUid++, defId, x, y, on: true, powered: false,
wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0, upgrades: [], gpus: [], hotSince: null,
};
s.devices.push(d);
pushEvent(s, `Installed ${def.name}`, "buy");
return d;
}
export function cardStats(d: Device) {
let tok = 0, watts = 0, vram = 0, heat = 0;
for (const id of d.gpus) {
const g = GPU_DEF[id];
if (!g) continue;
tok += g.tok; watts += g.watts; vram += g.vram; heat += g.heat;
}
return { tok, watts, vram, heat };
}
/** intrinsic + installed-card token rate */
export function deviceTokRate(d: Device): number {
return DEF[d.defId].tokRate + cardStats(d).tok;
}
/** place without cost/afford checks — starter room setup */
export function grant(s: SimState, defId: string, x: number, y: number, gpus: string[] = []): Device {
const d: Device = {
uid: s.nextUid++, defId, x, y, on: true, powered: false,
wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0, upgrades: [], gpus, hotSince: null,
};
s.devices.push(d);
return d;
}
export function sell(s: SimState, uid: number) {
const i = s.devices.findIndex((d) => d.uid === uid);
if (i < 0) return;
const def = DEF[s.devices[i].defId];
s.cash += Math.round(def.cost * 0.5);
// orphan any cables pointing at a sold router
for (const d of s.devices) if (d.wiredTo === uid) d.wiredTo = null;
s.devices.splice(i, 1);
pushEvent(s, `Sold ${def.name} (+$${Math.round(def.cost * 0.5)})`, "info");
}
export function wire(s: SimState, deviceUid: number, targetUid: number): string | null {
const dev = s.devices.find((d) => d.uid === deviceUid);
const tgt = s.devices.find((d) => d.uid === targetUid);
if (!dev || !tgt) return "missing device";
const devDef = DEF[dev.defId], tgtDef = DEF[tgt.defId];
const devIsSwitch = !!devDef.switchMbps;
if (!isCompute(devDef) && !devIsSwitch) return "nothing to wire there";
if (!tgtDef.uplinkMbps && !tgtDef.switchMbps) return "that's not a router or switch";
if (devIsSwitch && tgtDef.switchMbps) return "no switch-to-switch daisy chains (yet)";
const ports = tgtDef.ports ?? 4;
const used = s.devices.filter((d) => d.wiredTo === targetUid).length;
if (used >= ports) return `ports full (${ports})`;
dev.wiredTo = targetUid;
pushEvent(s, `Wired ${devDef.name} to ${tgtDef.name}`, "info");
return null;
}
export function resetBreaker(s: SimState) {
s.breakerTripped = false;
pushEvent(s, "Breaker reset", "info");
}
export function raiseRound(s: SimState): string | null {
if (s.mandate) return "the board wants results, not more begging";
const offer = BAL.ROUNDS[s.round];
if (!offer) return "no bigger fools left to raise from";
s.cash += offer.cash;
s.mandate = {
name: offer.name,
startViews: s.totalViews,
target: offer.viewsGoal,
deadline: s.t + offer.timeS,
cash: offer.cash,
};
pushEvent(s, `💰 ${offer.name} closed: +$${offer.cash}. Mandate: ${(offer.viewsGoal / 1000).toFixed(0)}K views in ${Math.round(offer.timeS / 60)} min`, "good");
return null;
}
export function clapBack(s: SimState): string | null {
const cb = s.clapback;
if (!cb || s.t > cb.expires) return "the moment has passed";
if (s.cash < cb.cost) return "can't afford the ratio";
s.cash -= cb.cost;
s.clapback = null;
if (rand(s) < BAL.CLAPBACK_SUCCESS + computeMods(s).clapbackSuccessAdd) {
s.effects = s.effects.filter((e) => e.id !== cb.effectId);
s.effects.push({ id: "ratio", label: "😤 Ratio'd them into the dirt", viewMult: 1.25, expires: s.t + 20 });
pushEvent(s, "😤 CLAP BACK LANDED — effect neutralized, feed loves the drama", "good");
} else {
s.platformAdapt = Math.min(1, s.platformAdapt + 0.05);
pushEvent(s, "🤡 Clap back BACKFIRED — screenshots everywhere, filters tighten", "bad");
}
return null;
}
// debuff durations shrink with a lobbyist on retainer
function fxDur(s: SimState, base: number): number {
return base * computeMods(s).rivalDurMult;
}
function igniteDevice(s: SimState, d: Device) {
const def = DEF[d.defId];
// a nearby CO2 extinguisher sacrifices itself
const ext = s.devices.find((e) =>
e.defId === "extinguisher" && Math.abs(e.x - d.x) + Math.abs(e.y - d.y) <= 3);
if (ext) {
s.devices.splice(s.devices.indexOf(ext), 1);
d.hotSince = null;
s.heat[d.y * ROOMS[s.room].w + d.x] *= 0.4;
pushEvent(s, `🧯 CO2 extinguisher doused the ${def.name} — hardware saved, extinguisher spent`, "info");
return;
}
s.fires.push({ x: d.x, y: d.y, until: s.t + 10 });
const lostCards = d.gpus.map((g) => GPU_DEF[g]?.name).filter(Boolean);
for (const o of s.devices) if (o.wiredTo === d.uid) o.wiredTo = null;
s.devices.splice(s.devices.indexOf(d), 1);
pushEvent(s, `🔥 THERMAL RUNAWAY — ${def.name} caught fire${lostCards.length ? ` (RIP ${lostCards.join(", ")})` : ""}. Click the flames to fight the fire!`, "bad");
}
export function extinguishFire(s: SimState, x: number, y: number): boolean {
const i = s.fires.findIndex((f) => f.x === x && f.y === y);
if (i < 0) return false;
s.fires.splice(i, 1);
s.heat[y * ROOMS[s.room].w + x] *= 0.5;
pushEvent(s, "🧯 Fire beaten out with a hoodie", "info");
return true;
}
export function moveRoom(s: SimState, to: string): string | null {
const mv = MOVES.find((m) => m.to === to);
if (!mv || !ROOMS[to]) return "nowhere to move";
if (s.room === to) return "you already live here";
if (mv.from !== s.room) return `that move starts from the ${ROOMS[mv.from].name}`;
if (s.earnedTotal < mv.unlockAt) return `earn $${mv.unlockAt} total first`;
if (s.cash < mv.cost) return "can't afford the bond";
s.cash -= mv.cost;
s.room = to;
const room = ROOMS[to];
s.heat = new Float32Array(room.w * room.h);
s.fires = [];
for (const d of s.devices) d.hotSince = null;
pushEvent(s, `🚚 MOVED TO ${room.name.toUpperCase()}${room.w}x${room.h} tiles, ${room.breakerBase}W service`, "good");
return null;
}
export function spamBlast(s: SimState): string | null {
const cost = 40 * (s.round + 1);
if (s.t - s.lastSpamT < 60) return `spam cannon reloading (${Math.ceil(60 - (s.t - s.lastSpamT))}s)`;
if (s.cash < cost) return "can't afford the mailing list";
const live = s.videos.filter((v) => !v.dead);
if (!live.length) return "nothing live to spam about";
s.cash -= cost;
s.lastSpamT = s.t;
const tm = BAL.TIERS[s.tier].mult * (s.collapsed ? 0.5 : 1);
let views = 0;
for (const v of live) {
const boost = 400 * tm * v.viral;
v.views += boost;
views += boost;
}
s.totalViews += views;
s.cash += (views * BAL.CPM) / 1000;
s.earnedTotal += (views * BAL.CPM) / 1000;
s.platformAdapt = Math.min(1, s.platformAdapt + 0.04);
if (rand(s) < 0.15) {
s.effects.push({ id: "blacklist", label: "📧 spam-blacklisted", viewMult: 0.8, expires: s.t + 60 });
pushEvent(s, `📧 Spam blast: +${Math.round(views / 1000)}K views… and a blacklist (views -20% for 60s)`, "bad");
} else {
pushEvent(s, `📧 Spam blast lands: +${Math.round(views / 1000)}K views across the catalog`, "good");
}
return null;
}
// Fictional rival archetypes — event table rolled on a seeded timer
const RIVALS = [
{ id: "doom_warn", w: 2, run: (s: SimState) => {
if (computeMods(s).doomFlip) {
s.effects.push({ id: "doom_flip", label: "🕯 Doomsworth panic reframed as praise", viewMult: 1.3, expires: s.t + 30 });
pushEvent(s, "🕯 Doomsworth attacks — your safety blog spins it into an endorsement", "good");
return;
}
s.platformAdapt = Math.min(1, s.platformAdapt + 0.12);
pushEvent(s, "🕯 Clament Doomsworth warns Congress about slop — platforms panic (ALGO HEAT +12%)", "rival");
} },
{ id: "lawsuit", w: 1, run: (s: SimState) => {
const m = computeMods(s);
if (s.dataSource !== "scraped" || m.lawsuitImmune) {
pushEvent(s, "⚖️ Copyright trolls circle, find nothing actionable, leave disappointed", "info");
return;
}
const fine = Math.min(s.cash * 0.2, 400 * (s.round + 1)) * m.fineMult;
s.cash -= fine;
pushEvent(s, `⚖️ Copyright lawsuit over scraped training data — settled for $${fine.toFixed(0)}`, "rival");
} },
{ id: "doom_pub", w: 1, run: (s: SimState) => {
s.effects.push({ id: "doom_pub", label: "🕯 'dangerously capable' — free PR", viewMult: 1.5, expires: s.t + 45 });
pushEvent(s, "🕯 Doomsworth calls your model 'dangerously capable' — best ad you never bought", "good");
} },
{ id: "hype_demo", w: 2, run: (s: SimState) => {
s.effects.push({ id: "hype_demo", label: "🎪 Hypeman demo eating your feed", viewMult: 0.75, expires: s.t + fxDur(s, 60) });
s.clapback = { effectId: "hype_demo", label: "Expose the faked demo", cost: clapCost(s), expires: s.t + BAL.CLAPBACK_WINDOW_S };
pushEvent(s, "🎪 Chaz Hypeman's (obviously faked) demo is stealing your views", "rival");
} },
{ id: "weights_drop", w: 2, run: (s: SimState) => {
s.effects.push({ id: "weights_drop", label: "🐧 free clone cratering API prices", priceMult: 0.5, expires: s.t + fxDur(s, 90) });
s.clapback = { effectId: "weights_drop", label: "FUD their safety record", cost: clapCost(s), expires: s.t + BAL.CLAPBACK_WINDOW_S };
pushEvent(s, "🐧 Freeman Weights open-sourced a SlopLM clone — token prices halved", "rival");
} },
{ id: "poogle_algo", w: 2, run: (s: SimState) => {
s.effects.push({ id: "poogle_algo", label: "🏢 Poogle algo favors its own slop", viewMult: 0.7, expires: s.t + fxDur(s, 60) });
s.clapback = { effectId: "poogle_algo", label: "Cry antitrust on main", cost: clapCost(s), expires: s.t + BAL.CLAPBACK_WINDOW_S };
pushEvent(s, "🏢 Poogle tweaked the algorithm to favor its own slop farm", "rival");
} },
];
// ---------- Lab / R&D / socket commands ----------
export function unlockGen(s: SimState): string | null {
const next = BAL.TIERS[s.tier + 1];
if (!next) return "no bigger model left to imagine";
if (s.trainTokens < next.tokens!) return `need ${next.tokens} training tokens`;
s.trainTokens -= next.tokens!;
s.tier++;
s.platformAdapt *= BAL.ADAPT_TIER_RELIEF;
s.adaptStage = 0;
if (s.spec) pushEvent(s, "🧪 New base model — your old specialization tuning is gone", "info");
s.spec = null;
s.collapsed = false;
const label = `${s.modelName} ${VERSION_NAMES[s.tier]}`;
if (s.dataSource === "own" && rand(s) < COLLAPSE_CHANCE) {
s.collapsed = true;
pushEvent(s, `🌀 MODEL COLLAPSE — ${label} trained on its own slop outputs pure gibberish (revenue halved this version)`, "bad");
}
pushEvent(s, `🧠 ${label} RELEASED (revenue x${next.mult})`, "good");
pushEvent(s, `😎 ${label} slop slips right past the spam filters`, "good");
return null;
}
export function unlockModality(s: SimState, id: string): string | null {
const node = MODALITIES.find((n) => n.id === id);
if (!node) return "unknown research";
if (s.modalities.includes(id)) return "already unlocked";
for (const req of node.requires ?? [])
if (!s.modalities.includes(req)) return `requires ${MODALITIES.find((n) => n.id === req)?.name}`;
if (s.trainTokens < node.cost) return `need ${node.cost} training tokens`;
s.trainTokens -= node.cost;
s.modalities.push(id);
pushEvent(s, `🧪 NEW MODALITY: ${node.name}`, "good");
return null;
}
export function setSpec(s: SimState, id: string): string | null {
const node = SPECS.find((n) => n.id === id);
if (!node) return "unknown tuning";
if (s.spec === id) return "already tuned that way";
const cost = SPEC_COST_BY_GEN[Math.min(s.tier, SPEC_COST_BY_GEN.length - 1)];
if (s.trainTokens < cost) return `need ${cost} training tokens`;
s.trainTokens -= cost;
s.spec = id;
pushEvent(s, `🧪 ${s.modelName} ${VERSION_NAMES[s.tier]} fine-tuned: ${node.name}`, "good");
return null;
}
export function setDataSource(s: SimState, src: DataSource): string | null {
if (!DATA_SOURCES[src]) return "unknown data source";
s.dataSource = src;
pushEvent(s, `📚 Training data: ${DATA_SOURCES[src].name}`, "info");
return null;
}
export function unlockRnd(s: SimState, id: string): string | null {
const node = RND.find((n) => n.id === id);
if (!node) return "unknown project";
if (s.rndNodes.includes(id)) return "already researched";
for (const req of node.requires ?? [])
if (!s.rndNodes.includes(req)) return `requires ${RND.find((n) => n.id === req)?.name}`;
if (s.perkPoints < 1) return "need a perk point (close a VC round)";
if (s.cash < node.cash) return "can't afford it";
s.perkPoints--;
s.cash -= node.cash;
s.rndNodes.push(id);
pushEvent(s, id === "conveyor"
? "🏭 THE CONVEYOR is installed — money now flows directly into the fire"
: `🏢 R&D complete: ${node.name}`, "good");
return null;
}
export function buyUpgrade(s: SimState, uid: number, upgId: string): string | null {
const d = s.devices.find((x) => x.uid === uid);
const upg = SOCKETS.find((u) => u.id === upgId);
if (!d || !upg) return "missing device or upgrade";
if (!isCompute(DEF[d.defId])) return "only compute hardware takes upgrades";
if (d.upgrades.length >= SOCKET_SLOTS) return "no free sockets";
if (d.upgrades.includes(upgId)) return "already installed";
if (s.cash < upg.cost) return "can't afford it";
s.cash -= upg.cost;
d.upgrades.push(upgId);
pushEvent(s, `🔧 ${upg.name} installed in ${DEF[d.defId].name}`, "info");
return null;
}
export function toggleDevice(s: SimState, uid: number): string | null {
const d = s.devices.find((x) => x.uid === uid);
if (!d) return "missing device";
d.on = !d.on;
pushEvent(s, `${DEF[d.defId].name} switched ${d.on ? "ON" : "OFF"}`, "info");
return null;
}
export function shipGrade(s: SimState, gradeId: "mini" | "pro" | "max"): string | null {
const grade = GRADES.find((g) => g.id === gradeId);
if (!grade) return "unknown grade";
const existing = s.shippedGrades.find((g) => g.grade === gradeId);
if (existing && existing.version === s.tier) return "that grade is already on this version";
const basis = BAL.TIERS[s.tier].tokens ?? 2000; // v1 ships off a small basis
const cost = Math.round(basis * grade.costMult);
if (s.trainTokens < cost) return `need ${cost} training tokens`;
s.trainTokens -= cost;
const label = `${s.modelName} ${VERSION_NAMES[s.tier]}${grade.suffix}`;
if (existing) {
existing.version = s.tier;
existing.distilled = false; // fresh weights, fresh moat
pushEvent(s, `🚀 ${label} SHIPPED — distillation moat restored`, "good");
} else {
s.shippedGrades.push({ grade: gradeId, version: s.tier, distilled: false });
pushEvent(s, `🚀 ${label} SHIPPED — now hostable on the API market`, "good");
}
return null;
}
export function switchHosting(s: SimState, id: number | null): string | null {
if (id === s.hostedId) return "already serving that";
if (id !== null) {
if (id < 0) {
const grade = GRADES[-id - 1];
if (!grade || !s.shippedGrades.find((g) => g.grade === grade.id)) return "ship that grade first";
} else if (!s.market.find((m) => m.id === id)) {
return "that model is gone";
}
}
s.hostedId = id;
if (id === null) {
pushEvent(s, "API hosting stopped", "info");
} else {
s.hostSwitchAt = s.t + 8;
pushEvent(s, "📦 Loading weights onto the cluster (8s)...", "info");
}
return null;
}
export function buyGpu(s: SimState, uid: number, gpuId: string): string | null {
const d = s.devices.find((x) => x.uid === uid);
const g = GPU_DEF[gpuId];
if (!d || !g) return "missing carrier or card";
const def = DEF[d.defId];
if (!def.gpuSlots) return "that doesn't take GPU cards";
if (d.gpus.length >= def.gpuSlots) return "no free GPU slots";
if (g.rackOnly && !def.rackClass) return "datacenter card — rack only";
if ((g.unlockAt ?? 0) > s.earnedTotal) return `locked — earn $${g.unlockAt} total first`;
if (s.cash < g.cost) return "can't afford it";
s.cash -= g.cost;
d.gpus.push(gpuId);
pushEvent(s, `🎮 ${g.name} installed in ${def.name} (+${g.tok} tok/s)`, "info");
return null;
}
export function sellGpu(s: SimState, uid: number, slot: number): string | null {
const d = s.devices.find((x) => x.uid === uid);
if (!d || slot < 0 || slot >= d.gpus.length) return "no card there";
const g = GPU_DEF[d.gpus[slot]];
d.gpus.splice(slot, 1);
s.cash += Math.round(g.cost * 0.5);
pushEvent(s, `Sold ${g.name} (+$${Math.round(g.cost * 0.5)})`, "info");
return null;
}
export { GPUS };
export function unlockScrape(s: SimState, id: string): string | null {
const node = SCRAPE_OPS.find((n) => n.id === id);
if (!node) return "unknown rig";
if (s.scrapeNodes.includes(id)) return "already running";
if (node.requires && !s.scrapeNodes.includes(node.requires))
return `requires ${SCRAPE_OPS.find((n) => n.id === node.requires)?.name}`;
if (s.cash < node.cash) return "can't afford it";
s.cash -= node.cash;
s.scrapeNodes.push(id);
pushEvent(s, `🕷 Scraping ops: ${node.name} online`, "good");
return null;
}
function clapCost(s: SimState): number {
return 50 * (s.round + 1);
}
export function breakerLimit(s: SimState): number {
let limit = ROOMS[s.room].breakerBase;
for (const d of s.devices) {
if (d.defId === "power_strip") limit += 200;
limit += DEF[d.defId].circuitBonus ?? 0;
}
return limit;
}
function tierMult(s: SimState): number {
// a collapsed generation earns half until you train your way out of it
return BAL.TIERS[s.tier].mult * (s.collapsed ? 0.5 : 1);
}
export function step(s: SimState) {
const dt = BAL.SIM_DT;
s.tick++;
s.t += dt;
const mods = computeMods(s);
const room = ROOMS[s.room];
const W = room.w, H = room.h;
const idx = (x: number, y: number) => y * W + x;
// --- power pass ---
const limit = breakerLimit(s);
let watts = 0;
for (const d of s.devices) {
const def = DEF[d.defId];
// desired utilization: compute devices run flat out when wired; coolers when on
d.util = d.on ? (isCompute(def) ? (d.wiredTo !== null ? 1 : 0.1) : 1) : 0;
// furnaces don't care about your breaker — fire predates electricity
d.powered = def.burnRate ? d.on : d.on && !s.breakerTripped;
if (d.powered) {
const dm = deviceMods(d.upgrades);
const loadScale = isCompute(def) ? mods.wattsPerTokMult * dm.watts : 1;
watts += def.wattsIdle + (def.wattsLoad - def.wattsIdle) * d.util * loadScale;
watts += cardStats(d).watts * d.util * mods.wattsPerTokMult * dm.watts;
}
}
if (!s.breakerTripped && watts > limit) {
// Grid Whisperer: a few seconds of grace before the trip
if (s.overSince === null) s.overSince = s.t;
if (s.t - s.overSince >= mods.breakerGraceS) {
s.breakerTripped = true;
watts = 0;
for (const d of s.devices) d.powered = DEF[d.defId].burnRate ? d.on : false;
s.overSince = null;
if (mods.diesel) {
s.dieselResetAt = s.t + 5;
pushEvent(s, "⚡ Breaker tripped — diesel generator coughing to life (5s)", "bad");
} else {
const loss = Math.round(BAL.CHECKPOINT_LOSS * 100);
s.trainTokens *= 1 - BAL.CHECKPOINT_LOSS;
pushEvent(s, `⚡ BREAKER TRIPPED — checkpoint corrupted (-${loss}% training)`, "bad");
if (s.subs >= 1) {
s.subs *= 1 - BAL.SAAS.TRIP_HIT;
pushEvent(s, `📱 Outage! ${Math.round(BAL.SAAS.TRIP_HIT * 100)}% of subscribers rage-quit`, "bad");
}
}
}
} else if (watts <= limit) {
s.overSince = null;
}
if (s.breakerTripped && s.dieselResetAt !== null && s.t >= s.dieselResetAt) {
s.breakerTripped = false;
s.dieselResetAt = null;
pushEvent(s, "🛢 Diesel backup online — power restored", "info");
}
// --- heat pass ---
const heat = s.heat;
// active fires pump heat into their tiles
s.fires = s.fires.filter((f) => f.until > s.t);
for (const f of s.fires) heat[idx(f.x, f.y)] += 35 * dt;
for (const d of s.devices) {
const def = DEF[d.defId];
if (!d.powered) continue;
// packed cards choke airflow — heat rises superlinearly, and throttling
// does NOT cool a crammed chassis (that's how runaways happen).
// Immersion tanks dampen card heat instead (cardHeatMult).
const packMult = 1 + 0.15 * Math.max(0, d.gpus.length - 1);
const cardHeat = cardStats(d).heat * packMult * (def.cardHeatMult ?? 1);
if (def.heatOut || cardHeat)
heat[idx(d.x, d.y)] += (def.heatOut + cardHeat) * d.util * dt * 2.2 *
(isCompute(def) ? mods.heatMult * deviceMods(d.upgrades).heat : 1);
if (def.coolRate)
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) {
const dist = Math.abs(x - d.x) + Math.abs(y - d.y);
if (dist <= 4) heat[idx(x, y)] = Math.max(0, heat[idx(x, y)] - (def.coolRate * dt) / (1 + dist));
}
}
// diffusion (fans boost local diffusion), then decay
const next = new Float32Array(heat);
const fanBoost = new Float32Array(W * H);
for (const d of s.devices)
if (DEF[d.defId].fan && d.powered)
for (let x = Math.max(0, d.x - 1); x <= Math.min(W - 1, d.x + 1); x++)
for (let y = Math.max(0, d.y - 1); y <= Math.min(H - 1, d.y + 1); y++)
fanBoost[idx(x, y)] = BAL.FAN_DIFFUSE_BONUS;
for (let x = 0; x < W; x++)
for (let y = 0; y < H; y++) {
let sum = 0, n = 0;
if (x > 0) { sum += heat[idx(x - 1, y)]; n++; }
if (x < W - 1) { sum += heat[idx(x + 1, y)]; n++; }
if (y > 0) { sum += heat[idx(x, y - 1)]; n++; }
if (y < H - 1) { sum += heat[idx(x, y + 1)]; n++; }
const k = BAL.DIFFUSE + fanBoost[idx(x, y)];
next[idx(x, y)] = heat[idx(x, y)] + k * (sum / n - heat[idx(x, y)]);
next[idx(x, y)] = Math.max(0, next[idx(x, y)] * (1 - BAL.DECAY));
}
s.heat = next;
// --- device temps & throttle ---
let maxTemp = BAL.AMBIENT_C;
for (const d of s.devices) {
const def = DEF[d.defId];
d.temp = BAL.AMBIENT_C + s.heat[idx(d.x, d.y)] + (isCompute(def) ? d.util * 18 : 0);
maxTemp = Math.max(maxTemp, d.temp);
if (isCompute(def)) {
const over = d.temp - BAL.THROTTLE_C;
d.perf = over <= 0 ? 1 : Math.max(BAL.THROTTLE_FLOOR, 1 - over / 30);
// thermal runaway: sustained >95°C under load ignites the hardware
if (d.temp > 95 && d.util > 0.5 && !DEF[d.defId].fireImmune) {
d.hotSince ??= s.t;
if (s.t - d.hotSince > 10) igniteDevice(s, d);
} else {
d.hotSince = null;
}
}
}
// --- data topology: device → (switch →) uplink ---
let uplink = 0;
for (const d of s.devices)
if (DEF[d.defId].uplinkMbps && d.powered) uplink += DEF[d.defId].uplinkMbps!;
const mbpsPerTok = BAL.MBPS_PER_TOK * mods.mbpsPerTokMult;
const byId = new Map(s.devices.map((d) => [d.uid, d]));
let tokDirect = 0;
const perSwitch = new Map<number, number>(); // switch uid -> raw tok/s arriving
for (const d of s.devices) {
const def = DEF[d.defId];
if (!isCompute(def) || !d.powered || d.wiredTo === null) continue;
const tgt = byId.get(d.wiredTo);
if (!tgt || !tgt.powered) continue;
const devTok = deviceTokRate(d) * d.perf * mods.tokRateMult * deviceMods(d.upgrades).tok;
const tgtDef = DEF[tgt.defId];
if (tgtDef.uplinkMbps) {
tokDirect += devTok;
} else if (tgtDef.switchMbps) {
// switch must itself reach a live uplink
const up = tgt.wiredTo !== null ? byId.get(tgt.wiredTo) : undefined;
if (up && up.powered && DEF[up.defId].uplinkMbps)
perSwitch.set(tgt.uid, (perSwitch.get(tgt.uid) ?? 0) + devTok);
}
}
let tokViaSwitches = 0;
for (const [swUid, tokSum] of perSwitch) {
const cap = DEF[byId.get(swUid)!.defId].switchMbps!;
tokViaSwitches += tokSum * Math.min(1, cap / Math.max(0.001, tokSum * mbpsPerTok));
}
const tokReaching = tokDirect + tokViaSwitches;
const demand = tokReaching * mbpsPerTok;
const dataMult = demand > 0 ? Math.min(1, uplink / demand) : 1;
// --- VRAM: your cluster has to actually hold the weights ---
let vramHave = 0;
for (const d of s.devices) if (d.powered && isCompute(DEF[d.defId])) vramHave += cardStats(d).vram;
const vramNeed = BAL.TIERS[s.tier].vramGB;
const vramMult = vramNeed > 0 && vramHave < vramNeed
? Math.max(BAL.VRAM_STARVE_FLOOR, vramHave / vramNeed)
: 1;
const tokEff = tokReaching * dataMult * vramMult;
// --- economy: compute splits three ways (slop / training / hosting) ---
const tm = tierMult(s);
const trainShare = Math.min(1, s.allocTraining);
const hostShare = Math.min(s.allocHosting, 1 - trainShare);
const tokInf = tokEff * (1 - trainShare - hostShare) * dt;
const tokTrain = tokEff * trainShare * dt;
const tokHost = tokEff * hostShare; // tok/s
let income = tokInf * BAL.TOK_PRICE * tm; // baseline API drip
// training fills the research bank; the Lab decides what it buys.
// checkpoints for the version you're training toward need disk to live on.
let storageGB = 0;
for (const d of s.devices)
if (d.powered && DEF[d.defId].storageGB) storageGB += DEF[d.defId].storageGB!;
const ckptNeed = BAL.TIERS[s.tier + 1]?.checkpointGB ?? 0;
const trainStalled = tokTrain > 0 && ckptNeed > 0 && storageGB < ckptNeed;
if (trainStalled && !s.rates.trainStalled)
pushEvent(s, `💾 TRAINING STALLED — ${ckptNeed}GB of checkpoint space needed, ${storageGB}GB online`, "bad");
const src = DATA_SOURCES[s.dataSource];
let dataRate = src.rate;
if (s.dataSource === "scraped") {
dataRate += s.scrapeNodes.reduce((a, id) => a + (SCRAPE_OPS.find((n) => n.id === id)?.scrapedRateAdd ?? 0), 0);
if (s.t < s.scrapeBanUntil) dataRate *= 0.15; // pipeline is IP-banned
}
if (!trainStalled) {
s.trainTokens += tokTrain * dataRate;
income -= tokTrain * src.costPerTok; // licensed data bills per token
}
// platform adaptation drifts back down while you're not spamming
s.platformAdapt = Math.max(0, s.platformAdapt - BAL.ADAPT_DECAY_S * mods.adaptDecayMult * dt);
const stages = [0.25, 0.5, 0.75];
while (s.adaptStage < stages.length && s.platformAdapt >= stages[s.adaptStage]) {
s.adaptStage++;
pushEvent(s, `🤖 Platforms updated their spam filters (wariness ${Math.round(stages[s.adaptStage - 1] * 100)}%)`, "bad");
}
// SaaS serving eats inference first — paying customers get the tokens
const serveNeed = s.subs * BAL.SAAS.TOK_PER_SUB * dt;
const saasLatency = s.subs > 1 && serveNeed > tokInf;
// slop videos — richer modalities cost more tokens per video but earn far more
const videoCost = BAL.VIDEO_COST_TOK * mods.videoCostMult;
s.tokenBank += Math.max(0, tokInf - serveNeed);
while (s.tokenBank >= videoCost) {
s.tokenBank -= videoCost;
const viralChance = BAL.VIRAL_CHANCE + mods.viralChanceAdd;
const viral = rand(s) < viralChance ? 2 + rand(s) * (BAL.VIRAL_MULT_MAX - 2) : 1;
const v = {
title: rollTitle(s),
thumb: Math.floor(rand(s) * THUMB_COUNT),
born: s.t, views: 0, rate: 0, viral, dead: false,
};
s.videos.push(v);
s.platformAdapt = Math.min(1, s.platformAdapt +
BAL.ADAPT_PER_VIDEO * mods.adaptPerVideoMult * (1 - s.platformAdapt));
if (viral > 6) pushEvent(s, `🔥 VIDEO WENT VIRAL (x${viral.toFixed(0)})`, "good");
if (s.videos.length > 40) s.videos.shift();
}
// flaky used cards die under sustained load
for (const d of s.devices) {
if (!d.powered || d.util < 0.5 || !d.gpus.length) continue;
for (let i = d.gpus.length - 1; i >= 0; i--) {
const g = GPU_DEF[d.gpus[i]];
if (g?.flaky && rand(s) < (g.flaky * dt) / 60) {
d.gpus.splice(i, 1);
pushEvent(s, `💀 The ${g.name} in your ${DEF[d.defId].name} just died. It owed you nothing.`, "bad");
}
}
}
// ad spend: continuous marketing burn with diminishing returns
let adHype = 1;
if (s.adSpend > 0 && s.cash > s.adSpend * dt) {
s.cash -= s.adSpend * dt;
adHype = 1 + 0.25 * Math.log10(1 + s.adSpend * 3);
}
// cash furnaces: burn money, buy attention
let hype = 1;
for (const d of s.devices) {
const def = DEF[d.defId];
if (!def.burnRate || !d.on) continue;
const burn = def.burnRate * dt;
if (s.cash >= burn) {
s.cash -= burn;
hype *= 1 + (def.hypeMult ?? 0);
d.util = 1;
} else {
d.util = 0; // starved — no cash, no hype
}
}
// rival effects (timed view/price modifiers)
s.effects = s.effects.filter((e) => e.expires > s.t);
if (s.clapback && s.t > s.clapback.expires) s.clapback = null;
let viewFx = 1, priceFx = 1;
for (const e of s.effects) {
viewFx *= e.viewMult ?? 1;
priceFx *= e.priceMult ?? 1;
}
income *= priceFx;
// --- API hosting market ---
if (s.nextMarketT === 0) {
// seed the launch-day market
for (let i = 0; i < 3; i++)
s.market.push(rollMarketModel(() => rand(s), s.nextMarketId++, s.t));
s.nextMarketT = s.t + 90 + rand(s) * 120;
} else if (s.t >= s.nextMarketT) {
const drop = rollMarketModel(() => rand(s), s.nextMarketId++, s.t);
for (const m of s.market) m.margin *= 0.72; // hot drop craters the incumbents
s.market.push(drop);
while (s.market.length > 5) {
const i = s.market.findIndex((m) => m.id !== s.hostedId);
if (i < 0) break;
s.market.splice(i, 1);
}
pushEvent(s, `${drop.flag} ${drop.name} just dropped — hosting margins crater`, "rival");
s.nextMarketT = s.t + 90 + rand(s) * 150;
}
for (const m of s.market) m.margin *= 1 - 0.0003 * dt; // slow fade as hype cools
let hostIncome = 0;
let hostingOwnMax = false;
const loading = s.hostSwitchAt !== null && s.t < s.hostSwitchAt;
if (s.hostSwitchAt !== null && s.t >= s.hostSwitchAt) {
s.hostSwitchAt = null;
pushEvent(s, "📦 Weights loaded — serving traffic", "info");
}
if (s.hostedId !== null && !loading && tokHost > 0) {
if (s.hostedId < 0) {
const grade = GRADES[-s.hostedId - 1];
const shipped = s.shippedGrades.find((g) => g.grade === grade.id);
if (shipped) {
const margin = grade.margin * (shipped.distilled ? DISTILL_MARGIN_CUT : 1);
hostIncome = Math.min(tokHost, grade.demand) * margin * priceFx;
if (grade.hypeMult && !shipped.distilled) hostingOwnMax = grade.id === "max";
// distillation roll — hosting your own weights is how they steal them
if (s.nextDistillT === 0) s.nextDistillT = s.t + 100 + rand(s) * 120;
else if (s.t >= s.nextDistillT) {
if (!shipped.distilled && rand(s) < grade.distillChance) {
shipped.distilled = true;
const thief = rollMarketModel(() => rand(s), s.nextMarketId++, s.t);
thief.margin *= 1.3;
s.market.push(thief);
pushEvent(s, `🥷 YOU GOT DISTILLED — ${thief.flag} ${thief.name} is a suspiciously good clone of ${s.modelName} ${VERSION_NAMES[shipped.version]}${GRADES.find((g) => g.id === shipped.grade)!.suffix} (margin gutted this version)`, "bad");
}
s.nextDistillT = s.t + 100 + rand(s) * 120;
}
}
} else {
const m = s.market.find((x) => x.id === s.hostedId);
if (m) hostIncome = Math.min(tokHost, m.demand) * m.margin * priceFx;
else { s.hostedId = null; pushEvent(s, "Hosted model delisted — allocate elsewhere", "info"); }
}
income += hostIncome * dt;
}
// --- scraping ops: IP bans while living off scraped data ---
if (s.dataSource === "scraped" && s.videos.length > 0) {
if (s.nextBanT === 0) s.nextBanT = s.t + 70 + rand(s) * 80;
else if (s.t >= s.nextBanT) {
const severity = 1 + Math.floor(rand(s) * 3);
const banLevel = Math.max(0, ...s.scrapeNodes.map((id) => SCRAPE_OPS.find((n) => n.id === id)?.banLevel ?? 0));
if (severity > banLevel) {
s.scrapeBanUntil = s.t + 45;
pushEvent(s, `🚫 IP BANNED (severity ${severity}) — scraping pipeline offline 45s`, "bad");
} else {
pushEvent(s, "🔁 Ban wave hit — proxies rotated, the scrape continues", "info");
}
s.nextBanT = s.t + 70 + rand(s) * 80;
}
}
const adaptMult = (1 - s.platformAdapt * BAL.ADAPT_MAX_PENALTY) * hype * adHype * viewFx *
mods.viewsPerVideoMult * mods.conveyorHype * (hostingOwnMax ? 1.1 : 1);
let liveViewRate = 0;
for (const v of s.videos) {
if (v.dead) continue;
const age = s.t - v.born;
if (age > BAL.VIDEO_LIFETIME_S) { v.dead = true; v.rate = 0; continue; }
v.rate = BAL.VIDEO_BASE_VIEWS_S * v.viral * tm * adaptMult *
Math.exp(-age / (BAL.VIDEO_LIFETIME_S / 3));
v.views += v.rate * dt;
s.totalViews += v.rate * dt;
liveViewRate += v.rate;
income += (v.rate * dt * BAL.CPM) / 1000;
}
// --- SaaS: the "Instant Girlfriend App" wrapper business ---
let subsNet = 0;
if (s.shippedGrades.length > 0) {
const signups = liveViewRate * BAL.SAAS.CONV * dt;
let churn = BAL.SAAS.CHURN_S;
if (saasLatency) churn *= BAL.SAAS.LATENCY_CHURN_MULT;
if (s.collapsed) churn *= BAL.SAAS.COLLAPSE_CHURN_MULT;
const before = s.subs;
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;
const stages = [100, 1_000, 10_000, 100_000];
while (s.subStage < stages.length && s.subs >= stages[s.subStage]) {
s.subStage++;
pushEvent(s, `🎉 ${stages[s.subStage - 1].toLocaleString()} SUBSCRIBERS — the wrapper prints money`, "good");
}
}
// VC mandate resolution
if (s.mandate) {
const got = s.totalViews - s.mandate.startViews;
if (got >= s.mandate.target) {
s.round++;
s.perkPoints++;
pushEvent(s, `🏆 ${s.mandate.name} mandate CRUSHED (${(got / 1000).toFixed(0)}K views) — board grants an R&D perk point`, "good");
s.mandate = null;
} else if (s.t > s.mandate.deadline) {
s.cash *= 1 - BAL.MANDATE_FAIL_CASH_CUT;
s.equityMult *= 1 - BAL.MANDATE_FAIL_INCOME_CUT;
pushEvent(s, `📉 ${s.mandate.name} mandate FAILED — bridge round at brutal terms (-${BAL.MANDATE_FAIL_CASH_CUT * 100}% cash, -${BAL.MANDATE_FAIL_INCOME_CUT * 100}% equity forever)`, "bad");
s.mandate = null;
}
}
// rival CEO timer — they only notice you once you're publishing
if (s.videos.length > 0) {
if (s.nextRivalT === 0) s.nextRivalT = s.t + 60 + rand(s) * 60;
else if (s.t >= s.nextRivalT) {
const totalW = RIVALS.reduce((a, r) => a + r.w, 0);
let roll = rand(s) * totalW;
for (const r of RIVALS) {
roll -= r.w;
if (roll <= 0) { r.run(s); break; }
}
s.nextRivalT = s.t + BAL.RIVAL_MIN_S + rand(s) * (BAL.RIVAL_MAX_S - BAL.RIVAL_MIN_S);
}
}
income *= s.equityMult;
// THE CONVEYOR: a fixed cut of gross income rides the belt into the furnace
if (mods.conveyorPct > 0 && income > 0) income *= 1 - mods.conveyorPct;
// --- costs & bookkeeping ---
const powerCost = ((watts / 1000) * BAL.KWH_PRICE * dt) / 3600;
s.cash += income - powerCost;
s.earnedTotal += income;
const sm = 1 - Math.exp(-dt / 1.5); // ~1.5s smoothing for HUD rates
const r = s.rates;
r.watts += (watts - r.watts) * sm;
r.breakerWatts = limit;
r.tokEff += (tokEff - r.tokEff) * sm;
r.income += (income / dt - r.income) * sm;
r.powerCost += (powerCost / dt - r.powerCost) * sm;
r.demandMbps = demand;
r.uplinkMbps = uplink;
r.dataMult = dataMult;
r.maxTemp = maxTemp;
r.adapt = s.platformAdapt;
r.hostIncome += (hostIncome - r.hostIncome) * sm;
r.hostTok = tokHost;
r.vramHave = vramHave;
r.vramNeed = vramNeed;
r.storageGB = storageGB;
r.trainStalled = trainStalled;
r.subsNet += (subsNet - r.subsNet) * sm;
r.saasLatency = saasLatency;
}
/** HUD helper: progress toward the next slop video + whether tokens flow at all */
export function videoProgress(s: SimState): { pct: number; producing: boolean } {
const cost = BAL.VIDEO_COST_TOK * computeMods(s).videoCostMult;
return { pct: Math.min(1, s.tokenBank / cost), producing: s.rates.tokEff > 0.5 };
}
export function shopList(s: SimState) {
return CATALOG.map((def) => ({
def,
locked: (def.unlockAt ?? 0) > s.earnedTotal,
affordable: s.cash >= def.cost,
}));
}