@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
@@ -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
+ }