/** * LANE-RENDER — dev-only mock snapshot. NOT part of the game. * * LANE-SIM builds in parallel and hasn't landed heat, splitters or `BeltItem.id` yet, * so there is no live source for most of what this lane renders. This fabricates a * factory that exercises every visual path — both corner handednesses, a merge, a heat * ramp through throttle into scram, a filling buffer, stable cargo ids — purely so the * renderer can be checked with real eyes before the sim catches up. * * Gated behind `?devscene` AND `import.meta.env.DEV`, and only used while the real sim * has no entities: the moment LANE-SIM produces anything, this yields automatically. * It never fabricates over live sim data. * * Cargo advances along the belt CHAIN (handing off between belt entities) rather than * by wrapping `t` on one belt, because that's what the real sim does — and it's what * exercises the renderer's cross-belt interpolation and corner pathing honestly. */ import { TICKS_PER_SECOND, type Dir, type EntityState, type GameData, type SimSnapshot } from '../contracts'; /** * Dev builds only. The `import.meta.env.DEV` guard is load-bearing, not decoration: * without it `?devscene` would fabricate a fake factory in a shipped build and expose * the renderer internals to anyone with a query string. It also lets Vite drop this * whole module from the production bundle. */ export const devSceneEnabled = (): boolean => import.meta.env.DEV && new URLSearchParams(location.search).has('devscene'); /** `?devscene=stress` builds the standing-rule load: 500 entities + 2,000 belt items. */ export const devSceneStress = (): boolean => import.meta.env.DEV && new URLSearchParams(location.search).get('devscene') === 'stress'; interface Segment { ids: number[]; // belt entity ids, in travel order item: string; count: number; tag: number; // id namespace so cargo ids stay stable and unique } export class DevScene { private entities: EntityState[] = []; private segments: Segment[] = []; private speed = 2; private ok = false; private nextId = 1; /** The mock's own scram latch — see snapshot(). */ private scrammed = false; constructor(private data: GameData, private stress = false) { this.build(); } get usable(): boolean { return this.ok; } private has(id: string): boolean { return this.data.machines.some((m) => m.id === id); } private machine(def: string, x: number, y: number, dir: Dir = 1, recipe: string | null = null): number { const id = this.nextId++; this.entities.push({ id, def, pos: { x, y }, dir, recipe, progress: 0, inputBuf: {}, outputBuf: {}, jammed: null, heat: 0, }); return id; } /** A run of belts along explicit tiles; returns their ids in travel order. */ private belts(tiles: Array<[number, number, Dir]>): number[] { return tiles.map(([x, y, d]) => this.machine('belt', x, y, d)); } private segment(ids: number[], item: string, count: number): void { if (this.data.items.some((i) => i.id === item)) { this.segments.push({ ids, item, count, tag: this.segments.length + 1 }); } } private build(): void { if (!this.has('belt')) return; this.speed = this.data.machines.find((m) => m.id === 'belt')?.beltSpeed ?? 2; if (this.stress) { const items = ['mdat-ore', 'luma-bars', 'coefficient-pack', 'melt']; const machines = ['quantizer', 'demuxer', 'mosh-reactor', 'decode-asic'].filter((m) => this.has(m)); for (let r = 0; r < 20; r++) { const y = -28 + r * 3; const tiles: Array<[number, number, Dir]> = []; for (let x = -30; x <= -7; x++) tiles.push([x, y, 1]); // 24 belts x 20 = 480 this.segment(this.belts(tiles), items[r % items.length], 100); // 2,000 items if (machines.length) this.machine(machines[r % machines.length], -5, y, 1); // +20 = 500 } this.ok = true; return; } // --- the M1 line, bent so cargo rides BOTH corner handednesses ----------------- if (this.has('seam-extractor')) this.machine('seam-extractor', -14, 0, 1, 'extract-mdat'); // Main run east along y=0. The belt at x=-6 is fed from behind AND from the spur // above it, so topology should classify it a merge. const main: Array<[number, number, Dir]> = []; for (let x = -12; x <= -3; x++) main.push([x, 0, 1]); this.segment(this.belts(main), 'mdat-ore', 8); // Spur dropping south into the main line -> forces the merge piece. this.segment(this.belts([[-6, -3, 2], [-6, -2, 2], [-6, -1, 2]]), 'chroma-slurry', 3); // One chain: corner right (fed from W, turns S), south run, corner left (fed from // N, turns E), east run. Cargo sweeps both arcs on the way to the shipper. const bend: Array<[number, number, Dir]> = [ [-2, 0, 2], // cornerA — fed from local +x [-2, 1, 2], [-2, 2, 2], [-2, 3, 2], [-2, 4, 2], [-2, 5, 1], // cornerB — fed from local -x [-1, 5, 1], [0, 5, 1], [1, 5, 1], [2, 5, 1], ]; this.segment(this.belts(bend), 'melt', 6); if (this.has('shipper')) this.machine('shipper', 3, 5, 1); // Showroom row for the round-2 machine visuals, clear of the belt path. if (this.has('lane-splitter')) this.machine('lane-splitter', -12, 8, 1); if (this.has('buffer-tank')) this.machine('buffer-tank', -8, 8, 1); if (this.has('software-decoder')) this.machine('software-decoder', -4, 8, 1); if (this.has('quantizer')) this.machine('quantizer', 0, 8, 1, 'quantize'); if (this.has('mosh-reactor')) this.machine('mosh-reactor', 4, 8, 1, 'mosh'); this.ok = true; } snapshot(tick: number): SimSnapshot { // Heat ramp: normal -> throttled (>0.7) -> scram (1.0) -> restart, on a ~10s loop // so all three states are reachable in a short verification session. const heat = Math.min(1, (tick % 300) / 200); const brownout = Math.floor(tick / 300) % 4 === 3; // The mock owns its own latch, the way the real sim owns its own: scram at 1.0 and // stay dead while cooling. The renderer must read `scrammed` and never infer it — // that's the whole point of the v3 field. if (heat >= 1) this.scrammed = true; else if (heat < 0.5) this.scrammed = false; for (const e of this.entities) { if (e.def === 'software-decoder') { e.heat = heat; e.scrammed = this.scrammed; } if (e.def === 'demuxer') e.jammed = Math.floor(tick / 450) % 3 === 2 ? 'output full: LUMA BARS' : null; if (e.recipe) e.progress = (tick % 90) / 90; } const beltItems: SimSnapshot['beltItems'] = this.segments.flatMap((seg) => { const perTick = this.speed / TICKS_PER_SECOND; const span = seg.ids.length; return Array.from({ length: seg.count }, (_, k) => { const p = (tick * perTick + (k * span) / seg.count) % span; const idx = Math.min(span - 1, Math.floor(p)); // Stable per-item id (contracts v2): identity survives the belt-to-belt handoff, // which is exactly what exact interpolation needs. return { item: seg.item, entity: seg.ids[idx], t: p - idx, id: seg.tag * 1000 + k }; }); }); return { tick, paused: false, entities: this.entities, beltItems, // Oscillate the pool so buffer tanks visibly fill and drain. bandwidth: { gen: 20, draw: brownout ? 26 : 14, stored: (0.5 + 0.5 * Math.sin(tick / 60)) * 100, capacity: 100, brownout, }, shippedTotal: { melt: Math.floor(tick / 90) }, activeCommission: null, commissionProgress: {}, // v4: nothing researched, so every tech-gated machine reads locked — which is what // the ghost's padlock is for. `unlocked` is writable from the console to check the // other half (see NOTES). research: this.research, }; } /** Mutable so a verification session can unlock techs and watch padlocks clear. */ readonly research = { active: null as string | null, progress: {}, unlocked: [] as string[] }; }