M5: named model line, API hosting market, distillation, scraping ops

You name your model at incorporation and release versions 1/1.5/2/3,
shipping each in mini/pro/MAX grades from the training bank (volume vs
margin vs prestige). Compute now splits three ways: slop, training, and
API hosting. A rotating market of third-party models (DeepSlop-236B,
Kimi-Chi-7B, SloPangu-Air...) offers hosting margins that crater ~28%
every time a hot new model drops. Hosting your own grades pays premium
margin and +10% views for MAX — but rolls distillation: a rival lab
clones your weights and guts that grade's margin until you re-ship it
on a newer version. Scraping ops cash ladder (puppeteer scripts →
proxy pool → residential botnet → captcha farm) buys training-rate
bonuses and immunity against escalating IP-ban outages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-01 11:12:17 +10:00
parent c3d57e09b3
commit bd8b84b332
7 changed files with 371 additions and 25 deletions

View File

@ -54,9 +54,19 @@ Date.now/Math.random inside sim).
Regulatory Capture (lawsuit immune, 2x adapt decay).
• Sockets: click device → inspector panel (stats, on/off, sell, 2 upgrade
slots: fans/undervolt/risers).
→ M5 candidates: hazards (thermal runaway fire), Tier 2 garage move
(bigger room = new backdrop + grid refit), subscribers/SaaS second
revenue stream with churn, endgame Conveyor visual (belt sprite).
✅ M5 model line + API market: player names their model at incorporation
(versions 1/1.5/2/3 = the old tier ladder); ship grades mini/pro/MAX
from the training bank (margin/demand/distill-risk per grade, GRADES in
research.ts); 3-way compute split train/host/slop (allocHosting);
rotating third-party market (rollMarketModel), hot drops crater
incumbent margins ~28%; hosting your own grade rolls distillation
(margin cut 60% that version — re-ship on a newer version to restore
the moat); scraping ops cash ladder (puppeteer→proxies→botnet→captcha)
vs severity-1..3 IP-ban outages. Market panel = M key.
→ M6 backlog: ad-spend slider + email-spam campaign buttons (one-shot
view bursts w/ ban risk), ACTIVE scraping minigame, hazards (thermal
runaway fire), Tier 2 garage move, subscribers/SaaS churn revenue,
Conveyor belt visual.
Design pillars (John-approved framing): Throughput, Allocation, Attention,
Capital, Expansion. Macro layer (Tier 4-5) = strategic map, NOT city-builder.
Cut until earned: Tiers 35, immersion cooling, offshore ships, lobbying.

View File

@ -1,6 +1,6 @@
import { BAL } from "./sim/balance";
import { createState, pushEvent } from "./sim/state";
import { step, place, canPlace, wire, sell, grant, raiseRound, clapBack } from "./sim/sim";
import { step, place, canPlace, wire, sell, grant, raiseRound, clapBack, shipGrade, switchHosting, unlockScrape } from "./sim/sim";
import { saveState, loadState, clearSave } from "./sim/save";
import { DEF } from "./sim/catalog";
import { Scene } from "./render/scene";
@ -35,6 +35,7 @@ async function boot() {
hud.setWireActive(wireMode);
},
onAlloc: (v) => { state.allocTraining = v; },
onAllocHost: (v) => { state.allocHosting = v; },
onNewGame: () => { wiped = true; clearSave(); location.reload(); },
onRaise: () => {
const err = raiseRound(state);
@ -49,6 +50,7 @@ async function boot() {
const panels = new Panels(state);
(hud.root.querySelector("#lab-btn") as HTMLButtonElement).onclick = () => panels.toggle("lab");
(hud.root.querySelector("#rnd-btn") as HTMLButtonElement).onclick = () => panels.toggle("rnd");
(hud.root.querySelector("#market-btn") as HTMLButtonElement).onclick = () => panels.toggle("market");
const cancel = () => {
shopSel = null; hud.selectedShop = null; hud.lockedCache = "";
@ -63,6 +65,7 @@ async function boot() {
}
if (e.key === "l" || e.key === "L") panels.toggle("lab");
if (e.key === "r" || e.key === "R") panels.toggle("rnd");
if (e.key === "m" || e.key === "M") panels.toggle("market");
});
scene.app.canvas.addEventListener("pointermove", (e) => {
@ -155,9 +158,11 @@ async function boot() {
grant(state, "desk", 5, 7);
grant(state, "desk_pc", 7, 7);
grant(state, "router", 2, 1);
state.company = await hud.showNameModal();
const names = await hud.showNameModal();
state.company = names.company;
state.modelName = names.model;
document.title = `${state.company} — GigaSlop`;
pushEvent(state, `${state.company} is live. Press W and wire the PC to the router.`, "info");
pushEvent(state, `${state.company} is live. ${state.modelName} 1 awaits its slop. Wire the PC to the router (W).`, "info");
saveState(state);
} else {
document.title = `${state.company} — GigaSlop`;
@ -186,6 +191,9 @@ async function boot() {
data: (k: DataSource) => setDataSource(state, k),
rnd: (id: string) => unlockRnd(state, id),
upg: (uid: number, id: string) => buyUpgrade(state, uid, id),
ship: (g: "mini" | "pro" | "max") => shipGrade(state, g),
host: (id: number | null) => switchHosting(state, id),
scrape: (id: string) => unlockScrape(state, id),
};
}

View File

@ -123,6 +123,79 @@ export const RND: RndNode[] = [
mods: { lawsuitImmune: true, adaptDecayMult: 2 } },
];
// ---------- Model grades (ship a version in up to three sizes) ----------
export interface GradeDef {
id: "mini" | "pro" | "max";
suffix: string;
costMult: number; // × the version's training cost to ship
margin: number; // $/token when hosted
demand: number; // tok/s the market will buy
distillChance: number; // per distill-roll while hosted
hypeMult?: number; // MAX carries prestige
}
export const GRADES: GradeDef[] = [
{ id: "mini", suffix: "-mini", costMult: 0.25, margin: 0.0008, demand: 220, distillChance: 0.15 },
{ id: "pro", suffix: "-pro", costMult: 0.5, margin: 0.002, demand: 90, distillChance: 0.3 },
{ id: "max", suffix: "-MAX", costMult: 0.9, margin: 0.005, demand: 35, distillChance: 0.5, hypeMult: 1.1 },
];
export const VERSION_NAMES = ["1", "1.5", "2", "3"];
export const DISTILL_MARGIN_CUT = 0.4; // distilled grade keeps 40% margin (this version)
// ---------- Scraping ops (cash-paid data acquisition ladder) ----------
export interface ScrapeNode {
id: string;
name: string;
cash: number;
desc: string;
requires?: string;
scrapedRateAdd: number; // additive bonus to scraped training rate
banLevel: number; // blocks IP-ban events of severity <= banLevel
}
export const SCRAPE_OPS: ScrapeNode[] = [
{ id: "puppeteer", name: "Shitty Puppeteer Scripts", cash: 100, scrapedRateAdd: 0.1, banLevel: 0,
desc: "headless-chrome:latest. Scraped training +10%. Gets IP banned constantly." },
{ id: "proxy_pool", name: "Proxy Pool", cash: 400, requires: "puppeteer", scrapedRateAdd: 0.1, banLevel: 1,
desc: "1,000 datacenter IPs. +10% more, shrugs off basic bans." },
{ id: "resi_botnet", name: "Residential Botnet", cash: 1500, requires: "proxy_pool", scrapedRateAdd: 0.25, banLevel: 2,
desc: "Someone's smart fridge is scraping Reddit for you. +25%, survives serious bans." },
{ id: "captcha_farm", name: "Captcha Farm", cash: 4000, requires: "resi_botnet", scrapedRateAdd: 0.4, banLevel: 3,
desc: "Click the crosswalks, all of them, forever. +40%, effectively unbannable." },
];
// ---------- The API hosting market ----------
export interface MarketModel {
id: number;
name: string;
flag: string;
margin: number; // $/tok
demand: number; // tok/s cap
born: number;
yours?: "mini" | "pro" | "max"; // set when this row is one of your shipped grades
}
const LAB_NAMES = ["Qwoon", "DeepSlop", "Kimi-Chi", "GLM-Slop", "Yi-Haw", "Baidoodle", "MoonSlop", "Zhipoo", "StepSlop", "MiniMaxx", "Doubaozled", "SloPangu"];
const LAB_SIZES = ["-7B", "-72B", "-236B", "-R2", "-Turbo", "-VL", "-Air", "-Ω"];
const LAB_FLAGS = ["🇨🇳", "🇨🇳", "🇨🇳", "🇨🇳", "🇺🇸", "🇫🇷", "🇰🇷"];
export function rollMarketModel(rand: () => number, id: number, t: number): MarketModel {
return {
id,
name: LAB_NAMES[Math.floor(rand() * LAB_NAMES.length)] + LAB_SIZES[Math.floor(rand() * LAB_SIZES.length)],
flag: LAB_FLAGS[Math.floor(rand() * LAB_FLAGS.length)],
margin: 0.0008 + rand() * 0.0017,
demand: 60 + Math.floor(rand() * 240),
born: t,
};
}
export const MODEL_NAME_POOL = ["Goonita", "SlopGPT", "Chudini", "BrainrotNet", "YapLM", "GigaYap", "Sloppo", "DerpSeek", "Goonami", "Sludge", "Chunguscore", "GoonDiffusion"];
// ---------- Device socket upgrades ----------
export interface SocketUpgrade {

View File

@ -4,6 +4,7 @@ import { Device, SimState, pushEvent, rand } from "./state";
import {
computeMods, deviceMods, MODALITIES, SPECS, SPEC_COST_BY_GEN,
DATA_SOURCES, COLLAPSE_CHANCE, RND, SOCKETS, SOCKET_SLOTS, DataSource,
GRADES, VERSION_NAMES, DISTILL_MARGIN_CUT, SCRAPE_OPS, rollMarketModel,
} from "./research";
import SLOP_TITLE_COUNT_JSON from "../data/sloptitles.json";
@ -192,12 +193,13 @@ export function unlockGen(s: SimState): string | null {
if (s.spec) pushEvent(s, "🧪 New base model — your old specialization tuning is gone", "info");
s.spec = null;
s.collapsed = false;
const label = `${s.modelName} ${VERSION_NAMES[s.tier]}`;
if (s.dataSource === "own" && rand(s) < COLLAPSE_CHANCE) {
s.collapsed = true;
pushEvent(s, `🌀 MODEL COLLAPSE — ${next.name} trained on its own slop outputs pure gibberish (revenue halved this gen)`, "bad");
pushEvent(s, `🌀 MODEL COLLAPSE — ${label} trained on its own slop outputs pure gibberish (revenue halved this version)`, "bad");
}
pushEvent(s, `🧠 MODEL TIER UP: ${next.name} (revenue x${next.mult})`, "good");
pushEvent(s, `😎 ${next.name} slop slips right past the spam filters`, "good");
pushEvent(s, `🧠 ${label} RELEASED (revenue x${next.mult})`, "good");
pushEvent(s, `😎 ${label} slop slips right past the spam filters`, "good");
return null;
}
@ -222,7 +224,7 @@ export function setSpec(s: SimState, id: string): string | null {
if (s.trainTokens < cost) return `need ${cost} training tokens`;
s.trainTokens -= cost;
s.spec = id;
pushEvent(s, `🧪 ${BAL.TIERS[s.tier].name} fine-tuned: ${node.name}`, "good");
pushEvent(s, `🧪 ${s.modelName} ${VERSION_NAMES[s.tier]} fine-tuned: ${node.name}`, "good");
return null;
}
@ -272,6 +274,60 @@ export function toggleDevice(s: SimState, uid: number): string | null {
return null;
}
export function shipGrade(s: SimState, gradeId: "mini" | "pro" | "max"): string | null {
const grade = GRADES.find((g) => g.id === gradeId);
if (!grade) return "unknown grade";
const existing = s.shippedGrades.find((g) => g.grade === gradeId);
if (existing && existing.version === s.tier) return "that grade is already on this version";
const basis = BAL.TIERS[s.tier].tokens ?? 2000; // v1 ships off a small basis
const cost = Math.round(basis * grade.costMult);
if (s.trainTokens < cost) return `need ${cost} training tokens`;
s.trainTokens -= cost;
const label = `${s.modelName} ${VERSION_NAMES[s.tier]}${grade.suffix}`;
if (existing) {
existing.version = s.tier;
existing.distilled = false; // fresh weights, fresh moat
pushEvent(s, `🚀 ${label} SHIPPED — distillation moat restored`, "good");
} else {
s.shippedGrades.push({ grade: gradeId, version: s.tier, distilled: false });
pushEvent(s, `🚀 ${label} SHIPPED — now hostable on the API market`, "good");
}
return null;
}
export function switchHosting(s: SimState, id: number | null): string | null {
if (id === s.hostedId) return "already serving that";
if (id !== null) {
if (id < 0) {
const grade = GRADES[-id - 1];
if (!grade || !s.shippedGrades.find((g) => g.grade === grade.id)) return "ship that grade first";
} else if (!s.market.find((m) => m.id === id)) {
return "that model is gone";
}
}
s.hostedId = id;
if (id === null) {
pushEvent(s, "API hosting stopped", "info");
} else {
s.hostSwitchAt = s.t + 8;
pushEvent(s, "📦 Loading weights onto the cluster (8s)...", "info");
}
return null;
}
export function unlockScrape(s: SimState, id: string): string | null {
const node = SCRAPE_OPS.find((n) => n.id === id);
if (!node) return "unknown rig";
if (s.scrapeNodes.includes(id)) return "already running";
if (node.requires && !s.scrapeNodes.includes(node.requires))
return `requires ${SCRAPE_OPS.find((n) => n.id === node.requires)?.name}`;
if (s.cash < node.cash) return "can't afford it";
s.cash -= node.cash;
s.scrapeNodes.push(id);
pushEvent(s, `🕷 Scraping ops: ${node.name} online`, "good");
return null;
}
function clapCost(s: SimState): number {
return 50 * (s.round + 1);
}
@ -400,15 +456,23 @@ export function step(s: SimState) {
const dataMult = demand > 0 ? Math.min(1, uplink / demand) : 1;
const tokEff = tokRaw * dataMult;
// --- economy ---
// --- economy: compute splits three ways (slop / training / hosting) ---
const tm = tierMult(s);
const tokInf = tokEff * (1 - s.allocTraining) * dt;
const tokTrain = tokEff * s.allocTraining * dt;
const trainShare = Math.min(1, s.allocTraining);
const hostShare = Math.min(s.allocHosting, 1 - trainShare);
const tokInf = tokEff * (1 - trainShare - hostShare) * dt;
const tokTrain = tokEff * trainShare * dt;
const tokHost = tokEff * hostShare; // tok/s
let income = tokInf * BAL.TOK_PRICE * tm; // baseline API drip
// training fills the research bank; the Lab decides what it buys
const src = DATA_SOURCES[s.dataSource];
s.trainTokens += tokTrain * src.rate;
let dataRate = src.rate;
if (s.dataSource === "scraped") {
dataRate += s.scrapeNodes.reduce((a, id) => a + (SCRAPE_OPS.find((n) => n.id === id)?.scrapedRateAdd ?? 0), 0);
if (s.t < s.scrapeBanUntil) dataRate *= 0.15; // pipeline is IP-banned
}
s.trainTokens += tokTrain * dataRate;
income -= tokTrain * src.costPerTok; // licensed data bills per token
// platform adaptation drifts back down while you're not spamming
@ -462,8 +526,80 @@ export function step(s: SimState) {
}
income *= priceFx;
// --- API hosting market ---
if (s.nextMarketT === 0) {
// seed the launch-day market
for (let i = 0; i < 3; i++)
s.market.push(rollMarketModel(() => rand(s), s.nextMarketId++, s.t));
s.nextMarketT = s.t + 90 + rand(s) * 120;
} else if (s.t >= s.nextMarketT) {
const drop = rollMarketModel(() => rand(s), s.nextMarketId++, s.t);
for (const m of s.market) m.margin *= 0.72; // hot drop craters the incumbents
s.market.push(drop);
while (s.market.length > 5) {
const i = s.market.findIndex((m) => m.id !== s.hostedId);
if (i < 0) break;
s.market.splice(i, 1);
}
pushEvent(s, `${drop.flag} ${drop.name} just dropped — hosting margins crater`, "rival");
s.nextMarketT = s.t + 90 + rand(s) * 150;
}
for (const m of s.market) m.margin *= 1 - 0.0003 * dt; // slow fade as hype cools
let hostIncome = 0;
let hostingOwnMax = false;
const loading = s.hostSwitchAt !== null && s.t < s.hostSwitchAt;
if (s.hostSwitchAt !== null && s.t >= s.hostSwitchAt) {
s.hostSwitchAt = null;
pushEvent(s, "📦 Weights loaded — serving traffic", "info");
}
if (s.hostedId !== null && !loading && tokHost > 0) {
if (s.hostedId < 0) {
const grade = GRADES[-s.hostedId - 1];
const shipped = s.shippedGrades.find((g) => g.grade === grade.id);
if (shipped) {
const margin = grade.margin * (shipped.distilled ? DISTILL_MARGIN_CUT : 1);
hostIncome = Math.min(tokHost, grade.demand) * margin * priceFx;
if (grade.hypeMult && !shipped.distilled) hostingOwnMax = grade.id === "max";
// distillation roll — hosting your own weights is how they steal them
if (s.nextDistillT === 0) s.nextDistillT = s.t + 100 + rand(s) * 120;
else if (s.t >= s.nextDistillT) {
if (!shipped.distilled && rand(s) < grade.distillChance) {
shipped.distilled = true;
const thief = rollMarketModel(() => rand(s), s.nextMarketId++, s.t);
thief.margin *= 1.3;
s.market.push(thief);
pushEvent(s, `🥷 YOU GOT DISTILLED — ${thief.flag} ${thief.name} is a suspiciously good clone of ${s.modelName} ${VERSION_NAMES[shipped.version]}${GRADES.find((g) => g.id === shipped.grade)!.suffix} (margin gutted this version)`, "bad");
}
s.nextDistillT = s.t + 100 + rand(s) * 120;
}
}
} else {
const m = s.market.find((x) => x.id === s.hostedId);
if (m) hostIncome = Math.min(tokHost, m.demand) * m.margin * priceFx;
else { s.hostedId = null; pushEvent(s, "Hosted model delisted — allocate elsewhere", "info"); }
}
income += hostIncome * dt;
}
// --- scraping ops: IP bans while living off scraped data ---
if (s.dataSource === "scraped" && s.videos.length > 0) {
if (s.nextBanT === 0) s.nextBanT = s.t + 70 + rand(s) * 80;
else if (s.t >= s.nextBanT) {
const severity = 1 + Math.floor(rand(s) * 3);
const banLevel = Math.max(0, ...s.scrapeNodes.map((id) => SCRAPE_OPS.find((n) => n.id === id)?.banLevel ?? 0));
if (severity > banLevel) {
s.scrapeBanUntil = s.t + 45;
pushEvent(s, `🚫 IP BANNED (severity ${severity}) — scraping pipeline offline 45s`, "bad");
} else {
pushEvent(s, "🔁 Ban wave hit — proxies rotated, the scrape continues", "info");
}
s.nextBanT = s.t + 70 + rand(s) * 80;
}
}
const adaptMult = (1 - s.platformAdapt * BAL.ADAPT_MAX_PENALTY) * hype * viewFx *
mods.viewsPerVideoMult * mods.conveyorHype;
mods.viewsPerVideoMult * mods.conveyorHype * (hostingOwnMax ? 1.1 : 1);
for (const v of s.videos) {
if (v.dead) continue;
const age = s.t - v.born;
@ -527,6 +663,8 @@ export function step(s: SimState) {
r.dataMult = dataMult;
r.maxTemp = maxTemp;
r.adapt = s.platformAdapt;
r.hostIncome += (hostIncome - r.hostIncome) * sm;
r.hostTok = tokHost;
}
export function shopList(s: SimState) {

View File

@ -54,6 +54,20 @@ export interface SimState {
// Corporate R&D
perkPoints: number;
rndNodes: string[];
// Model line & API market
modelName: string;
shippedGrades: { grade: "mini" | "pro" | "max"; version: number; distilled: boolean }[];
allocHosting: number; // 0..1 share of compute sold on the API market
market: import("./research").MarketModel[];
nextMarketId: number;
hostedId: number | null; // market row id currently hosted (negative ids = your grades)
hostSwitchAt: number | null; // weights loading until this sim-time
nextMarketT: number;
nextDistillT: number;
// Scraping ops
scrapeNodes: string[];
scrapeBanUntil: number; // scraped training offline until this sim-time
nextBanT: number;
// breaker grace bookkeeping
overSince: number | null;
dieselResetAt: number | null;
@ -70,6 +84,8 @@ export interface SimState {
dataMult: number;
maxTemp: number;
adapt: number;
hostIncome: number; // $/s from API hosting
hostTok: number; // tok/s allocated to hosting
};
events: { msg: string; t: number; kind: string }[];
rng: number;
@ -104,6 +120,18 @@ export function createState(): SimState {
collapsed: false,
perkPoints: 0,
rndNodes: [],
modelName: "SlopGPT",
shippedGrades: [],
allocHosting: 0,
market: [],
nextMarketId: 1,
hostedId: null,
hostSwitchAt: null,
nextMarketT: 0,
nextDistillT: 0,
scrapeNodes: [],
scrapeBanUntil: 0,
nextBanT: 0,
overSince: null,
dieselResetAt: null,
tokenBank: 0,
@ -111,7 +139,7 @@ export function createState(): SimState {
rates: {
watts: 0, breakerWatts: BAL.BREAKER_WATTS, tokEff: 0, income: 0,
powerCost: 0, demandMbps: 0, uplinkMbps: 0, dataMult: 1, maxTemp: BAL.AMBIENT_C,
adapt: 0,
adapt: 0, hostIncome: 0, hostTok: 0,
},
events: [],
rng: 0x5109a & 0xffffffff,

View File

@ -3,6 +3,7 @@ import { BAL } from "../sim/balance";
import { SimState } from "../sim/state";
import { shopList, resetBreaker } from "../sim/sim";
import { sfx, setMuted, isMuted } from "./sfx";
import { MODEL_NAME_POOL, VERSION_NAMES } from "../sim/research";
import TITLES from "../data/sloptitles.json";
export interface HudCallbacks {
@ -10,6 +11,7 @@ export interface HudCallbacks {
onToggleOverlay: (which: "heat" | "power" | "data" | "grid") => void;
onWireTool: () => void;
onAlloc: (trainShare: number) => void;
onAllocHost: (hostShare: number) => void;
onNewGame: () => void;
onRaise: () => void;
onClapback: () => void;
@ -24,6 +26,10 @@ export function randomCompanyName(): string {
return pick(NAME_A) + pick(NAME_B) + pick(NAME_C);
}
export function randomModelName(): string {
return MODEL_NAME_POOL[Math.floor(Math.random() * MODEL_NAME_POOL.length)];
}
const fmt = (n: number, d = 2) =>
n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "K" : n.toFixed(d);
@ -47,6 +53,7 @@ export class Hud {
<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">VIEWS</span><span class="val" id="views">0</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">HOSTING</span><span class="val" id="host">$0/s</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>
<div class="stat" title="Platform spam-filter wariness. Publishing raises it, views drop as it climbs. Train a new model tier for relief."><span class="label">ALGO HEAT</span><span class="val" id="adapt">0%</span><div class="bar"><div id="adaptbar"></div></div></div>
<button id="breaker-reset">RESET BREAKER</button>
@ -60,6 +67,7 @@ export class Hud {
<button class="chip" id="wire-tool">🔌 Wire (W)</button>
<button class="chip" id="lab-btn">🧪 Model Lab (L)</button>
<button class="chip" id="rnd-btn">🏢 R&D (R)</button>
<button class="chip" id="market-btn">📡 Market (M)</button>
<button class="chip" id="mute">${isMuted() ? "🔇 Muted" : "🔊 Sound"}</button>
<button class="chip" id="new-game">🗑 New Game</button>
<button class="chip" id="raise" style="display:none">💰 Raise</button>
@ -69,8 +77,10 @@ export class Hud {
</div>
<button id="clap" style="display:none"></button>
<div id="alloc-row">
<span class="label" style="color:var(--dim);font-size:9px">TRAIN <b id="allocpct">25</b>% / INFER</span>
<span class="label" style="color:var(--dim);font-size:9px">TRAIN <b id="allocpct">25</b>%</span>
<input id="alloc" type="range" min="0" max="90" value="25" />
<span class="label" style="color:var(--dim);font-size:9px">HOST <b id="hostpct">0</b>% · rest = slop</span>
<input id="allochost" type="range" min="0" max="90" value="0" />
</div>
</div>
<div id="shop" class="panel"><h3>HARDWARE STORE</h3></div>
@ -100,6 +110,11 @@ export class Hud {
(this.root.querySelector("#allocpct") as HTMLElement).textContent = alloc.value;
cb.onAlloc(Number(alloc.value) / 100);
};
const allochost = this.root.querySelector("#allochost") as HTMLInputElement;
allochost.oninput = () => {
(this.root.querySelector("#hostpct") as HTMLElement).textContent = allochost.value;
cb.onAllocHost(Number(allochost.value) / 100);
};
}
bindBreaker(state: SimState) {
@ -134,8 +149,8 @@ export class Hud {
}
}
/** modal for naming the company on a fresh game; resolves with the chosen name */
showNameModal(): Promise<string> {
/** modal for naming the company + model line on a fresh game */
showNameModal(): Promise<{ company: string; model: string }> {
return new Promise((resolve) => {
const m = document.createElement("div");
m.id = "name-modal";
@ -147,22 +162,35 @@ export class Hud {
<input id="cname" maxlength="28" value="${randomCompanyName()}" spellcheck="false"/>
<button id="reroll" title="reroll">🎲</button>
</div>
<p style="margin-top:10px">and so does the model you'll inflict on the world.</p>
<div class="row">
<input id="mname" maxlength="20" value="${randomModelName()}" spellcheck="false"/>
<button id="mreroll" title="reroll">🎲</button>
</div>
<button id="cstart">INCORPORATE </button>
</div>`;
this.root.appendChild(m);
const input = m.querySelector("#cname") as HTMLInputElement;
const minput = m.querySelector("#mname") as HTMLInputElement;
(m.querySelector("#reroll") as HTMLButtonElement).onclick = () => {
input.value = randomCompanyName();
sfx.click();
};
(m.querySelector("#mreroll") as HTMLButtonElement).onclick = () => {
minput.value = randomModelName();
sfx.click();
};
const done = () => {
const name = input.value.trim() || "SlopCo";
m.remove();
sfx.tierUp();
resolve(name);
resolve({
company: input.value.trim() || "SlopCo",
model: minput.value.trim() || "SlopGPT",
});
};
(m.querySelector("#cstart") as HTMLButtonElement).onclick = done;
input.onkeydown = (e) => { if (e.key === "Enter") done(); e.stopPropagation(); };
for (const el of [input, minput])
el.onkeydown = (e) => { if (e.key === "Enter") done(); e.stopPropagation(); };
input.focus();
input.select();
});
@ -231,7 +259,9 @@ export class Hud {
(q("#adaptbar") as HTMLElement).style.width = Math.round(ad * 100) + "%";
(q("#adaptbar") as HTMLElement).style.background =
ad > 0.5 ? "var(--red)" : ad > 0.25 ? "var(--amber)" : "var(--green)";
q("#tier").textContent = BAL.TIERS[s.tier].name + (s.collapsed ? " 🌀" : "");
q("#tier").textContent = `${s.modelName} ${VERSION_NAMES[s.tier]}` + (s.collapsed ? " 🌀" : "");
q("#host").textContent = "$" + fmt(s.rates.hostIncome, 3) + "/s";
(q("#host") as HTMLElement).style.color = s.rates.hostIncome > 0 ? "var(--green)" : "";
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";

View File

@ -4,9 +4,11 @@ import { SimState } from "../sim/state";
import { DEF } from "../sim/catalog";
import {
MODALITIES, SPECS, SPEC_COST_BY_GEN, DATA_SOURCES, RND, SOCKETS, SOCKET_SLOTS, DataSource,
GRADES, VERSION_NAMES, SCRAPE_OPS,
} from "../sim/research";
import {
unlockGen, unlockModality, setSpec, setDataSource, unlockRnd, buyUpgrade, toggleDevice, sell,
shipGrade, switchHosting, unlockScrape,
} from "../sim/sim";
import { pushEvent } from "../sim/state";
import { sfx } from "./sfx";
@ -16,7 +18,7 @@ const fmt = (n: number) =>
export class Panels {
state: SimState;
open: "lab" | "rnd" | null = null;
open: "lab" | "rnd" | "market" | null = null;
selectedDevice: number | null = null;
root: HTMLElement;
@ -28,7 +30,7 @@ export class Panels {
this.root.addEventListener("click", (e) => this.dispatch(e));
}
toggle(which: "lab" | "rnd") {
toggle(which: "lab" | "rnd" | "market") {
this.open = this.open === which ? null : which;
sfx.click();
this.render();
@ -53,6 +55,9 @@ export class Panels {
case "spec": err = setSpec(s, arg!); break;
case "data": err = setDataSource(s, arg as DataSource); break;
case "rnd": err = unlockRnd(s, arg!); break;
case "ship": err = shipGrade(s, arg as "mini" | "pro" | "max"); break;
case "host": err = switchHosting(s, arg === "null" ? null : Number(arg)); break;
case "scrape": err = unlockScrape(s, arg!); break;
case "upg": err = buyUpgrade(s, this.selectedDevice!, arg!); break;
case "toggle": err = toggleDevice(s, this.selectedDevice!); break;
case "sell-dev":
@ -71,6 +76,7 @@ export class Panels {
let html = "";
if (this.open === "lab") html += this.labHtml(s);
if (this.open === "rnd") html += this.rndHtml(s);
if (this.open === "market") html += this.marketHtml(s);
if (this.selectedDevice !== null) {
const d = s.devices.find((x) => x.uid === this.selectedDevice);
if (d) html += this.deviceHtml(s, d);
@ -113,10 +119,63 @@ export class Panels {
const d = DATA_SOURCES[k];
return `<button class="node ${s.dataSource === k ? "owned" : "buy"}" data-cmd="data" data-arg="${k}">${d.name}<small>${d.desc}</small></button>`;
}).join("")}
${s.dataSource === "scraped" ? `<h4>SCRAPING OPS</h4>
${SCRAPE_OPS.map((n) => {
if (s.scrapeNodes.includes(n.id)) return `<div class="node owned">${n.name}<small>${n.desc}</small></div>`;
if (n.requires && !s.scrapeNodes.includes(n.requires))
return `<div class="node dim">${n.name}<small>requires ${SCRAPE_OPS.find((x) => x.id === n.requires)?.name}</small></div>`;
return `<button class="node buy ${s.cash >= n.cash ? "" : "dim"}" data-cmd="scrape" data-arg="${n.id}">${n.name}<small>${n.desc} · $${fmt(n.cash)}</small></button>`;
}).join("")}` : ""}
</div>
<div class="mcol">
<h4>SHIP GRADES ${s.modelName} ${VERSION_NAMES[s.tier]}</h4>
${GRADES.map((g) => {
const basis = BAL.TIERS[s.tier].tokens ?? 2000;
const cost = Math.round(basis * g.costMult);
const shipped = s.shippedGrades.find((x) => x.grade === g.id);
const label = `${s.modelName} ${VERSION_NAMES[s.tier]}${g.suffix}`;
if (shipped && shipped.version === s.tier)
return `<div class="node owned">${label} — LIVE${shipped.distilled ? " · 🥷 DISTILLED" : ""}<small>$${g.margin * 1000}/Ktok · demand ${g.demand} tok/s${g.hypeMult ? " · +10% views while hosted" : ""}</small></div>`;
const stale = shipped ? ` (v${VERSION_NAMES[shipped.version]} live${shipped.distilled ? ", distilled" : ""})` : "";
return `<button class="node buy ${s.trainTokens >= cost ? "" : "dim"}" data-cmd="ship" data-arg="${g.id}">Ship ${label}${stale}<small>$${g.margin * 1000}/Ktok · demand ${g.demand} tok/s · ${fmt(cost)} tok${shipped?.distilled ? " · restores moat" : ""}</small></button>`;
}).join("")}
<div class="node dim" style="border-style:dashed">Host your grades from the 📡 Market (M). Hosting your own weights invites distillation.</div>
</div>
</div></div>`;
}
private marketHtml(s: SimState): string {
const hosted = (id: number) => s.hostedId === id;
const loading = s.hostSwitchAt !== null && s.t < s.hostSwitchAt;
const row = (id: number, name: string, sub: string, live: boolean) => live
? `<div class="node owned">${name}${loading ? "LOADING WEIGHTS…" : "SERVING"}<small>${sub}</small></div>`
: `<button class="node buy" data-cmd="host" data-arg="${id}">${name}<small>${sub}</small></button>`;
const yours = s.shippedGrades.map((g) => {
const def = GRADES.find((x) => x.id === g.grade)!;
const gid = -(GRADES.indexOf(def) + 1);
const margin = def.margin * (g.distilled ? 0.4 : 1);
return row(gid,
`🏠 ${s.modelName} ${VERSION_NAMES[g.version]}${def.suffix}${g.distilled ? " 🥷" : ""}`,
`$${(margin * 1000).toFixed(2)}/Ktok · demand ${def.demand} tok/s · distill risk ${Math.round(def.distillChance * 100)}%`,
hosted(gid));
}).join("");
const listings = s.market.map((m) =>
row(m.id, `${m.flag} ${m.name}`,
`$${(m.margin * 1000).toFixed(2)}/Ktok · demand ${m.demand} tok/s`, hosted(m.id))).join("");
return `<div class="modal panel" id="market">
<div class="mhead"><h2>📡 API MARKET</h2>
<span class="bank">${s.rates.hostTok.toFixed(0)} tok/s allocated · $${s.rates.hostIncome.toFixed(3)}/s</span>
<button class="mclose" data-cmd="close"></button></div>
<div class="mcols"><div class="mcol">
<h4>YOUR MODELS</h4>
${yours || `<div class="node dim">Nothing shipped — train grades in the 🧪 Lab</div>`}
<h4>THIRD-PARTY LISTINGS</h4>
${listings}
${s.hostedId !== null ? `<button class="node buy" data-cmd="host" data-arg="null">⏹ Stop hosting<small>free the cluster for slop or training</small></button>` : ""}
<div class="node dim" style="border-style:dashed">Set HOST % in the left controls to sell compute here. New drops crater incumbent margins stay nimble.</div>
</div></div></div>`;
}
private rndHtml(s: SimState): string {
const branches: ["ops", "growth", "legal"] = ["ops", "growth", "legal"];
const titles = { ops: "OPS", growth: "GROWTH", legal: "LEGAL / PR" };