Tier 1 playable slice: bedroom slop empire

Deterministic 10Hz sim (power/breaker, spatial heat, uplink bandwidth,
train-vs-infer economy), Pixi isometric renderer over MODELBEAST-generated
room + sprites, DOM HUD with SlopTube feed, save/load, company naming,
WebAudio SFX. Slop thumbnails are genuine diffusion slop; low model tiers
publish deranged qwen-7B titles, training buys coherence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-01 09:55:55 +10:00
commit d2bdffaa16
50 changed files with 3137 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "gigaslop-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.local

34
CLAUDE.md Normal file
View File

@ -0,0 +1,34 @@
# GigaSlop — project notes for Claude
## What this is
Satirical AI-datacenter tycoon game. Web-first: TypeScript + Vite + PixiJS v8.
Design doctrine: **the sim stays engine-agnostic**`src/sim/` must never
import from `src/render/` or `src/ui/` (a future Godot/Steam port swaps the
renderer only). Fixed 10 Hz tick, deterministic (seeded rng in state, no
Date.now/Math.random inside sim).
## Dev loop
- `npm run dev` on :5173 (`.claude/launch.json` has the preview config).
- The Browser-pane tab is usually `visibility: hidden` → RAF doesn't fire.
The sim is pumped by setInterval on purpose; don't move it back into RAF.
- `window.__giga` = debug handle {state, scene, place(), wire()} for
JS-driven playtesting. Keep it working — automation depends on it.
- All balance tunables live in `src/sim/balance.ts`. Tune there, nowhere else.
## Art pipeline (MODELBEAST)
- `python3 tools/mb_gen.py [--only room|objects|slop]` regenerates art on the
farm (queue http://100.89.131.57:8777, token from ~/Documents/backnforth/.env).
Deterministic seeds from asset slug — same slug, same image.
- Object sprites: flux_local → bg_remove_local (background:"transparent") →
autocropped in place (PIL alpha-bbox, 8px pad). If you regenerate, re-crop.
- Room/grid registration: `src/render/iso.ts` (TILE 82x41, ORIGIN 510,346)
is hand-fitted to `public/assets/gen/room.png`. New room image = refit
constants (toggle the Grid overlay to check).
- Slop thumbnails are intentionally AI slop; titles in src/data/sloptitles.json
(Ollama on m4pro was down at bake time; hand-authored — regenerate via
tailnet Ollama when it's back if more are needed).
## Roadmap (agreed with John 2026-08-01)
M1 art-forward Tier-1 slice (current) → M2 homelab tier + platform-decay
treadmill (model tiers force retraining) → M3 VC mandates + hazards.
Cut until earned: Tiers 35, immersion cooling, offshore ships, lobbying.

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# GigaSlop: Compute Tycoon
Factorio meets Software Inc., wrapped in a satirical sim of the AI slop economy.
Start with a budget PC in your bedroom generating $0.40/day of AI spam videos;
end up draining the county grid to feed slop-generation clusters.
**Stack:** TypeScript + Vite + PixiJS v8. The simulation (`src/sim/`) is a
deterministic fixed-tick (10 Hz) module with zero renderer dependencies — the
Pixi layer (`src/render/`) and DOM HUD (`src/ui/`) just read state.
## Run
```bash
npm install
npm run dev # http://localhost:5173
```
## Play (Tier 1 slice)
- You start with a PC, desk, and router. Press **W**, click the PC, then the
router to wire them — slop production starts.
- Watch the **breaker** (1400 W bedroom circuit): trip it and your training
checkpoint corrupts.
- Heat is spatial. Hot PCs throttle at 85°C; fans spread heat around, the
window AC deletes it.
- Uplink caps monetizable tokens — saturated cables mean dropped slop.
- The **TRAIN/INFER** slider trades cash now (inference → videos → ad revenue)
for model tiers later (higher revenue multipliers).
## Art pipeline
All art is generated on the local MODELBEAST farm (`tools/mb_gen.py`):
FLUX for the room shell + hardware sprites (bg-removed, autocropped), and the
in-game slop thumbnails are genuine SD/FLUX slop — the game about AI slop is
made of actual AI slop. Titles baked to `src/data/sloptitles.json`.
## Layout
```
src/sim/ balance.ts (all tunables) · catalog.ts (hardware defs)
state.ts · sim.ts (step, place/wire/sell commands)
src/render/ iso.ts (grid↔screen mapping) · scene.ts (Pixi world)
src/ui/ hud.ts + hud.css (DOM dashboards, shop, SlopTube feed)
tools/ mb_gen.py (MODELBEAST batch art generation)
```

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GigaSlop: Compute Tycoon</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0a0a0f; overflow: hidden; }
#app { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1262
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "gigaslop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"pixi.js": "^8.6.6"
},
"devDependencies": {
"typescript": "^5.6.3",
"vite": "^6.0.7"
}
}

BIN
public/assets/gen/desk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

BIN
public/assets/gen/room.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

111
src/data/sloptitles.json Normal file
View File

@ -0,0 +1,111 @@
[
"Cat LAWYER Cries In Rain (EMOTIONAL) 😭",
"Baby DEADLIFTS Toyota Corolla?! Doctors HATE Him",
"Podcast Bro DESTROYS Other Podcast Bro With FACTS",
"Julius Caesar Plays Fortnite For The FIRST Time",
"INFINITE Golden Retriever Spiral (10 HOURS) 🐕",
"Watermelon SUBMARINE Found At Bottom Of Ocean 😱",
"Grandma Vs Robot ARM WRESTLE — You Won't BELIEVE",
"SHARK Crashes 5 Year Old's Pool Party (REAL)",
"Medieval Knight Tries Fries For First Time ⚔️🍟",
"City Made Of PASTA Discovered By Google Earth",
"Abraham Lincoln Reviews Gaming Chairs (HONEST)",
"This Dog Speaks FLUENT Italian (NOT CLICKBAIT)",
"AI Girlfriend BREAKS UP With Man Over Minecraft",
"Top 10 Foods That Don't EXIST Anymore",
"Man Builds House Using Only CHEESE (Day 47)",
"Napoleon Reacts To His OWN TikToks 💀",
"Toddler Beats Chess Grandmaster BLINDFOLDED",
"POV: Your Toaster Is Secretly A BILLIONAIRE",
"Scientists SHOCKED As Moon Turns Out To Be Egg 🥚",
"Horse Runs FOR PRESIDENT And Almost WINS",
"I Survived 100 Days In A Walmart (EMOTIONAL)",
"Cleopatra's SKINCARE Routine LEAKED By Insider",
"Fake Podcast Hosts ARGUE About Fake Podcast",
"Duck Inherits $4 MILLION, Buys Lake Immediately",
"Gordon Ramsay Made Of Bees Rates Your Fridge 🐝",
"SECRET Door Found Behind Every Mirror (SCARY)",
"Baby Shark But Every Shark Is Your LANDLORD",
"Einstein Explains Skibidi Physics (EDUCATIONAL)",
"Cats Hold EMERGENCY MEETING About Red Dot",
"Man Marries SPREADSHEET, Files Joint Taxes",
"The Rock But He Is Literally A Rock Now 🪨",
"5 Life Hacks Using Only WET BREAD",
"Ghost Reviews Haunted Houses (ZILLOW TOUR)",
"Pigeon CEO Fires Entire Board, Eats Crumbs",
"Time Traveler Rates 2026 Slop (DISAPPOINTED)",
"Spaghetti Bridge Collapses, Engineers WEEP",
"Your Sleep Paralysis Demon Does Mukbang ASMR",
"Shakespeare Drops DISS TRACK On Marlowe (FIRE) 🔥",
"Ants Recreate Titanic Frame By Frame (4K)",
"Local Microwave Achieves CONSCIOUSNESS, Beeps Once",
"Dinosaur Tries Bubble Tea For First Time 🦖",
"Man Runs Marathon INSIDE Grocery Store (BANNED)",
"AI Learns To Feel LOVE, Immediately Regrets It",
"Fridge Light Finally Filmed Turning OFF (PROOF)",
"Genghis Khan Unboxes Mystery Loot Crate ⚔️",
"Whale Learns To Yodel, Ocean COMPLAINS",
"I Ate Only BLUE Foods For 30 Days (Doctor Mad)",
"Roomba Escapes House, Starts NEW LIFE In Ohio",
"Mona Lisa FINALLY Blinks After 500 Years 😳",
"Squirrel Day Trades Acorns Into 7 FIGURES",
"Every US President Ranked By VERTICAL JUMP",
"Alien Tries IKEA Furniture Assembly (RAGE)",
"Bread Falls BUTTER SIDE UP, Physicists Panic",
"My Goldfish Passed The BAR EXAM (Emotional)",
"Volcano Erupts CONFETTI, Town Mildly Pleased 🎉",
"Knights Vs Ninjas Vs HOA Board (WHO WINS?)",
"Toilet Paper Roll Debate ENDS Friendship LIVE",
"Owl Attends Night School, Graduates TOP Of Class",
"Vending Machine Proposes To ATM (WEDDING SOON)",
"Caveman Reacts To Standing Desks (CONFUSED)",
"WHY CAN'T MY CAT SPEAK LIKE JARVIS?! ⭐",
"THE DYNASTY'S NEW YOLO CHALLENGE 🏴‍☠️✨",
"EMPEROR CEASAR TWEETS FROM THE RAP GODS 🗣️🔥",
"ALISHA'S MAGIC PIZZA THAT TASTES LIKE SUSHI 💧🍕🍣",
"60 YOGURT TRICKS TO GET YOUR MARRIED FOR FREE 💰🥳",
"ROBO-LOVE: AI'S LATEST RELODENGE CTF ♥ ❤",
"BENJAMIN FRANKLIN ON FACEBOOK: A SCROLL THROUGH HISTORY 🕊️💻",
"CAN AI BAKE YOU A FANTASY CAKE? 🍰✨",
"LIL' JESUS IN TECH SUPPORT: THE TROUBLESOME TRUTH 💻💥",
"WILL YOUR PET BECOME A YOUTUBE VIRAL STAR? 🐾🔥",
"NAPOLÉON STRUMMING A GUITAR: CLASS VS. GUT 🎸⚔️",
"THE KITCHEN TABLE MAGIC OF FUSION CUISINE 🌴egovmex!",
"WILL YOUR SPICE BEET THE AI? 🔥🧂🤖",
"LINCOLN RUNS A VLOG IN 1863: SABOTAGE! ⚔️👁‍🗨",
"ROBOT CHOPPERS VS. COOKING SHOWMEN 🍕👨‍🍳🤖",
"EMMETT DUNKS HIS YO-YO LIKE KARINNA 💂‍♀️🏀",
"THE AI'S ULTIMATE POKEDEX EXPO: MIGHTY CREATURES 👾🌟",
"ANIMAL HANGTIGHTERS ON THE HOOD OF DISNEYLAND 🦁🚗Disneyland!",
"HAN SOLO LEARNING HOW TO SWIPES & SLIDES 💫📱💪",
"TIME TRAVEL MEETS AI: A DATE IN THE FUTURE 😘💥",
"MADAME TALBOT'S NEW AGE MEDITATION TECH 👽🧘‍♀️✨",
"EMMA'S MAGICAL CLEAVER THAT SHINES LIKE A STAR ✨🔪",
"CAN SUSHI ROLL INTO SPACE WITH AI'S HELP? 🌌🍣",
"FDR GAMESHIFTED FORTUNE: THE CHANCE OF THE DECADE 💼🎉",
"AI HAS CREATED A TARDIS IN THE KITCHEN 🕸️👨‍🍳",
"LOUISE ADAMS SPITS SOME TRUTH TIES 💬🔥",
"FDR'S GROCERY RUN TO MARTIAN SUPERMARKETS 🛒🌌",
"ROBOT COWBOYS RIDE A NEW DAWN IN TEXAS 🌞 Cowboys!",
"HILARY CLINTON WANTS YOU TO EAT QUAGGA MUFFINS 🍞👑",
"THE BUCKET LIST OF AI: UNFULFILLED DREAMS 💯!",
"HARRY POTTER LEARNS HOW TO SWIPE ON TikTok 🧙‍♂️💻✨",
"JACK THE RIPPER'S READING CIRCLES AND AI POETRY 🔪📚🤖",
"WALL-E FARMING: THE E-STRESS HIGHS 🌾🤖🔥",
"PARKER LEHMAN TEACHES MARTIANS ABOUT GIFTS 💫🚀",
"THE LAST VEDIC YOGA GUY MIGHT BE AI! ✨🧘‍♂️!",
"ALICE'S 101 FOOD EXCHANGES WITH THE SPIDERS 🕸️👩‍🍳",
"BETSY RUSSEL AND HER AUTOMATED NAPS 💤💖",
"VANESSA V. BEATS AI AT COOKING: WHO WILL WIN? 🔥👩‍🍳🤖",
"THE LAST DRY GHOST OF JERUSALEM TALKS TO GHOSTS 🙇geist!",
"HARRIET TUBMAN PLAYS THE MARKET ON BITCOIN 💵🔥",
"THOMAS EDISON AND THE ROBOT KARAOKE QUEENS 🎤💡✨",
"THE MAD SCIENCE OF AI-MADE LUNCHEONS 🍱🎉🌈",
"AUGUSTUS GLOOMER'S GARDEN HACKS VS AI 😊🌱🤖",
"MARTIN LUTHER WELTERS WITH AI IN A DIGITAL BATTLE! 💪❤️!",
"EUNICE RODAYRÁZORBREAKS THE YOUTUBE RECORD OF ANIMAL SINGERS 🐾🎤🔥",
"TODHUSBERG'S ALIEN SNACKS: EXTRATERRESTRIAL TASTES 🍦👽😂",
"THE 107 BEST AI-SPUN HUMMINGBIRDS FLY TOWARDS YOU 🔼💕✨",
"PENELOPE PIG VS. AI IN A LATEST MIND BENDER ⚖️💥🌈",
"GEORGE WASHINGTON KIDS VS. THE GOBBLENETS 🕯️🌟🔥"
]

153
src/main.ts Normal file
View File

@ -0,0 +1,153 @@
import { BAL } from "./sim/balance";
import { createState, pushEvent } from "./sim/state";
import { step, place, canPlace, wire, sell, grant } from "./sim/sim";
import { saveState, loadState, clearSave } from "./sim/save";
import { DEF } from "./sim/catalog";
import { Scene } from "./render/scene";
import { Hud } from "./ui/hud";
import { sfx } from "./ui/sfx";
async function boot() {
const el = document.getElementById("app")!;
const loaded = loadState();
const state = loaded ?? createState();
const scene = new Scene();
await scene.init(el);
// --- input state ---
let shopSel: string | null = null;
let wireMode = false;
let wireSource: number | null = null;
let mouse = { x: 0, y: 0 };
const hud = new Hud({
onSelectShop: (id) => { shopSel = id; wireMode = false; wireSource = null; hud.setWireActive(false); },
onToggleOverlay: (w) => { scene.overlays[w] = !scene.overlays[w]; },
onWireTool: () => {
wireMode = !wireMode;
wireSource = null;
shopSel = null;
hud.selectedShop = null;
hud.setWireActive(wireMode);
},
onAlloc: (v) => { state.allocTraining = v; },
onNewGame: () => { clearSave(); location.reload(); },
});
hud.bindBreaker(state);
const cancel = () => {
shopSel = null; hud.selectedShop = null; hud.lockedCache = "";
wireMode = false; wireSource = null; hud.setWireActive(false);
};
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") cancel();
if (e.key === "w" || e.key === "W") {
wireMode = !wireMode; wireSource = null; shopSel = null; hud.setWireActive(wireMode);
}
});
scene.app.canvas.addEventListener("pointermove", (e) => {
mouse = { x: e.clientX, y: e.clientY };
});
const deviceAt = (tx: number, ty: number) =>
state.devices.find((d) => {
const def = DEF[d.defId];
return tx >= d.x && tx < d.x + def.footprint.w && ty >= d.y && ty < d.y + def.footprint.h;
});
scene.app.canvas.addEventListener("pointerdown", (e) => {
const t = scene.tileAt(e.clientX, e.clientY);
if (e.button === 2) {
if (shopSel || wireMode) { cancel(); return; }
if (t.in) {
const d = deviceAt(t.x, t.y);
if (d) { sell(state, d.uid); sfx.sell(); }
}
return;
}
if (!t.in) return;
if (shopSel) {
const err = canPlace(state, shopSel, t.x, t.y);
if (err) { pushEvent(state, err, "info"); sfx.error(); return; }
place(state, shopSel, t.x, t.y);
sfx.place();
if (state.cash < DEF[shopSel].cost) { shopSel = null; hud.selectedShop = null; hud.lockedCache = ""; }
return;
}
if (wireMode) {
const d = deviceAt(t.x, t.y);
if (!d) return;
if (wireSource === null) {
if (DEF[d.defId].tokRate) { wireSource = d.uid; sfx.click(); }
else if (DEF[d.defId].uplinkMbps) pushEvent(state, "pick the PC first, then the router", "info");
} else {
const err = wire(state, wireSource, d.uid);
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.wire();
wireSource = null;
}
}
});
scene.app.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
// --- fixed-tick sim pump ---
// setInterval (not RAF) so the economy keeps running when the tab is hidden;
// background tabs clamp timers to ~1 Hz, the accumulator catches up (max 2 s).
let acc = 0;
let last = performance.now();
setInterval(() => {
const now = performance.now();
acc += Math.min(2, (now - last) / 1000);
last = now;
if (document.getElementById("name-modal")) { acc = 0; return; } // clock frozen until incorporated
while (acc >= BAL.SIM_DT) { step(state); acc -= BAL.SIM_DT; }
}, 50);
// --- render loop ---
const loop = () => {
const t = scene.tileAt(mouse.x, mouse.y);
const ghost = shopSel && t.in
? { defId: shopSel, x: t.x, y: t.y, ok: !canPlace(state, shopSel, t.x, t.y) }
: null;
scene.render(state, ghost, 1 / 60);
if (wireMode && wireSource !== null) {
const src = state.devices.find((d) => d.uid === wireSource);
scene.highlightWireSource(src ?? null, state);
}
requestAnimationFrame(loop);
};
// HUD at 5 Hz
setInterval(() => hud.update(state), 200);
hud.update(state);
if (!loaded) {
// starter bedroom: your old PC and router, not yet wired together
grant(state, "desk", 5, 7);
grant(state, "desk_pc", 7, 7);
grant(state, "router", 2, 1);
state.company = await hud.showNameModal();
document.title = `${state.company} — GigaSlop`;
pushEvent(state, `${state.company} is live. Press W and wire the PC to the router.`, "info");
saveState(state);
} else {
document.title = `${state.company} — GigaSlop`;
}
requestAnimationFrame(loop);
// autosave
setInterval(() => saveState(state), 10_000);
window.addEventListener("beforeunload", () => saveState(state));
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") saveState(state);
});
// debug/automation handle
(window as never as Record<string, unknown>).__giga = {
state, scene, place: (id: string, x: number, y: number) => place(state, id, x, y),
wire: (a: number, b: number) => wire(state, a, b),
};
}
boot();

50
src/render/iso.ts Normal file
View File

@ -0,0 +1,50 @@
import { BAL } from "../sim/balance";
// 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 };
export function tileToScreen(x: number, y: number) {
return {
x: ORIGIN.x + ((x - y) * TILE_W) / 2,
y: ORIGIN.y + ((x + y) * TILE_H) / 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);
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;
}
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,
];
}
// L-shaped tile path (x first, then y) for cable routing
export function lPath(ax: number, ay: number, bx: number, by: number): { x: number; y: number }[] {
const pts: { x: number; y: number }[] = [];
const stepx = Math.sign(bx - ax), stepy = Math.sign(by - ay);
for (let x = ax; x !== bx; x += stepx) pts.push({ x, y: ay });
for (let y = ay; ; y += stepy) {
pts.push({ x: bx, y });
if (y === by || stepy === 0) break;
}
if (pts.length === 0 || pts[pts.length - 1].x !== bx || pts[pts.length - 1].y !== by)
pts.push({ x: bx, y: by });
return pts;
}

231
src/render/scene.ts Normal file
View File

@ -0,0 +1,231 @@
import { Application, Assets, Container, Graphics, Sprite, Texture } from "pixi.js";
import { BAL } from "../sim/balance";
import { DEF } from "../sim/catalog";
import { SimState, Device } from "../sim/state";
import { TILE_W, TILE_H, ROOM_IMG, tileToScreen, screenToTile, diamond, lPath, inBounds } from "./iso";
export interface Overlays { heat: boolean; power: boolean; data: boolean; grid: boolean }
export class Scene {
app!: Application;
world = new Container(); // scaled/centered room space
deviceLayer = new Container();
cableGfx = new Graphics();
overlayGfx = new Graphics();
ghostSprite: Sprite | null = null;
sprites = new Map<number, Sprite>();
textures = new Map<string, Texture>();
slopTextures: Texture[] = [];
overlays: Overlays = { heat: true, power: false, data: false, grid: false };
pulsePhase = 0;
outlet = tileToScreen(0, 5); // wall outlet anchor (left wall)
async init(el: HTMLElement) {
this.app = new Application();
await this.app.init({ background: "#07070c", resizeTo: window, antialias: true });
el.appendChild(this.app.canvas);
// load textures with graceful fallback
const names = ["desk", "desk_pc", "router", "desk_fan", "gpu_rig", "window_ac", "power_strip", "server_rack_mini"];
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);
}
for (let i = 0; i < 10; i++) {
const t = await this.tryLoad(`/assets/slop/slop_${String(i).padStart(2, "0")}.png`);
if (t) this.slopTextures.push(t);
}
if (room) {
const bg = new Sprite(room);
this.world.addChild(bg);
}
this.deviceLayer.sortableChildren = true;
this.world.addChild(this.cableGfx, this.deviceLayer, this.overlayGfx);
this.app.stage.addChild(this.world);
this.layout();
window.addEventListener("resize", () => this.layout());
}
private async tryLoad(url: string): Promise<Texture | null> {
try {
return await Assets.load(url);
} catch {
console.warn("missing asset", url);
return null;
}
}
layout() {
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;
this.world.scale.set(s);
this.world.position.set((w - ROOM_IMG.w * s) / 2, (h - ROOM_IMG.h * s) / 2);
}
/** pointer event → room-image space */
toRoomSpace(clientX: number, clientY: number) {
const rect = this.app.canvas.getBoundingClientRect();
const gx = ((clientX - rect.left) / rect.width) * this.app.renderer.width;
const gy = ((clientY - rect.top) / rect.height) * this.app.renderer.height;
return {
x: (gx - this.world.position.x) / this.world.scale.x,
y: (gy - this.world.position.y) / this.world.scale.y,
};
}
spriteFor(d: Device): Sprite {
let sp = this.sprites.get(d.uid);
if (!sp) {
const def = DEF[d.defId];
const tex = this.textures.get(def.sprite);
sp = tex ? new Sprite(tex) : this.placeholderSprite();
sp.anchor.set(0.5, 0.82);
this.sprites.set(d.uid, sp);
this.deviceLayer.addChild(sp);
}
return sp;
}
private placeholderSprite(): Sprite {
const g = new Graphics().rect(-40, -80, 80, 80).fill({ color: 0x8b5cf6, alpha: 0.7 });
const tex = this.app.renderer.generateTexture(g);
const sp = new Sprite(tex);
return sp;
}
scaleFor(defId: string, sp: Sprite): number {
const def = DEF[defId];
const targetW = (def.footprint.w + def.footprint.h) * 0.5 * TILE_W * 1.35;
return targetW / sp.texture.width;
}
render(s: SimState, ghost: { defId: string; x: number; y: number; ok: boolean } | null, dt: number) {
this.pulsePhase = (this.pulsePhase + dt * 1.6) % 1;
// --- devices ---
const seen = new Set<number>();
for (const d of s.devices) {
seen.add(d.uid);
const def = DEF[d.defId];
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);
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);
// state tinting: unpowered = dim, throttled = red-ish
sp.tint = !d.powered ? 0x777788 : d.perf < 1 ? 0xffb0a0 : 0xffffff;
sp.alpha = 1;
}
for (const [uid, sp] of this.sprites)
if (!seen.has(uid)) { sp.destroy(); this.sprites.delete(uid); }
// --- ghost ---
if (ghost) {
const def = DEF[ghost.defId];
const tex = this.textures.get(def.sprite);
if (!this.ghostSprite) {
this.ghostSprite = tex ? new Sprite(tex) : this.placeholderSprite();
this.ghostSprite.anchor.set(0.5, 0.82);
this.deviceLayer.addChild(this.ghostSprite);
}
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);
if (def.wallOnly) g.position.y -= 55;
g.scale.set(this.scaleFor(ghost.defId, g));
g.alpha = 0.55;
g.tint = ghost.ok ? 0x88ff88 : 0xff6666;
g.zIndex = 999;
g.visible = true;
} else if (this.ghostSprite) {
this.ghostSprite.visible = false;
}
this.drawCables(s);
this.drawOverlays(s, ghost);
}
private drawCables(s: SimState) {
const g = this.cableGfx;
g.clear();
for (const d of s.devices) {
if (d.wiredTo === null) continue;
const rt = s.devices.find((r) => r.uid === d.wiredTo);
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 };
});
if (path.length < 2) continue;
const live = d.powered && rt.powered;
g.moveTo(path[0].x, path[0].y);
for (const p of path.slice(1)) g.lineTo(p.x, p.y);
g.stroke({ width: 4, color: 0x0e2a33, alpha: 0.9 });
g.moveTo(path[0].x, path[0].y);
for (const p of path.slice(1)) g.lineTo(p.x, p.y);
g.stroke({ width: 1.6, color: live ? 0x22d3ee : 0x2a4a55, alpha: live ? 0.95 : 0.6 });
// data pulse dots
if (live) {
const total = path.length - 1;
for (let k = 0; k < 2; k++) {
const t = ((this.pulsePhase + k * 0.5) % 1) * total;
const i = Math.min(Math.floor(t), total - 1);
const f = t - i;
const px = path[i].x + (path[i + 1].x - path[i].x) * f;
const py = path[i].y + (path[i + 1].y - path[i].y) * f;
g.circle(px, py, 3).fill({ color: 0x67e8f9, alpha: 0.9 });
}
}
}
// power lines to outlet (overlay only)
if (this.overlays.power) {
for (const d of s.devices) {
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.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 });
}
}
private drawOverlays(s: SimState, ghost: { defId: string } | null) {
const g = this.overlayGfx;
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];
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 });
}
if (this.overlays.grid || ghost) {
for (let x = 0; x < BAL.ROOM_W; x++)
for (let y = 0; y < BAL.ROOM_H; y++)
g.poly(diamond(x, y)).stroke({ width: 1.5, color: 0xc4b5fd, alpha: 0.5 });
}
}
highlightWireSource(d: Device | null, s: SimState) {
// draw a pulsing ring on the wire-tool source device via overlayGfx (called after drawOverlays)
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 });
void s;
}
tileAt(clientX: number, clientY: number) {
const p = this.toRoomSpace(clientX, clientY);
const t = screenToTile(p.x, p.y);
return { ...t, in: inBounds(t.x, t.y) };
}
}

41
src/sim/balance.ts Normal file
View File

@ -0,0 +1,41 @@
// All gameplay tunables in one place. Sim-day pacing target: first upgrade ~2 min in.
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
KWH_PRICE: 0.35, // $/kWh — punchy so burn rate is visible
CHECKPOINT_LOSS: 0.25, // training progress lost on breaker trip
// Heat (per-tile scalar, °C above ambient)
AMBIENT_C: 24,
DIFFUSE: 0.06, // neighbor averaging factor per tick
DECAY: 0.004, // passive loss toward ambient per tick
FAN_DIFFUSE_BONUS: 0.2, // fans spread heat (3x3)
THROTTLE_C: 85,
THROTTLE_FLOOR: 0.2, // perf multiplier at max overheat
// Data
MBPS_PER_TOK: 0.5, // bandwidth cost of shipping slop per token/s
// Economy
TOK_PRICE: 0.001, // baseline API $/token (inference share)
VIDEO_COST_TOK: 200, // tokens to render one slop video
VIDEO_LIFETIME_S: 90,
VIDEO_BASE_VIEWS_S: 10, // views/s at birth, tier 1, non-viral
VIRAL_CHANCE: 0.12,
VIRAL_MULT_MAX: 25,
CPM: 2.4, // $ per 1000 views
// Training / model tiers: tokens required to reach tier i+1, revenue multiplier per tier
TIERS: [
{ name: "SlopLM-1B", mult: 1 },
{ name: "SlopLM-7B", mult: 2.2, tokens: 6_000 },
{ name: "SlopLM-70B", mult: 5, tokens: 40_000 },
{ name: "SlopMoE-8x70B", mult: 12, tokens: 250_000 },
],
};

71
src/sim/catalog.ts Normal file
View File

@ -0,0 +1,71 @@
export interface DeviceDef {
id: string;
name: string;
desc: string;
cost: number;
wattsIdle: number;
wattsLoad: number; // at 100% utilization
tokRate: number; // tokens/s at full clock (0 = not a compute device)
heatOut: number; // heat units/s added to its tile at full load
coolRate?: number; // heat units/s removed (AC)
fan?: boolean; // spreads heat (diffusion bonus)
uplinkMbps?: number; // router-class device
wallOnly?: boolean;
footprint: { w: number; h: number };
sprite: string; // key in assets/gen/<sprite>_cut.png
unlockAt?: number; // cash-earned-total gate for the shop
}
export const CATALOG: DeviceDef[] = [
{
id: "desk", name: "Desk", cost: 15,
desc: "A surface for your empire. Purely moral support.",
wattsIdle: 0, wattsLoad: 0, tokRate: 0, heatOut: 0,
footprint: { w: 2, h: 1 }, sprite: "desk",
},
{
id: "desk_pc", name: "Budget PC", cost: 60,
desc: "i5 + GTX 1060. Generates 10 tok/s of pure slop.",
wattsIdle: 45, wattsLoad: 350, tokRate: 10, heatOut: 3.2,
footprint: { w: 1, h: 1 }, sprite: "desk_pc",
},
{
id: "router", name: "Wi-Fi Router", cost: 40,
desc: "100 Mbps uplink. Slop needs pipes. Wire PCs to it.",
wattsIdle: 12, wattsLoad: 12, tokRate: 0, heatOut: 0.2,
uplinkMbps: 100, footprint: { w: 1, h: 1 }, sprite: "router",
},
{
id: "desk_fan", name: "Desk Fan", cost: 18,
desc: "Moves hot air somewhere else. Somewhere is your problem.",
wattsIdle: 45, wattsLoad: 45, tokRate: 0, heatOut: 0,
fan: true, footprint: { w: 1, h: 1 }, sprite: "desk_fan",
},
{
id: "power_strip", name: "Power Strip", cost: 12,
desc: "Scorched but trustworthy. +200W breaker headroom (don't ask).",
wattsIdle: 0, wattsLoad: 0, tokRate: 0, heatOut: 0.4,
footprint: { w: 1, h: 1 }, sprite: "power_strip",
},
{
id: "gpu_rig", name: "Milk-Crate GPU Rig", cost: 420,
desc: "Four mismatched GPUs, zero regrets. 45 tok/s, big heat.",
wattsIdle: 90, wattsLoad: 900, tokRate: 45, heatOut: 9,
footprint: { w: 2, h: 1 }, sprite: "gpu_rig", unlockAt: 150,
},
{
id: "window_ac", name: "Window AC", cost: 260,
desc: "Drips on the carpet. Deletes heat from the room.",
wattsIdle: 60, wattsLoad: 800, tokRate: 0, heatOut: 0,
coolRate: 14, wallOnly: true, footprint: { w: 1, h: 1 },
sprite: "window_ac", unlockAt: 150,
},
{
id: "server_rack_mini", name: "Half Rack", cost: 1800,
desc: "eBay special. 120 tok/s. Your breaker is already crying.",
wattsIdle: 200, wattsLoad: 2000, tokRate: 120, heatOut: 20,
footprint: { w: 2, h: 2 }, sprite: "server_rack_mini", unlockAt: 900,
},
];
export const DEF = Object.fromEntries(CATALOG.map((d) => [d.id, d]));

34
src/sim/save.ts Normal file
View File

@ -0,0 +1,34 @@
import { BAL } from "./balance";
import { SimState, createState } from "./state";
const KEY = "gigaslop-save-v1";
export function saveState(s: SimState) {
try {
localStorage.setItem(KEY, JSON.stringify({ ...s, heat: Array.from(s.heat), v: 1 }));
} catch (e) {
console.warn("save failed", e);
}
}
export function loadState(): SimState | null {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
try {
const data = JSON.parse(raw);
if (data.v !== 1) return 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);
s.heat.set((data.heat as number[]).slice(0, s.heat.length));
return s;
} catch (e) {
console.warn("corrupt save, starting fresh", e);
return null;
}
}
export function clearSave() {
localStorage.removeItem(KEY);
}

254
src/sim/sim.ts Normal file
View File

@ -0,0 +1,254 @@
import { BAL } from "./balance";
import { CATALOG, DEF, DeviceDef } from "./catalog";
import { Device, SimState, pushEvent, rand } from "./state";
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;
const brokenChance = 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);
}
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][] = [];
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";
for (const [tx, ty] of footprintTiles(def, x, y)) {
if (tx < 0 || ty < 0 || tx >= W || ty >= 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,
};
s.devices.push(d);
pushEvent(s, `Installed ${def.name}`, "buy");
return d;
}
/** place without cost/afford checks — starter room setup */
export function grant(s: SimState, defId: string, x: number, y: number): Device {
const d: Device = {
uid: s.nextUid++, defId, x, y, on: true, powered: false,
wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0,
};
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 const ROUTER_PORTS = 4;
export function wire(s: SimState, deviceUid: number, routerUid: number): string | null {
const dev = s.devices.find((d) => d.uid === deviceUid);
const rt = s.devices.find((d) => d.uid === routerUid);
if (!dev || !rt) return "missing device";
if (!DEF[dev.defId].tokRate) return "nothing to wire there";
if (!DEF[rt.defId].uplinkMbps) return "that's not a router";
const used = s.devices.filter((d) => d.wiredTo === routerUid).length;
if (used >= ROUTER_PORTS) return "router ports full (4)";
dev.wiredTo = routerUid;
pushEvent(s, `Wired ${DEF[dev.defId].name} to router`, "info");
return null;
}
export function resetBreaker(s: SimState) {
s.breakerTripped = false;
pushEvent(s, "Breaker reset", "info");
}
export function breakerLimit(s: SimState): number {
const strips = s.devices.filter((d) => d.defId === "power_strip").length;
return BAL.BREAKER_WATTS + strips * 200;
}
function tierMult(s: SimState): number {
return BAL.TIERS[s.tier].mult;
}
export function step(s: SimState) {
const dt = BAL.SIM_DT;
s.tick++;
s.t += dt;
// --- 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 ? (def.tokRate ? (d.wiredTo !== null ? 1 : 0.1) : 1) : 0;
d.powered = d.on && !s.breakerTripped;
if (d.powered) watts += def.wattsIdle + (def.wattsLoad - def.wattsIdle) * d.util;
}
if (!s.breakerTripped && watts > limit) {
s.breakerTripped = true;
watts = 0;
for (const d of s.devices) d.powered = false;
const loss = Math.round(BAL.CHECKPOINT_LOSS * 100);
s.trainTokens *= 1 - BAL.CHECKPOINT_LOSS;
pushEvent(s, `⚡ BREAKER TRIPPED — checkpoint corrupted (-${loss}% training)`, "bad");
}
// --- heat pass ---
const heat = s.heat;
for (const d of s.devices) {
const def = DEF[d.defId];
if (!d.powered) continue;
if (def.heatOut) heat[idx(d.x, d.y)] += def.heatOut * d.util * d.perf * dt * 2.2;
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)] + (def.tokRate ? d.util * 18 : 0);
maxTemp = Math.max(maxTemp, d.temp);
if (def.tokRate) {
const over = d.temp - BAL.THROTTLE_C;
d.perf = over <= 0 ? 1 : Math.max(BAL.THROTTLE_FLOOR, 1 - over / 30);
}
}
// --- data / uplink ---
let uplink = 0;
for (const d of s.devices)
if (DEF[d.defId].uplinkMbps && d.powered) uplink += DEF[d.defId].uplinkMbps!;
let tokRaw = 0;
for (const d of s.devices) {
const def = DEF[d.defId];
if (def.tokRate && d.powered && d.wiredTo !== null) {
const routerAlive = s.devices.some((r) => r.uid === d.wiredTo && r.powered);
if (routerAlive) tokRaw += def.tokRate * d.perf;
}
}
const demand = tokRaw * BAL.MBPS_PER_TOK;
const dataMult = demand > 0 ? Math.min(1, uplink / demand) : 1;
const tokEff = tokRaw * dataMult;
// --- economy ---
const tm = tierMult(s);
const tokInf = tokEff * (1 - s.allocTraining) * dt;
const tokTrain = tokEff * s.allocTraining * dt;
let income = tokInf * BAL.TOK_PRICE * tm; // baseline API drip
// training / tier ups
s.trainTokens += tokTrain;
const nextTier = BAL.TIERS[s.tier + 1];
if (nextTier && s.trainTokens >= nextTier.tokens!) {
s.tier++;
s.trainTokens = 0;
pushEvent(s, `🧠 MODEL TIER UP: ${nextTier.name} (revenue x${nextTier.mult})`, "good");
}
// slop videos
s.tokenBank += tokInf;
while (s.tokenBank >= BAL.VIDEO_COST_TOK) {
s.tokenBank -= BAL.VIDEO_COST_TOK;
const viral = rand(s) < BAL.VIRAL_CHANCE ? 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);
if (viral > 6) pushEvent(s, `🔥 VIDEO WENT VIRAL (x${viral.toFixed(0)})`, "good");
if (s.videos.length > 40) s.videos.shift();
}
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 * Math.exp(-age / (BAL.VIDEO_LIFETIME_S / 3));
v.views += v.rate * dt;
income += (v.rate * dt * BAL.CPM) / 1000;
}
// --- 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;
}
export function shopList(s: SimState) {
return CATALOG.map((def) => ({
def,
locked: (def.unlockAt ?? 0) > s.earnedTotal,
affordable: s.cash >= def.cost,
}));
}

93
src/sim/state.ts Normal file
View File

@ -0,0 +1,93 @@
import { BAL } from "./balance";
export interface Device {
uid: number;
defId: string;
x: number;
y: number;
on: boolean;
powered: boolean;
wiredTo: number | null; // router uid for compute devices
temp: number;
perf: number; // thermal multiplier 0..1
util: number; // current utilization 0..1
}
export interface Video {
title: number; // index into sloptitles
thumb: number; // index into slop_XX
born: number;
views: number;
rate: number;
viral: number; // multiplier, 1 = normal
dead: boolean;
}
export interface SimState {
company: string;
t: number;
tick: number;
cash: number;
earnedTotal: number;
devices: Device[];
nextUid: number;
heat: Float32Array; // ROOM_W * ROOM_H, °C above ambient
breakerTripped: boolean;
allocTraining: number; // 0..1 share of compute
tier: number;
trainTokens: number;
tokenBank: number;
videos: Video[];
rates: {
watts: number;
breakerWatts: number;
tokEff: number;
income: number; // $/s smoothed
powerCost: number; // $/s
demandMbps: number;
uplinkMbps: number;
dataMult: number;
maxTemp: number;
};
events: { msg: string; t: number; kind: string }[];
rng: number;
}
export function createState(): SimState {
return {
company: "SlopCo",
t: 0,
tick: 0,
cash: BAL.START_CASH,
earnedTotal: 0,
devices: [],
nextUid: 1,
heat: new Float32Array(BAL.ROOM_W * BAL.ROOM_H),
breakerTripped: false,
allocTraining: 0.25,
tier: 0,
trainTokens: 0,
tokenBank: 0,
videos: [],
rates: {
watts: 0, breakerWatts: BAL.BREAKER_WATTS, tokEff: 0, income: 0,
powerCost: 0, demandMbps: 0, uplinkMbps: 0, dataMult: 1, maxTemp: BAL.AMBIENT_C,
},
events: [],
rng: 0x5109a & 0xffffffff,
};
}
// deterministic rng (mulberry32)
export function rand(s: SimState): number {
s.rng = (s.rng + 0x6d2b79f5) | 0;
let z = s.rng;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
}
export function pushEvent(s: SimState, msg: string, kind = "info") {
s.events.push({ msg, t: s.t, kind });
if (s.events.length > 50) s.events.shift();
}

91
src/ui/hud.css Normal file
View File

@ -0,0 +1,91 @@
:root {
--bg: #0b0b12;
--panel: rgba(16, 16, 26, 0.92);
--edge: #23233a;
--text: #e6e6f0;
--dim: #8a8aa3;
--purple: #8b5cf6;
--cyan: #22d3ee;
--amber: #f59e0b;
--red: #ef4444;
--green: #34d399;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
#hud { position: fixed; inset: 0; pointer-events: none; color: var(--text); font-size: 12px; }
#hud .panel { background: var(--panel); border: 1px solid var(--edge); border-radius: 10px; pointer-events: auto; }
#topbar {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
display: flex; gap: 18px; align-items: center; padding: 8px 16px; white-space: nowrap;
}
#topbar .stat { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
#topbar .stat .label { color: var(--dim); font-size: 9px; letter-spacing: 0.08em; }
#topbar .stat .val { font-size: 14px; font-weight: 700; }
#cash { color: var(--green); font-size: 18px !important; }
#income.neg, #watts.hot, #temp.hot { color: var(--red); }
.bar { width: 90px; height: 5px; background: #1c1c2e; border-radius: 3px; overflow: hidden; }
.bar > div { height: 100%; background: var(--amber); width: 0%; transition: width 0.2s; }
.bar.train > div { background: var(--purple); }
#breaker-reset {
display: none; background: var(--red); color: #fff; border: 0; border-radius: 6px;
padding: 6px 10px; font: inherit; font-weight: 700; cursor: pointer; animation: flash 0.6s infinite alternate;
}
@keyframes flash { from { opacity: 1; } to { opacity: 0.55; } }
#controls { position: absolute; top: 10px; left: 10px; display: flex; flex-direction: column; gap: 6px; padding: 10px; }
.chip {
background: #16162a; border: 1px solid var(--edge); color: var(--dim); border-radius: 6px;
padding: 5px 9px; cursor: pointer; font: inherit; text-align: left;
}
.chip.on { color: var(--text); border-color: var(--purple); background: #221a3a; }
#alloc-row { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
#alloc { width: 130px; accent-color: var(--purple); }
#shop {
position: absolute; right: 10px; top: 10px; bottom: 10px; width: 195px;
padding: 10px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;
}
#shop h3 { margin: 0 0 2px; font-size: 11px; color: var(--dim); letter-spacing: 0.15em; }
.shop-item {
display: flex; gap: 8px; align-items: center; border: 1px solid var(--edge); border-radius: 8px;
padding: 6px; cursor: pointer; background: #12121f;
}
.shop-item:hover { border-color: var(--cyan); }
.shop-item.selected { border-color: var(--green); background: #12281f; }
.shop-item.dim { opacity: 0.45; cursor: default; }
.shop-item img { width: 40px; height: 40px; object-fit: contain; }
.shop-item .nm { font-weight: 700; font-size: 11px; }
.shop-item .cost { color: var(--green); font-size: 11px; }
.shop-item .lock { color: var(--dim); font-size: 10px; }
#feed {
position: absolute; left: 10px; bottom: 10px; width: 320px; max-height: 46vh;
padding: 10px; display: flex; flex-direction: column; gap: 6px; overflow: hidden;
}
#feed h3 { margin: 0; font-size: 11px; color: var(--dim); letter-spacing: 0.15em; }
#feed h3 b { color: var(--red); }
.vid { display: flex; gap: 8px; align-items: center; }
.vid img { width: 76px; height: 44px; object-fit: cover; border-radius: 5px; }
.vid .t { font-size: 10.5px; line-height: 1.25; }
.vid .v { color: var(--dim); font-size: 10px; }
.vid .v b.viral { color: var(--amber); }
#toasts { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; align-items: center; }
.toast { padding: 8px 14px; border-radius: 8px; font-weight: 700; animation: rise 4s forwards; }
.toast.bad { background: #3a1214; border: 1px solid var(--red); color: #ffb4b4; }
.toast.good { background: #12312a; border: 1px solid var(--green); color: #a7f3d0; }
.toast.info { background: #16162a; border: 1px solid var(--edge); color: var(--dim); }
@keyframes rise { 0% { opacity: 0; transform: translateY(8px); } 8% { opacity: 1; transform: none; } 80% { opacity: 1; } 100% { opacity: 0; } }
#hint { position: absolute; bottom: 12px; right: 215px; color: var(--dim); font-size: 11px; text-align: right; }
#name-modal { position: fixed; inset: 0; background: rgba(5, 5, 10, 0.85); display: flex; align-items: center; justify-content: center; pointer-events: auto; }
#name-box { padding: 28px 32px; text-align: center; width: 360px; }
#name-box h2 { margin: 0 0 4px; letter-spacing: 0.3em; color: var(--purple); }
#name-box p { color: var(--dim); margin: 0 0 16px; }
#name-box .row { display: flex; gap: 6px; margin-bottom: 14px; }
#name-box input { flex: 1; background: #0c0c16; border: 1px solid var(--edge); color: var(--text); font: inherit; font-size: 14px; padding: 8px 10px; border-radius: 6px; }
#name-box #reroll { background: #16162a; border: 1px solid var(--edge); border-radius: 6px; font-size: 16px; cursor: pointer; padding: 0 12px; }
#name-box #cstart { width: 100%; background: var(--purple); color: #fff; border: 0; border-radius: 8px; padding: 10px; font: inherit; font-weight: 700; letter-spacing: 0.08em; cursor: pointer; }
#name-box #cstart:hover { filter: brightness(1.15); }

211
src/ui/hud.ts Normal file
View File

@ -0,0 +1,211 @@
import "./hud.css";
import { BAL } from "../sim/balance";
import { SimState } from "../sim/state";
import { shopList, resetBreaker } from "../sim/sim";
import { sfx, setMuted, isMuted } from "./sfx";
import TITLES from "../data/sloptitles.json";
export interface HudCallbacks {
onSelectShop: (defId: string | null) => void;
onToggleOverlay: (which: "heat" | "power" | "data" | "grid") => void;
onWireTool: () => void;
onAlloc: (trainShare: number) => void;
onNewGame: () => void;
}
const NAME_A = ["Slop", "Grift", "Chungus", "Synergy", "Vibe", "Goon", "Brainrot", "Yeet", "Chud", "Slud", "Content", "Engagement"];
const NAME_B = ["Works", "Labs", "Dynamics", "Compute", "Industries", "Cloud", "Farms", "Global", "Forge", "Mines", "Refinery"];
const NAME_C = ["", " Inc.", ".ai", " LLC", " Unlimited", " & Sons"];
export function randomCompanyName(): string {
const pick = (a: string[]) => a[Math.floor(Math.random() * a.length)];
return pick(NAME_A) + pick(NAME_B) + pick(NAME_C);
}
const fmt = (n: number, d = 2) =>
n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "K" : n.toFixed(d);
export class Hud {
root: HTMLElement;
cb: HudCallbacks;
selectedShop: string | null = null;
lastEventCount = 0;
lockedCache = "";
constructor(cb: HudCallbacks) {
this.cb = cb;
this.root = document.createElement("div");
this.root.id = "hud";
this.root.innerHTML = `
<div id="topbar" class="panel">
<div class="stat"><span class="label" id="company">SLOPCO</span><span class="val" id="cash">$0</span></div>
<div class="stat"><span class="label">NET/S</span><span class="val" id="income">+$0.00</span></div>
<div class="stat"><span class="label">POWER</span><span class="val" id="watts">0W</span><div class="bar"><div id="wattbar"></div></div></div>
<div class="stat"><span class="label">HOTTEST</span><span class="val" id="temp">24°C</span></div>
<div class="stat"><span class="label">SLOP RATE</span><span class="val" id="tok">0 tok/s</span></div>
<div class="stat"><span class="label">UPLINK</span><span class="val" id="net">0/0 Mbps</span></div>
<div class="stat"><span class="label">MODEL</span><span class="val" id="tier">SlopLM-1B</span><div class="bar train"><div id="trainbar"></div></div></div>
<button id="breaker-reset">RESET BREAKER</button>
</div>
<div id="controls" class="panel">
<button class="chip" data-ov="heat">🌡 Heat</button>
<button class="chip" data-ov="power"> Power</button>
<button class="chip" data-ov="data">📶 Data</button>
<button class="chip" data-ov="grid"> Grid</button>
<button class="chip" id="wire-tool">🔌 Wire (W)</button>
<button class="chip" id="mute">${isMuted() ? "🔇 Muted" : "🔊 Sound"}</button>
<button class="chip" id="new-game">🗑 New Game</button>
<div id="alloc-row">
<span class="label" style="color:var(--dim);font-size:9px">TRAIN <b id="allocpct">25</b>% / INFER</span>
<input id="alloc" type="range" min="0" max="90" value="25" />
</div>
</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>
<div id="hint">click store item, place in room · W: wire PCrouter · right-click: sell · Esc: cancel</div>
<div id="toasts"></div>`;
document.body.appendChild(this.root);
this.root.querySelectorAll<HTMLButtonElement>("[data-ov]").forEach((b) => {
b.onclick = () => { cb.onToggleOverlay(b.dataset.ov as never); b.classList.toggle("on"); };
if (b.dataset.ov === "heat") b.classList.add("on");
});
(this.root.querySelector("#wire-tool") as HTMLButtonElement).onclick = () => cb.onWireTool();
const muteBtn = this.root.querySelector("#mute") as HTMLButtonElement;
muteBtn.onclick = () => {
setMuted(!isMuted());
muteBtn.textContent = isMuted() ? "🔇 Muted" : "🔊 Sound";
if (!isMuted()) sfx.click();
};
(this.root.querySelector("#new-game") as HTMLButtonElement).onclick = () => {
if (confirm("Torch the whole company and start over?")) cb.onNewGame();
};
const alloc = this.root.querySelector("#alloc") as HTMLInputElement;
alloc.oninput = () => {
(this.root.querySelector("#allocpct") as HTMLElement).textContent = alloc.value;
cb.onAlloc(Number(alloc.value) / 100);
};
}
bindBreaker(state: SimState) {
(this.root.querySelector("#breaker-reset") as HTMLButtonElement).onclick = () => resetBreaker(state);
}
setWireActive(on: boolean) {
(this.root.querySelector("#wire-tool") as HTMLElement).classList.toggle("on", on);
}
refreshShop(s: SimState) {
const items = shopList(s);
const cacheKey = items.map((i) => `${i.locked}${i.affordable}`).join() + this.selectedShop;
if (cacheKey === this.lockedCache) return;
this.lockedCache = cacheKey;
const shop = this.root.querySelector("#shop") as HTMLElement;
shop.querySelectorAll(".shop-item").forEach((e) => e.remove());
for (const it of items) {
const el = document.createElement("div");
el.className = "shop-item" + (it.locked || !it.affordable ? " dim" : "") +
(this.selectedShop === it.def.id ? " selected" : "");
el.innerHTML = `<img src="/assets/gen/${it.def.sprite}_cut.png" onerror="this.style.visibility='hidden'"/>
<div><div class="nm">${it.def.name}</div>
${it.locked ? `<div class="lock">🔒 earn $${it.def.unlockAt}</div>` : `<div class="cost">$${it.def.cost}</div>`}</div>`;
el.title = it.def.desc;
if (!it.locked) el.onclick = () => {
this.selectedShop = this.selectedShop === it.def.id ? null : it.def.id;
this.cb.onSelectShop(this.selectedShop);
this.lockedCache = "";
};
shop.appendChild(el);
}
}
/** modal for naming the company on a fresh game; resolves with the chosen name */
showNameModal(): Promise<string> {
return new Promise((resolve) => {
const m = document.createElement("div");
m.id = "name-modal";
m.innerHTML = `
<div class="panel" id="name-box">
<h2>GIGASLOP</h2>
<p>Every empire of garbage needs a name.</p>
<div class="row">
<input id="cname" maxlength="28" value="${randomCompanyName()}" spellcheck="false"/>
<button id="reroll" title="reroll">🎲</button>
</div>
<button id="cstart">INCORPORATE </button>
</div>`;
this.root.appendChild(m);
const input = m.querySelector("#cname") as HTMLInputElement;
(m.querySelector("#reroll") as HTMLButtonElement).onclick = () => {
input.value = randomCompanyName();
sfx.click();
};
const done = () => {
const name = input.value.trim() || "SlopCo";
m.remove();
sfx.tierUp();
resolve(name);
};
(m.querySelector("#cstart") as HTMLButtonElement).onclick = done;
input.onkeydown = (e) => { if (e.key === "Enter") done(); e.stopPropagation(); };
input.focus();
input.select();
});
}
private prevVideoCount = -1;
update(s: SimState) {
const q = (id: string) => this.root.querySelector(id) as HTMLElement;
q("#company").textContent = s.company.toUpperCase();
q("#cash").textContent = "$" + fmt(s.cash);
const net = s.rates.income - s.rates.powerCost;
q("#income").textContent = (net >= 0 ? "+$" : "-$") + fmt(Math.abs(net), 3);
q("#income").classList.toggle("neg", net < 0);
q("#watts").textContent = `${Math.round(s.rates.watts)}/${s.rates.breakerWatts}W`;
q("#watts").classList.toggle("hot", s.rates.watts > s.rates.breakerWatts * 0.85);
(q("#wattbar") as HTMLElement).style.width =
Math.min(100, (s.rates.watts / s.rates.breakerWatts) * 100) + "%";
(q("#wattbar") as HTMLElement).style.background =
s.rates.watts > s.rates.breakerWatts * 0.85 ? "var(--red)" : "var(--amber)";
q("#temp").textContent = Math.round(s.rates.maxTemp) + "°C";
q("#temp").classList.toggle("hot", s.rates.maxTemp > BAL.THROTTLE_C);
q("#tok").textContent = fmt(s.rates.tokEff, 1) + " tok/s";
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("#tier").textContent = BAL.TIERS[s.tier].name;
const nt = BAL.TIERS[s.tier + 1];
(q("#trainbar") as HTMLElement).style.width = nt ? Math.min(100, (s.trainTokens / nt.tokens!) * 100) + "%" : "100%";
(q("#breaker-reset") as HTMLElement).style.display = s.breakerTripped ? "block" : "none";
// feed: newest 6 videos
const vids = q("#vids");
const latest = s.videos.slice(-6).reverse();
vids.innerHTML = latest.map((v) => `
<div class="vid">
<img src="/assets/slop/slop_${String(v.thumb).padStart(2, "0")}.png"/>
<div><div class="t">${(TITLES as string[])[v.title] ?? "slop"}</div>
<div class="v">👁 ${fmt(v.views, 0)} views ${v.viral > 6 ? '<b class="viral">🔥VIRAL</b>' : ""}${v.dead ? " · faded" : ""}</div></div>
</div>`).join("");
// publish blips; first update after boot swallows the loaded-save backlog silently
if (this.prevVideoCount === -1) this.lastEventCount = s.events.length;
if (this.prevVideoCount >= 0 && s.videos.length > this.prevVideoCount) sfx.publish();
this.prevVideoCount = s.videos.length;
// toasts + event sounds
const toasts = q("#toasts");
for (const ev of s.events.slice(this.lastEventCount)) {
if (ev.kind === "bad") sfx.breaker();
else if (ev.kind === "good") ev.msg.includes("TIER UP") ? sfx.tierUp() : sfx.viral();
if (ev.kind === "buy") continue;
const t = document.createElement("div");
t.className = "toast " + ev.kind;
t.textContent = ev.msg;
toasts.appendChild(t);
setTimeout(() => t.remove(), 4200);
}
this.lastEventCount = s.events.length;
this.refreshShop(s);
}
}

63
src/ui/sfx.ts Normal file
View File

@ -0,0 +1,63 @@
// Tiny WebAudio synth — no asset files. All sounds built from oscillators/noise.
let ctx: AudioContext | null = null;
let muted = localStorage.getItem("gigaslop-muted") === "1";
function ac(): AudioContext | null {
if (muted) return null;
if (!ctx) ctx = new AudioContext();
if (ctx.state === "suspended") ctx.resume();
return ctx;
}
export function setMuted(m: boolean) {
muted = m;
localStorage.setItem("gigaslop-muted", m ? "1" : "0");
}
export function isMuted() {
return muted;
}
function tone(freq: number, dur: number, type: OscillatorType, vol: number, when = 0, glideTo?: number) {
const c = ac();
if (!c) return;
const t0 = c.currentTime + when;
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t0);
if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t0 + dur);
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g).connect(c.destination);
o.start(t0);
o.stop(t0 + dur + 0.02);
}
function noise(dur: number, vol: number, lowpass = 800) {
const c = ac();
if (!c) return;
const buf = c.createBuffer(1, c.sampleRate * dur, c.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
const src = c.createBufferSource();
src.buffer = buf;
const f = c.createBiquadFilter();
f.type = "lowpass";
f.frequency.value = lowpass;
const g = c.createGain();
g.gain.value = vol;
src.connect(f).connect(g).connect(c.destination);
src.start();
}
export const sfx = {
click: () => tone(880, 0.05, "square", 0.04),
place: () => { noise(0.12, 0.25, 500); tone(140, 0.1, "sine", 0.15); },
sell: () => tone(520, 0.12, "triangle", 0.08, 0, 260),
wire: () => { tone(660, 0.06, "square", 0.05); tone(990, 0.06, "square", 0.05, 0.07); },
publish: () => { tone(523, 0.08, "sine", 0.07); tone(784, 0.12, "sine", 0.07, 0.08); },
viral: () => [523, 659, 784, 1047, 1319].forEach((f, i) => tone(f, 0.18, "triangle", 0.09, i * 0.07)),
tierUp: () => [392, 523, 659, 784].forEach((f, i) => tone(f, 0.3, "sawtooth", 0.05, i * 0.12)),
breaker: () => { noise(0.4, 0.5, 250); tone(80, 0.5, "sawtooth", 0.2, 0, 35); },
error: () => tone(180, 0.15, "square", 0.06),
};

185
tools/mb_gen.py Normal file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Batch-generate GigaSlop art on MODELBEAST (flux_local + bg_remove_local).
Contract per ~/Documents/MESHGOD/scripts/mb_recon.py and thriftgod/gen_assets.py:
multipart upload to /api/assets (field "file"); jobs take {operator, asset_id, params};
outputs found by scanning /api/assets for parent_job (jobs don't back-link).
Usage: python3 tools/mb_gen.py [--only room|objects|slop]
Writes PNGs to public/assets/{gen,slop}/, manifest to tools/mb_manifest.json.
Heartbeats to ~/.jobs/gigaslop-artgen.status.
"""
import hashlib, json, mimetypes, os, pathlib, sys, time, urllib.request, uuid
HOST = "http://100.89.131.57:8777"
ROOT = pathlib.Path(__file__).resolve().parent.parent
HEART = pathlib.Path.home() / ".jobs" / "gigaslop-artgen.status"
MAX_ACTIVE = 3 # guest cap is 4; leave headroom
def token():
for line in (pathlib.Path.home() / "Documents/backnforth/.env").read_text().splitlines():
if line.startswith("MB_TOKEN="):
return line.split("=", 1)[1].strip()
raise SystemExit("MB_TOKEN not found")
TOKEN = token()
def api(path, payload=None, data=None, headers=None, raw=False):
h = {"Authorization": "Bearer " + TOKEN}
if payload is not None:
data = json.dumps(payload).encode()
h["Content-Type"] = "application/json"
h.update(headers or {})
r = urllib.request.Request(HOST + path, data=data, headers=h)
body = urllib.request.urlopen(r, timeout=180).read()
if raw:
return body
s = body.decode("utf-8", "replace")
return json.loads("".join(c if c >= " " or c in "\t" else " " for c in s))
def upload(path):
boundary = uuid.uuid4().hex
ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
name = os.path.basename(path)
body = (
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f"Content-Type: {ctype}\r\n\r\n"
).encode() + pathlib.Path(path).read_bytes() + f"\r\n--{boundary}--\r\n".encode()
a = api("/api/assets", data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
return a.get("id") or (a.get("items") or [a])[0].get("id")
def seed_for(slug):
return int(hashlib.sha1(slug.encode()).hexdigest()[:7], 16)
def beat(msg):
HEART.parent.mkdir(exist_ok=True)
HEART.write_text(json.dumps({"ts": time.strftime("%F %T"), "msg": msg}) + "\n")
print(msg, flush=True)
def asset_for_job(jid):
items = api("/api/assets")
if isinstance(items, dict):
items = items.get("items", [])
mine = [a for a in items if a.get("parent_job") == jid]
return mine[0] if mine else None
def run_batch(specs, outdir):
"""specs: list of (label, operator, asset_id, params). Throttled, unordered completion."""
outdir.mkdir(parents=True, exist_ok=True)
results, queue, active = {}, list(specs), {}
while queue or active:
while queue and len(active) < MAX_ACTIVE:
label, op, aid, params = queue.pop(0)
jid = api("/api/jobs", {"operator": op, "asset_id": aid, "params": params})["id"]
active[jid] = label
beat(f"submitted {label} -> job {jid} ({len(queue)} queued)")
time.sleep(3)
for jid in list(active):
st = api(f"/api/jobs/{jid}").get("status")
if st in ("done", "error", "cancelled"):
label = active.pop(jid)
if st != "done":
beat(f"FAILED {label}: {st}")
continue
a = asset_for_job(jid)
if not a:
beat(f"done {label} but no asset for job {jid}")
continue
data = api(f"/api/assets/{a['id']}/file", raw=True)
path = outdir / f"{label}.png"
path.write_bytes(data)
results[label] = {"job": jid, "asset": a["id"], "path": str(path)}
beat(f"done {label} ({len(data)//1024} KB)")
return results
STYLE = (
"isometric video game asset, 2:1 isometric angle viewed from above at 45 degrees, "
"dark moody lighting with neon accent glow, detailed painterly style, crisp edges, "
"centered single object on plain solid dark grey background, no text, "
)
OBJECTS = [
("desk_pc", "a cheap beige-and-black budget gaming PC tower with one small GPU, side panel off, dusty, one RGB fan"),
("desk", "a cluttered small wooden computer desk with monitor, keyboard, energy drink cans, tangled cables"),
("router", "a consumer wifi router with antennas, blinking green LEDs"),
("desk_fan", "a cheap plastic oscillating desk fan"),
("gpu_rig", "a DIY open-air mining rig frame made of milk crates holding four mismatched GPUs, tangled riser cables"),
("window_ac", "a battered window air conditioning unit dripping slightly"),
("power_strip", "an overloaded power strip surge protector with too many plugs, slightly scorched"),
("server_rack_mini", "a small half-height 19 inch server rack with a few rack servers, status LEDs"),
]
SLOP = [
"hyperrealistic cat wearing a business suit crying in the rain, shocked face, red arrow, oversaturated",
"muscular baby bodybuilder lifting a car, impossible anatomy, oversaturated colors",
"two podcast hosts yelling at each other across a table, exaggerated shocked expressions, red arrows",
"ancient roman emperor playing a video game on a glowing gaming PC, dramatic lighting",
"infinite spiral of golden retrievers wearing sunglasses on a beach, uncanny, oversaturated",
"a submarine made of watermelons in the ocean, shocked scuba diver pointing, oversaturated",
"grandma arm wrestling a robot in a kitchen, sparks flying, exaggerated expressions",
"hyperrealistic shark bursting out of a swimming pool at a birthday party, red circle",
"medieval knight reviewing fast food fries, uncanny smile, bright arrows",
"city skyline made entirely of pasta at sunset, tiny cars, oversaturated dreamlike",
]
SLOP_STYLE = "AI generated clickbait youtube thumbnail, "
ROOM = (
"empty isometric bedroom interior for a video game, 2:1 isometric projection, two visible walls "
"meeting at back corner, dark wooden floor with subtle grid tiles, night time, window with city "
"lights, moody blue-purple lighting, one neon strip, no furniture, no people, no text, "
"clean detailed painterly game art"
)
def flux_spec(label, prompt, w, h):
return (label, "flux_local", None,
{"prompt": prompt, "model": "flux2-klein-4b", "steps": 4,
"width": w, "height": h, "seed": seed_for(label)})
def main():
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else None
manifest = {}
gen, slop = ROOT / "public/assets/gen", ROOT / "public/assets/slop"
if only in (None, "room"):
beat("phase: room shell")
manifest.update(run_batch([flux_spec("room", ROOM, 1024, 768)], gen))
if only in (None, "objects"):
beat("phase: object sprites")
raw = run_batch([flux_spec(n, STYLE + d, 768, 768) for n, d in OBJECTS], gen)
manifest.update(raw)
beat("phase: bg removal")
cut_specs = []
for name, info in raw.items():
aid = upload(info["path"])
cut_specs.append((f"{name}_cut", "bg_remove_local", aid,
{"resolution": 1024, "background": "transparent"}))
manifest.update(run_batch(cut_specs, gen))
if only in (None, "slop"):
beat("phase: slop thumbnails")
manifest.update(run_batch(
[flux_spec(f"slop_{i:02d}", SLOP_STYLE + p, 640, 384) for i, p in enumerate(SLOP)],
slop))
mpath = ROOT / "tools/mb_manifest.json"
old = json.loads(mpath.read_text()) if mpath.exists() else {}
old.update(manifest)
mpath.write_text(json.dumps(old, indent=2))
beat(f"ALL DONE — {len(manifest)} assets this run")
if __name__ == "__main__":
main()

137
tools/mb_manifest.json Normal file
View File

@ -0,0 +1,137 @@
{
"room": {
"job": "c4d4d939f089",
"asset": "d4b56e857e82",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/room.png"
},
"desk_pc": {
"job": "583451ed8b87",
"asset": "b1cfdad33419",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/desk_pc.png"
},
"desk": {
"job": "708b3acc32f6",
"asset": "bfacb6bcb5f0",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/desk.png"
},
"router": {
"job": "f01ce240ed5e",
"asset": "75283c961e0e",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/router.png"
},
"desk_fan": {
"job": "88acdeb33e05",
"asset": "47a05c125806",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/desk_fan.png"
},
"gpu_rig": {
"job": "fdeb8a673408",
"asset": "27bf44b1db90",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/gpu_rig.png"
},
"window_ac": {
"job": "353af81d4e97",
"asset": "9b7a6130eb21",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/window_ac.png"
},
"power_strip": {
"job": "a7e41642ce0b",
"asset": "c342b2c2446b",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/power_strip.png"
},
"server_rack_mini": {
"job": "cb2411c7a616",
"asset": "4a01585d28a4",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/server_rack_mini.png"
},
"desk_pc_cut": {
"job": "3bc8a10263ed",
"asset": "eb5ef4ad9cb6",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/desk_pc_cut.png"
},
"router_cut": {
"job": "76a87ec5f731",
"asset": "f359577f2124",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/router_cut.png"
},
"desk_cut": {
"job": "af49f81c3554",
"asset": "553d4a761d06",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/desk_cut.png"
},
"desk_fan_cut": {
"job": "0e976e0d3813",
"asset": "dfadeec26ff8",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/desk_fan_cut.png"
},
"gpu_rig_cut": {
"job": "4e12eb775a50",
"asset": "0e83e89880fc",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/gpu_rig_cut.png"
},
"window_ac_cut": {
"job": "dbbab41ba9cd",
"asset": "7716c7065247",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/window_ac_cut.png"
},
"power_strip_cut": {
"job": "4d634070e62d",
"asset": "f5376c9db4f1",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/power_strip_cut.png"
},
"server_rack_mini_cut": {
"job": "ab0fa5331c4c",
"asset": "997b8e88b51e",
"path": "/Users/jing/Documents/gigaslop/public/assets/gen/server_rack_mini_cut.png"
},
"slop_00": {
"job": "701ba1dd3ef3",
"asset": "45bf5020eb3e",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_00.png"
},
"slop_01": {
"job": "73dd75b027f2",
"asset": "faa0fb8e1a88",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_01.png"
},
"slop_02": {
"job": "f45c0a0f1263",
"asset": "06f3c835074e",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_02.png"
},
"slop_03": {
"job": "c0740801b9a7",
"asset": "f9a186a9d2da",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_03.png"
},
"slop_04": {
"job": "4b137bf240b4",
"asset": "1c695542b4ad",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_04.png"
},
"slop_05": {
"job": "1eeefd33eb53",
"asset": "591dd2a150bc",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_05.png"
},
"slop_06": {
"job": "eb2f42d89793",
"asset": "f97f2d6bbcf5",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_06.png"
},
"slop_07": {
"job": "2490169e5b10",
"asset": "384fb40951a0",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_07.png"
},
"slop_08": {
"job": "4460e78f6521",
"asset": "36af3443a3d2",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_08.png"
},
"slop_09": {
"job": "6f2f27867c46",
"asset": "bd5a265f6ede",
"path": "/Users/jing/Documents/gigaslop/public/assets/slop/slop_09.png"
}
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}

6
vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from "vite";
export default defineConfig({
server: { port: 5173, strictPort: true },
build: { target: "es2022" },
});