@ak--47/dungeon-master 1.0.0 → 1.1.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 +91 -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} +11 -4
- package/dungeons/technical/experiments-schema.json +197 -0
- package/dungeons/technical/experiments.js +168 -0
- package/dungeons/technical/foobar-schema.json +362 -0
- package/dungeons/{foobar.js → technical/foobar.js} +2 -2
- package/dungeons/technical/group-analytics-schema.json +149 -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} +1 -1
- package/dungeons/technical/sanity-schema.json +185 -0
- package/dungeons/{sanity.js → technical/sanity.js} +3 -2
- package/dungeons/technical/scale-test-schema.json +70 -0
- package/dungeons/technical/scale-test.js +90 -0
- package/dungeons/technical/scd-schema.json +270 -0
- package/dungeons/{scd.js → technical/scd.js} +19 -10
- package/dungeons/technical/simple-schema.json +278 -0
- package/dungeons/{simple.js → technical/simple.js} +6 -6
- package/dungeons/technical/simplest-schema.json +635 -0
- package/dungeons/{simplest.js → technical/simplest.js} +6 -6
- package/dungeons/technical/text-generation-schema.json +1059 -0
- package/dungeons/{text-generation.js → technical/text-generation.js} +23 -9
- package/dungeons/vertical/community-schema.json +557 -0
- package/dungeons/vertical/community.js +788 -0
- package/dungeons/vertical/devtools-schema.json +580 -0
- package/dungeons/vertical/devtools.js +837 -0
- package/dungeons/vertical/ecommerce-schema.json +586 -0
- package/dungeons/{ecommerce.js → vertical/ecommerce.js} +34 -3
- package/dungeons/vertical/education-schema.json +697 -0
- package/dungeons/{education.js → vertical/education.js} +54 -36
- package/dungeons/vertical/fintech-schema.json +563 -0
- package/dungeons/{fintech.js → vertical/fintech.js} +91 -53
- package/dungeons/vertical/fitness-schema.json +508 -0
- package/dungeons/vertical/fitness.js +802 -0
- package/dungeons/vertical/food-delivery-schema.json +678 -0
- package/dungeons/{food.js → vertical/food-delivery.js} +51 -23
- package/dungeons/vertical/gaming-schema.json +394 -0
- package/dungeons/{gaming.js → vertical/gaming.js} +9 -7
- package/dungeons/vertical/healthcare-schema.json +527 -0
- package/dungeons/vertical/healthcare.js +769 -0
- package/dungeons/vertical/insurance-application-schema.json +466 -0
- package/dungeons/{insurance-application.js → vertical/insurance-application.js} +33 -29
- package/dungeons/vertical/logistics-schema.json +552 -0
- package/dungeons/vertical/logistics.js +793 -0
- package/dungeons/vertical/marketplace-schema.json +511 -0
- package/dungeons/vertical/marketplace.js +844 -0
- package/dungeons/vertical/media-schema.json +787 -0
- package/dungeons/{media.js → vertical/media.js} +94 -16
- package/dungeons/vertical/rpg-schema.json +654 -0
- package/dungeons/{rpg.js → vertical/rpg.js} +78 -57
- package/dungeons/vertical/sass-schema.json +653 -0
- package/dungeons/{sass.js → vertical/sass.js} +49 -23
- package/dungeons/vertical/social-schema.json +552 -0
- package/dungeons/{social.js → vertical/social.js} +60 -14
- package/dungeons/vertical/travel-schema.json +558 -0
- package/dungeons/vertical/travel.js +736 -0
- package/index.js +19 -15
- package/lib/core/config-validator.js +286 -1
- package/lib/core/context.js +7 -5
- 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 +441 -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 +297 -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 +313 -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
package/index.js
CHANGED
|
@@ -61,15 +61,15 @@ global.FIXED_BEGIN = FIXED_BEGIN;
|
|
|
61
61
|
*
|
|
62
62
|
* @example
|
|
63
63
|
* // file path
|
|
64
|
-
* const result = await DUNGEON_MASTER('./dungeons/simple.js');
|
|
64
|
+
* const result = await DUNGEON_MASTER('./dungeons/technical/simple.js');
|
|
65
65
|
*
|
|
66
66
|
* @example
|
|
67
67
|
* // JSON dungeon (from UI export)
|
|
68
|
-
* const result = await DUNGEON_MASTER('./dungeons/simple-schema.json');
|
|
68
|
+
* const result = await DUNGEON_MASTER('./dungeons/technical/simple-schema.json');
|
|
69
69
|
*
|
|
70
70
|
* @example
|
|
71
71
|
* // multiple dungeons
|
|
72
|
-
* const results = await DUNGEON_MASTER(['./dungeons/gaming.js', './dungeons/media.js']);
|
|
72
|
+
* const results = await DUNGEON_MASTER(['./dungeons/vertical/gaming.js', './dungeons/vertical/media.js']);
|
|
73
73
|
*
|
|
74
74
|
* @example
|
|
75
75
|
* // raw JS text
|
|
@@ -83,7 +83,7 @@ global.FIXED_BEGIN = FIXED_BEGIN;
|
|
|
83
83
|
*
|
|
84
84
|
* @example
|
|
85
85
|
* // with overrides
|
|
86
|
-
* const result = await DUNGEON_MASTER('./dungeons/simple.js', { writeToDisk: true, verbose: true });
|
|
86
|
+
* const result = await DUNGEON_MASTER('./dungeons/technical/simple.js', { writeToDisk: true, verbose: true });
|
|
87
87
|
*/
|
|
88
88
|
async function DUNGEON_MASTER(input, overrides = {}) {
|
|
89
89
|
const { type, value } = detectInputType(input);
|
|
@@ -124,21 +124,25 @@ async function runDungeon(config) {
|
|
|
124
124
|
if (config.verbose) logger.info({ seed: config.seed }, 'Configuring dungeon');
|
|
125
125
|
let validatedConfig;
|
|
126
126
|
try {
|
|
127
|
+
// Initialize seeded RNG BEFORE validation — config-validator captures a
|
|
128
|
+
// chance reference for default userProps (spiritAnimal). If we init after,
|
|
129
|
+
// run 1 binds an unseeded instance while run 2 binds a stale one → non-deterministic.
|
|
130
|
+
if (config.seed) {
|
|
131
|
+
initChance(config.seed);
|
|
132
|
+
}
|
|
133
|
+
|
|
127
134
|
// Step 1: Validate and enrich configuration
|
|
128
135
|
validatedConfig = validateDungeonConfig(config);
|
|
129
136
|
|
|
130
|
-
//
|
|
131
|
-
// but npm-module consumers pass seed via config object)
|
|
132
|
-
if (validatedConfig.seed) {
|
|
133
|
-
initChance(validatedConfig.seed);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Update FIXED_BEGIN based on configured numDays
|
|
137
|
+
// Compute FIXED_BEGIN from validated numDays
|
|
137
138
|
const configNumDays = validatedConfig.numDays || 30;
|
|
138
|
-
|
|
139
|
+
const fixedBegin = dayjs.unix(FIXED_NOW).subtract(configNumDays, 'd').unix();
|
|
140
|
+
|
|
141
|
+
// Keep globals for backwards compatibility with tests/dungeons that read them
|
|
142
|
+
global.FIXED_BEGIN = fixedBegin;
|
|
139
143
|
|
|
140
|
-
// Step 2: Create context with validated config
|
|
141
|
-
const context = createContext(validatedConfig);
|
|
144
|
+
// Step 2: Create context with validated config (pass time constants explicitly)
|
|
145
|
+
const context = createContext(validatedConfig, null, { fixedNow: FIXED_NOW, fixedBegin });
|
|
142
146
|
|
|
143
147
|
// Step 3: Initialize storage containers
|
|
144
148
|
const storageManager = new StorageManager(context);
|
|
@@ -232,7 +236,7 @@ async function generateAdSpendData(context) {
|
|
|
232
236
|
|
|
233
237
|
const timeShift = context.TIME_SHIFT_SECONDS;
|
|
234
238
|
for (let day = 0; day < numDays; day++) {
|
|
235
|
-
const fixedDay = dayjs.unix(
|
|
239
|
+
const fixedDay = dayjs.unix(context.FIXED_BEGIN).add(day, 'day').unix();
|
|
236
240
|
const shiftedDay = Math.min(fixedDay + timeShift, context.MAX_TIME);
|
|
237
241
|
const targetDay = dayjs.unix(shiftedDay).toISOString();
|
|
238
242
|
const adSpendEvents = await makeAdSpend(context, targetDay);
|
|
@@ -13,6 +13,9 @@ import { makeName } from "ak-tools";
|
|
|
13
13
|
import * as u from "../utils/utils.js";
|
|
14
14
|
import { resolveSoup } from "../templates/soup-presets.js";
|
|
15
15
|
|
|
16
|
+
/** Fixed reference point for time calculations (2024-02-02) */
|
|
17
|
+
const FIXED_NOW = dayjs('2024-02-02').unix();
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
20
|
* Infers funnels from the provided events
|
|
18
21
|
* @param {EventConfig[]} events - Array of event configurations
|
|
@@ -275,6 +278,62 @@ export function validateDungeonConfig(config) {
|
|
|
275
278
|
// Event validation
|
|
276
279
|
const validatedEvents = u.validateEventConfig(events);
|
|
277
280
|
|
|
281
|
+
// ── Validate and resolve advanced features ──
|
|
282
|
+
|
|
283
|
+
// Feature 1: Personas
|
|
284
|
+
let personas = config.personas || null;
|
|
285
|
+
if (personas) {
|
|
286
|
+
personas = validatePersonas(personas);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Feature 2: World Events
|
|
290
|
+
let worldEvents = config.worldEvents || null;
|
|
291
|
+
if (worldEvents) {
|
|
292
|
+
worldEvents = resolveWorldEvents(worldEvents, numDays);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Feature 3: Engagement Decay
|
|
296
|
+
let engagementDecay = config.engagementDecay || null;
|
|
297
|
+
if (engagementDecay) {
|
|
298
|
+
engagementDecay = validateEngagementDecay(engagementDecay);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Feature 4: Data Quality
|
|
302
|
+
let dataQuality = config.dataQuality || null;
|
|
303
|
+
if (dataQuality) {
|
|
304
|
+
dataQuality = validateDataQuality(dataQuality);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Feature 5: Subscription
|
|
308
|
+
let subscription = config.subscription || null;
|
|
309
|
+
if (subscription) {
|
|
310
|
+
subscription = validateSubscription(subscription);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Feature 6: Attribution
|
|
314
|
+
let attribution = config.attribution || null;
|
|
315
|
+
if (attribution) {
|
|
316
|
+
attribution = validateAttribution(attribution, numDays);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Feature 7: Geo
|
|
320
|
+
let geo = config.geo || null;
|
|
321
|
+
if (geo) {
|
|
322
|
+
geo = validateGeo(geo);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Feature 8: Features (progressive adoption)
|
|
326
|
+
let features = config.features || null;
|
|
327
|
+
if (features) {
|
|
328
|
+
features = resolveFeatures(features, numDays);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Feature 9: Anomalies
|
|
332
|
+
let anomalies = config.anomalies || null;
|
|
333
|
+
if (anomalies) {
|
|
334
|
+
anomalies = resolveAnomalies(anomalies, numDays);
|
|
335
|
+
}
|
|
336
|
+
|
|
278
337
|
// Build final config object
|
|
279
338
|
const validatedConfig = {
|
|
280
339
|
...config,
|
|
@@ -314,7 +373,17 @@ export function validateDungeonConfig(config) {
|
|
|
314
373
|
hasDesktopDevices,
|
|
315
374
|
hasIOSDevices,
|
|
316
375
|
name,
|
|
317
|
-
strictEventCount
|
|
376
|
+
strictEventCount,
|
|
377
|
+
// Advanced features
|
|
378
|
+
personas,
|
|
379
|
+
worldEvents,
|
|
380
|
+
engagementDecay,
|
|
381
|
+
dataQuality,
|
|
382
|
+
subscription,
|
|
383
|
+
attribution,
|
|
384
|
+
geo,
|
|
385
|
+
features,
|
|
386
|
+
anomalies
|
|
318
387
|
};
|
|
319
388
|
|
|
320
389
|
return validatedConfig;
|
|
@@ -392,4 +461,220 @@ function transformSCDPropsWithoutCredentials(config) {
|
|
|
392
461
|
if (config.verbose !== false) console.log('\u2713 SCD properties converted to static properties\n');
|
|
393
462
|
}
|
|
394
463
|
|
|
464
|
+
// ── Advanced Feature Validation Functions ──
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Validates persona configurations
|
|
468
|
+
* @param {import('../../types').Persona[]} personas
|
|
469
|
+
* @returns {import('../../types').Persona[]}
|
|
470
|
+
*/
|
|
471
|
+
function validatePersonas(personas) {
|
|
472
|
+
if (!Array.isArray(personas) || personas.length === 0) return null;
|
|
473
|
+
for (const p of personas) {
|
|
474
|
+
if (!p.name) throw new Error('Each persona must have a name');
|
|
475
|
+
if (typeof p.weight !== 'number' || p.weight <= 0) throw new Error(`Persona "${p.name}" must have a positive weight`);
|
|
476
|
+
if (p.eventMultiplier === undefined) p.eventMultiplier = 1.0;
|
|
477
|
+
if (p.conversionModifier === undefined) p.conversionModifier = 1.0;
|
|
478
|
+
if (p.churnRate === undefined) p.churnRate = 0;
|
|
479
|
+
if (p.properties === undefined) p.properties = {};
|
|
480
|
+
}
|
|
481
|
+
return personas;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Resolves world events to absolute timestamps
|
|
486
|
+
* @param {import('../../types').WorldEvent[]} worldEvents
|
|
487
|
+
* @param {number} numDays
|
|
488
|
+
* @returns {import('../../types').ResolvedWorldEvent[]}
|
|
489
|
+
*/
|
|
490
|
+
function resolveWorldEvents(worldEvents, numDays) {
|
|
491
|
+
if (!Array.isArray(worldEvents) || worldEvents.length === 0) return null;
|
|
492
|
+
const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
|
|
493
|
+
|
|
494
|
+
return worldEvents.map(we => {
|
|
495
|
+
const startUnix = beginUnix + (we.startDay * 86400);
|
|
496
|
+
let endUnix;
|
|
497
|
+
if (we.duration === null || we.duration === undefined) {
|
|
498
|
+
endUnix = Infinity; // permanent
|
|
499
|
+
} else {
|
|
500
|
+
endUnix = startUnix + (we.duration * 86400);
|
|
501
|
+
}
|
|
502
|
+
const aftermathEndUnix = (we.aftermath && we.aftermath.duration !== undefined)
|
|
503
|
+
? endUnix + (we.aftermath.duration * 86400)
|
|
504
|
+
: undefined;
|
|
505
|
+
const resolved = { ...we, startUnix, endUnix, aftermathEndUnix };
|
|
506
|
+
if (!we.affectsEvents) resolved.affectsEvents = "*";
|
|
507
|
+
if (!we.volumeMultiplier) resolved.volumeMultiplier = 1.0;
|
|
508
|
+
if (!we.conversionModifier) resolved.conversionModifier = 1.0;
|
|
509
|
+
return resolved;
|
|
510
|
+
}).sort((a, b) => a.startUnix - b.startUnix || (a.name || '').localeCompare(b.name || ''));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Validates engagement decay config
|
|
515
|
+
* @param {import('../../types').EngagementDecay} decay
|
|
516
|
+
* @returns {import('../../types').EngagementDecay}
|
|
517
|
+
*/
|
|
518
|
+
function validateEngagementDecay(decay) {
|
|
519
|
+
const valid = ['exponential', 'linear', 'step', 'none'];
|
|
520
|
+
if (!valid.includes(decay.model)) throw new Error(`engagementDecay.model must be one of: ${valid.join(', ')}`);
|
|
521
|
+
if (decay.model === 'none') return decay;
|
|
522
|
+
if (decay.halfLife === undefined) decay.halfLife = 45;
|
|
523
|
+
if (decay.halfLife <= 0) throw new Error('engagementDecay.halfLife must be > 0');
|
|
524
|
+
if (decay.floor === undefined) decay.floor = 0.1;
|
|
525
|
+
decay.floor = Math.max(0, Math.min(1, decay.floor));
|
|
526
|
+
if (decay.reactivationChance === undefined) decay.reactivationChance = 0;
|
|
527
|
+
if (decay.reactivationMultiplier === undefined) decay.reactivationMultiplier = 2.0;
|
|
528
|
+
return decay;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Validates data quality config
|
|
533
|
+
* @param {import('../../types').DataQuality} dq
|
|
534
|
+
* @returns {import('../../types').DataQuality}
|
|
535
|
+
*/
|
|
536
|
+
function validateDataQuality(dq) {
|
|
537
|
+
if (dq.nullRate === undefined) dq.nullRate = 0;
|
|
538
|
+
if (dq.duplicateRate === undefined) dq.duplicateRate = 0;
|
|
539
|
+
if (dq.lateArrivingRate === undefined) dq.lateArrivingRate = 0;
|
|
540
|
+
if (dq.botUsers === undefined) dq.botUsers = 0;
|
|
541
|
+
if (dq.botEventsPerUser === undefined) dq.botEventsPerUser = 1000;
|
|
542
|
+
if (dq.timezoneConfusion === undefined) dq.timezoneConfusion = 0;
|
|
543
|
+
if (dq.emptyEvents === undefined) dq.emptyEvents = 0;
|
|
544
|
+
if (!dq.nullProps) dq.nullProps = "*";
|
|
545
|
+
// Clamp rates to [0, 1]
|
|
546
|
+
for (const key of ['nullRate', 'duplicateRate', 'lateArrivingRate', 'timezoneConfusion', 'emptyEvents']) {
|
|
547
|
+
dq[key] = Math.max(0, Math.min(1, dq[key]));
|
|
548
|
+
}
|
|
549
|
+
return dq;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Validates subscription config
|
|
554
|
+
* @param {import('../../types').Subscription} sub
|
|
555
|
+
* @returns {import('../../types').Subscription}
|
|
556
|
+
*/
|
|
557
|
+
function validateSubscription(sub) {
|
|
558
|
+
if (!sub.plans || !Array.isArray(sub.plans) || sub.plans.length === 0) {
|
|
559
|
+
throw new Error('subscription.plans must be a non-empty array');
|
|
560
|
+
}
|
|
561
|
+
const hasDefault = sub.plans.some(p => p.default);
|
|
562
|
+
if (!hasDefault) sub.plans[0].default = true;
|
|
563
|
+
if (!sub.lifecycle) sub.lifecycle = {};
|
|
564
|
+
const lc = sub.lifecycle;
|
|
565
|
+
if (lc.trialToPayRate === undefined) lc.trialToPayRate = 0.3;
|
|
566
|
+
if (lc.upgradeRate === undefined) lc.upgradeRate = 0.1;
|
|
567
|
+
if (lc.downgradeRate === undefined) lc.downgradeRate = 0.03;
|
|
568
|
+
if (lc.churnRate === undefined) lc.churnRate = 0.05;
|
|
569
|
+
if (lc.winBackRate === undefined) lc.winBackRate = 0.1;
|
|
570
|
+
if (lc.winBackDelay === undefined) lc.winBackDelay = 30;
|
|
571
|
+
if (lc.paymentFailureRate === undefined) lc.paymentFailureRate = 0.02;
|
|
572
|
+
if (!sub.events) sub.events = {};
|
|
573
|
+
const ev = sub.events;
|
|
574
|
+
if (!ev.trialStarted) ev.trialStarted = "trial started";
|
|
575
|
+
if (!ev.subscribed) ev.subscribed = "subscription started";
|
|
576
|
+
if (!ev.upgraded) ev.upgraded = "plan upgraded";
|
|
577
|
+
if (!ev.downgraded) ev.downgraded = "plan downgraded";
|
|
578
|
+
if (!ev.renewed) ev.renewed = "subscription renewed";
|
|
579
|
+
if (!ev.cancelled) ev.cancelled = "subscription cancelled";
|
|
580
|
+
if (!ev.paymentFailed) ev.paymentFailed = "payment failed";
|
|
581
|
+
if (!ev.wonBack) ev.wonBack = "subscription reactivated";
|
|
582
|
+
return sub;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Validates attribution config
|
|
587
|
+
* @param {import('../../types').Attribution} attr
|
|
588
|
+
* @param {number} numDays
|
|
589
|
+
* @returns {import('../../types').Attribution}
|
|
590
|
+
*/
|
|
591
|
+
function validateAttribution(attr, numDays) {
|
|
592
|
+
if (!attr.campaigns || !Array.isArray(attr.campaigns)) {
|
|
593
|
+
throw new Error('attribution.campaigns must be an array');
|
|
594
|
+
}
|
|
595
|
+
if (attr.model === undefined) attr.model = "last_touch";
|
|
596
|
+
if (attr.window === undefined) attr.window = 7;
|
|
597
|
+
if (attr.organicRate === undefined) attr.organicRate = 0.4;
|
|
598
|
+
for (const c of attr.campaigns) {
|
|
599
|
+
if (!c.name) throw new Error('Each attribution campaign must have a name');
|
|
600
|
+
if (!c.source) throw new Error(`Attribution campaign "${c.name}" must have a source`);
|
|
601
|
+
if (!c.activeDays) throw new Error(`Attribution campaign "${c.name}" must have activeDays [start, end]`);
|
|
602
|
+
if (!c.dailyBudget) c.dailyBudget = [50, 200];
|
|
603
|
+
if (c.acquisitionRate === undefined) c.acquisitionRate = 0.02;
|
|
604
|
+
}
|
|
605
|
+
return attr;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Validates geo config
|
|
610
|
+
* @param {import('../../types').GeoConfig} geo
|
|
611
|
+
* @returns {import('../../types').GeoConfig}
|
|
612
|
+
*/
|
|
613
|
+
function validateGeo(geo) {
|
|
614
|
+
if (geo.sticky === undefined) geo.sticky = false;
|
|
615
|
+
if (geo.regions && !Array.isArray(geo.regions)) throw new Error('geo.regions must be an array');
|
|
616
|
+
if (geo.regionalLaunches && !Array.isArray(geo.regionalLaunches)) throw new Error('geo.regionalLaunches must be an array');
|
|
617
|
+
return geo;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Resolves feature configs with logistic curve parameters
|
|
622
|
+
* @param {import('../../types').FeatureConfig[]} features
|
|
623
|
+
* @param {number} numDays
|
|
624
|
+
* @returns {import('../../types').FeatureConfig[]}
|
|
625
|
+
*/
|
|
626
|
+
function resolveFeatures(features, numDays) {
|
|
627
|
+
if (!Array.isArray(features) || features.length === 0) return null;
|
|
628
|
+
const curvePresets = {
|
|
629
|
+
fast: { k: 0.3, midpoint: 7 },
|
|
630
|
+
slow: { k: 0.08, midpoint: 30 },
|
|
631
|
+
instant: { k: 10, midpoint: 0 }
|
|
632
|
+
};
|
|
633
|
+
return features.map(f => {
|
|
634
|
+
if (!f.name) throw new Error('Each feature must have a name');
|
|
635
|
+
if (f.launchDay === undefined) throw new Error(`Feature "${f.name}" must have a launchDay`);
|
|
636
|
+
if (!f.property) throw new Error(`Feature "${f.name}" must have a property`);
|
|
637
|
+
if (!f.values || !Array.isArray(f.values) || f.values.length === 0) {
|
|
638
|
+
throw new Error(`Feature "${f.name}" must have a non-empty values array`);
|
|
639
|
+
}
|
|
640
|
+
if (!f.affectsEvents) f.affectsEvents = "*";
|
|
641
|
+
if (!f.adoptionCurve) f.adoptionCurve = "slow";
|
|
642
|
+
if (typeof f.adoptionCurve === 'string') {
|
|
643
|
+
f._resolvedCurve = curvePresets[f.adoptionCurve] || curvePresets.slow;
|
|
644
|
+
} else {
|
|
645
|
+
f._resolvedCurve = f.adoptionCurve;
|
|
646
|
+
}
|
|
647
|
+
// Pre-compute adopted values to avoid array allocation in hot loop
|
|
648
|
+
f._adoptedValues = f.values.length > 1 ? f.values.slice(1) : f.values;
|
|
649
|
+
return f;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Resolves anomaly configs with absolute timestamps
|
|
655
|
+
* @param {import('../../types').AnomalyConfig[]} anomalies
|
|
656
|
+
* @param {number} numDays
|
|
657
|
+
* @returns {import('../../types').AnomalyConfig[]}
|
|
658
|
+
*/
|
|
659
|
+
function resolveAnomalies(anomalies, numDays) {
|
|
660
|
+
if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
|
|
661
|
+
const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
|
|
662
|
+
return anomalies.map(a => {
|
|
663
|
+
if (!a.type) throw new Error('Each anomaly must have a type');
|
|
664
|
+
if (!a.event) throw new Error('Each anomaly must have an event name');
|
|
665
|
+
const resolved = { ...a };
|
|
666
|
+
if (a.day !== undefined) {
|
|
667
|
+
resolved._startUnix = beginUnix + (a.day * 86400);
|
|
668
|
+
if (a.duration) {
|
|
669
|
+
resolved._endUnix = resolved._startUnix + (a.duration * 86400);
|
|
670
|
+
} else if (a.window) {
|
|
671
|
+
resolved._endUnix = resolved._startUnix + (a.window * 86400);
|
|
672
|
+
} else {
|
|
673
|
+
resolved._endUnix = resolved._startUnix + 86400; // default 1 day
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return resolved;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
395
680
|
export { inferFunnels, transformSCDPropsWithoutCredentials };
|
package/lib/core/context.js
CHANGED
|
@@ -81,9 +81,10 @@ function createRuntimeState() {
|
|
|
81
81
|
* Context factory that creates a complete context object for data generation
|
|
82
82
|
* @param {Dungeon} config - Validated configuration object
|
|
83
83
|
* @param {Storage|null} storage - Storage containers (optional, can be set later)
|
|
84
|
+
* @param {{ fixedNow?: number, fixedBegin?: number }} [timeConstants] - Time constants (avoids globals)
|
|
84
85
|
* @returns {Context} Context object containing all state and dependencies
|
|
85
86
|
*/
|
|
86
|
-
export function createContext(config, storage = null) {
|
|
87
|
+
export function createContext(config, storage = null, timeConstants = {}) {
|
|
87
88
|
// Import campaign data (could be made configurable)
|
|
88
89
|
const campaignData = campaigns;
|
|
89
90
|
|
|
@@ -157,14 +158,15 @@ export function createContext(config, storage = null) {
|
|
|
157
158
|
return actualNow.diff(dayjs.unix(this.FIXED_NOW), "days");
|
|
158
159
|
},
|
|
159
160
|
|
|
160
|
-
// Time constants (
|
|
161
|
-
FIXED_NOW: global.FIXED_NOW,
|
|
162
|
-
FIXED_BEGIN: global.FIXED_BEGIN,
|
|
161
|
+
// Time constants (passed explicitly from index.js; global fallback only for direct test usage)
|
|
162
|
+
FIXED_NOW: timeConstants.fixedNow || global.FIXED_NOW,
|
|
163
|
+
FIXED_BEGIN: timeConstants.fixedBegin || global.FIXED_BEGIN,
|
|
163
164
|
|
|
164
165
|
// PERFORMANCE: Pre-calculated time shift (instead of calculating per-event)
|
|
165
166
|
TIME_SHIFT_SECONDS: (() => {
|
|
167
|
+
const fixedNow = timeConstants.fixedNow || global.FIXED_NOW;
|
|
166
168
|
const actualNow = dayjs().subtract(1, "hour");
|
|
167
|
-
return actualNow.diff(dayjs.unix(
|
|
169
|
+
return actualNow.diff(dayjs.unix(fixedNow), "seconds");
|
|
168
170
|
})(),
|
|
169
171
|
|
|
170
172
|
// Max timestamp (unix seconds) — clamp here to prevent future events
|
|
@@ -67,9 +67,9 @@ function createAdSpendEvent(network, campaign, day, chance) {
|
|
|
67
67
|
const views = Math.floor(impressions * avgCTR);
|
|
68
68
|
|
|
69
69
|
// Generate UTM parameters
|
|
70
|
-
const utm_medium = u.choose(
|
|
71
|
-
const utm_content = u.choose(
|
|
72
|
-
const utm_term = u.choose(
|
|
70
|
+
const utm_medium = u.choose(network.utm_medium);
|
|
71
|
+
const utm_content = u.choose(network.utm_content);
|
|
72
|
+
const utm_term = u.choose(network.utm_term);
|
|
73
73
|
|
|
74
74
|
// Create unique identifiers
|
|
75
75
|
const id = network.utm_source[0] + '-' + campaign;
|
package/lib/generators/events.js
CHANGED
|
@@ -13,6 +13,9 @@ import dayjs from "dayjs";
|
|
|
13
13
|
import * as u from "../utils/utils.js";
|
|
14
14
|
import { dataLogger as logger } from "../utils/logger.js";
|
|
15
15
|
|
|
16
|
+
// Keys that must never be nulled by data quality gremlins
|
|
17
|
+
const NULL_EXEMPT_KEYS = new Set(['event', 'time', 'insert_id', 'user_id', 'device_id', 'distinct_id', '_drop', '_anomaly', '_persona']);
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
20
|
* Creates a Mixpanel event with a flat shape
|
|
18
21
|
* @param {Context} context - Context object containing config, defaults, etc.
|
|
@@ -28,16 +31,17 @@ import { dataLogger as logger } from "../utils/logger.js";
|
|
|
28
31
|
* @returns {Promise<Object>} Generated event object
|
|
29
32
|
*/
|
|
30
33
|
export async function makeEvent(
|
|
31
|
-
context,
|
|
32
|
-
distinct_id,
|
|
33
|
-
earliestTime,
|
|
34
|
-
chosenEvent,
|
|
35
|
-
anonymousIds = [],
|
|
36
|
-
sessionIds = [],
|
|
37
|
-
superProps = {},
|
|
38
|
-
groupKeys = [],
|
|
39
|
-
isFirstEvent = false,
|
|
40
|
-
skipDefaults = false
|
|
34
|
+
context,
|
|
35
|
+
distinct_id,
|
|
36
|
+
earliestTime,
|
|
37
|
+
chosenEvent,
|
|
38
|
+
anonymousIds = [],
|
|
39
|
+
sessionIds = [],
|
|
40
|
+
superProps = {},
|
|
41
|
+
groupKeys = [],
|
|
42
|
+
isFirstEvent = false,
|
|
43
|
+
skipDefaults = false,
|
|
44
|
+
featureCtx = {}
|
|
41
45
|
) {
|
|
42
46
|
// Validate required parameters
|
|
43
47
|
if (!distinct_id) throw new Error("no distinct_id");
|
|
@@ -168,17 +172,140 @@ export async function makeEvent(
|
|
|
168
172
|
// Add group properties
|
|
169
173
|
addGroupProperties(eventTemplate, groupKeys);
|
|
170
174
|
|
|
175
|
+
// ── Event-level features (applied before hooks, so hooks can override) ──
|
|
176
|
+
const { userLocation, persona, worldEventsTimeline, resolvedFeatures, resolvedAnomalies, dataQuality: dq, geo: geoConfig, userCampaign } = featureCtx;
|
|
177
|
+
|
|
178
|
+
// Feature 6: Attribution — stamp UTM properties on events as touchpoints
|
|
179
|
+
// Mixpanel attribution analysis needs UTM on EVENTS, not just profiles.
|
|
180
|
+
// Pattern: first events carry acquisition UTM, later events occasionally carry re-engagement UTM.
|
|
181
|
+
if (userCampaign) {
|
|
182
|
+
// ~40% of events carry UTM (simulates page loads, session starts, ad clicks)
|
|
183
|
+
// First events (isFirstEvent) always carry UTM (acquisition touchpoint)
|
|
184
|
+
if (isFirstEvent || chance.bool({ likelihood: 40 })) {
|
|
185
|
+
eventTemplate.utm_source = userCampaign.source;
|
|
186
|
+
eventTemplate.utm_campaign = userCampaign.name;
|
|
187
|
+
if (userCampaign.medium) eventTemplate.utm_medium = userCampaign.medium;
|
|
188
|
+
if (userCampaign.utm_content) eventTemplate.utm_content = userCampaign.utm_content;
|
|
189
|
+
if (userCampaign.utm_term) eventTemplate.utm_term = userCampaign.utm_term;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Feature 7: Sticky geo location
|
|
194
|
+
if (userLocation && geoConfig?.sticky) {
|
|
195
|
+
// Override the random location with user's sticky location
|
|
196
|
+
for (const key in userLocation) {
|
|
197
|
+
eventTemplate[key] = userLocation[key];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Perf 1: Compute eventUnix once for all time-based checks
|
|
202
|
+
// Subtracts TIME_SHIFT to convert real time back to FIXED time (world events/features use FIXED time)
|
|
203
|
+
let eventUnix = null;
|
|
204
|
+
if ((worldEventsTimeline || resolvedFeatures) && eventTemplate.time) {
|
|
205
|
+
eventUnix = dayjs(eventTemplate.time).subtract(context.TIME_SHIFT_SECONDS, 'seconds').unix();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Feature 2: World events — inject props for active events at this timestamp
|
|
209
|
+
if (worldEventsTimeline && eventUnix !== null) {
|
|
210
|
+
for (const we of worldEventsTimeline) {
|
|
211
|
+
const inMainWindow = eventUnix >= we.startUnix && eventUnix < we.endUnix;
|
|
212
|
+
const inAftermath = we.aftermathEndUnix && eventUnix >= we.endUnix && eventUnix < we.aftermathEndUnix;
|
|
213
|
+
if (inMainWindow || inAftermath) {
|
|
214
|
+
const affects = we.affectsEvents;
|
|
215
|
+
if (affects === "*" || (Array.isArray(affects) && affects.includes(eventTemplate.event))) {
|
|
216
|
+
// Inject properties
|
|
217
|
+
if (we.injectProps && inMainWindow) {
|
|
218
|
+
for (const [k, v] of Object.entries(we.injectProps)) {
|
|
219
|
+
eventTemplate[k] = v;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Volume modulation via accept/reject: if volumeMultiplier < 1, randomly drop
|
|
223
|
+
const volMult = inAftermath ? (we.aftermath?.volumeMultiplier || 1.0) : we.volumeMultiplier;
|
|
224
|
+
if (volMult < 1.0 && !chance.bool({ likelihood: volMult * 100 })) {
|
|
225
|
+
eventTemplate._drop = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Feature 8: Progressive feature adoption
|
|
233
|
+
if (resolvedFeatures && eventUnix !== null) {
|
|
234
|
+
const daysSinceBegin = (eventUnix - context.FIXED_BEGIN) / 86400;
|
|
235
|
+
for (const feat of resolvedFeatures) {
|
|
236
|
+
const affects = feat.affectsEvents;
|
|
237
|
+
if (affects !== "*" && !(Array.isArray(affects) && affects.includes(eventTemplate.event))) continue;
|
|
238
|
+
if (daysSinceBegin < feat.launchDay) {
|
|
239
|
+
if (feat.defaultBefore !== undefined) {
|
|
240
|
+
eventTemplate[feat.property] = feat.defaultBefore;
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
const daysSinceLaunch = daysSinceBegin - feat.launchDay;
|
|
244
|
+
const { k, midpoint } = feat._resolvedCurve || { k: 0.08, midpoint: 30 };
|
|
245
|
+
const adoptionProb = 1 / (1 + Math.exp(-k * (daysSinceLaunch - midpoint)));
|
|
246
|
+
if (chance.bool({ likelihood: Math.min(100, adoptionProb * 100) })) {
|
|
247
|
+
eventTemplate[feat.property] = u.pickRandom(feat._adoptedValues);
|
|
248
|
+
} else if (feat.defaultBefore !== undefined) {
|
|
249
|
+
eventTemplate[feat.property] = feat.defaultBefore;
|
|
250
|
+
} else if (feat.values.length > 0) {
|
|
251
|
+
eventTemplate[feat.property] = feat.values[0];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Feature 9: Anomaly extreme values
|
|
258
|
+
if (resolvedAnomalies && eventTemplate.event) {
|
|
259
|
+
for (const a of resolvedAnomalies) {
|
|
260
|
+
if (a.type === 'extreme_value' && a.event === eventTemplate.event && a.property) {
|
|
261
|
+
if (chance.bool({ likelihood: (a.frequency ?? 0.001) * 100 })) {
|
|
262
|
+
const currentVal = eventTemplate[a.property];
|
|
263
|
+
if (typeof currentVal === 'number') {
|
|
264
|
+
eventTemplate[a.property] = currentVal * (a.multiplier || 10);
|
|
265
|
+
}
|
|
266
|
+
if (a.tag) eventTemplate._anomaly = a.tag;
|
|
267
|
+
if (a.properties) Object.assign(eventTemplate, a.properties);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Feature 4: Data quality — null injection and timezone confusion
|
|
274
|
+
if (dq) {
|
|
275
|
+
// Null injection
|
|
276
|
+
if (dq.nullRate > 0) {
|
|
277
|
+
const propsToNull = dq.nullProps === "*"
|
|
278
|
+
? Object.keys(eventTemplate).filter(k => !NULL_EXEMPT_KEYS.has(k))
|
|
279
|
+
: (Array.isArray(dq.nullProps) ? dq.nullProps : []);
|
|
280
|
+
for (const prop of propsToNull) {
|
|
281
|
+
if (eventTemplate[prop] !== undefined && chance.bool({ likelihood: dq.nullRate * 100 })) {
|
|
282
|
+
eventTemplate[prop] = null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Timezone confusion
|
|
287
|
+
if (dq.timezoneConfusion > 0 && chance.bool({ likelihood: dq.timezoneConfusion * 100 })) {
|
|
288
|
+
const offsetHours = chance.integer({ min: -12, max: 12 });
|
|
289
|
+
eventTemplate.time = dayjs(eventTemplate.time).add(offsetHours, 'hours').toISOString();
|
|
290
|
+
}
|
|
291
|
+
// Empty events
|
|
292
|
+
if (dq.emptyEvents > 0 && chance.bool({ likelihood: dq.emptyEvents * 100 })) {
|
|
293
|
+
eventTemplate.event = "";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
171
297
|
// Generate unique insert_id
|
|
172
298
|
const distinctId = eventTemplate.user_id || eventTemplate.device_id || eventTemplate.distinct_id || distinct_id;
|
|
173
299
|
const tuple = `${eventTemplate.event}-${eventTemplate.time}-${distinctId}`;
|
|
174
300
|
eventTemplate.insert_id = u.quickHash(tuple);
|
|
175
301
|
|
|
176
|
-
// Call hook if configured (
|
|
302
|
+
// Call hook if configured (hooks override everything — they are the final authority)
|
|
177
303
|
const { hook } = config;
|
|
178
304
|
if (hook) {
|
|
179
|
-
const hookedEvent = await hook(eventTemplate, "event", {
|
|
180
|
-
user: { distinct_id },
|
|
181
|
-
config
|
|
305
|
+
const hookedEvent = await hook(eventTemplate, "event", {
|
|
306
|
+
user: { distinct_id },
|
|
307
|
+
config,
|
|
308
|
+
persona: featureCtx.persona || null
|
|
182
309
|
});
|
|
183
310
|
// If hook returns a modified event, use it; otherwise use original
|
|
184
311
|
if (hookedEvent && typeof hookedEvent === 'object') {
|