@ak--47/dungeon-master 1.3.1 → 1.4.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 +58 -0
- package/dungeons/technical/hook-helpers-verify.js +89 -0
- package/dungeons/technical/identity-model-verify.js +47 -0
- package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
- package/dungeons/technical/pattern-attributed-by-source.js +42 -0
- package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
- package/dungeons/technical/pattern-funnel-frequency.js +54 -0
- package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
- package/dungeons/vertical/ai-platform.js +45 -52
- package/dungeons/vertical/community.js +11 -8
- package/dungeons/vertical/crypto.js +25 -24
- package/dungeons/vertical/dating.js +56 -48
- package/dungeons/vertical/devtools.js +25 -18
- package/dungeons/vertical/ecommerce.js +42 -38
- package/dungeons/vertical/education.js +24 -9
- package/dungeons/vertical/fintech.js +13 -8
- package/dungeons/vertical/fitness.js +73 -122
- package/dungeons/vertical/food-delivery.js +18 -19
- package/dungeons/vertical/gaming.js +19 -20
- package/dungeons/vertical/healthcare.js +11 -8
- package/dungeons/vertical/insurance-application.js +6 -3
- package/dungeons/vertical/logistics.js +15 -9
- package/dungeons/vertical/marketplace.js +36 -27
- package/dungeons/vertical/media.js +27 -25
- package/dungeons/vertical/real-estate.js +18 -7
- package/dungeons/vertical/sass.js +84 -68
- package/dungeons/vertical/social.js +46 -47
- package/dungeons/vertical/travel.js +8 -5
- package/index.js +17 -71
- package/lib/core/config-validator.js +143 -164
- package/lib/core/storage.js +5 -1
- package/lib/generators/events.js +49 -93
- package/lib/generators/funnels.js +202 -91
- package/lib/hook-helpers/_internal.js +23 -0
- package/lib/hook-helpers/cohort.js +124 -0
- package/lib/hook-helpers/identity.js +56 -0
- package/lib/hook-helpers/index.js +44 -0
- package/lib/hook-helpers/inject.js +99 -0
- package/lib/hook-helpers/mutate.js +151 -0
- package/lib/hook-helpers/timing.js +99 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
- package/lib/hook-patterns/attributed-by-source.js +72 -0
- package/lib/hook-patterns/frequency-by-frequency.js +46 -0
- package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
- package/lib/hook-patterns/index.js +14 -0
- package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
- package/lib/orchestrators/mixpanel-sender.js +46 -51
- package/lib/orchestrators/user-loop.js +119 -269
- package/lib/utils/utils.js +39 -16
- package/lib/verify/emulate-breakdown.js +281 -0
- package/lib/verify/index.js +12 -0
- package/lib/verify/verify-dungeon.js +61 -0
- package/package.json +6 -4
- package/types.d.ts +404 -212
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "harness-social";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -107,7 +107,7 @@ const chance = u.initChance(SEED);
|
|
|
107
107
|
* receive follow-backs and post more frequently.
|
|
108
108
|
*
|
|
109
109
|
* -------------------------------------------------------------------------------------
|
|
110
|
-
* 3. ALGORITHM CHANGE (
|
|
110
|
+
* 3. ALGORITHM CHANGE (everything)
|
|
111
111
|
* -------------------------------------------------------------------------------------
|
|
112
112
|
*
|
|
113
113
|
* PATTERN: On day 45, the dominant `source` for "post viewed" flips from
|
|
@@ -146,7 +146,7 @@ const chance = u.initChance(SEED);
|
|
|
146
146
|
* quality is awful, dragging down avg watch time.
|
|
147
147
|
*
|
|
148
148
|
* -------------------------------------------------------------------------------------
|
|
149
|
-
* 5. NOTIFICATION RE-ENGAGEMENT (
|
|
149
|
+
* 5. NOTIFICATION RE-ENGAGEMENT (everything)
|
|
150
150
|
* -------------------------------------------------------------------------------------
|
|
151
151
|
*
|
|
152
152
|
* PATTERN: After day 30, 30% of "post viewed" events have source flipped
|
|
@@ -190,14 +190,14 @@ const chance = u.initChance(SEED);
|
|
|
190
190
|
* 7. TOXICITY CHURN (everything)
|
|
191
191
|
* -------------------------------------------------------------------------------------
|
|
192
192
|
*
|
|
193
|
-
* PATTERN: Users with
|
|
193
|
+
* PATTERN: Users with 2+ "report submitted" events lose 60% of activity
|
|
194
194
|
* after day 30. No flag — discover via retention or per-user activity drop.
|
|
195
195
|
*
|
|
196
196
|
* HOW TO FIND IT IN MIXPANEL:
|
|
197
197
|
*
|
|
198
198
|
* Report 1: Retention by Toxicity
|
|
199
199
|
* - Report type: Retention
|
|
200
|
-
* - Cohort A: users with >=
|
|
200
|
+
* - Cohort A: users with >= 2 "report submitted"
|
|
201
201
|
* - Cohort B: rest
|
|
202
202
|
* - Expected: A ~ 40% retention vs B ~ 80%
|
|
203
203
|
*
|
|
@@ -276,15 +276,17 @@ const postIds = v.range(1, 1001).map(n => `post_${v.uid(8)}`);
|
|
|
276
276
|
|
|
277
277
|
/** @type {Config} */
|
|
278
278
|
const config = {
|
|
279
|
+
version: 2,
|
|
279
280
|
token,
|
|
280
281
|
seed: SEED,
|
|
281
282
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
282
|
-
datasetEnd: "2026-
|
|
283
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
283
284
|
soup: { dayOfWeekWeights: [1.0, 1.0, 1.0, 1.0, 1.0, 1.2, 1.2] },
|
|
284
285
|
// numDays: num_days,
|
|
285
286
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
286
287
|
numUsers: num_users,
|
|
287
|
-
hasAnonIds:
|
|
288
|
+
hasAnonIds: true,
|
|
289
|
+
avgDevicePerUser: 2,
|
|
288
290
|
hasSessionIds: true,
|
|
289
291
|
format: "json",
|
|
290
292
|
gzip: true,
|
|
@@ -379,6 +381,7 @@ const config = {
|
|
|
379
381
|
event: "account created",
|
|
380
382
|
weight: 1,
|
|
381
383
|
isFirstEvent: true,
|
|
384
|
+
isAuthEvent: true,
|
|
382
385
|
properties: {
|
|
383
386
|
"signup_method": ["email", "google", "apple", "sso"],
|
|
384
387
|
"referred_by": ["organic", "friend", "ad", "influencer"],
|
|
@@ -576,43 +579,6 @@ const config = {
|
|
|
576
579
|
}
|
|
577
580
|
|
|
578
581
|
|
|
579
|
-
// ─── EVENT-LEVEL HOOKS ───────────────────────────────────────────
|
|
580
|
-
|
|
581
|
-
if (type === "event") {
|
|
582
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
583
|
-
const ALGORITHM_CHANGE_DAY = datasetStart.add(45, 'days');
|
|
584
|
-
const REENGAGEMENT_START = datasetStart.add(30, 'days');
|
|
585
|
-
const EVENT_TIME = dayjs(record.time);
|
|
586
|
-
|
|
587
|
-
// Hook #3: ALGORITHM CHANGE - Day 45 flips feed -> explore.
|
|
588
|
-
// Mutates the existing config-defined `source` prop.
|
|
589
|
-
if (record.event === "post viewed") {
|
|
590
|
-
if (EVENT_TIME.isAfter(ALGORITHM_CHANGE_DAY)) {
|
|
591
|
-
if (chance.bool({ likelihood: 70 })) {
|
|
592
|
-
record.source = "explore";
|
|
593
|
-
}
|
|
594
|
-
} else {
|
|
595
|
-
if (chance.bool({ likelihood: 70 })) {
|
|
596
|
-
record.source = "feed";
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Hook #4: ENGAGEMENT BAIT - 20% of post views get crushed view duration.
|
|
602
|
-
// No flag — analyst sees bimodal duration distribution + low-tail share.
|
|
603
|
-
if (record.event === "post viewed") {
|
|
604
|
-
if (chance.bool({ likelihood: 20 })) {
|
|
605
|
-
record.view_duration_sec = chance.integer({ min: 1, max: 5 });
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Hook #5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views
|
|
609
|
-
// flip source to "notification". Mutates existing source prop.
|
|
610
|
-
if (EVENT_TIME.isAfter(REENGAGEMENT_START) && chance.bool({ likelihood: 30 })) {
|
|
611
|
-
record.source = "notification";
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
582
|
// ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
|
|
617
583
|
|
|
618
584
|
if (type === "everything") {
|
|
@@ -782,9 +748,42 @@ const config = {
|
|
|
782
748
|
}
|
|
783
749
|
}
|
|
784
750
|
|
|
751
|
+
// Hook #3: ALGORITHM CHANGE — day 45 flips feed → explore on post viewed.
|
|
752
|
+
// Hook #4: ENGAGEMENT BAIT — 20% of post-viewed events get crushed duration.
|
|
753
|
+
// Hook #5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views → notification.
|
|
754
|
+
// All three run AFTER injection passes so they apply to cloned events too.
|
|
755
|
+
const ALGORITHM_CHANGE_DAY = datasetStart.add(45, 'days');
|
|
756
|
+
const REENGAGEMENT_START = datasetStart.add(30, 'days');
|
|
757
|
+
userEvents.forEach(e => {
|
|
758
|
+
if (e.event === "post viewed") {
|
|
759
|
+
const eventTime = dayjs(e.time);
|
|
760
|
+
|
|
761
|
+
// Hook #3: Algorithm Change
|
|
762
|
+
if (eventTime.isAfter(ALGORITHM_CHANGE_DAY)) {
|
|
763
|
+
if (chance.bool({ likelihood: 70 })) {
|
|
764
|
+
e.source = "explore";
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
if (chance.bool({ likelihood: 70 })) {
|
|
768
|
+
e.source = "feed";
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Hook #4: Engagement Bait — 20% crushed view duration
|
|
773
|
+
if (chance.bool({ likelihood: 20 })) {
|
|
774
|
+
e.view_duration_sec = chance.integer({ min: 1, max: 5 });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Hook #5: Notification Re-engagement (runs after #3 so can override)
|
|
778
|
+
if (eventTime.isAfter(REENGAGEMENT_START) && chance.bool({ likelihood: 30 })) {
|
|
779
|
+
e.source = "notification";
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
785
784
|
// Hook #7: TOXICITY CHURN — drop 60% of activity after day 30 for high reporters.
|
|
786
|
-
// Discovery: cohort users with >=
|
|
787
|
-
if (reportSubmittedCount >=
|
|
785
|
+
// Discovery: cohort users with >=2 report-submitted events, observe retention drop.
|
|
786
|
+
if (reportSubmittedCount >= 2) {
|
|
788
787
|
const churnCutoff = datasetStart.add(30, 'days');
|
|
789
788
|
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
790
789
|
const evt = userEvents[i];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "dm4-travel";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -29,7 +29,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
29
29
|
* StayQuest — a hotel booking platform for business and leisure travelers.
|
|
30
30
|
* Users search destinations, compare hotels, book rooms, and leave reviews.
|
|
31
31
|
*
|
|
32
|
-
* - 5,000 users over
|
|
32
|
+
* - 5,000 users over 120 days, ~600K events
|
|
33
33
|
* - Segments: business travelers (weekday), leisure families, luxury, budget
|
|
34
34
|
* - Core loop: search → view hotel → compare → book → stay → review
|
|
35
35
|
* - Revenue: commission per booking + premium loyalty membership
|
|
@@ -210,14 +210,16 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
210
210
|
|
|
211
211
|
/** @type {Config} */
|
|
212
212
|
const config = {
|
|
213
|
+
version: 2,
|
|
213
214
|
token,
|
|
214
215
|
seed: SEED,
|
|
215
216
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
216
|
-
datasetEnd: "2026-
|
|
217
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
217
218
|
// numDays: num_days,
|
|
218
219
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
219
220
|
numUsers: num_users,
|
|
220
|
-
hasAnonIds:
|
|
221
|
+
hasAnonIds: true,
|
|
222
|
+
avgDevicePerUser: 2,
|
|
221
223
|
hasSessionIds: true,
|
|
222
224
|
format: "json",
|
|
223
225
|
gzip: true,
|
|
@@ -250,6 +252,7 @@ const config = {
|
|
|
250
252
|
event: "account created",
|
|
251
253
|
weight: 1,
|
|
252
254
|
isFirstEvent: true,
|
|
255
|
+
isAuthEvent: true,
|
|
253
256
|
properties: {
|
|
254
257
|
signup_source: ["organic", "google", "instagram", "tripadvisor", "referral", "email_campaign"],
|
|
255
258
|
},
|
package/index.js
CHANGED
|
@@ -18,14 +18,14 @@ import { detectInputType, loadFromFile, loadFromText, parseJSONDungeon, validate
|
|
|
18
18
|
|
|
19
19
|
// Orchestrators
|
|
20
20
|
import { userLoop } from './lib/orchestrators/user-loop.js';
|
|
21
|
-
import { sendToMixpanel } from './lib/orchestrators/mixpanel-sender.js';
|
|
21
|
+
import { sendToMixpanel, collectWrittenFiles } from './lib/orchestrators/mixpanel-sender.js';
|
|
22
22
|
// Generators
|
|
23
23
|
import { makeAdSpend } from './lib/generators/adspend.js';
|
|
24
24
|
import { makeMirror } from './lib/generators/mirror.js';
|
|
25
25
|
import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
|
|
26
26
|
|
|
27
27
|
// Utilities
|
|
28
|
-
import { initChance, setDatasetNow } from './lib/utils/utils.js';
|
|
28
|
+
import { initChance, setDatasetNow, deleteFile } from './lib/utils/utils.js';
|
|
29
29
|
|
|
30
30
|
// External dependencies
|
|
31
31
|
import dayjs from "dayjs";
|
|
@@ -122,6 +122,7 @@ async function runDungeon(config) {
|
|
|
122
122
|
|
|
123
123
|
if (config.verbose) logger.info({ seed: config.seed }, 'Configuring dungeon');
|
|
124
124
|
let validatedConfig;
|
|
125
|
+
let storage;
|
|
125
126
|
try {
|
|
126
127
|
// Initialize seeded RNG BEFORE validation — config-validator captures a
|
|
127
128
|
// chance reference for default userProps (spiritAnimal). If we init after,
|
|
@@ -148,7 +149,7 @@ async function runDungeon(config) {
|
|
|
148
149
|
|
|
149
150
|
// Step 3: Initialize storage containers
|
|
150
151
|
const storageManager = new StorageManager(context);
|
|
151
|
-
|
|
152
|
+
storage = await storageManager.initializeContainers();
|
|
152
153
|
updateContextWithStorage(context, storage);
|
|
153
154
|
|
|
154
155
|
// ! DATA GENERATION STARTS HERE
|
|
@@ -215,7 +216,7 @@ async function runDungeon(config) {
|
|
|
215
216
|
return {
|
|
216
217
|
...extractedData,
|
|
217
218
|
importResults,
|
|
218
|
-
files:
|
|
219
|
+
files: extractFileInfo(storage),
|
|
219
220
|
time: { start, end, delta, human },
|
|
220
221
|
operations: context.getOperations(),
|
|
221
222
|
eventCount: context.getStoredEventCount(),
|
|
@@ -225,6 +226,15 @@ async function runDungeon(config) {
|
|
|
225
226
|
} catch (error) {
|
|
226
227
|
logger.error({ err: error }, `Error: ${error.message}`);
|
|
227
228
|
throw error;
|
|
229
|
+
} finally {
|
|
230
|
+
if (validatedConfig?.cleanup && storage) {
|
|
231
|
+
const allFiles = collectWrittenFiles(storage);
|
|
232
|
+
if (allFiles.length > 0) {
|
|
233
|
+
if (validatedConfig.verbose) console.log(`\nCleaning up ${allFiles.length} written files...`);
|
|
234
|
+
await Promise.allSettled(allFiles.map(f => deleteFile(f)));
|
|
235
|
+
if (validatedConfig.verbose) console.log(`Cleanup complete`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
228
238
|
}
|
|
229
239
|
}
|
|
230
240
|
|
|
@@ -480,74 +490,10 @@ async function flushStorageToDisk(storage, config) {
|
|
|
480
490
|
/**
|
|
481
491
|
* Extract file information from storage containers
|
|
482
492
|
* @param {import('./types').Storage} storage - Storage object
|
|
483
|
-
* @
|
|
484
|
-
* @returns {Promise<string[]>} Array of file paths
|
|
493
|
+
* @returns {string[]} Array of file paths
|
|
485
494
|
*/
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
// Try to get paths from containers first
|
|
490
|
-
Object.values(storage).forEach(container => {
|
|
491
|
-
if (Array.isArray(container)) {
|
|
492
|
-
container.forEach(subContainer => {
|
|
493
|
-
if (subContainer?.getWritePath) {
|
|
494
|
-
files.push(subContainer.getWritePath());
|
|
495
|
-
}
|
|
496
|
-
});
|
|
497
|
-
} else if (container?.getWritePath) {
|
|
498
|
-
files.push(container.getWritePath());
|
|
499
|
-
}
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
// If no files found from containers and writeToDisk is enabled, scan the data directory
|
|
503
|
-
if (files.length === 0 && config.writeToDisk) {
|
|
504
|
-
try {
|
|
505
|
-
const fs = await import('fs');
|
|
506
|
-
const path = await import('path');
|
|
507
|
-
|
|
508
|
-
let dataDir = path.resolve("./data");
|
|
509
|
-
if (!fs.existsSync(dataDir)) {
|
|
510
|
-
dataDir = path.resolve("./");
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (fs.existsSync(dataDir)) {
|
|
514
|
-
const allFiles = fs.readdirSync(dataDir);
|
|
515
|
-
const simulationName = config.name;
|
|
516
|
-
|
|
517
|
-
// Filter files that match our patterns and were likely created by this run
|
|
518
|
-
const relevantFiles = allFiles.filter(file => {
|
|
519
|
-
// Skip system files
|
|
520
|
-
if (file.startsWith('.')) return false;
|
|
521
|
-
|
|
522
|
-
// If we have a simulation name, only include files with that prefix
|
|
523
|
-
if (simulationName && !file.startsWith(simulationName)) {
|
|
524
|
-
return false;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Check for common patterns
|
|
528
|
-
const hasEventPattern = file.includes('-EVENTS.');
|
|
529
|
-
const hasUserPattern = file.includes('-USERS.');
|
|
530
|
-
const hasScdPattern = file.includes('-SCD.');
|
|
531
|
-
const hasGroupPattern = file.includes('-GROUPS.');
|
|
532
|
-
const hasLookupPattern = file.includes('-LOOKUP.');
|
|
533
|
-
const hasAdspendPattern = file.includes('-ADSPEND.');
|
|
534
|
-
const hasMirrorPattern = file.includes('-MIRROR.');
|
|
535
|
-
|
|
536
|
-
return hasEventPattern || hasUserPattern || hasScdPattern ||
|
|
537
|
-
hasGroupPattern || hasLookupPattern || hasAdspendPattern || hasMirrorPattern;
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
// Convert to full paths
|
|
541
|
-
relevantFiles.forEach(file => {
|
|
542
|
-
files.push(path.join(dataDir, file));
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
} catch (error) {
|
|
546
|
-
// If scanning fails, just return empty array
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
return files;
|
|
495
|
+
function extractFileInfo(storage) {
|
|
496
|
+
return collectWrittenFiles(storage);
|
|
551
497
|
}
|
|
552
498
|
|
|
553
499
|
/**
|