@ak--47/dungeon-master 1.3.0 → 1.4.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/CHANGELOG.md +58 -0
- package/dungeons/technical/array-of-object-lookup.js +0 -2
- 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/technical/simple.js +3 -4
- package/dungeons/technical/simplest-schema.json +5 -0
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +216 -164
- package/dungeons/vertical/community.js +74 -41
- package/dungeons/vertical/crypto.js +343 -233
- package/dungeons/vertical/dating.js +246 -292
- package/dungeons/vertical/devtools.js +179 -70
- package/dungeons/vertical/ecommerce.js +275 -122
- package/dungeons/vertical/education.js +345 -312
- package/dungeons/vertical/fintech.js +445 -314
- package/dungeons/vertical/fitness.js +199 -165
- package/dungeons/vertical/food-delivery.js +309 -330
- package/dungeons/vertical/gaming.js +387 -241
- package/dungeons/vertical/healthcare.js +149 -66
- package/dungeons/vertical/insurance-application.js +173 -75
- package/dungeons/vertical/logistics.js +107 -12
- package/dungeons/vertical/marketplace.js +171 -76
- package/dungeons/vertical/media.js +259 -402
- package/dungeons/vertical/real-estate.js +336 -318
- package/dungeons/vertical/sass.js +294 -288
- package/dungeons/vertical/social.js +289 -234
- package/dungeons/vertical/travel.js +66 -20
- package/index.js +17 -17
- package/lib/core/config-validator.js +226 -175
- package/lib/core/context.js +10 -24
- package/lib/core/storage.js +6 -1
- package/lib/generators/events.js +57 -104
- package/lib/generators/funnels.js +212 -93
- 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/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/user-loop.js +143 -293
- package/lib/utils/utils.js +97 -52
- 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/scripts/smoke-test-all.mjs +162 -0
- package/scripts/verify-runner.mjs +72 -24
- package/types.d.ts +434 -225
- 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/vertical/ai-platform-schema.json +0 -617
- package/dungeons/vertical/community-schema.json +0 -579
- package/dungeons/vertical/crypto-schema.json +0 -546
- package/dungeons/vertical/dating-schema.json +0 -401
- 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 -2703
- 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/real-estate-schema.json +0 -527
- 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/utils/utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import Chance from 'chance';
|
|
3
3
|
import readline from 'readline';
|
|
4
|
-
import { comma
|
|
4
|
+
import { comma } from 'ak-tools';
|
|
5
5
|
import dayjs from 'dayjs';
|
|
6
6
|
import utc from 'dayjs/plugin/utc.js';
|
|
7
7
|
import path from 'path';
|
|
@@ -24,7 +24,27 @@ const { NODE_ENV = "unknown" } = process.env;
|
|
|
24
24
|
let globalChance;
|
|
25
25
|
let chanceInitialized = false;
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Module-scoped memoization cache for weighted-array resolvers in `choose()`.
|
|
28
|
+
// Lives for the lifetime of the node process; key is the function source string.
|
|
29
|
+
const weightedArrayCache = new Map();
|
|
30
|
+
|
|
31
|
+
// Reference "now" used by date() and day() factories. Defaults to wall-clock at
|
|
32
|
+
// import time; the orchestrator overrides this via setDatasetNow() once the dataset
|
|
33
|
+
// window is resolved, so date helpers in dungeon configs produce deterministic
|
|
34
|
+
// values relative to the dataset end (not the process start).
|
|
35
|
+
let DATASET_NOW = dayjs.utc();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Override the reference "now" used by date()/day() factories. Called by the
|
|
39
|
+
* orchestrator with the resolved dataset end so every date helper produces
|
|
40
|
+
* deterministic values relative to the dataset window.
|
|
41
|
+
* @param {number} unixSeconds
|
|
42
|
+
*/
|
|
43
|
+
function setDatasetNow(unixSeconds) {
|
|
44
|
+
if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds)) {
|
|
45
|
+
DATASET_NOW = dayjs.unix(unixSeconds).utc();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
28
48
|
|
|
29
49
|
|
|
30
50
|
import { Storage as cloudStorage } from '@google-cloud/storage';
|
|
@@ -102,7 +122,7 @@ function pick(items) {
|
|
|
102
122
|
*/
|
|
103
123
|
function date(inTheLast = 30, isPast = true, format = 'YYYY-MM-DD') {
|
|
104
124
|
const chance = getChance();
|
|
105
|
-
const now =
|
|
125
|
+
const now = DATASET_NOW;
|
|
106
126
|
if (Math.abs(inTheLast) > 365 * 10) inTheLast = chance.integer({ min: 1, max: 180 });
|
|
107
127
|
return function () {
|
|
108
128
|
const when = chance.integer({ min: 0, max: Math.abs(inTheLast) });
|
|
@@ -159,8 +179,8 @@ function datesBetween(start, end) {
|
|
|
159
179
|
*/
|
|
160
180
|
function day(start, end) {
|
|
161
181
|
// if (!end) end = global.FIXED_NOW ? global.FIXED_NOW : dayjs().unix();
|
|
162
|
-
if (!start) start =
|
|
163
|
-
if (!end) end =
|
|
182
|
+
if (!start) start = DATASET_NOW.subtract(30, 'd').toISOString();
|
|
183
|
+
if (!end) end = DATASET_NOW.toISOString();
|
|
164
184
|
const chance = getChance();
|
|
165
185
|
const format = 'YYYY-MM-DD';
|
|
166
186
|
return function (min, max) {
|
|
@@ -219,20 +239,15 @@ function choose(value) {
|
|
|
219
239
|
while (typeof value === 'function') {
|
|
220
240
|
const funcString = value.toString();
|
|
221
241
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
global.weightedArrayCache = new Map();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (global.weightedArrayCache.has(funcString)) {
|
|
228
|
-
value = global.weightedArrayCache.get(funcString);
|
|
242
|
+
if (weightedArrayCache.has(funcString)) {
|
|
243
|
+
value = weightedArrayCache.get(funcString);
|
|
229
244
|
break;
|
|
230
245
|
}
|
|
231
246
|
|
|
232
247
|
const result = value();
|
|
233
248
|
if (Array.isArray(result) && result.length > 10) {
|
|
234
249
|
// Cache large arrays (likely weighted arrays)
|
|
235
|
-
|
|
250
|
+
weightedArrayCache.set(funcString, result);
|
|
236
251
|
}
|
|
237
252
|
value = result;
|
|
238
253
|
}
|
|
@@ -1141,21 +1156,14 @@ CORE
|
|
|
1141
1156
|
*/
|
|
1142
1157
|
|
|
1143
1158
|
//the function which generates $distinct_id + $anonymous_ids, $session_ids, and created, skewing towards the present
|
|
1144
|
-
function generateUser(user_id, opts
|
|
1159
|
+
function generateUser(user_id, opts) {
|
|
1145
1160
|
const chance = getChance();
|
|
1146
|
-
const { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds } = opts;
|
|
1147
|
-
//
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
// Sine function for a smoother curve
|
|
1151
|
-
const sineValue = (Math.sin(u * Math.PI * frequency - Math.PI / 2) * amplitude + 1) / 2;
|
|
1152
|
-
|
|
1153
|
-
// Scale the sineValue to the range of days
|
|
1154
|
-
let daysAgoBorn = Math.round(sineValue * (numDays - 1)) + 1;
|
|
1155
|
-
|
|
1156
|
-
// Clamp values to ensure they are within the desired range
|
|
1161
|
+
const { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix, avgDevicePerUser } = opts;
|
|
1162
|
+
// Birth date placement is overridden by bornRecentBias in user-loop.js;
|
|
1163
|
+
// use a simple uniform distribution as the seed value.
|
|
1164
|
+
let daysAgoBorn = Math.round(chance.random() * (numDays - 1)) + 1;
|
|
1157
1165
|
daysAgoBorn = Math.min(daysAgoBorn, numDays);
|
|
1158
|
-
const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds);
|
|
1166
|
+
const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix, avgDevicePerUser);
|
|
1159
1167
|
|
|
1160
1168
|
const user = {
|
|
1161
1169
|
distinct_id: user_id,
|
|
@@ -1188,7 +1196,7 @@ const DEFAULT_HOD_WEIGHTS = [
|
|
|
1188
1196
|
0.584, 0.574, 0.554, 0.576, 0.604, 0.655, 0.722, 0.816
|
|
1189
1197
|
];
|
|
1190
1198
|
|
|
1191
|
-
function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0, dayOfWeekWeights = DEFAULT_DOW_WEIGHTS, hourOfDayWeights = DEFAULT_HOD_WEIGHTS
|
|
1199
|
+
function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0, dayOfWeekWeights = DEFAULT_DOW_WEIGHTS, hourOfDayWeights = DEFAULT_HOD_WEIGHTS) {
|
|
1192
1200
|
if (!earliestTime) earliestTime = global.FIXED_BEGIN ? global.FIXED_BEGIN : dayjs().subtract(30, 'd').unix();
|
|
1193
1201
|
if (!latestTime) latestTime = global.FIXED_NOW ? global.FIXED_NOW : dayjs().unix();
|
|
1194
1202
|
const chance = getChance();
|
|
@@ -1215,7 +1223,7 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
|
|
|
1215
1223
|
// Phase 2: DOW accept/reject — retry if day-of-week doesn't pass weight check
|
|
1216
1224
|
if (dayOfWeekWeights) {
|
|
1217
1225
|
for (let attempt = 0; attempt < 50; attempt++) {
|
|
1218
|
-
const dow = new Date(
|
|
1226
|
+
const dow = new Date(candidate * 1000).getUTCDay();
|
|
1219
1227
|
if (chance.random() < dayOfWeekWeights[dow]) break;
|
|
1220
1228
|
// Rejected — resample from Gaussian chunks
|
|
1221
1229
|
const pi = integer(0, peaks - 1);
|
|
@@ -1231,8 +1239,7 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
|
|
|
1231
1239
|
|
|
1232
1240
|
// Phase 3: Redistribute hour-of-day (changes only hour within same day)
|
|
1233
1241
|
if (hourOfDayWeights) {
|
|
1234
|
-
const
|
|
1235
|
-
const d = new Date(shifted * 1000);
|
|
1242
|
+
const d = new Date(candidate * 1000);
|
|
1236
1243
|
const currentMinute = d.getUTCMinutes();
|
|
1237
1244
|
const currentSecond = d.getUTCSeconds();
|
|
1238
1245
|
|
|
@@ -1244,9 +1251,8 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
|
|
|
1244
1251
|
if (roll <= 0) { newHour = h; break; }
|
|
1245
1252
|
}
|
|
1246
1253
|
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
candidate = newShifted - timeShiftSeconds;
|
|
1254
|
+
const dayStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / 1000;
|
|
1255
|
+
candidate = dayStart + newHour * 3600 + currentMinute * 60 + currentSecond;
|
|
1250
1256
|
candidate = Math.max(earliestTime, Math.min(latestTime, candidate));
|
|
1251
1257
|
}
|
|
1252
1258
|
|
|
@@ -1262,9 +1268,13 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
|
|
|
1262
1268
|
* @param {boolean} hasAvatar
|
|
1263
1269
|
* @param {boolean} hasAnonIds
|
|
1264
1270
|
* @param {boolean} hasSessionIds
|
|
1271
|
+
* @param {number} [datasetEndUnix]
|
|
1272
|
+
* @param {number} [avgDevicePerUser] - Whole number ≥ 0. When ≥ 1 (and `hasAnonIds: true`),
|
|
1273
|
+
* sets the per-user device pool size. `1` = exactly one device. `>1` = drawn from
|
|
1274
|
+
* `chance.normal({mean: avgDevicePerUser, dev: avgDevicePerUser/2})`, clamped ≥ 1, integer.
|
|
1265
1275
|
* @return {Person}
|
|
1266
1276
|
*/
|
|
1267
|
-
function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false) {
|
|
1277
|
+
function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false, datasetEndUnix, avgDevicePerUser) {
|
|
1268
1278
|
const chance = getChance();
|
|
1269
1279
|
//names and photos
|
|
1270
1280
|
const l = chance.letter.bind(chance);
|
|
@@ -1278,7 +1288,11 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
|
|
|
1278
1288
|
let randomAvatarNumber = integer(1, 99);
|
|
1279
1289
|
let avPath = gender === 'male' ? `/men/${randomAvatarNumber}.jpg` : `/women/${randomAvatarNumber}.jpg`;
|
|
1280
1290
|
let avatar = avatarPrefix + avPath;
|
|
1281
|
-
|
|
1291
|
+
// Birth date: anchor to dataset end (post-resolution) so user.created lives inside
|
|
1292
|
+
// the configured window, not wall-clock time. Falls back to dayjs() only when
|
|
1293
|
+
// person() is called outside the normal generation pipeline (e.g. direct unit tests).
|
|
1294
|
+
const anchor = datasetEndUnix ? dayjs.unix(datasetEndUnix) : dayjs();
|
|
1295
|
+
let created = anchor.subtract(bornDaysAgo, 'day').format('YYYY-MM-DD');
|
|
1282
1296
|
|
|
1283
1297
|
|
|
1284
1298
|
// const created = date(bornDaysAgo, true)();
|
|
@@ -1303,11 +1317,28 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
|
|
|
1303
1317
|
|
|
1304
1318
|
if (!hasAvatar) delete user.avatar;
|
|
1305
1319
|
|
|
1306
|
-
//
|
|
1320
|
+
// Device pool ("anonymousIds" — name preserved for backwards compat).
|
|
1321
|
+
// Phase 2 identity model: pool size is governed by `avgDevicePerUser` when set,
|
|
1322
|
+
// else legacy 2–10 random pool size for any dungeon that just sets `hasAnonIds: true`
|
|
1323
|
+
// without `avgDevicePerUser`. (Validator coerces `hasAnonIds: true` to an effective
|
|
1324
|
+
// `avgDevicePerUser >= 1`, so this branch fires whenever there's a device pool to build.)
|
|
1307
1325
|
if (hasAnonIds) {
|
|
1308
|
-
|
|
1326
|
+
let clusterSize;
|
|
1327
|
+
if (typeof avgDevicePerUser === 'number' && avgDevicePerUser >= 1) {
|
|
1328
|
+
if (avgDevicePerUser === 1) {
|
|
1329
|
+
clusterSize = 1;
|
|
1330
|
+
} else {
|
|
1331
|
+
const sd = avgDevicePerUser / 2;
|
|
1332
|
+
const sample = chance.normal({ mean: avgDevicePerUser, dev: sd });
|
|
1333
|
+
clusterSize = Math.max(1, Math.round(sample));
|
|
1334
|
+
}
|
|
1335
|
+
} else {
|
|
1336
|
+
// Legacy fallback (pre-1.4 behavior): random pool of 2–10 devices.
|
|
1337
|
+
clusterSize = integer(2, 10);
|
|
1338
|
+
}
|
|
1309
1339
|
for (let i = 0; i < clusterSize; i++) {
|
|
1310
|
-
|
|
1340
|
+
// Use seeded chance, not ak-tools uid() (which uses Math.random).
|
|
1341
|
+
const anonId = chance.string({ length: 42, pool: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' });
|
|
1311
1342
|
user.anonymousIds.push(anonId);
|
|
1312
1343
|
}
|
|
1313
1344
|
}
|
|
@@ -1429,11 +1460,26 @@ function deepClone(thing, opts) {
|
|
|
1429
1460
|
|
|
1430
1461
|
|
|
1431
1462
|
/**
|
|
1432
|
-
* Generates a session ID in the standard format
|
|
1463
|
+
* Generates a session ID in the standard format. Derives it deterministically
|
|
1464
|
+
* from a seed string (first event time + user_id, typically) via quickHash so
|
|
1465
|
+
* we don't consume from the main seeded RNG stream — that consumption would
|
|
1466
|
+
* cascade into all downstream events whenever the number of sessions changed
|
|
1467
|
+
* (e.g. when sessionTimeout differs).
|
|
1468
|
+
* @param {string} [seedStr] - Stable input string. If absent, falls back to a
|
|
1469
|
+
* chance.string() pull (only used by tests / direct callers without a seed).
|
|
1433
1470
|
* @returns {string} Session ID like "xxxxx-xxxxx-xxxxx-xxxxx"
|
|
1434
1471
|
*/
|
|
1435
|
-
function generateSessionId() {
|
|
1436
|
-
|
|
1472
|
+
function generateSessionId(seedStr) {
|
|
1473
|
+
if (seedStr) {
|
|
1474
|
+
// quickHash returns ~16 hex chars; expand to 4×5 segments via re-hashing.
|
|
1475
|
+
const h1 = quickHash(seedStr);
|
|
1476
|
+
const h2 = quickHash(h1);
|
|
1477
|
+
const all = (h1 + h2).replace(/[^a-zA-Z0-9]/g, '').padEnd(20, '0');
|
|
1478
|
+
return [all.slice(0, 5), all.slice(5, 10), all.slice(10, 15), all.slice(15, 20)].join('-');
|
|
1479
|
+
}
|
|
1480
|
+
const c = getChance();
|
|
1481
|
+
const seg = () => c.string({ length: 5, pool: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' });
|
|
1482
|
+
return [seg(), seg(), seg(), seg()].join("-");
|
|
1437
1483
|
}
|
|
1438
1484
|
|
|
1439
1485
|
/**
|
|
@@ -1459,7 +1505,7 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
|
|
|
1459
1505
|
|
|
1460
1506
|
const chance = getChance();
|
|
1461
1507
|
const { earliestTime, latestTime, peaks, deviation, mean,
|
|
1462
|
-
dayOfWeekWeights, hourOfDayWeights
|
|
1508
|
+
dayOfWeekWeights, hourOfDayWeights } = soupParams;
|
|
1463
1509
|
|
|
1464
1510
|
// Sort by time first
|
|
1465
1511
|
events.sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
|
|
@@ -1472,8 +1518,8 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
|
|
|
1472
1518
|
const anchors = [];
|
|
1473
1519
|
for (let i = 0; i < numSessions; i++) {
|
|
1474
1520
|
const soupTime = TimeSoup(earliestTime, latestTime, peaks, deviation, mean,
|
|
1475
|
-
dayOfWeekWeights, hourOfDayWeights
|
|
1476
|
-
anchors.push(soupTime
|
|
1521
|
+
dayOfWeekWeights, hourOfDayWeights);
|
|
1522
|
+
anchors.push(soupTime);
|
|
1477
1523
|
}
|
|
1478
1524
|
anchors.sort((a, b) => a - b);
|
|
1479
1525
|
|
|
@@ -1485,7 +1531,6 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
|
|
|
1485
1531
|
}
|
|
1486
1532
|
|
|
1487
1533
|
// Retime events within each session
|
|
1488
|
-
let writeIndex = 0;
|
|
1489
1534
|
for (let s = 0; s < numSessions; s++) {
|
|
1490
1535
|
const bucket = sessionBuckets[s];
|
|
1491
1536
|
if (bucket.length === 0) continue;
|
|
@@ -1493,17 +1538,13 @@ function bunchIntoSessions(events, timeoutMinutes, soupParams) {
|
|
|
1493
1538
|
let currentTime = anchors[s];
|
|
1494
1539
|
for (let e = 0; e < bucket.length; e++) {
|
|
1495
1540
|
const ev = bucket[e];
|
|
1496
|
-
const clampedTime = Math.min(currentTime,
|
|
1541
|
+
const clampedTime = Math.min(currentTime, latestTime);
|
|
1497
1542
|
|
|
1498
1543
|
ev.time = dayjs.unix(clampedTime).toISOString();
|
|
1499
1544
|
// Regenerate insert_id to match new time
|
|
1500
1545
|
const distinctId = ev.user_id || ev.device_id || ev.distinct_id || '';
|
|
1501
1546
|
ev.insert_id = quickHash(`${ev.event}-${ev.time}-${distinctId}`);
|
|
1502
1547
|
|
|
1503
|
-
if (currentTime > maxTime) {
|
|
1504
|
-
ev._drop = true;
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
1548
|
// Advance time within session: 5-300 seconds (5s to 5min)
|
|
1508
1549
|
currentTime += chance.integer({ min: 5, max: 300 });
|
|
1509
1550
|
}
|
|
@@ -1532,7 +1573,10 @@ function assignSessionIds(events, timeoutMinutes = 30) {
|
|
|
1532
1573
|
const timeoutMs = timeoutMinutes * 60 * 1000;
|
|
1533
1574
|
const maxSessionMs = 24 * 60 * 60 * 1000;
|
|
1534
1575
|
|
|
1535
|
-
|
|
1576
|
+
// Derive a stable seed from the first event so session IDs are deterministic
|
|
1577
|
+
// across runs without consuming the main seeded RNG stream.
|
|
1578
|
+
const userKey = events[0].user_id || events[0].device_id || events[0].distinct_id || '';
|
|
1579
|
+
let currentSessionId = generateSessionId(`${userKey}-${events[0].time}`);
|
|
1536
1580
|
let sessionStartMs = new Date(events[0].time).getTime();
|
|
1537
1581
|
let lastEventMs = sessionStartMs;
|
|
1538
1582
|
|
|
@@ -1542,7 +1586,7 @@ function assignSessionIds(events, timeoutMinutes = 30) {
|
|
|
1542
1586
|
const sessionDuration = eventMs - sessionStartMs;
|
|
1543
1587
|
|
|
1544
1588
|
if (gapFromLast > timeoutMs || sessionDuration > maxSessionMs) {
|
|
1545
|
-
currentSessionId = generateSessionId();
|
|
1589
|
+
currentSessionId = generateSessionId(`${userKey}-${event.time}`);
|
|
1546
1590
|
sessionStartMs = eventMs;
|
|
1547
1591
|
}
|
|
1548
1592
|
|
|
@@ -1606,4 +1650,5 @@ export {
|
|
|
1606
1650
|
generateSessionId,
|
|
1607
1651
|
assignSessionIds,
|
|
1608
1652
|
bunchIntoSessions,
|
|
1653
|
+
setDatasetNow,
|
|
1609
1654
|
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixpanel breakdown emulator.
|
|
3
|
+
*
|
|
4
|
+
* Best-effort approximation of the table shapes Mixpanel produces for the five
|
|
5
|
+
* analyses the Phase 4 hook patterns target. Used by `verify-hooks` to assert
|
|
6
|
+
* that engineered patterns actually produce the expected ratios in the data, AND
|
|
7
|
+
* by consumers who want to validate dungeons against expected business shapes
|
|
8
|
+
* outside of Mixpanel.
|
|
9
|
+
*
|
|
10
|
+
* Reference: Mixpanel Insights / Funnels / Flows reports, as of 2026-05.
|
|
11
|
+
*
|
|
12
|
+
* Caveats:
|
|
13
|
+
* - Mixpanel applies its own per-account UTC offset and time-bucketing rules. This
|
|
14
|
+
* emulator uses raw event times unless the breakdown explicitly involves a window.
|
|
15
|
+
* - Mixpanel "users" are typically distinct profiles with at least one event in
|
|
16
|
+
* the date range; this emulator counts unique `user_id` (falling back to
|
|
17
|
+
* `distinct_id`) found across the events array.
|
|
18
|
+
* - This is not bit-exact — it's the *shape* check Phase 4 verification needs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} EmulateOptions
|
|
23
|
+
* @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'} type
|
|
24
|
+
*
|
|
25
|
+
* @property {string} [metricEvent]
|
|
26
|
+
* @property {string} [breakdownByFrequencyOf]
|
|
27
|
+
* @property {boolean} [perUser]
|
|
28
|
+
*
|
|
29
|
+
* @property {string[]} [steps]
|
|
30
|
+
*
|
|
31
|
+
* @property {string} [event]
|
|
32
|
+
* @property {string} [property]
|
|
33
|
+
* @property {'avg'|'sum'|'count'|'max'|'min'} [agg]
|
|
34
|
+
*
|
|
35
|
+
* @property {string} [fromEvent]
|
|
36
|
+
* @property {string} [toEvent]
|
|
37
|
+
* @property {string} [breakdownByUserProperty]
|
|
38
|
+
* @property {Array<Object>} [profiles]
|
|
39
|
+
*
|
|
40
|
+
* @property {string} [conversionEvent]
|
|
41
|
+
* @property {string} [attributionEvent]
|
|
42
|
+
* @property {string} [attributionProperty]
|
|
43
|
+
* @property {'firstTouch'|'lastTouch'} [model]
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run a Mixpanel breakdown emulation against an events array.
|
|
48
|
+
* Routes to the type-specific implementation based on `config.type`.
|
|
49
|
+
*
|
|
50
|
+
* @param {Array<Object>} events
|
|
51
|
+
* @param {EmulateOptions} config
|
|
52
|
+
* @returns {Array<Object>} Breakdown table rows.
|
|
53
|
+
*/
|
|
54
|
+
export function emulateBreakdown(events, config) {
|
|
55
|
+
if (!Array.isArray(events)) throw new Error('emulateBreakdown: events must be an array');
|
|
56
|
+
if (!config || !config.type) throw new Error('emulateBreakdown: config.type is required');
|
|
57
|
+
switch (config.type) {
|
|
58
|
+
case 'frequencyByFrequency': return frequencyByFrequency(events, /** @type {*} */ (config));
|
|
59
|
+
case 'funnelFrequency': return funnelFrequency(events, /** @type {*} */ (config));
|
|
60
|
+
case 'aggregatePerUser': return aggregatePerUser(events, /** @type {*} */ (config));
|
|
61
|
+
case 'timeToConvert': return timeToConvert(events, /** @type {*} */ (config));
|
|
62
|
+
case 'attributedBy': return attributedBy(events, /** @type {*} */ (config));
|
|
63
|
+
default: throw new Error(`emulateBreakdown: unknown type "${config.type}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Frequency × Frequency (Insights, Frequency Distribution by per-user count of B) ──
|
|
68
|
+
|
|
69
|
+
function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf }) {
|
|
70
|
+
if (!metricEvent || !breakdownByFrequencyOf) {
|
|
71
|
+
throw new Error('frequencyByFrequency requires metricEvent and breakdownByFrequencyOf');
|
|
72
|
+
}
|
|
73
|
+
const userMetric = new Map();
|
|
74
|
+
const userBreakdown = new Map();
|
|
75
|
+
const uids = new Set();
|
|
76
|
+
for (const ev of events) {
|
|
77
|
+
const uid = userIdOf(ev);
|
|
78
|
+
if (!uid) continue;
|
|
79
|
+
uids.add(uid);
|
|
80
|
+
if (ev.event === metricEvent) userMetric.set(uid, (userMetric.get(uid) || 0) + 1);
|
|
81
|
+
if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
const cell = new Map(); // `${m}|${b}` → user_count
|
|
84
|
+
for (const uid of uids) {
|
|
85
|
+
const m = userMetric.get(uid) || 0;
|
|
86
|
+
const b = userBreakdown.get(uid) || 0;
|
|
87
|
+
const key = `${m}|${b}`;
|
|
88
|
+
cell.set(key, (cell.get(key) || 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
return [...cell.entries()].map(([k, count]) => {
|
|
91
|
+
const [m, b] = k.split('|').map(Number);
|
|
92
|
+
return { metric_freq: m, breakdown_freq: b, user_count: count };
|
|
93
|
+
}).sort((x, y) => x.breakdown_freq - y.breakdown_freq || x.metric_freq - y.metric_freq);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Funnel Frequency Breakdown (Funnel report broken down by per-user count of X) ──
|
|
97
|
+
|
|
98
|
+
function funnelFrequency(events, { steps, breakdownByFrequencyOf }) {
|
|
99
|
+
if (!Array.isArray(steps) || !steps.length) throw new Error('funnelFrequency requires steps[]');
|
|
100
|
+
if (!breakdownByFrequencyOf) throw new Error('funnelFrequency requires breakdownByFrequencyOf');
|
|
101
|
+
const userEvents = groupByUser(events);
|
|
102
|
+
const userBreakdown = new Map();
|
|
103
|
+
for (const [uid, evs] of userEvents) {
|
|
104
|
+
const c = evs.filter(e => e && e.event === breakdownByFrequencyOf).length;
|
|
105
|
+
userBreakdown.set(uid, c);
|
|
106
|
+
}
|
|
107
|
+
const result = [];
|
|
108
|
+
for (let s = 0; s < steps.length; s++) {
|
|
109
|
+
const stepName = steps[s];
|
|
110
|
+
const conversions = new Map(); // breakdown_freq → count
|
|
111
|
+
for (const [uid, evs] of userEvents) {
|
|
112
|
+
const sorted = sortByTime(evs);
|
|
113
|
+
let stepIdx = 0;
|
|
114
|
+
for (const ev of sorted) {
|
|
115
|
+
if (ev.event === steps[stepIdx]) stepIdx++;
|
|
116
|
+
if (stepIdx > s) break;
|
|
117
|
+
}
|
|
118
|
+
if (stepIdx > s) {
|
|
119
|
+
const b = userBreakdown.get(uid) || 0;
|
|
120
|
+
conversions.set(b, (conversions.get(b) || 0) + 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const [b, c] of conversions) {
|
|
124
|
+
result.push({ step: stepName, step_index: s, breakdown_freq: b, conversions: c, conversion_pct: 0 });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Conversion % at each step relative to its own breakdown_freq's step-0 baseline.
|
|
128
|
+
const baseline = new Map();
|
|
129
|
+
for (const r of result) {
|
|
130
|
+
if (r.step_index === 0) baseline.set(r.breakdown_freq, r.conversions);
|
|
131
|
+
}
|
|
132
|
+
for (const r of result) {
|
|
133
|
+
const denom = baseline.get(r.breakdown_freq) || 0;
|
|
134
|
+
r.conversion_pct = denom ? (r.conversions / denom) * 100 : 0;
|
|
135
|
+
}
|
|
136
|
+
return result.sort((a, b) => a.step_index - b.step_index || a.breakdown_freq - b.breakdown_freq);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Aggregate per user (Insights, sum/avg of property X by per-user count of B) ──
|
|
140
|
+
|
|
141
|
+
function aggregatePerUser(events, { event, property, agg = 'avg', breakdownByFrequencyOf }) {
|
|
142
|
+
if (!event) throw new Error('aggregatePerUser requires event');
|
|
143
|
+
if (!breakdownByFrequencyOf) throw new Error('aggregatePerUser requires breakdownByFrequencyOf');
|
|
144
|
+
if (agg !== 'count' && !property) throw new Error('aggregatePerUser requires property unless agg is "count"');
|
|
145
|
+
const userVals = new Map();
|
|
146
|
+
const userBreakdown = new Map();
|
|
147
|
+
for (const ev of events) {
|
|
148
|
+
const uid = userIdOf(ev);
|
|
149
|
+
if (!uid) continue;
|
|
150
|
+
if (ev.event === event) {
|
|
151
|
+
// `agg: 'count'` → count occurrences of the event regardless of property type.
|
|
152
|
+
// All other aggs only consider numeric property values.
|
|
153
|
+
if (agg === 'count') {
|
|
154
|
+
if (!userVals.has(uid)) userVals.set(uid, []);
|
|
155
|
+
userVals.get(uid).push(1);
|
|
156
|
+
} else if (property && typeof ev[property] === 'number') {
|
|
157
|
+
if (!userVals.has(uid)) userVals.set(uid, []);
|
|
158
|
+
userVals.get(uid).push(ev[property]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
|
|
162
|
+
}
|
|
163
|
+
const userAgg = new Map();
|
|
164
|
+
for (const [uid, vals] of userVals) userAgg.set(uid, applyAgg(vals, agg));
|
|
165
|
+
const buckets = new Map(); // breakdown_freq → [aggregates]
|
|
166
|
+
for (const [uid, v] of userAgg) {
|
|
167
|
+
const b = userBreakdown.get(uid) || 0;
|
|
168
|
+
if (!buckets.has(b)) buckets.set(b, []);
|
|
169
|
+
buckets.get(b).push(v);
|
|
170
|
+
}
|
|
171
|
+
return [...buckets.entries()].map(([b, vs]) => ({
|
|
172
|
+
breakdown_freq: b,
|
|
173
|
+
user_count: vs.length,
|
|
174
|
+
avg_aggregate: vs.reduce((a, x) => a + x, 0) / vs.length,
|
|
175
|
+
})).sort((x, y) => x.breakdown_freq - y.breakdown_freq);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Time to Convert (Funnel TTC, broken down by user property) ──
|
|
179
|
+
|
|
180
|
+
function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, profiles = [] }) {
|
|
181
|
+
if (!fromEvent || !toEvent) throw new Error('timeToConvert requires fromEvent and toEvent');
|
|
182
|
+
const userEvents = groupByUser(events);
|
|
183
|
+
const profileByUid = new Map();
|
|
184
|
+
for (const p of profiles) {
|
|
185
|
+
if (!p) continue;
|
|
186
|
+
const uid = p.distinct_id || p.user_id;
|
|
187
|
+
if (uid) profileByUid.set(uid, p);
|
|
188
|
+
}
|
|
189
|
+
const buckets = new Map(); // segValue → [ttcMs]
|
|
190
|
+
for (const [uid, evs] of userEvents) {
|
|
191
|
+
const sorted = sortByTime(evs);
|
|
192
|
+
const a = sorted.find(e => e && e.event === fromEvent);
|
|
193
|
+
if (!a) continue;
|
|
194
|
+
const aIdx = sorted.indexOf(a);
|
|
195
|
+
const b = sorted.slice(aIdx + 1).find(e => e && e.event === toEvent);
|
|
196
|
+
if (!b) continue;
|
|
197
|
+
const ttcMs = toMs(b.time) - toMs(a.time);
|
|
198
|
+
if (!Number.isFinite(ttcMs) || ttcMs < 0) continue;
|
|
199
|
+
const profile = profileByUid.get(uid);
|
|
200
|
+
const segValue = breakdownByUserProperty
|
|
201
|
+
? (profile ? (profile[breakdownByUserProperty] ?? 'unknown') : 'unknown')
|
|
202
|
+
: 'all';
|
|
203
|
+
if (!buckets.has(segValue)) buckets.set(segValue, []);
|
|
204
|
+
buckets.get(segValue).push(ttcMs);
|
|
205
|
+
}
|
|
206
|
+
return [...buckets.entries()].map(([seg, ttcs]) => ({
|
|
207
|
+
segment_value: seg,
|
|
208
|
+
user_count: ttcs.length,
|
|
209
|
+
avg_ttc_ms: ttcs.reduce((a, x) => a + x, 0) / ttcs.length,
|
|
210
|
+
median_ttc_ms: median(ttcs),
|
|
211
|
+
})).sort((x, y) => String(x.segment_value).localeCompare(String(y.segment_value)));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Attributed By (first-/last-touch attribution by event property value) ──
|
|
215
|
+
|
|
216
|
+
function attributedBy(events, { conversionEvent, attributionEvent, attributionProperty, model = 'firstTouch' }) {
|
|
217
|
+
if (!conversionEvent || !attributionEvent || !attributionProperty) {
|
|
218
|
+
throw new Error('attributedBy requires conversionEvent, attributionEvent, attributionProperty');
|
|
219
|
+
}
|
|
220
|
+
const userEvents = groupByUser(events);
|
|
221
|
+
const counts = new Map();
|
|
222
|
+
for (const [uid, evs] of userEvents) {
|
|
223
|
+
const sorted = sortByTime(evs);
|
|
224
|
+
const conversion = sorted.find(e => e && e.event === conversionEvent);
|
|
225
|
+
if (!conversion) continue;
|
|
226
|
+
const conversionTime = toMs(conversion.time);
|
|
227
|
+
const touches = sorted.filter(e =>
|
|
228
|
+
e && e.event === attributionEvent && toMs(e.time) <= conversionTime
|
|
229
|
+
);
|
|
230
|
+
if (!touches.length) continue;
|
|
231
|
+
const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
|
|
232
|
+
const v = touch[attributionProperty] ?? 'unknown';
|
|
233
|
+
counts.set(v, (counts.get(v) || 0) + 1);
|
|
234
|
+
}
|
|
235
|
+
return [...counts.entries()].map(([source, count]) => ({
|
|
236
|
+
attribution_value: source,
|
|
237
|
+
conversions: count,
|
|
238
|
+
})).sort((a, b) => b.conversions - a.conversions);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── shared helpers ──
|
|
242
|
+
|
|
243
|
+
function userIdOf(ev) {
|
|
244
|
+
return ev && (ev.user_id || ev.distinct_id || ev.device_id);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function groupByUser(events) {
|
|
248
|
+
const userEvents = new Map();
|
|
249
|
+
for (const ev of events) {
|
|
250
|
+
const uid = userIdOf(ev);
|
|
251
|
+
if (!uid) continue;
|
|
252
|
+
if (!userEvents.has(uid)) userEvents.set(uid, []);
|
|
253
|
+
userEvents.get(uid).push(ev);
|
|
254
|
+
}
|
|
255
|
+
return userEvents;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function sortByTime(evs) {
|
|
259
|
+
return evs.slice().sort((a, b) => toMs(a && a.time) - toMs(b && b.time));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
import { toMs } from '../hook-helpers/_internal.js';
|
|
263
|
+
|
|
264
|
+
function applyAgg(vals, agg) {
|
|
265
|
+
if (!vals || !vals.length) return 0;
|
|
266
|
+
switch (agg) {
|
|
267
|
+
case 'sum': return vals.reduce((a, b) => a + b, 0);
|
|
268
|
+
case 'count': return vals.length;
|
|
269
|
+
case 'max': return Math.max(...vals);
|
|
270
|
+
case 'min': return Math.min(...vals);
|
|
271
|
+
case 'avg':
|
|
272
|
+
default: return vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function median(arr) {
|
|
277
|
+
if (!arr.length) return 0;
|
|
278
|
+
const sorted = arr.slice().sort((a, b) => a - b);
|
|
279
|
+
const mid = Math.floor(sorted.length / 2);
|
|
280
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
281
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ak--47/dungeon-master/verify — Phase 4 verification surface.
|
|
3
|
+
*
|
|
4
|
+
* `emulateBreakdown` produces the table shapes Mixpanel shows for the five
|
|
5
|
+
* supported analyses (frequencyByFrequency, funnelFrequency, aggregatePerUser,
|
|
6
|
+
* timeToConvert, attributedBy). `verifyDungeon` is the higher-level wrapper
|
|
7
|
+
* that runs a dungeon and asserts emulator outputs match expected ratios — wire
|
|
8
|
+
* this into your CI to catch dungeon drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { emulateBreakdown } from './emulate-breakdown.js';
|
|
12
|
+
export { verifyDungeon } from './verify-dungeon.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyDungeon — run a dungeon (in-memory) and run a series of emulator checks
|
|
3
|
+
* against its output, returning a structured report. Designed for CI use:
|
|
4
|
+
*
|
|
5
|
+
* const report = await verifyDungeon(dungeonConfig, [
|
|
6
|
+
* {
|
|
7
|
+
* name: 'engaged users do 2x purchases',
|
|
8
|
+
* breakdown: { type: 'frequencyByFrequency',
|
|
9
|
+
* metricEvent: 'Purchase', breakdownByFrequencyOf: 'Browse' },
|
|
10
|
+
* assert: (rows) => {
|
|
11
|
+
* // custom assertion against the emulator's output table
|
|
12
|
+
* return { pass: true, detail: 'looks good' };
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ]);
|
|
16
|
+
* if (!report.pass) process.exit(1);
|
|
17
|
+
*
|
|
18
|
+
* The dungeon is run via the same default-export entry point external consumers
|
|
19
|
+
* use, so this is a true end-to-end check.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import DUNGEON_MASTER from '../../index.js';
|
|
23
|
+
import { emulateBreakdown } from './emulate-breakdown.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} VerifyCheck
|
|
27
|
+
* @property {string} name - Human-readable name for the check.
|
|
28
|
+
* @property {Object} breakdown - Argument passed to `emulateBreakdown`.
|
|
29
|
+
* @property {(rows: Array<Object>, ctx: { events: Array<Object>, profiles: Array<Object> }) => { pass: boolean, detail?: string }} assert
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
|
|
34
|
+
* @param {VerifyCheck[]} checks
|
|
35
|
+
* @returns {Promise<{ pass: boolean, results: Array<{ name: string, pass: boolean, detail?: string, rows?: Array<Object> }> }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function verifyDungeon(config, checks) {
|
|
38
|
+
if (!checks || !checks.length) throw new Error('verifyDungeon: at least one check required');
|
|
39
|
+
let result = await DUNGEON_MASTER(config);
|
|
40
|
+
if (Array.isArray(result)) result = result[0];
|
|
41
|
+
const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData);
|
|
42
|
+
const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData);
|
|
43
|
+
const ctx = { events, profiles };
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const check of checks) {
|
|
46
|
+
try {
|
|
47
|
+
const breakdownArgs = { ...check.breakdown };
|
|
48
|
+
// timeToConvert + attributedBy may want profiles; auto-inject if not provided.
|
|
49
|
+
if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
|
|
50
|
+
breakdownArgs.profiles = profiles;
|
|
51
|
+
}
|
|
52
|
+
const rows = emulateBreakdown(events, breakdownArgs);
|
|
53
|
+
const verdict = check.assert(rows, ctx);
|
|
54
|
+
results.push({ name: check.name, pass: !!verdict.pass, detail: verdict.detail, rows });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
results.push({ name: check.name, pass: false, detail: `error: ${err.message}` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const pass = results.every(r => r.pass);
|
|
60
|
+
return { pass, results };
|
|
61
|
+
}
|