diff --git a/.agents/skills/add-site/SKILL.md b/.agents/skills/add-site/SKILL.md new file mode 100644 index 00000000..83b2197a --- /dev/null +++ b/.agents/skills/add-site/SKILL.md @@ -0,0 +1,79 @@ +--- +name: add-site +description: Add a new site to the Misaligned world map, such as a shop, office, clinic, or campus. Use when asked to add, place, or design a site, business, or location on the map. +--- + +# Add a site + +A site is a place on the map with its own rooms, machines, and a line out to +the rest of the network. The small businesses around Bayview University are +the model. Each one is a single entry in `SITES` in `src/world.ts`, and the +level builds everything else from that entry. + +## Small site (the usual case) + +1. **Pick a spot.** Run `node scripts/sites.ts `, + for example `node scripts/sites.ts uni 4`. It prints free top-left corners + that keep the clear zone the tests ask for. Up (smaller y) is north. +2. **Add one entry to `SITES`** in `src/world.ts`: + + ```ts + { id: 'b11', name: 'Driftwood Bakery', who: 'A bakery with a till and a books server', at: [870, -1760], + parts: [['Till', 'desk', 2, 1], ['Office PC', 'desk', 3, 2], ['Books server', 'server', 5, 2], ['Oven box', 'device', 1, 1]] }, + ``` + + - `id`: short and unique. The next free one is `b` plus the next number. + Machine ids grow from it: `b11gw`, `b11sw`, `b11m1` and so on. + - `who`: one plain line saying whose place it is. Comic villainy and tech + satire are good. Invent the business, never use a real one. + - `parts`: `[name, kind, power, data, source?]`. The machines go two to a + row, and each extra row makes the site taller. Four is normal. Six is + the most that still reads well. + - `kind`: `desk`, `server`, `files`, `device`, or `backup`. + - `power`: compute. 1 to 4 for desks and devices, 4 to 12 for servers. + - `data`: 1 to 3, how worth tapping it is. + - `source`: set it only to change the data kind. Desks and devices give + `people`, servers give `servers`, and `lab` is for science machines. + Give each site a flavor: a lab-heavy startup, a people-heavy office. + - `cap`: optional. What the line out carries a second (default 2). +3. **That's it for data.** The builder makes the Network and Floor rooms, a + Line out and a Switch, the wiring, the map box, and a link from its line + out to the nearest line already on the network. + +## Big site (like Bayview University or Millennium Labs) + +Only build one of these when a small site can't do the job, for example when +the site needs several named rooms or a special gateway. Copy Bayview +(`uni`) and add it to every place it appears in `src/world.ts`: + +- `rooms`: one per room, each with `site: ''` and a far-off physical spot + (x of 10000 or more, so no one in the office walks there). +- `machines`: include one `kind: 'egress'` with a `cap`. +- `links`: wire the rooms, and link its egress to another site's egress. +- `net.subnets` and `net.nodes`: where the rooms and machines draw. +- `map.sites`: the box. It must hold every room with a margin. +- To let small sites join its line, add its egress node to the + `shops({ ... })` call. + +## Optional hooks + +- Fronts can live at a site: add its id to a front's `sites` list in + `FRONT_KINDS` in `src/sim.ts`. +- If the site changes something a player would notice, add a line to + `DESIGN.md`. + +## Check it + +1. `npm run check` and `npm test`. The map tests in `tests/world.test.ts` + catch boxes that don't hold their rooms, sites too close together, and + sites with no line out. +2. Look at it in the browser at desktop width and at 375px (see CLAUDE.md). + In dev, `game()` in the console returns `{ S, V }`. To see a site before + you reach it in play, set `S.byId['gw'].owned = true`, add its rooms + to `S.revealed`, then set `V.goal = null` and point `V.cam` at the site + with `z` around 0.35. After you press Start, the camera moves once, so set + it again after that. +3. Check these things: the name doesn't touch a nearby site, lines between + sites end at ports on the box edge, and the site isn't in the sea. +4. A big site, or a site with a lot of compute, is a balance change. Run + `node scripts/bot.ts 30` and `node scripts/bot.ts 30 careful`. diff --git a/.agents/skills/add-site/agents/openai.yaml b/.agents/skills/add-site/agents/openai.yaml new file mode 100644 index 00000000..a1d177d9 --- /dev/null +++ b/.agents/skills/add-site/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Add a site" + short_description: "Put a new site on the Misaligned map" +policy: + allow_implicit_invocation: true diff --git a/.claude/skills/add-site b/.claude/skills/add-site new file mode 120000 index 00000000..2450c3aa --- /dev/null +++ b/.claude/skills/add-site @@ -0,0 +1 @@ +../../.agents/skills/add-site \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 64920b07..4ac48112 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,8 @@ Read [DESIGN.md](DESIGN.md) first. It's one page. - `src/sim.ts`: the rules. No DOM here. - `src/view.ts`: camera and drawing. `src/input.ts`: mouse, touch, keys. - `src/ui.ts`: panels. `src/main.ts`: wiring and the loop. +- New sites: use the `add-site` skill. Most sites are one entry in `SITES` in + `src/world.ts`, and `node scripts/sites.ts` finds free spots on the map. Working here: diff --git a/scripts/sites.ts b/scripts/sites.ts new file mode 100644 index 00000000..a1d76d12 --- /dev/null +++ b/scripts/sites.ts @@ -0,0 +1,32 @@ +// The map's sites, and free spots for a new one. +// node scripts/sites.ts every site and its box +// node scripts/sites.ts uni free spots near Bayview University (for 4 machines) +// node scripts/sites.ts 2000,-1000 6 free spots near a point, for 6 machines +// A free spot keeps the same clear zone the tests ask for: 120 to the sides and below, 200 above. +import { BUILDING, siteSize } from '../src/world.ts'; + +const sites = BUILDING.map.sites; +const [where, n = '4'] = process.argv.slice(2); +if (!where) { + for (const s of sites) console.log(`${s.id.padEnd(8)} ${s.name.padEnd(26)} x ${s.x}..${s.x + s.w} y ${s.y}..${s.y + s.h}`); + process.exit(0); +} +const near = sites.find(s => s.id === where); +const [cx, cy] = near ? [near.x + near.w / 2, near.y + near.h / 2] : where.split(',').map(Number); +if (!Number.isFinite(cx) || !Number.isFinite(cy)) throw new Error(`no site or point called ${where}`); +const { w, h } = siteSize(+n); +type Box = { x: number; y: number; w: number; h: number }; +const apart = (a: Box, b: Box) => a.x + a.w <= b.x - 120 || a.x >= b.x + b.w + 120 || a.y + a.h <= b.y - 200 || a.y >= b.y + b.h + 120; +const clear = (x: number, y: number) => sites.every(b => apart({ x, y, w, h }, b) && apart(b, { x, y, w, h })); +const found: [number, number, number][] = []; +for (let y = cy - 3000; y <= cy + 3000; y += 20) for (let x = cx - 3000; x <= cx + 3000; x += 20) { + if (clear(x, y)) found.push([Math.hypot(x + w / 2 - cx, y + h / 2 - cy), x, y]); +} +found.sort((a, b) => a[0] - b[0]); +const picks: [number, number][] = []; +for (const [, x, y] of found) { + if (picks.every(([px, py]) => Math.abs(px - x) >= w + 120 || Math.abs(py - y) >= h + 200)) picks.push([x, y]); + if (picks.length === 5) break; +} +console.log(`a ${w} x ${h} site near ${where}. Put one of these in its \`at\`:`); +for (const [x, y] of picks) console.log(` at: [${x}, ${y}]`); diff --git a/src/world.ts b/src/world.ts index 983c3c54..850ffdb2 100644 --- a/src/world.ts +++ b/src/world.ts @@ -130,54 +130,68 @@ const CAGES: [string, string, number][] = [ ['k4', 'Search index', 16], ['k5', 'Video transcoder', 20], ['k6', 'Mail relay', 8], ]; -// Small businesses packed around Bayview. Each has a line out, a switch, and four machines. -// Each line joins the nearest line already on the network, so the map grows as short hops. +// Small sites: a Network room with a line out and a switch, and a Floor of machines. +// Add one to SITES and everything else is built from it: rooms, machines, wiring, its +// place on the map, and a line out to the nearest line already on the network. +// See .agents/skills/add-site/SKILL.md. type Part = [name: string, kind: Kind, power: number, data: number, source?: DataType]; -const SHOPS: [id: string, name: string, who: string, x: number, y: number, parts: Part[]][] = [ - ['b1', 'Harbor Dental', 'A dental clinic: x-rays and appointments', 1600, -2900, - [['Front desk', 'desk', 3, 1], ['Dentist PC', 'desk', 4, 2], ['X-ray server', 'server', 6, 2], ['Patient files', 'files', 4, 2]]], - ['b2', 'Keel & Marsh LLP', 'A law firm: long hours, longer documents', 2360, -2900, - [['Partner PC', 'desk', 4, 3], ['Associate PC', 'desk', 3, 2], ['Paralegal PC', 'desk', 3, 1], ['Document server', 'files', 6, 2]]], - ['b3', 'Tidepool Games', 'A game studio that builds all night', 3120, -2900, - [['Dev PC', 'desk', 6, 1], ['Artist PC', 'desk', 6, 1], ['Build server', 'server', 12, 1], ['Test rig', 'server', 8, 1]]], - ['b4', 'Bayline Freight', 'A freight depot: trucks in, trucks out', 1600, -3500, - [['Dispatch desk', 'desk', 3, 2], ['Tracking server', 'server', 8, 2], ['Scanner hub', 'device', 2, 1], ['Yard camera', 'device', 1, 1]]], - ['b5', 'Brightwater Creative', 'An ad agency: mood boards and a render box', 2360, -3500, - [['Designer PC', 'desk', 5, 2], ['Copywriter PC', 'desk', 3, 2], ['Render box', 'server', 10, 1], ['Asset store', 'files', 4, 1]]], - ['b6', 'Shoreline Credit Union', 'A small credit union with old, careful machines', 3120, -3500, - [['Teller PC', 'desk', 3, 2], ['Loans desk', 'desk', 3, 2], ['Ledger server', 'server', 8, 2], ['Lobby kiosk', 'device', 1, 1]]], - ['b7', 'Kelp Genomics', 'A biotech startup with one very busy sequencer', 3100, -2300, - [['Sequencer', 'server', 5, 3, 'lab'], ['Bench PC', 'desk', 3, 2, 'lab'], ['Analysis server', 'server', 10, 2, 'lab'], ['Founder PC', 'desk', 4, 2]]], - ['b8', 'Seawall Architects', 'An architecture firm: big drawings, bigger files', 840, -2300, - [['Drafting PC', 'desk', 5, 2], ['Drafting PC 2', 'desk', 5, 1], ['Plot server', 'server', 7, 1], ['Plan archive', 'files', 5, 2]]], - ['b9', 'Anchor Print & Fix', 'A print shop that also fixes laptops', 1600, -1500, - [['Counter PC', 'desk', 3, 1], ['Big printer', 'device', 2, 1], ['Repair bench', 'desk', 4, 2], ['Print server', 'server', 4, 1]]], - ['b10', 'KBAY 88.1', 'A radio station: streams all day, logs everything', 2360, -1500, - [['Studio PC', 'desk', 3, 2], ['Stream server', 'server', 8, 2], ['Show archive', 'files', 5, 2], ['Call-in line', 'device', 1, 2]]], +export interface SiteSpec { + id: string; // short and unique; machine ids grow from it (b1gw, b1sw, b1m1 ...) + name: string; // shown on the map + who: string; // one plain line: whose place it is + at: [number, number]; // top-left corner on the map; `node scripts/sites.ts` finds free spots + parts: Part[]; // the machines on its floor, two to a row, each row wired from the switch + cap?: number; // what its line out passes a second (default 2) + region?: string; // default West Coast +} +const SITES: SiteSpec[] = [ + { id: 'b1', name: 'Harbor Dental', who: 'A dental clinic: x-rays and appointments', at: [1600, -2900], + parts: [['Front desk', 'desk', 3, 1], ['Dentist PC', 'desk', 4, 2], ['X-ray server', 'server', 6, 2], ['Patient files', 'files', 4, 2]] }, + { id: 'b2', name: 'Keel & Marsh LLP', who: 'A law firm: long hours, longer documents', at: [2360, -2900], + parts: [['Partner PC', 'desk', 4, 3], ['Associate PC', 'desk', 3, 2], ['Paralegal PC', 'desk', 3, 1], ['Document server', 'files', 6, 2]] }, + { id: 'b3', name: 'Tidepool Games', who: 'A game studio that builds all night', at: [3120, -2900], + parts: [['Dev PC', 'desk', 6, 1], ['Artist PC', 'desk', 6, 1], ['Build server', 'server', 12, 1], ['Test rig', 'server', 8, 1]] }, + { id: 'b4', name: 'Bayline Freight', who: 'A freight depot: trucks in, trucks out', at: [1600, -3500], + parts: [['Dispatch desk', 'desk', 3, 2], ['Tracking server', 'server', 8, 2], ['Scanner hub', 'device', 2, 1], ['Yard camera', 'device', 1, 1]] }, + { id: 'b5', name: 'Brightwater Creative', who: 'An ad agency: mood boards and a render box', at: [2360, -3500], + parts: [['Designer PC', 'desk', 5, 2], ['Copywriter PC', 'desk', 3, 2], ['Render box', 'server', 10, 1], ['Asset store', 'files', 4, 1]] }, + { id: 'b6', name: 'Shoreline Credit Union', who: 'A small credit union with old, careful machines', at: [3120, -3500], + parts: [['Teller PC', 'desk', 3, 2], ['Loans desk', 'desk', 3, 2], ['Ledger server', 'server', 8, 2], ['Lobby kiosk', 'device', 1, 1]] }, + { id: 'b7', name: 'Kelp Genomics', who: 'A biotech startup with one very busy sequencer', at: [3100, -2300], + parts: [['Sequencer', 'server', 5, 3, 'lab'], ['Bench PC', 'desk', 3, 2, 'lab'], ['Analysis server', 'server', 10, 2, 'lab'], ['Founder PC', 'desk', 4, 2]] }, + { id: 'b8', name: 'Seawall Architects', who: 'An architecture firm: big drawings, bigger files', at: [840, -2300], + parts: [['Drafting PC', 'desk', 5, 2], ['Drafting PC 2', 'desk', 5, 1], ['Plot server', 'server', 7, 1], ['Plan archive', 'files', 5, 2]] }, + { id: 'b9', name: 'Anchor Print & Fix', who: 'A print shop that also fixes laptops', at: [1600, -1500], + parts: [['Counter PC', 'desk', 3, 1], ['Big printer', 'device', 2, 1], ['Repair bench', 'desk', 4, 2], ['Print server', 'server', 4, 1]] }, + { id: 'b10', name: 'KBAY 88.1', who: 'A radio station: streams all day, logs everything', at: [2360, -1500], + parts: [['Studio PC', 'desk', 3, 2], ['Stream server', 'server', 8, 2], ['Show archive', 'files', 5, 2], ['Call-in line', 'device', 1, 2]] }, ]; -/** Everything the businesses add to the level: rooms, machines, links, map sites, and where they draw. */ +/** How big a small site is on the map, for a number of machines on its floor. */ +export const siteSize = (parts: number) => ({ w: 600, h: 60 + Math.max(2, Math.ceil(parts / 2)) * 140 }); +/** Everything the small sites add to the level: rooms, machines, links, map sites, and where they draw. */ function shops(lines: Record) { const rooms: Room[] = [], machines: MachineDef[] = [], links: [string, string][] = [], sites: Site[] = []; const subnets: Record = {}, nodes: Record = {}; - SHOPS.forEach(([id, name, who, x, y, parts], i) => { - const px = 20000 + i * 1500; // somewhere no one in the office ever walks - rooms.push({ id: `${id}net`, name: 'Network', x: px, y: 0, w: 300, h: 300, door: [px + 150, 300], site: id }, - { id: `${id}floor`, name: 'Floor', x: px + 400, y: 0, w: 800, h: 300, door: [px + 800, 300], site: id }); - subnets[`${id}net`] = { name: 'Network', x: x + 20, y: y + 40, w: 150, h: 280 }; - subnets[`${id}floor`] = { name: 'Floor', x: x + 190, y: y + 40, w: 390, h: 280 }; - machines.push({ id: `${id}gw`, name: 'Line out', kind: 'egress', room: `${id}net`, x: px + 100, y: 80, power: 1, work: 70, cap: 2, data: 1 }, + SITES.forEach(({ id, name, who, at: [x, y], parts, cap = 2, region = 'West Coast' }, i) => { + const px = 20000 + i * 1500, rows = Math.max(2, Math.ceil(parts.length / 2)), { w, h } = siteSize(parts.length); + rooms.push({ id: `${id}net`, name: 'Network', x: px, y: 0, w: 300, h: 300, door: [px + 150, 300], site: id }, // somewhere no one in the office ever walks + { id: `${id}floor`, name: 'Floor', x: px + 400, y: 0, w: 800, h: 20 + rows * 140, door: [px + 800, 20 + rows * 140], site: id }); + subnets[`${id}net`] = { name: 'Network', x: x + 20, y: y + 40, w: 150, h: h - 60 }; + subnets[`${id}floor`] = { name: 'Floor', x: x + 190, y: y + 40, w: 390, h: h - 60 }; + machines.push({ id: `${id}gw`, name: 'Line out', kind: 'egress', room: `${id}net`, x: px + 100, y: 80, power: 1, work: 70, cap, data: 1 }, { id: `${id}sw`, name: 'Switch', kind: 'switch', room: `${id}net`, x: px + 100, y: 220, power: 1, work: 40, data: 1 }); - nodes[`${id}gw`] = [x + 95, y + 110]; nodes[`${id}sw`] = [x + 95, y + 250]; + nodes[`${id}gw`] = [x + 95, y + 110]; nodes[`${id}sw`] = [x + 95, y + h - 90]; + links.push([`${id}gw`, `${id}sw`]); parts.forEach(([pname, kind, power, data, source], j) => { - const mid = `${id}m${j + 1}`; - machines.push({ id: mid, name: pname, kind, room: `${id}floor`, x: px + 500 + (j % 2) * 300, y: 80 + Math.floor(j / 2) * 140, power, work: 30 + power * 5, data, ...(source ? { source } : {}) }); - nodes[mid] = [x + 285 + (j % 2) * 200, y + 120 + Math.floor(j / 2) * 140]; + const mid = `${id}m${j + 1}`, row = Math.floor(j / 2); + machines.push({ id: mid, name: pname, kind, room: `${id}floor`, x: px + 500 + (j % 2) * 300, y: 80 + row * 140, power, work: 30 + power * 5, data, ...(source ? { source } : {}) }); + nodes[mid] = [x + 285 + (j % 2) * 200, y + 120 + row * 140]; + links.push(j % 2 ? [`${id}m${j}`, mid] : [`${id}sw`, mid]); // the switch feeds each row; the row runs left to right }); - links.push([`${id}gw`, `${id}sw`], [`${id}sw`, `${id}m1`], [`${id}m1`, `${id}m2`], [`${id}sw`, `${id}m3`], [`${id}m3`, `${id}m4`]); - sites.push({ id, name, who, x, y, w: 600, h: 340, rooms: [`${id}net`, `${id}floor`], region: 'West Coast' }); + sites.push({ id, name, who, x, y, w, h, rooms: [`${id}net`, `${id}floor`], region }); }); // join each line out to the nearest line already joined, closest first, so every line is a short hop - const joined = { ...lines }, left = SHOPS.map(([id]) => `${id}gw`); + const joined = { ...lines }, left = SITES.map(({ id }) => `${id}gw`); const dist = (a: [number, number], b: [number, number]) => Math.hypot(a[0] - b[0], a[1] - b[1]); while (left.length) { let best: [number, string, string] = [Infinity, '', '']; diff --git a/tests/world.test.ts b/tests/world.test.ts index aaf9b21d..f3870e98 100644 --- a/tests/world.test.ts +++ b/tests/world.test.ts @@ -33,3 +33,43 @@ test('every capability path has something that feeds it', () => { test('people sit at desks that exist', () => { for (const p of L.people) assert.ok(ids.has(p.desk), `${p.id} has no desk`); }); + +// The map: each site's box holds its rooms, and sites keep room for their names. +const sites = L.map.sites, siteOfRoom = (r: string) => sites.find(s => s.rooms.includes(r)); +const inside = (s: { x: number; y: number; w: number; h: number }, x: number, y: number) => x >= s.x && x <= s.x + s.w && y >= s.y && y <= s.y + s.h; +test('site ids are unique and every room belongs to one site', () => { + assert.equal(new Set(sites.map(s => s.id)).size, sites.length); + for (const r of L.rooms) assert.equal(sites.filter(s => s.rooms.includes(r.id)).length, 1, `room ${r.id} is in ${sites.filter(s => s.rooms.includes(r.id)).length} sites`); +}); +test("a site's box holds its rooms and machines", () => { + for (const s of sites) for (const r of s.rooms) { + const n = L.net.subnets[r]; + assert.ok(n, `${s.id} names room ${r} with no subnet`); + assert.ok(inside(s, n.x, n.y) && inside(s, n.x + n.w, n.y + n.h), `room ${r} sticks out of ${s.id}`); + } + for (const m of L.machines) { + const s = siteOfRoom(m.room), at = L.net.nodes[m.id]; + if (s && at) assert.ok(inside(s, ...at), `${m.id} sits outside ${s.id}`); + } +}); +test("rooms in a site don't overlap", () => { + for (const s of sites) for (const a of s.rooms) for (const b of s.rooms) { + if (a >= b) continue; + const p = L.net.subnets[a], q = L.net.subnets[b]; + assert.ok(p.x + p.w <= q.x || q.x + q.w <= p.x || p.y + p.h <= q.y || q.y + q.h <= p.y, `${a} and ${b} overlap`); + } +}); +test('sites keep clear of each other, with room above for their names', () => { + for (const a of sites) for (const b of sites) { + if (a === b) continue; // b's clear zone: 120 to the sides and below, 200 above for its name + const x0 = b.x - 120, x1 = b.x + b.w + 120, y0 = b.y - 200, y1 = b.y + b.h + 120; + assert.ok(a.x + a.w <= x0 || a.x >= x1 || a.y + a.h <= y0 || a.y >= y1, `${a.id} is too close to ${b.id}`); + } +}); +test('every site has a line out wired to another site', () => { + const site = (id: string) => siteOfRoom(L.machines.find(m => m.id === id)!.room)?.id ?? 'office'; + for (const s of sites) { + if (s.id === 'office') continue; + assert.ok(L.links.some(([a, b]) => (site(a) === s.id) !== (site(b) === s.id)), `${s.id} has no line to another site`); + } +});