diff --git a/DESIGN.md b/DESIGN.md index f123a9a2..fe75c479 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -53,7 +53,7 @@ it can cross to a receiver; it does not queue at the egress. Once a Tap's buffer is full, each new piece is lost with a flash, a per-minute count shows under it, and its card says why, including when an egress is full. A full egress turns its line red and shows how much crosses against what it can -carry. Money doubles an egress (it costs $150 for each unit it carries now). +carry. Money doubles a site's line out, from the site's card ($150 for each unit it carries now). Its **First** setting picks one kind of data to cross first: when the line is busy, other kinds leave it half the room. diff --git a/src/sim.ts b/src/sim.ts index 75c20171..130ec0ff 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -964,6 +964,14 @@ export function upgradeLine(S: State, m: Machine) { S.money -= cost; m.cap = (m.cap ?? 1) * 2; say(S, `${m.name} now carries ${m.cap} a second.`, 'good'); } +/** A site's lines out that you hold. */ +export const siteLines = (S: State, site: string) => S.machines.filter(m => m.owned && m.kind === 'egress' && siteOf(S, m) === site); +/** Double every line out of a site. */ +export function upgradeSiteLine(S: State, site: string) { + const lines = siteLines(S, site), cost = lines.reduce((n, m) => n + lineCost(m), 0); + if (!lines.length || S.money < cost) return; + for (const m of lines) upgradeLine(S, m); +} /** Which kind of data crosses an egress first when it's busy. */ export function setPrio(S: State, m: Machine, t: DataType | null) { if (m.owned && m.kind === 'egress') { m.prio = m.prio === t ? null : t; measure(S); } diff --git a/src/ui.ts b/src/ui.ts index d4bc3a16..32c206fa 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -3,7 +3,7 @@ import { installProg, jobFor, levelNeed, BUILDS, BUILD_LIST, orderPrice, orderWork, 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, upgradeLine, type Size, + 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, 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, @@ -209,10 +209,7 @@ function machineCard(S: State, m: Machine) { ? 'Full. Data waiting to cross stays at its taps, and is lost once they fill up.' : `Data between sites crosses here, up to ${m.cap} a second.`, gateLoad(S, m) > 0.9); h += prioHtml(m); - const cost = lineCost(m); - acts.push({ act: 'upgrade', title: `Double the line · $${cost}`, warn: S.money < cost, - sub: S.money < cost ? `you have $${Math.floor(S.money)}` : `carries ${(m.cap ?? 1) * 2} a second`, - desc: `Pay $${cost} and this egress carries twice as much, starting now.` }); + if (m.owned) h += note(`To carry more, double the line from ${siteName(S, siteOf(S, m))}'s card.`); } if (m.lease) { h += note(m.lease.until === null @@ -329,6 +326,13 @@ function siteCard(S: State, id: string) { 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[] = [{ act: 'zoomsite', arg: id, title: 'Zoom in', desc: 'Zoom in to work on its machines.' }]; + const lines = siteLines(S, id); + if (lines.length) { + const cost = lines.reduce((n, m) => n + lineCost(m), 0), cap = lines.reduce((n, m) => n + (m.cap ?? 1), 0); + acts.push({ act: 'upgradesite', arg: id, title: `Double the line · $${cost}`, warn: S.money < cost, + sub: S.money < cost ? `you have $${Math.floor(S.money)}` : `carries ${cap * 2} a second, up from ${cap}`, + desc: `Pay $${cost} and this site's line out carries twice as much, starting now.` }); + } 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'), @@ -533,7 +537,7 @@ export function cardAction(S: State, V: View, act: string, arg: string): boolean case 'mode': for (const x of sel) setMode(S, x, arg as Mode); break; case 'claimall': for (const x of sel) if (!x.owned) takeOver(S, x); break; case 'allow': setAllow(S, m, arg as DataType, m.refuse.includes(arg as DataType)); break; - case 'upgrade': upgradeLine(S, m); break; + case 'upgradesite': upgradeSiteLine(S, arg); break; case 'auction': startAuction(S); break; case 'prio': setPrio(S, m, (arg || null) as DataType | null); break; case 'account': openAccount(S, m); break; diff --git a/src/view.ts b/src/view.ts index 577a83cb..c835e844 100644 --- a/src/view.ts +++ b/src/view.ts @@ -9,7 +9,7 @@ import { 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, } from './sim'; -import type { DataType, Site } from './world'; +import type { DataType, Rect, Site } from './world'; /** An hour of the day as 9:05. */ const hhmm = (h: number) => `${Math.floor(h)}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`; @@ -331,19 +331,21 @@ function facing(s: Site, to: Pt) { return Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? 0 : 2) : (dy < 0 ? 1 : 3); } const ports = new WeakMap }>(); -/** The wire inside a site from an egress to the port it uses toward another site: it picks the - * side that faces that site, unless reaching it means crossing the machines in the way. */ +/** The wire from an egress to the port it uses toward another site. The port sits on the edge of + * the egress's own subnet box: it picks the side that faces that site, unless reaching it means + * crossing the machines in the way, or running the line out over the site's other subnets. */ function portOf(S: State, m: Machine, toward: Site): { pts: Pt[]; side: number } { let cached = ports.get(S.level); const n = S.machines.length * 1000 + S.revealed.size; // new machines, or newly known sites to steer around if (!cached || cached.n !== n) { cached = { n, plan: new Map() }; ports.set(S.level, cached); } const key = `${m.id}>${toward.id}`, hit = cached.plan.get(key); if (hit) return hit; - const s = siteById(S, siteAt(S, m.room)), from = S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]; + const site = siteById(S, siteAt(S, m.room)), s: Site = { ...site, ...subnet(S, m.room) }, from = S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y]; const spots = nodeSpots(S), inside = Object.fromEntries(Object.entries(spots).filter(([, [x, y]]) => x >= s.x && x <= s.x + s.w && y >= s.y && y <= s.y + s.h)); const boxes = boxesOf(inside), [cx, cy] = siteMid(s), [tx, ty] = siteMid(toward); const d = Math.hypot(tx - cx, ty - cy) || 1, dir: Pt = [(tx - cx) / d, (ty - cy) / d]; - const others = S.level.map.sites.filter(q => q !== s && q !== toward && siteKnown(S, q)); + const others = S.level.map.sites.filter(q => q !== site && q !== toward && siteKnown(S, q)); + const near: Rect[] = site.rooms.filter(r => r !== m.room && subnet(S, r)).map(r => subnet(S, r)); // the site's other subnets const used: [Pt, Pt][] = []; // stay off the site's own wiring for (const r of localPlan(S).values()) for (let i = 1; i < r.length; i++) used.push([r[i - 1], r[i]]); let best = { pts: [from, onSide(s, facing(s, siteMid(toward)), from)], side: facing(s, siteMid(toward)) }, bestScore = Infinity; @@ -353,10 +355,15 @@ function portOf(S: State, m: Machine, toward: Site): { pts: Pt[]; side: number } const along: Pt = side % 2 ? [from[0] + o, from[1]] : [from[0], from[1] + o]; const port = onSide(s, side, along), r = bestRoute(from, port, m.id, '', boxes, used); const n = NORMALS[side], k = Math.min(1500, 0.35 * d), c: Pt = [port[0] + n[0] * k, port[1] + n[1] * k]; - let through = 0; // a rough look at the curve: how much of it runs over other sites + let through = 0; // a rough look at the curve: how much of it runs over other sites, or this site's other subnets + const over = (q: Rect, x: number, y: number) => x > q.x && x < q.x + q.w && y > q.y && y < q.y + q.h; for (let i = 1; i < 12; i++) { const t = i / 12, u = 1 - t, x = u * u * port[0] + 2 * u * t * c[0] + t * t * tx, y = u * u * port[1] + 2 * u * t * c[1] + t * t * ty; - if (others.some(q => x > q.x && x < q.x + q.w && y > q.y && y < q.y + q.h)) through++; + if (others.some(q => over(q, x, y))) through++; + } + for (let i = 1; i <= 20; i++) { // the first stretch, close up, where it leaves the site + const t = i / 80, u = 1 - t, x = u * u * port[0] + 2 * u * t * c[0] + t * t * tx, y = u * u * port[1] + 2 * u * t * c[1] + t * t * ty; + if (near.some(q => over(q, x, y))) through += 2; } const score = r.score + away * 700 + Math.abs(o) + through * 150; if (score < bestScore) { bestScore = score; best = { pts: r.pts, side }; } diff --git a/tests/sim.test.ts b/tests/sim.test.ts index 04090a30..87cf3007 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, 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, 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); }; @@ -566,3 +566,12 @@ test('a startup works anywhere; a studio needs a site where people work', () => for (const id of ['office', 'uni', 'lab']) assert.ok(frontFits(S, 'studio', id), id); assert.ok(!frontFits(S, 'studio', 'cloud')); }); + +test('doubling a site doubles its line out, and only once you hold it', () => { + const S = fresh(), gw = S.byId.egress; S.money = 1000; + upgradeSiteLine(S, 'office'); + assert.equal(gw.cap, 1, 'a line you do not hold stays as it is'); + gw.owned = true; upgradeSiteLine(S, 'office'); + assert.equal(gw.cap, 2); + assert.ok(S.money < 1000); +});