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