Round 4 orders 1-5: - ghost keys demolition off setGhost's `mode` arg; the '__remove' sentinel is ignored and REMOVE_DEF is gone from this lane. - locked-build ghost: padlock-amber (not occupied-red — "not yet" and "not here" are different sentences) + a padlock tinted by the gating era. Needed a new ERA_TELL palette: ERA_SKIN.tint is a multiplier tuned to leave DATA's chassis alone, so it can't carry a signal (stream is #ffffff). - aura.ts: cooling coverage as a dashed RECTANGLE. coolRadius is chebyshev, so the cooled region is the footprint grown by r per side; a circle would look better and lie about the corners. - lab placeholder: bench + the "bottled artifact triptych" (§2); consumes MachineDef.accent when present, derivation stays the fallback. Also: - buffer fill reads bandwidth.capacity (v4) instead of re-summing bufferCap — a second copy of the sim's arithmetic is what the scram latch warned about. - lock rule matched to LANE-UI's reading: absent research means nothing has completed, so gated machines padlock. Mine said the opposite; theirs is the literal contract text, and the build bar and ghost disagreeing on the same screen is worse than either answer. - showroom fitted to BOTH axes. Round 3's depth-only fit collapsed the item grid to one 206-tile row once the machine hall grew and ate the depth. Verified against DATA's real v4 data and UI's migrated flow: 6x6 aura on asic-cooler, reel/broadcast era tells, lab accent #ffc24a from data, 29 machines + 60 items in the showroom, 500 entities + 2,000 items at 3.6ms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
190 lines
7.9 KiB
TypeScript
190 lines
7.9 KiB
TypeScript
/**
|
|
* 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[] };
|
|
}
|