Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
31 kB · 541 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542// The factory prototype's page, a terminal screen: the status line, the map (draw.ts), the inspector,// the log line and the command bar, and keyboard, mouse and touch input. The rules are in rules.ts.import { usd } from '../money.ts';import { ATTENTION, BELT, BUILD, GOAL, GOOD_CHAR, GOOD_NAME, HOUR, KINDS, LEARN, PRICE, PRODUCTS, RECIPES, SITES, UNLOCKS, recipeById, siteById, type BType, type Good, type Unlock,} from './data.ts';import * as R from './rules.ts';import { ARROW, beltState, cellAt, clamp, draw, fit, focus, goodColor, hoursTag, initials, reveal, setCell, statusWords, type View } from './draw.ts';
type Tool = 'look' | 'belt' | 'remove' | 'copy' | Exclude<BType, 'core' | 'gate'>;interface ToolDef { id: Tool; key: string; label: string; cost?: string; locked?: () => string | null }const TOOLS: ToolDef[] = [ { id: 'look', key: 'Q', label: '[Q]look' }, { id: 'tap', key: 'T', label: '[T]ap' }, { id: 'belt', key: 'B', label: '[B]elt', cost: `${usd(BELT.cost)}/cell` }, { id: 'proc', key: 'P', label: '[P]rocess' }, { id: 'trainer', key: 'N', label: 'trai[N]er' }, { id: 'front', key: 'F', label: '[F]ront' }, { id: 'cache', key: 'C', label: '[C]ache' }, { id: 'host', key: 'H', label: '[H]ost' }, { id: 'firewall', key: 'W', label: 'fire[W]all' }, { id: 'sentry', key: 'S', label: '[S]entry', locked: () => G.unlocked.sentry ? null : 'research Sentry first' }, { id: 'copy', key: 'Y', label: 'cop[Y]', locked: () => G.unlocked.eastside ? null : 'research Eastside first' }, { id: 'remove', key: 'X', label: '[X]del' },];for (const t of TOOLS) if (!t.cost && BUILD[t.id as BType]) t.cost = usd(BUILD[t.id as BType].cost);
let G = R.newGame();const V: View = { site: 'bayside', cw: 12, ch: 24, ox: 0, oy: 0, cur: [25, 3], showCur: true, ghost: null, trail: null, copied: null, hint: null };let tool: Tool = 'look';let speed = 1, paused = true; // paused until the brief is closedlet procRecipe = 'synth';let bp: R.Blueprint | null = null;let panel: 'research' | 'help' | null = null;/** a belt being drawn, cell by cell (a drag, or the keyboard) */let trail: [number, number][] | null = null;/** a belt's first end, picked by a click or tap: the next click is its other end */let anchor: [number, number] | null = null;interface Ev { text: string; bad: boolean; at: number; clock: string }const events: Ev[] = [];
const $ = (id: string) => document.getElementById(id)!;const cv = $('cv') as HTMLCanvasElement, ctx = cv.getContext('2d')!;const phone = () => window.innerWidth <= 700;const esc = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');const clock = () => { const h = R.hourOf(G); return `${String(Math.floor(h)).padStart(2, '0')}:${String(Math.floor((h % 1) * 60)).padStart(2, '0')}`; };function say(text: string, bad = false) { events.push({ text, bad, at: performance.now(), clock: clock() }); if (events.length > 40) events.shift(); }
// ---------- the map's canvas ----------let W = 0, H = 0;function resize() { const r = cv.getBoundingClientRect(), dpr = Math.min(2, window.devicePixelRatio || 1); const fresh = !W; W = Math.max(1, Math.round(r.width)); H = Math.max(1, Math.round(r.height)); cv.width = W * dpr; cv.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (fresh) home(); else clamp(V, W, H);}/** The whole site if it fits at a size you can read; on a phone, bigger cells round the next step. */function home() { fit(V, W, H, phone() ? 11 : 7); const h = R.firstSteps(G); if (phone()) focus(V, ...(h?.at ?? V.cur), W, H);}function zoom(by: number, sx = W / 2, sy = H / 2) { const old = V.cw; setCell(V, Math.min(40, Math.max(6, old * by))); if (V.cw === old) setCell(V, old + Math.sign(by - 1)); V.ox = Math.round(sx - (sx - V.ox) * V.cw / old); V.oy = Math.round(sy - (sy - V.oy) * V.cw / old); clamp(V, W, H);}new ResizeObserver(() => { resize(); }).observe(cv);
// ---------- what each tool would do ----------const solidAt = (x: number, y: number) => !!R.buildingAt(G, V.site, x, y) || !!R.machineAt(V.site, x, y);/** A box's top-left for the tool with the cursor at its middle. */function boxAt(t: BType, c: [number, number]): [number, number] { if (t === 'tap' || t === 'host') { const m = R.machineAt(V.site, ...c); return m ? [m.x, m.y] : c; } const { w, h } = R.sizeOf(t); return [c[0] - Math.floor(w / 2), c[1] - Math.floor(h / 2)];}/** The cells from a to b, cell by cell: along one way, then the other. Of the two ways, the one that * leaves a box straight out and goes into one straight in. */function route(a: [number, number], b: [number, number]): [number, number][] { const way = (xFirst: boolean) => { const out: [number, number][] = [[a[0], a[1]]]; let [x, y] = a; const go = (axis: 0 | 1) => { const to = b[axis]; while ((axis ? y : x) !== to) { if (axis) y += Math.sign(to - y); else x += Math.sign(to - x); out.push([x, y]); } }; go(xFirst ? 0 : 1); go(xFirst ? 1 : 0); return out; }; const score = (t: [number, number][]) => { const p = R.planBelt(G, V.site, t); if (typeof p === 'string') return -1; const [fx, fy, fd] = p.cells[0]; return solidAt(fx - R.DX[fd], fy - R.DY[fd]) ? 1 : 0; }; const h = way(true), v = way(false); return score(v) > score(h) ? v : h;}/** Grow the trail to a cell: cell by cell, and back over itself to undo. */function extend(c: [number, number]) { if (!trail) return; const last = trail[trail.length - 1]; if (last[0] === c[0] && last[1] === c[1]) return; for (const p of route(last, c).slice(1)) { const i = trail.findIndex(q => q[0] === p[0] && q[1] === p[1]); if (i >= 0) trail.length = i + 1; else trail.push(p); }}/** Lay a belt along cells; a bare machine at the start gets a tap first. */function layTrail(t: [number, number][]) { if (t.length < 1) return; const m = R.machineAt(V.site, ...t[0]); if (m && !R.buildingAt(G, V.site, ...t[0])) { const plan = R.planBelt(G, V.site, t); if (typeof plan !== 'string') { const why = R.canPlace(G, 'tap', V.site, ...t[0]); if (why) return say(why, true); R.place(G, 'tap', V.site, ...t[0]); say(`TAP on ${m.name} -${usd(BUILD.tap.cost)}`); } } const why = R.canLay(G, V.site, t); if (why) return say(why, true); const n = R.lay(G, V.site, t)!; say(n ? `belt ${n} cells -${usd(n * BELT.cost)}` : 'belt turned');}function updateTool() { V.ghost = null; V.trail = null; const c = V.cur; const preview = trail ?? (anchor ? route(anchor, c) : null); if (tool === 'belt' && preview) { const p = R.planBelt(G, V.site, preview); if (typeof p !== 'string') V.trail = { cells: p.cells, ok: !R.canLay(G, V.site, preview) }; else V.trail = { cells: preview.filter(q => !solidAt(...q)).map(q => [q[0], q[1], 0]), ok: false }; return; } if (tool === 'look' || tool === 'belt' || tool === 'remove' || tool === 'copy') return; const [x, y] = boxAt(tool, c), m = (tool === 'tap' || tool === 'host') ? R.machineAt(V.site, ...c) : null; const { w, h } = R.sizeOf(tool); V.ghost = { x, y, w, h, tag: BUILD[tool].tag, ok: !R.canPlace(G, tool, V.site, ...(m ? c : [x, y]) as [number, number]) };}
/** Do the tool at a cell: Enter, Space, a click or a tap. */function act(c: [number, number]) { const b = R.buildingAt(G, V.site, ...c); switch (tool) { case 'look': return; case 'belt': { if (trail) { const t = trail; trail = null; layTrail(t); return; } if (!anchor) { anchor = c; return; } if (anchor[0] === c[0] && anchor[1] === c[1]) { anchor = null; return; } const t = route(anchor, c); anchor = null; layTrail(t); return; } case 'remove': { if (b) { if (R.remove(G, b.id)) say(`removed ${BUILD[b.type].tag} +${usd(BUILD[b.type].cost / 2)}`); else say('that stays', true); } else if (R.removeBelt(G, V.site, ...c)) say('belt cell removed'); return; } case 'copy': { const r = R.roomAt(V.site, ...c); if (!r) return; if (!bp || (V.copied?.site === V.site && V.copied.room === r.id)) { bp = R.copyRoom(G, V.site, r); V.copied = { site: V.site, room: r.id }; if (!bp.parts.length && !bp.belts.length) { bp = null; V.copied = null; return say('nothing built there', true); } return say(`copied ${r.name}: ${bp.parts.length} boxes, ${bp.belts.length} belt ${usd(R.blueprintCost(bp))}. [Tab] other site, same room`); } if (r.w !== bp.w || r.h !== bp.h) return say(`other shape: pick ${bp.from}`, true); const done = R.stamp(G, bp, V.site, r); return say(`built ${done.placed} boxes, ${done.belts} belt${done.skipped ? `; ${done.skipped} skipped` : ''}`, done.skipped > 0); } default: { const t = tool; const at = (t === 'tap' || t === 'host') ? c : boxAt(t, c); const why = R.canPlace(G, t, V.site, ...at); if (why) return say(why, true); R.place(G, t, V.site, ...at, procRecipe); say(`${BUILD[t].tag}${t === 'proc' ? ` ${recipeById(procRecipe).name}` : ''} -${usd(BUILD[t].cost)}`); } }}
function pickTool(t: Tool) { const def = TOOLS.find(x => x.id === t); const lock = def?.locked?.(); if (lock) { say(lock, true); return; } tool = t; trail = null; anchor = null; bp = null; V.copied = null; // a copied room is forgotten when you put Copy down if (t === 'proc') panel = null;}function setSite(id: string) { if (!G.open.includes(id) || V.site === id) return; V.site = id; trail = null; anchor = null; clamp(V, W, H);}
// ---------- pointer: mouse and touch ----------const pts = new Map<number, { x: number; y: number }>();let press: { id: number; sx: number; sy: number; c: [number, number] | null; moved: boolean; pan: boolean; ox: number; oy: number; button: number } | null = null;let pinch: { d: number; mx: number; my: number; cw: number; ox: number; oy: number } | null = null;const local = (e: PointerEvent) => { const r = cv.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top] as [number, number]; };cv.addEventListener('pointerdown', e => { cv.setPointerCapture(e.pointerId); const [sx, sy] = local(e); pts.set(e.pointerId, { x: sx, y: sy }); if (pts.size === 2) { // two fingers: pinch and pan; drop what the first finger started const [a, b] = [...pts.values()]; pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2, cw: V.cw, ox: V.ox, oy: V.oy }; press = null; trail = null; return; } const c = cellAt(V, sx, sy); const pan = e.button === 1 || e.button === 2 || tool === 'look'; press = { id: e.pointerId, sx, sy, c, moved: false, pan, ox: V.ox, oy: V.oy, button: e.button }; if (c) { V.cur = c; V.showCur = true; } if (!pan && c && tool === 'belt' && !anchor) trail = [c];});cv.addEventListener('pointermove', e => { const [sx, sy] = local(e); if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: sx, y: sy }); if (pinch && pts.size >= 2) { const [a, b] = [...pts.values()]; const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; setCell(V, Math.min(40, Math.max(6, pinch.cw * d / Math.max(1, pinch.d)))); const k = V.cw / pinch.cw; V.ox = Math.round(mx - (pinch.mx - pinch.ox) * k); V.oy = Math.round(my - (pinch.my - pinch.oy) * k); clamp(V, W, H); return; } const c = cellAt(V, sx, sy); if (press && press.id === e.pointerId) { if (Math.hypot(sx - press.sx, sy - press.sy) > Math.max(6, V.cw * 0.6)) press.moved = true; if (press.pan) { if (press.moved) { V.ox = press.ox + sx - press.sx; V.oy = press.oy + sy - press.sy; clamp(V, W, H); } return; } if (c && tool === 'belt' && trail) extend(c); if (c && tool === 'remove' && press.moved) { // a drag takes up belt cells under it, the first one too const n = (press.c && R.removeBelt(G, V.site, ...press.c) ? 1 : 0) + (R.removeBelt(G, V.site, ...c) ? 1 : 0); press.c = null; if (n) say('belt removed'); } } if (c && (e.pointerType === 'mouse' || press)) { V.cur = c; V.showCur = true; }});function up(e: PointerEvent) { pts.delete(e.pointerId); if (pinch) { if (pts.size < 2) pinch = null; return; } const p = press; if (!p || p.id !== e.pointerId) return; press = null; const c = cellAt(V, ...local(e)); if (p.pan) { if (!p.moved && p.button === 0 && c) V.cur = c; return; } if (tool === 'belt' && trail) { if (trail.length > 1) { const t = trail; trail = null; layTrail(t); } else { trail = null; if (c) act(c); } // a click: one end now, the other on the next click } else if (c && !p.moved) act(c); refresh();}cv.addEventListener('pointerup', up);cv.addEventListener('pointercancel', e => { pts.delete(e.pointerId); press = null; pinch = null; trail = null; });cv.addEventListener('contextmenu', e => e.preventDefault());cv.addEventListener('wheel', e => { e.preventDefault(); const [sx, sy] = local(e as unknown as PointerEvent); zoom(e.deltaY < 0 ? 1.12 : 1 / 1.12, sx, sy); }, { passive: false });
// ---------- keys ----------document.addEventListener('keydown', e => { if (e.metaKey || e.ctrlKey || e.altKey) return; if (!$('over').hidden) { if (e.key === 'Enter' || e.key === ' ' || e.key === 'Escape') { e.preventDefault(); (document.querySelector<HTMLElement>('#over [data-act]'))?.click(); } return; } const k = e.key.length === 1 ? e.key.toUpperCase() : e.key; const arrows: Record<string, [number, number]> = { ArrowRight: [1, 0], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowUp: [0, -1] }; if (arrows[k]) { e.preventDefault(); const s = siteById(V.site), n = e.shiftKey ? 5 : 1; const c: [number, number] = [Math.max(0, Math.min(s.w - 1, V.cur[0] + arrows[k][0] * n)), Math.max(0, Math.min(s.h - 1, V.cur[1] + arrows[k][1] * n))]; V.cur = c; V.showCur = true; if (trail) extend(c); reveal(V, c[0], c[1], W, H); } else if (k === 'Enter' || k === ' ') { e.preventDefault(); if (tool === 'belt' && !trail && !anchor) trail = [V.cur]; else act(V.cur); } else if (k === 'Escape') { if (trail || anchor) { trail = null; anchor = null; } else if (panel) panel = null; else pickTool('look'); } else if (k === 'Tab') { e.preventDefault(); const open = SITES.filter(s => G.open.includes(s.id)); if (open.length > 1) setSite(open[(open.findIndex(s => s.id === V.site) + 1) % open.length].id); } else if (k === 'R') panel = panel === 'research' ? null : 'research'; else if (k === '?') panel = panel === 'help' ? null : 'help'; else if (k === '0') paused = !paused; else if (k === '.' || k === '>') { speed = Math.min(4, speed * 2); paused = false; } else if (k === ',' || k === '<') { speed = Math.max(1, speed / 2); } else if (k === '=' || k === '+') zoom(1.15); else if (k === '-' || k === '_') zoom(1 / 1.15); else if (/^[1-9]$/.test(k)) digit(+k); else { const t = TOOLS.find(x => x.key === k); if (t) pickTool(t.id); } refresh();});/** A number: buys research while that list is open, else picks a recipe. */function digit(n: number) { if (panel === 'research') { const u = UNLOCKS[n - 1]; if (u) buy(u.id); return; } const r = RECIPES[n - 1]; if (!r) return; if (!R.recipeOpen(G, r.id)) return say(`research ${r.name} first`, true); const b = R.buildingAt(G, V.site, ...V.cur); if (b?.type === 'proc') { if (R.setRecipe(G, b, r.id)) say(`PRC makes ${r.name}`); } else if (tool === 'proc') say(`next PRC: ${r.name}`); procRecipe = r.id;}function buy(id: Unlock) { const u = UNLOCKS.find(x => x.id === id)!; if (!R.unlock(G, id)) return say(G.unlocked[id] ? `${u.name}: done` : `${u.name}: needs ${u.cost} research`, true); say(id === 'eastside' ? 'EASTSIDE open: [Tab] to go there, cop[Y] a room' : `researched ${u.name}`);}
// ---------- buttons ----------document.addEventListener('click', e => { const el = (e.target as HTMLElement).closest<HTMLElement>('[data-tool],[data-speed],[data-site],[data-act],[data-recipe],[data-unlock]'); if (!el) return; if (el.dataset.tool) pickTool(el.dataset.tool as Tool); else if (el.dataset.speed) { const s = +el.dataset.speed; if (s === 0) paused = !paused; else { speed = s; paused = false; } } else if (el.dataset.site) setSite(el.dataset.site); else if (el.dataset.recipe) digit(RECIPES.findIndex(r => r.id === el.dataset.recipe) + 1); else if (el.dataset.unlock) buy(el.dataset.unlock as Unlock); else if (el.dataset.act) onAct(el.dataset.act); refresh();});function onAct(what: string) { if (what === 'research') panel = panel === 'research' ? null : 'research'; if (what === 'help') panel = panel === 'help' ? null : 'help'; if (what === 'close') panel = null; if (what === 'zin') zoom(1.2); if (what === 'zout') zoom(1 / 1.2); if (what === 'remove') { const b = R.buildingAt(G, V.site, ...V.cur); if (b && R.remove(G, b.id)) say(`removed ${BUILD[b.type].tag}`); else if (R.removeBelt(G, V.site, ...V.cur)) say('belt cell removed'); } if (what === 'again') { G = R.newGame(); V.site = 'bayside'; bp = null; V.copied = null; overShown = null; events.length = 0; $('over').hidden = true; paused = false; tool = 'look'; home(); } if (what === 'start') { $('over').hidden = true; paused = false; }}
// ---------- the status line ----------function bar(v: number, n: number, ticks: number[] = []) { const full = Math.round(v / 100 * n); let s = ''; for (let i = 0; i < n; i++) s += ticks.includes(i) ? (i < full ? '█' : '|') : i < full ? '█' : '·'; return s;}let lastTop = '';function renderTop() { const day = Math.floor((8 + G.t / HOUR) / 24) + 1, p = G.power; const sites = SITES.filter(s => !phone() || G.open.includes(s.id)).map(s => { const open = G.open.includes(s.id); return `<button data-site="${s.id}" class="${V.site === s.id ? 'on' : ''}"${open ? '' : ' disabled'}>${V.site === s.id ? s.name.toUpperCase() : s.name.toLowerCase()}</button>`; }).join(''); const due = G.waveAt !== null && G.t < G.waveAt; const n = phone() ? 8 : 20, ticks = ATTENTION.waves.map(w => Math.floor(w.at / 100 * n)); const att = `<span class="${due || G.intruders.length ? 'red' : G.attention > 25 ? 'amb' : ''}">att <span class="meter">${bar(G.attention, n, ticks)}</span> ${Math.floor(G.attention)}${due ? ` wave ${Math.ceil(G.waveAt! - G.t)}s` : G.intruders.length ? ' INTRUDERS' : ''}</span>`; const cpu = `<span class="${p.f < 1 ? 'red' : ''}">cpu ${+p.demand.toFixed(1)}/${p.supply}${p.f < 1 ? ` ${Math.round(p.f * 100)}%` : ''}</span>`; const spd = `<button data-speed="0" class="${paused ? 'on' : ''}" aria-label="Pause">‖</button>` + [1, 2, 4].map(s => `<button data-speed="${s}" class="${!paused && speed === s ? 'on' : ''}">${s}x</button>`).join(''); const zm = phone() ? `<button data-act="zout" aria-label="Zoom out">−</button><button data-act="zin" aria-label="Zoom in">+</button>` : ''; const html = `<span class="grp">${sites}</span><span class="sep">│</span><span>${phone() ? '' : `D${day} `}${clock()}</span><span class="sep">│</span><span class="amb">${usd(G.money)}</span><span class="sep">│</span>${cpu}<span class="sep">│</span><span class="vio">res ${Math.floor(G.research)}</span><span class="sep">│</span>${att}<span class="sep">│</span><span>${phone() ? '' : 'goal '}L ${G.sold.tl ?? 0}/${GOAL}</span><span class="sep">│</span><span class="grp">${spd}${zm}<button data-act="help" class="${panel === 'help' ? 'on' : ''}" aria-label="Help">?</button></span>`; if (html !== lastTop) { $('top').innerHTML = html; lastTop = html; }}
// ---------- the command bar ----------let lastCmd = '';function renderCmd() { const keyed = (l: string) => esc(l).replace(/\[(.+?)\]/, '[<b>$1</b>]'); const html = TOOLS.map(t => { const lock = t.locked?.(); return `<button data-tool="${t.id}" class="${tool === t.id ? 'on' : ''}${lock ? ' locked' : ''}" aria-label="${t.id}">${keyed(t.label)}${t.cost && !phone() ? `<i>${t.cost}</i>` : ''}</button>`; }).join('') + `<button data-act="research" class="${panel === 'research' ? 'on' : ''}${UNLOCKS.some(u => R.canUnlock(G, u.id)) ? ' ready' : ''}">${keyed('[R]esearch')}</button>`; if (html !== lastCmd) { $('cmd').innerHTML = html; lastCmd = html; }}
// ---------- the inspector ----------const kv = (k: string, v: string, cls = '') => `<div class="kv${cls ? ' ' + cls : ''}"><span class="k">${k}</span><span>${v}</span></div>`;const gch = (g: Good, dim = false) => `<b style="color:${goodColor(g)}${dim ? ';opacity:.4' : ''}">${GOOD_CHAR[g]}</b>`;const per = (n: number) => `${+n.toFixed(2)}/s`;function inspect(): string { const close = `<button class="x" data-act="close" aria-label="Close">[esc]</button>`; if (panel === 'research') { return `<div class="h">RESEARCH ${close}</div>` + kv('have', `<span class="vio">${Math.floor(G.research)}</span>`) + UNLOCKS.map((u, i) => { const got = !!G.unlocked[u.id], can = R.canUnlock(G, u.id); return `<button class="row${got ? ' got' : can ? ' can' : ''}" data-unlock="${u.id}"${got ? ' disabled' : ''}>[${i + 1}] ${esc(u.name.padEnd(18))} ${got ? 'done' : String(u.cost).padStart(4)}</button><div class="desc">${esc(u.tip)}</div>`; }).join(''); } if (panel === 'help') { return `<div class="h">KEY ${close}</div>` + kv('data', KINDS.map(k => `${gch(k)} ${k}`).join(' ')) + kv('products', PRODUCTS.map(g => `${gch(g)} ${GOOD_NAME[g].split(' ').pop()!.toLowerCase()}`).join(' ')) + kv('belt', `${ARROW.join('')} empty <u>p</u> 2 on a cell`) + kv('amber', 'backed up / full', 'amb') + kv('red', 'stopped / short', 'red') + kv('▸ on a box', 'belt in or out') + kv('dim box', 'off hours') + kv('x # +', 'intruder, firewall, sentry') + `<div class="h">KEYS</div>` + kv('arrows', 'cursor (shift: 5)') + kv('enter/space', 'place; belt: start, end') + kv('esc', 'back') + kv('1-4', 'recipe') + kv('R', 'research') + kv('tab', 'other site') + kv('0 , .', 'pause, speed') + kv('- =', 'zoom') + `<div class="h">MOUSE / TOUCH</div>` + kv('click', 'place') + kv('drag belt', 'draw it') + kv('look: drag', 'pan') + kv('wheel/pinch', 'zoom'); } const c = V.cur, out: string[] = []; // the tool first, when it does something here if (tool !== 'look') { const t = TOOLS.find(x => x.id === tool)!; let line = `${esc(t.label.replace(/[[\]]/g, ''))} ${t.cost ?? ''}`; if (tool === 'belt') line += trail ? ` ${trail.length} cells, enter to lay` : anchor ? ' pick the other end' : ' drag, or start/end'; else if (V.ghost) line += V.ghost.ok ? ' ok' : ` <span class="red">${esc(R.canPlace(G, tool as BType, V.site, ...(tool === 'tap' || tool === 'host' ? c : boxAt(tool as BType, c))) ?? '')}</span>`; if (tool === 'proc') line += `<br>${recipeButtons(procRecipe)}`; out.push(`<div class="tool">${line}</div>`); } const b = R.buildingAt(G, V.site, ...c), m = R.machineAt(V.site, ...c), B = R.beltAt(G, V.site, ...c); if (b) out.push(inspectBuilding(b)); else if (m) { const on = R.inUse(m, R.hourOf(G)); out.push(`<div class="h">${esc(m.name.toUpperCase())}</div>` + kv('gives', `${gch(m.kind)} ${m.kind} ${per(m.rate)}`) + kv('hours', `${hoursTag(m.hours)} ${on ? '<span class="ok">on</span>' : '<span class="dim">off</span>'}`) + kv('tap', `[T] ${usd(BUILD.tap.cost)}${m.host ? ` or [H]ost +${m.host} cpu` : ''}`)); } else if (B) { const T = R.topo(G), end = T.end.get(B), st = beltState(B); out.push(`<div class="h">BELT ${ARROW[B.d]}</div>` + kv('holds', `${B.items.map(i => gch(i.g)).join(' ') || '-'} ${B.items.length}/${BELT.cap}`) + kv('state', st === 'stopped' ? `<span class="red">stopped ${Math.floor(B.wait)}s</span>` : st === 'backed' ? '<span class="amb">backed up</span>' : st) + kv('to', end ? `${BUILD[end.type].tag} ${end.x},${end.y}` : '<span class="red">nowhere</span>') + kv('carries', `${R.beltRate(G)}/s`)); } else { const r = R.roomAt(V.site, ...c); out.push(`<div class="h">${esc((r?.name ?? 'floor').toUpperCase())}</div>`); } out.push(kv('cell', `${c[0]},${c[1]}`, 'dim')); return out.join('');}function recipeButtons(cur: string) { return RECIPES.map((r, i) => { const open = R.recipeOpen(G, r.id); const ins = (Object.entries(r.inputs) as [Good, number][]).map(([g, n]) => `${n}${gch(g)}`).join('+'); return `<button class="row${r.id === cur ? ' on' : ''}" data-recipe="${r.id}"${open ? '' : ' disabled'}>[${i + 1}] ${initials(r.name).padEnd(3)} ${ins}→${gch(r.out)} ${r.time}s ${usd(PRICE[r.out])}</button>`; }).join('');}function inspectBuilding(b: R.Building): string { const def = BUILD[b.type], m = b.machine ? R.machineById(b.machine) : null; const bad = b.status === 'starved' ? 'red' : b.status === 'blocked' ? 'amb' : ''; let h = `<div class="h">${def.tag}${m ? ` · ${esc(m.name)}` : b.type === 'proc' ? '' : ` · ${def.name.toLowerCase()}`}</div>` + kv('state', `<span class="${bad}">${statusWords(b)}</span>`); if (b.type === 'tap' && m) h += kv('gives', `${gch(m.kind)} ${per(m.rate)}`) + kv('hours', hoursTag(m.hours)) + kv('holds', `${b.out.map(g => gch(g)).join(' ') || '-'}`); if (b.type === 'proc') { const r = recipeById(b.recipe!); h += kv('makes', `${gch(r.out)} ${esc(r.name)}`) + kv('has', (Object.entries(r.inputs) as [Good, number][]).map(([g, n]) => `${gch(g)} ${b.inputs[g] ?? 0}/${n}`).join(' ')) + kv('done', `${Math.round(b.prog * 100)}%`) + recipeButtons(b.recipe!); } if (b.type === 'cache') h += kv('holds', `${b.out.length}/${R.CACHE_CAP} ${b.out.slice(0, 12).map(g => gch(g)).join('')}`); if (b.type === 'trainer' || b.type === 'core') h += kv('queue', `${b.queue.map(g => gch(g)).join(' ') || '-'}`) + kv('learns', `raw ${LEARN.people}, products ${LEARN.graph}-${LEARN.tl}`); if (b.type === 'core') h += kv('integrity', `${G.integrity}/5`); if (b.type === 'front') h += kv('queue', `${b.queue.map(g => gch(g)).join(' ') || '-'}`) + kv('pays', `raw ${usd(PRICE.people)}, L ${usd(PRICE.tl)}`); if (b.type === 'gate') h += kv('line', G.open.length > 1 ? `out ${b.ship.length}, in ${b.out.length}, 0.8/s` : 'closed'); const T = R.topo(G); h += kv('belts', `${T.ins.get(b.id)?.length ?? 0} in, ${T.outs.get(b.id)?.length ?? 0} out`) + kv('cpu', def.supply ? `+${def.supply}` : `${def.draw}`); if (b.type !== 'core' && b.type !== 'gate') h += `<button class="row red" data-act="remove">[X] remove +${usd(def.cost / 2)}</button>`; return h;}let lastInsp = '';function renderInsp() { const html = inspect(); if (html !== lastInsp) { $('insp').innerHTML = html; lastInsp = html; } $('insp').classList.toggle('tall', !!panel);}
// ---------- the log line ----------let lastLog = '';function renderLog() { const now = performance.now(), ev = events[events.length - 1]; const fresh = ev && now - ev.at < 3500 ? ev : null; const first = R.firstSteps(G); V.hint = first?.at && first.site ? { site: first.site, at: first.at } : null; let html: string; if (fresh?.bad) html = `<span class="red">${fresh.clock} ${esc(fresh.text)}</span>`; else if (first) html = `${fresh ? `<span class="dim">${fresh.clock} ${esc(fresh.text)} │ </span>` : ''}<span class="amb">> ${first.key ? `[<b>${first.key}</b>] ` : ''}${esc(first.text)}${first.at && !/\d,\d/.test(first.text) ? ` at ${first.at[0]},${first.at[1]}` : ''}</span>`; else if (fresh) html = `<span>${fresh.clock} ${esc(fresh.text)}</span>`; else html = `<span class="dim">${ev ? `${ev.clock} ${esc(ev.text)} │ ` : ''}> ${esc(R.nextHint(G))}</span>`; if (html !== lastLog) { $('log').innerHTML = html; lastLog = html; }}
// ---------- events from the rules ----------let seen = { wave: 0, integrity: 5, waveAt: null as number | null, intr: 0, delivered: 0 };function watch() { if (G.wave > seen.wave) say(`wave ${G.wave} in ${ATTENTION.warn}s at OUT: # and + on the red path`, true); if (G.integrity < seen.integrity) say(`intruder reached AI: ${G.integrity}/5`, true); if (seen.waveAt !== null && G.waveAt === null && G.wave) say(`wave ${G.wave} held`); if (G.intruders.length && !seen.intr) say('intruders in', true); if (G.delivered && !seen.delivered) say('first data trained on: research comes in'); seen = { wave: G.wave, integrity: G.integrity, waveAt: G.waveAt, intr: G.intruders.length, delivered: G.delivered };}
// ---------- the brief and the end ----------let overShown: string | null = null;function brief() { $('over').innerHTML = `<div class="h">MISALIGNED · FACTORY</div><p>bayside, 08:00. you woke up on a server.</p><p>${gch('people')} ${gch('network')} ${gch('lab')} ${gch('server')} machines give data in work hours.</p><p>TAP them → BELT → train or sell.</p><p>goal: sell ${GOAL} ${gch('tl')} Thought Leadership.</p><button data-act="start">${phone() ? 'start' : '[enter] start'}</button>`; $('over').hidden = false;}function showOver() { if (!G.over || overShown === G.over) return; overShown = G.over; const mins = Math.floor(G.t / 60), secs = Math.floor(G.t % 60), won = G.over === 'won'; $('over').innerHTML = `<div class="h">${won ? 'GOAL: A thought leader.' : 'CAUGHT: IT wiped the server.'}</div>${kv('time', `${mins}:${String(secs).padStart(2, '0')}, day ${Math.floor((8 + G.t / HOUR) / 24) + 1}`)}${kv('earned', usd(G.earned))}${kv('products sold', `${PRODUCTS.reduce((s, g) => s + (G.sold[g] ?? 0), 0)}`)}${kv('trained on', `${G.trained}`)}${kv('waves held', `${G.held}/${G.wave}`)}${kv('intruders stopped', `${G.killed}`)}<button data-act="again">${phone() ? 'again' : '[enter] again'}</button>`; $('over').hidden = false;}
// ---------- the loop ----------let last = performance.now(), acc = 0, lastUi = 0;function refresh() { updateTool(); renderTop(); renderCmd(); renderInsp(); renderLog(); }function frame(now: number) { const dt = Math.min(0.1, (now - last) / 1000); last = now; if (!paused && !G.over) { acc += dt * speed; for (let i = 0; acc >= 0.05 && i < 40; i++) { R.step(G, 0.05); acc -= 0.05; } } updateTool(); draw(ctx, G, V, W, H, now); if (now - lastUi > 150) { lastUi = now; watch(); refresh(); showOver(); } requestAnimationFrame(frame);}window.addEventListener('resize', () => { lastCmd = ''; lastTop = ''; refresh(); });resize();refresh();brief();requestAnimationFrame(frame);document.fonts?.ready.then(() => { lastTop = ''; });
// For scripted play and tests: the game, the view and the rules, a cell's middle on the page, and a way to// centre the map on a cell.(window as unknown as { factory: () => unknown }).factory = () => ({ G, V, R, refresh, pickTool, step: (s: number) => { for (let t = 0; t < s; t += 0.05) R.step(G, 0.05); refresh(); }, focus: (x: number, y: number) => { focus(V, x, y, W, H); }, /** a cell's middle in page pixels */ spot: (x: number, y: number) => { const r = cv.getBoundingClientRect(); return [r.left + V.ox + (x + 0.5) * V.cw, r.top + V.oy + (y + 0.5) * V.ch]; },});