@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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration validation and enrichment module
|
|
3
|
+
* Extracted from index.js validateDungeonConfig function
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types.js').Dungeon} Dungeon */
|
|
7
|
+
/** @typedef {import('../../types.js').EventConfig} EventConfig */
|
|
8
|
+
/** @typedef {import('../../types.js').Context} Context */
|
|
9
|
+
/** @typedef {import('../../types.js').Funnel} Funnel */
|
|
10
|
+
|
|
11
|
+
import dayjs from "dayjs";
|
|
12
|
+
import { makeName } from "ak-tools";
|
|
13
|
+
import * as u from "../utils/utils.js";
|
|
14
|
+
import { resolveSoup } from "../templates/soup-presets.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Infers funnels from the provided events
|
|
18
|
+
* @param {EventConfig[]} events - Array of event configurations
|
|
19
|
+
* @returns {Funnel[]} Array of inferred funnel configurations
|
|
20
|
+
*/
|
|
21
|
+
function inferFunnels(events) {
|
|
22
|
+
const createdFunnels = [];
|
|
23
|
+
const firstEvents = events.filter((e) => e.isFirstEvent).map((e) => e.event);
|
|
24
|
+
const strictEvents = events.filter((e) => e.isStrictEvent).map((e) => e.event);
|
|
25
|
+
const usageEvents = events
|
|
26
|
+
.filter((e) => !e.isFirstEvent && !e.isStrictEvent)
|
|
27
|
+
.map((e) => e.event);
|
|
28
|
+
const numFunnelsToCreate = Math.ceil(usageEvents.length);
|
|
29
|
+
|
|
30
|
+
/** @type {import('../../types.js').Funnel} */
|
|
31
|
+
const funnelTemplate = {
|
|
32
|
+
sequence: [],
|
|
33
|
+
conversionRate: 50,
|
|
34
|
+
order: 'sequential',
|
|
35
|
+
requireRepeats: false,
|
|
36
|
+
props: {},
|
|
37
|
+
timeToConvert: 1,
|
|
38
|
+
isFirstFunnel: false,
|
|
39
|
+
weight: 1
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Create funnels for first events
|
|
43
|
+
if (firstEvents.length) {
|
|
44
|
+
for (const event of firstEvents) {
|
|
45
|
+
createdFunnels.push({
|
|
46
|
+
...u.deepClone(funnelTemplate),
|
|
47
|
+
sequence: [event],
|
|
48
|
+
isFirstFunnel: true,
|
|
49
|
+
conversionRate: 100
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// At least one funnel with all usage events
|
|
55
|
+
createdFunnels.push({ ...u.deepClone(funnelTemplate), sequence: usageEvents });
|
|
56
|
+
|
|
57
|
+
// Create random funnels for the rest
|
|
58
|
+
for (let i = 1; i < numFunnelsToCreate; i++) {
|
|
59
|
+
/** @type {import('../../types.js').Funnel} */
|
|
60
|
+
const funnel = { ...u.deepClone(funnelTemplate) };
|
|
61
|
+
funnel.conversionRate = u.integer(10, 50);
|
|
62
|
+
funnel.timeToConvert = u.integer(24, 72);
|
|
63
|
+
funnel.weight = u.integer(1, 10);
|
|
64
|
+
const sequence = u.shuffleArray(usageEvents).slice(0, u.integer(2, usageEvents.length));
|
|
65
|
+
funnel.sequence = sequence;
|
|
66
|
+
funnel.order = 'random';
|
|
67
|
+
createdFunnels.push(funnel);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return createdFunnels;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validates and enriches a dungeon configuration object
|
|
75
|
+
* @param {Partial<Dungeon>} config - Raw configuration object
|
|
76
|
+
* @returns {Dungeon} Validated and enriched configuration
|
|
77
|
+
*/
|
|
78
|
+
export function validateDungeonConfig(config) {
|
|
79
|
+
const chance = u.getChance();
|
|
80
|
+
|
|
81
|
+
// Transform SCD props to regular props if credentials are missing
|
|
82
|
+
// This MUST happen BEFORE we extract values from the config
|
|
83
|
+
transformSCDPropsWithoutCredentials(config);
|
|
84
|
+
|
|
85
|
+
// Extract configuration with defaults
|
|
86
|
+
let {
|
|
87
|
+
seed,
|
|
88
|
+
numEvents = 100_000,
|
|
89
|
+
numUsers = 1000,
|
|
90
|
+
numDays = 30,
|
|
91
|
+
epochStart = 0,
|
|
92
|
+
epochEnd = dayjs().unix(),
|
|
93
|
+
events = [{ event: "foo" }, { event: "bar" }, { event: "baz" }],
|
|
94
|
+
superProps = { luckyNumber: [2, 2, 4, 4, 42, 42, 42, 2, 2, 4, 4, 42, 42, 42, 420] },
|
|
95
|
+
funnels = [],
|
|
96
|
+
userProps = {
|
|
97
|
+
spiritAnimal: chance.animal.bind(chance),
|
|
98
|
+
},
|
|
99
|
+
scdProps = {},
|
|
100
|
+
mirrorProps = {},
|
|
101
|
+
groupKeys = [],
|
|
102
|
+
groupProps = {},
|
|
103
|
+
lookupTables = [],
|
|
104
|
+
hasAnonIds = false,
|
|
105
|
+
hasSessionIds = false,
|
|
106
|
+
format = "csv",
|
|
107
|
+
token = null,
|
|
108
|
+
region = "US",
|
|
109
|
+
writeToDisk = false,
|
|
110
|
+
verbose = false,
|
|
111
|
+
soup = {},
|
|
112
|
+
hook = (record) => record,
|
|
113
|
+
hasAdSpend = false,
|
|
114
|
+
hasCampaigns = false,
|
|
115
|
+
hasLocation = false,
|
|
116
|
+
hasAvatar = false,
|
|
117
|
+
isAnonymous = false,
|
|
118
|
+
hasBrowser = false,
|
|
119
|
+
hasAndroidDevices = false,
|
|
120
|
+
hasDesktopDevices = false,
|
|
121
|
+
hasIOSDevices = false,
|
|
122
|
+
alsoInferFunnels = false,
|
|
123
|
+
name = "",
|
|
124
|
+
batchSize = 2_500_000,
|
|
125
|
+
concurrency = 1,
|
|
126
|
+
strictEventCount = false
|
|
127
|
+
} = config;
|
|
128
|
+
|
|
129
|
+
// Allow concurrency override from config (default is now 1)
|
|
130
|
+
if (config.concurrency === undefined || config.concurrency === null) {
|
|
131
|
+
concurrency = 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Force concurrency to 1 when strictEventCount is enabled
|
|
135
|
+
// This ensures the bailout check works correctly without race conditions
|
|
136
|
+
if (strictEventCount && concurrency !== 1) {
|
|
137
|
+
concurrency = 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Auto-enable batch mode for large datasets to prevent OOM
|
|
141
|
+
if (numEvents >= 2_000_000 && config.batchSize === undefined) {
|
|
142
|
+
batchSize = 1_000_000;
|
|
143
|
+
console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M. Using batchSize of ${batchSize.toLocaleString()}.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Ensure defaults for deep objects
|
|
147
|
+
if (!config.superProps) config.superProps = superProps;
|
|
148
|
+
if (!config.userProps || Object.keys(config?.userProps || {})) config.userProps = userProps;
|
|
149
|
+
|
|
150
|
+
// Setting up "TIME"
|
|
151
|
+
if (epochStart && !numDays) numDays = dayjs.unix(epochEnd).diff(dayjs.unix(epochStart), "day");
|
|
152
|
+
if (!epochStart && numDays) epochStart = dayjs.unix(epochEnd).subtract(numDays, "day").unix();
|
|
153
|
+
if (epochStart && numDays) { } // noop
|
|
154
|
+
if (!epochStart && !numDays) {
|
|
155
|
+
throw new Error("Either epochStart or numDays must be provided");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Resolve soup presets (must happen after numDays is computed)
|
|
159
|
+
const resolved = resolveSoup(soup, numDays);
|
|
160
|
+
soup = resolved.soup;
|
|
161
|
+
// Apply suggested birth distribution params if not explicitly set by the dungeon
|
|
162
|
+
if (resolved.suggestedBornRecentBias !== undefined && config.bornRecentBias === undefined) {
|
|
163
|
+
config.bornRecentBias = resolved.suggestedBornRecentBias;
|
|
164
|
+
}
|
|
165
|
+
if (resolved.suggestedPercentUsersBornInDataset !== undefined && config.percentUsersBornInDataset === undefined) {
|
|
166
|
+
config.percentUsersBornInDataset = resolved.suggestedPercentUsersBornInDataset;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Use provided name if non-empty string, otherwise generate one
|
|
170
|
+
if (!name || name === "") {
|
|
171
|
+
name = makeName();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Convert string hook to function
|
|
175
|
+
if (typeof hook === 'string') {
|
|
176
|
+
try {
|
|
177
|
+
// Use eval in a controlled manner to convert the string to a function
|
|
178
|
+
// The string should be: function(record, type, meta) { ... }
|
|
179
|
+
// eslint-disable-next-line no-eval
|
|
180
|
+
hook = eval(`(${hook})`);
|
|
181
|
+
|
|
182
|
+
// Validate it's actually a function
|
|
183
|
+
if (typeof hook !== 'function') {
|
|
184
|
+
throw new Error('Hook string did not evaluate to a function');
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (config.verbose !== false) {
|
|
188
|
+
console.warn(`\u26a0\ufe0f Failed to convert hook string to function: ${error.message}`);
|
|
189
|
+
console.warn('Using default pass-through hook');
|
|
190
|
+
}
|
|
191
|
+
hook = (record) => record;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Ensure hook is a function
|
|
196
|
+
if (typeof hook !== 'function') {
|
|
197
|
+
if (config.verbose !== false) console.warn('\u26a0\ufe0f Hook is not a function, using default pass-through hook');
|
|
198
|
+
hook = (record) => record;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Validate events
|
|
202
|
+
if (!events || !events.length) events = [{ event: "foo" }, { event: "bar" }, { event: "baz" }];
|
|
203
|
+
|
|
204
|
+
// Convert string events to objects
|
|
205
|
+
if (typeof events[0] === "string") {
|
|
206
|
+
events = events.map(e => ({ event: /** @type {string} */ (e) }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Validate: if every user is born in dataset, we need either isFirstEvent or isFirstFunnel
|
|
210
|
+
const percentBorn = config.percentUsersBornInDataset ?? 15;
|
|
211
|
+
const hasFirstEvent = events.some(e => e.isFirstEvent);
|
|
212
|
+
const hasFirstFunnel = funnels.some(f => f.isFirstFunnel);
|
|
213
|
+
if (percentBorn >= 100 && !hasFirstEvent && !hasFirstFunnel) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
"percentUsersBornInDataset is 100% but no event has isFirstEvent and no funnel has isFirstFunnel. " +
|
|
216
|
+
"Either add isFirstEvent to an event, add a first funnel, or lower percentUsersBornInDataset."
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Handle funnel inference
|
|
221
|
+
if (alsoInferFunnels) {
|
|
222
|
+
const inferredFunnels = inferFunnels(events);
|
|
223
|
+
funnels = [...funnels, ...inferredFunnels];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Create funnel for events not in other funnels
|
|
227
|
+
const eventContainedInFunnels = Array.from(funnels.reduce((acc, f) => {
|
|
228
|
+
const events = f.sequence;
|
|
229
|
+
events.forEach(event => acc.add(event));
|
|
230
|
+
return acc;
|
|
231
|
+
}, new Set()));
|
|
232
|
+
|
|
233
|
+
const eventsNotInFunnels = events
|
|
234
|
+
.filter(e => !e.isFirstEvent)
|
|
235
|
+
.filter(e => !e.isStrictEvent)
|
|
236
|
+
.filter(e => !eventContainedInFunnels.includes(e.event))
|
|
237
|
+
.map(e => e.event);
|
|
238
|
+
|
|
239
|
+
if (eventsNotInFunnels.length) {
|
|
240
|
+
const sequence = u.shuffleArray(eventsNotInFunnels.flatMap(event => {
|
|
241
|
+
let evWeight;
|
|
242
|
+
// First check the config
|
|
243
|
+
if (config.events) {
|
|
244
|
+
evWeight = config.events.find(e => e.event === event)?.weight || 1;
|
|
245
|
+
}
|
|
246
|
+
// Fallback on default
|
|
247
|
+
else {
|
|
248
|
+
evWeight = 1;
|
|
249
|
+
}
|
|
250
|
+
// Clamp weight to reasonable range (1-10) and ensure integer
|
|
251
|
+
evWeight = Math.max(1, Math.min(Math.floor(evWeight) || 1, 10));
|
|
252
|
+
return Array(evWeight).fill(event);
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
funnels.push({
|
|
256
|
+
sequence,
|
|
257
|
+
conversionRate: 50,
|
|
258
|
+
order: 'random',
|
|
259
|
+
timeToConvert: 24 * 14,
|
|
260
|
+
requireRepeats: false,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ensure every event in funnel sequence exists in our eventConfig
|
|
265
|
+
const eventInFunnels = Array.from(new Set(funnels.map(funnel => funnel.sequence).flat()));
|
|
266
|
+
|
|
267
|
+
const definedEvents = events.map(e => e.event);
|
|
268
|
+
const missingEvents = eventInFunnels.filter(event => !definedEvents.includes(event));
|
|
269
|
+
if (missingEvents.length) {
|
|
270
|
+
throw new Error(`Funnel sequences contain events that are not defined in the events config:\n\n${missingEvents.join(', ')}\n\nPlease ensure all events in funnel sequences are defined in the events array.`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
// Event validation
|
|
276
|
+
const validatedEvents = u.validateEventConfig(events);
|
|
277
|
+
|
|
278
|
+
// Build final config object
|
|
279
|
+
const validatedConfig = {
|
|
280
|
+
...config,
|
|
281
|
+
concurrency,
|
|
282
|
+
funnels,
|
|
283
|
+
batchSize,
|
|
284
|
+
seed,
|
|
285
|
+
numEvents,
|
|
286
|
+
numUsers,
|
|
287
|
+
numDays,
|
|
288
|
+
epochStart,
|
|
289
|
+
epochEnd,
|
|
290
|
+
events: validatedEvents,
|
|
291
|
+
superProps,
|
|
292
|
+
userProps,
|
|
293
|
+
scdProps,
|
|
294
|
+
mirrorProps,
|
|
295
|
+
groupKeys,
|
|
296
|
+
groupProps,
|
|
297
|
+
lookupTables,
|
|
298
|
+
hasAnonIds,
|
|
299
|
+
hasSessionIds,
|
|
300
|
+
format,
|
|
301
|
+
token,
|
|
302
|
+
region,
|
|
303
|
+
writeToDisk,
|
|
304
|
+
verbose,
|
|
305
|
+
soup,
|
|
306
|
+
hook,
|
|
307
|
+
hasAdSpend,
|
|
308
|
+
hasCampaigns,
|
|
309
|
+
hasLocation,
|
|
310
|
+
hasAvatar,
|
|
311
|
+
isAnonymous,
|
|
312
|
+
hasBrowser,
|
|
313
|
+
hasAndroidDevices,
|
|
314
|
+
hasDesktopDevices,
|
|
315
|
+
hasIOSDevices,
|
|
316
|
+
name,
|
|
317
|
+
strictEventCount
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
return validatedConfig;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Transforms SCD properties to regular user/group properties when service account credentials are missing
|
|
325
|
+
* ONLY applies to UI jobs - programmatic usage always generates SCD files
|
|
326
|
+
* @param {Partial<Dungeon>} config - Configuration object
|
|
327
|
+
* @returns {void} Modifies config in place
|
|
328
|
+
*/
|
|
329
|
+
function transformSCDPropsWithoutCredentials(config) {
|
|
330
|
+
const { serviceAccount, projectId, serviceSecret, scdProps, isUIJob, token } = config;
|
|
331
|
+
|
|
332
|
+
// If no SCD props configured, nothing to validate
|
|
333
|
+
if (!scdProps || Object.keys(scdProps).length === 0) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// If we have all credentials, SCD import can proceed
|
|
338
|
+
if (serviceAccount && projectId && serviceSecret) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Missing credentials - handle based on job type
|
|
343
|
+
if (!isUIJob) {
|
|
344
|
+
// For programmatic/CLI usage, throw an error if trying to send SCDs to Mixpanel without credentials
|
|
345
|
+
if (token) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
'Configuration error: SCD properties are configured but service credentials are missing.\n' +
|
|
348
|
+
'To import SCD data to Mixpanel, you must provide:\n' +
|
|
349
|
+
' - serviceAccount: Your Mixpanel service account username\n' +
|
|
350
|
+
' - serviceSecret: Your Mixpanel service account secret\n' +
|
|
351
|
+
' - projectId: Your Mixpanel project ID\n' +
|
|
352
|
+
'Without these credentials, SCD data cannot be imported to Mixpanel.'
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
// If not sending to Mixpanel (no token), allow generation for testing
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// UI job without credentials - convert SCD props to regular props
|
|
360
|
+
if (config.verbose !== false) console.log('\u26a0\ufe0f Service account credentials missing - converting SCD properties to static properties');
|
|
361
|
+
|
|
362
|
+
// Ensure userProps and groupProps exist
|
|
363
|
+
if (!config.userProps) config.userProps = {};
|
|
364
|
+
if (!config.groupProps) config.groupProps = {};
|
|
365
|
+
|
|
366
|
+
// Process each SCD property
|
|
367
|
+
for (const [propKey, scdProp] of Object.entries(scdProps)) {
|
|
368
|
+
const { type = "user", values } = scdProp;
|
|
369
|
+
|
|
370
|
+
// Skip if no values
|
|
371
|
+
if (!values || JSON.stringify(values) === "{}" || JSON.stringify(values) === "[]") {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Determine if this is a user or group property
|
|
376
|
+
if (type === "user") {
|
|
377
|
+
// Add to userProps
|
|
378
|
+
config.userProps[propKey] = values;
|
|
379
|
+
if (config.verbose !== false) console.log(` \u2713 Converted user SCD property: ${propKey}`);
|
|
380
|
+
} else {
|
|
381
|
+
// Add to groupProps for the specific group type
|
|
382
|
+
if (!config.groupProps[type]) {
|
|
383
|
+
config.groupProps[type] = {};
|
|
384
|
+
}
|
|
385
|
+
config.groupProps[type][propKey] = values;
|
|
386
|
+
if (config.verbose !== false) console.log(` \u2713 Converted group SCD property: ${propKey} (${type})`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Clear out scdProps since we've converted everything
|
|
391
|
+
config.scdProps = {};
|
|
392
|
+
if (config.verbose !== false) console.log('\u2713 SCD properties converted to static properties\n');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export { inferFunnels, transformSCDPropsWithoutCredentials };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context module - replaces global variables with a context object
|
|
3
|
+
* Provides centralized state management and dependency injection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types.js').Dungeon} Dungeon */
|
|
7
|
+
/** @typedef {import('../../types.js').Storage} Storage */
|
|
8
|
+
/** @typedef {import('../../types.js').Context} Context */
|
|
9
|
+
/** @typedef {import('../../types.js').RuntimeState} RuntimeState */
|
|
10
|
+
/** @typedef {import('../../types.js').Defaults} Defaults */
|
|
11
|
+
|
|
12
|
+
import dayjs from "dayjs";
|
|
13
|
+
import { campaigns, devices, locations } from '../templates/defaults.js';
|
|
14
|
+
import * as u from '../utils/utils.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a defaults factory function that computes weighted defaults
|
|
18
|
+
* @param {Dungeon} config - Configuration object
|
|
19
|
+
* @param {Array} campaignData - Campaign data array
|
|
20
|
+
* @returns {Defaults} Defaults object with factory functions
|
|
21
|
+
*/
|
|
22
|
+
function createDefaults(config, campaignData) {
|
|
23
|
+
const { singleCountry } = config;
|
|
24
|
+
|
|
25
|
+
// Pre-compute weighted arrays based on configuration
|
|
26
|
+
const locationsUsers = singleCountry ?
|
|
27
|
+
locations.filter(l => l.country === singleCountry) :
|
|
28
|
+
locations;
|
|
29
|
+
|
|
30
|
+
const locationsEvents = singleCountry ?
|
|
31
|
+
locations.filter(l => l.country === singleCountry) :
|
|
32
|
+
locations;
|
|
33
|
+
|
|
34
|
+
// PERFORMANCE: Pre-calculate weighted arrays to avoid repeated weighArray calls
|
|
35
|
+
const weighedLocationsUsers = u.weighArray(locationsUsers);
|
|
36
|
+
const weighedLocationsEvents = u.weighArray(locationsEvents);
|
|
37
|
+
const weighedIOSDevices = u.weighArray(devices.iosDevices);
|
|
38
|
+
const weighedAndroidDevices = u.weighArray(devices.androidDevices);
|
|
39
|
+
const weighedDesktopDevices = u.weighArray(devices.desktopDevices);
|
|
40
|
+
const weighedBrowsers = u.weighArray(devices.browsers);
|
|
41
|
+
const weighedCampaigns = u.weighArray(campaignData);
|
|
42
|
+
|
|
43
|
+
// PERFORMANCE: Pre-compute device pools based on config to avoid rebuilding in makeEvent
|
|
44
|
+
const devicePools = {
|
|
45
|
+
android: config.hasAndroidDevices ? weighedAndroidDevices : [],
|
|
46
|
+
ios: config.hasIOSDevices ? weighedIOSDevices : [],
|
|
47
|
+
desktop: config.hasDesktopDevices ? weighedDesktopDevices : []
|
|
48
|
+
};
|
|
49
|
+
const allDevices = [...devicePools.android, ...devicePools.ios, ...devicePools.desktop];
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
locationsUsers: () => weighedLocationsUsers,
|
|
53
|
+
locationsEvents: () => weighedLocationsEvents,
|
|
54
|
+
iOSDevices: () => weighedIOSDevices,
|
|
55
|
+
androidDevices: () => weighedAndroidDevices,
|
|
56
|
+
desktopDevices: () => weighedDesktopDevices,
|
|
57
|
+
browsers: () => weighedBrowsers,
|
|
58
|
+
campaigns: () => weighedCampaigns,
|
|
59
|
+
|
|
60
|
+
// PERFORMANCE: Pre-computed device pools
|
|
61
|
+
devicePools,
|
|
62
|
+
allDevices
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates a runtime state object for tracking execution state
|
|
68
|
+
* @returns {RuntimeState} Runtime state with counters and flags
|
|
69
|
+
*/
|
|
70
|
+
function createRuntimeState() {
|
|
71
|
+
return {
|
|
72
|
+
operations: 0,
|
|
73
|
+
eventCount: 0,
|
|
74
|
+
userCount: 0,
|
|
75
|
+
isBatchMode: false,
|
|
76
|
+
verbose: false
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Context factory that creates a complete context object for data generation
|
|
82
|
+
* @param {Dungeon} config - Validated configuration object
|
|
83
|
+
* @param {Storage|null} storage - Storage containers (optional, can be set later)
|
|
84
|
+
* @returns {Context} Context object containing all state and dependencies
|
|
85
|
+
*/
|
|
86
|
+
export function createContext(config, storage = null) {
|
|
87
|
+
// Import campaign data (could be made configurable)
|
|
88
|
+
const campaignData = campaigns;
|
|
89
|
+
|
|
90
|
+
// Create computed defaults based on config
|
|
91
|
+
const defaults = createDefaults(config, campaignData);
|
|
92
|
+
|
|
93
|
+
// Create runtime state
|
|
94
|
+
const runtime = createRuntimeState();
|
|
95
|
+
|
|
96
|
+
// Set runtime flags from config
|
|
97
|
+
runtime.verbose = config.verbose || false;
|
|
98
|
+
runtime.isBatchMode = config.batchSize && config.batchSize < config.numEvents;
|
|
99
|
+
|
|
100
|
+
const context = {
|
|
101
|
+
config,
|
|
102
|
+
storage,
|
|
103
|
+
defaults,
|
|
104
|
+
campaigns: campaignData,
|
|
105
|
+
runtime,
|
|
106
|
+
|
|
107
|
+
// Helper methods for updating state
|
|
108
|
+
incrementOperations() {
|
|
109
|
+
runtime.operations++;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
incrementEvents() {
|
|
113
|
+
runtime.eventCount++;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
incrementUsers() {
|
|
117
|
+
runtime.userCount++;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
setStorage(storageObj) {
|
|
121
|
+
this.storage = storageObj;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// Getter methods for runtime state
|
|
125
|
+
getOperations() {
|
|
126
|
+
return runtime.operations;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
getEventCount() {
|
|
130
|
+
return runtime.eventCount;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
getUserCount() {
|
|
134
|
+
return runtime.userCount;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
incrementUserCount() {
|
|
138
|
+
runtime.userCount++;
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
incrementEventCount() {
|
|
142
|
+
runtime.eventCount++;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
isBatchMode() {
|
|
146
|
+
return runtime.isBatchMode;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// Time helper methods
|
|
150
|
+
getTimeShift() {
|
|
151
|
+
const actualNow = dayjs().subtract(1, "hour");
|
|
152
|
+
return actualNow.diff(dayjs.unix(this.FIXED_NOW), "seconds");
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
getDaysShift() {
|
|
156
|
+
const actualNow = dayjs().subtract(1, "hour");
|
|
157
|
+
return actualNow.diff(dayjs.unix(this.FIXED_NOW), "days");
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// Time constants (previously globals)
|
|
161
|
+
FIXED_NOW: global.FIXED_NOW,
|
|
162
|
+
FIXED_BEGIN: global.FIXED_BEGIN,
|
|
163
|
+
|
|
164
|
+
// PERFORMANCE: Pre-calculated time shift (instead of calculating per-event)
|
|
165
|
+
TIME_SHIFT_SECONDS: (() => {
|
|
166
|
+
const actualNow = dayjs().subtract(1, "hour");
|
|
167
|
+
return actualNow.diff(dayjs.unix(global.FIXED_NOW), "seconds");
|
|
168
|
+
})(),
|
|
169
|
+
|
|
170
|
+
// Max timestamp (unix seconds) — clamp here to prevent future events
|
|
171
|
+
MAX_TIME: dayjs().unix(),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return context;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Updates an existing context with new storage containers
|
|
179
|
+
* @param {Context} context - Existing context object
|
|
180
|
+
* @param {Storage} storage - New storage containers
|
|
181
|
+
* @returns {Context} Updated context object
|
|
182
|
+
*/
|
|
183
|
+
export function updateContextWithStorage(context, storage) {
|
|
184
|
+
context.storage = storage;
|
|
185
|
+
return context;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Validates that a context object has all required properties
|
|
190
|
+
* @param {Context} context - Context to validate
|
|
191
|
+
* @throws {Error} If context is missing required properties
|
|
192
|
+
*/
|
|
193
|
+
export function validateContext(context) {
|
|
194
|
+
const required = ['config', 'defaults', 'campaigns', 'runtime'];
|
|
195
|
+
const missing = required.filter(prop => !context[prop]);
|
|
196
|
+
|
|
197
|
+
if (missing.length > 0) {
|
|
198
|
+
throw new Error(`Context is missing required properties: ${missing.join(', ')}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!context.config.numUsers || !context.config.numEvents) {
|
|
202
|
+
throw new Error('Context config must have numUsers and numEvents');
|
|
203
|
+
}
|
|
204
|
+
}
|