@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.
- package/README.md +518 -0
- package/dungeons/array-of-object-lookup-schema.json +327 -0
- package/dungeons/array-of-object-lookup.js +220 -0
- package/dungeons/ecommerce-schema.json +462 -0
- package/dungeons/ecommerce.js +447 -0
- package/dungeons/education-schema.json +2409 -0
- package/dungeons/education.js +768 -0
- package/dungeons/fintech-schema.json +14034 -0
- package/dungeons/fintech.js +696 -0
- package/dungeons/foobar-schema.json +403 -0
- package/dungeons/foobar.js +296 -0
- package/dungeons/food-delivery-schema.json +192 -0
- package/dungeons/food-delivery.js +602 -0
- package/dungeons/food-schema.json +1152 -0
- package/dungeons/food.js +754 -0
- package/dungeons/gaming-schema.json +1270 -0
- package/dungeons/gaming.js +508 -0
- package/dungeons/insurance-application-schema.json +204 -0
- package/dungeons/insurance-application.js +605 -0
- package/dungeons/media-schema.json +906 -0
- package/dungeons/media.js +790 -0
- package/dungeons/retention-cadence-schema.json +78 -0
- package/dungeons/retention-cadence.js +244 -0
- package/dungeons/rpg-schema.json +4526 -0
- package/dungeons/rpg.js +919 -0
- package/dungeons/sanity-schema.json +255 -0
- package/dungeons/sanity.js +152 -0
- package/dungeons/sass-schema.json +1291 -0
- package/dungeons/sass.js +795 -0
- package/dungeons/scd-schema.json +919 -0
- package/dungeons/scd.js +277 -0
- package/dungeons/simple-schema.json +608 -0
- package/dungeons/simple.js +285 -0
- package/dungeons/simplest-schema.json +1418 -0
- package/dungeons/simplest.js +392 -0
- package/dungeons/social-schema.json +1118 -0
- package/dungeons/social.js +686 -0
- package/dungeons/text-generation-schema.json +3096 -0
- package/dungeons/text-generation.js +812 -0
- package/index.js +567 -0
- package/lib/core/config-validator.js +395 -0
- package/lib/core/context.js +204 -0
- package/lib/core/dungeon-loader.js +337 -0
- package/lib/core/storage.js +379 -0
- package/lib/generators/adspend.js +132 -0
- package/lib/generators/events.js +271 -0
- package/lib/generators/funnels.js +407 -0
- package/lib/generators/mirror.js +167 -0
- package/lib/generators/product-lookup.js +262 -0
- package/lib/generators/product-names.js +195 -0
- package/lib/generators/profiles.js +93 -0
- package/lib/generators/scd.js +124 -0
- package/lib/generators/text.js +1192 -0
- package/lib/orchestrators/mixpanel-sender.js +266 -0
- package/lib/orchestrators/user-loop.js +335 -0
- package/lib/templates/abbreviated.d.ts +169 -0
- package/lib/templates/defaults.js +1405 -0
- package/lib/templates/phrases.js +2526 -0
- package/lib/templates/schema.d.ts +173 -0
- package/lib/templates/soup-presets.js +188 -0
- package/lib/utils/function-registry.js +302 -0
- package/lib/utils/json-evaluator.js +172 -0
- package/lib/utils/logger.js +34 -0
- package/lib/utils/utils.js +1490 -0
- package/package.json +89 -0
- 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
|
+
|