/** * FKTRY main loop — ORCHESTRATOR-OWNED wiring. Lanes do not edit. * Fixed-timestep sim (30 tps) + interpolated render + event fan-out. */ import { TICKS_PER_SECOND, type Command, type Dir, type GameData, type SelectionState, type UIBus } from './contracts'; import { createSim } from './sim'; import { createRenderer } from './render'; import { createUI } from './ui'; import { createScreenFX } from './screen'; import { createAudioFX, getRadio } from './audio'; import items from '../data/items.json'; import machines from '../data/machines.json'; import recipes from '../data/recipes.json'; import tech from '../data/tech.json'; import commissions from '../data/commissions.json'; import seams from '../data/seams.json'; const data = { items, machines, recipes, tech, commissions, seams } as unknown as GameData; const sim = createSim(); const renderer = createRenderer(); const ui = createUI(); const screen = createScreenFX(); const audio = createAudioFX(); /** * Radio → RadioControl adapter (round-5 review fix: the dial drove nothing). * Two problems it solves, both integration seams between LANE-SCREEN's engine and * LANE-UI's dock: * 1. TIMING — the dock resolves its control ONCE, inside ui.init(), before any user * gesture has built the audio graph. This adapter EXISTS immediately (so the * init-time probe finds a real subscribe()) and lazily attaches to the engine * when the first gesture creates it, replaying state to subscribers. * 2. SHAPE + UNITS — UI speaks RadioControl {setFrequency(MHz), volume(), mute(), * state.freqMHz, state.station:name}; the engine speaks Radio {setFrequency(native * kHz for AM/SW), setVolume(), setMuted(), state.frequency, state.locked:id}. */ function createSpeakerAdapter() { type EngineRadio = NonNullable>; let engine: EngineRadio | null = null; let names = new Map(); const subs = new Set<(s: any) => void>(); // pre-engine local state so the dock behaves before the first gesture let local = { freqMHz: 88.0, band: 'FM' as 'AM' | 'FM' | 'SW', volume: 0.7, muted: false, station: null as string | null }; const toMHz = (band: string, f: number) => (band === 'FM' ? f : f / 1000); const toNative = (band: string, mhz: number) => (band === 'FM' ? mhz : mhz * 1000); const translate = (s: any) => ({ freqMHz: toMHz(s.band, s.frequency), band: s.band, volume: s.volume, muted: s.muted, station: s.locked ? (names.get(s.locked) ?? s.locked) : null, }); const control = { setFrequency(mhz: number) { local.freqMHz = mhz; if (engine) engine.setFrequency(toNative(engine.getState().band, mhz)); else notifyLocal(); }, setBand(band: 'AM' | 'FM' | 'SW') { local.band = band; if (engine) engine.setBand(band); else notifyLocal(); }, volume(v: number) { local.volume = Math.max(0, Math.min(1, v)); if (engine) engine.setVolume(local.volume); else notifyLocal(); }, mute(on: boolean) { local.muted = on; if (engine) engine.setMuted(on); else notifyLocal(); }, getState() { return engine ? translate(engine.getState()) : { ...local }; }, subscribe(cb: (s: any) => void) { subs.add(cb); cb(control.getState()); return () => subs.delete(cb); }, /** v8: station tick marks for the dial — engine truth, MHz units, empty pre-gesture */ listStations() { return engine ? engine.listStations().map((st) => ({ ...st, freqMHz: toMHz(st.band, st.freq) })) : []; }, }; function notifyLocal() { for (const cb of subs) cb({ ...local }); } return { control, attach() { const r = getRadio(); if (!r || engine) return; engine = r; names = new Map(r.listStations().map((s) => [s.id, s.name])); // carry any pre-gesture dial fiddling into the engine r.setBand(local.band); r.setFrequency(toNative(local.band, local.freqMHz)); r.setVolume(local.volume); r.setMuted(local.muted); r.subscribe((s) => { const t = translate(s); for (const cb of subs) cb(t); }); }, }; } const speaker = createSpeakerAdapter(); // v4 bridge: _sel keeps the legacy {def,dir} shape (including the deprecated // '__remove' sentinel) so unmigrated lanes keep working; selection()/setSelection() // are the typed protocol. The sentinel path is deleted in v5. const bus: UIBus & { _sel: { def: string; dir: Dir } | null; _state: SelectionState } = { _sel: null, // legacy mirror (build/remove only) — served for selectedBuild() compat _state: null, // v5: typed truth, carries inspect mode dispatch: (cmd: Command) => sim.enqueue(cmd), selectedBuild: () => bus._sel, pickTile: (x, y) => renderer.pickTile(x, y), selection: (): SelectionState => { if (bus._state) return bus._state; if (!bus._sel) return null; // legacy writer fallback if (bus._sel.def === '__remove') return { mode: 'remove' }; return { mode: 'build', def: bus._sel.def, dir: bus._sel.dir }; }, setSelection: (sel: SelectionState) => { bus._state = sel; bus._sel = sel?.mode === 'build' ? { def: sel.def, dir: sel.dir } : sel?.mode === 'remove' ? { def: '__remove', dir: 0 as Dir } : null; }, }; // LANE-UI sets the build selection through this hook: (window as any).__fktryBus = bus; async function boot() { sim.init(data, 1337); // publish the speaker handshake BEFORE ui.init — the dock resolves its control // exactly once, during init, and must find a live-shaped RadioControl there. (window as any).__fktrySpeaker = { radio: speaker.control }; await renderer.init(document.getElementById('game')!, data); ui.init(document.getElementById('ui')!, data, bus); screen.init(document.getElementById('screen') as HTMLCanvasElement, data); audio.init(data); // browsers gate AudioContext on a user gesture — unlock on the first one, // then attach the adapter to the freshly built engine. addEventListener('pointerdown', () => { audio.unlock(); speaker.attach(); }, { once: true }); // click-to-place / hover ghost glue const game = document.getElementById('game')!; // Ghost refresh runs on move AND every frame (a keyboard disarm — Esc — used to leave // a stale demolition tint on screen until the mouse moved; round-5 UI finding). let lastPointer: { x: number; y: number } | null = null; function refreshGhost() { if (!lastPointer) return; const sel = bus.selection!(); const tile = renderer.pickTile(lastPointer.x, lastPointer.y); if (sel?.mode === 'remove') { renderer.setGhost(null, tile, 0, 'remove'); } else if (sel?.mode === 'build') { renderer.setGhost(sel.def, tile, sel.dir, 'build'); } else { renderer.setGhost(null, tile, 0, 'build'); // inspect / no selection: no ghost } } game.addEventListener('pointermove', (e) => { lastPointer = { x: e.clientX, y: e.clientY }; refreshGhost(); }); game.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; // pan/context buttons never place const sel = bus.selection!(); const tile = renderer.pickTile(e.clientX, e.clientY); if (!sel || !tile) return; if (sel.mode === 'remove') sim.enqueue({ kind: 'remove', pos: tile }); else if (sel.mode === 'build') sim.enqueue({ kind: 'place', def: sel.def, pos: tile, dir: sel.dir }); }); const MS_PER_TICK = 1000 / TICKS_PER_SECOND; let acc = 0, last = performance.now(); function loop(now: number) { acc += Math.min(250, now - last); last = now; while (acc >= MS_PER_TICK) { sim.tick(); acc -= MS_PER_TICK; } const snap = sim.snapshot(); const events = sim.drainEvents(); refreshGhost(); renderer.onEvents?.(events); // v9: same array Screen/Audio get — read-only, same frame renderer.render(snap, acc / MS_PER_TICK); ui.update(snap, events); screen.onEvents(events); screen.frame(now, snap); audio.onEvents(events); audio.frame(now, snap); requestAnimationFrame(loop); } requestAnimationFrame(loop); // dev-only: paste-free demo — builds SIM's reference factory. The layout uses // research-gated ids, so it is a post-research save by definition: grant the techs // it needs first (testkit rides the public save/load API), then dispatch. if (import.meta.env.DEV) { (window as any).__fktryDemo = async () => { const { referenceFactory, referenceFactoryTech } = await import('./sim/reference'); const techs = referenceFactoryTech(data); sim.enqueue({ kind: 'grantResearch', tech: techs }); // v5 command; queued first const cmds = referenceFactory(data); cmds.forEach((c) => sim.enqueue(c)); return `${techs.length} techs granted, ${cmds.length} commands dispatched`; }; } } boot();