diff --git a/CLAUDE.md b/CLAUDE.md index 77cc250..802ca14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,10 +73,18 @@ Date.now/Math.random inside sim). device→switch→uplink with per-switch Mbps caps (no daisy chains). Shop has category headers. Old saves get equivalent card loadouts (save.ts migration). -→ M7 backlog: ad-spend slider + email-spam campaigns, ACTIVE scraping - minigame, hazards (thermal runaway fire), Tier 2 garage move, - subscribers/SaaS churn revenue, Conveyor belt visual, per-carrier - PSU capacity, unreliable used cards (degradation). +✅ M7: rooms are data (src/sim/rooms.ts — geometry hand-registered per + backdrop; iso.ts is room-aware via setRoomGeom; sim reads ROOMS[s.room]). + Garage move: $3k at $2.5k earned, 14x14, 2.4kW base. Thermal runaway: + packed carriers get +15% heat per extra card and throttling does NOT + reduce heat — sustained >95°C ignites (device + cards destroyed, fire + spreads heat, click flames to fight; CO2 extinguisher auto-douses one + 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). 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/extinguisher.png b/public/assets/gen/extinguisher.png new file mode 100644 index 0000000..81be4c2 Binary files /dev/null and b/public/assets/gen/extinguisher.png differ diff --git a/public/assets/gen/extinguisher_cut.png b/public/assets/gen/extinguisher_cut.png new file mode 100644 index 0000000..00dbdbe Binary files /dev/null and b/public/assets/gen/extinguisher_cut.png differ diff --git a/public/assets/gen/garage.png b/public/assets/gen/garage.png new file mode 100644 index 0000000..a640683 Binary files /dev/null and b/public/assets/gen/garage.png differ diff --git a/src/main.ts b/src/main.ts index 98b1ee8..79516d4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ 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 } from "./sim/sim"; +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 } from "./sim/catalog"; import { Scene } from "./render/scene"; @@ -16,7 +16,7 @@ async function boot() { const state = loaded ?? createState(); let wiped = false; const scene = new Scene(); - await scene.init(el); + await scene.init(el, state.room); // --- input state --- let shopSel: string | null = null; @@ -45,6 +45,16 @@ async function boot() { 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); @@ -89,6 +99,7 @@ async function boot() { 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; } @@ -197,6 +208,8 @@ async function boot() { 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), }; } diff --git a/src/render/iso.ts b/src/render/iso.ts index 663f288..94d6367 100644 --- a/src/render/iso.ts +++ b/src/render/iso.ts @@ -1,37 +1,40 @@ -import { BAL } from "../sim/balance"; +import { ROOMS, RoomDef } from "../sim/rooms"; -// Grid-to-screen mapping tuned to the generated room backdrop (1024x768). -// Tile (0,0) is the back corner of the floor; +x runs down-right, +y down-left. -export const TILE_W = 82; -export const TILE_H = 41; -export const ORIGIN = { x: 510, y: 346 }; // center of tile (0,0) in room-image space -export const ROOM_IMG = { w: 1024, h: 768 }; +// Active room geometry — scene calls setRoomGeom on init and on HQ moves. +let G: RoomDef = ROOMS.bedroom; + +export function setRoomGeom(r: RoomDef) { + G = r; +} +export function geom(): RoomDef { + return G; +} export function tileToScreen(x: number, y: number) { return { - x: ORIGIN.x + ((x - y) * TILE_W) / 2, - y: ORIGIN.y + ((x + y) * TILE_H) / 2, + x: G.origin.x + ((x - y) * G.tileW) / 2, + y: G.origin.y + ((x + y) * G.tileH) / 2, }; } export function screenToTile(sx: number, sy: number) { - const dx = sx - ORIGIN.x; - const dy = sy - ORIGIN.y; - const fx = dx / (TILE_W / 2), fy = dy / (TILE_H / 2); + const dx = sx - G.origin.x; + const dy = sy - G.origin.y; + const fx = dx / (G.tileW / 2), fy = dy / (G.tileH / 2); return { x: Math.round((fx + fy) / 2), y: Math.round((fy - fx) / 2) }; } export function inBounds(x: number, y: number) { - return x >= 0 && y >= 0 && x < BAL.ROOM_W && y < BAL.ROOM_H; + return x >= 0 && y >= 0 && x < G.w && y < G.h; } export function diamond(x: number, y: number): number[] { const c = tileToScreen(x, y); return [ - c.x, c.y - TILE_H / 2, - c.x + TILE_W / 2, c.y, - c.x, c.y + TILE_H / 2, - c.x - TILE_W / 2, c.y, + c.x, c.y - G.tileH / 2, + c.x + G.tileW / 2, c.y, + c.x, c.y + G.tileH / 2, + c.x - G.tileW / 2, c.y, ]; } diff --git a/src/render/scene.ts b/src/render/scene.ts index 24c3f1c..fd04d56 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -1,8 +1,8 @@ import { Application, Assets, Container, Graphics, Sprite, Texture } from "pixi.js"; -import { BAL } from "../sim/balance"; import { CATALOG, DEF } from "../sim/catalog"; import { SimState, Device } from "../sim/state"; -import { TILE_W, TILE_H, ROOM_IMG, tileToScreen, screenToTile, diamond, lPath, inBounds } from "./iso"; +import { geom, setRoomGeom, tileToScreen, screenToTile, diamond, lPath, inBounds } from "./iso"; +import { ROOMS } from "../sim/rooms"; export interface Overlays { heat: boolean; power: boolean; data: boolean; grid: boolean } @@ -18,16 +18,19 @@ export class Scene { slopTextures: Texture[] = []; overlays: Overlays = { heat: true, power: false, data: false, grid: false }; pulsePhase = 0; - outlet = tileToScreen(0, 5); // wall outlet anchor (left wall) + bgSprite: Sprite | null = null; - async init(el: HTMLElement) { + get outlet() { + return tileToScreen(0, Math.floor(geom().h / 2)); // wall outlet anchor (left wall) + } + + async init(el: HTMLElement, roomId: string) { this.app = new Application(); await this.app.init({ background: "#07070c", resizeTo: window, antialias: true }); el.appendChild(this.app.canvas); // load textures with graceful fallback — sprite list derives from the catalog const names = [...new Set(CATALOG.map((d) => d.sprite))]; - const room = await this.tryLoad("/assets/gen/room.png"); for (const n of names) { const t = await this.tryLoad(`/assets/gen/${n}_cut.png`); if (t) this.textures.set(n, t); @@ -37,17 +40,23 @@ export class Scene { if (t) this.slopTextures.push(t); } - if (room) { - const bg = new Sprite(room); - this.world.addChild(bg); - } + this.bgSprite = new Sprite(); + this.world.addChild(this.bgSprite); this.deviceLayer.sortableChildren = true; this.world.addChild(this.cableGfx, this.deviceLayer, this.overlayGfx); this.app.stage.addChild(this.world); - this.layout(); + await this.setRoom(roomId); window.addEventListener("resize", () => this.layout()); } + async setRoom(roomId: string) { + const room = ROOMS[roomId] ?? ROOMS.bedroom; + setRoomGeom(room); + const tex = await this.tryLoad(room.image); + if (tex && this.bgSprite) this.bgSprite.texture = tex; + this.layout(); + } + private async tryLoad(url: string): Promise { try { return await Assets.load(url); @@ -58,10 +67,11 @@ export class Scene { } layout() { + const g = geom(); const w = this.app.renderer.width, h = this.app.renderer.height; - const s = Math.min(w / ROOM_IMG.w, h / ROOM_IMG.h) * 0.98; + const s = Math.min(w / g.imgW, h / g.imgH) * 0.98; this.world.scale.set(s); - this.world.position.set((w - ROOM_IMG.w * s) / 2, (h - ROOM_IMG.h * s) / 2); + this.world.position.set((w - g.imgW * s) / 2, (h - g.imgH * s) / 2); } /** pointer event → room-image space */ @@ -97,7 +107,7 @@ export class Scene { scaleFor(defId: string, sp: Sprite): number { const def = DEF[defId]; - const targetW = (def.footprint.w + def.footprint.h) * 0.5 * TILE_W * 1.35; + const targetW = (def.footprint.w + def.footprint.h) * 0.5 * geom().tileW * 1.35; return targetW / sp.texture.width; } @@ -112,7 +122,7 @@ export class Scene { const sp = this.spriteFor(d); // center of footprint const c = tileToScreen(d.x + (def.footprint.w - 1) / 2, d.y + (def.footprint.h - 1) / 2); - sp.position.set(c.x, c.y + TILE_H / 2); + sp.position.set(c.x, c.y + geom().tileH / 2); if (def.wallOnly) sp.position.y -= 55; // hang on the wall (window height) sp.scale.set(this.scaleFor(d.defId, sp)); sp.zIndex = d.x + d.y + (def.wallOnly ? -100 : 0); @@ -134,7 +144,7 @@ export class Scene { } const g = this.ghostSprite; const c = tileToScreen(ghost.x + (def.footprint.w - 1) / 2, ghost.y + (def.footprint.h - 1) / 2); - g.position.set(c.x, c.y + TILE_H / 2); + g.position.set(c.x, c.y + geom().tileH / 2); if (def.wallOnly) g.position.y -= 55; g.scale.set(this.scaleFor(ghost.defId, g)); g.alpha = 0.55; @@ -158,7 +168,7 @@ export class Scene { if (!rt) continue; const path = lPath(d.x, d.y, rt.x, rt.y).map((p) => { const c = tileToScreen(p.x, p.y); - return { x: c.x, y: c.y + TILE_H * 0.45 }; + return { x: c.x, y: c.y + geom().tileH * 0.45 }; }); if (path.length < 2) continue; const live = d.powered && rt.powered; @@ -187,11 +197,11 @@ export class Scene { const def = DEF[d.defId]; if (!def.wattsLoad || !d.powered) continue; const c = tileToScreen(d.x, d.y); - g.moveTo(c.x, c.y + TILE_H * 0.45); - g.lineTo(this.outlet.x - TILE_W / 2, this.outlet.y); + g.moveTo(c.x, c.y + geom().tileH * 0.45); + g.lineTo(this.outlet.x - geom().tileW / 2, this.outlet.y); g.stroke({ width: 1.4, color: 0xf59e0b, alpha: 0.5 }); } - g.circle(this.outlet.x - TILE_W / 2, this.outlet.y, 6).fill({ color: 0xf59e0b, alpha: 0.9 }); + g.circle(this.outlet.x - geom().tileW / 2, this.outlet.y, 6).fill({ color: 0xf59e0b, alpha: 0.9 }); } } @@ -200,16 +210,25 @@ export class Scene { g.clear(); // heat: subtle always, strong when toggled const boost = this.overlays.heat ? 1 : 0.4; - for (let x = 0; x < BAL.ROOM_W; x++) - for (let y = 0; y < BAL.ROOM_H; y++) { - const h = s.heat[y * BAL.ROOM_W + x]; + const room = geom(); + for (let x = 0; x < room.w; x++) + for (let y = 0; y < room.h; y++) { + const h = s.heat[y * room.w + x]; if (h < 1.5) continue; const a = Math.min(0.55, (h / 70) * boost); g.poly(diamond(x, y)).fill({ color: h > 55 ? 0xff2200 : 0xff7700, alpha: a }); } + // active fires: pulsing flame glow + for (const f of s.fires) { + const c = tileToScreen(f.x, f.y); + const p = 0.7 + Math.sin(this.pulsePhase * Math.PI * 4) * 0.3; + g.circle(c.x, c.y, room.tileW * 0.42 * p).fill({ color: 0xff4400, alpha: 0.5 }); + g.circle(c.x, c.y - 8, room.tileW * 0.26 * p).fill({ color: 0xffaa00, alpha: 0.75 }); + g.circle(c.x, c.y - 14, room.tileW * 0.12).fill({ color: 0xffee88, alpha: 0.9 }); + } if (this.overlays.grid || ghost) { - for (let x = 0; x < BAL.ROOM_W; x++) - for (let y = 0; y < BAL.ROOM_H; y++) + for (let x = 0; x < room.w; x++) + for (let y = 0; y < room.h; y++) g.poly(diamond(x, y)).stroke({ width: 1.5, color: 0xc4b5fd, alpha: 0.5 }); } } @@ -219,7 +238,7 @@ export class Scene { if (!d) return; const c = tileToScreen(d.x, d.y); const r = 18 + Math.sin(this.pulsePhase * Math.PI * 2) * 3; - this.overlayGfx.circle(c.x, c.y + TILE_H * 0.3, r).stroke({ width: 2, color: 0x22d3ee, alpha: 0.9 }); + this.overlayGfx.circle(c.x, c.y + geom().tileH * 0.3, r).stroke({ width: 2, color: 0x22d3ee, alpha: 0.9 }); void s; } diff --git a/src/sim/balance.ts b/src/sim/balance.ts index f1b07ee..9305341 100644 --- a/src/sim/balance.ts +++ b/src/sim/balance.ts @@ -3,11 +3,7 @@ export const BAL = { SIM_DT: 0.1, // s per tick (10 Hz) START_CASH: 25, - ROOM_W: 10, - ROOM_H: 10, - - // Power - BREAKER_WATTS: 1400, // bedroom circuit + // Power (per-room breaker bases live in rooms.ts) KWH_PRICE: 0.35, // $/kWh — punchy so burn rate is visible CHECKPOINT_LOSS: 0.25, // training progress lost on breaker trip diff --git a/src/sim/catalog.ts b/src/sim/catalog.ts index 69de43e..a1f4940 100644 --- a/src/sim/catalog.ts +++ b/src/sim/catalog.ts @@ -34,20 +34,21 @@ export interface GpuDef { watts: number; vram: number; // GB heat: number; // heat units/s at full load + flaky?: number; // chance per minute of dying while under load rackOnly?: boolean; sprite: "gpu_budget" | "gpu_gaming" | "gpu_workstation" | "gpu_datacenter"; unlockAt?: number; } export const GPUS: GpuDef[] = [ - { id: "p106", name: "Ex-Mining P106", cost: 25, tok: 8, watts: 90, vram: 6, heat: 0.8, sprite: "gpu_budget", - desc: "No video out. Smells like a Kazakh warehouse." }, + { id: "p106", name: "Ex-Mining P106", cost: 25, tok: 8, watts: 90, vram: 6, heat: 0.8, flaky: 0.02, sprite: "gpu_budget", + desc: "No video out. Smells like a Kazakh warehouse. Will die on you." }, { id: "gfx1060", name: "nGreedia GFX 1060 Dumpster", cost: 60, tok: 10, watts: 120, vram: 6, heat: 1.2, sprite: "gpu_budget", desc: "The people's slop engine." }, { id: "radish580", name: "AMD Radish RX 580", cost: 80, tok: 14, watts: 185, vram: 8, heat: 2.2, sprite: "gpu_budget", desc: "Cheap tokens, free space heater." }, - { id: "mtt_s80", name: "Moore Slops MTT S80", cost: 45, tok: 18, watts: 250, vram: 16, heat: 3.0, sprite: "gpu_gaming", - desc: "Mystery-meat silicon. Absurd VRAM for the price, drinks power." }, + { id: "mtt_s80", name: "Moore Slops MTT S80", cost: 45, tok: 18, watts: 250, vram: 16, heat: 3.0, flaky: 0.012, sprite: "gpu_gaming", + desc: "Mystery-meat silicon. Absurd VRAM for the price, drinks power, dies young." }, { id: "sweatbox", name: "nGreedia GFX 2070 Sweatbox", cost: 140, tok: 22, watts: 175, vram: 8, heat: 1.6, sprite: "gpu_gaming", desc: "Runs warm. Runs slop. Runs up your bill." }, { id: "radish7", name: "AMD Radish VII Furnace", cost: 450, tok: 40, watts: 300, vram: 16, heat: 3.5, sprite: "gpu_gaming", @@ -152,6 +153,12 @@ export const CATALOG: DeviceDef[] = [ wattsIdle: 45, wattsLoad: 45, tokRate: 0, heatOut: 0, fan: true, footprint: { w: 1, h: 1 }, sprite: "desk_fan", }, + { + id: "extinguisher", name: "CO2 Extinguisher", cat: "cooling", cost: 90, + desc: "Auto-douses one fire within 3 tiles, then it's spent. OSHA would be proud.", + wattsIdle: 0, wattsLoad: 0, tokRate: 0, heatOut: 0, + footprint: { w: 1, h: 1 }, sprite: "extinguisher", unlockAt: 150, + }, { id: "window_ac", name: "Window AC", cat: "cooling", cost: 260, desc: "Drips on the carpet. Deletes heat from the room.", diff --git a/src/sim/rooms.ts b/src/sim/rooms.ts new file mode 100644 index 0000000..2752053 --- /dev/null +++ b/src/sim/rooms.ts @@ -0,0 +1,32 @@ +// Room definitions — the Expansion ladder. Grid geometry is hand-registered +// to each generated backdrop (toggle the Grid overlay to refit). +export interface RoomDef { + id: string; + name: string; + image: string; + w: number; + h: number; + tileW: number; + tileH: number; + origin: { x: number; y: number }; // center of tile (0,0) in image space + imgW: number; + imgH: number; + breakerBase: number; // base circuit watts for this building +} + +export const ROOMS: Record = { + bedroom: { + id: "bedroom", name: "Bedroom", image: "/assets/gen/room.png", + w: 10, h: 10, tileW: 82, tileH: 41, origin: { x: 510, y: 346 }, + imgW: 1024, imgH: 768, breakerBase: 1400, + }, + garage: { + id: "garage", name: "The Garage", image: "/assets/gen/garage.png", + w: 14, h: 14, tileW: 58.5, tileH: 29.5, origin: { x: 510, y: 330 }, + imgW: 1024, imgH: 768, breakerBase: 2400, + }, +}; + +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." }, +]; diff --git a/src/sim/save.ts b/src/sim/save.ts index 8b14ee7..eeacbaa 100644 --- a/src/sim/save.ts +++ b/src/sim/save.ts @@ -1,5 +1,5 @@ -import { BAL } from "./balance"; import { SimState, createState } from "./state"; +import { ROOMS } from "./rooms"; const KEY = "gigaslop-save-v1"; @@ -20,10 +20,12 @@ export function loadState(): SimState | null { const s = createState(); // new fields get defaults, then saved fields win Object.assign(s, data); delete (s as unknown as Record).v; - s.heat = new Float32Array(BAL.ROOM_W * BAL.ROOM_H); + const room = ROOMS[s.room] ?? ROOMS.bedroom; + s.heat = new Float32Array(room.w * room.h); s.heat.set((data.heat as number[]).slice(0, s.heat.length)); for (const d of s.devices) { d.upgrades ??= []; // pre-sockets saves + d.hotSince ??= null; // pre-GPU-catalog saves: carriers had intrinsic tok — grant equivalent cards if (d.gpus === undefined) { d.gpus = diff --git a/src/sim/sim.ts b/src/sim/sim.ts index b8ddd38..ff54a51 100644 --- a/src/sim/sim.ts +++ b/src/sim/sim.ts @@ -6,6 +6,7 @@ import { 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; @@ -22,9 +23,6 @@ function rollTitle(s: SimState): number { return COHERENT_TITLES + Math.floor(rand(s) * brokenCount); return Math.floor(rand(s) * COHERENT_TITLES); } -const W = BAL.ROOM_W, H = BAL.ROOM_H; - -const idx = (x: number, y: number) => y * W + x; export function footprintTiles(def: DeviceDef, x: number, y: number): [number, number][] { const tiles: [number, number][] = []; @@ -37,8 +35,9 @@ export function canPlace(s: SimState, defId: string, x: number, y: number): stri 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 >= W || ty >= H) return "out of bounds"; + 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)) @@ -53,7 +52,7 @@ export function place(s: SimState, defId: string, x: number, y: number): Device 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: [], + wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0, upgrades: [], gpus: [], hotSince: null, }; s.devices.push(d); pushEvent(s, `Installed ${def.name}`, "buy"); @@ -79,7 +78,7 @@ export function deviceTokRate(d: Device): number { 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, + wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0, upgrades: [], gpus, hotSince: null, }; s.devices.push(d); return d; @@ -156,6 +155,78 @@ 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 (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) => { @@ -379,7 +450,7 @@ function clapCost(s: SimState): number { } export function breakerLimit(s: SimState): number { - let limit = BAL.BREAKER_WATTS; + 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; @@ -397,6 +468,9 @@ export function step(s: SimState) { 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); @@ -442,12 +516,18 @@ export function step(s: SimState) { // --- 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; - const cardHeat = cardStats(d).heat; + // packed cards choke airflow — heat rises superlinearly, and throttling + // does NOT cool a crammed chassis (that's how runaways happen) + const packMult = 1 + 0.15 * Math.max(0, d.gpus.length - 1); + const cardHeat = cardStats(d).heat * packMult; if (def.heatOut || cardHeat) - heat[idx(d.x, d.y)] += (def.heatOut + cardHeat) * d.util * d.perf * dt * 2.2 * + 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++) { @@ -485,6 +565,13 @@ export function step(s: SimState) { 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) { + d.hotSince ??= s.t; + if (s.t - d.hotSince > 10) igniteDevice(s, d); + } else { + d.hotSince = null; + } } } @@ -586,6 +673,25 @@ export function step(s: SimState) { 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) { @@ -683,7 +789,7 @@ export function step(s: SimState) { } } - const adaptMult = (1 - s.platformAdapt * BAL.ADAPT_MAX_PENALTY) * hype * viewFx * + const adaptMult = (1 - s.platformAdapt * BAL.ADAPT_MAX_PENALTY) * hype * adHype * viewFx * mods.viewsPerVideoMult * mods.conveyorHype * (hostingOwnMax ? 1.1 : 1); for (const v of s.videos) { if (v.dead) continue; diff --git a/src/sim/state.ts b/src/sim/state.ts index bc9ca8c..65b569c 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -1,4 +1,5 @@ import { BAL } from "./balance"; +import { ROOMS } from "./rooms"; export interface Device { uid: number; @@ -13,6 +14,7 @@ export interface Device { util: number; // current utilization 0..1 upgrades: string[]; // socket upgrade ids gpus: string[]; // installed GPU card ids (carriers only) + hotSince: number | null; // sim-time the device crossed ignition temp } export interface Video { @@ -27,6 +29,10 @@ export interface Video { export interface SimState { company: string; + room: string; // ROOMS key + fires: { x: number; y: number; until: number }[]; + adSpend: number; // $/s marketing burn + lastSpamT: number; // last email-spam blast t: number; tick: number; cash: number; @@ -99,13 +105,17 @@ export interface SimState { export function createState(): SimState { return { company: "SlopCo", + room: "bedroom", + fires: [], + adSpend: 0, + lastSpamT: -999, t: 0, tick: 0, cash: BAL.START_CASH, earnedTotal: 0, devices: [], nextUid: 1, - heat: new Float32Array(BAL.ROOM_W * BAL.ROOM_H), + heat: new Float32Array(ROOMS.bedroom.w * ROOMS.bedroom.h), breakerTripped: false, allocTraining: 0.25, tier: 0, @@ -142,7 +152,7 @@ export function createState(): SimState { tokenBank: 0, videos: [], rates: { - watts: 0, breakerWatts: BAL.BREAKER_WATTS, tokEff: 0, income: 0, + watts: 0, breakerWatts: ROOMS.bedroom.breakerBase, tokEff: 0, income: 0, 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, diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 477a183..ea55b2f 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -4,6 +4,7 @@ import { SimState } from "../sim/state"; import { shopList, resetBreaker } from "../sim/sim"; import { sfx, setMuted, isMuted } from "./sfx"; import { MODEL_NAME_POOL, VERSION_NAMES } from "../sim/research"; +import { MOVES, ROOMS } from "../sim/rooms"; import TITLES from "../data/sloptitles.json"; export interface HudCallbacks { @@ -15,6 +16,9 @@ export interface HudCallbacks { onNewGame: () => void; onRaise: () => void; onClapback: () => void; + onAdSpend: (perSec: number) => void; + onSpam: () => void; + onMove: (to: string) => void; } const NAME_A = ["Slop", "Grift", "Chungus", "Synergy", "Vibe", "Goon", "Brainrot", "Yeet", "Chud", "Slud", "Content", "Engagement"]; @@ -82,7 +86,11 @@ export class Hud { HOST 0% · rest = slop + ADS $0/s + + +

HARDWARE STORE

SLOPTUBE STUDIO

@@ -116,6 +124,16 @@ export class Hud { (this.root.querySelector("#hostpct") as HTMLElement).textContent = allochost.value; cb.onAllocHost(Number(allochost.value) / 100); }; + const adspend = this.root.querySelector("#adspend") as HTMLInputElement; + adspend.oninput = () => { + (this.root.querySelector("#adspct") as HTMLElement).textContent = adspend.value; + cb.onAdSpend(Number(adspend.value)); + }; + (this.root.querySelector("#spam") as HTMLButtonElement).onclick = () => cb.onSpam(); + (this.root.querySelector("#move-hq") as HTMLButtonElement).onclick = () => { + const mv = MOVES[0]; + if (mv && confirm(`${ROOMS[mv.to].name}: ${mv.blurb} — $${mv.cost}?`)) cb.onMove(mv.to); + }; } bindBreaker(state: SimState) { @@ -245,6 +263,16 @@ export class Hud { if (offer) raise.textContent = `💰 Raise ${offer.name}: +$${fmt(offer.cash, 0)}`; } + // spam cooldown + move availability + const spam = q("#spam"); + 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); + (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})`; + // clap-back window const clap = q("#clap"); if (s.clapback) { diff --git a/tools/mb_add.py b/tools/mb_add.py index 453d9e9..c38c99e 100644 --- a/tools/mb_add.py +++ b/tools/mb_add.py @@ -7,18 +7,19 @@ sys.path.insert(0, str(pathlib.Path(__file__).parent)) from mb_gen import ROOT, STYLE, flux_spec, run_batch, upload, beat NEW = [ - ("dumb_switch", "a small cheap plastic 5-port ethernet switch with green blinking LEDs and a few patch cables"), - ("managed_switch", "a professional 1U rackmount managed network switch with many ports and orange status LEDs"), - ("ssd_shoebox", "an old shoebox overflowing with loose SATA SSDs and tangled cables"), - ("nvme_hoard", "a small stack of NVMe M.2 drives in an open anti-static tray next to a drive dock"), - ("nas_tower", "a black tower NAS storage server with six visible drive bays, one bay open"), - ("gpu_budget", "a single old dusty budget graphics card with one small fan, product photo style"), - ("gpu_gaming", "a chunky three-fan RGB gaming graphics card, product photo style"), - ("gpu_workstation", "a sleek blower-style professional workstation graphics card, product photo style"), - ("gpu_datacenter", "a gold-and-black datacenter AI accelerator card with no fans and huge heatsink, product photo style"), + ("extinguisher", "a red CO2 fire extinguisher standing on the floor"), +] + +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, " + "clean detailed painterly game art", 1024, 768), ] gen = ROOT / "public/assets/gen" +run_batch([flux_spec(n, d, w, h) for n, d, w, h in ROOMS], gen) raw = run_batch([flux_spec(n, STYLE + d, 768, 768) for n, d in NEW], gen) beat("bg removal for new sprites") cuts = run_batch( diff --git a/tools/mb_manifest.json b/tools/mb_manifest.json index 478308d..58bed9c 100644 --- a/tools/mb_manifest.json +++ b/tools/mb_manifest.json @@ -253,5 +253,15 @@ "job": "33f517d4fdca", "asset": "1d45b47a2cf7", "path": "/Users/jing/Documents/gigaslop/public/assets/gen/gpu_datacenter_cut.png" + }, + "extinguisher": { + "job": "cb6676c87ac0", + "asset": "9b2844ec3695", + "path": "/Users/jing/Documents/gigaslop/public/assets/gen/extinguisher.png" + }, + "extinguisher_cut": { + "job": "d8ff5530b60b", + "asset": "4a55508fae90", + "path": "/Users/jing/Documents/gigaslop/public/assets/gen/extinguisher_cut.png" } } \ No newline at end of file