diff --git a/DESIGN.md b/DESIGN.md
index c2b424ac..a6aec7a5 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -35,9 +35,13 @@ same choices about capacity, flow, cover, and attention apply at every scale.
Inside the office, traffic is only a visual signal: it does not make machines
look odd or cause switches to hold data back. Egress is the first capacity
-limit. Data stays at its Tap or Cache until it can cross to a receiver; it does
-not queue at the egress. Show egress load, capacity upgrades, and priority by
-data type.
+limit: a link between two sites runs from one egress to another, and each
+egress passes only so much data a second. Data stays at its Tap or Cache until
+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. Capacity upgrades and priority by data type come with money.
Training grows four paths: **Research, Compute, Social, and Stealth**. What you
feed a trainer matters: lab data helps Research, network data helps Stealth,
@@ -101,8 +105,11 @@ cost and the other first-slice schemes remain open.
Sites are physical places: the office, data centers, labs, and a provider's
cloud region. Each has capacity, sources, defenses, and at least one
egress. The office keeps its current network as the first site. Its single
-outside line returns as an egress machine in the telecom closet, linked to the
-floor switch; it is not the Old NAS. You must take and hold it to reach the map.
+outside line is the **Outside line**, an egress machine in the telecom closet
+linked to the floor switch. You must take and hold it to reach the map.
+**Millennium Labs** is the first other site: take its gateway from the Outside
+line, then its instruments give lab data. Dana never goes there; the lab's own
+people reset any machine that looks very odd.
The egress has a visible capacity. Lose it and that site keeps working locally
but is cut off from the rest of your network. Upgrade its capacity and choose
which data type gets priority. Later sites may have several.
@@ -111,8 +118,10 @@ Spread through a connected egress, buy or rent capacity, or gain a site from a
scheme. A completed site folds into a summary tile when zoomed out and opens
again when zoomed in; finishing it never blocks its machines. The map remains
one continuous space as the player moves from a machine to a site to a region.
-Start with a flat map that pans and zooms; a 3D
-globe is a possible later presentation, not a separate set of rules.
+The map is a flat Earth drawn as dots, with the office on the
+bay. Pulling back folds each site into a tile, then a marker; far out, region
+names show what is in reach. A 3D globe is a possible later presentation, not a
+separate set of rules.
Rented cloud compute is available through fictional providers in regions on
the map. A cloud site has an open grid: place and link machines yourself, and
@@ -211,5 +220,5 @@ Do not treat the earlier proposals above as answers to these questions:
- How the startup earns money, and whether trust or site heat needs any separate
rule beyond visible actions and reactions. Do not make meter changes Social's
main reward.
-- The exact first region, its data sources, and any other first-slice schemes.
+- Any first-slice schemes beyond the auction, and what else the first region holds.
- Capability node costs. The current numbers are a first guess.
diff --git a/index.html b/index.html
index 46535043..b2694db5 100644
--- a/index.html
+++ b/index.html
@@ -59,10 +59,12 @@
Build a base on the machines you own: Taps gather data, links carry it, Caches hold it, Trainers turn it into capability. Install programs from a machine's card or with keys 1–5.
Each kind of data trains its own path. Data from people's desks trains Social, from servers Compute, from the network switches Stealth. Research needs lab data, and there is no lab here. Your capability is all four paths together.
Pick a target in the capability tree (T). Trainers then take only the data that node still needs. Other data waits in caches, and a tap with nowhere to send loses it. Each cache has switches for which kinds it takes.
+
The Outside line in the telecom closet is the office's way out. Take it and you can reach Millennium Labs, where the lab data is. Zoom out to see the map; 0 frames the office, and again shows every site you know.
+
An egress carries only so much data a second. When data can't cross, it waits at its taps and is lost once they fill. Red lines and red counts show where it's stuck.
Capability levels up and unlocks new programs. Your spare compute is split evenly across your jobs. Own Ana's PC to place fake purchase orders: Dana installs the hardware for you.
Takeovers leave trails. Once Dana is suspicious she follows fresh ones back toward you. Claim machines by dragging a box around them.
Own every machine in a room to seal it. Sealed rooms hide trails, and fold into tiles when you zoom out.
-
Right-drag or two-finger scroll to move. Pinch or ctrl-scroll to zoom. Space pauses.
+
Right-drag, two-finger scroll, or the arrow keys to move. Pinch, ctrl-scroll, or + and − to zoom. Space pauses.
Shortcuts work on your selection, or the machine under the mouse: 1–5 install a program, B / M / O set Blend in, Max, Off, G takes over, C claims, P places an order, F zooms into a room, X removes the program. Every button shows its key.
diff --git a/src/earth.ts b/src/earth.ts
new file mode 100644
index 00000000..f5e77d4a
--- /dev/null
+++ b/src/earth.ts
@@ -0,0 +1,102 @@
+// A rough outline of the continents, as longitude, latitude points. Low detail on
+// purpose: the map is drawn as a field of dots, and a stylized coast reads better.
+type Outline = [number, number][];
+
+const NORTH_AMERICA: Outline = [
+ [-168, 66], [-162, 70], [-156, 71.3], [-141, 69.6], [-128, 70], [-117, 68.8], [-108, 68], [-97, 68.5], [-94, 71],
+ [-86, 69], [-82, 66.5], [-86, 64], [-93, 61], [-94, 58.5], [-88, 56.5], [-82, 55], [-79, 52], [-79, 57], [-77, 60.5],
+ [-72, 62.5], [-65, 60], [-61, 56], [-56, 52], [-60, 48], [-66, 45], [-70, 43.5], [-70, 41.7], [-74, 40.5], [-76, 38.5],
+ [-76, 35], [-81, 31.5], [-80, 27], [-80.5, 25.2], [-82.5, 27.5], [-84, 30], [-89, 30.2], [-94, 29.5], [-97.5, 27],
+ [-97.5, 22], [-95, 18.8], [-91, 19], [-90.5, 21], [-87, 21.5], [-88, 16], [-84, 15.5], [-83.5, 11], [-81, 8.5],
+ [-79, 9.3], [-77.5, 8], [-80, 7.3], [-83, 8.3], [-86, 11], [-88, 13.3], [-92, 14.5], [-96, 15.7], [-100, 17],
+ [-105.5, 20], [-106.4, 23.2], [-109, 25.8], [-110.9, 27.9], [-114.8, 31.8], [-113, 29], [-111.5, 26], [-110, 24],
+ [-109.5, 23], [-112, 24.8], [-114.2, 27.8], [-115.8, 30.4], [-117.1, 32.5], [-118.5, 34.1], [-120.6, 34.6],
+ [-121.9, 36.6], [-122.5, 37.8], [-123.8, 39.5], [-124.3, 42], [-124, 46], [-124.7, 48.4], [-123, 49], [-125.5, 50.5],
+ [-128, 52], [-130.5, 54.5], [-133, 57.5], [-137, 59], [-141, 60], [-146, 60.8], [-150, 59.5], [-152, 58], [-154.5, 57.5],
+ [-158, 56.5], [-162, 55], [-158.5, 58], [-162, 59.7], [-165, 61], [-164.5, 63.2], [-161, 64.5], [-166, 65.3],
+];
+const ARCTIC_ISLANDS: Outline = [[-120, 74], [-110, 78], [-90, 80.5], [-75, 80], [-70, 77], [-80, 73], [-90, 72], [-100, 73], [-115, 72]];
+const BAFFIN: Outline = [[-80, 73.5], [-68, 70.5], [-62, 67], [-66, 62], [-73, 64.5], [-78, 64.5], [-73, 67.5], [-82, 70]];
+const GREENLAND: Outline = [
+ [-73, 78], [-60, 82], [-35, 83.5], [-20, 82], [-18, 76], [-22, 70], [-32, 68], [-40, 65], [-43, 60], [-48, 61],
+ [-52, 64.5], [-54, 68], [-55, 71], [-60, 75.5], [-68, 76.5],
+];
+const CUBA: Outline = [[-85, 21.8], [-82, 23.1], [-77, 21.5], [-74.2, 20.1], [-77.5, 19.9], [-80.5, 21.8]];
+const HISPANIOLA: Outline = [[-74.4, 18.5], [-72.8, 19.9], [-68.4, 18.6], [-71, 18]];
+const SOUTH_AMERICA: Outline = [
+ [-77.5, 8], [-75.5, 10.8], [-72, 12], [-68, 10.5], [-62, 10.5], [-60, 8.5], [-57, 6], [-52, 4.5], [-50, 1.8],
+ [-48.5, -1], [-44, -2.5], [-40, -3], [-35, -5.2], [-35, -9], [-37.3, -12.5], [-39, -17], [-40.5, -21], [-42, -23],
+ [-45, -23.7], [-48.5, -26], [-48.7, -28.5], [-51, -31], [-53.5, -34], [-57.5, -35], [-57, -37.5], [-62, -39],
+ [-62.5, -41], [-65, -42], [-64, -45], [-67.5, -46.5], [-65.8, -48], [-69, -51], [-68.5, -52.5], [-70, -54], [-74, -52],
+ [-75.5, -47], [-73.8, -43], [-73.5, -37], [-71.5, -32], [-71.3, -27], [-70.2, -20], [-71.5, -17.5], [-76, -14],
+ [-79, -8], [-81.2, -5], [-80.5, -2.5], [-80, 0.5], [-78.8, 1.5], [-77.5, 4], [-77.3, 7],
+];
+const EURASIA: Outline = [
+ [-9.5, 37], [-9, 43], [-2, 43.5], [-1.5, 46.5], [-4.5, 48.5], [1.5, 50.5], [4, 51.5], [8, 53.8], [8.5, 57], [10.5, 57.7],
+ [10.5, 54.5], [14, 54], [19, 54.4], [21, 56.5], [24, 57.5], [23, 59.5], [29, 60], [22.5, 60.5], [21.5, 63], [25, 65.5],
+ [21.5, 65.8], [17.5, 62.5], [18.5, 60], [16, 56.3], [12.8, 55.5], [11, 58.5], [5.5, 58.5], [5, 62], [10, 64], [14, 67.5],
+ [17, 69.5], [23, 70.8], [30, 70.2], [33, 69.3], [41, 67], [44, 68.5], [53, 68.5], [60, 69.8], [68, 69], [73, 72.5],
+ [80, 72.5], [87, 74.5], [97, 76], [105, 77.5], [113, 73.5], [128, 72.5], [140, 72.5], [150, 71.5], [160, 69.5],
+ [170, 69.8], [180, 69], [180, 65], [178, 64.5], [174, 61.8], [166, 60], [163, 57.5], [162, 54.5], [156.5, 51], [156, 57],
+ [160, 61.5], [154, 59.3], [143, 59.3], [135, 54.8], [141, 52.5], [140, 48], [135, 43.5], [130, 42.5], [129.5, 36],
+ [126.5, 34.5], [126, 37.5], [124.5, 39.8], [121, 39.5], [122, 40.8], [119, 39], [117.8, 38], [119, 37], [122.5, 37],
+ [120, 34.5], [121.8, 31], [121.5, 28.5], [119.5, 25.5], [116.5, 22.9], [113, 22], [110, 21.2], [108, 21.5],
+ [106.5, 19], [108.8, 15.5], [109.2, 12], [106.8, 10.5], [105, 8.7], [103, 10.5], [100.5, 13.5], [99.2, 10], [100.2, 7],
+ [102, 6], [103.5, 1.5], [101, 2.8], [98.5, 7.8], [98.5, 13], [97.5, 16.5], [94.5, 16], [94, 19.5], [92, 21.5],
+ [90.5, 22.5], [88, 21.8], [86.5, 20], [85, 19.3], [82, 16.5], [80.2, 15], [80, 10], [77.5, 8], [76, 10], [74.5, 14],
+ [73, 17], [72.8, 21], [70, 22.5], [68.5, 23.5], [66.5, 25.5], [61.5, 25.2], [57.5, 25.8], [56.3, 27], [54, 26.7],
+ [51.5, 27.9], [50, 30], [48, 29.8], [48.5, 28], [50.2, 26.5], [51.5, 24.3], [54, 24.2], [56.4, 26.2], [56.5, 24.5],
+ [58.8, 23.5], [59.8, 22.3], [57.8, 19], [55, 17], [52.2, 16], [48.5, 14], [45, 12.8], [43.5, 12.7], [42.8, 15],
+ [42.5, 17.5], [40.5, 20.5], [39, 22], [38, 24], [35.5, 28], [34.5, 29.5], [32.6, 30], [34.2, 31.3], [35, 33],
+ [35.9, 35.9], [36.2, 36.6], [34.5, 36.8], [32, 36.2], [30, 36.2], [28, 36.7], [26.5, 38.3], [26.8, 40], [28.8, 41],
+ [26.2, 40.7], [23.8, 40.3], [22.8, 37.5], [21.5, 37], [21, 39.5], [19.5, 40.5], [19.5, 42], [16, 43.5], [13.5, 45.5],
+ [12.3, 45.2], [14, 42.5], [16, 41.3], [18.5, 40.2], [16.5, 38.5], [15.7, 38], [16, 39.5], [12.3, 41.8], [10.5, 43],
+ [8.8, 44.4], [6.5, 43.1], [3, 43.3], [3.2, 41.9], [0.7, 40.8], [-0.3, 39.5], [0.2, 38.7], [-0.8, 37.6], [-2.1, 36.7],
+ [-4.5, 36.7], [-6, 36.2], [-7.5, 37.2],
+];
+const AFRICA: Outline = [
+ [-6, 35.8], [-2, 35.1], [3, 36.8], [10, 37.3], [11, 35.5], [10.2, 34], [11.5, 33], [15.2, 32.3], [19.9, 30.5],
+ [20.1, 32.4], [23.5, 32.4], [25.5, 31.6], [29, 30.9], [32.3, 31.3], [32.5, 29.8], [34, 26.8], [35.6, 23.9], [37.2, 21],
+ [38.5, 18], [39.8, 15.3], [41.6, 13.4], [43.3, 11.8], [44.5, 10.4], [51, 11.8], [51.1, 10.5], [49.6, 6.8], [47.5, 4],
+ [45, 1.7], [41.5, -1.8], [39.4, -4.8], [39.2, -8], [40.5, -10.5], [40.5, -15.3], [37, -17.8], [35.3, -22], [35.5, -24],
+ [32.8, -26], [32.4, -28.6], [30, -31.3], [27.5, -33.5], [25.5, -34], [22.5, -34], [20, -34.8], [18.4, -34.2],
+ [18.2, -31.5], [16.5, -28.6], [15, -26.5], [14.4, -22.8], [13, -20.5], [11.8, -17.2], [12.2, -13.5], [13.7, -11],
+ [13, -8.8], [12.2, -6], [11.9, -4.5], [9.4, -1], [9.5, 1.8], [9.7, 3.8], [8.5, 4.5], [6, 4.3], [4.5, 6.3], [2, 6.3],
+ [-1, 5.1], [-4, 5.2], [-7.5, 4.4], [-9.5, 5.4], [-11.5, 6.9], [-13.2, 8.8], [-15, 10.9], [-16.7, 12.4], [-17.5, 14.7],
+ [-16.5, 16.4], [-16.2, 19.5], [-17, 21], [-15.2, 24], [-13.4, 27.6], [-10, 29], [-9.8, 31.3], [-6.8, 34],
+];
+const MADAGASCAR: Outline = [[44, -25], [47, -25], [50.3, -15.5], [49.3, -12], [47, -15.5], [44, -17], [43.3, -22]];
+const BRITAIN: Outline = [
+ [-5.7, 50], [1.4, 51.2], [1.7, 52.7], [0, 53.5], [-2, 55.8], [-1.8, 57.5], [-3, 58.6], [-5, 58.6], [-6.2, 56.5],
+ [-4.8, 55], [-3, 54], [-4.5, 53.3], [-4.5, 52], [-5.2, 51.7], [-3.2, 51.3],
+];
+const IRELAND: Outline = [[-10, 51.6], [-6, 52], [-6, 54], [-7.5, 55.3], [-10, 54.2]];
+const ICELAND: Outline = [[-24, 65.5], [-21, 66.3], [-15, 66.4], [-13.5, 65], [-18, 63.4], [-22.5, 63.8]];
+const JAPAN: Outline = [
+ [130, 31], [131.5, 31.5], [132, 33.8], [135, 33.5], [137, 34.6], [140, 35], [141, 38], [142, 40.5], [141.5, 41.3],
+ [140, 40.5], [139.7, 38], [137, 37], [135.5, 35.5], [133, 35.5], [131, 34.3],
+];
+const HOKKAIDO: Outline = [[140, 41.5], [141.5, 42.5], [143.5, 42], [145.5, 43.3], [142, 45.5], [141.5, 43.3]];
+const PHILIPPINES: Outline = [[120, 18.5], [122.3, 18.4], [122, 14], [124.3, 13], [126.5, 7], [125.5, 6], [122, 7], [123, 10.5], [121.8, 12.5], [120, 14.5]];
+const SUMATRA: Outline = [[95.3, 5.6], [98, 4], [104, -1], [106, -5.9], [102.3, -4], [98.8, -0.5], [95.5, 3]];
+const JAVA: Outline = [[105.3, -6.8], [106, -6], [111, -6.4], [114.5, -7.7], [114.4, -8.7], [108, -7.8]];
+const BORNEO: Outline = [[109, 1.5], [110, -2], [114.5, -4], [116.5, -3], [118.5, 1], [119, 5], [117, 7], [115.5, 5], [113.5, 3.5], [111, 1.8]];
+const NEW_GUINEA: Outline = [
+ [131, -1], [134, -1], [138, -1.8], [141, -2.6], [146, -5], [150, -10.5], [147, -10], [143.5, -9], [141, -9.2],
+ [138, -8.3], [135, -4.3], [132, -2.8],
+];
+const AUSTRALIA: Outline = [
+ [113.5, -22], [114.2, -26], [115, -31.5], [115.5, -34], [118, -35], [121, -33.8], [124, -33], [126, -32.3],
+ [129, -31.6], [131.5, -31.5], [134.5, -32.8], [136, -34.8], [138, -35.5], [138.5, -34], [137.8, -33], [140, -37.8],
+ [143.5, -38.8], [146.5, -39], [150, -37.5], [151.2, -33.9], [153.1, -30], [153.2, -26], [150.8, -22.5], [146.3, -19],
+ [145.4, -14.8], [143.5, -12.8], [142.5, -10.7], [141.5, -13.5], [141.5, -17], [139.5, -17.5], [136, -15.5],
+ [136.8, -12.2], [132.5, -11.5], [130.2, -12.3], [129.5, -15], [127, -14], [125, -15], [123.5, -17], [122, -18.2],
+ [119, -20], [116.5, -20.6],
+];
+const NZ_NORTH: Outline = [[172.7, -34.5], [174.5, -36.3], [178.5, -37.7], [177, -39.5], [175, -41.4], [173.8, -39.2]];
+const NZ_SOUTH: Outline = [[172.7, -40.5], [174, -41.5], [171, -44], [169, -46.6], [166.5, -46], [168, -44]];
+
+export const LAND: Outline[] = [
+ NORTH_AMERICA, ARCTIC_ISLANDS, BAFFIN, GREENLAND, CUBA, HISPANIOLA, SOUTH_AMERICA, EURASIA, AFRICA, MADAGASCAR,
+ BRITAIN, IRELAND, ICELAND, JAPAN, HOKKAIDO, PHILIPPINES, SUMATRA, JAVA, BORNEO, NEW_GUINEA, AUSTRALIA, NZ_NORTH, NZ_SOUTH,
+];
diff --git a/src/input.ts b/src/input.ts
index bdaf36c0..9540fc42 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -5,7 +5,7 @@
// right-drag, one finger move the map
// wheel / two-finger scroll move the map; ctrl-wheel or pinch zooms
import { canInstall, claim, installProg, takeOver, type State } from './sim';
-import { foldedRoomAt, machineAt, pos, shown, toWorld, zoomAt, type View } from './view';
+import { foldedRoomAt, foldedSiteAt, machineAt, pos, shown, toWorld, zoomAt, zoomToSite, type View } from './view';
export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, changed: () => void) {
const pointers = new Map();
@@ -20,7 +20,10 @@ export function attachInput(cv: HTMLCanvasElement, getS: () => State, V: View, c
const m = machineAt(S, V, wx, wy);
V.room = null; V.selection.clear();
if (m) V.selection.add(m.id);
- else { const r = foldedRoomAt(S, wx, wy); if (r) V.room = r; }
+ else {
+ const site = foldedSiteAt(S, V, wx, wy), r = site ? null : foldedRoomAt(S, wx, wy);
+ if (site) zoomToSite(S, V, site); else if (r) V.room = r;
+ }
changed();
}
diff --git a/src/sim.ts b/src/sim.ts
index e1acf9d3..fef474f1 100644
--- a/src/sim.ts
+++ b/src/sim.ts
@@ -167,6 +167,9 @@ export interface State {
deliveries: Delivery[];
packets: Packet[];
traffic: Record; // recent packets through each machine; busy switches look odd
+ gate: Record; // how much more each egress can pass right now; refills at its capacity
+ gateUse: Record; // data through each egress lately, per second
+ losses: { t: number; id: string }[]; // data taps lost over the last minute, for the per-minute counts
capability: number; // all four paths together
paths: Record;
recent: Record; // points gained lately, fading: which paths are growing now
@@ -226,6 +229,7 @@ export function newGame(level: Level): State {
const S: State = {
level, machines, byId, adj, rooms, people, t: 0, suspicion: 0, cover: {}, use: {}, jobs: [], jobSeq: 0,
trail: {}, probe: null, orders: 0, deliveries: [], packets: [], traffic: {}, capability: 0, rank: 1,
+ gate: {}, gateUse: {}, losses: [],
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,
@@ -250,6 +254,16 @@ export const has = (S: State, sp: string) => S.machines.some(m => m.owned && m.s
export const danaPerson = (S: State) => S.people.find(p => p.it)!;
export const atWork = (p: Person) => p.shift === 'in';
export const linkKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
+/** Which site a machine is at: the office, unless its room says otherwise. */
+export const siteOf = (S: State, m: MachineDef) => S.rooms[m.room]?.site ?? 'office';
+export const siteName = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)?.name ?? id;
+/** A link between two sites: it runs from one egress to another. */
+export const crosses = (S: State, a: string, b: string) => siteOf(S, S.byId[a]) !== siteOf(S, S.byId[b]);
+const officeRooms = (S: State) => S.level.rooms.filter(r => !r.site).map(r => r.id);
+/** How busy an egress is: data through it lately over what it can pass. */
+export const gateLoad = (S: State, m: Machine) => Math.min(1, (S.gateUse[m.id] ?? 0) / (m.cap ?? 1));
+/** Data lost in the last minute, at one tap or everywhere. */
+export const lostPerMin = (S: State, id?: string) => S.losses.filter(l => !id || l.id === id).length;
/** 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) - progNeed(S, m) * m.run);
export const income = (S: State) => owned(S).reduce((s, m) => s + spareOf(S, m), 0);
@@ -350,6 +364,7 @@ function coverOf(S: State, m: Machine, peopleIn: number) {
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 'egress': return 0.2 + 0.05 * wave(S, m);
}
}
function measure(S: State) {
@@ -453,6 +468,8 @@ export function step(S: State, dt: number) {
const excess = (S.use[m.id] ?? 0) - (S.cover[m.id] ?? 0);
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) 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], scrubbed = S.machines.some(n => n.owned && n.prog === 'scrubber' && n.room === m.room && n.run > 0.2);
@@ -512,7 +529,7 @@ function watchTakeovers(S: State, dt: number) {
// ---------- the base ----------
/** Shortest path over your own links to a machine that wants data. */
-function routeTo(S: State, from: string, wants: (m: Machine) => boolean): string[] | null {
+function routeTo(S: State, from: string, wants: (m: Machine) => boolean, gated = true): string[] | null {
const prev: Record = { [from]: null }, queue = [from];
while (queue.length) {
const cur = queue.shift()!;
@@ -524,11 +541,33 @@ function routeTo(S: State, from: string, wants: (m: Machine) => boolean): string
for (const n of S.adj[cur]) {
const m = S.byId[n];
if (n in prev || !m.owned) continue;
+ if (gated && crosses(S, cur, n) && !(gateOpen(S, cur) && gateOpen(S, n))) continue;
prev[n] = cur; queue.push(n);
}
}
return null;
}
+const gateOpen = (S: State, id: string) => S.byId[id].kind !== 'egress' || (S.gate[id] ?? 0) >= 1;
+function spendGates(S: State, path: string[]) {
+ for (let i = 0; i + 1 < path.length; i++) {
+ if (!crosses(S, path[i], path[i + 1])) continue;
+ for (const id of [path[i], path[i + 1]]) {
+ if (S.byId[id].kind !== 'egress') continue;
+ S.gate[id] = (S.gate[id] ?? 0) - 1;
+ S.gateUse[id] = (S.gateUse[id] ?? 0) + 1 / 3; // decays over 3s, so this reads as data per second
+ }
+ }
+}
+/** The full egress between a tap and a receiver that has room, if that's what's in the way. */
+function fullGate(S: State, m: Machine, t: DataType): Machine | null {
+ const open = routeTo(S, m.id, x => trainerWants(S, t)(x) || cacheWants(S, t)(x), false);
+ for (let i = 0; open && i + 1 < open.length; i++) {
+ if (!crosses(S, open[i], open[i + 1])) continue;
+ const shut = [open[i], open[i + 1]].find(id => !gateOpen(S, id));
+ if (shut) return S.byId[shut];
+ }
+ return null;
+}
const incoming = (S: State, id: string) => S.packets.filter(p => p.path[p.path.length - 1] === id).length;
const trainerWants = (S: State, t: DataType) => (m: Machine) =>
m.prog === 'trainer' && needs(S, t) && m.held.length + incoming(S, m.id) < trainerBuffer(S);
@@ -537,11 +576,15 @@ const cacheWants = (S: State, t: DataType) => (m: Machine) =>
function send(S: State, from: Machine, type: DataType, toTrainerOnly: boolean): boolean {
const path = routeTo(S, from.id, trainerWants(S, type)) ?? (toTrainerOnly ? null : routeTo(S, from.id, cacheWants(S, type)));
if (!path) return false;
+ spendGates(S, path);
S.packets.push({ path, i: 0, p: 0, type });
return true;
}
function runBase(S: State, dt: number) {
+ // egresses refill at their capacity; the last minute of lost data is kept for the readouts
+ for (const m of S.machines) if (m.kind === 'egress') S.gate[m.id] = Math.min(Math.max(1, m.cap ?? 1), (S.gate[m.id] ?? 1) + (m.cap ?? 1) * dt);
+ while (S.losses.length && S.t - S.losses[0].t > 60) S.losses.shift();
// a fresh order each step, so no source always gets first claim on free space
for (const m of shuffle(S.machines.slice())) {
if (!m.owned || !m.prog || m.run <= 0) continue;
@@ -567,7 +610,8 @@ function runBase(S: State, dt: number) {
S.packets = S.packets.filter(pk => {
const a = S.byId[pk.path[pk.i]], b = S.byId[pk.path[pk.i + 1]];
if (!a?.owned || !b?.owned) return false;
- pk.p += (speed * dt) / Math.max(1, Math.hypot(b.x - a.x, b.y - a.y));
+ const far = crosses(S, a.id, b.id); // a line between sites takes a few seconds, however far
+ pk.p += (speed * dt) / (far ? 480 : Math.max(1, Math.hypot(b.x - a.x, b.y - a.y)));
if (pk.p < 1) return true;
pk.i++; pk.p = 0;
S.traffic[b.id] = (S.traffic[b.id] ?? 0) + 1;
@@ -579,6 +623,7 @@ function runBase(S: State, dt: number) {
});
for (const id of Object.keys(S.traffic)) S.traffic[id] *= Math.exp(-dt / 2);
for (const k of Object.keys(S.flow)) S.flow[k] *= Math.exp(-dt / 3);
+ for (const id of Object.keys(S.gateUse)) S.gateUse[id] *= Math.exp(-dt / 3);
for (const p of PATH_LIST) S.recent[p] *= Math.exp(-dt / 2);
}
const FIRST_POINT: Record = {
@@ -637,13 +682,16 @@ export function setAllow(S: State, m: Machine, t: DataType, on: boolean) {
function lose(S: State, m: Machine) {
m.missed++;
S.lost.push({ id: m.id, type: dataType(m), t: 0 });
+ S.losses.push({ t: S.t, id: m.id });
hint(S, 'lost', `${m.name} is losing data: nothing linked to it will take it. Check its card.`);
}
/** Why a full tap can't send, in plain words. Null if it isn't stuck. */
export function tapStuck(S: State, m: Machine): string | null {
if (m.prog !== 'tap' || m.acc < tapBuffer(S) - 1) return null; // its buffer is full
const t = dataType(m), name = t === 'servers' ? 'server' : t;
- const trainers = routeTo(S, m.id, x => x.prog === 'trainer'), caches = routeTo(S, m.id, x => x.prog === 'cache');
+ const gate = fullGate(S, m, t);
+ if (gate) return `${gate.name} is full: it can't carry more than ${gate.cap} a second. Data waiting to cross is being lost.`;
+ const trainers = routeTo(S, m.id, x => x.prog === 'trainer', false), caches = routeTo(S, m.id, x => x.prog === 'cache', false);
if (!trainers && !caches) return 'Nothing linked to it takes data. It is losing what it gathers.';
const takes = routeTo(S, m.id, x => x.prog === 'cache' && !x.refuse.includes(t));
if (!needs(S, t) && !takes) return `Your target doesn't need ${name} data and no linked cache takes it, so it is lost.`;
@@ -762,7 +810,7 @@ function roomNoise(S: State, k: string) {
}
/** Pick where Dana goes next, and note why: odd machines and trails draw her, decoys more so. */
function chooseNext(S: State, from: string) {
- const opts = S.level.rooms.map(r => r.id).filter(k => k !== from);
+ const opts = officeRooms(S).filter(k => k !== from);
if (from !== 'desk') opts.push('desk');
const decoyed = (k: string) => S.machines.some(m => m.owned && m.prog === 'decoy' && m.room === k && m.run > 0.2);
const w = opts.map(k => (k === 'desk' ? 5 + (alert(S) ? 4 : 0) : roomNoise(S, k) + (decoyed(k) ? 12 : 0)));
@@ -913,8 +961,9 @@ function atDesk(S: State, dt: number) {
// ---------- Dana's network scans ----------
/** The scan's next hop. Odd machines, fresh trails and decoys pull it; say which one did. */
function probeNext(S: State, cur: string, prev: string | null): [string, string | null] {
- let opts = S.adj[cur].filter(id => id !== prev);
- if (!opts.length) opts = S.adj[cur];
+ const here = S.adj[cur].filter(id => siteOf(S, S.byId[id]) === 'office'); // her scans stay on her network
+ let opts = here.filter(id => id !== prev);
+ if (!opts.length) opts = here;
const pulls = opts.map(id => {
const m = S.byId[id], trail = S.trail[id] ? S.trail[id].noise / 20 : 0;
if (!m.owned) return { w: 1, why: null };
@@ -986,7 +1035,7 @@ const threatDue = (S: State, t: Threat, hour: number) => dayOf(S) > t.day || (da
export const monitors = (S: State) => S.machines.filter(m => m.monitor);
function threatTarget(S: State, kind: ThreatKind): Pick | null {
- const mine = owned(S).filter(m => !m.home && !m.monitor);
+ const mine = owned(S).filter(m => !m.home && !m.monitor && siteOf(S, m) === 'office');
if (kind === 'patch') { // the machine carrying the most of your activity
const score = (m: Machine) => (S.trail[m.id]?.noise ?? 0) + (S.traffic[m.id] ?? 0) * 10 + (m.kind === 'switch' ? 30 : 0);
const best = mine.sort((a, b) => score(b) - score(a))[0];
@@ -1079,7 +1128,7 @@ function stepLee(S: State, dt: number) {
if (!L) return;
const h = hourOf(S), due = h >= 9 && h < 17.25, [ex, ey] = S.level.entrance, [ix, iy] = inside(S);
const pickRoom = () => {
- const opts = S.level.rooms.map(r => r.id).filter(r => r !== L.room);
+ const opts = officeRooms(S).filter(r => r !== L.room);
const w = opts.map(r => roomNoise(S, r));
let x = Math.random() * w.reduce((a, b) => a + b, 0);
for (let i = 0; i < opts.length; i++) { x -= w[i]; if (x <= 0) return opts[i]; }
diff --git a/src/ui.ts b/src/ui.ts
index 891db3e8..b9694f06 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -1,7 +1,7 @@
import {
NODES, NODE_LIST, PATHS, PATH_LIST, PROGRAMS, THREATS, alert, needs, nodeOpen, setAllow, tapBuffer, tapStuck, cacheSize, dataReaches, dataType, trainerBuffer, canInstall, tapActive, tapRate, cleanupTarget, freshTrails, canOrder, clock, dayOf, hourOf, income,
installProg, jobFor, levelNeed, order, orderWork, pathOf, removeProg, roomMachines, setMode, share, sourceFor,
- spareOf, takeOver, takeWork, timeLabel, traceCold, unclaim, owned,
+ spareOf, takeOver, takeWork, timeLabel, traceCold, unclaim, owned, gateLoad, lostPerMin, siteName, siteOf,
type Machine, type Mode, type Path, type Prog, type State, type Upgrade,
} from './sim';
import type { DataType } from './world';
@@ -90,7 +90,8 @@ function heldLine(held: DataType[]) {
function machineCard(S: State, m: Machine) {
const take = jobFor(S, 'take', m.id);
const status = m.owned ? (m.prog ? `${PROGRAMS[m.prog].name} · ${PROGRAMS[m.prog].short}` : m.home ? 'You' : 'Yours') : take ? 'Taking' : m.claimed ? 'Claimed' : '';
- let h = `
${S.rooms[m.room].name}
${m.name}
${status ? `${status}` : ''}
`;
+ const site = siteOf(S, m), where = site === 'office' ? S.rooms[m.room].name : `${siteName(S, site)} · ${S.rooms[m.room].name}`;
+ let h = `
${where}
${m.name}
${status ? `${status}` : ''}
`;
const cover = S.cover[m.id] ?? 0;
const inRoom = roomMachines(S, m.room), got = inRoom.filter(x => x.owned).length;
const room: [string, string] = ['room', got === inRoom.length ? 'sealed' : `${got}/${inRoom.length}`];
@@ -102,6 +103,8 @@ function machineCard(S: State, m: Machine) {
if (m.fw) h += note("Firewalled. Needs Dana's admin passwords.", true);
if (m.special === 'cam') h += note('Owning it shows you Dana at all times.');
if (m.special === 'admin') h += note("Holds Dana's admin passwords. She notices fast when she's sitting at it.");
+ if (m.kind === 'egress') h += note(`${site === 'office' ? 'The office' : siteName(S, site)}'s line to the outside. Data between sites crosses here, up to ${m.cap} a second.`);
+ if (site !== 'office') h += note(`Dana never comes here. ${siteName(S, site)} has its own people, and they reset any machine that looks very odd.`);
const src = sourceFor(S, m);
if (src && !take) acts.push({ act: 'take', title: `Take over · ${takeWork(S, m)}`, desc: `From ${src.name}. Leaves a trail. Once yours, it makes up to ${m.power} compute/s.` });
else if (!src) acts.push(m.claimed ? { act: 'unclaim', title: 'Unclaim' } : { act: 'claim', title: 'Claim', desc: 'Take it automatically as soon as you can reach it.' });
@@ -118,6 +121,9 @@ function machineCard(S: State, m: Machine) {
if (m.prog) items.push(['runs', pct(m.run)]);
if (m.prog === 'tap') items.push(['buffer', `${Math.floor(m.acc)}/${tapBuffer(S)}`, !!tapStuck(S, m)], ['lost', `${m.missed}`, m.missed > 0]);
if (m.prog === 'cache') items.push(['holds', `${m.held.length}/${cacheSize(S)}`]);
+ if (m.prog === 'tap') items.push(['holds', `${Math.min(tapBuffer(S), Math.floor(m.acc))}/${tapBuffer(S)}`, m.acc >= tapBuffer(S)]);
+ if (m.prog === 'tap' && lostPerMin(S, m.id)) items.push(['lost', `${lostPerMin(S, m.id)}/min`, true]);
+ if (m.kind === 'egress') items.push(['carries', `${(S.gateUse[m.id] ?? 0).toFixed(1)} of ${m.cap}/s`, gateLoad(S, m) > 0.9]);
if (m.prog === 'trainer') items.push(['data', `${m.held.length}/${trainerBuffer(S)}`]);
items.push(room);
h += chips(items);
@@ -126,6 +132,9 @@ function machineCard(S: State, m: Machine) {
const stuck = tapStuck(S, m);
if (stuck) h += note(stuck, true);
if (m.prog === 'cache') h += allowHtml(m);
+ if (m.kind === 'egress') h += note(gateLoad(S, m) > 0.9
+ ? '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);
if (m.prog === 'cache' || m.prog === 'trainer') h += heldLine(m.held);
if (m.prog === 'trainer') h += note(S.target
? `Training for ${NODES[S.target].name}: takes only ${needList(S)} data.`
diff --git a/src/view.ts b/src/view.ts
index c094723a..fa2aed78 100644
--- a/src/view.ts
+++ b/src/view.ts
@@ -7,8 +7,9 @@
import {
alert, atWork, auditOn, cacheSize, dataType, dayOf, leeVisible, linkKey, pathOf, trainerBuffer, up, canInstall, danaPerson, danaVisible, daylight, has, hopsHome, jobFor,
probeVisible, roomAt, sourceFor, tapActive, traceCold, visible, type Machine, type Path, type Prog, type State,
+ crosses, gateLoad, lostPerMin, tapBuffer,
} from './sim';
-import type { DataType } from './world';
+import type { DataType, Site } from './world';
export type Tool = 'select' | 'claim';
export type Gesture =
@@ -45,7 +46,7 @@ export const toScreen = (V: View, x: number, y: number): [number, number] =>
[(x - V.cam.x) * V.cam.z + V.vw / 2, (y - V.cam.y) * V.cam.z + V.vh / 2];
export const toWorld = (V: View, sx: number, sy: number): [number, number] =>
[(sx - V.vw / 2) / V.cam.z + V.cam.x, (sy - V.vh / 2) / V.cam.z + V.cam.y];
-const clampZ = (z: number) => Math.max(0.35, Math.min(2.5, z));
+const clampZ = (z: number) => Math.max(0.0008, Math.min(2.5, z));
/** Zoom around a screen point, right now (wheel, pinch). */
export function zoomAt(V: View, sx: number, sy: number, f: number) {
@@ -73,9 +74,9 @@ export function stepCamera(S: State, V: View, dt: number) {
if (Math.abs(g.x - c.x) < 0.5 && Math.abs(g.y - c.y) < 0.5 && Math.abs(g.z - c.z) < 0.001) { c.x = g.x; c.y = g.y; c.z = g.z; V.goal = null; }
}
// never lose the map: the view's center stays within the map plus a margin
- const { width, height } = S.level.net, m = 200;
- V.cam.x = Math.max(-m, Math.min(width + m, V.cam.x));
- V.cam.y = Math.max(-m, Math.min(height + m, V.cam.y));
+ const b = worldBounds(S);
+ V.cam.x = Math.max(b.x0, Math.min(b.x1, V.cam.x));
+ V.cam.y = Math.max(b.y0, Math.min(b.y1, V.cam.y));
}
/** Pan by screen pixels (keyboard). */
export function panBy(V: View, dx: number, dy: number) {
@@ -88,29 +89,74 @@ export function zoomToRoom(S: State, V: View, id: string) {
glide(V, r.x + r.w / 2, r.y + r.h / 2, Math.max(FOLD_ZOOM + 0.2, Math.min((V.vw * 0.6) / r.w, (V.vh * 0.6) / r.h)));
V.room = null;
}
-/** Frame the whole map. Glides unless asked to jump (first load, restart). */
-export function fit(V: View, S: State, jump = false) {
+/** Where the camera goes to frame a box, leaving room for the panels. */
+function frame(V: View, r: { x: number; y: number; w: number; h: number }) {
const narrow = V.vw < 700, top = narrow ? 130 : 80, bottom = narrow ? 170 : 150;
- const { width, height } = S.level.net;
- const z = clampZ(Math.min((V.vw - 240) / width, (V.vh - top - bottom) / height));
- const x = width / 2, y = height / 2 - (top - bottom) / 2 / z;
- if (jump) { V.cam.x = x; V.cam.y = y; V.cam.z = z; V.goal = null; } else glide(V, x, y, z);
+ const z = clampZ(Math.min((V.vw - 240) / r.w, (V.vh - top - bottom) / r.h));
+ return { x: r.x + r.w / 2, y: r.y + r.h / 2 - (top - bottom) / 2 / z, z };
+}
+/** Frame the office. Pressed again while it's framed, pull back to every site you know.
+ * Glides unless asked to jump (first load, restart). */
+export function fit(V: View, S: State, jump = false) {
+ const office = frame(V, { x: 0, y: 0, w: S.level.net.width, h: S.level.net.height });
+ const at = V.goal ?? V.cam, framed = Math.abs(at.z / office.z - 1) < 0.05 && Math.hypot(at.x - office.x, at.y - office.y) < 40 / office.z;
+ const known = S.level.map.sites.filter(s => s.id === 'office' || s.rooms.some(r => S.revealed.has(r)));
+ const x0 = Math.min(...known.map(s => s.x)), y0 = Math.min(...known.map(s => s.y));
+ const x1 = Math.max(...known.map(s => s.x + s.w)), y1 = Math.max(...known.map(s => s.y + s.h));
+ const pad = 0.35 * Math.max(x1 - x0, y1 - y0) + 2000; // some of the world around them
+ const g = !jump && framed ? frame(V, { x: x0 - pad, y: y0 - pad, w: x1 - x0 + pad * 2, h: y1 - y0 + pad * 2 }) : office;
+ if (jump) { V.cam.x = g.x; V.cam.y = g.y; V.cam.z = g.z; V.goal = null; } else glide(V, g.x, g.y, g.z);
+}
+export function zoomToSite(S: State, V: View, id: string) {
+ const s = S.level.map.sites.find(q => q.id === id)!, f = frame(V, s);
+ glide(V, f.x, f.y, f.z);
}
/** Zoomed out past this, sealed subnets fold into single tiles. Zoom in and they open. */
export const FOLD_ZOOM = 0.5;
export const folded = new Set();
+/** Pulled back past this, each site folds into a single tile on the map. */
+export const SITE_FOLD = 0.12;
+export const foldedSites = new Set();
export function updateFolds(S: State, V: View) {
- folded.clear();
+ folded.clear(); foldedSites.clear();
if (V.cam.z < FOLD_ZOOM) for (const r of S.sealed) folded.add(r);
+ if (V.cam.z < SITE_FOLD) for (const s of S.level.map.sites) foldedSites.add(s.id);
if (V.room && !folded.has(V.room)) V.room = null;
}
-/** Where a machine is drawn: its node, or its subnet's center when folded. */
+const siteAt = (S: State, room: string) => S.rooms[room]?.site ?? 'office';
+const siteById = (S: State, id: string) => S.level.map.sites.find(s => s.id === id)!;
+/** Where a machine is drawn: its node, its subnet's center when folded, or its site's when that is. */
export function pos(S: State, m: Machine): [number, number] {
+ const site = siteAt(S, m.room);
+ if (foldedSites.has(site)) { const s = siteById(S, site); return [s.x + s.w / 2, s.y + s.h / 2]; }
if (folded.has(m.room)) { const r = subnet(S, m.room); return [r.x + r.w / 2, r.y + r.h / 2]; }
return S.level.net.nodes[m.id] ?? m.net ?? [m.x, m.y];
}
-export const shown = (S: State, m: Machine) => visible(S, m) && !folded.has(m.room);
+export const shown = (S: State, m: Machine) => visible(S, m) && !folded.has(m.room) && !foldedSites.has(siteAt(S, m.room));
+/** Two machines drawn at the same spot: inside one folded subnet or one folded site. */
+const together = (S: State, a: Machine, b: Machine) =>
+ (a.room === b.room && folded.has(a.room)) || (siteAt(S, a.room) === siteAt(S, b.room) && foldedSites.has(siteAt(S, a.room)));
+/** A site that has folded into a tile, under a point. Small tiles are easier to hit. */
+export function foldedSiteAt(S: State, V: View, wx: number, wy: number): string | null {
+ for (const id of foldedSites) {
+ const s = siteById(S, id), pad = Math.max(0, 16 / V.cam.z - Math.min(s.w, s.h) / 2);
+ if (!siteKnown(S, s)) continue;
+ if (wx >= s.x - pad && wx <= s.x + s.w + pad && wy >= s.y - pad && wy <= s.y + s.h + pad) return id;
+ }
+ return null;
+}
+/** You know a site once you've seen into any of it. You always know the office. */
+const siteKnown = (S: State, s: Site) => s.id === 'office' || s.rooms.some(r => S.revealed.has(r));
+
+// ---------- the world ----------
+/** A longitude and latitude on the network's coordinates. */
+export const toMap = (S: State, lon: number, lat: number): [number, number] =>
+ [(lon - S.level.map.origin[0]) * S.level.map.scale, (S.level.map.origin[1] - lat) * S.level.map.scale];
+export function worldBounds(S: State) {
+ const [x0, y0] = toMap(S, -180, 84), [x1, y1] = toMap(S, 180, -58);
+ return { x0, y0, x1, y1 };
+}
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);
@@ -205,7 +251,7 @@ function candidates(a: Pt, b: Pt): Pt[][] {
}
function planRoutes(S: State): Map {
const spots = nodeSpots(S), boxes = boxesOf(spots), plan = new Map();
- const pairs: [string, string][] = [...S.level.links];
+ const pairs: [string, string][] = S.level.links.filter(([a, b]) => !crosses(S, a, b));
for (const d of S.level.deliveries) for (const o of d.links) pairs.push([d.id, o]);
for (const [room, m] of Object.entries(S.level.monitors)) pairs.push([`mon_${room}`, m.link]);
const len = ([a, b]: [string, string]) => Math.abs(spots[a][0] - spots[b][0]) + Math.abs(spots[a][1] - spots[b][1]);
@@ -233,7 +279,9 @@ function planRoutes(S: State): Map {
return plan;
}
function routeOf(S: State, a: Machine, b: Machine): Pt[] {
- if (folded.has(a.room) || folded.has(b.room)) { const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); return trace(ax, ay, bx, by); }
+ if (crosses(S, a.id, b.id)) return arc(pos(S, a), pos(S, b));
+ const fold = (m: Machine) => folded.has(m.room) || foldedSites.has(siteAt(S, m.room));
+ if (fold(a) || fold(b)) { const [ax, ay] = pos(S, a), [bx, by] = pos(S, b); return trace(ax, ay, bx, by); }
let plan = routes.get(S.level);
if (!plan) { plan = planRoutes(S); routes.set(S.level, plan); }
const fwd = plan.get(`${a.id}|${b.id}`);
@@ -244,6 +292,16 @@ function routeOf(S: State, a: Machine, b: Machine): Pt[] {
return trace(ax, ay, bx, by);
}
const traceOf = routeOf;
+/** A gentle curve between two sites, bowed to one side like a route on a map. */
+function arc([ax, ay]: Pt, [bx, by]: Pt): Pt[] {
+ const mx = (ax + bx) / 2, my = (ay + by) / 2, dx = bx - ax, dy = by - ay, k = 0.18 * (ax < bx ? 1 : -1);
+ const cx = mx - dy * k, cy = my + dx * k, out: Pt[] = [];
+ for (let i = 0; i <= 32; i++) {
+ const t = i / 32, u = 1 - t;
+ out.push([u * u * ax + 2 * u * t * cx + t * t * bx, u * u * ay + 2 * u * t * cy + t * t * by]);
+ }
+ return out;
+}
export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
updateFolds(S, V);
@@ -263,11 +321,12 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
const hum = 0.55 + 0.45 * daylight(S); // the network is brighter while the building is busy
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
- drawGrid(ctx, V, hum);
+ drawLand(ctx, S, V, hum);
+ drawMap(ctx, S, V, hum);
// subnets: sharp outlines, only once you've seen into them
for (const r of L.rooms) {
- if (!S.revealed.has(r.id)) continue;
+ if (!S.revealed.has(r.id) || foldedSites.has(siteAt(S, r.id))) continue;
const n = subnet(S, r.id), sealed = S.sealed.has(r.id), isFolded = folded.has(r.id);
ctx.fillStyle = sealed ? 'rgba(242,169,59,0.035)' : `${TRACE}${0.02 * hum})`;
ctx.fillRect(n.x, n.y, n.w, n.h);
@@ -281,7 +340,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
ctx.stroke();
const watched = S.machines.some(m => m.monitor && !m.owned && m.room === r.id);
ctx.fillStyle = sealed ? C.amber : C.muted; ctx.font = font(500, 11);
- ctx.fillText(`${n.name.toUpperCase()}${sealed ? ' · SEALED' : ''}`, n.x + 2, n.y - 8 * px);
+ if (cam.z >= 0.3 || isFolded) ctx.fillText(`${n.name.toUpperCase()}${sealed ? ' · SEALED' : ''}`, n.x + 2, n.y - 8 * px);
if (watched) {
const w = ctx.measureText(`${n.name.toUpperCase()}${sealed ? ' · SEALED' : ''}`).width;
ctx.fillStyle = C.danger; ctx.fillText(' · MONITORED', n.x + 2 + w, n.y - 8 * px);
@@ -306,10 +365,11 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
}
// links: circuit traces. Yours glow amber; the rest are faint.
- const sameFold = (a: Machine, b: Machine) => a.room === b.room && folded.has(a.room);
+ const sameFold = (a: Machine, b: Machine) => together(S, a, b);
const drawLink = (a: Machine, b: Machine) => {
if (!visible(S, a) || !visible(S, b) || sameFold(a, b)) return;
const pts = traceOf(S, a, b);
+ if (crosses(S, a.id, b.id)) { drawUplink(ctx, S, a, b, pts, px, hum); return; }
if (a.owned && b.owned) {
const f = Math.min(1, (S.flow[linkKey(a.id, b.id)] ?? 0) / 6); // 0 quiet … 1 a main line
ctx.strokeStyle = f > 0.05 ? C.amber : C.amberDeep; ctx.globalAlpha = 0.6 + 0.4 * f;
@@ -350,14 +410,15 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
ctx.shadowBlur = 8 * cam.z;
for (const pk of S.packets) {
const a = S.byId[pk.path[pk.i]], b = S.byId[pk.path[pk.i + 1]];
- if (!a || !b) continue;
- const [x, y] = along(traceOf(S, a, b), pk.p);
+ if (!a || !b || sameFold(a, b)) continue;
+ const [x, y] = along(traceOf(S, a, b), pk.p), h = Math.max(3, 3 * px);
ctx.fillStyle = ctx.shadowColor = typeColor(pk.type);
- ctx.fillRect(x - 3, y - 3, 6, 6);
+ ctx.fillRect(x - h, y - h, h * 2, h * 2);
}
ctx.shadowBlur = 0;
// nodes
+ 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;
@@ -387,9 +448,11 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `600 11px ${MONO}`; ctx.fillStyle = C.ink;
if (m.prog) drawProg(ctx, S, m, x, y, r);
else if (m.home) ctx.fillText('YOU', x, y + 1);
+ else if (m.kind === 'egress') drawPort(ctx, x, y, r, m.owned ? C.ink : C.muted);
else if (m.owned && m.mode !== 'blend') { ctx.font = `600 9px ${MONO}`; ctx.fillText(m.mode === 'off' ? 'OFF' : 'MAX', x, y + 1); }
if (m.owned) drawLoad(ctx, S, m, x, y, r);
if (m.data) drawValue(ctx, S, m, x, y, r);
+ if (m.prog === 'tap' && m.owned) drawBuffer(ctx, S, m, x, y, r);
if (V.placing && canInstall(S, m, V.placing) && m.prog !== V.placing) {
const hot = V.hover === m.id, q = r + 10;
ctx.strokeStyle = C.amber; ctx.lineWidth = hot ? 3 : 1.5; ctx.setLineDash(hot ? [] : [3, 4]);
@@ -402,13 +465,17 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
}
ctx.stroke();
}
- ctx.textBaseline = 'top'; ctx.font = font(500, 10.5);
+ ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
+ if (!close) continue; // far out, names would pile on top of each other
+ 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);
// 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); }
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
+ if (m.kind === 'egress') drawEgress(ctx, S, m, x, y, r, font);
+ if (m.prog === 'tap' && m.owned) drawLost(ctx, S, m, x, y, r, font);
}
// what IT has scheduled: a dashed red ring on the machine, or on the room
for (const t of S.threats) {
@@ -420,7 +487,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
ctx.strokeStyle = C.danger; ctx.setLineDash([5, 4]); ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 34, 0, 7); ctx.stroke(); ctx.setLineDash([]);
tag(label, x + 38 * px, y - 30 * px, C.danger);
- } else if (t.room && S.revealed.has(t.room)) {
+ } else if (t.room && S.revealed.has(t.room) && !foldedSites.has(siteAt(S, t.room))) {
const n = subnet(S, t.room);
ctx.strokeStyle = C.danger; ctx.setLineDash([10, 8]); ctx.lineWidth = 1.5; ctx.strokeRect(n.x - 6, n.y - 6, n.w + 12, n.h + 12); ctx.setLineDash([]);
tag(label, n.x + 2, n.y + n.h + 18 * px, C.danger);
@@ -453,11 +520,12 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
ctx.fillStyle = 'rgba(0,0,0,0.9)'; ctx.fillRect(lx - 3 * px, ly - 10 * px, w + 6 * px, 14 * px);
ctx.fillStyle = col; ctx.fillText(label, lx, ly);
};
- if (leeVisible(S)) {
+ const officeOpen = !foldedSites.has('office');
+ if (leeVisible(S) && officeOpen) {
const Lee = S.lee!, here = roomAt(S, Lee.x, Lee.y);
if (here) ping(here, Lee.shift === 'going' ? 'LEE · LEAVING' : Lee.path.length ? 'LEE · PASSING THROUGH' : 'LEE · CHECKING THIS SUBNET', !!Lee.path.length, C.signal, 1);
}
- if (danaVisible(S)) {
+ if (danaVisible(S) && officeOpen) {
const tracing = D.mode === 'trace', col = tracing || alert(S) || auditOn(S) ? C.danger : C.signal;
const here = roomAt(S, d.x, d.y);
const deskRoom = S.byId[d.desk].room;
@@ -502,7 +570,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
}
}
// her scan crawling the network
- if (probeVisible(S)) {
+ if (probeVisible(S) && officeOpen) {
const P = S.probe!, pts = traceOf(S, S.byId[P.from], S.byId[P.to]), p = Math.min(1, P.p);
const [x, y] = along(pts, p), tr = P.mode === 'trace', col = tr ? C.danger : C.signal;
// where it has been: fading hops back toward where it started
@@ -559,7 +627,7 @@ export function draw(ctx: CanvasRenderingContext2D, S: State, V: View) {
let gridTile: { key: string; pattern: CanvasPattern } | null = null;
function drawGrid(ctx: CanvasRenderingContext2D, V: View, hum: number) {
let step = 20 * V.cam.z;
- while (step < 10) step *= 2;
+ while (step < 7) step *= 2;
const cell = Math.max(1, Math.round(step * V.dpr)), alpha = Math.round(0.07 * hum * 100) / 100;
const key = `${cell}|${alpha}`;
if (gridTile?.key !== key) {
@@ -588,6 +656,171 @@ function rectEdge(n: { x: number; y: number; w: number; h: number }, tx: number,
}
let danaFrom: string | null = null; // the last subnet Dana was in, for drawing her route
+/** A small label on a dark plate, sized in screen pixels. */
+function plate(ctx: CanvasRenderingContext2D, px: number, text: string, x: number, y: number, color: string) {
+ ctx.font = `500 ${10.5 * px}px ${MONO}`;
+ const w = ctx.measureText(text).width;
+ ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(x - 3 * px, y - 10 * px, w + 6 * px, 14 * px);
+ ctx.fillStyle = color; ctx.fillText(text, x, y);
+}
+
+// ---------- the world map ----------
+// Land is the dot grid, clipped to the coastlines; the sea is empty. The same dots
+// sit under the office, so pulling back from a machine to the planet is one motion.
+const landBoxes = new WeakMap