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>
11
.claude/launch.json
Normal 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
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
34
CLAUDE.md
Normal 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 3–5, immersion cooling, offshore ships, lobbying.
|
||||||
45
README.md
Normal 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
@ -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
18
package.json
Normal 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
|
After Width: | Height: | Size: 397 KiB |
BIN
public/assets/gen/desk_cut.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
public/assets/gen/desk_fan.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
public/assets/gen/desk_fan_cut.png
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
public/assets/gen/desk_pc.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
public/assets/gen/desk_pc_cut.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
public/assets/gen/gpu_rig.png
Normal file
|
After Width: | Height: | Size: 560 KiB |
BIN
public/assets/gen/gpu_rig_cut.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
public/assets/gen/power_strip.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
public/assets/gen/power_strip_cut.png
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
public/assets/gen/room.png
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
public/assets/gen/router.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
public/assets/gen/router_cut.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
public/assets/gen/server_rack_mini.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
public/assets/gen/server_rack_mini_cut.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
public/assets/gen/window_ac.png
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
public/assets/gen/window_ac_cut.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
public/assets/slop/slop_00.png
Normal file
|
After Width: | Height: | Size: 503 KiB |
BIN
public/assets/slop/slop_01.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
public/assets/slop/slop_02.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
public/assets/slop/slop_03.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
public/assets/slop/slop_04.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
BIN
public/assets/slop/slop_05.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
public/assets/slop/slop_06.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
public/assets/slop/slop_07.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
public/assets/slop/slop_08.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
public/assets/slop/slop_09.png
Normal file
|
After Width: | Height: | Size: 447 KiB |
111
src/data/sloptitles.json
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 PC→router · 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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: { port: 5173, strictPort: true },
|
||||||
|
build: { target: "es2022" },
|
||||||
|
});
|
||||||