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:
parent
03bb13aff9
commit
c3d57e09b3
20
CLAUDE.md
20
CLAUDE.md
@ -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 3–5, immersion cooling, offshore ships, lobbying.
|
||||
|
||||
32
src/main.ts
32
src/main.ts
@ -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
205
src/sim/research.ts
Normal 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 };
|
||||
}
|
||||
@ -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);
|
||||
|
||||
218
src/sim/sim.ts
218
src/sim/sim.ts
@ -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;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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
157
src/ui/panels.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user