achievements-engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1699 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Lightweight, type-safe event emitter for the achievements engine
5
+ * Zero dependencies, memory-leak safe implementation
6
+ */
7
+ class EventEmitter {
8
+ constructor() {
9
+ this.listeners = new Map();
10
+ this.onceListeners = new Map();
11
+ }
12
+ /**
13
+ * Subscribe to an event
14
+ * @param event - Event name
15
+ * @param handler - Event handler function
16
+ * @returns Unsubscribe function
17
+ */
18
+ on(event, handler) {
19
+ if (!this.listeners.has(event)) {
20
+ this.listeners.set(event, new Set());
21
+ }
22
+ this.listeners.get(event).add(handler);
23
+ // Return unsubscribe function
24
+ return () => this.off(event, handler);
25
+ }
26
+ /**
27
+ * Subscribe to an event once (auto-unsubscribes after first emission)
28
+ * @param event - Event name
29
+ * @param handler - Event handler function
30
+ * @returns Unsubscribe function
31
+ */
32
+ once(event, handler) {
33
+ if (!this.onceListeners.has(event)) {
34
+ this.onceListeners.set(event, new Set());
35
+ }
36
+ this.onceListeners.get(event).add(handler);
37
+ // Return unsubscribe function
38
+ return () => {
39
+ const onceSet = this.onceListeners.get(event);
40
+ if (onceSet) {
41
+ onceSet.delete(handler);
42
+ }
43
+ };
44
+ }
45
+ /**
46
+ * Unsubscribe from an event
47
+ * @param event - Event name
48
+ * @param handler - Event handler function to remove
49
+ */
50
+ off(event, handler) {
51
+ const regularListeners = this.listeners.get(event);
52
+ if (regularListeners) {
53
+ regularListeners.delete(handler);
54
+ // Clean up empty sets to prevent memory leaks
55
+ if (regularListeners.size === 0) {
56
+ this.listeners.delete(event);
57
+ }
58
+ }
59
+ const onceSet = this.onceListeners.get(event);
60
+ if (onceSet) {
61
+ onceSet.delete(handler);
62
+ // Clean up empty sets
63
+ if (onceSet.size === 0) {
64
+ this.onceListeners.delete(event);
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * Emit an event to all subscribers
70
+ * @param event - Event name
71
+ * @param data - Event payload
72
+ */
73
+ emit(event, data) {
74
+ // Call regular listeners
75
+ const regularListeners = this.listeners.get(event);
76
+ if (regularListeners) {
77
+ // Create a copy to avoid issues if listeners modify the set during iteration
78
+ const listenersCopy = Array.from(regularListeners);
79
+ listenersCopy.forEach(handler => {
80
+ try {
81
+ handler(data);
82
+ }
83
+ catch (error) {
84
+ // Prevent one handler's error from stopping other handlers
85
+ console.error(`Error in event handler for "${event}":`, error);
86
+ }
87
+ });
88
+ }
89
+ // Call once listeners and remove them
90
+ const onceSet = this.onceListeners.get(event);
91
+ if (onceSet) {
92
+ const onceListenersCopy = Array.from(onceSet);
93
+ // Clear the set before calling handlers to prevent re-entry issues
94
+ this.onceListeners.delete(event);
95
+ onceListenersCopy.forEach(handler => {
96
+ try {
97
+ handler(data);
98
+ }
99
+ catch (error) {
100
+ console.error(`Error in once event handler for "${event}":`, error);
101
+ }
102
+ });
103
+ }
104
+ }
105
+ /**
106
+ * Remove all listeners for a specific event, or all events if no event specified
107
+ * @param event - Optional event name. If not provided, removes all listeners.
108
+ */
109
+ removeAllListeners(event) {
110
+ if (event) {
111
+ this.listeners.delete(event);
112
+ this.onceListeners.delete(event);
113
+ }
114
+ else {
115
+ this.listeners.clear();
116
+ this.onceListeners.clear();
117
+ }
118
+ }
119
+ /**
120
+ * Get the number of listeners for an event
121
+ * @param event - Event name
122
+ * @returns Number of listeners
123
+ */
124
+ listenerCount(event) {
125
+ var _a, _b;
126
+ const regularCount = ((_a = this.listeners.get(event)) === null || _a === void 0 ? void 0 : _a.size) || 0;
127
+ const onceCount = ((_b = this.onceListeners.get(event)) === null || _b === void 0 ? void 0 : _b.size) || 0;
128
+ return regularCount + onceCount;
129
+ }
130
+ /**
131
+ * Get all event names that have listeners
132
+ * @returns Array of event names
133
+ */
134
+ eventNames() {
135
+ const regularEvents = Array.from(this.listeners.keys());
136
+ const onceEvents = Array.from(this.onceListeners.keys());
137
+ // Combine and deduplicate
138
+ return Array.from(new Set([...regularEvents, ...onceEvents]));
139
+ }
140
+ }
141
+
142
+ // Type guard to check if config is simple format
143
+ function isSimpleConfig(config) {
144
+ if (!config || typeof config !== 'object')
145
+ return false;
146
+ const firstKey = Object.keys(config)[0];
147
+ if (!firstKey)
148
+ return true; // Empty config is considered simple
149
+ const firstValue = config[firstKey];
150
+ // Check if it's the current complex format (array of AchievementCondition)
151
+ if (Array.isArray(firstValue))
152
+ return false;
153
+ // Check if it's the simple format (object with string keys)
154
+ return typeof firstValue === 'object' && !Array.isArray(firstValue);
155
+ }
156
+ // Generate a unique ID for achievements
157
+ function generateId() {
158
+ return Math.random().toString(36).substr(2, 9);
159
+ }
160
+ // Check if achievement details has a custom condition
161
+ function hasCustomCondition(details) {
162
+ return 'condition' in details && typeof details.condition === 'function';
163
+ }
164
+ // Convert simple config to complex config format
165
+ function normalizeAchievements(config) {
166
+ if (!isSimpleConfig(config)) {
167
+ // Already in complex format, return as-is
168
+ return config;
169
+ }
170
+ const normalized = {};
171
+ Object.entries(config).forEach(([metric, achievements]) => {
172
+ normalized[metric] = Object.entries(achievements).map(([key, achievement]) => {
173
+ if (hasCustomCondition(achievement)) {
174
+ // Custom condition function
175
+ return {
176
+ isConditionMet: (_value, _state) => {
177
+ // Convert internal metrics format (arrays) to simple format for custom conditions
178
+ const simpleMetrics = {};
179
+ Object.entries(_state.metrics).forEach(([key, val]) => {
180
+ simpleMetrics[key] = Array.isArray(val) ? val[0] : val;
181
+ });
182
+ return achievement.condition(simpleMetrics);
183
+ },
184
+ achievementDetails: {
185
+ achievementId: `${metric}_custom_${generateId()}`,
186
+ achievementTitle: achievement.title,
187
+ achievementDescription: achievement.description || '',
188
+ achievementIconKey: achievement.icon || 'default'
189
+ }
190
+ };
191
+ }
192
+ else {
193
+ // Threshold-based achievement
194
+ const threshold = parseFloat(key);
195
+ const isValidThreshold = !isNaN(threshold);
196
+ let conditionMet;
197
+ if (isValidThreshold) {
198
+ // Numeric threshold
199
+ conditionMet = (value) => {
200
+ const numValue = Array.isArray(value) ? value[0] : value;
201
+ return typeof numValue === 'number' && numValue >= threshold;
202
+ };
203
+ }
204
+ else {
205
+ // String or boolean threshold
206
+ conditionMet = (value) => {
207
+ const actualValue = Array.isArray(value) ? value[0] : value;
208
+ // Handle boolean thresholds
209
+ if (key === 'true')
210
+ return actualValue === true;
211
+ if (key === 'false')
212
+ return actualValue === false;
213
+ // Handle string thresholds
214
+ return actualValue === key;
215
+ };
216
+ }
217
+ return {
218
+ isConditionMet: conditionMet,
219
+ achievementDetails: {
220
+ achievementId: `${metric}_${key}`,
221
+ achievementTitle: achievement.title,
222
+ achievementDescription: achievement.description || (isValidThreshold ? `Reach ${threshold} ${metric}` : `Achieve ${key} for ${metric}`),
223
+ achievementIconKey: achievement.icon || 'default'
224
+ }
225
+ };
226
+ }
227
+ });
228
+ });
229
+ return normalized;
230
+ }
231
+
232
+ /**
233
+ * Exports achievement data to a JSON string
234
+ *
235
+ * @param metrics - Current achievement metrics
236
+ * @param unlocked - Array of unlocked achievement IDs
237
+ * @param configHash - Optional hash of achievement configuration for validation
238
+ * @returns JSON string containing all achievement data
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * const json = exportAchievementData(_metrics, ['score_100', 'level_5']);
243
+ * // Save json to file or send to server
244
+ * ```
245
+ */
246
+ function exportAchievementData(metrics, unlocked, configHash) {
247
+ const data = Object.assign({ version: '3.3.0', timestamp: Date.now(), metrics, unlockedAchievements: unlocked }, (configHash && { configHash }));
248
+ return JSON.stringify(data, null, 2);
249
+ }
250
+ /**
251
+ * Creates a simple hash of the achievement configuration
252
+ * Used to validate that imported data matches the current configuration
253
+ *
254
+ * @param config - Achievement configuration object
255
+ * @returns Simple hash string
256
+ */
257
+ function createConfigHash(config) {
258
+ // Simple hash based on stringified config
259
+ // In production, you might want to use a more robust hashing algorithm
260
+ const str = JSON.stringify(config);
261
+ let hash = 0;
262
+ for (let i = 0; i < str.length; i++) {
263
+ const char = str.charCodeAt(i);
264
+ hash = ((hash << 5) - hash) + char;
265
+ hash = hash & hash; // Convert to 32bit integer
266
+ }
267
+ return hash.toString(36);
268
+ }
269
+
270
+ /**
271
+ * Imports achievement data from a JSON string
272
+ *
273
+ * @param jsonString - JSON string containing exported achievement data
274
+ * @param currentMetrics - Current metrics state
275
+ * @param currentUnlocked - Current unlocked achievements
276
+ * @param options - Import options
277
+ * @returns Import result with success status and any errors
278
+ *
279
+ * @example
280
+ * ```typescript
281
+ * const result = importAchievementData(
282
+ * jsonString,
283
+ * currentMetrics,
284
+ * currentUnlocked,
285
+ * { mergeStrategy: 'merge', validate: true }
286
+ * );
287
+ *
288
+ * if (result.success) {
289
+ * console.log(`Imported ${result.imported.achievements} achievements`);
290
+ * } else {
291
+ * console.error('Import failed:', result.errors);
292
+ * }
293
+ * ```
294
+ */
295
+ function importAchievementData(jsonString, currentMetrics, currentUnlocked, options = {}) {
296
+ const { mergeStrategy = 'replace', validate = true, expectedConfigHash } = options;
297
+ const warnings = [];
298
+ // Parse JSON
299
+ let data;
300
+ try {
301
+ data = JSON.parse(jsonString);
302
+ }
303
+ catch (_a) {
304
+ return {
305
+ success: false,
306
+ imported: { metrics: 0, achievements: 0 },
307
+ errors: ['Invalid JSON format']
308
+ };
309
+ }
310
+ // Validate structure
311
+ if (validate) {
312
+ const validationErrors = validateExportedData(data, expectedConfigHash);
313
+ if (validationErrors.length > 0) {
314
+ return {
315
+ success: false,
316
+ imported: { metrics: 0, achievements: 0 },
317
+ errors: validationErrors
318
+ };
319
+ }
320
+ }
321
+ // Version compatibility check
322
+ if (data.version && data.version !== '3.3.0') {
323
+ warnings.push(`Data exported from version ${data.version}, current version is 3.3.0`);
324
+ }
325
+ // Merge metrics based on strategy
326
+ let mergedMetrics;
327
+ let mergedUnlocked;
328
+ switch (mergeStrategy) {
329
+ case 'replace':
330
+ // Replace all existing data
331
+ mergedMetrics = data.metrics;
332
+ mergedUnlocked = data.unlockedAchievements;
333
+ break;
334
+ case 'merge':
335
+ // Union of both datasets, keeping higher metric values
336
+ mergedMetrics = mergeMetrics(currentMetrics, data.metrics);
337
+ mergedUnlocked = Array.from(new Set([...currentUnlocked, ...data.unlockedAchievements]));
338
+ break;
339
+ case 'preserve':
340
+ // Keep existing values, only add new ones
341
+ mergedMetrics = preserveMetrics(currentMetrics, data.metrics);
342
+ mergedUnlocked = Array.from(new Set([...currentUnlocked, ...data.unlockedAchievements]));
343
+ break;
344
+ default:
345
+ return {
346
+ success: false,
347
+ imported: { metrics: 0, achievements: 0 },
348
+ errors: [`Invalid merge strategy: ${mergeStrategy}`]
349
+ };
350
+ }
351
+ return Object.assign(Object.assign({ success: true, imported: {
352
+ metrics: Object.keys(mergedMetrics).length,
353
+ achievements: mergedUnlocked.length
354
+ } }, (warnings.length > 0 && { warnings })), { mergedMetrics,
355
+ mergedUnlocked });
356
+ }
357
+ /**
358
+ * Validates the structure and content of exported data
359
+ */
360
+ function validateExportedData(data, expectedConfigHash) {
361
+ const errors = [];
362
+ // Check required fields
363
+ if (!data.version) {
364
+ errors.push('Missing version field');
365
+ }
366
+ if (!data.timestamp) {
367
+ errors.push('Missing timestamp field');
368
+ }
369
+ if (!data.metrics || typeof data.metrics !== 'object') {
370
+ errors.push('Missing or invalid metrics field');
371
+ }
372
+ if (!Array.isArray(data.unlockedAchievements)) {
373
+ errors.push('Missing or invalid unlockedAchievements field');
374
+ }
375
+ // Validate config hash if provided
376
+ if (expectedConfigHash && data.configHash && data.configHash !== expectedConfigHash) {
377
+ errors.push('Configuration mismatch: imported data may not be compatible with current achievement configuration');
378
+ }
379
+ // Validate metrics structure
380
+ if (data.metrics && typeof data.metrics === 'object') {
381
+ for (const [key, value] of Object.entries(data.metrics)) {
382
+ if (!Array.isArray(value)) {
383
+ errors.push(`Invalid metric format for "${key}": expected array, got ${typeof value}`);
384
+ }
385
+ }
386
+ }
387
+ // Validate achievement IDs are strings
388
+ if (Array.isArray(data.unlockedAchievements)) {
389
+ const invalidIds = data.unlockedAchievements.filter((id) => typeof id !== 'string');
390
+ if (invalidIds.length > 0) {
391
+ errors.push('All achievement IDs must be strings');
392
+ }
393
+ }
394
+ return errors;
395
+ }
396
+ /**
397
+ * Merges two metrics objects, keeping higher values for overlapping keys
398
+ */
399
+ function mergeMetrics(current, imported) {
400
+ const merged = Object.assign({}, current);
401
+ for (const [key, importedValues] of Object.entries(imported)) {
402
+ if (!merged[key]) {
403
+ // New metric, add it
404
+ merged[key] = importedValues;
405
+ }
406
+ else {
407
+ // Existing metric, merge values
408
+ merged[key] = mergeMetricValues(merged[key], importedValues);
409
+ }
410
+ }
411
+ return merged;
412
+ }
413
+ /**
414
+ * Merges two metric value arrays, keeping higher numeric values
415
+ */
416
+ function mergeMetricValues(current, imported) {
417
+ // For simplicity, we'll use the imported values if they're "higher"
418
+ // This works for numeric values; for other types, we prefer imported
419
+ const currentValue = current[0];
420
+ const importedValue = imported[0];
421
+ // If both are numbers, keep the higher one
422
+ if (typeof currentValue === 'number' && typeof importedValue === 'number') {
423
+ return currentValue >= importedValue ? current : imported;
424
+ }
425
+ // For non-numeric values, prefer imported (assume it's newer)
426
+ return imported;
427
+ }
428
+ /**
429
+ * Preserves existing metrics, only adding new ones from imported data
430
+ */
431
+ function preserveMetrics(current, imported) {
432
+ const preserved = Object.assign({}, current);
433
+ for (const [key, value] of Object.entries(imported)) {
434
+ if (!preserved[key]) {
435
+ // Only add if it doesn't exist
436
+ preserved[key] = value;
437
+ }
438
+ // If it exists, keep current value (preserve strategy)
439
+ }
440
+ return preserved;
441
+ }
442
+
443
+ /**
444
+ * Type definitions for the achievements engine
445
+ * Framework-agnostic achievement system types
446
+ */
447
+ const isDate = (value) => {
448
+ return value instanceof Date;
449
+ };
450
+ // Type guard to detect async storage
451
+ function isAsyncStorage(storage) {
452
+ // Check if methods return Promises
453
+ const testResult = storage.getMetrics();
454
+ return testResult && typeof testResult.then === 'function';
455
+ }
456
+ exports.StorageType = void 0;
457
+ (function (StorageType) {
458
+ StorageType["Local"] = "local";
459
+ StorageType["Memory"] = "memory";
460
+ StorageType["IndexedDB"] = "indexeddb";
461
+ StorageType["RestAPI"] = "restapi"; // Asynchronous REST API storage
462
+ })(exports.StorageType || (exports.StorageType = {}));
463
+
464
+ /**
465
+ * Base error class for all achievement-related errors
466
+ */
467
+ class AchievementError extends Error {
468
+ constructor(message, code, recoverable, remedy) {
469
+ super(message);
470
+ this.code = code;
471
+ this.recoverable = recoverable;
472
+ this.remedy = remedy;
473
+ this.name = 'AchievementError';
474
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
475
+ if (Error.captureStackTrace) {
476
+ Error.captureStackTrace(this, AchievementError);
477
+ }
478
+ }
479
+ }
480
+ /**
481
+ * Error thrown when browser storage quota is exceeded
482
+ */
483
+ class StorageQuotaError extends AchievementError {
484
+ constructor(bytesNeeded) {
485
+ super('Browser storage quota exceeded. Achievement data could not be saved.', 'STORAGE_QUOTA_EXCEEDED', true, 'Clear browser storage, reduce the number of achievements, or use an external database backend. You can export your current data using exportData() before clearing storage.');
486
+ this.bytesNeeded = bytesNeeded;
487
+ this.name = 'StorageQuotaError';
488
+ }
489
+ }
490
+ /**
491
+ * Error thrown when imported data fails validation
492
+ */
493
+ class ImportValidationError extends AchievementError {
494
+ constructor(validationErrors) {
495
+ super(`Imported data failed validation: ${validationErrors.join(', ')}`, 'IMPORT_VALIDATION_ERROR', true, 'Check that the imported data was exported from a compatible version and matches your current achievement configuration.');
496
+ this.validationErrors = validationErrors;
497
+ this.name = 'ImportValidationError';
498
+ }
499
+ }
500
+ /**
501
+ * Error thrown when storage operations fail
502
+ */
503
+ class StorageError extends AchievementError {
504
+ constructor(message, originalError) {
505
+ super(message, 'STORAGE_ERROR', true, 'Check browser storage permissions and available space. If using custom storage, verify the implementation is correct.');
506
+ this.originalError = originalError;
507
+ this.name = 'StorageError';
508
+ }
509
+ }
510
+ /**
511
+ * Error thrown when configuration is invalid
512
+ */
513
+ class ConfigurationError extends AchievementError {
514
+ constructor(message) {
515
+ super(message, 'CONFIGURATION_ERROR', false, 'Review your achievement configuration and ensure it follows the correct format.');
516
+ this.name = 'ConfigurationError';
517
+ }
518
+ }
519
+ /**
520
+ * Error thrown when network sync operations fail
521
+ */
522
+ class SyncError extends AchievementError {
523
+ constructor(message, details) {
524
+ super(message, 'SYNC_ERROR', true, // recoverable (can retry)
525
+ 'Check your network connection and try again. If the problem persists, achievements will sync when connection is restored.');
526
+ this.name = 'SyncError';
527
+ this.statusCode = details === null || details === void 0 ? void 0 : details.statusCode;
528
+ this.timeout = details === null || details === void 0 ? void 0 : details.timeout;
529
+ }
530
+ }
531
+ /**
532
+ * Type guard to check if an error is an AchievementError
533
+ */
534
+ function isAchievementError(error) {
535
+ return error instanceof AchievementError;
536
+ }
537
+ /**
538
+ * Type guard to check if an error is recoverable
539
+ */
540
+ function isRecoverableError(error) {
541
+ return isAchievementError(error) && error.recoverable;
542
+ }
543
+
544
+ class LocalStorage {
545
+ constructor(storageKey) {
546
+ this.storageKey = storageKey;
547
+ }
548
+ serializeValue(value) {
549
+ if (isDate(value)) {
550
+ return { __type: 'Date', value: value.toISOString() };
551
+ }
552
+ return value;
553
+ }
554
+ deserializeValue(value) {
555
+ if (value && typeof value === 'object' && value.__type === 'Date') {
556
+ return new Date(value.value);
557
+ }
558
+ return value;
559
+ }
560
+ serializeMetrics(metrics) {
561
+ const serialized = {};
562
+ for (const [key, values] of Object.entries(metrics)) {
563
+ serialized[key] = values.map(this.serializeValue);
564
+ }
565
+ return serialized;
566
+ }
567
+ deserializeMetrics(metrics) {
568
+ if (!metrics)
569
+ return {};
570
+ const deserialized = {};
571
+ for (const [key, values] of Object.entries(metrics)) {
572
+ deserialized[key] = values.map(this.deserializeValue);
573
+ }
574
+ return deserialized;
575
+ }
576
+ getStorageData() {
577
+ const data = localStorage.getItem(this.storageKey);
578
+ if (!data)
579
+ return { metrics: {}, unlockedAchievements: [] };
580
+ try {
581
+ const parsed = JSON.parse(data);
582
+ return {
583
+ metrics: this.deserializeMetrics(parsed.metrics || {}),
584
+ unlockedAchievements: parsed.unlockedAchievements || []
585
+ };
586
+ }
587
+ catch (_a) {
588
+ return { metrics: {}, unlockedAchievements: [] };
589
+ }
590
+ }
591
+ setStorageData(data) {
592
+ try {
593
+ const serialized = {
594
+ metrics: this.serializeMetrics(data.metrics),
595
+ unlockedAchievements: data.unlockedAchievements
596
+ };
597
+ const jsonString = JSON.stringify(serialized);
598
+ localStorage.setItem(this.storageKey, jsonString);
599
+ }
600
+ catch (error) {
601
+ // Throw proper error instead of silently failing
602
+ if (error instanceof DOMException &&
603
+ (error.name === 'QuotaExceededError' ||
604
+ error.name === 'NS_ERROR_DOM_QUOTA_REACHED')) {
605
+ const serialized = {
606
+ metrics: this.serializeMetrics(data.metrics),
607
+ unlockedAchievements: data.unlockedAchievements
608
+ };
609
+ const bytesNeeded = JSON.stringify(serialized).length;
610
+ throw new StorageQuotaError(bytesNeeded);
611
+ }
612
+ if (error instanceof Error) {
613
+ if (error.message && error.message.includes('QuotaExceeded')) {
614
+ const serialized = {
615
+ metrics: this.serializeMetrics(data.metrics),
616
+ unlockedAchievements: data.unlockedAchievements
617
+ };
618
+ const bytesNeeded = JSON.stringify(serialized).length;
619
+ throw new StorageQuotaError(bytesNeeded);
620
+ }
621
+ throw new StorageError(`Failed to save achievement data: ${error.message}`, error);
622
+ }
623
+ throw new StorageError('Failed to save achievement data');
624
+ }
625
+ }
626
+ getMetrics() {
627
+ return this.getStorageData().metrics;
628
+ }
629
+ setMetrics(metrics) {
630
+ const data = this.getStorageData();
631
+ this.setStorageData(Object.assign(Object.assign({}, data), { metrics }));
632
+ }
633
+ getUnlockedAchievements() {
634
+ return this.getStorageData().unlockedAchievements;
635
+ }
636
+ setUnlockedAchievements(achievements) {
637
+ const data = this.getStorageData();
638
+ this.setStorageData(Object.assign(Object.assign({}, data), { unlockedAchievements: achievements }));
639
+ }
640
+ clear() {
641
+ localStorage.removeItem(this.storageKey);
642
+ }
643
+ }
644
+
645
+ class MemoryStorage {
646
+ constructor() {
647
+ this.metrics = {};
648
+ this.unlockedAchievements = [];
649
+ }
650
+ getMetrics() {
651
+ return this.metrics;
652
+ }
653
+ setMetrics(metrics) {
654
+ this.metrics = metrics;
655
+ }
656
+ getUnlockedAchievements() {
657
+ return this.unlockedAchievements;
658
+ }
659
+ setUnlockedAchievements(achievements) {
660
+ this.unlockedAchievements = achievements;
661
+ }
662
+ clear() {
663
+ this.metrics = {};
664
+ this.unlockedAchievements = [];
665
+ }
666
+ }
667
+
668
+ /******************************************************************************
669
+ Copyright (c) Microsoft Corporation.
670
+
671
+ Permission to use, copy, modify, and/or distribute this software for any
672
+ purpose with or without fee is hereby granted.
673
+
674
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
675
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
676
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
677
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
678
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
679
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
680
+ PERFORMANCE OF THIS SOFTWARE.
681
+ ***************************************************************************** */
682
+ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
683
+
684
+
685
+ function __awaiter(thisArg, _arguments, P, generator) {
686
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
687
+ return new (P || (P = Promise))(function (resolve, reject) {
688
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
689
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
690
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
691
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
692
+ });
693
+ }
694
+
695
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
696
+ var e = new Error(message);
697
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
698
+ };
699
+
700
+ class IndexedDBStorage {
701
+ constructor(dbName = 'react-achievements') {
702
+ this.storeName = 'achievements';
703
+ this.db = null;
704
+ this.dbName = dbName;
705
+ this.initPromise = this.initDB();
706
+ }
707
+ /**
708
+ * Initialize IndexedDB database and object store
709
+ */
710
+ initDB() {
711
+ return __awaiter(this, void 0, void 0, function* () {
712
+ return new Promise((resolve, reject) => {
713
+ const request = indexedDB.open(this.dbName, 1);
714
+ request.onerror = () => {
715
+ reject(new StorageError('Failed to open IndexedDB'));
716
+ };
717
+ request.onsuccess = () => {
718
+ this.db = request.result;
719
+ resolve();
720
+ };
721
+ request.onupgradeneeded = (event) => {
722
+ const db = event.target.result;
723
+ // Create object store if it doesn't exist
724
+ if (!db.objectStoreNames.contains(this.storeName)) {
725
+ db.createObjectStore(this.storeName);
726
+ }
727
+ };
728
+ });
729
+ });
730
+ }
731
+ /**
732
+ * Generic get operation from IndexedDB
733
+ */
734
+ get(key) {
735
+ return __awaiter(this, void 0, void 0, function* () {
736
+ yield this.initPromise;
737
+ if (!this.db)
738
+ throw new StorageError('Database not initialized');
739
+ return new Promise((resolve, reject) => {
740
+ const transaction = this.db.transaction([this.storeName], 'readonly');
741
+ const store = transaction.objectStore(this.storeName);
742
+ const request = store.get(key);
743
+ request.onsuccess = () => {
744
+ resolve(request.result || null);
745
+ };
746
+ request.onerror = () => {
747
+ reject(new StorageError(`Failed to read from IndexedDB: ${key}`));
748
+ };
749
+ transaction.onerror = () => {
750
+ reject(new StorageError(`Transaction failed for key: ${key}`));
751
+ };
752
+ });
753
+ });
754
+ }
755
+ /**
756
+ * Generic set operation to IndexedDB
757
+ */
758
+ set(key, value) {
759
+ return __awaiter(this, void 0, void 0, function* () {
760
+ yield this.initPromise;
761
+ if (!this.db)
762
+ throw new StorageError('Database not initialized');
763
+ return new Promise((resolve, reject) => {
764
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
765
+ const store = transaction.objectStore(this.storeName);
766
+ const request = store.put(value, key);
767
+ request.onsuccess = () => {
768
+ resolve();
769
+ };
770
+ request.onerror = () => {
771
+ reject(new StorageError(`Failed to write to IndexedDB: ${key}`));
772
+ };
773
+ transaction.onerror = () => {
774
+ reject(new StorageError(`Transaction failed for key: ${key}`));
775
+ };
776
+ });
777
+ });
778
+ }
779
+ /**
780
+ * Delete operation from IndexedDB
781
+ */
782
+ delete(key) {
783
+ return __awaiter(this, void 0, void 0, function* () {
784
+ yield this.initPromise;
785
+ if (!this.db)
786
+ throw new StorageError('Database not initialized');
787
+ return new Promise((resolve, reject) => {
788
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
789
+ const store = transaction.objectStore(this.storeName);
790
+ const request = store.delete(key);
791
+ request.onsuccess = () => {
792
+ resolve();
793
+ };
794
+ request.onerror = () => {
795
+ reject(new StorageError(`Failed to delete from IndexedDB: ${key}`));
796
+ };
797
+ transaction.onerror = () => {
798
+ reject(new StorageError(`Transaction failed while deleting key: ${key}`));
799
+ };
800
+ });
801
+ });
802
+ }
803
+ getMetrics() {
804
+ return __awaiter(this, void 0, void 0, function* () {
805
+ const metrics = yield this.get('metrics');
806
+ return metrics || {};
807
+ });
808
+ }
809
+ setMetrics(metrics) {
810
+ return __awaiter(this, void 0, void 0, function* () {
811
+ yield this.set('metrics', metrics);
812
+ });
813
+ }
814
+ getUnlockedAchievements() {
815
+ return __awaiter(this, void 0, void 0, function* () {
816
+ const unlocked = yield this.get('unlocked');
817
+ return unlocked || [];
818
+ });
819
+ }
820
+ setUnlockedAchievements(achievements) {
821
+ return __awaiter(this, void 0, void 0, function* () {
822
+ yield this.set('unlocked', achievements);
823
+ });
824
+ }
825
+ clear() {
826
+ return __awaiter(this, void 0, void 0, function* () {
827
+ yield Promise.all([
828
+ this.delete('metrics'),
829
+ this.delete('unlocked')
830
+ ]);
831
+ });
832
+ }
833
+ /**
834
+ * Close the database connection
835
+ */
836
+ close() {
837
+ if (this.db) {
838
+ this.db.close();
839
+ this.db = null;
840
+ }
841
+ }
842
+ }
843
+
844
+ class RestApiStorage {
845
+ constructor(config) {
846
+ this.config = Object.assign({ timeout: 10000, headers: {} }, config);
847
+ }
848
+ /**
849
+ * Generic fetch wrapper with timeout and error handling
850
+ */
851
+ fetchWithTimeout(url, options) {
852
+ return __awaiter(this, void 0, void 0, function* () {
853
+ const controller = new AbortController();
854
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
855
+ try {
856
+ const response = yield fetch(url, Object.assign(Object.assign({}, options), { headers: Object.assign(Object.assign({ 'Content-Type': 'application/json' }, this.config.headers), options.headers), signal: controller.signal }));
857
+ clearTimeout(timeoutId);
858
+ if (!response.ok) {
859
+ throw new SyncError(`HTTP ${response.status}: ${response.statusText}`, { statusCode: response.status });
860
+ }
861
+ return response;
862
+ }
863
+ catch (error) {
864
+ clearTimeout(timeoutId);
865
+ if (error instanceof Error && error.name === 'AbortError') {
866
+ throw new SyncError('Request timeout', { timeout: this.config.timeout });
867
+ }
868
+ throw error;
869
+ }
870
+ });
871
+ }
872
+ getMetrics() {
873
+ return __awaiter(this, void 0, void 0, function* () {
874
+ var _a;
875
+ try {
876
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
877
+ const response = yield this.fetchWithTimeout(url, { method: 'GET' });
878
+ const data = yield response.json();
879
+ return data.metrics || {};
880
+ }
881
+ catch (error) {
882
+ // Re-throw SyncError and other AchievementErrors (but not StorageError)
883
+ // Multiple checks for Jest compatibility
884
+ const err = error;
885
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError') {
886
+ throw error;
887
+ }
888
+ // Also check instanceof for normal cases
889
+ if (error instanceof AchievementError && !(error instanceof StorageError)) {
890
+ throw error;
891
+ }
892
+ throw new StorageError('Failed to fetch metrics from API', error);
893
+ }
894
+ });
895
+ }
896
+ setMetrics(metrics) {
897
+ return __awaiter(this, void 0, void 0, function* () {
898
+ var _a;
899
+ try {
900
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
901
+ yield this.fetchWithTimeout(url, {
902
+ method: 'PUT',
903
+ body: JSON.stringify({ metrics })
904
+ });
905
+ }
906
+ catch (error) {
907
+ const err = error;
908
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
909
+ throw error;
910
+ if (error instanceof AchievementError && !(error instanceof StorageError))
911
+ throw error;
912
+ throw new StorageError('Failed to save metrics to API', error);
913
+ }
914
+ });
915
+ }
916
+ getUnlockedAchievements() {
917
+ return __awaiter(this, void 0, void 0, function* () {
918
+ var _a;
919
+ try {
920
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
921
+ const response = yield this.fetchWithTimeout(url, { method: 'GET' });
922
+ const data = yield response.json();
923
+ return data.unlocked || [];
924
+ }
925
+ catch (error) {
926
+ const err = error;
927
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
928
+ throw error;
929
+ if (error instanceof AchievementError && !(error instanceof StorageError))
930
+ throw error;
931
+ throw new StorageError('Failed to fetch unlocked achievements from API', error);
932
+ }
933
+ });
934
+ }
935
+ setUnlockedAchievements(achievements) {
936
+ return __awaiter(this, void 0, void 0, function* () {
937
+ var _a;
938
+ try {
939
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
940
+ yield this.fetchWithTimeout(url, {
941
+ method: 'PUT',
942
+ body: JSON.stringify({ unlocked: achievements })
943
+ });
944
+ }
945
+ catch (error) {
946
+ const err = error;
947
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
948
+ throw error;
949
+ if (error instanceof AchievementError && !(error instanceof StorageError))
950
+ throw error;
951
+ throw new StorageError('Failed to save unlocked achievements to API', error);
952
+ }
953
+ });
954
+ }
955
+ clear() {
956
+ return __awaiter(this, void 0, void 0, function* () {
957
+ var _a;
958
+ try {
959
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements`;
960
+ yield this.fetchWithTimeout(url, { method: 'DELETE' });
961
+ }
962
+ catch (error) {
963
+ const err = error;
964
+ if (((_a = err === null || err === void 0 ? void 0 : err.constructor) === null || _a === void 0 ? void 0 : _a.name) === 'SyncError' || (err === null || err === void 0 ? void 0 : err.name) === 'SyncError')
965
+ throw error;
966
+ if (error instanceof AchievementError && !(error instanceof StorageError))
967
+ throw error;
968
+ throw new StorageError('Failed to clear achievements via API', error);
969
+ }
970
+ });
971
+ }
972
+ }
973
+
974
+ class AsyncStorageAdapter {
975
+ constructor(asyncStorage, options) {
976
+ this.pendingWrites = [];
977
+ this.asyncStorage = asyncStorage;
978
+ this.onError = options === null || options === void 0 ? void 0 : options.onError;
979
+ this.cache = {
980
+ metrics: {},
981
+ unlocked: [],
982
+ loaded: false
983
+ };
984
+ // Eagerly load data from async storage (non-blocking)
985
+ this.initializeCache();
986
+ }
987
+ /**
988
+ * Initialize cache by loading from async storage
989
+ * This happens in the background during construction
990
+ */
991
+ initializeCache() {
992
+ return __awaiter(this, void 0, void 0, function* () {
993
+ try {
994
+ const [metrics, unlocked] = yield Promise.all([
995
+ this.asyncStorage.getMetrics(),
996
+ this.asyncStorage.getUnlockedAchievements()
997
+ ]);
998
+ this.cache.metrics = metrics;
999
+ this.cache.unlocked = unlocked;
1000
+ this.cache.loaded = true;
1001
+ }
1002
+ catch (error) {
1003
+ // Handle initialization errors
1004
+ console.error('Failed to initialize async storage:', error);
1005
+ if (this.onError) {
1006
+ const storageError = error instanceof AchievementError
1007
+ ? error
1008
+ : new StorageError('Failed to initialize storage', error);
1009
+ this.onError(storageError);
1010
+ }
1011
+ // Set to empty state on error
1012
+ this.cache.loaded = true; // Mark as loaded even on error to prevent blocking
1013
+ }
1014
+ });
1015
+ }
1016
+ /**
1017
+ * Wait for cache to be loaded (used internally)
1018
+ * Returns immediately if already loaded, otherwise waits
1019
+ */
1020
+ ensureCacheLoaded() {
1021
+ return __awaiter(this, void 0, void 0, function* () {
1022
+ while (!this.cache.loaded) {
1023
+ yield new Promise(resolve => setTimeout(resolve, 10));
1024
+ }
1025
+ });
1026
+ }
1027
+ /**
1028
+ * SYNC READ: Returns cached metrics immediately
1029
+ * Cache is loaded eagerly during construction
1030
+ */
1031
+ getMetrics() {
1032
+ return this.cache.metrics;
1033
+ }
1034
+ /**
1035
+ * SYNC WRITE: Updates cache immediately, writes to storage in background
1036
+ * Uses optimistic updates - assumes write will succeed
1037
+ */
1038
+ setMetrics(metrics) {
1039
+ // Update cache immediately (optimistic update)
1040
+ this.cache.metrics = metrics;
1041
+ // Write to async storage in background
1042
+ const writePromise = this.asyncStorage.setMetrics(metrics).catch(error => {
1043
+ console.error('Failed to write metrics to async storage:', error);
1044
+ if (this.onError) {
1045
+ const storageError = error instanceof AchievementError
1046
+ ? error
1047
+ : new StorageError('Failed to write metrics', error);
1048
+ this.onError(storageError);
1049
+ }
1050
+ });
1051
+ // Track pending write for cleanup/testing
1052
+ this.pendingWrites.push(writePromise);
1053
+ }
1054
+ /**
1055
+ * SYNC READ: Returns cached unlocked achievements immediately
1056
+ */
1057
+ getUnlockedAchievements() {
1058
+ return this.cache.unlocked;
1059
+ }
1060
+ /**
1061
+ * SYNC WRITE: Updates cache immediately, writes to storage in background
1062
+ */
1063
+ setUnlockedAchievements(achievements) {
1064
+ // Update cache immediately (optimistic update)
1065
+ this.cache.unlocked = achievements;
1066
+ // Write to async storage in background
1067
+ const writePromise = this.asyncStorage.setUnlockedAchievements(achievements).catch(error => {
1068
+ console.error('Failed to write unlocked achievements to async storage:', error);
1069
+ if (this.onError) {
1070
+ const storageError = error instanceof AchievementError
1071
+ ? error
1072
+ : new StorageError('Failed to write achievements', error);
1073
+ this.onError(storageError);
1074
+ }
1075
+ });
1076
+ // Track pending write
1077
+ this.pendingWrites.push(writePromise);
1078
+ }
1079
+ /**
1080
+ * SYNC CLEAR: Clears cache immediately, clears storage in background
1081
+ */
1082
+ clear() {
1083
+ // Clear cache immediately
1084
+ this.cache.metrics = {};
1085
+ this.cache.unlocked = [];
1086
+ // Clear async storage in background
1087
+ const clearPromise = this.asyncStorage.clear().catch(error => {
1088
+ console.error('Failed to clear async storage:', error);
1089
+ if (this.onError) {
1090
+ const storageError = error instanceof AchievementError
1091
+ ? error
1092
+ : new StorageError('Failed to clear storage', error);
1093
+ this.onError(storageError);
1094
+ }
1095
+ });
1096
+ // Track pending write
1097
+ this.pendingWrites.push(clearPromise);
1098
+ }
1099
+ /**
1100
+ * Wait for all pending writes to complete (useful for testing/cleanup)
1101
+ * NOT part of AchievementStorage interface - utility method
1102
+ */
1103
+ flush() {
1104
+ return __awaiter(this, void 0, void 0, function* () {
1105
+ yield Promise.all(this.pendingWrites);
1106
+ this.pendingWrites = [];
1107
+ });
1108
+ }
1109
+ }
1110
+
1111
+ /**
1112
+ * AchievementEngine - Framework-agnostic achievement system
1113
+ * Event-based core with support for multiple storage backends
1114
+ */
1115
+ class AchievementEngine extends EventEmitter {
1116
+ constructor(config) {
1117
+ super();
1118
+ this.metrics = {};
1119
+ this.unlockedAchievements = [];
1120
+ this.config = config;
1121
+ // Normalize achievements configuration
1122
+ this.achievements = normalizeAchievements(config.achievements);
1123
+ // Create config hash for export/import validation
1124
+ this.configHash = createConfigHash(config.achievements);
1125
+ // Initialize storage
1126
+ this.storage = this.initializeStorage(config);
1127
+ // Load initial state from storage
1128
+ this.loadFromStorage();
1129
+ }
1130
+ /**
1131
+ * Initialize storage based on configuration
1132
+ */
1133
+ initializeStorage(config) {
1134
+ const { storage, onError, restApiConfig } = config;
1135
+ // If no storage specified, use memory storage
1136
+ if (!storage) {
1137
+ return new MemoryStorage();
1138
+ }
1139
+ // Handle string storage types
1140
+ if (typeof storage === 'string') {
1141
+ switch (storage) {
1142
+ case 'local':
1143
+ return new LocalStorage('achievements');
1144
+ case 'memory':
1145
+ return new MemoryStorage();
1146
+ case 'indexeddb': {
1147
+ const indexedDB = new IndexedDBStorage('achievements-engine');
1148
+ return new AsyncStorageAdapter(indexedDB, { onError });
1149
+ }
1150
+ case 'restapi': {
1151
+ if (!restApiConfig) {
1152
+ throw new Error('restApiConfig is required when using StorageType.RestAPI');
1153
+ }
1154
+ const restApi = new RestApiStorage(restApiConfig);
1155
+ return new AsyncStorageAdapter(restApi, { onError });
1156
+ }
1157
+ default:
1158
+ throw new Error(`Unsupported storage type: ${storage}`);
1159
+ }
1160
+ }
1161
+ // Handle custom storage instances
1162
+ const storageInstance = storage;
1163
+ if (typeof storageInstance.getMetrics === 'function') {
1164
+ // Check if async storage
1165
+ const testResult = storageInstance.getMetrics();
1166
+ if (testResult && typeof testResult.then === 'function') {
1167
+ return new AsyncStorageAdapter(storageInstance, { onError });
1168
+ }
1169
+ return storageInstance;
1170
+ }
1171
+ throw new Error('Invalid storage configuration');
1172
+ }
1173
+ /**
1174
+ * Load state from storage
1175
+ */
1176
+ loadFromStorage() {
1177
+ try {
1178
+ const savedMetrics = this.storage.getMetrics() || {};
1179
+ const savedUnlocked = this.storage.getUnlockedAchievements() || [];
1180
+ // Convert metrics from array format to simple format
1181
+ Object.entries(savedMetrics).forEach(([key, value]) => {
1182
+ this.metrics[key] = Array.isArray(value) ? value[0] : value;
1183
+ });
1184
+ this.unlockedAchievements = savedUnlocked;
1185
+ }
1186
+ catch (error) {
1187
+ this.handleError(error, 'loadFromStorage');
1188
+ }
1189
+ }
1190
+ /**
1191
+ * Save state to storage
1192
+ */
1193
+ saveToStorage() {
1194
+ try {
1195
+ // Convert metrics to array format for storage
1196
+ const metricsForStorage = {};
1197
+ Object.entries(this.metrics).forEach(([key, value]) => {
1198
+ metricsForStorage[key] = Array.isArray(value) ? value : [value];
1199
+ });
1200
+ this.storage.setMetrics(metricsForStorage);
1201
+ this.storage.setUnlockedAchievements(this.unlockedAchievements);
1202
+ }
1203
+ catch (error) {
1204
+ this.handleError(error, 'saveToStorage');
1205
+ }
1206
+ }
1207
+ /**
1208
+ * Handle errors with optional callback
1209
+ */
1210
+ handleError(error, context) {
1211
+ const errorEvent = {
1212
+ error,
1213
+ context,
1214
+ timestamp: Date.now()
1215
+ };
1216
+ // Emit error event
1217
+ this.emit('error', errorEvent);
1218
+ // Call config error handler if provided
1219
+ if (this.config.onError) {
1220
+ this.config.onError(error);
1221
+ }
1222
+ else {
1223
+ // Fallback to console.error if no error handler provided
1224
+ console.error('[AchievementEngine]', context ? `${context}:` : '', error);
1225
+ }
1226
+ }
1227
+ /**
1228
+ * Emit a custom event and optionally update metrics based on event mapping
1229
+ * @param eventName - Name of the event
1230
+ * @param data - Event data
1231
+ */
1232
+ emit(eventName, data) {
1233
+ // If this is a mapped event, update metrics
1234
+ if (this.config.eventMapping && eventName in this.config.eventMapping) {
1235
+ const mapping = this.config.eventMapping[eventName];
1236
+ if (typeof mapping === 'string') {
1237
+ // Direct mapping: event name -> metric name
1238
+ this.update({ [mapping]: data });
1239
+ }
1240
+ else if (typeof mapping === 'function') {
1241
+ // Custom transformer function
1242
+ const metricsUpdate = mapping(data, Object.assign({}, this.metrics));
1243
+ this.update(metricsUpdate);
1244
+ }
1245
+ }
1246
+ // Emit the event to listeners
1247
+ super.emit(eventName, data);
1248
+ }
1249
+ /**
1250
+ * Update metrics and evaluate achievements
1251
+ * @param newMetrics - Metrics to update
1252
+ */
1253
+ update(newMetrics) {
1254
+ Object.assign({}, this.metrics);
1255
+ // Update metrics
1256
+ Object.entries(newMetrics).forEach(([key, value]) => {
1257
+ const oldValue = this.metrics[key];
1258
+ this.metrics[key] = value;
1259
+ // Emit metric updated event
1260
+ if (oldValue !== value) {
1261
+ const metricEvent = {
1262
+ metric: key,
1263
+ oldValue,
1264
+ newValue: value,
1265
+ timestamp: Date.now()
1266
+ };
1267
+ super.emit('metric:updated', metricEvent);
1268
+ }
1269
+ });
1270
+ // Evaluate achievements
1271
+ this.evaluateAchievements();
1272
+ // Save to storage
1273
+ this.saveToStorage();
1274
+ // Emit state changed event
1275
+ const stateEvent = {
1276
+ metrics: this.getMetricsAsArray(),
1277
+ unlocked: [...this.unlockedAchievements],
1278
+ timestamp: Date.now()
1279
+ };
1280
+ super.emit('state:changed', stateEvent);
1281
+ }
1282
+ /**
1283
+ * Evaluate all achievements and unlock any newly met conditions
1284
+ * This is the core evaluation logic extracted from AchievementProvider
1285
+ */
1286
+ evaluateAchievements() {
1287
+ const newlyUnlockedAchievements = [];
1288
+ // Convert metrics to array format for condition checking
1289
+ const metricsInArrayFormat = this.getMetricsAsArray();
1290
+ // Iterate through all achievements
1291
+ Object.entries(this.achievements).forEach(([metricName, metricAchievements]) => {
1292
+ metricAchievements.forEach((achievement) => {
1293
+ const state = {
1294
+ metrics: metricsInArrayFormat,
1295
+ unlockedAchievements: this.unlockedAchievements
1296
+ };
1297
+ const achievementId = achievement.achievementDetails.achievementId;
1298
+ // Check if already unlocked
1299
+ if (this.unlockedAchievements.includes(achievementId)) {
1300
+ return;
1301
+ }
1302
+ // Get current value for this metric
1303
+ const currentValue = this.metrics[metricName];
1304
+ // For custom conditions, we always check against all metrics
1305
+ // For threshold-based conditions, we check against the specific metric
1306
+ const shouldCheckAchievement = currentValue !== undefined ||
1307
+ achievementId.includes('_custom_');
1308
+ if (shouldCheckAchievement) {
1309
+ const valueToCheck = currentValue;
1310
+ if (achievement.isConditionMet(valueToCheck, state)) {
1311
+ newlyUnlockedAchievements.push(achievementId);
1312
+ // Emit achievement unlocked event
1313
+ const unlockEvent = {
1314
+ achievementId,
1315
+ achievementTitle: achievement.achievementDetails.achievementTitle || 'Achievement Unlocked!',
1316
+ achievementDescription: achievement.achievementDetails.achievementDescription || '',
1317
+ achievementIconKey: achievement.achievementDetails.achievementIconKey,
1318
+ timestamp: Date.now()
1319
+ };
1320
+ super.emit('achievement:unlocked', unlockEvent);
1321
+ }
1322
+ }
1323
+ });
1324
+ });
1325
+ // Add newly unlocked achievements to the list
1326
+ if (newlyUnlockedAchievements.length > 0) {
1327
+ this.unlockedAchievements = [...this.unlockedAchievements, ...newlyUnlockedAchievements];
1328
+ }
1329
+ }
1330
+ /**
1331
+ * Get metrics in array format (for backward compatibility with storage)
1332
+ */
1333
+ getMetricsAsArray() {
1334
+ const metricsInArrayFormat = {};
1335
+ Object.entries(this.metrics).forEach(([key, value]) => {
1336
+ metricsInArrayFormat[key] = Array.isArray(value) ? value : [value];
1337
+ });
1338
+ return metricsInArrayFormat;
1339
+ }
1340
+ /**
1341
+ * Get current metrics (readonly to prevent external modification)
1342
+ */
1343
+ getMetrics() {
1344
+ return Object.freeze(Object.assign({}, this.metrics));
1345
+ }
1346
+ /**
1347
+ * Get unlocked achievement IDs (readonly)
1348
+ */
1349
+ getUnlocked() {
1350
+ return Object.freeze([...this.unlockedAchievements]);
1351
+ }
1352
+ /**
1353
+ * Get all achievements with their unlock status
1354
+ */
1355
+ getAllAchievements() {
1356
+ const result = [];
1357
+ Object.entries(this.achievements).forEach(([_metricName, metricAchievements]) => {
1358
+ metricAchievements.forEach((achievement) => {
1359
+ const { achievementDetails } = achievement;
1360
+ const isUnlocked = this.unlockedAchievements.includes(achievementDetails.achievementId);
1361
+ result.push({
1362
+ achievementId: achievementDetails.achievementId,
1363
+ achievementTitle: achievementDetails.achievementTitle || '',
1364
+ achievementDescription: achievementDetails.achievementDescription || '',
1365
+ achievementIconKey: achievementDetails.achievementIconKey,
1366
+ isUnlocked
1367
+ });
1368
+ });
1369
+ });
1370
+ return result;
1371
+ }
1372
+ /**
1373
+ * Reset all achievement data
1374
+ */
1375
+ reset() {
1376
+ this.metrics = {};
1377
+ this.unlockedAchievements = [];
1378
+ try {
1379
+ this.storage.clear();
1380
+ }
1381
+ catch (error) {
1382
+ this.handleError(error, 'reset');
1383
+ }
1384
+ // Emit state changed event
1385
+ const stateEvent = {
1386
+ metrics: {},
1387
+ unlocked: [],
1388
+ timestamp: Date.now()
1389
+ };
1390
+ super.emit('state:changed', stateEvent);
1391
+ }
1392
+ /**
1393
+ * Clean up resources and event listeners
1394
+ */
1395
+ destroy() {
1396
+ this.removeAllListeners();
1397
+ }
1398
+ /**
1399
+ * Export achievement data as JSON string
1400
+ */
1401
+ export() {
1402
+ const metricsInArrayFormat = this.getMetricsAsArray();
1403
+ return exportAchievementData(metricsInArrayFormat, this.unlockedAchievements, this.configHash);
1404
+ }
1405
+ /**
1406
+ * Import achievement data from JSON string
1407
+ * @param jsonString - Exported achievement data
1408
+ * @param options - Import options
1409
+ */
1410
+ import(jsonString, options) {
1411
+ var _a;
1412
+ const metricsInArrayFormat = this.getMetricsAsArray();
1413
+ // Transform options from public API format to internal format
1414
+ const internalOptions = {
1415
+ mergeStrategy: (options === null || options === void 0 ? void 0 : options.merge) ? 'merge' :
1416
+ (options === null || options === void 0 ? void 0 : options.overwrite) ? 'replace' :
1417
+ 'replace',
1418
+ validate: (_a = options === null || options === void 0 ? void 0 : options.validateConfig) !== null && _a !== void 0 ? _a : true,
1419
+ expectedConfigHash: this.configHash
1420
+ };
1421
+ const result = importAchievementData(jsonString, metricsInArrayFormat, this.unlockedAchievements, internalOptions);
1422
+ if (result.success && 'mergedMetrics' in result && 'mergedUnlocked' in result) {
1423
+ // Convert metrics from array format to simple format
1424
+ const mergedMetrics = {};
1425
+ Object.entries(result.mergedMetrics).forEach(([key, value]) => {
1426
+ mergedMetrics[key] = Array.isArray(value) ? value[0] : value;
1427
+ });
1428
+ this.metrics = mergedMetrics;
1429
+ this.unlockedAchievements = result.mergedUnlocked || [];
1430
+ // Save to storage
1431
+ this.saveToStorage();
1432
+ // Emit state changed event
1433
+ const stateEvent = {
1434
+ metrics: this.getMetricsAsArray(),
1435
+ unlocked: [...this.unlockedAchievements],
1436
+ timestamp: Date.now()
1437
+ };
1438
+ super.emit('state:changed', stateEvent);
1439
+ }
1440
+ return result;
1441
+ }
1442
+ /**
1443
+ * Subscribe to engine events
1444
+ * @param event - Event name
1445
+ * @param handler - Event handler
1446
+ */
1447
+ on(event, handler) {
1448
+ return super.on(event, handler);
1449
+ }
1450
+ /**
1451
+ * Subscribe to an event once
1452
+ * @param event - Event name
1453
+ * @param handler - Event handler
1454
+ */
1455
+ once(event, handler) {
1456
+ return super.once(event, handler);
1457
+ }
1458
+ /**
1459
+ * Unsubscribe from an event
1460
+ * @param event - Event name
1461
+ * @param handler - Event handler
1462
+ */
1463
+ off(event, handler) {
1464
+ return super.off(event, handler);
1465
+ }
1466
+ }
1467
+
1468
+ class OfflineQueueStorage {
1469
+ constructor(innerStorage) {
1470
+ this.queue = [];
1471
+ this.isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
1472
+ this.isSyncing = false;
1473
+ this.queueStorageKey = 'achievements_offline_queue';
1474
+ this.handleOnline = () => {
1475
+ this.isOnline = true;
1476
+ console.log('[OfflineQueue] Back online, processing queue...');
1477
+ this.processQueue();
1478
+ };
1479
+ this.handleOffline = () => {
1480
+ this.isOnline = false;
1481
+ console.log('[OfflineQueue] Offline mode activated');
1482
+ };
1483
+ this.innerStorage = innerStorage;
1484
+ // Load queued operations from localStorage
1485
+ this.loadQueue();
1486
+ // Listen for online/offline events (only in browser environment)
1487
+ if (typeof window !== 'undefined') {
1488
+ window.addEventListener('online', this.handleOnline);
1489
+ window.addEventListener('offline', this.handleOffline);
1490
+ }
1491
+ // Process queue if already online
1492
+ if (this.isOnline) {
1493
+ this.processQueue();
1494
+ }
1495
+ }
1496
+ loadQueue() {
1497
+ try {
1498
+ if (typeof localStorage !== 'undefined') {
1499
+ const queueData = localStorage.getItem(this.queueStorageKey);
1500
+ if (queueData) {
1501
+ this.queue = JSON.parse(queueData);
1502
+ }
1503
+ }
1504
+ }
1505
+ catch (error) {
1506
+ console.error('Failed to load offline queue:', error);
1507
+ this.queue = [];
1508
+ }
1509
+ }
1510
+ saveQueue() {
1511
+ try {
1512
+ if (typeof localStorage !== 'undefined') {
1513
+ localStorage.setItem(this.queueStorageKey, JSON.stringify(this.queue));
1514
+ }
1515
+ }
1516
+ catch (error) {
1517
+ console.error('Failed to save offline queue:', error);
1518
+ }
1519
+ }
1520
+ processQueue() {
1521
+ return __awaiter(this, void 0, void 0, function* () {
1522
+ if (this.isSyncing || this.queue.length === 0 || !this.isOnline) {
1523
+ return;
1524
+ }
1525
+ this.isSyncing = true;
1526
+ try {
1527
+ // Process operations in order
1528
+ while (this.queue.length > 0 && this.isOnline) {
1529
+ const operation = this.queue[0];
1530
+ try {
1531
+ switch (operation.type) {
1532
+ case 'setMetrics':
1533
+ yield this.innerStorage.setMetrics(operation.data);
1534
+ break;
1535
+ case 'setUnlockedAchievements':
1536
+ yield this.innerStorage.setUnlockedAchievements(operation.data);
1537
+ break;
1538
+ case 'clear':
1539
+ yield this.innerStorage.clear();
1540
+ break;
1541
+ }
1542
+ // Operation succeeded, remove from queue
1543
+ this.queue.shift();
1544
+ this.saveQueue();
1545
+ }
1546
+ catch (error) {
1547
+ console.error('Failed to sync queued operation:', error);
1548
+ // Stop processing on error, will retry later
1549
+ break;
1550
+ }
1551
+ }
1552
+ }
1553
+ finally {
1554
+ this.isSyncing = false;
1555
+ }
1556
+ });
1557
+ }
1558
+ queueOperation(type, data) {
1559
+ const operation = {
1560
+ id: `${Date.now()}_${Math.random()}`,
1561
+ type,
1562
+ data,
1563
+ timestamp: Date.now()
1564
+ };
1565
+ this.queue.push(operation);
1566
+ this.saveQueue();
1567
+ // Try to process queue if online
1568
+ if (this.isOnline) {
1569
+ this.processQueue();
1570
+ }
1571
+ }
1572
+ getMetrics() {
1573
+ return __awaiter(this, void 0, void 0, function* () {
1574
+ // Reads always try to hit the server first
1575
+ try {
1576
+ return yield this.innerStorage.getMetrics();
1577
+ }
1578
+ catch (error) {
1579
+ if (!this.isOnline) {
1580
+ throw new StorageError('Cannot read metrics while offline');
1581
+ }
1582
+ throw error;
1583
+ }
1584
+ });
1585
+ }
1586
+ setMetrics(metrics) {
1587
+ return __awaiter(this, void 0, void 0, function* () {
1588
+ if (this.isOnline) {
1589
+ try {
1590
+ yield this.innerStorage.setMetrics(metrics);
1591
+ return;
1592
+ }
1593
+ catch (error) {
1594
+ // Failed while online, queue it
1595
+ console.warn('Failed to set metrics, queuing for later:', error);
1596
+ }
1597
+ }
1598
+ // Queue operation if offline or if online operation failed
1599
+ this.queueOperation('setMetrics', metrics);
1600
+ });
1601
+ }
1602
+ getUnlockedAchievements() {
1603
+ return __awaiter(this, void 0, void 0, function* () {
1604
+ // Reads always try to hit the server first
1605
+ try {
1606
+ return yield this.innerStorage.getUnlockedAchievements();
1607
+ }
1608
+ catch (error) {
1609
+ if (!this.isOnline) {
1610
+ throw new StorageError('Cannot read achievements while offline');
1611
+ }
1612
+ throw error;
1613
+ }
1614
+ });
1615
+ }
1616
+ setUnlockedAchievements(achievements) {
1617
+ return __awaiter(this, void 0, void 0, function* () {
1618
+ if (this.isOnline) {
1619
+ try {
1620
+ yield this.innerStorage.setUnlockedAchievements(achievements);
1621
+ return;
1622
+ }
1623
+ catch (error) {
1624
+ // Failed while online, queue it
1625
+ console.warn('Failed to set unlocked achievements, queuing for later:', error);
1626
+ }
1627
+ }
1628
+ // Queue operation if offline or if online operation failed
1629
+ this.queueOperation('setUnlockedAchievements', achievements);
1630
+ });
1631
+ }
1632
+ clear() {
1633
+ return __awaiter(this, void 0, void 0, function* () {
1634
+ if (this.isOnline) {
1635
+ try {
1636
+ yield this.innerStorage.clear();
1637
+ // Also clear the queue
1638
+ this.queue = [];
1639
+ this.saveQueue();
1640
+ return;
1641
+ }
1642
+ catch (error) {
1643
+ console.warn('Failed to clear, queuing for later:', error);
1644
+ }
1645
+ }
1646
+ // Queue operation if offline or if online operation failed
1647
+ this.queueOperation('clear');
1648
+ });
1649
+ }
1650
+ /**
1651
+ * Manually trigger queue processing (useful for testing)
1652
+ */
1653
+ sync() {
1654
+ return __awaiter(this, void 0, void 0, function* () {
1655
+ yield this.processQueue();
1656
+ });
1657
+ }
1658
+ /**
1659
+ * Get current queue status (useful for debugging)
1660
+ */
1661
+ getQueueStatus() {
1662
+ return {
1663
+ pending: this.queue.length,
1664
+ operations: [...this.queue]
1665
+ };
1666
+ }
1667
+ /**
1668
+ * Cleanup listeners (call on unmount)
1669
+ */
1670
+ destroy() {
1671
+ if (typeof window !== 'undefined') {
1672
+ window.removeEventListener('online', this.handleOnline);
1673
+ window.removeEventListener('offline', this.handleOffline);
1674
+ }
1675
+ }
1676
+ }
1677
+
1678
+ exports.AchievementEngine = AchievementEngine;
1679
+ exports.AchievementError = AchievementError;
1680
+ exports.AsyncStorageAdapter = AsyncStorageAdapter;
1681
+ exports.ConfigurationError = ConfigurationError;
1682
+ exports.EventEmitter = EventEmitter;
1683
+ exports.ImportValidationError = ImportValidationError;
1684
+ exports.IndexedDBStorage = IndexedDBStorage;
1685
+ exports.LocalStorage = LocalStorage;
1686
+ exports.MemoryStorage = MemoryStorage;
1687
+ exports.OfflineQueueStorage = OfflineQueueStorage;
1688
+ exports.RestApiStorage = RestApiStorage;
1689
+ exports.StorageError = StorageError;
1690
+ exports.StorageQuotaError = StorageQuotaError;
1691
+ exports.SyncError = SyncError;
1692
+ exports.createConfigHash = createConfigHash;
1693
+ exports.exportAchievementData = exportAchievementData;
1694
+ exports.importAchievementData = importAchievementData;
1695
+ exports.isAchievementError = isAchievementError;
1696
+ exports.isAsyncStorage = isAsyncStorage;
1697
+ exports.isRecoverableError = isRecoverableError;
1698
+ exports.normalizeAchievements = normalizeAchievements;
1699
+ //# sourceMappingURL=index.cjs.map