@adatechnology/game-test-room 0.2.0 → 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.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { createDestructionRenderer, specFromObject3D } from "@adatechnology/game
4
4
  import { createArsenal, DEFAULT_LOADOUT } from "@adatechnology/game-weapons";
5
5
  import { createFluids, createItemUse, ITEM_USES, resolveStrings as resolveFluidStrings } from "@adatechnology/game-fluids";
6
6
  import { createFluidRenderer } from "@adatechnology/game-fluids-three";
7
- import { checkAnimation, checkDestructible, checkSpec } from "@adatechnology/game-contracts";
7
+ import { checkAnimation, checkBench, checkDestructible, checkSpec } from "@adatechnology/game-contracts";
8
8
 
9
9
  // src/items.ts
10
10
  import { buildFrame } from "@adatechnology/game-destruction";
@@ -219,6 +219,21 @@ var en = {
219
219
  "ui.animations": "Animations",
220
220
  "ui.animations_aim": "Aim at an item or hold it to see its animations.",
221
221
  "ui.animations_none": "This item brings no animations.",
222
+ "mod.bench_ok": "{name}: bench ok ({controls} controls, {layers} layers, {metrics} metrics)",
223
+ "mod.bench_fail": "{name}: bench broken: {problems}",
224
+ "mod.metric_failed": '{name}: metric "{metric}" threw and shows n/a: {error}',
225
+ "mod.listener_failed": "{name}: a bench listener threw: {error}",
226
+ "ui.bench": "Bench",
227
+ "ui.layers": "Layers",
228
+ "ui.metrics": "Counters",
229
+ "metric.fps": "FPS",
230
+ "metric.frame_ms": "ms/frame",
231
+ "metric.draw_calls": "Draw calls",
232
+ "metric.triangles": "Triangles",
233
+ "metric.objects": "Objects",
234
+ "metric.fragments": "Fragments",
235
+ "metric.particles": "Particles",
236
+ "metric.decals": "Decals",
222
237
  "act.spawn": "Spawned {name} at x {x} \xB7 z {z}",
223
238
  "act.clear": "Cleared the room",
224
239
  "act.reset": "Repaired every object",
@@ -511,6 +526,21 @@ var ptBR = {
511
526
  "ui.animations": "Anima\xE7\xF5es",
512
527
  "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
513
528
  "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
529
+ "mod.bench_ok": "{name}: bancada ok ({controls} controles, {layers} camadas, {metrics} contadores)",
530
+ "mod.bench_fail": "{name}: bancada quebrada: {problems}",
531
+ "mod.metric_failed": '{name}: o contador "{metric}" lan\xE7ou erro e mostra n/a: {error}',
532
+ "mod.listener_failed": "{name}: um ouvinte da bancada lan\xE7ou erro: {error}",
533
+ "ui.bench": "Bancada",
534
+ "ui.layers": "Camadas",
535
+ "ui.metrics": "Contadores",
536
+ "metric.fps": "FPS",
537
+ "metric.frame_ms": "ms/quadro",
538
+ "metric.draw_calls": "Chamadas de desenho",
539
+ "metric.triangles": "Tri\xE2ngulos",
540
+ "metric.objects": "Objetos",
541
+ "metric.fragments": "Fragmentos",
542
+ "metric.particles": "Part\xEDculas",
543
+ "metric.decals": "Decalques",
514
544
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
515
545
  "act.clear": "Limpou a sala",
516
546
  "act.reset": "Consertou todos os objetos",
@@ -1122,6 +1152,154 @@ function createArena(THREE, size, scene) {
1122
1152
  };
1123
1153
  }
1124
1154
 
1155
+ // src/bench.ts
1156
+ var snap = (control, value) => {
1157
+ const clamped = Math.min(control.max, Math.max(control.min, value));
1158
+ if (!control.step) return clamped;
1159
+ const stepped = control.min + Math.round((clamped - control.min) / control.step) * control.step;
1160
+ return Number(Math.min(control.max, stepped).toFixed(10));
1161
+ };
1162
+ function fitValue(control, value) {
1163
+ if (control.kind === "number") return typeof value === "number" && Number.isFinite(value) ? snap(control, value) : void 0;
1164
+ if (control.kind === "boolean") return typeof value === "boolean" ? value : void 0;
1165
+ if (control.kind === "choice") return typeof value === "string" && control.options.some((option) => option.id === value) ? value : void 0;
1166
+ return void 0;
1167
+ }
1168
+ var fromText = (control, text) => {
1169
+ if (control.kind === "number") return text.trim() === "" ? Number.NaN : Number(text);
1170
+ if (control.kind === "boolean") return text === "1" ? true : text === "0" ? false : void 0;
1171
+ return text;
1172
+ };
1173
+ var toText = (value) => typeof value === "boolean" ? value ? "1" : "0" : String(value);
1174
+ function encodeBenchState(controls, layers) {
1175
+ const pairs = [];
1176
+ for (const control of controls) if (control.value !== void 0) pairs.push(`${control.id}=${encodeURIComponent(toText(control.value))}`);
1177
+ for (const layer of layers) pairs.push(`${layer.id}=${layer.on ? "1" : "0"}`);
1178
+ return pairs.join(";");
1179
+ }
1180
+ function decodeBenchState(encoded, controls, layers) {
1181
+ const out = /* @__PURE__ */ new Map();
1182
+ for (const pair of encoded.split(";")) {
1183
+ const at = pair.indexOf("=");
1184
+ if (at <= 0) continue;
1185
+ const id = pair.slice(0, at);
1186
+ let text;
1187
+ try {
1188
+ text = decodeURIComponent(pair.slice(at + 1));
1189
+ } catch {
1190
+ continue;
1191
+ }
1192
+ const control = controls.find((c) => c.id === id);
1193
+ if (control) {
1194
+ const value = fitValue(control, fromText(control, text));
1195
+ if (value !== void 0) out.set(id, value);
1196
+ continue;
1197
+ }
1198
+ if (layers.some((layer) => layer.id === id) && (text === "1" || text === "0")) out.set(id, text === "1");
1199
+ }
1200
+ return out;
1201
+ }
1202
+ function createBenchStore(module, locale, onListenerError) {
1203
+ const declared = { controls: module.controls?.(locale) ?? [], layers: module.layers?.(locale) ?? [], metrics: module.metrics?.(locale) ?? [] };
1204
+ const controls = declared.controls.map((control) => ({ ...control, disabled: false }));
1205
+ const values = /* @__PURE__ */ new Map();
1206
+ for (const control of controls) if (control.kind !== "action") values.set(control.id, control.default);
1207
+ const layers = new Map(declared.layers.map((layer) => [layer.id, layer.default !== false]));
1208
+ const listeners = /* @__PURE__ */ new Set();
1209
+ const watchers = /* @__PURE__ */ new Set();
1210
+ const emit = (change) => {
1211
+ for (const listener of listeners) {
1212
+ try {
1213
+ listener(change);
1214
+ } catch (error) {
1215
+ onListenerError?.(error);
1216
+ }
1217
+ }
1218
+ };
1219
+ const changed = () => {
1220
+ for (const watcher of watchers) watcher();
1221
+ };
1222
+ const controlOf = (id) => controls.find((control) => control.id === id);
1223
+ const store = {
1224
+ moduleId: module.id,
1225
+ metrics: declared.metrics,
1226
+ declared,
1227
+ controls: () => controls.map((control) => ({ ...control, value: values.get(control.id) })),
1228
+ layers: () => declared.layers.map((layer) => ({ ...layer, on: layers.get(layer.id) === true })),
1229
+ value: (id) => values.get(id),
1230
+ layer: (id) => layers.get(id) === true,
1231
+ set(id, value) {
1232
+ const control = controlOf(id);
1233
+ if (!control || control.disabled) return false;
1234
+ const fitted = fitValue(control, value);
1235
+ if (fitted === void 0) return false;
1236
+ values.set(id, fitted);
1237
+ emit({ kind: "control", id, value: fitted });
1238
+ changed();
1239
+ return true;
1240
+ },
1241
+ setLayer(id, on) {
1242
+ if (!layers.has(id)) return false;
1243
+ layers.set(id, on);
1244
+ emit({ kind: "layer", id, on });
1245
+ changed();
1246
+ return true;
1247
+ },
1248
+ act(id) {
1249
+ const control = controlOf(id);
1250
+ if (!control || control.kind !== "action" || control.disabled) return false;
1251
+ emit({ kind: "action", id });
1252
+ return true;
1253
+ },
1254
+ encode: () => encodeBenchState(store.controls(), store.layers()),
1255
+ onChange(listener) {
1256
+ listeners.add(listener);
1257
+ return () => listeners.delete(listener);
1258
+ },
1259
+ watch(listener) {
1260
+ watchers.add(listener);
1261
+ return () => watchers.delete(listener);
1262
+ },
1263
+ patch(id, patch) {
1264
+ const index = controls.findIndex((control2) => control2.id === id);
1265
+ if (index < 0) return;
1266
+ let control = controls[index];
1267
+ if (patch.hidden !== void 0) control = { ...control, hidden: patch.hidden };
1268
+ if (patch.disabled !== void 0) control = { ...control, disabled: patch.disabled };
1269
+ if (patch.options && control.kind === "choice" && patch.options.length > 0) {
1270
+ control = { ...control, options: [...patch.options] };
1271
+ const offered = control.options;
1272
+ if (!offered.some((option) => option.id === values.get(id))) values.set(id, offered[0].id);
1273
+ }
1274
+ controls[index] = control;
1275
+ if (patch.value !== void 0) {
1276
+ const fitted = fitValue(control, patch.value);
1277
+ if (fitted !== void 0) values.set(id, fitted);
1278
+ }
1279
+ changed();
1280
+ },
1281
+ sendInitial() {
1282
+ for (const control of controls) {
1283
+ const value = values.get(control.id);
1284
+ if (value !== void 0) emit({ kind: "control", id: control.id, value });
1285
+ }
1286
+ for (const layer of declared.layers) emit({ kind: "layer", id: layer.id, on: layers.get(layer.id) === true });
1287
+ changed();
1288
+ },
1289
+ restore(encoded) {
1290
+ for (const [id, value] of decodeBenchState(encoded, controls, declared.layers)) {
1291
+ if (typeof value === "boolean" && layers.has(id)) layers.set(id, value);
1292
+ else values.set(id, value);
1293
+ }
1294
+ },
1295
+ dispose() {
1296
+ listeners.clear();
1297
+ watchers.clear();
1298
+ }
1299
+ };
1300
+ return store;
1301
+ }
1302
+
1125
1303
  // src/log.ts
1126
1304
  var MAX_ENTRIES = 5e3;
1127
1305
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -1250,6 +1428,56 @@ function createLog(options = {}) {
1250
1428
  return log;
1251
1429
  }
1252
1430
 
1431
+ // src/metrics.ts
1432
+ var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals"];
1433
+ var WINDOW_S = 0.5;
1434
+ function createRoomMetrics(sources) {
1435
+ const wanted = new Set(sources.ids);
1436
+ const gl = sources.gl;
1437
+ let windowS = 0;
1438
+ let frames = 0;
1439
+ let fps;
1440
+ let frameMs;
1441
+ let calls;
1442
+ let triangles;
1443
+ let ownsRender = false;
1444
+ return {
1445
+ frame(dt) {
1446
+ frames++;
1447
+ windowS += dt;
1448
+ if (windowS < WINDOW_S) return;
1449
+ fps = frames / windowS;
1450
+ frameMs = windowS / frames * 1e3;
1451
+ frames = 0;
1452
+ windowS = 0;
1453
+ },
1454
+ rendered() {
1455
+ ownsRender = true;
1456
+ calls = gl.info?.render?.calls;
1457
+ triangles = gl.info?.render?.triangles;
1458
+ },
1459
+ sample() {
1460
+ const out = {};
1461
+ const put = (id, value) => {
1462
+ if (wanted.has(id) && value !== void 0) out[id] = value;
1463
+ };
1464
+ put("fps", fps);
1465
+ put("frame_ms", frameMs);
1466
+ const render = ownsRender ? { calls, triangles } : gl.info?.render;
1467
+ put("draw_calls", render?.calls);
1468
+ put("triangles", render?.triangles);
1469
+ if (!["objects", "fragments", "particles", "decals"].some((id) => wanted.has(id))) return out;
1470
+ const destruction = sources.destruction.stats();
1471
+ const fluids = sources.fluids.stats();
1472
+ put("objects", destruction.pieces);
1473
+ put("fragments", destruction.fragments);
1474
+ put("particles", destruction.particles + fluids.particles);
1475
+ put("decals", destruction.decals + fluids.stains);
1476
+ return out;
1477
+ }
1478
+ };
1479
+ }
1480
+
1253
1481
  // src/narrate.ts
1254
1482
  var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
1255
1483
  function createNarrator(c) {
@@ -2320,6 +2548,209 @@ function scenariosFor(locale = "en") {
2320
2548
  }
2321
2549
  var SCENARIOS = scenariosFor("en");
2322
2550
 
2551
+ // src/bench-panel.ts
2552
+ var SEGMENTED_MAX = 4;
2553
+ var mk = (tag, cls, text) => {
2554
+ const e = document.createElement(tag);
2555
+ if (cls) e.className = cls;
2556
+ if (text !== void 0) e.textContent = text;
2557
+ return e;
2558
+ };
2559
+ var shapeOf = (view) => JSON.stringify([view.title, view.controls.map((c) => ({ ...c, value: void 0 })), view.layers.map((l) => [l.id, l.name])]);
2560
+ function createBenchPanel(o) {
2561
+ const { t } = o;
2562
+ const element = mk("div", "tr-panel tr-bench");
2563
+ element.style.display = "none";
2564
+ element.addEventListener("keydown", (e) => e.stopPropagation());
2565
+ const roomLine = mk("div", "sub tr-bench-room");
2566
+ const body = mk("div");
2567
+ const metricTitle = mk("div", "sub", t("ui.metrics"));
2568
+ metricTitle.style.display = "none";
2569
+ const metricList = mk("dl", "tr-metrics");
2570
+ element.append(mk("h3", void 0, t("ui.bench")), roomLine, body, metricTitle, metricList);
2571
+ let shape = "";
2572
+ let hasRoom = false;
2573
+ let hasBench = false;
2574
+ const refreshers = [];
2575
+ const metricNodes = /* @__PURE__ */ new Map();
2576
+ let metricKey = "";
2577
+ const syncDisplay = () => {
2578
+ element.style.display = hasRoom || hasBench ? "" : "none";
2579
+ };
2580
+ const numberControl = (control) => {
2581
+ const wrap = mk("label", "tr-ctl");
2582
+ const input = mk("input");
2583
+ input.type = "range";
2584
+ input.min = String(control.min);
2585
+ input.max = String(control.max);
2586
+ input.step = control.step ? String(control.step) : "any";
2587
+ input.disabled = control.disabled;
2588
+ const reading = mk("output");
2589
+ input.oninput = () => o.onControl(control.id, Number(input.value));
2590
+ wrap.append(mk("span", void 0, control.name), input, reading);
2591
+ refreshers.push((view) => {
2592
+ const value = view.controls.find((c) => c.id === control.id)?.value;
2593
+ if (typeof value !== "number") return;
2594
+ if (Number(input.value) !== value) input.value = String(value);
2595
+ reading.textContent = `${o.num(value, control.step && control.step >= 1 ? 0 : 2)}${control.unit ? ` ${control.unit}` : ""}`;
2596
+ });
2597
+ return wrap;
2598
+ };
2599
+ const booleanControl = (control) => {
2600
+ const wrap = mk("label", "tr-ctl");
2601
+ const box2 = mk("input");
2602
+ box2.type = "checkbox";
2603
+ box2.disabled = control.disabled;
2604
+ box2.onchange = () => o.onControl(control.id, box2.checked);
2605
+ wrap.append(box2, document.createTextNode(` ${control.name}`));
2606
+ refreshers.push((view) => {
2607
+ box2.checked = view.controls.find((c) => c.id === control.id)?.value === true;
2608
+ });
2609
+ return wrap;
2610
+ };
2611
+ const choiceControl = (control) => {
2612
+ const wrap = mk("div", "tr-ctl");
2613
+ wrap.appendChild(mk("span", void 0, control.name));
2614
+ if (control.options.length <= SEGMENTED_MAX) {
2615
+ const group = mk("div", "tr-seg");
2616
+ group.setAttribute("role", "group");
2617
+ const buttons = control.options.map((option) => {
2618
+ const b = mk("button", "tr-btn", option.name);
2619
+ b.type = "button";
2620
+ b.disabled = control.disabled;
2621
+ b.onclick = () => o.onControl(control.id, option.id);
2622
+ group.appendChild(b);
2623
+ return { b, id: option.id };
2624
+ });
2625
+ wrap.appendChild(group);
2626
+ refreshers.push((view) => {
2627
+ const value = view.controls.find((c) => c.id === control.id)?.value;
2628
+ for (const { b, id } of buttons) {
2629
+ b.setAttribute("aria-pressed", String(id === value));
2630
+ b.classList.toggle("on", id === value);
2631
+ }
2632
+ });
2633
+ return wrap;
2634
+ }
2635
+ const select = mk("select");
2636
+ select.disabled = control.disabled;
2637
+ for (const option of control.options) {
2638
+ const op = mk("option", void 0, option.name);
2639
+ op.value = option.id;
2640
+ select.appendChild(op);
2641
+ }
2642
+ select.onchange = () => o.onControl(control.id, select.value);
2643
+ wrap.appendChild(select);
2644
+ refreshers.push((view) => {
2645
+ const value = view.controls.find((c) => c.id === control.id)?.value;
2646
+ if (typeof value === "string") select.value = value;
2647
+ });
2648
+ return wrap;
2649
+ };
2650
+ const actionControl = (control) => {
2651
+ const b = mk("button", "tr-btn", control.name);
2652
+ b.type = "button";
2653
+ b.disabled = control.disabled;
2654
+ b.onclick = () => o.onAction(control.id);
2655
+ return b;
2656
+ };
2657
+ const controlElement = (control) => {
2658
+ const el = control.kind === "number" ? numberControl(control) : control.kind === "boolean" ? booleanControl(control) : control.kind === "choice" ? choiceControl(control) : actionControl(control);
2659
+ if (control.hint) el.title = control.hint;
2660
+ return el;
2661
+ };
2662
+ const layerList = (layers) => {
2663
+ const wrap = mk("div", "tr-layers");
2664
+ wrap.appendChild(mk("div", "sub", t("ui.layers")));
2665
+ for (const layer of layers) {
2666
+ const label = mk("label", "tr-ctl");
2667
+ const box2 = mk("input");
2668
+ box2.type = "checkbox";
2669
+ box2.onchange = () => o.onLayer(layer.id, box2.checked);
2670
+ label.append(box2, document.createTextNode(` ${layer.name}`));
2671
+ wrap.appendChild(label);
2672
+ refreshers.push((view) => {
2673
+ box2.checked = view.layers.find((l) => l.id === layer.id)?.on === true;
2674
+ });
2675
+ }
2676
+ return wrap;
2677
+ };
2678
+ const rebuild = (view) => {
2679
+ body.textContent = "";
2680
+ refreshers.length = 0;
2681
+ body.appendChild(mk("div", "sub", view.title));
2682
+ const list = mk("div", "tr-ctls");
2683
+ for (const control of view.controls) if (!control.hidden) list.appendChild(controlElement(control));
2684
+ body.appendChild(list);
2685
+ if (view.layers.length) body.appendChild(layerList(view.layers));
2686
+ };
2687
+ const renderMetrics = (rows) => {
2688
+ metricList.textContent = "";
2689
+ metricNodes.clear();
2690
+ metricTitle.style.display = rows.length ? "" : "none";
2691
+ for (const row of rows) {
2692
+ const dd = mk("dd");
2693
+ const text = mk("span", void 0, row.text);
2694
+ dd.appendChild(text);
2695
+ let bar = null;
2696
+ if (row.ratio !== void 0) {
2697
+ bar = mk("div", "tr-bar");
2698
+ bar.appendChild(mk("i"));
2699
+ dd.appendChild(bar);
2700
+ }
2701
+ metricList.append(mk("dt", void 0, row.name), dd);
2702
+ metricNodes.set(row.id, { text, bar });
2703
+ }
2704
+ };
2705
+ const fillBar = (bar, ratio) => {
2706
+ bar.className = `tr-bar${ratio > 1 ? " bad" : ratio >= 0.8 ? " warn" : ""}`;
2707
+ const fill = bar.firstElementChild;
2708
+ fill.style.width = `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`;
2709
+ };
2710
+ return {
2711
+ element,
2712
+ show(view) {
2713
+ hasBench = view !== null;
2714
+ syncDisplay();
2715
+ if (!view) {
2716
+ shape = "";
2717
+ body.textContent = "";
2718
+ refreshers.length = 0;
2719
+ renderMetrics([]);
2720
+ metricKey = "";
2721
+ return;
2722
+ }
2723
+ const next = shapeOf(view);
2724
+ if (next !== shape) {
2725
+ shape = next;
2726
+ rebuild(view);
2727
+ }
2728
+ for (const refresh of refreshers) refresh(view);
2729
+ },
2730
+ metrics(rows) {
2731
+ hasRoom = rows.room.length > 0;
2732
+ syncDisplay();
2733
+ roomLine.textContent = rows.room.map((row) => `${row.name} ${row.text}`).join(" \xB7 ");
2734
+ const key = rows.module.map((row) => `${row.id}:${row.ratio !== void 0}`).join("|");
2735
+ if (key !== metricKey) {
2736
+ metricKey = key;
2737
+ renderMetrics(rows.module);
2738
+ }
2739
+ for (const row of rows.module) {
2740
+ const node = metricNodes.get(row.id);
2741
+ if (!node) continue;
2742
+ node.text.textContent = row.text;
2743
+ if (node.bar && row.ratio !== void 0) fillBar(node.bar, row.ratio);
2744
+ }
2745
+ },
2746
+ get visible() {
2747
+ if (element.style.display === "none") return false;
2748
+ const parent = element.parentElement;
2749
+ return !parent || getComputedStyle(parent).display !== "none";
2750
+ }
2751
+ };
2752
+ }
2753
+
2323
2754
  // src/ui.ts
2324
2755
  var CSS = `
2325
2756
  .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}
@@ -2372,6 +2803,13 @@ var CSS = `
2372
2803
  .tr-cat{flex:0 1 auto;max-height:40%}
2373
2804
  .tr-anim{flex:0 0 auto;max-height:18%}
2374
2805
  .tr-anim .list{display:flex;flex-wrap:wrap;gap:4px}.tr-anim .sub{color:#9aa3b2;margin-bottom:4px}
2806
+ .tr-bench{flex:0 1 auto;max-height:34%}
2807
+ .tr-bench .sub{color:#9aa3b2;margin-bottom:4px}
2808
+ .tr-ctls{display:flex;flex-direction:column;gap:5px}
2809
+ .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}
2810
+ .tr-seg{display:inline-flex;flex-wrap:wrap;gap:2px}
2811
+ .tr-layers{margin-top:6px;display:flex;flex-direction:column;gap:2px}
2812
+ .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}
2375
2813
  .tr-tests{flex:1 1 auto;min-height:100px}
2376
2814
  .tr-tests .list{display:flex;flex-direction:column;gap:3px;margin-top:6px}.tr-tests .list .tr-btn{text-align:left}
2377
2815
  .tr-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;align-items:center}
@@ -2380,7 +2818,7 @@ var CSS = `
2380
2818
  @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}}
2381
2819
  `;
2382
2820
  var fold = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
2383
- var mk = (tag, cls, text) => {
2821
+ var mk2 = (tag, cls, text) => {
2384
2822
  const e = document.createElement(tag);
2385
2823
  if (cls) e.className = cls;
2386
2824
  if (text !== void 0) e.textContent = text;
@@ -2388,42 +2826,42 @@ var mk = (tag, cls, text) => {
2388
2826
  };
2389
2827
  function createUi(o) {
2390
2828
  const { t } = o;
2391
- const style = mk("style");
2829
+ const style = mk2("style");
2392
2830
  style.textContent = CSS;
2393
- const root = mk("div", "tr-root");
2831
+ const root = mk2("div", "tr-root");
2394
2832
  const pos = getComputedStyle?.(o.container)?.position;
2395
2833
  if (!pos || pos === "static") o.container.style.position = "relative";
2396
2834
  root.appendChild(style);
2397
2835
  o.container.appendChild(root);
2398
- const cross = mk("div", "tr-cross");
2399
- cross.appendChild(mk("i"));
2400
- const top = mk("div", "tr-top");
2401
- const toolBox = mk("div", "tr-tool");
2402
- const status = mk("div", "tr-status");
2403
- const prog = mk("div", "tr-prog");
2404
- const progLabel = mk("b");
2405
- const progBar = mk("div", "tr-bar");
2406
- const progFill = mk("i");
2836
+ const cross = mk2("div", "tr-cross");
2837
+ cross.appendChild(mk2("i"));
2838
+ const top = mk2("div", "tr-top");
2839
+ const toolBox = mk2("div", "tr-tool");
2840
+ const status = mk2("div", "tr-status");
2841
+ const prog = mk2("div", "tr-prog");
2842
+ const progLabel = mk2("b");
2843
+ const progBar = mk2("div", "tr-bar");
2844
+ const progFill = mk2("i");
2407
2845
  progBar.appendChild(progFill);
2408
2846
  prog.append(progLabel, progBar);
2409
2847
  top.append(toolBox, status, prog);
2410
- const bottom = mk("div", "tr-bottom");
2411
- const held = mk("div", "tr-held");
2412
- const hotbar = mk("div", "tr-hotbar");
2848
+ const bottom = mk2("div", "tr-bottom");
2849
+ const held = mk2("div", "tr-held");
2850
+ const hotbar = mk2("div", "tr-hotbar");
2413
2851
  bottom.append(held, hotbar);
2414
- const help = mk("div", "tr-help");
2852
+ const help = mk2("div", "tr-help");
2415
2853
  help.textContent = t("ui.help");
2416
- const left = mk("div", "tr-left");
2417
- const insp = mk("div", "tr-panel tr-insp");
2418
- const logPanel = mk("div", "tr-panel tr-log");
2854
+ const left = mk2("div", "tr-left");
2855
+ const insp = mk2("div", "tr-panel tr-insp");
2856
+ const logPanel = mk2("div", "tr-panel tr-log");
2419
2857
  left.append(insp, logPanel);
2420
- const logTitle = mk("h3", void 0, t("ui.log"));
2421
- const logBar = mk("div", "tr-logbar");
2422
- const logBody = mk("div", "tr-logbody");
2858
+ const logTitle = mk2("h3", void 0, t("ui.log"));
2859
+ const logBar = mk2("div", "tr-logbar");
2860
+ const logBody = mk2("div", "tr-logbody");
2423
2861
  const filters = { action: true, event: true, check: true };
2424
2862
  const filterBtns = [];
2425
2863
  for (const ch of ["action", "event", "check"]) {
2426
- const b = mk("button", "tr-btn on", t(`ui.filter.${ch}`));
2864
+ const b = mk2("button", "tr-btn on", t(`ui.filter.${ch}`));
2427
2865
  b.type = "button";
2428
2866
  b.onclick = () => {
2429
2867
  filters[ch] = !filters[ch];
@@ -2434,8 +2872,8 @@ function createUi(o) {
2434
2872
  logBar.appendChild(b);
2435
2873
  }
2436
2874
  let autoscroll = true;
2437
- const asLabel = mk("label");
2438
- const asBox = mk("input");
2875
+ const asLabel = mk2("label");
2876
+ const asBox = mk2("input");
2439
2877
  asBox.type = "checkbox";
2440
2878
  asBox.checked = true;
2441
2879
  asBox.onchange = () => {
@@ -2444,7 +2882,7 @@ function createUi(o) {
2444
2882
  asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
2445
2883
  logBar.appendChild(asLabel);
2446
2884
  const logBtn = (label, fn) => {
2447
- const b = mk("button", "tr-btn", label);
2885
+ const b = mk2("button", "tr-btn", label);
2448
2886
  b.type = "button";
2449
2887
  b.onclick = fn;
2450
2888
  logBar.appendChild(b);
@@ -2452,7 +2890,7 @@ function createUi(o) {
2452
2890
  logBtn(t("ui.copy_json"), () => {
2453
2891
  const text = o.log.toJSON();
2454
2892
  const select = () => {
2455
- const ta = mk("textarea");
2893
+ const ta = mk2("textarea");
2456
2894
  ta.value = text;
2457
2895
  ta.setAttribute("readonly", "");
2458
2896
  ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
@@ -2486,12 +2924,12 @@ function createUi(o) {
2486
2924
  const fill = (n, e) => {
2487
2925
  n.className = lineClass(e);
2488
2926
  n.textContent = "";
2489
- const tm = mk("time", void 0, fmtTime(e.t));
2927
+ const tm = mk2("time", void 0, fmtTime(e.t));
2490
2928
  n.append(tm, document.createTextNode(e.text));
2491
2929
  };
2492
2930
  const addNode = (e) => {
2493
2931
  if (!filters[e.channel]) return;
2494
- const n = mk("div");
2932
+ const n = mk2("div");
2495
2933
  fill(n, e);
2496
2934
  nodes.set(e.seq, n);
2497
2935
  logBody.appendChild(n);
@@ -2513,17 +2951,18 @@ function createUi(o) {
2513
2951
  if (n) fill(n, e);
2514
2952
  } else addNode(e);
2515
2953
  });
2516
- const right = mk("div", "tr-right");
2517
- const cat = mk("div", "tr-panel tr-cat");
2518
- const anim = mk("div", "tr-panel tr-anim");
2519
- const tests = mk("div", "tr-panel tr-tests");
2520
- right.append(cat, anim, tests);
2521
- cat.appendChild(mk("h3", void 0, t("ui.catalog")));
2522
- const itemSearch = mk("input", "tr-search");
2954
+ const right = mk2("div", "tr-right");
2955
+ const cat = mk2("div", "tr-panel tr-cat");
2956
+ const anim = mk2("div", "tr-panel tr-anim");
2957
+ const tests = mk2("div", "tr-panel tr-tests");
2958
+ const benchPanel = createBenchPanel({ t, num: (n, d) => num(n, d), onControl: o.onControl, onLayer: o.onLayer, onAction: o.onAction });
2959
+ right.append(cat, anim, benchPanel.element, tests);
2960
+ cat.appendChild(mk2("h3", void 0, t("ui.catalog")));
2961
+ const itemSearch = mk2("input", "tr-search");
2523
2962
  itemSearch.type = "search";
2524
2963
  itemSearch.placeholder = t("ui.search_items");
2525
- const tabs = mk("div", "tr-tabs");
2526
- const items = mk("div", "tr-items");
2964
+ const tabs = mk2("div", "tr-tabs");
2965
+ const items = mk2("div", "tr-items");
2527
2966
  cat.append(itemSearch, tabs, items);
2528
2967
  let activeGroup = "";
2529
2968
  const visibleItems = () => {
@@ -2535,17 +2974,17 @@ function createUi(o) {
2535
2974
  items.textContent = "";
2536
2975
  const searching = itemSearch.value.trim() !== "";
2537
2976
  const found = visibleItems();
2538
- if (!found.length) items.appendChild(mk("div", "tr-empty", t("ui.no_results")));
2977
+ if (!found.length) items.appendChild(mk2("div", "tr-empty", t("ui.no_results")));
2539
2978
  for (const { item: it, group } of found) {
2540
- const wrap = mk("div", "tr-item");
2541
- const b = mk("button", "tr-btn", it.name);
2542
- if (searching) b.appendChild(mk("small", void 0, group));
2979
+ const wrap = mk2("div", "tr-item");
2980
+ const b = mk2("button", "tr-btn", it.name);
2981
+ if (searching) b.appendChild(mk2("small", void 0, group));
2543
2982
  b.type = "button";
2544
2983
  b.title = it.note ?? it.name;
2545
2984
  b.onclick = () => o.onSpawn(it.id);
2546
2985
  wrap.appendChild(b);
2547
2986
  if (it.weapon) {
2548
- const w = mk("button", "tr-btn", "\u2694");
2987
+ const w = mk2("button", "tr-btn", "\u2694");
2549
2988
  w.type = "button";
2550
2989
  w.title = t("ui.equip");
2551
2990
  w.onclick = () => o.onEquip(it.id);
@@ -2559,7 +2998,7 @@ function createUi(o) {
2559
2998
  const groupNames = [...o.groups().keys()];
2560
2999
  if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
2561
3000
  for (const g of groupNames) {
2562
- const b = mk("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
3001
+ const b = mk2("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
2563
3002
  b.type = "button";
2564
3003
  b.onclick = () => {
2565
3004
  activeGroup = g;
@@ -2572,9 +3011,9 @@ function createUi(o) {
2572
3011
  renderTabs();
2573
3012
  itemSearch.oninput = renderItems;
2574
3013
  for (const box2 of [itemSearch]) box2.addEventListener("keydown", (e) => e.stopPropagation());
2575
- const controls = mk("div", "tr-row");
3014
+ const controls = mk2("div", "tr-row");
2576
3015
  const ctl = (label, fn) => {
2577
- const b = mk("button", "tr-btn", label);
3016
+ const b = mk2("button", "tr-btn", label);
2578
3017
  b.type = "button";
2579
3018
  b.onclick = () => fn(b);
2580
3019
  controls.appendChild(b);
@@ -2595,29 +3034,29 @@ function createUi(o) {
2595
3034
  o.onFast(fast);
2596
3035
  });
2597
3036
  const tapBtn = ctl(t("ui.tap"), () => o.onTap());
2598
- const qSel = mk("select");
3037
+ const qSel = mk2("select");
2599
3038
  for (const q of ["low", "medium", "high"]) {
2600
- const op = mk("option", void 0, t(`ui.quality.${q}`));
3039
+ const op = mk2("option", void 0, t(`ui.quality.${q}`));
2601
3040
  op.value = q;
2602
3041
  qSel.appendChild(op);
2603
3042
  }
2604
3043
  qSel.value = o.quality;
2605
3044
  qSel.onchange = () => o.onQuality(qSel.value);
2606
- const qLabel = mk("label", void 0, t("ui.quality") + " ");
3045
+ const qLabel = mk2("label", void 0, t("ui.quality") + " ");
2607
3046
  qLabel.appendChild(qSel);
2608
3047
  controls.appendChild(qLabel);
2609
- const count = mk("span");
3048
+ const count = mk2("span");
2610
3049
  controls.appendChild(count);
2611
3050
  cat.appendChild(controls);
2612
- tests.appendChild(mk("h3", void 0, t("ui.tests")));
2613
- const runAll = mk("button", "tr-btn", t("ui.run_all"));
3051
+ tests.appendChild(mk2("h3", void 0, t("ui.tests")));
3052
+ const runAll = mk2("button", "tr-btn", t("ui.run_all"));
2614
3053
  runAll.type = "button";
2615
- const summary = mk("div", "tr-summary");
2616
- const testSearch = mk("input", "tr-search");
3054
+ const summary = mk2("div", "tr-summary");
3055
+ const testSearch = mk2("input", "tr-search");
2617
3056
  testSearch.type = "search";
2618
3057
  testSearch.placeholder = t("ui.search_tests");
2619
3058
  testSearch.addEventListener("keydown", (e) => e.stopPropagation());
2620
- const list = mk("div", "list");
3059
+ const list = mk2("div", "list");
2621
3060
  tests.append(runAll, summary, testSearch, list);
2622
3061
  testSearch.oninput = () => {
2623
3062
  const term = fold(testSearch.value.trim());
@@ -2640,7 +3079,7 @@ function createUi(o) {
2640
3079
  list.textContent = "";
2641
3080
  scBtns.clear();
2642
3081
  for (const s of o.scenarios()) {
2643
- const b = mk("button", "tr-btn", s.name);
3082
+ const b = mk2("button", "tr-btn", s.name);
2644
3083
  b.type = "button";
2645
3084
  b.onclick = async () => {
2646
3085
  if (busy) return;
@@ -2666,7 +3105,7 @@ function createUi(o) {
2666
3105
  busy = false;
2667
3106
  }
2668
3107
  };
2669
- const toggle = mk("button", "tr-btn tr-toggle", t("ui.panel"));
3108
+ const toggle = mk2("button", "tr-btn tr-toggle", t("ui.panel"));
2670
3109
  toggle.type = "button";
2671
3110
  const togglePanels = () => {
2672
3111
  root.classList.toggle("open");
@@ -2675,15 +3114,15 @@ function createUi(o) {
2675
3114
  toggle.onclick = togglePanels;
2676
3115
  root.append(cross, top, bottom, help, left, right, toggle);
2677
3116
  const bar = (value, max, cls) => {
2678
- const b = mk("div", `tr-bar ${cls}`);
2679
- const i = mk("i");
3117
+ const b = mk2("div", `tr-bar ${cls}`);
3118
+ const i = mk2("i");
2680
3119
  i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
2681
3120
  b.appendChild(i);
2682
3121
  return b;
2683
3122
  };
2684
3123
  const row = (k, v) => {
2685
- const r = mk("div", "r");
2686
- r.append(mk("span", void 0, k), mk("span", void 0, v));
3124
+ const r = mk2("div", "r");
3125
+ r.append(mk2("span", void 0, k), mk2("span", void 0, v));
2687
3126
  return r;
2688
3127
  };
2689
3128
  const num = (n, d = 1) => {
@@ -2692,12 +3131,12 @@ function createUi(o) {
2692
3131
  };
2693
3132
  const renderInspector = (i) => {
2694
3133
  insp.textContent = "";
2695
- insp.appendChild(mk("h3", void 0, t("ui.inspector")));
3134
+ insp.appendChild(mk2("h3", void 0, t("ui.inspector")));
2696
3135
  if (!i) {
2697
- insp.appendChild(mk("div", void 0, t("ui.aim_at_something")));
3136
+ insp.appendChild(mk2("div", void 0, t("ui.aim_at_something")));
2698
3137
  return;
2699
3138
  }
2700
- insp.appendChild(mk("div", "t", `${i.object} \u203A ${i.part}`));
3139
+ insp.appendChild(mk2("div", "t", `${i.object} \u203A ${i.part}`));
2701
3140
  insp.appendChild(row(t("ins.material"), i.material));
2702
3141
  insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
2703
3142
  insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
@@ -2716,30 +3155,30 @@ function createUi(o) {
2716
3155
  insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
2717
3156
  }
2718
3157
  if (i.joints.length) {
2719
- insp.appendChild(mk("div", void 0, t("ins.joints") + ":"));
3158
+ insp.appendChild(mk2("div", void 0, t("ins.joints") + ":"));
2720
3159
  for (const j of i.joints) {
2721
3160
  const tools = j.tools.length ? j.tools.join(", ") : t("tool.none");
2722
- insp.appendChild(mk("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
3161
+ insp.appendChild(mk2("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
2723
3162
  }
2724
3163
  }
2725
- if (i.note) insp.appendChild(mk("div", void 0, i.note));
3164
+ if (i.note) insp.appendChild(mk2("div", void 0, i.note));
2726
3165
  };
2727
3166
  let lastAnim = "";
2728
3167
  const renderAnimations = (a) => {
2729
3168
  anim.textContent = "";
2730
- anim.appendChild(mk("h3", void 0, t("ui.animations")));
3169
+ anim.appendChild(mk2("h3", void 0, t("ui.animations")));
2731
3170
  if (!a) {
2732
- anim.appendChild(mk("div", "sub", t("ui.animations_aim")));
3171
+ anim.appendChild(mk2("div", "sub", t("ui.animations_aim")));
2733
3172
  return;
2734
3173
  }
2735
- anim.appendChild(mk("div", "sub", a.name));
3174
+ anim.appendChild(mk2("div", "sub", a.name));
2736
3175
  if (a.clips.length === 0) {
2737
- anim.appendChild(mk("div", "sub", t("ui.animations_none")));
3176
+ anim.appendChild(mk2("div", "sub", t("ui.animations_none")));
2738
3177
  return;
2739
3178
  }
2740
- const l = mk("div", "list");
3179
+ const l = mk2("div", "list");
2741
3180
  for (const c of a.clips) {
2742
- 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)`);
3181
+ 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)`);
2743
3182
  b.type = "button";
2744
3183
  b.onclick = () => o.onPlay(a.objectId, c.id);
2745
3184
  l.appendChild(b);
@@ -2756,7 +3195,7 @@ function createUi(o) {
2756
3195
  hud(s) {
2757
3196
  toolBox.textContent = "";
2758
3197
  toolBox.append(document.createTextNode(s.toolName));
2759
- if (s.ammo) toolBox.append(mk("small", void 0, s.ammo));
3198
+ if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
2760
3199
  if (s.status) status.textContent = s.status;
2761
3200
  else if (!toastTimer) status.textContent = "";
2762
3201
  if (s.progress) {
@@ -2777,7 +3216,7 @@ function createUi(o) {
2777
3216
  ];
2778
3217
  if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
2779
3218
  for (const [k, label] of keys) {
2780
- held.append(mk("kbd", void 0, k), document.createTextNode(` ${label} `));
3219
+ held.append(mk2("kbd", void 0, k), document.createTextNode(` ${label} `));
2781
3220
  }
2782
3221
  } else held.style.display = "none";
2783
3222
  const key = s.slots.join("|") + s.page;
@@ -2787,9 +3226,9 @@ function createUi(o) {
2787
3226
  slotsEl.length = 0;
2788
3227
  hudPage = s.page - 1;
2789
3228
  s.slots.forEach((name, i) => {
2790
- const b = mk("button", "tr-slot");
3229
+ const b = mk2("button", "tr-slot");
2791
3230
  b.type = "button";
2792
- b.append(mk("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
3231
+ b.append(mk2("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
2793
3232
  b.title = name;
2794
3233
  b.onclick = () => o.onTool(hudPage * 10 + i);
2795
3234
  hotbar.appendChild(b);
@@ -2827,6 +3266,11 @@ function createUi(o) {
2827
3266
  renderTabs();
2828
3267
  },
2829
3268
  togglePanels,
3269
+ bench: benchPanel.show,
3270
+ metrics: benchPanel.metrics,
3271
+ get benchVisible() {
3272
+ return benchPanel.visible;
3273
+ },
2830
3274
  dispose() {
2831
3275
  offLog();
2832
3276
  if (toastTimer) clearTimeout(toastTimer);
@@ -2839,6 +3283,7 @@ function createUi(o) {
2839
3283
 
2840
3284
  // src/room.ts
2841
3285
  var FIXED_DT = 1 / 60;
3286
+ var METRICS_INTERVAL_S = 0.25;
2842
3287
  var v3 = (v) => [v.x, v.y, v.z];
2843
3288
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
2844
3289
  function createTestRoom(options) {
@@ -3470,7 +3915,7 @@ function createTestRoom(options) {
3470
3915
  }
3471
3916
  });
3472
3917
  player.kick(recoil[0], recoil[1]);
3473
- emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil });
3918
+ emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil, rounds: shots.filter((s) => s.pellet === 0).length, mode: tool.mode });
3474
3919
  const f = first;
3475
3920
  const d = f ? num(f.distance, 1) : "";
3476
3921
  let text;
@@ -3652,6 +4097,7 @@ function createTestRoom(options) {
3652
4097
  const detonate = (p, at, impact) => {
3653
4098
  const w = toolName(p.weapon);
3654
4099
  const results = world.explode(at, p.energy, p.radius, "player");
4100
+ renderer.explode(at, p.energy, p.radius);
3655
4101
  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 } });
3656
4102
  if (p.fire) {
3657
4103
  fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
@@ -3995,9 +4441,12 @@ function createTestRoom(options) {
3995
4441
  say(t("hud.reloading"));
3996
4442
  }
3997
4443
  break;
3998
- case "KeyQ":
3999
- tool.cycleMode();
4444
+ case "KeyQ": {
4445
+ const before = tool.mode;
4446
+ const mode = tool.cycleMode();
4447
+ if (mode !== before) emitHost({ type: "mode", weapon: toolId, mode });
4000
4448
  break;
4449
+ }
4001
4450
  case "KeyZ":
4002
4451
  tiltKey = 1;
4003
4452
  break;
@@ -4085,6 +4534,10 @@ function createTestRoom(options) {
4085
4534
  let timeScale = 1;
4086
4535
  let fastForward = false;
4087
4536
  let hudAcc = 1;
4537
+ let metricsAcc = 0;
4538
+ let ownsLoop = false;
4539
+ const roomMetricIds = options.metrics === false ? [] : options.metrics ?? ROOM_METRICS;
4540
+ const roomMetrics = createRoomMetrics({ gl, destruction: renderer, fluids: fluidView, ids: roomMetricIds });
4088
4541
  let qualityName = quality;
4089
4542
  const syncContainers = (sdt) => {
4090
4543
  for (const e of entries.values()) {
@@ -4148,6 +4601,7 @@ function createTestRoom(options) {
4148
4601
  flush();
4149
4602
  };
4150
4603
  const update = (dt) => {
4604
+ if (!ownsLoop) roomMetrics.frame(dt);
4151
4605
  player.update(dt);
4152
4606
  if (tiltKey || heldTilt > 0) {
4153
4607
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
@@ -4166,6 +4620,11 @@ function createTestRoom(options) {
4166
4620
  hudAcc = 0;
4167
4621
  ui.hud(hudState());
4168
4622
  }
4623
+ metricsAcc += dt;
4624
+ if (metricsAcc >= METRICS_INTERVAL_S && ui) {
4625
+ metricsAcc = 0;
4626
+ if (ui.benchVisible) ui.metrics(metricRows());
4627
+ }
4169
4628
  };
4170
4629
  const hudState = () => {
4171
4630
  const page = Math.floor(toolIndex / 10);
@@ -4281,7 +4740,57 @@ function createTestRoom(options) {
4281
4740
  log.add({ channel: "check", type: ok ? "pass" : "fail", text: `${ok ? "\u2713" : "\u2717"} ${text}`, itemId, data: { ok, ...data } });
4282
4741
  };
4283
4742
  const modules = /* @__PURE__ */ new Map();
4284
- const detachers = [];
4743
+ const detachers = /* @__PURE__ */ new Map();
4744
+ const benches = /* @__PURE__ */ new Map();
4745
+ let activeBench = null;
4746
+ const lastBench = () => [...benches.keys()].pop() ?? null;
4747
+ const benchView = () => {
4748
+ const store = activeBench ? benches.get(activeBench) : void 0;
4749
+ if (!store) return null;
4750
+ return { title: modules.get(store.moduleId)?.name ?? store.moduleId, controls: store.controls(), layers: store.layers() };
4751
+ };
4752
+ const METRIC_UNAVAILABLE = "n/a";
4753
+ const metricText = (value, digits, unit) => typeof value === "number" ? `${num(value, digits)}${unit ? ` ${unit}` : ""}` : value ?? METRIC_UNAVAILABLE;
4754
+ const reportedFailures = /* @__PURE__ */ new Set();
4755
+ const reportOnce = (key, text) => {
4756
+ if (reportedFailures.has(key)) return;
4757
+ reportedFailures.add(key);
4758
+ host.log(text);
4759
+ };
4760
+ const failureText = (error) => error instanceof Error ? error.message : String(error);
4761
+ const moduleMetrics = () => {
4762
+ const store = activeBench ? benches.get(activeBench) : void 0;
4763
+ const out = {};
4764
+ for (const metric of store?.metrics ?? []) {
4765
+ try {
4766
+ out[metric.id] = metric.read();
4767
+ } catch (error) {
4768
+ out[metric.id] = METRIC_UNAVAILABLE;
4769
+ reportOnce(`${store?.moduleId}:metric:${metric.id}`, t("mod.metric_failed", { name: modules.get(store?.moduleId ?? "")?.name ?? "", metric: metric.id, error: failureText(error) }));
4770
+ }
4771
+ }
4772
+ return out;
4773
+ };
4774
+ const metricRows = () => {
4775
+ const sampled = roomMetrics.sample();
4776
+ const room2 = roomMetricIds.flatMap((id) => {
4777
+ const value = sampled[id];
4778
+ return value === void 0 ? [] : [{ id, name: t(`metric.${id}`), text: num(value, id === "frame_ms" ? 1 : 0) }];
4779
+ });
4780
+ const values = moduleMetrics();
4781
+ const declared = (activeBench ? benches.get(activeBench)?.metrics : void 0) ?? [];
4782
+ const module = declared.map((metric) => {
4783
+ const value = values[metric.id];
4784
+ const ratio = metric.max && typeof value === "number" ? value / metric.max : void 0;
4785
+ return { id: metric.id, name: metric.name, text: metricText(value, metric.digits ?? 0, metric.unit), ...ratio === void 0 ? {} : { ratio } };
4786
+ });
4787
+ return { room: room2, module };
4788
+ };
4789
+ const showBench = () => {
4790
+ if (!ui) return;
4791
+ ui.bench(benchView());
4792
+ ui.metrics(metricRows());
4793
+ };
4285
4794
  const hostListeners = /* @__PURE__ */ new Set();
4286
4795
  const frameListeners = /* @__PURE__ */ new Set();
4287
4796
  let reloadingSeen = false;
@@ -4297,7 +4806,7 @@ function createTestRoom(options) {
4297
4806
  camera,
4298
4807
  locale,
4299
4808
  groundAt,
4300
- tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading }),
4809
+ tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading, mode: tool.mode }),
4301
4810
  on(listener) {
4302
4811
  hostListeners.add(listener);
4303
4812
  return () => hostListeners.delete(listener);
@@ -4310,6 +4819,23 @@ function createTestRoom(options) {
4310
4819
  action("module_note", text, { data });
4311
4820
  }
4312
4821
  };
4822
+ const lendBench = (store) => ({
4823
+ value: store.value,
4824
+ layer: store.layer,
4825
+ onChange: store.onChange,
4826
+ patch: store.patch,
4827
+ look: (eye, target) => room.look(eye, target)
4828
+ });
4829
+ const checkModuleBench = (mod, fail) => {
4830
+ const store = createBenchStore(mod, locale, (error) => reportOnce(`${mod.id}:listener`, t("mod.listener_failed", { name: mod.name, error: failureText(error) })));
4831
+ const { controls, layers, metrics } = store.declared;
4832
+ if (controls.length + layers.length + metrics.length === 0) return null;
4833
+ const r = checkBench(store.declared);
4834
+ 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("; ") });
4835
+ moduleCheck(r.ok, text, { module: mod.id, check: "bench", problems: r.problems });
4836
+ for (const p of r.problems) fail(`bench: ${p}`);
4837
+ return r.ok ? store : null;
4838
+ };
4313
4839
  const loadModule = (mod) => {
4314
4840
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
4315
4841
  const fail = (problem) => {
@@ -4374,8 +4900,29 @@ function createTestRoom(options) {
4374
4900
  report.scenarios.push(sc.id);
4375
4901
  }
4376
4902
  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 } });
4903
+ const bench = checkModuleBench(mod, fail);
4904
+ detachers.get(mod.id)?.();
4905
+ detachers.delete(mod.id);
4906
+ const previous = benches.get(mod.id);
4907
+ const kept = previous?.encode();
4908
+ previous?.dispose();
4909
+ benches.delete(mod.id);
4910
+ for (const key of [...reportedFailures]) if (key.startsWith(`${mod.id}:`)) reportedFailures.delete(key);
4377
4911
  modules.set(mod.id, mod);
4378
- if (mod.attach) detachers.push(mod.attach(host));
4912
+ if (bench) {
4913
+ const seed = options.bench?.state?.[mod.id];
4914
+ const restored = kept ?? (typeof seed === "string" ? seed : void 0);
4915
+ if (restored !== void 0) bench.restore(restored);
4916
+ benches.set(mod.id, bench);
4917
+ bench.watch(() => {
4918
+ options.bench?.onChange?.(mod.id, bench.encode());
4919
+ if (activeBench === mod.id) ui?.bench(benchView());
4920
+ });
4921
+ if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
4922
+ } else if (activeBench === mod.id) activeBench = lastBench();
4923
+ if (mod.attach) detachers.set(mod.id, mod.attach(bench ? { ...host, bench: lendBench(bench) } : host));
4924
+ bench?.sendInitial();
4925
+ showBench();
4379
4926
  ui?.refresh();
4380
4927
  return report;
4381
4928
  };
@@ -4454,6 +5001,8 @@ function createTestRoom(options) {
4454
5001
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
4455
5002
  const label = mod?.group ?? S.groups[area] ?? area;
4456
5003
  ui?.showGroup(label);
5004
+ activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
5005
+ showBench();
4457
5006
  const layout = mod?.area?.(locale);
4458
5007
  if (layout) return layArea(layout);
4459
5008
  const items = catalog.filter((item) => item.group === label);
@@ -4545,8 +5094,18 @@ function createTestRoom(options) {
4545
5094
  loadModule,
4546
5095
  animations: animationsOf,
4547
5096
  playAnimation,
5097
+ bench(moduleId) {
5098
+ const id = moduleId ?? activeBench;
5099
+ return (id ? benches.get(id) : void 0) ?? null;
5100
+ },
5101
+ metrics() {
5102
+ return { room: roomMetrics.sample(), module: moduleMetrics() };
5103
+ },
4548
5104
  dispose() {
4549
- for (const detach of detachers.splice(0)) detach();
5105
+ for (const detach of detachers.values()) detach();
5106
+ detachers.clear();
5107
+ for (const store of benches.values()) store.dispose();
5108
+ benches.clear();
4550
5109
  cancelAnimationFrame?.(raf);
4551
5110
  window.removeEventListener("resize", onResize);
4552
5111
  player.dispose();
@@ -4617,19 +5176,33 @@ function createTestRoom(options) {
4617
5176
  onPlay: (objectId, clipId) => {
4618
5177
  playAnimation(clipId, objectId);
4619
5178
  },
4620
- onResize: resize
5179
+ onResize: resize,
5180
+ onControl: (id, value) => {
5181
+ if (activeBench) benches.get(activeBench)?.set(id, value);
5182
+ },
5183
+ onLayer: (id, on) => {
5184
+ if (activeBench) benches.get(activeBench)?.setLayer(id, on);
5185
+ },
5186
+ onAction: (id) => {
5187
+ if (activeBench) benches.get(activeBench)?.act(id);
5188
+ }
4621
5189
  });
4622
5190
  ui.hud(hudState());
5191
+ showBench();
4623
5192
  }
4624
5193
  let raf = 0;
4625
5194
  if (ownRenderer && typeof requestAnimationFrame === "function") {
4626
5195
  let last = performance.now();
5196
+ ownsLoop = true;
4627
5197
  const loop = (now) => {
4628
5198
  raf = requestAnimationFrame(loop);
4629
- const dt = Math.min(0.05, (now - last) / 1e3);
5199
+ const realDelta = (now - last) / 1e3;
5200
+ roomMetrics.frame(realDelta);
5201
+ const dt = Math.min(0.05, realDelta);
4630
5202
  last = now;
4631
5203
  if (!scenarioRunning) update(dt);
4632
5204
  gl.render(scene, camera);
5205
+ roomMetrics.rendered();
4633
5206
  };
4634
5207
  raf = requestAnimationFrame(loop);
4635
5208
  }
@@ -4651,7 +5224,9 @@ export {
4651
5224
  buildFromSpec,
4652
5225
  createLog,
4653
5226
  createTestRoom,
5227
+ decodeBenchState,
4654
5228
  demoItems,
5229
+ encodeBenchState,
4655
5230
  locales,
4656
5231
  makeScenarios,
4657
5232
  resolveStrings,