@adatechnology/game-test-room 0.2.0 → 0.4.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",
@@ -234,6 +249,15 @@ var en = {
234
249
  "act.heat.air": "{weapon}: nothing in range",
235
250
  "act.throw.grenade": "{weapon}: thrown, fuse {fuse} s",
236
251
  "act.throw.molotov": "{weapon}: thrown, breaks on impact",
252
+ "act.launch": "{weapon}: launched, arms after {arm} m",
253
+ "act.place": "{weapon}: charge set down, waits for {triggers}",
254
+ "act.tripwire": "{weapon}: {who} crossed the trip wire",
255
+ "act.dud": "{weapon}: hit before arming ({distance} m), did not explode",
256
+ "act.backblast": "{weapon}: blast behind the tube, {radius} m, {n} objects in the cone, {player} on the shooter",
257
+ "act.blast_player": "{weapon}: you are in the {zone} zone, {distance} m away (stun {stun} s)",
258
+ "hud.no_charges": "No charge set down waits for the remote detonator",
259
+ "blast.zone.lethal": "lethal",
260
+ "blast.zone.injury": "injury",
237
261
  "act.explode": "{weapon}: exploded, {energy} J within {radius} m, {n} objects in range",
238
262
  "act.molotov_fire": "{weapon}: fire on the ground, radius {radius} m for {seconds} s",
239
263
  "act.tool.none": "{weapon}: nothing in reach",
@@ -259,6 +283,15 @@ var en = {
259
283
  "hud.reloading": "Reloading...",
260
284
  "hud.empty": "Empty",
261
285
  "hud.no_fuel": "Out of fuel",
286
+ "hud.fault": "Jammed ({fault}): press X to clear",
287
+ "hud.safe": "Safety on: press B",
288
+ "hud.clearing": "Clearing the jam...",
289
+ "hud.safety_on": "Safety on",
290
+ "hud.safety_off": "Safety off",
291
+ "fault.misfire": "misfire",
292
+ "fault.stovepipe": "stovepipe",
293
+ "fault.double_feed": "double feed",
294
+ "fault.failure_to_extract": "failure to extract",
262
295
  "hud.nothing_to_grab": "Nothing to grab here",
263
296
  "hud.cannot_grab": "{name} cannot be picked up",
264
297
  "hud.too_heavy": "{name} is too heavy ({kg} kg)",
@@ -269,7 +302,7 @@ var en = {
269
302
  "hud.throw": "throw",
270
303
  "hud.tilt": "tilt (hold)",
271
304
  "hud.equip": "equip {weapon}",
272
- "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 Tab: panels \xB7 Esc: free the mouse",
305
+ "ui.help": "Click: look \xB7 WASD: walk \xB7 Shift: run \xB7 Space: jump \xB7 1-0 / wheel: tool \xB7 LMB: use \xB7 E: grab/drop \xB7 G or RMB: throw \xB7 R: reload \xB7 Q: fire mode \xB7 X: clear a jam \xB7 B: safety \xB7 Y: detonate charges set down \xB7 Tab: panels \xB7 Esc: free the mouse",
273
306
  "ui.log": "Behaviour log",
274
307
  "ui.filter.action": "actions",
275
308
  "ui.filter.event": "events",
@@ -511,6 +544,21 @@ var ptBR = {
511
544
  "ui.animations": "Anima\xE7\xF5es",
512
545
  "ui.animations_aim": "Mire num item ou segure-o para ver as anima\xE7\xF5es.",
513
546
  "ui.animations_none": "Este item n\xE3o traz anima\xE7\xF5es.",
547
+ "mod.bench_ok": "{name}: bancada ok ({controls} controles, {layers} camadas, {metrics} contadores)",
548
+ "mod.bench_fail": "{name}: bancada quebrada: {problems}",
549
+ "mod.metric_failed": '{name}: o contador "{metric}" lan\xE7ou erro e mostra n/a: {error}',
550
+ "mod.listener_failed": "{name}: um ouvinte da bancada lan\xE7ou erro: {error}",
551
+ "ui.bench": "Bancada",
552
+ "ui.layers": "Camadas",
553
+ "ui.metrics": "Contadores",
554
+ "metric.fps": "FPS",
555
+ "metric.frame_ms": "ms/quadro",
556
+ "metric.draw_calls": "Chamadas de desenho",
557
+ "metric.triangles": "Tri\xE2ngulos",
558
+ "metric.objects": "Objetos",
559
+ "metric.fragments": "Fragmentos",
560
+ "metric.particles": "Part\xEDculas",
561
+ "metric.decals": "Decalques",
514
562
  "act.spawn": "Criou {name} em x {x} \xB7 z {z}",
515
563
  "act.clear": "Limpou a sala",
516
564
  "act.reset": "Consertou todos os objetos",
@@ -526,6 +574,15 @@ var ptBR = {
526
574
  "act.heat.air": "{weapon}: nada ao alcance",
527
575
  "act.throw.grenade": "{weapon}: arremessada, pavio de {fuse} s",
528
576
  "act.throw.molotov": "{weapon}: arremessado, quebra no impacto",
577
+ "act.launch": "{weapon}: disparado, arma depois de {arm} m",
578
+ "act.place": "{weapon}: carga posta, espera {triggers}",
579
+ "act.tripwire": "{weapon}: {who} cruzou o fio",
580
+ "act.dud": "{weapon}: bateu antes de armar ({distance} m), n\xE3o explodiu",
581
+ "act.backblast": "{weapon}: sopro atr\xE1s do tubo, {radius} m, {n} objetos no cone, {player} no atirador",
582
+ "act.blast_player": "{weapon}: voc\xEA est\xE1 na zona {zone}, a {distance} m (atordoado {stun} s)",
583
+ "hud.no_charges": "Nenhuma carga posta espera o detonador",
584
+ "blast.zone.lethal": "letal",
585
+ "blast.zone.injury": "de ferimento",
529
586
  "act.explode": "{weapon}: explodiu, {energy} J em {radius} m, {n} objetos ao alcance",
530
587
  "act.molotov_fire": "{weapon}: fogo no ch\xE3o, raio de {radius} m por {seconds} s",
531
588
  "act.tool.none": "{weapon}: nada ao alcance",
@@ -551,6 +608,15 @@ var ptBR = {
551
608
  "hud.reloading": "Recarregando...",
552
609
  "hud.empty": "Sem muni\xE7\xE3o",
553
610
  "hud.no_fuel": "Sem combust\xEDvel",
611
+ "hud.fault": "Travou ({fault}): aperte X para destravar",
612
+ "hud.safe": "Trava ligada: aperte B",
613
+ "hud.clearing": "Destravando...",
614
+ "hud.safety_on": "Trava ligada",
615
+ "hud.safety_off": "Trava desligada",
616
+ "fault.misfire": "nega",
617
+ "fault.stovepipe": "chamin\xE9",
618
+ "fault.double_feed": "dupla alimenta\xE7\xE3o",
619
+ "fault.failure_to_extract": "falha de extra\xE7\xE3o",
554
620
  "hud.nothing_to_grab": "Nada para pegar aqui",
555
621
  "hud.cannot_grab": "{name} n\xE3o d\xE1 para pegar",
556
622
  "hud.too_heavy": "{name} \xE9 pesado demais ({kg} kg)",
@@ -561,7 +627,7 @@ var ptBR = {
561
627
  "hud.throw": "arremessar",
562
628
  "hud.tilt": "inclinar (segurar)",
563
629
  "hud.equip": "equipar {weapon}",
564
- "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar \xB7 E: pegar/soltar \xB7 G ou bot\xE3o direito: arremessar \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
630
+ "ui.help": "Clique: olhar \xB7 WASD: andar \xB7 Shift: correr \xB7 Espa\xE7o: pular \xB7 1-0 / roda: ferramenta \xB7 bot\xE3o esquerdo: usar \xB7 E: pegar/soltar \xB7 G ou bot\xE3o direito: arremessar \xB7 R: recarregar \xB7 Q: modo de tiro \xB7 X: destravar \xB7 B: trava \xB7 Y: detonar cargas postas \xB7 Tab: pain\xE9is \xB7 Esc: soltar o mouse",
565
631
  "ui.log": "Registro de comportamento",
566
632
  "ui.filter.action": "a\xE7\xF5es",
567
633
  "ui.filter.event": "eventos",
@@ -1122,6 +1188,494 @@ function createArena(THREE, size, scene) {
1122
1188
  };
1123
1189
  }
1124
1190
 
1191
+ // src/bench.ts
1192
+ var snap = (control, value) => {
1193
+ const clamped = Math.min(control.max, Math.max(control.min, value));
1194
+ if (!control.step) return clamped;
1195
+ const stepped = control.min + Math.round((clamped - control.min) / control.step) * control.step;
1196
+ return Number(Math.min(control.max, stepped).toFixed(10));
1197
+ };
1198
+ function fitValue(control, value) {
1199
+ if (control.kind === "number") return typeof value === "number" && Number.isFinite(value) ? snap(control, value) : void 0;
1200
+ if (control.kind === "boolean") return typeof value === "boolean" ? value : void 0;
1201
+ if (control.kind === "choice") return typeof value === "string" && control.options.some((option) => option.id === value) ? value : void 0;
1202
+ return void 0;
1203
+ }
1204
+ var fromText = (control, text) => {
1205
+ if (control.kind === "number") return text.trim() === "" ? Number.NaN : Number(text);
1206
+ if (control.kind === "boolean") return text === "1" ? true : text === "0" ? false : void 0;
1207
+ return text;
1208
+ };
1209
+ var toText = (value) => typeof value === "boolean" ? value ? "1" : "0" : String(value);
1210
+ function encodeBenchState(controls, layers) {
1211
+ const pairs = [];
1212
+ for (const control of controls) if (control.value !== void 0) pairs.push(`${control.id}=${encodeURIComponent(toText(control.value))}`);
1213
+ for (const layer of layers) pairs.push(`${layer.id}=${layer.on ? "1" : "0"}`);
1214
+ return pairs.join(";");
1215
+ }
1216
+ function decodeBenchState(encoded, controls, layers) {
1217
+ const out = /* @__PURE__ */ new Map();
1218
+ for (const pair of encoded.split(";")) {
1219
+ const at = pair.indexOf("=");
1220
+ if (at <= 0) continue;
1221
+ const id = pair.slice(0, at);
1222
+ let text;
1223
+ try {
1224
+ text = decodeURIComponent(pair.slice(at + 1));
1225
+ } catch {
1226
+ continue;
1227
+ }
1228
+ const control = controls.find((c) => c.id === id);
1229
+ if (control) {
1230
+ const value = fitValue(control, fromText(control, text));
1231
+ if (value !== void 0) out.set(id, value);
1232
+ continue;
1233
+ }
1234
+ if (layers.some((layer) => layer.id === id) && (text === "1" || text === "0")) out.set(id, text === "1");
1235
+ }
1236
+ return out;
1237
+ }
1238
+ function createBenchStore(module, locale, onListenerError) {
1239
+ const declared = { controls: module.controls?.(locale) ?? [], layers: module.layers?.(locale) ?? [], metrics: module.metrics?.(locale) ?? [] };
1240
+ const controls = declared.controls.map((control) => ({ ...control, disabled: false }));
1241
+ const values = /* @__PURE__ */ new Map();
1242
+ for (const control of controls) if (control.kind !== "action") values.set(control.id, control.default);
1243
+ const layers = new Map(declared.layers.map((layer) => [layer.id, layer.default !== false]));
1244
+ const listeners = /* @__PURE__ */ new Set();
1245
+ const watchers = /* @__PURE__ */ new Set();
1246
+ const emit = (change) => {
1247
+ for (const listener of listeners) {
1248
+ try {
1249
+ listener(change);
1250
+ } catch (error) {
1251
+ onListenerError?.(error);
1252
+ }
1253
+ }
1254
+ };
1255
+ const changed = () => {
1256
+ for (const watcher of watchers) watcher();
1257
+ };
1258
+ const controlOf = (id) => controls.find((control) => control.id === id);
1259
+ const store = {
1260
+ moduleId: module.id,
1261
+ metrics: declared.metrics,
1262
+ declared,
1263
+ controls: () => controls.map((control) => ({ ...control, value: values.get(control.id) })),
1264
+ layers: () => declared.layers.map((layer) => ({ ...layer, on: layers.get(layer.id) === true })),
1265
+ value: (id) => values.get(id),
1266
+ layer: (id) => layers.get(id) === true,
1267
+ set(id, value) {
1268
+ const control = controlOf(id);
1269
+ if (!control || control.disabled) return false;
1270
+ const fitted = fitValue(control, value);
1271
+ if (fitted === void 0) return false;
1272
+ values.set(id, fitted);
1273
+ emit({ kind: "control", id, value: fitted });
1274
+ changed();
1275
+ return true;
1276
+ },
1277
+ setLayer(id, on) {
1278
+ if (!layers.has(id)) return false;
1279
+ layers.set(id, on);
1280
+ emit({ kind: "layer", id, on });
1281
+ changed();
1282
+ return true;
1283
+ },
1284
+ act(id) {
1285
+ const control = controlOf(id);
1286
+ if (!control || control.kind !== "action" || control.disabled) return false;
1287
+ emit({ kind: "action", id });
1288
+ return true;
1289
+ },
1290
+ encode: () => encodeBenchState(store.controls(), store.layers()),
1291
+ onChange(listener) {
1292
+ listeners.add(listener);
1293
+ return () => listeners.delete(listener);
1294
+ },
1295
+ watch(listener) {
1296
+ watchers.add(listener);
1297
+ return () => watchers.delete(listener);
1298
+ },
1299
+ patch(id, patch) {
1300
+ const index = controls.findIndex((control2) => control2.id === id);
1301
+ if (index < 0) return;
1302
+ let control = controls[index];
1303
+ if (patch.hidden !== void 0) control = { ...control, hidden: patch.hidden };
1304
+ if (patch.disabled !== void 0) control = { ...control, disabled: patch.disabled };
1305
+ if (patch.options && control.kind === "choice" && patch.options.length > 0) {
1306
+ control = { ...control, options: [...patch.options] };
1307
+ const offered = control.options;
1308
+ if (!offered.some((option) => option.id === values.get(id))) values.set(id, offered[0].id);
1309
+ }
1310
+ controls[index] = control;
1311
+ if (patch.value !== void 0) {
1312
+ const fitted = fitValue(control, patch.value);
1313
+ if (fitted !== void 0) values.set(id, fitted);
1314
+ }
1315
+ changed();
1316
+ },
1317
+ sendInitial() {
1318
+ for (const control of controls) {
1319
+ const value = values.get(control.id);
1320
+ if (value !== void 0) emit({ kind: "control", id: control.id, value });
1321
+ }
1322
+ for (const layer of declared.layers) emit({ kind: "layer", id: layer.id, on: layers.get(layer.id) === true });
1323
+ changed();
1324
+ },
1325
+ restore(encoded) {
1326
+ for (const [id, value] of decodeBenchState(encoded, controls, declared.layers)) {
1327
+ if (typeof value === "boolean" && layers.has(id)) layers.set(id, value);
1328
+ else values.set(id, value);
1329
+ }
1330
+ },
1331
+ dispose() {
1332
+ listeners.clear();
1333
+ watchers.clear();
1334
+ }
1335
+ };
1336
+ return store;
1337
+ }
1338
+
1339
+ // src/explosives.ts
1340
+ import { backblastEffect, blastEffect, createExplosiveBody, tripwireSegment } from "@adatechnology/game-weapons";
1341
+
1342
+ // src/segment-box.ts
1343
+ var segmentTouchesBox = (from, to, min, max) => {
1344
+ let near = 0;
1345
+ let far = 1;
1346
+ for (let axis = 0; axis < 3; axis++) {
1347
+ const start = from[axis];
1348
+ const delta = to[axis] - start;
1349
+ const low = min[axis];
1350
+ const high = max[axis];
1351
+ if (Math.abs(delta) < 1e-12) {
1352
+ if (start < low || start > high) return false;
1353
+ continue;
1354
+ }
1355
+ const entering = (low - start) / delta;
1356
+ const leaving = (high - start) / delta;
1357
+ near = Math.max(near, Math.min(entering, leaving));
1358
+ far = Math.min(far, Math.max(entering, leaving));
1359
+ if (near > far) return false;
1360
+ }
1361
+ return true;
1362
+ };
1363
+
1364
+ // src/explosives.ts
1365
+ var CONE_BLAST_RADIUS = 0.5;
1366
+ var toArray = (vector) => [vector.x, vector.y, vector.z];
1367
+ function createExplosives(context) {
1368
+ const { THREE, scene, camera, world, renderer, arsenal, translate, format, action } = context;
1369
+ const projectiles = [];
1370
+ const meshes = [];
1371
+ const up = new THREE.Vector3(0, 1, 0);
1372
+ const clear = () => {
1373
+ projectiles.length = 0;
1374
+ for (const mesh of meshes) mesh.removeFromParent();
1375
+ meshes.length = 0;
1376
+ };
1377
+ const addMesh = (geometry, color, metalness, at) => {
1378
+ const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness }));
1379
+ mesh.castShadow = true;
1380
+ mesh.position.set(at[0], at[1], at[2]);
1381
+ scene.add(mesh);
1382
+ meshes.push(mesh);
1383
+ return mesh;
1384
+ };
1385
+ const remove = (projectile) => {
1386
+ for (const extra of projectile.attached ?? []) {
1387
+ extra.removeFromParent();
1388
+ const extraAt = meshes.indexOf(extra);
1389
+ if (extraAt >= 0) meshes.splice(extraAt, 1);
1390
+ }
1391
+ projectile.mesh.removeFromParent();
1392
+ const meshAt = meshes.indexOf(projectile.mesh);
1393
+ if (meshAt >= 0) meshes.splice(meshAt, 1);
1394
+ const at = projectiles.indexOf(projectile);
1395
+ if (at >= 0) projectiles.splice(at, 1);
1396
+ };
1397
+ const throwUse = (use) => {
1398
+ const isMolotov = !!use.fire;
1399
+ const isRocket = use.trigger === "impact" && (use.armDistance ?? 0) > 0;
1400
+ const geometry = isRocket ? new THREE.CylinderGeometry(0.04, 0.04, 0.5, 8) : isMolotov ? new THREE.CylinderGeometry(0.035, 0.04, 0.2, 8) : new THREE.SphereGeometry(0.045, 8, 6);
1401
+ const mesh = addMesh(geometry, isMolotov ? 3897915 : isRocket ? 7041116 : 4936480, isMolotov ? 0 : 0.4, use.origin);
1402
+ projectiles.push({
1403
+ mesh,
1404
+ pos: new THREE.Vector3(use.origin[0], use.origin[1], use.origin[2]),
1405
+ vel: new THREE.Vector3(use.velocity[0], use.velocity[1], use.velocity[2]),
1406
+ weapon: context.toolId(),
1407
+ fuse: use.fuse,
1408
+ burstOnImpact: isMolotov && use.fuse <= 0,
1409
+ energy: use.energy,
1410
+ radius: use.radius,
1411
+ fire: use.fire,
1412
+ age: 0,
1413
+ ...use.explosive !== void 0 ? { body: createExplosiveBody(use, arsenal.explosives, arsenal.rules.explosives), aligned: isRocket } : {}
1414
+ });
1415
+ const key = isRocket ? "act.launch" : isMolotov ? "act.throw.molotov" : "act.throw.grenade";
1416
+ action(isRocket ? "launch_projectile" : "throw_projectile", translate(key, { weapon: context.toolName(), fuse: format(use.fuse, 1), arm: format(use.armDistance ?? 0, 0) }), {
1417
+ data: { weapon: context.toolId(), fuse: use.fuse, explosive: use.explosive }
1418
+ });
1419
+ };
1420
+ const placementPoint = (use) => {
1421
+ const hit = context.withoutHeld(() => world.raycast(use.origin, use.direction, 3));
1422
+ if (hit) return [hit.point[0] + hit.normal[0] * 0.03, hit.point[1] + hit.normal[1] * 0.03, hit.point[2] + hit.normal[2] * 0.03];
1423
+ const x = use.origin[0] + use.direction[0] * 2;
1424
+ const z = use.origin[2] + use.direction[2] * 2;
1425
+ return [x, context.groundAt(x, z) + 0.03, z];
1426
+ };
1427
+ const markerAt = (at) => {
1428
+ const marker = new THREE.Group();
1429
+ marker.name = "charge_marker";
1430
+ const stake = new THREE.Mesh(new THREE.CylinderGeometry(0.01, 0.01, 0.4, 6), new THREE.MeshStandardMaterial({ color: 9136957, roughness: 0.8 }));
1431
+ stake.position.y = 0.2;
1432
+ const flag = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.12, 4), new THREE.MeshStandardMaterial({ color: 16738816, emissive: 16724736, emissiveIntensity: 0.4 }));
1433
+ flag.position.y = 0.46;
1434
+ marker.add(stake, flag);
1435
+ marker.position.set(at[0] + 0.12, at[1] - 0.03, at[2]);
1436
+ scene.add(marker);
1437
+ meshes.push(marker);
1438
+ return marker;
1439
+ };
1440
+ const wireLine = (from, to) => {
1441
+ const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...from), new THREE.Vector3(...to)]);
1442
+ const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 15262920 }));
1443
+ line.name = "charge_wire";
1444
+ scene.add(line);
1445
+ meshes.push(line);
1446
+ return line;
1447
+ };
1448
+ const touching = (wire) => context.bodies().filter((body) => segmentTouchesBox(wire.from, wire.to, toArray(body.box.min), toArray(body.box.max))).map((body) => body.id);
1449
+ const placeUse = (use) => {
1450
+ const at = placementPoint(use);
1451
+ const placed = { ...use, origin: at };
1452
+ const body = createExplosiveBody(placed, arsenal.explosives, arsenal.rules.explosives);
1453
+ if (!body) return;
1454
+ const mesh = addMesh(new THREE.BoxGeometry(0.18, 0.06, 0.1), use.explosive === "claymore" ? 5597999 : 12757112, 0.1, at);
1455
+ mesh.rotation.y = Math.atan2(use.direction[0], use.direction[2]);
1456
+ const attached = [markerAt(at)];
1457
+ const segment = tripwireSegment(placed);
1458
+ const projectile = { mesh, pos: new THREE.Vector3(at[0], at[1], at[2]), vel: new THREE.Vector3(), weapon: context.toolId(), fuse: 0, burstOnImpact: false, energy: use.energy, radius: use.radius, age: 0, body, placed: true, attached };
1459
+ if (segment) {
1460
+ const wire = { from: segment.from, to: segment.to, letThrough: /* @__PURE__ */ new Set() };
1461
+ for (const id of touching(wire)) wire.letThrough.add(id);
1462
+ attached.push(wireLine(segment.from, segment.to));
1463
+ projectile.wire = wire;
1464
+ }
1465
+ projectiles.push(projectile);
1466
+ action("place_charge", translate("act.place", { weapon: context.toolName(), triggers: use.triggers.join("/") || "-" }), { data: { weapon: context.toolId(), explosive: use.explosive, triggers: [...use.triggers], point: at, ...segment ? { wire: segment } : {} } });
1467
+ };
1468
+ const backblastUse = (use) => {
1469
+ renderer.emit("smoke", use.origin, use.direction, 14, use.radius);
1470
+ let reached = 0;
1471
+ for (const object of context.objects()) if (backblastEffect(use, toArray(object.position)) > 0) reached++;
1472
+ const onPlayer = backblastEffect(use, toArray(camera.position));
1473
+ action("backblast", translate("act.backblast", { weapon: context.toolName(), radius: format(use.radius, 0), n: reached, player: format(onPlayer, 2) }), {
1474
+ data: { weapon: context.toolId(), radius: use.radius, halfAngleDeg: use.halfAngleDeg, objects: reached, onPlayer }
1475
+ });
1476
+ };
1477
+ const surfaceProbe = (from, to) => {
1478
+ const dx = to[0] - from[0];
1479
+ const dy = to[1] - from[1];
1480
+ const dz = to[2] - from[2];
1481
+ const length = Math.hypot(dx, dy, dz);
1482
+ if (length < 1e-6) return null;
1483
+ const direction = [dx / length, dy / length, dz / length];
1484
+ const ray = context.withoutHeld(() => world.raycast(from, direction, length + 0.03));
1485
+ let best = ray && ray.distance <= length + 0.03 ? { point: ray.point, normal: ray.normal, ground: ray.normal[1] > 0.7, at: ray.distance } : null;
1486
+ const floor = context.groundAt(to[0], to[2]) + 0.045;
1487
+ if (to[1] < floor) {
1488
+ const point = [to[0], floor, to[2]];
1489
+ const at = Math.hypot(point[0] - from[0], point[1] - from[1], point[2] - from[2]);
1490
+ if (!best || at < best.at) best = { point, normal: [0, 1, 0], ground: true, at };
1491
+ }
1492
+ return best ? { point: best.point, normal: best.normal, ground: best.ground } : null;
1493
+ };
1494
+ const mostExposedPoint = (box2, detonation) => {
1495
+ const { min, max } = box2;
1496
+ const candidates = [toArray(box2.getCenter(new THREE.Vector3())), toArray(box2.clampPoint(new THREE.Vector3(...detonation.point), new THREE.Vector3()))];
1497
+ for (const x of [min.x, max.x]) for (const y of [min.y, max.y]) for (const z of [min.z, max.z]) candidates.push([x, y, z]);
1498
+ let best;
1499
+ for (const point of candidates) {
1500
+ const effect = blastEffect(detonation, point);
1501
+ if (effect.zone !== "none" && (!best || effect.falloff > best.falloff)) best = { point, falloff: effect.falloff };
1502
+ }
1503
+ return best?.point;
1504
+ };
1505
+ const coneBlast = (detonation) => {
1506
+ let reached = 0;
1507
+ for (const object of context.objects()) {
1508
+ const box2 = new THREE.Box3().setFromObject(object);
1509
+ if (box2.isEmpty()) box2.expandByPoint(object.position);
1510
+ const at = mostExposedPoint(box2, detonation);
1511
+ if (!at) continue;
1512
+ reached += world.explode(at, detonation.energy * blastEffect(detonation, at).falloff, CONE_BLAST_RADIUS, "player").length;
1513
+ }
1514
+ return reached;
1515
+ };
1516
+ const detonate = (projectile, detonation) => {
1517
+ const weaponName = context.toolName(projectile.weapon);
1518
+ const reach = Math.max(detonation.injuryRadius, detonation.lethalRadius);
1519
+ const reached = detonation.energy <= 0 ? 0 : detonation.cone ? coneBlast(detonation) : world.explode(detonation.point, detonation.energy, reach, "player").length;
1520
+ if (detonation.energy > 0) renderer.explode(detonation.point, detonation.energy, reach);
1521
+ if (detonation.stunSeconds !== void 0) renderer.emit("flash", detonation.point, [0, 1, 0], 1, 1);
1522
+ const onPlayer = blastEffect(detonation, toArray(camera.position));
1523
+ action("explode", translate("act.explode", { weapon: weaponName, energy: format(detonation.energy, 0), radius: format(reach, 1), n: reached }), {
1524
+ data: { weapon: projectile.weapon, explosive: detonation.explosive, cause: detonation.cause, energyJ: detonation.energy, radius: reach, point: detonation.point, player: onPlayer.zone, stunSeconds: onPlayer.stunSeconds }
1525
+ });
1526
+ if (onPlayer.zone !== "none") {
1527
+ action("blast_player", translate("act.blast_player", { weapon: weaponName, zone: translate(`blast.zone.${onPlayer.zone}`), distance: format(onPlayer.distance, 1), stun: format(onPlayer.stunSeconds, 1) }), { data: { ...onPlayer } });
1528
+ }
1529
+ context.emitHost({ type: "explosion", source: projectile.weapon, point: detonation.point, energy: detonation.energy, radius: reach, cause: detonation.cause });
1530
+ if (detonation.fire) context.startFire(projectile.weapon, detonation.point, detonation.fire, detonation.cause === "impact");
1531
+ };
1532
+ const detonateBare = (projectile, isImpact) => {
1533
+ detonate(projectile, {
1534
+ explosive: projectile.weapon,
1535
+ cause: isImpact ? "impact" : "timer",
1536
+ point: toArray(projectile.pos),
1537
+ direction: [0, 1, 0],
1538
+ at: projectile.age,
1539
+ energy: projectile.energy,
1540
+ lethalRadius: 0,
1541
+ injuryRadius: projectile.radius,
1542
+ noise: 0,
1543
+ destructionLevel: 0,
1544
+ ...projectile.fire ? { fire: projectile.fire } : {}
1545
+ });
1546
+ };
1547
+ const detonateCharges = () => {
1548
+ let count = 0;
1549
+ for (const projectile of [...projectiles]) {
1550
+ if (!projectile.placed || !projectile.body) continue;
1551
+ const event = projectile.body.trigger("remote");
1552
+ if (event?.type !== "detonate") continue;
1553
+ detonate(projectile, event.detonation);
1554
+ remove(projectile);
1555
+ count++;
1556
+ }
1557
+ if (count === 0) context.say(translate("hud.no_charges"));
1558
+ return count;
1559
+ };
1560
+ const stepBody = (projectile, body, dt) => {
1561
+ const events = body.step(dt, surfaceProbe);
1562
+ projectile.pos.set(body.position[0], body.position[1], body.position[2]);
1563
+ projectile.mesh.position.copy(projectile.pos);
1564
+ if (projectile.aligned && body.velocity[0] ** 2 + body.velocity[1] ** 2 + body.velocity[2] ** 2 > 1e-6) {
1565
+ projectile.mesh.quaternion.setFromUnitVectors(up, new THREE.Vector3(body.velocity[0], body.velocity[1], body.velocity[2]).normalize());
1566
+ } else if (!projectile.placed) projectile.mesh.rotation.x += dt * 8;
1567
+ for (const event of events) {
1568
+ if (event.type === "dud") action("dud", translate("act.dud", { weapon: context.toolName(projectile.weapon), distance: format(body.travelled, 1) }), { data: { weapon: projectile.weapon, point: event.point } });
1569
+ else if (event.type === "detonate") detonate(projectile, event.detonation);
1570
+ }
1571
+ };
1572
+ const flyBare = (projectile, dt) => {
1573
+ const { gravity } = arsenal.rules.explosives;
1574
+ let isBurst = false;
1575
+ projectile.vel.y -= gravity * dt;
1576
+ const speed = projectile.vel.length();
1577
+ const step = speed * dt;
1578
+ if (step > 1e-6) {
1579
+ const direction = [projectile.vel.x / speed, projectile.vel.y / speed, projectile.vel.z / speed];
1580
+ const hit = context.withoutHeld(() => world.raycast(toArray(projectile.pos), direction, step + 0.05));
1581
+ if (hit && hit.distance <= step + 0.04) {
1582
+ projectile.pos.set(hit.point[0] - direction[0] * 0.03, hit.point[1] - direction[1] * 0.03, hit.point[2] - direction[2] * 0.03);
1583
+ if (projectile.burstOnImpact) isBurst = true;
1584
+ else {
1585
+ const normal = hit.normal;
1586
+ const normalSpeed = projectile.vel.x * normal[0] + projectile.vel.y * normal[1] + projectile.vel.z * normal[2];
1587
+ projectile.vel.x = (projectile.vel.x - 2 * normalSpeed * normal[0]) * 0.35;
1588
+ projectile.vel.y = (projectile.vel.y - 2 * normalSpeed * normal[1]) * 0.35;
1589
+ projectile.vel.z = (projectile.vel.z - 2 * normalSpeed * normal[2]) * 0.35;
1590
+ }
1591
+ } else projectile.pos.addScaledVector(projectile.vel, dt);
1592
+ }
1593
+ const ground = context.groundAt(projectile.pos.x, projectile.pos.z) + 0.045;
1594
+ if (projectile.pos.y < ground) {
1595
+ projectile.pos.y = ground;
1596
+ if (projectile.burstOnImpact) isBurst = true;
1597
+ else {
1598
+ projectile.vel.y = Math.abs(projectile.vel.y) * 0.35;
1599
+ projectile.vel.x *= 0.7;
1600
+ projectile.vel.z *= 0.7;
1601
+ }
1602
+ }
1603
+ return isBurst;
1604
+ };
1605
+ const checkWire = (projectile, body) => {
1606
+ const wire = projectile.wire;
1607
+ if (!wire) return;
1608
+ const crossing = touching(wire);
1609
+ for (const id of [...wire.letThrough]) if (!crossing.includes(id)) wire.letThrough.delete(id);
1610
+ const crosser = crossing.find((id) => !wire.letThrough.has(id));
1611
+ if (crosser === void 0) return;
1612
+ const event = body.trigger("wire");
1613
+ if (event?.type !== "detonate") return;
1614
+ action("tripwire", translate("act.tripwire", { weapon: context.toolName(projectile.weapon), who: crosser }), { data: { weapon: projectile.weapon, body: crosser } });
1615
+ detonate(projectile, event.detonation);
1616
+ };
1617
+ const update = (dt) => {
1618
+ for (let index = projectiles.length - 1; index >= 0; index--) {
1619
+ const projectile = projectiles[index];
1620
+ if (!projectile) continue;
1621
+ projectile.age += dt;
1622
+ if (projectile.body) {
1623
+ if (projectile.wire && !projectile.body.done) checkWire(projectile, projectile.body);
1624
+ stepBody(projectile, projectile.body, dt);
1625
+ if (projectile.body.done || !projectile.placed && projectile.age > 60) remove(projectile);
1626
+ continue;
1627
+ }
1628
+ const isBurst = flyBare(projectile, dt);
1629
+ projectile.mesh.position.copy(projectile.pos);
1630
+ projectile.mesh.rotation.x += dt * 8;
1631
+ projectile.fuse -= dt;
1632
+ if (isBurst || !projectile.burstOnImpact && projectile.fuse <= 0 || projectile.age > 30) {
1633
+ detonateBare(projectile, isBurst);
1634
+ remove(projectile);
1635
+ }
1636
+ }
1637
+ };
1638
+ return { throwUse, placeUse, backblastUse, detonateCharges, update, clear };
1639
+ }
1640
+
1641
+ // src/host-events.ts
1642
+ var NO_RECOIL = [0, 0];
1643
+ var durationOf = (phase, id) => phase?.id === id ? { duration: Math.round((phase.end - phase.start) * 1e6) / 1e6 } : {};
1644
+ var toHostEvent = (weapon, event, options = {}) => {
1645
+ const { recoil = NO_RECOIL, phase } = options;
1646
+ switch (event.type) {
1647
+ case "draw":
1648
+ return event.phase === "start" ? { type: "tool", weapon, ...durationOf(phase, "draw") } : null;
1649
+ // a launcher spends a round and throws one projectile: the weapon counts none because it is a throw, a host counts the one that leaves
1650
+ case "shot":
1651
+ return { type: "shot", weapon, shots: Math.max(1, event.shots), recoil, rounds: event.rounds, mode: event.mode };
1652
+ case "misfire":
1653
+ return { type: "misfire", weapon };
1654
+ case "reload":
1655
+ return { type: "reload", weapon, phase: event.phase, ...event.phase === "start" ? { kind: event.kind, ...durationOf(phase, "reload") } : {} };
1656
+ case "reload_stage":
1657
+ return { type: "stage", weapon, stage: event.stage };
1658
+ case "feed":
1659
+ return { type: "feed", weapon };
1660
+ case "cycle":
1661
+ return { type: "cycle", weapon, phase: event.phase, ...event.phase === "start" ? durationOf(phase, "cycle") : {} };
1662
+ case "empty":
1663
+ return { type: "empty", weapon };
1664
+ case "mode":
1665
+ return { type: "mode", weapon, mode: event.mode };
1666
+ case "fault":
1667
+ return { type: "fault", weapon, fault: event.fault };
1668
+ case "clear":
1669
+ return { type: "clear", weapon, fault: event.fault, phase: event.phase, ...event.phase === "start" ? durationOf(phase, "clear") : {} };
1670
+ case "safety":
1671
+ return { type: "safety", weapon, on: event.on };
1672
+ case "cook":
1673
+ return { type: "cook", weapon, phase: event.phase, fuse: event.fuse };
1674
+ default:
1675
+ return null;
1676
+ }
1677
+ };
1678
+
1125
1679
  // src/log.ts
1126
1680
  var MAX_ENTRIES = 5e3;
1127
1681
  var pad = (n, w = 2) => String(n).padStart(w, "0");
@@ -1250,6 +1804,56 @@ function createLog(options = {}) {
1250
1804
  return log;
1251
1805
  }
1252
1806
 
1807
+ // src/metrics.ts
1808
+ var ROOM_METRICS = ["fps", "frame_ms", "draw_calls", "triangles", "objects", "fragments", "particles", "decals"];
1809
+ var WINDOW_S = 0.5;
1810
+ function createRoomMetrics(sources) {
1811
+ const wanted = new Set(sources.ids);
1812
+ const gl = sources.gl;
1813
+ let windowS = 0;
1814
+ let frames = 0;
1815
+ let fps;
1816
+ let frameMs;
1817
+ let calls;
1818
+ let triangles;
1819
+ let ownsRender = false;
1820
+ return {
1821
+ frame(dt) {
1822
+ frames++;
1823
+ windowS += dt;
1824
+ if (windowS < WINDOW_S) return;
1825
+ fps = frames / windowS;
1826
+ frameMs = windowS / frames * 1e3;
1827
+ frames = 0;
1828
+ windowS = 0;
1829
+ },
1830
+ rendered() {
1831
+ ownsRender = true;
1832
+ calls = gl.info?.render?.calls;
1833
+ triangles = gl.info?.render?.triangles;
1834
+ },
1835
+ sample() {
1836
+ const out = {};
1837
+ const put = (id, value) => {
1838
+ if (wanted.has(id) && value !== void 0) out[id] = value;
1839
+ };
1840
+ put("fps", fps);
1841
+ put("frame_ms", frameMs);
1842
+ const render = ownsRender ? { calls, triangles } : gl.info?.render;
1843
+ put("draw_calls", render?.calls);
1844
+ put("triangles", render?.triangles);
1845
+ if (!["objects", "fragments", "particles", "decals"].some((id) => wanted.has(id))) return out;
1846
+ const destruction = sources.destruction.stats();
1847
+ const fluids = sources.fluids.stats();
1848
+ put("objects", destruction.pieces);
1849
+ put("fragments", destruction.fragments);
1850
+ put("particles", destruction.particles + fluids.particles);
1851
+ put("decals", destruction.decals + fluids.stains);
1852
+ return out;
1853
+ }
1854
+ };
1855
+ }
1856
+
1253
1857
  // src/narrate.ts
1254
1858
  var SKIP_DAMAGE = /* @__PURE__ */ new Set(["fire", "heat"]);
1255
1859
  function createNarrator(c) {
@@ -1763,7 +2367,7 @@ function createNarrator(c) {
1763
2367
 
1764
2368
  // src/player.ts
1765
2369
  var EYE = 1.7;
1766
- var RADIUS = 0.3;
2370
+ var PLAYER_RADIUS = 0.3;
1767
2371
  var WALK = 3.4;
1768
2372
  var RUN = 6.2;
1769
2373
  var JUMP = 4.6;
@@ -1788,8 +2392,8 @@ function createPlayer(THREE, o) {
1788
2392
  };
1789
2393
  apply();
1790
2394
  const blocked = (x, z) => {
1791
- if (Math.abs(x) > o.limit - RADIUS || Math.abs(z) > o.limit - RADIUS) return true;
1792
- for (const c of o.colliders) if (x > c[0] - RADIUS && x < c[2] + RADIUS && z > c[1] - RADIUS && z < c[3] + RADIUS) return true;
2395
+ if (Math.abs(x) > o.limit - PLAYER_RADIUS || Math.abs(z) > o.limit - PLAYER_RADIUS) return true;
2396
+ for (const c of o.colliders) if (x > c[0] - PLAYER_RADIUS && x < c[2] + PLAYER_RADIUS && z > c[1] - PLAYER_RADIUS && z < c[3] + PLAYER_RADIUS) return true;
1793
2397
  return false;
1794
2398
  };
1795
2399
  const move = (dx, dz) => {
@@ -2011,6 +2615,7 @@ function makeScenarios(t, num, _locale = "en") {
2011
2615
  sc("steel_through_hole", ({ room, step, expect }) => {
2012
2616
  const id = at(room, "steel_sheet", 0, -4);
2013
2617
  room.setTool("pistol_9mm");
2618
+ room.waitUntilReady();
2014
2619
  look(room, [0, 1.1, -1.5], [0, 1.1, -4]);
2015
2620
  room.use();
2016
2621
  step(0.2);
@@ -2025,6 +2630,7 @@ function makeScenarios(t, num, _locale = "en") {
2025
2630
  sc("concrete_stops_bullet", ({ room, step, expect }) => {
2026
2631
  const id = at(room, "concrete_block", 0, -4);
2027
2632
  room.setTool("pistol_9mm");
2633
+ room.waitUntilReady();
2028
2634
  look(room, [0, 0.5, -1.5], [0, 0.2, -3.8]);
2029
2635
  room.use();
2030
2636
  step(0.2);
@@ -2040,6 +2646,7 @@ function makeScenarios(t, num, _locale = "en") {
2040
2646
  sc("hammer_dents_drum", ({ room, step, expect }) => {
2041
2647
  const id = at(room, "steel_drum", 0, -4);
2042
2648
  room.setTool("hammer");
2649
+ room.waitUntilReady();
2043
2650
  look(room, [0, 0.6, -3.35], [0, 0.5, -4]);
2044
2651
  let swings = 0;
2045
2652
  for (; swings < 8 && (part(room, id, "drum_body")?.dents.length ?? 0) === 0; swings++) {
@@ -2056,6 +2663,7 @@ function makeScenarios(t, num, _locale = "en") {
2056
2663
  sc("glass_shatters", ({ room, step, expect }) => {
2057
2664
  const id = at(room, "glass_pane", 0, -4);
2058
2665
  room.setTool("pistol_9mm");
2666
+ room.waitUntilReady();
2059
2667
  look(room, [0, 1.3, -1.5], [0, 1.05, -4]);
2060
2668
  let shots = 0;
2061
2669
  for (; shots < 10 && part(room, id, "pane")?.status !== "broken"; shots++) {
@@ -2070,6 +2678,7 @@ function makeScenarios(t, num, _locale = "en") {
2070
2678
  sc("shotgun_splinters_crate", ({ room, step, expect }) => {
2071
2679
  const id = at(room, "wooden_crate", 0, -4);
2072
2680
  room.setTool("shotgun_pump_12");
2681
+ room.waitUntilReady();
2073
2682
  look(room, [0, 0.6, -1.8], [0, 0.25, -3.77]);
2074
2683
  let shots = 0;
2075
2684
  const splinters = () => room.log.entries({ type: "fracture", objectId: id }).filter((e) => e.data?.style === "splinter").length;
@@ -2103,6 +2712,7 @@ function makeScenarios(t, num, _locale = "en") {
2103
2712
  const id = at(room, "chair", 0, -4);
2104
2713
  const before = room.renderer.pieces().length;
2105
2714
  room.setTool("kick");
2715
+ room.waitUntilReady();
2106
2716
  let kicks = 0;
2107
2717
  for (; kicks < 8 && room.renderer.pieces().length <= before && part(room, id, "leg_1"); kicks++) {
2108
2718
  const st = room.world.state(id);
@@ -2119,6 +2729,7 @@ function makeScenarios(t, num, _locale = "en") {
2119
2729
  sc("screwdriver_shelf_board", ({ room, step, expect }) => {
2120
2730
  const id = at(room, "shelf", 0, -4);
2121
2731
  room.setTool("screwdriver");
2732
+ room.waitUntilReady();
2122
2733
  look(room, [0, 0.8, -3.5], [0, 0.75, -3.86]);
2123
2734
  const t0 = room.log.entries().length ? room.log.entries().slice(-1)[0].t : 0;
2124
2735
  room.use(5);
@@ -2132,6 +2743,7 @@ function makeScenarios(t, num, _locale = "en") {
2132
2743
  sc("wrong_tool_refused", ({ room, expect }) => {
2133
2744
  const id = at(room, "steel_sheet", 0, -4);
2134
2745
  room.setTool("screwdriver");
2746
+ room.waitUntilReady();
2135
2747
  look(room, [0.2, 1.05, -3.6], [0.34, 1, -4]);
2136
2748
  room.use(0.5);
2137
2749
  const refused = count(room, "tool_refused");
@@ -2141,6 +2753,7 @@ function makeScenarios(t, num, _locale = "en") {
2141
2753
  sc("lighter_burns_crate", ({ room, step, expect }) => {
2142
2754
  const id = at(room, "wooden_crate", 0, -4);
2143
2755
  room.setTool("lighter");
2756
+ room.waitUntilReady();
2144
2757
  look(room, [0, 0.3, -3.65], [0, 0.25, -3.76]);
2145
2758
  room.use(5);
2146
2759
  const lit = anyBurning(room, id);
@@ -2163,6 +2776,7 @@ function makeScenarios(t, num, _locale = "en") {
2163
2776
  const melt0 = part(room, drum2, "drum_body")?.melt ?? 0;
2164
2777
  expect(melt0 === 0 && peak < 1450, t("sc.torch_melts_steel.wood", { peak: num(peak, 0), melt: num(melt0 * 100, 0), crate: yn(anyBurning(room, crate2)) }), { peak });
2165
2778
  room.setTool("cutting_torch");
2779
+ room.waitUntilReady();
2166
2780
  look(room, [-0.15, 0.55, -3.55], [-0.15, 0.5, -3.72]);
2167
2781
  room.use(20);
2168
2782
  const p = part(room, drum2, "drum_body");
@@ -2171,6 +2785,7 @@ function makeScenarios(t, num, _locale = "en") {
2171
2785
  sc("bucket_melts_drips", ({ room, step, expect }) => {
2172
2786
  const id = at(room, "plastic_bucket", 0, -4);
2173
2787
  room.setTool("flamethrower");
2788
+ room.waitUntilReady();
2174
2789
  look(room, [0, 0.5, -2.4], [0, 0.15, -4]);
2175
2790
  room.use(2.5);
2176
2791
  step(10);
@@ -2245,6 +2860,7 @@ function makeScenarios(t, num, _locale = "en") {
2245
2860
  sc("jerrycan_leaks_fuel", ({ room, step, expect }) => {
2246
2861
  const id = at(room, "jerrycan", 0, -3.5);
2247
2862
  room.setTool("pistol_9mm");
2863
+ room.waitUntilReady();
2248
2864
  look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2249
2865
  room.use();
2250
2866
  step(1.5);
@@ -2259,6 +2875,7 @@ function makeScenarios(t, num, _locale = "en") {
2259
2875
  const id = at(room, "jerrycan", 0, -3.5);
2260
2876
  const crate2 = at(room, "wooden_crate", 0.9, -3.2);
2261
2877
  room.setTool("pistol_9mm");
2878
+ room.waitUntilReady();
2262
2879
  look(room, [0, 0.4, -1.5], [0, 0.12, -3.43]);
2263
2880
  room.use();
2264
2881
  step(6);
@@ -2266,6 +2883,7 @@ function makeScenarios(t, num, _locale = "en") {
2266
2883
  expect(!!stain, t("sc.fuel_stain_burns.stain", { stain: yn(!!stain) }));
2267
2884
  if (!stain) return;
2268
2885
  room.setTool("lighter");
2886
+ room.waitUntilReady();
2269
2887
  look(room, [stain.center[0], 0.1, stain.center[2] + 0.06], [stain.center[0], 0, stain.center[2]]);
2270
2888
  room.use(1);
2271
2889
  step(4);
@@ -2301,6 +2919,7 @@ function makeScenarios(t, num, _locale = "en") {
2301
2919
  const before = anyBurning(room, id);
2302
2920
  expect(before, t("sc.extinguisher_puts_out_fire.burning", { burning: yn(before) }));
2303
2921
  room.setTool("extinguisher");
2922
+ room.waitUntilReady();
2304
2923
  look(room, [0, 0.9, -2.2], [0, 0.25, -4]);
2305
2924
  room.use(2.5);
2306
2925
  step(1);
@@ -2320,6 +2939,209 @@ function scenariosFor(locale = "en") {
2320
2939
  }
2321
2940
  var SCENARIOS = scenariosFor("en");
2322
2941
 
2942
+ // src/bench-panel.ts
2943
+ var SEGMENTED_MAX = 4;
2944
+ var mk = (tag, cls, text) => {
2945
+ const e = document.createElement(tag);
2946
+ if (cls) e.className = cls;
2947
+ if (text !== void 0) e.textContent = text;
2948
+ return e;
2949
+ };
2950
+ var shapeOf = (view) => JSON.stringify([view.title, view.controls.map((c) => ({ ...c, value: void 0 })), view.layers.map((l) => [l.id, l.name])]);
2951
+ function createBenchPanel(o) {
2952
+ const { t } = o;
2953
+ const element = mk("div", "tr-panel tr-bench");
2954
+ element.style.display = "none";
2955
+ element.addEventListener("keydown", (e) => e.stopPropagation());
2956
+ const roomLine = mk("div", "sub tr-bench-room");
2957
+ const body = mk("div");
2958
+ const metricTitle = mk("div", "sub", t("ui.metrics"));
2959
+ metricTitle.style.display = "none";
2960
+ const metricList = mk("dl", "tr-metrics");
2961
+ element.append(mk("h3", void 0, t("ui.bench")), roomLine, body, metricTitle, metricList);
2962
+ let shape = "";
2963
+ let hasRoom = false;
2964
+ let hasBench = false;
2965
+ const refreshers = [];
2966
+ const metricNodes = /* @__PURE__ */ new Map();
2967
+ let metricKey = "";
2968
+ const syncDisplay = () => {
2969
+ element.style.display = hasRoom || hasBench ? "" : "none";
2970
+ };
2971
+ const numberControl = (control) => {
2972
+ const wrap = mk("label", "tr-ctl");
2973
+ const input = mk("input");
2974
+ input.type = "range";
2975
+ input.min = String(control.min);
2976
+ input.max = String(control.max);
2977
+ input.step = control.step ? String(control.step) : "any";
2978
+ input.disabled = control.disabled;
2979
+ const reading = mk("output");
2980
+ input.oninput = () => o.onControl(control.id, Number(input.value));
2981
+ wrap.append(mk("span", void 0, control.name), input, reading);
2982
+ refreshers.push((view) => {
2983
+ const value = view.controls.find((c) => c.id === control.id)?.value;
2984
+ if (typeof value !== "number") return;
2985
+ if (Number(input.value) !== value) input.value = String(value);
2986
+ reading.textContent = `${o.num(value, control.step && control.step >= 1 ? 0 : 2)}${control.unit ? ` ${control.unit}` : ""}`;
2987
+ });
2988
+ return wrap;
2989
+ };
2990
+ const booleanControl = (control) => {
2991
+ const wrap = mk("label", "tr-ctl");
2992
+ const box2 = mk("input");
2993
+ box2.type = "checkbox";
2994
+ box2.disabled = control.disabled;
2995
+ box2.onchange = () => o.onControl(control.id, box2.checked);
2996
+ wrap.append(box2, document.createTextNode(` ${control.name}`));
2997
+ refreshers.push((view) => {
2998
+ box2.checked = view.controls.find((c) => c.id === control.id)?.value === true;
2999
+ });
3000
+ return wrap;
3001
+ };
3002
+ const choiceControl = (control) => {
3003
+ const wrap = mk("div", "tr-ctl");
3004
+ wrap.appendChild(mk("span", void 0, control.name));
3005
+ if (control.options.length <= SEGMENTED_MAX) {
3006
+ const group = mk("div", "tr-seg");
3007
+ group.setAttribute("role", "group");
3008
+ const buttons = control.options.map((option) => {
3009
+ const b = mk("button", "tr-btn", option.name);
3010
+ b.type = "button";
3011
+ b.disabled = control.disabled;
3012
+ b.onclick = () => o.onControl(control.id, option.id);
3013
+ group.appendChild(b);
3014
+ return { b, id: option.id };
3015
+ });
3016
+ wrap.appendChild(group);
3017
+ refreshers.push((view) => {
3018
+ const value = view.controls.find((c) => c.id === control.id)?.value;
3019
+ for (const { b, id } of buttons) {
3020
+ b.setAttribute("aria-pressed", String(id === value));
3021
+ b.classList.toggle("on", id === value);
3022
+ }
3023
+ });
3024
+ return wrap;
3025
+ }
3026
+ const select = mk("select");
3027
+ select.disabled = control.disabled;
3028
+ for (const option of control.options) {
3029
+ const op = mk("option", void 0, option.name);
3030
+ op.value = option.id;
3031
+ select.appendChild(op);
3032
+ }
3033
+ select.onchange = () => o.onControl(control.id, select.value);
3034
+ wrap.appendChild(select);
3035
+ refreshers.push((view) => {
3036
+ const value = view.controls.find((c) => c.id === control.id)?.value;
3037
+ if (typeof value === "string") select.value = value;
3038
+ });
3039
+ return wrap;
3040
+ };
3041
+ const actionControl = (control) => {
3042
+ const b = mk("button", "tr-btn", control.name);
3043
+ b.type = "button";
3044
+ b.disabled = control.disabled;
3045
+ b.onclick = () => o.onAction(control.id);
3046
+ return b;
3047
+ };
3048
+ const controlElement = (control) => {
3049
+ const el = control.kind === "number" ? numberControl(control) : control.kind === "boolean" ? booleanControl(control) : control.kind === "choice" ? choiceControl(control) : actionControl(control);
3050
+ if (control.hint) el.title = control.hint;
3051
+ return el;
3052
+ };
3053
+ const layerList = (layers) => {
3054
+ const wrap = mk("div", "tr-layers");
3055
+ wrap.appendChild(mk("div", "sub", t("ui.layers")));
3056
+ for (const layer of layers) {
3057
+ const label = mk("label", "tr-ctl");
3058
+ const box2 = mk("input");
3059
+ box2.type = "checkbox";
3060
+ box2.onchange = () => o.onLayer(layer.id, box2.checked);
3061
+ label.append(box2, document.createTextNode(` ${layer.name}`));
3062
+ wrap.appendChild(label);
3063
+ refreshers.push((view) => {
3064
+ box2.checked = view.layers.find((l) => l.id === layer.id)?.on === true;
3065
+ });
3066
+ }
3067
+ return wrap;
3068
+ };
3069
+ const rebuild = (view) => {
3070
+ body.textContent = "";
3071
+ refreshers.length = 0;
3072
+ body.appendChild(mk("div", "sub", view.title));
3073
+ const list = mk("div", "tr-ctls");
3074
+ for (const control of view.controls) if (!control.hidden) list.appendChild(controlElement(control));
3075
+ body.appendChild(list);
3076
+ if (view.layers.length) body.appendChild(layerList(view.layers));
3077
+ };
3078
+ const renderMetrics = (rows) => {
3079
+ metricList.textContent = "";
3080
+ metricNodes.clear();
3081
+ metricTitle.style.display = rows.length ? "" : "none";
3082
+ for (const row of rows) {
3083
+ const dd = mk("dd");
3084
+ const text = mk("span", void 0, row.text);
3085
+ dd.appendChild(text);
3086
+ let bar = null;
3087
+ if (row.ratio !== void 0) {
3088
+ bar = mk("div", "tr-bar");
3089
+ bar.appendChild(mk("i"));
3090
+ dd.appendChild(bar);
3091
+ }
3092
+ metricList.append(mk("dt", void 0, row.name), dd);
3093
+ metricNodes.set(row.id, { text, bar });
3094
+ }
3095
+ };
3096
+ const fillBar = (bar, ratio) => {
3097
+ bar.className = `tr-bar${ratio > 1 ? " bad" : ratio >= 0.8 ? " warn" : ""}`;
3098
+ const fill = bar.firstElementChild;
3099
+ fill.style.width = `${Math.round(Math.max(0, Math.min(1, ratio)) * 100)}%`;
3100
+ };
3101
+ return {
3102
+ element,
3103
+ show(view) {
3104
+ hasBench = view !== null;
3105
+ syncDisplay();
3106
+ if (!view) {
3107
+ shape = "";
3108
+ body.textContent = "";
3109
+ refreshers.length = 0;
3110
+ renderMetrics([]);
3111
+ metricKey = "";
3112
+ return;
3113
+ }
3114
+ const next = shapeOf(view);
3115
+ if (next !== shape) {
3116
+ shape = next;
3117
+ rebuild(view);
3118
+ }
3119
+ for (const refresh of refreshers) refresh(view);
3120
+ },
3121
+ metrics(rows) {
3122
+ hasRoom = rows.room.length > 0;
3123
+ syncDisplay();
3124
+ roomLine.textContent = rows.room.map((row) => `${row.name} ${row.text}`).join(" \xB7 ");
3125
+ const key = rows.module.map((row) => `${row.id}:${row.ratio !== void 0}`).join("|");
3126
+ if (key !== metricKey) {
3127
+ metricKey = key;
3128
+ renderMetrics(rows.module);
3129
+ }
3130
+ for (const row of rows.module) {
3131
+ const node = metricNodes.get(row.id);
3132
+ if (!node) continue;
3133
+ node.text.textContent = row.text;
3134
+ if (node.bar && row.ratio !== void 0) fillBar(node.bar, row.ratio);
3135
+ }
3136
+ },
3137
+ get visible() {
3138
+ if (element.style.display === "none") return false;
3139
+ const parent = element.parentElement;
3140
+ return !parent || getComputedStyle(parent).display !== "none";
3141
+ }
3142
+ };
3143
+ }
3144
+
2323
3145
  // src/ui.ts
2324
3146
  var CSS = `
2325
3147
  .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 +3194,13 @@ var CSS = `
2372
3194
  .tr-cat{flex:0 1 auto;max-height:40%}
2373
3195
  .tr-anim{flex:0 0 auto;max-height:18%}
2374
3196
  .tr-anim .list{display:flex;flex-wrap:wrap;gap:4px}.tr-anim .sub{color:#9aa3b2;margin-bottom:4px}
3197
+ .tr-bench{flex:0 1 auto;max-height:34%}
3198
+ .tr-bench .sub{color:#9aa3b2;margin-bottom:4px}
3199
+ .tr-ctls{display:flex;flex-direction:column;gap:5px}
3200
+ .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}
3201
+ .tr-seg{display:inline-flex;flex-wrap:wrap;gap:2px}
3202
+ .tr-layers{margin-top:6px;display:flex;flex-direction:column;gap:2px}
3203
+ .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
3204
  .tr-tests{flex:1 1 auto;min-height:100px}
2376
3205
  .tr-tests .list{display:flex;flex-direction:column;gap:3px;margin-top:6px}.tr-tests .list .tr-btn{text-align:left}
2377
3206
  .tr-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;align-items:center}
@@ -2380,7 +3209,7 @@ var CSS = `
2380
3209
  @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
3210
  `;
2382
3211
  var fold = (text) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
2383
- var mk = (tag, cls, text) => {
3212
+ var mk2 = (tag, cls, text) => {
2384
3213
  const e = document.createElement(tag);
2385
3214
  if (cls) e.className = cls;
2386
3215
  if (text !== void 0) e.textContent = text;
@@ -2388,42 +3217,42 @@ var mk = (tag, cls, text) => {
2388
3217
  };
2389
3218
  function createUi(o) {
2390
3219
  const { t } = o;
2391
- const style = mk("style");
3220
+ const style = mk2("style");
2392
3221
  style.textContent = CSS;
2393
- const root = mk("div", "tr-root");
3222
+ const root = mk2("div", "tr-root");
2394
3223
  const pos = getComputedStyle?.(o.container)?.position;
2395
3224
  if (!pos || pos === "static") o.container.style.position = "relative";
2396
3225
  root.appendChild(style);
2397
3226
  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");
3227
+ const cross = mk2("div", "tr-cross");
3228
+ cross.appendChild(mk2("i"));
3229
+ const top = mk2("div", "tr-top");
3230
+ const toolBox = mk2("div", "tr-tool");
3231
+ const status = mk2("div", "tr-status");
3232
+ const prog = mk2("div", "tr-prog");
3233
+ const progLabel = mk2("b");
3234
+ const progBar = mk2("div", "tr-bar");
3235
+ const progFill = mk2("i");
2407
3236
  progBar.appendChild(progFill);
2408
3237
  prog.append(progLabel, progBar);
2409
3238
  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");
3239
+ const bottom = mk2("div", "tr-bottom");
3240
+ const held = mk2("div", "tr-held");
3241
+ const hotbar = mk2("div", "tr-hotbar");
2413
3242
  bottom.append(held, hotbar);
2414
- const help = mk("div", "tr-help");
3243
+ const help = mk2("div", "tr-help");
2415
3244
  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");
3245
+ const left = mk2("div", "tr-left");
3246
+ const insp = mk2("div", "tr-panel tr-insp");
3247
+ const logPanel = mk2("div", "tr-panel tr-log");
2419
3248
  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");
3249
+ const logTitle = mk2("h3", void 0, t("ui.log"));
3250
+ const logBar = mk2("div", "tr-logbar");
3251
+ const logBody = mk2("div", "tr-logbody");
2423
3252
  const filters = { action: true, event: true, check: true };
2424
3253
  const filterBtns = [];
2425
3254
  for (const ch of ["action", "event", "check"]) {
2426
- const b = mk("button", "tr-btn on", t(`ui.filter.${ch}`));
3255
+ const b = mk2("button", "tr-btn on", t(`ui.filter.${ch}`));
2427
3256
  b.type = "button";
2428
3257
  b.onclick = () => {
2429
3258
  filters[ch] = !filters[ch];
@@ -2434,8 +3263,8 @@ function createUi(o) {
2434
3263
  logBar.appendChild(b);
2435
3264
  }
2436
3265
  let autoscroll = true;
2437
- const asLabel = mk("label");
2438
- const asBox = mk("input");
3266
+ const asLabel = mk2("label");
3267
+ const asBox = mk2("input");
2439
3268
  asBox.type = "checkbox";
2440
3269
  asBox.checked = true;
2441
3270
  asBox.onchange = () => {
@@ -2444,7 +3273,7 @@ function createUi(o) {
2444
3273
  asLabel.append(asBox, document.createTextNode(" " + t("ui.autoscroll")));
2445
3274
  logBar.appendChild(asLabel);
2446
3275
  const logBtn = (label, fn) => {
2447
- const b = mk("button", "tr-btn", label);
3276
+ const b = mk2("button", "tr-btn", label);
2448
3277
  b.type = "button";
2449
3278
  b.onclick = fn;
2450
3279
  logBar.appendChild(b);
@@ -2452,7 +3281,7 @@ function createUi(o) {
2452
3281
  logBtn(t("ui.copy_json"), () => {
2453
3282
  const text = o.log.toJSON();
2454
3283
  const select = () => {
2455
- const ta = mk("textarea");
3284
+ const ta = mk2("textarea");
2456
3285
  ta.value = text;
2457
3286
  ta.setAttribute("readonly", "");
2458
3287
  ta.style.cssText = "position:fixed;left:8px;bottom:8px;width:min(480px,80vw);height:120px;z-index:99999";
@@ -2486,12 +3315,12 @@ function createUi(o) {
2486
3315
  const fill = (n, e) => {
2487
3316
  n.className = lineClass(e);
2488
3317
  n.textContent = "";
2489
- const tm = mk("time", void 0, fmtTime(e.t));
3318
+ const tm = mk2("time", void 0, fmtTime(e.t));
2490
3319
  n.append(tm, document.createTextNode(e.text));
2491
3320
  };
2492
3321
  const addNode = (e) => {
2493
3322
  if (!filters[e.channel]) return;
2494
- const n = mk("div");
3323
+ const n = mk2("div");
2495
3324
  fill(n, e);
2496
3325
  nodes.set(e.seq, n);
2497
3326
  logBody.appendChild(n);
@@ -2513,17 +3342,18 @@ function createUi(o) {
2513
3342
  if (n) fill(n, e);
2514
3343
  } else addNode(e);
2515
3344
  });
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");
3345
+ const right = mk2("div", "tr-right");
3346
+ const cat = mk2("div", "tr-panel tr-cat");
3347
+ const anim = mk2("div", "tr-panel tr-anim");
3348
+ const tests = mk2("div", "tr-panel tr-tests");
3349
+ const benchPanel = createBenchPanel({ t, num: (n, d) => num(n, d), onControl: o.onControl, onLayer: o.onLayer, onAction: o.onAction });
3350
+ right.append(cat, anim, benchPanel.element, tests);
3351
+ cat.appendChild(mk2("h3", void 0, t("ui.catalog")));
3352
+ const itemSearch = mk2("input", "tr-search");
2523
3353
  itemSearch.type = "search";
2524
3354
  itemSearch.placeholder = t("ui.search_items");
2525
- const tabs = mk("div", "tr-tabs");
2526
- const items = mk("div", "tr-items");
3355
+ const tabs = mk2("div", "tr-tabs");
3356
+ const items = mk2("div", "tr-items");
2527
3357
  cat.append(itemSearch, tabs, items);
2528
3358
  let activeGroup = "";
2529
3359
  const visibleItems = () => {
@@ -2535,17 +3365,17 @@ function createUi(o) {
2535
3365
  items.textContent = "";
2536
3366
  const searching = itemSearch.value.trim() !== "";
2537
3367
  const found = visibleItems();
2538
- if (!found.length) items.appendChild(mk("div", "tr-empty", t("ui.no_results")));
3368
+ if (!found.length) items.appendChild(mk2("div", "tr-empty", t("ui.no_results")));
2539
3369
  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));
3370
+ const wrap = mk2("div", "tr-item");
3371
+ const b = mk2("button", "tr-btn", it.name);
3372
+ if (searching) b.appendChild(mk2("small", void 0, group));
2543
3373
  b.type = "button";
2544
3374
  b.title = it.note ?? it.name;
2545
3375
  b.onclick = () => o.onSpawn(it.id);
2546
3376
  wrap.appendChild(b);
2547
3377
  if (it.weapon) {
2548
- const w = mk("button", "tr-btn", "\u2694");
3378
+ const w = mk2("button", "tr-btn", "\u2694");
2549
3379
  w.type = "button";
2550
3380
  w.title = t("ui.equip");
2551
3381
  w.onclick = () => o.onEquip(it.id);
@@ -2559,7 +3389,7 @@ function createUi(o) {
2559
3389
  const groupNames = [...o.groups().keys()];
2560
3390
  if (!groupNames.includes(activeGroup)) activeGroup = groupNames[0] ?? "";
2561
3391
  for (const g of groupNames) {
2562
- const b = mk("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
3392
+ const b = mk2("button", g === activeGroup ? "tr-btn on" : "tr-btn", g);
2563
3393
  b.type = "button";
2564
3394
  b.onclick = () => {
2565
3395
  activeGroup = g;
@@ -2572,9 +3402,9 @@ function createUi(o) {
2572
3402
  renderTabs();
2573
3403
  itemSearch.oninput = renderItems;
2574
3404
  for (const box2 of [itemSearch]) box2.addEventListener("keydown", (e) => e.stopPropagation());
2575
- const controls = mk("div", "tr-row");
3405
+ const controls = mk2("div", "tr-row");
2576
3406
  const ctl = (label, fn) => {
2577
- const b = mk("button", "tr-btn", label);
3407
+ const b = mk2("button", "tr-btn", label);
2578
3408
  b.type = "button";
2579
3409
  b.onclick = () => fn(b);
2580
3410
  controls.appendChild(b);
@@ -2595,29 +3425,29 @@ function createUi(o) {
2595
3425
  o.onFast(fast);
2596
3426
  });
2597
3427
  const tapBtn = ctl(t("ui.tap"), () => o.onTap());
2598
- const qSel = mk("select");
3428
+ const qSel = mk2("select");
2599
3429
  for (const q of ["low", "medium", "high"]) {
2600
- const op = mk("option", void 0, t(`ui.quality.${q}`));
3430
+ const op = mk2("option", void 0, t(`ui.quality.${q}`));
2601
3431
  op.value = q;
2602
3432
  qSel.appendChild(op);
2603
3433
  }
2604
3434
  qSel.value = o.quality;
2605
3435
  qSel.onchange = () => o.onQuality(qSel.value);
2606
- const qLabel = mk("label", void 0, t("ui.quality") + " ");
3436
+ const qLabel = mk2("label", void 0, t("ui.quality") + " ");
2607
3437
  qLabel.appendChild(qSel);
2608
3438
  controls.appendChild(qLabel);
2609
- const count = mk("span");
3439
+ const count = mk2("span");
2610
3440
  controls.appendChild(count);
2611
3441
  cat.appendChild(controls);
2612
- tests.appendChild(mk("h3", void 0, t("ui.tests")));
2613
- const runAll = mk("button", "tr-btn", t("ui.run_all"));
3442
+ tests.appendChild(mk2("h3", void 0, t("ui.tests")));
3443
+ const runAll = mk2("button", "tr-btn", t("ui.run_all"));
2614
3444
  runAll.type = "button";
2615
- const summary = mk("div", "tr-summary");
2616
- const testSearch = mk("input", "tr-search");
3445
+ const summary = mk2("div", "tr-summary");
3446
+ const testSearch = mk2("input", "tr-search");
2617
3447
  testSearch.type = "search";
2618
3448
  testSearch.placeholder = t("ui.search_tests");
2619
3449
  testSearch.addEventListener("keydown", (e) => e.stopPropagation());
2620
- const list = mk("div", "list");
3450
+ const list = mk2("div", "list");
2621
3451
  tests.append(runAll, summary, testSearch, list);
2622
3452
  testSearch.oninput = () => {
2623
3453
  const term = fold(testSearch.value.trim());
@@ -2640,7 +3470,7 @@ function createUi(o) {
2640
3470
  list.textContent = "";
2641
3471
  scBtns.clear();
2642
3472
  for (const s of o.scenarios()) {
2643
- const b = mk("button", "tr-btn", s.name);
3473
+ const b = mk2("button", "tr-btn", s.name);
2644
3474
  b.type = "button";
2645
3475
  b.onclick = async () => {
2646
3476
  if (busy) return;
@@ -2666,7 +3496,7 @@ function createUi(o) {
2666
3496
  busy = false;
2667
3497
  }
2668
3498
  };
2669
- const toggle = mk("button", "tr-btn tr-toggle", t("ui.panel"));
3499
+ const toggle = mk2("button", "tr-btn tr-toggle", t("ui.panel"));
2670
3500
  toggle.type = "button";
2671
3501
  const togglePanels = () => {
2672
3502
  root.classList.toggle("open");
@@ -2675,15 +3505,15 @@ function createUi(o) {
2675
3505
  toggle.onclick = togglePanels;
2676
3506
  root.append(cross, top, bottom, help, left, right, toggle);
2677
3507
  const bar = (value, max, cls) => {
2678
- const b = mk("div", `tr-bar ${cls}`);
2679
- const i = mk("i");
3508
+ const b = mk2("div", `tr-bar ${cls}`);
3509
+ const i = mk2("i");
2680
3510
  i.style.width = `${Math.max(0, Math.min(100, value / Math.max(1e-9, max) * 100))}%`;
2681
3511
  b.appendChild(i);
2682
3512
  return b;
2683
3513
  };
2684
3514
  const row = (k, v) => {
2685
- const r = mk("div", "r");
2686
- r.append(mk("span", void 0, k), mk("span", void 0, v));
3515
+ const r = mk2("div", "r");
3516
+ r.append(mk2("span", void 0, k), mk2("span", void 0, v));
2687
3517
  return r;
2688
3518
  };
2689
3519
  const num = (n, d = 1) => {
@@ -2692,12 +3522,12 @@ function createUi(o) {
2692
3522
  };
2693
3523
  const renderInspector = (i) => {
2694
3524
  insp.textContent = "";
2695
- insp.appendChild(mk("h3", void 0, t("ui.inspector")));
3525
+ insp.appendChild(mk2("h3", void 0, t("ui.inspector")));
2696
3526
  if (!i) {
2697
- insp.appendChild(mk("div", void 0, t("ui.aim_at_something")));
3527
+ insp.appendChild(mk2("div", void 0, t("ui.aim_at_something")));
2698
3528
  return;
2699
3529
  }
2700
- insp.appendChild(mk("div", "t", `${i.object} \u203A ${i.part}`));
3530
+ insp.appendChild(mk2("div", "t", `${i.object} \u203A ${i.part}`));
2701
3531
  insp.appendChild(row(t("ins.material"), i.material));
2702
3532
  insp.appendChild(row(t("ins.status"), i.status + (i.burning ? ` \xB7 ${t("ins.burning")}` : "")));
2703
3533
  insp.appendChild(row(t("ins.hp"), `${num(i.hp, 0)} / ${num(i.maxHp, 0)}`));
@@ -2716,30 +3546,30 @@ function createUi(o) {
2716
3546
  insp.appendChild(bar(Math.min(i.stress, 1.2), 1.2, i.stress > 1 ? "bad" : i.stress > 0.7 ? "warn" : "hp"));
2717
3547
  }
2718
3548
  if (i.joints.length) {
2719
- insp.appendChild(mk("div", void 0, t("ins.joints") + ":"));
3549
+ insp.appendChild(mk2("div", void 0, t("ins.joints") + ":"));
2720
3550
  for (const j of i.joints) {
2721
3551
  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}`}`));
3552
+ insp.appendChild(mk2("div", void 0, `\u2022 ${j.kind} (${j.id})${j.open ? ` \xB7 ${t("ins.open")}` : ` \u2192 ${tools}`}`));
2723
3553
  }
2724
3554
  }
2725
- if (i.note) insp.appendChild(mk("div", void 0, i.note));
3555
+ if (i.note) insp.appendChild(mk2("div", void 0, i.note));
2726
3556
  };
2727
3557
  let lastAnim = "";
2728
3558
  const renderAnimations = (a) => {
2729
3559
  anim.textContent = "";
2730
- anim.appendChild(mk("h3", void 0, t("ui.animations")));
3560
+ anim.appendChild(mk2("h3", void 0, t("ui.animations")));
2731
3561
  if (!a) {
2732
- anim.appendChild(mk("div", "sub", t("ui.animations_aim")));
3562
+ anim.appendChild(mk2("div", "sub", t("ui.animations_aim")));
2733
3563
  return;
2734
3564
  }
2735
- anim.appendChild(mk("div", "sub", a.name));
3565
+ anim.appendChild(mk2("div", "sub", a.name));
2736
3566
  if (a.clips.length === 0) {
2737
- anim.appendChild(mk("div", "sub", t("ui.animations_none")));
3567
+ anim.appendChild(mk2("div", "sub", t("ui.animations_none")));
2738
3568
  return;
2739
3569
  }
2740
- const l = mk("div", "list");
3570
+ const l = mk2("div", "list");
2741
3571
  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)`);
3572
+ 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
3573
  b.type = "button";
2744
3574
  b.onclick = () => o.onPlay(a.objectId, c.id);
2745
3575
  l.appendChild(b);
@@ -2756,7 +3586,7 @@ function createUi(o) {
2756
3586
  hud(s) {
2757
3587
  toolBox.textContent = "";
2758
3588
  toolBox.append(document.createTextNode(s.toolName));
2759
- if (s.ammo) toolBox.append(mk("small", void 0, s.ammo));
3589
+ if (s.ammo) toolBox.append(mk2("small", void 0, s.ammo));
2760
3590
  if (s.status) status.textContent = s.status;
2761
3591
  else if (!toastTimer) status.textContent = "";
2762
3592
  if (s.progress) {
@@ -2777,7 +3607,7 @@ function createUi(o) {
2777
3607
  ];
2778
3608
  if (s.held.weapon) keys.push(["H", t("hud.equip", { weapon: s.held.weapon })]);
2779
3609
  for (const [k, label] of keys) {
2780
- held.append(mk("kbd", void 0, k), document.createTextNode(` ${label} `));
3610
+ held.append(mk2("kbd", void 0, k), document.createTextNode(` ${label} `));
2781
3611
  }
2782
3612
  } else held.style.display = "none";
2783
3613
  const key = s.slots.join("|") + s.page;
@@ -2787,9 +3617,9 @@ function createUi(o) {
2787
3617
  slotsEl.length = 0;
2788
3618
  hudPage = s.page - 1;
2789
3619
  s.slots.forEach((name, i) => {
2790
- const b = mk("button", "tr-slot");
3620
+ const b = mk2("button", "tr-slot");
2791
3621
  b.type = "button";
2792
- b.append(mk("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
3622
+ b.append(mk2("b", void 0, String((i + 1) % 10)), document.createTextNode(name));
2793
3623
  b.title = name;
2794
3624
  b.onclick = () => o.onTool(hudPage * 10 + i);
2795
3625
  hotbar.appendChild(b);
@@ -2827,6 +3657,11 @@ function createUi(o) {
2827
3657
  renderTabs();
2828
3658
  },
2829
3659
  togglePanels,
3660
+ bench: benchPanel.show,
3661
+ metrics: benchPanel.metrics,
3662
+ get benchVisible() {
3663
+ return benchPanel.visible;
3664
+ },
2830
3665
  dispose() {
2831
3666
  offLog();
2832
3667
  if (toastTimer) clearTimeout(toastTimer);
@@ -2839,6 +3674,7 @@ function createUi(o) {
2839
3674
 
2840
3675
  // src/room.ts
2841
3676
  var FIXED_DT = 1 / 60;
3677
+ var METRICS_INTERVAL_S = 0.25;
2842
3678
  var v3 = (v) => [v.x, v.y, v.z];
2843
3679
  var dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
2844
3680
  function createTestRoom(options) {
@@ -2952,10 +3788,14 @@ function createTestRoom(options) {
2952
3788
  const tools = /* @__PURE__ */ new Map();
2953
3789
  let toolIndex = Math.max(0, loadout.indexOf("hand"));
2954
3790
  let toolId = loadout[toolIndex] ?? "hand";
3791
+ const toolSettings = {};
3792
+ let fuseLength;
3793
+ const SETTING_IDS = { condition: "condition", ammoQuality: "ammo_quality", fuseLength: "fuse_length" };
2955
3794
  const toolState = (id) => {
2956
3795
  let s = tools.get(id);
2957
3796
  if (!s) {
2958
- s = arsenal.create(id, { seed: 7, reserve: 999 });
3797
+ s = arsenal.create(id, { seed: 7, reserve: 999, ...toolSettings });
3798
+ s.setFuseLength(fuseLength);
2959
3799
  s.refill();
2960
3800
  tools.set(id, s);
2961
3801
  }
@@ -3056,6 +3896,7 @@ function createTestRoom(options) {
3056
3896
  for (const ev of batch) {
3057
3897
  if (ev.type === "leak") puncture(ev);
3058
3898
  else if (ev.type === "ignite") igniteNear(ev.point, 0.9);
3899
+ else if (ev.type === "explode") emitHost({ type: "explosion", source: ev.object, point: ev.point, energy: ev.energy, radius: ev.radius, cause: "part" });
3059
3900
  fire("event", ev);
3060
3901
  options.onEvent?.(ev);
3061
3902
  }
@@ -3076,7 +3917,7 @@ function createTestRoom(options) {
3076
3917
  },
3077
3918
  primaryUp: () => {
3078
3919
  useHeld = false;
3079
- tool.release();
3920
+ releaseTrigger();
3080
3921
  },
3081
3922
  secondary: () => {
3082
3923
  if (heldEntry) throwHeld();
@@ -3233,8 +4074,7 @@ function createTestRoom(options) {
3233
4074
  if (heldEntry || heldPiece) releaseHeld();
3234
4075
  useItemPlayer = null;
3235
4076
  playing.length = 0;
3236
- projectiles.length = 0;
3237
- clearProjectileMeshes();
4077
+ explosives.clear();
3238
4078
  fireZones.length = 0;
3239
4079
  burningStains.length = 0;
3240
4080
  for (const id of [...entries.keys()]) if (!entries.get(id)?.arena) removeEntry(id);
@@ -3470,7 +4310,6 @@ function createTestRoom(options) {
3470
4310
  }
3471
4311
  });
3472
4312
  player.kick(recoil[0], recoil[1]);
3473
- emitHost({ type: "shot", weapon: toolId, shots: shots.length, recoil });
3474
4313
  const f = first;
3475
4314
  const d = f ? num(f.distance, 1) : "";
3476
4315
  let text;
@@ -3618,99 +4457,46 @@ function createTestRoom(options) {
3618
4457
  1
3619
4458
  );
3620
4459
  };
3621
- const projectiles = [];
3622
- const projMeshes = [];
3623
- const clearProjectileMeshes = () => {
3624
- for (const m of projMeshes) m.removeFromParent();
3625
- projMeshes.length = 0;
3626
- };
3627
- const throwUse = (u) => {
3628
- const w = toolName();
3629
- const molotov = !!u.fire;
3630
- const mesh = new THREE.Mesh(
3631
- molotov ? new THREE.CylinderGeometry(0.035, 0.04, 0.2, 8) : new THREE.SphereGeometry(0.045, 8, 6),
3632
- new THREE.MeshStandardMaterial({ color: molotov ? 3897915 : 4936480, roughness: 0.6, metalness: molotov ? 0 : 0.4 })
3633
- );
3634
- mesh.castShadow = true;
3635
- mesh.position.set(u.origin[0], u.origin[1], u.origin[2]);
3636
- scene.add(mesh);
3637
- projMeshes.push(mesh);
3638
- projectiles.push({
3639
- mesh,
3640
- pos: new THREE.Vector3(u.origin[0], u.origin[1], u.origin[2]),
3641
- vel: new THREE.Vector3(u.velocity[0], u.velocity[1], u.velocity[2]),
3642
- weapon: toolId,
3643
- fuse: u.fuse,
3644
- burstOnImpact: molotov && u.fuse <= 0,
3645
- energy: u.energy,
3646
- radius: u.radius,
3647
- fire: u.fire,
3648
- age: 0
3649
- });
3650
- action("throw_projectile", t(molotov ? "act.throw.molotov" : "act.throw.grenade", { weapon: w, fuse: num(u.fuse, 1) }), { data: { weapon: toolId, fuse: u.fuse } });
3651
- };
3652
- const detonate = (p, at, impact) => {
3653
- const w = toolName(p.weapon);
3654
- const results = world.explode(at, p.energy, p.radius, "player");
3655
- 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
- if (p.fire) {
3657
- fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
3658
- fireZones.push({ point: [at[0], Math.max(0.02, groundAt(at[0], at[2])), at[2]], radius: p.fire.radius, temperature: p.fire.temperature, remaining: p.fire.seconds, acc: 0 });
3659
- renderer.emit("fire", at, [0, 1, 0], 24, 3);
3660
- action("molotov_fire", t("act.molotov_fire", { weapon: w, seconds: num(p.fire.seconds, 0), radius: num(p.fire.radius, 1) }), { data: { weapon: p.weapon, impact } });
3661
- }
3662
- };
3663
- const updateProjectiles = (dt) => {
3664
- for (let i = projectiles.length - 1; i >= 0; i--) {
3665
- const p = projectiles[i];
3666
- p.age += dt;
3667
- p.vel.y -= 9.81 * dt;
3668
- const speed = p.vel.length();
3669
- const step2 = speed * dt;
3670
- let burst = false;
3671
- if (step2 > 1e-6) {
3672
- const d = [p.vel.x / speed, p.vel.y / speed, p.vel.z / speed];
3673
- const r = withoutHeld(() => world.raycast(v3(p.pos), d, step2 + 0.05));
3674
- if (r && r.distance <= step2 + 0.04) {
3675
- p.pos.set(r.point[0] - d[0] * 0.03, r.point[1] - d[1] * 0.03, r.point[2] - d[2] * 0.03);
3676
- if (p.burstOnImpact) burst = true;
3677
- else {
3678
- const n = r.normal;
3679
- const vn = p.vel.x * n[0] + p.vel.y * n[1] + p.vel.z * n[2];
3680
- p.vel.x = (p.vel.x - 2 * vn * n[0]) * 0.35;
3681
- p.vel.y = (p.vel.y - 2 * vn * n[1]) * 0.35;
3682
- p.vel.z = (p.vel.z - 2 * vn * n[2]) * 0.35;
3683
- }
3684
- } else p.pos.addScaledVector(p.vel, dt);
3685
- }
3686
- const g = groundAt(p.pos.x, p.pos.z) + 0.045;
3687
- if (p.pos.y < g) {
3688
- p.pos.y = g;
3689
- if (p.burstOnImpact) burst = true;
3690
- else {
3691
- p.vel.y = Math.abs(p.vel.y) * 0.35;
3692
- p.vel.x *= 0.7;
3693
- p.vel.z *= 0.7;
3694
- }
3695
- }
3696
- p.mesh.position.copy(p.pos);
3697
- p.mesh.rotation.x += dt * 8;
3698
- p.fuse -= dt;
3699
- if (burst || !p.burstOnImpact && p.fuse <= 0 || p.age > 30) {
3700
- detonate(p, v3(p.pos), burst);
3701
- p.mesh.removeFromParent();
3702
- projMeshes.splice(projMeshes.indexOf(p.mesh), 1);
3703
- projectiles.splice(i, 1);
3704
- }
3705
- }
3706
- };
4460
+ const startFire = (weapon, at, fire2, impact) => {
4461
+ fluids.splash("fuel", [at[0], Math.max(at[1], groundAt(at[0], at[2])), at[2]], 0.5);
4462
+ fireZones.push({ point: [at[0], Math.max(0.02, groundAt(at[0], at[2])), at[2]], radius: fire2.radius, temperature: fire2.temperature, remaining: fire2.seconds, acc: 0 });
4463
+ renderer.emit("fire", at, [0, 1, 0], 24, 3);
4464
+ action("molotov_fire", t("act.molotov_fire", { weapon: toolName(weapon), seconds: num(fire2.seconds, 0), radius: num(fire2.radius, 1) }), { data: { weapon, impact } });
4465
+ };
4466
+ function crossingBodies() {
4467
+ const bodies = [...entries.values()].filter((e) => !e.arena).map((e) => ({ id: e.so.objectId, box: new THREE.Box3().setFromObject(e.so.root) }));
4468
+ const { x, y, z } = camera.position;
4469
+ const feet = groundAt(x, z);
4470
+ bodies.push({ id: "player", box: new THREE.Box3(new THREE.Vector3(x - PLAYER_RADIUS, feet, z - PLAYER_RADIUS), new THREE.Vector3(x + PLAYER_RADIUS, y + 0.1, z + PLAYER_RADIUS)) });
4471
+ return bodies;
4472
+ }
4473
+ const explosives = createExplosives({
4474
+ THREE,
4475
+ scene,
4476
+ camera,
4477
+ world,
4478
+ renderer,
4479
+ arsenal,
4480
+ objects: () => [...entries.values()].filter((e) => !e.arena).map((e) => e.so.root),
4481
+ bodies: crossingBodies,
4482
+ toolId: () => toolId,
4483
+ toolName,
4484
+ withoutHeld,
4485
+ groundAt,
4486
+ translate: t,
4487
+ format: num,
4488
+ action,
4489
+ emitHost,
4490
+ startFire,
4491
+ say
4492
+ });
3707
4493
  const useTool = (dt, held) => {
3708
4494
  aim();
3709
4495
  const cat = tool.def.category;
3710
4496
  if (cat === "hand") return;
3711
4497
  const res = tool.trigger([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]], dt, held, "player");
4498
+ flushEvents(toolId, tool, res.recoil);
3712
4499
  if (res.status === "empty") {
3713
- emitHost({ type: "empty", weapon: toolId });
3714
4500
  if (!tool.reloading && tool.reserve > 0) startReload();
3715
4501
  say(t(tool.reloading ? "hud.reloading" : "hud.empty"));
3716
4502
  return;
@@ -3718,8 +4504,13 @@ function createTestRoom(options) {
3718
4504
  if (res.status !== "ok") {
3719
4505
  if (res.status === "reloading") say(t("hud.reloading"));
3720
4506
  else if (res.status === "no_fuel") say(t("hud.no_fuel"));
4507
+ else if (res.status === "fault" && res.fault) say(t("hud.fault", { fault: t(`fault.${res.fault}`) }));
4508
+ else if (res.status === "safe") say(t("hud.safe"));
3721
4509
  return;
3722
4510
  }
4511
+ applyUses(res, dt);
4512
+ };
4513
+ const applyUses = (res, dt) => {
3723
4514
  if (res.shots.length) fireShots(res.shots, res.recoil);
3724
4515
  for (const u of res.uses) {
3725
4516
  switch (u.kind) {
@@ -3733,7 +4524,13 @@ function createTestRoom(options) {
3733
4524
  heatRays(u, "heat");
3734
4525
  break;
3735
4526
  case "throw":
3736
- throwUse(u);
4527
+ explosives.throwUse(u);
4528
+ break;
4529
+ case "place":
4530
+ explosives.placeUse(u);
4531
+ break;
4532
+ case "backblast":
4533
+ explosives.backblastUse(u);
3737
4534
  break;
3738
4535
  case "tool":
3739
4536
  toolWork(u, dt);
@@ -3746,6 +4543,16 @@ function createTestRoom(options) {
3746
4543
  }
3747
4544
  }
3748
4545
  };
4546
+ const releaseTrigger = () => {
4547
+ if (!tool.cooking) {
4548
+ tool.release();
4549
+ return;
4550
+ }
4551
+ aim();
4552
+ const res = tool.release([aimO[0], aimO[1], aimO[2]], [aimD[0], aimD[1], aimD[2]]);
4553
+ flushEvents(toolId, tool);
4554
+ if (res) applyUses(res, 0);
4555
+ };
3749
4556
  const kick = () => {
3750
4557
  if (!arsenal.weapons.has("kick")) return;
3751
4558
  aim();
@@ -3755,7 +4562,7 @@ function createTestRoom(options) {
3755
4562
  };
3756
4563
  const setTool = (id) => {
3757
4564
  if (!arsenal.weapons.has(id)) throw new Error(`game-test-room: unknown weapon "${id}"`);
3758
- tool.release();
4565
+ releaseTrigger();
3759
4566
  toolId = id;
3760
4567
  const i = loadout.indexOf(id);
3761
4568
  if (i >= 0) toolIndex = i;
@@ -3763,7 +4570,8 @@ function createTestRoom(options) {
3763
4570
  progress = null;
3764
4571
  action("tool", t("act.tool", { weapon: toolName(id) }), { data: { weapon: id } });
3765
4572
  fire("tool", id);
3766
- emitHost({ type: "tool", weapon: id });
4573
+ tool.draw();
4574
+ flushEvents(id, tool);
3767
4575
  };
3768
4576
  const cycleTool = (dir) => {
3769
4577
  if (!loadout.length) return;
@@ -3995,8 +4803,16 @@ function createTestRoom(options) {
3995
4803
  say(t("hud.reloading"));
3996
4804
  }
3997
4805
  break;
3998
- case "KeyQ":
4806
+ case "KeyQ": {
3999
4807
  tool.cycleMode();
4808
+ flushEvents(toolId, tool);
4809
+ break;
4810
+ }
4811
+ case "KeyX":
4812
+ clearToolFault();
4813
+ break;
4814
+ case "KeyB":
4815
+ toggleSafety();
4000
4816
  break;
4001
4817
  case "KeyZ":
4002
4818
  tiltKey = 1;
@@ -4014,6 +4830,9 @@ function createTestRoom(options) {
4014
4830
  case "KeyT":
4015
4831
  toggleTap();
4016
4832
  break;
4833
+ case "KeyY":
4834
+ explosives.detonateCharges();
4835
+ break;
4017
4836
  case "KeyH": {
4018
4837
  const w = (heldEntry ?? entryOf(inspectTarget()?.object ?? ""))?.item?.weapon;
4019
4838
  if (w) equip(w);
@@ -4085,6 +4904,10 @@ function createTestRoom(options) {
4085
4904
  let timeScale = 1;
4086
4905
  let fastForward = false;
4087
4906
  let hudAcc = 1;
4907
+ let metricsAcc = 0;
4908
+ let ownsLoop = false;
4909
+ const roomMetricIds = options.metrics === false ? [] : options.metrics ?? ROOM_METRICS;
4910
+ const roomMetrics = createRoomMetrics({ gl, destruction: renderer, fluids: fluidView, ids: roomMetricIds });
4088
4911
  let qualityName = quality;
4089
4912
  const syncContainers = (sdt) => {
4090
4913
  for (const e of entries.values()) {
@@ -4124,7 +4947,7 @@ function createTestRoom(options) {
4124
4947
  const reps = fastForward ? 10 : 1;
4125
4948
  updateAnimations(dt);
4126
4949
  updateItemUse(dt);
4127
- updateProjectiles(dt);
4950
+ explosives.update(dt);
4128
4951
  syncContainers(dt);
4129
4952
  for (let i = 0; i < reps; i++) {
4130
4953
  world.update(dt);
@@ -4135,9 +4958,10 @@ function createTestRoom(options) {
4135
4958
  renderer.update(dt);
4136
4959
  updateImpacts();
4137
4960
  fluidView.update(dt);
4138
- for (const s of tools.values()) s.update(dt);
4139
- if (reloadingSeen && !tool.reloading) emitHost({ type: "reload", weapon: toolId, phase: "end" });
4140
- reloadingSeen = tool.reloading;
4961
+ for (const [id, s] of tools) {
4962
+ s.update(dt);
4963
+ flushEvents(id, s);
4964
+ }
4141
4965
  for (const update2 of frameListeners) update2(dt);
4142
4966
  for (const tr of tracers) {
4143
4967
  if (tr.life > 0) {
@@ -4148,6 +4972,7 @@ function createTestRoom(options) {
4148
4972
  flush();
4149
4973
  };
4150
4974
  const update = (dt) => {
4975
+ if (!ownsLoop) roomMetrics.frame(dt);
4151
4976
  player.update(dt);
4152
4977
  if (tiltKey || heldTilt > 0) {
4153
4978
  heldTilt = Math.max(0, Math.min(2.4, heldTilt + (tiltKey ? 2.2 : -3) * dt));
@@ -4166,6 +4991,11 @@ function createTestRoom(options) {
4166
4991
  hudAcc = 0;
4167
4992
  ui.hud(hudState());
4168
4993
  }
4994
+ metricsAcc += dt;
4995
+ if (metricsAcc >= METRICS_INTERVAL_S && ui) {
4996
+ metricsAcc = 0;
4997
+ if (ui.benchVisible) ui.metrics(metricRows());
4998
+ }
4169
4999
  };
4170
5000
  const hudState = () => {
4171
5001
  const page = Math.floor(toolIndex / 10);
@@ -4281,15 +5111,82 @@ function createTestRoom(options) {
4281
5111
  log.add({ channel: "check", type: ok ? "pass" : "fail", text: `${ok ? "\u2713" : "\u2717"} ${text}`, itemId, data: { ok, ...data } });
4282
5112
  };
4283
5113
  const modules = /* @__PURE__ */ new Map();
4284
- const detachers = [];
5114
+ const detachers = /* @__PURE__ */ new Map();
5115
+ const benches = /* @__PURE__ */ new Map();
5116
+ let activeBench = null;
5117
+ const lastBench = () => [...benches.keys()].pop() ?? null;
5118
+ const benchView = () => {
5119
+ const store = activeBench ? benches.get(activeBench) : void 0;
5120
+ if (!store) return null;
5121
+ return { title: modules.get(store.moduleId)?.name ?? store.moduleId, controls: store.controls(), layers: store.layers() };
5122
+ };
5123
+ const METRIC_UNAVAILABLE = "n/a";
5124
+ const metricText = (value, digits, unit) => typeof value === "number" ? `${num(value, digits)}${unit ? ` ${unit}` : ""}` : value ?? METRIC_UNAVAILABLE;
5125
+ const reportedFailures = /* @__PURE__ */ new Set();
5126
+ const reportOnce = (key, text) => {
5127
+ if (reportedFailures.has(key)) return;
5128
+ reportedFailures.add(key);
5129
+ host.log(text);
5130
+ };
5131
+ const failureText = (error) => error instanceof Error ? error.message : String(error);
5132
+ const moduleMetrics = () => {
5133
+ const store = activeBench ? benches.get(activeBench) : void 0;
5134
+ const out = {};
5135
+ for (const metric of store?.metrics ?? []) {
5136
+ try {
5137
+ out[metric.id] = metric.read();
5138
+ } catch (error) {
5139
+ out[metric.id] = METRIC_UNAVAILABLE;
5140
+ reportOnce(`${store?.moduleId}:metric:${metric.id}`, t("mod.metric_failed", { name: modules.get(store?.moduleId ?? "")?.name ?? "", metric: metric.id, error: failureText(error) }));
5141
+ }
5142
+ }
5143
+ return out;
5144
+ };
5145
+ const metricRows = () => {
5146
+ const sampled = roomMetrics.sample();
5147
+ const room2 = roomMetricIds.flatMap((id) => {
5148
+ const value = sampled[id];
5149
+ return value === void 0 ? [] : [{ id, name: t(`metric.${id}`), text: num(value, id === "frame_ms" ? 1 : 0) }];
5150
+ });
5151
+ const values = moduleMetrics();
5152
+ const declared = (activeBench ? benches.get(activeBench)?.metrics : void 0) ?? [];
5153
+ const module = declared.map((metric) => {
5154
+ const value = values[metric.id];
5155
+ const ratio = metric.max && typeof value === "number" ? value / metric.max : void 0;
5156
+ return { id: metric.id, name: metric.name, text: metricText(value, metric.digits ?? 0, metric.unit), ...ratio === void 0 ? {} : { ratio } };
5157
+ });
5158
+ return { room: room2, module };
5159
+ };
5160
+ const showBench = () => {
5161
+ if (!ui) return;
5162
+ ui.bench(benchView());
5163
+ ui.metrics(metricRows());
5164
+ };
4285
5165
  const hostListeners = /* @__PURE__ */ new Set();
4286
5166
  const frameListeners = /* @__PURE__ */ new Set();
4287
- let reloadingSeen = false;
4288
5167
  function emitHost(event) {
4289
5168
  for (const listener of hostListeners) listener(event);
4290
5169
  }
5170
+ function flushEvents(id, state, recoil) {
5171
+ const phase = state.phase;
5172
+ for (const event of state.drain()) {
5173
+ const hostEvent = toHostEvent(id, event, { ...recoil ? { recoil } : {}, phase });
5174
+ if (hostEvent) emitHost(hostEvent);
5175
+ }
5176
+ }
4291
5177
  function startReload() {
4292
- if (tool.reload()) emitHost({ type: "reload", weapon: toolId, phase: "start" });
5178
+ tool.reload();
5179
+ flushEvents(toolId, tool);
5180
+ }
5181
+ function clearToolFault() {
5182
+ if (!tool.clearFault()) return;
5183
+ flushEvents(toolId, tool);
5184
+ say(t("hud.clearing"));
5185
+ }
5186
+ function toggleSafety() {
5187
+ tool.setSafety(!tool.safety);
5188
+ flushEvents(toolId, tool);
5189
+ say(t(tool.safety ? "hud.safety_on" : "hud.safety_off"));
4293
5190
  }
4294
5191
  const host = {
4295
5192
  THREE,
@@ -4297,7 +5194,31 @@ function createTestRoom(options) {
4297
5194
  camera,
4298
5195
  locale,
4299
5196
  groundAt,
4300
- tool: () => ({ weapon: toolId, category: tool.def.category, ammo: tool.ammo, capacity: tool.capacity, reserve: tool.reserve, reloading: tool.reloading }),
5197
+ tool: () => ({
5198
+ weapon: toolId,
5199
+ category: tool.def.category,
5200
+ ammo: tool.ammo,
5201
+ capacity: tool.capacity,
5202
+ reserve: tool.reserve,
5203
+ reloading: tool.reloading,
5204
+ mode: tool.mode,
5205
+ ...tool.fault ? { fault: tool.fault } : {},
5206
+ safety: tool.safety,
5207
+ heat: tool.heatLevel
5208
+ }),
5209
+ configureTool(settings) {
5210
+ const condition = settings[SETTING_IDS.condition];
5211
+ const ammoQuality = settings[SETTING_IDS.ammoQuality];
5212
+ if (condition !== void 0) toolSettings.condition = condition;
5213
+ if (ammoQuality !== void 0) toolSettings.ammoQuality = ammoQuality;
5214
+ const fuseMetres = settings[SETTING_IDS.fuseLength] === void 0 ? void 0 : Number(settings[SETTING_IDS.fuseLength]);
5215
+ if (fuseMetres !== void 0 && fuseMetres > 0) fuseLength = fuseMetres;
5216
+ for (const state of tools.values()) {
5217
+ if (condition !== void 0) state.setCondition(condition);
5218
+ if (ammoQuality !== void 0) state.setAmmoQuality(ammoQuality);
5219
+ if (fuseMetres !== void 0 && fuseMetres > 0) state.setFuseLength(fuseMetres);
5220
+ }
5221
+ },
4301
5222
  on(listener) {
4302
5223
  hostListeners.add(listener);
4303
5224
  return () => hostListeners.delete(listener);
@@ -4310,6 +5231,23 @@ function createTestRoom(options) {
4310
5231
  action("module_note", text, { data });
4311
5232
  }
4312
5233
  };
5234
+ const lendBench = (store) => ({
5235
+ value: store.value,
5236
+ layer: store.layer,
5237
+ onChange: store.onChange,
5238
+ patch: store.patch,
5239
+ look: (eye, target) => room.look(eye, target)
5240
+ });
5241
+ const checkModuleBench = (mod, fail) => {
5242
+ const store = createBenchStore(mod, locale, (error) => reportOnce(`${mod.id}:listener`, t("mod.listener_failed", { name: mod.name, error: failureText(error) })));
5243
+ const { controls, layers, metrics } = store.declared;
5244
+ if (controls.length + layers.length + metrics.length === 0) return null;
5245
+ const r = checkBench(store.declared);
5246
+ 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("; ") });
5247
+ moduleCheck(r.ok, text, { module: mod.id, check: "bench", problems: r.problems });
5248
+ for (const p of r.problems) fail(`bench: ${p}`);
5249
+ return r.ok ? store : null;
5250
+ };
4313
5251
  const loadModule = (mod) => {
4314
5252
  const report = { module: mod.id, items: [], scenarios: [], ok: true, problems: [] };
4315
5253
  const fail = (problem) => {
@@ -4374,8 +5312,29 @@ function createTestRoom(options) {
4374
5312
  report.scenarios.push(sc.id);
4375
5313
  }
4376
5314
  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 } });
5315
+ const bench = checkModuleBench(mod, fail);
5316
+ detachers.get(mod.id)?.();
5317
+ detachers.delete(mod.id);
5318
+ const previous = benches.get(mod.id);
5319
+ const kept = previous?.encode();
5320
+ previous?.dispose();
5321
+ benches.delete(mod.id);
5322
+ for (const key of [...reportedFailures]) if (key.startsWith(`${mod.id}:`)) reportedFailures.delete(key);
4377
5323
  modules.set(mod.id, mod);
4378
- if (mod.attach) detachers.push(mod.attach(host));
5324
+ if (bench) {
5325
+ const seed = options.bench?.state?.[mod.id];
5326
+ const restored = kept ?? (typeof seed === "string" ? seed : void 0);
5327
+ if (restored !== void 0) bench.restore(restored);
5328
+ benches.set(mod.id, bench);
5329
+ bench.watch(() => {
5330
+ options.bench?.onChange?.(mod.id, bench.encode());
5331
+ if (activeBench === mod.id) ui?.bench(benchView());
5332
+ });
5333
+ if (!activeBench || !benches.has(activeBench)) activeBench = mod.id;
5334
+ } else if (activeBench === mod.id) activeBench = lastBench();
5335
+ if (mod.attach) detachers.set(mod.id, mod.attach(bench ? { ...host, bench: lendBench(bench) } : host));
5336
+ bench?.sendInitial();
5337
+ showBench();
4379
5338
  ui?.refresh();
4380
5339
  return report;
4381
5340
  };
@@ -4454,6 +5413,8 @@ function createTestRoom(options) {
4454
5413
  const mod = [...modules.values()].find((m) => m.id === area || m.group === area);
4455
5414
  const label = mod?.group ?? S.groups[area] ?? area;
4456
5415
  ui?.showGroup(label);
5416
+ activeBench = mod && benches.has(mod.id) ? mod.id : lastBench();
5417
+ showBench();
4457
5418
  const layout = mod?.area?.(locale);
4458
5419
  if (layout) return layArea(layout);
4459
5420
  const items = catalog.filter((item) => item.group === label);
@@ -4495,7 +5456,7 @@ function createTestRoom(options) {
4495
5456
  stepSim(FIXED_DT);
4496
5457
  t0 += FIXED_DT;
4497
5458
  }
4498
- tool.release();
5459
+ releaseTrigger();
4499
5460
  },
4500
5461
  grab,
4501
5462
  throwHeld,
@@ -4512,6 +5473,15 @@ function createTestRoom(options) {
4512
5473
  return [...entries.values()].filter((e) => !e.arena).map((e) => e.so);
4513
5474
  },
4514
5475
  update,
5476
+ waitUntilReady(maxSeconds = 5) {
5477
+ let waited = 0;
5478
+ while (tool.phase && waited < maxSeconds) {
5479
+ update(FIXED_DT);
5480
+ waited += FIXED_DT;
5481
+ }
5482
+ update(FIXED_DT);
5483
+ return waited + FIXED_DT;
5484
+ },
4515
5485
  on(type, fn) {
4516
5486
  listeners[type].add(fn);
4517
5487
  return () => listeners[type].delete(fn);
@@ -4545,8 +5515,18 @@ function createTestRoom(options) {
4545
5515
  loadModule,
4546
5516
  animations: animationsOf,
4547
5517
  playAnimation,
5518
+ bench(moduleId) {
5519
+ const id = moduleId ?? activeBench;
5520
+ return (id ? benches.get(id) : void 0) ?? null;
5521
+ },
5522
+ metrics() {
5523
+ return { room: roomMetrics.sample(), module: moduleMetrics() };
5524
+ },
4548
5525
  dispose() {
4549
- for (const detach of detachers.splice(0)) detach();
5526
+ for (const detach of detachers.values()) detach();
5527
+ detachers.clear();
5528
+ for (const store of benches.values()) store.dispose();
5529
+ benches.clear();
4550
5530
  cancelAnimationFrame?.(raf);
4551
5531
  window.removeEventListener("resize", onResize);
4552
5532
  player.dispose();
@@ -4554,7 +5534,7 @@ function createTestRoom(options) {
4554
5534
  renderer.dispose();
4555
5535
  fluidView.dispose();
4556
5536
  for (const tr of tracers) tr.line.removeFromParent();
4557
- clearProjectileMeshes();
5537
+ explosives.clear();
4558
5538
  if (ownRenderer) {
4559
5539
  gl.dispose();
4560
5540
  dom.remove();
@@ -4617,19 +5597,33 @@ function createTestRoom(options) {
4617
5597
  onPlay: (objectId, clipId) => {
4618
5598
  playAnimation(clipId, objectId);
4619
5599
  },
4620
- onResize: resize
5600
+ onResize: resize,
5601
+ onControl: (id, value) => {
5602
+ if (activeBench) benches.get(activeBench)?.set(id, value);
5603
+ },
5604
+ onLayer: (id, on) => {
5605
+ if (activeBench) benches.get(activeBench)?.setLayer(id, on);
5606
+ },
5607
+ onAction: (id) => {
5608
+ if (activeBench) benches.get(activeBench)?.act(id);
5609
+ }
4621
5610
  });
4622
5611
  ui.hud(hudState());
5612
+ showBench();
4623
5613
  }
4624
5614
  let raf = 0;
4625
5615
  if (ownRenderer && typeof requestAnimationFrame === "function") {
4626
5616
  let last = performance.now();
5617
+ ownsLoop = true;
4627
5618
  const loop = (now) => {
4628
5619
  raf = requestAnimationFrame(loop);
4629
- const dt = Math.min(0.05, (now - last) / 1e3);
5620
+ const realDelta = (now - last) / 1e3;
5621
+ roomMetrics.frame(realDelta);
5622
+ const dt = Math.min(0.05, realDelta);
4630
5623
  last = now;
4631
5624
  if (!scenarioRunning) update(dt);
4632
5625
  gl.render(scene, camera);
5626
+ roomMetrics.rendered();
4633
5627
  };
4634
5628
  raf = requestAnimationFrame(loop);
4635
5629
  }
@@ -4651,7 +5645,9 @@ export {
4651
5645
  buildFromSpec,
4652
5646
  createLog,
4653
5647
  createTestRoom,
5648
+ decodeBenchState,
4654
5649
  demoItems,
5650
+ encodeBenchState,
4655
5651
  locales,
4656
5652
  makeScenarios,
4657
5653
  resolveStrings,