@taphubhq/sdk-core 0.23.4 → 0.24.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/dist/index.cjs CHANGED
@@ -59,7 +59,8 @@ __export(index_exports, {
59
59
  isTerminal: () => isTerminal,
60
60
  isWin: () => isWin,
61
61
  normalCDF: () => normalCDF,
62
- normalPDF: () => normalPDF
62
+ normalPDF: () => normalPDF,
63
+ pairIdFromBidResultTopic: () => pairIdFromBidResultTopic
63
64
  });
64
65
  module.exports = __toCommonJS(index_exports);
65
66
 
@@ -1043,6 +1044,19 @@ function walletBalanceTopic(userId) {
1043
1044
  function agencyPairStatsTopic(aid, pairId) {
1044
1045
  return `public/agency/${aid}/pair/${pairId}/stats`;
1045
1046
  }
1047
+ function userBidsWildcardTopic(userId) {
1048
+ return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
1049
+ }
1050
+ function topicMatchesWildcard(pattern, topic) {
1051
+ const pp = pattern.split("/");
1052
+ const tp = topic.split("/");
1053
+ if (pp.length !== tp.length) return false;
1054
+ for (let i = 0; i < pp.length; i++) {
1055
+ if (pp[i] === "+") continue;
1056
+ if (pp[i] !== tp[i]) return false;
1057
+ }
1058
+ return true;
1059
+ }
1046
1060
  function topicFor(gameId, suffix, userId) {
1047
1061
  if (USER_SCOPED_SUFFIXES.includes(suffix)) {
1048
1062
  return `${TOPIC_PREFIX}/${gameId}/user/${userId}/${suffix}`;
@@ -1061,6 +1075,7 @@ function createMqttTransport(endpoint, opts = {}) {
1061
1075
  const candleSubscriptions = /* @__PURE__ */ new Map();
1062
1076
  const statsSubscriptions = /* @__PURE__ */ new Map();
1063
1077
  const walletSubscriptions = /* @__PURE__ */ new Map();
1078
+ const userBidsSubscriptions = /* @__PURE__ */ new Map();
1064
1079
  const { onLifecycle, auth } = opts;
1065
1080
  let connectStartedAt = 0;
1066
1081
  function fireLifecycle(event) {
@@ -1111,6 +1126,9 @@ function createMqttTransport(endpoint, opts = {}) {
1111
1126
  for (const entry of walletSubscriptions.values()) {
1112
1127
  c.subscribe(entry.topic, { qos: 1 });
1113
1128
  }
1129
+ for (const entry of userBidsSubscriptions.values()) {
1130
+ c.subscribe(entry.pattern, { qos: 1 });
1131
+ }
1114
1132
  fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
1115
1133
  });
1116
1134
  client.on("reconnect", () => {
@@ -1159,6 +1177,19 @@ function createMqttTransport(endpoint, opts = {}) {
1159
1177
  walletSub.onMessage(receivedTopic, payload2);
1160
1178
  return;
1161
1179
  }
1180
+ let userBidsPayload;
1181
+ for (const sub of userBidsSubscriptions.values()) {
1182
+ if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
1183
+ if (userBidsPayload === void 0) {
1184
+ try {
1185
+ userBidsPayload = JSON.parse(message.toString());
1186
+ } catch {
1187
+ userBidsPayload = null;
1188
+ sub.onError(new Error(`Invalid JSON on topic ${receivedTopic}`));
1189
+ }
1190
+ }
1191
+ if (userBidsPayload != null) sub.onMessage(receivedTopic, userBidsPayload);
1192
+ }
1162
1193
  let matched;
1163
1194
  for (const sub of subscriptions.values()) {
1164
1195
  if (sub.topics.includes(receivedTopic)) {
@@ -1246,6 +1277,19 @@ function createMqttTransport(endpoint, opts = {}) {
1246
1277
  if (client) client.unsubscribe(entry.topic);
1247
1278
  walletSubscriptions.delete(userId);
1248
1279
  },
1280
+ subscribeUserBids(userId, onMessage, onError) {
1281
+ if (userBidsSubscriptions.has(userId)) return;
1282
+ const pattern = userBidsWildcardTopic(userId);
1283
+ const mqttClient = ensureConnected();
1284
+ userBidsSubscriptions.set(userId, { pattern, onMessage, onError });
1285
+ mqttClient.subscribe(pattern, { qos: 1 });
1286
+ },
1287
+ unsubscribeUserBids(userId) {
1288
+ const entry = userBidsSubscriptions.get(userId);
1289
+ if (!entry) return;
1290
+ if (client) client.unsubscribe(entry.pattern);
1291
+ userBidsSubscriptions.delete(userId);
1292
+ },
1249
1293
  unsubscribeAll(gameId, userId) {
1250
1294
  const matches = entriesForGame(gameId);
1251
1295
  if (matches.length === 0) return;
@@ -1286,11 +1330,15 @@ function createMqttTransport(endpoint, opts = {}) {
1286
1330
  for (const entry of walletSubscriptions.values()) {
1287
1331
  client.unsubscribe(entry.topic);
1288
1332
  }
1333
+ for (const entry of userBidsSubscriptions.values()) {
1334
+ client.unsubscribe(entry.pattern);
1335
+ }
1289
1336
  }
1290
1337
  subscriptions.clear();
1291
1338
  candleSubscriptions.clear();
1292
1339
  statsSubscriptions.clear();
1293
1340
  walletSubscriptions.clear();
1341
+ userBidsSubscriptions.clear();
1294
1342
  if (client) {
1295
1343
  client.end(true);
1296
1344
  client = null;
@@ -1300,7 +1348,7 @@ function createMqttTransport(endpoint, opts = {}) {
1300
1348
  }
1301
1349
 
1302
1350
  // src/modules/realtime/index.ts
1303
- var import_eventemitter33 = __toESM(require("eventemitter3"));
1351
+ var import_eventemitter34 = __toESM(require("eventemitter3"));
1304
1352
 
1305
1353
  // src/modules/realtime/GameChannel.ts
1306
1354
  var import_eventemitter3 = __toESM(require("eventemitter3"));
@@ -1312,9 +1360,19 @@ var GameChannel = class extends import_eventemitter3.default {
1312
1360
  }
1313
1361
  };
1314
1362
 
1315
- // src/modules/realtime/WalletChannel.ts
1363
+ // src/modules/realtime/UserBidsChannel.ts
1316
1364
  var import_eventemitter32 = __toESM(require("eventemitter3"));
1317
- var WalletChannel = class extends import_eventemitter32.default {
1365
+ var UserBidsChannel = class extends import_eventemitter32.default {
1366
+ userId;
1367
+ constructor(userId) {
1368
+ super();
1369
+ this.userId = userId;
1370
+ }
1371
+ };
1372
+
1373
+ // src/modules/realtime/WalletChannel.ts
1374
+ var import_eventemitter33 = __toESM(require("eventemitter3"));
1375
+ var WalletChannel = class extends import_eventemitter33.default {
1318
1376
  userId;
1319
1377
  constructor(userId) {
1320
1378
  super();
@@ -1322,6 +1380,19 @@ var WalletChannel = class extends import_eventemitter32.default {
1322
1380
  }
1323
1381
  };
1324
1382
 
1383
+ // src/modules/realtime/topic.ts
1384
+ var BID_RESULT_SUFFIX = "bid_result";
1385
+ function pairIdFromBidResultTopic(topic) {
1386
+ if (!topic.endsWith(`/${BID_RESULT_SUFFIX}`)) return void 0;
1387
+ const parts = topic.split("/");
1388
+ if (parts.length !== 5 || parts[0] !== "game" || parts[2] !== "user") return void 0;
1389
+ const gameId = parts[1];
1390
+ const sep = gameId.indexOf(":");
1391
+ if (sep < 0) return void 0;
1392
+ const pairId = gameId.slice(sep + 1);
1393
+ return pairId === "" ? void 0 : pairId;
1394
+ }
1395
+
1325
1396
  // src/modules/realtime/index.ts
1326
1397
  var TOPIC_SUFFIX_CANDLE = "candle";
1327
1398
  var TOPIC_SUFFIX_BID_RESULT = "bid_result";
@@ -1344,7 +1415,7 @@ function mapWireCandle(raw) {
1344
1415
  if (c.coefMults !== void 0) result.coefMults = c.coefMults;
1345
1416
  return result;
1346
1417
  }
1347
- function mapWireBidResult(raw) {
1418
+ function mapWireBidResult(raw, topicPairId) {
1348
1419
  const p = raw;
1349
1420
  switch (p.type) {
1350
1421
  case "accepted": {
@@ -1361,13 +1432,13 @@ function mapWireBidResult(raw) {
1361
1432
  userId: p.user_id
1362
1433
  };
1363
1434
  if (typeof p.balance === "string") data.balance = p.balance;
1435
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1364
1436
  return { event: "bidWon", data };
1365
1437
  }
1366
1438
  case "lost": {
1367
- return {
1368
- event: "bidLost",
1369
- data: { bidId: p.bidId }
1370
- };
1439
+ const data = { bidId: p.bidId };
1440
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1441
+ return { event: "bidLost", data };
1371
1442
  }
1372
1443
  default:
1373
1444
  console.warn(`Unknown bid_result type "${String(p.type)}"`);
@@ -1415,7 +1486,7 @@ function mapWireToEvent(topic, payload) {
1415
1486
  return { event: "candle", data: mapWireCandle(payload) };
1416
1487
  }
1417
1488
  if (topic.endsWith(`/${TOPIC_SUFFIX_BID_RESULT}`)) {
1418
- return mapWireBidResult(payload);
1489
+ return mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1419
1490
  }
1420
1491
  if (topic.endsWith(`/${TOPIC_SUFFIX_BALANCE_UPDATE}`)) {
1421
1492
  return { event: "balanceUpdate", data: mapWireBalanceUpdate(payload) };
@@ -1431,11 +1502,13 @@ function mapWireToEvent(topic, payload) {
1431
1502
  function normaliseUserId2(userId) {
1432
1503
  return userId && userId !== "" ? userId : null;
1433
1504
  }
1434
- var RealtimeModule = class extends import_eventemitter33.default {
1505
+ var RealtimeModule = class extends import_eventemitter34.default {
1435
1506
  #transport;
1436
1507
  #entries = /* @__PURE__ */ new Map();
1437
1508
  #walletEntries = /* @__PURE__ */ new Map();
1438
1509
  // keyed by userId
1510
+ #userBidsEntries = /* @__PURE__ */ new Map();
1511
+ // keyed by userId
1439
1512
  #agencyId;
1440
1513
  constructor(mqttEndpointOrOptions) {
1441
1514
  super();
@@ -1566,6 +1639,64 @@ var RealtimeModule = class extends import_eventemitter33.default {
1566
1639
  entry.channel.removeAllListeners();
1567
1640
  this.#transport.unsubscribeWallet(cleanUserId);
1568
1641
  }
1642
+ /**
1643
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
1644
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
1645
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
1646
+ * on — independent of which pair is currently displayed (mirrors
1647
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
1648
+ * from the concrete result topic, so consumers can attribute and label it.
1649
+ *
1650
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
1651
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
1652
+ * channel drives state + sound, this cross-pair channel drives notifications.
1653
+ */
1654
+ subscribeUserBids(userId) {
1655
+ const cleanUserId = normaliseUserId2(userId);
1656
+ if (!cleanUserId) {
1657
+ throw new TaphubError("userId is required to subscribe to user bids", {
1658
+ code: "UserIdRequired"
1659
+ });
1660
+ }
1661
+ if (/[/+#]/.test(cleanUserId)) {
1662
+ throw new TaphubError("userId must not contain MQTT topic separators (/ + #)", {
1663
+ code: "InvalidUserId"
1664
+ });
1665
+ }
1666
+ const existing = this.#userBidsEntries.get(cleanUserId);
1667
+ if (existing) {
1668
+ existing.refcount += 1;
1669
+ return existing.channel;
1670
+ }
1671
+ const channel = new UserBidsChannel(cleanUserId);
1672
+ const onMessage = (topic, payload) => {
1673
+ const mapped = mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1674
+ if (!mapped) return;
1675
+ if (mapped.event === "bidWon") channel.emit("bidWon", mapped.data);
1676
+ else if (mapped.event === "bidLost") channel.emit("bidLost", mapped.data);
1677
+ };
1678
+ const onError = (err) => {
1679
+ channel.emit("error", err);
1680
+ };
1681
+ this.#userBidsEntries.set(cleanUserId, { userId: cleanUserId, channel, refcount: 1 });
1682
+ this.#transport.subscribeUserBids(cleanUserId, onMessage, onError);
1683
+ return channel;
1684
+ }
1685
+ /**
1686
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1687
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1688
+ */
1689
+ unsubscribeUserBids(userId) {
1690
+ const cleanUserId = normaliseUserId2(userId);
1691
+ if (!cleanUserId) return;
1692
+ const entry = this.#userBidsEntries.get(cleanUserId);
1693
+ if (!entry) return;
1694
+ entry.refcount -= 1;
1695
+ if (entry.refcount > 0) return;
1696
+ this.#userBidsEntries.delete(cleanUserId);
1697
+ entry.channel.removeAllListeners();
1698
+ this.#transport.unsubscribeUserBids(cleanUserId);
1699
+ }
1569
1700
  /**
1570
1701
  * Subscribe to public market candle data for a pair, keyed by pairId
1571
1702
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1617,6 +1748,10 @@ var RealtimeModule = class extends import_eventemitter33.default {
1617
1748
  entry.channel.removeAllListeners();
1618
1749
  }
1619
1750
  this.#walletEntries.clear();
1751
+ for (const entry of this.#userBidsEntries.values()) {
1752
+ entry.channel.removeAllListeners();
1753
+ }
1754
+ this.#userBidsEntries.clear();
1620
1755
  this.#transport.close();
1621
1756
  }
1622
1757
  };
@@ -1848,20 +1983,9 @@ function classifyNetworkLevel(rtt) {
1848
1983
  // src/network/RttSmoother.ts
1849
1984
  var EMA_ALPHA = 0.25;
1850
1985
  var OUTLIER_MULTIPLIER = 3;
1851
- var DEBOUNCE_DEGRADE = 2;
1852
- var DEBOUNCE_IMPROVE = 5;
1853
- var LEVEL_RANK = {
1854
- good: 0,
1855
- fair: 1,
1856
- poor: 2,
1857
- offline: 3
1858
- };
1859
1986
  var RttSmoother = class {
1860
1987
  ema = 0;
1861
1988
  level = "good";
1862
- committed = "good";
1863
- candidate = "good";
1864
- candidateCount = 0;
1865
1989
  overrideOffline = false;
1866
1990
  consecutiveOutliers = 0;
1867
1991
  add(rtt) {
@@ -1873,23 +1997,7 @@ var RttSmoother = class {
1873
1997
  }
1874
1998
  const coldStart = this.ema === 0;
1875
1999
  this.ema = coldStart ? rtt : EMA_ALPHA * rtt + (1 - EMA_ALPHA) * this.ema;
1876
- const next = classifyNetworkLevel(this.ema);
1877
- if (coldStart) {
1878
- this.committed = next;
1879
- this.candidate = next;
1880
- this.candidateCount = 1;
1881
- } else {
1882
- if (next === this.candidate) {
1883
- this.candidateCount += 1;
1884
- } else {
1885
- this.candidate = next;
1886
- this.candidateCount = 1;
1887
- }
1888
- if (this.candidateCount >= this.requiredSamplesFor(next)) {
1889
- this.committed = next;
1890
- }
1891
- }
1892
- this.level = this.overrideOffline ? "offline" : this.committed;
2000
+ this.level = this.overrideOffline ? "offline" : classifyNetworkLevel(this.ema);
1893
2001
  }
1894
2002
  forceOffline() {
1895
2003
  this.overrideOffline = true;
@@ -1897,20 +2005,14 @@ var RttSmoother = class {
1897
2005
  }
1898
2006
  releaseOffline() {
1899
2007
  this.overrideOffline = false;
1900
- this.level = this.committed;
2008
+ this.level = classifyNetworkLevel(this.ema);
1901
2009
  }
1902
2010
  reset() {
1903
2011
  this.ema = 0;
1904
- this.committed = "good";
1905
- this.candidate = "good";
1906
- this.candidateCount = 0;
1907
2012
  this.consecutiveOutliers = 0;
1908
2013
  this.overrideOffline = false;
1909
2014
  this.level = "good";
1910
2015
  }
1911
- requiredSamplesFor(next) {
1912
- return LEVEL_RANK[next] > LEVEL_RANK[this.committed] ? DEBOUNCE_DEGRADE : DEBOUNCE_IMPROVE;
1913
- }
1914
2016
  };
1915
2017
 
1916
2018
  // src/network/NetworkQualityMonitor.ts
@@ -1937,7 +2039,6 @@ var NetworkQualityMonitor = class {
1937
2039
  smoother = new RttSmoother();
1938
2040
  mqttConnected = false;
1939
2041
  mqttDisconnectedAt = null;
1940
- committedNetwork = "good";
1941
2042
  committedBackend = "ok";
1942
2043
  // The last snapshot we emitted, used to decide whether the next recompute is
1943
2044
  // worth emitting. Seeded with the initial default so the first real change
@@ -2015,7 +2116,6 @@ var NetworkQualityMonitor = class {
2015
2116
  this.smoother.reset();
2016
2117
  this.mqttConnected = false;
2017
2118
  this.mqttDisconnectedAt = null;
2018
- this.committedNetwork = "good";
2019
2119
  this.committedBackend = "ok";
2020
2120
  this.emitIfChanged(this.snapshot());
2021
2121
  }
@@ -2079,26 +2179,12 @@ var NetworkQualityMonitor = class {
2079
2179
  const useConnection = realSamples.length < COLD_START_REAL_SAMPLE_THRESHOLD;
2080
2180
  const effectiveSamples = useConnection ? allSamples : realSamples;
2081
2181
  const httpSamples = effectiveSamples.filter((s) => isHttpSource(s.source));
2082
- const backend = classifyBackendHealth(httpSamples);
2083
- const metrics = this.deriveMetricsFrom(effectiveSamples);
2084
- const candidate = classifyNetworkLevel(metrics.emaForLevel);
2085
- let nextNetwork = this.smoother.level;
2086
- if (effectiveSamples.length === 0) {
2087
- nextNetwork = this.committedNetwork;
2088
- } else if (this.smoother.ema > 0) {
2089
- nextNetwork = this.smoother.level;
2090
- } else {
2091
- nextNetwork = candidate;
2092
- }
2182
+ this.committedBackend = classifyBackendHealth(httpSamples);
2093
2183
  if (this.isHardOffline()) {
2094
2184
  this.smoother.forceOffline();
2095
- nextNetwork = "offline";
2096
2185
  } else if (this.smoother.level === "offline") {
2097
2186
  this.smoother.releaseOffline();
2098
- nextNetwork = this.smoother.level;
2099
2187
  }
2100
- this.committedNetwork = nextNetwork;
2101
- this.committedBackend = backend;
2102
2188
  this.emitIfChanged(this.snapshot());
2103
2189
  }
2104
2190
  // Emits network:change when any *displayed* field of the snapshot moves. This
@@ -2159,10 +2245,12 @@ var NetworkQualityMonitor = class {
2159
2245
  snapshot() {
2160
2246
  const samples = this.allHttpAndMqttSamples();
2161
2247
  const metrics = this.deriveMetricsFrom(samples);
2248
+ const rtt = Math.round(metrics.emaForLevel);
2249
+ const network = this.smoother.level === "offline" ? "offline" : classifyNetworkLevel(rtt);
2162
2250
  return {
2163
- network: this.committedNetwork,
2251
+ network,
2164
2252
  backend: this.committedBackend,
2165
- rtt: Math.round(metrics.emaForLevel),
2253
+ rtt,
2166
2254
  jitter: Math.round(metrics.jitter),
2167
2255
  lossRate: metrics.lossRate,
2168
2256
  mqttConnected: this.mqttConnected,
@@ -2983,5 +3071,6 @@ function calculateProbWin_v2(time1, time2, _price1, price2, creationTime, curren
2983
3071
  isTerminal,
2984
3072
  isWin,
2985
3073
  normalCDF,
2986
- normalPDF
3074
+ normalPDF,
3075
+ pairIdFromBidResultTopic
2987
3076
  });
package/dist/index.d.mts CHANGED
@@ -694,6 +694,7 @@ type MqttLifecycleHook = (event: MqttLifecycleEvent) => void;
694
694
  type MqttCandleHandler = (topic: string, payload: MqttWirePayload) => void;
695
695
  type MqttAgencyPairStatsHandler = (topic: string, payload: MqttWirePayload) => void;
696
696
  type MqttWalletMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
+ type MqttUserBidsMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
698
  interface MqttTransport {
698
699
  /**
699
700
  * Subscribe to a game's MQTT topics (config, ideal_config, bid_result, balance)
@@ -737,6 +738,16 @@ interface MqttTransport {
737
738
  subscribeWallet(userId: string, onMessage: MqttWalletMessageHandler, onError: MqttErrorHandler): void;
738
739
  /** Tear down the wallet subscription for a `userId`. No-op if not subscribed. */
739
740
  unsubscribeWallet(userId: string): void;
741
+ /**
742
+ * Subscribe to the user's bid-result stream across ALL pairs via the wildcard
743
+ * topic `game/+/user/{userId}/bid_result`. Independent of any game
744
+ * subscription — NOT torn down by `unsubscribeAll`, survives pair switches.
745
+ * Idempotent per `userId`. The handler receives the CONCRETE delivered topic
746
+ * (with the real gameId), so the caller can parse the pairId from it.
747
+ */
748
+ subscribeUserBids(userId: string, onMessage: MqttUserBidsMessageHandler, onError: MqttErrorHandler): void;
749
+ /** Tear down the cross-pair user-bids subscription for a `userId`. No-op if absent. */
750
+ unsubscribeUserBids(userId: string): void;
740
751
  close(): void;
741
752
  }
742
753
 
@@ -791,9 +802,19 @@ interface MqttBidWonEvent {
791
802
  payout: string;
792
803
  userId: string;
793
804
  balance?: string;
805
+ /**
806
+ * Pair the won bid belongs to, e.g. "grid-ETH-USD". Derived from the result
807
+ * topic (`game/{agencyId}:{pairId}/user/.../bid_result`) since the won payload
808
+ * carries no pair. Lets consumers attribute a result to its pair — required by
809
+ * the cross-pair subscription (a win for a non-active pair). Undefined only
810
+ * when the topic shape is unrecognised.
811
+ */
812
+ pairId?: string;
794
813
  }
795
814
  interface MqttBidLostEvent {
796
815
  bidId: string;
816
+ /** Pair the lost bid belongs to (see {@link MqttBidWonEvent.pairId}). */
817
+ pairId?: string;
797
818
  }
798
819
  interface MqttBidCancelledEvent {
799
820
  bidId: string;
@@ -878,6 +899,24 @@ declare class GameChannel extends EventEmitter<GameChannelEvents> {
878
899
  constructor(gameId: string);
879
900
  }
880
901
 
902
+ interface UserBidsChannelEvents {
903
+ bidWon: [MqttBidWonEvent];
904
+ bidLost: [MqttBidLostEvent];
905
+ error: [Error];
906
+ }
907
+ /**
908
+ * User-scoped, cross-pair bid-result channel. Emits `bidWon` / `bidLost` for the
909
+ * user's settlements on ANY pair, via the wildcard subscription
910
+ * `game/+/user/{userId}/bid_result`. Independent of which pair is currently
911
+ * displayed — keyed by `userId`, not `gameId`, so it survives pair switches
912
+ * (mirror of {@link WalletChannel}). Each emitted event carries the bid's
913
+ * `pairId` (parsed from the result topic) so consumers can attribute it.
914
+ */
915
+ declare class UserBidsChannel extends EventEmitter<UserBidsChannelEvents> {
916
+ readonly userId: string;
917
+ constructor(userId: string);
918
+ }
919
+
881
920
  interface WalletChannelEvents {
882
921
  walletBalanceUpdate: [MqttWalletBalanceEvent];
883
922
  error: [Error];
@@ -946,6 +985,24 @@ declare class RealtimeModule extends EventEmitter<RealtimeModuleEvents> {
946
985
  * No-op if there is no active wallet subscription for `userId`.
947
986
  */
948
987
  unsubscribeWallet(userId: string): void;
988
+ /**
989
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
990
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
991
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
992
+ * on — independent of which pair is currently displayed (mirrors
993
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
994
+ * from the concrete result topic, so consumers can attribute and label it.
995
+ *
996
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
997
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
998
+ * channel drives state + sound, this cross-pair channel drives notifications.
999
+ */
1000
+ subscribeUserBids(userId: string): UserBidsChannel;
1001
+ /**
1002
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1003
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1004
+ */
1005
+ unsubscribeUserBids(userId: string): void;
949
1006
  /**
950
1007
  * Subscribe to public market candle data for a pair, keyed by pairId
951
1008
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1042,7 +1099,6 @@ declare class NetworkQualityMonitor {
1042
1099
  private readonly smoother;
1043
1100
  private mqttConnected;
1044
1101
  private mqttDisconnectedAt;
1045
- private committedNetwork;
1046
1102
  private committedBackend;
1047
1103
  private lastEmitted;
1048
1104
  private tickHandle;
@@ -1175,6 +1231,20 @@ declare class TaphubSlippageError extends TaphubValidationError {
1175
1231
  });
1176
1232
  }
1177
1233
 
1234
+ /**
1235
+ * Extract the slash-free pairId from a bid-result topic.
1236
+ *
1237
+ * Accepts the concrete topic delivered by the broker, e.g.
1238
+ * `game/acme-corp:grid-ETH-USD/user/{uid}/bid_result` → `grid-ETH-USD`.
1239
+ *
1240
+ * Returns `undefined` when the topic does not match the expected shape (so the
1241
+ * caller can fall back gracefully rather than throw). The gameId segment is the
1242
+ * composite `{agencyId}:{pairId}`; the pairId is everything after the FIRST `:`
1243
+ * (pairId itself is slash-free and colon-free by the bid-260602-v2 id rules, but
1244
+ * we split on the first `:` to be safe if an agencyId ever contained one).
1245
+ */
1246
+ declare function pairIdFromBidResultTopic(topic: string): string | undefined;
1247
+
1178
1248
  declare function errorFunction(x: number): number;
1179
1249
  declare function normalCDF(x: number): number;
1180
1250
  declare function normalPDF(x: number): number;
@@ -1182,4 +1252,4 @@ declare function adaptiveSimpson(f: (x: number) => number, a: number, b: number,
1182
1252
  declare function calculateProbWin(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1183
1253
  declare function calculateProbWin_v2(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1184
1254
 
1185
- export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF };
1255
+ export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserBidsChannel, type UserBidsChannelEvents, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF, pairIdFromBidResultTopic };
package/dist/index.d.ts CHANGED
@@ -694,6 +694,7 @@ type MqttLifecycleHook = (event: MqttLifecycleEvent) => void;
694
694
  type MqttCandleHandler = (topic: string, payload: MqttWirePayload) => void;
695
695
  type MqttAgencyPairStatsHandler = (topic: string, payload: MqttWirePayload) => void;
696
696
  type MqttWalletMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
+ type MqttUserBidsMessageHandler = (topic: string, payload: MqttWirePayload) => void;
697
698
  interface MqttTransport {
698
699
  /**
699
700
  * Subscribe to a game's MQTT topics (config, ideal_config, bid_result, balance)
@@ -737,6 +738,16 @@ interface MqttTransport {
737
738
  subscribeWallet(userId: string, onMessage: MqttWalletMessageHandler, onError: MqttErrorHandler): void;
738
739
  /** Tear down the wallet subscription for a `userId`. No-op if not subscribed. */
739
740
  unsubscribeWallet(userId: string): void;
741
+ /**
742
+ * Subscribe to the user's bid-result stream across ALL pairs via the wildcard
743
+ * topic `game/+/user/{userId}/bid_result`. Independent of any game
744
+ * subscription — NOT torn down by `unsubscribeAll`, survives pair switches.
745
+ * Idempotent per `userId`. The handler receives the CONCRETE delivered topic
746
+ * (with the real gameId), so the caller can parse the pairId from it.
747
+ */
748
+ subscribeUserBids(userId: string, onMessage: MqttUserBidsMessageHandler, onError: MqttErrorHandler): void;
749
+ /** Tear down the cross-pair user-bids subscription for a `userId`. No-op if absent. */
750
+ unsubscribeUserBids(userId: string): void;
740
751
  close(): void;
741
752
  }
742
753
 
@@ -791,9 +802,19 @@ interface MqttBidWonEvent {
791
802
  payout: string;
792
803
  userId: string;
793
804
  balance?: string;
805
+ /**
806
+ * Pair the won bid belongs to, e.g. "grid-ETH-USD". Derived from the result
807
+ * topic (`game/{agencyId}:{pairId}/user/.../bid_result`) since the won payload
808
+ * carries no pair. Lets consumers attribute a result to its pair — required by
809
+ * the cross-pair subscription (a win for a non-active pair). Undefined only
810
+ * when the topic shape is unrecognised.
811
+ */
812
+ pairId?: string;
794
813
  }
795
814
  interface MqttBidLostEvent {
796
815
  bidId: string;
816
+ /** Pair the lost bid belongs to (see {@link MqttBidWonEvent.pairId}). */
817
+ pairId?: string;
797
818
  }
798
819
  interface MqttBidCancelledEvent {
799
820
  bidId: string;
@@ -878,6 +899,24 @@ declare class GameChannel extends EventEmitter<GameChannelEvents> {
878
899
  constructor(gameId: string);
879
900
  }
880
901
 
902
+ interface UserBidsChannelEvents {
903
+ bidWon: [MqttBidWonEvent];
904
+ bidLost: [MqttBidLostEvent];
905
+ error: [Error];
906
+ }
907
+ /**
908
+ * User-scoped, cross-pair bid-result channel. Emits `bidWon` / `bidLost` for the
909
+ * user's settlements on ANY pair, via the wildcard subscription
910
+ * `game/+/user/{userId}/bid_result`. Independent of which pair is currently
911
+ * displayed — keyed by `userId`, not `gameId`, so it survives pair switches
912
+ * (mirror of {@link WalletChannel}). Each emitted event carries the bid's
913
+ * `pairId` (parsed from the result topic) so consumers can attribute it.
914
+ */
915
+ declare class UserBidsChannel extends EventEmitter<UserBidsChannelEvents> {
916
+ readonly userId: string;
917
+ constructor(userId: string);
918
+ }
919
+
881
920
  interface WalletChannelEvents {
882
921
  walletBalanceUpdate: [MqttWalletBalanceEvent];
883
922
  error: [Error];
@@ -946,6 +985,24 @@ declare class RealtimeModule extends EventEmitter<RealtimeModuleEvents> {
946
985
  * No-op if there is no active wallet subscription for `userId`.
947
986
  */
948
987
  unsubscribeWallet(userId: string): void;
988
+ /**
989
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
990
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
991
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
992
+ * on — independent of which pair is currently displayed (mirrors
993
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
994
+ * from the concrete result topic, so consumers can attribute and label it.
995
+ *
996
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
997
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
998
+ * channel drives state + sound, this cross-pair channel drives notifications.
999
+ */
1000
+ subscribeUserBids(userId: string): UserBidsChannel;
1001
+ /**
1002
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1003
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1004
+ */
1005
+ unsubscribeUserBids(userId: string): void;
949
1006
  /**
950
1007
  * Subscribe to public market candle data for a pair, keyed by pairId
951
1008
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1042,7 +1099,6 @@ declare class NetworkQualityMonitor {
1042
1099
  private readonly smoother;
1043
1100
  private mqttConnected;
1044
1101
  private mqttDisconnectedAt;
1045
- private committedNetwork;
1046
1102
  private committedBackend;
1047
1103
  private lastEmitted;
1048
1104
  private tickHandle;
@@ -1175,6 +1231,20 @@ declare class TaphubSlippageError extends TaphubValidationError {
1175
1231
  });
1176
1232
  }
1177
1233
 
1234
+ /**
1235
+ * Extract the slash-free pairId from a bid-result topic.
1236
+ *
1237
+ * Accepts the concrete topic delivered by the broker, e.g.
1238
+ * `game/acme-corp:grid-ETH-USD/user/{uid}/bid_result` → `grid-ETH-USD`.
1239
+ *
1240
+ * Returns `undefined` when the topic does not match the expected shape (so the
1241
+ * caller can fall back gracefully rather than throw). The gameId segment is the
1242
+ * composite `{agencyId}:{pairId}`; the pairId is everything after the FIRST `:`
1243
+ * (pairId itself is slash-free and colon-free by the bid-260602-v2 id rules, but
1244
+ * we split on the first `:` to be safe if an agencyId ever contained one).
1245
+ */
1246
+ declare function pairIdFromBidResultTopic(topic: string): string | undefined;
1247
+
1178
1248
  declare function errorFunction(x: number): number;
1179
1249
  declare function normalCDF(x: number): number;
1180
1250
  declare function normalPDF(x: number): number;
@@ -1182,4 +1252,4 @@ declare function adaptiveSimpson(f: (x: number) => number, a: number, b: number,
1182
1252
  declare function calculateProbWin(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1183
1253
  declare function calculateProbWin_v2(time1: number, time2: number, _price1: number, price2: number, creationTime: number, currentPrice: number, volatility: number): number;
1184
1254
 
1185
- export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF };
1255
+ export { type AgencyPairFilter, AgencyPairModule, type AgencyPairStats, type AgencyPairStatus, AuthModule, type BackendHealth, type Bid, BidModule, type BidStatus, type CancelBidResult, type Candle, type Constraints, type Currency, GameChannel, type GameConfig, type GridConfig, type LeaderboardEntry, LeaderboardModule, type LeaderboardPeriod, type LeaderboardSortBy, LocaleModule, type LocaleRefreshResult, type LocaleResponse, type LocaleTranslations, type LoginResult, type Me, type MqttAcceptedBid, type MqttAgencyPairStatsEvent, type MqttAuthConfig, type MqttBalanceEvent, type MqttBidAcceptedEvent, type MqttBidLostEvent, type MqttBidWonEvent, type MqttCandleEvent, type MqttConfigEvent, type MqttIdealConfigEvent, type MqttWalletBalanceEvent, type MyRank, type NetworkLevel, type NetworkQuality, NetworkQualityMonitor, type Pair, type PairInfo, PairModule, type PlaceBidInput, type RankBoard, type RankEntry, type RankPeriod, type RankSort, RealtimeModule, type Sample, type SampleReason, type SignalSource, type SortDir, TaphubAuthError, TaphubClient, type TaphubClientConfig, TaphubError, TaphubEventBus, type TaphubEventMap, TaphubNetworkError, TaphubServerError, TaphubSlippageError, TaphubStorage, type TaphubStorageAdapter, TaphubValidationError, type User, UserBidsChannel, type UserBidsChannelEvents, UserModule, type UserPnL, type UserPnLPeriod, type Wallet as UserWallet, type Wallet$1 as Wallet, type WalletBalanceReason, WalletChannel, adaptiveSimpson, autoDetectStorage, calculateProbWin, calculateProbWin_v2, errorFunction, isCancelled, isLoss, isPending, isTerminal, isWin, normalCDF, normalPDF, pairIdFromBidResultTopic };
package/dist/index.js CHANGED
@@ -978,6 +978,19 @@ function walletBalanceTopic(userId) {
978
978
  function agencyPairStatsTopic(aid, pairId) {
979
979
  return `public/agency/${aid}/pair/${pairId}/stats`;
980
980
  }
981
+ function userBidsWildcardTopic(userId) {
982
+ return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
983
+ }
984
+ function topicMatchesWildcard(pattern, topic) {
985
+ const pp = pattern.split("/");
986
+ const tp = topic.split("/");
987
+ if (pp.length !== tp.length) return false;
988
+ for (let i = 0; i < pp.length; i++) {
989
+ if (pp[i] === "+") continue;
990
+ if (pp[i] !== tp[i]) return false;
991
+ }
992
+ return true;
993
+ }
981
994
  function topicFor(gameId, suffix, userId) {
982
995
  if (USER_SCOPED_SUFFIXES.includes(suffix)) {
983
996
  return `${TOPIC_PREFIX}/${gameId}/user/${userId}/${suffix}`;
@@ -996,6 +1009,7 @@ function createMqttTransport(endpoint, opts = {}) {
996
1009
  const candleSubscriptions = /* @__PURE__ */ new Map();
997
1010
  const statsSubscriptions = /* @__PURE__ */ new Map();
998
1011
  const walletSubscriptions = /* @__PURE__ */ new Map();
1012
+ const userBidsSubscriptions = /* @__PURE__ */ new Map();
999
1013
  const { onLifecycle, auth } = opts;
1000
1014
  let connectStartedAt = 0;
1001
1015
  function fireLifecycle(event) {
@@ -1046,6 +1060,9 @@ function createMqttTransport(endpoint, opts = {}) {
1046
1060
  for (const entry of walletSubscriptions.values()) {
1047
1061
  c.subscribe(entry.topic, { qos: 1 });
1048
1062
  }
1063
+ for (const entry of userBidsSubscriptions.values()) {
1064
+ c.subscribe(entry.pattern, { qos: 1 });
1065
+ }
1049
1066
  fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
1050
1067
  });
1051
1068
  client.on("reconnect", () => {
@@ -1094,6 +1111,19 @@ function createMqttTransport(endpoint, opts = {}) {
1094
1111
  walletSub.onMessage(receivedTopic, payload2);
1095
1112
  return;
1096
1113
  }
1114
+ let userBidsPayload;
1115
+ for (const sub of userBidsSubscriptions.values()) {
1116
+ if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
1117
+ if (userBidsPayload === void 0) {
1118
+ try {
1119
+ userBidsPayload = JSON.parse(message.toString());
1120
+ } catch {
1121
+ userBidsPayload = null;
1122
+ sub.onError(new Error(`Invalid JSON on topic ${receivedTopic}`));
1123
+ }
1124
+ }
1125
+ if (userBidsPayload != null) sub.onMessage(receivedTopic, userBidsPayload);
1126
+ }
1097
1127
  let matched;
1098
1128
  for (const sub of subscriptions.values()) {
1099
1129
  if (sub.topics.includes(receivedTopic)) {
@@ -1181,6 +1211,19 @@ function createMqttTransport(endpoint, opts = {}) {
1181
1211
  if (client) client.unsubscribe(entry.topic);
1182
1212
  walletSubscriptions.delete(userId);
1183
1213
  },
1214
+ subscribeUserBids(userId, onMessage, onError) {
1215
+ if (userBidsSubscriptions.has(userId)) return;
1216
+ const pattern = userBidsWildcardTopic(userId);
1217
+ const mqttClient = ensureConnected();
1218
+ userBidsSubscriptions.set(userId, { pattern, onMessage, onError });
1219
+ mqttClient.subscribe(pattern, { qos: 1 });
1220
+ },
1221
+ unsubscribeUserBids(userId) {
1222
+ const entry = userBidsSubscriptions.get(userId);
1223
+ if (!entry) return;
1224
+ if (client) client.unsubscribe(entry.pattern);
1225
+ userBidsSubscriptions.delete(userId);
1226
+ },
1184
1227
  unsubscribeAll(gameId, userId) {
1185
1228
  const matches = entriesForGame(gameId);
1186
1229
  if (matches.length === 0) return;
@@ -1221,11 +1264,15 @@ function createMqttTransport(endpoint, opts = {}) {
1221
1264
  for (const entry of walletSubscriptions.values()) {
1222
1265
  client.unsubscribe(entry.topic);
1223
1266
  }
1267
+ for (const entry of userBidsSubscriptions.values()) {
1268
+ client.unsubscribe(entry.pattern);
1269
+ }
1224
1270
  }
1225
1271
  subscriptions.clear();
1226
1272
  candleSubscriptions.clear();
1227
1273
  statsSubscriptions.clear();
1228
1274
  walletSubscriptions.clear();
1275
+ userBidsSubscriptions.clear();
1229
1276
  if (client) {
1230
1277
  client.end(true);
1231
1278
  client = null;
@@ -1235,7 +1282,7 @@ function createMqttTransport(endpoint, opts = {}) {
1235
1282
  }
1236
1283
 
1237
1284
  // src/modules/realtime/index.ts
1238
- import EventEmitter3 from "eventemitter3";
1285
+ import EventEmitter4 from "eventemitter3";
1239
1286
 
1240
1287
  // src/modules/realtime/GameChannel.ts
1241
1288
  import EventEmitter from "eventemitter3";
@@ -1247,9 +1294,19 @@ var GameChannel = class extends EventEmitter {
1247
1294
  }
1248
1295
  };
1249
1296
 
1250
- // src/modules/realtime/WalletChannel.ts
1297
+ // src/modules/realtime/UserBidsChannel.ts
1251
1298
  import EventEmitter2 from "eventemitter3";
1252
- var WalletChannel = class extends EventEmitter2 {
1299
+ var UserBidsChannel = class extends EventEmitter2 {
1300
+ userId;
1301
+ constructor(userId) {
1302
+ super();
1303
+ this.userId = userId;
1304
+ }
1305
+ };
1306
+
1307
+ // src/modules/realtime/WalletChannel.ts
1308
+ import EventEmitter3 from "eventemitter3";
1309
+ var WalletChannel = class extends EventEmitter3 {
1253
1310
  userId;
1254
1311
  constructor(userId) {
1255
1312
  super();
@@ -1257,6 +1314,19 @@ var WalletChannel = class extends EventEmitter2 {
1257
1314
  }
1258
1315
  };
1259
1316
 
1317
+ // src/modules/realtime/topic.ts
1318
+ var BID_RESULT_SUFFIX = "bid_result";
1319
+ function pairIdFromBidResultTopic(topic) {
1320
+ if (!topic.endsWith(`/${BID_RESULT_SUFFIX}`)) return void 0;
1321
+ const parts = topic.split("/");
1322
+ if (parts.length !== 5 || parts[0] !== "game" || parts[2] !== "user") return void 0;
1323
+ const gameId = parts[1];
1324
+ const sep = gameId.indexOf(":");
1325
+ if (sep < 0) return void 0;
1326
+ const pairId = gameId.slice(sep + 1);
1327
+ return pairId === "" ? void 0 : pairId;
1328
+ }
1329
+
1260
1330
  // src/modules/realtime/index.ts
1261
1331
  var TOPIC_SUFFIX_CANDLE = "candle";
1262
1332
  var TOPIC_SUFFIX_BID_RESULT = "bid_result";
@@ -1279,7 +1349,7 @@ function mapWireCandle(raw) {
1279
1349
  if (c.coefMults !== void 0) result.coefMults = c.coefMults;
1280
1350
  return result;
1281
1351
  }
1282
- function mapWireBidResult(raw) {
1352
+ function mapWireBidResult(raw, topicPairId) {
1283
1353
  const p = raw;
1284
1354
  switch (p.type) {
1285
1355
  case "accepted": {
@@ -1296,13 +1366,13 @@ function mapWireBidResult(raw) {
1296
1366
  userId: p.user_id
1297
1367
  };
1298
1368
  if (typeof p.balance === "string") data.balance = p.balance;
1369
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1299
1370
  return { event: "bidWon", data };
1300
1371
  }
1301
1372
  case "lost": {
1302
- return {
1303
- event: "bidLost",
1304
- data: { bidId: p.bidId }
1305
- };
1373
+ const data = { bidId: p.bidId };
1374
+ if (topicPairId !== void 0) data.pairId = topicPairId;
1375
+ return { event: "bidLost", data };
1306
1376
  }
1307
1377
  default:
1308
1378
  console.warn(`Unknown bid_result type "${String(p.type)}"`);
@@ -1350,7 +1420,7 @@ function mapWireToEvent(topic, payload) {
1350
1420
  return { event: "candle", data: mapWireCandle(payload) };
1351
1421
  }
1352
1422
  if (topic.endsWith(`/${TOPIC_SUFFIX_BID_RESULT}`)) {
1353
- return mapWireBidResult(payload);
1423
+ return mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1354
1424
  }
1355
1425
  if (topic.endsWith(`/${TOPIC_SUFFIX_BALANCE_UPDATE}`)) {
1356
1426
  return { event: "balanceUpdate", data: mapWireBalanceUpdate(payload) };
@@ -1366,11 +1436,13 @@ function mapWireToEvent(topic, payload) {
1366
1436
  function normaliseUserId2(userId) {
1367
1437
  return userId && userId !== "" ? userId : null;
1368
1438
  }
1369
- var RealtimeModule = class extends EventEmitter3 {
1439
+ var RealtimeModule = class extends EventEmitter4 {
1370
1440
  #transport;
1371
1441
  #entries = /* @__PURE__ */ new Map();
1372
1442
  #walletEntries = /* @__PURE__ */ new Map();
1373
1443
  // keyed by userId
1444
+ #userBidsEntries = /* @__PURE__ */ new Map();
1445
+ // keyed by userId
1374
1446
  #agencyId;
1375
1447
  constructor(mqttEndpointOrOptions) {
1376
1448
  super();
@@ -1501,6 +1573,64 @@ var RealtimeModule = class extends EventEmitter3 {
1501
1573
  entry.channel.removeAllListeners();
1502
1574
  this.#transport.unsubscribeWallet(cleanUserId);
1503
1575
  }
1576
+ /**
1577
+ * Subscribe to the user's bid-result stream across ALL pairs, via the wildcard
1578
+ * topic `game/+/user/{userId}/bid_result`. Returns a `UserBidsChannel` that
1579
+ * emits `bidWon` / `bidLost` for settlements on any pair the user holds a bid
1580
+ * on — independent of which pair is currently displayed (mirrors
1581
+ * `subscribeWallet`). Each emitted event carries the bid's `pairId`, parsed
1582
+ * from the concrete result topic, so consumers can attribute and label it.
1583
+ *
1584
+ * Reference-counted by `userId`. The active pair's bid_result is ALSO delivered
1585
+ * on the per-pair `subscribe` channel — that is intentional: the per-pair
1586
+ * channel drives state + sound, this cross-pair channel drives notifications.
1587
+ */
1588
+ subscribeUserBids(userId) {
1589
+ const cleanUserId = normaliseUserId2(userId);
1590
+ if (!cleanUserId) {
1591
+ throw new TaphubError("userId is required to subscribe to user bids", {
1592
+ code: "UserIdRequired"
1593
+ });
1594
+ }
1595
+ if (/[/+#]/.test(cleanUserId)) {
1596
+ throw new TaphubError("userId must not contain MQTT topic separators (/ + #)", {
1597
+ code: "InvalidUserId"
1598
+ });
1599
+ }
1600
+ const existing = this.#userBidsEntries.get(cleanUserId);
1601
+ if (existing) {
1602
+ existing.refcount += 1;
1603
+ return existing.channel;
1604
+ }
1605
+ const channel = new UserBidsChannel(cleanUserId);
1606
+ const onMessage = (topic, payload) => {
1607
+ const mapped = mapWireBidResult(payload, pairIdFromBidResultTopic(topic));
1608
+ if (!mapped) return;
1609
+ if (mapped.event === "bidWon") channel.emit("bidWon", mapped.data);
1610
+ else if (mapped.event === "bidLost") channel.emit("bidLost", mapped.data);
1611
+ };
1612
+ const onError = (err) => {
1613
+ channel.emit("error", err);
1614
+ };
1615
+ this.#userBidsEntries.set(cleanUserId, { userId: cleanUserId, channel, refcount: 1 });
1616
+ this.#transport.subscribeUserBids(cleanUserId, onMessage, onError);
1617
+ return channel;
1618
+ }
1619
+ /**
1620
+ * Decrement the cross-pair user-bids subscription refcount for `userId`. Tears
1621
+ * down the wildcard topic and removes the channel only at zero. No-op if absent.
1622
+ */
1623
+ unsubscribeUserBids(userId) {
1624
+ const cleanUserId = normaliseUserId2(userId);
1625
+ if (!cleanUserId) return;
1626
+ const entry = this.#userBidsEntries.get(cleanUserId);
1627
+ if (!entry) return;
1628
+ entry.refcount -= 1;
1629
+ if (entry.refcount > 0) return;
1630
+ this.#userBidsEntries.delete(cleanUserId);
1631
+ entry.channel.removeAllListeners();
1632
+ this.#transport.unsubscribeUserBids(cleanUserId);
1633
+ }
1504
1634
  /**
1505
1635
  * Subscribe to public market candle data for a pair, keyed by pairId
1506
1636
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1552,6 +1682,10 @@ var RealtimeModule = class extends EventEmitter3 {
1552
1682
  entry.channel.removeAllListeners();
1553
1683
  }
1554
1684
  this.#walletEntries.clear();
1685
+ for (const entry of this.#userBidsEntries.values()) {
1686
+ entry.channel.removeAllListeners();
1687
+ }
1688
+ this.#userBidsEntries.clear();
1555
1689
  this.#transport.close();
1556
1690
  }
1557
1691
  };
@@ -1783,20 +1917,9 @@ function classifyNetworkLevel(rtt) {
1783
1917
  // src/network/RttSmoother.ts
1784
1918
  var EMA_ALPHA = 0.25;
1785
1919
  var OUTLIER_MULTIPLIER = 3;
1786
- var DEBOUNCE_DEGRADE = 2;
1787
- var DEBOUNCE_IMPROVE = 5;
1788
- var LEVEL_RANK = {
1789
- good: 0,
1790
- fair: 1,
1791
- poor: 2,
1792
- offline: 3
1793
- };
1794
1920
  var RttSmoother = class {
1795
1921
  ema = 0;
1796
1922
  level = "good";
1797
- committed = "good";
1798
- candidate = "good";
1799
- candidateCount = 0;
1800
1923
  overrideOffline = false;
1801
1924
  consecutiveOutliers = 0;
1802
1925
  add(rtt) {
@@ -1808,23 +1931,7 @@ var RttSmoother = class {
1808
1931
  }
1809
1932
  const coldStart = this.ema === 0;
1810
1933
  this.ema = coldStart ? rtt : EMA_ALPHA * rtt + (1 - EMA_ALPHA) * this.ema;
1811
- const next = classifyNetworkLevel(this.ema);
1812
- if (coldStart) {
1813
- this.committed = next;
1814
- this.candidate = next;
1815
- this.candidateCount = 1;
1816
- } else {
1817
- if (next === this.candidate) {
1818
- this.candidateCount += 1;
1819
- } else {
1820
- this.candidate = next;
1821
- this.candidateCount = 1;
1822
- }
1823
- if (this.candidateCount >= this.requiredSamplesFor(next)) {
1824
- this.committed = next;
1825
- }
1826
- }
1827
- this.level = this.overrideOffline ? "offline" : this.committed;
1934
+ this.level = this.overrideOffline ? "offline" : classifyNetworkLevel(this.ema);
1828
1935
  }
1829
1936
  forceOffline() {
1830
1937
  this.overrideOffline = true;
@@ -1832,20 +1939,14 @@ var RttSmoother = class {
1832
1939
  }
1833
1940
  releaseOffline() {
1834
1941
  this.overrideOffline = false;
1835
- this.level = this.committed;
1942
+ this.level = classifyNetworkLevel(this.ema);
1836
1943
  }
1837
1944
  reset() {
1838
1945
  this.ema = 0;
1839
- this.committed = "good";
1840
- this.candidate = "good";
1841
- this.candidateCount = 0;
1842
1946
  this.consecutiveOutliers = 0;
1843
1947
  this.overrideOffline = false;
1844
1948
  this.level = "good";
1845
1949
  }
1846
- requiredSamplesFor(next) {
1847
- return LEVEL_RANK[next] > LEVEL_RANK[this.committed] ? DEBOUNCE_DEGRADE : DEBOUNCE_IMPROVE;
1848
- }
1849
1950
  };
1850
1951
 
1851
1952
  // src/network/NetworkQualityMonitor.ts
@@ -1872,7 +1973,6 @@ var NetworkQualityMonitor = class {
1872
1973
  smoother = new RttSmoother();
1873
1974
  mqttConnected = false;
1874
1975
  mqttDisconnectedAt = null;
1875
- committedNetwork = "good";
1876
1976
  committedBackend = "ok";
1877
1977
  // The last snapshot we emitted, used to decide whether the next recompute is
1878
1978
  // worth emitting. Seeded with the initial default so the first real change
@@ -1950,7 +2050,6 @@ var NetworkQualityMonitor = class {
1950
2050
  this.smoother.reset();
1951
2051
  this.mqttConnected = false;
1952
2052
  this.mqttDisconnectedAt = null;
1953
- this.committedNetwork = "good";
1954
2053
  this.committedBackend = "ok";
1955
2054
  this.emitIfChanged(this.snapshot());
1956
2055
  }
@@ -2014,26 +2113,12 @@ var NetworkQualityMonitor = class {
2014
2113
  const useConnection = realSamples.length < COLD_START_REAL_SAMPLE_THRESHOLD;
2015
2114
  const effectiveSamples = useConnection ? allSamples : realSamples;
2016
2115
  const httpSamples = effectiveSamples.filter((s) => isHttpSource(s.source));
2017
- const backend = classifyBackendHealth(httpSamples);
2018
- const metrics = this.deriveMetricsFrom(effectiveSamples);
2019
- const candidate = classifyNetworkLevel(metrics.emaForLevel);
2020
- let nextNetwork = this.smoother.level;
2021
- if (effectiveSamples.length === 0) {
2022
- nextNetwork = this.committedNetwork;
2023
- } else if (this.smoother.ema > 0) {
2024
- nextNetwork = this.smoother.level;
2025
- } else {
2026
- nextNetwork = candidate;
2027
- }
2116
+ this.committedBackend = classifyBackendHealth(httpSamples);
2028
2117
  if (this.isHardOffline()) {
2029
2118
  this.smoother.forceOffline();
2030
- nextNetwork = "offline";
2031
2119
  } else if (this.smoother.level === "offline") {
2032
2120
  this.smoother.releaseOffline();
2033
- nextNetwork = this.smoother.level;
2034
2121
  }
2035
- this.committedNetwork = nextNetwork;
2036
- this.committedBackend = backend;
2037
2122
  this.emitIfChanged(this.snapshot());
2038
2123
  }
2039
2124
  // Emits network:change when any *displayed* field of the snapshot moves. This
@@ -2094,10 +2179,12 @@ var NetworkQualityMonitor = class {
2094
2179
  snapshot() {
2095
2180
  const samples = this.allHttpAndMqttSamples();
2096
2181
  const metrics = this.deriveMetricsFrom(samples);
2182
+ const rtt = Math.round(metrics.emaForLevel);
2183
+ const network = this.smoother.level === "offline" ? "offline" : classifyNetworkLevel(rtt);
2097
2184
  return {
2098
- network: this.committedNetwork,
2185
+ network,
2099
2186
  backend: this.committedBackend,
2100
- rtt: Math.round(metrics.emaForLevel),
2187
+ rtt,
2101
2188
  jitter: Math.round(metrics.jitter),
2102
2189
  lossRate: metrics.lossRate,
2103
2190
  mqttConnected: this.mqttConnected,
@@ -2917,5 +3004,6 @@ export {
2917
3004
  isTerminal,
2918
3005
  isWin,
2919
3006
  normalCDF,
2920
- normalPDF
3007
+ normalPDF,
3008
+ pairIdFromBidResultTopic
2921
3009
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taphubhq/sdk-core",
3
- "version": "0.23.4",
3
+ "version": "0.24.1",
4
4
  "description": "Core SDK for building on the TabHub platform",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.cjs",