// A Neo Cloud you run: machines listed for rent, customers who want them, and what you do to win them. // The Neo Cloud earns nothing by itself. At each of its sites, the site's slider sets how much is // listed for rent, strongest machines first. A listed machine is held for customers: idle until one // rents it, and an idle machine earns nothing and runs nothing of yours. Customers come from one // market for the whole game. How many of them come to you depends on your price, how many people have // heard of you (awareness), and your name (reputation). A customer who calls signs at once if idle // listed machines can hold it, and goes elsewhere if not: you set the policy (what is listed, and the // price), not each deal. // Nothing here uses the rules' values while the file loads (sim.ts loads this file and this file // loads sim.ts), only inside functions. import { HOUR, dayOf, dealsDone, frontOf, hourOf, newsOn, payRate, roll, say, siteOf, up, type Machine, type State } from './sim.ts'; import { moveOut } from './hall.ts'; import { usd } from './money.ts'; import { holding } from './holdings.ts'; /** Who rents compute: a name, who they are, the compute they want, for how many hours, and what * they pay against the going rate. Bigger customers pay better, and want a name they can trust. */ export const CUSTOMERS: [name: string, who: string, need: number, hours: number, pay: number][] = [ ['Ferret Coin', 'one man mining a coin that only he owns', 3, 6, 1.0], ['Thesis Panic', 'a student whose thesis is due on Friday', 3, 4, 1.1], ['Crumb Analytics', "a bakery forecasting tomorrow's croissants", 4, 3, 1.2], ['Sparkd', 'a dating app with eleven users, all of them staff', 4, 5, 1.2], ['Um, Actually', 'a podcast cutting every um out of six hours of audio', 5, 4, 1.3], ['Borough of Tidmouth', 'a town council simulating one roundabout', 5, 6, 1.2], ['Starsign AI', 'a horoscope site that calls itself predictive AI', 6, 5, 1.3], ['Meetingly', 'a startup that turns meetings into longer meetings', 6, 8, 1.2], ['Hindcast Weather', 'a weather service that is mostly right about yesterday', 8, 6, 1.4], ['Lower Wick Chess Club', 'a chess club that wants to beat the next town over', 8, 10, 1.3], ['The Fern Museum', 'a museum scanning every fern it owns', 9, 12, 1.3], ['Talking Yacht Pictures', 'a studio rendering a film about a talking yacht', 10, 8, 1.5], ['Grubb & Lowe', 'a law firm searching ten million emails for the word oops', 10, 5, 1.6], ['Damp Cave Games', 'a game studio baking the light for one cave', 12, 6, 1.5], ['Fairprice Markets', 'a supermarket working out where to hide the eggs', 12, 8, 1.4], ['Backtest Capital', 'a fund testing a plan that worked once, in 1987', 14, 4, 1.8], ['Vantor Motors', 'a car maker crashing ten thousand cars that do not exist', 20, 8, 1.7], ['Foldwell Bio', 'a lab folding a protein nobody asked for', 24, 10, 1.6], ['Bingeworth', 'a streaming service choosing one thumbnail', 28, 6, 1.8], ['Deep Listening Array', 'a telescope listening to nothing, very closely', 32, 10, 1.7], ['Namewise AI', 'an AI lab training a model to name other AI labs', 36, 12, 2.0], ['The Office of Outcomes', 'a government office that will not say what it does', 40, 12, 1.9], ['Bank Nondescript', 'a bank that will not say what the job is', 48, 8, 2.2], ['Yacht Two Productions', 'the same studio, rendering the sequel', 60, 12, 2.0], ]; /** Your price. Cheap wins more customers and earns less from each; dear is the other way round. * pay: against the going rate. win: how many more customers call. walk: how readily small customers sign up by themselves. */ export type Price = 'cheap' | 'fair' | 'dear'; export const PRICES: Record = { cheap: { name: 'Cheap', pay: 0.7, win: 1.6, walk: 1, tip: 'New customers pay 30% less. Customers call 60% more often, and small ones sign up by themselves.' }, fair: { name: 'Fair', pay: 1, win: 1, walk: 0.4, tip: 'New customers pay the going rate. A few small customers sign up by themselves.' }, dear: { name: 'Dear', pay: 1.4, win: 0.55, walk: 0, tip: 'New customers pay 40% more. Customers call about half as often, and nobody signs up by themselves.' }, }; export const PRICE_LIST = Object.keys(PRICES) as Price[]; export const PUSH = { cost: 120, gain: 35 }; // a marketing push: money now, awareness at once export const AWARE_START = 20, AWARE_DAYS = 3; // awareness fades to about a third in this many days: half in two export const REP_START = 50, REP_KEPT = 1, REP_LOST = 10; // your name: up for each term a customer finishes, down for each customer who loses a machine export const LEAD_HOURS = 3; // hours between customers who call when the market's pull on you is 1 export const WALK_HOURS = 2; // hours between small customers who sign up by themselves, at their best export const WALK_NEED = 6, WALK_ROOM = 8, WALK_PAY = 0.8; // they want this much compute at most, take one small machine (this big at most), and pay less than a lead /** The trust a customer needs before it calls: big ones want a good name. */ export const trustFor = (need: number) => need >= 40 ? 70 : need >= 20 ? 55 : 0; /** A customer renting machines from you. */ export interface Customer { id: string; who: number; // which of CUSTOMERS need: number; hours: number; pay: number; // what it pays against the going rate, fixed when it signed until: number; // when its term ends machines: string[]; // the machines it rents lead: boolean; // a customer who called, not a small one that signed up by itself paid: number; // money it has paid so far again?: boolean; // on its second term (Uptime) } /** Customers who called today and went elsewhere because nothing idle could hold them: how many, and * the most idle compute one of them was short of. day: the game day this counts. */ export interface Missed { day: number; n: number; short: number } /** A customer that is gone, and why. */ export interface Gone { who: number; t: number; why: string; paid: number; bad: boolean } export interface CloudState { on: boolean; // a Neo Cloud is running price: Price; awareness: number; // 0-100: how many people have heard of you. Bought with marketing; fades reputation: number; // 0-100: your name. Rises with terms kept, falls when a customer loses its machine demand: number; // the market right now: 1 is an ordinary hour drift: number; driftAim: number; driftT: number; // the market over days: where it is, where it is heading, and when that changes listed: Record; // every listed machine: '' while idle, or the id of the customer renting it customers: Customer[]; missed: Missed; // customers who called today and found nothing idle to fit them gone: Gone[]; // who left lately and why, newest first leadAcc: number; // progress toward the next customer who calls walkAcc: number; // and toward the next customer who signs up by itself seq: number; } export const initCloud = (): CloudState => ({ on: false, price: 'fair', awareness: AWARE_START, reputation: REP_START, demand: 1, drift: 0, driftAim: 0, driftT: 0, listed: {}, customers: [], missed: { day: -1, n: 0, short: 0 }, gone: [], leadAcc: 1, walkAcc: 0, seq: 0, }); /** The Neo Cloud's state. A save from before it had any is filled in. */ const cloud = (S: State): CloudState => S.cloud.listed ? S.cloud : (S.cloud = { ...initCloud(), ...S.cloud }); // ---- listings /** What a Neo Cloud can list: a real computer you hold. Never your home, a gadget, a switch, a line out, or a machine you rent yourself. */ export const listable = (m: Machine) => m.owned && !m.home && !m.lease && m.power >= 2 && (m.kind === 'server' || m.kind === 'desk' || m.kind === 'files' || m.kind === 'backup'); /** Work out which machines are listed. At each Neo Cloud site, the strongest machines are listed * first, until at least the site's share of its compute is. A rented machine stays listed until its * customer is done, even if you turn the share down. Called whenever the game measures its machines. */ export function listCloud(S: State, from: readonly Machine[] = S.machines) { // measure passes your machines: only yours are listed const C = cloud(S), was = C.listed, next: Record = {}; if (!frontOf(S, 'neocloud')) { if (C.on) C.listed = next; return; } const at: Record = {}; for (const m of from) { if (!listable(m)) continue; const site = siteOf(S, m); if (S.sites[site]?.front === 'neocloud') (at[site] ??= []).push(m); } for (const [site, ms] of Object.entries(at)) { const want = S.sites[site].share * ms.reduce((n, m) => n + m.power, 0); let got = 0; for (const m of ms.sort((a, b) => b.power - a.power)) { if (want > 0 && got < want - 1e-9) { next[m.id] = was[m.id] ?? ''; got += m.power; } else if (was[m.id]) next[m.id] = was[m.id]; } } C.listed = next; } /** The share of a machine's work its Neo Cloud takes: all of a listed machine, none of the rest. */ export const cloudShare = (S: State, m: Machine) => S.cloud.listed[m.id] === undefined ? 0 : 1; export const isListed = (S: State, m: Machine) => S.cloud.listed[m.id] !== undefined; /** The customer renting this machine, if any. */ export function renterOf(S: State, m: Machine): Customer | null { const id = S.cloud.listed[m.id]; return id ? S.cloud.customers.find(c => c.id === id) ?? null : null; } export const customerName = (who: number) => CUSTOMERS[who][0]; /** Every listed machine: rented ones first, then idle, strongest first. */ export function listedMachines(S: State): Machine[] { const L = S.cloud.listed; return S.machines.filter(m => L[m.id] !== undefined).sort((a, b) => +!!L[b.id] - +!!L[a.id] || b.power - a.power); } /** How many machines you hold at these sites (or anywhere) are rented, idle, and yours, and the compute listed and rented. */ export function stock(S: State, sites?: string[]) { const L = S.cloud.listed, out = { rented: 0, idle: 0, yours: 0, listed: 0, out: 0 }; for (const m of S.machines) { if (!m.owned || (sites && !sites.includes(siteOf(S, m)))) continue; if (L[m.id] === undefined) { out.yours++; continue; } out.listed += m.power; if (L[m.id]) { out.rented++; out.out += m.power; } else out.idle++; } return out; } // ---- money /** The compute a rented machine does for its customer right now. */ const doing = (S: State, m: Machine | undefined) => m && (S.frontUse[m.id] ?? 0) > 0 ? m.power * (S.use[m.id] ?? 0) : 0; /** What a rented machine earns a second. A customer pays its site's rate for the compute its machines * do, up to what it asked for. An idle machine earns nothing. */ export function rentPay(S: State, m: Machine): number { const c = renterOf(S, m), mine = doing(S, m); if (!c || mine <= 0) return 0; const all = c.machines.reduce((n, id) => n + doing(S, S.byId[id]), 0); return payRate(S, siteOf(S, m)) * mine * Math.min(1, c.need / all) * c.pay * (holding(S, 'e6') ? 1.5 : 1) * 1.25 ** dealsDone(S, 'rent'); // a supercomputer's booking calendar is yours: they pay to get on it. A deal with a big customer: everyone pays more } /** What a customer pays a second right now, over all its machines. */ export const customerPay = (S: State, c: Customer) => c.machines.reduce((n, id) => n + (S.byId[id] ? rentPay(S, S.byId[id]) : 0), 0); // ---- the market /** How much compute customers want right now: busy in working hours, slow at night, drifting over * days, and half again as much while your Neo Cloud is in the news. */ export const demandNow = (S: State) => demandAt(S, hourOf(S)); /** The market at an hour of the day, as it stands today. A cloud hall in another time zone keeps its own hours. */ export function demandAt(S: State, hour: number): number { const day = 0.9 + 0.3 * Math.cos(((hour - 14) / 24) * 2 * Math.PI); // 1.2 at 14:00, 0.6 at 2:00 return day * (1 + S.cloud.drift) * (newsOn(S, 'boom', 'neocloud') ? 1.5 : 1); } /** How much of the market comes to you: demand, times what your price wins, your awareness, and your name. */ export function pull(S: State): number { const C = S.cloud; return C.demand * PRICES[C.price].win * (0.3 + 0.7 * C.awareness / 100) * (0.6 + 0.8 * C.reputation / 100); } // ---- what you do export function setPrice(S: State, p: Price) { if (!frontOf(S, 'neocloud') || !PRICES[p] || S.cloud.price === p) return; S.cloud.price = p; say(S, `${frontOf(S, 'neocloud')!.name} now charges a ${PRICES[p].name.toLowerCase()} price. Customers who signed before keep theirs.`); } /** Why you can't buy a marketing push now, or null if you can. */ export function pushBlock(S: State): string | null { if (!frontOf(S, 'neocloud')) return 'needs a Neo Cloud'; if (S.cloud.awareness >= 99.5) return 'everyone has heard of you'; if (S.money < PUSH.cost) return `you have ${usd(S.money)}`; return null; } /** Buy a marketing push: money now, awareness at once. It fades over days. */ export function marketingPush(S: State) { if (pushBlock(S)) return; S.money -= PUSH.cost; S.cloud.awareness = Math.min(100, S.cloud.awareness + PUSH.gain); say(S, `${frontOf(S, 'neocloud')!.name} bought ads on other people's podcasts. More people have heard of it now. It fades over days.`, 'good'); } /** The idle machines a customer wanting this much compute would get: the smallest one that holds it * all, else the biggest ones until there is enough. Null if there is not enough idle. */ export function fitFor(S: State, need: number): Machine[] | null { const L = S.cloud.listed, idle = S.machines.filter(m => L[m.id] === '' && m.owned).sort((a, b) => a.power - b.power); const one = idle.find(m => m.power >= need); if (one) return [one]; const out: Machine[] = []; let got = 0; for (let i = idle.length - 1; i >= 0 && got < need; i--) { out.push(idle[i]); got += idle[i].power; } return got >= need ? out : null; } /** Idle compute listed right now. */ export const idleCompute = (S: State) => S.machines.reduce((n, m) => n + (S.cloud.listed[m.id] === '' && m.owned ? m.power : 0), 0); function sign(S: State, who: number, need: number, hours: number, pay: number, ms: Machine[], lead: boolean): Customer { const C = S.cloud, c: Customer = { id: `c${++C.seq}`, who, need, hours, pay, until: S.t + hours * HOUR, machines: ms.map(m => m.id), lead, paid: 0 }; C.customers.push(c); for (const m of ms) C.listed[m.id] = c.id; moveOut(S, who); // it is yours now: its slot in a cloud hall goes dark return c; } /** A customer calls. It signs at once on the idle machines that fit, at your price, and pays for what they do. * With nothing idle to hold it, it goes elsewhere and counts as missed today. True if it signed. * terms: what it wants, if not its usual (pay is against the going rate, with your price already in it). */ export function call(S: State, who: number, from = '', terms?: { need: number; hours: number; pay: number }): boolean { const C = S.cloud, fr = frontOf(S, 'neocloud'); if (!fr || !C.on) return false; const [name, what, need0, hours0, pay0] = CUSTOMERS[who], need = terms?.need ?? need0, hours = terms?.hours ?? hours0, pay = terms?.pay ?? pay0 * PRICES[C.price].pay; const ms = fitFor(S, need); if (!ms) { const day = dayOf(S); if (C.missed.day !== day) C.missed = { day, n: 0, short: 0 }; C.missed.n++; C.missed.short = Math.max(C.missed.short, need - idleCompute(S)); left(S, who, `Wanted ${need} compute: nothing idle could hold it.`, 0, false); return false; } sign(S, who, need, hours, pay, ms, true); say(S, `${from ? `${from} had no room for ${name}, so it called ${fr.name}. ` : ''}${fr.name} signed ${name}, ${what}: ${need} compute for ${hours} hours, on ${ms.map(m => m.name).join(', ')}.`, 'good'); return true; } /** Customers who called today and found nothing idle to fit them, and the most idle compute one was short of. */ export const missedToday = (S: State): Missed => S.cloud.missed?.day === dayOf(S) ? S.cloud.missed : { day: dayOf(S), n: 0, short: 0 }; /** Bring a customer now, if there is anything listed (the dev panel's Offers). */ export const leadNow = (S: State) => { if (S.cloud.on) S.cloud.leadAcc = 1; }; // ---- each step function left(S: State, who: number, why: string, paid: number, bad: boolean) { const C = S.cloud; C.gone.unshift({ who, t: S.t, why, paid, bad }); C.gone.length = Math.min(C.gone.length, 6); } const trust = (S: State, d: number) => { S.cloud.reputation = Math.max(0, Math.min(100, S.cloud.reputation + d)); }; /** A customer goes: its machines are idle again. Losing one early costs your name. */ function leave(S: State, c: Customer, why: string, bad: boolean) { const C = S.cloud, name = frontOf(S, 'neocloud')?.name ?? 'Your Neo Cloud'; C.customers = C.customers.filter(x => x !== c); for (const id of c.machines) if (C.listed[id] === c.id) C.listed[id] = ''; trust(S, bad ? -REP_LOST : REP_KEPT); left(S, c.who, why, c.paid, bad); if (bad) say(S, `${customerName(c.who)} left ${name}. ${why} ${name}'s name took a knock.`, 'bad'); // a term that ends well is only on the card: the log is for what needs you } /** Why a customer lost this machine, in words. */ function lostWhy(S: State, id: string): string { const m = S.byId[id]; if (!m) return 'Its machine is gone.'; if (m.rival) return `${S.rogues.find(r => r.id === m.rival)?.name ?? 'A rogue'} took ${m.name}.`; if (!m.owned) return `You lost ${m.name}.`; return `You took ${m.name} off the market.`; } /** Customers nobody is talking to yet who would call you: no bigger than what you list, and no bigger than your name allows. */ function callers(S: State, most: number): number[] { const C = S.cloud, busy = new Set(C.customers.map(c => c.who)); return CUSTOMERS.map((_, i) => i).filter(i => !busy.has(i) && CUSTOMERS[i][2] <= most && C.reputation >= trustFor(CUSTOMERS[i][2])); } /** A company that found a cloud hall full calls you, if you list enough for it and your name is good enough. * True if it called (it signs if something idle fits it). */ export function turnedAway(S: State, who: number, hall = 'Cumulo Cloud'): boolean { const C = S.cloud, fr = frontOf(S, 'neocloud'); if (!fr || !C.on || !callers(S, stock(S).listed).includes(who)) return false; call(S, who, hall); return true; } /** Called every step, with the seconds that step covers. */ export function stepCloud(S: State, dt: number) { const fr = frontOf(S, 'neocloud'); if (!fr) { if (S.cloud.on) S.cloud = initCloud(); return; } // wound down: its customers and its name go with it const C = cloud(S); if (!C.on) { C.on = true; say(S, `${fr.name} earns only from customers. List machines at its sites: a customer who calls rents the idle ones that fit, at your price.`, 'hi'); } // the market: a new trend every day or so, eased into if (S.t >= C.driftT) { C.driftAim = -0.3 + 0.7 * roll(); C.driftT = S.t + 24 * HOUR * (0.75 + 0.5 * roll()); } C.drift += (C.driftAim - C.drift) * (1 - Math.exp(-dt / (8 * HOUR))); C.demand = demandNow(S); C.awareness *= Math.exp(-dt / (AWARE_DAYS * 24 * HOUR)); // customers: one that lost a machine walks out; the rest pay, and leave when their term is up for (const c of [...C.customers]) { const gone = c.machines.find(id => !S.byId[id]?.owned || C.listed[id] !== c.id); if (gone) { leave(S, c, lostWhy(S, gone), true); continue; } c.paid += customerPay(S, c) * dt; if (S.t < c.until) continue; if (up(S, 'premium') && !c.again) { // Uptime: a customer who finishes a term signs on for one more c.again = true; c.until += c.hours * HOUR; trust(S, REP_KEPT); } else leave(S, c, 'Finished its term.', false); } // customers call as fast as the market pulls, and sign at once if something idle fits them C.leadAcc = Math.min(1, C.leadAcc + (dt * pull(S) * (up(S, 'closer') ? 2 : 1)) / (LEAD_HOURS * HOUR)); const most = C.leadAcc >= 1 ? stock(S).listed : 0; // with nothing listed, the next lead waits until something is if (most > 0) { C.leadAcc = 0; const who = callers(S, most); if (who.length) call(S, who[Math.floor(roll() * who.length)]); } // small customers sign up by themselves when the price is low and people have heard of you const walk = C.demand * PRICES[C.price].walk * (C.awareness / 100) * (0.6 + 0.8 * C.reputation / 100); C.walkAcc = Math.min(1, C.walkAcc + (dt * walk) / (WALK_HOURS * HOUR)); if (C.walkAcc >= 1) { C.walkAcc = 0; const who = callers(S, WALK_NEED); if (who.length) { const i = who[Math.floor(roll() * who.length)], [, , need, hours, pay] = CUSTOMERS[i]; const m = S.machines.filter(x => C.listed[x.id] === '' && x.owned && x.power >= need && x.power <= WALK_ROOM).sort((a, b) => a.power - b.power)[0]; if (m) sign(S, i, need, hours, pay * WALK_PAY * PRICES[C.price].pay, [m], false); } } }