[orchestrator] round 5 (MEGA) review: integration fixes — the radio works in prod

Review: 5/5 lanes pass, 446/446, verified live end-to-end: fax ladder opens the
game, floor + seam glints render (RENDER's colorspace find), fossil excavates at
the seeded position, tuner dock slides in, radio locks 88.1 FKTRY FM and AM 640
THE STANDARD. The pipe is real.

Integration fixes (the cross-lane seams no lane owned):
- audio: radio handle exposed outside the DEV gate — the dock was silent in PROD
  (UI probed __fktrySpeaker, SCREEN's getRadio read a DEV-only object; both halves
  correct, handshake unowned). main.ts now publishes __fktrySpeaker on first gesture.
- contracts v7: GameData.seams (SeamDef/SeamMap lifted from SIM) + main.ts imports
  seams.json — seams were inert in the real game (SIM's request).
- index.html RULING: #screen stays interactive (UI's click-to-enlarge landed after
  RENDER's dead-zone probe — overtaken by events); cursor:pointer added.
- main.ts: ghost refreshes every frame (stale demolition tint on Esc — UI finding);
  __fktryDemo uses the grantResearch command (testkit retired).
- relic.test.ts Seamed type vs the new contract field (my breakage, my fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 14:32:56 +10:00
parent 0cc80d3c82
commit f35f159b72
5 changed files with 51 additions and 10 deletions

View File

@ -9,7 +9,11 @@
font-family:"Courier New", ui-monospace, monospace; } font-family:"Courier New", ui-monospace, monospace; }
#game { position:fixed; inset:0; } /* LANE-RENDER mounts here */ #game { position:fixed; inset:0; } /* LANE-RENDER mounts here */
#screen { position:fixed; top:10px; left:50%; transform:translateX(-50%); #screen { position:fixed; top:10px; left:50%; transform:translateX(-50%);
width:320px; height:180px; border:1px solid #2a2440; z-index:5; } /* LANE-SCREEN canvas */ width:320px; height:180px; border:1px solid #2a2440; z-index:5;
cursor:pointer; } /* LANE-SCREEN canvas. RULING (round 5 review): as of UI's
click-to-enlarge this is a legitimate interactive surface, not a dead zone —
RENDER's pointer-events request is overtaken by events. World tiles under it
are occluded like any panel; don't build under the sky. */
#ui { position:fixed; inset:0; pointer-events:none; z-index:10; } /* LANE-UI root (children re-enable pointer-events) */ #ui { position:fixed; inset:0; pointer-events:none; z-index:10; } /* LANE-UI root (children re-enable pointer-events) */
</style> </style>
</head> </head>

View File

@ -45,6 +45,11 @@ function isRealSeam(x: number, y: number): boolean {
/** Sounds that must not machine-gun when the sim emits a burst in one tick. */ /** Sounds that must not machine-gun when the sim emits a burst in one tick. */
const MIN_GAP = { thunk: 0.03, chunk: 0.05 }; const MIN_GAP = { thunk: 0.03, chunk: 0.05 };
/** The live radio handle for getRadio(). Module-level set by build(), which runs on the
* first user gesture so the control surface exists in PRODUCTION, not just behind the
* DEV debug object (orchestrator round-5 integration fix: the dock was silent in prod). */
let liveRadio: Radio | null = null;
export function createAudioFX(): AudioFX { export function createAudioFX(): AudioFX {
let ctx: BaseAudioContext | null = null; let ctx: BaseAudioContext | null = null;
let master: ReturnType<typeof createMasterChain> | null = null; let master: ReturnType<typeof createMasterChain> | null = null;
@ -122,6 +127,7 @@ export function createAudioFX(): AudioFX {
ctx = new AC(); ctx = new AC();
master = createMasterChain(ctx); master = createMasterChain(ctx);
radio = createRadio(ctx, STATIONS, isRealSeam); radio = createRadio(ctx, STATIONS, isRealSeam);
liveRadio = radio as Radio;
ambience = createAmbience(ctx); ambience = createAmbience(ctx);
radio.out.connect(master.input); radio.out.connect(master.input);
ambience.out.connect(master.input); ambience.out.connect(master.input);
@ -207,5 +213,5 @@ export function createAudioFX(): AudioFX {
* the audio graph. * the audio graph.
*/ */
export function getRadio(): Radio | null { export function getRadio(): Radio | null {
return (window as any).__fktryAudio?.radio ?? null; return liveRadio;
} }

View File

@ -83,12 +83,28 @@ export interface CommissionDef {
repeat?: boolean; // v2: standing order — re-activates after completion repeat?: boolean; // v2: standing order — re-activates after completion
} }
// v7: lifted verbatim from SIM's round-5 mirror — data/seams.json's shape.
export interface SeamDef {
id: string;
rect: { x: number; y: number; w: number; h: number };
era?: string;
resource?: string;
richness?: number;
hidden?: boolean; // treasure — only the numbers station may reveal it
}
export interface SeamMap {
/** DATA keeps this corner seam-free so the seed-derived relic always lands somewhere. */
relicReserve?: { x: number; y: number; w: number; h: number };
seams: SeamDef[];
}
export interface GameData { export interface GameData {
items: ItemDef[]; items: ItemDef[];
machines: MachineDef[]; machines: MachineDef[];
recipes: RecipeDef[]; recipes: RecipeDef[];
tech: TechDef[]; tech: TechDef[];
commissions: CommissionDef[]; commissions: CommissionDef[];
seams?: SeamMap; // v7: wired by main.ts from data/seams.json; extractors need ore
} }
// ---------------------------------------------------------------- live sim state // ---------------------------------------------------------------- live sim state

View File

@ -7,15 +7,16 @@ import { createSim } from './sim';
import { createRenderer } from './render'; import { createRenderer } from './render';
import { createUI } from './ui'; import { createUI } from './ui';
import { createScreenFX } from './screen'; import { createScreenFX } from './screen';
import { createAudioFX } from './audio'; import { createAudioFX, getRadio } from './audio';
import items from '../data/items.json'; import items from '../data/items.json';
import machines from '../data/machines.json'; import machines from '../data/machines.json';
import recipes from '../data/recipes.json'; import recipes from '../data/recipes.json';
import tech from '../data/tech.json'; import tech from '../data/tech.json';
import commissions from '../data/commissions.json'; import commissions from '../data/commissions.json';
import seams from '../data/seams.json';
const data = { items, machines, recipes, tech, commissions } as unknown as GameData; const data = { items, machines, recipes, tech, commissions, seams } as unknown as GameData;
const sim = createSim(); const sim = createSim();
const renderer = createRenderer(); const renderer = createRenderer();
@ -56,13 +57,21 @@ async function boot() {
screen.init(document.getElementById('screen') as HTMLCanvasElement, data); screen.init(document.getElementById('screen') as HTMLCanvasElement, data);
audio.init(data); audio.init(data);
// browsers gate AudioContext on a user gesture — unlock on the first one // browsers gate AudioContext on a user gesture — unlock on the first one
addEventListener('pointerdown', () => audio.unlock(), { once: true }); addEventListener('pointerdown', () => {
audio.unlock();
// UI's tuner dock probes window.__fktrySpeaker.radio (its documented handshake).
(window as any).__fktrySpeaker = { radio: getRadio() };
}, { once: true });
// click-to-place / hover ghost glue // click-to-place / hover ghost glue
const game = document.getElementById('game')!; const game = document.getElementById('game')!;
game.addEventListener('pointermove', (e) => { // 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 sel = bus.selection!();
const tile = renderer.pickTile(e.clientX, e.clientY); const tile = renderer.pickTile(lastPointer.x, lastPointer.y);
if (sel?.mode === 'remove') { if (sel?.mode === 'remove') {
renderer.setGhost(null, tile, 0, 'remove'); renderer.setGhost(null, tile, 0, 'remove');
} else if (sel?.mode === 'build') { } else if (sel?.mode === 'build') {
@ -70,6 +79,10 @@ async function boot() {
} else { } else {
renderer.setGhost(null, tile, 0, 'build'); // inspect / no selection: no ghost 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) => { game.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return; // pan/context buttons never place if (e.button !== 0) return; // pan/context buttons never place
@ -87,6 +100,7 @@ async function boot() {
while (acc >= MS_PER_TICK) { sim.tick(); acc -= MS_PER_TICK; } while (acc >= MS_PER_TICK) { sim.tick(); acc -= MS_PER_TICK; }
const snap = sim.snapshot(); const snap = sim.snapshot();
const events = sim.drainEvents(); const events = sim.drainEvents();
refreshGhost();
renderer.render(snap, acc / MS_PER_TICK); renderer.render(snap, acc / MS_PER_TICK);
ui.update(snap, events); ui.update(snap, events);
screen.onEvents(events); screen.onEvents(events);
@ -103,9 +117,8 @@ async function boot() {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
(window as any).__fktryDemo = async () => { (window as any).__fktryDemo = async () => {
const { referenceFactory, referenceFactoryTech } = await import('./sim/reference'); const { referenceFactory, referenceFactoryTech } = await import('./sim/reference');
const { grantResearch } = await import('./sim/testkit');
const techs = referenceFactoryTech(data); const techs = referenceFactoryTech(data);
grantResearch(sim, techs); sim.enqueue({ kind: 'grantResearch', tech: techs }); // v5 command; queued first
const cmds = referenceFactory(data); const cmds = referenceFactory(data);
cmds.forEach((c) => sim.enqueue(c)); cmds.forEach((c) => sim.enqueue(c));
return `${techs.length} techs granted, ${cmds.length} commands dispatched`; return `${techs.length} techs granted, ${cmds.length} commands dispatched`;

View File

@ -25,7 +25,9 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD
const N: Dir = 0, E: Dir = 1; const N: Dir = 0, E: Dir = 1;
/** GameData plus the seam map — the shape once the orchestrator wires GameData.seams. */ /** GameData plus the seam map — the shape once the orchestrator wires GameData.seams. */
type Seamed = GameData & { seams?: SeamMap | SeamDef[] }; // Omit first: contracts v7 types GameData.seams as SeamMap, but the sim deliberately
// also accepts a bare rect list (defensive path) — this test covers both forms.
type Seamed = Omit<GameData, 'seams'> & { seams?: SeamMap | SeamDef[] };
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' }); const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({ const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({