ultimatedarktower 3.0.0 → 4.0.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/CHANGELOG.md +42 -0
- package/README.md +28 -15
- package/dist/esm/index.mjs +1039 -45
- package/dist/src/UltimateDarkTower.d.ts +42 -0
- package/dist/src/UltimateDarkTower.js +102 -2
- package/dist/src/UltimateDarkTower.js.map +1 -1
- package/dist/src/adapters/NodeBluetoothAdapter.js +9 -5
- package/dist/src/adapters/NodeBluetoothAdapter.js.map +1 -1
- package/dist/src/adapters/WebBluetoothAdapter.js +11 -8
- package/dist/src/adapters/WebBluetoothAdapter.js.map +1 -1
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +34 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/sinks/IndexedDBSink.d.ts +26 -0
- package/dist/src/sinks/IndexedDBSink.js +165 -0
- package/dist/src/sinks/IndexedDBSink.js.map +1 -0
- package/dist/src/udtBleConnection.d.ts +24 -1
- package/dist/src/udtBleConnection.js +68 -2
- package/dist/src/udtBleConnection.js.map +1 -1
- package/dist/src/udtBluetoothAdapter.d.ts +6 -6
- package/dist/src/udtBluetoothAdapter.js.map +1 -1
- package/dist/src/udtCommandQueue.d.ts +4 -1
- package/dist/src/udtCommandQueue.js +26 -2
- package/dist/src/udtCommandQueue.js.map +1 -1
- package/dist/src/udtConstants.d.ts +2 -0
- package/dist/src/udtConstants.js +2 -0
- package/dist/src/udtConstants.js.map +1 -1
- package/dist/src/udtDiagnostics.d.ts +122 -0
- package/dist/src/udtDiagnostics.js +228 -0
- package/dist/src/udtDiagnostics.js.map +1 -0
- package/dist/src/udtGameBoard.d.ts +38 -0
- package/dist/src/udtGameBoard.js +86 -0
- package/dist/src/udtGameBoard.js.map +1 -0
- package/dist/src/udtLogger.d.ts +15 -0
- package/dist/src/udtLogger.js +17 -0
- package/dist/src/udtLogger.js.map +1 -1
- package/dist/src/udtSeedParser.d.ts +124 -0
- package/dist/src/udtSeedParser.js +369 -0
- package/dist/src/udtSeedParser.js.map +1 -0
- package/dist/src/udtSystemRandom.d.ts +58 -0
- package/dist/src/udtSystemRandom.js +154 -0
- package/dist/src/udtSystemRandom.js.map +1 -0
- package/dist/src/udtTowerCommands.d.ts +2 -0
- package/dist/src/udtTowerCommands.js +7 -29
- package/dist/src/udtTowerCommands.js.map +1 -1
- package/package.json +5 -1
package/dist/esm/index.mjs
CHANGED
|
@@ -125,7 +125,9 @@ var init_udtConstants = __esm({
|
|
|
125
125
|
rotationDrumTop: 16,
|
|
126
126
|
rotationDrumMiddle: 17,
|
|
127
127
|
rotationDrumBottom: 18,
|
|
128
|
-
monthStarted: 19
|
|
128
|
+
monthStarted: 19,
|
|
129
|
+
wholeTowerBreathing: 20,
|
|
130
|
+
slowFlareThenFade: 21
|
|
129
131
|
};
|
|
130
132
|
TOWER_MESSAGES = {
|
|
131
133
|
TOWER_STATE: { name: "Tower State", value: 0, critical: false },
|
|
@@ -452,9 +454,10 @@ var init_WebBluetoothAdapter = __esm({
|
|
|
452
454
|
await this.rxCharacteristic.startNotifications();
|
|
453
455
|
this.boundOnCharacteristicValueChanged = (event) => {
|
|
454
456
|
const target = event.target;
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
457
|
+
const dataView = target.value;
|
|
458
|
+
const receivedData = new Uint8Array(dataView.byteLength);
|
|
459
|
+
for (let i = 0; i < dataView.byteLength; i++) {
|
|
460
|
+
receivedData[i] = dataView.getUint8(i);
|
|
458
461
|
}
|
|
459
462
|
if (this.characteristicCallback) {
|
|
460
463
|
this.characteristicCallback(receivedData);
|
|
@@ -468,11 +471,12 @@ var init_WebBluetoothAdapter = __esm({
|
|
|
468
471
|
if (error instanceof BluetoothDeviceNotFoundError || error instanceof BluetoothUserCancelledError || error instanceof BluetoothConnectionError) {
|
|
469
472
|
throw error;
|
|
470
473
|
}
|
|
471
|
-
const errorMsg = error
|
|
474
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
475
|
+
const errorName = error instanceof Error ? error.name : "";
|
|
472
476
|
if (errorMsg.includes("User cancelled")) {
|
|
473
477
|
throw new BluetoothUserCancelledError("User cancelled device selection", error);
|
|
474
478
|
}
|
|
475
|
-
if (errorMsg.includes("not found") ||
|
|
479
|
+
if (errorMsg.includes("not found") || errorName === "NotFoundError") {
|
|
476
480
|
throw new BluetoothDeviceNotFoundError("Device not found", error);
|
|
477
481
|
}
|
|
478
482
|
throw new BluetoothConnectionError(`Failed to connect: ${errorMsg}`, error);
|
|
@@ -482,11 +486,11 @@ var init_WebBluetoothAdapter = __esm({
|
|
|
482
486
|
if (!this.device) {
|
|
483
487
|
return;
|
|
484
488
|
}
|
|
485
|
-
if (this.device.gatt
|
|
489
|
+
if (this.device.gatt?.connected) {
|
|
486
490
|
if (this.boundOnDeviceDisconnected) {
|
|
487
491
|
this.device.removeEventListener("gattserverdisconnected", this.boundOnDeviceDisconnected);
|
|
488
492
|
}
|
|
489
|
-
await this.device.gatt
|
|
493
|
+
await this.device.gatt?.disconnect();
|
|
490
494
|
}
|
|
491
495
|
this.device = null;
|
|
492
496
|
this.txCharacteristic = null;
|
|
@@ -602,8 +606,9 @@ var init_NodeBluetoothAdapter = __esm({
|
|
|
602
606
|
try {
|
|
603
607
|
await noble.waitForPoweredOnAsync();
|
|
604
608
|
} catch (error) {
|
|
609
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
605
610
|
throw new BluetoothConnectionError(
|
|
606
|
-
`Bluetooth adapter not ready: ${
|
|
611
|
+
`Bluetooth adapter not ready: ${msg}`,
|
|
607
612
|
error
|
|
608
613
|
);
|
|
609
614
|
}
|
|
@@ -638,10 +643,10 @@ var init_NodeBluetoothAdapter = __esm({
|
|
|
638
643
|
this.allCharacteristics = characteristics;
|
|
639
644
|
this.txCharacteristic = characteristics.find(
|
|
640
645
|
(c) => this.normalizeUuid(c.uuid) === txUuid
|
|
641
|
-
);
|
|
646
|
+
) ?? null;
|
|
642
647
|
this.rxCharacteristic = characteristics.find(
|
|
643
648
|
(c) => this.normalizeUuid(c.uuid) === rxUuid
|
|
644
|
-
);
|
|
649
|
+
) ?? null;
|
|
645
650
|
if (!this.txCharacteristic || !this.rxCharacteristic) {
|
|
646
651
|
throw new BluetoothConnectionError(
|
|
647
652
|
"TX or RX characteristic not found on device"
|
|
@@ -659,8 +664,9 @@ var init_NodeBluetoothAdapter = __esm({
|
|
|
659
664
|
if (error instanceof BluetoothDeviceNotFoundError || error instanceof BluetoothConnectionError || error instanceof BluetoothTimeoutError) {
|
|
660
665
|
throw error;
|
|
661
666
|
}
|
|
667
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
662
668
|
throw new BluetoothConnectionError(
|
|
663
|
-
`Connection failed: ${
|
|
669
|
+
`Connection failed: ${msg}`,
|
|
664
670
|
error
|
|
665
671
|
);
|
|
666
672
|
}
|
|
@@ -698,8 +704,9 @@ var init_NodeBluetoothAdapter = __esm({
|
|
|
698
704
|
const buffer = Buffer.from(data);
|
|
699
705
|
await this.txCharacteristic.writeAsync(buffer, false);
|
|
700
706
|
} catch (error) {
|
|
707
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
701
708
|
throw new BluetoothConnectionError(
|
|
702
|
-
`Write failed: ${
|
|
709
|
+
`Write failed: ${msg}`,
|
|
703
710
|
error
|
|
704
711
|
);
|
|
705
712
|
}
|
|
@@ -1227,11 +1234,19 @@ var Logger = class _Logger {
|
|
|
1227
1234
|
constructor() {
|
|
1228
1235
|
this.outputs = [];
|
|
1229
1236
|
this.enabledLevels = /* @__PURE__ */ new Set(["all"]);
|
|
1237
|
+
this.diagnosticsTarget = null;
|
|
1230
1238
|
this.outputs.push(new ConsoleOutput());
|
|
1231
1239
|
}
|
|
1232
1240
|
static {
|
|
1233
1241
|
this.instance = null;
|
|
1234
1242
|
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Bridge warn/error log lines into a diagnostics recorder so they appear
|
|
1245
|
+
* in the disconnect incident ring buffer in correct chronological order.
|
|
1246
|
+
*/
|
|
1247
|
+
setDiagnosticsTarget(target) {
|
|
1248
|
+
this.diagnosticsTarget = target;
|
|
1249
|
+
}
|
|
1235
1250
|
static getInstance() {
|
|
1236
1251
|
if (!_Logger.instance) {
|
|
1237
1252
|
_Logger.instance = new _Logger();
|
|
@@ -1286,6 +1301,13 @@ var Logger = class _Logger {
|
|
|
1286
1301
|
console.error("Logger output error:", error);
|
|
1287
1302
|
}
|
|
1288
1303
|
});
|
|
1304
|
+
if ((level === "warn" || level === "error") && this.diagnosticsTarget?.enabled) {
|
|
1305
|
+
try {
|
|
1306
|
+
this.diagnosticsTarget.recordLog(level, message, context);
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
console.error("Diagnostics log bridge error:", error);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1289
1311
|
}
|
|
1290
1312
|
debug(message, context) {
|
|
1291
1313
|
this.log("debug", message, context);
|
|
@@ -1531,7 +1553,12 @@ var BluetoothAdapterFactory = class {
|
|
|
1531
1553
|
|
|
1532
1554
|
// src/udtBleConnection.ts
|
|
1533
1555
|
var UdtBleConnection = class {
|
|
1534
|
-
constructor(logger2, callbacks, adapter) {
|
|
1556
|
+
constructor(logger2, callbacks, adapter, recorder) {
|
|
1557
|
+
this.recorder = null;
|
|
1558
|
+
// Snapshot providers wired by UltimateDarkTower so the recorder can capture
|
|
1559
|
+
// higher-level state (command queue, tower state, broken seals) at the
|
|
1560
|
+
// moment a disconnect cause fires.
|
|
1561
|
+
this.snapshotProviders = null;
|
|
1535
1562
|
// Connection state
|
|
1536
1563
|
this.isConnected = false;
|
|
1537
1564
|
this.isDisposed = false;
|
|
@@ -1576,6 +1603,7 @@ var UdtBleConnection = class {
|
|
|
1576
1603
|
this.logger = logger2;
|
|
1577
1604
|
this.callbacks = callbacks;
|
|
1578
1605
|
this.responseProcessor = new TowerResponseProcessor();
|
|
1606
|
+
this.recorder = recorder ?? null;
|
|
1579
1607
|
this.bluetoothAdapter = adapter || BluetoothAdapterFactory.create("auto" /* AUTO */);
|
|
1580
1608
|
this.bluetoothAdapter.onCharacteristicValueChanged((data) => {
|
|
1581
1609
|
this.onRxData(data);
|
|
@@ -1587,6 +1615,35 @@ var UdtBleConnection = class {
|
|
|
1587
1615
|
this.bleAvailabilityChange(available);
|
|
1588
1616
|
});
|
|
1589
1617
|
}
|
|
1618
|
+
setDiagnosticsSnapshotProviders(providers) {
|
|
1619
|
+
this.snapshotProviders = providers;
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Record a disconnect incident with the recorder. Public so higher layers
|
|
1623
|
+
* (e.g. UltimateDarkTower's beforeunload handler) can synthesize causes
|
|
1624
|
+
* like 'page_unload' that aren't tied to a specific BLE detection path.
|
|
1625
|
+
*/
|
|
1626
|
+
recordIncidentPublic(cause) {
|
|
1627
|
+
return this.recordIncident(cause);
|
|
1628
|
+
}
|
|
1629
|
+
recordIncident(cause) {
|
|
1630
|
+
if (!this.recorder || !this.recorder.enabled) return null;
|
|
1631
|
+
const queueSnapshot = this.snapshotProviders?.commandQueue() ?? {
|
|
1632
|
+
queueLength: 0,
|
|
1633
|
+
isProcessing: false,
|
|
1634
|
+
currentCommand: null
|
|
1635
|
+
};
|
|
1636
|
+
const towerState = this.snapshotProviders?.towerState() ?? null;
|
|
1637
|
+
const brokenSeals = this.snapshotProviders?.brokenSeals() ?? [];
|
|
1638
|
+
return this.recorder.recordIncident({
|
|
1639
|
+
cause,
|
|
1640
|
+
connectionStatus: this.getConnectionStatus(),
|
|
1641
|
+
deviceInformation: this.getDeviceInformation(),
|
|
1642
|
+
commandQueue: queueSnapshot,
|
|
1643
|
+
towerState,
|
|
1644
|
+
brokenSeals
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1590
1647
|
async connect() {
|
|
1591
1648
|
if (this.isDisposed) {
|
|
1592
1649
|
throw new Error("UdtBleConnection instance has been disposed and cannot reconnect");
|
|
@@ -1601,6 +1658,7 @@ var UdtBleConnection = class {
|
|
|
1601
1658
|
this.isConnected = true;
|
|
1602
1659
|
this.lastSuccessfulCommand = Date.now();
|
|
1603
1660
|
this.lastBatteryHeartbeat = Date.now();
|
|
1661
|
+
this.recorder?.beginSession();
|
|
1604
1662
|
await this.readDeviceInformation();
|
|
1605
1663
|
if (this.enableConnectionMonitoring) {
|
|
1606
1664
|
this.startConnectionMonitoring();
|
|
@@ -1609,11 +1667,14 @@ var UdtBleConnection = class {
|
|
|
1609
1667
|
} catch (error) {
|
|
1610
1668
|
this.logger.error(`Tower Connection Error: ${error}`, "[UDT][BLE]");
|
|
1611
1669
|
this.isConnected = false;
|
|
1612
|
-
|
|
1670
|
+
throw error;
|
|
1613
1671
|
}
|
|
1614
1672
|
}
|
|
1615
1673
|
async disconnect() {
|
|
1616
1674
|
this.stopConnectionMonitoring();
|
|
1675
|
+
if (this.isConnected) {
|
|
1676
|
+
this.recordIncident("user_initiated");
|
|
1677
|
+
}
|
|
1617
1678
|
if (this.bluetoothAdapter.isConnected()) {
|
|
1618
1679
|
await this.bluetoothAdapter.disconnect();
|
|
1619
1680
|
this.logger.info("Tower disconnected", "[UDT]");
|
|
@@ -1625,6 +1686,7 @@ var UdtBleConnection = class {
|
|
|
1625
1686
|
* Used by UdtTowerCommands instead of direct characteristic access.
|
|
1626
1687
|
*/
|
|
1627
1688
|
async writeCommand(command) {
|
|
1689
|
+
this.recorder?.recordCommandPayload("cmd_sent", command, { len: command.length });
|
|
1628
1690
|
return await this.bluetoothAdapter.writeCharacteristic(command);
|
|
1629
1691
|
}
|
|
1630
1692
|
/**
|
|
@@ -1635,6 +1697,9 @@ var UdtBleConnection = class {
|
|
|
1635
1697
|
this.lastSuccessfulCommand = Date.now();
|
|
1636
1698
|
const { cmdKey } = this.responseProcessor.getTowerCommand(receivedData[0]);
|
|
1637
1699
|
const isBattery = this.responseProcessor.isBatteryResponse(cmdKey);
|
|
1700
|
+
if (this.recorder?.enabled && !isBattery) {
|
|
1701
|
+
this.recorder.recordCommandPayload("cmd_response", receivedData, { cmdKey, len: receivedData.length });
|
|
1702
|
+
}
|
|
1638
1703
|
const shouldLogCommand = this.logTowerResponses && this.responseProcessor.shouldLogResponse(cmdKey, this.logTowerResponseConfig) && !isBattery;
|
|
1639
1704
|
if (shouldLogCommand) {
|
|
1640
1705
|
this.logger.info(`${cmdKey}`, "[UDT][BLE][RCVD]");
|
|
@@ -1649,6 +1714,7 @@ var UdtBleConnection = class {
|
|
|
1649
1714
|
this.lastBatteryHeartbeat = Date.now();
|
|
1650
1715
|
const millivolts = getMilliVoltsFromTowerResponse(receivedData);
|
|
1651
1716
|
const batteryPercentage = milliVoltsToPercentage(millivolts);
|
|
1717
|
+
this.recorder?.recordBattery(millivolts, milliVoltsToPercentageNumber(millivolts));
|
|
1652
1718
|
const didBatteryLevelChange = this.lastBatteryPercentage !== "" && this.lastBatteryPercentage !== batteryPercentage;
|
|
1653
1719
|
const batteryLogFrequencyPassed = Date.now() - this.lastBatteryLog >= this.batteryLogFrequency;
|
|
1654
1720
|
const shouldLog = this.batteryLogEnabled && (this.batteryLogOnChangeOnly ? didBatteryLevelChange || this.lastBatteryPercentage === "" : batteryLogFrequencyPassed);
|
|
@@ -1668,17 +1734,20 @@ var UdtBleConnection = class {
|
|
|
1668
1734
|
const dataSkullDropCount = receivedData[SKULL_DROP_COUNT_POS];
|
|
1669
1735
|
const state = rtdt_unpack_state(receivedData);
|
|
1670
1736
|
this.logger.debug(`Tower State: ${JSON.stringify(state)} `, "[UDT][BLE]");
|
|
1737
|
+
this.recorder?.recordEvent("tower_state_response");
|
|
1671
1738
|
if (this.performingCalibration) {
|
|
1672
1739
|
this.performingCalibration = false;
|
|
1673
1740
|
this.performingLongCommand = false;
|
|
1674
1741
|
this.lastBatteryHeartbeat = Date.now();
|
|
1675
1742
|
this.callbacks.onCalibrationComplete();
|
|
1676
1743
|
this.logger.info("Tower calibration complete", "[UDT]");
|
|
1744
|
+
this.recorder?.recordEvent("calibration_complete");
|
|
1677
1745
|
}
|
|
1678
1746
|
if (dataSkullDropCount !== this.towerSkullDropCount) {
|
|
1679
1747
|
if (dataSkullDropCount) {
|
|
1680
1748
|
this.callbacks.onSkullDrop(dataSkullDropCount);
|
|
1681
1749
|
this.logger.info(`Skull drop detected: app:${this.towerSkullDropCount < 0 ? "empty" : this.towerSkullDropCount} tower:${dataSkullDropCount}`, "[UDT]");
|
|
1750
|
+
this.recorder?.recordEvent("skull_drop", { count: dataSkullDropCount });
|
|
1682
1751
|
} else {
|
|
1683
1752
|
this.logger.info(`Skull count reset to ${dataSkullDropCount}`, "[UDT]");
|
|
1684
1753
|
}
|
|
@@ -1704,11 +1773,15 @@ var UdtBleConnection = class {
|
|
|
1704
1773
|
this.logger.info("Bluetooth availability changed", "[UDT][BLE]");
|
|
1705
1774
|
if (!available && this.isConnected) {
|
|
1706
1775
|
this.logger.warn("Bluetooth became unavailable - handling disconnection", "[UDT][BLE]");
|
|
1776
|
+
this.recordIncident("bt_unavailable");
|
|
1707
1777
|
this.handleDisconnection();
|
|
1708
1778
|
}
|
|
1709
1779
|
}
|
|
1710
1780
|
onTowerDeviceDisconnected() {
|
|
1711
1781
|
this.logger.warn("Tower device disconnected unexpectedly", "[UDT][BLE]");
|
|
1782
|
+
if (this.isConnected) {
|
|
1783
|
+
this.recordIncident("adapter_event");
|
|
1784
|
+
}
|
|
1712
1785
|
this.handleDisconnection();
|
|
1713
1786
|
}
|
|
1714
1787
|
handleDisconnection() {
|
|
@@ -1741,6 +1814,7 @@ var UdtBleConnection = class {
|
|
|
1741
1814
|
}
|
|
1742
1815
|
if (!this.bluetoothAdapter.isGattConnected()) {
|
|
1743
1816
|
this.logger.warn("GATT connection lost detected during health check", "[UDT][BLE]");
|
|
1817
|
+
this.recordIncident("gatt_health_check");
|
|
1744
1818
|
this.handleDisconnection();
|
|
1745
1819
|
return;
|
|
1746
1820
|
}
|
|
@@ -1758,12 +1832,17 @@ var UdtBleConnection = class {
|
|
|
1758
1832
|
this.logger.info("Verifying tower connection status before triggering disconnection...", "[UDT][BLE]");
|
|
1759
1833
|
if (this.bluetoothAdapter.isGattConnected()) {
|
|
1760
1834
|
this.logger.info("GATT connection still available - heartbeat timeout may be temporary", "[UDT][BLE]");
|
|
1835
|
+
this.recorder?.recordEvent("heartbeat_late", {
|
|
1836
|
+
sinceMs: timeSinceLastBatteryHeartbeat,
|
|
1837
|
+
threshold: timeoutThreshold
|
|
1838
|
+
});
|
|
1761
1839
|
this.lastBatteryHeartbeat = Date.now();
|
|
1762
1840
|
this.logger.info("Reset battery heartbeat timer - will monitor for another timeout period", "[UDT][BLE]");
|
|
1763
1841
|
return;
|
|
1764
1842
|
}
|
|
1765
1843
|
}
|
|
1766
1844
|
this.logger.warn("Tower possibly disconnected due to battery depletion or power loss", "[UDT][BLE]");
|
|
1845
|
+
this.recordIncident("heartbeat_timeout");
|
|
1767
1846
|
this.handleDisconnection();
|
|
1768
1847
|
return;
|
|
1769
1848
|
}
|
|
@@ -1771,6 +1850,7 @@ var UdtBleConnection = class {
|
|
|
1771
1850
|
const timeSinceLastResponse = Date.now() - this.lastSuccessfulCommand;
|
|
1772
1851
|
if (timeSinceLastResponse > this.connectionTimeoutThreshold) {
|
|
1773
1852
|
this.logger.warn("General connection timeout detected - no responses received", "[UDT][BLE]");
|
|
1853
|
+
this.recordIncident("response_timeout");
|
|
1774
1854
|
this.handleDisconnection();
|
|
1775
1855
|
}
|
|
1776
1856
|
}
|
|
@@ -2103,8 +2183,7 @@ init_udtConstants();
|
|
|
2103
2183
|
|
|
2104
2184
|
// src/udtCommandQueue.ts
|
|
2105
2185
|
var CommandQueue = class {
|
|
2106
|
-
|
|
2107
|
-
constructor(logger2, sendCommandFn) {
|
|
2186
|
+
constructor(logger2, sendCommandFn, recorder) {
|
|
2108
2187
|
this.logger = logger2;
|
|
2109
2188
|
this.sendCommandFn = sendCommandFn;
|
|
2110
2189
|
this.queue = [];
|
|
@@ -2112,6 +2191,12 @@ var CommandQueue = class {
|
|
|
2112
2191
|
this.timeoutHandle = null;
|
|
2113
2192
|
this.isProcessing = false;
|
|
2114
2193
|
this.timeoutMs = 3e4;
|
|
2194
|
+
// 30 seconds
|
|
2195
|
+
this.recorder = null;
|
|
2196
|
+
this.recorder = recorder ?? null;
|
|
2197
|
+
}
|
|
2198
|
+
setRecorder(recorder) {
|
|
2199
|
+
this.recorder = recorder;
|
|
2115
2200
|
}
|
|
2116
2201
|
/**
|
|
2117
2202
|
* Enqueue a command for processing
|
|
@@ -2128,6 +2213,11 @@ var CommandQueue = class {
|
|
|
2128
2213
|
};
|
|
2129
2214
|
this.queue.push(queuedCommand);
|
|
2130
2215
|
this.logger.debug(`Command queued: ${description || "unnamed"} (queue size: ${this.queue.length})`, "[UDT]");
|
|
2216
|
+
this.recorder?.recordEvent("cmd_enqueued", {
|
|
2217
|
+
id: queuedCommand.id,
|
|
2218
|
+
description,
|
|
2219
|
+
queueDepth: this.queue.length
|
|
2220
|
+
});
|
|
2131
2221
|
if (!this.isProcessing) {
|
|
2132
2222
|
this.processNext();
|
|
2133
2223
|
}
|
|
@@ -2151,6 +2241,11 @@ var CommandQueue = class {
|
|
|
2151
2241
|
await this.sendCommandFn(command);
|
|
2152
2242
|
} catch (error) {
|
|
2153
2243
|
this.clearTimeout();
|
|
2244
|
+
this.recorder?.recordEvent("cmd_failed", {
|
|
2245
|
+
id,
|
|
2246
|
+
description,
|
|
2247
|
+
error: error?.message ?? String(error)
|
|
2248
|
+
});
|
|
2154
2249
|
this.currentCommand = null;
|
|
2155
2250
|
this.isProcessing = false;
|
|
2156
2251
|
reject(error);
|
|
@@ -2176,8 +2271,14 @@ var CommandQueue = class {
|
|
|
2176
2271
|
*/
|
|
2177
2272
|
onTimeout() {
|
|
2178
2273
|
if (this.currentCommand) {
|
|
2179
|
-
const { description, id } = this.currentCommand;
|
|
2274
|
+
const { description, id, timestamp } = this.currentCommand;
|
|
2180
2275
|
this.logger.warn(`Command timeout after ${this.timeoutMs}ms: ${description || id}`, "[UDT]");
|
|
2276
|
+
this.recorder?.recordEvent("cmd_timeout", {
|
|
2277
|
+
id,
|
|
2278
|
+
description,
|
|
2279
|
+
ageMs: Date.now() - timestamp,
|
|
2280
|
+
queueDepth: this.queue.length
|
|
2281
|
+
});
|
|
2181
2282
|
const reject = this.currentCommand.reject;
|
|
2182
2283
|
this.currentCommand = null;
|
|
2183
2284
|
this.isProcessing = false;
|
|
@@ -2232,7 +2333,8 @@ var UdtTowerCommands = class {
|
|
|
2232
2333
|
this.deps = dependencies;
|
|
2233
2334
|
this.commandQueue = new CommandQueue(
|
|
2234
2335
|
this.deps.logger,
|
|
2235
|
-
(command) => this.sendTowerCommandDirect(command)
|
|
2336
|
+
(command) => this.sendTowerCommandDirect(command),
|
|
2337
|
+
this.deps.recorder
|
|
2236
2338
|
);
|
|
2237
2339
|
}
|
|
2238
2340
|
/**
|
|
@@ -2291,6 +2393,7 @@ var UdtTowerCommands = class {
|
|
|
2291
2393
|
async calibrate() {
|
|
2292
2394
|
if (!this.deps.bleConnection.performingCalibration) {
|
|
2293
2395
|
this.deps.logger.info("Performing Tower Calibration", "[UDT][CMD]");
|
|
2396
|
+
this.deps.recorder?.recordEvent("calibration_started");
|
|
2294
2397
|
await this.sendTowerCommand(new Uint8Array([TOWER_COMMANDS.calibration]), "calibrate");
|
|
2295
2398
|
this.deps.bleConnection.performingCalibration = true;
|
|
2296
2399
|
this.deps.bleConnection.performingLongCommand = true;
|
|
@@ -2584,29 +2687,8 @@ var UdtTowerCommands = class {
|
|
|
2584
2687
|
stateWithVolume.audio = { sample: 0, loop: false, volume: actualVolume };
|
|
2585
2688
|
await this.sendTowerStateStateful(stateWithVolume);
|
|
2586
2689
|
}
|
|
2587
|
-
this.deps.logger.info(
|
|
2588
|
-
await this.
|
|
2589
|
-
const sideCorners = {
|
|
2590
|
-
north: ["northeast", "northwest"],
|
|
2591
|
-
east: ["northeast", "southeast"],
|
|
2592
|
-
south: ["southeast", "southwest"],
|
|
2593
|
-
west: ["southwest", "northwest"]
|
|
2594
|
-
};
|
|
2595
|
-
const ledgeLights = sideCorners[seal.side].map((corner) => ({
|
|
2596
|
-
position: corner,
|
|
2597
|
-
style: "on"
|
|
2598
|
-
}));
|
|
2599
|
-
const doorwayLights = [{
|
|
2600
|
-
level: seal.level,
|
|
2601
|
-
position: seal.side,
|
|
2602
|
-
style: "breatheFast"
|
|
2603
|
-
}];
|
|
2604
|
-
const lights = {
|
|
2605
|
-
ledge: ledgeLights,
|
|
2606
|
-
doorway: doorwayLights
|
|
2607
|
-
};
|
|
2608
|
-
this.deps.logger.info(`Breaking seal ${seal.level}-${seal.side} - lighting ledges and doorways with breath effect`, "[UDT]");
|
|
2609
|
-
await this.lights(lights);
|
|
2690
|
+
this.deps.logger.info(`Breaking seal ${seal.level}-${seal.side} - triggering firmware sealReveal animation`, "[UDT]");
|
|
2691
|
+
await this.lightOverrides(TOWER_LIGHT_SEQUENCES.sealReveal, TOWER_AUDIO_LIBRARY.TowerSeal.value);
|
|
2610
2692
|
}
|
|
2611
2693
|
/**
|
|
2612
2694
|
* Randomly rotates specified tower levels to random positions.
|
|
@@ -2807,9 +2889,201 @@ var UdtTowerCommands = class {
|
|
|
2807
2889
|
}
|
|
2808
2890
|
};
|
|
2809
2891
|
|
|
2892
|
+
// src/udtDiagnostics.ts
|
|
2893
|
+
var RING_BUFFER_SIZE = 500;
|
|
2894
|
+
var RING_BUFFER_DRAIN = 50;
|
|
2895
|
+
var BATTERY_HISTORY_SIZE = 60;
|
|
2896
|
+
var PAYLOAD_MAX_BYTES = 32;
|
|
2897
|
+
var LIBRARY_VERSION = "3.0.0";
|
|
2898
|
+
function detectPlatform() {
|
|
2899
|
+
if (typeof window !== "undefined" && typeof window.navigator !== "undefined") {
|
|
2900
|
+
return "web";
|
|
2901
|
+
}
|
|
2902
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
2903
|
+
return "node";
|
|
2904
|
+
}
|
|
2905
|
+
return "custom";
|
|
2906
|
+
}
|
|
2907
|
+
function makeId() {
|
|
2908
|
+
const g = globalThis;
|
|
2909
|
+
if (g.crypto && typeof g.crypto.randomUUID === "function") {
|
|
2910
|
+
try {
|
|
2911
|
+
return g.crypto.randomUUID();
|
|
2912
|
+
} catch {
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
|
2916
|
+
}
|
|
2917
|
+
function bytesToHex(data, maxBytes = PAYLOAD_MAX_BYTES) {
|
|
2918
|
+
const slice = data.length > maxBytes ? data.subarray(0, maxBytes) : data;
|
|
2919
|
+
let out = "";
|
|
2920
|
+
for (let i = 0; i < slice.length; i++) {
|
|
2921
|
+
out += slice[i].toString(16).padStart(2, "0");
|
|
2922
|
+
}
|
|
2923
|
+
if (data.length > maxBytes) {
|
|
2924
|
+
out += `..(+${data.length - maxBytes})`;
|
|
2925
|
+
}
|
|
2926
|
+
return out;
|
|
2927
|
+
}
|
|
2928
|
+
var InMemorySink = class {
|
|
2929
|
+
constructor(maxIncidents = 50) {
|
|
2930
|
+
this.incidents = [];
|
|
2931
|
+
this.maxIncidents = maxIncidents;
|
|
2932
|
+
}
|
|
2933
|
+
onIncident(report) {
|
|
2934
|
+
this.incidents.push(report);
|
|
2935
|
+
if (this.incidents.length > this.maxIncidents) {
|
|
2936
|
+
this.incidents.splice(0, this.incidents.length - this.maxIncidents);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
list() {
|
|
2940
|
+
return [...this.incidents];
|
|
2941
|
+
}
|
|
2942
|
+
get(incidentId) {
|
|
2943
|
+
return this.incidents.find((r) => r.incidentId === incidentId);
|
|
2944
|
+
}
|
|
2945
|
+
clear() {
|
|
2946
|
+
this.incidents = [];
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2949
|
+
var UdtDiagnosticsRecorder = class {
|
|
2950
|
+
constructor(config) {
|
|
2951
|
+
this.events = [];
|
|
2952
|
+
this.batteryHistory = [];
|
|
2953
|
+
this.sessionId = "";
|
|
2954
|
+
this.connectedAt = null;
|
|
2955
|
+
this.lastIncident = null;
|
|
2956
|
+
this.enabled = config.enabled;
|
|
2957
|
+
this.capturePayloads = config.capturePayloads ?? false;
|
|
2958
|
+
this.sinks = config.sinks ?? [];
|
|
2959
|
+
}
|
|
2960
|
+
setSinks(sinks) {
|
|
2961
|
+
this.sinks = sinks;
|
|
2962
|
+
}
|
|
2963
|
+
getSinks() {
|
|
2964
|
+
return [...this.sinks];
|
|
2965
|
+
}
|
|
2966
|
+
addSink(sink) {
|
|
2967
|
+
this.sinks.push(sink);
|
|
2968
|
+
}
|
|
2969
|
+
/** Mark the start of a connected session. Called from BLE connect path. */
|
|
2970
|
+
beginSession() {
|
|
2971
|
+
if (!this.enabled) return;
|
|
2972
|
+
this.sessionId = makeId();
|
|
2973
|
+
this.connectedAt = Date.now();
|
|
2974
|
+
this.events = [];
|
|
2975
|
+
this.batteryHistory = [];
|
|
2976
|
+
this.recordEvent("connect");
|
|
2977
|
+
}
|
|
2978
|
+
recordEvent(kind, data) {
|
|
2979
|
+
if (!this.enabled) return;
|
|
2980
|
+
const event = { t: Date.now(), kind };
|
|
2981
|
+
if (data) event.data = data;
|
|
2982
|
+
this.events.push(event);
|
|
2983
|
+
if (this.events.length > RING_BUFFER_SIZE) {
|
|
2984
|
+
this.events.splice(0, RING_BUFFER_DRAIN);
|
|
2985
|
+
}
|
|
2986
|
+
for (const sink of this.sinks) {
|
|
2987
|
+
if (sink.onEvent) {
|
|
2988
|
+
try {
|
|
2989
|
+
sink.onEvent(event);
|
|
2990
|
+
} catch (e) {
|
|
2991
|
+
console.error("Diagnostics sink onEvent error:", e);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
recordCommandPayload(kind, data, extra) {
|
|
2997
|
+
if (!this.enabled) return;
|
|
2998
|
+
const payload = { ...extra };
|
|
2999
|
+
if (this.capturePayloads) {
|
|
3000
|
+
payload.payloadHex = bytesToHex(data);
|
|
3001
|
+
payload.payloadLen = data.length;
|
|
3002
|
+
}
|
|
3003
|
+
this.recordEvent(kind, payload);
|
|
3004
|
+
}
|
|
3005
|
+
recordBattery(mv, pct) {
|
|
3006
|
+
if (!this.enabled) return;
|
|
3007
|
+
this.batteryHistory.push({ t: Date.now(), mv, pct });
|
|
3008
|
+
if (this.batteryHistory.length > BATTERY_HISTORY_SIZE) {
|
|
3009
|
+
this.batteryHistory.splice(0, this.batteryHistory.length - BATTERY_HISTORY_SIZE);
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
/** Forwards a log line into the events ring (called by Logger when bridged). */
|
|
3013
|
+
recordLog(level, message, context) {
|
|
3014
|
+
if (!this.enabled) return;
|
|
3015
|
+
this.recordEvent("log", { level, message, context });
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* Capture an incident snapshot and dispatch to sinks.
|
|
3019
|
+
* Must be called BEFORE the BLE layer clears state.
|
|
3020
|
+
*/
|
|
3021
|
+
recordIncident(inputs) {
|
|
3022
|
+
if (!this.enabled) return null;
|
|
3023
|
+
const triggeredAt = Date.now();
|
|
3024
|
+
const inFlightCommandAgeMs = inputs.commandQueue.currentCommand ? triggeredAt - inputs.commandQueue.currentCommand.timestamp : null;
|
|
3025
|
+
const report = {
|
|
3026
|
+
schemaVersion: 1,
|
|
3027
|
+
incidentId: makeId(),
|
|
3028
|
+
sessionId: this.sessionId || makeId(),
|
|
3029
|
+
cause: inputs.cause,
|
|
3030
|
+
triggeredAt,
|
|
3031
|
+
connectedAt: this.connectedAt,
|
|
3032
|
+
sessionDurationMs: this.connectedAt ? triggeredAt - this.connectedAt : 0,
|
|
3033
|
+
connectionStatus: { ...inputs.connectionStatus },
|
|
3034
|
+
deviceInformation: { ...inputs.deviceInformation },
|
|
3035
|
+
commandQueue: {
|
|
3036
|
+
queueLength: inputs.commandQueue.queueLength,
|
|
3037
|
+
isProcessing: inputs.commandQueue.isProcessing,
|
|
3038
|
+
currentCommand: inputs.commandQueue.currentCommand ? { ...inputs.commandQueue.currentCommand } : null
|
|
3039
|
+
},
|
|
3040
|
+
inFlightCommandAgeMs,
|
|
3041
|
+
towerState: inputs.towerState ? JSON.parse(JSON.stringify(inputs.towerState)) : null,
|
|
3042
|
+
brokenSeals: [...inputs.brokenSeals],
|
|
3043
|
+
recentEvents: [...this.events],
|
|
3044
|
+
batteryHistory: [...this.batteryHistory],
|
|
3045
|
+
library: { version: LIBRARY_VERSION, platform: detectPlatform() },
|
|
3046
|
+
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : void 0
|
|
3047
|
+
};
|
|
3048
|
+
this.lastIncident = report;
|
|
3049
|
+
this.recordEvent("disconnect", { cause: inputs.cause });
|
|
3050
|
+
for (const sink of this.sinks) {
|
|
3051
|
+
try {
|
|
3052
|
+
const result = sink.onIncident(report);
|
|
3053
|
+
if (result && typeof result.then === "function") {
|
|
3054
|
+
result.catch((e) => console.error("Diagnostics sink onIncident error:", e));
|
|
3055
|
+
}
|
|
3056
|
+
} catch (e) {
|
|
3057
|
+
console.error("Diagnostics sink onIncident error:", e);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
return report;
|
|
3061
|
+
}
|
|
3062
|
+
getRingBuffer() {
|
|
3063
|
+
return [...this.events];
|
|
3064
|
+
}
|
|
3065
|
+
getBatteryHistory() {
|
|
3066
|
+
return [...this.batteryHistory];
|
|
3067
|
+
}
|
|
3068
|
+
getSessionId() {
|
|
3069
|
+
return this.sessionId;
|
|
3070
|
+
}
|
|
3071
|
+
getConnectedAt() {
|
|
3072
|
+
return this.connectedAt;
|
|
3073
|
+
}
|
|
3074
|
+
getLastIncident() {
|
|
3075
|
+
return this.lastIncident;
|
|
3076
|
+
}
|
|
3077
|
+
clearRingBuffer() {
|
|
3078
|
+
this.events = [];
|
|
3079
|
+
this.batteryHistory = [];
|
|
3080
|
+
}
|
|
3081
|
+
};
|
|
3082
|
+
|
|
2810
3083
|
// src/UltimateDarkTower.ts
|
|
2811
3084
|
var UltimateDarkTower = class {
|
|
2812
3085
|
constructor(config) {
|
|
3086
|
+
this.beforeUnloadHandler = null;
|
|
2813
3087
|
// tower configuration
|
|
2814
3088
|
this.retrySendCommandCountRef = { value: 0 };
|
|
2815
3089
|
this.retrySendCommandMax = DEFAULT_RETRY_SEND_COMMAND_MAX;
|
|
@@ -2851,8 +3125,10 @@ var UltimateDarkTower = class {
|
|
|
2851
3125
|
// utility
|
|
2852
3126
|
this._logDetail = false;
|
|
2853
3127
|
this.initializeLogger();
|
|
3128
|
+
this.initializeDiagnostics(config?.diagnostics);
|
|
2854
3129
|
this.initializeComponents(config);
|
|
2855
3130
|
this.setupTowerResponseCallback();
|
|
3131
|
+
this.installBeforeUnloadHandler();
|
|
2856
3132
|
}
|
|
2857
3133
|
/**
|
|
2858
3134
|
* Initialize the logger with default console output
|
|
@@ -2861,6 +3137,20 @@ var UltimateDarkTower = class {
|
|
|
2861
3137
|
this.logger = new Logger();
|
|
2862
3138
|
this.logger.addOutput(new ConsoleOutput());
|
|
2863
3139
|
}
|
|
3140
|
+
/**
|
|
3141
|
+
* Initialize the diagnostics recorder. Always constructed; `enabled` defaults
|
|
3142
|
+
* to false, so when no config is supplied the recorder is a no-op aside from
|
|
3143
|
+
* a single boolean check at each hook site.
|
|
3144
|
+
*/
|
|
3145
|
+
initializeDiagnostics(config) {
|
|
3146
|
+
const sinks = config?.sinks ?? (config?.enabled ? [new InMemorySink()] : []);
|
|
3147
|
+
this.diagnosticsRecorder = new UdtDiagnosticsRecorder({
|
|
3148
|
+
enabled: config?.enabled ?? false,
|
|
3149
|
+
capturePayloads: config?.capturePayloads,
|
|
3150
|
+
sinks
|
|
3151
|
+
});
|
|
3152
|
+
this.logger.setDiagnosticsTarget(this.diagnosticsRecorder);
|
|
3153
|
+
}
|
|
2864
3154
|
/**
|
|
2865
3155
|
* Initialize all tower components and their dependencies
|
|
2866
3156
|
*/
|
|
@@ -2872,11 +3162,16 @@ var UltimateDarkTower = class {
|
|
|
2872
3162
|
adapter = BluetoothAdapterFactory.create(config.platform);
|
|
2873
3163
|
}
|
|
2874
3164
|
this.towerEventCallbacks = this.createTowerEventCallbacks();
|
|
2875
|
-
this.bleConnection = new UdtBleConnection(this.logger, this.towerEventCallbacks, adapter);
|
|
3165
|
+
this.bleConnection = new UdtBleConnection(this.logger, this.towerEventCallbacks, adapter, this.diagnosticsRecorder);
|
|
2876
3166
|
this.responseProcessor = new TowerResponseProcessor(this.logDetail);
|
|
2877
3167
|
this.commandFactory = new UdtCommandFactory();
|
|
2878
3168
|
const commandDependencies = this.createCommandDependencies();
|
|
2879
3169
|
this.towerCommands = new UdtTowerCommands(commandDependencies);
|
|
3170
|
+
this.bleConnection.setDiagnosticsSnapshotProviders({
|
|
3171
|
+
commandQueue: () => this.towerCommands.getQueueStatus(),
|
|
3172
|
+
towerState: () => this.currentTowerState,
|
|
3173
|
+
brokenSeals: () => Array.from(this.brokenSeals)
|
|
3174
|
+
});
|
|
2880
3175
|
if (config?.brokenSeals) {
|
|
2881
3176
|
for (const seal of config.brokenSeals) {
|
|
2882
3177
|
const sealKey = `${seal.level}-${seal.side}`;
|
|
@@ -2884,6 +3179,23 @@ var UltimateDarkTower = class {
|
|
|
2884
3179
|
}
|
|
2885
3180
|
}
|
|
2886
3181
|
}
|
|
3182
|
+
/**
|
|
3183
|
+
* Browser-only: synthesize a `page_unload` incident if the page closes while
|
|
3184
|
+
* connected. Without this, refreshing the page during a hang loses the
|
|
3185
|
+
* lead-up context. IndexedDB writes during unload are best-effort.
|
|
3186
|
+
*/
|
|
3187
|
+
installBeforeUnloadHandler() {
|
|
3188
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
3189
|
+
this.beforeUnloadHandler = () => {
|
|
3190
|
+
if (this.diagnosticsRecorder.enabled && this.bleConnection?.isConnected) {
|
|
3191
|
+
try {
|
|
3192
|
+
this.bleConnection.recordIncidentPublic("page_unload");
|
|
3193
|
+
} catch {
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
};
|
|
3197
|
+
window.addEventListener("beforeunload", this.beforeUnloadHandler);
|
|
3198
|
+
}
|
|
2887
3199
|
/**
|
|
2888
3200
|
* Set up the tower response callback after all components are initialized
|
|
2889
3201
|
*/
|
|
@@ -2938,7 +3250,8 @@ var UltimateDarkTower = class {
|
|
|
2938
3250
|
retrySendCommandCount: this.retrySendCommandCountRef,
|
|
2939
3251
|
retrySendCommandMax: this.retrySendCommandMax,
|
|
2940
3252
|
getCurrentTowerState: () => this.currentTowerState,
|
|
2941
|
-
setTowerState: (newState, source) => this.setTowerState(newState, source)
|
|
3253
|
+
setTowerState: (newState, source) => this.setTowerState(newState, source),
|
|
3254
|
+
recorder: this.diagnosticsRecorder
|
|
2942
3255
|
};
|
|
2943
3256
|
}
|
|
2944
3257
|
/**
|
|
@@ -3230,6 +3543,7 @@ var UltimateDarkTower = class {
|
|
|
3230
3543
|
updateTowerStateFromResponse(stateData) {
|
|
3231
3544
|
const newState = rtdt_unpack_state(stateData);
|
|
3232
3545
|
newState.audio = { sample: 0, loop: false, volume: this.currentTowerState.audio.volume };
|
|
3546
|
+
newState.led_sequence = 0;
|
|
3233
3547
|
this.setTowerState(newState, "tower response");
|
|
3234
3548
|
}
|
|
3235
3549
|
//#endregion
|
|
@@ -3547,19 +3861,680 @@ var UltimateDarkTower = class {
|
|
|
3547
3861
|
async cleanup() {
|
|
3548
3862
|
this.logger.info("Cleaning up UltimateDarkTower instance", "[UDT]");
|
|
3549
3863
|
this.towerCommands.clearQueue();
|
|
3864
|
+
if (this.beforeUnloadHandler && typeof window !== "undefined") {
|
|
3865
|
+
window.removeEventListener("beforeunload", this.beforeUnloadHandler);
|
|
3866
|
+
this.beforeUnloadHandler = null;
|
|
3867
|
+
}
|
|
3868
|
+
this.logger.setDiagnosticsTarget(null);
|
|
3550
3869
|
await this.bleConnection.cleanup();
|
|
3551
3870
|
}
|
|
3552
3871
|
//#endregion
|
|
3872
|
+
//#region Diagnostics (BLE flight recorder)
|
|
3873
|
+
/**
|
|
3874
|
+
* Get the diagnostics recorder for direct access (live ring buffer, sinks,
|
|
3875
|
+
* runtime enable/disable). Always returns a recorder; check `.enabled` to
|
|
3876
|
+
* see whether capture is active.
|
|
3877
|
+
*/
|
|
3878
|
+
getDiagnosticsRecorder() {
|
|
3879
|
+
return this.diagnosticsRecorder;
|
|
3880
|
+
}
|
|
3881
|
+
/**
|
|
3882
|
+
* Toggle diagnostics capture at runtime without reconstructing the tower.
|
|
3883
|
+
* When enabled mid-session, the next BLE event begins populating the buffer.
|
|
3884
|
+
*/
|
|
3885
|
+
setDiagnosticsEnabled(enabled) {
|
|
3886
|
+
this.diagnosticsRecorder.enabled = enabled;
|
|
3887
|
+
}
|
|
3888
|
+
/**
|
|
3889
|
+
* Whether diagnostics capture is currently active.
|
|
3890
|
+
*/
|
|
3891
|
+
isDiagnosticsEnabled() {
|
|
3892
|
+
return this.diagnosticsRecorder.enabled;
|
|
3893
|
+
}
|
|
3894
|
+
/**
|
|
3895
|
+
* Get the most recent disconnect incident report, or null if none captured
|
|
3896
|
+
* since this instance was created.
|
|
3897
|
+
*/
|
|
3898
|
+
getLastIncident() {
|
|
3899
|
+
return this.diagnosticsRecorder.getLastIncident();
|
|
3900
|
+
}
|
|
3901
|
+
/**
|
|
3902
|
+
* Export current ring buffer + last incident as JSON for sharing/analysis.
|
|
3903
|
+
* Useful as a one-liner in a "copy diagnostic info" button.
|
|
3904
|
+
*/
|
|
3905
|
+
exportDiagnosticsJSON() {
|
|
3906
|
+
return JSON.stringify({
|
|
3907
|
+
schemaVersion: 1,
|
|
3908
|
+
capturedAt: Date.now(),
|
|
3909
|
+
sessionId: this.diagnosticsRecorder.getSessionId(),
|
|
3910
|
+
ringBuffer: this.diagnosticsRecorder.getRingBuffer(),
|
|
3911
|
+
batteryHistory: this.diagnosticsRecorder.getBatteryHistory(),
|
|
3912
|
+
lastIncident: this.diagnosticsRecorder.getLastIncident()
|
|
3913
|
+
}, null, 2);
|
|
3914
|
+
}
|
|
3915
|
+
//#endregion
|
|
3553
3916
|
};
|
|
3554
3917
|
var UltimateDarkTower_default = UltimateDarkTower;
|
|
3555
3918
|
|
|
3556
3919
|
// src/index.ts
|
|
3557
3920
|
init_udtConstants();
|
|
3558
3921
|
init_udtBluetoothAdapter();
|
|
3922
|
+
|
|
3923
|
+
// src/sinks/IndexedDBSink.ts
|
|
3924
|
+
var DB_NAME = "udt-diagnostics";
|
|
3925
|
+
var DB_VERSION = 1;
|
|
3926
|
+
var STORE_NAME = "incidents";
|
|
3927
|
+
function indexedDBAvailable() {
|
|
3928
|
+
try {
|
|
3929
|
+
return typeof indexedDB !== "undefined";
|
|
3930
|
+
} catch {
|
|
3931
|
+
return false;
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
function openDb() {
|
|
3935
|
+
return new Promise((resolve, reject) => {
|
|
3936
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
3937
|
+
req.onupgradeneeded = () => {
|
|
3938
|
+
const db = req.result;
|
|
3939
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
3940
|
+
const store = db.createObjectStore(STORE_NAME, { keyPath: "incidentId" });
|
|
3941
|
+
store.createIndex("triggeredAt", "triggeredAt", { unique: false });
|
|
3942
|
+
}
|
|
3943
|
+
};
|
|
3944
|
+
req.onsuccess = () => resolve(req.result);
|
|
3945
|
+
req.onerror = () => reject(req.error);
|
|
3946
|
+
});
|
|
3947
|
+
}
|
|
3948
|
+
var IndexedDBSink = class {
|
|
3949
|
+
constructor(maxIncidents = 50) {
|
|
3950
|
+
this.dbPromise = null;
|
|
3951
|
+
this.maxIncidents = maxIncidents;
|
|
3952
|
+
this.available = indexedDBAvailable();
|
|
3953
|
+
}
|
|
3954
|
+
async getDb() {
|
|
3955
|
+
if (!this.available) return null;
|
|
3956
|
+
if (!this.dbPromise) {
|
|
3957
|
+
this.dbPromise = openDb().catch((err) => {
|
|
3958
|
+
this.available = false;
|
|
3959
|
+
this.dbPromise = null;
|
|
3960
|
+
throw err;
|
|
3961
|
+
});
|
|
3962
|
+
}
|
|
3963
|
+
try {
|
|
3964
|
+
return await this.dbPromise;
|
|
3965
|
+
} catch {
|
|
3966
|
+
return null;
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
async onIncident(report) {
|
|
3970
|
+
const db = await this.getDb();
|
|
3971
|
+
if (!db) return;
|
|
3972
|
+
await new Promise((resolve, reject) => {
|
|
3973
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
3974
|
+
const store = tx.objectStore(STORE_NAME);
|
|
3975
|
+
store.put(report);
|
|
3976
|
+
tx.oncomplete = () => resolve();
|
|
3977
|
+
tx.onerror = () => reject(tx.error);
|
|
3978
|
+
tx.onabort = () => reject(tx.error);
|
|
3979
|
+
}).catch((e) => console.error("IndexedDBSink put failed:", e));
|
|
3980
|
+
await this.evictOld();
|
|
3981
|
+
}
|
|
3982
|
+
async list() {
|
|
3983
|
+
const db = await this.getDb();
|
|
3984
|
+
if (!db) return [];
|
|
3985
|
+
return new Promise((resolve, reject) => {
|
|
3986
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
3987
|
+
const store = tx.objectStore(STORE_NAME);
|
|
3988
|
+
const req = store.getAll();
|
|
3989
|
+
req.onsuccess = () => {
|
|
3990
|
+
const all = req.result.slice();
|
|
3991
|
+
all.sort((a, b) => b.triggeredAt - a.triggeredAt);
|
|
3992
|
+
resolve(all);
|
|
3993
|
+
};
|
|
3994
|
+
req.onerror = () => reject(req.error);
|
|
3995
|
+
});
|
|
3996
|
+
}
|
|
3997
|
+
async get(incidentId) {
|
|
3998
|
+
const db = await this.getDb();
|
|
3999
|
+
if (!db) return void 0;
|
|
4000
|
+
return new Promise((resolve, reject) => {
|
|
4001
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
4002
|
+
const store = tx.objectStore(STORE_NAME);
|
|
4003
|
+
const req = store.get(incidentId);
|
|
4004
|
+
req.onsuccess = () => resolve(req.result);
|
|
4005
|
+
req.onerror = () => reject(req.error);
|
|
4006
|
+
});
|
|
4007
|
+
}
|
|
4008
|
+
async delete(incidentId) {
|
|
4009
|
+
const db = await this.getDb();
|
|
4010
|
+
if (!db) return;
|
|
4011
|
+
await new Promise((resolve, reject) => {
|
|
4012
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
4013
|
+
tx.objectStore(STORE_NAME).delete(incidentId);
|
|
4014
|
+
tx.oncomplete = () => resolve();
|
|
4015
|
+
tx.onerror = () => reject(tx.error);
|
|
4016
|
+
}).catch((e) => console.error("IndexedDBSink delete failed:", e));
|
|
4017
|
+
}
|
|
4018
|
+
async clear() {
|
|
4019
|
+
const db = await this.getDb();
|
|
4020
|
+
if (!db) return;
|
|
4021
|
+
await new Promise((resolve, reject) => {
|
|
4022
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
4023
|
+
tx.objectStore(STORE_NAME).clear();
|
|
4024
|
+
tx.oncomplete = () => resolve();
|
|
4025
|
+
tx.onerror = () => reject(tx.error);
|
|
4026
|
+
}).catch((e) => console.error("IndexedDBSink clear failed:", e));
|
|
4027
|
+
}
|
|
4028
|
+
/** Insert an externally-supplied report (e.g. from a JSON import). */
|
|
4029
|
+
async put(report) {
|
|
4030
|
+
return this.onIncident(report);
|
|
4031
|
+
}
|
|
4032
|
+
async evictOld() {
|
|
4033
|
+
const db = await this.getDb();
|
|
4034
|
+
if (!db) return;
|
|
4035
|
+
await new Promise((resolve) => {
|
|
4036
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
4037
|
+
const store = tx.objectStore(STORE_NAME);
|
|
4038
|
+
const countReq = store.count();
|
|
4039
|
+
countReq.onsuccess = () => {
|
|
4040
|
+
const total = countReq.result;
|
|
4041
|
+
if (total <= this.maxIncidents) {
|
|
4042
|
+
resolve();
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
const toRemove = total - this.maxIncidents;
|
|
4046
|
+
const idx = store.index("triggeredAt");
|
|
4047
|
+
const cursorReq = idx.openCursor();
|
|
4048
|
+
let removed = 0;
|
|
4049
|
+
cursorReq.onsuccess = () => {
|
|
4050
|
+
const cursor = cursorReq.result;
|
|
4051
|
+
if (!cursor || removed >= toRemove) {
|
|
4052
|
+
resolve();
|
|
4053
|
+
return;
|
|
4054
|
+
}
|
|
4055
|
+
cursor.delete();
|
|
4056
|
+
removed++;
|
|
4057
|
+
cursor.continue();
|
|
4058
|
+
};
|
|
4059
|
+
cursorReq.onerror = () => resolve();
|
|
4060
|
+
};
|
|
4061
|
+
countReq.onerror = () => resolve();
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
4064
|
+
};
|
|
4065
|
+
|
|
4066
|
+
// src/udtSeedParser.ts
|
|
4067
|
+
var ALPHABET = "a123456789bcdefghijklmnpqrstuvwxyz";
|
|
4068
|
+
var BASE = 34;
|
|
4069
|
+
var SETUP_LENGTH = 6;
|
|
4070
|
+
var RNG_SEED_LENGTH = 6;
|
|
4071
|
+
var SEED_LENGTH = SETUP_LENGTH + RNG_SEED_LENGTH;
|
|
4072
|
+
var CHAR_TO_VALUE = /* @__PURE__ */ new Map();
|
|
4073
|
+
var VALUE_TO_CHAR = /* @__PURE__ */ new Map();
|
|
4074
|
+
for (let i = 0; i < ALPHABET.length; i++) {
|
|
4075
|
+
CHAR_TO_VALUE.set(ALPHABET[i], i);
|
|
4076
|
+
VALUE_TO_CHAR.set(i, ALPHABET[i]);
|
|
4077
|
+
}
|
|
4078
|
+
var TIER1_FOES = ["Brigands", "Oreks", "Shadow Wolves", "Spine Fiends"];
|
|
4079
|
+
var TIER2_FOES = ["Frost Trolls", "Clan of Neuri", "Lemures", "Widowmade Spiders"];
|
|
4080
|
+
var TIER3_FOES = ["Dragons", "Mormos", "Striga", "Titans"];
|
|
4081
|
+
var ADVERSARIES = [
|
|
4082
|
+
"Ashstrider",
|
|
4083
|
+
"Bane of Omens",
|
|
4084
|
+
"Empress of Shades",
|
|
4085
|
+
"Gaze Eternal",
|
|
4086
|
+
"Gravemaw",
|
|
4087
|
+
"Isa the Exile",
|
|
4088
|
+
"Lingering Rot",
|
|
4089
|
+
"Utuk'Ku"
|
|
4090
|
+
];
|
|
4091
|
+
var ALLIES = [
|
|
4092
|
+
"Gleb",
|
|
4093
|
+
"Grigor",
|
|
4094
|
+
"Hakan",
|
|
4095
|
+
"Letha",
|
|
4096
|
+
"Miras",
|
|
4097
|
+
"Nimet",
|
|
4098
|
+
"Tomas",
|
|
4099
|
+
"Vasa",
|
|
4100
|
+
"Yana",
|
|
4101
|
+
"Zaida"
|
|
4102
|
+
];
|
|
4103
|
+
var DIFFICULTIES = ["Heroic", "Gritty"];
|
|
4104
|
+
var GAME_SOURCES = ["Core", "Competitive"];
|
|
4105
|
+
function charToValue(c) {
|
|
4106
|
+
const v = CHAR_TO_VALUE.get(c.toLowerCase());
|
|
4107
|
+
if (v === void 0) {
|
|
4108
|
+
throw new Error(`Invalid seed character: '${c}'`);
|
|
4109
|
+
}
|
|
4110
|
+
return v;
|
|
4111
|
+
}
|
|
4112
|
+
function valueToChar(v) {
|
|
4113
|
+
const c = VALUE_TO_CHAR.get(v);
|
|
4114
|
+
if (c === void 0) {
|
|
4115
|
+
throw new Error(`Invalid seed value: ${v} (must be 0\u2013${BASE - 1})`);
|
|
4116
|
+
}
|
|
4117
|
+
return c;
|
|
4118
|
+
}
|
|
4119
|
+
function validateSeed(seed) {
|
|
4120
|
+
const stripped = seed.replace(/[-\s]/g, "").toLowerCase();
|
|
4121
|
+
if (stripped.length !== SEED_LENGTH) {
|
|
4122
|
+
throw new Error(`Invalid seed length: expected ${SEED_LENGTH} characters, got ${stripped.length}`);
|
|
4123
|
+
}
|
|
4124
|
+
for (const c of stripped) {
|
|
4125
|
+
if (!CHAR_TO_VALUE.has(c)) {
|
|
4126
|
+
throw new Error(`Invalid seed character: '${c}'`);
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
const upper = stripped.toUpperCase();
|
|
4130
|
+
return `${upper.slice(0, 4)}-${upper.slice(4, 8)}-${upper.slice(8, 12)}`;
|
|
4131
|
+
}
|
|
4132
|
+
function decodeSeed(seed) {
|
|
4133
|
+
const normalized = validateSeed(seed);
|
|
4134
|
+
const stripped = normalized.replace(/-/g, "").toLowerCase();
|
|
4135
|
+
const setup = [];
|
|
4136
|
+
for (let i = 0; i < SETUP_LENGTH; i++) {
|
|
4137
|
+
setup.push(charToValue(stripped[i]));
|
|
4138
|
+
}
|
|
4139
|
+
let rngSeed = 0;
|
|
4140
|
+
for (let i = 0; i < RNG_SEED_LENGTH; i++) {
|
|
4141
|
+
const value = charToValue(stripped[SETUP_LENGTH + i]);
|
|
4142
|
+
rngSeed += value * Math.round(Math.pow(BASE, i));
|
|
4143
|
+
}
|
|
4144
|
+
const foeByteA = setup[0];
|
|
4145
|
+
const tier1 = foeByteA & 3;
|
|
4146
|
+
const tier2 = (foeByteA & 12) >> 2;
|
|
4147
|
+
const foeByteB = setup[1];
|
|
4148
|
+
const tier3 = (foeByteA & 16) >> 4 | (foeByteB & 16) >> 3;
|
|
4149
|
+
const adversaryIndex = foeByteB & 15;
|
|
4150
|
+
const allyIndex = setup[2];
|
|
4151
|
+
const extra = setup[3];
|
|
4152
|
+
const difficultyIndex = extra & 1;
|
|
4153
|
+
const expansionBits = (extra & 6) >> 1;
|
|
4154
|
+
const sourceBits = (extra & 8) >> 2;
|
|
4155
|
+
const playerCount = (setup[5] & 3) + 1;
|
|
4156
|
+
const expansions = [];
|
|
4157
|
+
if (expansionBits & 1) expansions.push("Monuments");
|
|
4158
|
+
if (expansionBits & 2) expansions.push("Alliances");
|
|
4159
|
+
let source;
|
|
4160
|
+
switch (sourceBits) {
|
|
4161
|
+
case 2:
|
|
4162
|
+
source = "Competitive";
|
|
4163
|
+
break;
|
|
4164
|
+
default:
|
|
4165
|
+
source = "Core";
|
|
4166
|
+
break;
|
|
4167
|
+
}
|
|
4168
|
+
const seedBank = {
|
|
4169
|
+
initializationSeed: rngSeed,
|
|
4170
|
+
questSeed: rngSeed - 1,
|
|
4171
|
+
seedString: normalized
|
|
4172
|
+
};
|
|
4173
|
+
return {
|
|
4174
|
+
seed: normalized,
|
|
4175
|
+
tier1Foe: TIER1_FOES[tier1],
|
|
4176
|
+
tier2Foe: TIER2_FOES[tier2],
|
|
4177
|
+
tier3Foe: TIER3_FOES[tier3],
|
|
4178
|
+
adversary: ADVERSARIES[adversaryIndex],
|
|
4179
|
+
ally: ALLIES[allyIndex],
|
|
4180
|
+
difficulty: DIFFICULTIES[difficultyIndex],
|
|
4181
|
+
source,
|
|
4182
|
+
expansions,
|
|
4183
|
+
playerCount,
|
|
4184
|
+
rngSeed,
|
|
4185
|
+
seedBank,
|
|
4186
|
+
setup
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
function decodeRngSeed(seed) {
|
|
4190
|
+
const normalized = validateSeed(seed);
|
|
4191
|
+
const stripped = normalized.replace(/-/g, "").toLowerCase();
|
|
4192
|
+
let rngSeed = 0;
|
|
4193
|
+
for (let i = 0; i < RNG_SEED_LENGTH; i++) {
|
|
4194
|
+
const value = charToValue(stripped[SETUP_LENGTH + i]);
|
|
4195
|
+
rngSeed += value * Math.round(Math.pow(BASE, i));
|
|
4196
|
+
}
|
|
4197
|
+
return rngSeed;
|
|
4198
|
+
}
|
|
4199
|
+
function createSeed(config) {
|
|
4200
|
+
let foeByteA = 0;
|
|
4201
|
+
let foeByteB = 0;
|
|
4202
|
+
const tier1Index = TIER1_FOES.indexOf(config.foes[0]);
|
|
4203
|
+
const tier2Index = TIER2_FOES.indexOf(config.foes[1]);
|
|
4204
|
+
const tier3Index = TIER3_FOES.indexOf(config.foes[2]);
|
|
4205
|
+
if (tier1Index < 0) throw new Error(`Invalid Tier 1 foe: ${config.foes[0]}`);
|
|
4206
|
+
if (tier2Index < 0) throw new Error(`Invalid Tier 2 foe: ${config.foes[1]}`);
|
|
4207
|
+
if (tier3Index < 0) throw new Error(`Invalid Tier 3 foe: ${config.foes[2]}`);
|
|
4208
|
+
foeByteA = tier1Index & 3;
|
|
4209
|
+
foeByteA |= (tier2Index & 3) << 2;
|
|
4210
|
+
foeByteA |= (tier3Index & 1) << 4;
|
|
4211
|
+
foeByteB |= (tier3Index >> 1 & 1) << 4;
|
|
4212
|
+
const adversaryIndex = ADVERSARIES.indexOf(config.adversary);
|
|
4213
|
+
if (adversaryIndex < 0) throw new Error(`Invalid adversary: ${config.adversary}`);
|
|
4214
|
+
foeByteB |= adversaryIndex & 15;
|
|
4215
|
+
const allyIndex = ALLIES.indexOf(config.ally);
|
|
4216
|
+
if (allyIndex < 0) throw new Error(`Invalid ally: ${config.ally}`);
|
|
4217
|
+
let extraByte = 0;
|
|
4218
|
+
if (config.difficulty === "Gritty") extraByte |= 1;
|
|
4219
|
+
for (const expansion of config.expansions) {
|
|
4220
|
+
switch (expansion) {
|
|
4221
|
+
case "Monuments":
|
|
4222
|
+
extraByte |= 2;
|
|
4223
|
+
break;
|
|
4224
|
+
case "Alliances":
|
|
4225
|
+
extraByte |= 4;
|
|
4226
|
+
break;
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
if (config.source === "Competitive") extraByte |= 8;
|
|
4230
|
+
const versionByte = 0;
|
|
4231
|
+
const playerCountByte = Math.max(0, Math.min(3, config.playerCount - 1));
|
|
4232
|
+
let seedStr = valueToChar(foeByteA) + valueToChar(foeByteB) + valueToChar(allyIndex) + valueToChar(extraByte) + valueToChar(versionByte) + valueToChar(playerCountByte);
|
|
4233
|
+
let rngValue = 0;
|
|
4234
|
+
for (let i = 0; i < RNG_SEED_LENGTH; i++) {
|
|
4235
|
+
const value = Math.floor(Math.random() * BASE);
|
|
4236
|
+
seedStr += valueToChar(value);
|
|
4237
|
+
rngValue += value * Math.round(Math.pow(BASE, i));
|
|
4238
|
+
}
|
|
4239
|
+
const upper = seedStr.toUpperCase();
|
|
4240
|
+
const formatted = `${upper.slice(0, 4)}-${upper.slice(4, 8)}-${upper.slice(8, 12)}`;
|
|
4241
|
+
return { seed: formatted, rngValue };
|
|
4242
|
+
}
|
|
4243
|
+
function encodeSeed(config, rngValue) {
|
|
4244
|
+
let foeByteA = 0;
|
|
4245
|
+
let foeByteB = 0;
|
|
4246
|
+
const tier1Index = TIER1_FOES.indexOf(config.foes[0]);
|
|
4247
|
+
const tier2Index = TIER2_FOES.indexOf(config.foes[1]);
|
|
4248
|
+
const tier3Index = TIER3_FOES.indexOf(config.foes[2]);
|
|
4249
|
+
if (tier1Index < 0) throw new Error(`Invalid Tier 1 foe: ${config.foes[0]}`);
|
|
4250
|
+
if (tier2Index < 0) throw new Error(`Invalid Tier 2 foe: ${config.foes[1]}`);
|
|
4251
|
+
if (tier3Index < 0) throw new Error(`Invalid Tier 3 foe: ${config.foes[2]}`);
|
|
4252
|
+
foeByteA = tier1Index & 3;
|
|
4253
|
+
foeByteA |= (tier2Index & 3) << 2;
|
|
4254
|
+
foeByteA |= (tier3Index & 1) << 4;
|
|
4255
|
+
foeByteB |= (tier3Index >> 1 & 1) << 4;
|
|
4256
|
+
const adversaryIndex = ADVERSARIES.indexOf(config.adversary);
|
|
4257
|
+
if (adversaryIndex < 0) throw new Error(`Invalid adversary: ${config.adversary}`);
|
|
4258
|
+
foeByteB |= adversaryIndex & 15;
|
|
4259
|
+
const allyIndex = ALLIES.indexOf(config.ally);
|
|
4260
|
+
if (allyIndex < 0) throw new Error(`Invalid ally: ${config.ally}`);
|
|
4261
|
+
let extraByte = 0;
|
|
4262
|
+
if (config.difficulty === "Gritty") extraByte |= 1;
|
|
4263
|
+
for (const expansion of config.expansions) {
|
|
4264
|
+
switch (expansion) {
|
|
4265
|
+
case "Monuments":
|
|
4266
|
+
extraByte |= 2;
|
|
4267
|
+
break;
|
|
4268
|
+
case "Alliances":
|
|
4269
|
+
extraByte |= 4;
|
|
4270
|
+
break;
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
if (config.source === "Competitive") extraByte |= 8;
|
|
4274
|
+
const versionByte = 0;
|
|
4275
|
+
const playerCountByte = Math.max(0, Math.min(3, config.playerCount - 1));
|
|
4276
|
+
let seedStr = valueToChar(foeByteA) + valueToChar(foeByteB) + valueToChar(allyIndex) + valueToChar(extraByte) + valueToChar(versionByte) + valueToChar(playerCountByte);
|
|
4277
|
+
let remaining = rngValue;
|
|
4278
|
+
for (let i = 0; i < RNG_SEED_LENGTH; i++) {
|
|
4279
|
+
const digit = remaining % BASE;
|
|
4280
|
+
seedStr += valueToChar(digit);
|
|
4281
|
+
remaining = Math.floor(remaining / BASE);
|
|
4282
|
+
}
|
|
4283
|
+
const upper = seedStr.toUpperCase();
|
|
4284
|
+
return `${upper.slice(0, 4)}-${upper.slice(4, 8)}-${upper.slice(8, 12)}`;
|
|
4285
|
+
}
|
|
4286
|
+
function compareSeedsRaw(seed1, seed2) {
|
|
4287
|
+
const n1 = validateSeed(seed1);
|
|
4288
|
+
const n2 = validateSeed(seed2);
|
|
4289
|
+
const s1 = n1.replace(/-/g, "").toLowerCase();
|
|
4290
|
+
const s2 = n2.replace(/-/g, "").toLowerCase();
|
|
4291
|
+
const diffs = [];
|
|
4292
|
+
for (let i = 0; i < SEED_LENGTH; i++) {
|
|
4293
|
+
const v1 = charToValue(s1[i]);
|
|
4294
|
+
const v2 = charToValue(s2[i]);
|
|
4295
|
+
if (v1 !== v2) {
|
|
4296
|
+
diffs.push({
|
|
4297
|
+
charIndex: i,
|
|
4298
|
+
value1: v1,
|
|
4299
|
+
value2: v2,
|
|
4300
|
+
char1: s1[i],
|
|
4301
|
+
char2: s2[i]
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
return {
|
|
4306
|
+
seed1: n1,
|
|
4307
|
+
seed2: n2,
|
|
4308
|
+
diffs,
|
|
4309
|
+
setupDiffs: diffs.filter((d) => d.charIndex < SETUP_LENGTH),
|
|
4310
|
+
rngDiffs: diffs.filter((d) => d.charIndex >= SETUP_LENGTH)
|
|
4311
|
+
};
|
|
4312
|
+
}
|
|
4313
|
+
var SETUP_FIELD_LABELS = {
|
|
4314
|
+
0: "Tier1/Tier2/Tier3lo",
|
|
4315
|
+
1: "Adversary/Tier3hi",
|
|
4316
|
+
2: "Ally",
|
|
4317
|
+
3: "Difficulty/Expansions/Source",
|
|
4318
|
+
4: "Version",
|
|
4319
|
+
5: "PlayerCount"
|
|
4320
|
+
};
|
|
4321
|
+
function dumpSeedChars(seed) {
|
|
4322
|
+
const normalized = validateSeed(seed);
|
|
4323
|
+
const stripped = normalized.replace(/-/g, "").toLowerCase();
|
|
4324
|
+
const chars = [];
|
|
4325
|
+
for (let i = 0; i < SEED_LENGTH; i++) {
|
|
4326
|
+
const isSetup = i < SETUP_LENGTH;
|
|
4327
|
+
chars.push({
|
|
4328
|
+
index: i,
|
|
4329
|
+
char: stripped[i],
|
|
4330
|
+
value: charToValue(stripped[i]),
|
|
4331
|
+
section: isSetup ? "setup" : "rng",
|
|
4332
|
+
field: isSetup ? SETUP_FIELD_LABELS[i] : void 0
|
|
4333
|
+
});
|
|
4334
|
+
}
|
|
4335
|
+
return { seed: normalized, chars };
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
// src/udtSystemRandom.ts
|
|
4339
|
+
var INT32_MAX = 2147483647;
|
|
4340
|
+
var MSEED = 161803398;
|
|
4341
|
+
function toInt32(n) {
|
|
4342
|
+
return n | 0;
|
|
4343
|
+
}
|
|
4344
|
+
var SystemRandom = class {
|
|
4345
|
+
/**
|
|
4346
|
+
* Create a new PRNG instance with the given seed.
|
|
4347
|
+
* Matches C# `new System.Random(seed)` exactly.
|
|
4348
|
+
*/
|
|
4349
|
+
constructor(seed) {
|
|
4350
|
+
this.seedArray = new Array(56).fill(0);
|
|
4351
|
+
this.inext = 0;
|
|
4352
|
+
this.inextp = 0;
|
|
4353
|
+
this.initialize(seed);
|
|
4354
|
+
}
|
|
4355
|
+
/**
|
|
4356
|
+
* Replicate .NET's System.Random constructor seeding algorithm.
|
|
4357
|
+
*/
|
|
4358
|
+
initialize(seed) {
|
|
4359
|
+
let subtraction;
|
|
4360
|
+
if (seed === -2147483648) {
|
|
4361
|
+
subtraction = INT32_MAX;
|
|
4362
|
+
} else {
|
|
4363
|
+
subtraction = Math.abs(seed);
|
|
4364
|
+
}
|
|
4365
|
+
let mj = toInt32(MSEED - subtraction);
|
|
4366
|
+
this.seedArray[55] = mj;
|
|
4367
|
+
let mk = 1;
|
|
4368
|
+
for (let i = 1; i < 55; i++) {
|
|
4369
|
+
const ii = 21 * i % 55;
|
|
4370
|
+
this.seedArray[ii] = mk;
|
|
4371
|
+
mk = toInt32(mj - mk);
|
|
4372
|
+
if (mk < 0) mk = toInt32(mk + INT32_MAX);
|
|
4373
|
+
mj = this.seedArray[ii];
|
|
4374
|
+
}
|
|
4375
|
+
for (let k = 1; k < 5; k++) {
|
|
4376
|
+
for (let i = 1; i < 56; i++) {
|
|
4377
|
+
this.seedArray[i] = toInt32(this.seedArray[i] - this.seedArray[1 + (i + 30) % 55]);
|
|
4378
|
+
if (this.seedArray[i] < 0) {
|
|
4379
|
+
this.seedArray[i] = toInt32(this.seedArray[i] + INT32_MAX);
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
this.inext = 0;
|
|
4384
|
+
this.inextp = 21;
|
|
4385
|
+
}
|
|
4386
|
+
/**
|
|
4387
|
+
* Internal sample — returns value in range [0, Int32.MaxValue).
|
|
4388
|
+
* Matches C#'s InternalSample().
|
|
4389
|
+
*/
|
|
4390
|
+
internalSample() {
|
|
4391
|
+
let retVal;
|
|
4392
|
+
let locINext = this.inext;
|
|
4393
|
+
let locINextp = this.inextp;
|
|
4394
|
+
if (++locINext >= 56) locINext = 1;
|
|
4395
|
+
if (++locINextp >= 56) locINextp = 1;
|
|
4396
|
+
retVal = toInt32(this.seedArray[locINext] - this.seedArray[locINextp]);
|
|
4397
|
+
if (retVal === INT32_MAX) retVal--;
|
|
4398
|
+
if (retVal < 0) retVal = toInt32(retVal + INT32_MAX);
|
|
4399
|
+
this.seedArray[locINext] = retVal;
|
|
4400
|
+
this.inext = locINext;
|
|
4401
|
+
this.inextp = locINextp;
|
|
4402
|
+
return retVal;
|
|
4403
|
+
}
|
|
4404
|
+
/**
|
|
4405
|
+
* Sample — returns a double in range [0.0, 1.0).
|
|
4406
|
+
* Matches C#'s Sample().
|
|
4407
|
+
*/
|
|
4408
|
+
sample() {
|
|
4409
|
+
return this.internalSample() * (1 / INT32_MAX);
|
|
4410
|
+
}
|
|
4411
|
+
/**
|
|
4412
|
+
* Returns a non-negative random integer less than Int32.MaxValue.
|
|
4413
|
+
* Matches C# `Random.Next()`.
|
|
4414
|
+
*/
|
|
4415
|
+
next() {
|
|
4416
|
+
return this.internalSample();
|
|
4417
|
+
}
|
|
4418
|
+
/**
|
|
4419
|
+
* Returns a non-negative random integer less than maxValue.
|
|
4420
|
+
* Matches C# `Random.Next(maxValue)`.
|
|
4421
|
+
*/
|
|
4422
|
+
nextMax(maxValue) {
|
|
4423
|
+
if (maxValue < 0) {
|
|
4424
|
+
throw new Error("maxValue must be non-negative");
|
|
4425
|
+
}
|
|
4426
|
+
return toInt32(this.sample() * maxValue);
|
|
4427
|
+
}
|
|
4428
|
+
/**
|
|
4429
|
+
* Returns a random integer in range [minValue, maxValue).
|
|
4430
|
+
* Matches C# `Random.Next(minValue, maxValue)`.
|
|
4431
|
+
*/
|
|
4432
|
+
nextRange(minValue, maxValue) {
|
|
4433
|
+
if (minValue > maxValue) {
|
|
4434
|
+
throw new Error("minValue must be less than or equal to maxValue");
|
|
4435
|
+
}
|
|
4436
|
+
const range = maxValue - minValue;
|
|
4437
|
+
if (range <= INT32_MAX) {
|
|
4438
|
+
return toInt32(this.sample() * range) + minValue;
|
|
4439
|
+
}
|
|
4440
|
+
return toInt32(this.internalSample() * (1 / INT32_MAX) * range) + minValue;
|
|
4441
|
+
}
|
|
4442
|
+
/**
|
|
4443
|
+
* Returns a random double in range [0.0, 1.0).
|
|
4444
|
+
* Matches C# `Random.NextDouble()`.
|
|
4445
|
+
*/
|
|
4446
|
+
nextDouble() {
|
|
4447
|
+
return this.sample();
|
|
4448
|
+
}
|
|
4449
|
+
};
|
|
4450
|
+
|
|
4451
|
+
// src/udtGameBoard.ts
|
|
4452
|
+
var BOARD_GROUPINGS = {
|
|
4453
|
+
/** Dayside and Fivepint (North kingdom lakes). */
|
|
4454
|
+
LONG_WATER: "Long Water",
|
|
4455
|
+
/** Delmsmire, Arkartus, and Yellowpike (West kingdom forests). */
|
|
4456
|
+
THE_GREAT_WOODS: "The Great Woods",
|
|
4457
|
+
/** The Throne, The Cloister, and Archmont (South kingdom grasslands). */
|
|
4458
|
+
REGAL_RUN: "Regal Run"
|
|
4459
|
+
};
|
|
4460
|
+
var BOARD_LOCATIONS = [
|
|
4461
|
+
// ── North ───────────────────────────────────────────────────────────────
|
|
4462
|
+
{ name: "Broken Lands", terrain: "Hills", kingdom: "north" },
|
|
4463
|
+
{ name: "Dayside", terrain: "Lake", building: "Bazaar", kingdom: "north", grouping: BOARD_GROUPINGS.LONG_WATER },
|
|
4464
|
+
{ name: "Egan's End", terrain: "Grasslands", building: "Village", kingdom: "north" },
|
|
4465
|
+
{ name: "Fivepint", terrain: "Lake", kingdom: "north", grouping: BOARD_GROUPINGS.LONG_WATER },
|
|
4466
|
+
{ name: "Green Bridge", terrain: "Grasslands", kingdom: "north" },
|
|
4467
|
+
{ name: "Lodestone Mountains", terrain: "Mountains", kingdom: "north" },
|
|
4468
|
+
{ name: "Lower Ice Fangs", terrain: "Mountains", kingdom: "north" },
|
|
4469
|
+
{ name: "Muted Forest", terrain: "Forest", kingdom: "north" },
|
|
4470
|
+
{ name: "Peaks of the Djinn", terrain: "Mountains", kingdom: "north" },
|
|
4471
|
+
{ name: "Pearl of the North", terrain: "Grasslands", kingdom: "north" },
|
|
4472
|
+
{ name: "Radiant Mountains", terrain: "Mountains", building: "Citadel", kingdom: "north" },
|
|
4473
|
+
{ name: "Rimeweald", terrain: "Forest", kingdom: "north" },
|
|
4474
|
+
{ name: "The Tundra", terrain: "Desert", kingdom: "north" },
|
|
4475
|
+
{ name: "Tower Scar Desert", terrain: "Desert", kingdom: "north" },
|
|
4476
|
+
{ name: "Upper Ice Fangs", terrain: "Mountains", building: "Sanctuary", kingdom: "north" },
|
|
4477
|
+
// ── East ────────────────────────────────────────────────────────────────
|
|
4478
|
+
{ name: "Big Sister", terrain: "Mountains", kingdom: "east" },
|
|
4479
|
+
{ name: "Bleak Wastes", terrain: "Desert", kingdom: "east" },
|
|
4480
|
+
{ name: "Copper Grove", terrain: "Forest", kingdom: "east" },
|
|
4481
|
+
{ name: "Dragontooth Lake", terrain: "Lake", kingdom: "east" },
|
|
4482
|
+
{ name: "Duwani", terrain: "Grasslands", building: "Village", kingdom: "east" },
|
|
4483
|
+
{ name: "Forest of Shades", terrain: "Forest", kingdom: "east" },
|
|
4484
|
+
{ name: "Greater Tombstones", terrain: "Hills", building: "Sanctuary", kingdom: "east" },
|
|
4485
|
+
{ name: "Inner Kinghills", terrain: "Hills", building: "Citadel", kingdom: "east" },
|
|
4486
|
+
{ name: "Jewel Hills", terrain: "Hills", kingdom: "east" },
|
|
4487
|
+
{ name: "Lake of Songs", terrain: "Lake", kingdom: "east" },
|
|
4488
|
+
{ name: "Lesser Tombstones", terrain: "Hills", kingdom: "east" },
|
|
4489
|
+
{ name: "Outer Kinghills", terrain: "Hills", kingdom: "east" },
|
|
4490
|
+
{ name: "The Decaying Wilds", terrain: "Grasslands", kingdom: "east" },
|
|
4491
|
+
{ name: "Three Rivers", terrain: "Grasslands", building: "Bazaar", kingdom: "east" },
|
|
4492
|
+
{ name: "Utar's Barrows", terrain: "Desert", kingdom: "east" },
|
|
4493
|
+
// ── West ────────────────────────────────────────────────────────────────
|
|
4494
|
+
{ name: "Anza", terrain: "Grasslands", building: "Village", kingdom: "west" },
|
|
4495
|
+
{ name: "Arkartus", terrain: "Forest", building: "Sanctuary", kingdom: "west", grouping: BOARD_GROUPINGS.THE_GREAT_WOODS },
|
|
4496
|
+
{ name: "Ash Hills", terrain: "Hills", kingdom: "west" },
|
|
4497
|
+
{ name: "Cloudhold", terrain: "Mountains", kingdom: "west" },
|
|
4498
|
+
{ name: "Delmsmire", terrain: "Forest", kingdom: "west", grouping: BOARD_GROUPINGS.THE_GREAT_WOODS },
|
|
4499
|
+
{ name: "Hissing Groves", terrain: "Forest", building: "Citadel", kingdom: "west" },
|
|
4500
|
+
{ name: "Idran Forest", terrain: "Forest", kingdom: "west" },
|
|
4501
|
+
{ name: "Lonelight Hills", terrain: "Hills", kingdom: "west" },
|
|
4502
|
+
{ name: "Lost Lands", terrain: "Desert", kingdom: "west" },
|
|
4503
|
+
{ name: "Plains of Plovo", terrain: "Grasslands", building: "Bazaar", kingdom: "west" },
|
|
4504
|
+
{ name: "Plains of Woldra", terrain: "Grasslands", kingdom: "west" },
|
|
4505
|
+
{ name: "The Empty Glade", terrain: "Grasslands", kingdom: "west" },
|
|
4506
|
+
{ name: "The Grass Sea", terrain: "Grasslands", kingdom: "west" },
|
|
4507
|
+
{ name: "Weeping Waters", terrain: "Lake", kingdom: "west" },
|
|
4508
|
+
{ name: "Yellowpike", terrain: "Forest", kingdom: "west", grouping: BOARD_GROUPINGS.THE_GREAT_WOODS },
|
|
4509
|
+
// ── South ───────────────────────────────────────────────────────────────
|
|
4510
|
+
{ name: "Archmont", terrain: "Grasslands", kingdom: "south", grouping: BOARD_GROUPINGS.REGAL_RUN },
|
|
4511
|
+
{ name: "Azkol's Bane", terrain: "Desert", kingdom: "south" },
|
|
4512
|
+
{ name: "Bone Hills", terrain: "Hills", kingdom: "south" },
|
|
4513
|
+
{ name: "Howling Desert", terrain: "Desert", building: "Citadel", kingdom: "south" },
|
|
4514
|
+
{ name: "Irontops", terrain: "Mountains", kingdom: "south" },
|
|
4515
|
+
{ name: "Little Sister", terrain: "Mountains", kingdom: "south" },
|
|
4516
|
+
{ name: "Middle Sister", terrain: "Mountains", kingdom: "south" },
|
|
4517
|
+
{ name: "Mountains of the Watchers", terrain: "Mountains", kingdom: "south" },
|
|
4518
|
+
{ name: "Pine Barrens", terrain: "Forest", kingdom: "south" },
|
|
4519
|
+
{ name: "Sands of Madness", terrain: "Desert", building: "Sanctuary", kingdom: "south" },
|
|
4520
|
+
{ name: "Southern Wastes", terrain: "Desert", building: "Village", kingdom: "south" },
|
|
4521
|
+
{ name: "The Cloister", terrain: "Grasslands", kingdom: "south", grouping: BOARD_GROUPINGS.REGAL_RUN },
|
|
4522
|
+
{ name: "The Emerald Expanse", terrain: "Grasslands", building: "Bazaar", kingdom: "south" },
|
|
4523
|
+
{ name: "The Throne", terrain: "Grasslands", kingdom: "south", grouping: BOARD_GROUPINGS.REGAL_RUN },
|
|
4524
|
+
{ name: "Ulamel's Hollow", terrain: "Grasslands", kingdom: "south" }
|
|
4525
|
+
];
|
|
4526
|
+
var BOARD_LOCATION_BY_NAME = Object.fromEntries(BOARD_LOCATIONS.map((loc) => [loc.name, loc]));
|
|
4527
|
+
|
|
4528
|
+
// src/index.ts
|
|
3559
4529
|
var index_default = UltimateDarkTower_default;
|
|
3560
4530
|
export {
|
|
4531
|
+
ADVERSARIES,
|
|
4532
|
+
ALLIES,
|
|
3561
4533
|
AUDIO_COMMAND_POS,
|
|
3562
4534
|
BATTERY_STATUS_FREQUENCY,
|
|
4535
|
+
BOARD_GROUPINGS,
|
|
4536
|
+
BOARD_LOCATIONS,
|
|
4537
|
+
BOARD_LOCATION_BY_NAME,
|
|
3563
4538
|
BluetoothAdapterFactory,
|
|
3564
4539
|
BluetoothConnectionError,
|
|
3565
4540
|
BluetoothDeviceNotFoundError,
|
|
@@ -3573,6 +4548,7 @@ export {
|
|
|
3573
4548
|
DEFAULT_CONNECTION_MONITORING_FREQUENCY,
|
|
3574
4549
|
DEFAULT_CONNECTION_MONITORING_TIMEOUT,
|
|
3575
4550
|
DEFAULT_RETRY_SEND_COMMAND_MAX,
|
|
4551
|
+
DIFFICULTIES,
|
|
3576
4552
|
DIS_FIRMWARE_REVISION_UUID,
|
|
3577
4553
|
DIS_HARDWARE_REVISION_UUID,
|
|
3578
4554
|
DIS_IEEE_REGULATORY_UUID,
|
|
@@ -3585,7 +4561,10 @@ export {
|
|
|
3585
4561
|
DIS_SYSTEM_ID_UUID,
|
|
3586
4562
|
DOMOutput,
|
|
3587
4563
|
DRUM_PACKETS,
|
|
4564
|
+
GAME_SOURCES,
|
|
3588
4565
|
GLYPHS,
|
|
4566
|
+
InMemorySink,
|
|
4567
|
+
IndexedDBSink,
|
|
3589
4568
|
LAYER_TO_POSITION,
|
|
3590
4569
|
LEDGE_BASE_LIGHT_POSITIONS,
|
|
3591
4570
|
LED_CHANNEL_LOOKUP,
|
|
@@ -3595,7 +4574,11 @@ export {
|
|
|
3595
4574
|
RING_LIGHT_POSITIONS,
|
|
3596
4575
|
SKULL_DROP_COUNT_POS,
|
|
3597
4576
|
STATE_DATA_LENGTH,
|
|
4577
|
+
SystemRandom,
|
|
3598
4578
|
TC,
|
|
4579
|
+
TIER1_FOES,
|
|
4580
|
+
TIER2_FOES,
|
|
4581
|
+
TIER3_FOES,
|
|
3599
4582
|
TOWER_AUDIO_LIBRARY,
|
|
3600
4583
|
TOWER_COMMANDS,
|
|
3601
4584
|
TOWER_COMMAND_HEADER_SIZE,
|
|
@@ -3612,18 +4595,29 @@ export {
|
|
|
3612
4595
|
UART_RX_CHARACTERISTIC_UUID,
|
|
3613
4596
|
UART_SERVICE_UUID,
|
|
3614
4597
|
UART_TX_CHARACTERISTIC_UUID,
|
|
4598
|
+
UdtDiagnosticsRecorder,
|
|
3615
4599
|
UltimateDarkTower_default as UltimateDarkTower,
|
|
3616
4600
|
VOLTAGE_LEVELS,
|
|
3617
4601
|
VOLUME_DESCRIPTIONS,
|
|
3618
4602
|
VOLUME_ICONS,
|
|
4603
|
+
bytesToHex,
|
|
4604
|
+
charToValue,
|
|
4605
|
+
compareSeedsRaw,
|
|
3619
4606
|
createDefaultTowerState,
|
|
4607
|
+
createSeed,
|
|
4608
|
+
decodeRngSeed,
|
|
4609
|
+
decodeSeed,
|
|
3620
4610
|
index_default as default,
|
|
3621
4611
|
drumPositionCmds,
|
|
4612
|
+
dumpSeedChars,
|
|
4613
|
+
encodeSeed,
|
|
3622
4614
|
isCalibrated,
|
|
3623
4615
|
logger,
|
|
3624
4616
|
milliVoltsToPercentage,
|
|
3625
4617
|
milliVoltsToPercentageNumber,
|
|
3626
4618
|
parseDifferentialReadings,
|
|
3627
4619
|
rtdt_pack_state,
|
|
3628
|
-
rtdt_unpack_state
|
|
4620
|
+
rtdt_unpack_state,
|
|
4621
|
+
validateSeed,
|
|
4622
|
+
valueToChar
|
|
3629
4623
|
};
|