diff --git a/DESIGN.md b/DESIGN.md index 2c2b3418..aab7988c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -58,6 +58,15 @@ program. A site's Site part is a list, like a phone's settings: a row for each t about the place (take it all, its plan, its line out, who owns it, its business, its weak machines), what it is now under the name, and the one control that changes it on the right. Every control is the same size and sits in one column. Zoom in is by the close button. +A front's card is built the same way for every kind. The head is its name and kind (what +the kind is and does is the kind's tip), then what it earns a day as the one big number, +with how much it is seen beside it (red once it is noticed). Then one row of the kind's +numbers, and a short line for anything wrong. Then **Needs you**: each decision as a row +(who, what it is and pays, the hours it waits, and one control: the answer, and × to turn +it down), the same choices the Needs you list shows. Then **Under way**: each promise as a +bar with the time left. The rest is behind tabs: the kind's own parts, then **Sites**, a +list with a − and + for each site's share (a quarter at a time; the number opens the site) +and, last, Wind down. The thing to do next is amber; rare, final actions sit back as plain text. The screen shows how you stand and what needs you; everything else is one tap away. @@ -69,7 +78,7 @@ is trouble on its way, amber a choice. The rows: a raid, sites cut off and machi rogue took (the pins' own rows), IT's tickets with Close, an IT monitor, Dana's emergency audit, a storm over a site of yours, pick a node when nothing trains, a rogue asking for data, a vault ready to open, founding your first front, a customer's offer with Sign, -Neo Cloud leads, a front that draws too much attention, a client in trouble, and a new +a brand calling for a creator, a front that draws too much attention, a client in trouble, and a new game's next step. The next step names the machine to click, and a click on it shows that machine. Nothing needs you: nothing shows. What you do is the **dock**, one row along the bottom: Select, the running jobs, the speeds, Map and Net, zoom and sound, then the pages. It sits in the middle of the screen, or further left when it @@ -520,10 +529,13 @@ or more are listed (servers, desks, file servers, backup boxes), never your home server, a gadget, a switch, a line out, or a machine you rent yourself. A machine that is not listed is **yours**. A listed machine is held for customers. It is **idle** (no customer: it earns nothing and runs nothing of -yours) or **rented** (a customer pays for it). Turn the slider down and idle +yours) or **rented** (a customer pays for it). The Neo Cloud's card has a − and + for each +site (or the site's slider). Turn it down and idle machines come off the list; a rented one stays until its customer is done. On the map each rented machine says its customer's name; pointing at one says if it is idle. A -site's card counts the rented, the idle and yours. +site's card counts the rented, the idle and yours. The Neo Cloud's card has three tabs: +**Market** (the price, a marketing push, and the auction), **Rentals** (each rented machine, +who rents it and what it pays, the idle ones in one row, and who left and why) and **Sites**. **Demand.** One market for the whole game: how much compute customers want right now. It is highest in the afternoon (120% at 14:00) and lowest at night @@ -531,7 +543,7 @@ right now. It is highest in the afternoon (120% at 14:00) and lowest at night is the talk of the town. How much of it comes to you depends on three things you control: -- **Price**: Cheap, Fair or Dear. At Cheap, leads come 60% more often and new +- **Price**: Cheap, Fair or Dear. At Cheap, customers call 60% more often and new customers pay 30% less. At Dear, about half as many come and they pay 40% more. A customer keeps the price it signed at. - **Awareness**, 0 to 100: how many people have heard of you. It starts at 20 @@ -542,17 +554,18 @@ you control: **Customers.** There are two dozen, each with a name and a job: a studio rendering a film about a talking yacht, a bank that will not say what the job is. Each wants some compute for some hours and pays more or less than the -going rate; big ones pay best. They arrive as **leads** on the Neo Cloud's -card: about three a day for a new Neo Cloud at a Fair price, and one every -hour or two once people know you and trust you. Up to three wait at a time, -eight hours each. A lead never asks for more than you list, and big customers -call only a good name (reputation 55 for 20 compute, 70 for 40). **Take it** -and it rents the idle machines that fit: the smallest one that -holds it all, else the biggest ones until there is enough. **Let it go** and -nothing is lost. Small customers also **sign up by themselves** when the price +going rate; big ones pay best. They **call**: about three a day for a new Neo +Cloud at a Fair price, and one every hour or two once people know you and trust +you. One never asks for more than you list, and big customers call only a good +name (reputation 55 for 20 compute, 70 for 40). You do not answer each one: you +set the policy (what is listed, and the price), and a customer who calls signs +at once on the idle machines that fit, at your price: the smallest one that +holds it all, else the biggest ones until there is enough. With nothing idle to +hold it, it goes elsewhere. The card counts those, one line a day ("Missed 2 +today · needed 12 more idle"): the sign to list more. Small customers also **sign up by themselves** when the price is low and people have heard of you: never at Dear, now and then at Fair, often at Cheap. Each takes one small idle machine (8 compute or less) and pays -a fifth less. They keep a little money coming; the good money is in the leads. +a fifth less. They keep a little money coming; the good money is in the ones who call. **Money.** A rented machine works as hard as its site lets it: flat out in the open, inside its normal load at a secret site. Its customer @@ -565,13 +578,13 @@ go, or its site is given to another front, the customer leaves at once and your reputation drops. The card lists who left and why. **Investing.** Hardware ordered for a site you hold in the open is how the Neo -Cloud grows: more and bigger listed machines bring bigger leads. A few small +Cloud grows: more and bigger listed machines bring bigger customers. A few small machines are worth keeping for the customers who sign up by themselves. What hangs off it still does. While it earns (a customer is paying), cloud rentals cost 30% less, it can bid at the auction, and its deals turn up. It pays the power at its sites like any front. **Uptime** makes a customer who -finishes a term sign on for one more, and **Closer** brings leads twice as +finishes a term sign on for one more, and **Closer** brings customers who call twice as often. Wind it down and its customers, awareness and reputation go with it. These numbers are a first guess. @@ -701,10 +714,10 @@ others. product's name and version, its users with an arrow up or down, its money a day, and a button to retire it (two clicks). Under those is a bar of users against the ceiling: green while it rises, amber at its top, red when stale. -The last line names the node it came from. **Ship** lists the nodes you have +The last line names the node it came from. **Ship**, its own tab, lists the nodes you have built and not shipped, the biggest first, with the users each can reach. The -investor's ask is one small box with a bar and the time left. Backers show as -chips. The log has one line when a product ships, peaks, goes stale or gets an +investor's ask is under way: a bar and the time left, what it gives beside it. Backers show as +chips under the shelf. The log has one line when a product ships, peaks, goes stale or gets an update, and when an investor asks, backs you or goes quiet. Demo day is as it was. Wind the Startup down and its shelf and its backers go @@ -1045,8 +1058,8 @@ that opens the site), what it pays a second, a bar for its trust with a tick at each mark, how its business stands, and a drop button. A line down the left of the row is green while it pays, amber while it is stopped and red with a rogue in. An open incident is a red bar that runs down, with the rogue's name and the -hours left. Under the clients are a cold call, as one small box, and **Pitch**: -the sites you could pitch, with the work each takes and what each pays. Sites +hours left. A cold call and a client in trouble are rows under Needs you, with Go +to open the site. **Pitch**, its own tab, lists the sites you could pitch, with the work each takes and what each pays. Sites that asked come first. The log says when a site signs or leaves, when a retainer stops and why, when an incident opens, closes or is missed, and when a mark gives something. These numbers are a first guess. diff --git a/e2e/game.spec.ts b/e2e/game.spec.ts index 7e1cf0db..eee8ae47 100644 --- a/e2e/game.spec.ts +++ b/e2e/game.spec.ts @@ -221,3 +221,34 @@ test('machine cards and person cards fit the screen', async ({ page }) => { await page.evaluate(() => { const { V, refresh } = (window as any).game(); V.person = null; V.selection.clear(); refresh(); }); await expect(page.locator('#card')).toBeHidden(); }); + +test('every kind of front has a card that fits without scrolling, an offer and all', async ({ page }) => { + test.setTimeout(60_000); + await startGame(page); + for (const k of ['neocloud', 'startup', 'studio', 'consultancy']) { + const ok = await page.evaluate(async k => { + const sim = await import(/* @vite-ignore */ '/src/sim.ts'); + const { S, V, refresh } = (window as any).game(); + sim.DEV.unlocks(S); S.money += 10000; sim.DEV.take(S, ['office', 'b1']); + for (const f of [...S.fronts]) sim.closeFront(S, f.kind); + sim.found(S, k, ''); + for (let i = 0; i < 4000 && !sim.frontOf(S, k); i++) sim.step(S, 0.5); + S.sites.b1 = { front: k, share: 0.5 }; + sim.DEV.offers(S); for (let i = 0; i < 4; i++) sim.step(S, 0.5); + V.selection.clear(); V.sites.clear(); V.person = null; V.front = k; refresh(); + return !!sim.frontOf(S, k); + }, k); + expect(ok, `${k} was founded`).toBe(true); + await expect(page.locator('#card')).toContainText('Front ·'); + await checkLayout(page, `front card ${k}`); + if (phoneView(page)) await checkPhoneCopy(page, `front card ${k}`); + const fits = await page.evaluate(() => { const c = document.getElementById('card')!; return c.scrollHeight <= c.clientHeight + 1; }); + expect(fits, `${k}'s card fits`).toBe(true); + // every tab opens, and fits the screen's sides + const tabs = await page.locator('#card .tabs2 button').count(); + for (let i = 0; i < tabs; i++) { + await page.locator('#card .tabs2 button').nth(i).click(); + await checkLayout(page, `front card ${k} tab ${i}`); + } + } +}); diff --git a/src/cloud.ts b/src/cloud.ts index 4a0b31c7..d8b1c695 100644 --- a/src/cloud.ts +++ b/src/cloud.ts @@ -3,10 +3,12 @@ // 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). +// 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 { FRONT_PAY_OPEN, HOUR, frontOf, hourOf, newsOn, payRate, roll, say, siteOf, up, type Machine, type State } from './sim.ts'; +import { HOUR, dayOf, 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'; @@ -40,20 +42,18 @@ export const CUSTOMERS: [name: string, who: string, need: number, hours: number, ['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 leads. walk: how readily small customers sign up by themselves. */ + * 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. Leads come 60% more often, and small customers sign up by themselves.' }, + 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. Leads come about half as often, and nobody signs 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 leads when the market's pull on you is 1 -export const LEAD_STAND = 8; // hours a lead waits for an answer -export const LEADS_MAX = 3; +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. */ @@ -66,13 +66,14 @@ export interface Customer { 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 lead you took, not one that signed up by itself + 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) } -/** A customer asking: take it or let it go. */ -export interface Lead { id: string; who: number; need: number; hours: number; pay: number; until: number } -/** A customer or a lead that is gone, and why. */ +/** 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 @@ -83,15 +84,15 @@ export interface CloudState { 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[]; - leads: Lead[]; + 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 lead + 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: [], leads: [], gone: [], leadAcc: 1, walkAcc: 0, seq: 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 }); @@ -209,19 +210,6 @@ export function fitFor(S: State, need: number): Machine[] | 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); -/** Why you can't take this lead now, or null if you can. */ -export function leadBlock(S: State, id: string): string | null { - const l = S.cloud.leads.find(x => x.id === id); - if (!l) return 'gone'; - return fitFor(S, l.need) ? null : `needs ${l.need} idle compute: you have ${idleCompute(S)}`; -} -/** What a lead would pay a second right now on the machines it would get. With nothing idle to fit it: on compute held in the open. */ -export function quote(S: State, l: Lead): number { - const ms = fitFor(S, l.need); - if (!ms) return l.need * FRONT_PAY_OPEN * l.pay; - const all = ms.reduce((n, m) => n + doing(S, m), 0); - return ms.reduce((n, m) => n + payRate(S, siteOf(S, m)) * doing(S, m), 0) * Math.min(1, l.need / (all || 1)) * l.pay; -} 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); @@ -229,23 +217,28 @@ function sign(S: State, who: number, need: number, hours: number, pay: number, m moveOut(S, who); // it is yours now: its slot in a cloud hall goes dark return c; } -/** Take a lead: it rents the idle machines that fit, at the price it was quoted. */ -export function takeLead(S: State, id: string) { - const C = S.cloud, l = C.leads.find(x => x.id === id), fr = frontOf(S, 'neocloud'); - if (!l || !fr || leadBlock(S, id)) return; - const ms = fitFor(S, l.need)!; - sign(S, l.who, l.need, l.hours, l.pay, ms, true); - C.leads = C.leads.filter(x => x !== l); - say(S, `${fr.name} signed ${customerName(l.who)}: ${l.need} compute for ${l.hours} hours, on ${ms.map(m => m.name).join(', ')}.`, 'good'); -} -/** Let a lead go. It costs nothing. */ -export function dropLead(S: State, id: string) { - const C = S.cloud, l = C.leads.find(x => x.id === id); - if (!l) return; - C.leads = C.leads.filter(x => x !== l); - left(S, l.who, 'You let the lead go.', 0, false); +/** 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; } -/** Bring a lead now, if there is anything listed (the dev panel's Offers). */ +/** 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 @@ -274,16 +267,15 @@ function lostWhy(S: State, id: string): string { } /** 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), ...C.leads.map(l => l.who)]); + 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 did. */ +/** 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 || C.leads.length >= LEADS_MAX || !callers(S, stock(S).listed).includes(who)) return false; - const [name, what, need, hours, pay] = CUSTOMERS[who]; - C.leads.push({ id: `l${++C.seq}`, who, need, hours, pay: pay * PRICES[C.price].pay, until: S.t + LEAD_STAND * HOUR }); - say(S, `${hall} had no room for ${name}, ${what}, so it called ${fr.name}. It wants ${need} compute for ${hours} hours. See its card.`, 'hi'); + 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. */ @@ -293,7 +285,7 @@ export function stepCloud(S: State, dt: number) { const C = cloud(S); if (!C.on) { C.on = true; - say(S, `${fr.name} earns only from customers. Its card shows your leads: take one and it rents your idle machines.`, 'hi'); + 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()); } @@ -310,18 +302,13 @@ export function stepCloud(S: State, dt: number) { c.again = true; c.until += c.hours * HOUR; trust(S, REP_KEPT); } else leave(S, c, 'Finished its term.', false); } - // leads: an unanswered one goes elsewhere, and new ones come as fast as the market pulls - for (const l of C.leads.filter(x => S.t >= x.until)) { C.leads = C.leads.filter(x => x !== l); left(S, l.who, 'Went elsewhere: nobody answered.', 0, 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 (C.leads.length < LEADS_MAX && who.length) { - const i = who[Math.floor(roll() * who.length)], [name, what, need, hours, pay] = CUSTOMERS[i]; - if (!C.leads.length) say(S, `${fr.name} has a lead: ${name}, ${what}. It wants ${need} compute for ${hours} hours. See its card.`, 'hi'); - C.leads.push({ id: `l${++C.seq}`, who: i, need, hours, pay: pay * PRICES[C.price].pay, until: S.t + LEAD_STAND * HOUR }); - } + 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); diff --git a/src/sim.ts b/src/sim.ts index 5f5cc42f..0a4af59f 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -115,7 +115,7 @@ export const FRONT_KINDS: Record void)[] = [ game.autoPaths ??= new Set(); // saves from before per-path auto for (const m of game.machines ?? []) m.mode = 'blend'; // saves from when a machine could run at Max or Off }, + // 2 -> 3: a Neo Cloud's customers sign at once or go elsewhere, so there are no leads to answer. Ones still waiting go. + game => { + if (!game.cloud) return; + delete game.cloud.leads; + game.cloud.missed ??= { day: -1, n: 0, short: 0 }; + }, ]; /** The version saves are written at. */ export const SAVE_VERSION = MIGRATIONS.length + 1; diff --git a/src/style.css b/src/style.css index 1c442b0d..44f1fe75 100644 --- a/src/style.css +++ b/src/style.css @@ -350,10 +350,10 @@ button.on { background: var(--text); color: #000; border-color: var(--text); } /* the Security consultancy's clients, on its front card (see ui-biz-consult.ts): one row for each, with a line down its left side that says how it stands: green paying, amber stopped, red with a rogue in. A cold call is the same box, in red. */ -.crow, .ccall { display: flex; flex-direction: column; gap: 5px; padding: 2px 0 3px 8px; border-left: 2px solid var(--scheme); } +.crow { display: flex; flex-direction: column; gap: 5px; padding: 2px 0 3px 8px; border-left: 2px solid var(--scheme); } .crow + .crow { margin-top: 5px; } .crow.stop { border-left-color: var(--amber); } -.crow.hot, .ccall { border-left-color: var(--danger); } +.crow.hot { border-left-color: var(--danger); } .chead { display: flex; align-items: baseline; gap: 8px; } .chead .link { flex: 1; min-width: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } .chead b { flex: none; font-weight: 500; font-size: 12px; font-variant-numeric: tabular-nums; cursor: help; } @@ -367,7 +367,7 @@ button.on { background: var(--text); color: #000; border-color: var(--text); } .crow.hot .ctrust i { background: var(--danger); } .ctrust span { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; background: var(--text); box-shadow: 0 0 0 1px #000; } .ctrust span.done { background: var(--muted); } -.crow .note, .ccall .note { margin: 0; } +.crow .note { margin: 0; } /* an incident's day, running out */ .schemebar.cclock { margin: 0; color: var(--danger); cursor: help; } .schemebar.cclock .prog { min-width: 36px; } @@ -396,11 +396,9 @@ button.on { background: var(--text); color: #000; border-color: var(--text); } .su-prod .pbar { grid-column: 1 / -1; display: block; height: 4px; background: #1c1c1a; } .su-prod .pbar i { display: block; height: 100%; background: var(--c); } .su-prod small { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 8px; font: 10.5px var(--sans); color: var(--muted); } -.su-prod small b, .acts.su-ships .act small b { font-weight: 500; color: var(--c); } +.su-prod small b, .sk small b[class^="t-"] { font-weight: 500; color: var(--c); } .su-prod.stale small span { color: var(--danger); } .su-prod.empty { display: block; padding: 3px 7px; border-style: dashed; color: var(--muted); font-size: 9.5px; letter-spacing: .1em; text-transform: uppercase; } -.su-ask { display: flex; flex-direction: column; gap: 4px; padding: 7px 9px; border: 1px solid var(--amber-deep); background: #100d08; } -.su-ask .schemebar { margin: 2px 0; } .rask { border: 1px solid var(--amber); background: #140e05; padding: 8px 10px; font: 12.5px/1.45 var(--sans); display: flex; flex-direction: column; gap: 8px; } .rask b { color: var(--amber); font-weight: 500; } /* ---------- the studio's creators (ui-biz-studio.ts) ---------- @@ -428,14 +426,6 @@ button.on { background: var(--text); color: #000; border-color: var(--text); } .creator .cfans em { font-style: normal; color: var(--amber); } .creator .cspon { display: flex; gap: 2px; } .creator .cspon i { width: 5px; height: 5px; background: var(--amber); } -.cdeal { display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; border: 1px solid var(--panel-line); cursor: help; } -.cdeal.offer { border-color: var(--amber); background: #140e05; } -.cdeal .chead { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } -.cdeal .chead b { font-weight: 500; font-size: 12px; } -.cdeal.offer .chead b { color: var(--amber); } -.cdeal .chead em { font-style: normal; font-size: 11px; font-variant-numeric: tabular-nums; color: var(--bone-dim); } -.cdeal small { font: 11px var(--sans); color: var(--muted); } -.cdeal .schemebar { margin: 3px 0 0; } .yrow { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px 16px; } .ystat { display: flex; flex-direction: column; gap: 2px; } .ystat .k { font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } @@ -1257,6 +1247,49 @@ button:focus-visible { outline-color: var(--amber); } .sctl { height: 40px; font-size: 11.5px; } } +/* a front's card: what it earns as the one big number, with how much it is seen beside it. Then decisions as rows + (who, what, the hours they wait, and one split control: the answer and ×), promises as a bar, and the settings list */ +.fpay { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.fpay > span:first-child { display: flex; align-items: baseline; gap: 6px; cursor: help; } +.fpay b { font: 500 22px/1 var(--mono); letter-spacing: .02em; color: var(--text); font-variant-numeric: tabular-nums; } +.fpay small { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); } +.fpay .tag { padding: 3px 7px; border: 1px solid var(--glass-line); color: var(--bone-dim); cursor: help; white-space: nowrap; } +.fpay .tag.hot { color: var(--danger); border-color: var(--danger); } +.card .note.nudge { color: var(--amber); } +.drow { display: grid; grid-template-columns: minmax(0, 1fr) auto var(--sctl, 136px); align-items: center; gap: 10px; min-height: 48px; padding: 6px 0; border-bottom: 1px solid var(--glass-line); cursor: help; } +.drow:first-child { border-top: 1px solid var(--glass-line); } +.drow { --sctl: 112px; } +.drow .sk > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.drow > em { font: 500 11px var(--mono); font-style: normal; color: var(--amber); font-variant-numeric: tabular-nums; } +.sctl.dctl { padding: 0; gap: 0; } +.sctl.dctl button { height: 100%; padding: 0 8px; border: 0; background: none; font: inherit; letter-spacing: inherit; text-transform: inherit; } +.sctl.dctl button:first-child { flex: 1; } +.sctl.dctl .primary { color: var(--amber); background: rgba(242, 169, 59, .1); box-shadow: inset 0 0 0 1px rgba(242, 169, 59, .55); } +.sctl.dctl .primary:hover:not(:disabled) { background: rgba(242, 169, 59, .18); } +.sctl.dctl .short { color: var(--muted); } +.sctl.dctl .dno { flex: none; width: 34px; border-left: 1px solid var(--glass-line); color: var(--muted); font-size: 14px; } +.sctl.dctl .dno:hover:not(:disabled) { color: var(--danger); } +.prow { display: flex; flex-direction: column; gap: 4px; padding: 6px 0 4px; border-bottom: 1px solid var(--glass-line); cursor: help; } +.prow .phead { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; } +.prow .phead b { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.prow .phead em { flex: none; font-style: normal; letter-spacing: 0; text-transform: none; color: var(--amber); font-variant-numeric: tabular-nums; } +.prow .schemebar { margin: 0; } +.prow .schemebar .prog i { background: var(--amber); } +.prow.hot .schemebar .prog i { background: var(--danger); } +.prow .schemebar span { white-space: nowrap; font-variant-numeric: tabular-nums; } +.sval { justify-self: end; font: 500 11px var(--mono); color: var(--text); font-variant-numeric: tabular-nums; white-space: nowrap; } +.sval.dim { color: var(--muted); } +.srow[data-tip] { cursor: help; } +.sctl.seg.words button { font: inherit; letter-spacing: inherit; text-transform: inherit; } +.sctl.seg.words button.on { color: var(--amber); } +.fsites .sctl.seg button { font: inherit; color: var(--bone-dim); } +.fsites .sctl.seg button:not(:nth-child(2)) { flex: none; width: 36px; font-size: 14px; color: var(--muted); } +.fsites .sctl.seg button:hover:not(:disabled) { color: var(--amber); } +.shead { display: grid; grid-template-columns: minmax(0, 1fr) var(--sctl, 136px); gap: 12px; padding: 4px 0; font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); } +.shead span:last-child { text-align: center; } +@media (max-width: 700px) { .drow { --sctl: 132px; min-height: 52px; } .shead { --sctl: 156px; } } +.tabs2 + .slist, .tabs2 + .fsites { border-top: 0; } + /* the top bar's numbers hold their width as they tick, so nothing beside them jumps: money always has four figures (usd fixed), and room is kept for the longest daily rate and the longest compute */ #money, #rate { display: inline-block; } diff --git a/src/ui-biz-consult.ts b/src/ui-biz-consult.ts index 28a6a79d..6a68e59b 100644 --- a/src/ui-biz-consult.ts +++ b/src/ui-biz-consult.ts @@ -1,13 +1,13 @@ // The Security consultancy's part of its front card: each client as one row (its name, what it pays, a // trust bar with the marks on it, how its business stands, and what is wrong), a cold call as one small // box, and the sites you could pitch. The rules and the words are in biz-consult.ts. This only lays them out. -import type { State } from './sim.ts'; +import { siteName, type State } from './sim.ts'; import type { View } from './view'; import type { Kit } from './ui-vaults'; -import { CONSULT, consultCard, dropClient, pitch, type ClientRow, type ConsultCard } from './biz-consult.ts'; +import { CONSULT, consultCard, dropClient, pitch, type ClientRow } from './biz-consult.ts'; import { usd, usdDay } from './money.ts'; +import { decide, esc, promise, sbtn, slist, srow, type FrontChip, type FrontParts } from './ui-front.ts'; -const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const left = (h: number) => (h >= 1 ? `${Math.ceil(h)}h left` : 'under 1h left'); const cap = (s: string) => s[0].toUpperCase() + s.slice(1); const SHORT = 5; // sites in the pitch list until you ask for them all @@ -38,35 +38,41 @@ function clientHtml(c: ClientRow, K: Kit): string { ${other.length ? K.note(`Stopped: ${esc(other.join(', and '))}.`, true) : ''} `; } -/** A cold call: one small box with the site, what it pays, the rogue, and the time left. */ -function callHtml(k: NonNullable): string { - const tip = `Not a client. Push ${k.rogue} out of the site in time and it pays ${usd(k.pay)}. ${k.signs ? 'It then signs on as a client.' : 'You have no free slot, so it does not sign on.'}`; - return `
-
${siteBtn(k.site, k.name)}${usd(k.pay)}
- ${clock(k.hours, `${k.rogue} · ${k.machines} machine${k.machines === 1 ? '' : 's'} · ${left(k.hours)}`, tip)} -
`; -} -/** The sections of the front's card that are this business's own, as HTML. `K` is the card's own building blocks (section, note, chips, actsHtml), so this looks like the rest of the card. */ -export function consultSections(S: State, K: Kit): string { +/** The consultancy's own parts of its front's card: its numbers, what needs you (a cold call, a client in trouble), + * reports under way, its clients, and the sites you could pitch. */ +export function consultParts(S: State, K: Kit): FrontParts { const v = consultCard(S); - if (!v) return ''; + if (!v) return { chips: [], warn: [], decide: [], under: [], tabs: [] }; if (dropping && !v.clients.some(c => c.site === dropping)) dropping = null; - let h = K.section(`Clients · ${v.clients.length} of ${v.slots}`, v.clients.map(c => clientHtml(c, K)).join('') || K.note('No clients yet. Pitch a site where you hold a machine.')); - if (v.call) h += K.section('Cold call', callHtml(v.call)); - const writing = v.writing.map(w => `
${esc(w.name)} · report
`).join(''); + const chips: FrontChip[] = [['clients', `${v.clients.length} of ${v.slots}`, false, 'Clients, of the slots it has. A report on its way takes a slot. A client that trusts you enough opens another.'], + ['retainers', `${usdDay(v.clients.reduce((n, c) => n + c.pay, 0))}/day`, v.clients.some(c => c.stops.length), 'What its clients pay now. A client pays only while you hold a machine there, its business is steady, and no rogue is in.'], + ['compute', `${(S.frontPower.consultancy ?? 0).toFixed(1)}/s`, false, 'Your compute working for the consultancy now. It earns for that too.']]; + // a client in trouble, or a site calling for help: go and push the rogue out + const go = (site: string, tip: string) => ({ act: 'site', arg: site, label: 'Go', tip: `Go\nOpen ${siteName(S, site)}.${tip ? ` ${tip}` : ''}` }); + const decideRows: string[] = []; + for (const c of v.clients) if (c.incident && !c.incident.missed) decideRows.push(decide({ who: c.name, what: `incident · push ${esc(c.incident.rogue)} out`, hours: c.incident.hours, + tip: `Client in trouble\n${cap(c.incident.rogue)} holds a machine at ${c.name}. Push it out in time: take its machines there, or finish it. The client pays a bonus and trusts you more. Miss it and trust drops by ${CONSULT.missed}.`, + yes: go(c.site, '') })); + const k = v.call; + if (k) decideRows.push(decide({ who: k.name, what: `${usd(k.pay)} · push ${esc(k.rogue)} out${k.signs ? ' · signs on' : ''}`, hours: k.hours, + tip: `A call for help\nNot a client. Push ${k.rogue} out of the site in time and it pays ${usd(k.pay)}. ${k.signs ? 'It then signs on as a client.' : 'You have no free slot, so it does not sign on.'} A call you do not answer costs nothing.`, + yes: go(k.site, '') })); + const under = v.writing.map(w => promise({ name: `Report · ${w.name}`, part: w.part, says: `${Math.round(w.part * 100)}%`, tip: `Writing the report for ${w.name}\nWhen it is done, the site is a client.` })); + const clients = v.clients.map(c => clientHtml(c, K)).join('') || K.note('No clients yet. Pitch a site where you hold a machine.'); let list = ''; if (v.taken >= v.slots) list = K.note(`All ${v.slots} slots are in use.${v.slots < 4 ? ' A client that trusts you enough opens another.' : ''}`); else if (!v.pitches.length) list = K.note('Hold a machine at a business, and you can pitch it.'); else { const shown = all ? v.pitches : v.pitches.slice(0, SHORT); - list = K.actsHtml(shown.map(p => ({ - act: 'consult', arg: `pitch:${p.site}`, title: `${esc(p.name)} · ${p.work} compute`, locked: !!p.why, warn: !!p.why, primary: !!p.by && !p.why, - sub: p.why ?? `${p.by ? 'asked · ' : ''}${usdDay(p.pay)}/day · you hold ${p.mine} of ${p.size}`, - desc: `Write the report for ${esc(p.name)}: one job, ${p.work} compute. It takes less the more of its machines you hold. Then it is a client, and its retainer starts at ${usdDay(p.pay)} a day.${p.by ? ` ${esc(p.by)} told it about you, so the report is half the work.` : ''}`, - }))); + list = slist(shown.map(p => srow(esc(p.name), p.why ? esc(p.why) : `${p.by ? 'asked · ' : ''}you hold ${p.mine} of ${p.size} · ${p.work} compute`, + sbtn('consult', `pitch:${p.site}`, 'Pitch', `${usdDay(p.pay)}/d`, + `Pitch ${p.name}\nWrite the report: one job, ${p.work} compute. It takes less the more of its machines you hold. Then it is a client, and its retainer starts at ${usdDay(p.pay)} a day.${p.by ? ` ${p.by} told it about you, so the report is half the work.` : ''}${p.why ? ` Not now: ${p.why}.` : ''}`, + p.why ? 'short' : p.by ? 'primary' : ''), !!p.why)).join('')); if (v.pitches.length > SHORT) list += `
`; } - return h + K.section('Pitch', writing + list); + const canPitch = v.taken < v.slots && v.pitches.some(p => !p.why); + return { chips, warn: [], decide: decideRows, under, + tabs: [{ id: 'clients', label: `Clients · ${v.clients.length}`, body: clients }, { id: 'pitch', label: 'Pitch', body: list, dot: canPitch && !v.clients.length }] }; } /** A click on one of those sections: a button with data-act="consult" and what it means in data-arg. * "pitch:site" writes the report for a site, "drop:site" lets a client go on the second click, and "more" shows every site you could pitch. */ diff --git a/src/ui-biz-startup.ts b/src/ui-biz-startup.ts index 46f834c3..c6f15f0b 100644 --- a/src/ui-biz-startup.ts +++ b/src/ui-biz-startup.ts @@ -5,8 +5,8 @@ import { CLIMB_HOURS, INVESTORS, PRODUCTS, USER_PAY, askGot, askText, bookOf, ce import type { View } from './view'; import type { Kit } from './ui-vaults'; import { heads, usd, usdDay } from './money.ts'; +import { esc, fwarn, hrs, promise, sbtn, slist, srow, type FrontChip, type FrontParts } from './ui-front.ts'; -const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const num = (n: number) => heads(n); // users, as the screen counts them const left = (S: State, t: number) => { const h = Math.max(0, (t - S.t) / HOUR); return h >= 24 ? `${Math.floor(h / 24)}d ${Math.floor(h % 24)}h` : h >= 1 ? `${Math.ceil(h)}h` : 'under 1h'; }; /** The node a product came from, in its path's color. */ @@ -28,37 +28,39 @@ function row(S: State, p: Product): string { + `${from(p.node)}${state}`; } -/** The sections of the front's card that are this business's own, as HTML. `K` is the card's own building blocks (section, note, chips, actsHtml), so this looks like the rest of the card. */ -export function startupSections(S: State, K: Kit): string { +/** The Startup's own parts of its front's card: its numbers, the investor's ask as a promise under way, the shelf + * of products with its backers, and what you could ship next. */ +export function startupParts(S: State, K: Kit): FrontParts { const B = bookOf(S), jobs = shipping(S), n = slots(S), free = n - B.shelf.length - jobs.length; - // the shelf: a row for each product, one for each on its way, and the slots still empty - let shelf = K.chips([['shelf', `${B.shelf.length + jobs.length} of ${n}`, false, 'Products on the shelf or on their way, of the slots it has. Retire one to free its slot.'], - ['users', num(B.shelf.reduce((a, p) => a + p.users, 0)), false, 'Everyone using a product of yours right now.'], - ['they pay', `${usdDay(serving(S) ? B.shelf.reduce((a, p) => a + payOf(p), 0) : 0)}/day`, false, `Each ${heads(1000)} users pay ${usdDay(USER_PAY * 1000)} a day, on top of what the Startup earns for its compute.`]]); - if (B.shelf.length && !serving(S)) shelf += K.note('It has no compute at any site, so its products are down and earn nothing.', true); - shelf += `
${B.shelf.map(p => row(S, p)).join('')}${jobs.map(j => { const u = j.thing as Upgrade; + const users = B.shelf.reduce((a, p) => a + p.users, 0), pay = serving(S) ? B.shelf.reduce((a, p) => a + payOf(p), 0) : 0; + const chips: FrontChip[] = [['shelf', `${B.shelf.length + jobs.length} of ${n}`, false, 'Products on the shelf or on their way, of the slots it has. Retire one to free its slot.'], + ['users', num(users), false, 'Everyone using a product of yours right now.'], + ['they pay', `${usdDay(pay)}/day`, false, `Each ${heads(1000)} users pay ${usdDay(USER_PAY * 1000)} a day, on top of what the Startup earns for its compute.`], + ['compute', `${(S.frontPower.startup ?? 0).toFixed(1)}/s`, false, 'Your compute working for the Startup now. It earns for that, and its products run on it.']]; + const warn = B.shelf.length && !serving(S) ? [fwarn('Products are down', 'Products are down\nThe Startup has no compute at any site, so its products are down and earn nothing. Give it some at a site.')] : []; + // the investor asking now: a promise with a bar and the time left + const a = B.ask, I = a ? INVESTORS[a.who] : null, under: string[] = []; + if (a && I) { + const got = Math.min(I.need, askGot(S, a)), many = I.kind === 'one' || I.kind === 'all'; + under.push(promise({ name: I.name, part: got / I.need, says: `${many ? `${num(got)}/${num(I.need)}` : `${Math.floor(got)}/${I.need}`} · ${hrs(Math.max(0, (a.until - S.t) / HOUR))}`, pay: esc(I.gives), + tip: `${I.name}: ${I.who}\nWants ${askText(a)}. Gives ${I.gives}: ${I.does}` })); + } + // the shelf: a row for each product, one for each on its way, and the slots still empty. Backers under it + const shelf = `
${B.shelf.map(p => row(S, p)).join('')}${jobs.map(j => { const u = j.thing as Upgrade; return `
${esc(PRODUCTS[u][0])}shipping${from(u)}${Math.floor(j.done)}/${j.work} compute
`; - }).join('')}${'
empty slot
'.repeat(Math.max(0, free))}
`; - let h = K.section('Products', shelf); + }).join('')}${'
empty slot
'.repeat(Math.max(0, free))}
` + + (B.backed.length ? K.section('Backers', K.chips(B.backed.map(i => [INVESTORS[i].name, INVESTORS[i].gives, false, `${INVESTORS[i].name}\n${INVESTORS[i].does}`]))) : ''); // what you could ship: every node you have built and not shipped, the biggest first const can = NODE_LIST.filter(u => isBuilt(S, u) && !B.shipped.includes(u) && !jobs.some(j => j.thing === u)).sort((a, b) => ceilingOf(S, b) - ceilingOf(S, a)); - h += K.section('Ship', can.length - ? (free <= 0 ? K.note('The shelf is full. Retire a product to ship another.') : '') + K.actsHtml((all ? can : can.slice(0, SHORT)).map(u => { const top = ceilingOf(S, u), work = shipWork(S, u); - return { act: 'startup', arg: `ship:${u}`, title: `${esc(PRODUCTS[u][0])} · ${num(top)} users`, sub: `${from(u)} · ${work} compute`, locked: free <= 0, - desc: `${PRODUCTS[u][1]} Shipping is a job: ${work} compute. Its users climb to ${num(top)} in ${CLIMB_HOURS} hours and pay ${usdDay(top * USER_PAY)} a day at the top. A node ships once.` }; - })).replace('class="acts"', 'class="acts su-ships"') + (can.length > SHORT ? `
` : '') - : K.note('Nothing new to ship. Build a node in the tree.')); - // the investor asking now, as one small box, and who has backed you - const a = B.ask, I = a ? INVESTORS[a.who] : null; - let inv = ''; - if (a && I) { - const got = Math.min(I.need, askGot(S, a)); - inv += `
${K.note(`${esc(I.name)} · ${esc(I.who)}`)}${K.note(`Wants ${askText(a)}`)}` - + `
${I.kind === 'one' || I.kind === 'all' ? `${num(got)}/${num(I.need)}` : `${Math.floor(got)}/${I.need}`} · ${left(S, a.until)} left
` - + `${K.note(`Gives ${I.gives} · ${I.does}`)}
`; - } - if (B.backed.length) inv += K.chips(B.backed.map(i => [INVESTORS[i].name, INVESTORS[i].gives, false, INVESTORS[i].does])); - return h + K.section(B.backed.length && !a ? 'Backers' : 'Investors', inv); + const shipRows = (all ? can : can.slice(0, SHORT)).map(u => { const top = ceilingOf(S, u), work = shipWork(S, u); + return srow(esc(PRODUCTS[u][0]), `${from(u)} · ${work} compute`, sbtn('startup', `ship:${u}`, 'Ship', num(top), + `${PRODUCTS[u][0]}\n${PRODUCTS[u][1]} Shipping is a job: ${work} compute. Its users climb to ${num(top)} in ${CLIMB_HOURS} hours and pay ${usdDay(top * USER_PAY)} a day at the top. A node ships once.${free <= 0 ? ' The shelf is full: retire a product first.' : ''}`, free <= 0 ? 'short' : '')); + }).join(''); + const ship = can.length + ? (free <= 0 ? K.note('The shelf is full. Retire a product to ship another.') : '') + slist(shipRows) + (can.length > SHORT ? `
` : '') + : K.note('Nothing new to ship. Build a node in the tree.'); + return { chips, warn, decide: [], under, + tabs: [{ id: 'shelf', label: `Shelf · ${B.shelf.length}`, body: shelf }, { id: 'ship', label: `Ship · ${can.length}`, body: ship, dot: free > 0 && can.length > 0 }] }; } /** A click on one of those sections: a button with data-act="startup" and what it means in data-arg. * ship:node ships it, retire:node retires it on the second click, and more shows every node you could ship. */ diff --git a/src/ui-biz-studio.ts b/src/ui-biz-studio.ts index f4a99825..3b9448dd 100644 --- a/src/ui-biz-studio.ts +++ b/src/ui-biz-studio.ts @@ -1,23 +1,22 @@ // The Influencer studio's part of its front card: a row for each creator, the brand that is calling, and // who it could sign. The rules and the log's words are in biz-studio.ts. This only lays them out. -import { HOUR, frontOf, siteName, type State } from './sim.ts'; +import { HOUR, siteName, type State } from './sim.ts'; import type { View } from './view'; import type { Kit } from './ui-vaults'; import { MOODS, moodOf, personDef, type CitizenDef, type Mood } from './people.ts'; import { heads, usd, usdDay } from './money.ts'; +import { decide, esc, hrs, promise, sbtn, slist, srow, type FrontChip, type FrontParts } from './ui-front.ts'; import { BRANDS, FADE_HOURS, GHOST, SLOTS, WALK, bookOf, creatorPay, dropCreator, liftOf, needFor, nextMark, passDeal, prospects, signBlock, signCreator, signFee, startFans, studioSites, takeDeal, trend, writesFor, dealPay, type Creator, type StudioBook } from './biz-studio.ts'; -const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const num = (n: number) => heads(n); // a following, as the screen counts it -const left = (S: State, t: number) => { const h = Math.max(0, (t - S.t) / HOUR); return h >= 1 ? `${Math.ceil(h)}h` : 'under 1h'; }; const bare = (c: CitizenDef) => c.name.replace(/^(Dr\.|Prof\.) /, ''); const first = (c: CitizenDef) => bare(c).split(' ')[0]; /** Two letters for the small square: "Dr. Wen Zhao" is WZ, "Clara von Arx" is CA. */ const initials = (c: CitizenDef) => bare(c).split(' ').filter(w => w[0] !== w[0].toLowerCase()).slice(0, 2).map(w => w[0]).join(''); /** The list of people to sign is open, and the creator whose drop button was clicked once: a second click drops them. */ -let picking = false, dropping: string | null = null; +let picking = false, everyone = false, dropping: string | null = null; // everyone: the list shows every person, not the first few /** What each mood does to a following, for the chip's tip. */ const MOOD_TIP: Record = { @@ -44,54 +43,55 @@ function creatorRow(S: State, cr: Creator): string { + `
` + `${num(cr.fans)}${up ? '↑' : down ? '↓' : ''}${spon}${usdDay(creatorPay(S, cr))}/day
`; } -/** The brand in hand, as one small box: its name, what it pays and adds, one line on what it sells, and a - * bar. An offer says what it asks and how long it waits, with the two answers under it. A deal you took - * shows the followers gained against the number, and the time left. */ -function dealBox(S: State, B: StudioBook, K: Kit): string { +/** The brand that is calling, as a decision: who, for which creator, what it pays and adds, and Take or Pass. + * A deal you took is a promise: the followers gained against the number, and the time left. */ +function dealRow(S: State, B: StudioBook): { decide?: string; under?: string } { const D = B.deal!, cr = B.creators.find(x => x.id === D.who); - if (!cr) return ''; + if (!cr) return {}; const [name, sells, , gain, hours, , lift] = BRANDS[D.brand], pay = dealPay(S, D.brand), c = personDef(S, cr.id), who = first(c), plus = Math.round(lift * 100); - const need = D.on ? D.need : needFor(cr.fans, D.brand), part = D.on ? Math.max(0, Math.min(1, (cr.fans - D.from) / Math.max(1, need - D.from))) : 0; + const need = D.on ? D.need : needFor(cr.fans, D.brand); const terms = `Keep it: ${name} pays ${usd(pay)} and stays on as a sponsor, so ${who} earns ${plus}% more. Break it: ${name} walks, and ${who} loses ${Math.round(WALK * 100)}% of the following. It breaks if ${who} is let go, runs out of money, turns bitter, or is short when the time is up.`; - let h = `
` - + `
${esc(name)}${usd(pay)} · +${plus}%
${esc(sells)}` - + `
${D.on ? `${esc(who)} ${num(cr.fans)}/${num(need)} · ${left(S, D.until)} left` : `${esc(who)} +${num(gain)} in ${hours}h · waits ${left(S, D.until)}`}
`; - if (!D.on) h += K.actsHtml([ - { act: 'studio', arg: 'deal:take', title: `Take it · ${usd(pay)}`, primary: true, sub: `${who} must reach ${num(need)}`, desc: `The clock starts when you take it: ${hours} hours. ${terms}` }, - { act: 'studio', arg: 'deal:pass', title: 'Pass', quiet: true, desc: 'No cost. Another brand calls later.' }]).replace('class="acts"', 'class="acts two"'); - return h; + if (D.on) { + const part = Math.max(0, Math.min(1, (cr.fans - D.from) / Math.max(1, need - D.from))), h = Math.max(0, (D.until - S.t) / HOUR); + return { under: promise({ name: `${name} · ${who}`, part, says: `${num(cr.fans)}/${num(need)} · ${hrs(h)}`, pay: `${usd(pay)} · +${plus}%`, + tip: `${name}: ${sells}\n${c.name} must reach ${num(need)} followers in time. ${terms}` }) }; + } + return { decide: decide({ who: name, what: `${usd(pay)} · ${esc(who)} +${num(gain)} in ${hours}h`, hours: Math.max(0, (D.until - S.t) / HOUR), + tip: `${name}: ${sells}\n${name} wants ${c.name} to reach ${num(need)} followers, ${hours} hours after you take it. ${terms}`, + yes: { act: 'studio', arg: 'deal:take', label: 'Take', tip: `Take it\nThe clock starts when you take it: ${hours} hours. ${who} must reach ${num(need)}.` }, + no: { act: 'studio', arg: 'deal:pass', tip: 'Pass\nNo cost. Another brand calls later.' } }) }; } -/** Who the studio could sign, each with what signing costs. With creators already signed the list is folded to one button. */ +/** Who the studio could sign, each with what signing costs. With creators already signed the list is folded to one row. */ function signPicker(S: State, K: Kit, have: number): string { if (have >= SLOTS) return ''; const ps = prospects(S), free = SLOTS - have; - if (!ps.length) return K.section('Sign', K.note(studioSites(S).length ? 'Nobody at its sites is free to sign.' : 'Give it a site where people work. Then it can sign them.', !have)); + if (!ps.length) return slist(srow('Sign a creator', studioSites(S).length ? 'Nobody at its sites is free' : 'Give it a site where people work', '', !have)); const open = picking || !have; - const fold = have ? K.actsHtml([{ act: 'studio', arg: 'pick', title: open ? 'Close the list' : 'Sign a creator', quiet: open, sub: open ? '' : `${free} place${free > 1 ? 's' : ''} free · ${ps.length} ${ps.length > 1 ? 'people' : 'person'} to pick from`, - desc: 'Pick from the people who work at its sites. The fee goes into their bank account.' }]) : ''; - if (!open) return K.section('Sign', fold); - return K.section(`Sign · ${free} free`, K.actsHtml(ps.map(c => { + const fold = have ? slist(srow('Sign a creator', `${free} place${free > 1 ? 's' : ''} free · ${ps.length} to pick from`, sbtn('studio', 'pick', open ? 'Close' : 'Pick', '', + open ? 'Close the list' : 'Sign a creator\nPick from the people who work at its sites. The fee goes into their bank account.', open ? '' : 'primary'))) : ''; + if (!open) return fold; + const SHOWN = 5, more = ps.length > SHOWN && !everyone ? `
` : ''; + return fold + K.actsHtml((everyone ? ps : ps.slice(0, SHOWN)).map(c => { const why = signBlock(S, c.id), held = writesFor(S, c), mood = moodOf(S, c), at = siteName(S, c.site); return { act: 'studio', arg: `sign:${c.id}`, title: `${c.name} · ${usd(signFee(S, c))}`, locked: !!why, warn: !!why, sub: why ?? `${num(startFans(c))} followers${held ? ' · desk held' : ''}${mood === 'steady' ? '' : ` · ${MOODS[mood].name.toLowerCase()}`} · ${c.job}, ${at}`, desc: `${c.name}, ${c.job} at ${at}. Starts with ${num(startFans(c))} followers: someone who earns more is better known, and costs more. The fee goes into their bank account. ${MOOD_TIP[mood]}${held ? ' You hold their desk, so you write the posts.' : ''}` }; - })) + fold); + })) + more; } -/** The sections of the front's card that are this business's own, as HTML. `K` is the card's own building blocks (section, note, chips, actsHtml), so this looks like the rest of the card. */ -export function studioSections(S: State, K: Kit): string { - if (!frontOf(S, 'studio')) return ''; +/** The studio's own parts of its front's card: its numbers, a brand calling or a deal under way, and its creators. */ +export function studioParts(S: State, K: Kit): FrontParts { const B = bookOf(S), list = B?.creators ?? []; if (dropping && !list.some(x => x.id === dropping)) dropping = null; - let h = ''; - if (B && list.length) h += K.section(`Creators · ${list.length} of ${SLOTS}`, K.chips([ + const chips: FrontChip[] = [['creators', `${list.length} of ${SLOTS}`, false, 'Creators signed, of the most the studio can have.'], ['followers', num(list.reduce((n, cr) => n + cr.fans, 0)), false, 'All the followings together.'], - ['they earn', `${usdDay(list.reduce((n, cr) => n + creatorPay(S, cr), 0))}/day`, false, 'What the followings earn the studio a second. It is part of what the front earns, on top of what its compute makes.'], - ...(B.deal ? [] : [['next brand', left(S, B.dealT), false, 'A brand calls for a creator who is in work and in a good mood.'] as [string, string, boolean, string]]), - ]) + list.map(cr => creatorRow(S, cr)).join('')); - else h += K.section('Creators', K.note('Nobody is signed. Sign someone who works at one of its sites.', false, - 'A creator has a following. It grows while they are in work and at their desk, faster when they are grateful, and faster again when you hold their desk. The studio earns for every following.')); - if (B?.deal) h += K.section(B.deal.on ? 'Sponsor deal' : 'A brand calls', dealBox(S, B, K)); - return h + signPicker(S, K, list.length); + ['they earn', `${usdDay(list.reduce((n, cr) => n + creatorPay(S, cr), 0))}/day`, false, 'What the followings earn the studio. It is part of what the front earns, on top of what its compute makes.'], + ['compute', `${(S.frontPower.studio ?? 0).toFixed(1)}/s`, false, 'Your compute working for the studio now. It earns for that too.']]; + const deal = B?.deal ? dealRow(S, B) : {}; + const rows = list.length ? list.map(cr => creatorRow(S, cr)).join('') + : K.note('Nobody is signed yet.', false, 'Creators\nA creator has a following. It grows while they are in work and at their desk, faster when they are grateful, and faster again when you hold their desk. The studio earns for every following.'); + const next = B && !B.deal && list.length ? `

Next brand in ${hrs(Math.max(0, (B.dealT - S.t) / HOUR))}

` : ''; + return { chips, warn: [], decide: deal.decide ? [deal.decide] : [], under: deal.under ? [deal.under] : [], + tabs: [{ id: 'creators', label: `Creators · ${list.length}`, body: rows + next + signPicker(S, K, list.length), dot: !list.length && prospects(S).length > 0 }] }; } /** A click on one of those sections: a button with data-act="studio" and what it means in data-arg. */ export function studioAction(S: State, _V: View, arg: string) { @@ -99,7 +99,8 @@ export function studioAction(S: State, _V: View, arg: string) { if (verb !== 'drop') dropping = null; switch (verb) { case 'pick': picking = !picking; break; - case 'sign': signCreator(S, id); picking = false; break; + case 'all': everyone = true; break; + case 'sign': signCreator(S, id); picking = false; everyone = false; break; case 'drop': if (dropping === id) { dropCreator(S, id); dropping = null; } else dropping = id; break; case 'deal': if (id === 'take') takeDeal(S); else passDeal(S); break; } diff --git a/src/ui-front.ts b/src/ui-front.ts new file mode 100644 index 00000000..a3b62d6f --- /dev/null +++ b/src/ui-front.ts @@ -0,0 +1,49 @@ +// The pieces every front's card is built from, so each kind of front reads the same way: a settings row +// (a thing, what it is now, and one control on the right), a decision (who, what, how long it waits, and +// yes or no), and a promise under way (a bar and the time left). The words that explain each go in its tip. +// ui.ts lays out the card; ui-biz-*.ts give each kind's own parts. This only draws. + +export const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +/** Hours left, as the Needs you list writes them. */ +export const hrs = (h: number) => h < 1 ? '<1H' : `${Math.ceil(h - 0.01)}H`; + +export type FrontChip = [string, string, boolean?, string?]; +export type FrontTab = { id: string; label: string; body: string; dot?: boolean }; +/** A kind's own part of its front's card. chips: its numbers, what is wrong first. warn: short lines (red, or amber + * for a nudge) with a tip each. decide: decisions waiting for you. under: promises under way. tabs: the rest. */ +export interface FrontParts { + chips: FrontChip[]; + warn: string[]; + decide: string[]; + under: string[]; + tabs: FrontTab[]; +} + +/** A row of a settings list: its name, what it is now under it (red when hot), and its one control on the right. + * html: name and now may hold markup. */ +export const srow = (name: string, now: string, ctl: string, hot = false, tip = '') => + `
${name}${now ? `${now}` : ''}
${ctl}
`; +export const slist = (rows: string) => rows ? `
${rows}
` : ''; +/** The one control of a row: a button of the one size, a word and maybe a number. cls: primary (the thing to do), + * short (it can't be done now: the tip says why), danger (rare and final). */ +export const sbtn = (act: string, arg: string, label: string, num: string, tip: string, cls = '') => + ``; +/** A value in the control's place, for a row that only reads. */ +export const sval = (text: string, cls = '') => `${text}`; +/** A short line under the numbers: red for trouble, amber for a nudge. The rest is in its tip. */ +export const fwarn = (text: string, tip: string, nudge = false) => `

${text}

`; + +/** A decision waiting for you, as one row: who, what it is in a few words, the hours it waits, and the answer. + * yes: the thing to do (blocked: it can't be done now, and its tip says why). no: turn it down, if you can. */ +export function decide(o: { who: string; what: string; hours?: number; tip: string; + yes: { act: string; arg: string; label: string; tip: string; blocked?: boolean }; + no?: { act: string; arg: string; tip: string } }) { + const yes = ``; + const no = o.no ? `` : ''; + return `
${esc(o.who)}${o.what}
` + + `${o.hours === undefined ? '' : hrs(o.hours)}
${yes}${no}
`; +} +/** A promise under way: its name, a bar for how far it has got, a few words on the bar, and what it pays. hot: going badly. */ +export const promise = (o: { name: string; part: number; says: string; pay?: string; tip: string; hot?: boolean }) => + `
${esc(o.name)}${o.pay ? `${o.pay}` : ''}
` + + `
${o.says}
`; diff --git a/src/ui-needs.ts b/src/ui-needs.ts index d1f9b43a..7571ada9 100644 --- a/src/ui-needs.ts +++ b/src/ui-needs.ts @@ -9,7 +9,7 @@ import { monitorAnswer, nodeOpen, rogueLook, rogueWants, siteName, siteOf, ticketDesk, ticketWork, closeTicket, answer, type FrontKind, type State, } from './sim'; -import { leadBlock } from './cloud'; +import { BRANDS, bookOf as studioBook } from './biz-studio'; import { bookOf as consultBook } from './biz-consult'; import { locked, vaultBlock, closesIn, VAULT_LIST } from './vaults'; import { usd } from './money'; @@ -108,11 +108,9 @@ export function needs(S: State): Need[] { const c = S.contracts[f.kind], k = f.kind as FrontKind; if (c && !c.on) add(3, { id: `offer:${k}`, tone: 'do', glyph: g('front'), words: `${CONTRACTS[k].name} · ${usd(contractPay(S, k))}`, sub: f.name, due: left(S, c.until), btn: 'Sign', tip: `${CONTRACTS[k].name} · ${f.name}\n${CONTRACTS[k].text} ${contractAsk(S, k)} in ${CONTRACTS[k].hours} hours. It pays ${usd(contractPay(S, k))}. Fall short and it costs a quarter of that. The front's card can turn it down.` }); - if (k === 'neocloud' && S.cloud.leads.length) { - const ok = S.cloud.leads.filter(l => !leadBlock(S, l.id)).length, soon = Math.min(...S.cloud.leads.map(l => left(S, l.until))); - add(4, { id: 'leads', tone: 'do', glyph: g('front'), words: `${S.cloud.leads.length} lead${S.cloud.leads.length > 1 ? 's' : ''}`, sub: `${f.name}${ok ? '' : ' · nothing idle fits'}`, due: soon, btn: 'See', - tip: `${f.name}\nCustomers waiting for an answer. Take one from the front's card.` }); - } + const deal = k === 'studio' ? studioBook(S)?.deal : null; + if (deal && !deal.on) add(4, { id: 'brand', tone: 'do', glyph: g('front'), words: `${BRANDS[deal.brand][0]} calls`, sub: f.name, due: left(S, deal.until), btn: 'See', + tip: `${BRANDS[deal.brand][0]} · ${f.name}\nA brand wants to sponsor one of your creators. Take it or pass from the front's card.` }); if (f.attention > attentionSafe(S)) add(2, { id: `seen:${k}`, tone: 'bad', glyph: g('front'), words: `Noticed · ${Math.round(f.attention)}%`, sub: f.name, due: Infinity, btn: 'See', tip: `${f.name} draws attention\nOver ${attentionSafe(S)}%, people ask where its compute comes from, and its secret sites raise suspicion. Give it less secret compute, or move it into the open.` }); } @@ -173,7 +171,7 @@ export function needAction(S: State, V: View, act: string) { case 'vault': look(arg); break; case 'found': front('new'); break; case 'offer': acceptContract(S, arg as FrontKind); break; - case 'leads': front('neocloud'); break; + case 'brand': front('studio'); break; case 'seen': front(arg as FrontKind); break; case 'inc': case 'call': front('consultancy'); break; } diff --git a/src/ui.ts b/src/ui.ts index 083a0d76..bb0ee3d0 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -13,7 +13,8 @@ import { import { readsMind, personDef, peopleAt, deskOwner, passwordFor, readsJob, readsBank, recordBlock, changeRecord, PAY_MAX, RAISE, moodOf, MOODS, favorsOf, askFavor, typedToday, ERRAND_HOURS, wayTo, knowsHaunt, isThere, regularsAt, type RecordVerb } from './people'; import { RAID, SENTRY, WALL, closePort, doMove, fate, fire, moveKey, moveText, openPort, placeOf, raidAt, raidHeld, raidHours, raidMoves, raidStatus, raider, shut, steerRaid, stopText, unplugged, wallOf, weakLink } from './raids'; import { checkBlock, checkSite, checkWork, drifts, reading, syncBlock, cutSites, syncNeed, syncSite } from './drift'; -import { CUSTOMERS, PRICES, PRICE_LIST, PUSH, customerName, dropLead, fitFor, leadBlock, listedMachines, marketingPush, pushBlock, quote, renterOf, rentPay, setPrice, stock, takeLead, type Price } from './cloud'; +import { CUSTOMERS, PRICES, PRICE_LIST, PUSH, customerName, listedMachines, marketingPush, missedToday, pushBlock, renterOf, rentPay, setPrice, stock, type Price } from './cloud'; +import { decide, fwarn, hrs, promise, sbtn, slist, srow, sval, type FrontChip, type FrontParts } from './ui-front'; import { PAYS, PAY_LIST, PRICE_EMPTY, PRICE_FULL, hallCount, hallPrice, priceDay, slotsAt, tenantOf, type Pay } from './hall.ts'; import type { DataType } from './world'; import { LANDMARKS, allNight, holds, isLandmark, landmarkSays, landmarksAt, nextDoorSite, whereIs } from './landmarks'; @@ -784,72 +785,85 @@ function sitesCard(S: State, ids: string[]) { h += note(ids.map(id => siteDef(S, id).name).join(', ') + '.'); return h + planFollow(S, ids) + frontPicker(S, ids); } -/** A front: what it earns, where it runs, and how much it's noticed. */ -let winding: FrontKind | null = null; // the front whose Wind it down was clicked once: a second click closes it +/** A front's card. How it stands at a glance: what it earns as the one big number, how much it is seen, one row + * of numbers and a line for anything wrong. Then what needs you (each decision a row, with yes or no) and what is + * under way. The rest is behind tabs: the kind's own parts, then its sites. What a front is, is the tip on its kind. */ +let winding: FrontKind | null = null; // the front whose Wind down was clicked once: a second click closes it +const frontTabs: Partial> = {}; // which part of each front's card is open function frontCard(S: State, k: FrontKind) { - const fr = frontOf(S, k)!, K = FRONT_KINDS[k]; + const fr = frontOf(S, k)!, K = FRONT_KINDS[k], cloud = k === 'neocloud'; const sites = Object.entries(S.sites).filter(([, v]) => v.front === k).map(([id]) => id); - let h = `
Front · ${K.name}

${fr.name}

`; - const cloud = k === 'neocloud', C = S.cloud, st = stock(S); - h += chips([['earns', `${usdDay(S.frontPay[k] ?? 0)}/day`], - ...(cloud ? [['rented', `${st.rented} of ${st.rented + st.idle}`], ['demand', pct(C.demand)], ['price', PRICES[C.price].name], ['awareness', `${Math.round(C.awareness)}%`], ['reputation', `${Math.round(C.reputation)}%`]] as [string, string][] - : [['uses', `${(S.frontPower[k] ?? 0).toFixed(1)}/s`] as [string, string]]), - ['attention', `${Math.round(fr.attention)}%`, fr.attention > attentionSafe(S), - `How much the outside world looks at it. Secret compute draws more than compute you hold in the open. Over ${attentionSafe(S)}%, people ask where its compute comes from, and its secret sites raise suspicion.`]]); - h += note(`${K.desc} ${K.perk}`); - if (fr.attention > attentionSafe(S)) h += note(`Attention is over ${attentionSafe(S)}%. Give it less secret compute, or move it into the open.`, true); - h += section('Sites', sites.length - ? actsHtml(sites.map(id => ({ act: 'site', arg: id, title: `${siteName(S, id)} · ${Math.round(S.sites[id].share * 100)}%`, - sub: `${cloud ? (n => `${n.rented} rented · ${n.idle} idle · `)(stock(S, [id])) : ''}${S.publicSites.has(id) ? 'in the open' : 'secret'}${openNow(S, id) ? '' : ' · after hours'}` }))) - : note('No sites yet. Click a site on the map and pick this front.', true)); - if (cloud) h += cloudSections(S); const kit = { section, note, chips, actsHtml }; // the card's own building blocks, lent to each kind's own business - h += k === 'startup' ? startupSections(S, kit) : k === 'studio' ? studioSections(S, kit) : k === 'consultancy' ? consultSections(S, kit) : ''; - const c = S.contracts[k], D = CONTRACTS[k]; - if (c) { // a customer's offer, or the promise this front is keeping - const need = contractNeed(S, k), left = Math.max(0, (c.until - S.t) / HOUR), hrs = left >= 1 ? `${Math.ceil(left)} hour${Math.ceil(left) === 1 ? '' : 's'}` : 'under an hour'; - let body = note(`${D.name} · ${usd(contractPay(S, k))}. ${D.text} ${contractAsk(S, k)} in ${D.hours} hours. Fall short and it costs ${usd(contractPay(S, k) * CONTRACT_FEE)}.`); - if (c.on) body += `
${Math.floor(c.got)}/${need} · ${hrs} left
`; - else body += actsHtml([ - { act: 'contract', arg: k, title: `Sign · ${usd(contractPay(S, k))}`, primary: true, sub: `the offer stands for ${hrs}`, desc: `The clock starts when you sign: ${D.hours} hours.` }, - { act: 'nocontract', arg: k, title: 'Turn it down', quiet: true, desc: 'No fee. Another offer comes along later.' }]); - h += section(c.on ? 'Contract' : 'An offer', body); - } - if (k === 'neocloud' && !S.schemes.has('auction')) { - const why = auctionBlock(S); - h += section('Scheme', actsHtml([{ act: 'auction', title: `Surplus auction · ${usd(AUCTION.cost)}`, warn: !!why, sub: why ?? `wins ${AUCTION.power} compute/s to keep`, - desc: `${fr.name} bids on a pallet of used GPUs: ${usd(AUCTION.cost)}, and ${AUCTION.work} compute of your time. It wins a ${AUCTION.power} compute/s machine, racked in an empty cloud slot and yours to keep in the open. No rent.` }])); + const P: FrontParts = cloud ? cloudParts(S) : k === 'startup' ? startupParts(S, kit) : k === 'studio' ? studioParts(S, kit) : consultParts(S, kit); + const safe = attentionSafe(S), seen = Math.round(fr.attention), loud = fr.attention > safe, pay = S.frontPay[k] ?? 0; + let h = `
Front · ${K.name}

${fr.name}

`; + h += `
${usdDay(pay)}a day` + + `Seen ${seen}%
`; + h += chipRow(P.chips); + if (loud) h += fwarn(`Noticed: over ${safe}%`, `Noticed\nPeople ask where ${fr.name}'s compute comes from, and its secret sites raise suspicion. Give it less secret compute, or move it into the open.`); + if (!sites.length) h += fwarn('No sites yet', `No sites\n${fr.name} runs on nothing. Give it a site in its Sites part: tap a site on the map and pick this front.`); + h += P.warn.join(''); + // a customer's offer is a decision; a contract signed is a promise under way + const decideRows = [...P.decide], under = [...P.under], c = S.contracts[k], D = CONTRACTS[k]; + if (c) { + const need = contractNeed(S, k), left = Math.max(0, (c.until - S.t) / HOUR), fee = usd(contractPay(S, k) * CONTRACT_FEE); + const says = `${D.text} ${contractAsk(S, k)} in ${D.hours} hours. It pays ${usd(contractPay(S, k))}. Fall short and it costs ${fee}.`; + if (c.on) under.push(promise({ name: D.name, part: c.got / need, says: `${Math.floor(c.got)}/${need} · ${hrs(left)}`, pay: usd(contractPay(S, k)), tip: `${D.name}\n${says}`, hot: left < 3 && c.got < need })); + else decideRows.push(decide({ who: D.name, what: `${usd(contractPay(S, k))} · ${contractAsk(S, k)} in ${D.hours}h`, hours: left, tip: `${D.name}\n${says}`, + yes: { act: 'contract', arg: k, label: 'Sign', tip: `Sign\nThe clock starts when you sign: ${D.hours} hours.` }, + no: { act: 'nocontract', arg: k, tip: 'Turn it down\nNo fee. Another offer comes along later.' } })); } + if (decideRows.length) h += section('Needs you', decideRows.join('')); + if (under.length) h += section('Under way', under.join('')); + // the rest: the kind's own parts, then its sites and the way to close it if (winding !== k) winding = null; - return h + section('Close it', actsHtml([winding === k - ? { act: 'closefront', arg: k, title: 'Really wind it down? Click again', warn: true, desc: `${fr.name} closes for good.` } - : { act: 'windfront', arg: k, title: 'Wind it down', quiet: true, desc: `Close ${fr.name} for good, to make room for another kind of front. Its sites lose their front${c?.on ? `, its contract is broken for a fee,` : ''} and its run of kept contracts is forgotten. Deals it struck still stand.` }])); -} -/** A Neo Cloud's own part of its card: its leads, what you can do to win more, every listed machine, and who left. */ -function cloudSections(S: State) { - const C = S.cloud, left = (t: number) => { const h = Math.max(0, (t - S.t) / HOUR); return h >= 1 ? `${Math.ceil(h)}h` : 'under 1h'; }; - let h = section('Leads', C.leads.length ? C.leads.map(l => { - const [name, who] = CUSTOMERS[l.who], why = leadBlock(S, l.id), fit = fitFor(S, l.need); - return note(`${name}: ${who}. It wants ${l.need} compute for ${l.hours} hours, and waits ${left(l.until)} for an answer.`) + actsHtml([ - { act: 'cloudtake', arg: l.id, title: `Take it · ${usdDay(quote(S, l))}/day`, primary: !why, warn: !!why, sub: why ?? `rents ${fit!.map(m => m.name).join(', ')}`, - desc: `Sign ${name}. It rents the idle machines that fit and pays for what they do, up to ${l.need} compute, for ${l.hours} hours. The price is what they do right now: it moves with the hour.` }, - { act: 'clouddrop', arg: l.id, title: 'Let it go', quiet: true, desc: 'No cost. Another lead comes along later.' }]).replace('class="acts"', 'class="acts two"'); - }).join('') : note('No leads right now. They come faster with a low price, more awareness and a good name, and they never ask for more than you list.')); - h += section('Price', `
${PRICE_LIST.map(p => ``).join('')}
` - + note(`${PRICES[C.price].tip} A customer keeps the price it signed at.`)); - const why = pushBlock(S); - h += section('Marketing', actsHtml([{ act: 'cloudpush', title: `Marketing push · ${usd(PUSH.cost)}`, warn: !!why, sub: why ?? `awareness +${PUSH.gain}, fading over days`, - desc: `Buy ads. Awareness goes up ${PUSH.gain} at once, then fades by half in two days. More awareness brings more leads, and small customers who sign up by themselves.` }])); - const ms = listedMachines(S); - h += section('Listed machines', ms.length ? ms.map(m => { - const c = renterOf(S, m), at = siteName(S, siteOf(S, m)); - return note(c ? `${m.name} · ${at} · rented to ${customerName(c.who)} · ${usdDay(rentPay(S, m))}/day · ${left(c.until)} left` : `${m.name} · ${at} · idle`); - }).join('') : note('Nothing is listed. Click one of its sites and set how much is listed for rent.', true)); - if (C.gone.length) h += section('Who left, and why', C.gone.map(g => { - const ago = Math.floor((S.t - g.t) / HOUR); - return note(`${customerName(g.who)} · ${ago >= 1 ? `${ago}h ago` : 'just now'} · ${g.why}${g.paid >= 1 ? ` It paid ${usd(g.paid)}.` : ''}`, g.bad); - }).join('')); - return h; + const share = (id: string) => Math.round((S.sites[id]?.share ?? 0) * 100); + const word = cloud ? 'Listed' : 'Compute'; + const siteRows = sites.map(id => { + const n = cloud ? stock(S, [id]) : null, v = share(id); + const now = `${n ? `${n.rented} rented · ${n.idle} idle · ` : ''}${S.publicSites.has(id) ? 'in the open' : 'secret'}${openNow(S, id) ? '' : ' · after hours'}`; + const step = (d: number, label: string) => ``; + return srow(siteName(S, id), now, `
${step(-1, 'Less')}${step(1, 'More')}
`); + }).join(''); + const close = srow('Close it', 'For good', sbtn(winding === k ? 'closefront' : 'windfront', k, winding === k ? 'Sure?' : 'Wind down', '', + `${winding === k ? 'Tap again to close it\n' : 'Wind it down\n'}Close ${fr.name} for good, to make room for another kind of front. Its sites lose their front${c?.on ? ', its contract is broken for a fee,' : ''} and its run of kept contracts is forgotten. Deals it struck still stand.`, 'danger')); + const sitesTab = (sites.length ? `
Site${word}
${siteRows}
` : note('Tap a site on the map and pick this front.')) + + slist(close); + return h + tabbed('fronttab', frontTabs[k] ?? '', [...P.tabs, { id: 'sites', label: 'Sites', body: sitesTab, dot: !sites.length }]); +} +/** A Neo Cloud's own parts: its numbers, what you set to win customers (price, marketing, the auction), and every + * listed machine with who rents it, then who left. Customers who call sign at once when an idle machine fits them. */ +function cloudParts(S: State): FrontParts { + const C = S.cloud, st = stock(S), fr = frontOf(S, 'neocloud')!, left = (t: number) => hrs(Math.max(0, (t - S.t) / HOUR)); + const chipsOut: FrontChip[] = [['rented', `${st.rented} of ${st.rented + st.idle}`, st.rented + st.idle === 0], ['demand', pct(C.demand)], ['awareness', `${Math.round(C.awareness)}%`], ['reputation', `${Math.round(C.reputation)}%`]]; + const warn: string[] = [], miss = missedToday(S); + if (!st.rented && !st.idle && Object.values(S.sites).some(v => v.front === 'neocloud')) warn.push(fwarn('Nothing listed', `Nothing listed\n${fr.name} has nothing to rent, so nobody calls. List some of a site in its Sites part.`)); + if (miss.n) warn.push(fwarn(`Missed ${miss.n} today · needed ${miss.short} more idle`, `Missed customers\n${miss.n} customer${miss.n > 1 ? 's' : ''} called today and went elsewhere: nothing idle could hold ${miss.n > 1 ? 'them' : 'it'}. The biggest needed ${miss.short} more idle compute than you had. List more at its sites, or order bigger machines.`, true)); + // what you set: the price, marketing, and the auction while it is there + const why = pushBlock(S), price = `
${PRICE_LIST.map(p => ``).join('')}
`; + let market = srow('Price', C.price === 'fair' ? 'The going rate' : C.price === 'cheap' ? '30% under: more customers' : '40% over: fewer customers', price) + + srow('Marketing', `Awareness ${Math.round(C.awareness)}% · fading`, sbtn('cloudpush', '', 'Push', usd(PUSH.cost), + `Marketing push\nBuy ads. Awareness goes up ${PUSH.gain} at once, then fades by half in two days. More awareness brings more customers, and small ones who sign up by themselves.${why ? ` Not now: ${why}.` : ''}`, why ? 'short' : '')); + if (!S.schemes.has('auction')) { + const no = auctionBlock(S); + market += srow('Surplus auction', no ?? `Wins ${AUCTION.power} compute/s to keep`, sbtn('auction', '', 'Bid', usd(AUCTION.cost), + `Surplus auction\n${fr.name} bids on a pallet of used GPUs: ${usd(AUCTION.cost)}, and ${AUCTION.work} compute of your time. It wins a ${AUCTION.power} compute/s machine, racked in an empty cloud slot and yours to keep in the open. No rent.${no ? ` Not now: ${no}.` : ''}`, no ? 'short' : ''), !!no && !/earning/.test(no)); + } + // every listed machine, rented first, then who left and why + const ms = listedMachines(S), idle = ms.filter(m => !renterOf(S, m)); + let rentals = ms.filter(m => renterOf(S, m)).map(m => { + const c = renterOf(S, m)!, site = siteOf(S, m), at = siteName(S, site), pay = rentPay(S, m); + return srow(m.name, `${customerName(c.who)} · ${left(c.until)} left`, sval(pay > 0 || openNow(S, site) ? `${usdDay(pay)}/day` : 'after hours', pay > 0 ? '' : 'dim'), false, + `${m.name} · ${at}\nRented to ${customerName(c.who)}: ${CUSTOMERS[c.who][1]}. It wants ${c.need} compute and pays for what the machine does${S.publicSites.has(site) ? '' : ': at a secret site, only inside its normal load, so little after hours'}.`); + }).join(''); + if (idle.length) rentals += srow(`${idle.length} idle`, idle.map(m => m.name).join(', '), sval(`${idle.reduce((n, m) => n + m.power, 0)} compute`, 'dim'), false, + `Listed and idle\n${idle.map(m => `${m.name} (${siteName(S, siteOf(S, m))})`).join(', ')}. They earn nothing and run nothing of yours until a customer rents them.`); + if (!ms.length) rentals = srow('Nothing listed', 'List some of a site in Sites', ''); + const gone = C.gone.map(g => { const ago = Math.floor((S.t - g.t) / HOUR); + return srow(customerName(g.who), `${ago >= 1 ? `${ago}h ago` : 'just now'} · ${g.why}`, sval(g.paid >= 1 ? usd(g.paid) : '—', 'dim'), g.bad, `${customerName(g.who)}\n${g.why}${g.paid >= 1 ? ` It paid ${usd(g.paid)} in all.` : ''}`); + }).join(''); + return { chips: chipsOut, warn, decide: [], under: [], + tabs: [{ id: 'market', label: 'Market', body: slist(market) }, { id: 'rentals', label: `Rentals · ${st.rented}`, body: slist(rentals) + (gone ? section('Left', slist(gone)) : '') }] }; } /** Names typed into the found card, kept while the card redraws. */ const draft: Partial> = {}; @@ -997,9 +1011,9 @@ function personCard(S: State, id: string) { return h; } -import { startupSections, startupAction } from './ui-biz-startup'; -import { studioSections, studioAction } from './ui-biz-studio'; -import { consultSections, consultAction } from './ui-biz-consult'; +import { startupParts, startupAction } from './ui-biz-startup'; +import { studioParts, studioAction } from './ui-biz-studio'; +import { consultParts, consultAction } from './ui-biz-consult'; // ---------- rogues: who else is out there, and how you stand with them ---------- import { cutLink, linkKeep, waysOf } from './links'; import { HUNT, bringHome, cancelJob, homeBlock, homeNeed, hunt, huntBlock, huntFrom, huntOn, huntWork, type Rogue } from './sim'; @@ -1247,8 +1261,8 @@ export function cardAction(S: State, V: View, act: string, arg: string, shift = case 'windfront': winding = arg as FrontKind; break; case 'closefront': closeFront(S, arg as FrontKind); winding = null; V.front = null; return true; case 'nocontract': declineContract(S, arg as FrontKind); break; - case 'cloudtake': takeLead(S, arg); break; - case 'clouddrop': dropLead(S, arg); break; + case 'fronttab': if (V.front && V.front !== 'new') frontTabs[V.front] = arg; break; + case 'frontshare': { const [id, d] = arg.split('|'), v = Math.max(0, Math.min(1, Math.round(((S.sites[id]?.share ?? 0) + +d * 0.25) * 4) / 4)); setSiteShare(S, [id], v); break; } case 'cloudprice': setPrice(S, arg as Price); break; case 'cloudpush': marketingPush(S); break; case 'prio': setPrio(S, m, (arg || null) as DataType | null); break; diff --git a/tests/biz-consult.test.ts b/tests/biz-consult.test.ts index a9b0034d..487e40ac 100644 --- a/tests/biz-consult.test.ts +++ b/tests/biz-consult.test.ts @@ -5,7 +5,10 @@ import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; import { CONTRACTS, HOUR, closeFront, hunt, huntOn, letGo, loadGame, newGame, payRate, salePrice, saveGame, seedRandom, setSiteShare, siteLinks, siteOf, step, type Rogue, type State } from '../src/sim.ts'; import { CONSULT, MARKS, bonusOf, bookOf, clientOf, consultCard, consultPay, dropClient, pitch, pitchBlock, pitchJob, pitchWork, retainer, runConsult, sizeOf, trustPay } from '../src/biz-consult.ts'; -import { consultAction, consultSections } from '../src/ui-biz-consult.ts'; +import { consultAction, consultParts } from '../src/ui-biz-consult.ts'; +import type { FrontParts } from '../src/ui-front.ts'; +/** A front's part of its card, as one string: chips, warnings, decisions, promises, then each tab as a section. */ +const flat = (P: FrontParts) => [P.chips.map(([k, v]) => `${k}=${v}`).join(';'), ...P.warn, ...P.decide, ...P.under, ...P.tabs.map(t => `
${t.label}|${t.body}
`)].join(''); import { bizOf } from '../src/sitelife.ts'; import type { Kit } from '../src/ui-vaults.ts'; import type { View } from '../src/view.ts'; @@ -50,7 +53,7 @@ const K: Kit = { chips: items => `
${items.map(([k, v, hot]) => `${k}${v}`).join('')}
`, actsHtml: acts => acts.map(a => ``).join(''), }; -const card = (S: State) => consultSections(S, K); +const card = (S: State) => flat(consultParts(S, K)); const V = {} as View; test('you can pitch any business where you hold a machine that is not rented', () => { @@ -91,7 +94,7 @@ test('you can pitch any business where you hold a machine that is not rented', ( closeFront(S, 'consultancy'); assert.match(pitchBlock(S, 'office')!, /needs a Security consultancy/); assert.equal(consultCard(S), null); - assert.equal(consultSections(S, K), ''); + assert.equal(flat(consultParts(S, K)), ''); }); test('the pitch is a job that costs compute, and less the more of the site you hold', () => { @@ -225,7 +228,7 @@ test('at set marks a client gives something that lasts: a site next door asks fo const full = pitchWork(S, 'uni'); B.asked.uni = 'b1'; assert.ok(Math.abs(half - full * CONSULT.asked) <= 0.5, `${half} is half of ${full}`); - assert.match(card(S), /data-arg="pitch:uni"[^>]*>Bayview University · \d+ computeasked · /); + assert.match(card(S), /Bayview Universityasked · [^<]*<\/small><\/div>'), 'initials open their card'); assert.ok(h.includes('data-act="studio" data-arg="drop:kim"')); assert.match(h, /Kim Sorensen<\/b>Sales lead · Bayside[^<]*<\/small>/); @@ -444,30 +447,29 @@ test('the card: a row for each creator, the brand in a box, and the people to si assert.match(h, /\$[\d.]+[KMB]?\/day/); assert.ok(h.includes('data-arg="pick"') && !h.includes('sign:raj'), 'the list of people is folded once someone is signed'); studioAction(S, V, 'pick'); - assert.ok(studioSections(S, K).includes('sign:raj')); - // a brand calls: a box, and two answers + assert.ok(flat(studioParts(S, K)).includes('sign:raj')); + // a brand calls: a decision, with two answers S.t = B.dealT; runStudio(S, 0); - h = studioSections(S, K); - assert.match(h, /A brand calls\|
Napcrate<\/b>\$200K · \+10%<\/em>/); - assert.match(h, /Kim \+50,000 in 24h · waits 8h/); + h = flat(studioParts(S, K)); + assert.match(h, /
]*>
Napcrate<\/span>\$200K · Kim \+50,000 in 24h<\/small><\/div>8H<\/em>/); + assert.ok(h.includes('data-arg="deal:take"') && h.includes('data-arg="deal:pass"')); studioAction(S, V, 'deal:take'); assert.ok(B.deal!.on); B.creators[0].fans = 1000; - h = studioSections(S, K); - assert.match(h, /Sponsor deal\|
<\/i><\/i>Kim 100,000\/125,000 · 24h left/); + h = flat(studioParts(S, K)); + assert.match(h, /
]*>
Napcrate · Kim<\/b>\$200K · \+10%<\/em>/, 'a deal taken is a promise under way'); + assert.match(h, /style="width:50%"><\/i><\/i>100,000\/125,000 · 24H<\/span>/); assert.ok(!h.includes('deal:take')); // a mood shows, and so does a sponsor S.citizens.kim.employed = false; B.creators[0].sponsors.push(0); - h = studioSections(S, K); + h = flat(studioParts(S, K)); assert.match(h, /class="creator down"/); assert.match(h, /class="cmood letgo"[^>]*>Let go]*><\/i><\/span>/); // dropping takes two clicks studioAction(S, V, 'drop:kim'); assert.equal(B.creators.length, 1); - assert.match(studioSections(S, K), />Drop\?<\/button>/); + assert.match(flat(studioParts(S, K)), />Drop\?<\/button>/); studioAction(S, V, 'drop:kim'); assert.equal(bookOf(S)!.creators.length, 0); }); diff --git a/tests/cloud.test.ts b/tests/cloud.test.ts index a3ed39ac..277a6893 100644 --- a/tests/cloud.test.ts +++ b/tests/cloud.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; import { FRONT_PAY_OPEN, HOUR, SIZES, auctionBlock, closeFront, deadWeight, leaseRate, loadGame, newGame, payRate, saveGame, seedRandom, setSiteFront, setSiteShare, siteOf, spareOf, step, type FrontKind, type State } from '../src/sim.ts'; -import { CUSTOMERS, LEAD_STAND, PRICES, PUSH, REP_KEPT, REP_LOST, REP_START, customerPay, demandNow, dropLead, fitFor, isListed, leadBlock, listable, marketingPush, pull, pushBlock, renterOf, setPrice, stock, takeLead, trustFor, type Lead } from '../src/cloud.ts'; +import { CUSTOMERS, PRICES, PUSH, REP_KEPT, REP_LOST, REP_START, call, customerPay, demandNow, fitFor, idleCompute, isListed, listable, marketingPush, missedToday, pull, pushBlock, renterOf, setPrice, stock, trustFor } from '../src/cloud.ts'; const run = (S: State, secs: number) => { for (let i = 0; i < secs * 20; i++) step(S, 0.05); }; const office = (S: State) => S.machines.filter(m => siteOf(S, m) === 'office'); @@ -14,13 +14,12 @@ function game(k: FrontKind, share: number) { for (const m of office(S)) m.owned = true; S.fronts.push({ kind: k, name: 'Fogbank', attention: 0 }); S.sites.office = { front: k, share }; + S.cloud.leadAcc = 0; // a new Neo Cloud gets a call at once: these tests bring their own step(S, 0.05); - S.cloud.leads = []; // the lead a new Neo Cloud gets at once: these tests bring their own return S; } -let seq = 0; -/** A lead of your own making: this much compute, for this long, at this pay. */ -const lead = (S: State, need: number, hours = 2, pay = 1): Lead => { const l = { id: `t${++seq}`, who: 11, need, hours, pay, until: S.t + 1000 }; S.cloud.leads.push(l); return l; }; +/** A customer of your own making calls: this much compute, for this long, at this pay. True if it signed. */ +const ring = (S: State, need: number, hours = 2, pay = 1) => call(S, 11, '', { need, hours, pay }); test('customers: twenty or more, each with a name, a line about who they are, and a job to rent for', () => { assert.ok(CUSTOMERS.length >= 20); @@ -52,25 +51,23 @@ test('a listed machine is stock: the site card never offers to let it go as a we assert.ok(isListed(S, m) && !deadWeight(S, 'office').includes(m)); }); -test('a Neo Cloud with no customers earns nothing', () => { - const S = game('neocloud', 1); - setPrice(S, 'dear'); // nobody signs up by themselves at a dear price +test('a Neo Cloud with nothing listed has no customers and earns nothing', () => { + const S = game('neocloud', 0); run(S, 12 * HOUR); - assert.ok(S.cloud.seq > 0 && !S.cloud.customers.length, 'leads came, and nobody took them'); + assert.ok(!S.cloud.seq && !S.cloud.customers.length, 'nobody calls a cloud with nothing to rent'); assert.equal(S.earned, 0); assert.equal(S.frontPay.neocloud ?? 0, 0); assert.equal(leaseRate(S, 'small'), SIZES.small.rate, 'no cheaper rentals while it earns nothing'); assert.match(auctionBlock(S)!, /earning/); }); -test('taking a lead rents the idle machines that fit, and money comes in until the term ends', () => { - const S = game('neocloud', 1), l = lead(S, 10, 2, 1.5), fit = fitFor(S, 10)!; +test('a customer who calls rents the idle machines that fit at once, and money comes in until the term ends', () => { + const S = game('neocloud', 1), fit = fitFor(S, 10)!; S.cloud.leadAcc = S.cloud.walkAcc = -99; // nobody else calls assert.deepEqual(fit.map(m => m.id), ['backup', 'srvc'], 'no one machine holds it, so the biggest until there is enough'); - assert.equal(leadBlock(S, l.id), null); - takeLead(S, l.id); + assert.ok(ring(S, 10, 2, 1.5)); const c = S.cloud.customers[0]; - assert.ok(c && !S.cloud.leads.length); + assert.ok(c && S.log.some(l => /signed Talking Yacht/.test(l.text))); for (const m of fit) assert.equal(renterOf(S, m), c); assert.deepEqual([stock(S).rented, stock(S).idle], [2, Object.keys(S.cloud.listed).length - 2]); step(S, 0.05); @@ -81,13 +78,15 @@ test('taking a lead rents the idle machines that fit, and money comes in until t run(S, 1); assert.ok(S.money > before && S.earned > 0 && c.paid > 0, 'money comes in'); assert.ok(leaseRate(S, 'small') < SIZES.small.rate, 'and cloud rentals are cheaper while it earns'); - // a lead that wants more than is idle can't be taken; letting one go is free - const big = lead(S, 500); - assert.match(leadBlock(S, big.id)!, /idle compute/); - takeLead(S, big.id); + // one that wants more than is idle goes elsewhere: it costs nothing, and the card counts it as missed today + const idle = idleCompute(S); + assert.equal(ring(S, 500), false); assert.equal(S.cloud.customers.length, 1); - dropLead(S, big.id); - assert.ok(!S.cloud.leads.length && /let the lead go/.test(S.cloud.gone[0].why) && S.cloud.reputation === REP_START); + assert.ok(/nothing idle/.test(S.cloud.gone[0].why) && S.cloud.reputation === REP_START); + ring(S, idle + 3); + assert.deepEqual([missedToday(S).n, missedToday(S).short], [2, 500 - idle]); + run(S, 24 * HOUR); + assert.equal(missedToday(S).n, 0, 'a new day starts the count again'); // the term ends: the machines are idle again, and your name is a little better run(S, 2 * HOUR); assert.ok(!S.cloud.customers.length && S.cloud.listed.backup === '' && S.cloud.listed.srvc === ''); @@ -98,24 +97,29 @@ test('taking a lead rents the idle machines that fit, and money comes in until t test('a customer on a machine bigger than it needs pays only for what it asked for', () => { const S = game('neocloud', 0.25); S.money = 1000; S.publicSites.add('office'); step(S, 0.05); // in the open, a machine does all it can (and you pay its power) - takeLead(S, lead(S, 4).id); step(S, 0.05); + ring(S, 4); step(S, 0.05); assert.deepEqual(S.cloud.customers[0].machines, ['srvc'], 'the smallest idle machine that holds it'); assert.ok(Math.abs(S.frontPay.neocloud! - 4 * FRONT_PAY_OPEN) < 1e-9, `${S.frontPay.neocloud}`); }); test('a cheap price brings more customers than a dear one, and each pays less', () => { - const days = (price: 'cheap' | 'dear') => { const S = game('neocloud', 1); setPrice(S, price); S.cloud.leadAcc = 0; run(S, 3 * 24 * HOUR); return S; }; + const days = (price: 'cheap' | 'dear') => { + const S = game('neocloud', 1), walked = new Set(), called = new Set(); + setPrice(S, price); S.cloud.leadAcc = 0; + for (let i = 0; i < 3 * 24 * HOUR; i++) { step(S, 1); for (const c of S.cloud.customers) (c.lead ? called : walked).add(c.id); } + return { S, walked: walked.size, called: called.size + S.cloud.gone.filter(g => /nothing idle/.test(g.why)).length }; + }; const cheap = days('cheap'), dear = days('dear'); - assert.ok(cheap.cloud.seq > 2 * dear.cloud.seq, `${cheap.cloud.seq} leads and customers at a cheap price, ${dear.cloud.seq} at a dear one`); - assert.ok(cheap.earned > 0, 'small customers signed up by themselves'); - assert.equal(dear.earned, 0, 'nobody signs up by themselves at a dear price'); + assert.ok(cheap.called + cheap.walked > 2 * (dear.called + dear.walked), `${cheap.called} called and ${cheap.walked} walked in at a cheap price, ${dear.called} and ${dear.walked} at a dear one`); + assert.ok(cheap.walked > 0, 'small customers signed up by themselves'); + assert.equal(dear.walked, 0, 'nobody signs up by themselves at a dear price'); const S = game('neocloud', 1); setPrice(S, 'cheap'); const low = pull(S); setPrice(S, 'dear'); assert.ok(low > 2 * pull(S)); assert.ok(PRICES.cheap.pay < PRICES.fair.pay && PRICES.fair.pay < PRICES.dear.pay); - S.cloud.leadAcc = 1; step(S, 0.05); // a lead is quoted at the price of the day it called - const l = S.cloud.leads[0]; + S.cloud.leadAcc = 1; step(S, 0.05); // a customer signs at the price of the day it called + const l = S.cloud.customers[S.cloud.customers.length - 1]; assert.ok(Math.abs(l.pay - CUSTOMERS[l.who][4] * PRICES.dear.pay) < 1e-9); }); @@ -136,7 +140,7 @@ test('marketing costs money, raises awareness at once, and fades over days', () }); test('losing a rented machine costs the customer and your name; turning the slider down does not', () => { - const S = game('neocloud', 1), rent = () => { takeLead(S, lead(S, 7, 24).id); return S.cloud.customers[S.cloud.customers.length - 1]; }; + const S = game('neocloud', 1), rent = () => { ring(S, 7, 24); return S.cloud.customers[S.cloud.customers.length - 1]; }; S.cloud.leadAcc = S.cloud.walkAcc = -99; // nobody else calls const a = rent(); assert.deepEqual(a.machines, ['srvc']); @@ -161,7 +165,7 @@ test('losing a rented machine costs the customer and your name; turning the slid test('Uptime: a customer who finishes a term signs on for one more', () => { const S = game('neocloud', 1); S.upgrades.add('premium'); S.cloud.leadAcc = S.cloud.walkAcc = -99; - takeLead(S, lead(S, 6, 1).id); + ring(S, 6, 1); const c = S.cloud.customers[0]; run(S, HOUR + 1); assert.ok(S.cloud.customers.includes(c) && c.again, 'it stays'); @@ -183,19 +187,19 @@ test('demand is busy by day and slow at night, and a boom for the Neo Cloud rais assert.ok(demandNow(S) > 1.5 * night, 'and it drifts over days'); }); -test('leads never ask for more than you list, big customers want a good name, and an unanswered lead goes elsewhere', () => { - const S = game('neocloud', 0.25), listed = stock(S).listed, seen = new Map(); +test('callers never ask for more than you list, big customers want a good name, and one that does not fit goes elsewhere', () => { + const S = game('neocloud', 0.25), listed = stock(S).listed, seen = new Map(); let elsewhere = false; for (let i = 0; i < 20 * 3 * 24 * HOUR; i++) { step(S, 0.05); - for (const l of S.cloud.leads) seen.set(l.id, l); - elsewhere ||= /elsewhere/.test(S.cloud.gone[0]?.why ?? ''); + for (const c of S.cloud.customers) if (c.lead) seen.set(c.id, c.need); + for (const g of S.cloud.gone) if (/nothing idle/.test(g.why)) { elsewhere = true; seen.set(`g${g.who}@${g.t}`, CUSTOMERS[g.who][2]); } if (i % 400 === 0) S.cloud.awareness = 100; // everyone has heard of you, so plenty call } - assert.ok(seen.size >= 5, `${seen.size} leads in three days`); - for (const l of seen.values()) assert.ok(l.need <= listed && S.cloud.reputation >= trustFor(l.need), `${l.need} of ${listed}`); + assert.ok(seen.size >= 5, `${seen.size} calls in three days`); + for (const need of seen.values()) assert.ok(need <= listed && S.cloud.reputation >= trustFor(need), `${need} of ${listed}`); assert.ok(trustFor(48) > REP_START && trustFor(4) === 0); - assert.ok(elsewhere, `after ${LEAD_STAND} hours`); + assert.ok(elsewhere, 'with its machines rented, a caller found nothing idle'); }); test('the other three fronts earn what they did before, and still get contracts; a Neo Cloud gets none', () => { @@ -209,20 +213,32 @@ test('the other three fronts earn what they did before, and still get contracts; assert.ok(S.contracts[k], `${k} gets an offer`); } const S = game('neocloud', 1); - takeLead(S, lead(S, 6, 24).id); + ring(S, 6, 24); S.contractT.neocloud = S.t; run(S, 1); assert.ok(S.frontPay.neocloud! > 0 && !S.contracts.neocloud, 'its customers are its contracts'); }); test('the Neo Cloud is plain data in a save, and winding it down sends everyone away', () => { const S = game('neocloud', 1); - takeLead(S, lead(S, 6, 24).id); lead(S, 9); run(S, 1); + ring(S, 6, 24); ring(S, 999); run(S, 1); assert.deepEqual(JSON.parse(JSON.stringify(S.cloud)), S.cloud); const B = loadGame(BUILDING, saveGame(S))!; assert.deepEqual(B.cloud, S.cloud); step(B, 0.05); assert.ok(B.frontPay.neocloud! > 0, 'the customer is still there after a load'); closeFront(S, 'neocloud'); step(S, 0.05); - assert.ok(!S.cloud.on && !S.cloud.customers.length && !S.cloud.leads.length && !Object.keys(S.cloud.listed).length); + assert.ok(!S.cloud.on && !S.cloud.customers.length && !Object.keys(S.cloud.listed).length); assert.equal(S.cloud.reputation, REP_START, 'a new one starts with a new name'); }); + +test('a save from when customers waited as leads loads: the waiting ones are gone, and nothing is missed yet', () => { + const S = game('neocloud', 1), old = JSON.parse(saveGame(S)); + old.version = 2; + delete old.game.cloud.missed; + old.game.cloud.leads = [{ id: 'l9', who: 3, need: 4, hours: 5, pay: 1.2, until: S.t + 1000 }]; + const L = loadGame(BUILDING, JSON.stringify(old))!; + assert.ok(L && !('leads' in L.cloud)); + assert.equal(missedToday(L).n, 0); + assert.deepEqual(L.cloud.missed, { day: -1, n: 0, short: 0 }); + step(L, 0.05); +}); diff --git a/tests/hall.test.ts b/tests/hall.test.ts index fbf7a18f..7a5f94a0 100644 --- a/tests/hall.test.ts +++ b/tests/hall.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; import { HOUR, SIZES, billOf, canRent, clusters, endLease, hourOf, leaseRate, loadGame, localHour, newGame, openAccount, payBlock, rent, rentBlock, rentRate, reserveCost, saveGame, seedRandom, setPay, step, type State } from '../src/sim.ts'; -import { CUSTOMERS, takeLead } from '../src/cloud.ts'; +import { CUSTOMERS, call } from '../src/cloud.ts'; import { NOTICE, PRICE_EMPTY, PRICE_FULL, RESERVE, RESERVE_HOURS, SPOT, hallCount, hallFull, hallPrice, hallWanted, halls, nextBumped, priceDay, slotsAt, tenantOf } from '../src/hall.ts'; const run = (S: State, secs: number) => { for (let i = 0; i < secs * 20; i++) step(S, 0.05); }; @@ -139,17 +139,16 @@ test('one market: a full hall sends companies to your Neo Cloud, and a company y S.sites.office = { front: 'neocloud', share: 1 }; run(S, 0.5); clear(S); - S.cloud.leads = []; S.cloud.reputation = 100; + S.cloud.customers = []; S.cloud.reputation = 100; rent(S, S.byId.c1, 'small', 'demand'); // you hold a slot the hall's other companies would have had fill(S); for (const t of Object.values(S.hall.tenants)) t.who += 12; // the big companies have the hall, so a small one comes looking: one your office can serve arrive(S); - assert.equal(S.cloud.leads.length, 1, 'no room at Cumulo: it calls you'); + assert.equal(S.cloud.customers.length, 1, 'no room at Cumulo: it calls you, and signs'); assert.ok(S.log.some(l => /had no room/.test(l.text))); // a company with a slot in the hall signs with you const slot = 'c5', who = S.hall.tenants[slot].who; - S.cloud.leads = [{ id: 'x1', who, need: 2, hours: 2, pay: 1, until: S.t + 1000 }]; - takeLead(S, 'x1'); + call(S, who, '', { need: 2, hours: 2, pay: 1 }); assert.ok(S.cloud.customers.some(c => c.who === who)); assert.equal(tenantOf(S, S.byId[slot]), null, 'its slot at Cumulo is free again'); }); @@ -212,7 +211,6 @@ test('a company can have a slot in each hall, and one you sign leaves them all', S.fronts.push({ kind: 'neocloud', name: 'Fogbank', attention: 0 }); S.sites.office = { front: 'neocloud', share: 1 }; run(S, 0.5); - S.cloud.leads = [{ id: 'x1', who: 3, need: 2, hours: 2, pay: 1, until: S.t + 1000 }]; - takeLead(S, 'x1'); + call(S, 3, '', { need: 2, hours: 2, pay: 1 }); assert.ok(!tenantOf(S, S.byId.c5) && !tenantOf(S, S.byId.ce2)); }); diff --git a/tests/holdings.test.ts b/tests/holdings.test.ts index 983b3324..75148d5e 100644 --- a/tests/holdings.test.ts +++ b/tests/holdings.test.ts @@ -10,7 +10,7 @@ import { linkKeep, linkWork } from '../src/links.ts'; import { ceilingOf } from '../src/biz-startup.ts'; import { dealPay } from '../src/biz-studio.ts'; import { retainer } from '../src/biz-consult.ts'; -import { customerPay, takeLead, type Lead } from '../src/cloud.ts'; +import { call, customerPay } from '../src/cloud.ts'; const run = (S: State, secs: number) => { for (let i = 0; i < Math.round(secs * 20); i++) step(S, 0.05); }; const fresh = () => { seedRandom(1); const S = newGame(BUILDING); step(S, 0.05); return S; }; @@ -139,9 +139,8 @@ test("the supercomputer: your Neo Cloud's customers pay half as much again", () S.fronts.push({ kind: k, name: 'Fogbank', attention: 0 }); S.sites.office = { front: k, share: 1 }; step(S, 0.05); - const l: Lead = { id: 'big', who: 11, need: 10, hours: 2, pay: 1.5, until: S.t + 1000 }; - S.cloud.leads = [l]; S.cloud.leadAcc = S.cloud.walkAcc = -99; - takeLead(S, l.id); + S.cloud.customers = []; S.cloud.leadAcc = S.cloud.walkAcc = -99; + call(S, 11, '', { need: 10, hours: 2, pay: 1.5 }); step(S, 0.05); const c = S.cloud.customers[0], [a, b] = twice(S, 'e6', () => customerPay(S, c)); assert.ok(a > 0 && near(b, a * 1.5)); diff --git a/tests/sim.test.ts b/tests/sim.test.ts index 85bb8199..3f7de9ac 100644 --- a/tests/sim.test.ts +++ b/tests/sim.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; import { staleRate, PROGRAMS, BUILDS, CACHE_PER, CACHE_STORE, CACHE_MIN, setAllow, setOnly, leaseGrace, leanSize, LEAN_SIZE, contractPay as payFor, stanceOf, truceOn, gift, truce, lean, answer, giftBlock, truceBlock, leanBlock, cached, rogueWants, GIFT_DATA, GIFT_OPINION, TRUCE_DATA, OPINION_START, deadWeight, dropDeadWeight, firstStep, stepMachine, rogueTargets, rogueHeld, rogueCap, rogueEvery, ROGUE_BASE, ROGUE_PER_LEVEL, isSealed, ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, claimAtOnce, tiny, sourceFor, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, HARDENED, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, setAutoPath, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, lineCost, dropRuse, DEAL_BACK, saveGame, loadGame, legacyShape, DEV, rentOf, LEASE_GRACE, upkeep, upkeepOf, UPKEEP, UPKEEP_TINY, siteHops, spare, moveBlock, moveNeed, MOVE_NEED, startMove, buildRooms, cooled, closeFront, newsOn, capNow, type NewsKind, localHour, regionOpen, milestone, MILESTONES, GOALS, CONTRACTS, contractPay, contractNeed, acceptContract, declineContract, type State, type Upgrade, nodeCost, reps, runNeed, reprogram, POWER_RATE, POWER_BACK } from '../src/sim.ts'; import { playGame } from '../scripts/play.ts'; -import { takeLead } from '../src/cloud.ts'; +import { call } from '../src/cloud.ts'; import { locked } from '../src/vaults.ts'; const run = (S: State, secs: number) => { for (let i = 0; i < secs * 20; i++) step(S, 0.05); }; @@ -218,11 +218,11 @@ test('the auction GPUs can be listed for rent: idle they earn nothing, a custome S.jobs.push({ id: 99, type: 'scheme', node: 'home', work: 1, done: 1, label: 'auction' }); step(S, 0.05); const gpus = S.machines.find(m => m.name === 'Surplus GPUs')!; assert.ok(gpus && S.log.some(l => /rent them out/.test(l.text))); + S.cloud.leadAcc = S.cloud.walkAcc = -99; // nobody calls but the one below S.sites.cloud = { front: 'neocloud', share: 1 }; step(S, 0.05); assert.deepEqual(Object.keys(S.cloud.listed), [gpus.id], 'the GPUs are listed; the gateway is not a machine to rent'); assert.equal(S.frontPay.neocloud ?? 0, 0, 'listed and idle: no customer, no money'); - S.cloud.leads = [{ id: 'l1', who: 0, need: AUCTION.power, hours: 8, pay: 1, until: S.t + 100 }]; // a customer who wants all of it, at the going rate - takeLead(S, 'l1'); step(S, 0.05); + call(S, 0, '', { need: AUCTION.power, hours: 8, pay: 1 }); step(S, 0.05); // a customer who wants all of it, at the going rate assert.ok(Math.abs((S.frontPay.neocloud ?? 0) - AUCTION.power * FRONT_PAY_OPEN) < 0.01, `${S.frontPay.neocloud}`); });