@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
@@ -42,6 +42,15 @@ export async function userLoop(context) {
42
42
  percentUsersBornInDataset = 15,
43
43
  strictEventCount = false,
44
44
  bornRecentBias = 0.3, // 0 = uniform distribution, 1 = heavily biased toward recent births
45
+ personas,
46
+ worldEvents,
47
+ engagementDecay: globalEngagementDecay,
48
+ dataQuality,
49
+ subscription,
50
+ attribution,
51
+ geo,
52
+ features,
53
+ anomalies
45
54
  } = config;
46
55
 
47
56
  const { eventData, userProfilesData, scdTableData } = storage;
@@ -97,6 +106,19 @@ export async function userLoop(context) {
97
106
  const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds });
98
107
  const { distinct_id, created } = user;
99
108
  const userIsBornInDataset = chance.bool({ likelihood: percentUsersBornInDataset });
109
+
110
+ // Feature 1: Assign persona
111
+ let persona = null;
112
+ if (personas && personas.length > 0) {
113
+ const weights = personas.map(p => p.weight);
114
+ const totalWeight = weights.reduce((a, b) => a + b, 0);
115
+ let roll = chance.floating({ min: 0, max: totalWeight });
116
+ for (const p of personas) {
117
+ roll -= p.weight;
118
+ if (roll <= 0) { persona = p; break; }
119
+ }
120
+ if (!persona) persona = personas[personas.length - 1];
121
+ }
100
122
  let numEventsPreformed = 0;
101
123
 
102
124
  if (!userIsBornInDataset) delete user.created;
@@ -112,7 +134,7 @@ export async function userLoop(context) {
112
134
 
113
135
  if (bornRecentBias !== 0) {
114
136
  // Calculate how far into the dataset this user was born (0 = start, 1 = end/recent)
115
- const datasetStart = dayjs.unix(global.FIXED_BEGIN);
137
+ const datasetStart = dayjs.unix(context.FIXED_BEGIN);
116
138
  const datasetEnd = dayjs.unix(context.FIXED_NOW);
117
139
  const totalDuration = datasetEnd.diff(datasetStart);
118
140
  // Clamp userPosition to [0, 1] to handle edge cases from rounding in time calculations
@@ -138,25 +160,113 @@ export async function userLoop(context) {
138
160
  // Update user.created to match biased timestamp for profile consistency
139
161
  user.created = adjustedCreated.toISOString();
140
162
  } else {
141
- adjustedCreated = dayjs.unix(global.FIXED_BEGIN);
163
+ adjustedCreated = dayjs.unix(context.FIXED_BEGIN);
142
164
  }
143
165
 
144
- if (hasLocation) {
166
+ // Feature 7: Geographic intelligence — assign sticky location
167
+ let userLocation = null;
168
+ let userRegion = null;
169
+ let userTimezoneOffset = 0;
170
+ if (geo && geo.sticky && geo.regions && geo.regions.length > 0) {
171
+ // Assign region using weighted selection
172
+ const regionWeights = geo.regions.map(r => r.weight);
173
+ const totalRegionWeight = regionWeights.reduce((a, b) => a + b, 0);
174
+ let regionRoll = chance.floating({ min: 0, max: totalRegionWeight });
175
+ for (const r of geo.regions) {
176
+ regionRoll -= r.weight;
177
+ if (regionRoll <= 0) { userRegion = r; break; }
178
+ }
179
+ if (!userRegion) userRegion = geo.regions[geo.regions.length - 1];
180
+ userTimezoneOffset = userRegion.timezoneOffset || 0;
181
+
182
+ // Pick a location matching one of this region's countries
183
+ const regionLocations = u.choose(defaults.locationsUsers).filter(
184
+ loc => userRegion.countries.includes(loc.country_code || loc.country)
185
+ );
186
+ if (regionLocations.length > 0) {
187
+ userLocation = u.pickRandom(regionLocations);
188
+ } else {
189
+ userLocation = u.pickRandom(u.choose(defaults.locationsUsers));
190
+ }
191
+ for (const key in userLocation) {
192
+ user[key] = userLocation[key];
193
+ }
194
+ // Inject region properties
195
+ if (userRegion.properties) {
196
+ for (const [k, v] of Object.entries(userRegion.properties)) {
197
+ user[k] = v;
198
+ }
199
+ }
200
+ } else if (hasLocation) {
145
201
  const location = u.pickRandom(u.choose(defaults.locationsUsers));
146
202
  for (const key in location) {
147
203
  user[key] = location[key];
148
204
  }
205
+ userLocation = location;
206
+ }
207
+
208
+ // Feature 6: Attribution — assign campaign to users born in dataset
209
+ let userCampaign = null;
210
+ if (attribution && userIsBornInDataset) {
211
+ // adjustedCreated is in the internal FIXED time range
212
+ const birthUnix = adjustedCreated.unix();
213
+ const birthDay = Math.max(0, (birthUnix - context.FIXED_BEGIN) / 86400);
214
+ const isOrganic = chance.bool({ likelihood: (attribution.organicRate || 0.4) * 100 });
215
+ if (!isOrganic) {
216
+ // Find active campaigns at birth day
217
+ const activeCampaigns = attribution.campaigns.filter(c =>
218
+ birthDay >= c.activeDays[0] && birthDay <= c.activeDays[1]
219
+ );
220
+ if (activeCampaigns.length > 0) {
221
+ userCampaign = chance.pickone(activeCampaigns);
222
+ }
223
+ }
149
224
  }
150
225
 
151
226
  // Profile creation
152
227
  const profile = await makeUserProfile(context, userProps, user);
153
228
 
154
- // Call user hook after profile creation
229
+ // Feature 1: Merge persona properties into profile (before hook, so hook can override)
230
+ if (persona && persona.properties) {
231
+ for (const [key, value] of Object.entries(persona.properties)) {
232
+ profile[key] = u.choose(value);
233
+ }
234
+ profile._persona = persona.name;
235
+ }
236
+
237
+ // Feature 6: Add attribution to profile
238
+ if (userCampaign) {
239
+ profile.utm_source = userCampaign.source;
240
+ profile.utm_campaign = userCampaign.name;
241
+ if (userCampaign.medium) profile.utm_medium = userCampaign.medium;
242
+ }
243
+
244
+ // Feature 7: Add region to profile
245
+ if (userRegion) {
246
+ profile._region = userRegion.name;
247
+ }
248
+
249
+ // Build feature context for event generation
250
+ const featureCtx = {
251
+ persona,
252
+ userLocation,
253
+ worldEventsTimeline: worldEvents,
254
+ resolvedFeatures: features,
255
+ resolvedAnomalies: anomalies,
256
+ dataQuality,
257
+ geo,
258
+ userCampaign,
259
+ userRegion,
260
+ userTimezoneOffset
261
+ };
262
+
263
+ // Call user hook after profile creation (hooks override persona properties)
155
264
  if (config.hook) {
156
265
  await config.hook(profile, "user", {
157
266
  user,
158
267
  config,
159
- userIsBornInDataset
268
+ userIsBornInDataset,
269
+ persona
160
270
  });
161
271
  }
162
272
 
@@ -172,16 +282,18 @@ export async function userLoop(context) {
172
282
  let changes = await makeSCD(context, scdProps[key], key, distinct_id, mutations, created);
173
283
  userSCD[key] = changes;
174
284
 
175
- const hookResult = await config.hook(changes, "scd-pre", {
176
- profile,
177
- type: 'user',
178
- scd: { [key]: scdProps[key] },
179
- config,
180
- allSCDs: userSCD
181
- });
182
- if (Array.isArray(hookResult)) {
183
- changes = hookResult;
184
- userSCD[key] = changes;
285
+ if (config.hook) {
286
+ const hookResult = await config.hook(changes, "scd-pre", {
287
+ profile,
288
+ type: 'user',
289
+ scd: { [key]: scdProps[key] },
290
+ config,
291
+ allSCDs: userSCD
292
+ });
293
+ if (Array.isArray(hookResult)) {
294
+ changes = hookResult;
295
+ userSCD[key] = changes;
296
+ }
185
297
  }
186
298
  }
187
299
 
@@ -191,8 +303,14 @@ export async function userLoop(context) {
191
303
  }) * 0.714159265359);
192
304
 
193
305
  // Power users and low-activity users logic
194
- chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
195
- chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
306
+ if (persona) {
307
+ // Persona-driven event multiplier replaces the old dice rolls
308
+ numEventsThisUserWillPreform *= persona.eventMultiplier;
309
+ } else {
310
+ // Legacy behavior when no personas configured
311
+ chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
312
+ chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
313
+ }
196
314
  numEventsThisUserWillPreform = Math.round(numEventsThisUserWillPreform);
197
315
 
198
316
  let userFirstEventTime;
@@ -228,7 +346,7 @@ export async function userLoop(context) {
228
346
  if (firstFunnels.length && userIsBornInDataset) {
229
347
  const firstFunnel = chance.pickone(firstFunnels, user);
230
348
  const firstTime = adjustedCreated.subtract(noise(), 'seconds').unix();
231
- const [data, converted] = await makeFunnel(context, firstFunnel, user, firstTime, profile, userSCD);
349
+ const [data, converted] = await makeFunnel(context, firstFunnel, user, firstTime, profile, userSCD, persona, featureCtx);
232
350
  userConverted = converted;
233
351
 
234
352
  const timeShift = context.getTimeShift();
@@ -245,11 +363,11 @@ export async function userLoop(context) {
245
363
  let newEvents;
246
364
  if (usageFunnels.length && userConverted) {
247
365
  const currentFunnel = chance.pickone(usageFunnels);
248
- const [data, converted] = await makeFunnel(context, currentFunnel, user, userFirstEventTime, profile, userSCD);
366
+ const [data, converted] = await makeFunnel(context, currentFunnel, user, userFirstEventTime, profile, userSCD, persona, featureCtx);
249
367
  numEventsPreformed += data.length;
250
368
  newEvents = data;
251
369
  } else {
252
- const data = await makeEvent(context, distinct_id, userFirstEventTime, u.pick(weightedEvents), user.anonymousIds, user.sessionIds, {}, config.groupKeys, true);
370
+ const data = await makeEvent(context, distinct_id, userFirstEventTime, u.pick(weightedEvents), user.anonymousIds, user.sessionIds, {}, config.groupKeys, true, false, featureCtx);
253
371
  numEventsPreformed++;
254
372
  newEvents = [data];
255
373
  }
@@ -275,13 +393,69 @@ export async function userLoop(context) {
275
393
  // Remove events flagged as future timestamps (before dungeon hooks see them)
276
394
  usersEvents = usersEvents.filter(e => !e._drop);
277
395
 
278
- // Hook for processing all user events
396
+ // Feature 3: Engagement decay filter behavioral events BEFORE subscription injection
397
+ // Subscription events (trial, upgrade, cancel) must not be randomly dropped by decay
398
+ const userDecay = persona?.engagementDecay || globalEngagementDecay;
399
+ if (userDecay && userDecay.model !== 'none' && usersEvents.length > 0) {
400
+ // adjustedCreated is in FIXED time space, but ev.time is in PRESENT time (shifted).
401
+ // Shift adjustedCreated to present so daysSinceBirth reflects within-dataset age.
402
+ const adjustedCreatedPresent = adjustedCreated.add(context.TIME_SHIFT_SECONDS, 'seconds');
403
+ usersEvents = applyEngagementDecay(usersEvents, userDecay, adjustedCreatedPresent, context, chance);
404
+ }
405
+
406
+ // Feature 5: Subscription lifecycle — inject after decay (exempt from decay filtering)
407
+ if (subscription && userIsBornInDataset) {
408
+ const subEvents = generateSubscriptionEvents(
409
+ subscription, user, persona, adjustedCreated, context, chance
410
+ );
411
+ if (subEvents.length > 0) {
412
+ usersEvents = usersEvents.concat(subEvents);
413
+ // Perf 3: ISO strings sort lexicographically — avoid Date() allocation
414
+ usersEvents.sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
415
+ }
416
+ // Set current plan on profile
417
+ if (subEvents.length > 0) {
418
+ const lastSubEvent = subEvents[subEvents.length - 1];
419
+ if (lastSubEvent._currentPlan) {
420
+ profile.subscription_plan = lastSubEvent._currentPlan;
421
+ profile.subscription_status = lastSubEvent._subStatus || 'active';
422
+ }
423
+ }
424
+ }
425
+
426
+ // Feature 4: Data quality — duplicates and late-arriving
427
+ if (dataQuality) {
428
+ if (dataQuality.duplicateRate > 0) {
429
+ const dupes = [];
430
+ for (const ev of usersEvents) {
431
+ if (chance.bool({ likelihood: dataQuality.duplicateRate * 100 })) {
432
+ const dupe = { ...ev };
433
+ dupe.time = dayjs(ev.time).add(chance.integer({ min: 1, max: 60 }), 'seconds').toISOString();
434
+ // Fix 2: Regenerate insert_id so Mixpanel doesn't silently deduplicate
435
+ const dupeId = dupe.user_id || dupe.device_id || '';
436
+ dupe.insert_id = u.quickHash(`${dupe.event}-${dupe.time}-${dupeId}-dupe`);
437
+ dupes.push(dupe);
438
+ }
439
+ }
440
+ usersEvents = usersEvents.concat(dupes);
441
+ }
442
+ if (dataQuality.lateArrivingRate > 0) {
443
+ for (const ev of usersEvents) {
444
+ if (chance.bool({ likelihood: dataQuality.lateArrivingRate * 100 })) {
445
+ ev.time = dayjs(ev.time).subtract(chance.integer({ min: 1, max: 7 }), 'days').toISOString();
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ // Hook for processing all user events (hooks override everything)
279
452
  if (config.hook) {
280
453
  const newEvents = await config.hook(usersEvents, "everything", {
281
454
  profile,
282
455
  scd: userSCD,
283
456
  config,
284
- userIsBornInDataset
457
+ userIsBornInDataset,
458
+ persona
285
459
  });
286
460
  if (Array.isArray(newEvents)) usersEvents = newEvents;
287
461
  }
@@ -314,6 +488,16 @@ export async function userLoop(context) {
314
488
  // Wait for all users to complete
315
489
  await Promise.all(userPromises);
316
490
 
491
+ // Feature 4: Generate bot users (after regular users)
492
+ if (dataQuality && dataQuality.botUsers > 0) {
493
+ await generateBotUsers(context, dataQuality, storage);
494
+ }
495
+
496
+ // Feature 9: Anomaly burst/coordinated injection (after all users)
497
+ if (anomalies) {
498
+ await generateAnomalyBursts(context, anomalies, storage);
499
+ }
500
+
317
501
  // Clean up SIGINT handler
318
502
  process.removeListener('SIGINT', onSigint);
319
503
  }
@@ -333,3 +517,241 @@ export function matchConditions(profile, conditions) {
333
517
  }
334
518
  return true;
335
519
  }
520
+
521
+ // ── Advanced Feature Helper Functions ──
522
+
523
+ /**
524
+ * Feature 3: Apply engagement decay to a user's events
525
+ */
526
+ function applyEngagementDecay(events, decay, userCreated, context, chance) {
527
+ if (!events.length) return events;
528
+ // Perf 2: Use Date.parse instead of dayjs for hot loop
529
+ const userStartUnix = new Date(userCreated.toISOString ? userCreated.toISOString() : userCreated).getTime() / 1000;
530
+ const halfLifeDays = decay.halfLife || 45;
531
+ const floor = decay.floor ?? 0.1;
532
+ const reactivationChance = decay.reactivationChance || 0;
533
+ const reactivationMult = decay.reactivationMultiplier || 2.0;
534
+
535
+ return events.filter(ev => {
536
+ const evUnix = new Date(ev.time).getTime() / 1000;
537
+ const daysSinceBirth = Math.max(0, (evUnix - userStartUnix) / 86400);
538
+
539
+ let retention;
540
+ switch (decay.model) {
541
+ case 'exponential':
542
+ retention = Math.max(floor, Math.pow(0.5, daysSinceBirth / halfLifeDays));
543
+ break;
544
+ case 'linear':
545
+ retention = Math.max(floor, 1 - (daysSinceBirth / (halfLifeDays * 2)));
546
+ break;
547
+ case 'step':
548
+ retention = daysSinceBirth < halfLifeDays ? 1.0 : floor;
549
+ break;
550
+ default:
551
+ return true;
552
+ }
553
+
554
+ // Reactivation chance can spike retention back up
555
+ if (reactivationChance > 0 && retention < 0.5 && chance.bool({ likelihood: reactivationChance * 100 })) {
556
+ retention = Math.min(1.0, retention * reactivationMult);
557
+ }
558
+
559
+ return chance.bool({ likelihood: retention * 100 });
560
+ });
561
+ }
562
+
563
+ /**
564
+ * Feature 5: Generate subscription lifecycle events for a user
565
+ */
566
+ function generateSubscriptionEvents(subscription, user, persona, userCreated, context, chance) {
567
+ const { plans, lifecycle, events: eventNames } = subscription;
568
+ const lc = lifecycle;
569
+ const subEvents = [];
570
+
571
+ const defaultPlan = plans.find(p => p.default) || plans[0];
572
+ let currentPlan = defaultPlan;
573
+ let currentStatus = 'active';
574
+ const paidPlans = plans.filter(p => p.price > 0);
575
+
576
+ const userStartUnix = dayjs(userCreated).unix();
577
+ const endUnix = context.MAX_TIME;
578
+ const timeShift = context.TIME_SHIFT_SECONDS;
579
+
580
+ // Persona modifiers for subscription behavior
581
+ const personaChurnMod = persona?.churnRate ? (1 + persona.churnRate) : 1.0;
582
+ const personaUpgradeMod = persona?.conversionModifier || 1.0;
583
+
584
+ let currentUnix = userStartUnix;
585
+ const monthSeconds = 30 * 86400;
586
+
587
+ // If the default plan has a trial, start a trial
588
+ const firstPaidPlan = paidPlans[0];
589
+ if (firstPaidPlan && firstPaidPlan.trialDays) {
590
+ const trialStart = currentUnix + chance.integer({ min: 0, max: 86400 });
591
+ if (trialStart + timeShift < endUnix) {
592
+ subEvents.push(makeSubEvent(eventNames.trialStarted, trialStart + timeShift, user, firstPaidPlan.name, 'trial'));
593
+ currentUnix = trialStart + (firstPaidPlan.trialDays * 86400);
594
+
595
+ // Trial to paid conversion
596
+ if (chance.bool({ likelihood: lc.trialToPayRate * personaUpgradeMod * 100 })) {
597
+ if (currentUnix + timeShift < endUnix) {
598
+ subEvents.push(makeSubEvent(eventNames.subscribed, currentUnix + timeShift, user, firstPaidPlan.name, 'active'));
599
+ currentPlan = firstPaidPlan;
600
+ currentStatus = 'active';
601
+ }
602
+ } else {
603
+ currentStatus = 'expired_trial';
604
+ return subEvents; // didn't convert, no more sub events
605
+ }
606
+ }
607
+ }
608
+
609
+ // Monthly lifecycle loop
610
+ while (currentUnix + timeShift < endUnix) {
611
+ currentUnix += monthSeconds + chance.integer({ min: -86400, max: 86400 });
612
+ if (currentUnix + timeShift >= endUnix) break;
613
+ if (currentStatus === 'cancelled') {
614
+ // Win-back check
615
+ if (chance.bool({ likelihood: lc.winBackRate * 100 })) {
616
+ currentUnix += lc.winBackDelay * 86400;
617
+ if (currentUnix + timeShift >= endUnix) break;
618
+ subEvents.push(makeSubEvent(eventNames.wonBack, currentUnix + timeShift, user, currentPlan.name, 'active'));
619
+ currentStatus = 'active';
620
+ }
621
+ break;
622
+ }
623
+
624
+ // Payment failure
625
+ if (currentPlan.price > 0 && chance.bool({ likelihood: lc.paymentFailureRate * 100 })) {
626
+ subEvents.push(makeSubEvent(eventNames.paymentFailed, currentUnix + timeShift, user, currentPlan.name, 'payment_issue'));
627
+ }
628
+
629
+ // Churn
630
+ if (chance.bool({ likelihood: lc.churnRate * personaChurnMod * 100 })) {
631
+ subEvents.push(makeSubEvent(eventNames.cancelled, currentUnix + timeShift, user, currentPlan.name, 'cancelled'));
632
+ currentStatus = 'cancelled';
633
+ continue;
634
+ }
635
+
636
+ // Upgrade
637
+ const currentPlanIndex = plans.indexOf(currentPlan);
638
+ if (currentPlanIndex < plans.length - 1 && chance.bool({ likelihood: lc.upgradeRate * personaUpgradeMod * 100 })) {
639
+ const newPlan = plans[currentPlanIndex + 1];
640
+ subEvents.push(makeSubEvent(eventNames.upgraded, currentUnix + timeShift, user, newPlan.name, 'active', currentPlan.name));
641
+ currentPlan = newPlan;
642
+ continue;
643
+ }
644
+
645
+ // Downgrade
646
+ if (currentPlanIndex > 0 && currentPlan.price > 0 && chance.bool({ likelihood: lc.downgradeRate * 100 })) {
647
+ const newPlan = plans[currentPlanIndex - 1];
648
+ subEvents.push(makeSubEvent(eventNames.downgraded, currentUnix + timeShift, user, newPlan.name, 'active', currentPlan.name));
649
+ currentPlan = newPlan;
650
+ continue;
651
+ }
652
+
653
+ // Renewal
654
+ if (currentPlan.price > 0) {
655
+ subEvents.push(makeSubEvent(eventNames.renewed, currentUnix + timeShift, user, currentPlan.name, 'active'));
656
+ }
657
+ }
658
+
659
+ return subEvents;
660
+ }
661
+
662
+ function makeSubEvent(eventName, unixTime, user, planName, status, previousPlan) {
663
+ const ev = {
664
+ event: eventName,
665
+ time: dayjs.unix(Math.min(unixTime, dayjs().unix())).toISOString(),
666
+ user_id: user.distinct_id,
667
+ insert_id: u.quickHash(`${eventName}-${unixTime}-${user.distinct_id}`),
668
+ subscription_plan: planName,
669
+ _currentPlan: planName,
670
+ _subStatus: status
671
+ };
672
+ if (previousPlan) ev.previous_plan = previousPlan;
673
+ return ev;
674
+ }
675
+
676
+ /**
677
+ * Feature 4: Generate bot users with repetitive patterns
678
+ */
679
+ async function generateBotUsers(context, dataQuality, storage) {
680
+ const { botUsers, botEventsPerUser } = dataQuality;
681
+ const chance = u.getChance();
682
+ const { config } = context;
683
+ const events = config.events || [{ event: 'page_view' }];
684
+ // Bots use only 1-2 event types repetitively
685
+ const botEventTypes = events.slice(0, Math.min(2, events.length));
686
+
687
+ for (let b = 0; b < botUsers; b++) {
688
+ const botId = `bot_${chance.guid().slice(0, 8)}`;
689
+ const botEvents = [];
690
+
691
+ // Bots generate events at machine-like intervals
692
+ let currentTime = context.FIXED_BEGIN + context.TIME_SHIFT_SECONDS;
693
+ const interval = Math.floor(((context.MAX_TIME - currentTime) / botEventsPerUser));
694
+
695
+ for (let e = 0; e < botEventsPerUser; e++) {
696
+ const chosenEvent = botEventTypes[e % botEventTypes.length];
697
+ currentTime += interval + chance.integer({ min: 0, max: 10 });
698
+ if (currentTime > context.MAX_TIME) break;
699
+ botEvents.push({
700
+ event: chosenEvent.event,
701
+ time: dayjs.unix(currentTime).toISOString(),
702
+ user_id: botId,
703
+ device_id: botId,
704
+ insert_id: u.quickHash(`${chosenEvent.event}-${currentTime}-${botId}`),
705
+ is_bot: true
706
+ });
707
+ }
708
+
709
+ // Store bot profile
710
+ await storage.userProfilesData.hookPush({
711
+ distinct_id: botId,
712
+ name: `Bot ${b + 1}`,
713
+ is_bot: true
714
+ });
715
+
716
+ // Store bot events
717
+ await storage.eventData.hookPush(botEvents, { profile: { distinct_id: botId, is_bot: true } });
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Feature 9: Generate anomaly burst and coordinated events
723
+ */
724
+ async function generateAnomalyBursts(context, anomalies, storage) {
725
+ const chance = u.getChance();
726
+ const timeShift = context.TIME_SHIFT_SECONDS;
727
+
728
+ for (const a of anomalies) {
729
+ if (a.type !== 'burst' && a.type !== 'coordinated') continue;
730
+ if (!a._startUnix || !a.count) continue;
731
+
732
+ const burstEvents = [];
733
+ const startUnix = a._startUnix + timeShift;
734
+ const endUnix = a._endUnix + timeShift;
735
+ const windowSeconds = endUnix - startUnix;
736
+
737
+ for (let i = 0; i < a.count; i++) {
738
+ const eventTime = startUnix + chance.integer({ min: 0, max: Math.max(1, windowSeconds) });
739
+ if (eventTime > context.MAX_TIME) continue;
740
+ const userId = a.type === 'coordinated'
741
+ ? `anomaly_${chance.guid().slice(0, 8)}`
742
+ : `burst_${chance.integer({ min: 1, max: 100 })}`;
743
+
744
+ const ev = {
745
+ event: a.event,
746
+ time: dayjs.unix(eventTime).toISOString(),
747
+ user_id: userId,
748
+ insert_id: u.quickHash(`${a.event}-${eventTime}-${userId}-${i}`),
749
+ };
750
+ if (a.tag) ev._anomaly = a.tag;
751
+ if (a.properties) Object.assign(ev, a.properties);
752
+ burstEvents.push(ev);
753
+ }
754
+
755
+ await storage.eventData.hookPush(burstEvents, {});
756
+ }
757
+ }
@@ -62,8 +62,8 @@ export const SOUP_PRESETS = {
62
62
  * Clear peaks and valleys, dramatic variation. Fewer Gaussian clusters + tight deviation.
63
63
  */
64
64
  spiky: {
65
- peaks: (numDays) => Math.max(5, Math.ceil(numDays / 10)),
66
- deviation: 3.5,
65
+ peaks: (numDays) => Math.max(4, Math.ceil(numDays / 15)),
66
+ deviation: 5,
67
67
  mean: 0,
68
68
  dayOfWeekWeights: REAL_DOW,
69
69
  hourOfDayWeights: REAL_HOD,
@@ -44,11 +44,10 @@ RNG
44
44
  * @returns {Chance}
45
45
  */
46
46
  function initChance(seed) {
47
- if (process.env.SEED) seed = process.env.SEED; // Override seed with environment variable if available
48
- if (!chanceInitialized) {
49
- globalChance = new Chance(seed);
50
- chanceInitialized = true;
51
- }
47
+ // Use env SEED only as fallback when no seed is explicitly passed
48
+ if (!seed && process.env.SEED) seed = process.env.SEED;
49
+ globalChance = new Chance(seed);
50
+ chanceInitialized = true;
52
51
  return globalChance;
53
52
  }
54
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -19,6 +19,7 @@
19
19
  "lib/",
20
20
  "dungeons/",
21
21
  "!dungeons/customers/",
22
+ "scripts/",
22
23
  "package.json",
23
24
  "README.md"
24
25
  ],
@@ -32,10 +33,10 @@
32
33
  "typecheck": "tsc --noEmit",
33
34
  "dev": "nodemon scratch.mjs --ignore ./data/*",
34
35
  "prune": "rm -f ./data/* && rm -f ./tmp/* && rm -f vscode-profile-*",
35
- "deps": "./scripts/update-deps.sh",
36
36
  "dungeon:run": "node ./scripts/run-dungeon.mjs",
37
37
  "dungeon:to-json": "node ./scripts/dungeon-to-json.mjs",
38
- "dungeon:from-json": "node ./scripts/json-to-dungeon.mjs"
38
+ "dungeon:from-json": "node ./scripts/json-to-dungeon.mjs",
39
+ "dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs"
39
40
  },
40
41
  "repository": {
41
42
  "type": "git",