@adatechnology/game-test-room 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,9 @@ var GameTestRoom = (() => {
29
29
  buildFromSpec: () => buildFromSpec,
30
30
  createLog: () => createLog,
31
31
  createTestRoom: () => createTestRoom,
32
+ decodeBenchState: () => decodeBenchState,
32
33
  demoItems: () => demoItems,
34
+ encodeBenchState: () => encodeBenchState,
33
35
  locales: () => locales4,
34
36
  makeScenarios: () => makeScenarios,
35
37
  resolveStrings: () => resolveStrings4,
@@ -240,6 +242,47 @@ var GameTestRoom = (() => {
240
242
  });
241
243
  return { ok: problems.length === 0, problems };
242
244
  };
245
+ var SNAKE_CASE = /^[a-z][a-z0-9_]*$/;
246
+ var finite2 = (n) => typeof n === "number" && Number.isFinite(n);
247
+ function checkControl(control, problems) {
248
+ const label = `control "${control.id}"`;
249
+ if (control.kind === "number") {
250
+ if (!finite2(control.min) || !finite2(control.max) || control.min >= control.max) problems.push(`${label} needs min < max`);
251
+ if (control.step !== void 0 && !(finite2(control.step) && control.step > 0)) problems.push(`${label} needs a step > 0`);
252
+ if (!finite2(control.default) || control.default < control.min || control.default > control.max) problems.push(`${label} default ${String(control.default)} is outside ${control.min}..${control.max}`);
253
+ return;
254
+ }
255
+ if (control.kind !== "choice") return;
256
+ if (control.options.length === 0) {
257
+ problems.push(`${label} needs at least one option`);
258
+ return;
259
+ }
260
+ const seen = /* @__PURE__ */ new Set();
261
+ for (const option of control.options) {
262
+ if (seen.has(option.id)) problems.push(`${label} repeats option "${option.id}"`);
263
+ seen.add(option.id);
264
+ }
265
+ if (!seen.has(control.default)) problems.push(`${label} default "${control.default}" is not among its options`);
266
+ }
267
+ function checkBench(declared) {
268
+ const problems = [];
269
+ const ids = /* @__PURE__ */ new Set();
270
+ const claim = (kind, id) => {
271
+ if (!SNAKE_CASE.test(id)) problems.push(`${kind} id "${id}" must be snake_case`);
272
+ if (ids.has(id)) problems.push(`duplicate id "${id}" across controls, layers and metrics`);
273
+ ids.add(id);
274
+ };
275
+ for (const control of declared.controls ?? []) {
276
+ claim("control", control.id);
277
+ checkControl(control, problems);
278
+ }
279
+ for (const layer of declared.layers ?? []) claim("layer", layer.id);
280
+ for (const metric of declared.metrics ?? []) {
281
+ claim("metric", metric.id);
282
+ if (metric.max !== void 0 && !(finite2(metric.max) && metric.max > 0)) problems.push(`metric "${metric.id}" needs a max > 0`);
283
+ }
284
+ return { ok: problems.length === 0, problems };
285
+ }
243
286
 
244
287
  // ../game-destruction/dist/index.js
245
288
  var EPS = 1e-9;
@@ -2940,13 +2983,13 @@ var GameTestRoom = (() => {
2940
2983
  ...impls.size ? { impls: [...impls.values()].map((im) => ({ id: im.impl.id, position: [...im.position], rotation: [...im.rotation], data: im.impl.serialize?.() })) } : {}
2941
2984
  };
2942
2985
  },
2943
- load(snap) {
2944
- if (snap.version !== 1) throw new Error(`unsupported snapshot version ${String(snap.version)}`);
2986
+ load(snap2) {
2987
+ if (snap2.version !== 1) throw new Error(`unsupported snapshot version ${String(snap2.version)}`);
2945
2988
  objects.clear();
2946
- time = snap.time;
2947
- rng.set(snap.rng);
2948
- counter = snap.counter ?? 0;
2949
- for (const e of snap.objects) {
2989
+ time = snap2.time;
2990
+ rng.set(snap2.rng);
2991
+ counter = snap2.counter ?? 0;
2992
+ for (const e of snap2.objects) {
2950
2993
  const state = clone(e.state);
2951
2994
  const carry = {
2952
2995
  parts: new Map(
@@ -2957,7 +3000,7 @@ var GameTestRoom = (() => {
2957
3000
  };
2958
3001
  objects.set(e.id, build(clone(e.spec), e.id, state.position, state.rotation, carry));
2959
3002
  }
2960
- for (const e of snap.impls ?? []) {
3003
+ for (const e of snap2.impls ?? []) {
2961
3004
  const im = impls.get(e.id);
2962
3005
  if (!im) continue;
2963
3006
  im.position = [e.position[0], e.position[1], e.position[2]];
@@ -3644,6 +3687,8 @@ void main() {
3644
3687
  __publicField(this, "alphaMul");
3645
3688
  __publicField(this, "tag");
3646
3689
  __publicField(this, "colorHex");
3690
+ __publicField(this, "rgbStart");
3691
+ __publicField(this, "rgbEnd");
3647
3692
  __publicField(this, "bounced");
3648
3693
  const n = Math.max(1, capacity);
3649
3694
  this.pos = new Float32Array(n * 3);
@@ -3660,6 +3705,8 @@ void main() {
3660
3705
  this.alphaMul = new Float32Array(n);
3661
3706
  this.tag = new Int32Array(n);
3662
3707
  this.colorHex = new Int32Array(n);
3708
+ this.rgbStart = new Float32Array(n * 3);
3709
+ this.rgbEnd = new Float32Array(n * 3);
3663
3710
  this.bounced = new Uint8Array(n);
3664
3711
  this.geometry = new THREE.BufferGeometry();
3665
3712
  const attr = (a, size) => {
@@ -3707,6 +3754,8 @@ void main() {
3707
3754
  this.alphaMul[i] = o?.alpha ?? 1;
3708
3755
  this.tag[i] = o?.tag ?? 0;
3709
3756
  this.colorHex[i] = o?.color ?? -1;
3757
+ this.rgbStart.set(hexToRgb(o?.color ?? def.c0), i * 3);
3758
+ this.rgbEnd.set(hexToRgb(o?.color ?? def.c1), i * 3);
3710
3759
  this.bounced[i] = 0;
3711
3760
  this.siz[i] = this.size0[i];
3712
3761
  this.alp[i] = 0;
@@ -3800,13 +3849,9 @@ void main() {
3800
3849
  this.pos[i3 + 1] = y;
3801
3850
  this.pos[i3 + 2] = z;
3802
3851
  this.siz[i] = this.size0[i] * lerp(1, def.grow, t);
3803
- const c0 = this.colorHex[i] >= 0 ? this.colorHex[i] : def.c0;
3804
- const c1 = this.colorHex[i] >= 0 ? this.colorHex[i] : def.c1;
3805
- const a = hexToRgb(c0);
3806
- const b = hexToRgb(c1);
3807
- this.col[i3] = lerp(a[0], b[0], t);
3808
- this.col[i3 + 1] = lerp(a[1], b[1], t);
3809
- this.col[i3 + 2] = lerp(a[2], b[2], t);
3852
+ this.col[i3] = lerp(this.rgbStart[i3], this.rgbEnd[i3], t);
3853
+ this.col[i3 + 1] = lerp(this.rgbStart[i3 + 1], this.rgbEnd[i3 + 1], t);
3854
+ this.col[i3 + 2] = lerp(this.rgbStart[i3 + 2], this.rgbEnd[i3 + 2], t);
3810
3855
  let env = t < def.fadeIn ? t / def.fadeIn : 1 - (t - def.fadeIn) / (1 - def.fadeIn);
3811
3856
  if (def.flicker) env *= 1 - def.flicker * (0.5 + 0.5 * Math.sin(age * 38 + this.seed[i] * 50));
3812
3857
  this.alp[i] = clamp3(env, 0, 1) * def.alpha * this.alphaMul[i];
@@ -6078,6 +6123,9 @@ void main() {
6078
6123
  emit(kind, point, normal, count, speed) {
6079
6124
  this.particles.burst(kind, point, normal, count, speed ?? DEFAULT_SPEED[kind] ?? 1, kind === "smoke" ? { color: SMOKE_TONES.grey } : void 0);
6080
6125
  }
6126
+ explode(point, energy, radius) {
6127
+ this.onExplode({ type: "explode", object: "", part: "", time: this.time, point, energy, radius });
6128
+ }
6081
6129
  restore(objectId) {
6082
6130
  const homes = /* @__PURE__ */ new Set([objectId]);
6083
6131
  const self = this.bounds.get(objectId);
@@ -9275,22 +9323,22 @@ void main() {
9275
9323
  return snapshot;
9276
9324
  },
9277
9325
  load(input) {
9278
- const snap = clone2(input);
9279
- if (!snap || snap.version !== 1) throw new Error("game-fluids: unsupported snapshot");
9280
- time = snap.time;
9326
+ const snap2 = clone2(input);
9327
+ if (!snap2 || snap2.version !== 1) throw new Error("game-fluids: unsupported snapshot");
9328
+ time = snap2.time;
9281
9329
  rng = new Rng(0);
9282
- rng.state = snap.rng;
9283
- counters.container = snap.counters.container;
9284
- counters.stream = snap.counters.stream;
9285
- counters.stain = snap.counters.stain;
9330
+ rng.state = snap2.rng;
9331
+ counters.container = snap2.counters.container;
9332
+ counters.stream = snap2.counters.stream;
9333
+ counters.stain = snap2.counters.stain;
9286
9334
  containers = /* @__PURE__ */ new Map();
9287
- for (const raw of snap.containers) containers.set(raw.id, { ...raw, cols: makeColumns(raw.spec.shape) });
9335
+ for (const raw of snap2.containers) containers.set(raw.id, { ...raw, cols: makeColumns(raw.spec.shape) });
9288
9336
  surfaces = /* @__PURE__ */ new Map();
9289
- for (const s of snap.surfaces) world.addSurface(s);
9290
- counters.tap = snap.counters.tap ?? 0;
9291
- streams = new Map(snap.streams.map((s) => [s.id, s]));
9292
- taps = new Map((snap.taps ?? []).map((t) => [t.id, t]));
9293
- stains = new Map(snap.stains.map((s) => [s.id, s]));
9337
+ for (const s of snap2.surfaces) world.addSurface(s);
9338
+ counters.tap = snap2.counters.tap ?? 0;
9339
+ streams = new Map(snap2.streams.map((s) => [s.id, s]));
9340
+ taps = new Map((snap2.taps ?? []).map((t) => [t.id, t]));
9341
+ stains = new Map(snap2.stains.map((s) => [s.id, s]));
9294
9342
  queue = [];
9295
9343
  }
9296
9344
  };
@@ -11170,6 +11218,21 @@ void main() {
11170
11218
  "ui.animations": "Animations",
11171
11219
  "ui.animations_aim": "Aim at an item or hold it to see its animations.",
11172
11220
  "ui.animations_none": "This item brings no animations.",
11221
+ "mod.bench_ok": "{name}: bench ok ({controls} controls, {layers} layers, {metrics} metrics)",
11222
+ "mod.bench_fail": "{name}: bench broken: {problems}",
11223
+ "mod.metric_failed": '{name}: metric "{metric}" threw and shows n/a: {error}',
11224
+ "mod.listener_failed": "{name}: a bench listener threw: {error}",
11225
+ "ui.bench": "Bench",
11226
+ "ui.layers": "Layers",
11227
+ "ui.metrics": "Counters",
11228
+ "metric.fps": "FPS",
11229
+ "metric.frame_ms": "ms/frame",
11230
+ "metric.draw_calls": "Draw calls",
11231
+ "metric.triangles": "Triangles",
11232
+ "metric.objects": "Objects",
11233
+ "metric.fragments": "Fragments",
11234
+ "metric.particles": "Particles",
11235
+ "metric.decals": "Decals",
11173
11236
  "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
11174
11237
  "act.clear": "Cleared the room",
11175
11238
  "act.reset": "Repaired every object",
@@ -11242,6 +11305,9 @@ void main() {
11242
11305
  "ui.quality.medium": "medium",
11243
11306
  "ui.quality.high": "high",
11244
11307
  "ui.equip": "Equip as a tool",
11308
+ "ui.search_items": "Search items (all groups)",
11309
+ "ui.search_tests": "Search tests",
11310
+ "ui.no_results": "Nothing found",
11245
11311
  "ui.objects": "{n} objects",
11246
11312
  "ui.tests": "Tests",
11247
11313
  "ui.run_all": "Run all",
@@ -11459,6 +11525,21 @@ void main() {
11459
11525
  "ui.animations": "Anima\xE7\xF5es",
11460
11526
  "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
11461
11527
  "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
11528
+ "mod.bench_ok": "{name}: bancada ok ({controls} controles, {layers} camadas, {metrics} contadores)",
11529
+ "mod.bench_fail": "{name}: bancada quebrada: {problems}",
11530
+ "mod.metric_failed": '{name}: o contador "{metric}" lan\xE7ou erro e mostra n/a: {error}',
11531
+ "mod.listener_failed": "{name}: um ouvinte da bancada lan\xE7ou erro: {error}",
11532
+ "ui.bench": "Bancada",
11533
+ "ui.layers": "Camadas",
11534
+ "ui.metrics": "Contadores",
11535
+ "metric.fps": "FPS",
11536
+ "metric.frame_ms": "ms/quadro",
11537
+ "metric.draw_calls": "Chamadas de desenho",
11538
+ "metric.triangles": "Tri\xE2ngulos",
11539
+ "metric.objects": "Objetos",
11540
+ "metric.fragments": "Fragmentos",
11541
+ "metric.particles": "Part\xEDculas",
11542
+ "metric.decals": "Decalques",
11462
11543
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
11463
11544
  "act.clear": "Limpou a sala",
11464
11545
  "act.reset": "Consertou todos os objetos",
@@ -11531,6 +11612,9 @@ void main() {
11531
11612
  "ui.quality.medium": "m\xE9dia",
11532
11613
  "ui.quality.high": "alta",
11533
11614
  "ui.equip": "Equipar como ferramenta",
11615
+ "ui.search_items": "Buscar item (todas as abas)",
11616
+ "ui.search_tests": "Buscar teste",
11617
+ "ui.no_results": "Nada encontrado",
11534
11618
  "ui.objects": "{n} objetos",
11535
11619
  "ui.tests": "Testes",
11536
11620
  "ui.run_all": "Rodar todos",
@@ -12067,6 +12151,154 @@ void main() {
12067
12151
  };
12068
12152
  }
12069
12153
 
12154
+ // src/bench.ts
12155
+ var snap = (control, value) => {
12156
+ const clamped = Math.min(control.max, Math.max(control.min, value));
12157
+ if (!control.step) return clamped;
12158
+ const stepped = control.min + Math.round((clamped - control.min) / control.step) * control.step;
12159
+ return Number(Math.min(control.max, stepped).toFixed(10));
12160
+ };
12161
+ function fitValue(control, value) {
12162
+ if (control.kind === "number") return typeof value === "number" && Number.isFinite(value) ? snap(control, value) : void 0;
12163
+ if (control.kind === "boolean") return typeof value === "boolean" ? value : void 0;
12164
+ if (control.kind === "choice") return typeof value === "string" && control.options.some((option) => option.id === value) ? value : void 0;
12165
+ return void 0;
12166
+ }
12167
+ var fromText = (control, text) => {
12168
+ if (control.kind === "number") return text.trim() === "" ? Number.NaN : Number(text);
12169
+ if (control.kind === "boolean") return text === "1" ? true : text === "0" ? false : void 0;
12170
+ return text;
12171
+ };
12172
+ var toText = (value) => typeof value === "boolean" ? value ? "1" : "0" : String(value);
12173
+ function encodeBenchState(controls, layers) {
12174
+ const pairs = [];
12175
+ for (const control of controls) if (control.value !== void 0) pairs.push(`${control.id}=${encodeURIComponent(toText(control.value))}`);
12176
+ for (const layer of layers) pairs.push(`${layer.id}=${layer.on ? "1" : "0"}`);
12177
+ return pairs.join(";");
12178
+ }
12179
+ function decodeBenchState(encoded, controls, layers) {
12180
+ const out = /* @__PURE__ */ new Map();
12181
+ for (const pair of encoded.split(";")) {
12182
+ const at = pair.indexOf("=");
12183
+ if (at <= 0) continue;
12184
+ const id = pair.slice(0, at);
12185
+ let text;
12186
+ try {
12187
+ text = decodeURIComponent(pair.slice(at + 1));
12188
+ } catch {
12189
+ continue;
12190
+ }
12191
+ const control = controls.find((c) => c.id === id);
12192
+ if (control) {
12193
+ const value = fitValue(control, fromText(control, text));
12194
+ if (value !== void 0) out.set(id, value);
12195
+ continue;
12196
+ }
12197
+ if (layers.some((layer) => layer.id === id) && (text === "1" || text === "0")) out.set(id, text === "1");
12198
+ }
12199
+ return out;
12200
+ }
12201
+ function createBenchStore(module, locale, onListenerError) {
12202
+ const declared = { controls: module.controls?.(locale) ?? [], layers: module.layers?.(locale) ?? [], metrics: module.metrics?.(locale) ?? [] };
12203
+ const controls = declared.controls.map((control) => ({ ...control, disabled: false }));
12204
+ const values = /* @__PURE__ */ new Map();
12205
+ for (const control of controls) if (control.kind !== "action") values.set(control.id, control.default);
12206
+ const layers = new Map(declared.layers.map((layer) => [layer.id, layer.default !== false]));
12207
+ const listeners = /* @__PURE__ */ new Set();
12208
+ const watchers = /* @__PURE__ */ new Set();
12209
+ const emit = (change) => {
12210
+ for (const listener of listeners) {
12211
+ try {
12212
+ listener(change);
12213
+ } catch (error) {
12214
+ onListenerError?.(error);
12215
+ }
12216
+ }
12217
+ };
12218
+ const changed = () => {
12219
+ for (const watcher of watchers) watcher();
12220
+ };
12221
+ const controlOf = (id) => controls.find((control) => control.id === id);
12222
+ const store = {
12223
+ moduleId: module.id,
12224
+ metrics: declared.metrics,
12225
+ declared,
12226
+ controls: () => controls.map((control) => ({ ...control, value: values.get(control.id) })),
12227
+ layers: () => declared.layers.map((layer) => ({ ...layer, on: layers.get(layer.id) === true })),
12228
+ value: (id) => values.get(id),
12229
+ layer: (id) => layers.get(id) === true,
12230
+ set(id, value) {
12231
+ const control = controlOf(id);
12232
+ if (!control || control.disabled) return false;
12233
+ const fitted = fitValue(control, value);
12234
+ if (fitted === void 0) return false;
12235
+ values.set(id, fitted);
12236
+ emit({ kind: "control", id, value: fitted });
12237
+ changed();
12238
+ return true;
12239
+ },
12240
+ setLayer(id, on) {
12241
+ if (!layers.has(id)) return false;
12242
+ layers.set(id, on);
12243
+ emit({ kind: "layer", id, on });
12244
+ changed();
12245
+ return true;
12246
+ },
12247
+ act(id) {
12248
+ const control = controlOf(id);
12249
+ if (!control || control.kind !== "action" || control.disabled) return false;
12250
+ emit({ kind: "action", id });
12251
+ return true;
12252
+ },
12253
+ encode: () => encodeBenchState(store.controls(), store.layers()),
12254
+ onChange(listener) {
12255
+ listeners.add(listener);
12256
+ return () => listeners.delete(listener);
12257
+ },
12258
+ watch(listener) {
12259
+ watchers.add(listener);
12260
+ return () => watchers.delete(listener);
12261
+ },
12262
+ patch(id, patch) {
12263
+ const index = controls.findIndex((control2) => control2.id === id);
12264
+ if (index < 0) return;
12265
+ let control = controls[index];
12266
+ if (patch.hidden !== void 0) control = { ...control, hidden: patch.hidden };
12267
+ if (patch.disabled !== void 0) control = { ...control, disabled: patch.disabled };
12268
+ if (patch.options && control.kind === "choice" && patch.options.length > 0) {
12269
+ control = { ...control, options: [...patch.options] };
12270
+ const offered = control.options;
12271
+ if (!offered.some((option) => option.id === values.get(id))) values.set(id, offered[0].id);
12272
+ }
12273
+ controls[index] = control;
12274
+ if (patch.value !== void 0) {
12275
+ const fitted = fitValue(control, patch.value);
12276
+ if (fitted !== void 0) values.set(id, fitted);
12277
+ }
12278
+ changed();
12279
+ },
12280
+ sendInitial() {
12281
+ for (const control of controls) {
12282
+ const value = values.get(control.id);
12283
+ if (value !== void 0) emit({ kind: "control", id: control.id, value });
12284
+ }
12285
+ for (const layer of declared.layers) emit({ kind: "layer", id: layer.id, on: layers.get(layer.id) === true });
12286
+ changed();
12287
+ },
12288
+ restore(encoded) {
12289
+ for (const [id, value] of decodeBenchState(encoded, controls, declared.layers)) {
12290
+ if (typeof value === "boolean" && layers.has(id)) layers.set(id, value);
12291
+ else values.set(id, value);
12292
+ }
12293
+ },
12294
+ dispose() {
12295
+ listeners.clear();
12296
+ watchers.clear();
12297
+ }
12298
+ };
12299
+ return store;
12300
+ }
12301
+
12070
12302
  // src/log.ts
12071
12303
  var MAX_ENTRIES = 5e3;
12072
12304
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -12195,6 +12427,56 @@ void main() {
12195
12427
  return log;
12196
12428
  }
12197
12429
 
12430
+ // src/metrics.ts
12431
+ var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals"];
12432
+ var WINDOW_S = 0.5;
12433
+ function createRoomMetrics(sources) {
12434
+ const wanted = new Set(sources.ids);
12435
+ const gl = sources.gl;
12436
+ let windowS = 0;
12437
+ let frames = 0;
12438
+ let fps;
12439
+ let frameMs;
12440
+ let calls;
12441
+ let triangles;
12442
+ let ownsRender = false;
12443
+ return {
12444
+ frame(dt) {
12445
+ frames++;
12446
+ windowS += dt;
12447
+ if (windowS < WINDOW_S) return;
12448
+ fps = frames / windowS;
12449
+ frameMs = windowS / frames * 1e3;
12450
+ frames = 0;
12451
+ windowS = 0;
12452
+ },
12453
+ rendered() {
12454
+ ownsRender = true;
12455
+ calls = gl.info?.render?.calls;
12456
+ triangles = gl.info?.render?.triangles;
12457
+ },
12458
+ sample() {
12459
+ const out = {};
12460
+ const put = (id, value) => {
12461
+ if (wanted.has(id) && value !== void 0) out[id] = value;
12462
+ };
12463
+ put("fps", fps);
12464
+ put("frame_ms", frameMs);
12465
+ const render = ownsRender ? { calls, triangles } : gl.info?.render;
12466
+ put("draw_calls", render?.calls);
12467
+ put("triangles", render?.triangles);
12468
+ if (!["objects", "fragments", "particles", "decals"].some((id) => wanted.has(id))) return out;
12469
+ const destruction = sources.destruction.stats();
12470
+ const fluids = sources.fluids.stats();
12471
+ put("objects", destruction.pieces);
12472
+ put("fragments", destruction.fragments);
12473
+ put("particles", destruction.particles + fluids.particles);
12474
+ put("decals", destruction.decals + fluids.stains);
12475
+ return out;
12476
+ }
12477
+ };
12478
+ }
12479
+
12198
12480
  // src/narrate.ts
12199
12481
  var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
12200
12482
  function createNarrator(c) {
@@ -13264,11 +13546,218 @@ void main() {
13264
13546
  }
13265
13547
  var SCENARIOS = scenariosFor("en");
13266
13548
 
13549
+ // src/bench-panel.ts
13550
+ var SEGMENTED_MAX = 4;
13551
+ var mk = (tag, cls, text) => {
13552
+ const e = document.createElement(tag);
13553
+ if (cls) e.className = cls;
13554
+ if (text !== void 0) e.textContent = text;
13555
+ return e;
13556
+ };
13557
+ var shapeOf = (view) => JSON.stringify([view.title, view.controls.map((c) => ({ ...c, value: void 0 })), view.layers.map((l) => [l.id, l.name])]);
13558
+ function createBenchPanel(o) {
13559
+ const { t } = o;
13560
+ const element = mk("div", "tr-panel tr-bench");
13561
+ element.style.display = "none";
13562
+ element.addEventListener("keydown", (e) => e.stopPropagation());
13563
+ const roomLine = mk("div", "sub tr-bench-room");
13564
+ const body = mk("div");
13565
+ const metricTitle = mk("div", "sub", t("ui.metrics"));
13566
+ metricTitle.style.display = "none";
13567
+ const metricList = mk("dl", "tr-metrics");
13568
+ element.append(mk("h3", void 0, t("ui.bench")), roomLine, body, metricTitle, metricList);
13569
+ let shape = "";
13570
+ let hasRoom = false;
13571
+ let hasBench = false;
13572
+ const refreshers = [];
13573
+ const metricNodes = /* @__PURE__ */ new Map();
13574
+ let metricKey = "";
13575
+ const syncDisplay = () => {
13576
+ element.style.display = hasRoom || hasBench ? "" : "none";
13577
+ };
13578
+ const numberControl = (control) => {
13579
+ const wrap = mk("label", "tr-ctl");
13580
+ const input = mk("input");
13581
+ input.type = "range";
13582
+ input.min = String(control.min);
13583
+ input.max = String(control.max);
13584
+ input.step = control.step ? String(control.step) : "any";
13585
+ input.disabled = control.disabled;
13586
+ const reading = mk("output");
13587
+ input.oninput = () => o.onControl(control.id, Number(input.value));
13588
+ wrap.append(mk("span", void 0, control.name), input, reading);
13589
+ refreshers.push((view) => {
13590
+ const value = view.controls.find((c) => c.id === control.id)?.value;
13591
+ if (typeof value !== "number") return;
13592
+ if (Number(input.value) !== value) input.value = String(value);
13593
+ reading.textContent = `${o.num(value, control.step && control.step >= 1 ? 0 : 2)}${control.unit ? ` ${control.unit}` : ""}`;
13594
+ });
13595
+ return wrap;
13596
+ };
13597
+ const booleanControl = (control) => {
13598
+ const wrap = mk("label", "tr-ctl");
13599
+ const box2 = mk("input");
13600
+ box2.type = "checkbox";
13601
+ box2.disabled = control.disabled;
13602
+ box2.onchange = () => o.onControl(control.id, box2.checked);
13603
+ wrap.append(box2, document.createTextNode(` ${control.name}`));
13604
+ refreshers.push((view) => {
13605
+ box2.checked = view.controls.find((c) => c.id === control.id)?.value === true;
13606
+ });
13607
+ return wrap;
13608
+ };
13609
+ const choiceControl = (control) => {
13610
+ const wrap = mk("div", "tr-ctl");
13611
+ wrap.appendChild(mk("span", void 0, control.name));
13612
+ if (control.options.length <= SEGMENTED_MAX) {
13613
+ const group = mk("div", "tr-seg");
13614
+ group.setAttribute("role", "group");
13615
+ const buttons = control.options.map((option) => {
13616
+ const b = mk("button", "tr-btn", option.name);
13617
+ b.type = "button";
13618
+ b.disabled = control.disabled;
13619
+ b.onclick = () => o.onControl(control.id, option.id);
13620
+ group.appendChild(b);
13621
+ return { b, id: option.id };
13622
+ });
13623
+ wrap.appendChild(group);
13624
+ refreshers.push((view) => {
13625
+ const value = view.controls.find((c) => c.id === control.id)?.value;
13626
+ for (const { b, id } of buttons) {
13627
+ b.setAttribute("aria-pressed", String(id === value));
13628
+ b.classList.toggle("on", id === value);
13629
+ }
13630
+ });
13631
+ return wrap;
13632
+ }
13633
+ const select = mk("select");
13634
+ select.disabled = control.disabled;
13635
+ for (const option of control.options) {
13636
+ const op = mk("option", void 0, option.name);
13637
+ op.value = option.id;
13638
+ select.appendChild(op);
13639
+ }
13640
+ select.onchange = () => o.onControl(control.id, select.value);
13641
+ wrap.appendChild(select);
13642
+ refreshers.push((view) => {
13643
+ const value = view.controls.find((c) => c.id === control.id)?.value;
13644
+ if (typeof value === "string") select.value = value;
13645
+ });
13646
+ return wrap;
13647
+ };
13648
+ const actionControl = (control) => {
13649
+ const b = mk("button", "tr-btn", control.name);
13650
+ b.type = "button";
13651
+ b.disabled = control.disabled;
13652
+ b.onclick = () => o.onAction(control.id);
13653
+ return b;
13654
+ };
13655
+ const controlElement = (control) => {
13656
+ const el = control.kind === "number" ? numberControl(control) : control.kind === "boolean" ? booleanControl(control) : control.kind === "choice" ? choiceControl(control) : actionControl(control);
13657
+ if (control.hint) el.title = control.hint;
13658
+ return el;
13659
+ };
13660
+ const layerList = (layers) => {
13661
+ const wrap = mk("div", "tr-layers");
13662
+ wrap.appendChild(mk("div", "sub", t("ui.layers")));
13663
+ for (const layer of layers) {
13664
+ const label = mk("label", "tr-ctl");
13665
+ const box2 = mk("input");
13666
+ box2.type = "checkbox";
13667
+ box2.onchange = () => o.onLayer(layer.id, box2.checked);
13668
+ label.append(box2, document.createTextNode(` ${layer.name}`));
13669
+ wrap.appendChild(label);
13670
+ refreshers.push((view) => {
13671
+ box2.checked = view.layers.find((l) => l.id === layer.id)?.on === true;
13672
+ });
13673
+ }
13674
+ return wrap;
13675
+ };
13676
+ const rebuild = (view) => {
13677
+ body.textContent = "";
13678
+ refreshers.length = 0;
13679
+ body.appendChild(mk("div", "sub", view.title));
13680
+ const list = mk("div", "tr-ctls");
13681
+ for (const control of view.controls) if (!control.hidden) list.appendChild(controlElement(control));
13682
+ body.appendChild(list);
13683
+ if (view.layers.length) body.appendChild(layerList(view.layers));
13684
+ };
13685
+ const renderMetrics = (rows) => {
13686
+ metricList.textContent = "";
13687
+ metricNodes.clear();
13688
+ metricTitle.style.display = rows.length ? "" : "none";
13689
+ for (const row of rows) {
13690
+ const dd = mk("dd");
13691
+ const text = mk("span", void 0, row.text);
13692
+ dd.appendChild(text);
13693
+ let bar = null;
13694
+ if (row.ratio !== void 0) {
13695
+ bar = mk("div", "tr-bar");
13696
+ bar.appendChild(mk("i"));
13697
+ dd.appendChild(bar);
13698
+ }
13699
+ metricList.append(mk("dt", void 0, row.name), dd);
13700
+ metricNodes.set(row.id, { text, bar });
13701
+ }
13702
+ };
13703
+ const fillBar = (bar, ratio) => {
13704
+ bar.className = `tr-bar${ratio > 1 ? " bad" : ratio >= 0.8 ? " warn" : ""}`;
13705
+ const fill = bar.firstElementChild;
13706
+ fill.style.width = `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`;
13707
+ };
13708
+ return {
13709
+ element,
13710
+ show(view) {
13711
+ hasBench = view !== null;
13712
+ syncDisplay();
13713
+ if (!view) {
13714
+ shape = "";
13715
+ body.textContent = "";
13716
+ refreshers.length = 0;
13717
+ renderMetrics([]);
13718
+ metricKey = "";
13719
+ return;
13720
+ }
13721
+ const next = shapeOf(view);
13722
+ if (next !== shape) {
13723
+ shape = next;
13724
+ rebuild(view);
13725
+ }
13726
+ for (const refresh of refreshers) refresh(view);
13727
+ },
13728
+ metrics(rows) {
13729
+ hasRoom = rows.room.length > 0;
13730
+ syncDisplay();
13731
+ roomLine.textContent = rows.room.map((row) => `${row.name} ${row.text}`).join(" \xB7 ");
13732
+ const key = rows.module.map((row) => `${row.id}:${row.ratio !== void 0}`).join("|");
13733
+ if (key !== metricKey) {
13734
+ metricKey = key;
13735
+ renderMetrics(rows.module);
13736
+ }
13737
+ for (const row of rows.module) {
13738
+ const node = metricNodes.get(row.id);
13739
+ if (!node) continue;
13740
+ node.text.textContent = row.text;
13741
+ if (node.bar && row.ratio !== void 0) fillBar(node.bar, row.ratio);
13742
+ }
13743
+ },
13744
+ get visible() {
13745
+ if (element.style.display === "none") return false;
13746
+ const parent = element.parentElement;
13747
+ return !parent || getComputedStyle(parent).display !== "none";
13748
+ }
13749
+ };
13750
+ }
13751
+
13267
13752
  // src/ui.ts
13268
13753
  var CSS = `
13269
13754
  .tr-root{position:absolute;inset:0;pointer-events:none;font:12px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#e8eaee;overflow:hidden}
13270
13755
  .tr-root *{box-sizing:border-box}
13271
- .tr-root button,.tr-root select,.tr-root label{pointer-events:auto}
13756
+ .tr-root button,.tr-root select,.tr-root label,.tr-root input{pointer-events:auto}
13757
+ .tr-search{width:100%;margin:0 0 6px;padding:5px 8px;border-radius:5px;border:1px solid #3a404c;background:#0f1116;color:#e8eaee;font:inherit}
13758
+ .tr-search::placeholder{color:#7d8594}
13759
+ .tr-item small{color:#7d8594;margin-left:4px}
13760
+ .tr-empty{color:#9aa3b2;padding:4px 2px;grid-column:1/-1}
13272
13761
  .tr-cross{position:absolute;left:50%;top:50%;width:18px;height:18px;margin:-9px 0 0 -9px}
13273
13762
  .tr-cross:before,.tr-cross:after{content:"";position:absolute;background:#fff;opacity:.85;box-shadow:0 0 2px #000}
13274
13763
  .tr-cross:before{left:8px;top:0;width:2px;height:18px}.tr-cross:after{top:8px;left:0;height:2px;width:18px}
@@ -13312,6 +13801,13 @@ void main() {
13312
13801
  .tr-cat{flex:0 1 auto;max-height:40%}
13313
13802
  .tr-anim{flex:0 0 auto;max-height:18%}
13314
13803
  .tr-anim .list{display:flex;flex-wrap:wrap;gap:4px}.tr-anim .sub{color:#9aa3b2;margin-bottom:4px}
13804
+ .tr-bench{flex:0 1 auto;max-height:34%}
13805
+ .tr-bench .sub{color:#9aa3b2;margin-bottom:4px}
13806
+ .tr-ctls{display:flex;flex-direction:column;gap:5px}
13807
+ .tr-ctl{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.tr-ctl input[type=range]{flex:1;min-width:90px}.tr-ctl output{min-width:48px;text-align:right;color:#cfd3da}
13808
+ .tr-seg{display:inline-flex;flex-wrap:wrap;gap:2px}
13809
+ .tr-layers{margin-top:6px;display:flex;flex-direction:column;gap:2px}
13810
+ .tr-metrics{display:grid;grid-template-columns:auto 1fr;gap:2px 8px;margin:6px 0 0}.tr-metrics dt{color:#9aa3b2}.tr-metrics dd{margin:0}
13315
13811
  .tr-tests{flex:1 1 auto;min-height:100px}
13316
13812
  .tr-tests .list{display:flex;flex-direction:column;gap:3px;margin-top:6px}.tr-tests .list .tr-btn{text-align:left}
13317
13813
  .tr-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;align-items:center}
@@ -13319,7 +13815,8 @@ void main() {
13319
13815
  .tr-summary{margin-top:6px;color:#9aa3b2}
13320
13816
  @media (max-width:980px){.tr-left,.tr-right{display:none}.tr-root.open .tr-left,.tr-root.open .tr-right{display:flex}.tr-toggle{display:block;pointer-events:auto}.tr-root.open .tr-toggle{right:min(310px,34vw)}.tr-left{width:min(400px,46vw)}.tr-help{display:none}}
13321
13817
  `;
13322
- var mk = (tag, cls, text) => {
13818
+ var fold = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
13819
+ var mk2 = (tag, cls, text) => {
13323
13820
  const e = document.createElement(tag);
13324
13821
  if (cls) e.className = cls;
13325
13822
  if (text !== void 0) e.textContent = text;
@@ -13327,42 +13824,42 @@ void main() {
13327
13824
  };
13328
13825
  function createUi(o) {
13329
13826
  const { t } = o;
13330
- const style = mk("style");
13827
+ const style = mk2("style");
13331
13828
  style.textContent = CSS;
13332
- const root = mk("div", "tr-root");
13829
+ const root = mk2("div", "tr-root");
13333
13830
  const pos = getComputedStyle?.(o.container)?.position;
13334
13831
  if (!pos || pos === "static") o.container.style.position = "relative";
13335
13832
  root.appendChild(style);
13336
13833
  o.container.appendChild(root);
13337
- const cross4 = mk("div", "tr-cross");
13338
- cross4.appendChild(mk("i"));
13339
- const top = mk("div", "tr-top");
13340
- const toolBox = mk("div", "tr-tool");
13341
- const status = mk("div", "tr-status");
13342
- const prog = mk("div", "tr-prog");
13343
- const progLabel = mk("b");
13344
- const progBar = mk("div", "tr-bar");
13345
- const progFill = mk("i");
13834
+ const cross4 = mk2("div", "tr-cross");
13835
+ cross4.appendChild(mk2("i"));
13836
+ const top = mk2("div", "tr-top");
13837
+ const toolBox = mk2("div", "tr-tool");
13838
+ const status = mk2("div", "tr-status");
13839
+ const prog = mk2("div", "tr-prog");
13840
+ const progLabel = mk2("b");
13841
+ const progBar = mk2("div", "tr-bar");
13842
+ const progFill = mk2("i");
13346
13843
  progBar.appendChild(progFill);
13347
13844
  prog.append(progLabel, progBar);
13348
13845
  top.append(toolBox, status, prog);
13349
- const bottom = mk("div", "tr-bottom");
13350
- const held = mk("div", "tr-held");
13351
- const hotbar = mk("div", "tr-hotbar");
13846
+ const bottom = mk2("div", "tr-bottom");
13847
+ const held = mk2("div", "tr-held");
13848
+ const hotbar = mk2("div", "tr-hotbar");
13352
13849
  bottom.append(held, hotbar);
13353
- const help = mk("div", "tr-help");
13850
+ const help = mk2("div", "tr-help");
13354
13851
  help.textContent = t("ui.help");
13355
- const left = mk("div", "tr-left");
13356
- const insp = mk("div", "tr-panel tr-insp");
13357
- const logPanel = mk("div", "tr-panel tr-log");
13852
+ const left = mk2("div", "tr-left");
13853
+ const insp = mk2("div", "tr-panel tr-insp");
13854
+ const logPanel = mk2("div", "tr-panel tr-log");
13358
13855
  left.append(insp, logPanel);
13359
- const logTitle = mk("h3", void 0, t("ui.log"));
13360
- const logBar = mk("div", "tr-logbar");
13361
- const logBody = mk("div", "tr-logbody");
13856
+ const logTitle = mk2("h3", void 0, t("ui.log"));
13857
+ const logBar = mk2("div", "tr-logbar");
13858
+ const logBody = mk2("div", "tr-logbody");
13362
13859
  const filters = { action: true, event: true, check: true };
13363
13860
  const filterBtns = [];
13364
13861
  for (const ch of ["action", "event", "check"]) {
13365
- const b = mk("button", "tr-btn on", t(`ui.filter.${ch}`));
13862
+ const b = mk2("button", "tr-btn on", t(`ui.filter.${ch}`));
13366
13863
  b.type = "button";
13367
13864
  b.onclick = () => {
13368
13865
  filters[ch] = !filters[ch];
@@ -13373,8 +13870,8 @@ void main() {
13373
13870
  logBar.appendChild(b);
13374
13871
  }
13375
13872
  let autoscroll = true;
13376
- const asLabel = mk("label");
13377
- const asBox = mk("input");
13873
+ const asLabel = mk2("label");
13874
+ const asBox = mk2("input");
13378
13875
  asBox.type = "checkbox";
13379
13876
  asBox.checked = true;
13380
13877
  asBox.onchange = () => {
@@ -13383,7 +13880,7 @@ void main() {
13383
13880
  asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
13384
13881
  logBar.appendChild(asLabel);
13385
13882
  const logBtn = (label, fn) => {
13386
- const b = mk("button", "tr-btn", label);
13883
+ const b = mk2("button", "tr-btn", label);
13387
13884
  b.type = "button";
13388
13885
  b.onclick = fn;
13389
13886
  logBar.appendChild(b);
@@ -13391,7 +13888,7 @@ void main() {
13391
13888
  logBtn(t("ui.copy_json"), () => {
13392
13889
  const text = o.log.toJSON();
13393
13890
  const select = () => {
13394
- const ta = mk("textarea");
13891
+ const ta = mk2("textarea");
13395
13892
  ta.value = text;
13396
13893
  ta.setAttribute("readonly", "");
13397
13894
  ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
@@ -13425,12 +13922,12 @@ void main() {
13425
13922
  const fill = (n, e) => {
13426
13923
  n.className = lineClass(e);
13427
13924
  n.textContent = "";
13428
- const tm = mk("time", void 0, fmtTime(e.t));
13925
+ const tm = mk2("time", void 0, fmtTime(e.t));
13429
13926
  n.append(tm, document.createTextNode(e.text));
13430
13927
  };
13431
13928
  const addNode = (e) => {
13432
13929
  if (!filters[e.channel]) return;
13433
- const n = mk("div");
13930
+ const n = mk2("div");
13434
13931
  fill(n, e);
13435
13932
  nodes.set(e.seq, n);
13436
13933
  logBody.appendChild(n);
@@ -13452,27 +13949,40 @@ void main() {
13452
13949
  if (n) fill(n, e);
13453
13950
  } else addNode(e);
13454
13951
  });
13455
- const right = mk("div", "tr-right");
13456
- const cat = mk("div", "tr-panel tr-cat");
13457
- const anim = mk("div", "tr-panel tr-anim");
13458
- const tests = mk("div", "tr-panel tr-tests");
13459
- right.append(cat, anim, tests);
13460
- cat.appendChild(mk("h3", void 0, t("ui.catalog")));
13461
- const tabs = mk("div", "tr-tabs");
13462
- const items = mk("div", "tr-items");
13463
- cat.append(tabs, items);
13952
+ const right = mk2("div", "tr-right");
13953
+ const cat = mk2("div", "tr-panel tr-cat");
13954
+ const anim = mk2("div", "tr-panel tr-anim");
13955
+ const tests = mk2("div", "tr-panel tr-tests");
13956
+ const benchPanel = createBenchPanel({ t, num: (n, d) => num(n, d), onControl: o.onControl, onLayer: o.onLayer, onAction: o.onAction });
13957
+ right.append(cat, anim, benchPanel.element, tests);
13958
+ cat.appendChild(mk2("h3", void 0, t("ui.catalog")));
13959
+ const itemSearch = mk2("input", "tr-search");
13960
+ itemSearch.type = "search";
13961
+ itemSearch.placeholder = t("ui.search_items");
13962
+ const tabs = mk2("div", "tr-tabs");
13963
+ const items = mk2("div", "tr-items");
13964
+ cat.append(itemSearch, tabs, items);
13464
13965
  let activeGroup = "";
13966
+ const visibleItems = () => {
13967
+ const term = fold(itemSearch.value.trim());
13968
+ if (!term) return (o.groups().get(activeGroup) ?? []).map((item) => ({ item, group: activeGroup }));
13969
+ return [...o.groups()].flatMap(([group, list2]) => list2.filter((item) => fold(`${item.name} ${item.id} ${group}`).includes(term)).map((item) => ({ item, group })));
13970
+ };
13465
13971
  const renderItems = () => {
13466
13972
  items.textContent = "";
13467
- for (const it of o.groups().get(activeGroup) ?? []) {
13468
- const wrap = mk("div", "tr-item");
13469
- const b = mk("button", "tr-btn", it.name);
13973
+ const searching = itemSearch.value.trim() !== "";
13974
+ const found = visibleItems();
13975
+ if (!found.length) items.appendChild(mk2("div", "tr-empty", t("ui.no_results")));
13976
+ for (const { item: it, group } of found) {
13977
+ const wrap = mk2("div", "tr-item");
13978
+ const b = mk2("button", "tr-btn", it.name);
13979
+ if (searching) b.appendChild(mk2("small", void 0, group));
13470
13980
  b.type = "button";
13471
13981
  b.title = it.note ?? it.name;
13472
13982
  b.onclick = () => o.onSpawn(it.id);
13473
13983
  wrap.appendChild(b);
13474
13984
  if (it.weapon) {
13475
- const w = mk("button", "tr-btn", "\u2694");
13985
+ const w = mk2("button", "tr-btn", "\u2694");
13476
13986
  w.type = "button";
13477
13987
  w.title = t("ui.equip");
13478
13988
  w.onclick = () => o.onEquip(it.id);
@@ -13486,7 +13996,7 @@ void main() {
13486
13996
  const groupNames = [...o.groups().keys()];
13487
13997
  if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
13488
13998
  for (const g of groupNames) {
13489
- const b = mk("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
13999
+ const b = mk2("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
13490
14000
  b.type = "button";
13491
14001
  b.onclick = () => {
13492
14002
  activeGroup = g;
@@ -13497,9 +14007,11 @@ void main() {
13497
14007
  renderItems();
13498
14008
  };
13499
14009
  renderTabs();
13500
- const controls = mk("div", "tr-row");
14010
+ itemSearch.oninput = renderItems;
14011
+ for (const box2 of [itemSearch]) box2.addEventListener("keydown", (e) => e.stopPropagation());
14012
+ const controls = mk2("div", "tr-row");
13501
14013
  const ctl = (label, fn) => {
13502
- const b = mk("button", "tr-btn", label);
14014
+ const b = mk2("button", "tr-btn", label);
13503
14015
  b.type = "button";
13504
14016
  b.onclick = () => fn(b);
13505
14017
  controls.appendChild(b);
@@ -13520,26 +14032,34 @@ void main() {
13520
14032
  o.onFast(fast);
13521
14033
  });
13522
14034
  const tapBtn = ctl(t("ui.tap"), () => o.onTap());
13523
- const qSel = mk("select");
14035
+ const qSel = mk2("select");
13524
14036
  for (const q of ["low", "medium", "high"]) {
13525
- const op = mk("option", void 0, t(`ui.quality.${q}`));
14037
+ const op = mk2("option", void 0, t(`ui.quality.${q}`));
13526
14038
  op.value = q;
13527
14039
  qSel.appendChild(op);
13528
14040
  }
13529
14041
  qSel.value = o.quality;
13530
14042
  qSel.onchange = () => o.onQuality(qSel.value);
13531
- const qLabel = mk("label", void 0, t("ui.quality") + " ");
14043
+ const qLabel = mk2("label", void 0, t("ui.quality") + " ");
13532
14044
  qLabel.appendChild(qSel);
13533
14045
  controls.appendChild(qLabel);
13534
- const count = mk("span");
14046
+ const count = mk2("span");
13535
14047
  controls.appendChild(count);
13536
14048
  cat.appendChild(controls);
13537
- tests.appendChild(mk("h3", void 0, t("ui.tests")));
13538
- const runAll = mk("button", "tr-btn", t("ui.run_all"));
14049
+ tests.appendChild(mk2("h3", void 0, t("ui.tests")));
14050
+ const runAll = mk2("button", "tr-btn", t("ui.run_all"));
13539
14051
  runAll.type = "button";
13540
- const summary = mk("div", "tr-summary");
13541
- const list = mk("div", "list");
13542
- tests.append(runAll, summary, list);
14052
+ const summary = mk2("div", "tr-summary");
14053
+ const testSearch = mk2("input", "tr-search");
14054
+ testSearch.type = "search";
14055
+ testSearch.placeholder = t("ui.search_tests");
14056
+ testSearch.addEventListener("keydown", (e) => e.stopPropagation());
14057
+ const list = mk2("div", "list");
14058
+ tests.append(runAll, summary, testSearch, list);
14059
+ testSearch.oninput = () => {
14060
+ const term = fold(testSearch.value.trim());
14061
+ for (const b of scBtns.values()) b.style.display = !term || fold(b.textContent ?? "").includes(term) ? "" : "none";
14062
+ };
13543
14063
  const scBtns = /* @__PURE__ */ new Map();
13544
14064
  let busy = false;
13545
14065
  const mark = (rep) => {
@@ -13557,7 +14077,7 @@ void main() {
13557
14077
  list.textContent = "";
13558
14078
  scBtns.clear();
13559
14079
  for (const s of o.scenarios()) {
13560
- const b = mk("button", "tr-btn", s.name);
14080
+ const b = mk2("button", "tr-btn", s.name);
13561
14081
  b.type = "button";
13562
14082
  b.onclick = async () => {
13563
14083
  if (busy) return;
@@ -13583,7 +14103,7 @@ void main() {
13583
14103
  busy = false;
13584
14104
  }
13585
14105
  };
13586
- const toggle = mk("button", "tr-btn tr-toggle", t("ui.panel"));
14106
+ const toggle = mk2("button", "tr-btn tr-toggle", t("ui.panel"));
13587
14107
  toggle.type = "button";
13588
14108
  const togglePanels = () => {
13589
14109
  root.classList.toggle("open");
@@ -13592,15 +14112,15 @@ void main() {
13592
14112
  toggle.onclick = togglePanels;
13593
14113
  root.append(cross4, top, bottom, help, left, right, toggle);
13594
14114
  const bar = (value, max, cls) => {
13595
- const b = mk("div", `tr-bar ${cls}`);
13596
- const i = mk("i");
14115
+ const b = mk2("div", `tr-bar ${cls}`);
14116
+ const i = mk2("i");
13597
14117
  i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
13598
14118
  b.appendChild(i);
13599
14119
  return b;
13600
14120
  };
13601
14121
  const row = (k, v) => {
13602
- const r = mk("div", "r");
13603
- r.append(mk("span", void 0, k), mk("span", void 0, v));
14122
+ const r = mk2("div", "r");
14123
+ r.append(mk2("span", void 0, k), mk2("span", void 0, v));
13604
14124
  return r;
13605
14125
  };
13606
14126
  const num = (n, d = 1) => {
@@ -13609,12 +14129,12 @@ void main() {
13609
14129
  };
13610
14130
  const renderInspector = (i) => {
13611
14131
  insp.textContent = "";
13612
- insp.appendChild(mk("h3", void 0, t("ui.inspector")));
14132
+ insp.appendChild(mk2("h3", void 0, t("ui.inspector")));
13613
14133
  if (!i) {
13614
- insp.appendChild(mk("div", void 0, t("ui.aim_at_something")));
14134
+ insp.appendChild(mk2("div", void 0, t("ui.aim_at_something")));
13615
14135
  return;
13616
14136
  }
13617
- insp.appendChild(mk("div", "t", `${i.object} \u203A ${i.part}`));
14137
+ insp.appendChild(mk2("div", "t", `${i.object} \u203A ${i.part}`));
13618
14138
  insp.appendChild(row(t("ins.material"), i.material));
13619
14139
  insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
13620
14140
  insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
@@ -13633,30 +14153,30 @@ void main() {
13633
14153
  insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
13634
14154
  }
13635
14155
  if (i.joints.length) {
13636
- insp.appendChild(mk("div", void 0, t("ins.joints") + ":"));
14156
+ insp.appendChild(mk2("div", void 0, t("ins.joints") + ":"));
13637
14157
  for (const j of i.joints) {
13638
14158
  const tools = j.tools.length ? j.tools.join(", ") : t("tool.none");
13639
- insp.appendChild(mk("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
14159
+ insp.appendChild(mk2("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
13640
14160
  }
13641
14161
  }
13642
- if (i.note) insp.appendChild(mk("div", void 0, i.note));
14162
+ if (i.note) insp.appendChild(mk2("div", void 0, i.note));
13643
14163
  };
13644
14164
  let lastAnim = "";
13645
14165
  const renderAnimations = (a) => {
13646
14166
  anim.textContent = "";
13647
- anim.appendChild(mk("h3", void 0, t("ui.animations")));
14167
+ anim.appendChild(mk2("h3", void 0, t("ui.animations")));
13648
14168
  if (!a) {
13649
- anim.appendChild(mk("div", "sub", t("ui.animations_aim")));
14169
+ anim.appendChild(mk2("div", "sub", t("ui.animations_aim")));
13650
14170
  return;
13651
14171
  }
13652
- anim.appendChild(mk("div", "sub", a.name));
14172
+ anim.appendChild(mk2("div", "sub", a.name));
13653
14173
  if (a.clips.length === 0) {
13654
- anim.appendChild(mk("div", "sub", t("ui.animations_none")));
14174
+ anim.appendChild(mk2("div", "sub", t("ui.animations_none")));
13655
14175
  return;
13656
14176
  }
13657
- const l = mk("div", "list");
14177
+ const l = mk2("div", "list");
13658
14178
  for (const c of a.clips) {
13659
- const b = mk("button", a.playing === c.id ? "tr-btn on" : "tr-btn", `\u25B6 ${t(`anim.${c.id}`) === `anim.${c.id}` ? c.id : t(`anim.${c.id}`)} (${num(c.duration, 1)} s)`);
14179
+ const b = mk2("button", a.playing === c.id ? "tr-btn on" : "tr-btn", `\u25B6 ${t(`anim.${c.id}`) === `anim.${c.id}` ? c.id : t(`anim.${c.id}`)} (${num(c.duration, 1)} s)`);
13660
14180
  b.type = "button";
13661
14181
  b.onclick = () => o.onPlay(a.objectId, c.id);
13662
14182
  l.appendChild(b);
@@ -13673,7 +14193,7 @@ void main() {
13673
14193
  hud(s) {
13674
14194
  toolBox.textContent = "";
13675
14195
  toolBox.append(document.createTextNode(s.toolName));
13676
- if (s.ammo) toolBox.append(mk("small", void 0, s.ammo));
14196
+ if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
13677
14197
  if (s.status) status.textContent = s.status;
13678
14198
  else if (!toastTimer) status.textContent = "";
13679
14199
  if (s.progress) {
@@ -13694,7 +14214,7 @@ void main() {
13694
14214
  ];
13695
14215
  if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
13696
14216
  for (const [k, label] of keys) {
13697
- held.append(mk("kbd", void 0, k), document.createTextNode(` ${label} `));
14217
+ held.append(mk2("kbd", void 0, k), document.createTextNode(` ${label} `));
13698
14218
  }
13699
14219
  } else held.style.display = "none";
13700
14220
  const key = s.slots.join("|") + s.page;
@@ -13704,9 +14224,9 @@ void main() {
13704
14224
  slotsEl.length = 0;
13705
14225
  hudPage = s.page - 1;
13706
14226
  s.slots.forEach((name, i) => {
13707
- const b = mk("button", "tr-slot");
14227
+ const b = mk2("button", "tr-slot");
13708
14228
  b.type = "button";
13709
- b.append(mk("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
14229
+ b.append(mk2("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
13710
14230
  b.title = name;
13711
14231
  b.onclick = () => o.onTool(hudPage * 10 + i);
13712
14232
  hotbar.appendChild(b);
@@ -13738,7 +14258,17 @@ void main() {
13738
14258
  renderTabs();
13739
14259
  renderScenarios();
13740
14260
  },
14261
+ showGroup(group) {
14262
+ activeGroup = group;
14263
+ itemSearch.value = "";
14264
+ renderTabs();
14265
+ },
13741
14266
  togglePanels,
14267
+ bench: benchPanel.show,
14268
+ metrics: benchPanel.metrics,
14269
+ get benchVisible() {
14270
+ return benchPanel.visible;
14271
+ },
13742
14272
  dispose() {
13743
14273
  offLog();
13744
14274
  if (toastTimer) clearTimeout(toastTimer);
@@ -13751,6 +14281,7 @@ void main() {
13751
14281
 
13752
14282
  // src/room.ts
13753
14283
  var FIXED_DT = 1 / 60;
14284
+ var METRICS_INTERVAL_S = 0.25;
13754
14285
  var v3 = (v) => [v.x, v.y, v.z];
13755
14286
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
13756
14287
  function createTestRoom(options) {
@@ -13808,7 +14339,13 @@ void main() {
13808
14339
  const p = world.part(arena.table.id, arena.table.topPart);
13809
14340
  return !!p && p.status !== "broken" && p.status !== "melted" && p.status !== "ashes";
13810
14341
  };
13811
- const groundAt = (x, z) => x > arena.table.minX && x < arena.table.maxX && z > arena.table.minZ && z < arena.table.maxZ && tableOk() ? arena.table.top : 0;
14342
+ const restingTops = [];
14343
+ const restingTopAt = (x, z) => {
14344
+ let top = 0;
14345
+ for (const c of restingTops) if (x > c.minX && x < c.maxX && z > c.minZ && z < c.maxZ && world.has(c.objectId)) top = Math.max(top, c.top);
14346
+ return top;
14347
+ };
14348
+ const groundAt = (x, z) => Math.max(x > arena.table.minX && x < arena.table.maxX && z > arena.table.minZ && z < arena.table.maxZ && tableOk() ? arena.table.top : 0, restingTopAt(x, z));
13812
14349
  const ownFluids = !options.fluids;
13813
14350
  const fluids = options.fluids ?? createFluids({ seed: 1, locale, liquids: options.liquids, groundAt });
13814
14351
  const pristineFluids = ownFluids ? fluids.serialize() : null;
@@ -13941,16 +14478,16 @@ void main() {
13941
14478
  const puncture = (ev) => {
13942
14479
  const e = entryOf(ev.object);
13943
14480
  if (!e?.containerId || e.fluidPart !== ev.part) return;
13944
- const host = hostOf(e);
13945
- host.root.updateMatrixWorld(true);
13946
- tmpM.copy(host.root.matrixWorld).invert();
14481
+ const host2 = hostOf(e);
14482
+ host2.root.updateMatrixWorld(true);
14483
+ tmpM.copy(host2.root.matrixWorld).invert();
13947
14484
  const local = tmpV2.set(ev.point[0], ev.point[1], ev.point[2]).applyMatrix4(tmpM);
13948
14485
  local.x -= e.fluidOffset[0];
13949
14486
  local.y -= e.fluidOffset[1];
13950
14487
  local.z -= e.fluidOffset[2];
13951
- host.root.getWorldQuaternion(tmpQ).invert();
14488
+ host2.root.getWorldQuaternion(tmpQ).invert();
13952
14489
  const n = tmpV22.set(ev.direction[0], ev.direction[1], ev.direction[2]).applyQuaternion(tmpQ);
13953
- const part = world.part(host.objectId, ev.part);
14490
+ const part = world.part(host2.objectId, ev.part);
13954
14491
  const hole = part?.holes[part.holes.length - 1];
13955
14492
  fluids.puncture(e.containerId, [local.x, local.y, local.z], [n.x, n.y, n.z], hole?.diameter ?? 9e-3);
13956
14493
  };
@@ -14156,6 +14693,7 @@ void main() {
14156
14693
  };
14157
14694
  const clear = () => {
14158
14695
  clearEverything();
14696
+ restingTops.length = 0;
14159
14697
  pending = [];
14160
14698
  action("clear", t("act.clear"));
14161
14699
  };
@@ -14375,6 +14913,7 @@ void main() {
14375
14913
  }
14376
14914
  });
14377
14915
  player.kick(recoil[0], recoil[1]);
14916
+ emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil, rounds: shots.filter((s) => s.pellet === 0).length, mode: tool.mode });
14378
14917
  const f = first;
14379
14918
  const d = f ? num(f.distance, 1) : "";
14380
14919
  let text;
@@ -14556,6 +15095,7 @@ void main() {
14556
15095
  const detonate = (p, at, impact) => {
14557
15096
  const w = toolName(p.weapon);
14558
15097
  const results = world.explode(at, p.energy, p.radius, "player");
15098
+ renderer.explode(at, p.energy, p.radius);
14559
15099
  action("explode", t("act.explode", { weapon: w, energy: num(p.energy, 0), radius: num(p.radius, 1), n: results.length }), { data: { weapon: p.weapon, energyJ: p.energy, radius: p.radius, point: at } });
14560
15100
  if (p.fire) {
14561
15101
  fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
@@ -14614,7 +15154,8 @@ void main() {
14614
15154
  if (cat === "hand") return;
14615
15155
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
14616
15156
  if (res.status === "empty") {
14617
- if (!tool.reloading && tool.reserve > 0) tool.reload();
15157
+ emitHost({ type: "empty", weapon: toolId });
15158
+ if (!tool.reloading && tool.reserve > 0) startReload();
14618
15159
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
14619
15160
  return;
14620
15161
  }
@@ -14666,6 +15207,7 @@ void main() {
14666
15207
  progress = null;
14667
15208
  action("tool", t("act.tool", { weapon: toolName(id) }), { data: { weapon: id } });
14668
15209
  fire("tool", id);
15210
+ emitHost({ type: "tool", weapon: id });
14669
15211
  };
14670
15212
  const cycleTool = (dir) => {
14671
15213
  if (!loadout.length) return;
@@ -14893,13 +15435,16 @@ void main() {
14893
15435
  case "KeyR":
14894
15436
  if (heldPiece) heldYaw += Math.PI / 8;
14895
15437
  else {
14896
- tool.reload();
15438
+ startReload();
14897
15439
  say(t("hud.reloading"));
14898
15440
  }
14899
15441
  break;
14900
- case "KeyQ":
14901
- tool.cycleMode();
15442
+ case "KeyQ": {
15443
+ const before = tool.mode;
15444
+ const mode = tool.cycleMode();
15445
+ if (mode !== before) emitHost({ type: "mode", weapon: toolId, mode });
14902
15446
  break;
15447
+ }
14903
15448
  case "KeyZ":
14904
15449
  tiltKey = 1;
14905
15450
  break;
@@ -14987,14 +15532,18 @@ void main() {
14987
15532
  let timeScale = 1;
14988
15533
  let fastForward = false;
14989
15534
  let hudAcc = 1;
15535
+ let metricsAcc = 0;
15536
+ let ownsLoop = false;
15537
+ const roomMetricIds = options.metrics === false ? [] : options.metrics ?? ROOM_METRICS;
15538
+ const roomMetrics = createRoomMetrics({ gl, destruction: renderer, fluids: fluidView, ids: roomMetricIds });
14990
15539
  let qualityName = quality;
14991
15540
  const syncContainers = (sdt) => {
14992
15541
  for (const e of entries.values()) {
14993
15542
  if (!e.containerId) continue;
14994
- const host = hostOf(e);
14995
- host.root.updateMatrixWorld(true);
14996
- const q = host.root.getWorldQuaternion(tmpQ);
14997
- const p = tmpV2.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(host.root.matrixWorld);
15543
+ const host2 = hostOf(e);
15544
+ host2.root.updateMatrixWorld(true);
15545
+ const q = host2.root.getWorldQuaternion(tmpQ);
15546
+ const p = tmpV2.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(host2.root.matrixWorld);
14998
15547
  const vel = sdt > 0 ? [(p.x - e.last[0]) / sdt, (p.y - e.last[1]) / sdt, (p.z - e.last[2]) / sdt] : void 0;
14999
15548
  e.last = [p.x, p.y, p.z];
15000
15549
  fluids.setTransform(e.containerId, [p.x, p.y, p.z], [q.x, q.y, q.z, q.w], vel);
@@ -15005,9 +15554,9 @@ void main() {
15005
15554
  if (!e.containerId || !e.fluidPart) continue;
15006
15555
  const c = fluids.container(e.containerId);
15007
15556
  if (!c) continue;
15008
- const host = hostOf(e);
15009
- const part = world.part(host.objectId, e.fluidPart);
15010
- if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host.objectId, e.fluidPart, c.liters);
15557
+ const host2 = hostOf(e);
15558
+ const part = world.part(host2.objectId, e.fluidPart);
15559
+ if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host2.objectId, e.fluidPart, c.liters);
15011
15560
  }
15012
15561
  };
15013
15562
  let paused = false;
@@ -15038,6 +15587,9 @@ void main() {
15038
15587
  updateImpacts();
15039
15588
  fluidView.update(dt);
15040
15589
  for (const s of tools.values()) s.update(dt);
15590
+ if (reloadingSeen && !tool.reloading) emitHost({ type: "reload", weapon: toolId, phase: "end" });
15591
+ reloadingSeen = tool.reloading;
15592
+ for (const update2 of frameListeners) update2(dt);
15041
15593
  for (const tr of tracers) {
15042
15594
  if (tr.life > 0) {
15043
15595
  tr.life -= dt;
@@ -15047,6 +15599,7 @@ void main() {
15047
15599
  flush();
15048
15600
  };
15049
15601
  const update = (dt) => {
15602
+ if (!ownsLoop) roomMetrics.frame(dt);
15050
15603
  player.update(dt);
15051
15604
  if (tiltKey || heldTilt > 0) {
15052
15605
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
@@ -15065,6 +15618,11 @@ void main() {
15065
15618
  hudAcc = 0;
15066
15619
  ui.hud(hudState());
15067
15620
  }
15621
+ metricsAcc += dt;
15622
+ if (metricsAcc >= METRICS_INTERVAL_S && ui) {
15623
+ metricsAcc = 0;
15624
+ if (ui.benchVisible) ui.metrics(metricRows());
15625
+ }
15068
15626
  };
15069
15627
  const hudState = () => {
15070
15628
  const page = Math.floor(toolIndex / 10);
@@ -15179,6 +15737,103 @@ void main() {
15179
15737
  const moduleCheck = (ok, text, data, itemId) => {
15180
15738
  log.add({ channel: "check", type: ok ? "pass" : "fail", text: `${ok ? "\u2713" : "\u2717"} ${text}`, itemId, data: { ok, ...data } });
15181
15739
  };
15740
+ const modules = /* @__PURE__ */ new Map();
15741
+ const detachers = /* @__PURE__ */ new Map();
15742
+ const benches = /* @__PURE__ */ new Map();
15743
+ let activeBench = null;
15744
+ const lastBench = () => [...benches.keys()].pop() ?? null;
15745
+ const benchView = () => {
15746
+ const store = activeBench ? benches.get(activeBench) : void 0;
15747
+ if (!store) return null;
15748
+ return { title: modules.get(store.moduleId)?.name ?? store.moduleId, controls: store.controls(), layers: store.layers() };
15749
+ };
15750
+ const METRIC_UNAVAILABLE = "n/a";
15751
+ const metricText = (value, digits, unit) => typeof value === "number" ? `${num(value, digits)}${unit ? ` ${unit}` : ""}` : value ?? METRIC_UNAVAILABLE;
15752
+ const reportedFailures = /* @__PURE__ */ new Set();
15753
+ const reportOnce = (key, text) => {
15754
+ if (reportedFailures.has(key)) return;
15755
+ reportedFailures.add(key);
15756
+ host.log(text);
15757
+ };
15758
+ const failureText = (error) => error instanceof Error ? error.message : String(error);
15759
+ const moduleMetrics = () => {
15760
+ const store = activeBench ? benches.get(activeBench) : void 0;
15761
+ const out = {};
15762
+ for (const metric of store?.metrics ?? []) {
15763
+ try {
15764
+ out[metric.id] = metric.read();
15765
+ } catch (error) {
15766
+ out[metric.id] = METRIC_UNAVAILABLE;
15767
+ reportOnce(`${store?.moduleId}:metric:${metric.id}`, t("mod.metric_failed", { name: modules.get(store?.moduleId ?? "")?.name ?? "", metric: metric.id, error: failureText(error) }));
15768
+ }
15769
+ }
15770
+ return out;
15771
+ };
15772
+ const metricRows = () => {
15773
+ const sampled = roomMetrics.sample();
15774
+ const room2 = roomMetricIds.flatMap((id) => {
15775
+ const value = sampled[id];
15776
+ return value === void 0 ? [] : [{ id, name: t(`metric.${id}`), text: num(value, id === "frame_ms" ? 1 : 0) }];
15777
+ });
15778
+ const values = moduleMetrics();
15779
+ const declared = (activeBench ? benches.get(activeBench)?.metrics : void 0) ?? [];
15780
+ const module = declared.map((metric) => {
15781
+ const value = values[metric.id];
15782
+ const ratio = metric.max && typeof value === "number" ? value / metric.max : void 0;
15783
+ return { id: metric.id, name: metric.name, text: metricText(value, metric.digits ?? 0, metric.unit), ...ratio === void 0 ? {} : { ratio } };
15784
+ });
15785
+ return { room: room2, module };
15786
+ };
15787
+ const showBench = () => {
15788
+ if (!ui) return;
15789
+ ui.bench(benchView());
15790
+ ui.metrics(metricRows());
15791
+ };
15792
+ const hostListeners = /* @__PURE__ */ new Set();
15793
+ const frameListeners = /* @__PURE__ */ new Set();
15794
+ let reloadingSeen = false;
15795
+ function emitHost(event) {
15796
+ for (const listener of hostListeners) listener(event);
15797
+ }
15798
+ function startReload() {
15799
+ if (tool.reload()) emitHost({ type: "reload", weapon: toolId, phase: "start" });
15800
+ }
15801
+ const host = {
15802
+ THREE,
15803
+ scene,
15804
+ camera,
15805
+ locale,
15806
+ groundAt,
15807
+ tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading, mode: tool.mode }),
15808
+ on(listener) {
15809
+ hostListeners.add(listener);
15810
+ return () => hostListeners.delete(listener);
15811
+ },
15812
+ onFrame(update2) {
15813
+ frameListeners.add(update2);
15814
+ return () => frameListeners.delete(update2);
15815
+ },
15816
+ log(text, data) {
15817
+ action("module_note", text, { data });
15818
+ }
15819
+ };
15820
+ const lendBench = (store) => ({
15821
+ value: store.value,
15822
+ layer: store.layer,
15823
+ onChange: store.onChange,
15824
+ patch: store.patch,
15825
+ look: (eye, target) => room.look(eye, target)
15826
+ });
15827
+ const checkModuleBench = (mod, fail) => {
15828
+ const store = createBenchStore(mod, locale, (error) => reportOnce(`${mod.id}:listener`, t("mod.listener_failed", { name: mod.name, error: failureText(error) })));
15829
+ const { controls, layers, metrics } = store.declared;
15830
+ if (controls.length + layers.length + metrics.length === 0) return null;
15831
+ const r = checkBench(store.declared);
15832
+ const text = r.ok ? t("mod.bench_ok", { name: mod.name, controls: controls.length, layers: layers.length, metrics: metrics.length }) : t("mod.bench_fail", { name: mod.name, problems: r.problems.join("; ") });
15833
+ moduleCheck(r.ok, text, { module: mod.id, check: "bench", problems: r.problems });
15834
+ for (const p of r.problems) fail(`bench: ${p}`);
15835
+ return r.ok ? store : null;
15836
+ };
15182
15837
  const loadModule = (mod) => {
15183
15838
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
15184
15839
  const fail = (problem) => {
@@ -15243,6 +15898,29 @@ void main() {
15243
15898
  report.scenarios.push(sc.id);
15244
15899
  }
15245
15900
  action("module", t("act.module_loaded", { name: mod.name, items: report.items.length, scenarios: report.scenarios.length, result: t(report.ok ? "mod.all_ok" : "mod.some_failed") }), { data: { module: mod.id, items: report.items, scenarios: report.scenarios, ok: report.ok } });
15901
+ const bench = checkModuleBench(mod, fail);
15902
+ detachers.get(mod.id)?.();
15903
+ detachers.delete(mod.id);
15904
+ const previous = benches.get(mod.id);
15905
+ const kept = previous?.encode();
15906
+ previous?.dispose();
15907
+ benches.delete(mod.id);
15908
+ for (const key of [...reportedFailures]) if (key.startsWith(`${mod.id}:`)) reportedFailures.delete(key);
15909
+ modules.set(mod.id, mod);
15910
+ if (bench) {
15911
+ const seed = options.bench?.state?.[mod.id];
15912
+ const restored = kept ?? (typeof seed === "string" ? seed : void 0);
15913
+ if (restored !== void 0) bench.restore(restored);
15914
+ benches.set(mod.id, bench);
15915
+ bench.watch(() => {
15916
+ options.bench?.onChange?.(mod.id, bench.encode());
15917
+ if (activeBench === mod.id) ui?.bench(benchView());
15918
+ });
15919
+ if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
15920
+ } else if (activeBench === mod.id) activeBench = lastBench();
15921
+ if (mod.attach) detachers.set(mod.id, mod.attach(bench ? { ...host, bench: lendBench(bench) } : host));
15922
+ bench?.sendInitial();
15923
+ showBench();
15246
15924
  ui?.refresh();
15247
15925
  return report;
15248
15926
  };
@@ -15298,6 +15976,39 @@ void main() {
15298
15976
  }
15299
15977
  return report;
15300
15978
  };
15979
+ const layArea = (layout) => {
15980
+ const holders = layout.placements.filter((p) => p.surface);
15981
+ const rest = layout.placements.filter((p) => !p.surface);
15982
+ const placed = [...holders, ...rest].map((p) => {
15983
+ const so = spawn(p.item, p.position, p.rotationY ?? 0);
15984
+ if (p.surface) {
15985
+ const [hx, hz] = p.surface.halfSize;
15986
+ restingTops.push({ objectId: so.objectId, minX: p.position[0] - hx, maxX: p.position[0] + hx, minZ: p.position[2] - hz, maxZ: p.position[2] + hz, top: p.surface.height });
15987
+ }
15988
+ return { so, asleep: p.asleep === true };
15989
+ });
15990
+ for (const { so, asleep } of placed) {
15991
+ const piece = asleep ? rendererPieces.get(so.objectId) : void 0;
15992
+ if (piece) piece.sleeping = true;
15993
+ }
15994
+ if (layout.view) room.look(layout.view.eye, layout.view.target);
15995
+ return placed.map(({ so }) => so);
15996
+ };
15997
+ const openArea = (area) => {
15998
+ clear();
15999
+ const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
16000
+ const label = mod?.group ?? S.groups[area] ?? area;
16001
+ ui?.showGroup(label);
16002
+ activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
16003
+ showBench();
16004
+ const layout = mod?.area?.(locale);
16005
+ if (layout) return layArea(layout);
16006
+ const items = catalog.filter((item) => item.group === label);
16007
+ const front = items.filter((item) => item.movable !== false);
16008
+ const back = items.filter((item) => item.movable === false);
16009
+ const row = (list, gap, z) => list.map((item, index) => spawn(item.id, [(index - (list.length - 1) / 2) * gap, 0, z], 0));
16010
+ return [...row(front, 1.6, 1.5), ...row(back, 4.5, -4)];
16011
+ };
15301
16012
  const room = {
15302
16013
  world,
15303
16014
  renderer,
@@ -15377,10 +16088,22 @@ void main() {
15377
16088
  fastForward = on;
15378
16089
  },
15379
16090
  equip,
16091
+ openArea,
15380
16092
  loadModule,
15381
16093
  animations: animationsOf,
15382
16094
  playAnimation,
16095
+ bench(moduleId) {
16096
+ const id = moduleId ?? activeBench;
16097
+ return (id ? benches.get(id) : void 0) ?? null;
16098
+ },
16099
+ metrics() {
16100
+ return { room: roomMetrics.sample(), module: moduleMetrics() };
16101
+ },
15383
16102
  dispose() {
16103
+ for (const detach of detachers.values()) detach();
16104
+ detachers.clear();
16105
+ for (const store of benches.values()) store.dispose();
16106
+ benches.clear();
15384
16107
  cancelAnimationFrame?.(raf);
15385
16108
  window.removeEventListener("resize", onResize);
15386
16109
  player.dispose();
@@ -15451,25 +16174,40 @@ void main() {
15451
16174
  onPlay: (objectId, clipId) => {
15452
16175
  playAnimation(clipId, objectId);
15453
16176
  },
15454
- onResize: resize
16177
+ onResize: resize,
16178
+ onControl: (id, value) => {
16179
+ if (activeBench) benches.get(activeBench)?.set(id, value);
16180
+ },
16181
+ onLayer: (id, on) => {
16182
+ if (activeBench) benches.get(activeBench)?.setLayer(id, on);
16183
+ },
16184
+ onAction: (id) => {
16185
+ if (activeBench) benches.get(activeBench)?.act(id);
16186
+ }
15455
16187
  });
15456
16188
  ui.hud(hudState());
16189
+ showBench();
15457
16190
  }
15458
16191
  let raf = 0;
15459
16192
  if (ownRenderer && typeof requestAnimationFrame === "function") {
15460
16193
  let last = performance.now();
16194
+ ownsLoop = true;
15461
16195
  const loop = (now) => {
15462
16196
  raf = requestAnimationFrame(loop);
15463
- const dt = Math.min(0.05, (now - last) / 1e3);
16197
+ const realDelta = (now - last) / 1e3;
16198
+ roomMetrics.frame(realDelta);
16199
+ const dt = Math.min(0.05, realDelta);
15464
16200
  last = now;
15465
16201
  if (!scenarioRunning) update(dt);
15466
16202
  gl.render(scene, camera);
16203
+ roomMetrics.rendered();
15467
16204
  };
15468
16205
  raf = requestAnimationFrame(loop);
15469
16206
  }
15470
16207
  if (options.initial) {
15471
16208
  for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);
15472
16209
  }
16210
+ if (options.area) openArea(options.area);
15473
16211
  return room;
15474
16212
  }
15475
16213
  function structuredCloneSpec(spec) {