@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.
- package/README.md +7 -7
- package/dungeons/technical/ad-spend-schema.json +128 -0
- package/dungeons/technical/ad-spend.js +116 -0
- package/dungeons/technical/anonymous-users-schema.json +92 -0
- package/dungeons/technical/anonymous-users.js +102 -0
- package/dungeons/technical/array-of-object-lookup-schema.json +191 -0
- package/dungeons/{array-of-object-lookup.js → technical/array-of-object-lookup.js} +12 -5
- package/dungeons/technical/experiments-schema.json +203 -0
- package/dungeons/technical/experiments.js +168 -0
- package/dungeons/technical/foobar-schema.json +362 -0
- package/dungeons/{foobar.js → technical/foobar.js} +3 -3
- package/dungeons/technical/group-analytics-schema.json +241 -0
- package/dungeons/technical/group-analytics.js +151 -0
- package/dungeons/technical/mirror-strategies-schema.json +84 -0
- package/dungeons/technical/mirror-strategies.js +126 -0
- package/dungeons/technical/nested-objects-schema.json +145 -0
- package/dungeons/technical/nested-objects.js +207 -0
- package/dungeons/technical/retention-cadence-schema.json +37 -0
- package/dungeons/{retention-cadence.js → technical/retention-cadence.js} +2 -3
- package/dungeons/technical/sanity-schema.json +185 -0
- package/dungeons/{sanity.js → technical/sanity.js} +4 -2
- package/dungeons/technical/scale-test-schema.json +70 -0
- package/dungeons/technical/scale-test.js +90 -0
- package/dungeons/technical/scd-schema.json +467 -0
- package/dungeons/{scd.js → technical/scd.js} +20 -10
- package/dungeons/technical/simple-schema.json +362 -0
- package/dungeons/{simple.js → technical/simple.js} +7 -7
- package/dungeons/technical/simplest-schema.json +724 -0
- package/dungeons/{simplest.js → technical/simplest.js} +7 -7
- package/dungeons/technical/text-generation-schema.json +1062 -0
- package/dungeons/{text-generation.js → technical/text-generation.js} +24 -11
- package/dungeons/vertical/community-schema.json +579 -0
- package/dungeons/vertical/community.js +794 -0
- package/dungeons/vertical/devtools-schema.json +601 -0
- package/dungeons/vertical/devtools.js +843 -0
- package/dungeons/vertical/ecommerce-schema.json +604 -0
- package/dungeons/{ecommerce.js → vertical/ecommerce.js} +43 -5
- package/dungeons/vertical/education-schema.json +5686 -0
- package/dungeons/{education.js → vertical/education.js} +70 -39
- package/dungeons/vertical/fintech-schema.json +630 -0
- package/dungeons/{fintech.js → vertical/fintech.js} +107 -56
- package/dungeons/vertical/fitness-schema.json +530 -0
- package/dungeons/vertical/fitness.js +808 -0
- package/dungeons/vertical/food-delivery-schema.json +36728 -0
- package/dungeons/{food.js → vertical/food-delivery.js} +67 -26
- package/dungeons/vertical/gaming-schema.json +438 -0
- package/dungeons/{gaming.js → vertical/gaming.js} +29 -16
- package/dungeons/vertical/healthcare-schema.json +549 -0
- package/dungeons/vertical/healthcare.js +775 -0
- package/dungeons/vertical/insurance-application-schema.json +485 -0
- package/dungeons/{insurance-application.js → vertical/insurance-application.js} +41 -31
- package/dungeons/vertical/logistics-schema.json +574 -0
- package/dungeons/vertical/logistics.js +799 -0
- package/dungeons/vertical/marketplace-schema.json +533 -0
- package/dungeons/vertical/marketplace.js +850 -0
- package/dungeons/vertical/media-schema.json +4749 -0
- package/dungeons/{media.js → vertical/media.js} +103 -19
- package/dungeons/vertical/rpg-schema.json +2491 -0
- package/dungeons/{rpg.js → vertical/rpg.js} +87 -60
- package/dungeons/vertical/sass-schema.json +3128 -0
- package/dungeons/{sass.js → vertical/sass.js} +65 -26
- package/dungeons/vertical/social-schema.json +620 -0
- package/dungeons/{social.js → vertical/social.js} +76 -17
- package/dungeons/vertical/travel-schema.json +580 -0
- package/dungeons/vertical/travel.js +742 -0
- package/index.js +20 -16
- package/lib/core/config-validator.js +286 -1
- package/lib/core/context.js +16 -5
- package/lib/core/storage.js +2 -0
- package/lib/generators/adspend.js +3 -3
- package/lib/generators/events.js +141 -14
- package/lib/generators/funnels.js +67 -7
- package/lib/generators/text.js +2 -2
- package/lib/orchestrators/user-loop.js +444 -22
- package/lib/templates/soup-presets.js +2 -2
- package/lib/utils/utils.js +4 -5
- package/package.json +4 -3
- package/scripts/dungeon-to-json.mjs +163 -0
- package/scripts/extract-dungeon-schema.mjs +343 -0
- package/scripts/json-to-dungeon.mjs +200 -0
- package/scripts/run-dungeon.mjs +76 -0
- package/scripts/run-many.mjs +118 -0
- package/scripts/verify-runner.mjs +42 -0
- package/types.d.ts +316 -1
- package/dungeons/array-of-object-lookup-schema.json +0 -327
- package/dungeons/ecommerce-schema.json +0 -462
- package/dungeons/education-schema.json +0 -2409
- package/dungeons/fintech-schema.json +0 -14034
- package/dungeons/foobar-schema.json +0 -403
- package/dungeons/food-delivery-schema.json +0 -192
- package/dungeons/food-delivery.js +0 -602
- package/dungeons/food-schema.json +0 -1152
- package/dungeons/gaming-schema.json +0 -1270
- package/dungeons/insurance-application-schema.json +0 -204
- package/dungeons/media-schema.json +0 -906
- package/dungeons/retention-cadence-schema.json +0 -78
- package/dungeons/rpg-schema.json +0 -4526
- package/dungeons/sanity-schema.json +0 -255
- package/dungeons/sass-schema.json +0 -1291
- package/dungeons/scd-schema.json +0 -919
- package/dungeons/simple-schema.json +0 -608
- package/dungeons/simplest-schema.json +0 -1418
- package/dungeons/social-schema.json +0 -1118
- 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(
|
|
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(
|
|
163
|
+
adjustedCreated = dayjs.unix(context.FIXED_BEGIN);
|
|
142
164
|
}
|
|
143
165
|
|
|
144
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
//
|
|
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(
|
|
66
|
-
deviation:
|
|
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,
|
package/lib/utils/utils.js
CHANGED
|
@@ -44,11 +44,10 @@ RNG
|
|
|
44
44
|
* @returns {Chance}
|
|
45
45
|
*/
|
|
46
46
|
function initChance(seed) {
|
|
47
|
-
|
|
48
|
-
if (!
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
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",
|