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.js ADDED
@@ -0,0 +1,1672 @@
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
+ });
750
+ });
751
+ }
752
+ /**
753
+ * Generic set operation to IndexedDB
754
+ */
755
+ set(key, value) {
756
+ return __awaiter(this, void 0, void 0, function* () {
757
+ yield this.initPromise;
758
+ if (!this.db)
759
+ throw new StorageError('Database not initialized');
760
+ return new Promise((resolve, reject) => {
761
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
762
+ const store = transaction.objectStore(this.storeName);
763
+ const request = store.put(value, key);
764
+ request.onsuccess = () => {
765
+ resolve();
766
+ };
767
+ request.onerror = () => {
768
+ reject(new StorageError(`Failed to write to IndexedDB: ${key}`));
769
+ };
770
+ });
771
+ });
772
+ }
773
+ /**
774
+ * Delete operation from IndexedDB
775
+ */
776
+ delete(key) {
777
+ return __awaiter(this, void 0, void 0, function* () {
778
+ yield this.initPromise;
779
+ if (!this.db)
780
+ throw new StorageError('Database not initialized');
781
+ return new Promise((resolve, reject) => {
782
+ const transaction = this.db.transaction([this.storeName], 'readwrite');
783
+ const store = transaction.objectStore(this.storeName);
784
+ const request = store.delete(key);
785
+ request.onsuccess = () => {
786
+ resolve();
787
+ };
788
+ request.onerror = () => {
789
+ reject(new StorageError(`Failed to delete from IndexedDB: ${key}`));
790
+ };
791
+ });
792
+ });
793
+ }
794
+ getMetrics() {
795
+ return __awaiter(this, void 0, void 0, function* () {
796
+ const metrics = yield this.get('metrics');
797
+ return metrics || {};
798
+ });
799
+ }
800
+ setMetrics(metrics) {
801
+ return __awaiter(this, void 0, void 0, function* () {
802
+ yield this.set('metrics', metrics);
803
+ });
804
+ }
805
+ getUnlockedAchievements() {
806
+ return __awaiter(this, void 0, void 0, function* () {
807
+ const unlocked = yield this.get('unlocked');
808
+ return unlocked || [];
809
+ });
810
+ }
811
+ setUnlockedAchievements(achievements) {
812
+ return __awaiter(this, void 0, void 0, function* () {
813
+ yield this.set('unlocked', achievements);
814
+ });
815
+ }
816
+ clear() {
817
+ return __awaiter(this, void 0, void 0, function* () {
818
+ yield Promise.all([
819
+ this.delete('metrics'),
820
+ this.delete('unlocked')
821
+ ]);
822
+ });
823
+ }
824
+ }
825
+
826
+ class RestApiStorage {
827
+ constructor(config) {
828
+ this.config = Object.assign({ timeout: 10000, headers: {} }, config);
829
+ }
830
+ /**
831
+ * Generic fetch wrapper with timeout and error handling
832
+ */
833
+ fetchWithTimeout(url, options) {
834
+ return __awaiter(this, void 0, void 0, function* () {
835
+ const controller = new AbortController();
836
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
837
+ try {
838
+ 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 }));
839
+ clearTimeout(timeoutId);
840
+ if (!response.ok) {
841
+ throw new SyncError(`HTTP ${response.status}: ${response.statusText}`, { statusCode: response.status });
842
+ }
843
+ return response;
844
+ }
845
+ catch (error) {
846
+ clearTimeout(timeoutId);
847
+ if (error instanceof Error && error.name === 'AbortError') {
848
+ throw new SyncError('Request timeout', { timeout: this.config.timeout });
849
+ }
850
+ throw error;
851
+ }
852
+ });
853
+ }
854
+ getMetrics() {
855
+ return __awaiter(this, void 0, void 0, function* () {
856
+ var _a;
857
+ try {
858
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
859
+ const response = yield this.fetchWithTimeout(url, { method: 'GET' });
860
+ const data = yield response.json();
861
+ return data.metrics || {};
862
+ }
863
+ catch (error) {
864
+ // Re-throw SyncError and other AchievementErrors (but not StorageError)
865
+ // Multiple checks for Jest compatibility
866
+ const err = error;
867
+ 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') {
868
+ throw error;
869
+ }
870
+ // Also check instanceof for normal cases
871
+ if (error instanceof AchievementError && !(error instanceof StorageError)) {
872
+ throw error;
873
+ }
874
+ throw new StorageError('Failed to fetch metrics from API', error);
875
+ }
876
+ });
877
+ }
878
+ setMetrics(metrics) {
879
+ return __awaiter(this, void 0, void 0, function* () {
880
+ var _a;
881
+ try {
882
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/metrics`;
883
+ yield this.fetchWithTimeout(url, {
884
+ method: 'PUT',
885
+ body: JSON.stringify({ metrics })
886
+ });
887
+ }
888
+ catch (error) {
889
+ const err = error;
890
+ 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')
891
+ throw error;
892
+ if (error instanceof AchievementError && !(error instanceof StorageError))
893
+ throw error;
894
+ throw new StorageError('Failed to save metrics to API', error);
895
+ }
896
+ });
897
+ }
898
+ getUnlockedAchievements() {
899
+ return __awaiter(this, void 0, void 0, function* () {
900
+ var _a;
901
+ try {
902
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
903
+ const response = yield this.fetchWithTimeout(url, { method: 'GET' });
904
+ const data = yield response.json();
905
+ return data.unlocked || [];
906
+ }
907
+ catch (error) {
908
+ const err = error;
909
+ 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')
910
+ throw error;
911
+ if (error instanceof AchievementError && !(error instanceof StorageError))
912
+ throw error;
913
+ throw new StorageError('Failed to fetch unlocked achievements from API', error);
914
+ }
915
+ });
916
+ }
917
+ setUnlockedAchievements(achievements) {
918
+ return __awaiter(this, void 0, void 0, function* () {
919
+ var _a;
920
+ try {
921
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements/unlocked`;
922
+ yield this.fetchWithTimeout(url, {
923
+ method: 'PUT',
924
+ body: JSON.stringify({ unlocked: achievements })
925
+ });
926
+ }
927
+ catch (error) {
928
+ const err = error;
929
+ 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')
930
+ throw error;
931
+ if (error instanceof AchievementError && !(error instanceof StorageError))
932
+ throw error;
933
+ throw new StorageError('Failed to save unlocked achievements to API', error);
934
+ }
935
+ });
936
+ }
937
+ clear() {
938
+ return __awaiter(this, void 0, void 0, function* () {
939
+ var _a;
940
+ try {
941
+ const url = `${this.config.baseUrl}/users/${this.config.userId}/achievements`;
942
+ yield this.fetchWithTimeout(url, { method: 'DELETE' });
943
+ }
944
+ catch (error) {
945
+ const err = error;
946
+ 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')
947
+ throw error;
948
+ if (error instanceof AchievementError && !(error instanceof StorageError))
949
+ throw error;
950
+ throw new StorageError('Failed to clear achievements via API', error);
951
+ }
952
+ });
953
+ }
954
+ }
955
+
956
+ class AsyncStorageAdapter {
957
+ constructor(asyncStorage, options) {
958
+ this.pendingWrites = [];
959
+ this.asyncStorage = asyncStorage;
960
+ this.onError = options === null || options === void 0 ? void 0 : options.onError;
961
+ this.cache = {
962
+ metrics: {},
963
+ unlocked: [],
964
+ loaded: false
965
+ };
966
+ // Eagerly load data from async storage (non-blocking)
967
+ this.initializeCache();
968
+ }
969
+ /**
970
+ * Initialize cache by loading from async storage
971
+ * This happens in the background during construction
972
+ */
973
+ initializeCache() {
974
+ return __awaiter(this, void 0, void 0, function* () {
975
+ try {
976
+ const [metrics, unlocked] = yield Promise.all([
977
+ this.asyncStorage.getMetrics(),
978
+ this.asyncStorage.getUnlockedAchievements()
979
+ ]);
980
+ this.cache.metrics = metrics;
981
+ this.cache.unlocked = unlocked;
982
+ this.cache.loaded = true;
983
+ }
984
+ catch (error) {
985
+ // Handle initialization errors
986
+ console.error('Failed to initialize async storage:', error);
987
+ if (this.onError) {
988
+ const storageError = error instanceof AchievementError
989
+ ? error
990
+ : new StorageError('Failed to initialize storage', error);
991
+ this.onError(storageError);
992
+ }
993
+ // Set to empty state on error
994
+ this.cache.loaded = true; // Mark as loaded even on error to prevent blocking
995
+ }
996
+ });
997
+ }
998
+ /**
999
+ * Wait for cache to be loaded (used internally)
1000
+ * Returns immediately if already loaded, otherwise waits
1001
+ */
1002
+ ensureCacheLoaded() {
1003
+ return __awaiter(this, void 0, void 0, function* () {
1004
+ while (!this.cache.loaded) {
1005
+ yield new Promise(resolve => setTimeout(resolve, 10));
1006
+ }
1007
+ });
1008
+ }
1009
+ /**
1010
+ * SYNC READ: Returns cached metrics immediately
1011
+ * Cache is loaded eagerly during construction
1012
+ */
1013
+ getMetrics() {
1014
+ return this.cache.metrics;
1015
+ }
1016
+ /**
1017
+ * SYNC WRITE: Updates cache immediately, writes to storage in background
1018
+ * Uses optimistic updates - assumes write will succeed
1019
+ */
1020
+ setMetrics(metrics) {
1021
+ // Update cache immediately (optimistic update)
1022
+ this.cache.metrics = metrics;
1023
+ // Write to async storage in background
1024
+ const writePromise = this.asyncStorage.setMetrics(metrics).catch(error => {
1025
+ console.error('Failed to write metrics to async storage:', error);
1026
+ if (this.onError) {
1027
+ const storageError = error instanceof AchievementError
1028
+ ? error
1029
+ : new StorageError('Failed to write metrics', error);
1030
+ this.onError(storageError);
1031
+ }
1032
+ });
1033
+ // Track pending write for cleanup/testing
1034
+ this.pendingWrites.push(writePromise);
1035
+ }
1036
+ /**
1037
+ * SYNC READ: Returns cached unlocked achievements immediately
1038
+ */
1039
+ getUnlockedAchievements() {
1040
+ return this.cache.unlocked;
1041
+ }
1042
+ /**
1043
+ * SYNC WRITE: Updates cache immediately, writes to storage in background
1044
+ */
1045
+ setUnlockedAchievements(achievements) {
1046
+ // Update cache immediately (optimistic update)
1047
+ this.cache.unlocked = achievements;
1048
+ // Write to async storage in background
1049
+ const writePromise = this.asyncStorage.setUnlockedAchievements(achievements).catch(error => {
1050
+ console.error('Failed to write unlocked achievements to async storage:', error);
1051
+ if (this.onError) {
1052
+ const storageError = error instanceof AchievementError
1053
+ ? error
1054
+ : new StorageError('Failed to write achievements', error);
1055
+ this.onError(storageError);
1056
+ }
1057
+ });
1058
+ // Track pending write
1059
+ this.pendingWrites.push(writePromise);
1060
+ }
1061
+ /**
1062
+ * SYNC CLEAR: Clears cache immediately, clears storage in background
1063
+ */
1064
+ clear() {
1065
+ // Clear cache immediately
1066
+ this.cache.metrics = {};
1067
+ this.cache.unlocked = [];
1068
+ // Clear async storage in background
1069
+ const clearPromise = this.asyncStorage.clear().catch(error => {
1070
+ console.error('Failed to clear async storage:', error);
1071
+ if (this.onError) {
1072
+ const storageError = error instanceof AchievementError
1073
+ ? error
1074
+ : new StorageError('Failed to clear storage', error);
1075
+ this.onError(storageError);
1076
+ }
1077
+ });
1078
+ // Track pending write
1079
+ this.pendingWrites.push(clearPromise);
1080
+ }
1081
+ /**
1082
+ * Wait for all pending writes to complete (useful for testing/cleanup)
1083
+ * NOT part of AchievementStorage interface - utility method
1084
+ */
1085
+ flush() {
1086
+ return __awaiter(this, void 0, void 0, function* () {
1087
+ yield Promise.all(this.pendingWrites);
1088
+ this.pendingWrites = [];
1089
+ });
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * AchievementEngine - Framework-agnostic achievement system
1095
+ * Event-based core with support for multiple storage backends
1096
+ */
1097
+ class AchievementEngine extends EventEmitter {
1098
+ constructor(config) {
1099
+ super();
1100
+ this.metrics = {};
1101
+ this.unlockedAchievements = [];
1102
+ this.config = config;
1103
+ // Normalize achievements configuration
1104
+ this.achievements = normalizeAchievements(config.achievements);
1105
+ // Create config hash for export/import validation
1106
+ this.configHash = createConfigHash(config.achievements);
1107
+ // Initialize storage
1108
+ this.storage = this.initializeStorage(config);
1109
+ // Load initial state from storage
1110
+ this.loadFromStorage();
1111
+ }
1112
+ /**
1113
+ * Initialize storage based on configuration
1114
+ */
1115
+ initializeStorage(config) {
1116
+ const { storage, onError, restApiConfig } = config;
1117
+ // If no storage specified, use memory storage
1118
+ if (!storage) {
1119
+ return new MemoryStorage();
1120
+ }
1121
+ // Handle string storage types
1122
+ if (typeof storage === 'string') {
1123
+ switch (storage) {
1124
+ case 'local':
1125
+ return new LocalStorage('achievements');
1126
+ case 'memory':
1127
+ return new MemoryStorage();
1128
+ case 'indexeddb': {
1129
+ const indexedDB = new IndexedDBStorage('achievements-engine');
1130
+ return new AsyncStorageAdapter(indexedDB, { onError });
1131
+ }
1132
+ case 'restapi': {
1133
+ if (!restApiConfig) {
1134
+ throw new Error('restApiConfig is required when using StorageType.RestAPI');
1135
+ }
1136
+ const restApi = new RestApiStorage(restApiConfig);
1137
+ return new AsyncStorageAdapter(restApi, { onError });
1138
+ }
1139
+ default:
1140
+ throw new Error(`Unsupported storage type: ${storage}`);
1141
+ }
1142
+ }
1143
+ // Handle custom storage instances
1144
+ const storageInstance = storage;
1145
+ if (typeof storageInstance.getMetrics === 'function') {
1146
+ // Check if async storage
1147
+ const testResult = storageInstance.getMetrics();
1148
+ if (testResult && typeof testResult.then === 'function') {
1149
+ return new AsyncStorageAdapter(storageInstance, { onError });
1150
+ }
1151
+ return storageInstance;
1152
+ }
1153
+ throw new Error('Invalid storage configuration');
1154
+ }
1155
+ /**
1156
+ * Load state from storage
1157
+ */
1158
+ loadFromStorage() {
1159
+ try {
1160
+ const savedMetrics = this.storage.getMetrics() || {};
1161
+ const savedUnlocked = this.storage.getUnlockedAchievements() || [];
1162
+ // Convert metrics from array format to simple format
1163
+ Object.entries(savedMetrics).forEach(([key, value]) => {
1164
+ this.metrics[key] = Array.isArray(value) ? value[0] : value;
1165
+ });
1166
+ this.unlockedAchievements = savedUnlocked;
1167
+ }
1168
+ catch (error) {
1169
+ this.handleError(error, 'loadFromStorage');
1170
+ }
1171
+ }
1172
+ /**
1173
+ * Save state to storage
1174
+ */
1175
+ saveToStorage() {
1176
+ try {
1177
+ // Convert metrics to array format for storage
1178
+ const metricsForStorage = {};
1179
+ Object.entries(this.metrics).forEach(([key, value]) => {
1180
+ metricsForStorage[key] = Array.isArray(value) ? value : [value];
1181
+ });
1182
+ this.storage.setMetrics(metricsForStorage);
1183
+ this.storage.setUnlockedAchievements(this.unlockedAchievements);
1184
+ }
1185
+ catch (error) {
1186
+ this.handleError(error, 'saveToStorage');
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Handle errors with optional callback
1191
+ */
1192
+ handleError(error, context) {
1193
+ const errorEvent = {
1194
+ error,
1195
+ context,
1196
+ timestamp: Date.now()
1197
+ };
1198
+ // Emit error event
1199
+ this.emit('error', errorEvent);
1200
+ // Call config error handler if provided
1201
+ if (this.config.onError) {
1202
+ this.config.onError(error);
1203
+ }
1204
+ else {
1205
+ // Fallback to console.error if no error handler provided
1206
+ console.error('[AchievementEngine]', context ? `${context}:` : '', error);
1207
+ }
1208
+ }
1209
+ /**
1210
+ * Emit a custom event and optionally update metrics based on event mapping
1211
+ * @param eventName - Name of the event
1212
+ * @param data - Event data
1213
+ */
1214
+ emit(eventName, data) {
1215
+ // If this is a mapped event, update metrics
1216
+ if (this.config.eventMapping && eventName in this.config.eventMapping) {
1217
+ const mapping = this.config.eventMapping[eventName];
1218
+ if (typeof mapping === 'string') {
1219
+ // Direct mapping: event name -> metric name
1220
+ this.update({ [mapping]: data });
1221
+ }
1222
+ else if (typeof mapping === 'function') {
1223
+ // Custom transformer function
1224
+ const metricsUpdate = mapping(data, Object.assign({}, this.metrics));
1225
+ this.update(metricsUpdate);
1226
+ }
1227
+ }
1228
+ // Emit the event to listeners
1229
+ super.emit(eventName, data);
1230
+ }
1231
+ /**
1232
+ * Update metrics and evaluate achievements
1233
+ * @param newMetrics - Metrics to update
1234
+ */
1235
+ update(newMetrics) {
1236
+ Object.assign({}, this.metrics);
1237
+ // Update metrics
1238
+ Object.entries(newMetrics).forEach(([key, value]) => {
1239
+ const oldValue = this.metrics[key];
1240
+ this.metrics[key] = value;
1241
+ // Emit metric updated event
1242
+ if (oldValue !== value) {
1243
+ const metricEvent = {
1244
+ metric: key,
1245
+ oldValue,
1246
+ newValue: value,
1247
+ timestamp: Date.now()
1248
+ };
1249
+ super.emit('metric:updated', metricEvent);
1250
+ }
1251
+ });
1252
+ // Evaluate achievements
1253
+ this.evaluateAchievements();
1254
+ // Save to storage
1255
+ this.saveToStorage();
1256
+ // Emit state changed event
1257
+ const stateEvent = {
1258
+ metrics: this.getMetricsAsArray(),
1259
+ unlocked: [...this.unlockedAchievements],
1260
+ timestamp: Date.now()
1261
+ };
1262
+ super.emit('state:changed', stateEvent);
1263
+ }
1264
+ /**
1265
+ * Evaluate all achievements and unlock any newly met conditions
1266
+ * This is the core evaluation logic extracted from AchievementProvider
1267
+ */
1268
+ evaluateAchievements() {
1269
+ const newlyUnlockedAchievements = [];
1270
+ // Convert metrics to array format for condition checking
1271
+ const metricsInArrayFormat = this.getMetricsAsArray();
1272
+ // Iterate through all achievements
1273
+ Object.entries(this.achievements).forEach(([metricName, metricAchievements]) => {
1274
+ metricAchievements.forEach((achievement) => {
1275
+ const state = {
1276
+ metrics: metricsInArrayFormat,
1277
+ unlockedAchievements: this.unlockedAchievements
1278
+ };
1279
+ const achievementId = achievement.achievementDetails.achievementId;
1280
+ // Check if already unlocked
1281
+ if (this.unlockedAchievements.includes(achievementId)) {
1282
+ return;
1283
+ }
1284
+ // Get current value for this metric
1285
+ const currentValue = this.metrics[metricName];
1286
+ // For custom conditions, we always check against all metrics
1287
+ // For threshold-based conditions, we check against the specific metric
1288
+ const shouldCheckAchievement = currentValue !== undefined ||
1289
+ achievementId.includes('_custom_');
1290
+ if (shouldCheckAchievement) {
1291
+ const valueToCheck = currentValue;
1292
+ if (achievement.isConditionMet(valueToCheck, state)) {
1293
+ newlyUnlockedAchievements.push(achievementId);
1294
+ // Emit achievement unlocked event
1295
+ const unlockEvent = {
1296
+ achievementId,
1297
+ achievementTitle: achievement.achievementDetails.achievementTitle || 'Achievement Unlocked!',
1298
+ achievementDescription: achievement.achievementDetails.achievementDescription || '',
1299
+ achievementIconKey: achievement.achievementDetails.achievementIconKey,
1300
+ timestamp: Date.now()
1301
+ };
1302
+ super.emit('achievement:unlocked', unlockEvent);
1303
+ }
1304
+ }
1305
+ });
1306
+ });
1307
+ // Add newly unlocked achievements to the list
1308
+ if (newlyUnlockedAchievements.length > 0) {
1309
+ this.unlockedAchievements = [...this.unlockedAchievements, ...newlyUnlockedAchievements];
1310
+ }
1311
+ }
1312
+ /**
1313
+ * Get metrics in array format (for backward compatibility with storage)
1314
+ */
1315
+ getMetricsAsArray() {
1316
+ const metricsInArrayFormat = {};
1317
+ Object.entries(this.metrics).forEach(([key, value]) => {
1318
+ metricsInArrayFormat[key] = Array.isArray(value) ? value : [value];
1319
+ });
1320
+ return metricsInArrayFormat;
1321
+ }
1322
+ /**
1323
+ * Get current metrics (readonly to prevent external modification)
1324
+ */
1325
+ getMetrics() {
1326
+ return Object.freeze(Object.assign({}, this.metrics));
1327
+ }
1328
+ /**
1329
+ * Get unlocked achievement IDs (readonly)
1330
+ */
1331
+ getUnlocked() {
1332
+ return Object.freeze([...this.unlockedAchievements]);
1333
+ }
1334
+ /**
1335
+ * Get all achievements with their unlock status
1336
+ */
1337
+ getAllAchievements() {
1338
+ const result = [];
1339
+ Object.entries(this.achievements).forEach(([_metricName, metricAchievements]) => {
1340
+ metricAchievements.forEach((achievement) => {
1341
+ const { achievementDetails } = achievement;
1342
+ const isUnlocked = this.unlockedAchievements.includes(achievementDetails.achievementId);
1343
+ result.push({
1344
+ achievementId: achievementDetails.achievementId,
1345
+ achievementTitle: achievementDetails.achievementTitle || '',
1346
+ achievementDescription: achievementDetails.achievementDescription || '',
1347
+ achievementIconKey: achievementDetails.achievementIconKey,
1348
+ isUnlocked
1349
+ });
1350
+ });
1351
+ });
1352
+ return result;
1353
+ }
1354
+ /**
1355
+ * Reset all achievement data
1356
+ */
1357
+ reset() {
1358
+ this.metrics = {};
1359
+ this.unlockedAchievements = [];
1360
+ try {
1361
+ this.storage.clear();
1362
+ }
1363
+ catch (error) {
1364
+ this.handleError(error, 'reset');
1365
+ }
1366
+ // Emit state changed event
1367
+ const stateEvent = {
1368
+ metrics: {},
1369
+ unlocked: [],
1370
+ timestamp: Date.now()
1371
+ };
1372
+ super.emit('state:changed', stateEvent);
1373
+ }
1374
+ /**
1375
+ * Clean up resources and event listeners
1376
+ */
1377
+ destroy() {
1378
+ this.removeAllListeners();
1379
+ }
1380
+ /**
1381
+ * Export achievement data as JSON string
1382
+ */
1383
+ export() {
1384
+ const metricsInArrayFormat = this.getMetricsAsArray();
1385
+ return exportAchievementData(metricsInArrayFormat, this.unlockedAchievements, this.configHash);
1386
+ }
1387
+ /**
1388
+ * Import achievement data from JSON string
1389
+ * @param jsonString - Exported achievement data
1390
+ * @param options - Import options
1391
+ */
1392
+ import(jsonString, options) {
1393
+ const metricsInArrayFormat = this.getMetricsAsArray();
1394
+ const result = importAchievementData(jsonString, metricsInArrayFormat, this.unlockedAchievements, Object.assign(Object.assign({}, options), { expectedConfigHash: this.configHash }));
1395
+ if (result.success && 'mergedMetrics' in result && 'mergedUnlocked' in result) {
1396
+ // Convert metrics from array format to simple format
1397
+ const mergedMetrics = {};
1398
+ Object.entries(result.mergedMetrics).forEach(([key, value]) => {
1399
+ mergedMetrics[key] = Array.isArray(value) ? value[0] : value;
1400
+ });
1401
+ this.metrics = mergedMetrics;
1402
+ this.unlockedAchievements = result.mergedUnlocked || [];
1403
+ // Save to storage
1404
+ this.saveToStorage();
1405
+ // Emit state changed event
1406
+ const stateEvent = {
1407
+ metrics: this.getMetricsAsArray(),
1408
+ unlocked: [...this.unlockedAchievements],
1409
+ timestamp: Date.now()
1410
+ };
1411
+ super.emit('state:changed', stateEvent);
1412
+ }
1413
+ return result;
1414
+ }
1415
+ /**
1416
+ * Subscribe to engine events
1417
+ * @param event - Event name
1418
+ * @param handler - Event handler
1419
+ */
1420
+ on(event, handler) {
1421
+ return super.on(event, handler);
1422
+ }
1423
+ /**
1424
+ * Subscribe to an event once
1425
+ * @param event - Event name
1426
+ * @param handler - Event handler
1427
+ */
1428
+ once(event, handler) {
1429
+ return super.once(event, handler);
1430
+ }
1431
+ /**
1432
+ * Unsubscribe from an event
1433
+ * @param event - Event name
1434
+ * @param handler - Event handler
1435
+ */
1436
+ off(event, handler) {
1437
+ return super.off(event, handler);
1438
+ }
1439
+ }
1440
+
1441
+ class OfflineQueueStorage {
1442
+ constructor(innerStorage) {
1443
+ this.queue = [];
1444
+ this.isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
1445
+ this.isSyncing = false;
1446
+ this.queueStorageKey = 'achievements_offline_queue';
1447
+ this.handleOnline = () => {
1448
+ this.isOnline = true;
1449
+ console.log('[OfflineQueue] Back online, processing queue...');
1450
+ this.processQueue();
1451
+ };
1452
+ this.handleOffline = () => {
1453
+ this.isOnline = false;
1454
+ console.log('[OfflineQueue] Offline mode activated');
1455
+ };
1456
+ this.innerStorage = innerStorage;
1457
+ // Load queued operations from localStorage
1458
+ this.loadQueue();
1459
+ // Listen for online/offline events (only in browser environment)
1460
+ if (typeof window !== 'undefined') {
1461
+ window.addEventListener('online', this.handleOnline);
1462
+ window.addEventListener('offline', this.handleOffline);
1463
+ }
1464
+ // Process queue if already online
1465
+ if (this.isOnline) {
1466
+ this.processQueue();
1467
+ }
1468
+ }
1469
+ loadQueue() {
1470
+ try {
1471
+ if (typeof localStorage !== 'undefined') {
1472
+ const queueData = localStorage.getItem(this.queueStorageKey);
1473
+ if (queueData) {
1474
+ this.queue = JSON.parse(queueData);
1475
+ }
1476
+ }
1477
+ }
1478
+ catch (error) {
1479
+ console.error('Failed to load offline queue:', error);
1480
+ this.queue = [];
1481
+ }
1482
+ }
1483
+ saveQueue() {
1484
+ try {
1485
+ if (typeof localStorage !== 'undefined') {
1486
+ localStorage.setItem(this.queueStorageKey, JSON.stringify(this.queue));
1487
+ }
1488
+ }
1489
+ catch (error) {
1490
+ console.error('Failed to save offline queue:', error);
1491
+ }
1492
+ }
1493
+ processQueue() {
1494
+ return __awaiter(this, void 0, void 0, function* () {
1495
+ if (this.isSyncing || this.queue.length === 0 || !this.isOnline) {
1496
+ return;
1497
+ }
1498
+ this.isSyncing = true;
1499
+ try {
1500
+ // Process operations in order
1501
+ while (this.queue.length > 0 && this.isOnline) {
1502
+ const operation = this.queue[0];
1503
+ try {
1504
+ switch (operation.type) {
1505
+ case 'setMetrics':
1506
+ yield this.innerStorage.setMetrics(operation.data);
1507
+ break;
1508
+ case 'setUnlockedAchievements':
1509
+ yield this.innerStorage.setUnlockedAchievements(operation.data);
1510
+ break;
1511
+ case 'clear':
1512
+ yield this.innerStorage.clear();
1513
+ break;
1514
+ }
1515
+ // Operation succeeded, remove from queue
1516
+ this.queue.shift();
1517
+ this.saveQueue();
1518
+ }
1519
+ catch (error) {
1520
+ console.error('Failed to sync queued operation:', error);
1521
+ // Stop processing on error, will retry later
1522
+ break;
1523
+ }
1524
+ }
1525
+ }
1526
+ finally {
1527
+ this.isSyncing = false;
1528
+ }
1529
+ });
1530
+ }
1531
+ queueOperation(type, data) {
1532
+ const operation = {
1533
+ id: `${Date.now()}_${Math.random()}`,
1534
+ type,
1535
+ data,
1536
+ timestamp: Date.now()
1537
+ };
1538
+ this.queue.push(operation);
1539
+ this.saveQueue();
1540
+ // Try to process queue if online
1541
+ if (this.isOnline) {
1542
+ this.processQueue();
1543
+ }
1544
+ }
1545
+ getMetrics() {
1546
+ return __awaiter(this, void 0, void 0, function* () {
1547
+ // Reads always try to hit the server first
1548
+ try {
1549
+ return yield this.innerStorage.getMetrics();
1550
+ }
1551
+ catch (error) {
1552
+ if (!this.isOnline) {
1553
+ throw new StorageError('Cannot read metrics while offline');
1554
+ }
1555
+ throw error;
1556
+ }
1557
+ });
1558
+ }
1559
+ setMetrics(metrics) {
1560
+ return __awaiter(this, void 0, void 0, function* () {
1561
+ if (this.isOnline) {
1562
+ try {
1563
+ yield this.innerStorage.setMetrics(metrics);
1564
+ return;
1565
+ }
1566
+ catch (error) {
1567
+ // Failed while online, queue it
1568
+ console.warn('Failed to set metrics, queuing for later:', error);
1569
+ }
1570
+ }
1571
+ // Queue operation if offline or if online operation failed
1572
+ this.queueOperation('setMetrics', metrics);
1573
+ });
1574
+ }
1575
+ getUnlockedAchievements() {
1576
+ return __awaiter(this, void 0, void 0, function* () {
1577
+ // Reads always try to hit the server first
1578
+ try {
1579
+ return yield this.innerStorage.getUnlockedAchievements();
1580
+ }
1581
+ catch (error) {
1582
+ if (!this.isOnline) {
1583
+ throw new StorageError('Cannot read achievements while offline');
1584
+ }
1585
+ throw error;
1586
+ }
1587
+ });
1588
+ }
1589
+ setUnlockedAchievements(achievements) {
1590
+ return __awaiter(this, void 0, void 0, function* () {
1591
+ if (this.isOnline) {
1592
+ try {
1593
+ yield this.innerStorage.setUnlockedAchievements(achievements);
1594
+ return;
1595
+ }
1596
+ catch (error) {
1597
+ // Failed while online, queue it
1598
+ console.warn('Failed to set unlocked achievements, queuing for later:', error);
1599
+ }
1600
+ }
1601
+ // Queue operation if offline or if online operation failed
1602
+ this.queueOperation('setUnlockedAchievements', achievements);
1603
+ });
1604
+ }
1605
+ clear() {
1606
+ return __awaiter(this, void 0, void 0, function* () {
1607
+ if (this.isOnline) {
1608
+ try {
1609
+ yield this.innerStorage.clear();
1610
+ // Also clear the queue
1611
+ this.queue = [];
1612
+ this.saveQueue();
1613
+ return;
1614
+ }
1615
+ catch (error) {
1616
+ console.warn('Failed to clear, queuing for later:', error);
1617
+ }
1618
+ }
1619
+ // Queue operation if offline or if online operation failed
1620
+ this.queueOperation('clear');
1621
+ });
1622
+ }
1623
+ /**
1624
+ * Manually trigger queue processing (useful for testing)
1625
+ */
1626
+ sync() {
1627
+ return __awaiter(this, void 0, void 0, function* () {
1628
+ yield this.processQueue();
1629
+ });
1630
+ }
1631
+ /**
1632
+ * Get current queue status (useful for debugging)
1633
+ */
1634
+ getQueueStatus() {
1635
+ return {
1636
+ pending: this.queue.length,
1637
+ operations: [...this.queue]
1638
+ };
1639
+ }
1640
+ /**
1641
+ * Cleanup listeners (call on unmount)
1642
+ */
1643
+ destroy() {
1644
+ if (typeof window !== 'undefined') {
1645
+ window.removeEventListener('online', this.handleOnline);
1646
+ window.removeEventListener('offline', this.handleOffline);
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ exports.AchievementEngine = AchievementEngine;
1652
+ exports.AchievementError = AchievementError;
1653
+ exports.AsyncStorageAdapter = AsyncStorageAdapter;
1654
+ exports.ConfigurationError = ConfigurationError;
1655
+ exports.EventEmitter = EventEmitter;
1656
+ exports.ImportValidationError = ImportValidationError;
1657
+ exports.IndexedDBStorage = IndexedDBStorage;
1658
+ exports.LocalStorage = LocalStorage;
1659
+ exports.MemoryStorage = MemoryStorage;
1660
+ exports.OfflineQueueStorage = OfflineQueueStorage;
1661
+ exports.RestApiStorage = RestApiStorage;
1662
+ exports.StorageError = StorageError;
1663
+ exports.StorageQuotaError = StorageQuotaError;
1664
+ exports.SyncError = SyncError;
1665
+ exports.createConfigHash = createConfigHash;
1666
+ exports.exportAchievementData = exportAchievementData;
1667
+ exports.importAchievementData = importAchievementData;
1668
+ exports.isAchievementError = isAchievementError;
1669
+ exports.isAsyncStorage = isAsyncStorage;
1670
+ exports.isRecoverableError = isRecoverableError;
1671
+ exports.normalizeAchievements = normalizeAchievements;
1672
+ //# sourceMappingURL=index.js.map