@ak--47/dungeon-master 1.0.0 → 1.2.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 (104) hide show
  1. package/README.md +7 -7
  2. package/dungeons/technical/ad-spend-schema.json +128 -0
  3. package/dungeons/technical/ad-spend.js +116 -0
  4. package/dungeons/technical/anonymous-users-schema.json +92 -0
  5. package/dungeons/technical/anonymous-users.js +102 -0
  6. package/dungeons/technical/array-of-object-lookup-schema.json +191 -0
  7. package/dungeons/{array-of-object-lookup.js → technical/array-of-object-lookup.js} +12 -5
  8. package/dungeons/technical/experiments-schema.json +203 -0
  9. package/dungeons/technical/experiments.js +168 -0
  10. package/dungeons/technical/foobar-schema.json +362 -0
  11. package/dungeons/{foobar.js → technical/foobar.js} +3 -3
  12. package/dungeons/technical/group-analytics-schema.json +241 -0
  13. package/dungeons/technical/group-analytics.js +151 -0
  14. package/dungeons/technical/mirror-strategies-schema.json +84 -0
  15. package/dungeons/technical/mirror-strategies.js +126 -0
  16. package/dungeons/technical/nested-objects-schema.json +145 -0
  17. package/dungeons/technical/nested-objects.js +207 -0
  18. package/dungeons/technical/retention-cadence-schema.json +37 -0
  19. package/dungeons/{retention-cadence.js → technical/retention-cadence.js} +2 -3
  20. package/dungeons/technical/sanity-schema.json +185 -0
  21. package/dungeons/{sanity.js → technical/sanity.js} +4 -2
  22. package/dungeons/technical/scale-test-schema.json +70 -0
  23. package/dungeons/technical/scale-test.js +90 -0
  24. package/dungeons/technical/scd-schema.json +467 -0
  25. package/dungeons/{scd.js → technical/scd.js} +20 -10
  26. package/dungeons/technical/simple-schema.json +362 -0
  27. package/dungeons/{simple.js → technical/simple.js} +7 -7
  28. package/dungeons/technical/simplest-schema.json +724 -0
  29. package/dungeons/{simplest.js → technical/simplest.js} +7 -7
  30. package/dungeons/technical/text-generation-schema.json +1062 -0
  31. package/dungeons/{text-generation.js → technical/text-generation.js} +24 -11
  32. package/dungeons/vertical/community-schema.json +579 -0
  33. package/dungeons/vertical/community.js +794 -0
  34. package/dungeons/vertical/devtools-schema.json +601 -0
  35. package/dungeons/vertical/devtools.js +843 -0
  36. package/dungeons/vertical/ecommerce-schema.json +604 -0
  37. package/dungeons/{ecommerce.js → vertical/ecommerce.js} +43 -5
  38. package/dungeons/vertical/education-schema.json +5686 -0
  39. package/dungeons/{education.js → vertical/education.js} +70 -39
  40. package/dungeons/vertical/fintech-schema.json +630 -0
  41. package/dungeons/{fintech.js → vertical/fintech.js} +107 -56
  42. package/dungeons/vertical/fitness-schema.json +530 -0
  43. package/dungeons/vertical/fitness.js +808 -0
  44. package/dungeons/vertical/food-delivery-schema.json +36728 -0
  45. package/dungeons/{food.js → vertical/food-delivery.js} +67 -26
  46. package/dungeons/vertical/gaming-schema.json +438 -0
  47. package/dungeons/{gaming.js → vertical/gaming.js} +29 -16
  48. package/dungeons/vertical/healthcare-schema.json +549 -0
  49. package/dungeons/vertical/healthcare.js +775 -0
  50. package/dungeons/vertical/insurance-application-schema.json +485 -0
  51. package/dungeons/{insurance-application.js → vertical/insurance-application.js} +41 -31
  52. package/dungeons/vertical/logistics-schema.json +574 -0
  53. package/dungeons/vertical/logistics.js +799 -0
  54. package/dungeons/vertical/marketplace-schema.json +533 -0
  55. package/dungeons/vertical/marketplace.js +850 -0
  56. package/dungeons/vertical/media-schema.json +4749 -0
  57. package/dungeons/{media.js → vertical/media.js} +103 -19
  58. package/dungeons/vertical/rpg-schema.json +2491 -0
  59. package/dungeons/{rpg.js → vertical/rpg.js} +87 -60
  60. package/dungeons/vertical/sass-schema.json +3128 -0
  61. package/dungeons/{sass.js → vertical/sass.js} +65 -26
  62. package/dungeons/vertical/social-schema.json +620 -0
  63. package/dungeons/{social.js → vertical/social.js} +76 -17
  64. package/dungeons/vertical/travel-schema.json +580 -0
  65. package/dungeons/vertical/travel.js +742 -0
  66. package/index.js +20 -16
  67. package/lib/core/config-validator.js +286 -1
  68. package/lib/core/context.js +16 -5
  69. package/lib/core/storage.js +2 -0
  70. package/lib/generators/adspend.js +3 -3
  71. package/lib/generators/events.js +141 -14
  72. package/lib/generators/funnels.js +67 -7
  73. package/lib/generators/text.js +2 -2
  74. package/lib/orchestrators/user-loop.js +444 -22
  75. package/lib/templates/soup-presets.js +2 -2
  76. package/lib/utils/utils.js +4 -5
  77. package/package.json +4 -3
  78. package/scripts/dungeon-to-json.mjs +163 -0
  79. package/scripts/extract-dungeon-schema.mjs +343 -0
  80. package/scripts/json-to-dungeon.mjs +200 -0
  81. package/scripts/run-dungeon.mjs +76 -0
  82. package/scripts/run-many.mjs +118 -0
  83. package/scripts/verify-runner.mjs +42 -0
  84. package/types.d.ts +316 -1
  85. package/dungeons/array-of-object-lookup-schema.json +0 -327
  86. package/dungeons/ecommerce-schema.json +0 -462
  87. package/dungeons/education-schema.json +0 -2409
  88. package/dungeons/fintech-schema.json +0 -14034
  89. package/dungeons/foobar-schema.json +0 -403
  90. package/dungeons/food-delivery-schema.json +0 -192
  91. package/dungeons/food-delivery.js +0 -602
  92. package/dungeons/food-schema.json +0 -1152
  93. package/dungeons/gaming-schema.json +0 -1270
  94. package/dungeons/insurance-application-schema.json +0 -204
  95. package/dungeons/media-schema.json +0 -906
  96. package/dungeons/retention-cadence-schema.json +0 -78
  97. package/dungeons/rpg-schema.json +0 -4526
  98. package/dungeons/sanity-schema.json +0 -255
  99. package/dungeons/sass-schema.json +0 -1291
  100. package/dungeons/scd-schema.json +0 -919
  101. package/dungeons/simple-schema.json +0 -608
  102. package/dungeons/simplest-schema.json +0 -1418
  103. package/dungeons/social-schema.json +0 -1118
  104. package/dungeons/text-generation-schema.json +0 -3096
package/index.js CHANGED
@@ -61,15 +61,15 @@ global.FIXED_BEGIN = FIXED_BEGIN;
61
61
  *
62
62
  * @example
63
63
  * // file path
64
- * const result = await DUNGEON_MASTER('./dungeons/simple.js');
64
+ * const result = await DUNGEON_MASTER('./dungeons/technical/simple.js');
65
65
  *
66
66
  * @example
67
67
  * // JSON dungeon (from UI export)
68
- * const result = await DUNGEON_MASTER('./dungeons/simple-schema.json');
68
+ * const result = await DUNGEON_MASTER('./dungeons/technical/simple-schema.json');
69
69
  *
70
70
  * @example
71
71
  * // multiple dungeons
72
- * const results = await DUNGEON_MASTER(['./dungeons/gaming.js', './dungeons/media.js']);
72
+ * const results = await DUNGEON_MASTER(['./dungeons/vertical/gaming.js', './dungeons/vertical/media.js']);
73
73
  *
74
74
  * @example
75
75
  * // raw JS text
@@ -83,7 +83,7 @@ global.FIXED_BEGIN = FIXED_BEGIN;
83
83
  *
84
84
  * @example
85
85
  * // with overrides
86
- * const result = await DUNGEON_MASTER('./dungeons/simple.js', { writeToDisk: true, verbose: true });
86
+ * const result = await DUNGEON_MASTER('./dungeons/technical/simple.js', { writeToDisk: true, verbose: true });
87
87
  */
88
88
  async function DUNGEON_MASTER(input, overrides = {}) {
89
89
  const { type, value } = detectInputType(input);
@@ -124,21 +124,25 @@ async function runDungeon(config) {
124
124
  if (config.verbose) logger.info({ seed: config.seed }, 'Configuring dungeon');
125
125
  let validatedConfig;
126
126
  try {
127
+ // Initialize seeded RNG BEFORE validation — config-validator captures a
128
+ // chance reference for default userProps (spiritAnimal). If we init after,
129
+ // run 1 binds an unseeded instance while run 2 binds a stale one → non-deterministic.
130
+ if (config.seed) {
131
+ initChance(config.seed);
132
+ }
133
+
127
134
  // Step 1: Validate and enrich configuration
128
135
  validatedConfig = validateDungeonConfig(config);
129
136
 
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
+ // Compute FIXED_BEGIN from validated numDays
137
138
  const configNumDays = validatedConfig.numDays || 30;
138
- global.FIXED_BEGIN = dayjs.unix(FIXED_NOW).subtract(configNumDays, 'd').unix();
139
+ const fixedBegin = dayjs.unix(FIXED_NOW).subtract(configNumDays, 'd').unix();
140
+
141
+ // Keep globals for backwards compatibility with tests/dungeons that read them
142
+ global.FIXED_BEGIN = fixedBegin;
139
143
 
140
- // Step 2: Create context with validated config
141
- const context = createContext(validatedConfig);
144
+ // Step 2: Create context with validated config (pass time constants explicitly)
145
+ const context = createContext(validatedConfig, null, { fixedNow: FIXED_NOW, fixedBegin });
142
146
 
143
147
  // Step 3: Initialize storage containers
144
148
  const storageManager = new StorageManager(context);
@@ -212,7 +216,7 @@ async function runDungeon(config) {
212
216
  files: await extractFileInfo(storage, validatedConfig),
213
217
  time: { start, end, delta, human },
214
218
  operations: context.getOperations(),
215
- eventCount: context.getEventCount(),
219
+ eventCount: context.getStoredEventCount(),
216
220
  userCount: context.getUserCount()
217
221
  };
218
222
 
@@ -232,7 +236,7 @@ async function generateAdSpendData(context) {
232
236
 
233
237
  const timeShift = context.TIME_SHIFT_SECONDS;
234
238
  for (let day = 0; day < numDays; day++) {
235
- const fixedDay = dayjs.unix(global.FIXED_BEGIN).add(day, 'day').unix();
239
+ const fixedDay = dayjs.unix(context.FIXED_BEGIN).add(day, 'day').unix();
236
240
  const shiftedDay = Math.min(fixedDay + timeShift, context.MAX_TIME);
237
241
  const targetDay = dayjs.unix(shiftedDay).toISOString();
238
242
  const adSpendEvents = await makeAdSpend(context, targetDay);
@@ -13,6 +13,9 @@ import { makeName } from "ak-tools";
13
13
  import * as u from "../utils/utils.js";
14
14
  import { resolveSoup } from "../templates/soup-presets.js";
15
15
 
16
+ /** Fixed reference point for time calculations (2024-02-02) */
17
+ const FIXED_NOW = dayjs('2024-02-02').unix();
18
+
16
19
  /**
17
20
  * Infers funnels from the provided events
18
21
  * @param {EventConfig[]} events - Array of event configurations
@@ -275,6 +278,62 @@ export function validateDungeonConfig(config) {
275
278
  // Event validation
276
279
  const validatedEvents = u.validateEventConfig(events);
277
280
 
281
+ // ── Validate and resolve advanced features ──
282
+
283
+ // Feature 1: Personas
284
+ let personas = config.personas || null;
285
+ if (personas) {
286
+ personas = validatePersonas(personas);
287
+ }
288
+
289
+ // Feature 2: World Events
290
+ let worldEvents = config.worldEvents || null;
291
+ if (worldEvents) {
292
+ worldEvents = resolveWorldEvents(worldEvents, numDays);
293
+ }
294
+
295
+ // Feature 3: Engagement Decay
296
+ let engagementDecay = config.engagementDecay || null;
297
+ if (engagementDecay) {
298
+ engagementDecay = validateEngagementDecay(engagementDecay);
299
+ }
300
+
301
+ // Feature 4: Data Quality
302
+ let dataQuality = config.dataQuality || null;
303
+ if (dataQuality) {
304
+ dataQuality = validateDataQuality(dataQuality);
305
+ }
306
+
307
+ // Feature 5: Subscription
308
+ let subscription = config.subscription || null;
309
+ if (subscription) {
310
+ subscription = validateSubscription(subscription);
311
+ }
312
+
313
+ // Feature 6: Attribution
314
+ let attribution = config.attribution || null;
315
+ if (attribution) {
316
+ attribution = validateAttribution(attribution, numDays);
317
+ }
318
+
319
+ // Feature 7: Geo
320
+ let geo = config.geo || null;
321
+ if (geo) {
322
+ geo = validateGeo(geo);
323
+ }
324
+
325
+ // Feature 8: Features (progressive adoption)
326
+ let features = config.features || null;
327
+ if (features) {
328
+ features = resolveFeatures(features, numDays);
329
+ }
330
+
331
+ // Feature 9: Anomalies
332
+ let anomalies = config.anomalies || null;
333
+ if (anomalies) {
334
+ anomalies = resolveAnomalies(anomalies, numDays);
335
+ }
336
+
278
337
  // Build final config object
279
338
  const validatedConfig = {
280
339
  ...config,
@@ -314,7 +373,17 @@ export function validateDungeonConfig(config) {
314
373
  hasDesktopDevices,
315
374
  hasIOSDevices,
316
375
  name,
317
- strictEventCount
376
+ strictEventCount,
377
+ // Advanced features
378
+ personas,
379
+ worldEvents,
380
+ engagementDecay,
381
+ dataQuality,
382
+ subscription,
383
+ attribution,
384
+ geo,
385
+ features,
386
+ anomalies
318
387
  };
319
388
 
320
389
  return validatedConfig;
@@ -392,4 +461,220 @@ function transformSCDPropsWithoutCredentials(config) {
392
461
  if (config.verbose !== false) console.log('\u2713 SCD properties converted to static properties\n');
393
462
  }
394
463
 
464
+ // ── Advanced Feature Validation Functions ──
465
+
466
+ /**
467
+ * Validates persona configurations
468
+ * @param {import('../../types').Persona[]} personas
469
+ * @returns {import('../../types').Persona[]}
470
+ */
471
+ function validatePersonas(personas) {
472
+ if (!Array.isArray(personas) || personas.length === 0) return null;
473
+ for (const p of personas) {
474
+ if (!p.name) throw new Error('Each persona must have a name');
475
+ if (typeof p.weight !== 'number' || p.weight <= 0) throw new Error(`Persona "${p.name}" must have a positive weight`);
476
+ if (p.eventMultiplier === undefined) p.eventMultiplier = 1.0;
477
+ if (p.conversionModifier === undefined) p.conversionModifier = 1.0;
478
+ if (p.churnRate === undefined) p.churnRate = 0;
479
+ if (p.properties === undefined) p.properties = {};
480
+ }
481
+ return personas;
482
+ }
483
+
484
+ /**
485
+ * Resolves world events to absolute timestamps
486
+ * @param {import('../../types').WorldEvent[]} worldEvents
487
+ * @param {number} numDays
488
+ * @returns {import('../../types').ResolvedWorldEvent[]}
489
+ */
490
+ function resolveWorldEvents(worldEvents, numDays) {
491
+ if (!Array.isArray(worldEvents) || worldEvents.length === 0) return null;
492
+ const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
493
+
494
+ return worldEvents.map(we => {
495
+ const startUnix = beginUnix + (we.startDay * 86400);
496
+ let endUnix;
497
+ if (we.duration === null || we.duration === undefined) {
498
+ endUnix = Infinity; // permanent
499
+ } else {
500
+ endUnix = startUnix + (we.duration * 86400);
501
+ }
502
+ const aftermathEndUnix = (we.aftermath && we.aftermath.duration !== undefined)
503
+ ? endUnix + (we.aftermath.duration * 86400)
504
+ : undefined;
505
+ const resolved = { ...we, startUnix, endUnix, aftermathEndUnix };
506
+ if (!we.affectsEvents) resolved.affectsEvents = "*";
507
+ if (!we.volumeMultiplier) resolved.volumeMultiplier = 1.0;
508
+ if (!we.conversionModifier) resolved.conversionModifier = 1.0;
509
+ return resolved;
510
+ }).sort((a, b) => a.startUnix - b.startUnix || (a.name || '').localeCompare(b.name || ''));
511
+ }
512
+
513
+ /**
514
+ * Validates engagement decay config
515
+ * @param {import('../../types').EngagementDecay} decay
516
+ * @returns {import('../../types').EngagementDecay}
517
+ */
518
+ function validateEngagementDecay(decay) {
519
+ const valid = ['exponential', 'linear', 'step', 'none'];
520
+ if (!valid.includes(decay.model)) throw new Error(`engagementDecay.model must be one of: ${valid.join(', ')}`);
521
+ if (decay.model === 'none') return decay;
522
+ if (decay.halfLife === undefined) decay.halfLife = 45;
523
+ if (decay.halfLife <= 0) throw new Error('engagementDecay.halfLife must be > 0');
524
+ if (decay.floor === undefined) decay.floor = 0.1;
525
+ decay.floor = Math.max(0, Math.min(1, decay.floor));
526
+ if (decay.reactivationChance === undefined) decay.reactivationChance = 0;
527
+ if (decay.reactivationMultiplier === undefined) decay.reactivationMultiplier = 2.0;
528
+ return decay;
529
+ }
530
+
531
+ /**
532
+ * Validates data quality config
533
+ * @param {import('../../types').DataQuality} dq
534
+ * @returns {import('../../types').DataQuality}
535
+ */
536
+ function validateDataQuality(dq) {
537
+ if (dq.nullRate === undefined) dq.nullRate = 0;
538
+ if (dq.duplicateRate === undefined) dq.duplicateRate = 0;
539
+ if (dq.lateArrivingRate === undefined) dq.lateArrivingRate = 0;
540
+ if (dq.botUsers === undefined) dq.botUsers = 0;
541
+ if (dq.botEventsPerUser === undefined) dq.botEventsPerUser = 1000;
542
+ if (dq.timezoneConfusion === undefined) dq.timezoneConfusion = 0;
543
+ if (dq.emptyEvents === undefined) dq.emptyEvents = 0;
544
+ if (!dq.nullProps) dq.nullProps = "*";
545
+ // Clamp rates to [0, 1]
546
+ for (const key of ['nullRate', 'duplicateRate', 'lateArrivingRate', 'timezoneConfusion', 'emptyEvents']) {
547
+ dq[key] = Math.max(0, Math.min(1, dq[key]));
548
+ }
549
+ return dq;
550
+ }
551
+
552
+ /**
553
+ * Validates subscription config
554
+ * @param {import('../../types').Subscription} sub
555
+ * @returns {import('../../types').Subscription}
556
+ */
557
+ function validateSubscription(sub) {
558
+ if (!sub.plans || !Array.isArray(sub.plans) || sub.plans.length === 0) {
559
+ throw new Error('subscription.plans must be a non-empty array');
560
+ }
561
+ const hasDefault = sub.plans.some(p => p.default);
562
+ if (!hasDefault) sub.plans[0].default = true;
563
+ if (!sub.lifecycle) sub.lifecycle = {};
564
+ const lc = sub.lifecycle;
565
+ if (lc.trialToPayRate === undefined) lc.trialToPayRate = 0.3;
566
+ if (lc.upgradeRate === undefined) lc.upgradeRate = 0.1;
567
+ if (lc.downgradeRate === undefined) lc.downgradeRate = 0.03;
568
+ if (lc.churnRate === undefined) lc.churnRate = 0.05;
569
+ if (lc.winBackRate === undefined) lc.winBackRate = 0.1;
570
+ if (lc.winBackDelay === undefined) lc.winBackDelay = 30;
571
+ if (lc.paymentFailureRate === undefined) lc.paymentFailureRate = 0.02;
572
+ if (!sub.events) sub.events = {};
573
+ const ev = sub.events;
574
+ if (!ev.trialStarted) ev.trialStarted = "trial started";
575
+ if (!ev.subscribed) ev.subscribed = "subscription started";
576
+ if (!ev.upgraded) ev.upgraded = "plan upgraded";
577
+ if (!ev.downgraded) ev.downgraded = "plan downgraded";
578
+ if (!ev.renewed) ev.renewed = "subscription renewed";
579
+ if (!ev.cancelled) ev.cancelled = "subscription cancelled";
580
+ if (!ev.paymentFailed) ev.paymentFailed = "payment failed";
581
+ if (!ev.wonBack) ev.wonBack = "subscription reactivated";
582
+ return sub;
583
+ }
584
+
585
+ /**
586
+ * Validates attribution config
587
+ * @param {import('../../types').Attribution} attr
588
+ * @param {number} numDays
589
+ * @returns {import('../../types').Attribution}
590
+ */
591
+ function validateAttribution(attr, numDays) {
592
+ if (!attr.campaigns || !Array.isArray(attr.campaigns)) {
593
+ throw new Error('attribution.campaigns must be an array');
594
+ }
595
+ if (attr.model === undefined) attr.model = "last_touch";
596
+ if (attr.window === undefined) attr.window = 7;
597
+ if (attr.organicRate === undefined) attr.organicRate = 0.4;
598
+ for (const c of attr.campaigns) {
599
+ if (!c.name) throw new Error('Each attribution campaign must have a name');
600
+ if (!c.source) throw new Error(`Attribution campaign "${c.name}" must have a source`);
601
+ if (!c.activeDays) throw new Error(`Attribution campaign "${c.name}" must have activeDays [start, end]`);
602
+ if (!c.dailyBudget) c.dailyBudget = [50, 200];
603
+ if (c.acquisitionRate === undefined) c.acquisitionRate = 0.02;
604
+ }
605
+ return attr;
606
+ }
607
+
608
+ /**
609
+ * Validates geo config
610
+ * @param {import('../../types').GeoConfig} geo
611
+ * @returns {import('../../types').GeoConfig}
612
+ */
613
+ function validateGeo(geo) {
614
+ if (geo.sticky === undefined) geo.sticky = false;
615
+ if (geo.regions && !Array.isArray(geo.regions)) throw new Error('geo.regions must be an array');
616
+ if (geo.regionalLaunches && !Array.isArray(geo.regionalLaunches)) throw new Error('geo.regionalLaunches must be an array');
617
+ return geo;
618
+ }
619
+
620
+ /**
621
+ * Resolves feature configs with logistic curve parameters
622
+ * @param {import('../../types').FeatureConfig[]} features
623
+ * @param {number} numDays
624
+ * @returns {import('../../types').FeatureConfig[]}
625
+ */
626
+ function resolveFeatures(features, numDays) {
627
+ if (!Array.isArray(features) || features.length === 0) return null;
628
+ const curvePresets = {
629
+ fast: { k: 0.3, midpoint: 7 },
630
+ slow: { k: 0.08, midpoint: 30 },
631
+ instant: { k: 10, midpoint: 0 }
632
+ };
633
+ return features.map(f => {
634
+ if (!f.name) throw new Error('Each feature must have a name');
635
+ if (f.launchDay === undefined) throw new Error(`Feature "${f.name}" must have a launchDay`);
636
+ if (!f.property) throw new Error(`Feature "${f.name}" must have a property`);
637
+ if (!f.values || !Array.isArray(f.values) || f.values.length === 0) {
638
+ throw new Error(`Feature "${f.name}" must have a non-empty values array`);
639
+ }
640
+ if (!f.affectsEvents) f.affectsEvents = "*";
641
+ if (!f.adoptionCurve) f.adoptionCurve = "slow";
642
+ if (typeof f.adoptionCurve === 'string') {
643
+ f._resolvedCurve = curvePresets[f.adoptionCurve] || curvePresets.slow;
644
+ } else {
645
+ f._resolvedCurve = f.adoptionCurve;
646
+ }
647
+ // Pre-compute adopted values to avoid array allocation in hot loop
648
+ f._adoptedValues = f.values.length > 1 ? f.values.slice(1) : f.values;
649
+ return f;
650
+ });
651
+ }
652
+
653
+ /**
654
+ * Resolves anomaly configs with absolute timestamps
655
+ * @param {import('../../types').AnomalyConfig[]} anomalies
656
+ * @param {number} numDays
657
+ * @returns {import('../../types').AnomalyConfig[]}
658
+ */
659
+ function resolveAnomalies(anomalies, numDays) {
660
+ if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
661
+ const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
662
+ return anomalies.map(a => {
663
+ if (!a.type) throw new Error('Each anomaly must have a type');
664
+ if (!a.event) throw new Error('Each anomaly must have an event name');
665
+ const resolved = { ...a };
666
+ if (a.day !== undefined) {
667
+ resolved._startUnix = beginUnix + (a.day * 86400);
668
+ if (a.duration) {
669
+ resolved._endUnix = resolved._startUnix + (a.duration * 86400);
670
+ } else if (a.window) {
671
+ resolved._endUnix = resolved._startUnix + (a.window * 86400);
672
+ } else {
673
+ resolved._endUnix = resolved._startUnix + 86400; // default 1 day
674
+ }
675
+ }
676
+ return resolved;
677
+ });
678
+ }
679
+
395
680
  export { inferFunnels, transformSCDPropsWithoutCredentials };
@@ -71,6 +71,7 @@ function createRuntimeState() {
71
71
  return {
72
72
  operations: 0,
73
73
  eventCount: 0,
74
+ storedEventCount: 0,
74
75
  userCount: 0,
75
76
  isBatchMode: false,
76
77
  verbose: false
@@ -81,9 +82,10 @@ function createRuntimeState() {
81
82
  * Context factory that creates a complete context object for data generation
82
83
  * @param {Dungeon} config - Validated configuration object
83
84
  * @param {Storage|null} storage - Storage containers (optional, can be set later)
85
+ * @param {{ fixedNow?: number, fixedBegin?: number }} [timeConstants] - Time constants (avoids globals)
84
86
  * @returns {Context} Context object containing all state and dependencies
85
87
  */
86
- export function createContext(config, storage = null) {
88
+ export function createContext(config, storage = null, timeConstants = {}) {
87
89
  // Import campaign data (could be made configurable)
88
90
  const campaignData = campaigns;
89
91
 
@@ -142,6 +144,14 @@ export function createContext(config, storage = null) {
142
144
  runtime.eventCount++;
143
145
  },
144
146
 
147
+ incrementStoredEvents(count = 1) {
148
+ runtime.storedEventCount += count;
149
+ },
150
+
151
+ getStoredEventCount() {
152
+ return runtime.storedEventCount;
153
+ },
154
+
145
155
  isBatchMode() {
146
156
  return runtime.isBatchMode;
147
157
  },
@@ -157,14 +167,15 @@ export function createContext(config, storage = null) {
157
167
  return actualNow.diff(dayjs.unix(this.FIXED_NOW), "days");
158
168
  },
159
169
 
160
- // Time constants (previously globals)
161
- FIXED_NOW: global.FIXED_NOW,
162
- FIXED_BEGIN: global.FIXED_BEGIN,
170
+ // Time constants (passed explicitly from index.js; global fallback only for direct test usage)
171
+ FIXED_NOW: timeConstants.fixedNow || global.FIXED_NOW,
172
+ FIXED_BEGIN: timeConstants.fixedBegin || global.FIXED_BEGIN,
163
173
 
164
174
  // PERFORMANCE: Pre-calculated time shift (instead of calculating per-event)
165
175
  TIME_SHIFT_SECONDS: (() => {
176
+ const fixedNow = timeConstants.fixedNow || global.FIXED_NOW;
166
177
  const actualNow = dayjs().subtract(1, "hour");
167
- return actualNow.diff(dayjs.unix(global.FIXED_NOW), "seconds");
178
+ return actualNow.diff(dayjs.unix(fixedNow), "seconds");
168
179
  })(),
169
180
 
170
181
  // Max timestamp (unix seconds) — clamp here to prevent future events
@@ -99,8 +99,10 @@ export async function createHookArray(arr = [], opts) {
99
99
  // Fast path for passthrough hooks - no transformation needed
100
100
  if (Array.isArray(item)) {
101
101
  arr.push(...item);
102
+ if (type === "event" && context.incrementStoredEvents) context.incrementStoredEvents(item.length);
102
103
  } else {
103
104
  arr.push(item);
105
+ if (type === "event" && context.incrementStoredEvents) context.incrementStoredEvents(1);
104
106
  }
105
107
  } else {
106
108
  // Slow path for actual transformation hooks
@@ -67,9 +67,9 @@ function createAdSpendEvent(network, campaign, day, chance) {
67
67
  const views = Math.floor(impressions * avgCTR);
68
68
 
69
69
  // Generate UTM parameters
70
- const utm_medium = u.choose(u.pickAWinner(network.utm_medium)());
71
- const utm_content = u.choose(u.pickAWinner(network.utm_content)());
72
- const utm_term = u.choose(u.pickAWinner(network.utm_term)());
70
+ const utm_medium = u.choose(network.utm_medium);
71
+ const utm_content = u.choose(network.utm_content);
72
+ const utm_term = u.choose(network.utm_term);
73
73
 
74
74
  // Create unique identifiers
75
75
  const id = network.utm_source[0] + '-' + campaign;
@@ -13,6 +13,9 @@ import dayjs from "dayjs";
13
13
  import * as u from "../utils/utils.js";
14
14
  import { dataLogger as logger } from "../utils/logger.js";
15
15
 
16
+ // Keys that must never be nulled by data quality gremlins
17
+ const NULL_EXEMPT_KEYS = new Set(['event', 'time', 'insert_id', 'user_id', 'device_id', 'distinct_id', '_drop', '_anomaly', '_persona']);
18
+
16
19
  /**
17
20
  * Creates a Mixpanel event with a flat shape
18
21
  * @param {Context} context - Context object containing config, defaults, etc.
@@ -28,16 +31,17 @@ import { dataLogger as logger } from "../utils/logger.js";
28
31
  * @returns {Promise<Object>} Generated event object
29
32
  */
30
33
  export async function makeEvent(
31
- context,
32
- distinct_id,
33
- earliestTime,
34
- chosenEvent,
35
- anonymousIds = [],
36
- sessionIds = [],
37
- superProps = {},
38
- groupKeys = [],
39
- isFirstEvent = false,
40
- skipDefaults = false
34
+ context,
35
+ distinct_id,
36
+ earliestTime,
37
+ chosenEvent,
38
+ anonymousIds = [],
39
+ sessionIds = [],
40
+ superProps = {},
41
+ groupKeys = [],
42
+ isFirstEvent = false,
43
+ skipDefaults = false,
44
+ featureCtx = {}
41
45
  ) {
42
46
  // Validate required parameters
43
47
  if (!distinct_id) throw new Error("no distinct_id");
@@ -168,17 +172,140 @@ export async function makeEvent(
168
172
  // Add group properties
169
173
  addGroupProperties(eventTemplate, groupKeys);
170
174
 
175
+ // ── Event-level features (applied before hooks, so hooks can override) ──
176
+ const { userLocation, persona, worldEventsTimeline, resolvedFeatures, resolvedAnomalies, dataQuality: dq, geo: geoConfig, userCampaign } = featureCtx;
177
+
178
+ // Feature 6: Attribution — stamp UTM properties on events as touchpoints
179
+ // Mixpanel attribution analysis needs UTM on EVENTS, not just profiles.
180
+ // Pattern: first events carry acquisition UTM, later events occasionally carry re-engagement UTM.
181
+ if (userCampaign) {
182
+ // ~40% of events carry UTM (simulates page loads, session starts, ad clicks)
183
+ // First events (isFirstEvent) always carry UTM (acquisition touchpoint)
184
+ if (isFirstEvent || chance.bool({ likelihood: 40 })) {
185
+ eventTemplate.utm_source = userCampaign.source;
186
+ eventTemplate.utm_campaign = userCampaign.name;
187
+ if (userCampaign.medium) eventTemplate.utm_medium = userCampaign.medium;
188
+ if (userCampaign.utm_content) eventTemplate.utm_content = userCampaign.utm_content;
189
+ if (userCampaign.utm_term) eventTemplate.utm_term = userCampaign.utm_term;
190
+ }
191
+ }
192
+
193
+ // Feature 7: Sticky geo location
194
+ if (userLocation && geoConfig?.sticky) {
195
+ // Override the random location with user's sticky location
196
+ for (const key in userLocation) {
197
+ eventTemplate[key] = userLocation[key];
198
+ }
199
+ }
200
+
201
+ // Perf 1: Compute eventUnix once for all time-based checks
202
+ // Subtracts TIME_SHIFT to convert real time back to FIXED time (world events/features use FIXED time)
203
+ let eventUnix = null;
204
+ if ((worldEventsTimeline || resolvedFeatures) && eventTemplate.time) {
205
+ eventUnix = dayjs(eventTemplate.time).subtract(context.TIME_SHIFT_SECONDS, 'seconds').unix();
206
+ }
207
+
208
+ // Feature 2: World events — inject props for active events at this timestamp
209
+ if (worldEventsTimeline && eventUnix !== null) {
210
+ for (const we of worldEventsTimeline) {
211
+ const inMainWindow = eventUnix >= we.startUnix && eventUnix < we.endUnix;
212
+ const inAftermath = we.aftermathEndUnix && eventUnix >= we.endUnix && eventUnix < we.aftermathEndUnix;
213
+ if (inMainWindow || inAftermath) {
214
+ const affects = we.affectsEvents;
215
+ if (affects === "*" || (Array.isArray(affects) && affects.includes(eventTemplate.event))) {
216
+ // Inject properties
217
+ if (we.injectProps && inMainWindow) {
218
+ for (const [k, v] of Object.entries(we.injectProps)) {
219
+ eventTemplate[k] = v;
220
+ }
221
+ }
222
+ // Volume modulation via accept/reject: if volumeMultiplier < 1, randomly drop
223
+ const volMult = inAftermath ? (we.aftermath?.volumeMultiplier || 1.0) : we.volumeMultiplier;
224
+ if (volMult < 1.0 && !chance.bool({ likelihood: volMult * 100 })) {
225
+ eventTemplate._drop = true;
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ // Feature 8: Progressive feature adoption
233
+ if (resolvedFeatures && eventUnix !== null) {
234
+ const daysSinceBegin = (eventUnix - context.FIXED_BEGIN) / 86400;
235
+ for (const feat of resolvedFeatures) {
236
+ const affects = feat.affectsEvents;
237
+ if (affects !== "*" && !(Array.isArray(affects) && affects.includes(eventTemplate.event))) continue;
238
+ if (daysSinceBegin < feat.launchDay) {
239
+ if (feat.defaultBefore !== undefined) {
240
+ eventTemplate[feat.property] = feat.defaultBefore;
241
+ }
242
+ } else {
243
+ const daysSinceLaunch = daysSinceBegin - feat.launchDay;
244
+ const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
245
+ const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
246
+ if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
247
+ eventTemplate[feat.property] = u.pickRandom(feat._adoptedValues);
248
+ } else if (feat.defaultBefore !== undefined) {
249
+ eventTemplate[feat.property] = feat.defaultBefore;
250
+ } else if (feat.values.length > 0) {
251
+ eventTemplate[feat.property] = feat.values[0];
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ // Feature 9: Anomaly extreme values
258
+ if (resolvedAnomalies && eventTemplate.event) {
259
+ for (const a of resolvedAnomalies) {
260
+ if (a.type === 'extreme_value' && a.event === eventTemplate.event && a.property) {
261
+ if (chance.bool({ likelihood: (a.frequency ?? 0.001) * 100 })) {
262
+ const currentVal = eventTemplate[a.property];
263
+ if (typeof currentVal === 'number') {
264
+ eventTemplate[a.property] = currentVal * (a.multiplier || 10);
265
+ }
266
+ if (a.tag) eventTemplate._anomaly = a.tag;
267
+ if (a.properties) Object.assign(eventTemplate, a.properties);
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ // Feature 4: Data quality — null injection and timezone confusion
274
+ if (dq) {
275
+ // Null injection
276
+ if (dq.nullRate > 0) {
277
+ const propsToNull = dq.nullProps === "*"
278
+ ? Object.keys(eventTemplate).filter(k => !NULL_EXEMPT_KEYS.has(k))
279
+ : (Array.isArray(dq.nullProps) ? dq.nullProps : []);
280
+ for (const prop of propsToNull) {
281
+ if (eventTemplate[prop] !== undefined && chance.bool({ likelihood: dq.nullRate * 100 })) {
282
+ eventTemplate[prop] = null;
283
+ }
284
+ }
285
+ }
286
+ // Timezone confusion
287
+ if (dq.timezoneConfusion > 0 && chance.bool({ likelihood: dq.timezoneConfusion * 100 })) {
288
+ const offsetHours = chance.integer({ min: -12, max: 12 });
289
+ eventTemplate.time = dayjs(eventTemplate.time).add(offsetHours, 'hours').toISOString();
290
+ }
291
+ // Empty events
292
+ if (dq.emptyEvents > 0 && chance.bool({ likelihood: dq.emptyEvents * 100 })) {
293
+ eventTemplate.event = "";
294
+ }
295
+ }
296
+
171
297
  // Generate unique insert_id
172
298
  const distinctId = eventTemplate.user_id || eventTemplate.device_id || eventTemplate.distinct_id || distinct_id;
173
299
  const tuple = `${eventTemplate.event}-${eventTemplate.time}-${distinctId}`;
174
300
  eventTemplate.insert_id = u.quickHash(tuple);
175
301
 
176
- // Call hook if configured (before returning the event)
302
+ // Call hook if configured (hooks override everything — they are the final authority)
177
303
  const { hook } = config;
178
304
  if (hook) {
179
- const hookedEvent = await hook(eventTemplate, "event", {
180
- user: { distinct_id },
181
- config
305
+ const hookedEvent = await hook(eventTemplate, "event", {
306
+ user: { distinct_id },
307
+ config,
308
+ persona: featureCtx.persona || null
182
309
  });
183
310
  // If hook returns a modified event, use it; otherwise use original
184
311
  if (hookedEvent && typeof hookedEvent === 'object') {