// 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 = {
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 = {
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 = {
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 company
export 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,
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 = {
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 = {
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; run: Record; data: Record;
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; 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; // everyone who has been a candidate: a name that comes back is the same person
broke: boolean;
sites: Record; 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 = { 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 company
export 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 = 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 & { 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 = {}) {
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> = {
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;
}
// ---- people
export 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 do
const 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 {
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;
}
/** 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> } {
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> = {};
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, 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; 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 = { 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();
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 runs
function 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 clock
function 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(['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 });
}
}