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 => ({