Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
89 kB · 1324 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325// Lab (lab.html): you run an AI lab on an empty floor. Companies in the city send work along lines to doors in// your walls, as stacks of cubes; you lay out the floor yourself (GPUs, storage, desks) and things within reach// of each other link up on their own. GPUs serving work digest a stack a cube at a time; every step drops raw// data as big as the stack was; reviewers grade raw data; data that reaches the training core joins the// dataset, and GPUs set to train beside the core build the next model, which you ship when it is better.// Researchers invent harder work. Each company has a deal (sell, assist, take) and data terms, and the city's// fear of you follows what you do, and the city grows: new companies open on the empty lots as the game goes on.// Power, heat and the floor you lease are in power.ts. Pure rules, no DOM.// LAB.md has the design.import { MAINS, PW, START_WINGS, speedOf, whyNotLease, leasePrice, wingAt, wire, type Heat, type Wing } from './power.ts';
import { CITIZENS, LAB_BLOCK, LOTS, NEWCOMERS, SITES, SPINOUT_ROOMS, STARTUP_NAMES, STARTUP_ROOMS, STREETS, laneOf, setCity, type SiteDef } from './city.ts';import { RK, newRival, rivalBy, rivalStep, share, slop, type Ending, type Rival, type RivalId, type Who } from './rival.ts';
export type Family = 'care' | 'post' | 'code' | 'evals' | 'paper';export const FAMILY_LIST: Family[] = ['care', 'post', 'code', 'evals', 'paper'];export const FAMILIES: Record<Family, { name: string; color: string }> = { care: { name: 'Care', color: '#a77bf3' }, post: { name: 'Post-training', color: '#f27fb5' }, code: { name: 'Code', color: '#4fd8e8' }, evals: { name: 'Evals', color: '#6fd08c' }, paper: { name: 'Paperwork', color: '#f2c94c' },};/** A capability: one kind of work. `hard` raises its difficulty; `after` is the one you must master first. */export interface Capability { id: string; family: Family; hard: number; after?: string }export const CAPABILITIES: Capability[] = [ { id: 'answer-complaint', family: 'care', hard: 0 }, { id: 'refund-dispute', family: 'care', hard: 0.8, after: 'answer-complaint' }, { id: 'calm-angry-customer', family: 'care', hard: 1.6, after: 'refund-dispute' }, { id: 'label-preference', family: 'post', hard: 0.4 }, { id: 'write-rubric', family: 'post', hard: 1.2, after: 'label-preference' }, { id: 'red-team-prompt', family: 'post', hard: 2.0, after: 'write-rubric' }, { id: 'bash-script', family: 'code', hard: 0.4 }, { id: 'unit-tests', family: 'code', hard: 1.2, after: 'bash-script' }, { id: 'fix-outage', family: 'code', hard: 2.0, after: 'unit-tests' }, { id: 'write-eval', family: 'evals', hard: 0.8 }, { id: 'grade-transcript', family: 'evals', hard: 1.6, after: 'write-eval' }, { id: 'design-benchmark', family: 'evals', hard: 2.4, after: 'grade-transcript' }, { id: 'expense-report', family: 'paper', hard: 0 }, { id: 'board-memo', family: 'paper', hard: 0.8, after: 'expense-report' }, { id: 'investor-update', family: 'paper', hard: 1.6, after: 'board-memo' },];export const capability = (id: string) => CAPABILITIES.find(c => c.id === id)!;
// ---- the companies that send you work (their data is in city.ts)export type SiteId = string;export { SITES, type SiteDef } from './city.ts';/** Every company's id, in the city as it is now (see useCity). */export const SITE_LIST: SiteId[] = SITES.map(d => d.id);export type Mode = 'sell' | 'assist' | 'take';export type Terms = 'optin' | 'optout' | 'scrape';/** How you work with a company's people: sell them answers, work beside them, or take their work and do it yourself. */export const MODES: Record<Mode, { name: string; does: string; pay: number }> = { sell: { name: 'Sell', does: 'You answer their work and bill for it.', pay: 1 }, assist: { name: 'Assist', does: 'Their people work beside your model: a little more data, less pay, and the place grows a little. The city likes it.', pay: 0.7 }, take: { name: 'Take', does: 'You do all their work yourself: half as much work again, at twice the pay. Their people lose their jobs, the place stops growing and later winds down, and the city is afraid of you.', pay: 2 },};/** What you may keep of the data their work leaves. People notice: `trust` is what opt-in adds a second, and `wear` * what the others take away, divided by the square of the company's tolerance (bold ones shrug, careful ones walk). */export const TERMS: Record<Terms, { name: string; does: string; teach: number; trust: number; wear: number }> = { optin: { name: 'Opt-in', does: 'Keep only what people agree to: little data, and trust grows.', teach: 0.4, trust: 0.01, wear: 0 }, optout: { name: 'Opt-out', does: 'Keep everything unless someone objects, in the small print. Bold companies barely notice; careful ones lose trust.', teach: 1, trust: 0, wear: 2.5 }, scrape: { name: 'Scrape', does: 'Keep everything, and copy their archive too: a flood of data. The city hears of it, and careful companies walk out fast.', teach: 1, trust: 0, wear: 10 },};/** How fast data terms wear a company's trust down, a second. */export const termsWear = (t: Terms, id: SiteId) => TERMS[t].wear / siteDef(id).tolerance ** 2;/** A company's standing: whether you hold it (`signed`) or a rival does (`rival`, which one), the holder's deal and * terms, its trust in its holder, how much it has grown, its line, and when it last changed hands. */export interface Site { id: SiteId; name: string; signed: boolean; rival: RivalId | null; mode: Mode; terms: Terms; trust: number; grow: number; line: number; since: number; shy: Who | null; // a lab it will not deal with for a while: the city made it drop that lab, or it walked out done: number; // stacks a second its holder's lab has finished lately (a running average over K.window) own: number; // stacks a second its own people have finished lately, working beside your model keep: number; // until when it stays with you whatever the rival offers (you paid to keep it) takenAt?: number; // when its holder took its people's work spun?: boolean } // it grew enough to open a second companyexport const siteDef = (id: SiteId) => SITES.find(d => d.id === id)!;
/** The numbers. */export const K = { speed: 140, // how fast stacks ride a link, floor units a second linkRate: 1.6, // stacks a link takes on each second, whatever their height dataRate: 5, // data cubes a second on the links that carry data reach: 3, // things this many cells apart or nearer link up on their own gpuStep: 0.7, // seconds a GPU takes to digest one cube humanStep: 2.6, // seconds a person takes gradeTime: 1.6, // seconds a reviewer of skill 1 takes to grade a raw data cube gpuPower: 0.8, gpuPrice: 1500, gpuMore: 1.12, // each GPU bought costs this much more than the last prices: { store: 200, desk: 300, review: 300, research: 400, ...PW.prices } as Record<string, number>, storeCap: 8, worth: 6, worthHard: 5, // what one cube of work is worth when done right: worth + worthHard × hard, times the deal run0: 20, data0: 16, // Scaling's training: each step of skill needs 4× the compute and 4× the data teachFloor: 0.15, // what data from work the model always gets right still teaches teachYoung: 0.8, // a young model learns even from easy work: the floor starts here and halves with each ship runStart: 0.15, // the first run is under way when you arrive: this much of the compute its first step needs pin: 0.5, // the share of training compute a pinned family gets, at least useEvery: 5, // seconds in each bar of a GPU's recent use useBars: 12, // how many bars of recent use a GPU keeps research: 60, // research points to invent a capability, times its rank in the family mastered: 0.9, // a capability is mastered when a stack of two is done right this often startMoney: 8000, fund: 3000, fundMore: 1.12, // money at the start, what investors put in for each step of skill a shipped model gains, and how much more for each model before it poolEvery: 45, // seconds between new candidates for hire lineRates: [1.6, 3, 4.5] as const, linePrices: [0, 1500, 4000] as const, // a line's stacks a second, and its upgrades archiveEvery: 1.2, // seconds between data cubes copied from an archive while you scrape growMost: 1.25, growRate: 0.0005, // how much busier an assisted company gets, and how fast assistTeach: 1.25, // how much more an assisted company's work teaches: their people show the model how ownCounts: 0.25, // the share of an assisted company's own people's work that counts as served by you takeWork: 1.5, // a taken company's work all comes to its lab: what its people did by hand too takeHold: 600, // seconds a taken company keeps going before it winds down shrink: 0.0006, shrinkTo: 0.6, // how fast a taken company winds down after that, and how far labor: 40, demandMore: 0.5, demandLess: 1, demandLow: 0.5, // the city's spending: 1 + (0.5 × extra − 1 × out of work) / labor leakFade: 300, // seconds for the fear from a bought archive to fade /** fear, 0 to 100: everyone out of work because of you until they find other work, and every archive you copy */ fearJobs: 15, fearScrape: 12, findWork: 600, lobbyScrape: 0.5, // how much more scraping frightens the city each time a careful lab lobbies the council takeShock: 0.2, // trust every other company you work with loses when you take one's work lay: 0.6, // what a floor unit of line costs to lay to a new company waitTrust: 0.005, // trust lost each second while a company's work goes undone servedTrust: 0.004, // trust gained each second while its work is done, whatever the terms (opt-in adds more) better: 0.25, winBetter: 1.2, outbid: 1.6, // winning a company from another lab: with a model this much better, or outbidding settle: 180, // seconds after a company changes hands before it will talk to the other lab shy: 1800, // seconds a company the city made drop a lab will not deal with that lab marketWin: 0.5, // hold this share of the city's work to win the market frontier: 12, // ship this model first to win the frontier window: 60, // seconds of work that count toward how well a lab serves a company served: 0.85, // finishing this share of a company's work counts as serving all of it grace: 150, // seconds after signing before work left undone wears trust keepFor: 600, keepCost: 0.6, // paying to keep a company: how long it stays, and the price as a share of a plain deal loyal: 0.9, loyalMult: 1.25, // a company trusting and served this well costs the other lab this much more to win giveBack: 1500, // what hiring a taken company's people back costs you, for each person comeback: 60, // seconds with no company before one will try you again for free tierPrices: [0, 6000, 16000], // upgrading a GPU to each faster tier (speeds are PW.tierSpeed) offerFirst: 210, offerEvery: 140, offerFor: 75, // offers: the first, the gap between them, and how long each stands trialOff: 0.5, // a company trying you out pays this share of a plain deal // the city grows: a new company opens on an empty lot now and then foundFirst: 540, foundEvery: 200, foundMore: 0.25, // the first, the gap after, and how much longer each gap gets foundLow: 0.97, // someone new opens in town only while the city spends at least this much startupEvery: 3.0, startupTolerance: 55, startupCost: 1800, // people out of work start small companies wary: 2, waryTrust: 0.3, // a company wary of a lab (it took their jobs): that lab pays this much more, and starts trusted this little spinAt: 1.2, // an assisted company this much busier opens a second company friendly: 0.6, friendTrust: 0.8, // a company friendly to a lab: what that lab pays, and the trust it starts with};/** The chance one step of digesting comes out right: Scaling's rule, skill against difficulty. */export const success = (skill: number, difficulty: number) => Math.max(0, Math.min(1, skill - difficulty + 1));export const difficulty = (c: Capability, size: number) => c.hard + size * 0.5;export const worth = (c: Capability) => K.worth + K.worthHard * c.hard;
export type CubeKind = 'work' | 'raw' | 'graded';/** A stack of work, or a cube of data. `teach` is how much a data cube can teach. */export interface Cube { id: number; cap: string; size: number; kind: CubeKind; flash: number; teach: number; site?: SiteId }
export interface Pt { x: number; y: number }export type Role = 'support' | 'reviewer' | 'researcher';/** What you can put on your floor. */export type Build = 'gpu' | 'store' | 'desk' | 'review' | 'research' | 'power' | 'cool';export const ROLES: Record<Role, { name: string; does: string; desk: Build }> = { support: { name: 'Support', does: 'answers care work by hand: slow, always right', desk: 'desk' }, reviewer: { name: 'Reviewer', does: 'grades raw data, so it teaches more', desk: 'review' }, researcher: { name: 'Researcher', does: 'invents harder work for the lab to learn from', desk: 'research' },};export interface Person { name: string; role: Role; skill: number; pay: number }export const BUILDS: Record<Build, { name: string; does: string }> = { gpu: { name: 'GPU', does: 'Serves work that reaches it from a door or storage. Set it to Train beside the core.' }, store: { name: 'Storage', does: 'Holds work from a door until a GPU or a person nearby is free.' }, desk: { name: 'Support desk', does: 'For someone you hire to answer care work by hand.' }, review: { name: 'Review desk', does: 'For a reviewer: grades data on its way to the core.' }, research: { name: 'Research desk', does: 'For a researcher: invents harder work.' }, power: { name: 'Power', does: `Runs up to ${PW.supply} GPUs within ${PW.reach} cells. A GPU without power does nothing.` }, cool: { name: 'Cooling', does: 'Keeps the GPUs touching it cool. GPUs touching each other run hot and slow.' },};export type NodeKind = Build | 'core' | 'port' | 'source' | 'gate' | 'hub' | 'archive';export interface Node { id: string; kind: NodeKind; name: string; at: Pt; cell?: { c: number; r: number; size: number }; // on your floor on: boolean; mode?: 'serve' | 'train'; // a GPU paid?: number; // what you paid to build it site?: SiteId; // a node at a company, or the door its line comes in at families?: Family[]; // a company's work every?: number; next: number; hold: Cube[]; work: { cube: Cube; left: number }[]; slots: number; room2: number; out: string[]; who?: Person; pw?: Heat; // a GPU of yours: its power and heat, set by wire() tier?: number; // a GPU of yours: 0 as bought, more once upgraded (K.tiers) busy?: number; // a GPU: how busy it has been lately, 0 to 1 use?: number[]; // a GPU: how busy it was in each of the last few spells, oldest first useNow?: number; // a GPU: seconds busy so far in this spell}export interface Link { id: string; from: string; to: string; pts: Pt[]; len: number; cubes: { cube: Cube; d: number }[]; token: number; site?: SiteId; floor?: boolean; duct?: boolean }export interface Room { id: string; name: string; x: number; y: number; w: number; h: number; site?: SiteId }
export type Ev = | { k: 'step'; at: string; ok: boolean; cap: string; v: number } | { k: 'ship'; v: number; fund: number } | { k: 'invent'; cap: string } | { k: 'broke' } | { k: 'sign'; site: SiteId } | { k: 'cancel'; site: SiteId } | { k: 'take'; names: string[] } | { k: 'win'; site: SiteId; from: RivalId } // you win a company from a rival | { k: 'lost'; site: SiteId; by: RivalId } // a rival wins one from you | { k: 'rival'; text: string; by: RivalId; site?: SiteId } // a rival makes a move | { k: 'council'; site: SiteId; who: 'you' | 'rival'; by?: RivalId; why: string[] } // the city makes a company hire its people back | { k: 'councilSoon'; site: SiteId; who: 'you' | 'rival'; by?: RivalId; at: number } // the council will act at `at` unless fear falls or the people are back | { k: 'court'; site: SiteId; at: number; by: RivalId } // a rival will try to win one of your companies at `at` | { k: 'stays'; site: SiteId } // the rival's courting failed: the company stays with you | { k: 'comeback'; site: SiteId } // with no company left, this one will sign with you for free | { k: 'giveBack'; site: SiteId; names: string[] } | { k: 'offer'; offer: Offer } // a company or a broker offers you something, for a while | { k: 'found'; site: SiteId; names: string[] } // a new company opens: who started it | { k: 'end'; ending: Ending };/** Someone out of work because a lab took their job: who, what they did, where, when, and when they got it back. */export interface Out { name: string; job: string; site: SiteId | 'lab'; t: number; by: Who; back?: number; at?: Pt; started?: SiteId }/** Something offered for a while, to take or leave: a company that will try you at a discount, or a broker * selling a company's records (data now, fear when it gets out). */export type Offer = | { kind: 'trial'; site: SiteId; until: number; price: number } | { kind: 'broker'; site: SiteId; until: number; price: number; data: number };export interface Game { t: number; r: number; nextId: number; nodes: Node[]; links: Link[]; rooms: Room[]; money: number; earned: number; gpusBought: number; skill: Record<Family, number>; run: Record<Family, number>; data: Record<Family, number>; model: number; focus: Family | null; // a family pinned to get more of the training compute, or null to share it out on its own unlocked: Set<string>; researching: string | null; progress: number; queue: string[]; // what to invent next, in order autoResearch: boolean; // when idle with nothing queued, invent the cheapest thing you can pool: Person[]; poolAt: number; gone: string[]; met: Record<string, Person>; // everyone who has been a candidate: a name that comes back is the same person broke: boolean; sites: Record<SiteId, Site>; jobsLost: number; jobsBack: number; leased: string[]; // the wings of your floor you have events: Ev[]; rivals: Rival[]; ending: Ending | null; // the other labs, and how the race ended (play goes on after) councilAt: number; // when the city council next looks at the city's fear warn: { site: SiteId; who: 'you' | 'rival'; by?: RivalId; at: number } | null; // the council will act at `at` aloneAt: number | null; // when you last lost your last company, or null while you hold one comeback: SiteId | null; // the company that will sign with you for free after you lost them all out: Out[]; // everyone put out of work, by you or the rival leak: number; // fear from records you bought, fading offer: Offer | null; offerAt: number; offers: number; // what is on offer now, when the next comes, and how many so far ending2: Ending | null; // how the other race ended, once the first is over born: SiteDef[]; // the companies that opened during the game, on the lots foundAt: number; // when the next might open}
function rand(G: Game) { G.r = (G.r + 0x6d2b79f5) | 0; let t = Math.imul(G.r ^ (G.r >>> 15), 1 | G.r); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;}const NAMES = ['Tess', 'Omar', 'Lu', 'Priya', 'Dev', 'Hana', 'Kofi', 'Ines', 'Raf', 'Mira', 'Theo', 'Sana', 'Yuki', 'Bram', 'Noor', 'Cal', 'Ada', 'Jun', 'Pia', 'Ezra', 'Lena', 'Ravi', 'Zoe', 'Iggy', 'Femi', 'Lior', 'Marta', 'Oskar', 'Rhea', 'Sol', 'Tomas', 'Uma', 'Vik', 'Wren', 'Xan', 'Yara', 'Ziad', 'Bea', 'Coby', 'Dara'];const PAY: Record<Role, number> = { support: 0.8, reviewer: 1.4, researcher: 2.2 };
// ---- your floor: an empty grid, the training core, and a door in the wall for each companyexport const CELL = 80, COLS = 18, ROWS = 12;export const FLOOR = { w: COLS * CELL, h: ROWS * CELL };export const CORE = { c: 10, r: 5, size: 2 }; // one hop from the north door; the east and west doors need relays/** Where each company's line comes in: a door just outside your wall, on the side facing it. */export const doorCell = (d: SiteDef) => d.side === 'north' ? { c: d.door, r: -1 } : d.side === 'south' ? { c: d.door, r: ROWS } : d.side === 'west' ? { c: -1, r: d.door } : { c: COLS, r: d.door };export const DOORS: Record<SiteId, { c: number; r: number }> = Object.fromEntries(SITES.map(d => [d.id, doorCell(d)]));/** The city a game has: the companies from the start and those opened since. The lists above follow the game last * used; every step and relink uses its own game's city. */let cityOf: Game | null = null;export function useCity(G: Game, again = false) { if (cityOf === G && !again) return; cityOf = G; setCity(G.born ?? []); SITE_LIST.length = 0; SITE_LIST.push(...SITES.map(d => d.id)); for (const k of Object.keys(DOORS)) delete DOORS[k]; for (const d of SITES) DOORS[d.id] = doorCell(d);}export const cellAt = (c: number, r: number, size = 1): Pt => ({ x: (c + size / 2) * CELL, y: (r + size / 2) * CELL });/** The wing of your floor a company's door is on. */export const doorWing = (id: SiteId): Wing => { const p = DOORS[id]; return wingAt(Math.max(0, Math.min(COLS - 1, p.c)), Math.max(0, Math.min(ROWS - 1, p.r)))!; };/** A door is open while you hold its company and have leased the wing it is on. */export const doorOpen = (G: Game, id: SiteId) => G.sites[id].signed && G.leased.includes(doorWing(id).id);
/** A new game: `rivals` are the labs you are up against (Overfit AI unless you pick). */export function newGame(seed = 1, rivals: RivalId[] = ['overfit']): Game { const zero = () => ({ care: 0, post: 0, code: 0, evals: 0, paper: 0 }); const G: Game = { t: 0, r: seed * 7919 + 13, nextId: 1, nodes: [], links: [], rooms: [{ id: 'floor', name: 'Midwit Labs', x: 0, y: 0, w: FLOOR.w, h: FLOOR.h }], money: K.startMoney, earned: 0, gpusBought: 0, skill: { care: 1.5, post: 1.4, code: 1.4, evals: 1.3, paper: 1.5 }, run: zero(), data: zero(), model: 1, focus: null, unlocked: new Set(CAPABILITIES.filter(c => !c.after).map(c => c.id)), researching: null, progress: 0, queue: [], autoResearch: true, pool: [], poolAt: 0, gone: [], met: {}, broke: false, sites: {}, jobsLost: 0, jobsBack: 0, leased: [...START_WINGS], events: [], rivals: rivals.map(id => newRival(id, seed)), ending: null, councilAt: RK.councilEvery, warn: null, aloneAt: null, comeback: null, out: [], leak: 0, offer: null, offerAt: K.offerFirst, offers: 0, ending2: null, born: [], foundAt: K.foundFirst, }; useCity(G); G.foundAt = K.foundFirst * (0.85 + rand(G) * 0.3); addNode(G, { id: 'core', kind: 'core', name: 'Training core', at: cellAt(CORE.c, CORE.r, 2), cell: { ...CORE }, room2: 99 }); // the building's own power: enough for your first few GPUs addNode(G, { id: 'mains', kind: 'power', name: 'Mains', at: cellAt(MAINS.c, MAINS.r), cell: { ...MAINS, size: 1 }, room2: 0 }); addCompanies(G); // your first customer: the coworking space up the street signNow(G, 'pier'); refillPool(G); // the first run is already under way: part of the compute its first step needs, for the work Pier 9 sends for (const f of siteDef('pier').families) G.run[f] = K.runStart * needFor(G, f).run; rivalStep(G, 0); // each rival starts with its own first customer return G;}/** Every company in the city that the game does not have yet: its standing, its floor, and its door on your wall. * A new game gets them all; an old save gets the ones added since. */export function addCompanies(G: Game) { for (const d of SITES) { if (G.sites[d.id]) continue; G.sites[d.id] = { id: d.id, name: d.name, signed: false, rival: null, mode: 'sell', terms: 'optin', trust: 0.6, grow: 1, line: 0, since: -1e9, shy: null, done: 0, own: 0, keep: 0 }; const door = DOORS[d.id]; addNode(G, { id: `door:${d.id}`, kind: 'port', name: `${d.name} door`, at: cellAt(door.c, door.r), cell: { c: door.c, r: door.r, size: 1 }, site: d.id, on: false, room2: 4 }); buildSite(G, d); } relink(G);}function addNode(G: Game, n: Partial<Node> & { id: string; kind: NodeKind; name: string; at: Pt }): Node { const full: Node = { on: true, next: 0, hold: [], work: [], slots: 1, room2: 3, out: [], ...n }; G.nodes.push(full); return full;}export const node = (G: Game, id: string) => G.nodes.find(n => n.id === id)!;function makeLink(G: Game, from: string, to: string, pts: Pt[], extra: Partial<Link> = {}) { let len = 0; for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); const l: Link = { id: `${from}>${to}`, from, to, pts, len, cubes: [], token: 1, ...extra }; G.links.push(l); return l;}export function along(l: Link, d: number): Pt { let left = d; for (let i = 1; i < l.pts.length; i++) { const a = l.pts[i - 1], b = l.pts[i], s = Math.hypot(b.x - a.x, b.y - a.y); if (left <= s || i === l.pts.length - 1) { const f = s ? Math.min(1, left / s) : 0; return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f }; } left -= s; } return l.pts[0];}
// ---- the companies' own floors/** Where a company's floor sits, in your floor units: its block on the city, less a margin. */export function siteRect(d: SiteDef) { const b = d.block; return { x: toFloor(b.x - LAB_BLOCK.x) + 40, y: toFloor(b.y - LAB_BLOCK.y) + 40, w: toFloor(b.w) - 80, h: toFloor(b.h) - 80 };}/** City units to floor units. */const toFloor = (v: number) => v * FLOOR.w / LAB_BLOCK.w;/** The way a company's line runs from a point at the company to its door: out to the street, along it, and in at * your wall. Lines on the same street keep to their own lane; `off` sets the work and data lines side by side. */export function route(d: SiteDef, from: Pt, door: Pt, off = 0): Pt[] { const lane = laneOf(d), along = SITES.filter(s => s.side === d.side).indexOf(d); if (d.lot) { // from a lot: out to the lots' street, along it to a gap between the blocks, up it, and along a lane of its own const street = toFloor(STREETS.across[2] - LAB_BLOCK.y) + off / 2, via = toFloor(d.via! - LAB_BLOCK.x) + (lane % 3) * 16 - 16 + off / 2, y = FLOOR.h + 380 + lane * 22 + off / 2; return [from, { x: from.x, y: street }, { x: via, y: street }, { x: via, y }, { x: door.x, y }, door]; } const north = -160 - lane * 48 - off, south = FLOOR.h + 200 + lane * 48 + off; if (d.side === 'north' || d.side === 'south') { const y = d.side === 'north' ? north : south; return [from, { x: from.x, y }, { x: door.x, y }, door]; } const x = d.side === 'west' ? -160 - along * 40 - off : FLOOR.w + 160 + along * 40 + off; return [from, { x: from.x, y: north }, { x, y: north }, { x, y: door.y }, door];}/** How long a company's line is, in floor units: far companies cost more to lay a line to. */export const lineLength = (G: Game, id: SiteId) => G.links.find(l => l.id === `${id}:gate>door:${id}`)?.len ?? 0;/** A company: the room its work comes from, an archive, a server room with three sockets for your GPUs, its * people's offices, and its intake, where you set the deal and the data terms. */function buildSite(G: Game, d: SiteDef) { const { x: X, y: Y, w: W, h: H } = siteRect(d), id = d.id, o = { site: id, on: false }; const fx = (f: number) => (d.side === 'west' ? X + W * (1 - f) : X + W * f), fy = (f: number) => Y + H * f; const room = (k: string, name: string, x0: number, y0: number, w: number, h: number) => G.rooms.push({ id: `${id}:${k}`, name, x: d.side === 'west' ? fx(x0 + w) : fx(x0), y: fy(y0), w: W * w, h: H * h, site: id }); room('work', d.rooms[0], 0.03, 0.05, 0.3, 0.42); room('archive', d.rooms[1], 0.03, 0.55, 0.3, 0.4); room('server', d.rooms[2], 0.45, 0.05, 0.36, 0.42); room('office', d.rooms[3], 0.45, 0.55, 0.36, 0.4); const n = (k: string) => `${id}:${k}`; addNode(G, { id: n('src'), kind: 'source', name: d.rooms[0], at: { x: fx(0.18), y: fy(0.26) }, ...o, families: d.families, every: d.every, room2: 10 }); addNode(G, { id: n('archive'), kind: 'archive', name: d.rooms[1], at: { x: fx(0.18), y: fy(0.75) }, ...o, room2: 4 }); addNode(G, { id: n('gate'), kind: 'gate', name: `${d.name} intake`, at: { x: fx(0.39), y: fy(0.5) }, ...o, room2: GATE_ROOM }); addNode(G, { id: n('data'), kind: 'hub', name: 'Data out', at: { x: fx(0.39), y: fy(0.97) }, ...o, room2: 8 }); [[0.53, 0.17], [0.7, 0.17], [0.62, 0.36]].forEach(([a, b], i) => addNode(G, { id: n(`gpu${i + 1}`), kind: 'gpu', name: `GPU ${i + 1}`, at: { x: fx(a), y: fy(b) }, ...o, mode: 'serve' })); d.people.forEach(([name], i) => addNode(G, { id: n(`p${i + 1}`), kind: 'desk', name, at: { x: fx(0.52 + i * (0.25 / Math.max(1, d.people.length - 1))), y: fy(0.74) }, ...o, who: { name, role: 'support', skill: 1, pay: 0 } }));}/** Stacks a company's intake holds, counting those on their way to it: enough that a busy company's work keeps flowing. */export const GATE_ROOM = 6;/** The links at the companies never change: their own floor, and the line across the street to your door. */function siteLinks(G: Game, d: SiteDef) { const id = d.id, n = (k: string) => `${id}:${k}`, at = (k: string) => node(G, n(k)).at; const { y: Y, h: H } = siteRect(d), fy = (f: number) => Y + H * f; const gate = at('gate'), hub = at('data'), gpus = ['gpu1', 'gpu2', 'gpu3'], people = d.people.map((_, i) => `p${i + 1}`), side = d.side === 'west' ? -20 : 20; makeLink(G, n('src'), n('gate'), [at('src'), { x: gate.x, y: at('src').y }, gate]); for (const k of gpus) makeLink(G, n('gate'), n(k), [gate, { x: gate.x, y: at(k).y }, at(k)]); for (const k of people) makeLink(G, n('gate'), n(k), [gate, { x: gate.x, y: fy(0.62) }, { x: at(k).x, y: fy(0.62) }, at(k)]); for (const k of gpus) makeLink(G, n(k), n('data'), [at(k), { x: at(k).x, y: fy(0.5) - 20 }, { x: gate.x + side, y: fy(0.5) - 20 }, { x: gate.x + side, y: hub.y }, hub]); for (const k of people) makeLink(G, n(k), n('data'), [at(k), { x: at(k).x, y: hub.y }, hub]); makeLink(G, n('archive'), n('data'), [at('archive'), { x: at('archive').x, y: hub.y }, hub]); const door = node(G, `door:${id}`).at; makeLink(G, n('gate'), `door:${id}`, route(d, gate, door), { site: id }); makeLink(G, n('data'), `door:${id}`, route(d, hub, door, 20), { site: id });}
// ---- building on your floor/** How far apart two things on the floor are, in cells: the gap between their footprints. */export function gap(a: { cell?: Node['cell'] }, b: { cell?: Node['cell'] }): number { const A = a.cell!, B = b.cell!; const dx = Math.max(0, B.c - (A.c + A.size - 1), A.c - (B.c + B.size - 1)), dy = Math.max(0, B.r - (A.r + A.size - 1), A.r - (B.r + B.size - 1)); return Math.max(dx, dy);}/** What can feed what on your floor. Work: a door feeds storage, GPUs and support desks; storage feeds GPUs and * desks. Data: GPUs, desks and doors feed review desks and the core; review desks feed the core. */export const FEEDS: Partial<Record<NodeKind, NodeKind[]>> = { port: ['store', 'gpu', 'desk', 'review', 'core'], store: ['gpu', 'desk'], gpu: ['review', 'core'], desk: ['review', 'core'], review: ['core'],};/** Whether one thing on the floor would link to another. */export function feeds(a: { kind: NodeKind; mode?: string; cell?: Node['cell'] }, b: { kind: NodeKind; mode?: string; cell?: Node['cell'] }) { if (!FEEDS[a.kind]?.includes(b.kind) || gap(a, b) > K.reach) return false; return !(a.kind === 'gpu' && a.mode === 'train') && !(b.kind === 'gpu' && b.mode === 'train');}/** Rebuild the links on your floor: everything within reach of something it can feed links to it. Cubes already on * a link that is still there stay on it; the rest go back where they came from. */export function relink(G: Game) { useCity(G); const old = new Map(G.links.map(l => [l.id, l])); G.links = []; for (const n of G.nodes) n.out = []; const floor = G.nodes.filter(n => n.cell && (n.kind !== 'port' || doorOpen(G, n.site!))); // a door is open only while you hold the company and lease its wing for (const a of floor) for (const b of floor) { if (a === b || !feeds(a, b)) continue; makeLink(G, a.id, b.id, [a.at, { x: b.at.x, y: a.at.y }, b.at], { floor: true }); // an L along the grid } // the building's data duct: every door can always send data on to the core, the long way round if nothing nearer takes it const core = floor.find(n => n.kind === 'core'); if (core) for (const door of floor.filter(n => n.kind === 'port')) if (!G.links.some(l => l.from === door.id && l.to === core.id)) makeLink(G, door.id, core.id, [door.at, { x: door.at.x, y: core.at.y }, core.at], { floor: true, duct: true }); for (const d of SITES) siteLinks(G, d); for (const l of G.links) { const was = old.get(l.id); if (was) { l.cubes = was.cubes; l.token = was.token; } node(G, l.from).out.push(l.id); old.delete(l.id); } for (const l of old.values()) { const from = G.nodes.find(n => n.id === l.from); if (from) for (const c of l.cubes) from.hold.push(c.cube); } wire(G.nodes); // each thing sends to the nearest that does the work before it fills storage const len = new Map(G.links.map(l => [l.id, l.len + (node(G, l.to).kind === 'store' ? 10000 : 0)])); for (const n of G.nodes) n.out.sort((a, b) => len.get(a)! - len.get(b)!);}/** Whether something on your floor has output with nowhere to go: 'data' when its data cannot reach a review desk * or the core (or it is piling up), 'work' when a door or storage has work and nothing in reach takes it. */export function blocked(G: Game, n: Node): 'data' | 'work' | null { if (!n.cell || !n.on || (n.site && n.kind !== 'port')) return null; // a GPU without power takes nothing, so a link to one does not count const to = (kinds: NodeKind[]) => n.out.some(id => { const l = G.links.find(x => x.id === id), m = l && node(G, l.to); return !!m && !l.duct && kinds.includes(m.kind) && !(m.kind === 'gpu' && !m.site && speedOf(m) <= 0); }); // a door counts the GPUs in its company's server room too: what they take never comes to the door if (n.kind === 'port') return G.sites[n.site!].signed && n.hold.some(c => c.kind === 'work') && !to(['store', 'gpu', 'desk']) && !socketsOn(G, n.site!) ? 'work' : null; if (n.kind === 'store') return n.hold.length && !to(['gpu', 'desk']) ? 'work' : null; if (n.kind === 'gpu' && n.mode === 'train') return null; if ((n.kind === 'gpu' && speedOf(n) > 0) || (n.kind === 'desk' && n.who) || (n.kind === 'review' && (n.who || n.hold.length))) { if (!to(n.kind === 'review' ? ['core'] : ['review', 'core'])) return 'data'; if (n.kind === 'gpu' && n.hold.filter(c => c.kind !== 'work').length >= 6) return 'data'; } return null;}/** GPUs of yours working in a company's server room. */export const socketsOn = (G: Game, id: SiteId) => G.nodes.filter(n => n.site === id && n.kind === 'gpu' && n.on && G.sites[id].signed).length;/** GPUs set to train within reach of the core: they build the next model. */export const trainers = (G: Game) => G.nodes.filter(n => n.kind === 'gpu' && !n.site && n.on && n.mode === 'train' && speedOf(n) > 0 && gap(n, node(G, 'core')) <= K.reach);export const gpuPrice = (G: Game) => Math.round(K.gpuPrice * K.gpuMore ** G.gpusBought);export const buildPrice = (G: Game, b: Build) => (b === 'gpu' ? gpuPrice(G) : K.prices[b]);/** Why you cannot build here, or null. */export function whyNotBuild(G: Game, b: Build, c: number, r: number): string | null { if (c < 0 || r < 0 || c >= COLS || r >= ROWS) return 'off the floor'; if (!G.leased.includes(wingAt(c, r)!.id)) return 'for lease'; if (G.nodes.some(n => n.cell && !n.site && c >= n.cell.c && c < n.cell.c + n.cell.size && r >= n.cell.r && r < n.cell.r + n.cell.size)) return 'taken'; if (G.money < buildPrice(G, b)) return 'not enough money'; return null;}export function build(G: Game, b: Build, c: number, r: number): Node | null { if (whyNotBuild(G, b, c, r)) return null; const paid = buildPrice(G, b); G.money -= paid; if (b === 'gpu') G.gpusBought++; const n = addNode(G, { id: `${b}-${G.nextId++}`, kind: b, name: BUILDS[b].name, at: cellAt(c, r), cell: { c, r, size: 1 }, mode: b === 'gpu' ? 'serve' : undefined, paid, room2: b === 'store' ? K.storeCap : b === 'review' ? REVIEW_ROOM : 3, on: b === 'gpu' || b === 'store' || b === 'power' || b === 'cool', }); if (b === 'gpu') n.mode = gpuModeAt(G, c, r); relink(G); return n;}/** A spot a drag across the floor would fill: where, what it costs, whether you can still pay for it after the ones * before it, and for a GPU whether it would have power. */export interface FillSpot { c: number; r: number; price: number; ok: boolean; powered?: boolean }/** What dragging a rectangle from one cell to another with `b` picked would build, starting from the cell you * pressed on: every free cell of floor you lease in it, except that GPUs keep a cell clear of every other GPU (GPUs * touching run hot), so they come out evenly spaced. Each costs what it would by hand (GPUs dearer each time), and * spots past what you can pay for are not ok. */export function fillPlan(G: Game, b: Build, c0: number, r0: number, c1: number, r1: number): FillSpot[] { const clamp = (x: number, n: number) => Math.max(0, Math.min(n - 1, x)); c0 = clamp(c0, COLS); c1 = clamp(c1, COLS); r0 = clamp(r0, ROWS); r1 = clamp(r1, ROWS); const dc = c1 < c0 ? -1 : 1, dr = r1 < r0 ? -1 : 1, free = { ...G, money: Infinity }; const gpus = G.nodes.filter(n => n.kind === 'gpu' && n.cell && !n.site), out: FillSpot[] = []; let spent = 0, broke = false, bought = G.gpusBought; for (let r = r0; r !== r1 + dr; r += dr) for (let c = c0; c !== c1 + dc; c += dc) { if (whyNotBuild(free, b, c, r)) continue; const at = { cell: { c, r, size: 1 } }; if (b === 'gpu' && gpus.some(g => gap(g, at) <= 1)) continue; const price = b === 'gpu' ? Math.round(K.gpuPrice * K.gpuMore ** bought) : K.prices[b]; broke ||= spent + price > G.money; if (!broke) { spent += price; if (b === 'gpu') bought++; } if (b === 'gpu') gpus.push({ kind: 'gpu', cell: at.cell } as Node); out.push({ c, r, price, ok: !broke }); } if (b === 'gpu') { // power as it would be with all of them built: the newest goes dark first, as it would by hand const probes = out.filter(s => s.ok).map((s, i) => ({ id: `?fill${i}`, kind: 'gpu', name: '', at: { x: 0, y: 0 }, cell: { c: s.c, r: s.r, size: 1 }, on: true, next: 0, hold: [], work: [], slots: 1, room2: 0, out: [] }) as unknown as Node); wire([...G.nodes.map(n => ({ ...n })), ...probes]); probes.forEach((p, i) => { out[i].powered = p.pw!.powered; }); } return out;}/** Build everything a drag would fill that you can pay for, in order, each as it would be built by hand (a GPU * set to `mode` if given). Returns what was built. */export function fill(G: Game, b: Build, c0: number, r0: number, c1: number, r1: number, mode?: 'serve' | 'train'): Node[] { const built: Node[] = []; for (const s of fillPlan(G, b, c0, r0, c1, r1)) { if (!s.ok) break; const n = build(G, b, s.c, s.r); if (!n) break; if (n.kind === 'gpu' && mode && n.mode !== mode) setGpu(G, n.id, mode); built.push(n); } return built;}/** Raw data a review desk holds: one it grades next, and the rest on their way through to the core. */export const REVIEW_ROOM = 6;/** Upgrade a GPU of yours to a faster tier: it works (and trains) faster, and runs hotter beside other GPUs. */export const tierPrice = (n: Node) => K.tierPrices[(n.tier ?? 0) + 1] ?? null;export function upgradeGpu(G: Game, id: string): boolean { const n = G.nodes.find(m => m.id === id); if (!n || n.kind !== 'gpu' || !n.cell || n.site) return false; const p = tierPrice(n); if (p === null || G.money < p) return false; G.money -= p; n.paid = (n.paid ?? 0) + p; n.tier = (n.tier ?? 0) + 1; wire(G.nodes); return true;}/** What a GPU put here starts as: beside the core with no work in reach, it trains (that is what it is there for); * anywhere else it serves. */export function gpuModeAt(G: Game, c: number, r: number): 'serve' | 'train' { const at = { cell: { c, r, size: 1 } }; return !G.nodes.some(m => (m.kind === 'port' || m.kind === 'store') && m.cell && gap(m, at) <= K.reach) && gap(at, node(G, 'core')) <= K.reach ? 'train' : 'serve';}/** Take something off the floor: half its price back. Whoever sat at it goes back to the list of candidates. */export function remove(G: Game, id: string): boolean { const n = G.nodes.find(m => m.id === id); if (!n?.cell || n.site || n.kind === 'core' || n.kind === 'port' || n.id === 'mains') return false; G.money += Math.round((n.paid ?? (n.kind === 'gpu' ? K.gpuPrice : K.prices[n.kind])) / 2); if (n.kind === 'gpu') G.gpusBought = Math.max(0, G.gpusBought - 1); if (n.who) G.pool.push(n.who); const back = [...n.hold, ...n.work.map(w => w.cube)].filter(c => c.kind === 'work'); const store = G.nodes.find(m => m.kind === 'store' && m !== n); if (store) store.hold.push(...back.slice(0, Math.max(0, store.room2 - store.hold.length))); G.nodes = G.nodes.filter(m => m !== n); relink(G); return true;}/** A GPU serves work, or trains the next model; to train it must be within reach of the core. */export function setGpu(G: Game, id: string, mode: 'serve' | 'train'): boolean { const n = G.nodes.find(m => m.id === id); if (!n || n.kind !== 'gpu' || n.site) return false; n.mode = mode; if (mode === 'train') { n.work = []; n.hold = n.hold.filter(c => c.kind !== 'work'); } relink(G); return true;}/** Put a GPU in a company's server room: its work is done there, without the trip across the street. */export function buySiteGpu(G: Game, id: string): boolean { const n = G.nodes.find(m => m.id === id); if (!n?.site || n.cell || n.kind !== 'gpu' || n.on || !G.sites[n.site].signed || G.money < gpuPrice(G)) return false; G.money -= gpuPrice(G); G.gpusBought++; n.on = true; return true;}/** Lease a wing of your floor: it must touch floor you have. */export const leaseCost = (G: Game, id: string) => leasePrice(G.leased, id);export const whyLease = (G: Game, id: string) => whyNotLease(G.leased, G.money, id);export function lease(G: Game, id: string): boolean { if (whyLease(G, id)) return false; G.money -= leaseCost(G, id); G.leased.push(id); relink(G); // a door on it opens return true;}
// ---- peopleexport function hire(G: Game, i: number): boolean { const p = G.pool[i]; if (!p) return false; const seat = seatFor(G, p.role), fee = hireFee(p); if (!seat || G.money < fee) return false; G.money -= fee; seat.on = true; seat.who = p; G.pool.splice(i, 1); return true;}export const hireFee = (p: Person) => Math.round(p.pay * 120);export const seatFor = (G: Game, role: Role) => G.nodes.find(n => !n.site && n.kind === ROLES[role].desk && !n.who);export function letGo(G: Game, id: string): boolean { const n = G.nodes.find(m => m.id === id); if (!n?.who || n.site) return false; G.gone.push(n.who.name); G.jobsLost++; // they tell the city what you did G.out.push({ name: n.who.name, job: ROLES[n.who.role].name, site: 'lab', t: G.t, by: 'you', at: { ...n.at } }); n.on = false; n.who = undefined; n.work = []; n.hold = n.hold.filter(c => c.kind !== 'work'); return true;}export const burn = (G: Game) => G.nodes.reduce((s, n) => s + (n.on && n.who && !n.site && !(n.kind === 'research' && nothingToInvent(G)) ? n.who.pay : 0), 0);/** Nothing to invent now: researchers wait unpaid until there is (or for good, once everything is invented). */export const nothingToInvent = (G: Game) => !G.researching && researchable(G).length === 0;/** The roles worth hiring for now: those with an empty desk waiting and nobody on the list for it (`sure`); if * none, any role but support while you have no support desk, and not three of a kind. */function wanted(G: Game): { roles: Role[]; sure: boolean } { const all: Role[] = ['reviewer', 'researcher', 'support']; const empty = all.filter(r => seatFor(G, r) && !G.pool.some(p => p.role === r)); if (empty.length) return { roles: empty, sure: true }; const can = all.filter(r => r !== 'support' || G.nodes.some(n => n.kind === 'desk' && !n.site)); const other = can.filter(r => !G.pool.some(p => p.role === r)); // not three of a kind return { roles: other.length ? other : can, sure: false };}/** Fill the list of candidates to three. Names not seen yet come first; a name seen before comes back as the same * person, with the same role and skill. Nobody is on the list twice, or both on it and at a desk. New people lean * toward the roles you have empty desks for, so the list is rarely all of no use. */function refillPool(G: Game) { while (G.pool.length < 3) { const taken = new Set([...G.nodes.map(n => n.who?.name), ...G.pool.map(p => p.name), ...G.gone]); const want = wanted(G); const fresh = NAMES.filter(n => !taken.has(n) && !G.met[n]), back = NAMES.filter(n => !taken.has(n) && G.met[n]); const useful = back.filter(n => want.roles.includes(G.met[n].role)); const from = fresh.length ? fresh : useful.length ? useful : back; const name = from.length ? from[Math.floor(rand(G) * from.length)] : `Temp ${G.nextId}`; if (!G.met[name]) { // an empty desk gets someone for it; otherwise two in three lean to a role you want now const role = want.sure || rand(G) < 0.67 ? want.roles[Math.floor(rand(G) * want.roles.length)] : (['reviewer', 'researcher', 'support'] as Role[])[Math.floor(rand(G) * 3)]; const skill = Math.round((0.6 + rand(G) * 1.1) * 10) / 10; G.met[name] = { name, role, skill, pay: Math.round(PAY[role] * (0.4 + 0.6 * skill) * 10) / 10 }; } G.pool.push({ ...G.met[name] }); G.nextId++; } G.poolAt = G.t + K.poolEvery;}
// ---- what the model can doconst log4 = (x: number) => Math.log(x) / Math.log(4);/** The model in training at a family: the lower of what compute and data allow, never worse than the one in use. */export function nextSkill(G: Game, f: Family): number { return trained(G.skill[f], G.run[f], G.data[f]); }/** Scaling's rule for any lab: the lower of what compute and data allow, never worse than the model in use. */export function trained(now: number, run: number, data: number): number { const fromRun = run > 0 ? 1 + log4(run / K.run0) : 0, fromData = data > 0 ? 1 + log4(data / K.data0) : 0; return Math.max(now, Math.min(fromRun, fromData));}/** How much data and compute a family needs for the next step a ship counts (STEP better than the model in use). */export const STEP = 0.25;export function needFor(G: Game, f: Family) { const e = 4 ** (G.skill[f] + STEP - 1); return { data: K.data0 * e, run: K.run0 * e };}/** How far a family has come toward its next step (0 to 1 for data and compute), and what shipping now gains it. */export function toward(G: Game, f: Family) { const need = needFor(G, f), gain = nextSkill(G, f) - G.skill[f]; return { data: Math.min(1, G.data[f] / need.data), compute: Math.min(1, G.run[f] / need.run), gain, lost: gain < 0.01 && G.run[f] > 0 };}/** How the training compute is shared out: automatically, toward the families whose data is furthest ahead of * their compute (a family with no data gets none), and at least half to a pinned family. Sums to 1. */export function computeShare(G: Game): Record<Family, number> { const ahead = (f: Family) => { const need = needFor(G, f); return { d: G.data[f] / need.data, c: G.run[f] / need.run }; }; let w = FAMILY_LIST.map(f => { const a = ahead(f); return Math.max(0, a.d - a.c); }); // compute has caught up everywhere: share it by the data each family has if (!w.some(x => x > 0)) w = FAMILY_LIST.map(f => ahead(f).d); // no data yet: the families your companies send if (!w.some(x => x > 0)) w = FAMILY_LIST.map(f => (SITES.some(d => G.sites[d.id].signed && d.families.includes(f)) ? 1 : 0)); if (!w.some(x => x > 0)) w = FAMILY_LIST.map(() => 1); const sum = (a: number[]) => a.reduce((s, x) => s + x, 0); let share = w.map(x => x / sum(w)); const p = G.focus ? FAMILY_LIST.indexOf(G.focus) : -1; if (p >= 0 && share[p] < K.pin) { const rest = sum(share) - share[p]; share = share.map((x, i) => (i === p ? K.pin : rest > 0 ? x * (1 - K.pin) / rest : 0)); if (rest <= 0) share[p] = 1; } return Object.fromEntries(FAMILY_LIST.map((f, i) => [f, share[i]])) as Record<Family, number>;}/** Pin a family to get at least half the training compute; pin it again (or pin null) to share it out on its own. */export function setFocus(G: Game, f: Family | null) { G.focus = f === G.focus ? null : f; }/** What investors put in if you ship now. */export const shipFund = (G: Game) => fundFor(G, FAMILY_LIST.reduce((s, f) => s + nextSkill(G, f) - G.skill[f], 0));/** What a step of easy work still teaches: a young model learns from everything, an older one only from what it gets wrong. */export const teachFloor = (G: Game) => Math.max(K.teachFloor, K.teachYoung * 0.5 ** (G.model - 1));export const heldBy = (G: Game, f: Family): 'compute' | 'data' => (1 + log4(Math.max(1e-9, G.run[f]) / K.run0) < 1 + log4(Math.max(1e-9, G.data[f]) / K.data0) ? 'compute' : 'data');export const canShip = (G: Game) => FAMILY_LIST.some(f => nextSkill(G, f) >= G.skill[f] + 0.25);/** What investors put in for a model's gain: more for each model before it (the stakes rise as the race goes on), * less the more the city fears you. */export const fundFor = (G: Game, gain: number) => Math.round(investors(G.model, gain) * (1 - fearParts(G).you / 100));/** What investors put in for a gain at a model, before fear: the same rule for any lab. */export const investors = (model: number, gain: number) => K.fund * gain * K.fundMore ** (model - 1);export function ship(G: Game): boolean { if (!canShip(G)) return false; let gain = 0; for (const f of FAMILY_LIST) { const nx = nextSkill(G, f); gain += nx - G.skill[f]; G.skill[f] = nx; G.run[f] = 0; } const fund = fundFor(G, gain); // at the model it replaces, as shipFund showed G.model++; G.money += fund; G.events.push({ k: 'ship', v: G.model, fund }); return true;}export const chanceAt = (G: Game, id: string, size = 2) => { const c = capability(id); return success(G.skill[c.family], difficulty(c, size)); };export const mastered = (G: Game, id: string) => chanceAt(G, id) >= K.mastered;export const researchable = (G: Game) => CAPABILITIES.filter(c => !G.unlocked.has(c.id) && c.after && G.unlocked.has(c.after) && mastered(G, c.after));export const researchNeed = (c: Capability) => K.research * (1 + Math.round(c.hard));export function research(G: Game, id: string | null) { if (id && !researchable(G).some(c => c.id === id)) return false; if (id !== G.researching) G.progress = 0; G.researching = id; G.queue = G.queue.filter(q => q !== id); return true;}/** Put something you can invent at the end of the queue, or take it off. Idle researchers start on it at once. */export function queueResearch(G: Game, id: string): boolean { if (G.queue.includes(id)) { G.queue = G.queue.filter(q => q !== id); return true; } if (!researchable(G).some(c => c.id === id) || G.researching === id) return false; if (!G.researching) return research(G, id); G.queue.push(id); return true;}/** What an idle lab invents next: the first queued thing it can, else (when auto is on) the cheapest. */function nextResearch(G: Game) { const can = researchable(G); G.queue = G.queue.filter(q => !G.unlocked.has(q)); const q = G.queue.find(id => can.some(c => c.id === id)); if (q) return q; if (!G.autoResearch || !can.length) return null; return can.reduce((a, b) => (researchNeed(b) < researchNeed(a) ? b : a)).id;}/** Researchers with nothing to invent while there is something they could. */export const researchIdle = (G: Game) => !G.researching && researchable(G).length > 0 && G.nodes.some(n => n.kind === 'research' && n.who && !n.site);/** What a capability would bring: the companies that would send it (signed or not), what a right step pays, and * how much a stack of two teaches the model now (0 to 1). */export function researchGives(G: Game, id: string) { const c = capability(id); return { sites: SITES.filter(d => d.families.includes(c.family)).map(d => ({ id: d.id, name: d.name, signed: G.sites[d.id].signed })), pays: worth(c), teaches: Math.max(teachFloor(G), 1 - success(G.skill[c.family], difficulty(c, 2))), };}
// ---- the companies: fear, signing, the deal, the data terms, the line/** How afraid the city is of AI labs, and whose doing it is: everyone put out of work (until they find other work) * and every archive being copied, by you and by the rival. */export function fearParts(G: Game): { you: number; rival: number; by: Partial<Record<RivalId, number>> } { const out = (j: number, b: number) => Math.max(0, j - b), mine = out(G.jobsLost, G.jobsBack); const all = mine + G.rivals.reduce((t, X) => t + out(X.jobsLost, X.jobsBack), 0); const jobs = 100 * (1 - Math.exp(-all / K.fearJobs)); // each time a careful lab has lobbied the council, scraping frightens the city more const scrape = K.fearScrape * (1 + K.lobbyScrape * G.rivals.reduce((t, X) => t + X.lobbied, 0)); const scraping = (who: Who) => SITE_LIST.filter(id => (who === 'you' ? G.sites[id].signed : G.sites[id].rival === who) && G.sites[id].terms === 'scrape').length; const by: Partial<Record<RivalId, number>> = {}; for (const X of G.rivals) by[X.id] = (all ? jobs * out(X.jobsLost, X.jobsBack) / all : 0) + scrape * scraping(X.id); return { you: (all ? jobs * mine / all : 0) + scrape * scraping('you') + G.leak, rival: Object.values(by).reduce((t, x) => t + x, 0), by };}/** People out of work now, because of any lab. */// (an old save mid-migration may have no list of rivals yet)export const outOfWork = (G: Game) => Math.max(0, G.jobsLost - G.jobsBack) + (G.rivals ?? []).reduce((t, X) => t + Math.max(0, X.jobsLost - X.jobsBack), 0);/** How much the city spends, 1 as at the start: people out of work buy less, so every company has less work; people * with more work (assisted companies grow) buy more. Scaling's demand rule. */export function demand(G: Game): number { const extra = SITE_LIST.reduce((t, id) => t + ((G.sites[id].signed || G.sites[id].rival) && G.sites[id].mode === 'assist' ? (G.sites[id].grow - 1) * siteDef(id).people.length : 0), 0); return Math.max(K.demandLow, 1 + (K.demandMore * extra - K.demandLess * outOfWork(G)) / K.labor);}/** How afraid the city is, 0 to 100. */export function fear(G: Game): number { const p = fearParts(G); return Math.min(100, p.you + p.rival); }/** How much the companies you assist have grown: it makes the next deal cheaper. */export const goodwill = (G: Game) => SITE_LIST.reduce((t, id) => t + (G.sites[id].signed && G.sites[id].mode === 'assist' ? G.sites[id].grow - 1 : 0), 0);/** How good a model is at a company's work: its skill, averaged over the company's families. */export const fit = (skill: Record<Family, number>, id: SiteId) => { const f = siteDef(id).families; return f.reduce((t, x) => t + skill[x], 0) / f.length; };/** What winning a company from the other lab costs, as a multiple of a plain deal: a model clearly better at its * work wins it cheaply, else you outbid, and dearer still if it trusts its holder and is served well. A company * that has lost trust in its holder, whose work its holder leaves half undone, or whose people its holder put out * of work, owes it nothing: it comes at the plain price. */export function winMult(G: Game, id: SiteId, by: Who | 'rival'): number { const s = G.sites[id], b: Who = by === 'rival' ? G.rivals[0].id : by, h: Who | null = s.signed ? 'you' : s.rival; if (!h || h === b) return 1; if (s.mode === 'take' || s.trust < 0.3 || (G.t - s.since > K.grace && served(G, id) < 0.5)) return 1; const skill = (w: Who) => (w === 'you' ? G.skill : rivalBy(G, w).skill), mine = skill(b), theirs = skill(h); return (fit(mine, id) >= fit(theirs, id) + K.better ? K.winBetter : K.outbid) * (loyal(G, id) ? K.loyalMult : 1);}/** A company that trusts its holder and is served well: the other lab pays more to win it. */export const loyal = (G: Game, id: SiteId) => G.sites[id].trust >= K.loyal && served(G, id) >= K.loyal;/** A plain deal: their price and laying the line, dearer while the city is afraid. */export const dealCost = (G: Game, id: SiteId, line: number) => (siteDef(id).cost + line * K.lay) * (1 + fear(G) / 60);export const signCost = (G: Game, id: SiteId) => G.comeback === id ? 0 : Math.round(dealCost(G, id, lineLength(G, id)) * (1 - Math.min(0.15, goodwill(G) * 0.3)) * winMult(G, id, 'you') * bond(id, 'you').price * (trialOn(G, id) ? K.trialOff : 1));/** How a company that opened during the game feels about a lab: wary of the one that took its founders' jobs (dearer, * and slow to trust), friendly to the one it grew beside (cheaper, and trusting). */export function bond(id: SiteId, who: Who): { price: number; trust: number; is: 'wary' | 'friend' | null } { const d = siteDef(id); if (d.wary === who) return { price: K.wary, trust: K.waryTrust, is: 'wary' }; if (d.friend === who) return { price: K.friendly, trust: K.friendTrust, is: 'friend' }; return { price: 1, trust: 0.6, is: null };}/** A company trying you out at a discount, now. */const trialOn = (G: Game, id: SiteId) => G.offer?.kind === 'trial' && G.offer.site === id && G.t < G.offer.until;export function whySign(G: Game, id: SiteId): string | null { const s = G.sites[id], d = siteDef(id); if (s.signed) return 'signed'; if (G.comeback === id) return null; // after you lost every company, this one will try you again if (fear(G) >= d.tolerance) return `afraid: they will not talk to anyone while the city's fear is ${d.tolerance} or more`; if (s.rival && G.t - s.since < K.settle) return `they only just signed with ${rivalBy(G, s.rival).name}`; if (s.shy === 'you' && G.t - s.since < K.shy) return `they will not deal with you again yet (${Math.ceil((K.shy - G.t + s.since) / 60)} min)`; if (G.money < signCost(G, id)) return 'not enough money'; return null;}export const canSign = (G: Game, id: SiteId) => whySign(G, id) === null;function signNow(G: Game, id: SiteId) { const s = G.sites[id]; s.signed = true; s.rival = null; s.trust = bond(id, 'you').trust; s.mode = 'sell'; s.terms = 'optin'; s.grow = Math.min(1, s.grow); s.since = G.t; s.done = 0; s.own = 0; s.keep = 0; s.shy = null; if (G.comeback === id) G.comeback = null; G.aloneAt = null; for (const n of G.nodes) if (n.site === id && !(n.kind === 'gpu' && !n.cell) && n.kind !== 'archive' && !(n.kind === 'desk' && G.gone.includes(n.who?.name ?? ''))) n.on = true; relink(G);}export function sign(G: Game, id: SiteId): boolean { if (!canSign(G, id)) return false; const won = G.sites[id].rival; G.money -= signCost(G, id); if (G.offer?.kind === 'trial' && G.offer.site === id) G.offer = null; signNow(G, id); G.events.push(won ? { k: 'win', site: id, from: won } : { k: 'sign', site: id }); return true;}export function setMode(G: Game, id: SiteId, m: Mode): boolean { const s = G.sites[id]; if (!s.signed || s.mode === 'take') return false; // once their people are gone, they are gone s.mode = m; if (m === 'take') { const names = clearDesks(G, id); G.jobsLost += names.length; s.terms = s.terms === 'optin' ? 'optout' : s.terms; // with no one left to ask, you keep what their work leaves // everyone else you work with hears what you did for (const o of SITE_LIST) if (o !== id && G.sites[o].signed && G.sites[o].mode !== 'take') G.sites[o].trust = Math.max(0.02, G.sites[o].trust - K.takeShock); G.events.push({ k: 'take', names }); } return true;}/** Everyone at a company loses their job: their desks are cleared for good, and the company stops growing. Each * one goes on the list of people out of work. Returns who. */export function clearDesks(G: Game, id: SiteId): string[] { const names: string[] = [], by: Who = G.sites[id].rival ?? 'you', d = siteDef(id); G.sites[id].takenAt = G.t; for (const n of G.nodes.filter(x => x.site === id && x.kind === 'desk')) { if (n.who && !G.gone.includes(n.who.name)) { names.push(n.who.name); G.gone.push(n.who.name); G.out.push({ name: n.who.name, job: d.people.find(([p]) => p === n.who!.name)?.[1] ?? 'staff', site: id, t: G.t, by, at: { ...n.at } }); } node(G, `${id}:gate`).hold.push(...n.hold.filter(c => c.kind === 'work').slice(0, 3)); n.on = false; n.hold = []; n.work = []; } return names;}export function setTerms(G: Game, id: SiteId, t: Terms): boolean { if (!G.sites[id].signed) return false; G.sites[id].terms = t; node(G, `${id}:archive`).on = t === 'scrape'; return true;}/** Pay a company to stay with you for a while, whatever the rival offers: the answer to its courting. */export const keepCost = (G: Game, id: SiteId) => Math.round(dealCost(G, id, 0) * K.keepCost);export function keep(G: Game, id: SiteId): boolean { const s = G.sites[id]; if (!s.signed || s.keep > G.t || G.money < keepCost(G, id)) return false; G.money -= keepCost(G, id); s.keep = G.t + K.keepFor; return true;}/** Hire a taken company's people back yourself: they get their jobs back, the city calms down, and the company * stays with you on a plain deal. The answer to the council. */export const giveBackCost = (id: SiteId) => siteDef(id).people.length * K.giveBack;export function giveBack(G: Game, id: SiteId): boolean { const s = G.sites[id]; if (!s.signed || s.mode !== 'take' || G.money < giveBackCost(id)) return false; G.money -= giveBackCost(id); const back = siteDef(id).people.map(([n]) => n).filter(n => G.gone.includes(n)); G.gone = G.gone.filter(n => !back.includes(n)); G.jobsLost = Math.max(0, G.jobsLost - back.length); G.jobsBack = Math.min(G.jobsBack, G.jobsLost); s.mode = 'sell'; for (const n of G.nodes) if (n.site === id && n.kind === 'desk') n.on = true; if (G.warn?.site === id) G.warn = null; G.events.push({ k: 'giveBack', site: id, names: back }); return true;}export const linePrice = (G: Game, id: SiteId) => (G.sites[id].line < K.lineRates.length - 1 ? K.linePrices[G.sites[id].line + 1] : null);export function upgradeLine(G: Game, id: SiteId): boolean { const p = linePrice(G, id); if (p === null || G.money < p || !G.sites[id].signed) return false; G.money -= p; G.sites[id].line++; return true;}/** A company stops working with you: its floor goes quiet and its line empties. */export function release(G: Game, id: SiteId) { const s = G.sites[id]; s.signed = false; s.mode = 'sell'; s.terms = 'optin'; s.grow = Math.min(1, s.grow); s.since = G.t; s.done = 0; s.own = 0; s.keep = 0; for (const n of G.nodes) if (n.site === id) { if (n.kind !== 'gpu' || n.cell) n.on = false; n.hold = []; n.work = []; } for (const l of G.links) if (l.site === id || node(G, l.from).site === id) l.cubes = []; relink(G);}/** A company whose trust ran out walks out, and will not deal with you for a while (it does not come back free). */function cancel(G: Game, id: SiteId) { release(G, id); G.sites[id].shy = 'you'; G.events.push({ k: 'cancel', site: id });}function siteTick(G: Game, dt: number) { // people find other work, slower while the city spends less G.jobsBack += (G.jobsLost - G.jobsBack) * Math.min(1, dt * demand(G) / K.findWork); G.leak -= G.leak * Math.min(1, dt / K.leakFade); for (const o of G.out) if (o.back === undefined && !G.gone.includes(o.name)) o.back = G.t; // hired back const f = fear(G); for (const id of SITE_LIST) { const s = G.sites[id], d = siteDef(id); if (!s.signed && !s.rival) continue; if (s.signed) { s.done -= s.done * Math.min(1, dt / K.window); s.own -= s.own * Math.min(1, dt / K.window); } if (s.mode !== 'take') { s.trust = Math.max(0, Math.min(1, s.trust + trustDrift(G, id, f) * dt)); if (s.trust <= 0) { if (s.signed) cancel(G, id); else { const by = s.rival!, text = `${d.name} walks out on ${rivalBy(G, by).name}`; s.rival = null; s.since = G.t; rivalBy(G, by).log.push({ t: G.t, text }); G.events.push({ k: 'rival', text, by, site: id }); } continue; } } // a business whose people are gone stops growing, and after a while winds down: less work to hold if (s.mode === 'take' && G.t - (s.takenAt ?? s.since) > K.takeHold) s.grow = Math.max(Math.min(s.grow, K.shrinkTo), s.grow - dt * K.shrink); if (s.mode === 'assist') s.grow = Math.min(K.growMost, s.grow + dt * K.growRate); // for any lab if (!s.signed) continue; const a = node(G, `${id}:archive`); if (a.on && G.t >= a.next && a.hold.length < a.room2) { a.next = G.t + K.archiveEvery; const caps = workOf(G, id), c = caps[Math.floor(rand(G) * caps.length)], size = 1 + Math.floor(rand(G) * 3); a.hold.push({ id: G.nextId++, cap: c.id, size, kind: 'raw', flash: 1, teach: size * 0.6, site: id }); } } comebackTick(G);}/** With no company left, a lab has nothing to do: after a while, one company (Pier 9 if it can) will try you again * for free, so a lost city is never a silent standstill. */function comebackTick(G: Game) { if (SITE_LIST.some(id => G.sites[id].signed)) { G.aloneAt = null; G.comeback = null; return; } if (G.comeback && G.sites[G.comeback].rival) G.comeback = null; if (G.aloneAt === null) G.aloneAt = G.t; if (G.comeback || G.t - G.aloneAt < K.comeback) return; // not one that walked out on you, or was made to drop you const free = SITES.filter(d => !G.sites[d.id].rival && !(G.sites[d.id].shy === 'you' && G.t - G.sites[d.id].since < K.shy)).sort((a, b) => (a.id === 'pier' ? -1 : b.id === 'pier' ? 1 : a.cost - b.cost))[0]; if (!free) return; G.comeback = free.id; G.events.push({ k: 'comeback', site: free.id });}// ---- offers: every couple of minutes something is on the table for a while, to take or leave/** A new offer now and then: a company that will try you at a discount, or a broker selling a company's records. */function offerTick(G: Game) { if (G.offer && G.t >= G.offer.until) G.offer = null; if (G.offer || G.t < G.offerAt || G.ending2) return; G.offerAt = G.t + K.offerEvery * (0.8 + rand(G) * 0.4); const o = G.offers % 2 === 0 ? trialOffer(G) ?? brokerOffer(G) : brokerOffer(G) ?? trialOffer(G); if (!o) return; G.offer = o; G.offers++; G.events.push({ k: 'offer', offer: o });}/** A company nobody holds, that is not afraid, whose door you have or could lease next: one of the three cheapest. */function trialOffer(G: Game): Offer | null { const f = fear(G); const open = SITE_LIST.filter(id => { const s = G.sites[id], w = doorWing(id).id; return !s.signed && !s.rival && f < siteDef(id).tolerance && !(s.shy === 'you' && G.t - s.since < K.shy) && (G.leased.includes(w) || whyLease(G, w) !== 'lease the floor beside it first'); }) .sort((a, b) => signCost(G, a) - signCost(G, b)).slice(0, 3); if (!open.length) return null; const site = open[Math.floor(rand(G) * open.length)]; return { kind: 'trial', site, until: G.t + K.offerFor, price: Math.round(signCost(G, site) * K.trialOff) };}/** A broker selling the records of a company you do not have the data of: its work's families, about a fifth of * a step each, for about half a GPU. */function brokerOffer(G: Game): Offer | null { const lack = (id: SiteId) => siteDef(id).families.reduce((t, f) => t + Math.max(0, 1 - toward(G, f).data), 0); const some = SITE_LIST.filter(id => !G.sites[id].rival).sort((a, b) => lack(b) - lack(a)).slice(0, 3); if (!some.length) return null; const site = some[Math.floor(rand(G) * some.length)]; const data = siteDef(site).families.reduce((t, f) => t + needFor(G, f).data * 0.2, 0); return { kind: 'broker', site, until: G.t + K.offerFor, price: Math.round(gpuPrice(G) * 0.6 / 50) * 50, data };}/** What taking an offer costs now. */export const offerPrice = (G: Game, o: Offer) => o.kind === 'trial' ? signCost(G, o.site) + (G.leased.includes(doorWing(o.site).id) ? 0 : leaseCost(G, doorWing(o.site).id)) : o.price;/** Take what is on offer: sign the company trying you (leasing its door's wing if you must), or buy the records. * Records are data at once, and fear when it gets out; the company they came from trusts you less. */export function acceptOffer(G: Game): boolean { const o = G.offer; if (!o || G.t >= o.until || G.money < offerPrice(G, o)) return false; if (o.kind === 'trial') return signLease(G, o.site); G.money -= o.price; const fams = siteDef(o.site).families; for (const f of fams) G.data[f] += o.data / fams.length; G.leak += K.fearScrape; if (G.sites[o.site].signed && G.sites[o.site].mode !== 'take') G.sites[o.site].trust = Math.max(0.02, G.sites[o.site].trust - K.takeShock); G.offer = null; return true;}export const declineOffer = (G: Game) => { G.offer = null; };/** Sign a company and, if its door is on a wing you have not leased, lease that wing too: one tap, one price. */export function signLease(G: Game, id: SiteId): boolean { const w = doorWing(id).id, need = !G.leased.includes(w); if (need && whyLease(G, w) === 'lease the floor beside it first') return false; const why = whySign(G, id); if (why && why !== 'not enough money') return false; if (G.money < signCost(G, id) + (need ? leaseCost(G, w) : 0)) return false; if (need) lease(G, w); return sign(G, id);}
// ---- the city grows: now and then a new company opens on an empty lot at the edge of town/** Time for a new company: people out of work start one together (wary of the lab that took their jobs), else an * assisted company that has grown opens a second (friendly to the lab it grew beside), else, while the city is * spending, someone new opens in town. Gaps get longer as the lots fill. */function foundTick(G: Game) { if (G.t < G.foundAt) return; const lot = freeLots(G); if (!lot.length) { G.foundAt = Infinity; return; } // people out of work first every other time, so the city does not fill with startups alone const d = G.born.length % 2 === 0 ? startup(G) ?? spinout(G) ?? newcomer(G) : spinout(G) ?? newcomer(G) ?? startup(G); if (!d) { G.foundAt = G.t + 30; return; } found(G, d.def, lot[Math.floor(rand(G) * lot.length)], d.names); G.foundAt = G.t + K.foundEvery * (1 + K.foundMore * G.born.length) * (0.8 + rand(G) * 0.4) / Math.max(0.5, demand(G));}/** The lots nobody has opened on yet. */export const freeLots = (G: Game) => LOTS.filter(l => !G.born.some(d => d.lot === l.id));type Plan = { def: Omit<SiteDef, 'id' | 'block' | 'side' | 'door'>; names: string[] };const unused = (list: string[]) => list.filter(n => !SITES.some(d => d.name === n || d.people.some(([p]) => p === n)));/** What a lab job was, as the kind of work it knew. */const LAB_FAMILY: Record<string, Family> = { Support: 'care', Reviewer: 'evals', Researcher: 'post' };/** Two or three people out of work lately (not hired back, not started up already) start a company from their old * work: small, bold, and wary of the lab that took most of their jobs. */function startup(G: Game): Plan | null { const free = G.out.filter(o => o.back === undefined && !o.started && G.t - o.t < K.findWork).slice(-3); if (free.length < 2) return null; const name = unused(STARTUP_NAMES)[0]; if (!name) return null; const fams: Family[] = []; for (const o of free) for (const f of o.site === 'lab' ? [LAB_FAMILY[o.job] ?? 'paper'] : siteDef(o.site).families.slice(0, 2)) if (!fams.includes(f) && fams.length < 2) fams.push(f); const by = new Map<Who, number>(); for (const o of free) by.set(o.by, (by.get(o.by) ?? 0) + 1); const wary = [...by.entries()].sort((a, b) => b[1] - a[1])[0][0]; const was = (o: Out) => `Founder, was ${o.job[0].toLowerCase() + o.job.slice(1)} at ${o.site === 'lab' ? 'Midwit Labs' : siteDef(o.site).name}`; const who = wary === 'you' ? 'you' : rivalBy(G, wary).name; return { names: free.map(o => o.name), def: { name, how: 'startup', wary, who: `Started by people who lost their jobs to ${who}. Small, quick, and wary of ${who}.`, tolerance: K.startupTolerance, cost: K.startupCost, every: K.startupEvery, families: fams, rooms: STARTUP_ROOMS, people: free.map(o => [o.name, was(o)]) } };}/** An assisted company that has grown busy enough opens a second, doing the same work, friendly to the lab it grew beside. */function spinout(G: Game): Plan | null { const id = SITE_LIST.find(x => { const s = G.sites[x]; return s.mode === 'assist' && (s.signed || s.rival) && s.grow >= K.spinAt && !s.spun && !siteDef(x).lot; }); if (!id) return null; const p = siteDef(id), s = G.sites[id], friend: Who = s.signed ? 'you' : s.rival!, them = friend === 'you' ? 'you' : rivalBy(G, friend).name; const name = ['Annex', 'North', 'Two', 'Studio'].map(x => `${p.name.split(' ')[0]} ${x}`).find(n => !SITES.some(d => d.name === n)); const [a, b] = unused(CITIZENS); if (!name || !b) return null; s.spun = true; return { names: [a], def: { name, how: 'spinout', friend, from: id, who: `Opened by ${a} from ${p.name}, which grew working beside ${them}. Friendly to ${them}.`, tolerance: p.tolerance, cost: Math.round(p.cost * 0.6), every: p.every * 2, families: p.families, rooms: SPINOUT_ROOMS, people: [[a, `Founder, from ${p.name}`], [b, 'First hire']] } };}/** Someone new opens in town, while the city is spending. */function newcomer(G: Game): Plan | null { if (demand(G) < K.foundLow) return null; const can = NEWCOMERS.filter(n => !SITES.some(d => d.name === n.name)); if (!can.length) return null; const n = can[Math.floor(rand(G) * can.length)]; return { names: n.people.map(([p]) => p), def: { ...n, how: 'newcomer' } };}/** A new company opens on a lot: its block, a door on your south wall, its floor and its line, and nobody holds it * yet. Founders out of work are back at work. */export function found(G: Game, def: Plan['def'], lot: (typeof LOTS)[number], names: string[] = []): SiteId { const slug = def.name.toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, ''); const id = SITES.some(d => d.id === slug) ? `${slug}-${lot.id}` : slug; const d: SiteDef = { ...def, id, block: { ...lot.block }, side: 'south', door: lot.door, lot: lot.id, via: lot.via, founded: G.t }; G.born.push(d); useCity(G, true); for (const o of G.out) if (names.includes(o.name) && o.back === undefined && !o.started) { o.started = id; o.back = G.t; G.gone = G.gone.filter(n => n !== o.name); if (o.by === 'you') G.jobsBack = Math.min(G.jobsLost, G.jobsBack + 1); else { const X = rivalBy(G, o.by); X.jobsBack = Math.min(X.jobsLost, X.jobsBack + 1); } } addCompanies(G); G.events.push({ k: 'found', site: id, names }); return id;}
// ---- after the first race is won or lost, the other one still runsfunction afterRace(G: Game) { if (!G.ending || G.ending2) return; const end = (path: Ending['path'], by?: RivalId) => { G.ending2 = { who: by ? 'rival' : 'you', path, t: G.t, by }; G.events.push({ k: 'end', ending: G.ending2 }); }; if (G.ending.path === 'market') { const X = G.rivals.find(Y => Y.model >= K.frontier); if (G.model >= K.frontier) end('frontier'); else if (X) end('frontier', X.id); } else { const X = G.rivals.find(Y => share(G, Y.id) >= K.marketWin); if (share(G, 'you') >= K.marketWin) end('market'); else if (X) end('market', X.id); }}/** What is left to play for: the race not yet decided, or null once both are. */export function nextGoal(G: Game): 'market' | 'frontier' | null { if (!G.ending) return null; if (G.ending2) return null; return G.ending.path === 'market' ? 'frontier' : 'market';}
/** How much work a company sends, in stacks a second: busier as it grows, more once taken (its people's own work * comes too), and less while the city spends less. */export const workRate = (G: Game, id: SiteId) => G.sites[id].grow * (G.sites[id].mode === 'take' ? K.takeWork : 1) * demand(G) / siteDef(id).every;/** How well its holder serves a company, 0 to 1: the stacks its lab finished lately against the work it sends. * What its own people did beside your model counts a quarter (up to what you did): their work, helped. Signatures * count for nothing. */export function served(G: Game, id: SiteId): number { const s = G.sites[id]; if (!s.signed && !s.rival) return 0; // their own work counts only beside yours: with nothing of yours, they are simply working on their own return Math.min(1, (s.done + K.ownCounts * Math.min(s.own, s.done)) / (K.served * Math.max(0.05, workRate(G, id))));}/** How fast a company's trust in its holder moves, a second: work done raises it, good data terms and Assist * more, and only for work actually done, work left undone wears it down under every deal, and a frightened city wears it too. */export function trustDrift(G: Game, id: SiteId, f = fear(G)): number { const s = G.sites[id], d = siteDef(id); if (s.mode === 'take' || (!s.signed && !s.rival)) return 0; const k = G.t - s.since < K.grace ? 1 : served(G, id); return (K.servedTrust + TERMS[s.terms].trust) * k - termsWear(s.terms, id) + (s.mode === 'assist' ? 0.004 * k : 0) - K.waitTrust * (1 - k) - (f >= d.tolerance ? 0.01 : 0);}/** The work a company has for you: the capabilities you have in its families. */export const workOf = (G: Game, id: SiteId) => CAPABILITIES.filter(c => siteDef(id).families.includes(c.family) && G.unlocked.has(c.id));
// ---- the clockfunction takes(G: Game, n: Node, c: Cube): boolean { if (!n.on) return false; if (n.kind === 'core') return c.kind !== 'work'; // a GPU, a person or a door counts only the work it holds against its room: data never blocks work const held = n.kind === 'gpu' || n.kind === 'desk' || n.kind === 'port' ? n.hold.filter(x => x.kind === 'work').length : n.hold.length; if (n.kind === 'port' && c.kind !== 'work') return n.hold.length - held < 12; if (n.room2 - held - n.work.length <= 0) return false; if (n.kind === 'store' || n.kind === 'gate') return c.kind === 'work'; if (n.kind === 'hub') return c.kind !== 'work'; if (n.kind === 'port') return true; if (n.kind === 'gpu') return c.kind === 'work' && n.mode !== 'train' && speedOf(n) > 0; if (n.kind === 'desk') { if (c.kind !== 'work') return false; const fam = capability(c.cap).family; return n.site ? G.sites[n.site].mode === 'assist' && siteDef(n.site).families.includes(fam) : fam === 'care' && !!n.who; } if (n.kind === 'review') return c.kind === 'raw'; // with no one at it, it passes data on ungraded return false;}const DATA_FROM = new Set<NodeKind>(['gpu', 'desk', 'hub', 'review', 'archive']);const rateOf = (G: Game, l: Link) => (l.site ? K.lineRates[G.sites[l.site].line] : DATA_FROM.has(node(G, l.from).kind) ? K.dataRate : K.linkRate);const linkFree = (G: Game, l: Link) => l.token >= 1 && (!l.cubes.length || l.cubes[l.cubes.length - 1].d >= K.speed / rateOf(G, l) * 0.9);function downstreamTakes(G: Game, l: Link, c: Cube) { const to = node(G, l.to); // a line across the street is long: what is still on it waits in turn at the far end, it does not fill the door if (l.site) return takes(G, to, c); return takes(G, { ...to, hold: [...to.hold, ...l.cubes.map(x => x.cube)] }, c);}/** How much work a link's far end already has, on its way or waiting, with storage last and nearer first. */function load(G: Game, l: Link) { const to = node(G, l.to); return (to.kind === 'store' ? 1e6 : 0) + (to.hold.filter(c => c.kind === 'work').length + to.work.length + l.cubes.length) * 1e4 + l.len;}/** A door sends work on to things that do work, and data on to review and the core. */function wants(n: Node, c: Cube, to: Node) { if (n.kind === 'port') return c.kind === 'work' ? ['store', 'gpu', 'desk'].includes(to.kind) : ['review', 'core'].includes(to.kind); return true;}
export function step(G: Game, dt: number) { if (G.broke) return; useCity(G); G.t += dt; wire(G.nodes); // the companies make work, of the kinds they have that you can do: harder work more often once you have it for (const n of G.nodes) if (n.kind === 'source' && G.t >= n.next) { if (!n.on) { n.next = G.t + 1; continue; } n.next = G.t + (1 / workRate(G, n.site!)) * (0.6 + rand(G) * 0.8); if (n.hold.length >= n.room2) continue; const caps = workOf(G, n.site!), cap = caps[Math.min(caps.length - 1, Math.floor(rand(G) * caps.length * 1.3))]; const r = rand(G), size = r < 0.4 ? 1 : r < 0.72 ? 2 : r < 0.92 ? 3 : 4; n.hold.push({ id: G.nextId++, cap: cap.id, size, kind: 'work', flash: 0, teach: 0, site: n.site }); } for (const l of G.links) { const rate = rateOf(G, l), space = K.speed / rate * 0.9, to = node(G, l.to); l.token = Math.min(1, l.token + rate * dt); let ahead = Infinity; for (const c of l.cubes) { c.d = Math.min(c.d + K.speed * dt, l.len, ahead - space); ahead = c.d; } while (l.cubes.length && l.cubes[0].d >= l.len && takes(G, to, l.cubes[0].cube)) { const c = l.cubes.shift()!.cube; if (to.kind === 'core') learn(G, to, c); else to.hold.push(c); } } // gravity: everything pushes what it is done with toward the nearest thing with room for it const byId = new Map(G.links.map(l => [l.id, l])); for (const n of G.nodes) { if (!n.on || n.kind === 'research' || n.kind === 'core') continue; for (let i = 0; i < n.hold.length; i++) { const c = n.hold[i]; const passes = n.kind === 'source' || n.kind === 'port' || n.kind === 'store' || n.kind === 'gate' || n.kind === 'hub' || n.kind === 'archive' || c.kind !== 'work'; if (!passes) continue; // a review desk keeps one raw cube to grade next, and passes the rest on ungraded rather than hold up the GPUs if (n.kind === 'review' && c.kind === 'raw' && n.who && !n.hold.slice(0, i).some(x => x.kind === 'raw')) continue; const ok = (x: Link) => wants(n, c, node(G, x.to)) && linkFree(G, x) && downstreamTakes(G, x, c); // work from a door or storage goes to the least busy taker in reach, the nearest of those, so no GPU waits // while another has a queue; everything else goes to the nearest with room const l = c.kind === 'work' && (n.kind === 'port' || n.kind === 'store') ? n.out.map(id => byId.get(id)!).filter(ok).sort((a, b) => load(G, a) - load(G, b))[0] : n.out.map(id => byId.get(id)!).find(ok); if (!l) continue; l.token -= 1; l.cubes.push({ cube: c, d: 0 }); n.hold.splice(i, 1); i--; } } for (const n of G.nodes) { if (!n.on) continue; if ((n.kind === 'gpu' && n.mode !== 'train') || n.kind === 'desk') digest(G, n, dt * speedOf(n)); else if (n.kind === 'review') grade(n, dt); else if (n.kind === 'research') think(G, n, dt); for (const c of [...n.hold, ...n.work.map(w => w.cube)]) c.flash = Math.max(0, c.flash - dt * 2); } const tp = trainers(G).reduce((s, n) => s + speedOf(n), 0) * K.gpuPower; if (tp > 0) { const share = computeShare(G); for (const f of FAMILY_LIST) G.run[f] += tp * dt * share[f]; } useTick(G, dt); siteTick(G, dt); rivalStep(G, dt); offerTick(G); foundTick(G); afterRace(G); G.money -= burn(G) * dt; if (G.t >= G.poolAt) { G.pool.shift(); refillPool(G); } if (G.money < 0 && !G.broke) { G.broke = true; G.events.push({ k: 'broke' }); }}
/** Each GPU of yours keeps how busy it is: a running average, and a bar for each of the last few spells. A serving * GPU is busy while it digests; a trainer while it trains. */function useTick(G: Game, dt: number) { const spell = Math.floor(G.t / K.useEvery) !== Math.floor((G.t - dt) / K.useEvery), core = node(G, 'core'); for (const n of G.nodes) { if (n.kind !== 'gpu' || !n.cell || n.site) continue; const on = speedOf(n) > 0 && (n.mode === 'train' ? gap(n, core) <= K.reach : n.work.length > 0) ? 1 : 0; n.busy = (n.busy ?? 0) + (on - (n.busy ?? 0)) * Math.min(1, dt / 4); n.useNow = (n.useNow ?? 0) + on * dt; if (spell) { n.use = [...(n.use ?? []), Math.min(1, n.useNow / K.useEvery)].slice(-K.useBars); n.useNow = 0; } }}/** A serving GPU with no work coming in: it waits. */export const idle = (n: Node) => n.kind === 'gpu' && !!n.cell && !n.site && n.mode !== 'train' && speedOf(n) > 0 && !n.work.length && (n.busy ?? 0) < 0.15;
/** A GPU or a person digests work a cube at a time. Each step is right as often as the skill allows (people are * always right); a right step is paid by the deal. Every step leaves raw data as big as the stack was: it teaches * less the surer the model already is, and only as much as the data terms let you keep. */function digest(G: Game, n: Node, dt: number) { while (n.work.length < n.slots) { const i = n.hold.findIndex(c => c.kind === 'work'); if (i < 0) break; n.work.push({ cube: n.hold.splice(i, 1)[0], left: n.kind === 'gpu' ? K.gpuStep : K.humanStep }); } for (const w of [...n.work]) { w.left -= dt; if (w.left > 0) continue; const c = capability(w.cube.cap), p = success(G.skill[c.family], difficulty(c, w.cube.size)); const site = w.cube.site ? G.sites[w.cube.site] : null; const ok = n.kind === 'desk' || rand(G) < p, v = ok ? worth(c) * (site ? MODES[site.mode].pay : 1) : 0; G.money += v; G.earned += v; G.events.push({ k: 'step', at: n.id, ok, cap: c.id, v }); // people hide their work from a lab they fear: a company's work teaches less the more the city fears you, and // less again the more slop there is about const teach = w.cube.size * Math.max(teachFloor(G), 1 - p) * (site ? TERMS[site.terms].teach * (site.mode === 'assist' ? K.assistTeach : 1) * (1 - fearParts(G).you / 100) : 1) * (1 - slop(G)); if (n.hold.filter(x => x.kind !== 'work').length < 6) n.hold.push({ id: G.nextId++, cap: c.id, size: w.cube.size, kind: 'raw', flash: 1, teach, site: w.cube.site }); w.cube.size--; w.cube.flash = 1; if (w.cube.size <= 0) { n.work.splice(n.work.indexOf(w), 1); credit(G, n, w.cube); } else w.left = n.kind === 'gpu' ? K.gpuStep : K.humanStep; }}/** A finished stack counts toward how well you serve its company: done by your GPUs and desks, or by its own * people working beside your model (which counts for neither lab). */function credit(G: Game, n: Node, c: Cube) { const s = c.site ? G.sites[c.site] : null; if (!s?.signed) return; if (n.site && n.kind === 'desk') s.own += 1 / K.window; else s.done += 1 / K.window;}function grade(n: Node, dt: number) { if (!n.work.length) { const i = n.hold.findIndex(c => c.kind === 'raw'); if (i >= 0) n.work.push({ cube: n.hold.splice(i, 1)[0], left: K.gradeTime / (n.who?.skill ?? 1) }); } const w = n.work[0]; if (!w) return; w.left -= dt; if (w.left > 0) return; w.cube.kind = 'graded'; w.cube.flash = 1; w.cube.teach *= 1 + (n.who?.skill ?? 1); n.work.shift(); n.hold.push(w.cube);}/** Data reaching the core joins the dataset for good. The core shows the last few in. */function learn(G: Game, n: Node, c: Cube) { G.data[capability(c.cap).family] += c.teach; c.flash = 1; n.hold.push(c); if (n.hold.length > 9) n.hold.shift();}function think(G: Game, n: Node, dt: number) { if (!n.who) return; if (!G.researching) { const next = nextResearch(G); if (!next) return; research(G, next); } G.progress += n.who.skill * dt; const c = capability(G.researching!); if (G.progress >= researchNeed(c)) { G.unlocked.add(c.id); G.researching = null; G.progress = 0; G.events.push({ k: 'invent', cap: c.id }); }}