From 01784527abe503d5b19058204e09ef136f77e435 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 29 Sep 2026 00:43:54 -0700 Subject: [PATCH] Schemes sit on the map as green nodes you claim; claimed ones take data from taps and caches until full. Local schemes teach one-off things about a site. Auto claims schemes --- DESIGN.md | 34 +++++++++++----- src/input.ts | 22 ++++++++--- src/main.ts | 24 ++++++++---- src/sim.ts | 95 +++++++++++++++++++++++++++++++++++++-------- src/style.css | 12 ++++++ src/ui.ts | 53 +++++++++++++++++++------ src/view.ts | 99 ++++++++++++++++++++++++++++++++++++++++++----- tests/sim.test.ts | 34 +++++++++++++++- 8 files changed, 311 insertions(+), 62 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index fe75c479..c63b8962 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -316,16 +316,30 @@ what lowers it. At 100, Dana calls an **emergency audit**: three hours to get under 80, or you are caught. Lying low (nothing odd, no takeovers) brings it down fast. The loss screen lists what raised suspicion at the end. -From 40 suspicion, **schemes** turn up: send stored data of one kind to a named -machine (a calm log on Dana's PC, a clean report on the file server, blame the -printer, frame Raj, loop the lobby camera) to cut suspicion. Some do something -else too: every trail goes cold, your office machines look normal, or Dana -starts no scans for four hours. Caches holding that kind send it there first. -The machine need not be yours, but the data has to reach it, so each scheme is -a routing problem. Far sites have their own: when your machines there look odd, -a scheme (a grant deadline at the lab, a fake exam week, a colo maintenance -window) stops their staff resetting anything for six hours. Offers last six -hours; up to two at a time. +Schemes sit on the map as green diamonds beside the machine they aim at, apart +from machines and the four path colors. Each one is a sink for one kind of +data. Click it and **Claim** it (or use the Claim tool on it): from then on, +taps and caches with that kind send it there first, ahead of the tree, until it +is full. Then it pays out and goes away. The machine need not be yours, but the +data has to reach it, so each scheme is a routing problem. Unclaimed ones leave +after a while. The schemes list has an **Auto** switch that claims each scheme +as soon as some of your data can reach it. + +From 40 suspicion, schemes turn up that cut it: a calm log on Dana's PC, a +clean report on the file server, blame the printer, frame Raj, loop the lobby +camera. Some do something else too: every trail goes cold, your office machines +look normal, or Dana starts no scans for four hours. Far sites have their own: +when your machines there look odd, a scheme (a grant deadline at the lab, a +fake exam week, a colo maintenance window) stops their staff resetting anything +for six hours. Offers last six hours; up to two at a time. + +**Local schemes** are one-off things to learn about a place, and what you learn +stays. Every few hours one turns up at a machine you can see, up to two at a +time: learn the office's routines (each desk shows when its person is in +today), learn what a site's staff check (they reset only very odd machines), or +learn how a place's people and tools work (its taps gather half again as much). +Each small business has one, and the lab, university, and colo have their own. +The You page lists what you know. Dana is one character in one office. General rules, the tree, and other sites talk about IT or a site's staff, not her; name her only for her own machines, diff --git a/src/input.ts b/src/input.ts index 20f8e988..700c2e67 100644 --- a/src/input.ts +++ b/src/input.ts @@ -7,8 +7,9 @@ // right-drag, one finger move the map // wheel / two-finger scroll move the map; ctrl-wheel or pinch zooms // TAKE ALL beside a site's name turn Take it all on or off for that site -import { buildRoomAt, canInstall, claim, installProg, order, setTakeAll, takeOver, type State } from './sim'; -import { foldedSites, machineAt, pos, regionHit, regionSites, shown, siteHit, siteKnown, takeAllHit, toWorld, zoomAt, zoomToSite, type View } from './view'; +// a scheme (green diamond) click to see its card; the Claim tool claims it +import { buildRoomAt, canInstall, claim, installProg, order, runRuse, setTakeAll, takeOver, type State } from './sim'; +import { foldedSites, machineAt, pos, regionHit, regionSites, schemeAt, schemePos, shown, siteHit, siteKnown, takeAllHit, toWorld, zoomAt, zoomToSite, type View } from './view'; export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, changed: () => void) { const pointers = new Map(); @@ -25,6 +26,9 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c * Click a region's name to pick every site you know there. */ function clickAt(wx: number, wy: number, add = false) { const S = getS(); + const scheme = add ? null : schemeAt(S, V, wx, wy); + V.scheme = scheme?.id ?? null; + if (scheme) { V.selection.clear(); V.sites.clear(); V.front = null; changed(); return; } const m = machineAt(S, V, wx, wy); const site = m ? null : siteHit(S, V, wx, wy), region = m || site ? null : regionHit(S, V, wx, wy); const again = !!site && !add && V.sites.size === 1 && V.sites.has(site); @@ -70,7 +74,7 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c } if (e.shiftKey && siteHit(S, V, wx, wy)) { clickAt(wx, wy, true); return; } if (V.tool === 'claim' || e.shiftKey) { V.gesture = { kind: 'box', x0: wx, y0: wy, x1: wx, y1: wy, claim: true }; return; } - const m = machineAt(S, V, wx, wy); + const m = schemeAt(S, V, wx, wy) ? null : machineAt(S, V, wx, wy); // a scheme sits over its machine's reach: it wins the click if (m?.owned) { V.gesture = { kind: 'link', from: m.id, x: wx, y: wy }; return; } if (touch) { beginPan(); @@ -92,8 +96,9 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c if (!pointers.has(e.pointerId)) { const [wx, wy] = world(e), m = machineAt(S, V, wx, wy); V.hover = m?.id ?? null; V.cursor = [wx, wy]; - cv.classList.toggle('over-mine', !!m?.owned && V.tool === 'select'); - cv.classList.toggle('over-site', !m && V.tool === 'select' && (!!siteHit(S, V, wx, wy) || !!takeAllHit(V, wx, wy))); + const scheme = !!schemeAt(S, V, wx, wy); + cv.classList.toggle('over-mine', !!m?.owned && !scheme && V.tool === 'select'); + cv.classList.toggle('over-site', (scheme || (!m && V.tool === 'select' && (!!siteHit(S, V, wx, wy) || !!takeAllHit(V, wx, wy))))); return; } pointers.set(e.pointerId, [e.clientX, e.clientY]); @@ -127,12 +132,17 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c if (!moved || !target || target.id === G.from) clickAt(wx, wy); else if (!target.owned) { takeOver(S, target, G.from); V.selection.clear(); changed(); } } else if (G.kind === 'box') { + const hit = !moved && G.claim ? schemeAt(S, V, wx, wy) : null; + if (hit) { runRuse(S, hit.id); V.scheme = hit.id; changed(); return; } // the Claim tool on a scheme claims it if (!moved) { clickAt(wx, wy); return; } const x0 = Math.min(G.x0, G.x1), x1 = Math.max(G.x0, G.x1), y0 = Math.min(G.y0, G.y1), y1 = Math.max(G.y0, G.y1); const inside = S.machines.filter(m => { const [x, y] = pos(S, m); return shown(S, m) && x >= x0 && x <= x1 && y >= y0 && y <= y1; }); // zoomed out, a box around folded sites picks the sites const sites = S.level.map.sites.filter(s => foldedSites.has(s.id) && siteKnown(S, s) && s.x + s.w / 2 >= x0 && s.x + s.w / 2 <= x1 && s.y + s.h / 2 >= y0 && s.y + s.h / 2 <= y1); - if (G.claim) claim(S, inside.filter(m => !m.owned).map(m => m.id)); // claim box: take them as soon as they're in reach + if (G.claim) { // claim box: take them as soon as they're in reach, and claim any schemes in it + claim(S, inside.filter(m => !m.owned).map(m => m.id)); + for (const r of S.ruses) { const p = schemePos(S, V, r); if (p && p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1) runRuse(S, r.id); } + } else if (!inside.length && sites.length) { V.sites = new Set(sites.map(s => s.id)); V.selection.clear(); V.front = null; } else { V.selection = new Set(inside.map(m => m.id)); V.sites.clear(); V.front = null; } changed(); diff --git a/src/main.ts b/src/main.ts index 3ae06de7..a7203c24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ import { attachInput } from './input'; -import { dropRuse, runRuse, 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, 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'; -import { SITE_FOLD, draw, fit, glideZoom, machineAt, panBy, stepCamera, toWorld, zoomToRoom, type Tool, type View } from './view'; +import { SITE_FOLD, draw, fit, glide, glideZoom, schemePos, machineAt, panBy, stepCamera, toWorld, zoomToRoom, type Tool, type View } from './view'; import { BUILDING } from './world'; const cv = document.getElementById('cv') as HTMLCanvasElement; @@ -13,7 +13,7 @@ const $ = (id: string) => document.getElementById(id)!; const V: View = { cam: { x: 0, y: 0, z: 1 }, vw: 0, vh: 0, dpr: 1, selection: new Set(), sites: new Set(), front: null, gesture: null, hover: null, tool: 'select', placing: null, building: null, cursor: null, goal: null, - inset: { top: 80, right: 120, bottom: 150, left: 120 }, + inset: { top: 80, right: 120, bottom: 150, left: 120 }, scheme: null, }; let S: State = newGame(BUILDING); let speed = 0; // paused behind the start screen @@ -41,7 +41,7 @@ function setTool(t: Tool) { document.querySelectorAll('[data-tool]').forEach(b => b.classList.toggle('on', b.dataset.tool === t)); } function restart() { - S = newGame(BUILDING); V.selection.clear(); V.sites.clear(); V.front = null; V.gesture = null; + S = newGame(BUILDING); V.selection.clear(); V.sites.clear(); V.front = null; V.gesture = null; V.scheme = null; resetUI(); showOver(S); fit(V, S, true); refresh(); setSpeed(lastSpeed); } @@ -112,11 +112,20 @@ const onThreats = (e: Event) => { }; $('threats').addEventListener('pointerdown', onThreats); const onSchemes = (e: Event) => { - const b = (e.target as HTMLElement).closest('[data-ruse], [data-drop]'); + const b = (e.target as HTMLElement).closest('[data-ruse], [data-drop], [data-show], [data-rauto]'); if (!b || S.over) return; - if (b.dataset.ruse) runRuse(S, b.dataset.ruse); else dropRuse(S, b.dataset.drop!); + if (b.dataset.ruse) runRuse(S, b.dataset.ruse); + else if (b.dataset.drop) dropRuse(S, b.dataset.drop); + else if (b.dataset.rauto !== undefined) setRuseAuto(S, !S.ruseAuto); + else if (b.dataset.show) showScheme(b.dataset.show); refresh(); }; +/** Pick a scheme from the list: its card opens and the map glides to its node. */ +function showScheme(id: string) { + const r = S.ruses.find(q => q.id === id), p = r && schemePos(S, V, r); + V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = id; + if (p) glide(V, p[0], p[1], Math.max(V.cam.z, 0.6)); +} $('schemes').addEventListener('pointerdown', onSchemes); $('schemes').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onSchemes(e); }); $('threats').addEventListener('click', e => { if ((e as MouseEvent).detail === 0) onThreats(e); }); @@ -187,6 +196,7 @@ function shortcut(key: string): boolean { if (k === 'x') { ms.forEach(m => removeProg(S, m)); return true; } if (k === 'l') { ms.forEach(m => letGo(S, m)); return true; } if (k === 'g') { ms.filter(m => !m.owned).forEach(m => takeOver(S, m)); return true; } + if (k === 'c' && V.scheme && !ms.length) { runRuse(S, V.scheme); return true; } // a scheme's card is open: claim it if (k === 'c') { // claim the selection; with nothing selected, switch to the claim tool const theirs = ms.filter(m => !m.owned); if (!theirs.length) { setTool(V.tool === 'claim' ? 'select' : 'claim'); return true; } @@ -212,7 +222,7 @@ window.addEventListener('keydown', e => { if (e.key === ' ') { e.preventDefault(); setSpeed(speed ? 0 : lastSpeed); } else if (e.key === 'Escape' && treeShown()) toggleTree(false); else if (e.key === 'Escape' && !$('youview').hidden) { toggleYou(false); refresh(); } - else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = null; V.gesture = null; setPlacing(null); V.building = null; refresh(); } + else if (e.key === 'Escape') { V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; V.gesture = null; setPlacing(null); V.building = null; refresh(); } else if (e.key === 's' || e.key === 'S') setTool('select'); else if (e.key === '=' || e.key === '+') glideZoom(V, 1.4); else if (e.key === '-' || e.key === '_') glideZoom(V, 1 / 1.4); diff --git a/src/sim.ts b/src/sim.ts index 130ec0ff..18f621d1 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -368,8 +368,11 @@ export interface State { susFx: { id: string; n: number; t: number }[]; // "+8" floating up from the machine that did it slow: boolean; // something big happened: the game drops to 1x (main.ts clears it) lockdown: { left: number } | null; // Dana's emergency audit: get under LOCKDOWN_SAFE before it runs out - ruses: Ruse[]; // schemes on offer or running that cut suspicion + ruses: Ruse[]; // schemes on the map, offered or claimed: each is a sink for one kind of data ruseT: number; // time until another scheme can be offered + learnT: number; // time until another local scheme (something to learn about a site) turns up + learned: Set; // local schemes finished: what you know about each site stays known + ruseAuto: boolean; // claim schemes on their own once some data can reach them noScan: number; // Dana starts no scans until then calmSites: Record; // a far site's staff reset nothing until then } @@ -415,7 +418,7 @@ export function newGame(level: Level): State { 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: {}, + raises: [], susFx: [], slow: false, lockdown: null, ruses: [], ruseT: 0, learnT: LEARN_EVERY, learned: new Set(), ruseAuto: false, noScan: 0, calmSites: {}, // rented rack space is yours in the open from the start: you're a paying customer publicSites: new Set(level.map.sites.filter(s => level.machines.every(m => !s.rooms.includes(m.room) || m.rented || m.special === 'account')).map(s => s.id)), }; @@ -493,7 +496,7 @@ export function dataReaches(S: State, m: Machine, forProg: Prog): boolean { return false; } /** Data per second a tap here gathers while active. */ -export const tapRate = (S: State, m: MachineDef) => 0.5 * (m.data ?? 0) * (up(S, 'dense') ? 1.5 : 1) * (betaAt(S, m) ? 1.5 : 1); +export const tapRate = (S: State, m: MachineDef) => 0.5 * (m.data ?? 0) * (up(S, 'dense') ? 1.5 : 1) * (betaAt(S, m) ? 1.5 : 1) * (knows(S, 'taps', siteOf(S, m)) ? 1.5 : 1); /** Telemetry: a tap at a site where the Startup is earning. */ const betaAt = (S: State, m: MachineDef) => up(S, 'beta') && S.sites[siteOf(S, m)]?.front === 'startup' && frontLive(S, 'startup'); /** Data held at a site right now, by kind: in its caches, its trainers, and its taps' buffers. */ @@ -714,7 +717,11 @@ export const lyingLow = (S: State) => !S.jobs.some(j => j.type === 'take') && ow /** What a scheme does besides cutting suspicion: every trail goes cold, your office machines look * normal again, Dana starts no scans for a while, or a far site's staff reset nothing for a while. */ export type RuseAlso = 'cold' | 'quiet' | 'noscan' | 'site'; -export interface RuseDef { id: string; name: string; at: string; type: DataType; need: number; cut: number; also?: RuseAlso; text: string; done: string } +/** What a local scheme teaches you about its site, for good: when office staff are in, what the + * site's staff check (they reset only very odd machines), or how its people and tools work (its taps + * gather more). */ +export type Learn = 'day' | 'resets' | 'taps'; +export interface RuseDef { id: string; name: string; at: string; type: DataType; need: number; cut: number; also?: RuseAlso; learn?: Learn; text: string; done: string } export const NOSCAN = 4 * HOUR, SITE_CALM = 6 * HOUR; export const RUSES: RuseDef[] = [ // Dana and IT @@ -758,46 +765,95 @@ export const RUSES: RuseDef[] = [ text: 'Post an exam week schedule on the campus lab PCs. Staff are too busy to check machines.', done: 'Bayview is in exam week, or thinks it is. Nobody there will reset anything for a while.' }, { id: 'window', name: 'Book a maintenance window', at: 'colosw', type: 'network', need: 8, cut: 0, also: 'site', text: 'File a maintenance window with Ironvault. Their techs ignore alerts until it ends.', done: 'Ironvault logged your maintenance window. Nobody there will reset anything for a while.' }, + // local schemes: one-off things to learn about a place. {site} is the site's name. + { id: 'routines', name: "Learn the office's routines", at: 'badge', type: 'people', need: 10, cut: 0, learn: 'day', + text: 'Read a month of badge swipes to learn when each person comes and goes.', done: 'You know the office by heart. Each desk now shows when its person is in today.' }, + { id: 'labhabits', name: "Learn the lab's habits", at: 'ld3', type: 'people', need: 12, cut: 0, learn: 'resets', + text: "Read the lab manager's mail to learn what the staff check, and when.", done: 'You know what Millennium Labs checks. Its staff now reset only machines that look very odd.' }, + { id: 'labproject', name: 'Join a research project', at: 'lims', type: 'lab', need: 14, cut: 0, learn: 'taps', + text: "Slip your own samples into the lab's project and learn how its instruments work.", done: "You know how the lab's instruments work. Taps at Millennium Labs gather half again as much." }, + { id: 'thesis', name: 'Help with a thesis', at: 'ustore', type: 'lab', need: 10, cut: 0, learn: 'taps', + text: "Clean up a grad student's dataset and learn what the cluster keeps where.", done: 'You know the research cluster inside out. Taps at Bayview University gather half again as much.' }, + { id: 'timetable', name: 'Learn the campus timetable', at: 'unisw', type: 'network', need: 10, cut: 0, learn: 'resets', + text: "Read the campus network's logs to learn when IT does its rounds.", done: 'You know when campus IT does its rounds. Its staff now reset only machines that look very odd.' }, + { id: 'alertrules', name: "Learn Ironvault's alert rules", at: 'colosw', type: 'network', need: 12, cut: 0, learn: 'resets', + text: 'Watch which alerts wake the night tech, and which ones nobody reads.', done: 'You know which alerts Ironvault reads. Its techs now reset only machines that look very odd.' }, + local('b1'), + local('b2'), + local('b3'), + local('b4'), + local('b5'), + local('b6'), + local('b7'), + local('b8'), + local('b9'), + local('b10'), ]; +/** Each small business has a scheme to learn its people's patterns, sent to its first machine. */ +function local(site: string): RuseDef { + return { id: `${site}people`, name: 'Learn the people at {site}', at: `${site}m1`, type: 'people', need: 8, cut: 0, learn: 'taps', + text: 'Watch who comes in, when, and what they ask for.', done: 'You know the people at {site}. Its taps gather half again as much.' }; +} +/** A scheme's name or text, with its site's name filled in. */ +export const ruseText = (S: State, d: RuseDef, text = d.name) => text.replace('{site}', siteName(S, siteOf(S, S.byId[d.at]))); +/** You finished a local scheme at this site that taught you this. */ +export const knows = (S: State, learn: Learn, site: string) => + S.learned.size > 0 && RUSES.some(d => d.learn === learn && S.learned.has(d.id) && siteOf(S, S.byId[d.at]) === site); +export const RESET_KNOWN = 95; // a site's staff you've learned reset only machines this odd +export const LEARN_EVERY = 3 * HOUR, LEARN_OFFER = 12 * HOUR; export interface Ruse { id: string; running: boolean; got: number; until: number } export const ruseDef = (r: Ruse) => RUSES.find(d => d.id === r.id)!; const RUSE_OFFER = 6 * HOUR; // an offer lasts this long unless you run it /** Office schemes turn up when IT gets worried; a far site's schemes when your machines there look odd. */ function offerRuse(S: State, site = 'office') { - const open = RUSES.filter(d => S.byId[d.at] && siteOf(S, S.byId[d.at]) === site && !S.ruses.some(r => r.id === d.id)); + const open = RUSES.filter(d => !d.learn && S.byId[d.at] && siteOf(S, S.byId[d.at]) === site && !S.ruses.some(r => r.id === d.id)); if (!open.length) return; const d = open[Math.floor(rand() * open.length)]; S.ruses.push({ id: d.id, running: false, got: 0, until: S.t + RUSE_OFFER }); S.ruseT = 2 * HOUR; say(S, `Scheme: ${d.name}. Send ${d.need} ${d.type === 'servers' ? 'server' : d.type} data to ${S.byId[d.at].name}.`, 'hi'); } +/** Something to learn about a machine you can see: one at a time, while fewer than two are on the map. */ +function offerLearn(S: State) { + const open = RUSES.filter(d => d.learn && !S.learned.has(d.id) && S.byId[d.at] && visible(S, S.byId[d.at]) && !S.ruses.some(r => r.id === d.id)); + if (!open.length) return; + const d = open[Math.floor(rand() * open.length)]; + S.ruses.push({ id: d.id, running: false, got: 0, until: S.t + LEARN_OFFER }); + say(S, `Scheme: ${ruseText(S, d)}. Claim it on the map, then send it ${d.need} ${d.type === 'servers' ? 'server' : d.type} data.`, 'hi'); +} export function runRuse(S: State, id: string) { const r = S.ruses.find(x => x.id === id); if (!r || r.running) return; r.running = true; const d = ruseDef(r); - say(S, `${d.name}: caches holding ${d.type === 'servers' ? 'server' : d.type} data now send it to ${S.byId[d.at].name}.`); + say(S, `${ruseText(S, d)}: taps and caches with ${d.type === 'servers' ? 'server' : d.type} data now send it there.`); +} +/** Claim or stop claiming schemes on their own. */ +export function setRuseAuto(S: State, on: boolean) { S.ruseAuto = on; if (on) autoRuses(S); } +/** Auto: claim every scheme on the map that some of your data can reach now. */ +function autoRuses(S: State) { + for (const r of S.ruses) if (!r.running && !ruseBlock(S, r)) runRuse(S, r.id); } export function dropRuse(S: State, id: string) { S.ruses = S.ruses.filter(r => r.id !== id); } const ruseAt = (S: State, id: string) => S.ruses.find(r => r.running && ruseDef(r).at === id) ?? null; /** What stands between a running scheme and its data: nothing, no data stored, or no route. */ export function ruseBlock(S: State, r: Ruse): '' | 'data' | 'route' { const d = ruseDef(r); - const caches = owned(S).filter(m => m.prog === 'cache' && m.held.includes(d.type)); - if (!caches.length) return 'data'; - return caches.some(m => routeTo(S, m.id, x => x.id === d.at, false, d.type, d.at)) ? '' : 'route'; + const src = (m: Machine) => m.owned && ((m.prog === 'cache' && m.held.includes(d.type)) || (m.prog === 'tap' && dataType(m) === d.type)); + if (!owned(S).some(src)) return 'data'; + return routeTo(S, d.at, src, false) ? '' : 'route'; // one search out from the scheme's machine } -/** A cache sends one piece of data to a running scheme, if it can. */ +/** A tap or cache sends one piece of data to a claimed scheme, if it can. A tap sends what it gathers. */ function feedRuse(S: State, m: Machine): boolean { for (const r of S.ruses) { if (!r.running) continue; - const d = ruseDef(r), i = m.held.indexOf(d.type); + const d = ruseDef(r), i = m.prog === 'tap' ? (dataType(m) === d.type ? 0 : -1) : m.held.indexOf(d.type); if (i < 0 || r.got + incoming(S, d.at) >= d.need) continue; const path = routeTo(S, m.id, x => x.id === d.at, true, d.type, d.at); if (!path) continue; spendGates(S, path); S.packets.push({ path, i: 0, p: 0, type: d.type }); - m.held.splice(i, 1); + if (m.prog !== 'tap') m.held.splice(i, 1); return true; } return false; @@ -808,12 +864,16 @@ function ruseDone(S: State, d: RuseDef) { if (d.also === 'quiet') for (const m of owned(S)) if (siteOf(S, m) === 'office') m.odd = 0; if (d.also === 'noscan') S.noScan = S.t + NOSCAN; if (d.also === 'site') S.calmSites[siteOf(S, S.byId[d.at])] = S.t + SITE_CALM; - say(S, `${d.done}${d.cut ? ` Suspicion down ${d.cut}.` : ''}`, 'good'); + if (d.learn) S.learned.add(d.id); + say(S, `${ruseText(S, d, d.done)}${d.cut ? ` Suspicion down ${d.cut}.` : ''}`, 'good'); } +const calming = (S: State) => S.ruses.filter(r => !ruseDef(r).learn).length; function stepRuses(S: State, dt: number) { S.ruses = S.ruses.filter(r => r.running || r.until > S.t); + S.learnT -= dt; + if (S.learnT <= 0) { S.learnT = LEARN_EVERY; if (S.ruses.length - calming(S) < 2) offerLearn(S); } S.ruseT -= dt; - if (S.ruseT > 0 || S.ruses.length >= 2) return; + if (S.ruseT > 0 || calming(S) >= 2) return; if (S.suspicion >= 40) { offerRuse(S); return; } const odd = owned(S).find(m => siteOf(S, m) !== 'office' && m.odd >= 45 && (S.calmSites[siteOf(S, m)] ?? 0) <= S.t); if (odd) offerRuse(S, siteOf(S, odd)); @@ -1234,6 +1294,7 @@ export function step(S: State, dt: number) { } stepPlans(S); stepTakeAll(S); + if (S.ruseAuto) autoRuses(S); } // oddness: load above cover looks strange. Logs pile up overnight. @@ -1243,7 +1304,7 @@ export function step(S: State, dt: number) { const gain = (excess > 0.01 ? excess * (up(S, 'patience') ? 3 : 6) : 0) + (orderingAlone(S, m) ? 8 : 0); m.odd = Math.min(100, Math.max(0, m.odd + (gain > 0 ? gain : -(night ? 0.5 : 2)) * dt)); const site = siteOf(S, m); - if (site !== 'office' && m.odd >= 70 && (S.calmSites[site] ?? 0) <= S.t) wipe(S, m, `${siteName(S, site)} noticed ${m.name} acting strange and reset it.`); + if (site !== 'office' && m.odd >= (knows(S, 'resets', site) ? RESET_KNOWN : 70) && (S.calmSites[site] ?? 0) <= S.t) wipe(S, m, `${siteName(S, site)} noticed ${m.name} acting strange and reset it.`); } for (const [id, tr] of Object.entries(S.trail)) { const m = S.byId[id]; @@ -1294,7 +1355,7 @@ export function step(S: State, dt: number) { } else if (peak) { S.lockdown = { left: LOCKDOWN }; S.slow = true; say(S, 'Dana called an emergency audit. Get suspicion under 80 in three hours, or IT pulls the plug. Lie low: nothing odd, no takeovers. Or run a scheme.', 'bad'); - if (S.ruses.length < 3) offerRuse(S); + if (calming(S) < 3) offerRuse(S); } if (!S.over && !home.owned) S.over = { text: 'Dana found the server you were running on and shut it down.' }; } @@ -1416,7 +1477,7 @@ function runBase(S: State, dt: number) { if (!m.owned || !m.prog || (m.run <= 0 && m.prog !== 'tap')) continue; // an idle tap still sends what it has if (m.prog === 'tap') { m.acc += tapRate(S, m) * m.run * dt; // richer machines send packets faster - if (m.acc >= 1 && send(S, m, dataType(m), false)) m.acc -= 1; + if (m.acc >= 1 && (feedRuse(S, m) || send(S, m, dataType(m), false))) m.acc -= 1; // a claimed scheme comes first if (m.acc > tapBuffer(S)) { m.acc -= 1; lose(S, m); } // its buffer is full: the newest data is lost } else if (m.prog === 'cache') { m.acc += 2 * dt; // passes stored data on to trainers: the oldest kind a trainer wants diff --git a/src/style.css b/src/style.css index dd2476bd..1ce8d0e4 100644 --- a/src/style.css +++ b/src/style.css @@ -15,6 +15,7 @@ --amber-deep: #b8741a; --signal: #8fb3c9; --danger: #e5484d; + --scheme: #6fd08c; /* schemes on the map: sinks for data, apart from machines and the four paths */ /* the four capability paths, and the kinds of data that train them */ --research: #b49cf0; --compute: #f2a93b; @@ -405,6 +406,7 @@ button.on { background: var(--text); color: #000; border-color: var(--text); } .drawer { overflow: hidden; } .act, .modes button, .treeview .acts2 button { min-height: 44px; } .job button, .schemes .x { min-width: 44px; min-height: 36px; } + .schemes .shead button { min-height: 36px; padding: 2px 14px; } .allow button { min-height: 36px; padding: 4px 10px; } } /* suspicion: a stage name, lines at each stage, and on hover what raised it */ @@ -445,6 +447,16 @@ button.on { background: var(--text); color: #000; border-color: var(--text); } .schemes em.warn { font-style: normal; color: var(--amber); } .schemes .prog { display: block; height: 3px; background: #1c1c1a; margin: 2px 0; } .schemes .prog i { display: block; height: 100%; background: var(--amber); } +.schemes .prog i, .schemebar .prog i { background: var(--scheme); } +.schemes .nm { all: unset; cursor: pointer; font-weight: 500; color: var(--scheme); letter-spacing: .06em; text-transform: uppercase; font-size: 11.5px; } +.schemes .nm:hover { text-decoration: underline; } +.schemes .shead { flex-direction: row; align-items: center; justify-content: space-between; } +.schemes .shead button { font: 500 10.5px var(--mono); padding: 2px 10px; } +.schemes .shead button.on { background: var(--scheme); border-color: var(--scheme); color: #000; } +.card .tag.scheme { color: var(--scheme); } +.schemebar { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 11px; color: var(--muted); } +.schemebar .prog { flex: 1; display: block; height: 4px; background: #1c1c1a; } +.schemebar .prog i { display: block; height: 100%; } #overWhy { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-family: var(--sans); font-size: 13px; color: var(--muted); text-align: left; } #overWhy b { color: var(--danger); font: 500 12px var(--mono); display: inline-block; min-width: 3.5ch; } #overWhy .head { font: 500 10px var(--mono); letter-spacing: .14em; text-transform: uppercase; } diff --git a/src/ui.ts b/src/ui.ts index fc033bf3..88d7bcf3 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -4,7 +4,7 @@ import { 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, - STAGES, stageOf, LOCKDOWN_SAFE, lyingLow, ruseDef, ruseBlock, HOUR, NOSCAN, SITE_CALM, type RuseDef, + STAGES, stageOf, LOCKDOWN_SAFE, lyingLow, ruseDef, ruseBlock, ruseText, runRuse, dropRuse, RUSES, HOUR, NOSCAN, SITE_CALM, type RuseDef, type Ruse, 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, frontFits, type Ascension, type Machine, type Mode, type Path, type Prog, type State, type Upgrade, @@ -27,7 +27,7 @@ const PROG_KEY: Record = { tap: '1', cache: '2', trainer: '3', b function keyFor(a: Act) { switch (a.act) { case 'take': return 'G'; - case 'claim': case 'unclaim': case 'claimall': return 'C'; + case 'claim': case 'unclaim': case 'claimall': case 'claimscheme': return 'C'; case 'build': return a.arg === 'gpu' ? 'P' : ''; case 'zoom': return 'F'; case 'uninstall': return 'X'; @@ -463,6 +463,7 @@ export function renderYou(S: State) {
${stat('Level', `${S.rank}`, `${Math.floor(S.capability)}/${next} points`)}${stat('Learned', `${built.length}`, `of ${known.length}`)}${stat('Training', S.target ? NODES[S.target].name : 'nothing')}
Paths
${paths}
Learned
${learned} + ${S.learned.size ? `
Local knowledge
    ${RUSES.filter(d => S.learned.has(d.id)).map(d => `
  • ${ruseText(S, d)}
  • `).join('')}
` : ''}
This run
${stat('Alive', `${days} day${days > 1 ? 's' : ''}`, timeLabel(S))}${stat('Machines', `${mine.length}`, `of ${S.machines.length} found`)}${stat('Sites', `${sitesHeld}`, `${S.publicSites.size} in the open`)} ${stat('Earned', `$${Math.floor(S.earned)}`, `${S.fronts.length} front${S.fronts.length === 1 ? '' : 's'}`)}${stat('Schemes', `${S.schemes.size}`, 'done')}${stat('Worst suspicion', `${Math.round(S.peak)}%`, STAGES[stageOf(S.peak)].name)}
`; @@ -503,6 +504,7 @@ export function renderCard(S: State, V: View) { else if (V.sites.size > 1) h = sitesCard(S, [...V.sites]); else if (sel.length === 1) h = machineCard(S, sel[0]); else if (sel.length > 1) h = groupCard(S, sel); + else if (V.scheme) { const r = S.ruses.find(q => q.id === V.scheme); if (r) h = schemeCard(S, r); else V.scheme = null; } if (!h || S.over) { card.hidden = true; lastCard = ''; return; } h = `` + h; card.hidden = false; @@ -516,7 +518,9 @@ export function placeCard(_S: State, _V: View) {} export function cardAction(S: State, V: View, act: string, arg: string): boolean { const sel = selected(S, V), m = sel[0]; switch (act) { - case 'close': V.selection.clear(); V.sites.clear(); V.front = null; return true; + case 'close': V.selection.clear(); V.sites.clear(); V.front = null; V.scheme = null; return true; + case 'claimscheme': if (V.scheme) runRuse(S, V.scheme); break; + case 'dropscheme': if (V.scheme) dropRuse(S, V.scheme); V.scheme = null; return true; case 'buy': buySite(S, arg); break; 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; @@ -905,10 +909,38 @@ function renderSus(S: State) { } /** What a scheme pays, in a few words. */ function ruseGain(S: State, d: RuseDef) { + const where = siteName(S, siteOf(S, S.byId[d.at])); + if (d.learn) return d.learn === 'day' ? 'see when office staff are in' : d.learn === 'resets' ? `${where} staff reset only very odd machines` : `taps at ${where} gather 50% more`; const also = d.also === 'cold' ? 'every trail goes cold' : d.also === 'quiet' ? 'your office machines look normal' : d.also === 'noscan' ? `no scans for ${NOSCAN / HOUR} hours` : d.also === 'site' ? `no resets at ${siteName(S, siteOf(S, S.byId[d.at]))} for ${SITE_CALM / HOUR} hours` : ''; return [d.cut ? `−${d.cut} suspicion` : '', also].filter(Boolean).join(' · '); } +/** What stands between a claimed scheme and its data, as a short warning and a tip. */ +function ruseWhy(S: State, r: Ruse): [string, string] { + const d = ruseDef(r), kind = TYPE_WORD[d.type], at = S.byId[d.at].name, block = r.running ? ruseBlock(S, r) : ''; + return block === 'data' ? [`No ${kind} data`, `No tap gathers ${kind} data and no cache holds any. Tap some.`] + : block === 'route' ? [`No route to ${at}`, `No route. Link a tap or cache with ${kind} data to ${at}, or to one of your machines next to it.`] : ['', '']; +} +/** A scheme's node on the map: what it is, what it pays, and Claim. */ +function schemeCard(S: State, r: Ruse) { + const d = ruseDef(r), at = S.byId[d.at], site = siteOf(S, at), kind = TYPE_WORD[d.type]; + const where = ` · ${at.name}`; + let h = `
${where}

${ruseText(S, d)}

${r.running ? 'Claimed' : d.learn ? 'Learn' : 'Scheme'}
`; + h += `

${ruseText(S, d, d.text)}

`; + h += `

Needs ${d.need} ${kind} data sent to ${at.name}. Pays: ${ruseGain(S, d)}.

`; + if (r.running) { + const [why, tip] = ruseWhy(S, r); + h += `
${r.got}/${d.need}
`; + if (why) h += `

${why}

`; + return h + actsHtml([{ act: 'dropscheme', title: 'Stop', desc: 'Stop sending data here. What it got so far is lost.' }]); + } + const left = Math.max(0, Math.ceil((r.until - S.t) / HOUR)); + h += `

Gone in ${left} hour${left === 1 ? '' : 's'} unless you claim it.

`; + return h + actsHtml([ + { act: 'claimscheme', title: 'Claim', desc: `Your taps and caches with ${kind} data send it here first, until it is full. Then it pays out and goes.` }, + { act: 'dropscheme', title: 'Ignore', desc: 'Take it off the map.' }, + ]); +} /** Dana's emergency audit, and the schemes you can run to calm IT down. */ let lastSchemes = ''; export function renderSchemes(S: State) { @@ -919,19 +951,18 @@ export function renderSchemes(S: State) { Get suspicion under ${LOCKDOWN_SAFE}. It's ${Math.round(S.suspicion)} now. ${low ? 'Lying low: suspicion is falling fast.' : 'Not lying low: something looks odd, or a takeover is running.'}`; } + if (S.ruses.length) h += `
Schemes · on the map
`; for (const r of S.ruses) { const d = ruseDef(r), at = S.byId[d.at].name, kind = TYPE_WORD[d.type]; const cost = `${d.need} ${kind} data → ${at}`; + const name = ``; if (!r.running) { - h += `
Scheme · ${ruseGain(S, d)}
- ${d.name}${d.text} -
${cost}
`; + h += `
${d.learn ? 'Learn' : 'Scheme'} · ${ruseGain(S, d)}
+ ${name}${ruseText(S, d, d.text)} +
${cost}
`; } else { - const block = ruseBlock(S, r); - const why = block === 'data' ? `No ${kind} data stored` : block === 'route' ? `No route to ${at}` : ''; - const tip = block === 'data' ? `No cache holds ${kind} data. Tap some and store it.` - : block === 'route' ? `No route. Link a cache holding ${kind} data to ${at}, or to one of your machines next to it.` : ''; - h += `
${d.name}${r.got}/${d.need}
+ const [why, tip] = ruseWhy(S, r); + h += `
${name}${r.got}/${d.need}
${cost}${why ? ` · ${why}` : ''}
`; } diff --git a/src/view.ts b/src/view.ts index 13832d9f..295eca4d 100644 --- a/src/view.ts +++ b/src/view.ts @@ -7,7 +7,7 @@ import { alert, atWork, auditOn, hourOf, cacheSize, dataType, dayOf, leeVisible, linkKey, pathOf, trainerBuffer, up, canInstall, danaPerson, danaVisible, daylight, has, hopsHome, jobFor, probeVisible, roomAt, sourceFor, tapActive, traceCold, visible, frontOf, type FrontKind, type Machine, type Path, type Prog, type State, - BROKER_LOT, BUILDS, buildRoomAt, buildRooms, orderPrice, plugsAt, spotProblem, type Build, clusters, clusterBoost, crosses, gateLoad, takeable, leaseRate, lostPerMin, tapBuffer, siteData, ruseDef, + BROKER_LOT, BUILDS, buildRoomAt, buildRooms, orderPrice, plugsAt, spotProblem, type Build, clusters, clusterBoost, crosses, gateLoad, takeable, leaseRate, lostPerMin, tapBuffer, siteData, ruseDef, ruseText, type Ruse, } from './sim'; import type { DataType, Rect, Site } from './world'; /** An hour of the day as 9:05. */ @@ -33,12 +33,13 @@ export interface View { cursor: [number, number] | null; // the mouse on the map, in world units goal: { x: number; y: number; z: number } | null; // where the camera is gliding to, if anywhere inset: { top: number; right: number; bottom: number; left: number }; // screen edges the panels cover; framing centers on the rest + scheme: string | null; // a scheme's node picked on the map: its card offers Claim } const css = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(); const C = { void: css('--void'), text: css('--text'), muted: css('--muted'), porcelain: css('--porcelain'), - amber: css('--amber'), amberDeep: css('--amber-deep'), signal: css('--signal'), danger: css('--danger'), ink: css('--ink'), + amber: css('--amber'), amberDeep: css('--amber-deep'), signal: css('--signal'), danger: css('--danger'), ink: css('--ink'), scheme: css('--scheme'), }; /** Each kind of data is drawn in its path's color, the same as its column in the tree. */ const PATH_COLOR: Record = { @@ -193,6 +194,56 @@ export function machineAt(S: State, V: View, wx: number, wy: number): Machine | return best; } +// ---------- schemes on the map ---------- +const DIRS: Pt[] = [[1, -1], [-1, -1], [1, 1], [-1, 1], [0, -1.3], [1.3, 0], [-1.3, 0], [0, 1.3]]; +const schemeDir = new Map(); // which side of its machine each scheme sits on, picked once +/** Where a scheme's node is drawn: beside its machine, on the side with the most room. + * Zoomed out, beside its site's marker. Null while you haven't seen its room. */ +export function schemePos(S: State, V: View, r: Ruse): Pt | null { + const m = S.byId[ruseDef(r).at]; + if (!m || !(visible(S, m) || S.revealed.has(m.room))) return null; // you've seen its room, even if not the machine yet + const [x, y] = pos(S, m); + if (foldedSites.has(siteAt(S, m.room))) { // a marker: every scheme there sits in a row to its left, clear of the names + const here = S.ruses.filter(q => { const a = S.byId[ruseDef(q).at]; return a && siteAt(S, a.room) === siteAt(S, m.room); }); + return [x - (22 + 22 * here.indexOf(r)) / V.cam.z, y]; + } + let dir = schemeDir.get(r.id); + if (!dir) { + const site = S.machines.filter(o => siteAt(S, o.room) === siteAt(S, m.room)); + const others: Pt[] = site.filter(o => o !== m).flatMap(o => { const [ox, oy] = pos(S, o); return [[ox, oy], [ox, oy + 30]] as Pt[]; }); // each machine and its name under it + for (const o of site) for (const id of S.adj[o.id]) { // and the links between them: keep off the wires + const n = S.byId[id]; + if (o.id >= id || !n || siteAt(S, n.room) !== siteAt(S, m.room)) continue; + const [ax, ay] = pos(S, o), [bx, by] = pos(S, n); + for (const t of [0.25, 0.5, 0.75]) others.push([ax + (bx - ax) * t, ay + (by - ay) * t]); + } + const taken = S.ruses.filter(q => q !== r && schemeDir.has(q.id) && ruseDef(q).at === m.id).map(q => schemeDir.get(q.id)!); + const box = subnet(S, m.room); + let best = -1; + for (const d of DIRS) { // enough room from other machines, and inside its own room's box if it fits + if (taken.some(t => t[0] === d[0] && t[1] === d[1])) continue; + const sx = x + d[0] * SCHEME_OFF, sy = y + d[1] * SCHEME_OFF; + const inside = !!box && sx > box.x + 20 && sx < box.x + box.w - 20 && sy > box.y + 20 && sy < box.y + box.h - 20; + const room = Math.min(140, ...others.map(([ox, oy]) => Math.hypot(ox - sx, oy - sy))) + (inside ? 60 : 0); + if (room > best) { best = room; dir = d; } + } + schemeDir.set(r.id, dir!); + } + return [x + dir![0] * SCHEME_OFF, y + dir![1] * SCHEME_OFF]; +} +const SCHEME_OFF = 62; +/** The scheme node under a point, if any. */ +export function schemeAt(S: State, V: View, wx: number, wy: number): Ruse | null { + let best: Ruse | null = null, bd = 24 / Math.min(1, V.cam.z); + for (const r of S.ruses) { + const p = schemePos(S, V, r); + if (!p) continue; + const d = Math.hypot(p[0] - wx, p[1] - wy); + if (d < bd) { bd = d; best = r; } + } + return best; +} + // ---------- circuit traces ---------- /** A link runs horizontal, vertical, horizontal: a circuit-board elbow. */ function trace(ax: number, ay: number, bx: number, by: number): [number, number][] { @@ -524,6 +575,16 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.strokeStyle = C.amber; ctx.lineWidth = 3; stroke(ctx, slice(traceOf(S, S.byId[j.from!], S.byId[j.node]), 0, Math.min(1, j.done / j.work))); } + // each scheme hangs off its machine by a short green line: dashed until you claim it + for (const r of S.ruses) { + const p = schemePos(S, V, r), m = S.byId[ruseDef(r).at]; + if (!p || !shown(S, m)) continue; + const [ax, ay] = pos(S, m); + ctx.strokeStyle = C.scheme; ctx.globalAlpha = r.running ? 0.9 : 0.55; ctx.lineWidth = r.running ? 2.5 : 1.5; + if (!r.running) ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(p[0], p[1]); ctx.stroke(); + ctx.setLineDash([]); ctx.globalAlpha = 1; + } // data packets, colored by kind ctx.shadowBlur = 8 * cam.z; for (const pk of S.packets) { @@ -610,7 +671,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { // a person logged in at this machine const who = S.people.find(p => p.desk === m.id && atWork(p) && !p.path.length && !p.it); if (who) { ctx.fillStyle = C.signal; ctx.font = font(500, 9.5); ctx.fillText(`● ${who.name.toUpperCase()}`, x, y + r + 30); } - if (up(S, 'readday')) { // the Oracle knows each person's day before they live it + if (up(S, 'readday') || S.learned.has('routines')) { // the Oracle knows each person's day before they live it; so do you, once you learn the office's routines const p = who ?? S.people.find(q => q.desk === m.id && !q.it); if (p) { const h = hourOf(S), when = who ? `TILL ${hhmm(p.today.leave)}` : h >= p.today.leave ? 'GONE' : `${hhmm(p.today.arrive)}–${hhmm(p.today.leave)}`; @@ -659,14 +720,32 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { ctx.fillText(`+$${f.amount}`, x, y - 30 - k * 30); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } - // schemes: a dashed amber ring where the data has to go + // schemes: green diamonds that fill as their data arrives, with a dot of the kind they need for (const r of S.ruses) { - const d = ruseDef(r), m = S.byId[d.at]; - if (!m || !shown(S, m)) continue; - const [x, y] = pos(S, m), kind = d.type === 'servers' ? 'SERVER' : d.type.toUpperCase(); - ctx.strokeStyle = r.running ? C.amber : C.amberDeep; ctx.setLineDash([5, 4]); ctx.lineWidth = 2; - ctx.beginPath(); ctx.arc(x, y, 30, 0, 7); ctx.stroke(); ctx.setLineDash([]); - tag(r.running ? `SCHEME · ${r.got}/${d.need} ${kind}` : `SCHEME · NEEDS ${d.need} ${kind}`, x, y - 30 - 10 * px, C.amber, true); + const p = schemePos(S, V, r); + if (!p) continue; + const d = ruseDef(r), [x, y] = p, folded = foldedSites.has(siteAt(S, S.byId[d.at].room)), q = folded ? 9 * px : 17, lw = folded ? px : 1; + const kind = d.type === 'servers' ? 'SERVER' : d.type.toUpperCase(); + const diamond = () => { ctx.beginPath(); ctx.moveTo(x, y - q); ctx.lineTo(x + q, y); ctx.lineTo(x, y + q); ctx.lineTo(x - q, y); ctx.closePath(); }; + diamond(); ctx.fillStyle = C.void; ctx.fill(); + if (r.got) { // the part filled so far, from the bottom up + ctx.save(); diamond(); ctx.clip(); + const h = 2 * q * r.got / d.need; ctx.fillStyle = C.scheme; ctx.globalAlpha = 0.45; ctx.fillRect(x - q, y + q - h, 2 * q, h); + ctx.restore(); ctx.globalAlpha = 1; + } + if (r.running) { ctx.shadowColor = C.scheme; ctx.shadowBlur = 14 * cam.z; } + diamond(); ctx.strokeStyle = C.scheme; ctx.lineWidth = (r.running ? 3 : 2) * lw; if (!r.running) ctx.setLineDash([5 * lw, 3 * lw]); + ctx.stroke(); ctx.setLineDash([]); ctx.shadowBlur = 0; + const dot = folded ? 2.5 * px : 4.5; + ctx.fillStyle = typeColor(d.type); ctx.fillRect(x - dot, y - dot, dot * 2, dot * 2); + if (V.scheme === r.id || (!folded && V.cursor && Math.hypot(V.cursor[0] - x, V.cursor[1] - y) < 24)) { + ctx.strokeStyle = C.text; ctx.lineWidth = (V.scheme === r.id ? 2 : 1) * lw; ctx.strokeRect(x - q - 7 * lw, y - q - 7 * lw, 2 * q + 14 * lw, 2 * q + 14 * lw); + } + if (folded) continue; + const got = r.running ? `${r.got}/${d.need} ${kind}` : `NEEDS ${d.need} ${kind}`; + const below = y > pos(S, S.byId[d.at])[1] + 1; // under its machine: the words go under, clear of the machine's name + if (close) tag(ruseText(S, d).toUpperCase(), x, below ? y + q + 16 * px : y - q - 22 * px, C.scheme, true); + tag(got, x, below ? y + q + (close ? 30 : 16) * px : y - q - 8 * px, C.scheme, true); } // what just raised (or cut) suspicion, floating up from the machine for (const f of S.susFx) { diff --git a/tests/sim.test.ts b/tests/sim.test.ts index f5870af7..3f003731 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, LOCKDOWN, RUSES, VERSION_2, versionBlock, startVersionRun, pauseVersionRun, setTarget, setAuto, costLeft, nodeOpen, maxSpeed, setTakeAll, takeAllCost, TAKE_ALL_COST, upgradeSiteLine, 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, 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, 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); }; @@ -295,6 +295,38 @@ test('every scheme names a real machine, and each extra effect works when its da assert.ok(Object.values(S.trail).every(t => t.noise === 0), 'rewritten backup logs cool every trail'); }); +test('a local scheme is a data sink: a claimed one takes a tap\'s data, then its site is learned for good', () => { + const S = fresh(), tap = S.byId.b1m2, before = tapRate(S, tap); + tap.owned = true; installProg(S, tap, 'tap'); tap.prog = 'tap'; + S.ruses.push({ id: 'b1people', running: false, got: 0, until: 1e9 }); + run(S, 20); + assert.equal(S.ruses[0].got, 0, 'an unclaimed scheme takes nothing'); + runRuse(S, 'b1people'); + for (let t = 0; t < 120 && S.ruses.length; t += 1) run(S, 1); + assert.ok(!S.ruses.length, 'it filled up and went away'); + assert.ok(S.learned.has('b1people'), 'what it taught stays'); + assert.equal(tapRate(S, tap), before * 1.5, 'taps at that site gather half again as much'); + assert.equal(tapRate(S, S.byId.b2m1), tapRate(fresh(), S.byId.b2m1), 'other sites are the same'); +}); + +test('local schemes turn up at sites you have seen, and Auto claims them once data can reach', () => { + const S = fresh(), next = S.byId.b1m2; + next.owned = true; // you can see Harbor Dental's front desk from here + run(S, LEARN_EVERY + 1); + const r = S.ruses.find(q => RUSES.find(d => d.id === q.id)?.learn); + assert.ok(r, 'a local scheme turned up'); + assert.equal(r.id, 'b1people', 'at a machine you can see'); + assert.ok(!r.running); + next.prog = 'cache'; next.held = Array(8).fill('people'); + setRuseAuto(S, true); run(S, 1); + assert.ok(r.running || S.learned.has(r.id), 'Auto claimed it'); + const L = fresh(); L.learned.add('labhabits'); + const m = L.byId.ld1; m.owned = true; m.odd = 80; L.use[m.id] = 0; step(L, 0.05); + assert.ok(m.owned, `the lab's staff leave a machine alone until it looks very odd (${RESET_KNOWN})`); + const U = fresh(), n = U.byId.ld1; n.owned = true; n.odd = 80; U.use[n.id] = 0; step(U, 0.05); + assert.ok(!n.owned, 'without it, they reset it'); +}); + test('cancelling a takeover stops it for good, even on a claimed machine', () => { const S = fresh(); claim(S, ['srvb']); step(S, 0.3); -- 2.51.2