ultimatedarktower 5.0.0 → 5.0.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [5.0.1] - 2026-07-03
10
+
11
+ ### Fixed
12
+
13
+ Fixes from a full-source code review, ordered by severity:
14
+
15
+ - **Node adapter: a failed `connect()` no longer permanently breaks retries.** `NodeBluetoothAdapter.cleanup()` was nulling the `onCharacteristicValueChanged`/`onDisconnect`/`onBluetoothAvailabilityChanged` callbacks on any connection failure, but `UdtBleConnection` only wires those callbacks once. A failed connect (e.g. a scan timeout) silently broke all future notifications on the next successful `connect()` — no battery heartbeat, no command responses, forcing a disconnect ~3s later. `cleanup()` no longer clears these caller-owned callback fields, matching `WebBluetoothAdapter`'s existing behavior.
16
+ - **Tower state aliasing fixed: `onTowerStateUpdate` now receives distinct old/new objects.** `getCurrentTowerState()` (both the internal command-builder dependency and the public method) previously returned the live state object (or a shallow copy sharing nested `layer`/`drum` objects), so commands that mutated it in place before calling `setTowerState` produced `oldState === newState` — consumers couldn't diff, and `logDetail` always logged "No changes detected". Both now return a full deep copy (`UdtCommandFactory.deepCopyTowerState`, now public). `createTransientAudioCommandWithModifications` also switched from a shallow spread to a deep copy, since its `Object.assign` calls on nested drum/layer/beam objects were mutating the caller's original state.
17
+ - **Drum-position tracking after rotation.** As a consequence of the above, `rotate()` and `rotateWithState()` — which mutated the (now-copied) state directly — needed explicit fixes: `rotate()` now calls `setTowerState` after updating drum positions so the change is actually recorded and notified; `rotateWithState()`'s `finally` block no longer force-writes all three drum positions unconditionally, since that masked partial failures (a drum that never rotated could be recorded as if it had) and was redundant on success (each `rotateDrumStateful()` call already records its own drum).
18
+ - **Send failures are no longer swallowed.** `sendTowerCommandDirect` returned normally instead of throwing when not connected or after retries were exhausted, so the command queue only ever learned of the failure via its 30s timeout — any command issued while disconnected silently hung for 30 seconds before rejecting with a misleading "Command timeout". It now throws immediately in both cases.
19
+ - **Calibration flags are now set before the command is sent**, not after `await`, so a fast calibration-complete response can't arrive before `performingCalibration` is set (which would suppress heartbeat disconnect detection and never fire `onCalibrationComplete`). The flags are cleared again if the send fails.
20
+ - **`logDetail` no longer discards the command queue.** Setting `tower.logDetail` used to reconstruct the whole `UdtTowerCommands`/`CommandQueue`, orphaning any in-flight/queued commands (they could then only resolve via their own 30s timeout, since responses routed to the new queue). It now updates the existing instance in place. `retrySendCommandMax` is likewise now a live setter instead of a value copied once at construction (previously it only "refreshed" as an accidental side effect of toggling `logDetail`).
21
+ - **`breakSeal` now syncs volume whenever it actually changes**, not just when the requested volume is truthy. Requesting volume `0` (Loud) while the tower's tracked volume was non-zero previously skipped the sync entirely, so the seal sound played at the wrong volume.
22
+ - **Removed duplicate console logging** — `Logger`'s constructor already installs a default `ConsoleOutput`; `UltimateDarkTower` was adding a second one, duplicating every default log line.
23
+ - **Unsolicited tower notifications no longer resolve the wrong queued command.** Spontaneous mechanical-sensor notifications (jiggle detection, unexpected trigger, differential readings) can arrive at any time and were being treated like a command ack, prematurely completing whatever command was in flight. These are now excluded from queue resolution while still reaching the public raw `onTowerResponse` passthrough.
24
+ - **`DOMOutput` no longer goes silent on pages without `logLevel-*` checkboxes** — it now defaults to showing all levels when none of the expected checkbox elements exist, instead of filtering out all output.
25
+ - Diagnostics `LIBRARY_VERSION` corrected from a stale `3.0.0` to `5.0.0`.
26
+ - **NodeBluetoothAdapter** no longer leaks a noble `stateChange` listener across repeated connect/disconnect cycles (it's now removed in `disconnect()`, not just `cleanup()`).
27
+ - **WebBluetoothAdapter** now cleans up partial connection state (GATT connection, listeners) when `connect()` fails partway through, matching the Node adapter's existing behavior.
28
+ - **Sound index validation tightened.** `playSound`/`playSoundStateful` were checking `soundIndex === null`, which let `undefined`/`NaN` silently through as a "valid" index; they now use `Number.isFinite`. `rotate()`'s optional `soundIndex` is now bounds-checked against the audio library (previously written unvalidated).
29
+ - `UdtBleConnection.disconnect()` no longer fires `onTowerDisconnect` (and clears the command queue) when called on a connection that was never established.
30
+ - Battery log line now correctly labels its already-volts value as `v` instead of `mv`.
31
+ - **`SystemRandom.nextRange`'s large-range branch (> `Int32.MaxValue`) now faithfully ports .NET's `GetSampleForLargeRange()`** instead of a rougher approximation, so seed-derived sequences match the reference implementation for wide ranges too.
32
+ - Disconnect detection in `sendTowerCommandDirect` now checks `instanceof BluetoothConnectionError` first, falling back to message-substring checks only for untyped native errors; removed a dead substring check that could never match.
33
+ - Removed the deprecated, uncalled `updateGlyphPositionsForRotation` method.
34
+ - `commandToPacketString` now zero-pads each byte to 2 hex digits (e.g. `[0f,03]` instead of `[f,3]`), removing ambiguity in packet dumps/logs.
35
+
9
36
  ## [5.0.0] - 2026-06-20
10
37
 
11
38
  ### Changed
@@ -473,6 +473,7 @@ var init_WebBluetoothAdapter = __esm({
473
473
  this.boundOnCharacteristicValueChanged
474
474
  );
475
475
  } catch (error) {
476
+ await this.cleanup();
476
477
  if (error instanceof BluetoothDeviceNotFoundError || error instanceof BluetoothUserCancelledError || error instanceof BluetoothConnectionError) {
477
478
  throw error;
478
479
  }
@@ -677,6 +678,10 @@ var init_NodeBluetoothAdapter = __esm({
677
678
  }
678
679
  }
679
680
  async disconnect() {
681
+ if (noble && this.boundStateChangeHandler) {
682
+ noble.removeListener("stateChange", this.boundStateChangeHandler);
683
+ this.boundStateChangeHandler = void 0;
684
+ }
680
685
  if (!this.peripheral) return;
681
686
  try {
682
687
  if (this.rxCharacteristic) {
@@ -798,14 +803,6 @@ var init_NodeBluetoothAdapter = __esm({
798
803
  return info;
799
804
  }
800
805
  async cleanup() {
801
- if (noble) {
802
- if (this.boundStateChangeHandler) {
803
- noble.removeListener(
804
- "stateChange",
805
- this.boundStateChangeHandler
806
- );
807
- }
808
- }
809
806
  if (this.peripheral && this.boundDisconnectHandler) {
810
807
  this.peripheral.removeListener(
811
808
  "disconnect",
@@ -813,9 +810,6 @@ var init_NodeBluetoothAdapter = __esm({
813
810
  );
814
811
  }
815
812
  await this.disconnect();
816
- this.characteristicCallback = void 0;
817
- this.disconnectCallback = void 0;
818
- this.availabilityCallback = void 0;
819
813
  }
820
814
  /**
821
815
  * Scans for a BLE device by name using Noble's event-driven discovery
@@ -1075,10 +1069,7 @@ function commandToPacketString(command) {
1075
1069
  if (command.length === 0) {
1076
1070
  return "[]";
1077
1071
  }
1078
- let cmdStr = "[";
1079
- command.forEach((n) => cmdStr += n.toString(16) + ",");
1080
- cmdStr = cmdStr.slice(0, -1) + "]";
1081
- return cmdStr;
1072
+ return `[${Array.from(command).map((n) => n.toString(16).padStart(2, "0")).join(",")}]`;
1082
1073
  }
1083
1074
  function parseDifferentialReadings(response) {
1084
1075
  if (response.length < 5 || response[0] !== 6) {
@@ -1222,15 +1213,18 @@ var DOMOutput = class {
1222
1213
  this.updateBufferSizeDisplay();
1223
1214
  }
1224
1215
  getEnabledLevelsFromCheckboxes() {
1225
- const enabledLevels = /* @__PURE__ */ new Set();
1216
+ const levelNames = ["debug", "info", "warn", "error"];
1226
1217
  if (typeof document === "undefined") {
1227
- return enabledLevels;
1218
+ return new Set(levelNames);
1219
+ }
1220
+ const checkboxes = levelNames.map((level) => document.getElementById(`logLevel-${level}`));
1221
+ if (checkboxes.every((checkbox) => !checkbox)) {
1222
+ return new Set(levelNames);
1228
1223
  }
1229
- const checkboxes = ["debug", "info", "warn", "error"];
1230
- checkboxes.forEach((level) => {
1231
- const checkbox = document.getElementById(`logLevel-${level}`);
1224
+ const enabledLevels = /* @__PURE__ */ new Set();
1225
+ checkboxes.forEach((checkbox, index) => {
1232
1226
  if (checkbox && checkbox.checked) {
1233
- enabledLevels.add(level);
1227
+ enabledLevels.add(levelNames[index]);
1234
1228
  }
1235
1229
  });
1236
1230
  return enabledLevels;
@@ -1508,7 +1502,7 @@ var TowerResponseProcessor = class {
1508
1502
  return [towerCommand.name, commandToPacketString(command)];
1509
1503
  case TC.BATTERY: {
1510
1504
  const millivolts = getMilliVoltsFromTowerResponse(command);
1511
- const retval = [towerCommand.name, `${milliVoltsToPercentage(millivolts)} (${(millivolts / 1e3).toFixed(2)}mv)`];
1505
+ const retval = [towerCommand.name, `${milliVoltsToPercentage(millivolts)} (${(millivolts / 1e3).toFixed(2)}v)`];
1512
1506
  if (this.logDetail) {
1513
1507
  retval.push(commandToPacketString(command));
1514
1508
  }
@@ -1547,6 +1541,17 @@ var TowerResponseProcessor = class {
1547
1541
  isTowerStateResponse(cmdKey) {
1548
1542
  return cmdKey === TC.STATE;
1549
1543
  }
1544
+ /**
1545
+ * Checks if a command is a spontaneous mechanical-sensor notification that
1546
+ * isn't tied to any specific in-flight command (jiggle detection,
1547
+ * unexpected trigger, differential sensor readings). These can arrive at
1548
+ * any time and should not be treated as the ack for a queued command.
1549
+ * @param {string} cmdKey - Command key from tower message
1550
+ * @returns {boolean} True if this is an unsolicited notification
1551
+ */
1552
+ isUnsolicitedResponse(cmdKey) {
1553
+ return cmdKey === TC.JIGGLE || cmdKey === TC.UNEXPECTED || cmdKey === TC.DIFFERENTIAL;
1554
+ }
1550
1555
  };
1551
1556
 
1552
1557
  // src/udtBluetoothAdapterFactory.ts
@@ -1758,9 +1763,10 @@ var UdtBleConnection = class {
1758
1763
  }
1759
1764
  async disconnect() {
1760
1765
  this.stopConnectionMonitoring();
1761
- if (this.isConnected) {
1762
- this.recordIncident("user_initiated");
1766
+ if (!this.isConnected) {
1767
+ return;
1763
1768
  }
1769
+ this.recordIncident("user_initiated");
1764
1770
  const adapter = this.bluetoothAdapter;
1765
1771
  if (adapter?.isConnected()) {
1766
1772
  await adapter.disconnect();
@@ -2161,7 +2167,7 @@ var UdtCommandFactory = class {
2161
2167
  audio: audioMods
2162
2168
  };
2163
2169
  const command = this.createStatefulCommand(currentState, modifications);
2164
- const stateWithoutAudio = currentState ? { ...currentState } : this.createEmptyTowerState();
2170
+ const stateWithoutAudio = currentState ? this.deepCopyTowerState(currentState) : this.createEmptyTowerState();
2165
2171
  if (otherModifications.drum) {
2166
2172
  otherModifications.drum.forEach((drum, index) => {
2167
2173
  if (drum && stateWithoutAudio.drum[index]) {
@@ -2273,6 +2279,7 @@ var UdtCommandFactory = class {
2273
2279
 
2274
2280
  // src/udtTowerCommands.ts
2275
2281
  init_udtConstants();
2282
+ init_udtBluetoothAdapter();
2276
2283
 
2277
2284
  // src/udtCommandQueue.ts
2278
2285
  var CommandQueue = class {
@@ -2446,13 +2453,13 @@ var UdtTowerCommands = class {
2446
2453
  * @returns Promise that resolves when command is sent successfully
2447
2454
  */
2448
2455
  async sendTowerCommandDirect(command) {
2456
+ const cmdStr = commandToPacketString(command);
2457
+ this.deps.logDetail && this.deps.logger.debug(`${cmdStr}`, "[UDT][CMD]");
2458
+ if (!this.deps.bleConnection.isConnected) {
2459
+ this.deps.logger.warn("Tower is not connected", "[UDT][CMD]");
2460
+ throw new Error("Cannot send command: tower is not connected");
2461
+ }
2449
2462
  try {
2450
- const cmdStr = commandToPacketString(command);
2451
- this.deps.logDetail && this.deps.logger.debug(`${cmdStr}`, "[UDT][CMD]");
2452
- if (!this.deps.bleConnection.isConnected) {
2453
- this.deps.logger.warn("Tower is not connected", "[UDT][CMD]");
2454
- return;
2455
- }
2456
2463
  await this.deps.bleConnection.writeCommand(command);
2457
2464
  this.deps.retrySendCommandCount.value = 0;
2458
2465
  this.deps.bleConnection.lastSuccessfulCommand = Date.now();
@@ -2461,11 +2468,12 @@ var UdtTowerCommands = class {
2461
2468
  const errorMsg = error?.message ?? String(error);
2462
2469
  const wasCancelled = errorMsg.includes("User cancelled");
2463
2470
  const maxRetriesReached = this.deps.retrySendCommandCount.value >= this.deps.retrySendCommandMax;
2464
- const isDisconnected = errorMsg.includes("Cannot read properties of null") || errorMsg.includes("GATT Server is disconnected") || errorMsg.includes("Device is not connected") || errorMsg.includes("BluetoothConnectionError") || !this.deps.bleConnection.isConnected;
2471
+ const sendError = error instanceof Error ? error : new Error(errorMsg);
2472
+ const isDisconnected = error instanceof BluetoothConnectionError || errorMsg.includes("Cannot read properties of null") || errorMsg.includes("GATT Server is disconnected") || errorMsg.includes("Device is not connected") || !this.deps.bleConnection.isConnected;
2465
2473
  if (isDisconnected) {
2466
2474
  this.deps.logger.warn("Disconnect detected during command send", "[UDT][CMD]");
2467
2475
  await this.deps.bleConnection.disconnect();
2468
- return;
2476
+ throw sendError;
2469
2477
  }
2470
2478
  if (!maxRetriesReached && this.deps.bleConnection.isConnected && !wasCancelled) {
2471
2479
  this.deps.logger.info(`retrying tower command attempt ${this.deps.retrySendCommandCount.value + 1}`, "[UDT][CMD]");
@@ -2473,9 +2481,9 @@ var UdtTowerCommands = class {
2473
2481
  const delay = 250 * this.deps.retrySendCommandCount.value;
2474
2482
  await new Promise((resolve) => setTimeout(resolve, delay));
2475
2483
  return await this.sendTowerCommandDirect(command);
2476
- } else {
2477
- this.deps.retrySendCommandCount.value = 0;
2478
2484
  }
2485
+ this.deps.retrySendCommandCount.value = 0;
2486
+ throw sendError;
2479
2487
  }
2480
2488
  }
2481
2489
  /**
@@ -2484,16 +2492,21 @@ var UdtTowerCommands = class {
2484
2492
  * @returns Promise that resolves when calibration command is sent
2485
2493
  */
2486
2494
  async calibrate() {
2487
- if (!this.deps.bleConnection.performingCalibration) {
2488
- this.deps.logger.info("Performing Tower Calibration", "[UDT][CMD]");
2489
- this.deps.recorder?.recordEvent("calibration_started");
2490
- await this.sendTowerCommand(new Uint8Array([TOWER_COMMANDS.calibration]), "calibrate");
2491
- this.deps.bleConnection.performingCalibration = true;
2492
- this.deps.bleConnection.performingLongCommand = true;
2495
+ if (this.deps.bleConnection.performingCalibration) {
2496
+ this.deps.logger.warn("Tower calibration requested when tower is already performing calibration", "[UDT][CMD]");
2493
2497
  return;
2494
2498
  }
2495
- this.deps.logger.warn("Tower calibration requested when tower is already performing calibration", "[UDT][CMD]");
2496
- return;
2499
+ this.deps.logger.info("Performing Tower Calibration", "[UDT][CMD]");
2500
+ this.deps.recorder?.recordEvent("calibration_started");
2501
+ this.deps.bleConnection.performingCalibration = true;
2502
+ this.deps.bleConnection.performingLongCommand = true;
2503
+ try {
2504
+ await this.sendTowerCommand(new Uint8Array([TOWER_COMMANDS.calibration]), "calibrate");
2505
+ } catch (error) {
2506
+ this.deps.bleConnection.performingCalibration = false;
2507
+ this.deps.bleConnection.performingLongCommand = false;
2508
+ throw error;
2509
+ }
2497
2510
  }
2498
2511
  /**
2499
2512
  * Plays a sound from the tower's audio library using stateful commands that preserve existing tower state.
@@ -2502,7 +2515,7 @@ var UdtTowerCommands = class {
2502
2515
  * @returns Promise that resolves when sound command is sent
2503
2516
  */
2504
2517
  async playSound(soundIndex) {
2505
- const invalidIndex = soundIndex === null || soundIndex > Object.keys(TOWER_AUDIO_LIBRARY).length || soundIndex <= 0;
2518
+ const invalidIndex = !Number.isFinite(soundIndex) || soundIndex > Object.keys(TOWER_AUDIO_LIBRARY).length || soundIndex <= 0;
2506
2519
  if (invalidIndex) {
2507
2520
  this.deps.logger.error(`attempt to play invalid sound index ${soundIndex}`, "[UDT][CMD]");
2508
2521
  return;
@@ -2691,8 +2704,13 @@ var UdtTowerCommands = class {
2691
2704
  async rotate(top, middle, bottom, soundIndex) {
2692
2705
  this.deps.logDetail && this.deps.logger.debug(`Rotate Parameter TMB[${JSON.stringify(top)}|${middle}|${bottom}] S[${soundIndex}]`, "[UDT][CMD]");
2693
2706
  const rotateCommand = this.deps.commandFactory.createRotateCommand(top, middle, bottom);
2694
- if (soundIndex) {
2695
- rotateCommand[AUDIO_COMMAND_POS] = soundIndex;
2707
+ if (soundIndex !== void 0) {
2708
+ const validSoundIndex = Number.isFinite(soundIndex) && soundIndex > 0 && soundIndex <= Object.keys(TOWER_AUDIO_LIBRARY).length;
2709
+ if (validSoundIndex) {
2710
+ rotateCommand[AUDIO_COMMAND_POS] = soundIndex;
2711
+ } else {
2712
+ this.deps.logger.error(`attempt to play invalid sound index ${soundIndex} during rotate`, "[UDT][CMD]");
2713
+ }
2696
2714
  }
2697
2715
  this.deps.logger.info("Sending rotate command" + (soundIndex ? " with sound" : ""), "[UDT]");
2698
2716
  this.deps.bleConnection.performingLongCommand = true;
@@ -2711,6 +2729,7 @@ var UdtTowerCommands = class {
2711
2729
  towerState.drum[0].position = topPosition;
2712
2730
  towerState.drum[1].position = middlePosition;
2713
2731
  towerState.drum[2].position = bottomPosition;
2732
+ this.deps.setTowerState(towerState, "rotate");
2714
2733
  }
2715
2734
  }
2716
2735
  /**
@@ -2743,12 +2762,6 @@ var UdtTowerCommands = class {
2743
2762
  this.deps.bleConnection.performingLongCommand = false;
2744
2763
  this.deps.bleConnection.lastBatteryHeartbeat = Date.now();
2745
2764
  }, this.deps.bleConnection.longTowerCommandTimeout);
2746
- const towerState = this.deps.getCurrentTowerState();
2747
- if (towerState) {
2748
- towerState.drum[0].position = positionMap[top];
2749
- towerState.drum[1].position = positionMap[middle];
2750
- towerState.drum[2].position = positionMap[bottom];
2751
- }
2752
2765
  }
2753
2766
  }
2754
2767
  /**
@@ -2773,9 +2786,9 @@ var UdtTowerCommands = class {
2773
2786
  * @returns Promise that resolves when seal break sequence is complete
2774
2787
  */
2775
2788
  async breakSeal(seal, volume) {
2776
- const actualVolume = volume !== void 0 ? volume : this.deps.getCurrentTowerState().audio.volume;
2777
- if (actualVolume > 0) {
2778
- const currentState = this.deps.getCurrentTowerState();
2789
+ const currentState = this.deps.getCurrentTowerState();
2790
+ const actualVolume = volume !== void 0 ? volume : currentState.audio.volume;
2791
+ if (actualVolume !== currentState.audio.volume) {
2779
2792
  const stateWithVolume = { ...currentState };
2780
2793
  stateWithVolume.audio = { sample: 0, loop: false, volume: actualVolume };
2781
2794
  await this.sendTowerStateStateful(stateWithVolume);
@@ -2906,7 +2919,7 @@ var UdtTowerCommands = class {
2906
2919
  * @returns Promise that resolves when command is sent
2907
2920
  */
2908
2921
  async playSoundStateful(soundIndex, loop = false, volume) {
2909
- const invalidIndex = soundIndex === null || soundIndex > Object.keys(TOWER_AUDIO_LIBRARY).length || soundIndex <= 0;
2922
+ const invalidIndex = !Number.isFinite(soundIndex) || soundIndex > Object.keys(TOWER_AUDIO_LIBRARY).length || soundIndex <= 0;
2910
2923
  if (invalidIndex) {
2911
2924
  this.deps.logger.error(`attempt to play invalid sound index ${soundIndex}`, "[UDT][CMD]");
2912
2925
  return;
@@ -2956,6 +2969,20 @@ var UdtTowerCommands = class {
2956
2969
  await this.sendTowerCommand(command, "sendTowerStateStateful");
2957
2970
  }
2958
2971
  //#endregion
2972
+ /**
2973
+ * Updates the detailed-logging flag without discarding the command queue.
2974
+ * @param value - Whether detailed logging is enabled
2975
+ */
2976
+ updateLogDetail(value) {
2977
+ this.deps.logDetail = value;
2978
+ }
2979
+ /**
2980
+ * Updates the max retry count without discarding the command queue.
2981
+ * @param value - New max retry count
2982
+ */
2983
+ updateRetrySendCommandMax(value) {
2984
+ this.deps.retrySendCommandMax = value;
2985
+ }
2959
2986
  /**
2960
2987
  * Public access to sendTowerCommandDirect for testing purposes.
2961
2988
  * This bypasses the command queue and sends commands directly.
@@ -2991,7 +3018,7 @@ var RING_BUFFER_SIZE = 500;
2991
3018
  var RING_BUFFER_DRAIN = 50;
2992
3019
  var BATTERY_HISTORY_SIZE = 60;
2993
3020
  var PAYLOAD_MAX_BYTES = 32;
2994
- var LIBRARY_VERSION = "3.0.0";
3021
+ var LIBRARY_VERSION = "5.0.0";
2995
3022
  function detectPlatform() {
2996
3023
  if (typeof window !== "undefined" && typeof window.navigator !== "undefined") {
2997
3024
  return "web";
@@ -3183,7 +3210,7 @@ var UltimateDarkTower = class {
3183
3210
  this.beforeUnloadHandler = null;
3184
3211
  // tower configuration
3185
3212
  this.retrySendCommandCountRef = { value: 0 };
3186
- this.retrySendCommandMax = DEFAULT_RETRY_SEND_COMMAND_MAX;
3213
+ this._retrySendCommandMax = DEFAULT_RETRY_SEND_COMMAND_MAX;
3187
3214
  // tower state
3188
3215
  this.currentBatteryValue = 0;
3189
3216
  this.previousBatteryValue = 0;
@@ -3241,7 +3268,6 @@ var UltimateDarkTower = class {
3241
3268
  */
3242
3269
  initializeLogger() {
3243
3270
  this.logger = new Logger();
3244
- this.logger.addOutput(new ConsoleOutput());
3245
3271
  }
3246
3272
  /**
3247
3273
  * Initialize the diagnostics recorder. Always constructed; `enabled` defaults
@@ -3307,20 +3333,20 @@ var UltimateDarkTower = class {
3307
3333
  */
3308
3334
  setupTowerResponseCallback() {
3309
3335
  this.towerEventCallbacks.onTowerResponse = (response) => {
3310
- this.towerCommands.onTowerResponse();
3311
- if (response.length >= TOWER_STATE_RESPONSE_MIN_LENGTH) {
3312
- const { cmdKey } = this.responseProcessor.getTowerCommand(response[0]);
3313
- if (this.responseProcessor.isTowerStateResponse(cmdKey)) {
3314
- const stateData = response.slice(TOWER_STATE_DATA_OFFSET, TOWER_STATE_RESPONSE_MIN_LENGTH);
3315
- this.updateTowerStateFromResponse(stateData);
3316
- }
3336
+ const { cmdKey } = this.responseProcessor.getTowerCommand(response[0]);
3337
+ if (!this.responseProcessor.isUnsolicitedResponse(cmdKey)) {
3338
+ this.towerCommands.onTowerResponse();
3339
+ }
3340
+ if (response.length >= TOWER_STATE_RESPONSE_MIN_LENGTH && this.responseProcessor.isTowerStateResponse(cmdKey)) {
3341
+ const stateData = response.slice(TOWER_STATE_DATA_OFFSET, TOWER_STATE_RESPONSE_MIN_LENGTH);
3342
+ this.updateTowerStateFromResponse(stateData);
3317
3343
  }
3318
3344
  this.onTowerResponse(response);
3319
3345
  };
3320
3346
  }
3321
3347
  /**
3322
- * Create tower event callbacks for BLE connection
3323
- */
3348
+ * Create tower event callbacks for BLE connection
3349
+ */
3324
3350
  createTowerEventCallbacks() {
3325
3351
  return {
3326
3352
  onTowerConnect: () => this.onTowerConnect(),
@@ -3355,8 +3381,12 @@ var UltimateDarkTower = class {
3355
3381
  responseProcessor: this.responseProcessor,
3356
3382
  logDetail: this.logDetail,
3357
3383
  retrySendCommandCount: this.retrySendCommandCountRef,
3358
- retrySendCommandMax: this.retrySendCommandMax,
3359
- getCurrentTowerState: () => this.currentTowerState,
3384
+ retrySendCommandMax: this._retrySendCommandMax,
3385
+ // Return a deep copy so command builders can mutate it in place before
3386
+ // handing it to setTowerState() without aliasing the live state (the
3387
+ // old/new state passed to onTowerStateUpdate would otherwise be the
3388
+ // same object).
3389
+ getCurrentTowerState: () => this.commandFactory.deepCopyTowerState(this.currentTowerState),
3360
3390
  setTowerState: (newState, source) => this.setTowerState(newState, source),
3361
3391
  recorder: this.diagnosticsRecorder
3362
3392
  };
@@ -3377,15 +3407,17 @@ var UltimateDarkTower = class {
3377
3407
  this._logDetail = value;
3378
3408
  this.responseProcessor.setDetailedLogging(value);
3379
3409
  if (this.towerCommands) {
3380
- this.updateTowerCommandDependencies();
3410
+ this.towerCommands.updateLogDetail(value);
3381
3411
  }
3382
3412
  }
3383
- /**
3384
- * Update tower command dependencies when configuration changes
3385
- */
3386
- updateTowerCommandDependencies() {
3387
- const commandDependencies = this.createCommandDependencies();
3388
- this.towerCommands = new UdtTowerCommands(commandDependencies);
3413
+ get retrySendCommandMax() {
3414
+ return this._retrySendCommandMax;
3415
+ }
3416
+ set retrySendCommandMax(value) {
3417
+ this._retrySendCommandMax = value;
3418
+ if (this.towerCommands) {
3419
+ this.towerCommands.updateRetrySendCommandMax(value);
3420
+ }
3389
3421
  }
3390
3422
  // Getter methods for connection state
3391
3423
  get isConnected() {
@@ -3608,7 +3640,7 @@ var UltimateDarkTower = class {
3608
3640
  * @returns The current tower state object
3609
3641
  */
3610
3642
  getCurrentTowerState() {
3611
- return { ...this.currentTowerState };
3643
+ return this.commandFactory.deepCopyTowerState(this.currentTowerState);
3612
3644
  }
3613
3645
  /**
3614
3646
  * Sends a complete tower state to the tower, preserving existing state.
@@ -3775,23 +3807,6 @@ var UltimateDarkTower = class {
3775
3807
  this.updateGlyphPositionsAfterRotation(level, rotationSteps);
3776
3808
  }
3777
3809
  }
3778
- /**
3779
- * Updates glyph positions for a specific level rotation.
3780
- * @param level - The drum level that was rotated
3781
- * @param newPosition - The new position the drum was rotated to
3782
- * @deprecated Use calculateAndUpdateGlyphPositions instead
3783
- */
3784
- updateGlyphPositionsForRotation(level, newPosition) {
3785
- const currentPosition = this.getCurrentDrumPosition(level);
3786
- const sides = ["north", "east", "south", "west"];
3787
- const currentIndex = sides.indexOf(currentPosition);
3788
- const newIndex = sides.indexOf(newPosition);
3789
- let rotationSteps = newIndex - currentIndex;
3790
- if (rotationSteps < 0) {
3791
- rotationSteps += TOWER_SIDES_COUNT;
3792
- }
3793
- this.updateGlyphPositionsAfterRotation(level, rotationSteps);
3794
- }
3795
3810
  /**
3796
3811
  * Checks if a specific seal is broken.
3797
3812
  * @param seal - The seal identifier to check
@@ -6436,6 +6451,22 @@ var SystemRandom = class {
6436
6451
  sample() {
6437
6452
  return this.internalSample() * (1 / INT32_MAX);
6438
6453
  }
6454
+ /**
6455
+ * Sample for ranges wider than Int32.MaxValue.
6456
+ * Matches C#'s GetSampleForLargeRange(): draws two internal samples (the
6457
+ * second decides sign) and normalizes to [0.0, 1.0).
6458
+ */
6459
+ getSampleForLargeRange() {
6460
+ let result = this.internalSample();
6461
+ const negative = this.internalSample() % 2 === 0;
6462
+ if (negative) {
6463
+ result = -result;
6464
+ }
6465
+ let d = result;
6466
+ d += INT32_MAX - 1;
6467
+ d /= 2 * INT32_MAX - 1;
6468
+ return d;
6469
+ }
6439
6470
  /**
6440
6471
  * Returns a non-negative random integer less than Int32.MaxValue.
6441
6472
  * Matches C# `Random.Next()`.
@@ -6465,7 +6496,7 @@ var SystemRandom = class {
6465
6496
  if (range <= INT32_MAX) {
6466
6497
  return toInt32(this.sample() * range) + minValue;
6467
6498
  }
6468
- return toInt32(this.internalSample() * (1 / INT32_MAX) * range) + minValue;
6499
+ return Math.floor(this.getSampleForLargeRange() * range) + minValue;
6469
6500
  }
6470
6501
  /**
6471
6502
  * Returns a random double in range [0.0, 1.0).
@@ -51,7 +51,7 @@ declare class UltimateDarkTower {
51
51
  private commandFactory;
52
52
  private towerCommands;
53
53
  private retrySendCommandCountRef;
54
- retrySendCommandMax: number;
54
+ private _retrySendCommandMax;
55
55
  currentBatteryValue: number;
56
56
  previousBatteryValue: number;
57
57
  currentBatteryPercentage: number;
@@ -96,7 +96,8 @@ declare class UltimateDarkTower {
96
96
  /**
97
97
  * Set up the tower response callback after all components are initialized
98
98
  */
99
- private setupTowerResponseCallback; /**
99
+ private setupTowerResponseCallback;
100
+ /**
100
101
  * Create tower event callbacks for BLE connection
101
102
  */
102
103
  private createTowerEventCallbacks;
@@ -111,10 +112,8 @@ declare class UltimateDarkTower {
111
112
  private _logDetail;
112
113
  get logDetail(): boolean;
113
114
  set logDetail(value: boolean);
114
- /**
115
- * Update tower command dependencies when configuration changes
116
- */
117
- private updateTowerCommandDependencies;
115
+ get retrySendCommandMax(): number;
116
+ set retrySendCommandMax(value: number);
118
117
  get isConnected(): boolean;
119
118
  get isCalibrated(): boolean;
120
119
  get performingCalibration(): boolean;
@@ -316,13 +315,6 @@ declare class UltimateDarkTower {
316
315
  * @param newPosition - The position after rotation
317
316
  */
318
317
  private calculateAndUpdateGlyphPositions;
319
- /**
320
- * Updates glyph positions for a specific level rotation.
321
- * @param level - The drum level that was rotated
322
- * @param newPosition - The new position the drum was rotated to
323
- * @deprecated Use calculateAndUpdateGlyphPositions instead
324
- */
325
- private updateGlyphPositionsForRotation;
326
318
  /**
327
319
  * Checks if a specific seal is broken.
328
320
  * @param seal - The seal identifier to check