@pixels-online/pixels-client-js-sdk 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1551 @@
1
+ 'use strict';
2
+
3
+ class Logger {
4
+ constructor(config, prefix) {
5
+ this.config = config;
6
+ this.prefix = prefix;
7
+ }
8
+ log(...args) {
9
+ if (this.config.debug) {
10
+ console.log(`[${this.prefix}]`, ...args);
11
+ }
12
+ }
13
+ error(...args) {
14
+ console.error(`[${this.prefix}]`, ...args);
15
+ }
16
+ warn(...args) {
17
+ if (this.config.debug) {
18
+ console.warn(`[${this.prefix}]`, ...args);
19
+ }
20
+ }
21
+ info(...args) {
22
+ if (this.config.debug) {
23
+ console.info(`[${this.prefix}]`, ...args);
24
+ }
25
+ }
26
+ /**
27
+ * Create a child logger with an additional prefix
28
+ */
29
+ child(subPrefix) {
30
+ return new Logger(this.config, `${this.prefix}:${subPrefix}`);
31
+ }
32
+ }
33
+ /**
34
+ * Create a logger instance for a component
35
+ */
36
+ function createLogger(config, prefix) {
37
+ return new Logger(config, prefix);
38
+ }
39
+
40
+ class EventEmitter {
41
+ constructor(config) {
42
+ this.events = new Map();
43
+ this.logger = createLogger(config, 'EventEmitter');
44
+ }
45
+ on(event, handler) {
46
+ if (!this.events.has(event)) {
47
+ this.events.set(event, new Set());
48
+ }
49
+ this.events.get(event).add(handler);
50
+ this.logger.log(`Handler registered for event: ${event}`);
51
+ }
52
+ off(event, handler) {
53
+ const handlers = this.events.get(event);
54
+ if (handlers) {
55
+ handlers.delete(handler);
56
+ if (handlers.size === 0) {
57
+ this.events.delete(event);
58
+ }
59
+ }
60
+ this.logger.log(`Handler removed for event: ${event}`);
61
+ }
62
+ emit(event, data) {
63
+ const handlers = this.events.get(event);
64
+ if (handlers) {
65
+ handlers.forEach(handler => {
66
+ try {
67
+ handler(data);
68
+ }
69
+ catch (error) {
70
+ this.logger.error(`Error in handler for event ${event}:`, error);
71
+ }
72
+ });
73
+ }
74
+ this.logger.log(`Event emitted: ${event}`, data);
75
+ }
76
+ once(event, handler) {
77
+ const wrappedHandler = (data) => {
78
+ handler(data);
79
+ this.off(event, wrappedHandler);
80
+ };
81
+ this.on(event, wrappedHandler);
82
+ }
83
+ removeAllListeners(event) {
84
+ if (event) {
85
+ this.events.delete(event);
86
+ }
87
+ else {
88
+ this.events.clear();
89
+ }
90
+ this.logger.log(`Listeners removed for: ${event || 'all events'}`);
91
+ }
92
+ listenerCount(event) {
93
+ const handlers = this.events.get(event);
94
+ return handlers ? handlers.size : 0;
95
+ }
96
+ }
97
+
98
+ exports.OfferEvent = void 0;
99
+ (function (OfferEvent) {
100
+ OfferEvent["CONNECTED"] = "connected";
101
+ OfferEvent["DISCONNECTED"] = "disconnected";
102
+ OfferEvent["CONNECTION_ERROR"] = "connection_error";
103
+ OfferEvent["CONNECTION_STATE_CHANGED"] = "connection_state_changed";
104
+ OfferEvent["OFFER_SURFACED"] = "offer_surfaced";
105
+ OfferEvent["OFFER_CLAIMED"] = "offer_claimed";
106
+ OfferEvent["ERROR"] = "error";
107
+ OfferEvent["REFRESH"] = "refresh";
108
+ /* placeholder events for future use
109
+ OFFER_ADDED = 'offer_added',
110
+ OFFER_UPDATED = 'offer_updated',
111
+ SNAPSHOT_UPDATED = 'snapshot_updated',
112
+ */
113
+ })(exports.OfferEvent || (exports.OfferEvent = {}));
114
+
115
+ /**
116
+ * Connection states for SSE connection
117
+ */
118
+ exports.ConnectionState = void 0;
119
+ (function (ConnectionState) {
120
+ ConnectionState["DISCONNECTED"] = "disconnected";
121
+ ConnectionState["CONNECTING"] = "connecting";
122
+ ConnectionState["CONNECTED"] = "connected";
123
+ ConnectionState["RECONNECTING"] = "reconnecting";
124
+ ConnectionState["ERROR"] = "error";
125
+ })(exports.ConnectionState || (exports.ConnectionState = {}));
126
+
127
+ /**
128
+ * Custom EventSource implementation that supports headers
129
+ * This is needed because the native EventSource API doesn't support custom headers
130
+ */
131
+ var ReadyState;
132
+ (function (ReadyState) {
133
+ ReadyState[ReadyState["CONNECTING"] = 0] = "CONNECTING";
134
+ ReadyState[ReadyState["OPEN"] = 1] = "OPEN";
135
+ ReadyState[ReadyState["CLOSED"] = 2] = "CLOSED";
136
+ })(ReadyState || (ReadyState = {}));
137
+ class CustomEventSource {
138
+ constructor(url, init) {
139
+ this.readyState = ReadyState.CONNECTING;
140
+ this.eventListeners = new Map();
141
+ this.onopen = null;
142
+ this.onerror = null;
143
+ this.onmessage = null;
144
+ this.reader = null;
145
+ this.decoder = new TextDecoder();
146
+ this.eventBuffer = '';
147
+ this.abortController = null;
148
+ this.lastEventId = '';
149
+ this.reconnectTime = 3000;
150
+ this.onRetryUpdate = null;
151
+ this.url = url;
152
+ this.headers = {
153
+ 'Accept': 'text/event-stream',
154
+ 'Cache-Control': 'no-cache',
155
+ ...(init?.headers || {})
156
+ };
157
+ this.connect();
158
+ }
159
+ async connect() {
160
+ try {
161
+ this.readyState = ReadyState.CONNECTING;
162
+ this.abortController = new AbortController();
163
+ const response = await fetch(this.url, {
164
+ method: 'GET',
165
+ headers: this.headers,
166
+ credentials: 'same-origin',
167
+ signal: this.abortController.signal,
168
+ });
169
+ if (!response.ok) {
170
+ // Check for JWT errors
171
+ if (response.status === 400 || response.status === 401) {
172
+ try {
173
+ const json = await response.json();
174
+ if (json.message === 'jwt-expired' || json.message === 'jwt-invalid') {
175
+ const error = new Error(json.message);
176
+ error.isAuthError = true;
177
+ throw error;
178
+ }
179
+ }
180
+ catch (e) {
181
+ // If not JSON or no message, throw original error
182
+ }
183
+ }
184
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
185
+ }
186
+ if (!response.body) {
187
+ throw new Error('Response body is null');
188
+ }
189
+ this.readyState = ReadyState.OPEN;
190
+ // Dispatch open event
191
+ if (this.onopen) {
192
+ this.onopen(new Event('open'));
193
+ }
194
+ this.reader = response.body.getReader();
195
+ await this.readStream();
196
+ }
197
+ catch (error) {
198
+ this.handleError(error);
199
+ }
200
+ }
201
+ async readStream() {
202
+ if (!this.reader)
203
+ return;
204
+ try {
205
+ while (true) {
206
+ const { done, value } = await this.reader.read();
207
+ if (done) {
208
+ this.handleError(new Error('Stream ended'));
209
+ break;
210
+ }
211
+ const chunk = this.decoder.decode(value, { stream: true });
212
+ this.eventBuffer += chunk;
213
+ this.processBuffer();
214
+ }
215
+ }
216
+ catch (error) {
217
+ this.handleError(error);
218
+ }
219
+ }
220
+ processBuffer() {
221
+ const lines = this.eventBuffer.split('\n');
222
+ this.eventBuffer = lines.pop() || '';
223
+ let eventType = 'message';
224
+ let eventData = '';
225
+ let eventId = '';
226
+ for (const line of lines) {
227
+ if (line === '') {
228
+ // Empty line signals end of event
229
+ if (eventData) {
230
+ this.dispatchEvent(eventType, eventData.trim(), eventId);
231
+ eventType = 'message';
232
+ eventData = '';
233
+ eventId = '';
234
+ }
235
+ }
236
+ else if (line.startsWith(':')) {
237
+ // Comment, ignore
238
+ continue;
239
+ }
240
+ else if (line.startsWith('event:')) {
241
+ eventType = line.slice(6).trim();
242
+ }
243
+ else if (line.startsWith('data:')) {
244
+ eventData += line.slice(5).trim() + '\n';
245
+ }
246
+ else if (line.startsWith('id:')) {
247
+ eventId = line.slice(3).trim();
248
+ this.lastEventId = eventId;
249
+ }
250
+ else if (line.startsWith('retry:')) {
251
+ const retry = parseInt(line.slice(6).trim(), 10);
252
+ if (!isNaN(retry)) {
253
+ this.reconnectTime = retry;
254
+ // Notify SSEConnection about the server's suggested retry time
255
+ if (this.onRetryUpdate) {
256
+ this.onRetryUpdate(retry);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ dispatchEvent(type, data, id) {
263
+ const event = new MessageEvent(type, {
264
+ data: data.endsWith('\n') ? data.slice(0, -1) : data,
265
+ lastEventId: id || this.lastEventId,
266
+ origin: new URL(this.url).origin,
267
+ });
268
+ // Dispatch to specific event listeners
269
+ const listeners = this.eventListeners.get(type);
270
+ if (listeners) {
271
+ listeners.forEach(listener => listener(event));
272
+ }
273
+ // Dispatch to general message handler for 'message' events
274
+ if (type === 'message' && this.onmessage) {
275
+ this.onmessage(event);
276
+ }
277
+ }
278
+ handleError(error) {
279
+ this.readyState = ReadyState.CLOSED;
280
+ if (this.reader) {
281
+ this.reader.cancel();
282
+ this.reader = null;
283
+ }
284
+ // Dispatch error event with details
285
+ if (this.onerror) {
286
+ const errorEvent = new Event('error');
287
+ errorEvent.detail = error;
288
+ errorEvent.message = error?.message;
289
+ this.onerror(errorEvent);
290
+ }
291
+ // Reconnection is handled by SSEConnection, not here
292
+ }
293
+ addEventListener(type, listener) {
294
+ if (!this.eventListeners.has(type)) {
295
+ this.eventListeners.set(type, new Set());
296
+ }
297
+ this.eventListeners.get(type).add(listener);
298
+ }
299
+ removeEventListener(type, listener) {
300
+ const listeners = this.eventListeners.get(type);
301
+ if (listeners) {
302
+ listeners.delete(listener);
303
+ }
304
+ }
305
+ close() {
306
+ this.readyState = ReadyState.CLOSED;
307
+ if (this.abortController) {
308
+ this.abortController.abort();
309
+ this.abortController = null;
310
+ }
311
+ if (this.reader) {
312
+ this.reader.cancel();
313
+ this.reader = null;
314
+ }
315
+ this.eventListeners.clear();
316
+ this.onopen = null;
317
+ this.onerror = null;
318
+ this.onmessage = null;
319
+ }
320
+ get CONNECTING() {
321
+ return ReadyState.CONNECTING;
322
+ }
323
+ get OPEN() {
324
+ return ReadyState.OPEN;
325
+ }
326
+ get CLOSED() {
327
+ return ReadyState.CLOSED;
328
+ }
329
+ getReadyState() {
330
+ return this.readyState;
331
+ }
332
+ getReconnectTime() {
333
+ return this.reconnectTime;
334
+ }
335
+ }
336
+
337
+ class SSEConnection {
338
+ constructor(config, eventEmitter, tokenManager) {
339
+ this.config = config;
340
+ this.eventEmitter = eventEmitter;
341
+ this.tokenManager = tokenManager;
342
+ this.eventSource = null;
343
+ this.reconnectAttempts = 0;
344
+ this.reconnectTimeout = null;
345
+ this.isConnecting = false;
346
+ this.connectionState = exports.ConnectionState.DISCONNECTED;
347
+ this.serverSuggestedRetryTime = null;
348
+ this.logger = createLogger(config, 'SSEConnection');
349
+ }
350
+ /**
351
+ * Get current connection state
352
+ */
353
+ getConnectionState() {
354
+ return this.connectionState;
355
+ }
356
+ /**
357
+ * Set connection state and emit change event
358
+ */
359
+ setConnectionState(newState, error) {
360
+ const previousState = this.connectionState;
361
+ if (previousState === newState)
362
+ return;
363
+ this.connectionState = newState;
364
+ this.eventEmitter.emit(exports.OfferEvent.CONNECTION_STATE_CHANGED, {
365
+ state: newState,
366
+ previousState,
367
+ error,
368
+ attempt: this.reconnectAttempts,
369
+ maxAttempts: this.config.maxReconnectAttempts
370
+ });
371
+ }
372
+ connect() {
373
+ return new Promise(async (resolve, reject) => {
374
+ if (this.eventSource) {
375
+ this.logger.log('Already connected');
376
+ resolve();
377
+ return;
378
+ }
379
+ if (this.isConnecting) {
380
+ this.logger.log('Connection already in progress');
381
+ resolve();
382
+ return;
383
+ }
384
+ this.isConnecting = true;
385
+ this.setConnectionState(exports.ConnectionState.CONNECTING);
386
+ this.logger.log('Connecting to SSE endpoint...');
387
+ try {
388
+ // Create SSE URL
389
+ const url = new URL(this.config.endpoint + '/sse/connect');
390
+ const token = await this.tokenManager.getTokenForConnection();
391
+ // Use CustomEventSource with Authorization Bearer header
392
+ this.eventSource = new CustomEventSource(url.toString(), {
393
+ headers: {
394
+ 'Authorization': `Bearer ${token}`,
395
+ }
396
+ });
397
+ // Listen for server-suggested retry time updates
398
+ this.eventSource.onRetryUpdate = (retryTime) => {
399
+ this.serverSuggestedRetryTime = retryTime;
400
+ this.logger.log(`Server suggested retry time: ${retryTime}ms`);
401
+ };
402
+ this.eventSource.onopen = () => {
403
+ this.logger.log('SSE connection opened');
404
+ this.isConnecting = false;
405
+ this.reconnectAttempts = 0;
406
+ this.setConnectionState(exports.ConnectionState.CONNECTED);
407
+ this.eventEmitter.emit(exports.OfferEvent.CONNECTED, { timestamp: new Date() });
408
+ resolve();
409
+ };
410
+ this.eventSource.onerror = (error) => {
411
+ this.logger.log('SSE connection error', error);
412
+ this.isConnecting = false;
413
+ // Check if it's an auth error
414
+ if (error?.detail?.isAuthError || error?.message === 'jwt-expired' || error?.message === 'jwt-invalid') {
415
+ this.tokenManager.clearToken();
416
+ this.setConnectionState(exports.ConnectionState.DISCONNECTED);
417
+ // Try to reconnect with fresh token if reconnect is enabled
418
+ if (this.config.reconnect && this.reconnectAttempts < (this.config.maxReconnectAttempts || 5)) {
419
+ this.logger.log('JWT invalid/expired, attempting reconnect with fresh token');
420
+ this.handleReconnect();
421
+ resolve(); // Resolve instead of reject to allow reconnection
422
+ return;
423
+ }
424
+ else {
425
+ reject(new Error('JWT token invalid or expired'));
426
+ return;
427
+ }
428
+ }
429
+ const errorMsg = this.eventSource?.getReadyState() === ReadyState.CLOSED ?
430
+ 'Connection closed' : 'Connection error';
431
+ const connectionError = new Error(errorMsg);
432
+ this.setConnectionState(exports.ConnectionState.ERROR, connectionError);
433
+ if (this.eventSource?.getReadyState() === ReadyState.CLOSED) {
434
+ this.eventEmitter.emit(exports.OfferEvent.CONNECTION_ERROR, {
435
+ error: connectionError,
436
+ timestamp: new Date()
437
+ });
438
+ this.handleReconnect();
439
+ }
440
+ else {
441
+ this.eventEmitter.emit(exports.OfferEvent.CONNECTION_ERROR, {
442
+ error: connectionError,
443
+ timestamp: new Date()
444
+ });
445
+ reject(connectionError);
446
+ }
447
+ };
448
+ this.eventSource.addEventListener(exports.OfferEvent.OFFER_SURFACED, (event) => {
449
+ this.handleMessage(exports.OfferEvent.OFFER_SURFACED, event.data);
450
+ });
451
+ /*this.eventSource.addEventListener(OfferEvent.INIT, (event) => {
452
+ this.handleMessage(OfferEvent.INIT, event.data);
453
+ });
454
+
455
+ this.eventSource.addEventListener(OfferEvent.OFFER_ADDED, (event) => {
456
+ this.handleMessage(OfferEvent.OFFER_ADDED, event.data);
457
+ });
458
+
459
+
460
+ this.eventSource.addEventListener(OfferEvent.OFFER_UPDATED, (event) => {
461
+ this.handleMessage(OfferEvent.OFFER_UPDATED, event.data);
462
+ });
463
+
464
+ this.eventSource.addEventListener(OfferEvent.SNAPSHOT_UPDATED, (event) => {
465
+ this.handleMessage(OfferEvent.SNAPSHOT_UPDATED, event.data);
466
+ });
467
+ */
468
+ }
469
+ catch (error) {
470
+ this.isConnecting = false;
471
+ this.logger.log('Failed to create EventSource', error);
472
+ reject(error);
473
+ }
474
+ });
475
+ }
476
+ async handleMessage(type, data) {
477
+ try {
478
+ const parsed = JSON.parse(data);
479
+ this.logger.log(`Handling ${type} message:`, parsed);
480
+ switch (type) {
481
+ /*
482
+ case OfferEvent.INIT:
483
+ if (!parsed.offers || !Array.isArray(parsed.offers))
484
+ throw new Error('INIT message missing offers array');
485
+
486
+ if (!parsed.playerSnapshot)
487
+ throw new Error('INIT message missing playerSnapshot');
488
+
489
+ this.eventEmitter.emit(OfferEvent.INIT, {
490
+ offers: parsed.offers as IClientOffer[],
491
+ playerSnapshot: parsed.playerSnapshot as IClientPlayerSnapshot
492
+ });
493
+
494
+ break;
495
+ case OfferEvent.OFFER_ADDED:
496
+ if (!parsed.offer)
497
+ throw new Error('OFFER_ADDED message missing offer');
498
+ this.eventEmitter.emit(OfferEvent.OFFER_ADDED, {
499
+ offer: parsed.offer as IClientOffer,
500
+ });
501
+ break;
502
+
503
+ case OfferEvent.OFFER_UPDATED:
504
+ if (!parsed.offer)
505
+ throw new Error('OFFER_UPDATED message missing offer');
506
+
507
+ this.eventEmitter.emit(OfferEvent.OFFER_UPDATED, {
508
+ offer: parsed.offer as IClientOffer,
509
+ });
510
+ break;
511
+ case OfferEvent.SNAPSHOT_UPDATED:
512
+ if (!parsed.playerSnapshot)
513
+ throw new Error('SNAPSHOT_UPDATED message missing playerSnapshot');
514
+ this.eventEmitter.emit(OfferEvent.SNAPSHOT_UPDATED, {
515
+ playerSnapshot: parsed.playerSnapshot as IClientPlayerSnapshot
516
+ });
517
+ break;
518
+ case OfferEvent.OFFER_CLAIMED:
519
+ if (!parsed.offerId)
520
+ throw new Error('OFFER_CLAIMED message missing offerId');
521
+ this.eventEmitter.emit(OfferEvent.OFFER_CLAIMED, {
522
+ instanceId: parsed as string,
523
+ });
524
+ break;
525
+ */
526
+ case exports.OfferEvent.OFFER_SURFACED:
527
+ if (!parsed.offer)
528
+ throw new Error('OFFER_SURFACED message missing offer');
529
+ let surface = true;
530
+ if (this.config.hooks?.onOfferSurfaced) {
531
+ surface = await this.config.hooks.onOfferSurfaced(parsed.offer);
532
+ }
533
+ if (surface) {
534
+ this.logger.log('Offer surfaced hook returned true, popping notification for offer:', parsed.offer);
535
+ this.eventEmitter.emit(exports.OfferEvent.OFFER_SURFACED, {
536
+ offer: parsed.offer,
537
+ });
538
+ }
539
+ break;
540
+ default:
541
+ this.logger.log('Unknown message type:', type);
542
+ }
543
+ }
544
+ catch (error) {
545
+ this.logger.log('Error parsing SSE message:', error);
546
+ this.eventEmitter.emit(exports.OfferEvent.ERROR, {
547
+ error: error,
548
+ context: 'sse_message_parse'
549
+ });
550
+ }
551
+ }
552
+ handleReconnect() {
553
+ if (!this.config.reconnect) {
554
+ this.disconnect();
555
+ return;
556
+ }
557
+ if (this.reconnectAttempts >= (this.config.maxReconnectAttempts || 5)) {
558
+ this.logger.log('Max reconnection attempts reached');
559
+ this.setConnectionState(exports.ConnectionState.DISCONNECTED);
560
+ this.disconnect();
561
+ this.eventEmitter.emit(exports.OfferEvent.DISCONNECTED, {
562
+ reason: 'max_reconnect_attempts',
563
+ timestamp: new Date()
564
+ });
565
+ return;
566
+ }
567
+ this.reconnectAttempts++;
568
+ // Use server-suggested retry time if available, otherwise use config delay with exponential backoff
569
+ const baseDelay = this.serverSuggestedRetryTime || this.config.reconnectDelay || 1000;
570
+ const backoffDelay = baseDelay * Math.pow(2, this.reconnectAttempts - 1);
571
+ this.setConnectionState(exports.ConnectionState.RECONNECTING);
572
+ this.logger.log(`Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}`);
573
+ this.reconnectTimeout = window.setTimeout(() => {
574
+ this.eventSource = null;
575
+ this.connect().catch((error) => {
576
+ this.logger.log('Reconnection failed:', error);
577
+ });
578
+ }, backoffDelay);
579
+ }
580
+ disconnect() {
581
+ if (this.reconnectTimeout) {
582
+ clearTimeout(this.reconnectTimeout);
583
+ this.reconnectTimeout = null;
584
+ }
585
+ if (this.eventSource) {
586
+ this.logger.log('Closing SSE connection');
587
+ this.eventSource.close();
588
+ this.eventSource = null;
589
+ this.setConnectionState(exports.ConnectionState.DISCONNECTED);
590
+ this.eventEmitter.emit(exports.OfferEvent.DISCONNECTED, {
591
+ reason: 'manual',
592
+ timestamp: new Date()
593
+ });
594
+ }
595
+ this.isConnecting = false;
596
+ this.reconnectAttempts = 0;
597
+ }
598
+ isConnected() {
599
+ return this.eventSource?.getReadyState() === ReadyState.OPEN;
600
+ }
601
+ }
602
+
603
+ class OfferStore {
604
+ constructor(config) {
605
+ this.offers = new Map();
606
+ this.playerSnapshot = null;
607
+ this.logger = createLogger(config, 'OfferStore');
608
+ }
609
+ getSnapshot() {
610
+ return this.playerSnapshot;
611
+ }
612
+ setSnapshot(snapshot) {
613
+ this.playerSnapshot = snapshot;
614
+ this.logger.log('Updated player snapshot:', snapshot);
615
+ }
616
+ /**
617
+ * Set all offers (replaces existing)
618
+ */
619
+ setOffers(offers) {
620
+ this.offers.clear();
621
+ offers.forEach(offer => {
622
+ this.offers.set(offer.instanceId, offer);
623
+ });
624
+ this.logger.log(`Set ${offers.length} offers`);
625
+ }
626
+ /**
627
+ * Add or update a single offer
628
+ */
629
+ upsertOffer(offer) {
630
+ const previousOffer = this.offers.get(offer.instanceId);
631
+ this.offers.set(offer.instanceId, offer);
632
+ this.logger.log(`${previousOffer ? 'Updated' : 'Added'} offer:`, offer.instanceId);
633
+ return previousOffer;
634
+ }
635
+ /**
636
+ * Remove an offer
637
+ */
638
+ removeOffer(offerId) {
639
+ const removed = this.offers.delete(offerId);
640
+ if (removed) {
641
+ this.logger.log(`Removed offer:`, offerId);
642
+ }
643
+ return removed;
644
+ }
645
+ /**
646
+ * Get a single offer
647
+ */
648
+ getOffer(offerId) {
649
+ return this.offers.get(offerId);
650
+ }
651
+ /**
652
+ * Get all offers
653
+ */
654
+ getAllOffers() {
655
+ return Array.from(this.offers.values());
656
+ }
657
+ /**
658
+ * Get offers filtered by status
659
+ */
660
+ getOffersByStatus(status) {
661
+ return this.getAllOffers().filter(offer => offer.status === status);
662
+ }
663
+ /**
664
+ * Get active offers (not expired, not claimed)
665
+ */
666
+ getActiveOffers() {
667
+ return this.getAllOffers().filter(offer => {
668
+ if (!offer.status)
669
+ return false; // Must have a status
670
+ return offer.status === 'surfaced' || offer.status === 'viewed' || offer.status === 'claimable';
671
+ });
672
+ }
673
+ /**
674
+ * Get claimable offers
675
+ */
676
+ getClaimableOffers() {
677
+ return this.getOffersByStatus('claimable');
678
+ }
679
+ /**
680
+ * Check if an offer has expired
681
+ */
682
+ isOfferExpired(offerId) {
683
+ const offer = this.getOffer(offerId);
684
+ if (!offer)
685
+ return true;
686
+ // Check status
687
+ if (offer.status === 'expired')
688
+ return true;
689
+ // Check expiry date
690
+ if (offer.expiresAt && new Date(offer.expiresAt) < new Date()) {
691
+ return true;
692
+ }
693
+ return false;
694
+ }
695
+ /**
696
+ * Clear all offers
697
+ */
698
+ clear() {
699
+ this.offers.clear();
700
+ this.logger.log('Cleared all offers');
701
+ }
702
+ /**
703
+ * Get offer count
704
+ */
705
+ get size() {
706
+ return this.offers.size;
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Manages JWT tokens for the offerwall client
712
+ * Handles token storage, validation, and refresh logic
713
+ */
714
+ class TokenManager {
715
+ constructor(config) {
716
+ this.config = config;
717
+ this.currentToken = null;
718
+ this.logger = createLogger(config, 'TokenManager');
719
+ }
720
+ /**
721
+ * Get the current stored token
722
+ */
723
+ getCurrentToken() {
724
+ return this.currentToken;
725
+ }
726
+ /**
727
+ * Clear the stored token
728
+ */
729
+ clearToken() {
730
+ this.currentToken = null;
731
+ }
732
+ /**
733
+ * Get current token or fetch a new one if needed
734
+ */
735
+ async getTokenForConnection() {
736
+ if (!this.currentToken)
737
+ return this.fetchToken();
738
+ if (!this.validateToken(this.currentToken))
739
+ return this.fetchToken();
740
+ return this.currentToken;
741
+ }
742
+ async fetchToken() {
743
+ const token = await this.config.tokenProvider();
744
+ this.currentToken = token;
745
+ return token;
746
+ }
747
+ validateToken(token) {
748
+ try {
749
+ const { exp } = this.parseJWT(token);
750
+ const now = Date.now();
751
+ const expiryTime = exp * 1000;
752
+ var isValid = expiryTime > now + 60000; // valid if more than 1 minute left
753
+ return isValid;
754
+ }
755
+ catch (error) {
756
+ this.logger.log(`Failed to validate token: ${error}`);
757
+ return false;
758
+ }
759
+ }
760
+ parseJWT(token) {
761
+ try {
762
+ const parts = token.split(".");
763
+ if (parts.length !== 3)
764
+ throw new Error("Invalid JWT format");
765
+ const payload = JSON.parse(atob(parts[1]));
766
+ // Validate required claims
767
+ if (!payload.sub || typeof payload.sub !== "string") {
768
+ throw new Error("Invalid sub claim");
769
+ }
770
+ if (!payload.iss || typeof payload.iss !== "string") {
771
+ throw new Error("Invalid iss claim");
772
+ }
773
+ if (!payload.exp || typeof payload.exp !== "number") {
774
+ throw new Error("Invalid exp claim");
775
+ }
776
+ if (!payload.iat || typeof payload.iat !== "number") {
777
+ throw new Error("Invalid iat claim");
778
+ }
779
+ // discard unknown claims
780
+ return {
781
+ sub: payload.sub,
782
+ iss: payload.iss,
783
+ exp: payload.exp,
784
+ iat: payload.iat,
785
+ };
786
+ }
787
+ catch (error) {
788
+ throw new Error(`Failed to parse JWT: ${error}`);
789
+ }
790
+ }
791
+ }
792
+
793
+ class AssetHelper {
794
+ constructor(config) {
795
+ this.config = config;
796
+ }
797
+ resolveReward(reward) {
798
+ // Determine the ID to translate based on reward kind
799
+ let id;
800
+ switch (reward.kind) {
801
+ case 'item':
802
+ id = reward.itemId;
803
+ break;
804
+ case 'coins':
805
+ id = reward.currencyId;
806
+ break;
807
+ case 'exp':
808
+ id = reward.skillId;
809
+ break;
810
+ case 'on_chain':
811
+ id = reward.assetId;
812
+ break;
813
+ case 'trust_points':
814
+ id = 'trust_points';
815
+ break;
816
+ }
817
+ // Fallback to reward's built-in name, we should add 5x popberry etc
818
+ const formattedName = reward.amount > 1 ? `${reward.amount}x ${reward.name}` : reward.name;
819
+ // Try to resolve asset content if ID exists and resolver is set
820
+ if (id && this.config.assetResolver) {
821
+ const content = this.config.assetResolver(reward, id);
822
+ if (content) {
823
+ return {
824
+ name: content.name || formattedName,
825
+ image: content.image || this.config.fallbackRewardImage
826
+ };
827
+ }
828
+ }
829
+ return {
830
+ name: formattedName,
831
+ image: this.config.fallbackRewardImage
832
+ };
833
+ }
834
+ resolveOfferRewards(offer) {
835
+ return offer.rewards.map(reward => ({
836
+ ...reward,
837
+ ...this.resolveReward(reward),
838
+ }));
839
+ }
840
+ }
841
+
842
+ class OfferwallClient {
843
+ constructor(config) {
844
+ this.isInitializing = false;
845
+ this.config = {
846
+ endpoint: config.endpoint || 'https://api.buildon.pixels.xyz',
847
+ autoConnect: config.autoConnect ?? false,
848
+ reconnect: config.reconnect ?? true,
849
+ reconnectDelay: config.reconnectDelay ?? 1000,
850
+ maxReconnectAttempts: config.maxReconnectAttempts ?? 5,
851
+ debug: config.debug ?? false,
852
+ ...config
853
+ };
854
+ this.hooks = this.config.hooks || {};
855
+ this.logger = createLogger(this.config, 'OfferwallClient');
856
+ this.eventEmitter = new EventEmitter(this.config);
857
+ this.offerStore = new OfferStore(this.config);
858
+ this.tokenManager = new TokenManager(this.config);
859
+ this.assetHelper = new AssetHelper(this.config);
860
+ this.sseConnection = new SSEConnection(this.config, this.eventEmitter, this.tokenManager);
861
+ this.setupInternalListeners();
862
+ if (this.config.autoConnect) {
863
+ this.initialize().catch(err => {
864
+ this.logger.error('Auto-initialization failed:', err);
865
+ });
866
+ }
867
+ }
868
+ /**
869
+ * Get the offer store instance
870
+ */
871
+ get store() {
872
+ return this.offerStore;
873
+ }
874
+ /**
875
+ * Get the event emitter instance for event handling
876
+ */
877
+ get events() {
878
+ return this.eventEmitter;
879
+ }
880
+ /**
881
+ * Get asset helper for resolving offer/reward content
882
+ */
883
+ get assets() {
884
+ return this.assetHelper;
885
+ }
886
+ /**
887
+ * Initialize the offerwall client and connect
888
+ */
889
+ async initialize() {
890
+ try {
891
+ if (this.isInitializing) {
892
+ this.logger.log('Initialization already in progress');
893
+ return;
894
+ }
895
+ this.isInitializing = true;
896
+ await this.refreshOffersAndSnapshot();
897
+ await this.connect();
898
+ this.isInitializing = false;
899
+ }
900
+ catch (error) {
901
+ this.handleError(error, 'initialize');
902
+ throw error;
903
+ }
904
+ }
905
+ /**
906
+ * Connect to the offerwall SSE endpoint
907
+ */
908
+ async connect() {
909
+ if (this.sseConnection?.isConnected()) {
910
+ this.logger.log('Already connected');
911
+ return;
912
+ }
913
+ const token = await this.tokenManager.getTokenForConnection();
914
+ if (this.hooks.beforeConnect) {
915
+ await this.hooks.beforeConnect({ jwt: token, endpoint: this.config.endpoint });
916
+ }
917
+ try {
918
+ await this.sseConnection.connect();
919
+ if (this.hooks.afterConnect) {
920
+ await this.hooks.afterConnect();
921
+ }
922
+ }
923
+ catch (error) {
924
+ this.handleError(error, 'connection');
925
+ throw error;
926
+ }
927
+ }
928
+ /**
929
+ * Disconnect from the offerwall
930
+ */
931
+ async disconnect() {
932
+ if (this.hooks.beforeDisconnect) {
933
+ await this.hooks.beforeDisconnect();
934
+ }
935
+ if (this.sseConnection) {
936
+ this.sseConnection.disconnect();
937
+ }
938
+ this.offerStore.clear();
939
+ this.tokenManager.clearToken();
940
+ if (this.hooks.afterDisconnect) {
941
+ await this.hooks.afterDisconnect();
942
+ }
943
+ }
944
+ /**
945
+ * Claim rewards for an offer
946
+ */
947
+ async claimReward(instanceId) {
948
+ const offer = this.offerStore.getOffer(instanceId);
949
+ if (!offer) {
950
+ throw new Error(`Offer ${instanceId} not found`);
951
+ }
952
+ if (offer.status !== 'claimable') {
953
+ throw new Error(`Offer ${instanceId} is not claimable (status: ${offer.status})`);
954
+ }
955
+ if (this.hooks.beforeOfferClaim) {
956
+ const canClaim = await this.hooks.beforeOfferClaim(offer);
957
+ if (!canClaim) {
958
+ this.logger.log('Claim cancelled by beforeOfferClaim hook');
959
+ return;
960
+ }
961
+ }
962
+ try {
963
+ const response = await this.claimOfferAPI(instanceId);
964
+ const updatedOffer = { ...offer, status: 'claimed', };
965
+ this.offerStore.upsertOffer(updatedOffer);
966
+ this.eventEmitter.emit(exports.OfferEvent.OFFER_CLAIMED, {
967
+ instanceId,
968
+ });
969
+ if (this.hooks.afterOfferClaim) {
970
+ await this.hooks.afterOfferClaim(updatedOffer, offer.rewards);
971
+ }
972
+ return response;
973
+ }
974
+ catch (error) {
975
+ this.handleError(error, 'claim');
976
+ throw error;
977
+ }
978
+ }
979
+ /**
980
+ * Check if connected
981
+ */
982
+ isConnected() {
983
+ return this.sseConnection?.isConnected() || false;
984
+ }
985
+ /**
986
+ * Get current connection state
987
+ */
988
+ getConnectionState() {
989
+ return this.sseConnection?.getConnectionState() || exports.ConnectionState.DISCONNECTED;
990
+ }
991
+ setupInternalListeners() {
992
+ /*
993
+ this.eventEmitter.on(OfferEvent.INIT, ({ offers, playerSnapshot }) => {
994
+ this.offerStore.setOffers(offers);
995
+ this.offerStore.setSnapshot(playerSnapshot);
996
+ this.logger.log(`Loaded ${offers.length} offers`);
997
+ });
998
+
999
+ this.eventEmitter.on(OfferEvent.OFFER_ADDED, ({ offer }) => {
1000
+ this.offerStore.upsertOffer(offer);
1001
+ this.logger.log(`Added offer: ${offer.instanceId}`);
1002
+ });
1003
+
1004
+ this.eventEmitter.on(OfferEvent.OFFER_UPDATED, ({ offer }) => {
1005
+ this.offerStore.upsertOffer(offer);
1006
+ this.logger.log(`Updated offer: ${offer.instanceId}`);
1007
+ });
1008
+
1009
+ this.eventEmitter.on(OfferEvent.SNAPSHOT_UPDATED, ({ playerSnapshot }) => {
1010
+ this.offerStore.setSnapshot(playerSnapshot);
1011
+ this.logger.log(`Updated player snapshot`);
1012
+ });
1013
+
1014
+ this.eventEmitter.on(OfferEvent.ERROR, ({ error, context }) => {
1015
+ this.handleError(error, context);
1016
+ });
1017
+ */
1018
+ this.eventEmitter.on(exports.OfferEvent.OFFER_SURFACED, ({ offer }) => {
1019
+ this.offerStore.upsertOffer(offer);
1020
+ this.logger.log(`Surfaced offer: ${offer.instanceId}`);
1021
+ });
1022
+ }
1023
+ /**
1024
+ * Helper to POST to an endpoint with JWT and handle token expiry/retry logic
1025
+ */
1026
+ async postWithAuth(endpoint, body, retry = false) {
1027
+ const token = await this.tokenManager.getTokenForConnection();
1028
+ const response = await fetch(`${this.config.endpoint}${endpoint}`, {
1029
+ method: 'POST',
1030
+ headers: {
1031
+ 'Authorization': `Bearer ${token}`,
1032
+ 'Content-Type': 'application/json'
1033
+ },
1034
+ body: body ? JSON.stringify(body) : undefined,
1035
+ });
1036
+ if (!response.ok) {
1037
+ if (response.status === 400) {
1038
+ const errorData = await response.json();
1039
+ if ((errorData.message === 'jwt-expired' || errorData.message === 'jwt-invalid') &&
1040
+ !retry) {
1041
+ this.tokenManager.clearToken();
1042
+ return this.postWithAuth(endpoint, body, true);
1043
+ }
1044
+ }
1045
+ throw new Error(`Failed to POST to ${endpoint}: ${response.statusText}`);
1046
+ }
1047
+ return response.json();
1048
+ }
1049
+ async claimOfferAPI(instanceId) {
1050
+ return this.postWithAuth('/client/reward/claim', { instanceId, kind: 'offer' });
1051
+ }
1052
+ async refreshOffersAndSnapshot() {
1053
+ try {
1054
+ const { offers, snapshot } = await this.getOffersAndSnapshot();
1055
+ this.offerStore.setOffers(offers);
1056
+ this.offerStore.setSnapshot(snapshot);
1057
+ this.eventEmitter.emit(exports.OfferEvent.REFRESH, { offers, playerSnapshot: snapshot });
1058
+ this.logger.log('Refreshed offers and player snapshot');
1059
+ }
1060
+ catch (error) {
1061
+ this.handleError(error, 'refreshOffersAndSnapshot');
1062
+ throw error;
1063
+ }
1064
+ }
1065
+ async getOffersAndSnapshot() {
1066
+ const data = await this.postWithAuth('/client/player/campaigns', { viewingCampaigns: true });
1067
+ if (!data.offers || !Array.isArray(data.offers)) {
1068
+ throw new Error('No offers returned from offers endpoint');
1069
+ }
1070
+ if (!data.snapshot) {
1071
+ throw new Error('No player snapshot returned from offers endpoint');
1072
+ }
1073
+ return data;
1074
+ }
1075
+ async getAuthLinkToken() {
1076
+ const data = await this.postWithAuth('/auth/one_time_token/generate');
1077
+ if (!data.token) {
1078
+ throw new Error('No token returned from auth link endpoint');
1079
+ }
1080
+ return data.token;
1081
+ }
1082
+ handleError(error, context) {
1083
+ this.logger.error(`Error in ${context}:`, error);
1084
+ if (this.hooks.onError) {
1085
+ this.hooks.onError(error, context);
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ const meetsBaseConditions = ({ conditions, playerSnap, addDetails, }) => {
1091
+ const conditionData = [];
1092
+ let isValid = true;
1093
+ if (conditions?.minDaysInGame) {
1094
+ const isDisqualify = (playerSnap.daysInGame || 0) < conditions.minDaysInGame;
1095
+ if (addDetails) {
1096
+ conditionData.push({
1097
+ isMet: !isDisqualify,
1098
+ kind: 'minDaysInGame',
1099
+ trackerAmount: playerSnap.daysInGame || 0,
1100
+ text: `More than ${conditions.minDaysInGame} Days in Game`,
1101
+ });
1102
+ if (isDisqualify)
1103
+ isValid = false;
1104
+ }
1105
+ else {
1106
+ if (isDisqualify)
1107
+ return { isValid: false };
1108
+ }
1109
+ }
1110
+ if (conditions?.minTrustScore) {
1111
+ const isDisqualify = (playerSnap.trustScore || 0) < conditions.minTrustScore;
1112
+ if (addDetails) {
1113
+ conditionData.push({
1114
+ isMet: !isDisqualify,
1115
+ kind: 'minTrustScore',
1116
+ trackerAmount: playerSnap.trustScore || 0,
1117
+ text: `More than ${conditions.minTrustScore} Rep`,
1118
+ });
1119
+ if (isDisqualify)
1120
+ isValid = false;
1121
+ }
1122
+ else {
1123
+ if (isDisqualify)
1124
+ return { isValid: false };
1125
+ }
1126
+ }
1127
+ if (conditions?.maxTrustScore) {
1128
+ const isDisqualify = (playerSnap.trustScore || 0) > conditions.maxTrustScore;
1129
+ if (addDetails) {
1130
+ conditionData.push({
1131
+ isMet: !isDisqualify,
1132
+ kind: 'maxTrustScore',
1133
+ trackerAmount: playerSnap.trustScore || 0,
1134
+ text: `Less than ${conditions.maxTrustScore} Rep`,
1135
+ });
1136
+ if (isDisqualify)
1137
+ isValid = false;
1138
+ }
1139
+ else {
1140
+ if (isDisqualify)
1141
+ return { isValid: false };
1142
+ }
1143
+ }
1144
+ for (const key in conditions?.achievements) {
1145
+ const a = conditions.achievements[key];
1146
+ const playerAchData = playerSnap.achievements?.[key];
1147
+ if (!playerAchData) {
1148
+ const isDisqualify = true;
1149
+ if (addDetails) {
1150
+ conditionData.push({
1151
+ isMet: isDisqualify,
1152
+ kind: 'achievements',
1153
+ trackerAmount: 0,
1154
+ text: `Have the achievement ${a.name}`,
1155
+ });
1156
+ isValid = false;
1157
+ }
1158
+ else {
1159
+ return { isValid: false };
1160
+ }
1161
+ }
1162
+ if (a.minCount) {
1163
+ const isDisqualify = (playerAchData?.count || 1) < a.minCount;
1164
+ if (addDetails) {
1165
+ conditionData.push({
1166
+ isMet: !isDisqualify,
1167
+ kind: 'achievements',
1168
+ trackerAmount: playerAchData?.count || 0,
1169
+ text: `Have the achievement ${a.name} more than ${a.minCount} times`,
1170
+ });
1171
+ if (isDisqualify)
1172
+ isValid = false;
1173
+ return { isValid: false };
1174
+ }
1175
+ else {
1176
+ if (isDisqualify)
1177
+ return { isValid: false };
1178
+ }
1179
+ }
1180
+ }
1181
+ for (const key in conditions?.currencies) {
1182
+ const c = conditions.currencies[key];
1183
+ const playerCurrencyData = playerSnap.currencies?.[key];
1184
+ if (c.min) {
1185
+ const isDisqualify = (playerCurrencyData?.balance || 0) < c.min;
1186
+ if (addDetails) {
1187
+ conditionData.push({
1188
+ isMet: !isDisqualify,
1189
+ kind: 'currencies',
1190
+ trackerAmount: playerCurrencyData?.balance || 0,
1191
+ text: `Have more than ${c.min} ${c.name}`,
1192
+ });
1193
+ if (isDisqualify)
1194
+ isValid = false;
1195
+ }
1196
+ else {
1197
+ if (isDisqualify)
1198
+ return { isValid: false };
1199
+ }
1200
+ }
1201
+ if (c.max) {
1202
+ const isDisqualify = (playerCurrencyData?.balance || 0) > c.max;
1203
+ if (addDetails) {
1204
+ conditionData.push({
1205
+ isMet: !isDisqualify,
1206
+ kind: 'currencies',
1207
+ trackerAmount: playerCurrencyData?.balance || 0,
1208
+ text: `Have less than ${c.max} ${c.name}`,
1209
+ });
1210
+ if (isDisqualify)
1211
+ isValid = false;
1212
+ }
1213
+ else {
1214
+ if (isDisqualify)
1215
+ return { isValid: false };
1216
+ }
1217
+ }
1218
+ if (c.in) {
1219
+ const isDisqualify = (playerCurrencyData?.in || 0) < c.in;
1220
+ if (addDetails) {
1221
+ conditionData.push({
1222
+ isMet: !isDisqualify,
1223
+ kind: 'currencies',
1224
+ trackerAmount: playerCurrencyData?.in || 0,
1225
+ text: `Deposit at least ${c.in} ${c.name}`,
1226
+ });
1227
+ if (isDisqualify)
1228
+ isValid = false;
1229
+ }
1230
+ else {
1231
+ if (isDisqualify)
1232
+ return { isValid: false };
1233
+ }
1234
+ }
1235
+ if (c.out) {
1236
+ const isDisqualify = (playerCurrencyData?.out || 0) < c.out;
1237
+ if (addDetails) {
1238
+ conditionData.push({
1239
+ isMet: !isDisqualify,
1240
+ kind: 'currencies',
1241
+ trackerAmount: playerCurrencyData?.out || 0,
1242
+ text: `Withdraw at least ${c.out} ${c.name}`,
1243
+ });
1244
+ if (isDisqualify)
1245
+ isValid = false;
1246
+ }
1247
+ else {
1248
+ if (isDisqualify)
1249
+ return { isValid: false };
1250
+ }
1251
+ }
1252
+ }
1253
+ for (const key in conditions?.levels) {
1254
+ const l = conditions.levels[key];
1255
+ const playerLevelData = playerSnap.levels?.[key];
1256
+ if (l.min) {
1257
+ const isDisqualify = (playerLevelData?.level || 0) < l.min;
1258
+ if (addDetails) {
1259
+ conditionData.push({
1260
+ isMet: !isDisqualify,
1261
+ kind: 'levels',
1262
+ trackerAmount: playerLevelData?.level || 0,
1263
+ text: `Be above level ${l.min} ${l.name}`,
1264
+ });
1265
+ if (isDisqualify)
1266
+ isValid = false;
1267
+ }
1268
+ else {
1269
+ if (isDisqualify)
1270
+ return { isValid: false };
1271
+ }
1272
+ }
1273
+ if (l.max) {
1274
+ const isDisqualify = (playerLevelData?.level || 0) > l.max;
1275
+ if (addDetails) {
1276
+ conditionData.push({
1277
+ isMet: !isDisqualify,
1278
+ kind: 'levels',
1279
+ trackerAmount: playerLevelData?.level || 0,
1280
+ text: `Be under level ${l.max} ${l.name}`,
1281
+ });
1282
+ if (isDisqualify)
1283
+ isValid = false;
1284
+ }
1285
+ else {
1286
+ if (isDisqualify)
1287
+ return { isValid: false };
1288
+ }
1289
+ }
1290
+ }
1291
+ if (conditions?.quests) {
1292
+ for (const questId in conditions.quests) {
1293
+ const quest = conditions.quests[questId];
1294
+ const playerQuestData = playerSnap.quests?.[questId];
1295
+ const isDisqualify = playerQuestData
1296
+ ? (playerQuestData?.completions || 0) < (quest.completions || 0)
1297
+ : false;
1298
+ if (addDetails) {
1299
+ conditionData.push({
1300
+ isMet: !isDisqualify,
1301
+ kind: 'quests',
1302
+ trackerAmount: playerQuestData?.completions || 0,
1303
+ text: quest.completions === 1
1304
+ ? `Complete the quest ${quest.name}`
1305
+ : (quest.completions || 0) < 1
1306
+ ? `Start the quest ${quest.name}`
1307
+ : `Complete the quest ${quest.name} ${quest.completions} times`,
1308
+ });
1309
+ if (isDisqualify)
1310
+ isValid = false;
1311
+ }
1312
+ else {
1313
+ if (isDisqualify)
1314
+ return { isValid: false };
1315
+ }
1316
+ }
1317
+ }
1318
+ for (const key in conditions?.memberships) {
1319
+ const m = conditions.memberships[key];
1320
+ const playerMembershipsData = playerSnap.memberships?.[key];
1321
+ if (m.minCount) {
1322
+ const isDisqualify = (playerMembershipsData?.count || 0) < m.minCount;
1323
+ if (addDetails) {
1324
+ conditionData.push({
1325
+ isMet: !isDisqualify,
1326
+ kind: 'memberships',
1327
+ trackerAmount: playerMembershipsData?.count || 0,
1328
+ text: m.minCount > 1
1329
+ ? `Have at least ${m.minCount} ${m.name} memberships`
1330
+ : `Have a ${m.name} membership`,
1331
+ });
1332
+ if (isDisqualify)
1333
+ isValid = false;
1334
+ }
1335
+ else {
1336
+ if (isDisqualify)
1337
+ return { isValid: false };
1338
+ }
1339
+ }
1340
+ if (m.maxCount) {
1341
+ const isDisqualify = (playerMembershipsData?.count || 0) > m.maxCount;
1342
+ if (addDetails) {
1343
+ conditionData.push({
1344
+ isMet: !isDisqualify,
1345
+ kind: 'memberships',
1346
+ trackerAmount: playerMembershipsData?.count || 0,
1347
+ text: `Have less than ${m.maxCount} ${m.name} memberships`,
1348
+ });
1349
+ if (isDisqualify)
1350
+ isValid = false;
1351
+ }
1352
+ else {
1353
+ if (isDisqualify)
1354
+ return { isValid: false };
1355
+ }
1356
+ }
1357
+ const timeOwned = (playerMembershipsData?.expiresAt || 0) - Date.now();
1358
+ if (m.minMs) {
1359
+ const isDisqualify = timeOwned < m.minMs;
1360
+ if (addDetails) {
1361
+ conditionData.push({
1362
+ isMet: !isDisqualify,
1363
+ kind: 'memberships',
1364
+ trackerAmount: Number((timeOwned / (1000 * 60 * 60 * 24)).toFixed(1)),
1365
+ text: `Own ${m.name} membership for at least ${(m.minMs /
1366
+ (1000 * 60 * 60 * 24)).toFixed(1)} days`,
1367
+ });
1368
+ if (isDisqualify)
1369
+ isValid = false;
1370
+ }
1371
+ else {
1372
+ if (isDisqualify)
1373
+ return { isValid: false };
1374
+ }
1375
+ }
1376
+ if (m.maxMs) {
1377
+ const isDisqualify = timeOwned > m.maxMs;
1378
+ if (addDetails) {
1379
+ conditionData.push({
1380
+ isMet: !isDisqualify,
1381
+ kind: 'memberships',
1382
+ trackerAmount: Number((timeOwned / (1000 * 60 * 60 * 24)).toFixed(1)),
1383
+ text: `Own ${m.name} membership for less than ${(m.maxMs /
1384
+ (1000 * 60 * 60 * 24)).toFixed(1)} days`,
1385
+ });
1386
+ if (isDisqualify)
1387
+ isValid = false;
1388
+ }
1389
+ else {
1390
+ if (isDisqualify)
1391
+ return { isValid: false };
1392
+ }
1393
+ }
1394
+ }
1395
+ for (const key in conditions?.stakedTokens) {
1396
+ const s = conditions.stakedTokens[key];
1397
+ const playerStakedData = playerSnap.stakedTokens?.[key];
1398
+ if (s.min) {
1399
+ const isDisqualify = (playerStakedData?.balance || 0) < s.min;
1400
+ if (addDetails) {
1401
+ conditionData.push({
1402
+ isMet: !isDisqualify,
1403
+ kind: 'stakedTokens',
1404
+ trackerAmount: playerStakedData?.balance || 0,
1405
+ text: `Have at least ${s.min} ${s.name} staked`,
1406
+ });
1407
+ if (isDisqualify)
1408
+ isValid = false;
1409
+ }
1410
+ else {
1411
+ if (isDisqualify)
1412
+ return { isValid: false };
1413
+ }
1414
+ }
1415
+ }
1416
+ return { isValid, conditionData: addDetails ? conditionData : undefined };
1417
+ };
1418
+ const meetsCompletionConditions = ({ completionConditions, completionTrackers, playerSnap, addDetails = false, }) => {
1419
+ if (completionConditions) {
1420
+ const conditions = completionConditions;
1421
+ const conditionData = [];
1422
+ let isValid = true;
1423
+ if (conditions.buyItem) {
1424
+ const isDisqualify = (completionTrackers?.buyItem || 0) < (conditions.buyItem.amount || 1);
1425
+ if (addDetails) {
1426
+ conditionData.push({
1427
+ isMet: !isDisqualify,
1428
+ kind: 'buyItem',
1429
+ trackerAmount: completionTrackers?.buyItem || 0,
1430
+ text: `Buy ${conditions.buyItem.amount || 1} ${conditions.buyItem.name}`,
1431
+ });
1432
+ if (isDisqualify)
1433
+ isValid = false;
1434
+ }
1435
+ else {
1436
+ if (isDisqualify)
1437
+ return { isValid: false };
1438
+ }
1439
+ }
1440
+ if (conditions.spendCurrency) {
1441
+ const isDisqualify = (completionTrackers?.spendCurrency || 0) < (conditions.spendCurrency.amount || 1);
1442
+ if (addDetails) {
1443
+ conditionData.push({
1444
+ isMet: !isDisqualify,
1445
+ kind: 'spendCurrency',
1446
+ trackerAmount: completionTrackers?.spendCurrency || 0,
1447
+ text: `Spend ${conditions.spendCurrency.amount || 1} ${conditions.spendCurrency.name}`,
1448
+ });
1449
+ if (isDisqualify)
1450
+ isValid = false;
1451
+ }
1452
+ else {
1453
+ if (isDisqualify)
1454
+ return { isValid: false };
1455
+ }
1456
+ }
1457
+ if (conditions.depositCurrency) {
1458
+ const isDisqualify = (completionTrackers?.depositCurrency || 0) <
1459
+ (conditions.depositCurrency.amount || 1);
1460
+ if (addDetails) {
1461
+ conditionData.push({
1462
+ isMet: !isDisqualify,
1463
+ kind: 'depositCurrency',
1464
+ trackerAmount: completionTrackers?.depositCurrency || 0,
1465
+ text: `Deposit ${conditions.depositCurrency.amount || 1} ${conditions.depositCurrency.name}`,
1466
+ });
1467
+ }
1468
+ else {
1469
+ if (isDisqualify)
1470
+ return { isValid: false };
1471
+ }
1472
+ }
1473
+ if (conditions.login) {
1474
+ const isMet = completionTrackers?.login || false;
1475
+ if (addDetails) {
1476
+ conditionData.push({
1477
+ isMet,
1478
+ kind: 'login',
1479
+ trackerAmount: completionTrackers?.login ? 1 : 0,
1480
+ text: `Login to the game`,
1481
+ });
1482
+ isValid = isMet;
1483
+ }
1484
+ else {
1485
+ if (!isMet)
1486
+ return { isValid: false };
1487
+ }
1488
+ }
1489
+ if (conditions.loginStreak) {
1490
+ // player's login streak snapshot right now - their login streak when offer was surfaced = their login streak since the offer was surfaced
1491
+ // if their login streak since the offer was surfaced is less than the required login streak, then they are not yet eligible for the offer
1492
+ const streakSinceOffer = (playerSnap.loginStreak || 0) - (completionTrackers?.currentLoginStreak || 0);
1493
+ const isDisqualify = streakSinceOffer + 1 < conditions.loginStreak;
1494
+ if (addDetails) {
1495
+ conditionData.push({
1496
+ isMet: !isDisqualify,
1497
+ kind: 'loginStreak',
1498
+ trackerAmount: (playerSnap.loginStreak || 0) - (completionTrackers?.currentLoginStreak || 0),
1499
+ text: `Login streak of ${conditions.loginStreak || 0} days`,
1500
+ });
1501
+ if (isDisqualify)
1502
+ isValid = false;
1503
+ }
1504
+ else {
1505
+ if (isDisqualify)
1506
+ return { isValid: false };
1507
+ }
1508
+ }
1509
+ const r = meetsBaseConditions({
1510
+ conditions,
1511
+ playerSnap,
1512
+ addDetails: true,
1513
+ });
1514
+ isValid = r.isValid;
1515
+ conditionData.push(...(r.conditionData || []));
1516
+ return { isValid, conditionData };
1517
+ }
1518
+ return { isValid: true, conditionData: [] };
1519
+ };
1520
+
1521
+ // Taken from buildon_server/src/commons/types/offer.ts and merged into a single interface
1522
+ const PlayerOfferStatuses = [
1523
+ 'inQueue',
1524
+ 'surfaced',
1525
+ 'viewed',
1526
+ 'claimable',
1527
+ 'claimed',
1528
+ 'expired',
1529
+ ];
1530
+
1531
+ // Taken from buildon_server/src/commons/types/reward.ts
1532
+ // Use a const assertion for the array and infer the union type directly
1533
+ const rewardKinds = [
1534
+ 'item',
1535
+ 'coins',
1536
+ 'exp',
1537
+ 'trust_points',
1538
+ /** on-chain rewards require the builder to send funds to a custodial wallet that we use to send to player wallets*/
1539
+ 'on_chain',
1540
+ ];
1541
+
1542
+ exports.AssetHelper = AssetHelper;
1543
+ exports.EventEmitter = EventEmitter;
1544
+ exports.OfferStore = OfferStore;
1545
+ exports.OfferwallClient = OfferwallClient;
1546
+ exports.PlayerOfferStatuses = PlayerOfferStatuses;
1547
+ exports.SSEConnection = SSEConnection;
1548
+ exports.meetsBaseConditions = meetsBaseConditions;
1549
+ exports.meetsCompletionConditions = meetsCompletionConditions;
1550
+ exports.rewardKinds = rewardKinds;
1551
+ //# sourceMappingURL=index.js.map