M7: garage expansion, thermal runaway fires, flaky cards, ad tools
Rooms are data now: geometry per backdrop, sim and renderer both room-aware. First expansion beat: Move HQ to the garage ($3k) — 14x14 of concrete and 2.4kW of service, MODELBEAST-generated backdrop. Heat got teeth: packed carriers choke airflow (+15% heat per extra card) and throttling no longer self-regulates temps, so a crammed rig sustained over 95C ignites — device and cards destroyed, fire spreads heat and can cascade; click flames to beat them out with a hoodie, or a CO2 extinguisher within 3 tiles sacrifices itself automatically. Used cards (P106, MTT S80, RX 580) now die under load. Attention tools: ad-spend slider with log-diminishing hype, and the email Spam Blast — instant catalog-wide views on a 60s cooldown with a 15% blacklist gamble. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5d5bc7dcfb
commit
e4f0ddf0f2
16
CLAUDE.md
16
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.
|
||||
|
||||
BIN
public/assets/gen/extinguisher.png
Normal file
BIN
public/assets/gen/extinguisher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 369 KiB |
BIN
public/assets/gen/extinguisher_cut.png
Normal file
BIN
public/assets/gen/extinguisher_cut.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
public/assets/gen/garage.png
Normal file
BIN
public/assets/gen/garage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 658 KiB |
17
src/main.ts
17
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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<Texture | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.",
|
||||
|
||||
32
src/sim/rooms.ts
Normal file
32
src/sim/rooms.ts
Normal file
@ -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<string, RoomDef> = {
|
||||
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." },
|
||||
];
|
||||
@ -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<string, unknown>).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 =
|
||||
|
||||
126
src/sim/sim.ts
126
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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
<input id="alloc" type="range" min="0" max="90" value="25" />
|
||||
<span class="label" style="color:var(--dim);font-size:9px">HOST <b id="hostpct">0</b>% · rest = slop</span>
|
||||
<input id="allochost" type="range" min="0" max="90" value="0" />
|
||||
<span class="label" style="color:var(--dim);font-size:9px">ADS $<b id="adspct">0</b>/s</span>
|
||||
<input id="adspend" type="range" min="0" max="20" value="0" />
|
||||
</div>
|
||||
<button class="chip" id="spam">📧 Spam Blast</button>
|
||||
<button class="chip" id="move-hq" style="display:none">🚚 Move HQ</button>
|
||||
</div>
|
||||
<div id="shop" class="panel"><h3>HARDWARE STORE</h3></div>
|
||||
<div id="feed" class="panel"><h3><b>▶</b> SLOPTUBE STUDIO</h3><div id="vids"></div></div>
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user