// The factory prototype's rules: placing buildings, laying belts a cell at a time, data moving along // belts, recipes, compute, working hours, attention and waves (a placeholder), and blueprints. No DOM. import { ATTENTION, BELT, BOX, BUILD, WIDE_BOX, GOAL, GUARD, HOUR, LEARN, MACHINE_BOX, PRICE, PRODUCTS, SITES, START_HOUR, START_MONEY, UNLOCKS, recipeById, siteById, type BType, type Good, type MachineDef, type RoomDef, type Unlock, } from './data.ts'; /** right, down, left, up */ export type Dir = 0 | 1 | 2 | 3; export const DX = [1, 0, -1, 0], DY = [0, 1, 0, -1]; export interface Item { g: Good; p: number } export interface Belt { id: number; site: string; x: number; y: number; /** the way it moves things: into the cell beside it that way */ d: Dir; /** what is on it, the one nearest its far edge first; p is how far across the cell, 0..1 */ items: Item[]; /** seconds its front item has waited at the far edge: what is past it is not taking it */ wait: number; /** 0..1, smoothed: how much of the time it waited. Backed up when high */ busy: number; /** the belt that last fed it, so belts that merge into it take turns */ last?: number; } export type Status = 'ok' | 'idle' | 'blocked' | 'starved'; export interface Building { id: number; type: BType; site: string; /** its box: top-left cell and size */ x: number; y: number; w: number; h: number; /** a tap or host: the machine it sits on */ machine?: string; /** a processor: its recipe */ recipe?: string; /** waiting to leave on a belt (a gate: what came in on the line out) */ out: Good[]; /** a gate: waiting to leave on the line out */ ship: Good[]; /** a processor's ingredients */ inputs: Partial>; /** a trainer, front or the core: waiting to be used */ queue: Good[]; /** a tap: data gathered toward the next item; a processor: progress on one, 0..1; a sink: its next use */ prog: number; working: boolean; hp: number; status: Status; /** a starved processor: what it is short of */ missing: Good[]; /** game time something last arrived */ got: number; /** whose turn it is among its belts out */ rr: number; } /** The line out between two sites' gates: not laid by you. */ export interface Line { from: number; to: number; packets: Item[] } export interface Intruder { id: number; hp: number; max: number; /** cell centres it walks through */ path: [number, number][]; seg: number; x: number; y: number; /** the firewall holding it */ held: number | null; } export interface Shot { from: [number, number]; to: [number, number] } export interface Game { t: number; money: number; research: number; unlocked: Partial>; open: string[]; buildings: Building[]; belts: Belt[]; lines: Line[]; intruders: Intruder[]; nextId: number; /** goes up whenever a building or belt is placed, turned or removed */ rev: number; tick: number; power: { supply: number; demand: number; f: number }; sold: Partial>; trained: number; earned: number; delivered: number; /** money a second, smoothed */ income: number; /** PLACEHOLDER attention: 0..100 */ attention: number; /** waves started so far */ wave: number; /** game time the coming wave arrives, while one is coming or under way */ waveAt: number | null; spawnLeft: number; spawnHp: number; spawnT: number; integrity: number; killed: number; held: number; shots: Shot[]; over: null | 'won' | 'lost'; } const OUT_CAP = 2, CACHE_CAP = 12, SINK_CAP = 4, GATE_CAP = 6; /** items a second a trainer or front uses */ export const SINK_RATE = 2; /** the core trains on what reaches it too, slower */ export const CORE_RATE = 0.5; /** the line out: its length in seconds of travel at LINE_SPEED, and what it carries a second */ const LINE_RATE = 0.8, LINE_LEN = 10, LINE_SPEED = 3; export const PROC_HOLD = (need: number) => Math.max(2, need * 2); export { CACHE_CAP, SINK_CAP, OUT_CAP }; /** Distance in cell widths: a cell is about twice as tall as it is wide. */ export const cdist = (dx: number, dy: number) => Math.hypot(dx, dy * 2); // ---------- lookups ---------- export const machines = (site: string) => siteById(site).machines; const inBox = (b: { x: number; y: number; w: number; h: number }, x: number, y: number) => x >= b.x && x < b.x + b.w && y >= b.y && y < b.y + b.h; const mbox = (m: MachineDef) => ({ x: m.x, y: m.y, w: MACHINE_BOX.w, h: MACHINE_BOX.h }); /** The machine whose box covers this cell. */ export const machineAt = (site: string, x: number, y: number) => machines(site).find(m => inBox(mbox(m), x, y)); export const machineById = (id: string) => SITES.flatMap(s => s.machines).find(m => m.id === id)!; /** The building whose box covers this cell. */ export const buildingAt = (G: Game, site: string, x: number, y: number) => G.buildings.find(b => b.site === site && inBox(b, x, y)); export const byId = (G: Game, id: number) => G.buildings.find(b => b.id === id); export const beltAt = (G: Game, site: string, x: number, y: number) => topo(G).belt.get(key(site, x, y)); export const roomAt = (site: string, x: number, y: number) => siteById(site).rooms.find(r => inBox(r, x, y)); export const center = (b: { x: number; y: number; w: number; h: number }): [number, number] => [b.x + b.w / 2, b.y + b.h / 2]; export const hourOf = (G: Game) => (START_HOUR + G.t / HOUR) % 24; /** Is a machine in use at this hour? Hours may run past midnight. */ export function inUse(m: MachineDef, hour: number) { if (!m.hours) return true; const [a, b] = m.hours; return a < b ? hour >= a && hour < b : hour >= a || hour < b; } export const beltSpeed = (G: Game) => G.unlocked.fiber ? BELT.fiber : BELT.speed; /** Items a second a belt can carry. */ export const beltRate = (G: Game) => beltSpeed(G) / BELT.gap; const gives = (t: BType) => t === 'tap' || t === 'proc' || t === 'cache' || t === 'gate'; export const sizeOf = (type: BType) => type === 'tap' || type === 'host' ? MACHINE_BOX : type === 'core' || type === 'proc' ? WIDE_BOX : BOX; function addBuilding(G: Game, type: BType, site: string, x: number, y: number, extra: Partial = {}): Building { const { w, h } = sizeOf(type); const b: Building = { id: G.nextId++, type, site, x, y, w, h, out: [], ship: [], inputs: {}, queue: [], prog: 0, working: false, hp: type === 'firewall' ? GUARD.firewallHp : 1, status: 'idle', missing: [], got: -99, rr: 0, ...extra, }; G.buildings.push(b); G.rev++; return b; } export function newGame(): Game { const G: Game = { t: 0, money: START_MONEY, research: 0, unlocked: {}, open: ['bayside'], buildings: [], belts: [], lines: [], intruders: [], nextId: 1, rev: 0, tick: 0, power: { supply: 0, demand: 0, f: 1 }, sold: {}, trained: 0, earned: 0, delivered: 0, income: 0, attention: 0, wave: 0, waveAt: null, spawnLeft: 0, spawnHp: 0, spawnT: 0, integrity: GUARD.coreIntegrity, killed: 0, held: 0, shots: [], over: null, }; for (const s of SITES) { addBuilding(G, 'gate', s.id, s.gate[0], s.gate[1]); if (s.core) addBuilding(G, 'core', s.id, s.core[0], s.core[1]); } power(G); return G; } // ---------- the layout: what each belt feeds, in what order belts move ---------- const key = (site: string, x: number, y: number) => `${site}|${x}|${y}`; export interface Topo { rev: number; nb: number; nl: number; belt: Map; /** where a belt's front item goes: a belt, a building, or nowhere */ next: Map; /** where the run a belt is part of ends: a building, or nowhere */ end: Map; /** the belts that feed each belt */ feed: Map; /** a building's belts out (its output ports) and in (its input ports) */ outs: Map; ins: Map; /** belts by how far they are from their run's end: the far end moves first, so a run moves as one */ groups: Belt[][]; } const TOPO = new WeakMap(); export const isBelt = (x: Belt | Building | null | undefined): x is Belt => !!x && 'items' in x; export function topo(G: Game): Topo { const old = TOPO.get(G); if (old && old.rev === G.rev && old.nb === G.buildings.length && old.nl === G.belts.length) return old; const T: Topo = { rev: G.rev, nb: G.buildings.length, nl: G.belts.length, belt: new Map(), next: new Map(), feed: new Map(), end: new Map(), outs: new Map(), ins: new Map(), groups: [] }; for (const B of G.belts) T.belt.set(key(B.site, B.x, B.y), B); const push = (m: Map, id: number, B: Belt) => { const a = m.get(id); if (a) a.push(B); else m.set(id, [B]); }; for (const B of G.belts) { const nx = B.x + DX[B.d], ny = B.y + DY[B.d]; const nb = T.belt.get(key(B.site, nx, ny)); let next: Belt | Building | null = null; if (nb) { next = nb.d === (B.d + 2) % 4 ? null : nb; // head on: nowhere if (next) { const f = T.feed.get(nb); if (f) f.push(B); else T.feed.set(nb, [B]); } } else { const b = buildingAt(G, B.site, nx, ny); if (b) { next = b; push(T.ins, b.id, B); } } T.next.set(B, next); const back = buildingAt(G, B.site, B.x - DX[B.d], B.y - DY[B.d]); if (back && gives(back.type) && back !== next) push(T.outs, back.id, B); } // depth: cells to the end of the run. Loops count as ends where they close const depth = new Map(); for (const B of G.belts) { if (depth.has(B)) continue; const chain: Belt[] = [], seen = new Set(); let c: Belt | Building | null = B, base = -1, end: Building | null = null; while (isBelt(c)) { if (depth.has(c)) { base = depth.get(c)!; end = T.end.get(c)!; break; } if (seen.has(c)) break; seen.add(c); chain.push(c); c = T.next.get(c)!; } if (base < 0 && !isBelt(c)) end = c; for (let i = chain.length - 1, d = base + 1; i >= 0; i--, d++) { depth.set(chain[i], d); T.end.set(chain[i], end); } } for (const B of G.belts) { const d = depth.get(B)!; (T.groups[d] ??= []).push(B); } T.groups = T.groups.filter(Boolean); TOPO.set(G, T); return T; } // ---------- building ---------- /** Is every cell of this box on the floor and free? Taps and hosts sit on their machine instead. */ function boxFree(G: Game, site: string, x: number, y: number, w: number, h: number): string | null { const s = siteById(site); if (x < 0 || y < 0 || x + w > s.w || y + h > s.h) return 'Off the floor.'; for (let i = x; i < x + w; i++) for (let j = y; j < y + h; j++) { if (buildingAt(G, site, i, j)) return 'Something is in the way.'; if (machineAt(site, i, j)) return 'A machine is in the way.'; if (beltAt(G, site, i, j)) return 'A belt is in the way.'; } return null; } /** Why a building can't go with its box's top-left here (a tap or host: anywhere on its machine), or null. */ export function canPlace(G: Game, type: BType, site: string, x: number, y: number): string | null { const def = BUILD[type]; if (!G.open.includes(site)) return 'That site is not open yet.'; if (type === 'core' || type === 'gate') return 'There is only one.'; if (def.needs && !G.unlocked[def.needs]) return `Train ${UNLOCKS.find(u => u.id === def.needs)!.name} first.`; if (type === 'tap' || type === 'host') { const m = machineAt(site, x, y); if (!m) return `A ${def.name.toLowerCase()} goes on a machine.`; if (type === 'host' && !m.host) return 'A host goes on a server.'; if (buildingAt(G, site, m.x, m.y)) return 'That machine is already taken.'; } else { const { w, h } = sizeOf(type), why = boxFree(G, site, x, y, w, h); if (why) return why; } if (G.money < def.cost) return 'Not enough money.'; return null; } export function place(G: Game, type: BType, site: string, x: number, y: number, recipe = 'synth'): Building | null { if (canPlace(G, type, site, x, y)) return null; G.money -= BUILD[type].cost; const m = type === 'tap' || type === 'host' ? machineAt(site, x, y)! : undefined; const b = addBuilding(G, type, site, m ? m.x : x, m ? m.y : y, { machine: m?.id, recipe: type === 'proc' ? recipe : undefined }); if (type === 'proc' && !recipeOpen(G, recipe)) b.recipe = 'synth'; power(G); return b; } export const recipeOpen = (G: Game, id: string) => { const r = recipeById(id); return !r.needs || !!G.unlocked[r.needs]; }; export function setRecipe(G: Game, b: Building, id: string) { if (b.type !== 'proc' || !recipeOpen(G, id) || b.recipe === id) return false; b.recipe = id; b.inputs = {}; b.working = false; b.prog = 0; return true; } // ---------- belts ---------- export const dirOf = (a: [number, number], b: [number, number]): Dir => (b[0] > a[0] ? 0 : b[1] > a[1] ? 1 : b[0] < a[0] ? 2 : 3); const solid = (G: Game, site: string, x: number, y: number) => !!buildingAt(G, site, x, y) || !!machineAt(site, x, y); /** A trail of cells, each beside the last, as belt cells with their ways. The trail may start on a * building or machine (the belt leaves it) and end on one (the belt goes into it); those cells are not * belt. The last cell points into what the trail ends on, or on the way it was going. */ export function planBelt(G: Game, site: string, trail: [number, number][]): { cells: [number, number, Dir][]; from?: [number, number]; to?: [number, number] } | string { if (!trail.length) return 'Draw a belt.'; for (let i = 1; i < trail.length; i++) if (Math.abs(trail[i][0] - trail[i - 1][0]) + Math.abs(trail[i][1] - trail[i - 1][1]) !== 1) return 'A belt runs cell by cell, no diagonals.'; let a = 0, b = trail.length; while (a < b && solid(G, site, ...trail[a])) a++; while (b > a && solid(G, site, ...trail[b - 1])) b--; const run = trail.slice(a, b); if (!run.length) return 'A belt goes on free cells, between boxes.'; const s = siteById(site); for (const [x, y] of run) { if (x < 0 || y < 0 || x >= s.w || y >= s.h) return 'Off the floor.'; if (solid(G, site, x, y)) return 'A belt can\'t go through a box.'; } const cells = run.map((c, i): [number, number, Dir] => { const n = run[i + 1] ?? trail[b]; const d: Dir = n ? dirOf(c, n) : i > 0 ? dirOf(run[i - 1], c) : a > 0 ? dirOf(trail[a - 1], c) : 0; return [c[0], c[1], d]; }); // ending on a belt that is there: join it from the side or behind, as it is (unless it points back at us) const last = run[run.length - 1], there = trail[b] ? undefined : beltAt(G, site, ...last); if (there && run.length > 1) { const prev = run[run.length - 2]; if (there.x + DX[there.d] !== prev[0] || there.y + DY[there.d] !== prev[1]) cells[cells.length - 1][2] = there.d; } return { cells, from: a > 0 ? trail[a - 1] : undefined, to: trail[b] }; } /** Why a belt can't go along this trail, or null. */ export function canLay(G: Game, site: string, trail: [number, number][]): string | null { if (!G.open.includes(site)) return 'That site is not open yet.'; const plan = planBelt(G, site, trail); if (typeof plan === 'string') return plan; const fresh = plan.cells.filter(([x, y]) => !beltAt(G, site, x, y)).length; if (G.money < fresh * BELT.cost - 1e-9) return 'Not enough money.'; return null; } /** Lay a belt along a trail; cells already belt are turned to the new way and keep what is on them. * Returns how many new cells it laid, or null. */ export function lay(G: Game, site: string, trail: [number, number][]): number | null { if (canLay(G, site, trail)) return null; const plan = planBelt(G, site, trail) as Exclude, string>; let fresh = 0; for (const [x, y, d] of plan.cells) { const B = beltAt(G, site, x, y); if (B) { B.d = d; G.rev++; continue; } G.belts.push({ id: G.nextId++, site, x, y, d, items: [], wait: 0, busy: 0 }); G.money -= BELT.cost; fresh++; G.rev++; } // a belt that ended in nothing beside the new one's start now runs into it if (!plan.from) { const [fx, fy, fd] = plan.cells[0], T = topo(G), mine = new Set(plan.cells.map(([x, y]) => `${x},${y}`)); const ends = ([0, 1, 2, 3] as Dir[]).filter(k => k !== fd).map(k => beltAt(G, site, fx + DX[k], fy + DY[k])) .filter((N): N is Belt => !!N && !mine.has(`${N.x},${N.y}`) && T.next.get(N) === null && !buildingAt(G, site, N.x + DX[N.d], N.y + DY[N.d])); if (ends.length === 1) { const N = ends[0]; N.d = dirOf([N.x, N.y], [fx, fy]); G.rev++; } } power(G); return fresh; } /** Take up a belt cell, and what is on it, for half its cost back. */ export function removeBelt(G: Game, site: string, x: number, y: number): boolean { const B = beltAt(G, site, x, y); if (!B) return false; G.belts = G.belts.filter(b => b !== B); G.money += BELT.cost / 2; G.rev++; power(G); return true; } /** Take a building down for half its cost back. The core and gates stay; belts stay where they are. */ export function remove(G: Game, id: number, refund = true): boolean { const b = byId(G, id); if (!b || b.type === 'core' || b.type === 'gate') return false; if (refund) G.money += BUILD[b.type].cost / 2; G.buildings = G.buildings.filter(x => x !== b); G.rev++; for (const i of G.intruders) if (i.held === id) i.held = null; power(G); return true; } export function canUnlock(G: Game, id: Unlock) { const u = UNLOCKS.find(x => x.id === id)!; return !G.unlocked[id] && G.research >= u.cost; } export function unlock(G: Game, id: Unlock): boolean { if (!canUnlock(G, id)) return false; G.research -= UNLOCKS.find(x => x.id === id)!.cost; G.unlocked[id] = true; if (id === 'eastside') openSite(G, 'eastside'); return true; } /** Open a site and run the line out between its gate and Bayside's, both ways. */ export function openSite(G: Game, site: string) { if (G.open.includes(site)) return; G.open.push(site); const home = G.buildings.find(b => b.type === 'gate' && b.site === 'bayside')!; const gate = G.buildings.find(b => b.type === 'gate' && b.site === site)!; G.lines.push({ from: gate.id, to: home.id, packets: [] }, { from: home.id, to: gate.id, packets: [] }); } // ---------- compute ---------- export function power(G: Game) { let supply = 0, demand = 0; for (const b of G.buildings) { supply += BUILD[b.type].supply ?? 0; demand += BUILD[b.type].draw; } demand += G.belts.length * BELT.draw; G.power = { supply, demand, f: demand <= supply ? 1 : supply / demand }; } // ---------- flow ---------- /** Could this building ever use g? Nowhere (a belt that ends in nothing) takes anything: it piles up. */ export function uses(G: Game, d: Building | null | undefined, g: Good): boolean { if (!d) return true; switch (d.type) { case 'proc': return !!recipeById(d.recipe!).inputs[g]; case 'cache': case 'trainer': case 'front': case 'core': return true; case 'gate': return G.lines.some(L => L.from === d.id); default: return false; } } /** The building takes an item, if it has room now. */ function accept(G: Game, d: Building, g: Good): boolean { switch (d.type) { case 'proc': { const need = recipeById(d.recipe!).inputs[g]; if (!need || (d.inputs[g] ?? 0) >= PROC_HOLD(need)) return false; d.inputs[g] = (d.inputs[g] ?? 0) + 1; break; } case 'cache': if (d.out.length >= CACHE_CAP) return false; d.out.push(g); break; case 'trainer': case 'front': case 'core': if (d.queue.length >= SINK_CAP) return false; d.queue.push(g); break; case 'gate': if (!uses(G, d, g) || d.ship.length >= GATE_CAP) return false; d.ship.push(g); break; default: return false; } d.got = G.t; return true; } /** Is there room at the start of this belt cell for one more item? */ export const entryFree = (B: Belt) => B.items.length < BELT.cap && (!B.items.length || B.items[B.items.length - 1].p >= BELT.gap - 1e-9); function moveBelt(G: Game, T: Topo, B: Belt, v: number, dt: number) { const it = B.items; for (let i = 0; i < it.length; i++) { const lim = i === 0 ? 1 : it[i - 1].p - BELT.gap; it[i].p = Math.max(it[i].p, Math.min(it[i].p + v, lim)); } let waited = false; if (it.length && it[0].p >= 1 - 1e-9) { const nx = T.next.get(B); let moved = false; if (isBelt(nx)) { // where belts merge, one that fed last lets another that is waiting go first const turn = nx.last !== B.id || !(T.feed.get(nx) ?? []).some(o => o !== B && o.items.length && o.items[0].p >= 1 - 1e-9); if (turn && entryFree(nx)) { nx.items.push({ g: it[0].g, p: 0 }); nx.last = B.id; moved = true; } } else if (nx) moved = accept(G, nx, it[0].g); if (moved) it.shift(); else waited = true; } B.wait = waited ? B.wait + dt : 0; B.busy += ((waited ? 1 : 0) - B.busy) * Math.min(1, dt * 2); } export function step(G: Game, dt: number) { if (G.over) return; G.t += dt; G.tick++; power(G); const f = G.power.f, hour = hourOf(G), T = topo(G), v = beltSpeed(G) * f * dt; // belts: the far end of each run first, so a run moves as one; cells that feed the same cell take turns for (const grp of T.groups) { if (G.tick % 2) for (let i = grp.length - 1; i >= 0; i--) moveBelt(G, T, grp[i], v, dt); else for (const B of grp) moveBelt(G, T, B, v, dt); } // the lines out between sites for (const L of G.lines) { const gap = LINE_SPEED / LINE_RATE, dest = byId(G, L.to)!; for (let i = 0; i < L.packets.length; i++) { const P = L.packets[i], lim = i === 0 ? LINE_LEN : L.packets[i - 1].p - gap; P.p = Math.max(P.p, Math.min(P.p + LINE_SPEED * f * dt, lim)); } const head = L.packets[0]; if (head && head.p >= LINE_LEN - 1e-6 && dest.out.length < GATE_CAP) { dest.out.push(head.g); dest.got = G.t; L.packets.shift(); } const src = byId(G, L.from)!; if (src.ship.length && (!L.packets.length || L.packets[L.packets.length - 1].p >= gap - 1e-9)) L.packets.push({ g: src.ship.shift()!, p: 0 }); } // buildings do their work for (const b of G.buildings) work(G, b, f, hour, dt); // buildings put what they hold on their belts out, taking turns between belts, and only what the // run's end can use, so the wrong data never goes on for (const b of G.buildings) { const ls = T.outs.get(b.id); if (!ls || !b.out.length) continue; const n = ls.length; for (let k = 0; k < n; k++) { const B = ls[(b.rr + k) % n]; if (!entryFree(B)) continue; const end = T.end.get(B), i = b.out.findIndex(g => uses(G, end, g)); if (i < 0) continue; const [g] = b.out.splice(i, 1); B.items.push({ g, p: 0 }); b.rr = (b.rr + k + 1) % n; if (!b.out.length) break; } } intruders(G, f, dt); // PLACEHOLDER attention: falls by a share of itself; deliveries raised it in use() G.attention = Math.max(0, G.attention - G.attention * ATTENTION.decay * dt); const next = ATTENTION.waves[G.wave]; if (next && G.waveAt === null && G.attention >= next.at) { G.wave++; G.waveAt = G.t + ATTENTION.warn; G.spawnLeft = next.count; G.spawnHp = next.hp; G.spawnT = 0; } G.income += (0 - G.income) * Math.min(1, dt / 20); if ((G.sold.tl ?? 0) >= GOAL) G.over = 'won'; } function work(G: Game, b: Building, f: number, hour: number, dt: number) { switch (b.type) { case 'tap': { const m = machineById(b.machine!); if (!inUse(m, hour)) { b.status = 'idle'; return; } if (b.out.length >= OUT_CAP) { b.status = 'blocked'; b.prog = Math.min(b.prog, 1); return; } b.status = 'ok'; b.prog += m.rate * f * dt; while (b.prog >= 1 && b.out.length < OUT_CAP) { b.out.push(m.kind); b.prog -= 1; } return; } case 'proc': { const r = recipeById(b.recipe!); if (!b.working) { const missing = (Object.keys(r.inputs) as Good[]).filter(g => (b.inputs[g] ?? 0) < r.inputs[g]!); b.missing = missing; if (missing.length) { b.status = 'starved'; return; } if (b.out.length >= OUT_CAP) { b.status = 'blocked'; return; } for (const g of Object.keys(r.inputs) as Good[]) b.inputs[g]! -= r.inputs[g]!; b.working = true; b.prog = 0; } b.missing = []; if (b.prog >= 1 && b.out.length >= OUT_CAP) { b.status = 'blocked'; return; } b.status = 'ok'; b.prog = Math.min(1, b.prog + f * dt / r.time); if (b.prog >= 1 && b.out.length < OUT_CAP) { b.out.push(r.out); b.working = false; b.prog = 0; } return; } case 'trainer': case 'front': case 'core': { b.prog = Math.min(1, b.prog + (b.type === 'core' ? CORE_RATE : SINK_RATE) * f * dt); while (b.prog >= 1 && b.queue.length) { use(G, b, b.queue.shift()!); b.prog -= 1; } b.status = b.queue.length >= SINK_CAP ? 'blocked' : G.t - b.got < 3 ? 'ok' : 'idle'; return; } case 'cache': b.status = b.out.length >= CACHE_CAP ? 'blocked' : b.out.length ? 'ok' : 'idle'; return; case 'host': b.status = 'ok'; return; default: b.status = 'ok'; } } /** A trainer, front or the core uses one item. */ function use(G: Game, b: Building, g: Good) { G.delivered++; if (b.type !== 'front') { G.research += LEARN[g]; G.trained++; } else { G.money += PRICE[g]; G.earned += PRICE[g]; G.income += PRICE[g] / 20; G.sold[g] = (G.sold[g] ?? 0) + 1; } G.attention = Math.min(ATTENTION.max, G.attention + (PRODUCTS.includes(g) ? ATTENTION.product : ATTENTION.raw)); } // ---------- waves (PLACEHOLDER) ---------- const cellMid = (x: number, y: number): [number, number] => [x + 0.5, y + 0.5]; /** The way intruders take from Bayside's line out to the core: along your belts if belts and boxes join * the two (the fewest cells, either way along a belt), or across the floor if not: along the gate's row, * then up or down to the core. Cell centres. */ export function intruderPath(G: Game): [number, number][] { const gate = G.buildings.find(b => b.type === 'gate' && b.site === 'bayside')!; const core = G.buildings.find(b => b.type === 'core')!; const T = topo(G); type Node = Belt | Building; const adj = new Map(); const join = (a: Node, b: Node) => { for (const [p, q] of [[a, b], [b, a]]) { const l = adj.get(p); if (l) l.push(q); else adj.set(p, [q]); } }; for (const B of G.belts) { if (B.site !== 'bayside') continue; const n = T.next.get(B); if (n) join(B, n); } for (const [id, ls] of T.outs) { const b = byId(G, id)!; if (b.site === 'bayside') for (const B of ls) join(b, B); } const prev = new Map([[gate, null]]), queue: Node[] = [gate]; while (queue.length) { const u = queue.shift()!; if (u === core) break; for (const w of adj.get(u) ?? []) if (!prev.has(w)) { prev.set(w, u); queue.push(w); } } const gc = center(gate), cc = center(core); if (!prev.has(core)) return [gc, [cc[0], gc[1]], cc]; const path: [number, number][] = []; for (let n: Node | null = core; n; n = prev.get(n)!) path.unshift(isBelt(n) ? cellMid(n.x, n.y) : center(n)); return path; } function intruders(G: Game, f: number, dt: number) { G.shots = []; if (G.waveAt !== null && G.t >= G.waveAt && G.spawnLeft > 0) { G.spawnT -= dt; if (G.spawnT <= 0) { const path = intruderPath(G); G.intruders.push({ id: G.nextId++, hp: G.spawnHp, max: G.spawnHp, path, seg: 1, x: path[0][0], y: path[0][1], held: null }); G.spawnLeft--; G.spawnT = GUARD.spacing; } } const walls = G.buildings.filter(b => b.type === 'firewall' && b.site === 'bayside'); for (const I of G.intruders) { const wall = walls.find(w => { const [cx, cy] = center(w); return w.hp > 0 && cdist(cx - I.x, cy - I.y) <= GUARD.holdReach; }); I.held = wall?.id ?? null; if (wall) { wall.hp -= GUARD.intruderDps * dt; continue; } let move = GUARD.intruderSpeed * dt; while (move > 0 && I.seg < I.path.length) { const [tx, ty] = I.path[I.seg], d = cdist(tx - I.x, ty - I.y); if (d <= move) { I.x = tx; I.y = ty; I.seg++; move -= d; } else { I.x += (tx - I.x) / d * move; I.y += (ty - I.y) / d * move; move = 0; } } } for (const w of walls) if (w.hp <= 0) remove(G, w.id, false); for (const s of G.buildings) { if (s.type !== 'sentry' || s.site !== 'bayside') continue; const [sx, sy] = center(s); let target: Intruder | null = null, td = Infinity; for (const I of G.intruders) { const d = cdist(sx - I.x, sy - I.y); if (d <= GUARD.sentryReach && d < td) { target = I; td = d; } } if (!target) continue; target.hp -= GUARD.sentryDps * f * dt; G.shots.push({ from: [sx, sy], to: [target.x, target.y] }); } const left: Intruder[] = []; for (const I of G.intruders) { if (I.hp <= 0) { G.killed++; continue; } if (I.seg >= I.path.length) { G.integrity--; continue; } left.push(I); } G.intruders = left; if (G.integrity <= 0) { G.integrity = 0; G.over = 'lost'; } if (G.waveAt !== null && G.t >= G.waveAt && G.spawnLeft === 0 && !G.intruders.length) { G.waveAt = null; G.held++; } } // ---------- blueprints ---------- export interface Blueprint { w: number; h: number; from: string; parts: { type: BType; dx: number; dy: number; recipe?: string }[]; belts: { dx: number; dy: number; d: Dir }[]; } /** Everything you built in a room (boxes whose top-left is in it) and its belts, as a layout to stamp elsewhere. */ export function copyRoom(G: Game, site: string, room: RoomDef): Blueprint { const inside = G.buildings.filter(b => b.site === site && b.type !== 'core' && b.type !== 'gate' && roomAt(site, b.x, b.y) === room); return { w: room.w, h: room.h, from: room.name, parts: inside.map(b => ({ type: b.type, dx: b.x - room.x, dy: b.y - room.y, recipe: b.recipe })), belts: G.belts.filter(B => B.site === site && roomAt(site, B.x, B.y) === room).map(B => ({ dx: B.x - room.x, dy: B.y - room.y, d: B.d })), }; } /** Build a blueprint into a room of the same size: what fits and what you can pay for. What is already * there, of the same kind, is kept; belts already there are turned to the blueprint's way. */ export function stamp(G: Game, bp: Blueprint, site: string, room: RoomDef) { const out = { placed: 0, belts: 0, skipped: 0 }; if (room.w !== bp.w || room.h !== bp.h || !G.open.includes(site)) { out.skipped = bp.parts.length + bp.belts.length; return out; } for (const p of bp.parts) { const x = room.x + p.dx, y = room.y + p.dy, there = buildingAt(G, site, x, y); if (there && there.type === p.type && there.x === x && there.y === y) continue; if (place(G, p.type, site, x, y, p.recipe)) out.placed++; else out.skipped++; } for (const c of bp.belts) { const x = room.x + c.dx, y = room.y + c.dy, there = beltAt(G, site, x, y); if (there) { if (there.d !== c.d) { there.d = c.d; G.rev++; } continue; } if (solid(G, site, x, y) || G.money < BELT.cost) { out.skipped++; continue; } G.belts.push({ id: G.nextId++, site, x, y, d: c.d, items: [], wait: 0, busy: 0 }); G.money -= BELT.cost; G.rev++; out.belts++; } power(G); return out; } export const blueprintCost = (bp: Blueprint) => bp.parts.reduce((s, p) => s + BUILD[p.type].cost, 0) + bp.belts.length * BELT.cost; // ---------- the next thing to do ---------- export interface Hint { text: string; site?: string; at?: [number, number]; key?: string } /** A free top-left for a box of this size near a cell, searching outward. */ export function freeSpot(G: Game, site: string, type: BType, cx: number, cy: number): [number, number] | null { const { w, h } = sizeOf(type); for (let dy = 0; dy < 8; dy++) { const x = cx - Math.floor(w / 2), y = cy - Math.floor(h / 2) + dy; if (!boxFree(G, site, x, y, w, h)) return [x, y]; } for (let r = 0; r < 12; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; const x = cx - Math.floor(w / 2) + dx, y = cy - Math.floor(h / 2) + dy; if (!boxFree(G, site, x, y, w, h)) return [x, y]; } return null; } /** The first steps, one at a time, until data has reached a trainer or the core; then null. */ export function firstSteps(G: Game): Hint | null { if (G.delivered > 0) return null; const site = 'bayside', hour = hourOf(G); const tap = G.buildings.find(b => b.type === 'tap' && b.site === site); if (!tap) { const m = machines(site).find(m => m.kind === 'people' && inUse(m, hour)) ?? machines(site).find(m => inUse(m, hour))!; return { text: `place a TAP on ${m.name}`, site, at: [m.x + 3, m.y + 1], key: 'T' }; } const T = topo(G); const ends = (T.outs.get(tap.id) ?? []).map(B => T.end.get(B)); if (ends.some(e => e && (e.type === 'trainer' || e.type === 'core' || e.type === 'front'))) { if (!inUse(machineById(tap.machine!), hour)) return { text: `wait: ${machineById(tap.machine!).name} is off now`, site, at: [tap.x + 3, tap.y + 1] }; return { text: 'data is on its way', site }; } const sink = G.buildings.find(b => b.site === site && b.type === 'trainer'); if (!sink) { const [x, y] = freeSpot(G, site, 'trainer', tap.x + 3, tap.y + 6) ?? [tap.x + 1, tap.y + 5]; return { text: 'place a TRAINER below it', site, at: [x + 2, y + 1], key: 'N' }; } const from: [number, number] = [tap.x + 3, tap.y + tap.h], to: [number, number] = [sink.x + 2, sink.y - 1]; return { text: `drag a BELT from TAP ${from[0]},${from[1]} to T ${to[0]},${to[1]}`, site, at: from, key: 'B' }; } /** One short line on what to do next, once the first chain runs. */ export function nextHint(G: Game): string { const has = (t: BType) => G.buildings.some(b => b.type === t); if (G.waveAt !== null && G.t < G.waveAt) return `wave in ${Math.ceil(G.waveAt - G.t)}s at OUT: # and + on the red path`; if (G.intruders.length) return 'intruders inside: # holds them, + hurts them'; if (G.power.f < 1) return `compute short: all at ${Math.round(G.power.f * 100)}%. [H]ost on a server`; if (!has('proc')) return '[P]rocess: FILES (l) + a server (s) make R'; if (!has('front')) return '[F]ront sells products'; if (!G.unlocked.tl) return '[R]esearch Thought Leadership'; return `sell ${GOAL} L (Thought Leadership)`; }