@ak--47/dungeon-master 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +518 -0
  2. package/dungeons/array-of-object-lookup-schema.json +327 -0
  3. package/dungeons/array-of-object-lookup.js +220 -0
  4. package/dungeons/ecommerce-schema.json +462 -0
  5. package/dungeons/ecommerce.js +447 -0
  6. package/dungeons/education-schema.json +2409 -0
  7. package/dungeons/education.js +768 -0
  8. package/dungeons/fintech-schema.json +14034 -0
  9. package/dungeons/fintech.js +696 -0
  10. package/dungeons/foobar-schema.json +403 -0
  11. package/dungeons/foobar.js +296 -0
  12. package/dungeons/food-delivery-schema.json +192 -0
  13. package/dungeons/food-delivery.js +602 -0
  14. package/dungeons/food-schema.json +1152 -0
  15. package/dungeons/food.js +754 -0
  16. package/dungeons/gaming-schema.json +1270 -0
  17. package/dungeons/gaming.js +508 -0
  18. package/dungeons/insurance-application-schema.json +204 -0
  19. package/dungeons/insurance-application.js +605 -0
  20. package/dungeons/media-schema.json +906 -0
  21. package/dungeons/media.js +790 -0
  22. package/dungeons/retention-cadence-schema.json +78 -0
  23. package/dungeons/retention-cadence.js +244 -0
  24. package/dungeons/rpg-schema.json +4526 -0
  25. package/dungeons/rpg.js +919 -0
  26. package/dungeons/sanity-schema.json +255 -0
  27. package/dungeons/sanity.js +152 -0
  28. package/dungeons/sass-schema.json +1291 -0
  29. package/dungeons/sass.js +795 -0
  30. package/dungeons/scd-schema.json +919 -0
  31. package/dungeons/scd.js +277 -0
  32. package/dungeons/simple-schema.json +608 -0
  33. package/dungeons/simple.js +285 -0
  34. package/dungeons/simplest-schema.json +1418 -0
  35. package/dungeons/simplest.js +392 -0
  36. package/dungeons/social-schema.json +1118 -0
  37. package/dungeons/social.js +686 -0
  38. package/dungeons/text-generation-schema.json +3096 -0
  39. package/dungeons/text-generation.js +812 -0
  40. package/index.js +567 -0
  41. package/lib/core/config-validator.js +395 -0
  42. package/lib/core/context.js +204 -0
  43. package/lib/core/dungeon-loader.js +337 -0
  44. package/lib/core/storage.js +379 -0
  45. package/lib/generators/adspend.js +132 -0
  46. package/lib/generators/events.js +271 -0
  47. package/lib/generators/funnels.js +407 -0
  48. package/lib/generators/mirror.js +167 -0
  49. package/lib/generators/product-lookup.js +262 -0
  50. package/lib/generators/product-names.js +195 -0
  51. package/lib/generators/profiles.js +93 -0
  52. package/lib/generators/scd.js +124 -0
  53. package/lib/generators/text.js +1192 -0
  54. package/lib/orchestrators/mixpanel-sender.js +266 -0
  55. package/lib/orchestrators/user-loop.js +335 -0
  56. package/lib/templates/abbreviated.d.ts +169 -0
  57. package/lib/templates/defaults.js +1405 -0
  58. package/lib/templates/phrases.js +2526 -0
  59. package/lib/templates/schema.d.ts +173 -0
  60. package/lib/templates/soup-presets.js +188 -0
  61. package/lib/utils/function-registry.js +302 -0
  62. package/lib/utils/json-evaluator.js +172 -0
  63. package/lib/utils/logger.js +34 -0
  64. package/lib/utils/utils.js +1490 -0
  65. package/package.json +89 -0
  66. package/types.d.ts +865 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Mixpanel Sender Orchestrator module
3
+ * Handles sending all data types to Mixpanel
4
+ */
5
+
6
+ /** @typedef {import('../../types').Context} Context */
7
+
8
+ import dayjs from "dayjs";
9
+ import { comma, ls, rm } from "ak-tools";
10
+ import * as u from "../utils/utils.js";
11
+ import mp from "mixpanel-import";
12
+
13
+ /**
14
+ * Sends the data to Mixpanel
15
+ * @param {Context} context - Context object containing config, storage, etc.
16
+ * @returns {Promise<Object>} Import results for all data types
17
+ */
18
+ export async function sendToMixpanel(context) {
19
+ const { config, storage } = context;
20
+ const {
21
+ adSpendData,
22
+ eventData,
23
+ groupProfilesData,
24
+ scdTableData,
25
+ userProfilesData,
26
+ groupEventData
27
+ } = storage;
28
+
29
+ const {
30
+ token,
31
+ region,
32
+ writeToDisk = true,
33
+ format,
34
+ serviceAccount,
35
+ projectId,
36
+ serviceSecret
37
+ } = config;
38
+
39
+ const importResults = { events: {}, users: {}, groups: [] };
40
+ const isBATCH_MODE = context.isBatchMode();
41
+ _verbose = config.verbose !== false;
42
+
43
+ /** @type {import('mixpanel-import').Creds} */
44
+ const creds = { token };
45
+ const mpImportFormat = format === "json" ? "jsonl" : "csv";
46
+
47
+ /** @type {import('mixpanel-import').Options} */
48
+ const commonOpts = {
49
+ region,
50
+ fixData: true,
51
+ verbose: false,
52
+ forceStream: true,
53
+ strict: true,
54
+ epochEnd: dayjs().unix(),
55
+ dryRun: false,
56
+ abridged: false,
57
+ fixJson: false,
58
+ showProgress: !!config.verbose,
59
+ streamFormat: mpImportFormat,
60
+ workers: 35
61
+ };
62
+
63
+ log(`\n${'─'.repeat(50)}`);
64
+ log(` Importing data to Mixpanel (${region})`);
65
+ log(`${'─'.repeat(50)}\n`);
66
+
67
+ // Import events
68
+ if (eventData?.length > 0 || isBATCH_MODE) {
69
+ log(` Events`);
70
+ let eventDataToImport = u.deepClone(eventData);
71
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && eventData && eventData.length === 0);
72
+ if (shouldReadFromFiles && eventData?.getWriteDir) {
73
+ const writeDir = eventData.getWriteDir();
74
+ const files = await ls(writeDir);
75
+ // @ts-ignore
76
+ eventDataToImport = files.filter(f => f.includes('-EVENTS'));
77
+ }
78
+ const imported = await mp(creds, eventDataToImport, {
79
+ recordType: "event",
80
+ ...commonOpts,
81
+ });
82
+ log(` -> ${comma(imported.success)} events sent\n`);
83
+ importResults.events = imported;
84
+ }
85
+
86
+ // Import user profiles
87
+ if (userProfilesData?.length > 0 || isBATCH_MODE) {
88
+ log(` User Profiles`);
89
+ let userProfilesToImport = u.deepClone(userProfilesData);
90
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && userProfilesData && userProfilesData.length === 0);
91
+ if (shouldReadFromFiles && userProfilesData?.getWriteDir) {
92
+ const writeDir = userProfilesData.getWriteDir();
93
+ const files = await ls(writeDir);
94
+ // @ts-ignore
95
+ userProfilesToImport = files.filter(f => f.includes('-USERS'));
96
+ }
97
+ const imported = await mp(creds, userProfilesToImport, {
98
+ recordType: "user",
99
+ ...commonOpts,
100
+ });
101
+ log(` -> ${comma(imported.success)} user profiles sent\n`);
102
+ importResults.users = imported;
103
+ }
104
+
105
+ // Import ad spend data
106
+ if (adSpendData?.length > 0 || isBATCH_MODE) {
107
+ log(` Ad Spend`);
108
+ let adSpendDataToImport = u.deepClone(adSpendData);
109
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && adSpendData && adSpendData.length === 0);
110
+ if (shouldReadFromFiles && adSpendData?.getWriteDir) {
111
+ const writeDir = adSpendData.getWriteDir();
112
+ const files = await ls(writeDir);
113
+ // @ts-ignore
114
+ adSpendDataToImport = files.filter(f => f.includes('-ADSPEND'));
115
+ }
116
+ const imported = await mp(creds, adSpendDataToImport, {
117
+ recordType: "event",
118
+ ...commonOpts,
119
+ });
120
+ log(` -> ${comma(imported.success)} ad spend events sent\n`);
121
+ importResults.adSpend = imported;
122
+ }
123
+
124
+ // Import group profiles
125
+ if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
126
+ for (const groupEntity of groupProfilesData) {
127
+ if (!groupEntity || groupEntity.length === 0) continue;
128
+ const groupKey = groupEntity?.groupKey;
129
+ log(` Group Profiles (${groupKey})`);
130
+ let groupProfilesToImport = u.deepClone(groupEntity);
131
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEntity.length === 0);
132
+ if (shouldReadFromFiles && groupEntity?.getWriteDir) {
133
+ const writeDir = groupEntity.getWriteDir();
134
+ const files = await ls(writeDir);
135
+ // @ts-ignore
136
+ groupProfilesToImport = files.filter(f => f.includes(`-${groupKey}-GROUPS`));
137
+ }
138
+ const imported = await mp({ token, groupKey }, groupProfilesToImport, {
139
+ recordType: "group",
140
+ ...commonOpts,
141
+ groupKey,
142
+ });
143
+ log(` -> ${comma(imported.success)} ${groupKey} profiles sent\n`);
144
+ importResults.groups.push(imported);
145
+ }
146
+ }
147
+
148
+ // Import group events
149
+ if (groupEventData?.length > 0) {
150
+ log(` Group Events`);
151
+ let groupEventDataToImport = u.deepClone(groupEventData);
152
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEventData.length === 0);
153
+ if (shouldReadFromFiles && groupEventData?.getWriteDir) {
154
+ const writeDir = groupEventData.getWriteDir();
155
+ const files = await ls(writeDir);
156
+ // @ts-ignore
157
+ groupEventDataToImport = files.filter(f => f.includes('-GROUP-EVENTS'));
158
+ }
159
+ const imported = await mp(creds, groupEventDataToImport, {
160
+ recordType: "event",
161
+ ...commonOpts,
162
+ strict: false
163
+ });
164
+ log(` -> ${comma(imported.success)} group events sent\n`);
165
+ importResults.groupEvents = imported;
166
+ }
167
+
168
+ // Import SCD data (requires service account)
169
+ if (serviceAccount && projectId && serviceSecret) {
170
+ if (scdTableData && Array.isArray(scdTableData) && scdTableData.length > 0) {
171
+ for (const scdEntity of scdTableData) {
172
+ const scdKey = scdEntity?.scdKey;
173
+ const entityType = scdEntity?.entityType || 'user';
174
+ log(` SCD: ${scdKey}`);
175
+ let scdDataToImport = u.deepClone(scdEntity);
176
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && scdEntity && scdEntity.length === 0);
177
+ if (shouldReadFromFiles && scdEntity?.getWriteDir) {
178
+ const writeDir = scdEntity.getWriteDir();
179
+ const files = await ls(writeDir);
180
+ // @ts-ignore
181
+ scdDataToImport = files.filter(f => f.includes(`-${scdKey}-SCD`))?.pop();
182
+ }
183
+
184
+ /** @type {"string" | "number" | "boolean"} */
185
+ let scdType = 'string';
186
+ const scdExamplesValues = context.config.scdProps[Object.keys(context.config.scdProps).find(k => k === scdKey)].values;
187
+ if (scdExamplesValues) {
188
+ if (typeof scdExamplesValues[0] === 'number') {
189
+ scdType = 'number';
190
+ } else if (typeof scdExamplesValues[0] === 'boolean') {
191
+ scdType = 'boolean';
192
+ }
193
+ }
194
+
195
+ /** @type {import('mixpanel-import').Options} */
196
+ const options = {
197
+ recordType: "scd",
198
+ scdKey,
199
+ scdType,
200
+ scdLabel: `${scdKey}`,
201
+ fixData: true,
202
+ ...commonOpts,
203
+ };
204
+
205
+ if (entityType !== "user") {
206
+ options.groupKey = entityType;
207
+ }
208
+
209
+ try {
210
+ const imported = await mp(
211
+ {
212
+ token,
213
+ acct: serviceAccount,
214
+ pass: serviceSecret,
215
+ project: projectId
216
+ },
217
+ scdDataToImport,
218
+ options
219
+ );
220
+ log(` -> ${comma(imported.success)} ${scdKey} SCD entries sent\n`);
221
+ importResults[`${scdKey}_scd`] = imported;
222
+ } catch (err) {
223
+ log(` !! failed: ${scdKey} SCD — ${err.message}\n`);
224
+ importResults[`${scdKey}_scd`] = { success: 0, failed: 0, error: err.message };
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ log(`${'─'.repeat(50)}\n`);
231
+
232
+ // Clean up batch files if needed
233
+ if (!writeToDisk && isBATCH_MODE) {
234
+ const writeDir = eventData?.getWriteDir?.() || userProfilesData?.getWriteDir?.();
235
+ if (writeDir) {
236
+ const configName = context.config.name;
237
+ const listDir = await ls(writeDir);
238
+ // @ts-ignore
239
+ const files = listDir.filter(f => {
240
+ if (configName && !f.includes(configName)) return false;
241
+ return f.includes('-EVENTS') ||
242
+ f.includes('-USERS') ||
243
+ f.includes('-ADSPEND') ||
244
+ f.includes('-GROUPS') ||
245
+ f.includes('-GROUP-EVENTS') ||
246
+ f.includes('-SCD') ||
247
+ f.includes('-MIRROR') ||
248
+ f.includes('-LOOKUP');
249
+ });
250
+ for (const file of files) {
251
+ await rm(file);
252
+ }
253
+ }
254
+ }
255
+
256
+ return importResults;
257
+ }
258
+
259
+ /**
260
+ * Logging function that respects verbose config
261
+ * @param {string} message - Message to log
262
+ */
263
+ let _verbose = true;
264
+ function log(message) {
265
+ if (_verbose) console.log(message);
266
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * User Loop Orchestrator module
3
+ * Manages user generation and event creation workflow
4
+ */
5
+
6
+ /** @typedef {import('../../types').Context} Context */
7
+
8
+ import dayjs from "dayjs";
9
+ import pLimit from 'p-limit';
10
+ import os from 'os';
11
+ import * as u from "../utils/utils.js";
12
+ import * as t from 'ak-tools';
13
+ import { makeEvent } from "../generators/events.js";
14
+ import { makeFunnel } from "../generators/funnels.js";
15
+ import { makeUserProfile } from "../generators/profiles.js";
16
+ import { makeSCD } from "../generators/scd.js";
17
+
18
+ /**
19
+ * Main user generation loop that creates users, their profiles, events, and SCDs
20
+ * @param {Context} context - Context object containing config, defaults, storage, etc.
21
+ * @returns {Promise<void>}
22
+ */
23
+ export async function userLoop(context) {
24
+ const { config, storage, defaults } = context;
25
+ const chance = u.getChance();
26
+ const concurrency = config?.concurrency ?? 1;
27
+ const USER_CONN = pLimit(concurrency);
28
+
29
+ const {
30
+ verbose,
31
+ numUsers,
32
+ numEvents,
33
+ isAnonymous,
34
+ hasAvatar,
35
+ hasAnonIds,
36
+ hasSessionIds,
37
+ hasLocation,
38
+ funnels,
39
+ userProps,
40
+ scdProps,
41
+ numDays,
42
+ percentUsersBornInDataset = 15,
43
+ strictEventCount = false,
44
+ bornRecentBias = 0.3, // 0 = uniform distribution, 1 = heavily biased toward recent births
45
+ } = config;
46
+
47
+ const { eventData, userProfilesData, scdTableData } = storage;
48
+ const avgEvPerUser = numEvents / numUsers;
49
+ const startTime = Date.now();
50
+
51
+ // Create batches for parallel processing
52
+ const batchSize = Math.max(1, Math.ceil(numUsers / concurrency));
53
+ const userPromises = [];
54
+
55
+ // Track if we've already logged the strict event count message
56
+ let hasLoggedStrictCountReached = false;
57
+
58
+ // Handle graceful shutdown on SIGINT (Ctrl+C)
59
+ let cancelled = false;
60
+ const onSigint = () => {
61
+ cancelled = true;
62
+ USER_CONN.clearQueue();
63
+ if (verbose) console.log(`\n\nStopping generation (Ctrl+C)...\n`);
64
+ };
65
+ process.on('SIGINT', onSigint);
66
+
67
+ for (let i = 0; i < numUsers; i++) {
68
+ const userPromise = USER_CONN(async () => {
69
+ // Bail out if cancelled
70
+ if (cancelled) return;
71
+
72
+ // Bail out early if strictEventCount is enabled and we've hit numEvents
73
+ if (strictEventCount && context.getEventCount() >= numEvents) {
74
+ if (verbose && !hasLoggedStrictCountReached) {
75
+ console.log(`\n\u2713 Reached target of ${numEvents.toLocaleString()} events with strict event count enabled. Stopping user generation.`);
76
+ hasLoggedStrictCountReached = true;
77
+ }
78
+ return;
79
+ }
80
+
81
+ context.incrementUserCount();
82
+ const eps = Math.floor(context.getEventCount() / ((Date.now() - startTime) / 1000));
83
+ const memUsed = u.bytesHuman(process.memoryUsage().heapUsed);
84
+ const duration = u.formatDuration(Date.now() - startTime);
85
+
86
+ if (verbose) {
87
+ u.progress([
88
+ ["users", context.getUserCount()],
89
+ ["events", context.getEventCount()],
90
+ ["eps", eps],
91
+ ["mem", memUsed],
92
+ ["time", duration]
93
+ ]);
94
+ }
95
+
96
+ const userId = chance.guid();
97
+ const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds });
98
+ const { distinct_id, created } = user;
99
+ const userIsBornInDataset = chance.bool({ likelihood: percentUsersBornInDataset });
100
+ let numEventsPreformed = 0;
101
+
102
+ if (!userIsBornInDataset) delete user.created;
103
+
104
+ // Calculate time adjustments
105
+ const daysShift = context.getDaysShift();
106
+
107
+ // Apply recency bias to birth dates for users born in dataset
108
+ // bornRecentBias: 0 = uniform distribution, 1 = heavily biased toward recent
109
+ let adjustedCreated;
110
+ if (userIsBornInDataset) {
111
+ let biasedCreated = dayjs(created).subtract(daysShift, 'd');
112
+
113
+ if (bornRecentBias !== 0) {
114
+ // Calculate how far into the dataset this user was born (0 = start, 1 = end/recent)
115
+ const datasetStart = dayjs.unix(global.FIXED_BEGIN);
116
+ const datasetEnd = dayjs.unix(context.FIXED_NOW);
117
+ const totalDuration = datasetEnd.diff(datasetStart);
118
+ // Clamp userPosition to [0, 1] to handle edge cases from rounding in time calculations
119
+ const userPosition = Math.max(0, Math.min(1, biasedCreated.diff(datasetStart) / totalDuration));
120
+
121
+ let biasedPosition;
122
+ if (bornRecentBias > 0) {
123
+ // Positive bias: exponent < 1 shifts distribution toward 1 (recent)
124
+ const exponent = 1 - (bornRecentBias * 0.7); // 0.3 bias -> 0.79 exponent (gentle nudge)
125
+ biasedPosition = Math.pow(userPosition, exponent);
126
+ } else {
127
+ // Negative bias: mirror the power function to shift toward 0 (early)
128
+ // -0.3 bias -> 0.79 exponent applied to (1 - position), then mirrored back
129
+ const exponent = 1 - (Math.abs(bornRecentBias) * 0.7);
130
+ biasedPosition = 1 - Math.pow(1 - userPosition, exponent);
131
+ }
132
+
133
+ // Convert back to timestamp
134
+ biasedCreated = datasetStart.add(biasedPosition * totalDuration, 'millisecond');
135
+ }
136
+
137
+ adjustedCreated = biasedCreated;
138
+ // Update user.created to match biased timestamp for profile consistency
139
+ user.created = adjustedCreated.toISOString();
140
+ } else {
141
+ adjustedCreated = dayjs.unix(global.FIXED_BEGIN);
142
+ }
143
+
144
+ if (hasLocation) {
145
+ const location = u.pickRandom(u.choose(defaults.locationsUsers));
146
+ for (const key in location) {
147
+ user[key] = location[key];
148
+ }
149
+ }
150
+
151
+ // Profile creation
152
+ const profile = await makeUserProfile(context, userProps, user);
153
+
154
+ // Call user hook after profile creation
155
+ if (config.hook) {
156
+ await config.hook(profile, "user", {
157
+ user,
158
+ config,
159
+ userIsBornInDataset
160
+ });
161
+ }
162
+
163
+ // SCD creation
164
+ // @ts-ignore
165
+ const scdUserTables = t.objFilter(scdProps, (scd) => scd.type === 'user' || !scd.type);
166
+ const scdTableKeys = Object.keys(scdUserTables);
167
+
168
+ const userSCD = {};
169
+ for (const [index, key] of scdTableKeys.entries()) {
170
+ const { max = 10 } = scdProps[key];
171
+ const mutations = chance.integer({ min: 1, max });
172
+ let changes = await makeSCD(context, scdProps[key], key, distinct_id, mutations, created);
173
+ userSCD[key] = changes;
174
+
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;
185
+ }
186
+ }
187
+
188
+ let numEventsThisUserWillPreform = Math.floor(chance.normal({
189
+ mean: avgEvPerUser,
190
+ dev: avgEvPerUser / u.integer(u.integer(2, 5), u.integer(2, 7))
191
+ }) * 0.714159265359);
192
+
193
+ // Power users and low-activity users logic
194
+ chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
195
+ chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
196
+ numEventsThisUserWillPreform = Math.round(numEventsThisUserWillPreform);
197
+
198
+ let userFirstEventTime;
199
+
200
+ const firstFunnels = funnels.filter((f) => f.isFirstFunnel)
201
+ .filter((f) => !f.conditions || matchConditions(profile, f.conditions))
202
+ .reduce(weighFunnels, []);
203
+ const usageFunnels = funnels.filter((f) => !f.isFirstFunnel)
204
+ .filter((f) => !f.conditions || matchConditions(profile, f.conditions))
205
+ .reduce(weighFunnels, []);
206
+
207
+ const secondsInDay = 86400;
208
+ const noise = () => chance.integer({ min: 0, max: secondsInDay });
209
+ let usersEvents = [];
210
+ let userConverted = true;
211
+
212
+ // Pre-compute weighted events array for standalone event selection
213
+ const weightedEvents = config.events.reduce((acc, event) => {
214
+ const w = Math.max(1, Math.min(Math.floor(event.weight) || 1, 10));
215
+ for (let i = 0; i < w; i++) acc.push(event);
216
+ return acc;
217
+ }, []);
218
+
219
+ // Build churn event lookup: { eventName: returnLikelihood }
220
+ const churnEvents = new Map();
221
+ for (const ev of config.events) {
222
+ if (ev.isChurnEvent) {
223
+ churnEvents.set(ev.event, ev.returnLikelihood ?? 0);
224
+ }
225
+ }
226
+
227
+ // PATH FOR USERS BORN IN DATASET AND PERFORMING FIRST FUNNEL
228
+ if (firstFunnels.length && userIsBornInDataset) {
229
+ const firstFunnel = chance.pickone(firstFunnels, user);
230
+ const firstTime = adjustedCreated.subtract(noise(), 'seconds').unix();
231
+ const [data, converted] = await makeFunnel(context, firstFunnel, user, firstTime, profile, userSCD);
232
+ userConverted = converted;
233
+
234
+ const timeShift = context.getTimeShift();
235
+ userFirstEventTime = dayjs(data[0].time).subtract(timeShift, 'seconds').unix();
236
+ numEventsPreformed += data.length;
237
+ usersEvents = usersEvents.concat(data);
238
+ } else {
239
+ userFirstEventTime = adjustedCreated.subtract(noise(), 'seconds').unix();
240
+ }
241
+
242
+ // ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
243
+ let userChurned = false;
244
+ while (numEventsPreformed < numEventsThisUserWillPreform && !cancelled) {
245
+ let newEvents;
246
+ if (usageFunnels.length && userConverted) {
247
+ const currentFunnel = chance.pickone(usageFunnels);
248
+ const [data, converted] = await makeFunnel(context, currentFunnel, user, userFirstEventTime, profile, userSCD);
249
+ numEventsPreformed += data.length;
250
+ newEvents = data;
251
+ } else {
252
+ const data = await makeEvent(context, distinct_id, userFirstEventTime, u.pick(weightedEvents), user.anonymousIds, user.sessionIds, {}, config.groupKeys, true);
253
+ numEventsPreformed++;
254
+ newEvents = [data];
255
+ }
256
+ usersEvents = usersEvents.concat(newEvents);
257
+
258
+ // Check for churn events — if user churned, they may stop generating
259
+ if (churnEvents.size > 0) {
260
+ const eventsToCheck = Array.isArray(newEvents[0]) ? newEvents.flat() : newEvents;
261
+ for (const ev of eventsToCheck) {
262
+ if (ev.event && churnEvents.has(ev.event)) {
263
+ const returnLikelihood = churnEvents.get(ev.event);
264
+ const userReturns = returnLikelihood > 0 && chance.bool({ likelihood: returnLikelihood * 100 });
265
+ if (!userReturns) {
266
+ userChurned = true;
267
+ break;
268
+ }
269
+ }
270
+ }
271
+ if (userChurned) break;
272
+ }
273
+ }
274
+
275
+ // Remove events flagged as future timestamps (before dungeon hooks see them)
276
+ usersEvents = usersEvents.filter(e => !e._drop);
277
+
278
+ // Hook for processing all user events
279
+ if (config.hook) {
280
+ const newEvents = await config.hook(usersEvents, "everything", {
281
+ profile,
282
+ scd: userSCD,
283
+ config,
284
+ userIsBornInDataset
285
+ });
286
+ if (Array.isArray(newEvents)) usersEvents = newEvents;
287
+ }
288
+
289
+ // Store all user data
290
+ await userProfilesData.hookPush(profile);
291
+
292
+ if (Object.keys(userSCD).length) {
293
+ for (const [key, changesArray] of Object.entries(userSCD)) {
294
+ for (const changes of changesArray) {
295
+ try {
296
+ const target = scdTableData.filter(arr => arr.scdKey === key).pop();
297
+ await target.hookPush(changes, { profile, type: 'user' });
298
+ }
299
+ catch (e) {
300
+ // This is probably a test
301
+ const target = scdTableData[0];
302
+ await target.hookPush(changes, { profile, type: 'user' });
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ await eventData.hookPush(usersEvents, { profile });
309
+ });
310
+
311
+ userPromises.push(userPromise);
312
+ }
313
+
314
+ // Wait for all users to complete
315
+ await Promise.all(userPromises);
316
+
317
+ // Clean up SIGINT handler
318
+ process.removeListener('SIGINT', onSigint);
319
+ }
320
+
321
+
322
+ export function weighFunnels(acc, funnel) {
323
+ const weight = funnel?.weight || 1;
324
+ for (let i = 0; i < weight; i++) {
325
+ acc.push(funnel);
326
+ }
327
+ return acc;
328
+ }
329
+
330
+ export function matchConditions(profile, conditions) {
331
+ for (const [key, value] of Object.entries(conditions)) {
332
+ if (profile[key] !== value) return false;
333
+ }
334
+ return true;
335
+ }