@ak--47/dungeon-master 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.
Files changed (66) hide show
  1. package/README.md +518 -0
  2. package/dungeons/array-of-object-lookup-schema.json +327 -0
  3. package/dungeons/array-of-object-lookup.js +220 -0
  4. package/dungeons/ecommerce-schema.json +462 -0
  5. package/dungeons/ecommerce.js +447 -0
  6. package/dungeons/education-schema.json +2409 -0
  7. package/dungeons/education.js +768 -0
  8. package/dungeons/fintech-schema.json +14034 -0
  9. package/dungeons/fintech.js +696 -0
  10. package/dungeons/foobar-schema.json +403 -0
  11. package/dungeons/foobar.js +296 -0
  12. package/dungeons/food-delivery-schema.json +192 -0
  13. package/dungeons/food-delivery.js +602 -0
  14. package/dungeons/food-schema.json +1152 -0
  15. package/dungeons/food.js +754 -0
  16. package/dungeons/gaming-schema.json +1270 -0
  17. package/dungeons/gaming.js +508 -0
  18. package/dungeons/insurance-application-schema.json +204 -0
  19. package/dungeons/insurance-application.js +605 -0
  20. package/dungeons/media-schema.json +906 -0
  21. package/dungeons/media.js +790 -0
  22. package/dungeons/retention-cadence-schema.json +78 -0
  23. package/dungeons/retention-cadence.js +244 -0
  24. package/dungeons/rpg-schema.json +4526 -0
  25. package/dungeons/rpg.js +919 -0
  26. package/dungeons/sanity-schema.json +255 -0
  27. package/dungeons/sanity.js +152 -0
  28. package/dungeons/sass-schema.json +1291 -0
  29. package/dungeons/sass.js +795 -0
  30. package/dungeons/scd-schema.json +919 -0
  31. package/dungeons/scd.js +277 -0
  32. package/dungeons/simple-schema.json +608 -0
  33. package/dungeons/simple.js +285 -0
  34. package/dungeons/simplest-schema.json +1418 -0
  35. package/dungeons/simplest.js +392 -0
  36. package/dungeons/social-schema.json +1118 -0
  37. package/dungeons/social.js +686 -0
  38. package/dungeons/text-generation-schema.json +3096 -0
  39. package/dungeons/text-generation.js +812 -0
  40. package/index.js +567 -0
  41. package/lib/core/config-validator.js +395 -0
  42. package/lib/core/context.js +204 -0
  43. package/lib/core/dungeon-loader.js +337 -0
  44. package/lib/core/storage.js +379 -0
  45. package/lib/generators/adspend.js +132 -0
  46. package/lib/generators/events.js +271 -0
  47. package/lib/generators/funnels.js +407 -0
  48. package/lib/generators/mirror.js +167 -0
  49. package/lib/generators/product-lookup.js +262 -0
  50. package/lib/generators/product-names.js +195 -0
  51. package/lib/generators/profiles.js +93 -0
  52. package/lib/generators/scd.js +124 -0
  53. package/lib/generators/text.js +1192 -0
  54. package/lib/orchestrators/mixpanel-sender.js +266 -0
  55. package/lib/orchestrators/user-loop.js +335 -0
  56. package/lib/templates/abbreviated.d.ts +169 -0
  57. package/lib/templates/defaults.js +1405 -0
  58. package/lib/templates/phrases.js +2526 -0
  59. package/lib/templates/schema.d.ts +173 -0
  60. package/lib/templates/soup-presets.js +188 -0
  61. package/lib/utils/function-registry.js +302 -0
  62. package/lib/utils/json-evaluator.js +172 -0
  63. package/lib/utils/logger.js +34 -0
  64. package/lib/utils/utils.js +1490 -0
  65. package/package.json +89 -0
  66. package/types.d.ts +865 -0
package/index.js ADDED
@@ -0,0 +1,567 @@
1
+ /**
2
+ * dungeon-master: Generate realistic Mixpanel data for testing and demos
3
+ * Modular, scalable data generation with support for events, users, funnels, SCDs, and more
4
+ *
5
+ * @author AK <ak@mixpanel.com>
6
+ */
7
+
8
+ /** @typedef {import('./types').Dungeon} Config */
9
+ /** @typedef {import('./types').Storage} Storage */
10
+ /** @typedef {import('./types').Result} Result */
11
+ /** @typedef {import('./types').Context} Context */
12
+
13
+ // Core modules
14
+ import { createContext, updateContextWithStorage } from './lib/core/context.js';
15
+ import { validateDungeonConfig } from './lib/core/config-validator.js';
16
+ import { StorageManager } from './lib/core/storage.js';
17
+ import { detectInputType, loadFromFile, loadFromText, parseJSONDungeon, validateDungeonShape } from './lib/core/dungeon-loader.js';
18
+
19
+ // Orchestrators
20
+ import { userLoop } from './lib/orchestrators/user-loop.js';
21
+ import { sendToMixpanel } from './lib/orchestrators/mixpanel-sender.js';
22
+ // Generators
23
+ import { makeAdSpend } from './lib/generators/adspend.js';
24
+ import { makeMirror } from './lib/generators/mirror.js';
25
+ import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
26
+
27
+ // Utilities
28
+ import { initChance } from './lib/utils/utils.js';
29
+
30
+ // External dependencies
31
+ import dayjs from "dayjs";
32
+ import utc from "dayjs/plugin/utc.js";
33
+ import { timer } from 'ak-tools';
34
+ import { dataLogger as logger } from './lib/utils/logger.js';
35
+
36
+ // Initialize dayjs and time constants
37
+ dayjs.extend(utc);
38
+ const FIXED_NOW = dayjs('2024-02-02').unix();
39
+ global.FIXED_NOW = FIXED_NOW;
40
+ let FIXED_BEGIN = dayjs.unix(FIXED_NOW).subtract(90, 'd').unix();
41
+ global.FIXED_BEGIN = FIXED_BEGIN;
42
+
43
+
44
+ /**
45
+ * DUNGEON_MASTER: main entry point for generating Mixpanel data
46
+ *
47
+ * accepts multiple input formats:
48
+ * - a dungeon config object (plain JS object with events, funnels, hooks, etc.)
49
+ * - a file path to a .js/.mjs dungeon file on disk
50
+ * - a file path to a .json dungeon file (UI schema format)
51
+ * - an array of file paths (runs each dungeon, returns array of results)
52
+ * - a string of raw JavaScript containing a dungeon (must use `export default`)
53
+ *
54
+ * @param {Config | string | string[]} input - Dungeon config, file path(s), or JS source text
55
+ * @param {Partial<Config>} [overrides] - Optional config overrides merged into every dungeon
56
+ * @returns {Promise<Result | Result[]>} Generated data and metadata
57
+ *
58
+ * @example
59
+ * // config object
60
+ * const result = await DUNGEON_MASTER({ numUsers: 100, numEvents: 10_000, numDays: 30 });
61
+ *
62
+ * @example
63
+ * // file path
64
+ * const result = await DUNGEON_MASTER('./dungeons/simple.js');
65
+ *
66
+ * @example
67
+ * // JSON dungeon (from UI export)
68
+ * const result = await DUNGEON_MASTER('./dungeons/simple-schema.json');
69
+ *
70
+ * @example
71
+ * // multiple dungeons
72
+ * const results = await DUNGEON_MASTER(['./dungeons/gaming.js', './dungeons/media.js']);
73
+ *
74
+ * @example
75
+ * // raw JS text
76
+ * const result = await DUNGEON_MASTER(`
77
+ * export default {
78
+ * numUsers: 50,
79
+ * numEvents: 5_000,
80
+ * events: [{ event: "page view", weight: 5 }, { event: "click", weight: 3 }]
81
+ * };
82
+ * `);
83
+ *
84
+ * @example
85
+ * // with overrides
86
+ * const result = await DUNGEON_MASTER('./dungeons/simple.js', { writeToDisk: true, verbose: true });
87
+ */
88
+ async function DUNGEON_MASTER(input, overrides = {}) {
89
+ const { type, value } = detectInputType(input);
90
+
91
+ switch (type) {
92
+ case 'object':
93
+ return await runDungeon({ ...value, ...overrides });
94
+
95
+ case 'file':
96
+ const config = await loadFromFile(value);
97
+ return await runDungeon({ ...config, ...overrides });
98
+
99
+ case 'files': {
100
+ const results = [];
101
+ for (const filePath of value) {
102
+ const fileConfig = await loadFromFile(filePath);
103
+ results.push(await runDungeon({ ...fileConfig, ...overrides }));
104
+ }
105
+ return results;
106
+ }
107
+
108
+ case 'text': {
109
+ const textConfig = await loadFromText(value);
110
+ return await runDungeon({ ...textConfig, ...overrides });
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Run a single dungeon config through the generation pipeline
117
+ * @param {Config} config - Validated/enriched dungeon configuration
118
+ * @returns {Promise<Result>} Generated data and metadata
119
+ */
120
+ async function runDungeon(config) {
121
+ const jobTimer = timer('job');
122
+ jobTimer.start();
123
+
124
+ if (config.verbose) logger.info({ seed: config.seed }, 'Configuring dungeon');
125
+ let validatedConfig;
126
+ try {
127
+ // Step 1: Validate and enrich configuration
128
+ validatedConfig = validateDungeonConfig(config);
129
+
130
+ // Ensure seeded RNG is initialized (dungeons do this at module scope,
131
+ // but npm-module consumers pass seed via config object)
132
+ if (validatedConfig.seed) {
133
+ initChance(validatedConfig.seed);
134
+ }
135
+
136
+ // Update FIXED_BEGIN based on configured numDays
137
+ const configNumDays = validatedConfig.numDays || 30;
138
+ global.FIXED_BEGIN = dayjs.unix(FIXED_NOW).subtract(configNumDays, 'd').unix();
139
+
140
+ // Step 2: Create context with validated config
141
+ const context = createContext(validatedConfig);
142
+
143
+ // Step 3: Initialize storage containers
144
+ const storageManager = new StorageManager(context);
145
+ const storage = await storageManager.initializeContainers();
146
+ updateContextWithStorage(context, storage);
147
+
148
+ // ! DATA GENERATION STARTS HERE
149
+
150
+ // Step 4: Generate ad spend data (if enabled)
151
+ if (validatedConfig.hasAdSpend) {
152
+ await generateAdSpendData(context);
153
+ }
154
+
155
+ if (context.config.verbose) logger.info('Starting user and event generation...');
156
+ // Step 5: Main user and event generation
157
+ await userLoop(context);
158
+
159
+ // Step 6: Generate group profiles (if configured)
160
+ if (validatedConfig.groupKeys && validatedConfig.groupKeys.length > 0) {
161
+ await generateGroupProfiles(context);
162
+ }
163
+
164
+ // Step 7: Generate group SCDs (if configured)
165
+ if (validatedConfig.scdProps && validatedConfig.groupKeys && validatedConfig.groupKeys.length > 0) {
166
+ await generateGroupSCDs(context);
167
+ }
168
+
169
+ // Step 8: Generate lookup tables (if configured)
170
+ if (validatedConfig.lookupTables && validatedConfig.lookupTables.length > 0) {
171
+ await generateLookupTables(context);
172
+ }
173
+
174
+ // Step 9: Generate mirror datasets (if configured)
175
+ if (validatedConfig.mirrorProps && Object.keys(validatedConfig.mirrorProps).length > 0) {
176
+ await makeMirror(context);
177
+ }
178
+
179
+ if (context.config.verbose) logger.info('Data generation completed successfully');
180
+
181
+ // ! DATA GENERATION ENDS HERE
182
+
183
+ // Flush when writeToDisk is enabled OR batch mode activated (to capture tail data)
184
+ const shouldFlush = validatedConfig.writeToDisk || context.isBatchMode();
185
+
186
+ // Step 10: Flush lookup tables to disk (always as CSVs)
187
+ if (shouldFlush) {
188
+ await flushLookupTablesToDisk(storage, validatedConfig);
189
+ }
190
+
191
+ // Step 11: Flush other storage containers to disk
192
+ if (shouldFlush) {
193
+ await flushStorageToDisk(storage, validatedConfig);
194
+ }
195
+
196
+ // Step 12: Send to Mixpanel (if token provided)
197
+ // Now happens AFTER disk flush so batch files are available for import
198
+ let importResults;
199
+ if (validatedConfig.token) {
200
+ importResults = await sendToMixpanel(context);
201
+ }
202
+
203
+ // Step 13: Compile results
204
+ jobTimer.stop(false);
205
+ const { start, end, delta, human } = jobTimer.report(false);
206
+
207
+ const extractedData = extractStorageData(storage);
208
+
209
+ return {
210
+ ...extractedData,
211
+ importResults,
212
+ files: await extractFileInfo(storage, validatedConfig),
213
+ time: { start, end, delta, human },
214
+ operations: context.getOperations(),
215
+ eventCount: context.getEventCount(),
216
+ userCount: context.getUserCount()
217
+ };
218
+
219
+ } catch (error) {
220
+ logger.error({ err: error }, `Error: ${error.message}`);
221
+ throw error;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Generate ad spend data for configured date range
227
+ * @param {Context} context - Context object
228
+ */
229
+ async function generateAdSpendData(context) {
230
+ const { config, storage } = context;
231
+ const { numDays } = config;
232
+
233
+ const timeShift = context.TIME_SHIFT_SECONDS;
234
+ for (let day = 0; day < numDays; day++) {
235
+ const fixedDay = dayjs.unix(global.FIXED_BEGIN).add(day, 'day').unix();
236
+ const shiftedDay = Math.min(fixedDay + timeShift, context.MAX_TIME);
237
+ const targetDay = dayjs.unix(shiftedDay).toISOString();
238
+ const adSpendEvents = await makeAdSpend(context, targetDay);
239
+
240
+ if (adSpendEvents.length > 0) {
241
+ for (const adSpendEvent of adSpendEvents) {
242
+ await storage.adSpendData.hookPush(adSpendEvent);
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Generate group profiles for all configured group keys
250
+ * @param {Context} context - Context object
251
+ */
252
+ async function generateGroupProfiles(context) {
253
+ const { config, storage } = context;
254
+ const { groupKeys, groupProps = {} } = config;
255
+
256
+ if (config.verbose) {
257
+ logger.info('Generating group profiles...');
258
+ }
259
+
260
+ for (let i = 0; i < groupKeys.length; i++) {
261
+ const [groupKey, groupCount] = groupKeys[i];
262
+ const groupContainer = storage.groupProfilesData[i];
263
+
264
+ if (!groupContainer) {
265
+ if (config.verbose) console.warn(`Warning: No storage container found for group key: ${groupKey}`);
266
+ continue;
267
+ }
268
+
269
+ if (config.verbose) {
270
+ logger.info({ groupKey, groupCount }, `Creating ${groupCount.toLocaleString()} ${groupKey} profiles...`);
271
+ }
272
+
273
+ // Get group-specific props if available
274
+ const specificGroupProps = groupProps[groupKey] || {};
275
+
276
+ for (let j = 0; j < groupCount; j++) {
277
+ const groupProfile = await makeGroupProfile(context, groupKey, specificGroupProps, {
278
+ [groupKey]: String(j + 1)
279
+ });
280
+
281
+ await groupContainer.hookPush(groupProfile);
282
+ }
283
+ }
284
+
285
+ if (config.verbose) {
286
+ logger.info('Group profiles generated successfully');
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Generate lookup tables for all configured lookup schemas
292
+ * @param {Context} context - Context object
293
+ */
294
+ async function generateLookupTables(context) {
295
+ const { config, storage } = context;
296
+ const { lookupTables } = config;
297
+
298
+ if (config.verbose) {
299
+ logger.info('Generating lookup tables...');
300
+ }
301
+
302
+ for (let i = 0; i < lookupTables.length; i++) {
303
+ const lookupConfig = lookupTables[i];
304
+ const { key, entries, attributes } = lookupConfig;
305
+ const lookupContainer = storage.lookupTableData[i];
306
+
307
+ if (!lookupContainer) {
308
+ if (config.verbose) console.warn(`Warning: No storage container found for lookup table: ${key}`);
309
+ continue;
310
+ }
311
+
312
+ if (config.verbose) {
313
+ logger.info({ key, entries }, `Creating ${entries.toLocaleString()} ${key} lookup entries...`);
314
+ }
315
+
316
+ for (let j = 0; j < entries; j++) {
317
+ const lookupEntry = await makeProfile(context, attributes, {
318
+ id: j + 1 //primary key is always a number so it joins simply with events
319
+ // [key]: `${key}_${j + 1}` // we don't want to use the lookup name as a prefix here
320
+ });
321
+
322
+ await lookupContainer.hookPush(lookupEntry);
323
+ }
324
+ }
325
+
326
+ if (config.verbose) {
327
+ logger.info('Lookup tables generated successfully');
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Generate SCDs for group entities
333
+ * @param {Context} context - Context object
334
+ */
335
+ async function generateGroupSCDs(context) {
336
+ const { config, storage } = context;
337
+ const { scdProps, groupKeys } = config;
338
+
339
+ if (config.verbose) {
340
+ logger.info('Generating group SCDs...');
341
+ }
342
+
343
+ // Import utilities and generators
344
+ const { objFilter } = await import('ak-tools');
345
+ const { makeSCD } = await import('./lib/generators/scd.js');
346
+ const u = await import('./lib/utils/utils.js');
347
+ const chance = u.getChance();
348
+
349
+ // Get only group SCDs (not user SCDs)
350
+ // @ts-ignore
351
+ const groupSCDProps = objFilter(scdProps, (scd) => scd.type && scd.type !== 'user');
352
+
353
+ for (const [groupKey, groupCount] of groupKeys) {
354
+ // Filter SCDs that apply to this specific group key
355
+ // @ts-ignore
356
+ const groupSpecificSCDs = objFilter(groupSCDProps, (scd) => scd.type === groupKey);
357
+
358
+ if (Object.keys(groupSpecificSCDs).length === 0) {
359
+ continue; // No SCDs for this group type
360
+ }
361
+
362
+ if (config.verbose) {
363
+ logger.info({ groupKey, groupCount }, `Generating SCDs for ${groupCount.toLocaleString()} ${groupKey} entities...`);
364
+ }
365
+
366
+ // Generate SCDs for each group entity
367
+ for (let i = 0; i < groupCount; i++) {
368
+ const groupId = String(i + 1);
369
+
370
+ // Generate SCDs for this group entity
371
+ for (const [scdKey, scdConfig] of Object.entries(groupSpecificSCDs)) {
372
+ const { max = 10 } = scdConfig;
373
+ const mutations = chance.integer({ min: 1, max });
374
+
375
+ // Use a base time for the group entity (similar to user creation time)
376
+ const baseTime = context.FIXED_BEGIN || context.FIXED_NOW;
377
+ let changes = await makeSCD(context, scdConfig, scdKey, groupId, mutations, baseTime);
378
+
379
+ // Apply hook if configured
380
+ if (config.hook) {
381
+ const hookResult = await config.hook(changes, "scd-pre", {
382
+ type: 'group',
383
+ groupKey,
384
+ scd: { [scdKey]: scdConfig },
385
+ config
386
+ });
387
+ if (Array.isArray(hookResult)) {
388
+ changes = hookResult;
389
+ }
390
+ }
391
+
392
+ // Store SCDs in the appropriate SCD table
393
+ for (const change of changes) {
394
+ try {
395
+ const target = storage.scdTableData.filter(arr => arr.scdKey === scdKey).pop();
396
+ await target.hookPush(change, { type: 'group', groupKey });
397
+ } catch (e) {
398
+ // Fallback for tests
399
+ const target = storage.scdTableData[0];
400
+ await target.hookPush(change, { type: 'group', groupKey });
401
+ }
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ if (config.verbose) {
408
+ logger.info('Group SCDs generated successfully');
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Flush lookup tables to disk (always runs, regardless of writeToDisk setting)
414
+ * @param {import('./types').Storage} storage - Storage containers
415
+ * @param {import('./types').Dungeon} config - Configuration object
416
+ */
417
+ async function flushLookupTablesToDisk(storage, config) {
418
+ if (!storage.lookupTableData || !Array.isArray(storage.lookupTableData) || storage.lookupTableData.length === 0) {
419
+ return; // No lookup tables to flush
420
+ }
421
+
422
+ if (config.verbose) {
423
+ console.log('šŸ’¾ Writing lookup tables to disk...');
424
+ }
425
+
426
+ const flushPromises = [];
427
+ storage.lookupTableData.forEach(container => {
428
+ if (container?.flush) flushPromises.push(container.flush());
429
+ });
430
+
431
+ await Promise.all(flushPromises);
432
+
433
+ if (config.verbose) {
434
+ console.log('šŸ—‚ļø Lookup tables flushed to disk successfully');
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Flush all storage containers to disk (excluding lookup tables)
440
+ * @param {import('./types').Storage} storage - Storage containers
441
+ * @param {import('./types').Dungeon} config - Configuration object
442
+ */
443
+ async function flushStorageToDisk(storage, config) {
444
+ if (config.verbose) {
445
+ console.log('\nšŸ’¾ Writing data to disk...');
446
+ }
447
+
448
+ const flushPromises = [];
449
+
450
+ // Flush single HookedArray containers
451
+ if (storage.eventData?.flush) flushPromises.push(storage.eventData.flush());
452
+ if (storage.userProfilesData?.flush) flushPromises.push(storage.userProfilesData.flush());
453
+ if (storage.adSpendData?.flush) flushPromises.push(storage.adSpendData.flush());
454
+ if (storage.mirrorEventData?.flush) flushPromises.push(storage.mirrorEventData.flush());
455
+ if (storage.groupEventData?.flush) flushPromises.push(storage.groupEventData.flush());
456
+
457
+ // Flush arrays of HookedArrays (excluding lookup tables which are handled separately)
458
+ [storage.scdTableData, storage.groupProfilesData].forEach(arrayOfContainers => {
459
+ if (Array.isArray(arrayOfContainers)) {
460
+ arrayOfContainers.forEach(container => {
461
+ if (container?.flush) flushPromises.push(container.flush());
462
+ });
463
+ }
464
+ });
465
+
466
+ await Promise.all(flushPromises);
467
+
468
+ if (config.verbose) {
469
+ console.log('šŸ™ Data flushed to disk successfully');
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Extract file information from storage containers
475
+ * @param {import('./types').Storage} storage - Storage object
476
+ * @param {import('./types').Dungeon} config - Configuration object
477
+ * @returns {Promise<string[]>} Array of file paths
478
+ */
479
+ async function extractFileInfo(storage, config) {
480
+ const files = [];
481
+
482
+ // Try to get paths from containers first
483
+ Object.values(storage).forEach(container => {
484
+ if (Array.isArray(container)) {
485
+ container.forEach(subContainer => {
486
+ if (subContainer?.getWritePath) {
487
+ files.push(subContainer.getWritePath());
488
+ }
489
+ });
490
+ } else if (container?.getWritePath) {
491
+ files.push(container.getWritePath());
492
+ }
493
+ });
494
+
495
+ // If no files found from containers and writeToDisk is enabled, scan the data directory
496
+ if (files.length === 0 && config.writeToDisk) {
497
+ try {
498
+ const fs = await import('fs');
499
+ const path = await import('path');
500
+
501
+ let dataDir = path.resolve("./data");
502
+ if (!fs.existsSync(dataDir)) {
503
+ dataDir = path.resolve("./");
504
+ }
505
+
506
+ if (fs.existsSync(dataDir)) {
507
+ const allFiles = fs.readdirSync(dataDir);
508
+ const simulationName = config.name;
509
+
510
+ // Filter files that match our patterns and were likely created by this run
511
+ const relevantFiles = allFiles.filter(file => {
512
+ // Skip system files
513
+ if (file.startsWith('.')) return false;
514
+
515
+ // If we have a simulation name, only include files with that prefix
516
+ if (simulationName && !file.startsWith(simulationName)) {
517
+ return false;
518
+ }
519
+
520
+ // Check for common patterns
521
+ const hasEventPattern = file.includes('-EVENTS.');
522
+ const hasUserPattern = file.includes('-USERS.');
523
+ const hasScdPattern = file.includes('-SCD.');
524
+ const hasGroupPattern = file.includes('-GROUPS.');
525
+ const hasLookupPattern = file.includes('-LOOKUP.');
526
+ const hasAdspendPattern = file.includes('-ADSPEND.');
527
+ const hasMirrorPattern = file.includes('-MIRROR.');
528
+
529
+ return hasEventPattern || hasUserPattern || hasScdPattern ||
530
+ hasGroupPattern || hasLookupPattern || hasAdspendPattern || hasMirrorPattern;
531
+ });
532
+
533
+ // Convert to full paths
534
+ relevantFiles.forEach(file => {
535
+ files.push(path.join(dataDir, file));
536
+ });
537
+ }
538
+ } catch (error) {
539
+ // If scanning fails, just return empty array
540
+ }
541
+ }
542
+
543
+ return files;
544
+ }
545
+
546
+ /**
547
+ * Extract data from storage containers, preserving array structure for groups/lookups/SCDs
548
+ * @param {import('./types').Storage} storage - Storage object
549
+ * @returns {object} Extracted data in Result format
550
+ */
551
+ function extractStorageData(storage) {
552
+ return {
553
+ eventData: storage.eventData || [],
554
+ mirrorEventData: storage.mirrorEventData || [],
555
+ userProfilesData: storage.userProfilesData || [],
556
+ adSpendData: storage.adSpendData || [],
557
+ // Keep arrays of HookedArrays as separate arrays (don't flatten)
558
+ scdTableData: storage.scdTableData || [],
559
+ groupProfilesData: storage.groupProfilesData || [],
560
+ lookupTableData: storage.lookupTableData || []
561
+ };
562
+ }
563
+
564
+ // ES Module exports
565
+ export default DUNGEON_MASTER;
566
+ export { parseJSONDungeon, validateDungeonShape, loadFromFile, loadFromText };
567
+