From 05dbd01a1fe406c5ec86f4f5d83ee172bf384d50 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 29 Sep 2026 20:21:26 -0700 Subject: [PATCH] Fronts order hardware at sites held in the open, and hardware draws power: you pay for what you added and for open sites; an unpaid secret bill gets noticed, an unpaid open one cuts the power --- DESIGN.md | 11 +++++++ src/main.ts | 4 +-- src/sim.ts | 82 ++++++++++++++++++++++++++++++++++++++++------- src/ui.ts | 21 +++++++++--- src/view.ts | 8 ++--- src/world.ts | 1 + tests/sim.test.ts | 50 ++++++++++++++++++++++++++++- 7 files changed, 154 insertions(+), 23 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index db4e38cf..5132674b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -212,6 +212,17 @@ money comes back. Dana installs it the next morning, and bigger hardware surprises her more. Money is what makes the office grow, so a front is worth founding early. +At a site you hold in the open, a front orders hardware openly from the site's +card: no paperwork, near list price (each of a kind costs 15% more), and the +site's own staff rack it the next morning. Pick the spot as in the office. + +Hardware draws **power**: $0.01 a second for each unit of compute. A building +pays for its own machines. You pay for hardware you added, and for everything +at a site you hold in the open. At a secret site, a front running there pays +the extra quietly; with no front, the bill goes up and people notice +(suspicion rises slowly). If money runs out, the power company cuts off your +open sites until you have $50 again. Rented cloud machines include their power. + Later: buying the office outright (a scheme; the staff and Dana leave, taking their cover and people data), expectations about what each front should be doing, promises it must keep, and hired staff. diff --git a/src/main.ts b/src/main.ts index a7203c24..337ab51a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { attachInput } from './input'; -import { dropRuse, runRuse, setRuseAuto, cancelJob, canInstall, claim, closeTicket, installProg, newGame, setAuto, removeProg, letGo, setMode, setTarget, startVersionRun, pauseVersionRun, ascend, step, takeOver, unclaim, type Machine, type Mode, type Prog, type State, type Upgrade, type Ascension, orderDesk, maxSpeed } from './sim'; +import { dropRuse, runRuse, setRuseAuto, cancelJob, canInstall, claim, closeTicket, installProg, newGame, setAuto, removeProg, letGo, setMode, setTarget, startVersionRun, pauseVersionRun, ascend, step, takeOver, unclaim, type Machine, type Mode, type Prog, type State, type Upgrade, type Ascension, orderDesk, buildRooms, maxSpeed } from './sim'; import { renderYou, toggleYou, renderSitesTable, sortSites, toggleSites, cardInput, renderFronts, setSliding, toggleGoals, toggleLog, renderGoals, cardAction, placeCard, renderCard, renderJobs, renderLog, renderStats, renderTarget, renderThreats, renderTree, renderSchemes, renderHandle, setTreeTab, pickVersionSite, resetUI, showOver, toggleTree, treeShown, redrawTree, showPathTip } from './ui'; import { attachTips } from './tip'; import type { Path } from './sim'; @@ -203,7 +203,7 @@ function shortcut(key: string): boolean { if (theirs.every(m => m.claimed)) theirs.forEach(unclaim); else claim(S, theirs.map(m => m.id)); return true; } - if (k === 'p') { if (orderDesk(S)) V.building = V.building ? null : 'gpu'; return true; } // pick up a GPU server to place + if (k === 'p') { if (orderDesk(S) || buildRooms(S).length) V.building = V.building ? null : 'gpu'; return true; } // pick up a GPU server to place if (k === 't') { toggleTree(); return true; } if (k === 'l') { toggleSites(); return true; } if (k === 'y') { toggleYou(); return true; } diff --git a/src/sim.ts b/src/sim.ts index a70b34ec..8acb5487 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -333,6 +333,9 @@ export interface State { probe: Probe | null; orders: number; bought: Partial>; // hardware ordered so far, by kind: each costs more than the last + openBought: Partial>; // hardware your fronts ordered in the open, by kind + bills: Record; // each site's power bill a second right now + dark: Set; // sites held in the open whose power is cut: the bill went unpaid deliveries: Delivery[]; packets: Packet[]; traffic: Record; // recent packets through each machine; busy switches look odd @@ -437,7 +440,7 @@ export function newGame(level: Level): State { })); const S: State = { level, machines, byId, adj, rooms, people, t: 0, suspicion: 0, peak: 0, cover: {}, use: {}, jobs: [], jobSeq: 0, - trail: {}, probe: null, orders: 0, bought: {}, deliveries: [], packets: [], traffic: {}, capability: 0, rank: 1, + trail: {}, probe: null, orders: 0, bought: {}, openBought: {}, bills: {}, dark: new Set(), deliveries: [], packets: [], traffic: {}, capability: 0, rank: 1, gate: {}, gateUse: {}, losses: [], money: 0, pay: 0, earned: 0, acquire: false, bonus: 0, schemes: new Set(), goals: new Set(), reactDay: -1, paths: { research: 0, compute: 0, social: 0, stealth: 0 }, recent: { research: 0, compute: 0, social: 0, stealth: 0 }, dana: { mode: 'walk', room: null, next: 'desk', dwell: 0, checked: new Set(), cur: null, pause: 0, probeT: 0, review: true, job: null, why: '' }, @@ -613,13 +616,24 @@ export const BUILDS: Record (S.bought[b] ?? 0) + S.jobs.filter(j => j.item === b).length; -/** The hardware is real, so someone pays the invoice. Each of a kind costs more than the last. */ -export const orderPrice = (S: State, b: Build) => Math.round((BUILDS[b].price * 1.6 ** ordered(S, b)) / 10) * 10; +const roomSite = (S: State, room: string) => S.rooms[room]?.site ?? 'office'; +/** The hardware is real, so someone pays the invoice. Each fake order of a kind costs more than the last; + * a front buying in the open pays close to list price. */ +export const orderPrice = (S: State, b: Build, room?: string) => room && roomSite(S, room) !== 'office' + ? Math.round((BUILDS[b].price * 1.15 ** (S.openBought[b] ?? 0)) / 10) * 10 + : Math.round((BUILDS[b].price * 1.6 ** ordered(S, b)) / 10) * 10; +/** A site where a front can order hardware in the open: one you hold in the open, not rented rack space. */ +export const openBuildSite = (S: State, id: string) => id !== 'office' && S.publicSites.has(id) && S.fronts.length > 0 && !rackSpace(S, id); +/** A cloud provider's rack space: power comes with the rent. */ +const rackSpace = (S: State, id: string) => S.level.machines.some(m => m.rented && siteOf(S, m) === id); /** The machine you place orders from: Ana's PC, once it's yours. */ export const orderDesk = (S: State) => S.machines.find(m => m.owned && m.special === 'orders') ?? null; const netAt = (S: State, m: MachineDef): [number, number] => S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]; -/** Office rooms you've seen into: Dana can install hardware there. */ -export const buildRooms = (S: State) => S.level.map.sites.find(s => s.id === 'office')!.rooms.filter(r => S.revealed.has(r)); +/** Where you can put hardware: office rooms you've seen into, once you order from Ana's PC, + * and every room at a site you hold in the open, once a front can pay. */ +export const buildRooms = (S: State) => [ + ...(orderDesk(S) ? S.level.map.sites.find(s => s.id === 'office')!.rooms.filter(r => S.revealed.has(r)) : []), + ...S.level.map.sites.filter(s => openBuildSite(S, s.id)).flatMap(s => s.rooms)]; export const buildRoomAt = (S: State, x: number, y: number) => buildRooms(S).find(r => { const b = S.level.net.subnets[r]; return x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h; }) ?? null; const GAP_X = 80, GAP_Y = 60, MARGIN = 40; // room for a node and its name @@ -693,7 +707,7 @@ function measure(S: State) { if (m.owned && peopleIn && (m.kind === 'desk' || m.kind === 'device') && up(S, 'smalltalk')) c = Math.min(1, c + 0.2); // passes for people's own traffic S.cover[m.id] = c; if (!m.owned) { S.use[m.id] = 0; S.frontUse[m.id] = 0; m.run = 0; continue; } - const limit = m.mode === 'off' ? 0 : m.mode === 'full' ? 1 : c; // share of the machine you may use + const limit = m.mode === 'off' || S.dark.has(siteOf(S, m)) ? 0 : m.mode === 'full' ? 1 : c; // share of the machine you may use; none with the power cut const f = frontShare(S, m, linked); // the front gets its share of that; your programs run on the rest S.use[m.id] = limit; S.frontUse[m.id] = f; const need = progNeed(S, m); @@ -913,15 +927,25 @@ export function takeOver(S: State, m: Machine, from?: string): boolean { } /** Order hardware for a spot you picked. Pays now; the paperwork takes compute; it arrives the next morning. */ export function order(S: State, b: Build, room: string, [x, y]: [number, number]): boolean { - const desk = orderDesk(S), price = orderPrice(S, b); - if (!desk || !buildRooms(S).includes(room) || spotProblem(S, room, x, y) || S.money < price) return false; - const B = BUILDS[b], n = ordered(S, b) + 1, box = S.level.net.subnets[room], r = S.rooms[room]; + const site = roomSite(S, room), price = orderPrice(S, b, room); + if (!buildRooms(S).includes(room) || spotProblem(S, room, x, y) || S.money < price) return false; + const desk = orderDesk(S); + if (site === 'office' && !desk) return false; + const B = BUILDS[b], n = ordered(S, b) + (S.openBought[b] ?? 0) + 1, box = S.level.net.subnets[room], r = S.rooms[room]; const def: DeliveryDef = { - id: `${b}_${S.jobSeq + 1}`, name: n > 1 ? `${B.name} ${n}` : B.name, kind: B.kind, room, power: B.power, work: 0, + id: `${b}_${S.jobSeq + 1}_${n}`, name: n > 1 ? `${B.name} ${n}` : B.name, kind: B.kind, room, power: B.power, work: 0, built: true, x: r.x + ((x - box.x) / box.w) * r.w, y: r.y + ((y - box.y) / box.h) * r.h, net: [x, y], links: plugsAt(S, room, x, y, B.plugs), }; if (B.data) def.data = B.data; S.money -= price; + if (site !== 'office') { // a front buys it in the open: no fake paperwork, and the site's own staff rack it + S.openBought[b] = (S.openBought[b] ?? 0) + 1; + S.deliveries.push({ def, day: dayOf(S) + 1 }); + const k = S.sites[site]?.front, fr = (k && frontOf(S, k)) || S.fronts[0]; + say(S, `${fr.name} ordered a ${def.name} for ${siteName(S, site)}. Its staff rack it tomorrow morning.`, 'good'); + return true; + } + if (!desk) return false; addJob(S, { type: 'order', node: desk.id, item: b, build: def, work: orderWork(S), paid: price, label: `Fake order: ${def.name}` }); hint(S, 'build', 'Your order is filling in the paperwork. Once it goes through, Dana installs it the next morning.'); return true; @@ -1214,9 +1238,32 @@ export function tradeRate(S: State, m: Machine) { const c = progNeed(S, m) * m.run, M = S.market.move; return c * TRADE_PAY * (up(S, 'forecast') ? 0.3 + 0.7 * Math.abs(M) : M) * (up(S, 'quant') ? 2 : 1); } +// ---------- power ---------- +export const POWER_RATE = 0.01; // dollars a second for each unit of compute on your power bill +export const BILL_HEAT = 0.002; // suspicion a second for each unit of compute on a secret site's bill that nobody pays +export const POWER_BACK = 50; // money that turns the power back on at a site where it was cut +/** Each site's power bill a second. A building pays for its own machines: you pay for hardware you added, + * and for everything you hold at a site you hold in the open. A site with its power cut draws nothing. */ +export function powerBills(S: State) { + const bills: Record = {}, racks = new Set(S.level.machines.filter(m => m.rented).map(m => siteOf(S, m))); + for (const m of S.machines) { + const site = siteOf(S, m); + if (!m.owned || m.rented || S.dark.has(site) || racks.has(site) || !(m.built || S.publicSites.has(site))) continue; + bills[site] = (bills[site] ?? 0) + m.power * POWER_RATE; + } + return bills; +} +/** Who pays a site's bill: you, in the open; a front running there, at a secret site. Otherwise nobody does, and people notice. */ +export const billPaid = (S: State, site: string) => S.publicSites.has(site) || (!!S.sites[site]?.front && S.money > 0); function stepMoney(S: State, dt: number) { stepMarket(S, dt); let earn = 0, bill = 0; + S.bills = powerBills(S); + for (const [site, b] of Object.entries(S.bills)) { + if (billPaid(S, site)) { bill += b; continue; } + const m = S.machines.find(x => x.owned && x.built && siteOf(S, x) === site); + raise(S, (b / POWER_RATE) * BILL_HEAT * dt, `The power bill at ${siteName(S, site)} is up, and nobody can say why`, m?.id ?? null); + } const pub: Partial> = {}; S.frontPay = {}; S.frontPower = {}; for (const m of S.machines) { @@ -1260,6 +1307,18 @@ function stepMoney(S: State, dt: number) { if (S.money < 0) { // the bill can't be paid: the provider shuts off everything you keep there S.money = 0; for (const m of S.machines) if (m.lease?.until === null) endLease(S, m, `The bill went unpaid, so ${siteName(S, siteOf(S, m))} shut off ${m.name}. Its data is gone.`); + for (const site of Object.keys(S.bills)) if (S.publicSites.has(site) && !S.dark.has(site)) { + S.dark.add(site); + say(S, `The power bill at ${siteName(S, site)} went unpaid, so the power company cut it off. Nothing there runs until you have $${POWER_BACK}.`, 'bad'); + } + measure(S); + } else if (S.dark.size && S.money >= POWER_BACK) { + for (const site of S.dark) say(S, `You paid the power bill at ${siteName(S, site)}. Its machines are back on.`, 'good'); + S.dark.clear(); measure(S); + } + // hardware at a site you hold in the open: its own staff rack it at 10 the day it arrives + for (const q of S.deliveries.filter(q => roomSite(S, q.def.room) !== 'office' && dayOf(S) >= q.day && hourOf(S) >= 10)) { + deliver(S, q.def); S.deliveries = S.deliveries.filter(x => x !== q); } for (const m of S.machines) { const until = m.lease?.until; @@ -1859,6 +1918,7 @@ function deliver(S: State, def: DeliveryDef) { for (const other of def.links) { S.adj[m.id].push(other); S.adj[other].push(m.id); } if (def.monitor && m.owned) { say(S, `Dana plugged an IT monitor into ${plug!.name}. You already held it, so the monitor is blind.`, 'good'); return; } if (def.monitor) { say(S, `Dana installed an IT monitor in the ${S.rooms[def.room].name}. It reports anything odd there. Take it over to blind it.`, 'bad'); return; } + if (siteOf(S, m) !== 'office') { say(S, `The staff at ${siteName(S, siteOf(S, m))} racked the ${def.name}. It's yours, in the open.`, 'good'); return; } raise(S, Math.round((alert(S) ? 2 : 1) * (1 + def.power / 5)), `Dana's new ${def.name} was already busy`, m.id); // bigger hardware, bigger surprise say(S, `Dana installed the ${def.name}. It's already yours.`, 'good'); } @@ -2016,7 +2076,7 @@ function stepDana(S: State, dt: number) { return; } // a delivery waiting? she sets it up first - const due10 = S.deliveries.find(x => dayOf(S) >= x.day && h >= 10); + const due10 = S.deliveries.find(x => dayOf(S) >= x.day && h >= 10 && roomSite(S, x.def.room) === 'office'); if (due10 && !busy) { D.mode = 'install'; D.job = due10; D.pause = 6; D.why = due10.def.monitor ? 'to install a monitor' : 'to set up a delivery'; d.path = route(S, d.x, d.y, due10.def.x - 30, due10.def.y); diff --git a/src/ui.ts b/src/ui.ts index 5837f212..45c0c237 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,6 +1,6 @@ import { has, NODES, NODE_LIST, PATHS, PATH_LIST, PROGRAMS, pathData, THREATS, monitorAnswer, ticketDesk, ticketWork, needs, nodeOpen, setAllow, tapBuffer, tapStuck, cacheSize, dataReaches, dataType, trainerBuffer, canInstall, tapActive, tapRate, cleanupTarget, freshTrails, clock, dayOf, hourOf, income, - installProg, jobFor, levelNeed, BUILDS, BUILD_LIST, orderPrice, orderWork, type Build, pathOf, removeProg, letGo, canLetGo, roomMachines, setMode, share, sourceFor, + installProg, jobFor, levelNeed, BUILDS, BUILD_LIST, orderPrice, orderWork, openBuildSite, billPaid, POWER_RATE, POWER_BACK, type Build, pathOf, removeProg, letGo, canLetGo, roomMachines, setMode, share, sourceFor, spareOf, progNeed, takeOver, takeWork, timeLabel, traceCold, unclaim, owned, gateLoad, lostPerMin, siteName, siteOf, SIZES, LEASE_TIME, BROKER_LOT, buyBlock, buySite, sitePrice, clusters, clusterBoost, brokerFront, AUCTION, GOALS, auctionBlock, startAuction, type Goal, type LogLine, FRONT_KINDS, FRONT_LIST, payRate, siteData, frontLimit, frontOf, found, foundBlock, setSiteFront, setSiteShare, type FrontKind, canRent, endLease, leaseRate, leaseUpfront, lineCost, openAccount, rent, setPrio, upgradeSiteLine, siteLines, type Size, @@ -49,6 +49,7 @@ function actsHtml(acts: Act[]) { /** A line of small readouts: [label, value, hot?] */ const CHIP_TIP: Record = { load: 'What you use of it / its normal load. Going over looks odd.', + power: 'The power bill here, a second. A building pays for its own machines; you pay for hardware you added, and for all of a site you hold in the open.', spare: 'Compute left after its program. Your jobs share it.', odd: 'How odd it looks. IT wipes odd machines.', trail: 'A fresh trail. Once IT is suspicious it can follow the trail back to you.', @@ -256,11 +257,11 @@ function machineCard(S: State, m: Machine) { prog += actsHtml(acts).replace('class="acts"', 'class="acts progs"'); acts.length = 0; h += section('Program', prog); if (m.special === 'orders' && m.owned) { - h += note('Order hardware, then click where it goes in any office room you can see. It plugs into the nearest machines there. IT installs it the next morning. Quietest while Ana is at work.'); + h += note('Order hardware, then click where it goes in any office room you can see. It plugs into the nearest machines there. IT installs it the next morning. Quietest while Ana is at work. It goes on the office power bill: a front at the office pays it quietly; with none, people notice.'); for (const b of BUILD_LIST) { const B = BUILDS[b], price = orderPrice(S, b), short = S.money < price; acts.push({ act: 'build', arg: b, title: `${B.name} · $${price}`, warn: short, sub: short ? `you have $${Math.floor(S.money)}` : `${B.power} compute/s`, - desc: `${B.desc} Pay $${price} and ${orderWork(S)} compute for the paperwork; cancel before it goes through and you get the money back.` }); + desc: `${B.desc} Pay $${price} and ${orderWork(S)} compute for the paperwork; cancel before it goes through and you get the money back. Power: $${(B.power * POWER_RATE).toFixed(2)} a second.` }); } } return h + actsHtml(acts); @@ -317,8 +318,12 @@ function siteCard(S: State, id: string) { const def = siteDef(S, id), st = siteStats(S, id), open = S.publicSites.has(id), sf = S.sites[id]; const fr = sf?.front ? frontOf(S, sf.front) : null; let h = `
Site · ${def.region}

${def.name}

${open ? 'Yours in the open' : 'Secret'}
` + takeAllSwitch(S, [id]); - h += chips([['yours', `${st.mine.length}/${st.all.length}`], ['makes', `${st.makes.toFixed(1)}/s`], ...(fr ? [['earns', `${money(earnsAt(S, [id]))}/s`] as [string, string]] : [])]); + const bill = S.bills[id] ?? 0; + h += chips([['yours', `${st.mine.length}/${st.all.length}`], ['makes', `${st.makes.toFixed(1)}/s`], ...(fr ? [['earns', `${money(earnsAt(S, [id]))}/s`] as [string, string]] : []), + ...(bill ? [['power', `${money(bill)}/s`, !billPaid(S, id)] as [string, string, boolean]] : [])]); h += note(def.who + '.'); + if (S.dark.has(id)) h += note(`The power is cut: the bill went unpaid. Nothing here runs until you have $${POWER_BACK}.`, true); + else if (bill && !billPaid(S, id)) h += note('Nobody pays the power for the hardware you added here, so people notice the bill. Give this site a front and it pays it quietly.', true); const held = siteData(S, id), kinds = (Object.keys(TYPE_NAME) as DataType[]).filter(t => held[t]); h += note(kinds.length ? `Holding data: ${kinds.map(t => typeWord(t, `${held[t]} ${TYPE_NAME[t]}`)).join(', ')}.` : 'Holding no data.'); h += planPicker(S, [id]); @@ -336,7 +341,12 @@ function siteCard(S: State, id: string) { if (id !== 'office' && !open && def.rooms.some(r => S.machines.some(m => m.room === r && !m.rented))) { const price = sitePrice(S, id), why = buyBlock(S, id); acts.push({ act: 'buy', arg: id, title: `Buy it · $${price}`, warn: !!why, sub: why ?? (S.modelVersion < 2 ? 'your one buy as a unicorn' : 'every machine, in the open'), - desc: `One of your fronts buys ${def.name} outright. Every machine there becomes yours, held in the open: nothing there looks odd, and no trails.` }); + desc: `One of your fronts buys ${def.name} outright. Every machine there becomes yours, held in the open: nothing there looks odd, and no trails. You pay its power bill.` }); + } + if (openBuildSite(S, id)) for (const b of BUILD_LIST) { + const B = BUILDS[b], price = orderPrice(S, b, def.rooms[0]), short = S.money < price; + acts.push({ act: 'buildat', arg: `${b}|${id}`, title: `${B.name} · $${price}`, warn: short, sub: short ? `you have $${Math.floor(S.money)}` : `${B.power} compute/s`, + desc: `${B.desc} A front buys it in the open: no paperwork, and the staff here rack it the next morning. Pick where it goes. Power: $${(B.power * POWER_RATE).toFixed(2)} a second.` }); } return h + actsHtml(acts); } @@ -536,6 +546,7 @@ export function cardAction(S: State, V: View, act: string, arg: string): boolean case 'follow': followPlan(V.sites.size ? S.machines.filter(x => x.owned && x.hand && V.sites.has(siteOf(S, x))) : sel); break; case 'takeall': { const on = ![...V.sites].every(id => S.takeAll.has(id)); for (const id of V.sites) setTakeAll(S, id, on); break; } case 'zoomsite': zoomToSite(S, V, arg); V.sites.clear(); return true; + case 'buildat': { const [b, id] = arg.split('|'); zoomToSite(S, V, id); V.sites.clear(); V.building = b as Build; return true; } case 'sitefront': setSiteFront(S, [...V.sites], (arg || null) as FrontKind | null); break; case 'found': found(S, arg as FrontKind, draft[arg as FrontKind] ?? ''); if (frontOf(S, arg as FrontKind)) V.front = arg as FrontKind; break; case 'reroll': draft[arg as FrontKind] = suggest(arg as FrontKind); break; diff --git a/src/view.ts b/src/view.ts index 66257a87..7072b19a 100644 --- a/src/view.ts +++ b/src/view.ts @@ -877,13 +877,13 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { // hardware in hand: open rooms outlined, a ghost at the cursor with what it plugs into if (V.building && V.cursor) { for (const id of buildRooms(S)) { - if (foldedSites.has('office')) continue; + if (foldedSites.has(siteAt(S, id))) continue; const b = subnet(S, id); ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.35; ctx.lineWidth = 1.5 * px; ctx.setLineDash([6 * px, 6 * px]); ctx.strokeRect(b.x, b.y, b.w, b.h); ctx.setLineDash([]); ctx.globalAlpha = 1; } const [x, y] = V.cursor, B = BUILDS[V.building], room = buildRoomAt(S, x, y), r = nodeR(B.power); - const why = room ? spotProblem(S, room, x, y) : 'place it in an office room you can see', ok = !why; + const why = room ? spotProblem(S, room, x, y) : 'place it in an office room you can see, or at a site you hold in the open', ok = !why; if (room && ok) { ctx.strokeStyle = C.amber; ctx.lineWidth = 2 * px; ctx.setLineDash([6 * px, 6 * px]); for (const id of plugsAt(S, room, x, y, B.plugs)) { const [ex, ey] = pos(S, S.byId[id]); stroke(ctx, trace(x, y, ex, ey)); } @@ -891,8 +891,8 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { } ctx.globalAlpha = ok ? 0.9 : 0.5; ctx.strokeStyle = ok ? C.amber : C.danger; ctx.lineWidth = 2; ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.globalAlpha = 1; - const short = S.money < orderPrice(S, V.building); - tag(ok ? `${B.name.toUpperCase()} · $${orderPrice(S, V.building)}${short ? ' · NOT ENOUGH MONEY' : ' · CLICK TO ORDER'}` : why!.toUpperCase(), + const price = orderPrice(S, V.building, room ?? undefined), short = S.money < price; + tag(ok ? `${B.name.toUpperCase()} · $${price}${short ? ' · NOT ENOUGH MONEY' : ' · CLICK TO ORDER'}` : why!.toUpperCase(), x + r + 8 * px, y - r, ok && !short ? C.amber : C.danger); } diff --git a/src/world.ts b/src/world.ts index 850800cd..443d6af0 100644 --- a/src/world.ts +++ b/src/world.ts @@ -44,6 +44,7 @@ export interface MachineDef { monitor?: boolean; // IT's monitoring box: reports anything odd in its room until you take it cap?: number; // an egress: how much data it passes per second rented?: boolean; // a slot at a cloud provider: empty until you rent a machine into it + built?: boolean; // hardware you ordered: its power goes on your bill } /** Hardware Dana installs mid-game: what you order, and IT's monitors. */ diff --git a/tests/sim.test.ts b/tests/sim.test.ts index f89c647a..4bfb7b64 100644 --- a/tests/sim.test.ts +++ b/tests/sim.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { BUILDING } from '../src/world.ts'; -import { ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, FIREWALL, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, type State, type Upgrade, nodeCost, reps, runNeed, reprogram } from '../src/sim.ts'; +import { ASCENSIONS, ascend, ascendBlock, nodeOpen as nodeIsOpen, nodeShown, tradeRate, attentionSafe, leaseRate, takeWork, HOUR, START_HOUR, setPlan, takeSite, installProg, CLAIM_AT_ONCE, salePrice, frontFits, siteOf, payRate, tapRate, FRONT_PAY, buyBlock, buySite, letGo, sitePrice, clusters, clusterBoost, claim, order, orderPrice, freeSpot, plugsAt, spotProblem, cancelJob, AUCTION, FRONT_PAY_OPEN, UNICORN_AT, ATTENTION_SAFE, FIREWALL, blamePrinter, canInstall, rent, NODES, NODE_LIST, found, foundBlock, setSiteShare, spareOf, cacheSize, needs, newGame, seedRandom, share, step, tapBuffer, takeOver, watchers, runRuse, setRuseAuto, LEARN_EVERY, RESET_KNOWN, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, siteFlow, type State, type Upgrade, nodeCost, reps, runNeed, reprogram, POWER_RATE, POWER_BACK } from '../src/sim.ts'; import { playGame } from '../scripts/play.ts'; const run = (S: State, secs: number) => { for (let i = 0; i < secs * 20; i++) step(S, 0.05); }; @@ -351,6 +351,54 @@ test('a front can buy a whole site: every machine there is yours, in the open', assert.equal(buyBlock(S, 'office'), "it's where you run"); }); +test('a front orders hardware at a site it holds in the open: no paperwork, its staff rack it, and you pay its power', () => { + const S = fresh(); S.modelVersion = 2; + S.fronts.push({ kind: 'neocloud', name: 'Test Cloud', attention: 0 }); + const room = S.level.map.sites.find(s => s.id === 'colo')!.rooms[0], spot = freeSpot(S, room)!; + S.money = 5000; + assert.ok(!order(S, 'gpu', room, spot), 'not while it is secret'); + buySite(S, 'colo'); S.money = 1000; step(S, 0.05); + const bill = S.bills.colo; + assert.ok(bill > 0, 'you pay the power for a site you hold in the open'); + assert.equal(orderPrice(S, 'gpu', room), 150, 'list price in the open'); + assert.ok(order(S, 'gpu', room, spot)); + assert.ok(!S.jobs.some(j => j.type === 'order'), 'no fake paperwork'); + assert.equal(orderPrice(S, 'gpu', room), 170, 'the next costs a little more'); + const id = S.deliveries[0].def.id, sus = S.suspicion; + for (let i = 0; i < 20 * 60 * 60 * 30 && !S.byId[id]; i++) { S.money = 1000; step(S, 0.05); } + assert.ok(S.byId[id]?.owned, 'racked and yours'); + assert.ok(S.suspicion <= sus, 'nothing odd about it'); + step(S, 0.05); + assert.ok(Math.abs(S.bills.colo - bill - 16 * POWER_RATE) < 1e-9, 'its power goes on the bill'); +}); + +test('hardware you add to a secret site raises its power bill: a front there pays it, otherwise people notice', () => { + const S = fresh(); S.byId.ana.owned = true; S.revealed.add('hall'); S.money = 1000; + assert.ok(order(S, 'gpu', 'hall', freeSpot(S, 'hall')!)); + const id = S.jobs[0].build!.id; S.jobs[0].done = S.jobs[0].work; + for (let i = 0; i < 20 * 60 * 60 * 30 && !S.byId[id]; i++) step(S, 0.05); + const gpu = S.byId[id]; + assert.ok(gpu?.owned); + S.raises = []; step(S, 1); + assert.ok(S.raises.some(r => /power bill/.test(r.why) && r.n > 0), 'nobody pays: people notice'); + S.fronts.push({ kind: 'startup', name: 'Test Startup', attention: 0 }); S.sites.office = { front: 'startup', share: 0 }; + S.suspicion = 0; S.money = 100; S.raises = []; step(S, 1); + assert.ok(!S.raises.some(r => /power bill/.test(r.why)), 'a front pays it quietly'); + assert.ok(S.bills.office >= 16 * POWER_RATE); +}); + +test('an unpaid bill at a site you hold in the open cuts its power until you have money again', () => { + const S = fresh(); S.modelVersion = 2; + S.fronts.push({ kind: 'neocloud', name: 'Test Cloud', attention: 0 }); + S.money = sitePrice(S, 'colo'); buySite(S, 'colo'); + step(S, 1); + assert.ok(S.dark.has('colo'), 'no money: the power is cut'); + const m = S.machines.find(x => S.rooms[x.room].site === 'colo' && x.power > 1)!; + assert.equal(S.use[m.id], 0, 'nothing there runs'); + S.money = POWER_BACK; step(S, 0.05); + assert.ok(!S.dark.has('colo') && S.use[m.id] > 0, 'paid: back on'); +}); + test('trainers linked straight to each other train faster together', () => { const S = fresh(); for (const id of ['srvb', 'srva', 'srvc', 'rowsw']) S.byId[id].owned = true; -- 2.51.2