@stamprally/core 0.12.0 → 0.14.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/README.md CHANGED
@@ -23,6 +23,19 @@ The browser detectors `getCurrentGeoContext`, `readNfcContext`, and `readQrConte
23
23
 
24
24
  `InMemoryStorage`, `LocalStorageAdapter`, and `IndexedDBAdapter` implement `StampStorage`. `updateLocalizedField` updates one locale without dropping existing translations.
25
25
 
26
+ ## v0.13 offline synchronization
27
+
28
+ Configure `OfflineQueue` with `rallyId` and `userId` to persist pending work under
29
+ `stamprally:queue:<rallyId>:<userId-or-anonymous>`. `switchUser` loads the other
30
+ user's queue. Conflict policy `merge` unions stamp records, keeps the latest
31
+ timestamps, and gives `CONSUMED` reward states priority; `server_wins` uses the
32
+ server state unchanged. Sync adapters may return `ACCEPTED`, `REJECTED_PERMANENT`,
33
+ or `RETRYABLE_ERROR`; only the first two remove an operation.
34
+
35
+ `evaluateSpotStatus` derives `UNCLAIMED`, `CLAIMED`, `LOCKED`, or `VERIFYING`
36
+ without mutating state. A spot with incomplete `prerequisites` is `LOCKED` and
37
+ must not be verified by a client or viewer.
38
+
26
39
  ## License
27
40
 
28
41
  MIT
package/dist/index.cjs CHANGED
@@ -41,6 +41,14 @@ function evaluateConditionDetailed(condition2, context) {
41
41
  function evaluateCondition(condition2, context) {
42
42
  return evaluateConditionDetailed(condition2, context).ok;
43
43
  }
44
+ function evaluateSpotStatus(spot2, state, options = {}) {
45
+ if (options.verifying === true) return "VERIFYING";
46
+ if (state.records.some((record) => record.stampId === spot2.id)) return "CLAIMED";
47
+ const acquired = new Set(state.records.map((record) => record.stampId));
48
+ if (spot2.prerequisites?.some((prerequisite) => !acquired.has(prerequisite))) return "LOCKED";
49
+ return "UNCLAIMED";
50
+ }
51
+ var getSpotStatus = evaluateSpotStatus;
44
52
 
45
53
  // src/engine/order.ts
46
54
  function getOrderedSpots(spots) {
@@ -53,7 +61,9 @@ function calculateProgress(state, config) {
53
61
  const acquired = new Set(
54
62
  state.records.map((record) => record.stampId).filter((id2) => ids.has(id2))
55
63
  );
56
- const remaining = config.spots.filter((spot2) => !acquired.has(spot2.id));
64
+ const remaining = config.spots.filter(
65
+ (spot2) => !acquired.has(spot2.id) && (spot2.prerequisites === void 0 || spot2.prerequisites.every((id2) => acquired.has(id2)))
66
+ );
57
67
  return {
58
68
  acquired: acquired.size,
59
69
  total: config.spots.length,
@@ -117,24 +127,53 @@ function mergeRewardStates(serverRewards, localRewards) {
117
127
  merged.set(localReward.rewardId, localReward);
118
128
  continue;
119
129
  }
120
- if (localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED")
121
- merged.set(localReward.rewardId, localReward);
130
+ const winner = localReward.status === "CONSUMED" && serverReward.status !== "CONSUMED" ? localReward : serverReward;
131
+ merged.set(localReward.rewardId, {
132
+ ...winner,
133
+ ...serverReward.unlockedAt === void 0 && localReward.unlockedAt === void 0 ? {} : {
134
+ unlockedAt: serverReward.unlockedAt === void 0 ? localReward.unlockedAt : localReward.unlockedAt === void 0 ? serverReward.unlockedAt : latestTimestamp(serverReward.unlockedAt, localReward.unlockedAt)
135
+ },
136
+ ...serverReward.consumedAt === void 0 && localReward.consumedAt === void 0 ? {} : {
137
+ consumedAt: serverReward.consumedAt === void 0 ? localReward.consumedAt : localReward.consumedAt === void 0 ? serverReward.consumedAt : latestTimestamp(serverReward.consumedAt, localReward.consumedAt)
138
+ },
139
+ ...serverReward.redeemedCount === void 0 && localReward.redeemedCount === void 0 ? {} : {
140
+ redeemedCount: Math.max(
141
+ serverReward.redeemedCount ?? 0,
142
+ localReward.redeemedCount ?? 0
143
+ )
144
+ },
145
+ ...serverReward.userRedemptionCount === void 0 && localReward.userRedemptionCount === void 0 ? {} : {
146
+ userRedemptionCount: Math.max(
147
+ serverReward.userRedemptionCount ?? 0,
148
+ localReward.userRedemptionCount ?? 0
149
+ )
150
+ }
151
+ });
122
152
  }
123
153
  return [...merged.values()];
124
154
  }
125
- function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
126
- if (options.policy === "server_wins") return serverState;
127
- const records = [...serverState.records];
128
- const knownStamps = new Set(records.map((record) => record.stampId));
129
- for (const record of localState.records) {
130
- if (!knownStamps.has(record.stampId)) {
131
- records.push(record);
132
- knownStamps.add(record.stampId);
155
+ function mergeStampRecords(serverRecords, localRecords) {
156
+ const merged = /* @__PURE__ */ new Map();
157
+ for (const record of [...serverRecords, ...localRecords]) {
158
+ const current = merged.get(record.stampId);
159
+ if (current === void 0) {
160
+ merged.set(record.stampId, record);
161
+ continue;
133
162
  }
163
+ const acquiredAt = latestTimestamp(current.acquiredAt, record.acquiredAt);
164
+ merged.set(record.stampId, {
165
+ ...current,
166
+ ...acquiredAt === current.acquiredAt ? {} : { acquiredAt },
167
+ ...current.metadata === void 0 && record.metadata !== void 0 ? { metadata: record.metadata } : {}
168
+ });
134
169
  }
170
+ return [...merged.values()];
171
+ }
172
+ function resolveRallyStateConflict(serverState, localState, options = { policy: "merge" }) {
173
+ if (options.policy === "server_wins") return serverState;
135
174
  return {
136
175
  ...serverState,
137
- records,
176
+ records: mergeStampRecords(serverState.records, localState.records),
138
177
  rewards: mergeRewardStates(serverState.rewards, localState.rewards),
139
178
  updatedAt: latestTimestamp(serverState.updatedAt, localState.updatedAt)
140
179
  };
@@ -1072,7 +1111,10 @@ var StampRallyClient = class {
1072
1111
  initialize() {
1073
1112
  if (this.#state !== null) return Promise.resolve(this.#state);
1074
1113
  if (this.#initialization === null) {
1075
- this.#initialization = this.#storage.load(this.#config.id, this.#userId).then((state) => {
1114
+ this.#initialization = (async () => {
1115
+ await this.#offlineQueue?.setScope(this.#config.id, this.#userId);
1116
+ return this.#storage.load(this.#config.id, this.#userId);
1117
+ })().then((state) => {
1076
1118
  const next = state === null ? emptyState(this.#config, this.#userId, this.#now()) : this.#reconcile(state);
1077
1119
  this.#state = next;
1078
1120
  this.#emit(next);
@@ -1090,6 +1132,7 @@ var StampRallyClient = class {
1090
1132
  this.#userId = newUserId;
1091
1133
  this.#state = null;
1092
1134
  this.#initialization = null;
1135
+ await this.#offlineQueue?.switchUser(newUserId);
1093
1136
  return this.initialize();
1094
1137
  });
1095
1138
  }
@@ -1338,7 +1381,8 @@ var StampRallyClient = class {
1338
1381
  this.#state = next;
1339
1382
  this.#emit(next);
1340
1383
  }
1341
- if ("ok" in event.result && !event.result.ok)
1384
+ if (event.error !== void 0) this.#emitEvent({ type: "error", error: event.error });
1385
+ else if (event.result !== void 0 && "ok" in event.result && !event.result.ok)
1342
1386
  this.#emitEvent({ type: "error", error: event.result.error });
1343
1387
  }
1344
1388
  #now() {
@@ -1466,9 +1510,32 @@ function defaultStorage(databaseName) {
1466
1510
  function operationId(operation) {
1467
1511
  return operation.kind === "checkIn" ? `checkIn:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}` : `claimReward:${operation.request.rallyId}:${operation.request.userId ?? "anonymous"}:${operation.request.idempotencyKey}`;
1468
1512
  }
1513
+ function requestScope(operation) {
1514
+ return {
1515
+ rallyId: operation.request.rallyId,
1516
+ userId: operation.request.userId
1517
+ };
1518
+ }
1519
+ function errorValue(value, fallbackCode) {
1520
+ if (typeof value === "object" && value !== null) {
1521
+ const candidate = value;
1522
+ if (typeof candidate.code === "string" && typeof candidate.message === "string")
1523
+ return { ...candidate, code: candidate.code, message: candidate.message };
1524
+ }
1525
+ if (value instanceof Error) return { code: fallbackCode, message: value.message };
1526
+ if (typeof value === "string") return { code: fallbackCode, message: value };
1527
+ return { code: fallbackCode, message: "Offline operation was rejected." };
1528
+ }
1529
+ var syncLocks = /* @__PURE__ */ new Map();
1530
+ var SYNC_LOCK_TTL_MS = 3e4;
1531
+ function randomId() {
1532
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
1533
+ }
1469
1534
  var OfflineQueue = class {
1470
1535
  #storage;
1471
- #key;
1536
+ #configuredKey;
1537
+ #rallyId;
1538
+ #userId;
1472
1539
  #conflictPolicy;
1473
1540
  #onSyncConflict;
1474
1541
  #operations = [];
@@ -1478,14 +1545,24 @@ var OfflineQueue = class {
1478
1545
  #sender;
1479
1546
  #syncPromise = null;
1480
1547
  #syncResultListener;
1548
+ #synchronizeInstances;
1549
+ #instanceId = randomId();
1550
+ #lockStorage;
1551
+ #storageListener;
1552
+ #channel = null;
1481
1553
  constructor(options = {}) {
1482
1554
  if (options.storage !== void 0) this.#storage = options.storage;
1483
1555
  else if (options.storageLike !== void 0 && options.storageLike !== null)
1484
1556
  this.#storage = new LocalStorageQueueStorage(options.storageLike);
1485
1557
  else this.#storage = defaultStorage(options.databaseName);
1486
- this.#key = options.key ?? "stamprally:offline-queue";
1558
+ this.#configuredKey = options.key;
1559
+ this.#rallyId = options.rallyId;
1560
+ this.#userId = options.userId ?? null;
1487
1561
  this.#conflictPolicy = options.conflictPolicy ?? "server_wins";
1488
1562
  this.#onSyncConflict = options.onSyncConflict;
1563
+ this.#synchronizeInstances = options.synchronizeInstances ?? true;
1564
+ this.#lockStorage = options.storageLike ?? globalThis.localStorage ?? null;
1565
+ if (this.#synchronizeInstances) this.#subscribeToExternalChanges();
1489
1566
  }
1490
1567
  get syncState() {
1491
1568
  return this.#state;
@@ -1502,23 +1579,68 @@ var OfflineQueue = class {
1502
1579
  get conflictPolicy() {
1503
1580
  return this.#conflictPolicy;
1504
1581
  }
1582
+ get storageKey() {
1583
+ return this.#storageKey();
1584
+ }
1585
+ get rallyId() {
1586
+ return this.#rallyId;
1587
+ }
1588
+ get userId() {
1589
+ return this.#userId;
1590
+ }
1505
1591
  setSyncResultListener(listener) {
1506
1592
  this.#syncResultListener = listener;
1507
1593
  }
1508
1594
  async initialize() {
1509
1595
  if (this.#loaded) return;
1510
- this.#operations = [...await this.#storage.load(this.#key)];
1596
+ this.#operations = [...await this.#storage.load(this.#storageKey())];
1511
1597
  this.#loaded = true;
1512
1598
  }
1599
+ /** Releases browser listeners when the queue is no longer used. */
1600
+ dispose() {
1601
+ const windowLike = globalThis.window;
1602
+ if (windowLike !== void 0 && this.#storageListener !== void 0)
1603
+ windowLike.removeEventListener("storage", this.#storageListener);
1604
+ this.#storageListener = void 0;
1605
+ this.#channel?.close();
1606
+ this.#channel = null;
1607
+ this.#releaseSyncLock();
1608
+ }
1609
+ /** Selects a rally/user queue scope and loads its pending operations. */
1610
+ async setScope(rallyId, userId) {
1611
+ if (this.#configuredKey !== void 0) {
1612
+ this.#rallyId = rallyId;
1613
+ this.#userId = userId;
1614
+ return this.initialize();
1615
+ }
1616
+ if (this.#rallyId === rallyId && this.#userId === userId && this.#loaded) return;
1617
+ this.#rallyId = rallyId;
1618
+ this.#userId = userId;
1619
+ this.#operations = [];
1620
+ this.#loaded = false;
1621
+ await this.initialize();
1622
+ }
1623
+ async switchUser(newUserId) {
1624
+ if (this.#rallyId === void 0)
1625
+ throw new Error("OfflineQueue.switchUser requires a rally scope.");
1626
+ await this.setScope(this.#rallyId, newUserId);
1627
+ }
1513
1628
  setSender(sender) {
1514
1629
  this.#sender = sender;
1515
1630
  }
1516
1631
  async enqueue(operation) {
1632
+ if (this.#configuredKey === void 0) {
1633
+ const scope = requestScope(operation);
1634
+ if (this.#rallyId === void 0) await this.setScope(scope.rallyId, scope.userId);
1635
+ if (this.#rallyId !== scope.rallyId || this.#userId !== scope.userId)
1636
+ throw new Error("Offline operation belongs to another rally or user queue.");
1637
+ }
1517
1638
  await this.initialize();
1518
1639
  const id2 = operationId(operation);
1519
1640
  if (this.#operations.some((item) => operationId(item) === id2)) return;
1520
1641
  this.#operations = [...this.#operations, operation];
1521
- await this.#storage.save(this.#key, this.#operations);
1642
+ await this.#storage.save(this.#storageKey(), this.#operations);
1643
+ this.#announceChange();
1522
1644
  }
1523
1645
  async enqueueCheckIn(request) {
1524
1646
  return this.enqueue({ kind: "checkIn", request });
@@ -1529,7 +1651,8 @@ var OfflineQueue = class {
1529
1651
  async clear() {
1530
1652
  await this.initialize();
1531
1653
  this.#operations = [];
1532
- await this.#storage.save(this.#key, this.#operations);
1654
+ await this.#storage.save(this.#storageKey(), this.#operations);
1655
+ this.#announceChange();
1533
1656
  }
1534
1657
  async sync(sender = this.#sender) {
1535
1658
  await this.initialize();
@@ -1547,31 +1670,140 @@ var OfflineQueue = class {
1547
1670
  async #run(sender) {
1548
1671
  this.#state = "syncing";
1549
1672
  this.#error = null;
1673
+ if (!this.#acquireSyncLock()) {
1674
+ await this.#reloadFromStorage();
1675
+ this.#state = "idle";
1676
+ return;
1677
+ }
1550
1678
  try {
1551
1679
  while (this.#operations.length > 0) {
1552
1680
  const operation = this.#operations[0];
1553
1681
  if (operation === void 0) break;
1554
- let result;
1682
+ let rawResult;
1555
1683
  try {
1556
- result = await sender(operation);
1684
+ rawResult = await sender(operation);
1557
1685
  } catch (cause) {
1558
- throw cause instanceof Error ? cause : new Error(String(cause));
1686
+ throw new Error(errorValue(cause, "RETRYABLE_ERROR").message);
1559
1687
  }
1560
- const state = "conflict" in result && result.conflict === true ? await this.resolveConflict(operation, result.localState, result.serverState) : "ok" in result && result.ok ? result.value.state : void 0;
1688
+ const response = this.#normalizeResponse(rawResult);
1689
+ if (response.status === "RETRYABLE_ERROR") {
1690
+ const error2 = errorValue(response.error ?? response.reason, "RETRYABLE_ERROR");
1691
+ await this.#syncResultListener?.({ operation, status: response.status, error: error2 });
1692
+ throw new Error(error2.message);
1693
+ }
1694
+ const result = response.result;
1695
+ const state = response.state ?? (result !== void 0 && "conflict" in result && result.conflict === true ? await this.resolveConflict(operation, result.localState, result.serverState) : result !== void 0 && "ok" in result && result.ok ? result.value.state : void 0);
1696
+ const error = response.status === "REJECTED_PERMANENT" ? errorValue(response.error ?? response.reason, "REJECTED_PERMANENT") : void 0;
1697
+ this.#operations = this.#operations.slice(1);
1698
+ await this.#storage.save(this.#storageKey(), this.#operations);
1699
+ this.#announceChange();
1561
1700
  await this.#syncResultListener?.({
1562
1701
  operation,
1563
- result,
1702
+ ...result === void 0 ? {} : { result },
1703
+ status: response.status,
1704
+ ...error === void 0 ? {} : { error },
1564
1705
  ...state === void 0 ? {} : { state }
1565
1706
  });
1566
- this.#operations = this.#operations.slice(1);
1567
- await this.#storage.save(this.#key, this.#operations);
1568
1707
  }
1569
1708
  this.#state = "idle";
1570
1709
  } catch (cause) {
1571
1710
  this.#state = "error";
1572
1711
  this.#error = cause instanceof Error ? cause : new Error(String(cause));
1573
1712
  throw this.#error;
1713
+ } finally {
1714
+ this.#releaseSyncLock();
1715
+ }
1716
+ }
1717
+ #subscribeToExternalChanges() {
1718
+ const windowLike = globalThis.window;
1719
+ if (windowLike !== void 0) {
1720
+ this.#storageListener = (event) => {
1721
+ if (event.key === this.#storageKey()) void this.#reloadFromStorage();
1722
+ };
1723
+ windowLike.addEventListener("storage", this.#storageListener);
1574
1724
  }
1725
+ const Channel = globalThis.BroadcastChannel;
1726
+ if (Channel !== void 0) {
1727
+ try {
1728
+ this.#channel = new Channel("stamprally:queue-sync");
1729
+ this.#channel.addEventListener("message", (event) => {
1730
+ if (typeof event.data === "object" && event.data !== null && "key" in event.data && event.data.key === this.#storageKey())
1731
+ void this.#reloadFromStorage();
1732
+ });
1733
+ } catch {
1734
+ this.#channel = null;
1735
+ }
1736
+ }
1737
+ }
1738
+ #announceChange() {
1739
+ this.#channel?.postMessage({ key: this.#storageKey(), owner: this.#instanceId });
1740
+ }
1741
+ async #reloadFromStorage() {
1742
+ if (this.#state === "syncing") return;
1743
+ try {
1744
+ this.#operations = [...await this.#storage.load(this.#storageKey())];
1745
+ this.#loaded = true;
1746
+ } catch {
1747
+ }
1748
+ }
1749
+ #lockKey() {
1750
+ return `${this.#storageKey()}:sync-lock`;
1751
+ }
1752
+ #acquireSyncLock() {
1753
+ const key = this.#lockKey();
1754
+ const now = Date.now();
1755
+ const local = syncLocks.get(key);
1756
+ if (local !== void 0 && local.expiresAt > now && local.owner !== this.#instanceId)
1757
+ return false;
1758
+ if (this.#lockStorage !== null) {
1759
+ try {
1760
+ const existing = this.#lockStorage.getItem(key);
1761
+ if (existing !== null) {
1762
+ const parsed = JSON.parse(existing);
1763
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.expiresAt === "number" && parsed.expiresAt > now && parsed.owner !== this.#instanceId)
1764
+ return false;
1765
+ }
1766
+ this.#lockStorage.setItem(
1767
+ key,
1768
+ JSON.stringify({ owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS })
1769
+ );
1770
+ } catch {
1771
+ }
1772
+ }
1773
+ syncLocks.set(key, { owner: this.#instanceId, expiresAt: now + SYNC_LOCK_TTL_MS });
1774
+ return true;
1775
+ }
1776
+ #releaseSyncLock() {
1777
+ const key = this.#lockKey();
1778
+ const current = syncLocks.get(key);
1779
+ if (current?.owner === this.#instanceId) syncLocks.delete(key);
1780
+ if (this.#lockStorage !== null) {
1781
+ try {
1782
+ const value = this.#lockStorage.getItem(key);
1783
+ if (value !== null && JSON.parse(value).owner === this.#instanceId)
1784
+ this.#lockStorage.removeItem?.(key);
1785
+ } catch {
1786
+ }
1787
+ }
1788
+ }
1789
+ #storageKey() {
1790
+ if (this.#configuredKey !== void 0) return this.#configuredKey;
1791
+ return `stamprally:queue:${this.#rallyId ?? "unscoped"}:${this.#userId ?? "anonymous"}`;
1792
+ }
1793
+ #normalizeResponse(value) {
1794
+ if ("ok" in value) {
1795
+ if (value.ok === false) {
1796
+ if ("status" in value && value.status === "RETRYABLE_ERROR")
1797
+ return { status: "RETRYABLE_ERROR", error: errorValue(value.error, "RETRYABLE_ERROR") };
1798
+ return { status: "REJECTED_PERMANENT", result: value };
1799
+ }
1800
+ return { status: "ACCEPTED", result: value };
1801
+ }
1802
+ if ("status" in value) {
1803
+ if (value.status === "ACCEPTED") return value;
1804
+ return value;
1805
+ }
1806
+ return { status: "ACCEPTED", result: value };
1575
1807
  }
1576
1808
  async resolveConflict(operation, localState, serverState) {
1577
1809
  const configured = this.#onSyncConflict;
@@ -2314,21 +2546,112 @@ function validate(value, isPublic) {
2314
2546
  optionalString(value, "staffPasscode", "$", errors);
2315
2547
  if (hasOwn(value, "inventory") && value.inventory !== void 0 && !isRecord3(value.inventory))
2316
2548
  add(errors, "$.inventory", "Expected an object.", "invalid_type");
2549
+ if (hasOwn(value, "inventoryMode") && value.inventoryMode !== void 0 && value.inventoryMode !== "shared" && value.inventoryMode !== "per_reward")
2550
+ add(errors, "$.inventoryMode", "Expected shared or per_reward.", "invalid_enum");
2317
2551
  if (hasOwn(value, "serverMetadata") && value.serverMetadata !== void 0 && !isRecord3(value.serverMetadata))
2318
2552
  add(errors, "$.serverMetadata", "Expected an object.", "invalid_type");
2319
2553
  if (hasOwn(value, "publicMetadata") && value.publicMetadata !== void 0 && !isRecord3(value.publicMetadata))
2320
2554
  add(errors, "$.publicMetadata", "Expected an object.", "invalid_type");
2321
2555
  optionalString(value, "serverEndpoint", "$", errors);
2322
2556
  } else {
2323
- for (const key of ["staffPasscode", "serverMetadata", "inventory"])
2557
+ for (const key of ["staffPasscode", "serverMetadata", "inventory", "inventoryMode"])
2324
2558
  if (hasOwn(value, key))
2325
2559
  add(errors, `$.${key}`, "Private field is not allowed.", "private_field");
2326
2560
  optionalString(value, "serverEndpoint", "$", errors);
2327
2561
  }
2328
2562
  return errors;
2329
2563
  }
2564
+ function validateRallyConfigRelations(config) {
2565
+ const errors = [];
2566
+ const spotIds = /* @__PURE__ */ new Set();
2567
+ const rewardIds = /* @__PURE__ */ new Set();
2568
+ const orderIndexes = /* @__PURE__ */ new Map();
2569
+ config.spots.forEach((spot2, index) => {
2570
+ if (spotIds.has(spot2.id))
2571
+ add(errors, `spots[${index}].id`, "Spot ID must be unique.", "duplicate_spot_id");
2572
+ spotIds.add(spot2.id);
2573
+ const previousIndex = orderIndexes.get(spot2.orderIndex);
2574
+ if (previousIndex !== void 0)
2575
+ add(
2576
+ errors,
2577
+ `spots[${index}].orderIndex`,
2578
+ `orderIndex duplicates spots[${previousIndex}].`,
2579
+ "duplicate_order_index"
2580
+ );
2581
+ else orderIndexes.set(spot2.orderIndex, index);
2582
+ if (spot2.orderIndex < 0)
2583
+ add(
2584
+ errors,
2585
+ `spots[${index}].orderIndex`,
2586
+ "orderIndex must not be negative.",
2587
+ "negative_order_index"
2588
+ );
2589
+ spot2.prerequisites?.forEach((prerequisite, prerequisiteIndex) => {
2590
+ if (!spotIds.has(prerequisite) && !config.spots.some((candidate) => candidate.id === prerequisite))
2591
+ add(
2592
+ errors,
2593
+ `spots[${index}].prerequisites[${prerequisiteIndex}]`,
2594
+ "Prerequisite spot does not exist.",
2595
+ "missing_prerequisite"
2596
+ );
2597
+ });
2598
+ });
2599
+ config.rewards.forEach((reward2, index) => {
2600
+ if (rewardIds.has(reward2.id))
2601
+ add(errors, `rewards[${index}].id`, "Reward ID must be unique.", "duplicate_reward_id");
2602
+ rewardIds.add(reward2.id);
2603
+ const visit = (condition2, path) => {
2604
+ if (condition2.type === "stamps")
2605
+ condition2.stampIds.forEach((stampId, stampIndex) => {
2606
+ if (!spotIds.has(stampId))
2607
+ add(
2608
+ errors,
2609
+ `${path}.stampIds[${stampIndex}]`,
2610
+ "Referenced spot does not exist.",
2611
+ "missing_reward_spot"
2612
+ );
2613
+ });
2614
+ else if (condition2.type === "all" || condition2.type === "any")
2615
+ condition2.conditions.forEach((nested, nestedIndex) => {
2616
+ visit(nested, `${path}.conditions[${nestedIndex}]`);
2617
+ });
2618
+ };
2619
+ reward2.conditions?.forEach((condition2, conditionIndex) => {
2620
+ visit(condition2, `rewards[${index}].conditions[${conditionIndex}]`);
2621
+ });
2622
+ });
2623
+ const visiting = /* @__PURE__ */ new Set();
2624
+ const visited = /* @__PURE__ */ new Set();
2625
+ const cycleNodes = /* @__PURE__ */ new Set();
2626
+ const visitSpot = (spotId) => {
2627
+ if (visiting.has(spotId)) {
2628
+ cycleNodes.add(spotId);
2629
+ return;
2630
+ }
2631
+ if (visited.has(spotId)) return;
2632
+ visiting.add(spotId);
2633
+ const spot2 = config.spots.find((candidate) => candidate.id === spotId);
2634
+ spot2?.prerequisites?.forEach(visitSpot);
2635
+ visiting.delete(spotId);
2636
+ visited.add(spotId);
2637
+ };
2638
+ config.spots.forEach((spot2) => {
2639
+ visitSpot(spot2.id);
2640
+ });
2641
+ cycleNodes.forEach((spotId) => {
2642
+ const index = config.spots.findIndex((spot2) => spot2.id === spotId);
2643
+ add(
2644
+ errors,
2645
+ `spots[${index}].prerequisites`,
2646
+ "Prerequisites must form a DAG.",
2647
+ "cyclic_prerequisites"
2648
+ );
2649
+ });
2650
+ return errors;
2651
+ }
2330
2652
  function safeParseAdminConfig(input) {
2331
- const errors = validate(input, false);
2653
+ const errors = [...validate(input, false)];
2654
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2332
2655
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2333
2656
  }
2334
2657
  function parseAdminConfig(input) {
@@ -2337,7 +2660,8 @@ function parseAdminConfig(input) {
2337
2660
  return result.data;
2338
2661
  }
2339
2662
  function safeParsePublicConfig(input) {
2340
- const errors = validate(input, true);
2663
+ const errors = [...validate(input, true)];
2664
+ if (errors.length === 0) errors.push(...validateRallyConfigRelations(input));
2341
2665
  return errors.length === 0 ? { success: true, data: input } : { success: false, errors };
2342
2666
  }
2343
2667
  function parsePublicConfig(input) {
@@ -2486,9 +2810,11 @@ exports.createSignedSnapshotToken = createSignedSnapshotToken;
2486
2810
  exports.createUniqueClaimTicketNumber = createUniqueClaimTicketNumber;
2487
2811
  exports.evaluateCondition = evaluateCondition;
2488
2812
  exports.evaluateConditionDetailed = evaluateConditionDetailed;
2813
+ exports.evaluateSpotStatus = evaluateSpotStatus;
2489
2814
  exports.exportProgressToken = exportProgressToken;
2490
2815
  exports.getCurrentGeoContext = getCurrentGeoContext;
2491
2816
  exports.getOrderedSpots = getOrderedSpots;
2817
+ exports.getSpotStatus = getSpotStatus;
2492
2818
  exports.importProgressToken = importProgressToken;
2493
2819
  exports.isGeolocationSupported = isGeolocationSupported;
2494
2820
  exports.isNfcSupported = isNfcSupported;
@@ -2514,6 +2840,7 @@ exports.toLocalizedString = toLocalizedString;
2514
2840
  exports.toPublicConfig = toPublicConfig;
2515
2841
  exports.updateLocalizedField = updateLocalizedField;
2516
2842
  exports.validatePublicConfigSafety = validatePublicConfigSafety;
2843
+ exports.validateRallyConfigRelations = validateRallyConfigRelations;
2517
2844
  exports.verifyPasscode = verifyPasscode;
2518
2845
  exports.verifySecureToken = verifySecureToken;
2519
2846
  exports.verifySnapshotToken = verifySnapshotToken;