M4: Model Lab, Corporate R&D, and hardware sockets

Three progression structures, three currencies. Model Lab (training
tokens): generation ladder bought from the bank, modality branch
(voice/image/video/AV — richer slop earns more but costs tokens,
bandwidth, watts), one exclusive fine-tune per generation, and a data
source choice: own slop trains 30% faster with a 35% model-collapse
gamble, scraped draws copyright lawsuits, licensed bills per token.
Corporate R&D (perk points from closed VC rounds + cash): Ops, Growth,
Legal branches with capstones Diesel Backup, THE CONVEYOR (burns 10%
of gross income for +50% views forever), Regulatory Capture. Sockets:
device inspector panel with on/off, sell, and two upgrade slots.
All effects flow through a single computeMods() bundle in research.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-01 10:51:08 +10:00
parent 03bb13aff9
commit c3d57e09b3
9 changed files with 636 additions and 44 deletions

View File

@ -40,9 +40,23 @@ Date.now/Math.random inside sim).
Weights/Poogle) on a seeded timer with timed view/price effects and a
60/40 CLAP BACK gamble. Seed goal calibrated empirically: decisive play
≈48K views/8min, goal 40K, idle ≈12K.
→ M4 candidates: hazards (thermal runaway fire), Tier 2 garage move
(bigger room = new backdrop + grid refit), R&D skill tree (endgame:
automated cash-conveyor→furnace), subscribers/SaaS as second revenue.
✅ M4 skill trees (src/sim/research.ts is the single source of truth; sim
reads one computeMods() bundle — add nodes there, never special-case sim):
• Model Lab (L): gen ladder is now bought from the training bank; modality
branch (voice→image→video→A/V, richer = more views but pricier videos);
one exclusive spec per gen (engagement/stealth/efficient, cleared on
gen-up); data source own/scraped/licensed — own rolls 35% MODEL COLLAPSE
(half revenue + all-gibberish titles for that gen), scraped attracts
lawsuits, licensed bills per token.
• Corporate R&D (R): perk point per closed VC round + cash; Ops/Growth/
Legal branches; capstones Diesel Backup (grace+auto-reset, no checkpoint
corruption), THE CONVEYOR (10% gross income burned, +50% views),
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).
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

@ -5,7 +5,10 @@ import { saveState, loadState, clearSave } from "./sim/save";
import { DEF } from "./sim/catalog";
import { Scene } from "./render/scene";
import { Hud } from "./ui/hud";
import { Panels } from "./ui/panels";
import { sfx } from "./ui/sfx";
import { unlockGen, unlockModality, setSpec, setDataSource, unlockRnd, buyUpgrade } from "./sim/sim";
import { DataSource } from "./sim/research";
async function boot() {
const el = document.getElementById("app")!;
@ -43,6 +46,9 @@ async function boot() {
},
});
hud.bindBreaker(state);
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");
const cancel = () => {
shopSel = null; hud.selectedShop = null; hud.lockedCache = "";
@ -50,10 +56,13 @@ async function boot() {
};
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") cancel();
if ((e.target as HTMLElement)?.tagName === "INPUT") return;
if (e.key === "Escape") { cancel(); panels.closeAll(); }
if (e.key === "w" || e.key === "W") {
wireMode = !wireMode; wireSource = null; shopSel = null; hud.setWireActive(wireMode);
}
if (e.key === "l" || e.key === "L") panels.toggle("lab");
if (e.key === "r" || e.key === "R") panels.toggle("rnd");
});
scene.app.canvas.addEventListener("pointermove", (e) => {
@ -96,7 +105,13 @@ async function boot() {
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.wire();
wireSource = null;
}
return;
}
// no tool active: click inspects a device, empty floor deselects
const d = deviceAt(t.x, t.y);
panels.selectedDevice = d ? d.uid : null;
panels.render();
if (d) sfx.click();
});
scene.app.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
@ -127,8 +142,12 @@ async function boot() {
requestAnimationFrame(loop);
};
// HUD at 5 Hz
setInterval(() => hud.update(state), 200);
// HUD at 5 Hz; open panels refresh at 1 Hz (innerHTML rebuild resets scroll)
let hudTicks = 0;
setInterval(() => {
hud.update(state);
if (++hudTicks % 5 === 0 && (panels.open || panels.selectedDevice !== null)) panels.render();
}, 200);
hud.update(state);
if (!loaded) {
@ -160,6 +179,13 @@ async function boot() {
newGame: () => { wiped = true; clearSave(); location.reload(); },
raise: () => raiseRound(state),
clap: () => clapBack(state),
panels,
gen: () => unlockGen(state),
modality: (id: string) => unlockModality(state, id),
spec: (id: string) => setSpec(state, id),
data: (k: DataSource) => setDataSource(state, k),
rnd: (id: string) => unlockRnd(state, id),
upg: (uid: number, id: string) => buyUpgrade(state, uid, id),
};
}

205
src/sim/research.ts Normal file
View File

@ -0,0 +1,205 @@
// Both skill trees (Model Lab + Corporate R&D) and the computed Mods bundle.
// Design rule: nodes buy DECISIONS, not just power — most cross-pressure a pillar.
import { SimState } from "./state";
// ---------- Model Lab (paid in training tokens from the bank) ----------
export interface LabNode {
id: string;
name: string;
desc: string;
cost: number; // training tokens
kind: "modality" | "spec";
requires?: string[]; // modality prereqs
mods?: Partial<Mods>;
}
export const MODALITIES: LabNode[] = [
{
id: "voice_clone", name: "Voice Clone", kind: "modality", cost: 2500,
desc: "Fake podcast arguments at scale. Views +20%.",
mods: { viewsPerVideoMult: 1.2 },
},
{
id: "image_diff", name: "Image Diffusion", kind: "modality", cost: 6000,
desc: "Thumbnails from the uncanny valley. Views +30%, bandwidth +20%.",
mods: { viewsPerVideoMult: 1.3, mbpsPerTokMult: 1.2 },
},
{
id: "video_synth", name: "Video Synthesis", kind: "modality", cost: 20000,
requires: ["image_diff"],
desc: "Full motion slop. Views +80%, but videos cost +50% tokens, bandwidth +50%, watts +15%.",
mods: { viewsPerVideoMult: 1.8, videoCostMult: 1.5, mbpsPerTokMult: 1.5, wattsPerTokMult: 1.15 },
},
{
id: "av_slop", name: "Total A/V Slop", kind: "modality", cost: 60000,
requires: ["video_synth", "voice_clone"],
desc: "Indistinguishable from culture. Views +150%, videos cost +70% tokens, bandwidth +60%.",
mods: { viewsPerVideoMult: 2.5, videoCostMult: 1.7, mbpsPerTokMult: 1.6, wattsPerTokMult: 1.1 },
},
];
// One spec slot per model generation — unlocking a new generation clears it.
export const SPECS: LabNode[] = [
{
id: "spec_engagement", name: "Engagement-Tuned", kind: "spec", cost: 0, // cost set per gen
desc: "RLHF'd on rage clicks. Viral chance +8%.",
mods: { viralChanceAdd: 0.08 },
},
{
id: "spec_stealth", name: "Stealth-Tuned", kind: "spec", cost: 0,
desc: "Trained to smell human. ALGO HEAT per video -45%.",
mods: { adaptPerVideoMult: 0.55 },
},
{
id: "spec_efficient", name: "Efficiency-Tuned", kind: "spec", cost: 0,
desc: "Quantized to the bone. Watts -25%, heat -15%.",
mods: { wattsPerTokMult: 0.75, heatMult: 0.85 },
},
];
export const SPEC_COST_BY_GEN = [800, 2500, 15000, 80000];
export type DataSource = "own" | "scraped" | "licensed";
export const DATA_SOURCES: Record<DataSource, { name: string; desc: string; rate: number; costPerTok: number }> = {
own: {
name: "Our Own Slop", rate: 1.3, costPerTok: 0,
desc: "Free and plentiful. 35% chance each new model collapses into gibberish.",
},
scraped: {
name: "Scraped Internet", rate: 1.0, costPerTok: 0,
desc: "Everyone does it. Copyright lawyers may disagree.",
},
licensed: {
name: "Licensed Data", rate: 1.0, costPerTok: 0.004,
desc: "Clean, legal, and it costs money per token trained. Coward's choice.",
},
};
export const COLLAPSE_CHANCE = 0.35;
// ---------- Corporate R&D (perk points from closed rounds + cash) ----------
export interface RndNode {
id: string;
name: string;
branch: "ops" | "growth" | "legal";
cash: number;
desc: string;
requires?: string[];
mods?: Partial<Mods>;
}
export const RND: RndNode[] = [
// Ops
{ id: "cable_mgmt", name: "Cable Management", branch: "ops", cash: 200,
desc: "Velcro ties. Bandwidth per token -15%.", mods: { mbpsPerTokMult: 0.85 } },
{ id: "overclock", name: "Overclock Everything", branch: "ops", cash: 400,
desc: "Tokens +20%, heat +25%. What warranty?", mods: { tokRateMult: 1.2, heatMult: 1.25 } },
{ id: "grid_whisperer", name: "Grid Whisperer", branch: "ops", cash: 800, requires: ["overclock"],
desc: "3 seconds of breaker grace before it trips.", mods: { breakerGraceS: 3 } },
{ id: "diesel", name: "Diesel Backup Ritual", branch: "ops", cash: 2500, requires: ["grid_whisperer"],
desc: "Trips auto-reset in 5s and never corrupt checkpoints. Smells amazing.", mods: { diesel: true } },
// Growth
{ id: "ab_thumbs", name: "A/B Thumbnails", branch: "growth", cash: 300,
desc: "Redder arrows, wider mouths. Views +15%.", mods: { viewsPerVideoMult: 1.15 } },
{ id: "eng_psych", name: "Engagement Psychologist", branch: "growth", cash: 600, requires: ["ab_thumbs"],
desc: "They have a PhD and no soul. Viral chance +5%.", mods: { viralChanceAdd: 0.05 } },
{ id: "astroturf", name: "Astroturf Army", branch: "growth", cash: 1000,
desc: "10,000 accounts named Greg. Clap backs succeed +20%.", mods: { clapbackSuccessAdd: 0.2 } },
{ id: "conveyor", name: "THE CONVEYOR", branch: "growth", cash: 5000, requires: ["astroturf", "eng_psych"],
desc: "Automated belt feeds 10% of gross income directly into the furnace. Views +50%, forever.",
mods: { conveyorPct: 0.1, conveyorHype: 1.5 } },
// Legal
{ id: "fine_print", name: "Fine Print", branch: "legal", cash: 250,
desc: "Clause 47.3(b). Lawsuit fines -50%.", mods: { fineMult: 0.5 } },
{ id: "lobbyist", name: "Lobbyist on Retainer", branch: "legal", cash: 900,
desc: "Rival attack effects fade 40% faster.", mods: { rivalDurMult: 0.6 } },
{ id: "safety_blog", name: "'Thoughts on Safety' Blog", branch: "legal", cash: 1500, requires: ["fine_print"],
desc: "Doomsworth panics now read as endorsements: his attacks become free PR.", mods: { doomFlip: true } },
{ id: "reg_capture", name: "Regulatory Capture", branch: "legal", cash: 6000, requires: ["lobbyist", "safety_blog"],
desc: "You ARE the oversight board. No more lawsuits; ALGO HEAT decays 2x faster.",
mods: { lawsuitImmune: true, adaptDecayMult: 2 } },
];
// ---------- Device socket upgrades ----------
export interface SocketUpgrade {
id: string;
name: string;
cost: number;
desc: string;
heatMult?: number;
wattsMult?: number;
tokMult?: number;
}
export const SOCKETS: SocketUpgrade[] = [
{ id: "extra_fan", name: "Extra Fans", cost: 80, desc: "Heat -25%", heatMult: 0.75 },
{ id: "undervolt", name: "Undervolt", cost: 150, desc: "Watts -15%, tokens -5%", wattsMult: 0.85, tokMult: 0.95 },
{ id: "risers", name: "Riser Cards", cost: 250, desc: "Tokens +15%, heat +15%", tokMult: 1.15, heatMult: 1.15 },
];
export const SOCKET_SLOTS = 2;
// ---------- Mods ----------
export interface Mods {
viralChanceAdd: number;
adaptPerVideoMult: number;
adaptDecayMult: number;
wattsPerTokMult: number;
viewsPerVideoMult: number;
videoCostMult: number;
mbpsPerTokMult: number;
heatMult: number;
tokRateMult: number;
clapbackSuccessAdd: number;
breakerGraceS: number;
diesel: boolean;
doomFlip: boolean;
lawsuitImmune: boolean;
fineMult: number;
rivalDurMult: number;
conveyorPct: number;
conveyorHype: number;
}
const BASE: Mods = {
viralChanceAdd: 0, adaptPerVideoMult: 1, adaptDecayMult: 1, wattsPerTokMult: 1,
viewsPerVideoMult: 1, videoCostMult: 1, mbpsPerTokMult: 1, heatMult: 1, tokRateMult: 1,
clapbackSuccessAdd: 0, breakerGraceS: 0, diesel: false, doomFlip: false,
lawsuitImmune: false, fineMult: 1, rivalDurMult: 1, conveyorPct: 0, conveyorHype: 1,
};
function fold(into: Mods, m?: Partial<Mods>) {
if (!m) return;
for (const [k, v] of Object.entries(m)) {
const key = k as keyof Mods;
if (typeof v === "boolean") (into[key] as boolean) = (into[key] as boolean) || v;
else if (key.endsWith("Add") || key === "breakerGraceS" || key === "conveyorPct")
(into[key] as number) += v as number;
else if (key === "conveyorHype") (into[key] as number) *= v as number;
else (into[key] as number) *= v as number;
}
}
export function computeMods(s: SimState): Mods {
const m = { ...BASE };
for (const id of s.modalities) fold(m, MODALITIES.find((n) => n.id === id)?.mods);
if (s.spec) fold(m, SPECS.find((n) => n.id === s.spec)?.mods);
for (const id of s.rndNodes) fold(m, RND.find((n) => n.id === id)?.mods);
return m;
}
export function deviceMods(upgrades: string[]) {
let heat = 1, watts = 1, tok = 1;
for (const id of upgrades) {
const u = SOCKETS.find((x) => x.id === id);
if (!u) continue;
heat *= u.heatMult ?? 1;
watts *= u.wattsMult ?? 1;
tok *= u.tokMult ?? 1;
}
return { heat, watts, tok };
}

View File

@ -22,6 +22,7 @@ export function loadState(): SimState | null {
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));
for (const d of s.devices) d.upgrades ??= []; // pre-sockets saves
return s;
} catch (e) {
console.warn("corrupt save, starting fresh", e);

View File

@ -1,6 +1,10 @@
import { BAL } from "./balance";
import { CATALOG, DEF, DeviceDef } from "./catalog";
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,
} from "./research";
import SLOP_TITLE_COUNT_JSON from "../data/sloptitles.json";
const TITLE_COUNT = (SLOP_TITLE_COUNT_JSON as string[]).length;
@ -11,7 +15,8 @@ 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;
// a collapsed model outputs pure gibberish regardless of generation
const brokenChance = s.collapsed ? 1 : 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);
@ -47,7 +52,7 @@ export function place(s: SimState, defId: string, x: number, y: number): Device
s.cash -= def.cost;
const d: Device = {
uid: s.nextUid++, defId, x, y, on: true, powered: false,
wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0,
wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0, upgrades: [],
};
s.devices.push(d);
pushEvent(s, `Installed ${def.name}`, "buy");
@ -58,7 +63,7 @@ export function place(s: SimState, defId: string, x: number, y: number): Device
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,
wiredTo: null, temp: BAL.AMBIENT_C, perf: 1, util: 0, upgrades: [],
};
s.devices.push(d);
return d;
@ -116,7 +121,7 @@ export function clapBack(s: SimState): string | null {
if (s.cash < cb.cost) return "can't afford the ratio";
s.cash -= cb.cost;
s.clapback = null;
if (rand(s) < BAL.CLAPBACK_SUCCESS) {
if (rand(s) < BAL.CLAPBACK_SUCCESS + computeMods(s).clapbackSuccessAdd) {
s.effects = s.effects.filter((e) => e.id !== cb.effectId);
s.effects.push({ id: "ratio", label: "😤 Ratio'd them into the dirt", viewMult: 1.25, expires: s.t + 20 });
pushEvent(s, "😤 CLAP BACK LANDED — effect neutralized, feed loves the drama", "good");
@ -127,33 +132,146 @@ export function clapBack(s: SimState): string | null {
return null;
}
// debuff durations shrink with a lobbyist on retainer
function fxDur(s: SimState, base: number): number {
return base * computeMods(s).rivalDurMult;
}
// Fictional rival archetypes — event table rolled on a seeded timer
const RIVALS = [
{ id: "doom_warn", w: 2, run: (s: SimState) => {
if (computeMods(s).doomFlip) {
s.effects.push({ id: "doom_flip", label: "🕯 Doomsworth panic reframed as praise", viewMult: 1.3, expires: s.t + 30 });
pushEvent(s, "🕯 Doomsworth attacks — your safety blog spins it into an endorsement", "good");
return;
}
s.platformAdapt = Math.min(1, s.platformAdapt + 0.12);
pushEvent(s, "🕯 Clament Doomsworth warns Congress about slop — platforms panic (ALGO HEAT +12%)", "rival");
} },
{ id: "lawsuit", w: 1, run: (s: SimState) => {
const m = computeMods(s);
if (s.dataSource !== "scraped" || m.lawsuitImmune) {
pushEvent(s, "⚖️ Copyright trolls circle, find nothing actionable, leave disappointed", "info");
return;
}
const fine = Math.min(s.cash * 0.2, 400 * (s.round + 1)) * m.fineMult;
s.cash -= fine;
pushEvent(s, `⚖️ Copyright lawsuit over scraped training data — settled for $${fine.toFixed(0)}`, "rival");
} },
{ id: "doom_pub", w: 1, run: (s: SimState) => {
s.effects.push({ id: "doom_pub", label: "🕯 'dangerously capable' — free PR", viewMult: 1.5, expires: s.t + 45 });
pushEvent(s, "🕯 Doomsworth calls your model 'dangerously capable' — best ad you never bought", "good");
} },
{ id: "hype_demo", w: 2, run: (s: SimState) => {
s.effects.push({ id: "hype_demo", label: "🎪 Hypeman demo eating your feed", viewMult: 0.75, expires: s.t + 60 });
s.effects.push({ id: "hype_demo", label: "🎪 Hypeman demo eating your feed", viewMult: 0.75, expires: s.t + fxDur(s, 60) });
s.clapback = { effectId: "hype_demo", label: "Expose the faked demo", cost: clapCost(s), expires: s.t + BAL.CLAPBACK_WINDOW_S };
pushEvent(s, "🎪 Chaz Hypeman's (obviously faked) demo is stealing your views", "rival");
} },
{ id: "weights_drop", w: 2, run: (s: SimState) => {
s.effects.push({ id: "weights_drop", label: "🐧 free clone cratering API prices", priceMult: 0.5, expires: s.t + 90 });
s.effects.push({ id: "weights_drop", label: "🐧 free clone cratering API prices", priceMult: 0.5, expires: s.t + fxDur(s, 90) });
s.clapback = { effectId: "weights_drop", label: "FUD their safety record", cost: clapCost(s), expires: s.t + BAL.CLAPBACK_WINDOW_S };
pushEvent(s, "🐧 Freeman Weights open-sourced a SlopLM clone — token prices halved", "rival");
} },
{ id: "poogle_algo", w: 2, run: (s: SimState) => {
s.effects.push({ id: "poogle_algo", label: "🏢 Poogle algo favors its own slop", viewMult: 0.7, expires: s.t + 60 });
s.effects.push({ id: "poogle_algo", label: "🏢 Poogle algo favors its own slop", viewMult: 0.7, expires: s.t + fxDur(s, 60) });
s.clapback = { effectId: "poogle_algo", label: "Cry antitrust on main", cost: clapCost(s), expires: s.t + BAL.CLAPBACK_WINDOW_S };
pushEvent(s, "🏢 Poogle tweaked the algorithm to favor its own slop farm", "rival");
} },
];
// ---------- Lab / R&D / socket commands ----------
export function unlockGen(s: SimState): string | null {
const next = BAL.TIERS[s.tier + 1];
if (!next) return "no bigger model left to imagine";
if (s.trainTokens < next.tokens!) return `need ${next.tokens} training tokens`;
s.trainTokens -= next.tokens!;
s.tier++;
s.platformAdapt *= BAL.ADAPT_TIER_RELIEF;
s.adaptStage = 0;
if (s.spec) pushEvent(s, "🧪 New base model — your old specialization tuning is gone", "info");
s.spec = null;
s.collapsed = false;
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 TIER UP: ${next.name} (revenue x${next.mult})`, "good");
pushEvent(s, `😎 ${next.name} slop slips right past the spam filters`, "good");
return null;
}
export function unlockModality(s: SimState, id: string): string | null {
const node = MODALITIES.find((n) => n.id === id);
if (!node) return "unknown research";
if (s.modalities.includes(id)) return "already unlocked";
for (const req of node.requires ?? [])
if (!s.modalities.includes(req)) return `requires ${MODALITIES.find((n) => n.id === req)?.name}`;
if (s.trainTokens < node.cost) return `need ${node.cost} training tokens`;
s.trainTokens -= node.cost;
s.modalities.push(id);
pushEvent(s, `🧪 NEW MODALITY: ${node.name}`, "good");
return null;
}
export function setSpec(s: SimState, id: string): string | null {
const node = SPECS.find((n) => n.id === id);
if (!node) return "unknown tuning";
if (s.spec === id) return "already tuned that way";
const cost = SPEC_COST_BY_GEN[Math.min(s.tier, SPEC_COST_BY_GEN.length - 1)];
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");
return null;
}
export function setDataSource(s: SimState, src: DataSource): string | null {
if (!DATA_SOURCES[src]) return "unknown data source";
s.dataSource = src;
pushEvent(s, `📚 Training data: ${DATA_SOURCES[src].name}`, "info");
return null;
}
export function unlockRnd(s: SimState, id: string): string | null {
const node = RND.find((n) => n.id === id);
if (!node) return "unknown project";
if (s.rndNodes.includes(id)) return "already researched";
for (const req of node.requires ?? [])
if (!s.rndNodes.includes(req)) return `requires ${RND.find((n) => n.id === req)?.name}`;
if (s.perkPoints < 1) return "need a perk point (close a VC round)";
if (s.cash < node.cash) return "can't afford it";
s.perkPoints--;
s.cash -= node.cash;
s.rndNodes.push(id);
pushEvent(s, id === "conveyor"
? "🏭 THE CONVEYOR is installed — money now flows directly into the fire"
: `🏢 R&D complete: ${node.name}`, "good");
return null;
}
export function buyUpgrade(s: SimState, uid: number, upgId: string): string | null {
const d = s.devices.find((x) => x.uid === uid);
const upg = SOCKETS.find((u) => u.id === upgId);
if (!d || !upg) return "missing device or upgrade";
if (!DEF[d.defId].tokRate) return "only compute hardware takes upgrades";
if (d.upgrades.length >= SOCKET_SLOTS) return "no free sockets";
if (d.upgrades.includes(upgId)) return "already installed";
if (s.cash < upg.cost) return "can't afford it";
s.cash -= upg.cost;
d.upgrades.push(upgId);
pushEvent(s, `🔧 ${upg.name} installed in ${DEF[d.defId].name}`, "info");
return null;
}
export function toggleDevice(s: SimState, uid: number): string | null {
const d = s.devices.find((x) => x.uid === uid);
if (!d) return "missing device";
d.on = !d.on;
pushEvent(s, `${DEF[d.defId].name} switched ${d.on ? "ON" : "OFF"}`, "info");
return null;
}
function clapCost(s: SimState): number {
return 50 * (s.round + 1);
}
@ -168,13 +286,15 @@ export function breakerLimit(s: SimState): number {
}
function tierMult(s: SimState): number {
return BAL.TIERS[s.tier].mult;
// a collapsed generation earns half until you train your way out of it
return BAL.TIERS[s.tier].mult * (s.collapsed ? 0.5 : 1);
}
export function step(s: SimState) {
const dt = BAL.SIM_DT;
s.tick++;
s.t += dt;
const mods = computeMods(s);
// --- power pass ---
const limit = breakerLimit(s);
@ -185,15 +305,36 @@ export function step(s: SimState) {
d.util = d.on ? (def.tokRate ? (d.wiredTo !== null ? 1 : 0.1) : 1) : 0;
// furnaces don't care about your breaker — fire predates electricity
d.powered = def.burnRate ? d.on : d.on && !s.breakerTripped;
if (d.powered) watts += def.wattsIdle + (def.wattsLoad - def.wattsIdle) * d.util;
if (d.powered) {
const dm = deviceMods(d.upgrades);
const loadScale = def.tokRate ? mods.wattsPerTokMult * dm.watts : 1;
watts += def.wattsIdle + (def.wattsLoad - def.wattsIdle) * d.util * loadScale;
}
}
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");
// Grid Whisperer: a few seconds of grace before the trip
if (s.overSince === null) s.overSince = s.t;
if (s.t - s.overSince >= mods.breakerGraceS) {
s.breakerTripped = true;
watts = 0;
for (const d of s.devices) d.powered = DEF[d.defId].burnRate ? d.on : false;
s.overSince = null;
if (mods.diesel) {
s.dieselResetAt = s.t + 5;
pushEvent(s, "⚡ Breaker tripped — diesel generator coughing to life (5s)", "bad");
} else {
const loss = Math.round(BAL.CHECKPOINT_LOSS * 100);
s.trainTokens *= 1 - BAL.CHECKPOINT_LOSS;
pushEvent(s, `⚡ BREAKER TRIPPED — checkpoint corrupted (-${loss}% training)`, "bad");
}
}
} else if (watts <= limit) {
s.overSince = null;
}
if (s.breakerTripped && s.dieselResetAt !== null && s.t >= s.dieselResetAt) {
s.breakerTripped = false;
s.dieselResetAt = null;
pushEvent(s, "🛢 Diesel backup online — power restored", "info");
}
// --- heat pass ---
@ -201,7 +342,9 @@ export function step(s: SimState) {
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.heatOut)
heat[idx(d.x, d.y)] += def.heatOut * d.util * d.perf * dt * 2.2 *
(def.tokRate ? mods.heatMult * deviceMods(d.upgrades).heat : 1);
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);
@ -250,10 +393,10 @@ export function step(s: SimState) {
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;
if (routerAlive) tokRaw += def.tokRate * d.perf * mods.tokRateMult * deviceMods(d.upgrades).tok;
}
}
const demand = tokRaw * BAL.MBPS_PER_TOK;
const demand = tokRaw * BAL.MBPS_PER_TOK * mods.mbpsPerTokMult;
const dataMult = demand > 0 ? Math.min(1, uplink / demand) : 1;
const tokEff = tokRaw * dataMult;
@ -263,38 +406,34 @@ export function step(s: SimState) {
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;
s.platformAdapt *= BAL.ADAPT_TIER_RELIEF;
s.adaptStage = 0;
pushEvent(s, `🧠 MODEL TIER UP: ${nextTier.name} (revenue x${nextTier.mult})`, "good");
pushEvent(s, `😎 ${nextTier.name} slop slips right past the spam filters`, "good");
}
// training fills the research bank; the Lab decides what it buys
const src = DATA_SOURCES[s.dataSource];
s.trainTokens += tokTrain * src.rate;
income -= tokTrain * src.costPerTok; // licensed data bills per token
// platform adaptation drifts back down while you're not spamming
s.platformAdapt = Math.max(0, s.platformAdapt - BAL.ADAPT_DECAY_S * dt);
s.platformAdapt = Math.max(0, s.platformAdapt - BAL.ADAPT_DECAY_S * mods.adaptDecayMult * dt);
const stages = [0.25, 0.5, 0.75];
while (s.adaptStage < stages.length && s.platformAdapt >= stages[s.adaptStage]) {
s.adaptStage++;
pushEvent(s, `🤖 Platforms updated their spam filters (wariness ${Math.round(stages[s.adaptStage - 1] * 100)}%)`, "bad");
}
// slop videos
// slop videos — richer modalities cost more tokens per video but earn far more
const videoCost = BAL.VIDEO_COST_TOK * mods.videoCostMult;
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;
while (s.tokenBank >= videoCost) {
s.tokenBank -= videoCost;
const viralChance = BAL.VIRAL_CHANCE + mods.viralChanceAdd;
const viral = rand(s) < viralChance ? 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);
s.platformAdapt = Math.min(1, s.platformAdapt + BAL.ADAPT_PER_VIDEO * (1 - s.platformAdapt));
s.platformAdapt = Math.min(1, s.platformAdapt +
BAL.ADAPT_PER_VIDEO * mods.adaptPerVideoMult * (1 - s.platformAdapt));
if (viral > 6) pushEvent(s, `🔥 VIDEO WENT VIRAL (x${viral.toFixed(0)})`, "good");
if (s.videos.length > 40) s.videos.shift();
}
@ -323,7 +462,8 @@ export function step(s: SimState) {
}
income *= priceFx;
const adaptMult = (1 - s.platformAdapt * BAL.ADAPT_MAX_PENALTY) * hype * viewFx;
const adaptMult = (1 - s.platformAdapt * BAL.ADAPT_MAX_PENALTY) * hype * viewFx *
mods.viewsPerVideoMult * mods.conveyorHype;
for (const v of s.videos) {
if (v.dead) continue;
const age = s.t - v.born;
@ -340,7 +480,8 @@ export function step(s: SimState) {
const got = s.totalViews - s.mandate.startViews;
if (got >= s.mandate.target) {
s.round++;
pushEvent(s, `🏆 ${s.mandate.name} mandate CRUSHED (${(got / 1000).toFixed(0)}K views) — the board loves you`, "good");
s.perkPoints++;
pushEvent(s, `🏆 ${s.mandate.name} mandate CRUSHED (${(got / 1000).toFixed(0)}K views) — board grants an R&D perk point`, "good");
s.mandate = null;
} else if (s.t > s.mandate.deadline) {
s.cash *= 1 - BAL.MANDATE_FAIL_CASH_CUT;
@ -366,6 +507,9 @@ export function step(s: SimState) {
income *= s.equityMult;
// THE CONVEYOR: a fixed cut of gross income rides the belt into the furnace
if (mods.conveyorPct > 0 && income > 0) income *= 1 - mods.conveyorPct;
// --- costs & bookkeeping ---
const powerCost = ((watts / 1000) * BAL.KWH_PRICE * dt) / 3600;
s.cash += income - powerCost;

View File

@ -11,6 +11,7 @@ export interface Device {
temp: number;
perf: number; // thermal multiplier 0..1
util: number; // current utilization 0..1
upgrades: string[]; // socket upgrade ids
}
export interface Video {
@ -45,6 +46,17 @@ export interface SimState {
effects: { id: string; label: string; viewMult?: number; priceMult?: number; expires: number }[];
nextRivalT: number;
clapback: { effectId: string; label: string; cost: number; expires: number } | null;
// Model Lab
modalities: string[]; // unlocked modality node ids
spec: string | null; // per-generation specialization (cleared on gen-up)
dataSource: "own" | "scraped" | "licensed";
collapsed: boolean; // current generation suffered model collapse
// Corporate R&D
perkPoints: number;
rndNodes: string[];
// breaker grace bookkeeping
overSince: number | null;
dieselResetAt: number | null;
tokenBank: number;
videos: Video[];
rates: {
@ -86,6 +98,14 @@ export function createState(): SimState {
effects: [],
nextRivalT: 0,
clapback: null,
modalities: [],
spec: null,
dataSource: "scraped",
collapsed: false,
perkPoints: 0,
rndNodes: [],
overSince: null,
dieselResetAt: null,
tokenBank: 0,
videos: [],
rates: {

View File

@ -100,6 +100,29 @@
animation: flashfade 0.7s forwards; }
@keyframes flashfade { 0% { opacity: 0; } 15% { opacity: 1; } 100% { opacity: 0; } }
/* Lab / R&D modals + device panel */
.modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(720px, 90vw); max-height: 82vh; overflow-y: auto; padding: 14px 18px; z-index: 50; }
.mhead { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.mhead h2 { margin: 0; font-size: 15px; letter-spacing: 0.12em; color: var(--purple); }
.mhead .bank { color: var(--cyan); font-weight: 700; }
.mclose { margin-left: auto; background: none; border: 1px solid var(--edge); color: var(--dim);
border-radius: 6px; cursor: pointer; font: inherit; padding: 3px 8px; }
.mcols { display: flex; gap: 14px; align-items: flex-start; }
.mcol { flex: 1; min-width: 0; }
.mcol h4 { margin: 10px 0 6px; font-size: 10px; color: var(--dim); letter-spacing: 0.15em; }
.node { display: block; width: 100%; text-align: left; border: 1px solid var(--edge); background: #12121f;
border-radius: 8px; padding: 7px 9px; margin-bottom: 6px; font: inherit; color: var(--text); font-size: 11.5px; font-weight: 700; }
.node small { display: block; font-weight: 400; color: var(--dim); font-size: 10px; margin-top: 2px; }
.node.buy { cursor: pointer; }
.node.buy:hover { border-color: var(--cyan); }
.node.owned { border-color: var(--green); background: #0f2018; }
.node.dim { opacity: 0.45; }
#devpanel { position: absolute; right: 215px; bottom: 12px; width: 230px; padding: 10px; z-index: 40; }
#devpanel .devstats { color: var(--dim); font-size: 10.5px; margin-bottom: 6px; }
#devpanel .devbtns { display: flex; gap: 6px; margin-bottom: 4px; }
#devpanel h4 { margin: 8px 0 4px; font-size: 10px; color: var(--dim); letter-spacing: 0.15em; }
#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); }

View File

@ -58,6 +58,8 @@ export class Hud {
<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="lab-btn">🧪 Model Lab (L)</button>
<button class="chip" id="rnd-btn">🏢 R&D (R)</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>
@ -229,7 +231,7 @@ 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;
q("#tier").textContent = BAL.TIERS[s.tier].name + (s.collapsed ? " 🌀" : "");
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";

157
src/ui/panels.ts Normal file
View File

@ -0,0 +1,157 @@
// Model Lab modal, Corporate R&D modal, and the device inspector panel.
import { BAL } from "../sim/balance";
import { SimState } from "../sim/state";
import { DEF } from "../sim/catalog";
import {
MODALITIES, SPECS, SPEC_COST_BY_GEN, DATA_SOURCES, RND, SOCKETS, SOCKET_SLOTS, DataSource,
} from "../sim/research";
import {
unlockGen, unlockModality, setSpec, setDataSource, unlockRnd, buyUpgrade, toggleDevice, sell,
} from "../sim/sim";
import { pushEvent } from "../sim/state";
import { sfx } from "./sfx";
const fmt = (n: number) =>
n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "K" : String(Math.round(n));
export class Panels {
state: SimState;
open: "lab" | "rnd" | null = null;
selectedDevice: number | null = null;
root: HTMLElement;
constructor(state: SimState) {
this.state = state;
this.root = document.createElement("div");
this.root.id = "panels";
document.getElementById("hud")!.appendChild(this.root);
this.root.addEventListener("click", (e) => this.dispatch(e));
}
toggle(which: "lab" | "rnd") {
this.open = this.open === which ? null : which;
sfx.click();
this.render();
}
closeAll() {
this.open = null;
this.selectedDevice = null;
this.render();
}
private dispatch(e: Event) {
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-cmd]");
if (!el) return;
const s = this.state;
const { cmd, arg } = el.dataset;
let err: string | null = null;
switch (cmd) {
case "close": this.closeAll(); return;
case "gen": err = unlockGen(s); break;
case "modality": err = unlockModality(s, arg!); break;
case "spec": err = setSpec(s, arg!); break;
case "data": err = setDataSource(s, arg as DataSource); break;
case "rnd": err = unlockRnd(s, arg!); break;
case "upg": err = buyUpgrade(s, this.selectedDevice!, arg!); break;
case "toggle": err = toggleDevice(s, this.selectedDevice!); break;
case "sell-dev":
if (this.selectedDevice !== null) { sell(s, this.selectedDevice); sfx.sell(); this.selectedDevice = null; }
this.render();
return;
}
if (err) { pushEvent(s, err, "info"); sfx.error(); }
else if (cmd === "gen" || cmd === "modality" || cmd === "spec" || cmd === "rnd") sfx.tierUp();
else sfx.click();
this.render();
}
render() {
const s = this.state;
let html = "";
if (this.open === "lab") html += this.labHtml(s);
if (this.open === "rnd") html += this.rndHtml(s);
if (this.selectedDevice !== null) {
const d = s.devices.find((x) => x.uid === this.selectedDevice);
if (d) html += this.deviceHtml(s, d);
else this.selectedDevice = null;
}
this.root.innerHTML = html;
}
private labHtml(s: SimState): string {
const gen = BAL.TIERS[s.tier];
const next = BAL.TIERS[s.tier + 1];
const specCost = SPEC_COST_BY_GEN[Math.min(s.tier, SPEC_COST_BY_GEN.length - 1)];
const bank = s.trainTokens;
return `<div class="modal panel" id="lab">
<div class="mhead"><h2>🧪 MODEL LAB</h2>
<span class="bank">${fmt(bank)} training tok</span>
<button class="mclose" data-cmd="close"></button></div>
<div class="mcols">
<div class="mcol">
<h4>BASE MODEL</h4>
<div class="node owned">${gen.name} ${s.collapsed ? "🌀 COLLAPSED" : ""}<small>revenue x${gen.mult}${s.collapsed ? " → x" + gen.mult / 2 : ""}</small></div>
${next
? `<button class="node buy ${bank >= next.tokens! ? "" : "dim"}" data-cmd="gen">Train ${next.name}<small>x${next.mult} revenue · resets tuning · ${fmt(next.tokens!)} tok</small></button>`
: `<div class="node dim">No bigger model left</div>`}
<h4>FINE-TUNE (${gen.name})</h4>
${SPECS.map((n) => s.spec === n.id
? `<div class="node owned">${n.name}<small>${n.desc}</small></div>`
: `<button class="node buy ${bank >= specCost ? "" : "dim"}" data-cmd="spec" data-arg="${n.id}">${n.name}<small>${n.desc} · ${fmt(specCost)} tok</small></button>`).join("")}
</div>
<div class="mcol">
<h4>MODALITIES</h4>
${MODALITIES.map((n) => {
if (s.modalities.includes(n.id)) return `<div class="node owned">${n.name}<small>${n.desc}</small></div>`;
const missing = (n.requires ?? []).filter((r) => !s.modalities.includes(r));
if (missing.length) return `<div class="node dim">${n.name}<small>requires ${missing.join(", ")}</small></div>`;
return `<button class="node buy ${bank >= n.cost ? "" : "dim"}" data-cmd="modality" data-arg="${n.id}">${n.name}<small>${n.desc} · ${fmt(n.cost)} tok</small></button>`;
}).join("")}
<h4>TRAINING DATA</h4>
${(Object.keys(DATA_SOURCES) as DataSource[]).map((k) => {
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("")}
</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" };
return `<div class="modal panel" id="rnd">
<div class="mhead"><h2>🏢 CORPORATE R&D</h2>
<span class="bank">${s.perkPoints} perk point${s.perkPoints === 1 ? "" : "s"} · close VC rounds for more</span>
<button class="mclose" data-cmd="close"></button></div>
<div class="mcols">
${branches.map((b) => `<div class="mcol"><h4>${titles[b]}</h4>
${RND.filter((n) => n.branch === b).map((n) => {
if (s.rndNodes.includes(n.id)) return `<div class="node owned">${n.name}<small>${n.desc}</small></div>`;
const missing = (n.requires ?? []).filter((r) => !s.rndNodes.includes(r));
if (missing.length) return `<div class="node dim">${n.name}<small>requires ${missing.map((m) => RND.find((x) => x.id === m)?.name).join(" + ")}</small></div>`;
const ok = s.perkPoints >= 1 && s.cash >= n.cash;
return `<button class="node buy ${ok ? "" : "dim"}" data-cmd="rnd" data-arg="${n.id}">${n.name}<small>${n.desc} · $${fmt(n.cash)} + 1pt</small></button>`;
}).join("")}</div>`).join("")}
</div></div>`;
}
private deviceHtml(s: SimState, d: (typeof s.devices)[number]): string {
const def = DEF[d.defId];
const isCompute = def.tokRate > 0;
return `<div class="panel" id="devpanel">
<div class="mhead"><b>${def.name}</b><button class="mclose" data-cmd="close"></button></div>
<div class="devstats">${Math.round(d.temp)}°C · ${Math.round(d.util * 100)}% util · perf ${(d.perf * 100).toFixed(0)}%${isCompute ? ` · ${def.tokRate} tok/s base` : ""}</div>
<div class="devbtns">
<button class="chip" data-cmd="toggle">${d.on ? "⏻ Turn OFF" : "⏻ Turn ON"}</button>
<button class="chip" data-cmd="sell-dev">💸 Sell (+$${Math.round(def.cost * 0.5)})</button>
</div>
${isCompute ? `<h4>SOCKETS (${d.upgrades.length}/${SOCKET_SLOTS})</h4>
${d.upgrades.map((u) => `<div class="node owned">${SOCKETS.find((x) => x.id === u)?.name}</div>`).join("")}
${d.upgrades.length < SOCKET_SLOTS
? SOCKETS.filter((u) => !d.upgrades.includes(u.id)).map((u) =>
`<button class="node buy ${s.cash >= u.cost ? "" : "dim"}" data-cmd="upg" data-arg="${u.id}">${u.name}<small>${u.desc} · $${u.cost}</small></button>`).join("")
: ""}` : ""}
</div>`;
}
}