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/README.md +363 -0
- package/dist/index.cjs +1699 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +635 -0
- package/dist/index.esm.js +1677 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +1672 -0
- package/dist/index.js.map +1 -0
- package/dist/index.umd.js +1705 -0
- package/dist/index.umd.js.map +1 -0
- package/package.json +57 -0
|
@@ -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
|