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