@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.
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",
@@ -291,6 +306,9 @@ var en = {
291
306
  "ui.quality.medium": "medium",
292
307
  "ui.quality.high": "high",
293
308
  "ui.equip": "Equip as a tool",
309
+ "ui.search_items": "Search items (all groups)",
310
+ "ui.search_tests": "Search tests",
311
+ "ui.no_results": "Nothing found",
294
312
  "ui.objects": "{n} objects",
295
313
  "ui.tests": "Tests",
296
314
  "ui.run_all": "Run all",
@@ -508,6 +526,21 @@ var ptBR = {
508
526
  "ui.animations": "Anima\xE7\xF5es",
509
527
  "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
510
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",
511
544
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
512
545
  "act.clear": "Limpou a sala",
513
546
  "act.reset": "Consertou todos os objetos",
@@ -580,6 +613,9 @@ var ptBR = {
580
613
  "ui.quality.medium": "m\xE9dia",
581
614
  "ui.quality.high": "alta",
582
615
  "ui.equip": "Equipar como ferramenta",
616
+ "ui.search_items": "Buscar item (todas as abas)",
617
+ "ui.search_tests": "Buscar teste",
618
+ "ui.no_results": "Nada encontrado",
583
619
  "ui.objects": "{n} objetos",
584
620
  "ui.tests": "Testes",
585
621
  "ui.run_all": "Rodar todos",
@@ -1116,6 +1152,154 @@ function createArena(THREE, size, scene) {
1116
1152
  };
1117
1153
  }
1118
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
+
1119
1303
  // src/log.ts
1120
1304
  var MAX_ENTRIES = 5e3;
1121
1305
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -1244,6 +1428,56 @@ function createLog(options = {}) {
1244
1428
  return log;
1245
1429
  }
1246
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
+
1247
1481
  // src/narrate.ts
1248
1482
  var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
1249
1483
  function createNarrator(c) {
@@ -2314,11 +2548,218 @@ function scenariosFor(locale = "en") {
2314
2548
  }
2315
2549
  var SCENARIOS = scenariosFor("en");
2316
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
+
2317
2754
  // src/ui.ts
2318
2755
  var CSS = `
2319
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}
2320
2757
  .tr-root *{box-sizing:border-box}
2321
- .tr-root button,.tr-root select,.tr-root label{pointer-events:auto}
2758
+ .tr-root button,.tr-root select,.tr-root label,.tr-root input{pointer-events:auto}
2759
+ .tr-search{width:100%;margin:0 0 6px;padding:5px 8px;border-radius:5px;border:1px solid #3a404c;background:#0f1116;color:#e8eaee;font:inherit}
2760
+ .tr-search::placeholder{color:#7d8594}
2761
+ .tr-item small{color:#7d8594;margin-left:4px}
2762
+ .tr-empty{color:#9aa3b2;padding:4px 2px;grid-column:1/-1}
2322
2763
  .tr-cross{position:absolute;left:50%;top:50%;width:18px;height:18px;margin:-9px 0 0 -9px}
2323
2764
  .tr-cross:before,.tr-cross:after{content:"";position:absolute;background:#fff;opacity:.85;box-shadow:0 0 2px #000}
2324
2765
  .tr-cross:before{left:8px;top:0;width:2px;height:18px}.tr-cross:after{top:8px;left:0;height:2px;width:18px}
@@ -2362,6 +2803,13 @@ var CSS = `
2362
2803
  .tr-cat{flex:0 1 auto;max-height:40%}
2363
2804
  .tr-anim{flex:0 0 auto;max-height:18%}
2364
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}
2365
2813
  .tr-tests{flex:1 1 auto;min-height:100px}
2366
2814
  .tr-tests .list{display:flex;flex-direction:column;gap:3px;margin-top:6px}.tr-tests .list .tr-btn{text-align:left}
2367
2815
  .tr-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;align-items:center}
@@ -2369,7 +2817,8 @@ var CSS = `
2369
2817
  .tr-summary{margin-top:6px;color:#9aa3b2}
2370
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}}
2371
2819
  `;
2372
- var mk = (tag, cls, text) => {
2820
+ var fold = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
2821
+ var mk2 = (tag, cls, text) => {
2373
2822
  const e = document.createElement(tag);
2374
2823
  if (cls) e.className = cls;
2375
2824
  if (text !== void 0) e.textContent = text;
@@ -2377,42 +2826,42 @@ var mk = (tag, cls, text) => {
2377
2826
  };
2378
2827
  function createUi(o) {
2379
2828
  const { t } = o;
2380
- const style = mk("style");
2829
+ const style = mk2("style");
2381
2830
  style.textContent = CSS;
2382
- const root = mk("div", "tr-root");
2831
+ const root = mk2("div", "tr-root");
2383
2832
  const pos = getComputedStyle?.(o.container)?.position;
2384
2833
  if (!pos || pos === "static") o.container.style.position = "relative";
2385
2834
  root.appendChild(style);
2386
2835
  o.container.appendChild(root);
2387
- const cross = mk("div", "tr-cross");
2388
- cross.appendChild(mk("i"));
2389
- const top = mk("div", "tr-top");
2390
- const toolBox = mk("div", "tr-tool");
2391
- const status = mk("div", "tr-status");
2392
- const prog = mk("div", "tr-prog");
2393
- const progLabel = mk("b");
2394
- const progBar = mk("div", "tr-bar");
2395
- 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");
2396
2845
  progBar.appendChild(progFill);
2397
2846
  prog.append(progLabel, progBar);
2398
2847
  top.append(toolBox, status, prog);
2399
- const bottom = mk("div", "tr-bottom");
2400
- const held = mk("div", "tr-held");
2401
- 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");
2402
2851
  bottom.append(held, hotbar);
2403
- const help = mk("div", "tr-help");
2852
+ const help = mk2("div", "tr-help");
2404
2853
  help.textContent = t("ui.help");
2405
- const left = mk("div", "tr-left");
2406
- const insp = mk("div", "tr-panel tr-insp");
2407
- 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");
2408
2857
  left.append(insp, logPanel);
2409
- const logTitle = mk("h3", void 0, t("ui.log"));
2410
- const logBar = mk("div", "tr-logbar");
2411
- 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");
2412
2861
  const filters = { action: true, event: true, check: true };
2413
2862
  const filterBtns = [];
2414
2863
  for (const ch of ["action", "event", "check"]) {
2415
- const b = mk("button", "tr-btn on", t(`ui.filter.${ch}`));
2864
+ const b = mk2("button", "tr-btn on", t(`ui.filter.${ch}`));
2416
2865
  b.type = "button";
2417
2866
  b.onclick = () => {
2418
2867
  filters[ch] = !filters[ch];
@@ -2423,8 +2872,8 @@ function createUi(o) {
2423
2872
  logBar.appendChild(b);
2424
2873
  }
2425
2874
  let autoscroll = true;
2426
- const asLabel = mk("label");
2427
- const asBox = mk("input");
2875
+ const asLabel = mk2("label");
2876
+ const asBox = mk2("input");
2428
2877
  asBox.type = "checkbox";
2429
2878
  asBox.checked = true;
2430
2879
  asBox.onchange = () => {
@@ -2433,7 +2882,7 @@ function createUi(o) {
2433
2882
  asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
2434
2883
  logBar.appendChild(asLabel);
2435
2884
  const logBtn = (label, fn) => {
2436
- const b = mk("button", "tr-btn", label);
2885
+ const b = mk2("button", "tr-btn", label);
2437
2886
  b.type = "button";
2438
2887
  b.onclick = fn;
2439
2888
  logBar.appendChild(b);
@@ -2441,7 +2890,7 @@ function createUi(o) {
2441
2890
  logBtn(t("ui.copy_json"), () => {
2442
2891
  const text = o.log.toJSON();
2443
2892
  const select = () => {
2444
- const ta = mk("textarea");
2893
+ const ta = mk2("textarea");
2445
2894
  ta.value = text;
2446
2895
  ta.setAttribute("readonly", "");
2447
2896
  ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
@@ -2475,12 +2924,12 @@ function createUi(o) {
2475
2924
  const fill = (n, e) => {
2476
2925
  n.className = lineClass(e);
2477
2926
  n.textContent = "";
2478
- const tm = mk("time", void 0, fmtTime(e.t));
2927
+ const tm = mk2("time", void 0, fmtTime(e.t));
2479
2928
  n.append(tm, document.createTextNode(e.text));
2480
2929
  };
2481
2930
  const addNode = (e) => {
2482
2931
  if (!filters[e.channel]) return;
2483
- const n = mk("div");
2932
+ const n = mk2("div");
2484
2933
  fill(n, e);
2485
2934
  nodes.set(e.seq, n);
2486
2935
  logBody.appendChild(n);
@@ -2502,27 +2951,40 @@ function createUi(o) {
2502
2951
  if (n) fill(n, e);
2503
2952
  } else addNode(e);
2504
2953
  });
2505
- const right = mk("div", "tr-right");
2506
- const cat = mk("div", "tr-panel tr-cat");
2507
- const anim = mk("div", "tr-panel tr-anim");
2508
- const tests = mk("div", "tr-panel tr-tests");
2509
- right.append(cat, anim, tests);
2510
- cat.appendChild(mk("h3", void 0, t("ui.catalog")));
2511
- const tabs = mk("div", "tr-tabs");
2512
- const items = mk("div", "tr-items");
2513
- cat.append(tabs, items);
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");
2962
+ itemSearch.type = "search";
2963
+ itemSearch.placeholder = t("ui.search_items");
2964
+ const tabs = mk2("div", "tr-tabs");
2965
+ const items = mk2("div", "tr-items");
2966
+ cat.append(itemSearch, tabs, items);
2514
2967
  let activeGroup = "";
2968
+ const visibleItems = () => {
2969
+ const term = fold(itemSearch.value.trim());
2970
+ if (!term) return (o.groups().get(activeGroup) ?? []).map((item) => ({ item, group: activeGroup }));
2971
+ return [...o.groups()].flatMap(([group, list2]) => list2.filter((item) => fold(`${item.name} ${item.id} ${group}`).includes(term)).map((item) => ({ item, group })));
2972
+ };
2515
2973
  const renderItems = () => {
2516
2974
  items.textContent = "";
2517
- for (const it of o.groups().get(activeGroup) ?? []) {
2518
- const wrap = mk("div", "tr-item");
2519
- const b = mk("button", "tr-btn", it.name);
2975
+ const searching = itemSearch.value.trim() !== "";
2976
+ const found = visibleItems();
2977
+ if (!found.length) items.appendChild(mk2("div", "tr-empty", t("ui.no_results")));
2978
+ for (const { item: it, group } of found) {
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));
2520
2982
  b.type = "button";
2521
2983
  b.title = it.note ?? it.name;
2522
2984
  b.onclick = () => o.onSpawn(it.id);
2523
2985
  wrap.appendChild(b);
2524
2986
  if (it.weapon) {
2525
- const w = mk("button", "tr-btn", "\u2694");
2987
+ const w = mk2("button", "tr-btn", "\u2694");
2526
2988
  w.type = "button";
2527
2989
  w.title = t("ui.equip");
2528
2990
  w.onclick = () => o.onEquip(it.id);
@@ -2536,7 +2998,7 @@ function createUi(o) {
2536
2998
  const groupNames = [...o.groups().keys()];
2537
2999
  if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
2538
3000
  for (const g of groupNames) {
2539
- 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);
2540
3002
  b.type = "button";
2541
3003
  b.onclick = () => {
2542
3004
  activeGroup = g;
@@ -2547,9 +3009,11 @@ function createUi(o) {
2547
3009
  renderItems();
2548
3010
  };
2549
3011
  renderTabs();
2550
- const controls = mk("div", "tr-row");
3012
+ itemSearch.oninput = renderItems;
3013
+ for (const box2 of [itemSearch]) box2.addEventListener("keydown", (e) => e.stopPropagation());
3014
+ const controls = mk2("div", "tr-row");
2551
3015
  const ctl = (label, fn) => {
2552
- const b = mk("button", "tr-btn", label);
3016
+ const b = mk2("button", "tr-btn", label);
2553
3017
  b.type = "button";
2554
3018
  b.onclick = () => fn(b);
2555
3019
  controls.appendChild(b);
@@ -2570,26 +3034,34 @@ function createUi(o) {
2570
3034
  o.onFast(fast);
2571
3035
  });
2572
3036
  const tapBtn = ctl(t("ui.tap"), () => o.onTap());
2573
- const qSel = mk("select");
3037
+ const qSel = mk2("select");
2574
3038
  for (const q of ["low", "medium", "high"]) {
2575
- const op = mk("option", void 0, t(`ui.quality.${q}`));
3039
+ const op = mk2("option", void 0, t(`ui.quality.${q}`));
2576
3040
  op.value = q;
2577
3041
  qSel.appendChild(op);
2578
3042
  }
2579
3043
  qSel.value = o.quality;
2580
3044
  qSel.onchange = () => o.onQuality(qSel.value);
2581
- const qLabel = mk("label", void 0, t("ui.quality") + " ");
3045
+ const qLabel = mk2("label", void 0, t("ui.quality") + " ");
2582
3046
  qLabel.appendChild(qSel);
2583
3047
  controls.appendChild(qLabel);
2584
- const count = mk("span");
3048
+ const count = mk2("span");
2585
3049
  controls.appendChild(count);
2586
3050
  cat.appendChild(controls);
2587
- tests.appendChild(mk("h3", void 0, t("ui.tests")));
2588
- 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"));
2589
3053
  runAll.type = "button";
2590
- const summary = mk("div", "tr-summary");
2591
- const list = mk("div", "list");
2592
- tests.append(runAll, summary, list);
3054
+ const summary = mk2("div", "tr-summary");
3055
+ const testSearch = mk2("input", "tr-search");
3056
+ testSearch.type = "search";
3057
+ testSearch.placeholder = t("ui.search_tests");
3058
+ testSearch.addEventListener("keydown", (e) => e.stopPropagation());
3059
+ const list = mk2("div", "list");
3060
+ tests.append(runAll, summary, testSearch, list);
3061
+ testSearch.oninput = () => {
3062
+ const term = fold(testSearch.value.trim());
3063
+ for (const b of scBtns.values()) b.style.display = !term || fold(b.textContent ?? "").includes(term) ? "" : "none";
3064
+ };
2593
3065
  const scBtns = /* @__PURE__ */ new Map();
2594
3066
  let busy = false;
2595
3067
  const mark = (rep) => {
@@ -2607,7 +3079,7 @@ function createUi(o) {
2607
3079
  list.textContent = "";
2608
3080
  scBtns.clear();
2609
3081
  for (const s of o.scenarios()) {
2610
- const b = mk("button", "tr-btn", s.name);
3082
+ const b = mk2("button", "tr-btn", s.name);
2611
3083
  b.type = "button";
2612
3084
  b.onclick = async () => {
2613
3085
  if (busy) return;
@@ -2633,7 +3105,7 @@ function createUi(o) {
2633
3105
  busy = false;
2634
3106
  }
2635
3107
  };
2636
- const toggle = mk("button", "tr-btn tr-toggle", t("ui.panel"));
3108
+ const toggle = mk2("button", "tr-btn tr-toggle", t("ui.panel"));
2637
3109
  toggle.type = "button";
2638
3110
  const togglePanels = () => {
2639
3111
  root.classList.toggle("open");
@@ -2642,15 +3114,15 @@ function createUi(o) {
2642
3114
  toggle.onclick = togglePanels;
2643
3115
  root.append(cross, top, bottom, help, left, right, toggle);
2644
3116
  const bar = (value, max, cls) => {
2645
- const b = mk("div", `tr-bar ${cls}`);
2646
- const i = mk("i");
3117
+ const b = mk2("div", `tr-bar ${cls}`);
3118
+ const i = mk2("i");
2647
3119
  i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
2648
3120
  b.appendChild(i);
2649
3121
  return b;
2650
3122
  };
2651
3123
  const row = (k, v) => {
2652
- const r = mk("div", "r");
2653
- 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));
2654
3126
  return r;
2655
3127
  };
2656
3128
  const num = (n, d = 1) => {
@@ -2659,12 +3131,12 @@ function createUi(o) {
2659
3131
  };
2660
3132
  const renderInspector = (i) => {
2661
3133
  insp.textContent = "";
2662
- insp.appendChild(mk("h3", void 0, t("ui.inspector")));
3134
+ insp.appendChild(mk2("h3", void 0, t("ui.inspector")));
2663
3135
  if (!i) {
2664
- insp.appendChild(mk("div", void 0, t("ui.aim_at_something")));
3136
+ insp.appendChild(mk2("div", void 0, t("ui.aim_at_something")));
2665
3137
  return;
2666
3138
  }
2667
- insp.appendChild(mk("div", "t", `${i.object} \u203A ${i.part}`));
3139
+ insp.appendChild(mk2("div", "t", `${i.object} \u203A ${i.part}`));
2668
3140
  insp.appendChild(row(t("ins.material"), i.material));
2669
3141
  insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
2670
3142
  insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
@@ -2683,30 +3155,30 @@ function createUi(o) {
2683
3155
  insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
2684
3156
  }
2685
3157
  if (i.joints.length) {
2686
- insp.appendChild(mk("div", void 0, t("ins.joints") + ":"));
3158
+ insp.appendChild(mk2("div", void 0, t("ins.joints") + ":"));
2687
3159
  for (const j of i.joints) {
2688
3160
  const tools = j.tools.length ? j.tools.join(", ") : t("tool.none");
2689
- 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}`}`));
2690
3162
  }
2691
3163
  }
2692
- if (i.note) insp.appendChild(mk("div", void 0, i.note));
3164
+ if (i.note) insp.appendChild(mk2("div", void 0, i.note));
2693
3165
  };
2694
3166
  let lastAnim = "";
2695
3167
  const renderAnimations = (a) => {
2696
3168
  anim.textContent = "";
2697
- anim.appendChild(mk("h3", void 0, t("ui.animations")));
3169
+ anim.appendChild(mk2("h3", void 0, t("ui.animations")));
2698
3170
  if (!a) {
2699
- anim.appendChild(mk("div", "sub", t("ui.animations_aim")));
3171
+ anim.appendChild(mk2("div", "sub", t("ui.animations_aim")));
2700
3172
  return;
2701
3173
  }
2702
- anim.appendChild(mk("div", "sub", a.name));
3174
+ anim.appendChild(mk2("div", "sub", a.name));
2703
3175
  if (a.clips.length === 0) {
2704
- anim.appendChild(mk("div", "sub", t("ui.animations_none")));
3176
+ anim.appendChild(mk2("div", "sub", t("ui.animations_none")));
2705
3177
  return;
2706
3178
  }
2707
- const l = mk("div", "list");
3179
+ const l = mk2("div", "list");
2708
3180
  for (const c of a.clips) {
2709
- 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)`);
2710
3182
  b.type = "button";
2711
3183
  b.onclick = () => o.onPlay(a.objectId, c.id);
2712
3184
  l.appendChild(b);
@@ -2723,7 +3195,7 @@ function createUi(o) {
2723
3195
  hud(s) {
2724
3196
  toolBox.textContent = "";
2725
3197
  toolBox.append(document.createTextNode(s.toolName));
2726
- if (s.ammo) toolBox.append(mk("small", void 0, s.ammo));
3198
+ if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
2727
3199
  if (s.status) status.textContent = s.status;
2728
3200
  else if (!toastTimer) status.textContent = "";
2729
3201
  if (s.progress) {
@@ -2744,7 +3216,7 @@ function createUi(o) {
2744
3216
  ];
2745
3217
  if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
2746
3218
  for (const [k, label] of keys) {
2747
- held.append(mk("kbd", void 0, k), document.createTextNode(` ${label} `));
3219
+ held.append(mk2("kbd", void 0, k), document.createTextNode(` ${label} `));
2748
3220
  }
2749
3221
  } else held.style.display = "none";
2750
3222
  const key = s.slots.join("|") + s.page;
@@ -2754,9 +3226,9 @@ function createUi(o) {
2754
3226
  slotsEl.length = 0;
2755
3227
  hudPage = s.page - 1;
2756
3228
  s.slots.forEach((name, i) => {
2757
- const b = mk("button", "tr-slot");
3229
+ const b = mk2("button", "tr-slot");
2758
3230
  b.type = "button";
2759
- 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));
2760
3232
  b.title = name;
2761
3233
  b.onclick = () => o.onTool(hudPage * 10 + i);
2762
3234
  hotbar.appendChild(b);
@@ -2788,7 +3260,17 @@ function createUi(o) {
2788
3260
  renderTabs();
2789
3261
  renderScenarios();
2790
3262
  },
3263
+ showGroup(group) {
3264
+ activeGroup = group;
3265
+ itemSearch.value = "";
3266
+ renderTabs();
3267
+ },
2791
3268
  togglePanels,
3269
+ bench: benchPanel.show,
3270
+ metrics: benchPanel.metrics,
3271
+ get benchVisible() {
3272
+ return benchPanel.visible;
3273
+ },
2792
3274
  dispose() {
2793
3275
  offLog();
2794
3276
  if (toastTimer) clearTimeout(toastTimer);
@@ -2801,6 +3283,7 @@ function createUi(o) {
2801
3283
 
2802
3284
  // src/room.ts
2803
3285
  var FIXED_DT = 1 / 60;
3286
+ var METRICS_INTERVAL_S = 0.25;
2804
3287
  var v3 = (v) => [v.x, v.y, v.z];
2805
3288
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
2806
3289
  function createTestRoom(options) {
@@ -2858,7 +3341,13 @@ function createTestRoom(options) {
2858
3341
  const p = world.part(arena.table.id, arena.table.topPart);
2859
3342
  return !!p && p.status !== "broken" && p.status !== "melted" && p.status !== "ashes";
2860
3343
  };
2861
- 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;
3344
+ const restingTops = [];
3345
+ const restingTopAt = (x, z) => {
3346
+ let top = 0;
3347
+ 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);
3348
+ return top;
3349
+ };
3350
+ 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));
2862
3351
  const ownFluids = !options.fluids;
2863
3352
  const fluids = options.fluids ?? createFluids({ seed: 1, locale, liquids: options.liquids, groundAt });
2864
3353
  const pristineFluids = ownFluids ? fluids.serialize() : null;
@@ -2991,16 +3480,16 @@ function createTestRoom(options) {
2991
3480
  const puncture = (ev) => {
2992
3481
  const e = entryOf(ev.object);
2993
3482
  if (!e?.containerId || e.fluidPart !== ev.part) return;
2994
- const host = hostOf(e);
2995
- host.root.updateMatrixWorld(true);
2996
- tmpM.copy(host.root.matrixWorld).invert();
3483
+ const host2 = hostOf(e);
3484
+ host2.root.updateMatrixWorld(true);
3485
+ tmpM.copy(host2.root.matrixWorld).invert();
2997
3486
  const local = tmpV.set(ev.point[0], ev.point[1], ev.point[2]).applyMatrix4(tmpM);
2998
3487
  local.x -= e.fluidOffset[0];
2999
3488
  local.y -= e.fluidOffset[1];
3000
3489
  local.z -= e.fluidOffset[2];
3001
- host.root.getWorldQuaternion(tmpQ).invert();
3490
+ host2.root.getWorldQuaternion(tmpQ).invert();
3002
3491
  const n = tmpV2.set(ev.direction[0], ev.direction[1], ev.direction[2]).applyQuaternion(tmpQ);
3003
- const part = world.part(host.objectId, ev.part);
3492
+ const part = world.part(host2.objectId, ev.part);
3004
3493
  const hole = part?.holes[part.holes.length - 1];
3005
3494
  fluids.puncture(e.containerId, [local.x, local.y, local.z], [n.x, n.y, n.z], hole?.diameter ?? 9e-3);
3006
3495
  };
@@ -3206,6 +3695,7 @@ function createTestRoom(options) {
3206
3695
  };
3207
3696
  const clear = () => {
3208
3697
  clearEverything();
3698
+ restingTops.length = 0;
3209
3699
  pending = [];
3210
3700
  action("clear", t("act.clear"));
3211
3701
  };
@@ -3425,6 +3915,7 @@ function createTestRoom(options) {
3425
3915
  }
3426
3916
  });
3427
3917
  player.kick(recoil[0], recoil[1]);
3918
+ emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil, rounds: shots.filter((s) => s.pellet === 0).length, mode: tool.mode });
3428
3919
  const f = first;
3429
3920
  const d = f ? num(f.distance, 1) : "";
3430
3921
  let text;
@@ -3606,6 +4097,7 @@ function createTestRoom(options) {
3606
4097
  const detonate = (p, at, impact) => {
3607
4098
  const w = toolName(p.weapon);
3608
4099
  const results = world.explode(at, p.energy, p.radius, "player");
4100
+ renderer.explode(at, p.energy, p.radius);
3609
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 } });
3610
4102
  if (p.fire) {
3611
4103
  fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
@@ -3664,7 +4156,8 @@ function createTestRoom(options) {
3664
4156
  if (cat === "hand") return;
3665
4157
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
3666
4158
  if (res.status === "empty") {
3667
- if (!tool.reloading && tool.reserve > 0) tool.reload();
4159
+ emitHost({ type: "empty", weapon: toolId });
4160
+ if (!tool.reloading && tool.reserve > 0) startReload();
3668
4161
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
3669
4162
  return;
3670
4163
  }
@@ -3716,6 +4209,7 @@ function createTestRoom(options) {
3716
4209
  progress = null;
3717
4210
  action("tool", t("act.tool", { weapon: toolName(id) }), { data: { weapon: id } });
3718
4211
  fire("tool", id);
4212
+ emitHost({ type: "tool", weapon: id });
3719
4213
  };
3720
4214
  const cycleTool = (dir) => {
3721
4215
  if (!loadout.length) return;
@@ -3943,13 +4437,16 @@ function createTestRoom(options) {
3943
4437
  case "KeyR":
3944
4438
  if (heldPiece) heldYaw += Math.PI / 8;
3945
4439
  else {
3946
- tool.reload();
4440
+ startReload();
3947
4441
  say(t("hud.reloading"));
3948
4442
  }
3949
4443
  break;
3950
- case "KeyQ":
3951
- 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 });
3952
4448
  break;
4449
+ }
3953
4450
  case "KeyZ":
3954
4451
  tiltKey = 1;
3955
4452
  break;
@@ -4037,14 +4534,18 @@ function createTestRoom(options) {
4037
4534
  let timeScale = 1;
4038
4535
  let fastForward = false;
4039
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 });
4040
4541
  let qualityName = quality;
4041
4542
  const syncContainers = (sdt) => {
4042
4543
  for (const e of entries.values()) {
4043
4544
  if (!e.containerId) continue;
4044
- const host = hostOf(e);
4045
- host.root.updateMatrixWorld(true);
4046
- const q = host.root.getWorldQuaternion(tmpQ);
4047
- const p = tmpV.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(host.root.matrixWorld);
4545
+ const host2 = hostOf(e);
4546
+ host2.root.updateMatrixWorld(true);
4547
+ const q = host2.root.getWorldQuaternion(tmpQ);
4548
+ const p = tmpV.set(e.fluidOffset[0], e.fluidOffset[1], e.fluidOffset[2]).applyMatrix4(host2.root.matrixWorld);
4048
4549
  const vel = sdt > 0 ? [(p.x - e.last[0]) / sdt, (p.y - e.last[1]) / sdt, (p.z - e.last[2]) / sdt] : void 0;
4049
4550
  e.last = [p.x, p.y, p.z];
4050
4551
  fluids.setTransform(e.containerId, [p.x, p.y, p.z], [q.x, q.y, q.z, q.w], vel);
@@ -4055,9 +4556,9 @@ function createTestRoom(options) {
4055
4556
  if (!e.containerId || !e.fluidPart) continue;
4056
4557
  const c = fluids.container(e.containerId);
4057
4558
  if (!c) continue;
4058
- const host = hostOf(e);
4059
- const part = world.part(host.objectId, e.fluidPart);
4060
- if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host.objectId, e.fluidPart, c.liters);
4559
+ const host2 = hostOf(e);
4560
+ const part = world.part(host2.objectId, e.fluidPart);
4561
+ if (part && part.liters !== void 0 && Math.abs(part.liters - c.liters) > 1e-4) world.setLiters(host2.objectId, e.fluidPart, c.liters);
4061
4562
  }
4062
4563
  };
4063
4564
  let paused = false;
@@ -4088,6 +4589,9 @@ function createTestRoom(options) {
4088
4589
  updateImpacts();
4089
4590
  fluidView.update(dt);
4090
4591
  for (const s of tools.values()) s.update(dt);
4592
+ if (reloadingSeen && !tool.reloading) emitHost({ type: "reload", weapon: toolId, phase: "end" });
4593
+ reloadingSeen = tool.reloading;
4594
+ for (const update2 of frameListeners) update2(dt);
4091
4595
  for (const tr of tracers) {
4092
4596
  if (tr.life > 0) {
4093
4597
  tr.life -= dt;
@@ -4097,6 +4601,7 @@ function createTestRoom(options) {
4097
4601
  flush();
4098
4602
  };
4099
4603
  const update = (dt) => {
4604
+ if (!ownsLoop) roomMetrics.frame(dt);
4100
4605
  player.update(dt);
4101
4606
  if (tiltKey || heldTilt > 0) {
4102
4607
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
@@ -4115,6 +4620,11 @@ function createTestRoom(options) {
4115
4620
  hudAcc = 0;
4116
4621
  ui.hud(hudState());
4117
4622
  }
4623
+ metricsAcc += dt;
4624
+ if (metricsAcc >= METRICS_INTERVAL_S && ui) {
4625
+ metricsAcc = 0;
4626
+ if (ui.benchVisible) ui.metrics(metricRows());
4627
+ }
4118
4628
  };
4119
4629
  const hudState = () => {
4120
4630
  const page = Math.floor(toolIndex / 10);
@@ -4229,6 +4739,103 @@ function createTestRoom(options) {
4229
4739
  const moduleCheck = (ok, text, data, itemId) => {
4230
4740
  log.add({ channel: "check", type: ok ? "pass" : "fail", text: `${ok ? "\u2713" : "\u2717"} ${text}`, itemId, data: { ok, ...data } });
4231
4741
  };
4742
+ const modules = /* @__PURE__ */ new Map();
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
+ };
4794
+ const hostListeners = /* @__PURE__ */ new Set();
4795
+ const frameListeners = /* @__PURE__ */ new Set();
4796
+ let reloadingSeen = false;
4797
+ function emitHost(event) {
4798
+ for (const listener of hostListeners) listener(event);
4799
+ }
4800
+ function startReload() {
4801
+ if (tool.reload()) emitHost({ type: "reload", weapon: toolId, phase: "start" });
4802
+ }
4803
+ const host = {
4804
+ THREE,
4805
+ scene,
4806
+ camera,
4807
+ locale,
4808
+ groundAt,
4809
+ tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading, mode: tool.mode }),
4810
+ on(listener) {
4811
+ hostListeners.add(listener);
4812
+ return () => hostListeners.delete(listener);
4813
+ },
4814
+ onFrame(update2) {
4815
+ frameListeners.add(update2);
4816
+ return () => frameListeners.delete(update2);
4817
+ },
4818
+ log(text, data) {
4819
+ action("module_note", text, { data });
4820
+ }
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
+ };
4232
4839
  const loadModule = (mod) => {
4233
4840
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
4234
4841
  const fail = (problem) => {
@@ -4293,6 +4900,29 @@ function createTestRoom(options) {
4293
4900
  report.scenarios.push(sc.id);
4294
4901
  }
4295
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);
4911
+ modules.set(mod.id, mod);
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();
4296
4926
  ui?.refresh();
4297
4927
  return report;
4298
4928
  };
@@ -4348,6 +4978,39 @@ function createTestRoom(options) {
4348
4978
  }
4349
4979
  return report;
4350
4980
  };
4981
+ const layArea = (layout) => {
4982
+ const holders = layout.placements.filter((p) => p.surface);
4983
+ const rest = layout.placements.filter((p) => !p.surface);
4984
+ const placed = [...holders, ...rest].map((p) => {
4985
+ const so = spawn(p.item, p.position, p.rotationY ?? 0);
4986
+ if (p.surface) {
4987
+ const [hx, hz] = p.surface.halfSize;
4988
+ 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 });
4989
+ }
4990
+ return { so, asleep: p.asleep === true };
4991
+ });
4992
+ for (const { so, asleep } of placed) {
4993
+ const piece = asleep ? rendererPieces.get(so.objectId) : void 0;
4994
+ if (piece) piece.sleeping = true;
4995
+ }
4996
+ if (layout.view) room.look(layout.view.eye, layout.view.target);
4997
+ return placed.map(({ so }) => so);
4998
+ };
4999
+ const openArea = (area) => {
5000
+ clear();
5001
+ const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
5002
+ const label = mod?.group ?? S.groups[area] ?? area;
5003
+ ui?.showGroup(label);
5004
+ activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
5005
+ showBench();
5006
+ const layout = mod?.area?.(locale);
5007
+ if (layout) return layArea(layout);
5008
+ const items = catalog.filter((item) => item.group === label);
5009
+ const front = items.filter((item) => item.movable !== false);
5010
+ const back = items.filter((item) => item.movable === false);
5011
+ const row = (list, gap, z) => list.map((item, index) => spawn(item.id, [(index - (list.length - 1) / 2) * gap, 0, z], 0));
5012
+ return [...row(front, 1.6, 1.5), ...row(back, 4.5, -4)];
5013
+ };
4351
5014
  const room = {
4352
5015
  world,
4353
5016
  renderer,
@@ -4427,10 +5090,22 @@ function createTestRoom(options) {
4427
5090
  fastForward = on;
4428
5091
  },
4429
5092
  equip,
5093
+ openArea,
4430
5094
  loadModule,
4431
5095
  animations: animationsOf,
4432
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
+ },
4433
5104
  dispose() {
5105
+ for (const detach of detachers.values()) detach();
5106
+ detachers.clear();
5107
+ for (const store of benches.values()) store.dispose();
5108
+ benches.clear();
4434
5109
  cancelAnimationFrame?.(raf);
4435
5110
  window.removeEventListener("resize", onResize);
4436
5111
  player.dispose();
@@ -4501,25 +5176,40 @@ function createTestRoom(options) {
4501
5176
  onPlay: (objectId, clipId) => {
4502
5177
  playAnimation(clipId, objectId);
4503
5178
  },
4504
- 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
+ }
4505
5189
  });
4506
5190
  ui.hud(hudState());
5191
+ showBench();
4507
5192
  }
4508
5193
  let raf = 0;
4509
5194
  if (ownRenderer && typeof requestAnimationFrame === "function") {
4510
5195
  let last = performance.now();
5196
+ ownsLoop = true;
4511
5197
  const loop = (now) => {
4512
5198
  raf = requestAnimationFrame(loop);
4513
- 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);
4514
5202
  last = now;
4515
5203
  if (!scenarioRunning) update(dt);
4516
5204
  gl.render(scene, camera);
5205
+ roomMetrics.rendered();
4517
5206
  };
4518
5207
  raf = requestAnimationFrame(loop);
4519
5208
  }
4520
5209
  if (options.initial) {
4521
5210
  for (const [i, id] of options.initial.entries()) spawn(id, [(i - (options.initial.length - 1) / 2) * 1.3, 0, 0.5]);
4522
5211
  }
5212
+ if (options.area) openArea(options.area);
4523
5213
  return room;
4524
5214
  }
4525
5215
  function structuredCloneSpec(spec) {
@@ -4534,7 +5224,9 @@ export {
4534
5224
  buildFromSpec,
4535
5225
  createLog,
4536
5226
  createTestRoom,
5227
+ decodeBenchState,
4537
5228
  demoItems,
5229
+ encodeBenchState,
4538
5230
  locales,
4539
5231
  makeScenarios,
4540
5232
  resolveStrings,