diff --git a/CLAUDE.md b/CLAUDE.md index 802ca14..b901d84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,9 +82,16 @@ Date.now/Math.random inside sim). fire within 3 tiles then is spent). Flaky used cards (p106/mtt_s80/ radish580) die under load. Ad-spend slider (log-diminishing hype) + email Spam Blast (instant views, 60s cooldown, 15% blacklist risk). -→ M8 backlog: ACTIVE scraping minigame, subscribers/SaaS churn revenue, - warehouse room (tier 3), Conveyor belt visual, per-carrier PSU, - liquid/immersion cooling for racks (fires make it necessary). +✅ M8: warehouse tier (20x20, 8kW, MOVES ladder has from-fields now); + immersion tank (fire-immune carrier, card heat x0.15, 8 rack-class + slots) + CRAC unit + AIO socket; SaaS subscribers — signups from live + view rate (SAAS.CONV), churn on latency (serve demand > inference + share), collapse, and breaker trips (-15% instant); serving eats + inference tokens BEFORE the video pipeline. SUBS stat in HUD with + latency warning. +→ M9 backlog: ACTIVE scraping minigame, Conveyor belt visual, + per-carrier PSU, Tier 4 campus + strategic map layer, win/lose + states, sound design pass, Steam packaging decision. Design pillars (John-approved framing): Throughput, Allocation, Attention, Capital, Expansion. Macro layer (Tier 4-5) = strategic map, NOT city-builder. Cut until earned: Tiers 3–5, immersion cooling, offshore ships, lobbying. diff --git a/public/assets/gen/crac_unit.png b/public/assets/gen/crac_unit.png new file mode 100644 index 0000000..5be8350 Binary files /dev/null and b/public/assets/gen/crac_unit.png differ diff --git a/public/assets/gen/crac_unit_cut.png b/public/assets/gen/crac_unit_cut.png new file mode 100644 index 0000000..befad2d Binary files /dev/null and b/public/assets/gen/crac_unit_cut.png differ diff --git a/public/assets/gen/immersion_tank.png b/public/assets/gen/immersion_tank.png new file mode 100644 index 0000000..a1b0830 Binary files /dev/null and b/public/assets/gen/immersion_tank.png differ diff --git a/public/assets/gen/immersion_tank_cut.png b/public/assets/gen/immersion_tank_cut.png new file mode 100644 index 0000000..9b549f3 Binary files /dev/null and b/public/assets/gen/immersion_tank_cut.png differ diff --git a/public/assets/gen/warehouse.png b/public/assets/gen/warehouse.png new file mode 100644 index 0000000..55602d8 Binary files /dev/null and b/public/assets/gen/warehouse.png differ diff --git a/src/sim/balance.ts b/src/sim/balance.ts index 9305341..5b2ee99 100644 --- a/src/sim/balance.ts +++ b/src/sim/balance.ts @@ -59,4 +59,15 @@ export const BAL = { { name: "v3", mult: 12, tokens: 250_000, vramGB: 512, checkpointGB: 3_000 }, ], VRAM_STARVE_FLOOR: 0.25, // token multiplier floor when VRAM-starved + + // SaaS: subscribers funnel in from views, churn out on latency/downtime. + SAAS: { + CONV: 0.003, // signups per view + CHURN_S: 0.00033, // ~2%/min baseline + SUB_REV_S: 0.004, // $/s per subscriber + TOK_PER_SUB: 0.02, // tok/s each subscriber's requests consume (from slop share) + LATENCY_CHURN_MULT: 3, // when serve demand exceeds inference supply + COLLAPSE_CHURN_MULT: 3, // gibberish product + TRIP_HIT: 0.15, // subscribers lost instantly on a breaker trip + }, }; diff --git a/src/sim/catalog.ts b/src/sim/catalog.ts index a1f4940..4b8cd40 100644 --- a/src/sim/catalog.ts +++ b/src/sim/catalog.ts @@ -17,6 +17,8 @@ export interface DeviceDef { ports?: number; // wired-device capacity for router/switch-class (default 4) storageGB?: number; // checkpoint capacity circuitBonus?: number; // extra breaker watts (electrical infrastructure) + fireImmune?: boolean; // cannot thermally run away (immersion) + cardHeatMult?: number; // carrier-level multiplier on installed-card heat burnRate?: number; // $/s consumed while running (cash furnace) hypeMult?: number; // bonus view multiplier while burning (stacks multiplicatively) wallOnly?: boolean; @@ -87,6 +89,13 @@ export const CATALOG: DeviceDef[] = [ wattsIdle: 150, wattsLoad: 250, tokRate: 0, heatOut: 2, gpuSlots: 8, rackClass: true, footprint: { w: 2, h: 2 }, sprite: "server_rack_mini", unlockAt: 900, }, + { + id: "immersion_tank", name: "Immersion Tank", cat: "compute", cost: 4000, + desc: "Eight slots under mineral oil. 85% less heat, cannot catch fire. Smells like chips.", + wattsIdle: 250, wattsLoad: 400, tokRate: 0, heatOut: 1, gpuSlots: 8, rackClass: true, + fireImmune: true, cardHeatMult: 0.15, + footprint: { w: 2, h: 2 }, sprite: "immersion_tank", unlockAt: 5000, + }, // --- network --- { id: "router", name: "Wi-Fi Router", cat: "network", cost: 40, @@ -166,6 +175,13 @@ export const CATALOG: DeviceDef[] = [ coolRate: 14, wallOnly: true, footprint: { w: 1, h: 1 }, sprite: "window_ac", unlockAt: 150, }, + { + id: "crac_unit", name: "CRAC Unit", cat: "cooling", cost: 1500, + desc: "Precision air for people whose 'computer room' is real now.", + wattsIdle: 200, wattsLoad: 1600, tokRate: 0, heatOut: 0, + coolRate: 30, footprint: { w: 1, h: 1 }, + sprite: "crac_unit", unlockAt: 2500, + }, // --- misc --- { id: "desk", name: "Desk", cat: "misc", cost: 15, diff --git a/src/sim/research.ts b/src/sim/research.ts index e8cfe47..0d4d31e 100644 --- a/src/sim/research.ts +++ b/src/sim/research.ts @@ -212,6 +212,7 @@ export const SOCKETS: SocketUpgrade[] = [ { id: "extra_fan", name: "Extra Fans", cost: 80, desc: "Heat -25%", heatMult: 0.75 }, { id: "undervolt", name: "Undervolt", cost: 150, desc: "Watts -15%, tokens -5%", wattsMult: 0.85, tokMult: 0.95 }, { id: "risers", name: "Riser Cards", cost: 250, desc: "Tokens +15%, heat +15%", tokMult: 1.15, heatMult: 1.15 }, + { id: "aio_loop", name: "AIO Liquid Loop", cost: 400, desc: "Heat -50%", heatMult: 0.5 }, ]; export const SOCKET_SLOTS = 2; diff --git a/src/sim/rooms.ts b/src/sim/rooms.ts index 2752053..f5d42f8 100644 --- a/src/sim/rooms.ts +++ b/src/sim/rooms.ts @@ -25,8 +25,14 @@ export const ROOMS: Record = { w: 14, h: 14, tileW: 58.5, tileH: 29.5, origin: { x: 510, y: 330 }, imgW: 1024, imgH: 768, breakerBase: 2400, }, + warehouse: { + id: "warehouse", name: "The Warehouse", image: "/assets/gen/warehouse.png", + w: 20, h: 20, tileW: 41, tileH: 20.5, origin: { x: 510, y: 330 }, + imgW: 1024, imgH: 768, breakerBase: 8000, + }, }; -export const MOVES: { to: string; cost: number; unlockAt: number; blurb: string }[] = [ - { to: "garage", cost: 3000, unlockAt: 2500, blurb: "Mom wants her bedroom back. 14x14 of concrete, 2.4kW service." }, +export const MOVES: { from: string; to: string; cost: number; unlockAt: number; blurb: string }[] = [ + { from: "bedroom", to: "garage", cost: 3000, unlockAt: 2500, blurb: "Mom wants her bedroom back. 14x14 of concrete, 2.4kW service." }, + { from: "garage", to: "warehouse", cost: 25000, unlockAt: 15000, blurb: "Industrial unit, zoned 'flexible'. 20x20 tiles, 8kW three-phase." }, ]; diff --git a/src/sim/sim.ts b/src/sim/sim.ts index ff54a51..7f11e08 100644 --- a/src/sim/sim.ts +++ b/src/sim/sim.ts @@ -187,6 +187,7 @@ 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; @@ -503,6 +504,10 @@ export function step(s: SimState) { 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) { @@ -523,9 +528,10 @@ export function step(s: SimState) { 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) + // 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; + 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); @@ -566,7 +572,7 @@ export function step(s: SimState) { 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) { + 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 { @@ -655,9 +661,12 @@ export function step(s: SimState) { 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 += tokInf; + s.tokenBank += Math.max(0, tokInf - serveNeed); while (s.tokenBank >= videoCost) { s.tokenBank -= videoCost; const viralChance = BAL.VIRAL_CHANCE + mods.viralChanceAdd; @@ -791,6 +800,7 @@ export function step(s: SimState) { 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; @@ -799,9 +809,28 @@ export function step(s: SimState) { 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; @@ -860,6 +889,8 @@ export function step(s: SimState) { r.vramNeed = vramNeed; r.storageGB = storageGB; r.trainStalled = trainStalled; + r.subsNet += (subsNet - r.subsNet) * sm; + r.saasLatency = saasLatency; } export function shopList(s: SimState) { diff --git a/src/sim/state.ts b/src/sim/state.ts index 65b569c..50be7d4 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -33,6 +33,8 @@ export interface SimState { fires: { x: number; y: number; until: number }[]; adSpend: number; // $/s marketing burn lastSpamT: number; // last email-spam blast + subs: number; // SaaS subscribers + subStage: number; // last milestone toasted t: number; tick: number; cash: number; @@ -97,6 +99,8 @@ export interface SimState { vramNeed: number; storageGB: number; trainStalled: boolean; + subsNet: number; // subscribers/s net growth + saasLatency: boolean; }; events: { msg: string; t: number; kind: string }[]; rng: number; @@ -109,6 +113,8 @@ export function createState(): SimState { fires: [], adSpend: 0, lastSpamT: -999, + subs: 0, + subStage: 0, t: 0, tick: 0, cash: BAL.START_CASH, @@ -156,6 +162,7 @@ export function createState(): SimState { powerCost: 0, demandMbps: 0, uplinkMbps: 0, dataMult: 1, maxTemp: BAL.AMBIENT_C, adapt: 0, hostIncome: 0, hostTok: 0, vramHave: 0, vramNeed: 0, storageGB: 0, trainStalled: false, + subsNet: 0, saasLatency: false, }, events: [], rng: 0x5109a & 0xffffffff, diff --git a/src/ui/hud.ts b/src/ui/hud.ts index ea55b2f..258fe30 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -43,6 +43,7 @@ export class Hud { selectedShop: string | null = null; lastEventCount = 0; lockedCache = ""; + lastRoom = "bedroom"; constructor(cb: HudCallbacks) { this.cb = cb; @@ -56,6 +57,7 @@ export class Hud {
HOTTEST24°C
SLOP RATE0 tok/s
VIEWS0
+
SUBS0
UPLINK0/0 Mbps
HOSTING$0/s
VRAM0GB
@@ -131,7 +133,7 @@ export class Hud { }; (this.root.querySelector("#spam") as HTMLButtonElement).onclick = () => cb.onSpam(); (this.root.querySelector("#move-hq") as HTMLButtonElement).onclick = () => { - const mv = MOVES[0]; + const mv = MOVES.find((m) => m.from === this.lastRoom); if (mv && confirm(`${ROOMS[mv.to].name}: ${mv.blurb} — $${mv.cost}?`)) cb.onMove(mv.to); }; } @@ -244,6 +246,11 @@ export class Hud { q("#net").textContent = `${fmt(s.rates.demandMbps, 0)}/${fmt(s.rates.uplinkMbps, 0)} Mbps`; (q("#net") as HTMLElement).style.color = s.rates.dataMult < 1 ? "var(--red)" : ""; q("#views").textContent = fmt(s.totalViews, 0); + q("#subs").textContent = fmt(s.subs, 0) + (s.rates.saasLatency ? " ⚠" : ""); + (q("#subs") as HTMLElement).style.color = + s.rates.saasLatency ? "var(--red)" : s.rates.subsNet > 0.01 ? "var(--green)" : ""; + (q("#subs") as HTMLElement).title = + s.rates.saasLatency ? "LATENCY — subscribers churning; free up inference compute" : "SaaS subscribers"; // VC raise chip / mandate progress const raise = q("#raise"), mandate = q("#mandate"); @@ -268,10 +275,11 @@ export class Hud { const cd = Math.max(0, 60 - (s.t - s.lastSpamT)); spam.textContent = cd > 0 ? `📧 Spam Blast (${Math.ceil(cd)}s)` : `📧 Spam Blast ($${40 * (s.round + 1)})`; spam.classList.toggle("on", cd <= 0); - const mv = MOVES.find((m) => m.to !== s.room); + this.lastRoom = s.room; + const mv = MOVES.find((m) => m.from === s.room); (q("#move-hq") as HTMLElement).style.display = - mv && s.room === "bedroom" && s.earnedTotal >= mv.unlockAt ? "block" : "none"; - if (mv) q("#move-hq").textContent = `🚚 Move HQ ($${mv.cost})`; + mv && s.earnedTotal >= mv.unlockAt ? "block" : "none"; + if (mv) q("#move-hq").textContent = `🚚 ${ROOMS[mv.to].name} ($${fmt(mv.cost, 0)})`; // clap-back window const clap = q("#clap"); diff --git a/tools/mb_add.py b/tools/mb_add.py index c38c99e..3f0eaa2 100644 --- a/tools/mb_add.py +++ b/tools/mb_add.py @@ -7,14 +7,15 @@ sys.path.insert(0, str(pathlib.Path(__file__).parent)) from mb_gen import ROOT, STYLE, flux_spec, run_batch, upload, beat NEW = [ - ("extinguisher", "a red CO2 fire extinguisher standing on the floor"), + ("immersion_tank", "an open-top industrial immersion cooling tank filled with glowing clear mineral oil, server boards submerged inside, tubes and a small pump, sci-fi green glow"), + ("crac_unit", "a tall white industrial precision air conditioning unit for server rooms, floor standing, vent grilles, small status screen"), ] ROOMS = [ - ("garage", "empty isometric double garage interior for a video game, 2:1 isometric projection, " - "two visible walls meeting at back corner, sealed metal roller door on one wall, bare concrete " - "floor with subtle square grid lines, exposed stud walls, hanging work light, night time, " - "moody blue-purple lighting with one neon strip, no vehicles, no furniture, no people, no text, " + ("warehouse", "empty isometric industrial warehouse interior for a video game, 2:1 isometric projection, " + "two visible corrugated metal walls meeting at back corner, huge bare concrete floor with subtle " + "square grid lines, steel roof trusses hinted at top, high windows, sodium work lights, night time, " + "moody blue-purple lighting with cyan accents, no shelving, no vehicles, no people, no text, " "clean detailed painterly game art", 1024, 768), ] diff --git a/tools/mb_manifest.json b/tools/mb_manifest.json index 58bed9c..a4aae12 100644 --- a/tools/mb_manifest.json +++ b/tools/mb_manifest.json @@ -263,5 +263,25 @@ "job": "d8ff5530b60b", "asset": "4a55508fae90", "path": "/Users/jing/Documents/gigaslop/public/assets/gen/extinguisher_cut.png" + }, + "immersion_tank": { + "job": "59c0446c8226", + "asset": "5830eb712b59", + "path": "/Users/jing/Documents/gigaslop/public/assets/gen/immersion_tank.png" + }, + "crac_unit": { + "job": "a6d5df83247e", + "asset": "9568c9c5917d", + "path": "/Users/jing/Documents/gigaslop/public/assets/gen/crac_unit.png" + }, + "immersion_tank_cut": { + "job": "e4dde2353757", + "asset": "9b2a341a7b99", + "path": "/Users/jing/Documents/gigaslop/public/assets/gen/immersion_tank_cut.png" + }, + "crac_unit_cut": { + "job": "c15472693bd1", + "asset": "00c76075efec", + "path": "/Users/jing/Documents/gigaslop/public/assets/gen/crac_unit_cut.png" } } \ No newline at end of file