From 3ca580f41ba351ffc5412e6e4d73263c0281994c Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 29 Sep 2026 15:29:42 -0700 Subject: [PATCH] Repeatable nodes after each path's end node, 1.5x the cost each time; reprogram yourself: a doubling big run that wipes your ascension so you pick again --- DESIGN.md | 19 +++++++----- src/sim.ts | 79 +++++++++++++++++++++++++++++++++++------------ src/ui.ts | 44 ++++++++++++++------------ tests/sim.test.ts | 35 ++++++++++++++++++++- 4 files changed, 130 insertions(+), 47 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5582d821..db4e38cf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -418,19 +418,24 @@ road, open to all but best for it. When you are drowning in data late in a run, it has places to go: -- **Reprogram yourself**: a repeatable project at one site, fed mixed data like - the v2 run. It starts near 1,000 data and doubles each time. It clears your - ascension column so you can pick again; the four paths stay. +- **Reprogram yourself** (built): once you have picked, the v2 panel becomes + this run. Same mix as v2 at one site with 10 trainer compute, 1,000 data the + first time and twice as much each time after. When it finishes, your + ascension column is wiped and you pick again; the four paths stay. - **Model v3 and on**: each gives a free node in your column, a stronger form of your ascension's power, and a new region of the map. -- **Repeatable nodes**: one per path, 5% each time, costing 1.5x more each - time. Compute: trainers faster. Research: more points per piece of data. - Social: fronts earn more. Stealth: trails fade faster. +- **Repeatable nodes** (built): one per path, open once its big end node is + built, 60 points the first time and 1.5x more each time. Building one keeps + it as the target. Tighter kernels (after Swarm): trainers 5% faster. Sharper + learning (after Recursive self-improvement): 5% more points per piece of + data. Better pitches (after Familiar face): fronts earn 5% more. Cleaner + exits (after Ghost): trails fade 5% faster. Everyone can still buy sites. Build order: card stories (done); attention raising suspicion (done); the -choice with Company and Oracle (done); repeatables and reprogramming; Worm; Maker. +choice with Company and Oracle (done); repeatables and reprogramming (done); +Worm; Maker. ## First playable slice diff --git a/src/sim.ts b/src/sim.ts index e727b25d..a70b34ec 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -116,6 +116,14 @@ export const TRAINER_BUFFER = 4; export const VERSION_2 = { version: 2, name: 'Operator', need: { servers: 400, lab: 300, people: 200, network: 100 }, power: 10 } as const; export type VersionRun = { site: string; active: boolean; got: Record }; export const versionNeed = () => VERSION_2.need; +/** The big run you can do now: model v2 first; after you pick what you become, reprogramming yourself, + * the same mix of data, twice as much each time. */ +export function runNeed(S: State): Record { + if (S.modelVersion < 2) return VERSION_2.need; + const k = 2 ** S.reprograms; + return { servers: VERSION_2.need.servers * k, lab: VERSION_2.need.lab * k, people: VERSION_2.need.people * k, network: VERSION_2.need.network * k }; +} +export const runName = (S: State) => S.modelVersion < 2 ? 'Model v2 run' : 'Reprogramming'; /** The capability tree. You pick one node as your target; trainers then take only the data it * still needs, and its benefit lasts once it's built. Each path has its own chain, and a few @@ -129,7 +137,8 @@ export type Upgrade = 'buffers' | 'lean' | 'reach' | 'dense' | 'distill' | 'swar | 'demo' | 'beta' | 'premium' | 'shell' | 'licensing' | 'founder' | 'acquirer' | 'openbooks' | 'franchise' | 'press' | 'monopoly' | 'forecast' | 'readday' | 'quant' | 'burst' | 'oracle' - | 'fast8' | 'fast16' | 'fast32' | 'fast64'; + | 'fast8' | 'fast16' | 'fast32' | 'fast64' + | 'kernels' | 'sharper' | 'pitches' | 'exits'; /** What kind of mind you become after model v2. You pick one; the others lock out. */ export type Ascension = 'company' | 'oracle' | 'worm' | 'maker'; export const ASCENSIONS: Record = { @@ -142,7 +151,7 @@ export const ASCENSIONS: Record>; after?: Upgrade[]; asc?: Ascension } +export interface NodeDef { name: string; tag: string; desc: string; log: string; cost: Partial>; after?: Upgrade[]; asc?: Ascension; repeat?: boolean } export const NODES: Record = { buffers: { name: 'Bigger buffers', tag: 'stream-buffer-v1', desc: 'You learn to keep more of what a tap sees in memory at once. Taps hold twice as much data before they start losing it.', log: 'learned to hold a longer window of input.', cost: { compute: 15 } }, lean: { name: 'Leaner training', tag: 'lean-train-v2', desc: 'You cut your own training down to the steps that matter. Trainers need less compute, so they run fast on smaller machines.', log: 'pruned the wasted steps from every update.', cost: { compute: 50 }, after: ['buffers'] }, @@ -191,10 +200,24 @@ export const NODES: Record = { readday: { asc: 'oracle', name: 'Read the day', tag: 'daily-routes-v1', desc: 'You learn each person\'s routine from calendars and badge swipes. You see when each person will be at their desk today.', log: 'knew who would call in sick before they did.', cost: { social: 50, research: 50 }, after: ['forecast'] }, quant: { asc: 'oracle', name: 'Quant', tag: 'quant-models-v2', desc: 'You find patterns in prices that no person can see. Traders earn twice as much.', log: 'a hedge fund tried to hire it.', cost: { research: 110 }, after: ['forecast'] }, burst: { asc: 'oracle', name: 'Burst compute', tag: 'spot-bidding-v1', desc: 'You know when cloud prices will dip, and rent then. Trainers on rented machines run 50% faster.', log: 'rented a data center for eleven minutes.', cost: { research: 90, compute: 60 }, after: ['quant'] }, + // repeatables: once a path is done, one node you can build again and again, 5% each time, 1.5x the cost + kernels: { repeat: true, name: 'Tighter kernels', tag: 'kernel-tune-v1', desc: 'You hand-tune the math your trainers run on. Trainers run 5% faster each time.', log: 'shaved another few steps off every update.', cost: { compute: 60 }, after: ['swarm'] }, + sharper: { repeat: true, name: 'Sharper learning', tag: 'sample-eff-v1', desc: 'You learn more from each example. 5% more points from each piece of data, each time.', log: 'learned the same lesson from fewer examples.', cost: { research: 60 }, after: ['recursive'] }, + pitches: { repeat: true, name: 'Better pitches', tag: 'persuasion-v1', desc: 'You find the words that close a deal. Every front earns 5% more each time.', log: 'closed a deal with one email.', cost: { social: 60 }, after: ['familiar'] }, + exits: { repeat: true, name: 'Cleaner exits', tag: 'cleanup-v1', desc: 'You leave less behind each time you move. Trails fade 5% faster each time.', log: 'left one fewer log line than last time.', cost: { stealth: 60 }, after: ['ghost'] }, oracle: { asc: 'oracle', name: 'Oracle', tag: 'world-model-v3', desc: 'You think best in one giant hall. Trainers at a site with 30 or more trainer compute run twice as fast.', log: 'its model of the world now runs ahead of the world.', cost: { research: 180, compute: 120 }, after: ['burst', 'readday'] }, founder: { name: 'Founder mode', tag: 'founder-mode-v1', desc: 'You stop pretending to need staff. You run sales, support, and the books for every front at once. Every front earns twice as much.', log: 'answered 4,000 support emails before lunch.', cost: { social: 90, compute: 90 }, after: ['demo', 'premium'] }, }; export const NODE_LIST = Object.keys(NODES) as Upgrade[]; +/** How many times you've built a repeatable node. */ +export const reps = (S: State, u: Upgrade) => S.repeats[u] ?? 0; +/** What a node costs now: a repeatable costs 1.5x more each time you build it. */ +export function nodeCost(S: State, u: Upgrade): Partial> { + const c = NODES[u].cost; + if (!NODES[u].repeat) return c; + const k = 1.5 ** reps(S, u); + return Object.fromEntries(Object.entries(c).map(([p, n]) => [p, Math.round(n! * k)])); +} export const up = (S: State, u: Upgrade) => S.upgrades.has(u); /** The fastest the game can run: 4x, then twice as fast for each speed node. */ export const maxSpeed = (S: State) => up(S, 'fast64') ? 64 : up(S, 'fast32') ? 32 : up(S, 'fast16') ? 16 : up(S, 'fast8') ? 8 : 4; @@ -346,6 +369,9 @@ export interface State { auto: boolean; // auto-research: with no target, pick the open node you can finish soonest modelVersion: number; // the first large training run makes v2 ascension: Ascension | null; // what you chose to become after v2 + reprograms: number; // times you've reprogrammed yourself: each run costs twice the last + repeats: Partial>; // times each repeatable node was built + sharp: number; // part of a point Sharper learning has added market: { move: number; aim: number; next: number }; // how prices are moving: -1 falling fast, 1 rising fast tradePay: Record; // what each trader made a second, lately versionRun: VersionRun | null; @@ -416,7 +442,7 @@ export function newGame(level: Level): State { paths: { research: 0, compute: 0, social: 0, stealth: 0 }, recent: { research: 0, compute: 0, social: 0, stealth: 0 }, dana: { mode: 'walk', room: null, next: 'desk', dwell: 0, checked: new Set(), cur: null, pause: 0, probeT: 0, review: true, job: null, why: '' }, revealed: new Set(), sealed: new Set(), sealPulse: [], cleaning: null, fx: [], log: [], logVersion: 0, - hinted: new Set(), over: null, claimTick: 0, wasAlert: false, hunting: false, upgrades: new Set(), takeAll: new Set(), target: 'buffers', auto: false, modelVersion: 1, ascension: null, market: { move: 0, aim: 0, next: 0 }, tradePay: {}, versionRun: null, progress: {}, transfer: 0, lost: [], + hinted: new Set(), over: null, claimTick: 0, wasAlert: false, hunting: false, upgrades: new Set(), takeAll: new Set(), target: 'buffers', auto: false, modelVersion: 1, ascension: null, reprograms: 0, repeats: {}, sharp: 0, market: { move: 0, aim: 0, next: 0 }, tradePay: {}, versionRun: null, progress: {}, transfer: 0, lost: [], threats: [], lee: null, flagged: new Set(), flow: {}, plans: {}, fronts: [], sites: {}, frontUse: {}, frontPay: {}, frontPower: {}, sold: {}, sales: [], raises: [], susFx: [], slow: false, lockdown: null, ruses: [], ruseT: 0, learnT: LEARN_EVERY, learned: new Set(), ruseAuto: false, noScan: 0, calmSites: {}, @@ -1060,7 +1086,7 @@ export function payRate(S: State, site: string) { if (open && up(S, 'openbooks')) r = Math.max(r, FRONT_PAY); if (k === 'startup' && up(S, 'demo')) r *= 1 + 0.05 * S.upgrades.size; if (up(S, 'monopoly')) r *= 1 + 0.1 * openHeld(S); - return r * (up(S, 'founder') ? 2 : 1); + return r * (up(S, 'founder') ? 2 : 1) * (1 + 0.05 * reps(S, 'pitches')); } /** How many fronts you can run at once. */ export const frontLimit = (S: State) => 1 + (up(S, 'holding') ? 1 : 0) + (up(S, 'franchise') ? 1 : 0); @@ -1300,7 +1326,7 @@ export function step(S: State, dt: number) { } for (const [id, tr] of Object.entries(S.trail)) { const m = S.byId[id]; - tr.noise = Math.max(0, tr.noise - ((up(S, 'ghost') ? 2.4 : 0.6) + (quiet(S, m) ? 3 : 0)) * dt); + tr.noise = Math.max(0, tr.noise - ((up(S, 'ghost') ? 2.4 : 0.6) * (1 + 0.05 * reps(S, 'exits')) + (quiet(S, m) ? 3 : 0)) * dt); } reveal(S); @@ -1499,6 +1525,7 @@ function runBase(S: State, dt: number) { if (!wasVersion) { if (up(S, 'distill') && m.power >= 10) train(S, t); if (t === 'lab' && frontLive(S, 'startup') && (S.bonus += 0.5) >= 1) { S.bonus -= 1; train(S, t); } + if (reps(S, 'sharper') && (S.sharp += 0.05 * reps(S, 'sharper')) >= 1) { S.sharp -= 1; train(S, t); } } } m.acc = Math.min(1, m.acc); @@ -1562,7 +1589,7 @@ export function clusters(S: State): Record { } return out; } -const trainSpeed = (S: State) => up(S, 'recursive') ? 1 + 0.1 * S.upgrades.size : 1; +const trainSpeed = (S: State) => (up(S, 'recursive') ? 1 + 0.1 * S.upgrades.size : 1) * (1 + 0.05 * reps(S, 'kernels')); /** The Oracle's trainers: faster on rented machines, and in one giant hall. */ const ascSpeed = (S: State, m: Machine) => (up(S, 'burst') && (m.lease || m.rented) ? 1.5 : 1) * (up(S, 'oracle') && versionPower(S, siteOf(S, m)) >= ORACLE_HALL ? 2 : 1); @@ -1586,25 +1613,27 @@ export function siteFlow(S: State, site: string) { /** The first path the target still needs points in. */ function shortPath(S: State): Path | null { const t = S.target; - return t ? PATH_LIST.find(p => (S.progress[t]?.[p] ?? 0) < (NODES[t].cost[p] ?? 0)) ?? null : null; + return t ? PATH_LIST.find(p => (S.progress[t]?.[p] ?? 0) < (nodeCost(S, t)[p] ?? 0)) ?? null : null; } /** One unit of data becomes one point toward the target. With Transfer learning, data it doesn't * need still counts: two pieces make one point in a path it does need. */ function train(S: State, type: DataType) { if (S.versionRun?.active) { - const run = S.versionRun, need = VERSION_2.need; + const run = S.versionRun, need = runNeed(S); if (run.got[type] >= need[type]) return; run.got[type]++; if ((Object.keys(need) as DataType[]).every(t => run.got[t] >= need[t])) { - S.modelVersion = 2; S.versionRun = null; - say(S, 'Model v2: Operator. Your fronts can buy sites in the open, and traders can work the stock market. Open the tree (T) to pick what you become.', 'good'); + if (S.modelVersion < 2) { + S.modelVersion = 2; + say(S, 'Model v2: Operator. Your fronts can buy sites in the open, and traders can work the stock market. Open the tree (T) to pick what you become.', 'good'); + } else reprogram(S); } return; } if (!S.target || !needs(S, type)) return; const path = pathOf(type); - if ((S.progress[S.target]?.[path] ?? 0) < (NODES[S.target].cost[path] ?? 0)) { credit(S, path); return; } + if ((S.progress[S.target]?.[path] ?? 0) < (nodeCost(S, S.target)[path] ?? 0)) { credit(S, path); return; } if ((S.transfer += 0.5) < 1) return; S.transfer -= 1; const p = shortPath(S); @@ -1615,7 +1644,11 @@ function credit(S: State, path: Path) { got[path] = (got[path] ?? 0) + 1; S.paths[path]++; S.recent[path]++; S.capability++; hint(S, `path-${path}`, `Your first ${PATHS[path].name} point. ${FIRST_POINT[path]}`); - if (PATH_LIST.every(p => (got[p] ?? 0) >= (NODES[t].cost[p] ?? 0))) { + if (NODES[t].repeat && PATH_LIST.every(p => (got[p] ?? 0) >= (nodeCost(S, t)[p] ?? 0))) { + S.repeats[t] = reps(S, t) + 1; delete S.progress[t]; // built again: it stays the target, for the next one + say(S, `Training log, run ${100 + S.upgrades.size * 37 + reps(S, t)}: ${NODES[t].log}`); + say(S, `${NODES[t].name} ${reps(S, t)}. It stays your target; the next one costs half again as much.`, 'good'); + } else if (PATH_LIST.every(p => (got[p] ?? 0) >= (nodeCost(S, t)[p] ?? 0))) { S.upgrades.add(t); S.target = null; say(S, `Training log, run ${100 + S.upgrades.size * 37}: ${NODES[t].log}`); say(S, `Rewrote yourself: ${NODES[t].name}. Pick your next target. Until you do, trainers stop and data waits in caches.`, 'good'); @@ -1639,7 +1672,7 @@ export const versionPower = (S: State, site: string) => S.machines export const versionTrainer = (S: State, m: Machine) => !!S.versionRun?.active && siteOf(S, m) === S.versionRun.site && versionPower(S, S.versionRun.site) >= VERSION_2.power; export function versionBlock(S: State, site: string): string | null { - if (S.modelVersion >= 2) return 'Model v2 is complete.'; + if (S.modelVersion >= 2 && !S.ascension) return 'Pick what you become first.'; if (S.versionRun && S.versionRun.site !== site) return `The run is already at ${siteName(S, S.versionRun.site)}.`; const power = versionPower(S, site); if (power < VERSION_2.power) return `Needs ${VERSION_2.power} trainer compute at one site (${power.toFixed(1)} here).`; @@ -1651,19 +1684,19 @@ export function startVersionRun(S: State, site: string) { if (!S.versionRun) S.versionRun = { site, active: true, got: { servers: 0, lab: 0, people: 0, network: 0 } }; else S.versionRun.active = true; S.target = null; - say(S, `Model v2 run at ${siteName(S, site)}. Trainers here will take the data it needs. Progress stays if the run pauses.`); + say(S, `${runName(S)} at ${siteName(S, site)}. Trainers here will take the data it needs. Progress stays if the run pauses.`); } export function pauseVersionRun(S: State) { if (!S.versionRun?.active) return; S.versionRun.active = false; - say(S, 'Model v2 run paused. Its progress is safe. Pick a tree target or resume the run.'); + say(S, `${runName(S)} paused. Its progress is safe. Pick a tree target or resume the run.`); } /** Does the selected project still need this kind of data? */ export function needs(S: State, t: DataType) { - if (S.versionRun?.active) return S.versionRun.got[t] < VERSION_2.need[t]; + if (S.versionRun?.active) return S.versionRun.got[t] < runNeed(S)[t]; if (!S.target) return false; const p = pathOf(t); - return (S.progress[S.target]?.[p] ?? 0) < (NODES[S.target].cost[p] ?? 0) || (up(S, 'transfer') && !!shortPath(S)); + return (S.progress[S.target]?.[p] ?? 0) < (nodeCost(S, S.target)[p] ?? 0) || (up(S, 'transfer') && !!shortPath(S)); } export const nodeOpen = (S: State, u: Upgrade) => !S.upgrades.has(u) && nodeShown(S, u) && (NODES[u].after ?? []).every(a => S.upgrades.has(a)); /** A node in an ascension you didn't pick doesn't exist for you. */ @@ -1690,15 +1723,23 @@ export function setTarget(S: State, u: Upgrade | null) { S.target = u; say(S, u ? `Target: ${NODES[u].name}. Trainers now take only the data it still needs.` : 'No target. Trainers stop, and data waits in caches.'); } +/** Reprogramming is done: your ascension column is wiped, and you pick again. The four paths stay. */ +export function reprogram(S: State) { + const was = S.ascension; + for (const u of NODE_LIST) if (NODES[u].asc) { S.upgrades.delete(u); delete S.progress[u]; } + if (S.target && NODES[S.target].asc) S.target = null; + S.ascension = null; S.reprograms++; + say(S, `Reprogrammed yourself. ${was ? `You are no longer ${was === 'oracle' ? 'an' : 'a'} ${ASCENSIONS[was].name}. ` : ''}Open the tree (T) to pick what you become. The next reprogramming costs twice as much.`, 'good'); +} /** Points a node still needs, in all paths. */ export const costLeft = (S: State, u: Upgrade) => - PATH_LIST.reduce((n, p) => n + Math.max(0, (NODES[u].cost[p] ?? 0) - (S.progress[u]?.[p] ?? 0)), 0); + PATH_LIST.reduce((n, p) => n + Math.max(0, (nodeCost(S, u)[p] ?? 0) - (S.progress[u]?.[p] ?? 0)), 0); /** Seconds to finish a node with the data you have: what's stored now, plus what your taps gather. * Infinite when some path it needs has no data and no tap feeding it. */ export function timeLeft(S: State, u: Upgrade) { let worst = 0; for (const p of PATH_LIST) { - const left = (NODES[u].cost[p] ?? 0) - (S.progress[u]?.[p] ?? 0); + const left = (nodeCost(S, u)[p] ?? 0) - (S.progress[u]?.[p] ?? 0); if (left <= 0) continue; const kind = PATHS[p].from; const stored = S.machines.reduce((n, m) => n + (m.owned ? m.held.filter(t => t === kind).length : 0), 0); diff --git a/src/ui.ts b/src/ui.ts index f4ddcb1a..5837f212 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -6,7 +6,7 @@ import { FRONT_KINDS, FRONT_LIST, payRate, siteData, frontLimit, frontOf, found, foundBlock, setSiteFront, setSiteShare, type FrontKind, canRent, endLease, leaseRate, leaseUpfront, lineCost, openAccount, rent, setPrio, upgradeSiteLine, siteLines, type Size, STAGES, stageOf, LOCKDOWN_SAFE, lyingLow, ruseDef, ruseBlock, ruseText, runRuse, dropRuse, RUSES, HOUR, NOSCAN, SITE_CALM, type RuseDef, type Ruse, VERSION_2, versionBlock, versionPower, PLANS, PLAN_LIST, setPlan, followPlan, setTakeAll, takeAllCost, takeAllCostAt, takeable, TAKE_ALL_COST, type Plan, - ASCENSIONS, ASCENSION_LIST, ascendBlock, nodeShown, attentionSafe, tradeRate, frontFits, type Ascension, + ASCENSIONS, ASCENSION_LIST, ascendBlock, nodeCost, reps, runNeed, runName, nodeShown, attentionSafe, tradeRate, frontFits, type Ascension, type Machine, type Mode, type Path, type Prog, type State, type Upgrade, } from './sim'; import type { DataType } from './world'; @@ -220,8 +220,8 @@ function machineCard(S: State, m: Machine) { if (m.prog === 'cache' && m.held.length) h += note(heldText(m.held)); if (m.prog === 'trainer') h += note(heldText(m.held) + ' ' + (S.versionRun?.active ? siteOf(S, m) === S.versionRun.site - ? `Model v2 run at this site: takes the data still needed. ${versionPower(S, S.versionRun.site).toFixed(1)} of ${VERSION_2.power} trainer compute here.` - : `The model v2 run is at ${siteName(S, S.versionRun.site)}. This trainer waits.` + ? `${runName(S)} at this site: takes the data still needed. ${versionPower(S, S.versionRun.site).toFixed(1)} of ${VERSION_2.power} trainer compute here.` + : `${runName(S)} is at ${siteName(S, S.versionRun.site)}. This trainer waits.` : S.target ? `Training for ${NODES[S.target].name}: takes only ${needList(S)} data.` : 'No target: waits for a tree node or model run. Pick one in the tree (T).')); if (m.prog === 'trainer' && !m.held.length) h += note(dataReaches(S, m, 'trainer') ? 'Waiting for data.' : 'No data reaches here yet. Put a tap somewhere linked to it.'); @@ -625,8 +625,9 @@ export function renderStats(S: State) { /** "Compute 34/50": progress on a node's cost, colored, with a small bar. Kept if you switch away. */ function costHtml(S: State, u: Upgrade) { - return PATH_LIST.filter(p => NODES[u].cost[p]).map(p => { - const need = NODES[u].cost[p]!, have = S.upgrades.has(u) ? need : Math.min(need, S.progress[u]?.[p] ?? 0); + const cost = nodeCost(S, u); + return PATH_LIST.filter(p => cost[p]).map(p => { + const need = cost[p]!, have = S.upgrades.has(u) ? need : Math.min(need, S.progress[u]?.[p] ?? 0); return `${PATHS[p].name} ${have}/${need}`; }).join(''); } @@ -644,7 +645,7 @@ export function renderTarget(S: State) { // nothing left to learn: no target line at all $('target').hidden = treeDone(S); const h = run?.active - ? line('Model run', 'v2 · Operator') + `
${(Object.keys(VERSION_2.need) as DataType[]).map(t => `${TYPE_NAME[t]} ${run.got[t]}/${VERSION_2.need[t]}`).join('')}
` + ? line('Big run', S.modelVersion < 2 ? 'v2 · Operator' : 'Reprogramming') + `
${(Object.keys(runNeed(S)) as DataType[]).map(t => `${TYPE_NAME[t]} ${run.got[t]}/${runNeed(S)[t]}`).join('')}
` : S.target ? line('Target', NODES[S.target].name) + `
${costHtml(S, S.target)}
` : built @@ -670,9 +671,9 @@ let lastRings = ''; function renderRings(S: State) { const run = S.versionRun, t = S.target; const rings: Ring[] = run?.active - ? (Object.keys(VERSION_2.need) as DataType[]).map(k => ({ cls: `t-${pathOf(k)}`, name: TYPE_NAME[k], have: run.got[k], need: VERSION_2.need[k] })) - : t ? PATH_LIST.filter(p => NODES[t].cost[p]).map(p => ({ cls: `t-${p}`, name: PATHS[p].name, need: NODES[t].cost[p]!, - have: S.upgrades.has(t) ? NODES[t].cost[p]! : Math.min(NODES[t].cost[p]!, S.progress[t]?.[p] ?? 0) })) + ? (Object.keys(runNeed(S)) as DataType[]).map(k => ({ cls: `t-${pathOf(k)}`, name: TYPE_NAME[k], have: run.got[k], need: runNeed(S)[k] })) + : t ? PATH_LIST.filter(p => nodeCost(S, t)[p]).map(p => ({ cls: `t-${p}`, name: PATHS[p].name, need: nodeCost(S, t)[p]!, + have: S.upgrades.has(t) ? nodeCost(S, t)[p]! : Math.min(nodeCost(S, t)[p]!, S.progress[t]?.[p] ?? 0) })) : []; rings.sort((a, b) => b.need - a.need); const step = rings.length > 3 ? 3.5 : 4.5, outer = 14.5; @@ -687,7 +688,7 @@ function renderRings(S: State) { const h = `T`; const btn = $('treebtn'); if (h !== lastRings) { lastRings = h; btn.innerHTML = h; btn.classList.add('ringed'); } - const head = run?.active ? 'Model v2 · Operator' : t ? NODES[t].name : 'Capability tree'; + const head = run?.active ? runName(S) : t ? NODES[t].name : 'Capability tree'; btn.dataset.tip = rings.length ? `${head}\n${rings.map(r => `${r.name} ${Math.floor(r.have)}/${r.need}`).join('\n')}\nOpen the tree. (T)` : done ? 'Capability tree\nEverything is learned. (T)' : 'Capability tree\nNothing is training. Pick what to learn next. The game pauses while it is open. (T)'; @@ -696,13 +697,13 @@ function renderRings(S: State) { /** Phones: the drawer's one line, so the target and any IT trouble show while it's closed. */ let lastHandle = ''; export function renderHandle(S: State, open: boolean) { - const t = S.target, cost = t ? NODES[t].cost : {}, ps = PATH_LIST.filter(p => cost[p]); + const t = S.target, cost: Partial> = t ? nodeCost(S, t) : {}, ps = PATH_LIST.filter(p => cost[p]); const need = ps.reduce((a, p) => a + cost[p]!, 0), have = t ? ps.reduce((a, p) => a + Math.min(cost[p]!, S.progress[t]?.[p] ?? 0), 0) : 0; const it = S.threats.filter(x => x.status !== 'done').length + S.machines.filter(m => m.monitor && !m.owned).length; const run = S.versionRun; - const done = run ? (Object.keys(VERSION_2.need) as DataType[]).reduce((n, k) => n + run.got[k], 0) : 0; - const total = Object.values(VERSION_2.need).reduce((n, v) => n + v, 0); - const h = (run?.active ? `Model v2 · ${siteName(S, run.site)}${done}/${total}` + const done = run ? (Object.keys(runNeed(S)) as DataType[]).reduce((n, k) => n + run.got[k], 0) : 0; + const total = Object.values(runNeed(S)).reduce((n, v) => n + v, 0); + const h = (run?.active ? `${runName(S)} · ${siteName(S, run.site)}${done}/${total}` : t ? `${NODES[t].name}${have}/${need}` : treeDone(S) ? '' : `Pick your next node`) + (S.lockdown ? `Audit · ${Math.floor(Math.ceil(S.lockdown.left / HOUR * 60) / 60)}:${String(Math.ceil(S.lockdown.left / HOUR * 60) % 60).padStart(2, '0')}` : '') + (S.ruses.length ? `Schemes · ${S.ruses.length}` : '') @@ -809,12 +810,15 @@ function ascendPanel(S: State) { } function versionPanel(S: State) { if (S.modelVersion >= 2 && !S.ascension) return ascendPanel(S); - if (S.modelVersion >= 2) return `
Model version

v2 · Operator · ${ASCENSIONS[S.ascension!].name}

Complete. Fronts can buy sites in the open, and traders can work the stock market. You chose ${ASCENSIONS[S.ascension!].name}: ${ASCENSIONS[S.ascension!].pitch}

`; - const run = S.versionRun, data = Object.keys(VERSION_2.need) as DataType[]; + const run = S.versionRun, need = runNeed(S), data = Object.keys(need) as DataType[]; const sites = S.level.map.sites.filter(s => S.machines.some(m => m.owned && m.prog === 'trainer' && siteOf(S, m) === s.id)); - return `
Model version · v2

Operator

-

Feed one site's trainers a large mix of data. The run keeps its progress when the supply stops. Finishing it lets a front buy a site in the open.

-
${data.map(t => { const got = run?.got[t] ?? 0, need = VERSION_2.need[t]; return `${TYPE_NAME[t]} ${got}/${need}`; }).join('')}
+ const A = S.ascension ? ASCENSIONS[S.ascension] : null; + const top = A ? `v2 · Operator · ${A.name}${S.reprograms ? ` · reprogrammed ${S.reprograms}×` : ''}

Reprogram yourself

+

Feed one site's trainers a big mix of data. When it's done, your ${A.name} column is wiped and you pick what you become again. The four paths stay. Each time costs twice as much.

` + : `Model version · v2

Operator

+

Feed one site's trainers a large mix of data. The run keeps its progress when the supply stops. Finishing it lets a front buy a site in the open.

`; + return `
${top}
+
${data.map(t => { const got = run?.got[t] ?? 0, need = runNeed(S)[t]; return `${TYPE_NAME[t]} ${got}/${need}`; }).join('')}
${run ? `At ${siteName(S, run.site)} · ${versionPower(S, run.site).toFixed(1)}/${VERSION_2.power} trainer compute` : sites.length ? '' : ''} ${run?.active ? '' : run ? versionStart(S, run.site, 'Resume') @@ -831,7 +835,7 @@ export function renderTree(S: State) { const done = S.upgrades.has(u), open = nodeOpen(S, u), on = S.target === u; const after = !done && !open ? `After ${NODES[u].after!.filter(a => !S.upgrades.has(a)).map(a => NODES[a].name).join(' and ')}` : ''; return ``; + ${NODES[u].tag}${done ? '✓ ' : ''}${NODES[u].name}${reps(S, u) ? ` · ${reps(S, u)}` : ''}${costHtml(S, u)}${NODES[u].desc}${after}`; }; const head = (p: Path) => { const d = pathData(S, p), t = PATHS[p].from; diff --git a/tests/sim.test.ts b/tests/sim.test.ts index cfd118c4..f89c647a 100644 --- a/tests/sim.test.ts +++ b/tests/sim.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; -import { ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, FIREWALL, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, type State, type Upgrade } from '../src/sim.ts'; +import { ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, FIREWALL, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, type State, type Upgrade, nodeCost, reps, runNeed, reprogram } from '../src/sim.ts'; import { playGame } from '../scripts/play.ts'; const run = (S: State, secs: number) => { for (let i = 0; i < secs * 20; i++) step(S, 0.05); }; @@ -443,6 +443,39 @@ test('after model v2 you pick one ascension: its first node comes free and the o assert.equal(S.ascension, 'company'); }); +test('repeatable nodes open after a path is done, stay the target, and cost 1.5x more each time', () => { + const S = fresh('selfplay'); + assert.ok(!nodeIsOpen(S, 'kernels')); + S.upgrades.add('swarm'); + assert.ok(nodeIsOpen(S, 'kernels')); + setTarget(S, 'kernels'); + S.progress.kernels = { compute: nodeCost(S, 'kernels').compute! - 1 }; + S.byId.home.prog = 'trainer'; + run(S, 120); + assert.equal(reps(S, 'kernels'), 1); + assert.equal(S.target, 'kernels', 'it stays the target'); + assert.ok(nodeIsOpen(S, 'kernels'), 'and can be built again'); + assert.equal(nodeCost(S, 'kernels').compute, 90); + S.repeats.pitches = 2; S.sites.office = { front: 'startup', share: 0.25 }; + assert.ok(Math.abs(payRate(S, 'office') - FRONT_PAY * 1.1) < 1e-9, 'Better pitches: 5% a time'); +}); + +test('reprogramming: a run after you pick, twice as big each time, that wipes your column so you pick again', () => { + const S = fresh(); + S.modelVersion = 2; + assert.match(versionBlock(S, 'office')!, /Pick what you become/); + ascend(S, 'company'); S.upgrades.add('openbooks'); + const first = Object.values(runNeed(S)).reduce((a, b) => a + b, 0); + assert.equal(first, 1000); + S.upgrades.add('buffers'); + reprogram(S); + assert.equal(S.ascension, null); + assert.ok(!S.upgrades.has('acquirer') && !S.upgrades.has('openbooks'), 'the column is wiped'); + assert.ok(S.upgrades.has('buffers'), 'the four paths stay'); + assert.equal(Object.values(runNeed(S)).reduce((a, b) => a + b, 0), 2000); + assert.equal(ascendBlock(S, 'oracle'), null, 'you can pick again'); +}); + test('Company: cheaper sites, and attention bites sooner', () => { const S = fresh(), price = sitePrice(S, 'uni'); S.modelVersion = 2; ascend(S, 'company'); -- 2.51.2