@ak--47/dungeon-master 1.2.3 → 1.3.1

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 (89) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +51 -13
  3. package/dungeons/technical/ad-spend.js +2 -2
  4. package/dungeons/technical/anonymous-users.js +2 -2
  5. package/dungeons/technical/array-of-object-lookup.js +2 -4
  6. package/dungeons/technical/experiments.js +2 -2
  7. package/dungeons/technical/foobar.js +2 -2
  8. package/dungeons/technical/group-analytics.js +2 -2
  9. package/dungeons/technical/mirror-strategies.js +2 -2
  10. package/dungeons/technical/nested-objects.js +2 -2
  11. package/dungeons/technical/retention-cadence.js +2 -3
  12. package/dungeons/technical/sanity.js +2 -2
  13. package/dungeons/technical/scale-test.js +2 -2
  14. package/dungeons/technical/scd.js +2 -2
  15. package/dungeons/technical/simple.js +5 -6
  16. package/dungeons/technical/simplest-schema.json +5 -0
  17. package/dungeons/technical/simplest.js +2 -2
  18. package/dungeons/technical/text-generation.js +3 -3
  19. package/dungeons/vertical/ai-platform.js +858 -0
  20. package/dungeons/vertical/community.js +84 -40
  21. package/dungeons/vertical/crypto.js +830 -0
  22. package/dungeons/vertical/dating.js +744 -0
  23. package/dungeons/vertical/devtools.js +175 -69
  24. package/dungeons/vertical/ecommerce.js +242 -94
  25. package/dungeons/vertical/education.js +330 -313
  26. package/dungeons/vertical/fintech.js +442 -313
  27. package/dungeons/vertical/fitness.js +143 -61
  28. package/dungeons/vertical/food-delivery.js +327 -353
  29. package/dungeons/vertical/gaming.js +912 -382
  30. package/dungeons/vertical/healthcare.js +142 -63
  31. package/dungeons/vertical/insurance-application.js +170 -76
  32. package/dungeons/vertical/logistics.js +115 -20
  33. package/dungeons/vertical/marketplace.js +152 -58
  34. package/dungeons/vertical/media.js +248 -384
  35. package/dungeons/vertical/real-estate.js +781 -0
  36. package/dungeons/vertical/sass.js +255 -266
  37. package/dungeons/vertical/social.js +264 -206
  38. package/dungeons/vertical/travel.js +117 -41
  39. package/index.js +17 -17
  40. package/lib/core/config-validator.js +159 -31
  41. package/lib/core/context.js +10 -24
  42. package/lib/core/storage.js +6 -1
  43. package/lib/generators/events.js +11 -14
  44. package/lib/generators/funnels.js +12 -4
  45. package/lib/generators/mirror.js +3 -2
  46. package/lib/generators/product-names.js +1 -1
  47. package/lib/generators/scd.js +2 -1
  48. package/lib/generators/text.js +1 -1
  49. package/lib/orchestrators/user-loop.js +81 -47
  50. package/lib/templates/macro-presets.js +111 -0
  51. package/lib/templates/soup-presets.js +19 -36
  52. package/lib/utils/utils.js +71 -39
  53. package/package.json +8 -2
  54. package/scripts/smoke-test-all.mjs +162 -0
  55. package/scripts/verify-runner.mjs +72 -24
  56. package/types.d.ts +251 -51
  57. package/dungeons/technical/ad-spend-schema.json +0 -128
  58. package/dungeons/technical/anonymous-users-schema.json +0 -92
  59. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  60. package/dungeons/technical/experiments-schema.json +0 -203
  61. package/dungeons/technical/foobar-schema.json +0 -362
  62. package/dungeons/technical/group-analytics-schema.json +0 -241
  63. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  64. package/dungeons/technical/nested-objects-schema.json +0 -145
  65. package/dungeons/technical/retention-cadence-schema.json +0 -37
  66. package/dungeons/technical/sanity-schema.json +0 -185
  67. package/dungeons/technical/scale-test-schema.json +0 -70
  68. package/dungeons/technical/scd-schema.json +0 -467
  69. package/dungeons/technical/simple-schema.json +0 -362
  70. package/dungeons/technical/text-generation-schema.json +0 -1062
  71. package/dungeons/user/.gitkeep +0 -0
  72. package/dungeons/vertical/community-schema.json +0 -579
  73. package/dungeons/vertical/devtools-schema.json +0 -601
  74. package/dungeons/vertical/ecommerce-schema.json +0 -604
  75. package/dungeons/vertical/education-schema.json +0 -5686
  76. package/dungeons/vertical/fintech-schema.json +0 -630
  77. package/dungeons/vertical/fitness-schema.json +0 -530
  78. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  79. package/dungeons/vertical/gaming-schema.json +0 -438
  80. package/dungeons/vertical/healthcare-schema.json +0 -549
  81. package/dungeons/vertical/insurance-application-schema.json +0 -485
  82. package/dungeons/vertical/logistics-schema.json +0 -574
  83. package/dungeons/vertical/marketplace-schema.json +0 -533
  84. package/dungeons/vertical/media-schema.json +0 -4749
  85. package/dungeons/vertical/rpg-schema.json +0 -2491
  86. package/dungeons/vertical/rpg.js +0 -976
  87. package/dungeons/vertical/sass-schema.json +0 -3128
  88. package/dungeons/vertical/social-schema.json +0 -620
  89. package/dungeons/vertical/travel-schema.json +0 -580
@@ -101,19 +101,13 @@ export async function makeEvent(
101
101
 
102
102
  // Set event time using TimeSoup for realistic distribution
103
103
  if (earliestTime) {
104
- let shiftedTimestamp;
104
+ let unixTime;
105
105
  if (isFirstEvent) {
106
- shiftedTimestamp = earliestTime + context.TIME_SHIFT_SECONDS;
106
+ unixTime = earliestTime;
107
107
  } else {
108
- // TimeSoup returns unix seconds; shift and convert to ISO once
109
- const soupTimestamp = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights, context.TIME_SHIFT_SECONDS);
110
- shiftedTimestamp = soupTimestamp + context.TIME_SHIFT_SECONDS;
108
+ unixTime = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
111
109
  }
112
- // Drop events that would land in the future (Mixpanel rewrites these to "now", causing pile-ups)
113
- if (shiftedTimestamp > context.MAX_TIME) {
114
- eventTemplate._drop = true;
115
- }
116
- eventTemplate.time = dayjs.unix(Math.min(shiftedTimestamp, context.MAX_TIME)).toISOString();
110
+ eventTemplate.time = dayjs.unix(unixTime).toISOString();
117
111
  }
118
112
 
119
113
  // Add anonymous identifiers
@@ -196,11 +190,12 @@ export async function makeEvent(
196
190
  }
197
191
  }
198
192
 
199
- // Perf 1: Compute eventUnix once for all time-based checks
200
- // Subtracts TIME_SHIFT to convert real time back to FIXED time (world events/features use FIXED time)
193
+ // Perf 1: Compute eventUnix once for all time-based checks. World events &
194
+ // features were resolved against the dataset window (no shift), and event
195
+ // times now also live in that same window — direct unix conversion works.
201
196
  let eventUnix = null;
202
197
  if ((worldEventsTimeline || resolvedFeatures) && eventTemplate.time) {
203
- eventUnix = dayjs(eventTemplate.time).subtract(context.TIME_SHIFT_SECONDS, 'seconds').unix();
198
+ eventUnix = dayjs(eventTemplate.time).unix();
204
199
  }
205
200
 
206
201
  // Feature 2: World events — inject props for active events at this timestamp
@@ -303,7 +298,9 @@ export async function makeEvent(
303
298
  const hookedEvent = await hook(eventTemplate, "event", {
304
299
  user: { distinct_id },
305
300
  config,
306
- persona: featureCtx.persona || null
301
+ persona: featureCtx.persona || null,
302
+ datasetStart: context.DATASET_START_SECONDS,
303
+ datasetEnd: context.DATASET_END_SECONDS
307
304
  });
308
305
  // If hook returns a modified event, use it; otherwise use original
309
306
  if (hookedEvent && typeof hookedEvent === 'object') {
@@ -68,7 +68,11 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
68
68
  }
69
69
 
70
70
  // Call pre-funnel hook
71
- await hook(funnel, "funnel-pre", { user, profile, scd, funnel, config, firstEventTime });
71
+ await hook(funnel, "funnel-pre", {
72
+ user, profile, scd, funnel, config, firstEventTime,
73
+ datasetStart: context.DATASET_START_SECONDS,
74
+ datasetEnd: context.DATASET_END_SECONDS
75
+ });
72
76
 
73
77
  // Extract funnel configuration
74
78
  let {
@@ -206,7 +210,11 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
206
210
  );
207
211
 
208
212
  // Call post-funnel hook
209
- await hook(finalEvents, "funnel-post", { user, profile, scd, funnel, config });
213
+ await hook(finalEvents, "funnel-post", {
214
+ user, profile, scd, funnel, config,
215
+ datasetStart: context.DATASET_START_SECONDS,
216
+ datasetEnd: context.DATASET_END_SECONDS
217
+ });
210
218
 
211
219
  return [finalEvents, doesUserConvert];
212
220
  }
@@ -446,8 +454,8 @@ async function generateFunnelEvents(
446
454
 
447
455
  try {
448
456
  let computedTime = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds");
449
- // Drop events that would land in the future
450
- if (context.MAX_TIME && computedTime.unix() > context.MAX_TIME) {
457
+ // Drop events that would land past the dataset window
458
+ if (context.FIXED_NOW && computedTime.unix() > context.FIXED_NOW) {
451
459
  newEvent._drop = true;
452
460
  }
453
461
  if (computedTime.isValid()) {
@@ -22,8 +22,9 @@ export async function makeMirror(context) {
22
22
  if (!mirrorProps || Object.keys(mirrorProps).length === 0) {
23
23
  return; // No mirror properties configured
24
24
  }
25
-
26
- const now = dayjs();
25
+
26
+ // Anchor to the resolved dataset end (not wall-clock) so mirror behavior is deterministic.
27
+ const now = context.FIXED_NOW ? dayjs.unix(context.FIXED_NOW) : dayjs();
27
28
 
28
29
  for (const oldEvent of eventData) {
29
30
  let newEvent = null;
@@ -119,7 +119,7 @@ export const productComparisonGen = createTextGenerator({
119
119
  authenticityLevel: 0.7,
120
120
  specificityLevel: 0.8,
121
121
  min: 100,
122
- max: 300,
122
+ max: 254, // capped at Mixpanel string property limit
123
123
  includeMetadata: false
124
124
  });
125
125
 
@@ -48,7 +48,8 @@ export async function makeSCD(context, scdProp, scdKey, distinct_id, mutations,
48
48
 
49
49
  const scdEntries = [];
50
50
  let lastInserted = dayjs(created);
51
- const now = dayjs();
51
+ // Anchor to the resolved dataset end (not wall-clock) so SCD count is deterministic.
52
+ const now = context.FIXED_NOW ? dayjs.unix(context.FIXED_NOW) : dayjs();
52
53
  const deltaDays = now.diff(lastInserted, "day");
53
54
  const uuidKeyName = type === 'user' ? 'distinct_id' : type;
54
55
  let lastStartTime = null; // Track for monotonic ordering
@@ -524,7 +524,7 @@ class OrganicTextGenerator {
524
524
  intensity: 'medium',
525
525
  formality: 'casual',
526
526
  min: 100,
527
- max: 500,
527
+ max: 254, // default capped at Mixpanel string property limit; override per-generator if needed
528
528
  seed: null,
529
529
  keywords: null,
530
530
  keywordDensity: 0.15,
@@ -39,9 +39,11 @@ export async function userLoop(context) {
39
39
  userProps,
40
40
  scdProps,
41
41
  numDays,
42
+ avgEventsPerUserPerDay,
42
43
  percentUsersBornInDataset = 15,
43
44
  strictEventCount = false,
44
- bornRecentBias = 0.3, // 0 = uniform distribution, 1 = heavily biased toward recent births
45
+ bornRecentBias = 0, // -1..1; positive = births skew toward end of window
46
+ preExistingSpread = 'uniform', // 'pinned' (FIXED_BEGIN ± 1d) | 'uniform' ([FIXED_BEGIN-30d, FIXED_BEGIN])
45
47
  personas,
46
48
  worldEvents,
47
49
  engagementDecay: globalEngagementDecay,
@@ -54,7 +56,10 @@ export async function userLoop(context) {
54
56
  } = config;
55
57
 
56
58
  const { eventData, userProfilesData, scdTableData } = storage;
57
- const avgEvPerUser = numEvents / numUsers;
59
+ // Per-user-per-day rate is the canonical event-volume primitive (config-validator
60
+ // guarantees it is set). Each user's event count is rate × their active days, so
61
+ // born-late users don't compress a full per-user budget into a tiny window.
62
+ const ratePerDay = avgEventsPerUserPerDay ?? (numEvents / numUsers / numDays);
58
63
  const startTime = Date.now();
59
64
 
60
65
  // Create batches for parallel processing
@@ -103,7 +108,7 @@ export async function userLoop(context) {
103
108
  }
104
109
 
105
110
  const userId = chance.guid();
106
- const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds });
111
+ const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix: context.FIXED_NOW });
107
112
  const { distinct_id, created } = user;
108
113
  const userIsBornInDataset = chance.bool({ likelihood: percentUsersBornInDataset });
109
114
 
@@ -123,14 +128,13 @@ export async function userLoop(context) {
123
128
 
124
129
  if (!userIsBornInDataset) delete user.created;
125
130
 
126
- // Calculate time adjustments
127
- const daysShift = context.getDaysShift();
128
-
129
- // Apply recency bias to birth dates for users born in dataset
131
+ // Apply recency bias to birth dates for users born in dataset.
132
+ // `created` (from generateUser → person) is anchored to the dataset end,
133
+ // so it already lives inside [FIXED_BEGIN, FIXED_NOW]. No shift needed.
130
134
  // bornRecentBias: 0 = uniform distribution, 1 = heavily biased toward recent
131
135
  let adjustedCreated;
132
136
  if (userIsBornInDataset) {
133
- let biasedCreated = dayjs(created).subtract(daysShift, 'd');
137
+ let biasedCreated = dayjs(created);
134
138
 
135
139
  if (bornRecentBias !== 0) {
136
140
  // Calculate how far into the dataset this user was born (0 = start, 1 = end/recent)
@@ -160,7 +164,18 @@ export async function userLoop(context) {
160
164
  // Update user.created to match biased timestamp for profile consistency
161
165
  user.created = adjustedCreated.toISOString();
162
166
  } else {
163
- adjustedCreated = dayjs.unix(context.FIXED_BEGIN);
167
+ // Pre-existing user: choose where their "first ever event" lives.
168
+ // 'pinned' (legacy): exactly at FIXED_BEGIN — all pre-existing users stack
169
+ // at day 0, which front-loads early-window event density.
170
+ // 'uniform' (default): sample uniformly from [FIXED_BEGIN - 30d, FIXED_BEGIN]
171
+ // so pre-existing users have varied "born before dataset" timestamps.
172
+ // Their TimeSoup-distributed events still mostly land in [FIXED_BEGIN, FIXED_NOW].
173
+ if (preExistingSpread === 'uniform') {
174
+ const offsetDays = chance.floating({ min: 0, max: 30 });
175
+ adjustedCreated = dayjs.unix(context.FIXED_BEGIN).subtract(offsetDays, 'day');
176
+ } else {
177
+ adjustedCreated = dayjs.unix(context.FIXED_BEGIN);
178
+ }
164
179
  }
165
180
 
166
181
  // Feature 7: Geographic intelligence — assign sticky location
@@ -266,7 +281,9 @@ export async function userLoop(context) {
266
281
  user,
267
282
  config,
268
283
  userIsBornInDataset,
269
- persona
284
+ persona,
285
+ datasetStart: context.DATASET_START_SECONDS,
286
+ datasetEnd: context.DATASET_END_SECONDS
270
287
  });
271
288
  }
272
289
 
@@ -288,7 +305,9 @@ export async function userLoop(context) {
288
305
  type: 'user',
289
306
  scd: { [key]: scdProps[key] },
290
307
  config,
291
- allSCDs: userSCD
308
+ allSCDs: userSCD,
309
+ datasetStart: context.DATASET_START_SECONDS,
310
+ datasetEnd: context.DATASET_END_SECONDS
292
311
  });
293
312
  if (Array.isArray(hookResult)) {
294
313
  changes = hookResult;
@@ -297,9 +316,17 @@ export async function userLoop(context) {
297
316
  }
298
317
  }
299
318
 
319
+ // User's active days = how much of the dataset window they're alive for.
320
+ // Pre-existing users: full window. Born-in-dataset: from birth to FIXED_NOW.
321
+ // Floor at 1 day so users born on the very last day still emit a few events.
322
+ const userActiveDays = userIsBornInDataset
323
+ ? Math.max(1, (context.FIXED_NOW - adjustedCreated.unix()) / 86400)
324
+ : numDays;
325
+ const userEventBudget = ratePerDay * userActiveDays;
326
+
300
327
  let numEventsThisUserWillPreform = Math.floor(chance.normal({
301
- mean: avgEvPerUser,
302
- dev: avgEvPerUser / u.integer(u.integer(2, 5), u.integer(2, 7))
328
+ mean: userEventBudget,
329
+ dev: userEventBudget / u.integer(u.integer(2, 5), u.integer(2, 7))
303
330
  }) * 0.714159265359);
304
331
 
305
332
  // Power users and low-activity users logic
@@ -349,8 +376,7 @@ export async function userLoop(context) {
349
376
  const [data, converted] = await makeFunnel(context, firstFunnel, user, firstTime, profile, userSCD, persona, featureCtx);
350
377
  userConverted = converted;
351
378
 
352
- const timeShift = context.getTimeShift();
353
- userFirstEventTime = dayjs(data[0].time).subtract(timeShift, 'seconds').unix();
379
+ userFirstEventTime = dayjs(data[0].time).unix();
354
380
  numEventsPreformed += data.length;
355
381
  usersEvents = usersEvents.concat(data);
356
382
  } else {
@@ -399,10 +425,8 @@ export async function userLoop(context) {
399
425
  // Subscription events (trial, upgrade, cancel) must not be randomly dropped by decay
400
426
  const userDecay = persona?.engagementDecay || globalEngagementDecay;
401
427
  if (userDecay && userDecay.model !== 'none' && usersEvents.length > 0) {
402
- // adjustedCreated is in FIXED time space, but ev.time is in PRESENT time (shifted).
403
- // Shift adjustedCreated to present so daysSinceBirth reflects within-dataset age.
404
- const adjustedCreatedPresent = adjustedCreated.add(context.TIME_SHIFT_SECONDS, 'seconds');
405
- usersEvents = applyEngagementDecay(usersEvents, userDecay, adjustedCreatedPresent, context, chance);
428
+ // adjustedCreated and event times now share the same dataset window no shift.
429
+ usersEvents = applyEngagementDecay(usersEvents, userDecay, adjustedCreated, context, chance);
406
430
  }
407
431
 
408
432
  // Feature 5: Subscription lifecycle — inject after decay (exempt from decay filtering)
@@ -461,9 +485,7 @@ export async function userLoop(context) {
461
485
  earliestTime: userFirstEventTime,
462
486
  latestTime: context.FIXED_NOW,
463
487
  peaks: soupPeaks, deviation: soupDev, mean: soupMean,
464
- dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD,
465
- timeShiftSeconds: context.TIME_SHIFT_SECONDS,
466
- maxTime: context.MAX_TIME
488
+ dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD
467
489
  });
468
490
  u.assignSessionIds(usersEvents, sessionTimeout);
469
491
  }
@@ -475,11 +497,22 @@ export async function userLoop(context) {
475
497
  scd: userSCD,
476
498
  config,
477
499
  userIsBornInDataset,
478
- persona
500
+ persona,
501
+ datasetStart: context.DATASET_START_SECONDS,
502
+ datasetEnd: context.DATASET_END_SECONDS
479
503
  });
480
504
  if (Array.isArray(newEvents)) usersEvents = newEvents;
481
505
  }
482
506
 
507
+ // Defensive guard: drop any events whose timestamp landed past the
508
+ // configured dataset end. Hooks that duplicate events with time offsets
509
+ // (weekend surges, viral spreads) can leak a few past the boundary.
510
+ usersEvents = usersEvents.filter(e => {
511
+ if (!e || !e.time) return true;
512
+ const t = typeof e.time === 'string' ? Date.parse(e.time) / 1000 : Number(e.time);
513
+ return Number.isFinite(t) ? t <= context.FIXED_NOW : true;
514
+ });
515
+
483
516
  // Store all user data
484
517
  await userProfilesData.hookPush(profile);
485
518
 
@@ -594,8 +627,7 @@ function generateSubscriptionEvents(subscription, user, persona, userCreated, co
594
627
  const paidPlans = plans.filter(p => p.price > 0);
595
628
 
596
629
  const userStartUnix = dayjs(userCreated).unix();
597
- const endUnix = context.MAX_TIME;
598
- const timeShift = context.TIME_SHIFT_SECONDS;
630
+ const endUnix = context.FIXED_NOW;
599
631
 
600
632
  // Persona modifiers for subscription behavior
601
633
  const personaChurnMod = persona?.churnRate ? (1 + persona.churnRate) : 1.0;
@@ -608,14 +640,14 @@ function generateSubscriptionEvents(subscription, user, persona, userCreated, co
608
640
  const firstPaidPlan = paidPlans[0];
609
641
  if (firstPaidPlan && firstPaidPlan.trialDays) {
610
642
  const trialStart = currentUnix + chance.integer({ min: 0, max: 86400 });
611
- if (trialStart + timeShift < endUnix) {
612
- subEvents.push(makeSubEvent(eventNames.trialStarted, trialStart + timeShift, user, firstPaidPlan.name, 'trial'));
643
+ if (trialStart < endUnix) {
644
+ subEvents.push(makeSubEvent(eventNames.trialStarted, trialStart, user, firstPaidPlan.name, 'trial'));
613
645
  currentUnix = trialStart + (firstPaidPlan.trialDays * 86400);
614
646
 
615
647
  // Trial to paid conversion
616
648
  if (chance.bool({ likelihood: lc.trialToPayRate * personaUpgradeMod * 100 })) {
617
- if (currentUnix + timeShift < endUnix) {
618
- subEvents.push(makeSubEvent(eventNames.subscribed, currentUnix + timeShift, user, firstPaidPlan.name, 'active'));
649
+ if (currentUnix < endUnix) {
650
+ subEvents.push(makeSubEvent(eventNames.subscribed, currentUnix, user, firstPaidPlan.name, 'active'));
619
651
  currentPlan = firstPaidPlan;
620
652
  currentStatus = 'active';
621
653
  }
@@ -627,15 +659,15 @@ function generateSubscriptionEvents(subscription, user, persona, userCreated, co
627
659
  }
628
660
 
629
661
  // Monthly lifecycle loop
630
- while (currentUnix + timeShift < endUnix) {
662
+ while (currentUnix < endUnix) {
631
663
  currentUnix += monthSeconds + chance.integer({ min: -86400, max: 86400 });
632
- if (currentUnix + timeShift >= endUnix) break;
664
+ if (currentUnix >= endUnix) break;
633
665
  if (currentStatus === 'cancelled') {
634
666
  // Win-back check
635
667
  if (chance.bool({ likelihood: lc.winBackRate * 100 })) {
636
668
  currentUnix += lc.winBackDelay * 86400;
637
- if (currentUnix + timeShift >= endUnix) break;
638
- subEvents.push(makeSubEvent(eventNames.wonBack, currentUnix + timeShift, user, currentPlan.name, 'active'));
669
+ if (currentUnix >= endUnix) break;
670
+ subEvents.push(makeSubEvent(eventNames.wonBack, currentUnix, user, currentPlan.name, 'active'));
639
671
  currentStatus = 'active';
640
672
  }
641
673
  break;
@@ -643,12 +675,12 @@ function generateSubscriptionEvents(subscription, user, persona, userCreated, co
643
675
 
644
676
  // Payment failure
645
677
  if (currentPlan.price > 0 && chance.bool({ likelihood: lc.paymentFailureRate * 100 })) {
646
- subEvents.push(makeSubEvent(eventNames.paymentFailed, currentUnix + timeShift, user, currentPlan.name, 'payment_issue'));
678
+ subEvents.push(makeSubEvent(eventNames.paymentFailed, currentUnix, user, currentPlan.name, 'payment_issue'));
647
679
  }
648
680
 
649
681
  // Churn
650
682
  if (chance.bool({ likelihood: lc.churnRate * personaChurnMod * 100 })) {
651
- subEvents.push(makeSubEvent(eventNames.cancelled, currentUnix + timeShift, user, currentPlan.name, 'cancelled'));
683
+ subEvents.push(makeSubEvent(eventNames.cancelled, currentUnix, user, currentPlan.name, 'cancelled'));
652
684
  currentStatus = 'cancelled';
653
685
  continue;
654
686
  }
@@ -657,7 +689,7 @@ function generateSubscriptionEvents(subscription, user, persona, userCreated, co
657
689
  const currentPlanIndex = plans.indexOf(currentPlan);
658
690
  if (currentPlanIndex < plans.length - 1 && chance.bool({ likelihood: lc.upgradeRate * personaUpgradeMod * 100 })) {
659
691
  const newPlan = plans[currentPlanIndex + 1];
660
- subEvents.push(makeSubEvent(eventNames.upgraded, currentUnix + timeShift, user, newPlan.name, 'active', currentPlan.name));
692
+ subEvents.push(makeSubEvent(eventNames.upgraded, currentUnix, user, newPlan.name, 'active', currentPlan.name));
661
693
  currentPlan = newPlan;
662
694
  continue;
663
695
  }
@@ -665,14 +697,14 @@ function generateSubscriptionEvents(subscription, user, persona, userCreated, co
665
697
  // Downgrade
666
698
  if (currentPlanIndex > 0 && currentPlan.price > 0 && chance.bool({ likelihood: lc.downgradeRate * 100 })) {
667
699
  const newPlan = plans[currentPlanIndex - 1];
668
- subEvents.push(makeSubEvent(eventNames.downgraded, currentUnix + timeShift, user, newPlan.name, 'active', currentPlan.name));
700
+ subEvents.push(makeSubEvent(eventNames.downgraded, currentUnix, user, newPlan.name, 'active', currentPlan.name));
669
701
  currentPlan = newPlan;
670
702
  continue;
671
703
  }
672
704
 
673
705
  // Renewal
674
706
  if (currentPlan.price > 0) {
675
- subEvents.push(makeSubEvent(eventNames.renewed, currentUnix + timeShift, user, currentPlan.name, 'active'));
707
+ subEvents.push(makeSubEvent(eventNames.renewed, currentUnix, user, currentPlan.name, 'active'));
676
708
  }
677
709
  }
678
710
 
@@ -680,9 +712,12 @@ function generateSubscriptionEvents(subscription, user, persona, userCreated, co
680
712
  }
681
713
 
682
714
  function makeSubEvent(eventName, unixTime, user, planName, status, previousPlan) {
715
+ // Callers already guard `unixTime < endUnix` before invoking, so no clamp here.
716
+ // Previous code clamped against `dayjs()` (wall-clock now) — leaked wall-clock into
717
+ // timestamps and wasn't even the right upper bound.
683
718
  const ev = {
684
719
  event: eventName,
685
- time: dayjs.unix(Math.min(unixTime, dayjs().unix())).toISOString(),
720
+ time: dayjs.unix(unixTime).toISOString(),
686
721
  user_id: user.distinct_id,
687
722
  insert_id: u.quickHash(`${eventName}-${unixTime}-${user.distinct_id}`),
688
723
  subscription_plan: planName,
@@ -708,14 +743,14 @@ async function generateBotUsers(context, dataQuality, storage) {
708
743
  const botId = `bot_${chance.guid().slice(0, 8)}`;
709
744
  const botEvents = [];
710
745
 
711
- // Bots generate events at machine-like intervals
712
- let currentTime = context.FIXED_BEGIN + context.TIME_SHIFT_SECONDS;
713
- const interval = Math.floor(((context.MAX_TIME - currentTime) / botEventsPerUser));
746
+ // Bots generate events at machine-like intervals across the dataset window
747
+ let currentTime = context.FIXED_BEGIN;
748
+ const interval = Math.floor(((context.FIXED_NOW - currentTime) / botEventsPerUser));
714
749
 
715
750
  for (let e = 0; e < botEventsPerUser; e++) {
716
751
  const chosenEvent = botEventTypes[e % botEventTypes.length];
717
752
  currentTime += interval + chance.integer({ min: 0, max: 10 });
718
- if (currentTime > context.MAX_TIME) break;
753
+ if (currentTime > context.FIXED_NOW) break;
719
754
  botEvents.push({
720
755
  event: chosenEvent.event,
721
756
  time: dayjs.unix(currentTime).toISOString(),
@@ -743,20 +778,19 @@ async function generateBotUsers(context, dataQuality, storage) {
743
778
  */
744
779
  async function generateAnomalyBursts(context, anomalies, storage) {
745
780
  const chance = u.getChance();
746
- const timeShift = context.TIME_SHIFT_SECONDS;
747
781
 
748
782
  for (const a of anomalies) {
749
783
  if (a.type !== 'burst' && a.type !== 'coordinated') continue;
750
784
  if (!a._startUnix || !a.count) continue;
751
785
 
752
786
  const burstEvents = [];
753
- const startUnix = a._startUnix + timeShift;
754
- const endUnix = a._endUnix + timeShift;
787
+ const startUnix = a._startUnix;
788
+ const endUnix = a._endUnix;
755
789
  const windowSeconds = endUnix - startUnix;
756
790
 
757
791
  for (let i = 0; i < a.count; i++) {
758
792
  const eventTime = startUnix + chance.integer({ min: 0, max: Math.max(1, windowSeconds) });
759
- if (eventTime > context.MAX_TIME) continue;
793
+ if (eventTime > context.FIXED_NOW) continue;
760
794
  const userId = a.type === 'coordinated'
761
795
  ? `anomaly_${chance.guid().slice(0, 8)}`
762
796
  : `burst_${chance.integer({ min: 1, max: 100 })}`;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Macro preset configurations.
3
+ *
4
+ * Macro presets shape the BIG-PICTURE trend across the dataset window —
5
+ * how user births are distributed in time and what fraction of users are
6
+ * born inside the window vs already existing at its start. These are the
7
+ * knobs that determine whether the chart tilts up, tilts down, or stays flat.
8
+ *
9
+ * Soup presets (lib/templates/soup-presets.js) are the orthogonal axis:
10
+ * they shape intra-week and intra-day rhythm (DOW/HOD weights, peak count,
11
+ * deviation). The two are independent — pick a macro and a soup separately.
12
+ *
13
+ * The default macro is "flat" so a brand-new dungeon produces a steady
14
+ * baseline. Layer hooks on top to inject deliberate trends/spikes.
15
+ *
16
+ * Parameters:
17
+ * - bornRecentBias: -1..1. Negative pushes births toward dataset start,
18
+ * positive toward the end. Power-function shaped in user-loop.js.
19
+ * - percentUsersBornInDataset: 0..100. Fraction of users created inside the
20
+ * window. The rest are pre-existing (first event ≈ FIXED_BEGIN).
21
+ * - preExistingSpread: "pinned" (current behavior — pre-existing users start
22
+ * at FIXED_BEGIN ± 1 day) | "uniform" (sample first event time uniformly
23
+ * across [FIXED_BEGIN - 30d, FIXED_BEGIN] so they don't all stack at day 0).
24
+ */
25
+
26
+ /** @type {Record<string, {bornRecentBias: number, percentUsersBornInDataset: number, preExistingSpread: 'pinned'|'uniform'}>} */
27
+ export const MACRO_PRESETS = {
28
+ /**
29
+ * flat (DEFAULT) — Mature product, no growth narrative.
30
+ * Tail ratio ≈ 1.0. Pure weekly oscillation, no net drift.
31
+ */
32
+ flat: {
33
+ bornRecentBias: 0,
34
+ percentUsersBornInDataset: 15,
35
+ preExistingSpread: 'uniform',
36
+ },
37
+
38
+ /**
39
+ * steady — Lightly-growing SaaS.
40
+ * Slight uptrend without any visible spike at the right edge.
41
+ */
42
+ steady: {
43
+ bornRecentBias: 0.1,
44
+ percentUsersBornInDataset: 10,
45
+ preExistingSpread: 'uniform',
46
+ },
47
+
48
+ /**
49
+ * growth — Visible uptrend story without the meteoric blow-up.
50
+ * Use when the dataset wants to show clear acquisition over time.
51
+ */
52
+ growth: {
53
+ bornRecentBias: 0.3,
54
+ percentUsersBornInDataset: 25,
55
+ preExistingSpread: 'pinned',
56
+ },
57
+
58
+ /**
59
+ * viral — Hockey-stick acquisition.
60
+ * Strong late-window ramp. Pair with persona/feature hooks for the full effect.
61
+ */
62
+ viral: {
63
+ bornRecentBias: 0.6,
64
+ percentUsersBornInDataset: 50,
65
+ preExistingSpread: 'pinned',
66
+ },
67
+
68
+ /**
69
+ * decline — Sunsetting product, churning users.
70
+ * Few new users, those that exist are born early. Pair with churn hooks.
71
+ */
72
+ decline: {
73
+ bornRecentBias: -0.3,
74
+ percentUsersBornInDataset: 5,
75
+ preExistingSpread: 'uniform',
76
+ },
77
+ };
78
+
79
+ /** @type {string[]} */
80
+ export const MACRO_PRESET_NAMES = Object.keys(MACRO_PRESETS);
81
+
82
+ /**
83
+ * Resolve a macro config — accepts string presets, preset+overrides objects, or raw objects.
84
+ * Defaults to "flat" if nothing is provided.
85
+ *
86
+ * @param {string | object | undefined} macro - Macro config from dungeon
87
+ * @returns {{bornRecentBias: number, percentUsersBornInDataset: number, preExistingSpread: 'pinned' | 'uniform'}}
88
+ */
89
+ export function resolveMacro(macro) {
90
+ if (!macro) return { ...MACRO_PRESETS.flat };
91
+
92
+ if (typeof macro === 'string') {
93
+ const preset = MACRO_PRESETS[macro];
94
+ if (!preset) {
95
+ throw new Error(`Unknown macro preset: "${macro}". Valid presets: ${MACRO_PRESET_NAMES.join(', ')}`);
96
+ }
97
+ return { ...preset };
98
+ }
99
+
100
+ if (typeof macro === 'object' && macro.preset) {
101
+ const preset = MACRO_PRESETS[macro.preset];
102
+ if (!preset) {
103
+ throw new Error(`Unknown macro preset: "${macro.preset}". Valid presets: ${MACRO_PRESET_NAMES.join(', ')}`);
104
+ }
105
+ const { preset: _, ...overrides } = macro;
106
+ return { ...preset, ...overrides };
107
+ }
108
+
109
+ // Raw object: pass through, filling in flat defaults for missing fields
110
+ return { ...MACRO_PRESETS.flat, ...macro };
111
+ }