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