diff --git a/fktry/index.html b/fktry/index.html index 2901f7b..f9ed2fd 100644 --- a/fktry/index.html +++ b/fktry/index.html @@ -9,7 +9,11 @@ font-family:"Courier New", ui-monospace, monospace; } #game { position:fixed; inset:0; } /* LANE-RENDER mounts here */ #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) */ diff --git a/fktry/src/audio/index.ts b/fktry/src/audio/index.ts index b15cf7d..e85f8c4 100644 --- a/fktry/src/audio/index.ts +++ b/fktry/src/audio/index.ts @@ -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. */ 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 { let ctx: BaseAudioContext | null = null; let master: ReturnType | null = null; @@ -122,6 +127,7 @@ export function createAudioFX(): AudioFX { ctx = new AC(); master = createMasterChain(ctx); radio = createRadio(ctx, STATIONS, isRealSeam); + liveRadio = radio as Radio; ambience = createAmbience(ctx); radio.out.connect(master.input); ambience.out.connect(master.input); @@ -207,5 +213,5 @@ export function createAudioFX(): AudioFX { * the audio graph. */ export function getRadio(): Radio | null { - return (window as any).__fktryAudio?.radio ?? null; + return liveRadio; } diff --git a/fktry/src/contracts.ts b/fktry/src/contracts.ts index e12fbcc..fa950c8 100644 --- a/fktry/src/contracts.ts +++ b/fktry/src/contracts.ts @@ -83,12 +83,28 @@ export interface CommissionDef { 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 { items: ItemDef[]; machines: MachineDef[]; recipes: RecipeDef[]; tech: TechDef[]; commissions: CommissionDef[]; + seams?: SeamMap; // v7: wired by main.ts from data/seams.json; extractors need ore } // ---------------------------------------------------------------- live sim state diff --git a/fktry/src/main.ts b/fktry/src/main.ts index 43df33a..0db4806 100644 --- a/fktry/src/main.ts +++ b/fktry/src/main.ts @@ -7,15 +7,16 @@ import { createSim } from './sim'; import { createRenderer } from './render'; import { createUI } from './ui'; import { createScreenFX } from './screen'; -import { createAudioFX } from './audio'; +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 } as unknown as GameData; +const data = { items, machines, recipes, tech, commissions, seams } as unknown as GameData; const sim = createSim(); const renderer = createRenderer(); @@ -56,13 +57,21 @@ async function boot() { screen.init(document.getElementById('screen') as HTMLCanvasElement, data); audio.init(data); // 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 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 tile = renderer.pickTile(e.clientX, e.clientY); + const tile = renderer.pickTile(lastPointer.x, lastPointer.y); if (sel?.mode === 'remove') { renderer.setGhost(null, tile, 0, 'remove'); } else if (sel?.mode === 'build') { @@ -70,6 +79,10 @@ async function boot() { } 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 @@ -87,6 +100,7 @@ async function boot() { while (acc >= MS_PER_TICK) { sim.tick(); acc -= MS_PER_TICK; } const snap = sim.snapshot(); const events = sim.drainEvents(); + refreshGhost(); renderer.render(snap, acc / MS_PER_TICK); ui.update(snap, events); screen.onEvents(events); @@ -103,9 +117,8 @@ async function boot() { if (import.meta.env.DEV) { (window as any).__fktryDemo = async () => { const { referenceFactory, referenceFactoryTech } = await import('./sim/reference'); - const { grantResearch } = await import('./sim/testkit'); const techs = referenceFactoryTech(data); - grantResearch(sim, techs); + 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`; diff --git a/fktry/src/sim/relic.test.ts b/fktry/src/sim/relic.test.ts index 831f7cf..dd82aef 100644 --- a/fktry/src/sim/relic.test.ts +++ b/fktry/src/sim/relic.test.ts @@ -25,7 +25,9 @@ const DATA = { items, machines, recipes, tech, commissions } as unknown as GameD const N: Dir = 0, E: Dir = 1; /** 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 & { seams?: SeamMap | SeamDef[] }; const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' }); const mach = (m: Partial & Pick): MachineDef => ({