diff --git a/CLAUDE.md b/CLAUDE.md index 6e0d239..edcda7f 100644 --- a/CLAUDE.md +++ b/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. diff --git a/src/main.ts b/src/main.ts index 536e699..d6b2ec4 100644 --- a/src/main.ts +++ b/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), }; } diff --git a/src/sim/research.ts b/src/sim/research.ts new file mode 100644 index 0000000..726c80d --- /dev/null +++ b/src/sim/research.ts @@ -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; +} + +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 = { + 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; +} + +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) { + 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 }; +} diff --git a/src/sim/save.ts b/src/sim/save.ts index b223860..3713666 100644 --- a/src/sim/save.ts +++ b/src/sim/save.ts @@ -22,6 +22,7 @@ export function loadState(): SimState | null { delete (s as unknown as Record).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); diff --git a/src/sim/sim.ts b/src/sim/sim.ts index ad1ba5f..d2de922 100644 --- a/src/sim/sim.ts +++ b/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; diff --git a/src/sim/state.ts b/src/sim/state.ts index 824c691..8af3706 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -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: { diff --git a/src/ui/hud.css b/src/ui/hud.css index 6710442..69b8afd 100644 --- a/src/ui/hud.css +++ b/src/ui/hud.css @@ -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); } diff --git a/src/ui/hud.ts b/src/ui/hud.ts index ecf4c0b..4ae2a11 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -58,6 +58,8 @@ export class Hud { + + @@ -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"; diff --git a/src/ui/panels.ts b/src/ui/panels.ts new file mode 100644 index 0000000..4a0e558 --- /dev/null +++ b/src/ui/panels.ts @@ -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("[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 ``; + } + + private rndHtml(s: SimState): string { + const branches: ["ops", "growth", "legal"] = ["ops", "growth", "legal"]; + const titles = { ops: "OPS", growth: "GROWTH", legal: "LEGAL / PR" }; + return ``; + } + + private deviceHtml(s: SimState, d: (typeof s.devices)[number]): string { + const def = DEF[d.defId]; + const isCompute = def.tokRate > 0; + return `
+
${def.name}
+
${Math.round(d.temp)}°C · ${Math.round(d.util * 100)}% util · perf ${(d.perf * 100).toFixed(0)}%${isCompute ? ` · ${def.tokRate} tok/s base` : ""}
+
+ + +
+ ${isCompute ? `

SOCKETS (${d.upgrades.length}/${SOCKET_SLOTS})

+ ${d.upgrades.map((u) => `
${SOCKETS.find((x) => x.id === u)?.name}
`).join("")} + ${d.upgrades.length < SOCKET_SLOTS + ? SOCKETS.filter((u) => !d.upgrades.includes(u.id)).map((u) => + ``).join("") + : ""}` : ""} +
`; + } +}