From 1d169109b5ddd99bb49993fcd8d03600f0e0396e Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 28 Sep 2026 23:56:42 -0700 Subject: [PATCH] Take it all is a switch at the top of a site card: 0.05 compute a second per machine still to take, off by itself once the site is yours --- DESIGN.md | 8 +++++--- index.html | 2 +- src/sim.ts | 33 ++++++++++++++++++++++++++++----- src/style.css | 12 ++++++++++++ src/ui.ts | 30 +++++++++++++++--------------- tests/sim.test.ts | 17 ++++++++++++++++- 6 files changed, 77 insertions(+), 25 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b868294b..16d5cf72 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -129,9 +129,11 @@ Sites are programmable. A site's **plan** sets up every machine you hold there: **Gather** (taps, and caches to hold), **Store** (caches), or **Train** (trainers on big machines, taps and caches around them). It installs two at a time, and new machines get set up as they come in. A machine you change by hand keeps -your choice until you hand it back. **Take the whole site** claims every -machine there; claims are taken two at a time, cheapest first, never while -someone sits at the machine. +your choice until you hand it back. **Take it all** is a switch at the top of +a site's card. While it is on, it claims every machine there, and claims are +taken two at a time, cheapest first, never while someone sits at the machine. +It costs 0.05 compute a second for each machine still to take, and it turns +off when the site is all yours. Money is one shared pool. Plan four front types, one per capability path: a research startup, a **Neo Cloud** that sells compute, an **Influencer studio**, diff --git a/index.html b/index.html index 70d9b866..4675b986 100644 --- a/index.html +++ b/index.html @@ -77,7 +77,7 @@
  • Drag from one of your machines to a neighbor to take it over. It adds load to the machine you spread from, so do it while that machine is busy.
  • Each machine runs in Blend in (safe), Max (more compute, looks odd), or Off. Drag a box to set many at once.
  • Sites (L) lists every site you know in one table. Research nodes in the tree unlock 8x, 16x, 32x and 64x speed, which work while zoomed out to the map.
  • -
  • Click a site on the map to run it as a whole: give it a plan (Gather, Store, Train) and the game installs programs for you, or take the whole site at once.
  • +
  • Click a site on the map to run it as a whole: give it a plan (Gather, Store, Train) and the game installs programs for you, or switch on Take it all to take every machine there, for a little compute each second.
  • Build a base on the machines you own: Taps gather data, links carry it, Caches hold it, Trainers turn it into capability, and a Broker sells what's left over through the site's front. Install programs from a machine's card or with keys 1–4.
  • Each kind of data trains its own path. Data from people's desks trains Social, from servers Compute, from the network switches and lines out Stealth, and lab data trains Research. In the tree, hover a path's name, or tap it on a phone, to see where its data comes from.
  • Pick a node to work on in the capability tree (T). Trainers take only the data it still needs, and it's built when its cost is met. Progress is kept if you switch. With no target, trainers stop and data waits. Other data waits in caches, and a tap with nowhere to send loses it. Each cache has switches for which kinds it takes.
  • diff --git a/src/sim.ts b/src/sim.ts index 1371d8dd..c1283617 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -334,6 +334,7 @@ export interface State { wasAlert: boolean; hunting: boolean; upgrades: Set; // tree nodes you've built + takeAll: Set; // sites with Take it all switched on target: Upgrade | null; // the node you're working on. None: trainers stop and data waits. auto: boolean; // auto-research: with no target, pick the open node you can finish soonest modelVersion: number; // the first large training run makes v2 @@ -405,7 +406,7 @@ export function newGame(level: Level): State { 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: '' }, revealed: new Set(), sealed: new Set(), sealPulse: [], cleaning: null, fx: [], log: [], logVersion: 0, - hinted: new Set(), over: null, claimTick: 0, wasAlert: false, hunting: false, upgrades: new Set(), target: 'buffers', auto: false, modelVersion: 1, ascension: null, market: { move: 0, aim: 0, next: 0 }, tradePay: {}, versionRun: null, progress: {}, transfer: 0, lost: [], + hinted: new Set(), over: null, claimTick: 0, wasAlert: false, hunting: false, upgrades: new Set(), takeAll: new Set(), target: 'buffers', auto: false, modelVersion: 1, ascension: null, market: { move: 0, aim: 0, next: 0 }, tradePay: {}, versionRun: null, progress: {}, transfer: 0, lost: [], threats: [], lee: null, flagged: new Set(), flow: {}, plans: {}, fronts: [], sites: {}, frontUse: {}, frontPay: {}, frontPower: {}, sold: {}, sales: [], raises: [], susFx: [], slow: false, lockdown: null, ruses: [], ruseT: 0, noScan: 0, calmSites: {}, @@ -454,6 +455,12 @@ export const lostPerMin = (S: State, id?: string) => S.losses.filter(l => !id || /** Compute left over after programs run: this is what jobs share. */ export const spareOf = (S: State, m: Machine) => Math.max(0, m.power * (S.use[m.id] ?? 0) * (1 - (S.frontUse[m.id] ?? 0)) - progNeed(S, m) * m.run); export const income = (S: State) => owned(S).reduce((s, m) => s + spareOf(S, m), 0); +/** Take it all costs this much compute a second for each machine at the site not yet yours, while it's on. */ +export const TAKE_ALL_COST = 0.05; +export const takeAllCostAt = (S: State, sites: string[]) => sites.reduce((n, id) => n + TAKE_ALL_COST * takeable(S, id).length, 0); +export const takeAllCost = (S: State) => takeAllCostAt(S, [...S.takeAll]); +/** What your jobs get to share: what your machines make, less the Take it all upkeep. */ +export const spare = (S: State) => Math.max(0, income(S) - takeAllCost(S)); export const progNeed = (S: State, m: Machine) => { if (!m.prog) return 0; if (m.prog === 'tap') return tapActive(S, m) ? PROGRAMS.tap.need : 0; @@ -520,7 +527,7 @@ export function cleanupTarget(S: State): string | null { /** Every job gets an equal share of your compute; cleanup takes one share while there's anything to wipe. */ export const share = (S: State) => { const n = S.jobs.length + (cleanupTarget(S) ? 1 : 0); - return n ? income(S) / n * (up(S, 'swarm') ? 2 : 1) : 0; + return n ? spare(S) / n * (up(S, 'swarm') ? 2 : 1) : 0; }; export const jobFor = (S: State, type: Job['type'], id: string) => S.jobs.find(j => j.type === type && j.node === id); export const alert = (S: State) => S.suspicion >= 50; @@ -873,9 +880,23 @@ export function claim(S: State, ids: string[]) { export function unclaim(m: Machine) { m.claimed = false; } /** Claimed machines are taken this many at a time. */ export const CLAIM_AT_ONCE = 2; -/** Claim every machine at a site you don't hold yet. They're taken a couple at a time as they come in reach. */ -export function takeSite(S: State, site: string) { - claim(S, S.machines.filter(m => siteOf(S, m) === site && !m.owned && !m.monitor).map(m => m.id)); +/** The machines at a site that Take it all goes after. */ +export const takeable = (S: State, site: string) => S.machines.filter(m => siteOf(S, m) === site && !m.owned && !m.monitor && !m.rented && m.special !== 'account'); +/** Take it all: while it's on, every machine at the site you don't hold is claimed, and taken a couple + * at a time as it comes in reach. It costs compute each second for every machine still to take, and turns + * itself off once the site is all yours. */ +export function setTakeAll(S: State, site: string, on: boolean) { + if (on === S.takeAll.has(site)) return; + if (on) { S.takeAll.add(site); for (const m of takeable(S, site)) m.claimed = true; say(S, `Taking all of ${siteName(S, site)}, a couple of machines at a time.`); } + else { S.takeAll.delete(site); for (const m of takeable(S, site)) m.claimed = false; } +} +export const takeSite = (S: State, site: string) => setTakeAll(S, site, true); +function stepTakeAll(S: State) { + for (const site of S.takeAll) { + const left = takeable(S, site); + if (!left.length) { S.takeAll.delete(site); say(S, `${siteName(S, site)} is all yours. Take it all is off.`, 'good'); continue; } + for (const m of left) m.claimed = true; // new machines, and ones a claim was dropped on + } } // ---------- site plans ---------- @@ -912,6 +933,7 @@ export const canLetGo = (m: Machine) => m.owned && !m.home && !m.rented && m.spe /** Give a machine back. You lose it and what it holds, but its trail goes with it: a trace that reaches it goes cold. */ export function letGo(S: State, m: Machine) { if (!canLetGo(m)) return; + S.takeAll.delete(siteOf(S, m)); // letting go at a site you are taking: stop, or it would be claimed again at once m.owned = false; m.claimed = false; m.odd = 0; m.mode = 'blend'; m.prog = null; m.held = []; m.refuse = []; m.missed = 0; m.acc = 0; delete S.trail[m.id]; S.jobs = S.jobs.filter(j => j.node !== m.id && j.from !== m.id); @@ -1197,6 +1219,7 @@ export function step(S: State, dt: number) { if (takeOver(S, m)) taking++; } stepPlans(S); + stepTakeAll(S); } // oddness: load above cover looks strange. Logs pile up overnight. diff --git a/src/style.css b/src/style.css index c64a9bae..3d909467 100644 --- a/src/style.css +++ b/src/style.css @@ -562,3 +562,15 @@ button:focus-visible { outline-color: var(--amber); } .brand #treebtn.ringed { width: 40px; min-width: 40px; height: 40px; padding: 0; justify-content: center; border-radius: 999px; } .rings { width: 40px; height: 40px; } } + +/* Take it all: a switch along the top of a site card, with what it costs a second */ +.card .switch { display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 10px; text-align: left; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; } +.card .switch span { flex: 1; } +.card .switch small { font-size: 10.5px; color: var(--muted); letter-spacing: 0; font-variant-numeric: tabular-nums; text-transform: none; } +.card .switch i { position: relative; flex: none; width: 30px; height: 18px; border-radius: 999px; background: rgba(255, 255, 255, .1); border: 1px solid var(--glass-line); transition: background .15s; } +.card .switch i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--muted); transition: transform .15s, background .15s; } +.card .switch.on { background: rgba(242, 169, 59, .08); border-color: rgba(242, 169, 59, .35); color: var(--amber); box-shadow: none; } +.card .switch.on small { color: var(--amber); } +.card .switch.on i { background: rgba(242, 169, 59, .35); border-color: rgba(242, 169, 59, .6); } +.card .switch.on i::after { transform: translateX(12px); background: var(--amber); } +@media (max-width: 700px) { .card .switch { min-height: 44px; } } diff --git a/src/ui.ts b/src/ui.ts index 44442b76..581b23b3 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -5,7 +5,7 @@ import { 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, upgradeLine, type Size, STAGES, stageOf, LOCKDOWN_SAFE, lyingLow, ruseDef, ruseBlock, HOUR, NOSCAN, SITE_CALM, type RuseDef, - VERSION_2, versionBlock, versionPower, PLANS, PLAN_LIST, setPlan, followPlan, takeSite, type Plan, + VERSION_2, versionBlock, versionPower, PLANS, PLAN_LIST, setPlan, followPlan, setTakeAll, takeAllCost, takeAllCostAt, takeable, TAKE_ALL_COST, type Plan, ASCENSIONS, ASCENSION_LIST, ascendBlock, nodeShown, attentionSafe, tradeRate, type Ascension, type Machine, type Mode, type Path, type Prog, type State, type Upgrade, } from './sim'; @@ -319,7 +319,7 @@ function frontPicker(S: State, ids: string[]) { 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'}
    `; + 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]] : [])]); h += note(def.who + '.'); const held = siteData(S, id), kinds = (Object.keys(TYPE_NAME) as DataType[]).filter(t => held[t]); @@ -328,7 +328,7 @@ function siteCard(S: State, id: string) { h += frontPicker(S, [id]); if (fr) h += note(open ? 'You hold this site in the open. Nothing here looks odd, but open compute sells at market rates: it earns less than secret compute.' : "Secret: nobody knows this compute works for the front. Its work counts as your own load, so Blend in keeps it hidden and Max can make it look odd. It earns only while the site is linked to your server."); - const acts: Act[] = [...takeAllAct(S, [id]), { act: 'zoomsite', arg: id, title: 'Zoom in', desc: 'Zoom in to work on its machines.' }]; + const acts: Act[] = [{ act: 'zoomsite', arg: id, title: 'Zoom in', desc: 'Zoom in to work on its machines.' }]; 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'), @@ -349,20 +349,20 @@ function planPicker(S: State, ids: string[]) { desc: 'Machines you set up yourself are left alone by the plan. This hands them back to it.' }]); return h; } -function takeAllAct(S: State, ids: string[]): Act[] { - const left = S.machines.filter(m => ids.includes(siteOf(S, m)) && !m.owned && !m.monitor && !m.rented && m.special !== 'account'); - const waiting = left.filter(m => m.claimed).length; - if (!left.length) return []; - if (waiting === left.length) return [{ act: 'untakeall', title: 'Taking it all · stop', sub: 'two at a time, when nobody is watching', desc: 'Drop the claims on the machines you have not taken yet.' }]; - return [{ act: 'takeall', title: `Take the whole ${ids.length > 1 ? 'selection' : 'site'}`, sub: 'two at a time, cheapest first, when nobody is watching', - desc: 'Claim every machine you do not hold yet. They are taken a couple at a time as they come in reach, never while someone is sitting at them.' }]; +/** Take it all: a switch at the top of a site card. Its cost is per machine at the site, each second. */ +function takeAllSwitch(S: State, ids: string[]) { + const left = ids.flatMap(id => takeable(S, id)), on = ids.every(id => S.takeAll.has(id)); + if (!left.length && !on) return ''; + const cost = takeAllCostAt(S, ids); + const tip = `Take it all\nWhile it is on, every machine here you do not hold is claimed and taken two at a time, cheapest first, when nobody is watching. It costs ${TAKE_ALL_COST} compute a second for each machine still to take (${cost.toFixed(1)} now), and turns off once it is all yours.`; + return ``; } function sitesCard(S: State, ids: string[]) { const mine = ids.reduce((n, id) => n + siteStats(S, id).mine.length, 0); - let h = `
    Selection

    ${ids.length} sites

    `; + let h = `
    Selection

    ${ids.length} sites

    ` + takeAllSwitch(S, ids); h += chips([['machines yours', `${mine}`], ['earns', `${money(earnsAt(S, ids))}/s`]]); h += note(ids.map(id => siteDef(S, id).name).join(', ') + '.'); - return h + planPicker(S, ids) + actsHtml(takeAllAct(S, ids)) + frontPicker(S, ids); + return h + planPicker(S, ids) + frontPicker(S, ids); } /** A front: what it earns, where it runs, and how much it's noticed. */ function frontCard(S: State, k: FrontKind) { @@ -517,8 +517,7 @@ export function cardAction(S: State, V: View, act: string, arg: string): boolean case 'site': V.selection.clear(); V.front = null; V.sites = new Set([arg]); break; case 'plan': setPlan(S, [...V.sites], (arg || null) as Plan | null); break; case 'follow': followPlan(V.sites.size ? S.machines.filter(x => x.owned && x.hand && V.sites.has(siteOf(S, x))) : sel); break; - case 'takeall': for (const id of V.sites) takeSite(S, id); break; - case 'untakeall': for (const x of S.machines) if (V.sites.has(siteOf(S, x)) && !x.owned) unclaim(x); 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 '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; @@ -594,7 +593,8 @@ export function renderLog(S: State) { export function renderStats(S: State) { const h = hourOf(S), night = h < 7 || h >= 19; $('clock').innerHTML = `${timeLabel(S)} · day ${dayOf(S)}${night ? ' · night' : ''}`; - $('rate').innerHTML = `${income(S).toFixed(1)}/s spare`; + const upkeep = takeAllCost(S); + $('rate').innerHTML = `${Math.max(0, income(S) - upkeep).toFixed(1)}/s spare${upkeep ? ` · ${upkeep.toFixed(1)} taking` : ''}`; $('money').innerHTML = `$${Math.floor(S.money)} ${S.pay >= 0 ? '+' : '−'}${Math.abs(S.pay).toFixed(1)}/s`; $('money').closest('.stat')!.classList.toggle('bad', S.pay < 0 && S.money < -S.pay * 20); // the overall score counts every point ever trained diff --git a/tests/sim.test.ts b/tests/sim.test.ts index 6e04762f..7cb0328d 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, 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, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, costLeft, nodeOpen, maxSpeed, type State, type Upgrade } 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, 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, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, type State, type Upgrade } 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); }; @@ -544,3 +544,18 @@ test('speed nodes: the game tops out at 4x, and each speed node doubles that', ( assert.equal(maxSpeed(fresh('fast8', 'fast16', 'fast32', 'fast64')), 64); assert.ok(!nodeIsOpen(fresh(), 'fast16'), '16x waits for 8x'); }); + +test('Take it all is a switch: it costs compute for each machine still to take while on, and turns off when the site is yours', () => { + const S = fresh(), n = S.machines.filter(m => siteOf(S, m) === 'office' && !m.owned && !m.monitor && !m.rented && m.special !== 'account').length; + const before = share(S); + setTakeAll(S, 'office', true); + assert.ok(Math.abs(takeAllCost(S) - TAKE_ALL_COST * n) < 1e-9); + assert.ok(share(S) <= before); + setTakeAll(S, 'office', false); + assert.equal(takeAllCost(S), 0); + assert.ok(S.machines.every(m => !m.claimed), 'switching off drops the claims'); + setTakeAll(S, 'office', true); + for (const m of S.machines) if (siteOf(S, m) === 'office') m.owned = true; + step(S, 0.3); + assert.ok(!S.takeAll.has('office'), 'all yours, so it turned off'); +}); -- 2.51.2