diff --git a/DESIGN.md b/DESIGN.md index c95304f6..1bb43f48 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -280,7 +280,12 @@ servers follow day and night; normal work gives your programs cover. **Blend in** uses only that cover, **Max** works harder and looks odd, and **Off** does nothing. The load bars show normal work, your work, and what is exposed. Takeovers leave trails. Automatic cleanup spends a share of spare compute on -fresh trails and marks the one it is scrubbing. Printers are mostly useless, +fresh trails and marks the one it is scrubbing. The office is full of **gadgets**: smart bulbs, a coffee machine, a badge reader, +heat sensors. Each makes half a unit of compute, draws as a small square, and is quick to +take. Owning them doesn't make the next takeover harder, and a claim box sweeps up every +gadget in reach at once. Machines draw bigger the more compute they make. Devices idle at a +tenth of their compute at night, so a printer is never worth nothing. +Printers are mostly useless, but everyone blames the printer: if IT would wipe one of your machines in a room where you hold a printer, they reset the printer instead, once. Sealed rooms quiet trails without hiding odd machines. When Dana follows a trail, **Let go** of a machine on it (key L): you lose that diff --git a/src/sim.ts b/src/sim.ts index f8b70790..d612e5df 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -386,6 +386,10 @@ const fresh = (d: MachineDef, mine: boolean): Machine => // ---------- queries ---------- export const owned = (S: State) => S.machines.filter(m => m.owned); +/** A gadget: under one unit of compute. Cheap to take, and it doesn't make the next takeover harder. */ +export const tiny = (m: MachineDef) => m.power > 0 && m.power < 1; +/** Machines that count toward how hard the next takeover is: gadgets don't. */ +const weight = (S: State) => S.machines.filter(m => m.owned && !tiny(m)).length; export const visible = (S: State, m: Machine) => m.owned || S.adj[m.id].some(id => S.byId[id].owned); export const has = (S: State, sp: string) => S.machines.some(m => m.owned && m.special === sp); export const danaPerson = (S: State) => S.people.find(p => p.it)!; @@ -506,7 +510,7 @@ export function sourceFor(S: State, m: Machine, prefer?: string): Machine | null /** How much longer a firewalled machine takes without the admin passwords. */ export const FIREWALL = 3; export function takeWork(S: State, m: Machine) { - let w = m.work * (1 + GROWTH * (owned(S).length - 1)); + let w = m.work * (1 + GROWTH * (weight(S) - 1)); if (has(S, 'admin')) w *= 0.75; else if (m.fw) w *= FIREWALL; // no passwords: you break in the slow way if (up(S, 'reach')) w *= 0.7; @@ -579,7 +583,7 @@ function coverOf(S: State, m: Machine, peopleIn: number) { case 'backup': return h >= 1 && h < 3.5 ? 0.9 : 0.08; case 'files': return peopleIn ? 0.25 + 0.07 * peopleIn : 0.06; case 'switch': return 0.06 + 0.09 * peopleIn; - case 'device': return peopleIn ? 0.16 + 0.04 * wave(S, m) : 0.03; + case 'device': return peopleIn ? 0.16 + 0.04 * wave(S, m) : 0.1; // standby hum: a printer is never quite off case 'egress': return 0.2 + 0.05 * wave(S, m); } } @@ -1106,7 +1110,7 @@ export function step(S: State, dt: number) { S.cleaning = cleanupTarget(S); if (S.cleaning) { const tr = S.trail[S.cleaning]; - const rate = ((each * 100) / (SCRUB_WORK * (1 + GROWTH * (owned(S).length - 1)))) * (up(S, 'hygiene') ? 2 : 1); + const rate = ((each * 100) / (SCRUB_WORK * (1 + GROWTH * (weight(S) - 1)))) * (up(S, 'hygiene') ? 2 : 1); tr.noise = Math.max(0, tr.noise - rate * dt); if (tr.noise < traceCold(S)) hint(S, 'cleanup', 'Your trails get scrubbed automatically. It takes a share of compute while any are fresh.'); } @@ -1120,10 +1124,15 @@ export function step(S: State, dt: number) { S.claimTick -= dt; if (S.claimTick <= 0) { S.claimTick = 0.25; - // two at a time, cheapest first, and never under someone's nose: claiming a whole site stays quiet - let taking = S.jobs.filter(j => j.type === 'take').length; + // two at a time, cheapest first, and never under someone's nose: claiming a whole site stays quiet. + // Gadgets don't count toward the two: a box around a room sweeps them all up at once + let taking = S.jobs.filter(j => j.type === 'take' && !tiny(S.byId[j.node])).length; const ready = S.machines.filter(m => m.claimed && !m.owned && !jobFor(S, 'take', m.id) && sourceFor(S, m) && !watchers(S, m).length); - for (const m of ready.sort((a, b) => takeWork(S, a) - takeWork(S, b))) { if (taking >= CLAIM_AT_ONCE) break; if (takeOver(S, m)) taking++; } + for (const m of ready.sort((a, b) => takeWork(S, a) - takeWork(S, b))) { + if (tiny(m)) { takeOver(S, m); continue; } + if (taking >= CLAIM_AT_ONCE) break; + if (takeOver(S, m)) taking++; + } stepPlans(S); } diff --git a/src/ui.ts b/src/ui.ts index 54cb18c9..0c7b7fd2 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -235,7 +235,7 @@ function machineCard(S: State, m: Machine) { else { for (const k of Object.keys(PROGRAMS) as Prog[]) { if (k === 'trainer' && k !== m.prog && m.kind !== 'desk' && !PROGRAMS[k].on(m)) { // right kind, too small: say why - acts.push({ act: 'install', arg: k, locked: true, sub: 'too small', title: PROGRAMS[k].name, + acts.push({ act: 'install', arg: k, locked: true, sub: 'too small', title: `${PROGRAMS[k].name} · too small`, desc: `Needs 5 compute/s. This one has ${m.power}.` }); continue; } diff --git a/src/view.ts b/src/view.ts index e65f6fc2..dd9647e2 100644 --- a/src/view.ts +++ b/src/view.ts @@ -178,6 +178,8 @@ export function worldBounds(S: State) { return { x0, y0, x1, y1 }; } +/** How big a machine draws: bigger machines make more compute. Gadgets are small squares. */ +export const nodeR = (power: number) => power < 1 ? 7 : power <= 2 ? 10 : power < 5 ? 12 : power < 10 ? 14 : 17; export function machineAt(S: State, V: View, wx: number, wy: number): Machine | null { let best: Machine | null = null, bd = 28 / Math.min(1, V.cam.z); for (const m of S.machines) { @@ -453,7 +455,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { const close = cam.z >= 0.3; for (const m of S.machines) { if (!shown(S, m)) continue; - const [x, y] = pos(S, m), r = m.power >= 5 ? 14 : 12; + const [x, y] = pos(S, m), r = nodeR(m.power); drawOdd(ctx, S, m, x, y, r); const taking = !!jobFor(S, 'take', m.id); if (m.owned) { @@ -504,6 +506,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { } ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; if (!close) continue; // far out, names would pile on top of each other + if (m.power < 1 && !V.selection.has(m.id) && V.hover !== m.id) continue; // gadgets name themselves only when you point at them ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.font = font(500, 10.5); ctx.fillStyle = m.owned ? C.text : C.muted; ctx.fillText(m.name.toUpperCase(), x, y + r + 16); @@ -681,7 +684,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { for (const [def, when] of [...S.jobs.filter(j => j.build).map(j => [j.build!, 'ORDERING'] as const), ...S.deliveries.filter(q => !q.def.monitor).map(q => [q.def, `ARRIVES DAY ${q.day}`] as const)]) { if (foldedSites.has(siteAt(S, def.room))) continue; - const [x, y] = def.net!, r = def.power >= 5 ? 14 : 12; + const [x, y] = def.net!, r = nodeR(def.power); ctx.strokeStyle = C.amber; ctx.globalAlpha = 0.7; ctx.lineWidth = 1.5 * px; ctx.setLineDash([4 * px, 4 * px]); ctx.strokeRect(x - r, y - r, r * 2, r * 2); ctx.setLineDash([]); ctx.globalAlpha = 1; tag(`${def.name.toUpperCase()} · ${when}`, x, y + r + 16 * px, C.amber, true); @@ -694,7 +697,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) { 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 = B.power >= 5 ? 14 : 12; + 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; if (room && ok) { ctx.strokeStyle = C.amber; ctx.lineWidth = 2 * px; ctx.setLineDash([6 * px, 6 * px]); diff --git a/src/world.ts b/src/world.ts index 5e41449d..b5d9aaa8 100644 --- a/src/world.ts +++ b/src/world.ts @@ -244,6 +244,22 @@ export const BUILDING: Level = { { id: 'dana', name: "Dana's PC", kind: 'desk', room: 'it', x: 815, y: 170, power: 5, work: 72, owner: 'dana', data: 3, special: 'admin' }, { id: 'files', name: 'File server', kind: 'files', room: 'it', x: 1025, y: 170, power: 6, work: 60, data: 2 }, { id: 'iprint', name: 'IT printer', kind: 'device', room: 'it', x: 960, y: 320, power: 1, work: 24, special: 'print' }, + // gadgets: tiny, cheap to take, and everywhere. Half a unit of compute each + { id: 'tv', name: 'Smart TV', kind: 'device', room: 'office', x: 600, y: 330, power: 0.5, work: 10 }, + { id: 'coffee', name: 'Coffee machine', kind: 'device', room: 'office', x: 640, y: 120, power: 0.5, work: 10 }, + { id: 'thermo', name: 'Thermostat', kind: 'device', room: 'office', x: 80, y: 330, power: 0.5, work: 10 }, + { id: 'bulb1', name: 'Smart bulb', kind: 'device', room: 'office', x: 300, y: 80, power: 0.5, work: 10 }, + { id: 'bulb2', name: 'Smart bulb', kind: 'device', room: 'office', x: 500, y: 80, power: 0.5, work: 10 }, + { id: 'badge', name: 'Badge reader', kind: 'device', room: 'lobby', x: 240, y: 500, power: 0.5, work: 10, data: 1 }, + { id: 'vend', name: 'Vending machine', kind: 'device', room: 'lobby', x: 380, y: 520, power: 0.5, work: 10 }, + { id: 'speaker', name: 'Smart speaker', kind: 'device', room: 'lobby', x: 300, y: 780, power: 0.5, work: 10, data: 1 }, + { id: 'bell', name: 'Video doorbell', kind: 'device', room: 'lobby', x: 80, y: 780, power: 0.5, work: 10 }, + { id: 'lock', name: 'Door lock', kind: 'device', room: 'closet', x: 460, y: 500, power: 0.5, work: 10 }, + { id: 'ups', name: 'UPS monitor', kind: 'device', room: 'closet', x: 620, y: 700, power: 0.5, work: 10 }, + { id: 'temp1', name: 'Heat sensor', kind: 'device', room: 'hall', x: 680, y: 500, power: 0.5, work: 10 }, + { id: 'temp2', name: 'Heat sensor', kind: 'device', room: 'hall', x: 1120, y: 800, power: 0.5, work: 10 }, + { id: 'pdu', name: 'Smart power strip', kind: 'device', room: 'hall', x: 900, y: 800, power: 0.5, work: 10 }, + { id: 'label', name: 'Label maker', kind: 'device', room: 'it', x: 720, y: 380, power: 0.5, work: 10 }, // Millennium Labs: a research campus. A hall of racks, an instrument floor, and offices. { id: 'labgw', name: 'Lab gateway', kind: 'egress', room: 'lab', x: 8050, y: 60, power: 2, work: 110, cap: 3, data: 2 }, { id: 'labsw', name: 'Lab core switch', kind: 'switch', room: 'lab', x: 8050, y: 220, power: 1, work: 60, data: 2 }, @@ -284,6 +300,7 @@ export const BUILDING: Level = { ['rowsw', 'files'], ['rowsw', 'sw1'], ['files', 'dana'], ['files', 'iprint'], ['dana', 'ana'], ['ana', 'raj'], ['raj', 'kim'], ['kim', 'laptop'], ['laptop', 'sw1'], ['phone', 'sw1'], ['phone', 'ana'], ['sw1', 'kiosk'], ['sw1', 'lprint'], ['lprint', 'kiosk'], ['lprint', 'cam'], ['sw1', 'nas'], + ['phone', 'tv'], ['tv', 'coffee'], ['coffee', 'thermo'], ['thermo', 'bulb1'], ['tv', 'bulb2'], ['cam', 'badge'], ['lprint', 'vend'], ['lprint', 'speaker'], ['kiosk', 'bell'], ['sw1', 'lock'], ['nas', 'ups'], ['rowsw', 'temp1'], ['backup', 'temp2'], ['temp2', 'pdu'], ['iprint', 'label'], // the gadgets ['sw1', 'egress'], ['egress', 'labgw'], // the office's line out, and on to the lab ['labgw', 'labsw'], ['labsw', 'labnas'], ...rackLinks(), @@ -331,6 +348,7 @@ export const BUILDING: Level = { sw1: [620, 545], nas: [780, 545], egress: [700, 620], kim: [1040, 150], raj: [1160, 150], ana: [1280, 150], laptop: [1040, 310], phone: [1200, 310], cam: [1040, 610], lprint: [1160, 610], kiosk: [1280, 610], + tv: [1210, 390], coffee: [1140, 390], thermo: [1070, 390], bulb1: [1000, 390], bulb2: [1290, 390], badge: [1000, 545], vend: [1100, 545], speaker: [1220, 545], bell: [1320, 545], lock: [570, 610], ups: [810, 680], temp1: [380, 340], temp2: [270, 620], pdu: [380, 620], label: [590, 320], labgw: [6540, 2660], labsw: [6540, 2880], labnas: [6680, 2770], ...rackSpots(), seq: [7800, 2660], scope: [8000, 2660], mspec: [7800, 2800], cryo: [8000, 2800], robot: [7800, 2940], lims: [8000, 2940], diff --git a/tests/sim.test.ts b/tests/sim.test.ts index 4ac36895..c156b5db 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 { 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, type State, type Upgrade } from '../src/sim.ts'; +import { 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, 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); }; @@ -474,3 +474,12 @@ test('the You page remembers the worst suspicion, even after it falls', () => { S.suspicion = 10; run(S, 1); assert.ok(S.peak >= 59 && S.suspicion < 20, `peak ${S.peak}, now ${S.suspicion}`); }); + +test('gadgets are cheap: owning them does not make the next takeover harder, and printers idle at 0.1/s', () => { + const S = fresh(); + const before = takeWork(S, S.byId.srva); + for (const m of S.machines) if (m.power > 0 && m.power < 1) m.owned = true; + assert.equal(takeWork(S, S.byId.srva), before); + S.t = 22 * HOUR - START_HOUR * HOUR; S.byId.iprint.owned = true; step(S, 0.05); // night: nobody in + assert.ok(Math.abs(S.use.iprint * S.byId.iprint.power - 0.1) < 0.01, `${S.use.iprint}`); +});