@ak--47/dungeon-master 1.1.0 → 1.2.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 (72) hide show
  1. package/dungeons/technical/ad-spend-schema.json +2 -2
  2. package/dungeons/technical/ad-spend.js +16 -5
  3. package/dungeons/technical/anonymous-users-schema.json +4 -3
  4. package/dungeons/technical/anonymous-users.js +16 -6
  5. package/dungeons/technical/array-of-object-lookup-schema.json +1 -1
  6. package/dungeons/technical/array-of-object-lookup.js +22 -6
  7. package/dungeons/technical/experiments-schema.json +25 -19
  8. package/dungeons/technical/experiments.js +24 -6
  9. package/dungeons/technical/foobar.js +29 -13
  10. package/dungeons/technical/group-analytics-schema.json +92 -0
  11. package/dungeons/technical/group-analytics.js +16 -6
  12. package/dungeons/technical/mirror-strategies-schema.json +4 -4
  13. package/dungeons/technical/mirror-strategies.js +16 -6
  14. package/dungeons/technical/nested-objects.js +16 -5
  15. package/dungeons/technical/retention-cadence.js +14 -9
  16. package/dungeons/technical/sanity-schema.json +22 -22
  17. package/dungeons/technical/sanity.js +26 -6
  18. package/dungeons/technical/scale-test.js +16 -5
  19. package/dungeons/technical/scd-schema.json +230 -33
  20. package/dungeons/technical/scd.js +25 -5
  21. package/dungeons/technical/simple-schema.json +132 -48
  22. package/dungeons/technical/simple.js +21 -6
  23. package/dungeons/technical/simplest-schema.json +168 -79
  24. package/dungeons/technical/simplest.js +24 -7
  25. package/dungeons/technical/text-generation-schema.json +7 -4
  26. package/dungeons/technical/text-generation.js +23 -8
  27. package/dungeons/user/.gitkeep +0 -0
  28. package/dungeons/user/shalini.js +847 -0
  29. package/dungeons/vertical/community-schema.json +71 -49
  30. package/dungeons/vertical/community.js +57 -30
  31. package/dungeons/vertical/devtools-schema.json +69 -48
  32. package/dungeons/vertical/devtools.js +58 -31
  33. package/dungeons/vertical/ecommerce-schema.json +126 -108
  34. package/dungeons/vertical/ecommerce.js +32 -8
  35. package/dungeons/vertical/education-schema.json +5261 -272
  36. package/dungeons/vertical/education.js +45 -22
  37. package/dungeons/vertical/fintech-schema.json +165 -98
  38. package/dungeons/vertical/fintech.js +43 -11
  39. package/dungeons/vertical/fitness-schema.json +69 -47
  40. package/dungeons/vertical/fitness.js +56 -24
  41. package/dungeons/vertical/food-delivery-schema.json +36237 -187
  42. package/dungeons/vertical/food-delivery.js +67 -15
  43. package/dungeons/vertical/gaming-schema.json +104 -60
  44. package/dungeons/vertical/gaming.js +61 -16
  45. package/dungeons/vertical/healthcare-schema.json +70 -48
  46. package/dungeons/vertical/healthcare.js +77 -42
  47. package/dungeons/vertical/insurance-application-schema.json +66 -47
  48. package/dungeons/vertical/insurance-application.js +34 -10
  49. package/dungeons/vertical/logistics-schema.json +60 -38
  50. package/dungeons/vertical/logistics.js +77 -43
  51. package/dungeons/vertical/marketplace-schema.json +65 -43
  52. package/dungeons/vertical/marketplace.js +52 -21
  53. package/dungeons/vertical/media-schema.json +4085 -123
  54. package/dungeons/vertical/media.js +65 -18
  55. package/dungeons/vertical/rpg-schema.json +1984 -147
  56. package/dungeons/vertical/rpg.js +47 -11
  57. package/dungeons/vertical/sass-schema.json +2622 -147
  58. package/dungeons/vertical/sass.js +40 -10
  59. package/dungeons/vertical/social-schema.json +146 -78
  60. package/dungeons/vertical/social.js +39 -10
  61. package/dungeons/vertical/travel-schema.json +57 -35
  62. package/dungeons/vertical/travel.js +58 -21
  63. package/index.js +4 -1
  64. package/lib/core/config-validator.js +13 -2
  65. package/lib/core/context.js +9 -0
  66. package/lib/core/storage.js +2 -0
  67. package/lib/generators/events.js +3 -5
  68. package/lib/orchestrators/user-loop.js +24 -1
  69. package/lib/utils/utils.js +129 -9
  70. package/package.json +1 -1
  71. package/scripts/extract-dungeon-schema.mjs +64 -18
  72. package/types.d.ts +8 -3
@@ -1,16 +1,23 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "dm4-travel";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "dm4-travel";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
  const NOW = dayjs();
13
- const DATASET_START = NOW.subtract(days, "days");
20
+ const DATASET_START = NOW.subtract(num_days, "days");
14
21
 
15
22
  /** @typedef {import("../../types").Dungeon} Config */
16
23
 
@@ -164,10 +171,10 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
164
171
  * with consistent, high-frequency booking patterns.
165
172
  *
166
173
  * ───────────────────────────────────────────────────────────────
167
- * 8. REPEAT DESTINATION CLUSTERING (funnel-pre hook)
174
+ * 8. REPEAT DESTINATION CLUSTERING (everything hook — event filtering)
168
175
  * ───────────────────────────────────────────────────────────────
169
- * PATTERN: Business travelers convert 1.3x better through the
170
- * search-to-book funnel (repeat bookings are habitual).
176
+ * PATTERN: Non-business/luxury users have ~25% of "booking completed"
177
+ * events dropped, simulating lower funnel conversion for casual segments.
171
178
  *
172
179
  * HOW TO FIND IT IN MIXPANEL:
173
180
  * Report 1: Conversion by Segment
@@ -197,10 +204,10 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
197
204
 
198
205
  /** @type {Config} */
199
206
  const config = {
200
- token: "",
207
+ token,
201
208
  seed: SEED,
202
- numDays: days,
203
- numEvents: num_users * 120,
209
+ numDays: num_days,
210
+ numEvents: num_users * avg_events_per_user,
204
211
  numUsers: num_users,
205
212
  hasAnonIds: false,
206
213
  hasSessionIds: true,
@@ -217,10 +224,16 @@ const config = {
217
224
  hasAdSpend: false,
218
225
  percentUsersBornInDataset: 35,
219
226
  hasAvatar: true,
220
- batchSize: 2_500_000,
221
227
  concurrency: 1,
222
228
  writeToDisk: false,
223
- scdProps: {},
229
+ scdProps: {
230
+ membership_tier: {
231
+ values: ["member", "silver", "gold", "platinum"],
232
+ frequency: "month",
233
+ timing: "fuzzy",
234
+ max: 8
235
+ }
236
+ },
224
237
  mirrorProps: {},
225
238
  lookupTables: [],
226
239
 
@@ -439,7 +452,7 @@ const config = {
439
452
 
440
453
  // ── SuperProps ──────────────────────────────────────────
441
454
  superProps: {
442
- platform: ["ios", "android", "web", "web"],
455
+ Platform: ["ios", "android", "web", "web"],
443
456
  membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
444
457
  },
445
458
 
@@ -450,6 +463,8 @@ const config = {
450
463
  company_name: ["none"],
451
464
  preferred_destination: chance.pickone.bind(chance, destinationCities),
452
465
  avg_budget_per_night: u.weighNumRange(50, 400, 0.4, 150),
466
+ Platform: ["ios", "android", "web", "web"],
467
+ membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
453
468
  },
454
469
 
455
470
  // ── Personas ──────────────────────────────────
@@ -626,14 +641,10 @@ const config = {
626
641
  }
627
642
 
628
643
  // ── HOOK 8: REPEAT DESTINATION CLUSTERING (funnel-pre) ─
644
+ // conversionRate modifications moved to everything hook (event filtering)
645
+ // to avoid dilution by organic events
629
646
  if (type === "funnel-pre") {
630
- if (meta && meta.profile) {
631
- if (meta.profile.customer_segment === "business_traveler") {
632
- record.conversionRate = Math.min(record.conversionRate * 1.3, 90);
633
- } else if (meta.profile.customer_segment === "luxury_seeker") {
634
- record.conversionRate = Math.min(record.conversionRate * 1.15, 85);
635
- }
636
- }
647
+ // prop-setting only; no conversionRate changes
637
648
  }
638
649
 
639
650
  // ── HOOK 1: WEEKEND LEISURE SURGE (event) ────────────
@@ -666,6 +677,33 @@ const config = {
666
677
  const events = record;
667
678
  if (!events.length) return record;
668
679
 
680
+ const profile = meta.profile;
681
+
682
+ // ─── Stamp superProps from profile (consistent per user) ───
683
+ const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
684
+ const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
685
+ if (stampPlatform || stampTier) {
686
+ events.forEach(e => {
687
+ if (stampPlatform) e.Platform = stampPlatform;
688
+ if (stampTier) e.membership_tier = stampTier;
689
+ });
690
+ }
691
+
692
+ // ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
693
+ // Drop ~25% of "booking completed" events for users who are NOT
694
+ // business_traveler or luxury_seeker to simulate their lower funnel
695
+ // conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
696
+ const segment = profile && profile.customer_segment;
697
+ if (segment !== "business_traveler" && segment !== "luxury_seeker") {
698
+ for (let i = events.length - 1; i >= 0; i--) {
699
+ if (events[i].event === "booking completed") {
700
+ if (chance.bool({ likelihood: 25 })) {
701
+ events.splice(i, 1);
702
+ }
703
+ }
704
+ }
705
+ }
706
+
669
707
  // ── HOOK 3: LOYALTY TIER UPGRADE PATH ────────────
670
708
  let bookingCount = 0;
671
709
  events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
@@ -688,7 +726,6 @@ const config = {
688
726
  }
689
727
 
690
728
  // ── HOOK 5: UPSELL SUCCESS BY SEGMENT ────────────
691
- const profile = meta.profile;
692
729
  if (profile && profile.customer_segment === "luxury_seeker") {
693
730
  const templateUpgrade = events.find(e => e.event === "room upgrade selected");
694
731
  if (templateUpgrade) {
package/index.js CHANGED
@@ -109,6 +109,9 @@ async function DUNGEON_MASTER(input, overrides = {}) {
109
109
  const textConfig = await loadFromText(value);
110
110
  return await runDungeon({ ...textConfig, ...overrides });
111
111
  }
112
+
113
+ default:
114
+ throw new Error(`Unexpected input type: ${type}`);
112
115
  }
113
116
  }
114
117
 
@@ -216,7 +219,7 @@ async function runDungeon(config) {
216
219
  files: await extractFileInfo(storage, validatedConfig),
217
220
  time: { start, end, delta, human },
218
221
  operations: context.getOperations(),
219
- eventCount: context.getEventCount(),
222
+ eventCount: context.getStoredEventCount(),
220
223
  userCount: context.getUserCount()
221
224
  };
222
225
 
@@ -24,7 +24,6 @@ const FIXED_NOW = dayjs('2024-02-02').unix();
24
24
  function inferFunnels(events) {
25
25
  const createdFunnels = [];
26
26
  const firstEvents = events.filter((e) => e.isFirstEvent).map((e) => e.event);
27
- const strictEvents = events.filter((e) => e.isStrictEvent).map((e) => e.event);
28
27
  const usageEvents = events
29
28
  .filter((e) => !e.isFirstEvent && !e.isStrictEvent)
30
29
  .map((e) => e.event);
@@ -106,6 +105,7 @@ export function validateDungeonConfig(config) {
106
105
  lookupTables = [],
107
106
  hasAnonIds = false,
108
107
  hasSessionIds = false,
108
+ sessionTimeout = 30,
109
109
  format = "csv",
110
110
  token = null,
111
111
  region = "US",
@@ -140,6 +140,14 @@ export function validateDungeonConfig(config) {
140
140
  concurrency = 1;
141
141
  }
142
142
 
143
+ // Throw if token is the placeholder and nothing will be written to disk
144
+ if (token === "your-mixpanel-token" && !writeToDisk) {
145
+ throw new Error(
146
+ "No Mixpanel token set and writeToDisk is false — nothing useful will happen.\n" +
147
+ "Either set process.env.MP_TOKEN, change the token in the dungeon file, or set writeToDisk to true."
148
+ );
149
+ }
150
+
143
151
  // Auto-enable batch mode for large datasets to prevent OOM
144
152
  if (numEvents >= 2_000_000 && config.batchSize === undefined) {
145
153
  batchSize = 1_000_000;
@@ -148,7 +156,9 @@ export function validateDungeonConfig(config) {
148
156
 
149
157
  // Ensure defaults for deep objects
150
158
  if (!config.superProps) config.superProps = superProps;
151
- if (!config.userProps || Object.keys(config?.userProps || {})) config.userProps = userProps;
159
+ if (!config.userProps || Object.keys(config.userProps).length === 0) {
160
+ userProps = { spiritAnimal: chance.animal.bind(chance) };
161
+ }
152
162
 
153
163
  // Setting up "TIME"
154
164
  if (epochStart && !numDays) numDays = dayjs.unix(epochEnd).diff(dayjs.unix(epochStart), "day");
@@ -356,6 +366,7 @@ export function validateDungeonConfig(config) {
356
366
  lookupTables,
357
367
  hasAnonIds,
358
368
  hasSessionIds,
369
+ sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
359
370
  format,
360
371
  token,
361
372
  region,
@@ -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
@@ -143,6 +144,14 @@ export function createContext(config, storage = null, timeConstants = {}) {
143
144
  runtime.eventCount++;
144
145
  },
145
146
 
147
+ incrementStoredEvents(count = 1) {
148
+ runtime.storedEventCount += count;
149
+ },
150
+
151
+ getStoredEventCount() {
152
+ return runtime.storedEventCount;
153
+ },
154
+
146
155
  isBatchMode() {
147
156
  return runtime.isBatchMode;
148
157
  },
@@ -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
@@ -116,14 +116,12 @@ export async function makeEvent(
116
116
  eventTemplate.time = dayjs.unix(Math.min(shiftedTimestamp, context.MAX_TIME)).toISOString();
117
117
  }
118
118
 
119
- // Add anonymous and session identifiers
119
+ // Add anonymous identifiers
120
120
  if (anonymousIds.length) {
121
121
  eventTemplate.device_id = u.pickRandom(anonymousIds);
122
122
  }
123
-
124
- if (sessionIds.length) {
125
- eventTemplate.session_id = u.pickRandom(sessionIds);
126
- }
123
+
124
+ // Session IDs are assigned post-hoc in user-loop.js based on temporal gaps
127
125
 
128
126
  // Sometimes add user_id (for attribution modeling)
129
127
  if (!isFirstEvent && chance.bool({ likelihood: 42 })) {
@@ -359,6 +359,8 @@ export async function userLoop(context) {
359
359
 
360
360
  // ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
361
361
  let userChurned = false;
362
+ const sessionTimeout = config.sessionTimeout || 30;
363
+
362
364
  while (numEventsPreformed < numEventsThisUserWillPreform && !cancelled) {
363
365
  let newEvents;
364
366
  if (usageFunnels.length && userConverted) {
@@ -397,7 +399,10 @@ export async function userLoop(context) {
397
399
  // Subscription events (trial, upgrade, cancel) must not be randomly dropped by decay
398
400
  const userDecay = persona?.engagementDecay || globalEngagementDecay;
399
401
  if (userDecay && userDecay.model !== 'none' && usersEvents.length > 0) {
400
- usersEvents = applyEngagementDecay(usersEvents, userDecay, adjustedCreated, context, chance);
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);
401
406
  }
402
407
 
403
408
  // Feature 5: Subscription lifecycle — inject after decay (exempt from decay filtering)
@@ -445,6 +450,24 @@ export async function userLoop(context) {
445
450
  }
446
451
  }
447
452
 
453
+ // Session clustering: redistribute events into temporal bursts, then assign session IDs
454
+ if (hasSessionIds && usersEvents.length > 0) {
455
+ const soupCfg = /** @type {import('../../types').SoupConfig} */ (config.soup) || {};
456
+ const defaultPeaks = Math.max(5, (config.numDays || 30) * 2);
457
+ const { mean: soupMean = 0, deviation: soupDev = 2, peaks: soupPeaks = defaultPeaks,
458
+ dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD } = soupCfg;
459
+
460
+ u.bunchIntoSessions(usersEvents, sessionTimeout, {
461
+ earliestTime: userFirstEventTime,
462
+ latestTime: context.FIXED_NOW,
463
+ peaks: soupPeaks, deviation: soupDev, mean: soupMean,
464
+ dayOfWeekWeights: soupDOW, hourOfDayWeights: soupHOD,
465
+ timeShiftSeconds: context.TIME_SHIFT_SECONDS,
466
+ maxTime: context.MAX_TIME
467
+ });
468
+ u.assignSessionIds(usersEvents, sessionTimeout);
469
+ }
470
+
448
471
  // Hook for processing all user events (hooks override everything)
449
472
  if (config.hook) {
450
473
  const newEvents = await config.hook(usersEvents, "everything", {
@@ -1314,15 +1314,7 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
1314
1314
 
1315
1315
  if (!hasAnonIds) delete user.anonymousIds;
1316
1316
 
1317
- //session Ids
1318
- if (hasSessionIds) {
1319
- const sessionSize = integer(5, 30);
1320
- for (let i = 0; i < sessionSize; i++) {
1321
- const sessionId = [uid(5), uid(5), uid(5), uid(5)].join("-");
1322
- user.sessionIds.push(sessionId);
1323
- }
1324
- }
1325
-
1317
+ // Session IDs are now assigned post-hoc in user-loop.js based on temporal gaps
1326
1318
  if (!hasSessionIds) delete user.sessionIds;
1327
1319
 
1328
1320
  return user;
@@ -1436,6 +1428,131 @@ function deepClone(thing, opts) {
1436
1428
  };
1437
1429
 
1438
1430
 
1431
+ /**
1432
+ * Generates a session ID in the standard format
1433
+ * @returns {string} Session ID like "xxxxx-xxxxx-xxxxx-xxxxx"
1434
+ */
1435
+ function generateSessionId() {
1436
+ return [uid(5), uid(5), uid(5), uid(5)].join("-");
1437
+ }
1438
+
1439
+ /**
1440
+ * Redistributes events into temporal clusters (sessions).
1441
+ *
1442
+ * Algorithm:
1443
+ * 1. Sort events by time
1444
+ * 2. Determine number of sessions (total events / avg events per session)
1445
+ * 3. Generate session anchor times using TimeSoup
1446
+ * 4. Assign events round-robin to sessions
1447
+ * 5. Within each session, retime events with tight spacing (5-300s apart)
1448
+ * 6. Regenerate insert_ids for retimed events
1449
+ * 7. Re-sort by time
1450
+ *
1451
+ * Mutates events in place. Does NOT assign session_id (call assignSessionIds after).
1452
+ *
1453
+ * @param {Object[]} events - Array of event objects with .time (ISO string)
1454
+ * @param {number} timeoutMinutes - Session timeout in minutes (used to determine intra-session spacing)
1455
+ * @param {Object} soupParams - Parameters for TimeSoup anchor generation
1456
+ */
1457
+ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
1458
+ if (events.length < 2) return;
1459
+
1460
+ const chance = getChance();
1461
+ const { earliestTime, latestTime, peaks, deviation, mean,
1462
+ dayOfWeekWeights, hourOfDayWeights, timeShiftSeconds, maxTime } = soupParams;
1463
+
1464
+ // Sort by time first
1465
+ events.sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
1466
+
1467
+ // Determine number of sessions: target 3-8 events per session
1468
+ const eventsPerSession = chance.integer({ min: 3, max: 8 });
1469
+ const numSessions = Math.max(1, Math.ceil(events.length / eventsPerSession));
1470
+
1471
+ // Generate session anchor times using TimeSoup
1472
+ const anchors = [];
1473
+ for (let i = 0; i < numSessions; i++) {
1474
+ const soupTime = TimeSoup(earliestTime, latestTime, peaks, deviation, mean,
1475
+ dayOfWeekWeights, hourOfDayWeights, timeShiftSeconds);
1476
+ anchors.push(soupTime + timeShiftSeconds); // shifted to present time
1477
+ }
1478
+ anchors.sort((a, b) => a - b);
1479
+
1480
+ // Distribute events across sessions round-robin (preserving original order → temporal order)
1481
+ const sessionBuckets = anchors.map(() => []);
1482
+ for (let i = 0; i < events.length; i++) {
1483
+ const bucketIndex = Math.min(i % numSessions, numSessions - 1);
1484
+ sessionBuckets[bucketIndex].push(events[i]);
1485
+ }
1486
+
1487
+ // Retime events within each session
1488
+ let writeIndex = 0;
1489
+ for (let s = 0; s < numSessions; s++) {
1490
+ const bucket = sessionBuckets[s];
1491
+ if (bucket.length === 0) continue;
1492
+
1493
+ let currentTime = anchors[s];
1494
+ for (let e = 0; e < bucket.length; e++) {
1495
+ const ev = bucket[e];
1496
+ const clampedTime = Math.min(currentTime, maxTime);
1497
+
1498
+ ev.time = dayjs.unix(clampedTime).toISOString();
1499
+ // Regenerate insert_id to match new time
1500
+ const distinctId = ev.user_id || ev.device_id || ev.distinct_id || '';
1501
+ ev.insert_id = quickHash(`${ev.event}-${ev.time}-${distinctId}`);
1502
+
1503
+ if (currentTime > maxTime) {
1504
+ ev._drop = true;
1505
+ }
1506
+
1507
+ // Advance time within session: 5-300 seconds (5s to 5min)
1508
+ currentTime += chance.integer({ min: 5, max: 300 });
1509
+ }
1510
+ }
1511
+
1512
+ // Re-sort by time
1513
+ events.sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
1514
+ }
1515
+
1516
+ /**
1517
+ * Assigns session IDs to a chronologically sorted array of events.
1518
+ * A new session starts when:
1519
+ * - Gap between consecutive events exceeds timeoutMinutes
1520
+ * - Session duration exceeds 24 hours
1521
+ *
1522
+ * Events MUST be sorted by time before calling this function.
1523
+ * Mutates events in place (adds session_id property).
1524
+ *
1525
+ * @param {Object[]} events - Sorted array of event objects with .time (ISO string)
1526
+ * @param {number} timeoutMinutes - Session timeout in minutes (default 30)
1527
+ * @returns {Object[]} Same array, with session_id added to each event
1528
+ */
1529
+ function assignSessionIds(events, timeoutMinutes = 30) {
1530
+ if (!events.length) return events;
1531
+
1532
+ const timeoutMs = timeoutMinutes * 60 * 1000;
1533
+ const maxSessionMs = 24 * 60 * 60 * 1000;
1534
+
1535
+ let currentSessionId = generateSessionId();
1536
+ let sessionStartMs = new Date(events[0].time).getTime();
1537
+ let lastEventMs = sessionStartMs;
1538
+
1539
+ for (const event of events) {
1540
+ const eventMs = new Date(event.time).getTime();
1541
+ const gapFromLast = eventMs - lastEventMs;
1542
+ const sessionDuration = eventMs - sessionStartMs;
1543
+
1544
+ if (gapFromLast > timeoutMs || sessionDuration > maxSessionMs) {
1545
+ currentSessionId = generateSessionId();
1546
+ sessionStartMs = eventMs;
1547
+ }
1548
+
1549
+ event.session_id = currentSessionId;
1550
+ lastEventMs = eventMs;
1551
+ }
1552
+
1553
+ return events;
1554
+ }
1555
+
1439
1556
  export {
1440
1557
  pick,
1441
1558
  date,
@@ -1486,4 +1603,7 @@ export {
1486
1603
  wrapFunc,
1487
1604
  bytesHuman,
1488
1605
  formatDuration,
1606
+ generateSessionId,
1607
+ assignSessionIds,
1608
+ bunchIntoSessions,
1489
1609
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Extracts an abbreviated schema from a JavaScript dungeon file.
4
+ * Extracts a complete schema from a JavaScript dungeon file.
5
5
  *
6
- * Output contains only the event/property relationships, superProps, userProps,
7
- * and funnels no hooks, no config flags, no functions.
6
+ * Output contains everything about the dungeon EXCEPT:
7
+ * - hook function (runtime logic, not schema)
8
+ * - credentials (token, serviceAccount, serviceSecret, projectId)
9
+ * - execution params (seed, format, gzip, verbose, concurrency, batchSize, writeToDisk, region)
8
10
  *
9
11
  * Property values are simplified:
10
12
  * - Plain arrays → deduplicated
11
13
  * - weighNumRange(min,max) arrays → { "$range": [min, max] }
12
14
  * - Functions (pickAWinner, weighChoices, etc.) → sampled and deduplicated
13
- * - High-cardinality / complex values → omitted
15
+ * - High-cardinality / complex values → type hint
14
16
  *
15
17
  * Usage:
16
18
  * node scripts/extract-dungeon-schema.mjs <input.js> [output.json]
@@ -61,12 +63,16 @@ async function processOne(inputFile, outputFile) {
61
63
 
62
64
  const schema = {};
63
65
 
64
- // Events
66
+ // ── Events ──────────────────────────────────────────────────────
65
67
  if (config.events?.length) {
66
68
  schema.events = config.events.map(evt => {
67
69
  const entry = { event: evt.event };
68
70
  if (evt.weight && evt.weight !== 1) entry.weight = evt.weight;
69
71
  if (evt.isFirstEvent) entry.isFirstEvent = true;
72
+ if (evt.isChurnEvent) entry.isChurnEvent = true;
73
+ if (evt.returnLikelihood !== undefined) entry.returnLikelihood = evt.returnLikelihood;
74
+ if (evt.isStrictEvent) entry.isStrictEvent = true;
75
+ if (evt.isSessionStartEvent) entry.isSessionStartEvent = true;
70
76
  if (evt.properties && Object.keys(evt.properties).length) {
71
77
  const props = extractProps(evt.properties);
72
78
  if (Object.keys(props).length) entry.properties = props;
@@ -75,22 +81,11 @@ async function processOne(inputFile, outputFile) {
75
81
  });
76
82
  }
77
83
 
78
- // Super props
79
- if (config.superProps && Object.keys(config.superProps).length) {
80
- const sp = extractProps(config.superProps);
81
- if (Object.keys(sp).length) schema.superProps = sp;
82
- }
83
-
84
- // User props
85
- if (config.userProps && Object.keys(config.userProps).length) {
86
- const up = extractProps(config.userProps);
87
- if (Object.keys(up).length) schema.userProps = up;
88
- }
89
-
90
- // Funnels
84
+ // ── Funnels ─────────────────────────────────────────────────────
91
85
  if (config.funnels?.length) {
92
86
  schema.funnels = config.funnels.map(f => {
93
87
  const entry = { sequence: f.sequence };
88
+ if (f.name) entry.name = f.name;
94
89
  if (f.isFirstFunnel) entry.isFirstFunnel = true;
95
90
  if (f.conversionRate !== undefined) entry.conversionRate = f.conversionRate;
96
91
  if (f.timeToConvert !== undefined) entry.timeToConvert = f.timeToConvert;
@@ -98,6 +93,8 @@ async function processOne(inputFile, outputFile) {
98
93
  if (f.weight && f.weight !== 1) entry.weight = f.weight;
99
94
  if (f.requireRepeats) entry.requireRepeats = true;
100
95
  if (f.experiment) entry.experiment = true;
96
+ if (f.bindPropsIndex !== undefined) entry.bindPropsIndex = f.bindPropsIndex;
97
+ if (f.conditions) entry.conditions = f.conditions;
101
98
  if (f.props && Object.keys(f.props).length) {
102
99
  const fp = extractProps(f.props);
103
100
  if (Object.keys(fp).length) entry.props = fp;
@@ -106,6 +103,55 @@ async function processOne(inputFile, outputFile) {
106
103
  });
107
104
  }
108
105
 
106
+ // ── Super props ─────────────────────────────────────────────────
107
+ if (config.superProps && Object.keys(config.superProps).length) {
108
+ const sp = extractProps(config.superProps);
109
+ if (Object.keys(sp).length) schema.superProps = sp;
110
+ }
111
+
112
+ // ── User props ──────────────────────────────────────────────────
113
+ if (config.userProps && Object.keys(config.userProps).length) {
114
+ const up = extractProps(config.userProps);
115
+ if (Object.keys(up).length) schema.userProps = up;
116
+ }
117
+
118
+ // ── Group keys ──────────────────────────────────────────────────
119
+ if (config.groupKeys?.length) {
120
+ schema.groupKeys = config.groupKeys.map(gk => {
121
+ if (Array.isArray(gk)) {
122
+ const entry = { key: gk[0], count: gk[1] };
123
+ if (gk[2]) entry.affectsEvents = gk[2];
124
+ return entry;
125
+ }
126
+ return gk;
127
+ });
128
+ }
129
+
130
+ // ── Group props ─────────────────────────────────────────────────
131
+ if (config.groupProps && Object.keys(config.groupProps).length) {
132
+ schema.groupProps = {};
133
+ for (const [groupKey, props] of Object.entries(config.groupProps)) {
134
+ schema.groupProps[groupKey] = extractProps(props);
135
+ }
136
+ }
137
+
138
+ // ── SCD props ───────────────────────────────────────────────────
139
+ if (config.scdProps && Object.keys(config.scdProps).length) {
140
+ schema.scdProps = {};
141
+ for (const [key, scd] of Object.entries(config.scdProps)) {
142
+ const entry = {};
143
+ entry.type = scd.type || "user";
144
+ if (scd.frequency) entry.frequency = scd.frequency;
145
+ if (scd.timing) entry.timing = scd.timing;
146
+ if (scd.max !== undefined) entry.max = scd.max;
147
+ if (scd.values) {
148
+ const simplified = simplifyValue(scd.values);
149
+ if (simplified) entry.values = simplified;
150
+ }
151
+ schema.scdProps[key] = entry;
152
+ }
153
+ }
154
+
109
155
  writeFileSync(outputPath, JSON.stringify(schema, null, 2) + '\n', 'utf-8');
110
156
  console.log(` ✅ ${baseName}-schema.json`);
111
157
  }
package/types.d.ts CHANGED
@@ -72,8 +72,10 @@ export interface Dungeon {
72
72
  verbose?: boolean;
73
73
  /** If true, users get anonymous device IDs in addition to distinct_id. */
74
74
  hasAnonIds?: boolean;
75
- /** If true, users get session IDs attached to events. */
75
+ /** If true, users get session IDs attached to events based on temporal clustering. */
76
76
  hasSessionIds?: boolean;
77
+ /** Session timeout in minutes. Events with gaps exceeding this start a new session. Default: 30. Only used when hasSessionIds is true. */
78
+ sessionTimeout?: number;
77
79
  /** If true, auto-generates funnels from the events array in addition to any explicit funnels. */
78
80
  alsoInferFunnels?: boolean;
79
81
  /** Restrict all location data to a single country (e.g., "US", "GB"). */
@@ -231,8 +233,8 @@ export interface hookArrayOptions<T> {
231
233
  * an enriched array is an array that has a hookPush method that can be used to transform-then-push items into the array
232
234
  */
233
235
  export interface HookedArray<T> extends Array<T> {
234
- hookPush: (item: T | T[], ...meta: any[]) => any;
235
- flush: () => void;
236
+ hookPush: (item: T | T[], ...meta: any[]) => Promise<any>;
237
+ flush: () => Promise<void>;
236
238
  getWriteDir: () => string;
237
239
  getWritePath: () => string;
238
240
  [key: string]: any;
@@ -266,6 +268,7 @@ export interface Storage {
266
268
  export interface RuntimeState {
267
269
  operations: number;
268
270
  eventCount: number;
271
+ storedEventCount: number;
269
272
  userCount: number;
270
273
  isBatchMode: boolean;
271
274
  verbose: boolean;
@@ -305,11 +308,13 @@ export interface Context {
305
308
  incrementOperations(): void;
306
309
  incrementEvents(): void;
307
310
  incrementUsers(): void;
311
+ incrementStoredEvents(count?: number): void;
308
312
  setStorage(storage: Storage): void;
309
313
 
310
314
  // State getter methods
311
315
  getOperations(): number;
312
316
  getEventCount(): number;
317
+ getStoredEventCount(): number;
313
318
  getUserCount(): number;
314
319
  incrementUserCount(): void;
315
320
  incrementEventCount(): void;