@ak--47/dungeon-master 1.4.4 → 1.5.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/.claude/skills/analyze-soup/SKILL.md +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +147 -0
- package/HOOKS.md +1243 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +1 -1
- package/dungeons/technical/anonymous-users.js +1 -1
- package/dungeons/technical/array-of-object-lookup.js +1 -1
- package/dungeons/technical/datagen-v15-verify.js +74 -0
- package/dungeons/technical/experiments.js +1 -1
- package/dungeons/technical/foobar.js +1 -1
- package/dungeons/technical/group-analytics.js +1 -1
- package/dungeons/technical/mirror-strategies.js +1 -1
- package/dungeons/technical/nested-objects.js +1 -1
- package/dungeons/technical/retention-cadence.js +1 -1
- package/dungeons/technical/sanity.js +1 -1
- package/dungeons/technical/scale-test.js +1 -1
- package/dungeons/technical/scd.js +1 -1
- package/dungeons/technical/simple.js +1 -1
- package/dungeons/technical/simplest.js +74 -20
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +4 -0
- package/dungeons/vertical/community.js +9 -3
- package/dungeons/vertical/crypto.js +5 -0
- package/dungeons/vertical/dating.js +23 -10
- package/dungeons/vertical/devtools.js +10 -0
- package/dungeons/vertical/ecommerce.js +6 -0
- package/dungeons/vertical/education.js +11 -0
- package/dungeons/vertical/fintech.js +13 -0
- package/dungeons/vertical/fitness.js +10 -0
- package/dungeons/vertical/food-delivery.js +9 -0
- package/dungeons/vertical/gaming.js +10 -0
- package/dungeons/vertical/healthcare.js +5 -0
- package/dungeons/vertical/insurance-application.js +10 -0
- package/dungeons/vertical/logistics.js +8 -1
- package/dungeons/vertical/marketplace.js +7 -0
- package/dungeons/vertical/media.js +8 -0
- package/dungeons/vertical/real-estate.js +7 -1
- package/dungeons/vertical/sass.js +12 -0
- package/dungeons/vertical/social.js +9 -0
- package/dungeons/vertical/travel.js +5 -0
- package/index.js +45 -7
- package/lib/core/config-validator.js +270 -7
- package/lib/core/context.js +58 -0
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +72 -1
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +27 -1
- package/lib/orchestrators/user-loop.js +488 -29
- package/lib/templates/macro-presets.js +39 -9
- package/lib/utils/utils.js +16 -79
- package/lib/verify/counting.js +320 -0
- package/lib/verify/emulate-breakdown.js +512 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +19 -0
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +4 -2
- package/types.d.ts +314 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -30,6 +30,28 @@ import { validateSchema } from './schema-validator.js';
|
|
|
30
30
|
* @property {(rows: Array<Object>, ctx: { events: Array<Object>, profiles: Array<Object> }) => { pass: boolean, detail?: string }} assert
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Try to find the dungeon funnel whose `sequence` matches the breakdown's funnel
|
|
35
|
+
* steps. Used to auto-apply v1.5 `conversionWindowDays` + `order`-mode dispatch
|
|
36
|
+
* without forcing every check author to thread these args by hand.
|
|
37
|
+
* @param {Array<Object>} funnels
|
|
38
|
+
* @param {string[]} steps
|
|
39
|
+
* @returns {Object | null}
|
|
40
|
+
*/
|
|
41
|
+
function findMatchingFunnel(funnels, steps) {
|
|
42
|
+
if (!Array.isArray(funnels) || !Array.isArray(steps) || !steps.length) return null;
|
|
43
|
+
for (const f of funnels) {
|
|
44
|
+
if (!f || !Array.isArray(f.sequence)) continue;
|
|
45
|
+
if (f.sequence.length !== steps.length) continue;
|
|
46
|
+
let same = true;
|
|
47
|
+
for (let i = 0; i < steps.length; i++) {
|
|
48
|
+
if (f.sequence[i] !== steps[i]) { same = false; break; }
|
|
49
|
+
}
|
|
50
|
+
if (same) return f;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
/**
|
|
34
56
|
* @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
|
|
35
57
|
* @param {VerifyCheck[]} checks
|
|
@@ -44,12 +66,48 @@ export async function verifyDungeon(config, checks) {
|
|
|
44
66
|
const schemaReport = validateSchema(events, config);
|
|
45
67
|
const ctx = { events, profiles, schemaReport };
|
|
46
68
|
const results = [];
|
|
69
|
+
// `validateDungeonConfig` mutates funnels in place — by the time we read here,
|
|
70
|
+
// `conversionWindowDays` is populated and `order` is the validated value.
|
|
71
|
+
const validatedFunnels = (config && Array.isArray(config.funnels)) ? config.funnels : [];
|
|
47
72
|
for (const check of checks) {
|
|
48
73
|
try {
|
|
49
74
|
const breakdownArgs = { ...check.breakdown };
|
|
50
75
|
if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
|
|
51
76
|
breakdownArgs.profiles = profiles;
|
|
52
77
|
}
|
|
78
|
+
// v1.5: auto-apply funnel-level config when the check targets a funnel.
|
|
79
|
+
if (breakdownArgs.type === 'funnelFrequency' || breakdownArgs.type === 'timeToConvert') {
|
|
80
|
+
const targetSteps = breakdownArgs.steps
|
|
81
|
+
|| (breakdownArgs.fromEvent && breakdownArgs.toEvent
|
|
82
|
+
? [breakdownArgs.fromEvent, breakdownArgs.toEvent]
|
|
83
|
+
: null);
|
|
84
|
+
const matched = findMatchingFunnel(validatedFunnels, targetSteps);
|
|
85
|
+
if (matched) {
|
|
86
|
+
if (breakdownArgs.conversionWindowMs === undefined && Number.isFinite(matched.conversionWindowDays)) {
|
|
87
|
+
breakdownArgs.conversionWindowMs = matched.conversionWindowDays * 86400000;
|
|
88
|
+
}
|
|
89
|
+
if (breakdownArgs.funnelOrder === undefined && matched.order) {
|
|
90
|
+
breakdownArgs.funnelOrder = matched.order;
|
|
91
|
+
}
|
|
92
|
+
// v1.5.0: thread Funnel-level extension hints through to the verifier.
|
|
93
|
+
if (breakdownArgs.reentry === undefined && matched.reentry !== undefined) {
|
|
94
|
+
breakdownArgs.reentry = matched.reentry;
|
|
95
|
+
}
|
|
96
|
+
if (breakdownArgs.exclusionSteps === undefined && Array.isArray(matched.exclusionEvents) && matched.exclusionEvents.length) {
|
|
97
|
+
breakdownArgs.exclusionSteps = matched.exclusionEvents.map(name => ({ event: name }));
|
|
98
|
+
}
|
|
99
|
+
// stepFilters: Record<number, { prop, op, value }> → mutate steps to attach where-clause.
|
|
100
|
+
if (matched.stepFilters && breakdownArgs.steps && Array.isArray(breakdownArgs.steps)) {
|
|
101
|
+
breakdownArgs.steps = breakdownArgs.steps.map((s, i) => {
|
|
102
|
+
const filter = matched.stepFilters[i];
|
|
103
|
+
if (!filter) return s;
|
|
104
|
+
const stepObj = typeof s === 'string' ? { event: s } : { ...s };
|
|
105
|
+
if (!stepObj.where) stepObj.where = filter;
|
|
106
|
+
return stepObj;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
53
111
|
const rows = emulateBreakdown(events, breakdownArgs);
|
|
54
112
|
const verdict = check.assert(rows, ctx);
|
|
55
113
|
results.push({ name: check.name, pass: !!verdict.pass, detail: verdict.detail, rows });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ak--47/dungeon-master",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"dungeons/",
|
|
24
24
|
"!dungeons/customers/",
|
|
25
25
|
"!dungeons/user/",
|
|
26
|
+
"!dungeons/capstone/",
|
|
26
27
|
"scripts/",
|
|
28
|
+
".claude/skills/",
|
|
27
29
|
"package.json",
|
|
28
30
|
"README.md",
|
|
29
31
|
"CHANGELOG.md",
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
"test:coverage": "NODE_ENV=test vitest run --coverage",
|
|
42
44
|
"typecheck": "tsc --noEmit",
|
|
43
45
|
"dev": "nodemon scratch.mjs --ignore ./data/*",
|
|
44
|
-
"prune": "
|
|
46
|
+
"prune": "find ./data -mindepth 1 -not -name .gitkeep -delete && find ./tmp -mindepth 1 -not -name .gitkeep -delete && rm -f vscode-profile-*",
|
|
45
47
|
"dungeon:run": "node ./scripts/run-dungeon.mjs",
|
|
46
48
|
"dungeon:to-json": "node ./scripts/dungeon-to-json.mjs",
|
|
47
49
|
"dungeon:from-json": "node ./scripts/json-to-dungeon.mjs",
|
package/types.d.ts
CHANGED
|
@@ -28,6 +28,11 @@ export interface Dungeon {
|
|
|
28
28
|
/**
|
|
29
29
|
* Number of days the dataset spans. Default: 30.
|
|
30
30
|
*
|
|
31
|
+
* Safe range: `[14, 365]`. Below 14 → strict-bar engine-validation metrics use
|
|
32
|
+
* 14-day windows; results are noisy. Above 365 → memory cost grows linearly.
|
|
33
|
+
* Validator emits a warning below 14; does not clamp (because the window may have
|
|
34
|
+
* been pinned via `datasetStart`/`datasetEnd` upstream).
|
|
35
|
+
*
|
|
31
36
|
* Three resolution modes:
|
|
32
37
|
* 1. **`numDays` alone (no datasetStart/End):** Window = `[today - numDays, today]`.
|
|
33
38
|
* Simplest API for ad-hoc dungeons. NOT deterministic across runs (today changes).
|
|
@@ -58,7 +63,14 @@ export interface Dungeon {
|
|
|
58
63
|
numEvents?: number;
|
|
59
64
|
/** Number of unique users to generate. */
|
|
60
65
|
numUsers?: number;
|
|
61
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Average events per user per active day. The canonical event-volume primitive —
|
|
68
|
+
* born-late users get this rate × their remaining window, so per-day density stays
|
|
69
|
+
* constant. If both this and numEvents are set, this wins.
|
|
70
|
+
*
|
|
71
|
+
* Safe range: `[0.1, 50]`. Above 50 → unrealistic load + memory cost; the v1.5
|
|
72
|
+
* validator strict-clamps to 50 with a warning.
|
|
73
|
+
*/
|
|
62
74
|
avgEventsPerUserPerDay?: number;
|
|
63
75
|
/** Output format for files written to disk. */
|
|
64
76
|
format?: "csv" | "json" | "parquet" | string;
|
|
@@ -119,6 +131,24 @@ export interface Dungeon {
|
|
|
119
131
|
gzip?: boolean;
|
|
120
132
|
/** If true, prints progress to stdout during generation. */
|
|
121
133
|
verbose?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Optional callback that receives periodic progress updates during generation,
|
|
136
|
+
* import, and pipeline step transitions. Fire-and-forget: the callback is never
|
|
137
|
+
* awaited. If it throws 3 times, it is silently disabled for the rest of the job.
|
|
138
|
+
*
|
|
139
|
+
* The `update` argument is a discriminated union on `phase`:
|
|
140
|
+
* - `"generation"` — user/event counts, EPS, memory, percent complete
|
|
141
|
+
* - `"import"` — record type, processed/total counts, EPS, bytes
|
|
142
|
+
* - `"step"` — pipeline step name with start/complete status and duration
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* onProgress: (update) => {
|
|
146
|
+
* if (update.phase === 'generation') ws.send(JSON.stringify(update));
|
|
147
|
+
* }
|
|
148
|
+
*/
|
|
149
|
+
onProgress?: (update: ProgressUpdate) => void;
|
|
150
|
+
/** Minimum interval (ms) between progress callback invocations. Default: 500. Only throttles `generation` and `import` phases; `step` updates always fire immediately. */
|
|
151
|
+
progressInterval?: number;
|
|
122
152
|
/**
|
|
123
153
|
* @deprecated Prefer `avgDevicePerUser`. `true` is now an alias for `avgDevicePerUser: 1`
|
|
124
154
|
* (single sticky device per user, every event stamped with that `device_id`). `false`
|
|
@@ -224,12 +254,81 @@ export interface Dungeon {
|
|
|
224
254
|
// ── Distribution Controls ──
|
|
225
255
|
// These three knobs are normally set by the `macro` preset (default "flat").
|
|
226
256
|
// Setting them on the dungeon config directly overrides the preset's value.
|
|
227
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Percentage of users whose account creation falls within the dataset window (vs. pre-existing).
|
|
259
|
+
*
|
|
260
|
+
* Safe range: `[0, 100]` (sanity-clamped). Recommended `[0, 60]`. **v1.5 strict-clamp:**
|
|
261
|
+
* when `macro` is set to a named preset AND the user explicitly sets this field, it is
|
|
262
|
+
* clamped to the preset's default born% (flat=12, steady=12, growth=30, viral=55,
|
|
263
|
+
* decline=5) to preserve the macro's characteristic shape. Users who need higher born%
|
|
264
|
+
* should switch macros (flat→growth, growth→viral). When `macro` is not set, no clamp
|
|
265
|
+
* fires (legacy backward-compat).
|
|
266
|
+
*/
|
|
228
267
|
percentUsersBornInDataset?: number;
|
|
229
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Bias for birth dates of users born in dataset. -1..1; negative = early skew,
|
|
270
|
+
* positive = recent skew, 0 = uniform.
|
|
271
|
+
*
|
|
272
|
+
* Safe range: `[-0.5, 0.5]`. **v1.5 strict-clamp:** values outside `[-0.5, 0.5]` are
|
|
273
|
+
* clamped to the nearest bound (above 0.5 = unusable right-skew; below -0.5 = unusable
|
|
274
|
+
* left-skew). Compound clamp: when explicit `percentUsersBornInDataset > 60` AND
|
|
275
|
+
* explicit `bornRecentBias > 0.4`, bias is clamped to 0.3 to prevent right-edge
|
|
276
|
+
* cumulative-acquisition explosion. Macro presets (e.g., viral=0.6) are exempt.
|
|
277
|
+
*/
|
|
230
278
|
bornRecentBias?: number;
|
|
231
279
|
/** How pre-existing users' first event time is placed. "pinned" stacks them all at FIXED_BEGIN; "uniform" spreads across [FIXED_BEGIN-30d, FIXED_BEGIN]. Default (from macro: "flat"): "uniform" */
|
|
232
280
|
preExistingSpread?: "pinned" | "uniform";
|
|
281
|
+
|
|
282
|
+
// ── v1.5 Distinct-Day + Mixpanel Cap Primitives ──
|
|
283
|
+
/**
|
|
284
|
+
* Mean number of distinct UTC days each user fires events on. CONCENTRATOR semantic —
|
|
285
|
+
* total event count is preserved (still `avgEventsPerUserPerDay × userActiveDays`),
|
|
286
|
+
* but events cluster onto fewer days. Per-user count drawn from
|
|
287
|
+
* `normal(mean=avgActiveDaysPerUser, sd=mean/3)`, clamped to `[1, userActiveDays]`.
|
|
288
|
+
*
|
|
289
|
+
* Default: undefined (legacy — every window-day potentially active, no concentration).
|
|
290
|
+
*
|
|
291
|
+
* Per-active-day rate inflates: `(avgEventsPerUserPerDay × numDays) / avgActiveDaysPerUser`.
|
|
292
|
+
* Validator warns when implied per-active-day rate > 50.
|
|
293
|
+
*
|
|
294
|
+
* Day picking: weighted-without-replacement from candidate UTC days using
|
|
295
|
+
* `soup.dayOfWeekWeights`, so weekly rhythm is preserved at the cohort level.
|
|
296
|
+
*
|
|
297
|
+
* **Incompatibility with `engagementDecay`:** decay drops events from late picked days,
|
|
298
|
+
* eroding the effective active-day count below the configured target. Use one or the
|
|
299
|
+
* other; if you need both, write decay logic in an `everything` hook scoped to specific
|
|
300
|
+
* cohorts. See HOOKS.md §2.5.
|
|
301
|
+
*
|
|
302
|
+
* Safe range: `[1, numDays * 0.5]`. Above 50% of `numDays` defeats the concentrator
|
|
303
|
+
* purpose; the v1.5 validator strict-clamps to `floor(numDays * 0.5)` with a warning.
|
|
304
|
+
*/
|
|
305
|
+
avgActiveDaysPerUser?: number;
|
|
306
|
+
/**
|
|
307
|
+
* Maximum number of UTM-stamped events per user. Matches Mixpanel's `TOUCHPOINTS_LIMIT`
|
|
308
|
+
* (`backend/libquery/properties_over_time/attributed_value_reader.cpp` line 16).
|
|
309
|
+
*
|
|
310
|
+
* Default: 10.
|
|
311
|
+
*
|
|
312
|
+
* When `hasCampaigns: true` and a user has more eligible events than the cap, the
|
|
313
|
+
* engine takes a uniform-random sample of size `cap` from the eligible pool (seeded,
|
|
314
|
+
* deterministic), sorts the sample chronologically, then stamps UTMs. Sampling
|
|
315
|
+
* across the user's lifetime — NOT first-N-chronological — preserves realistic
|
|
316
|
+
* touch distribution and lets Mixpanel's last-10-window report give meaningful
|
|
317
|
+
* first/last-touch attribution.
|
|
318
|
+
*
|
|
319
|
+
* Set to `Infinity` to disable the cap (legacy behavior, ~25% of eligible events stamped).
|
|
320
|
+
*/
|
|
321
|
+
maxTouchpointsPerUser?: number;
|
|
322
|
+
/**
|
|
323
|
+
* If true (default), the engine sorts each user's events ascending by `time` after
|
|
324
|
+
* the `everything` hook returns, before the events are pushed to storage. Defends
|
|
325
|
+
* against the most common new footgun: hooks that `push()` cloned events with arbitrary
|
|
326
|
+
* timestamps and break the greedy funnel engine's chronological-order requirement.
|
|
327
|
+
*
|
|
328
|
+
* Set to `false` to preserve the hook's order (advanced — hook must guarantee its own
|
|
329
|
+
* ordering for downstream Mixpanel-aligned counting).
|
|
330
|
+
*/
|
|
331
|
+
autoSortAfterEverything?: boolean;
|
|
233
332
|
}
|
|
234
333
|
|
|
235
334
|
export type SCDProp = {
|
|
@@ -657,6 +756,12 @@ export interface Context {
|
|
|
657
756
|
incrementUserCount(): void;
|
|
658
757
|
incrementEventCount(): void;
|
|
659
758
|
isBatchMode(): boolean;
|
|
759
|
+
|
|
760
|
+
// Progress callback
|
|
761
|
+
/** Fire a progress update to the caller's `onProgress` callback (throttled, fault-tolerant). */
|
|
762
|
+
reportProgress(update: ProgressUpdate): void;
|
|
763
|
+
/** Return the progress callback summary (updates delivered, errors, disabled flag). */
|
|
764
|
+
getProgressSummary(): ProgressSummary;
|
|
660
765
|
}
|
|
661
766
|
|
|
662
767
|
/**
|
|
@@ -796,6 +901,22 @@ export interface Funnel {
|
|
|
796
901
|
* the time it takes (on average) to convert in hours
|
|
797
902
|
*/
|
|
798
903
|
timeToConvert?: number;
|
|
904
|
+
/**
|
|
905
|
+
* Mixpanel-style conversion window cap, in DAYS. Funnel-step events after step 0 must
|
|
906
|
+
* land within `conversionWindowDays * 86400000` ms of step 0's timestamp (strict `<`,
|
|
907
|
+
* matching `is_within_conversion_window` in `backend/arb/reader/funnels/conversion_window.cpp`).
|
|
908
|
+
*
|
|
909
|
+
* Default: 30 (Mixpanel UI default).
|
|
910
|
+
*
|
|
911
|
+
* If unset and `timeToConvert / 24 >= 30`, the validator auto-bumps to
|
|
912
|
+
* `min(180, ceil(timeToConvert / 24 * 1.5))` and warns. Hard cap: 180 days
|
|
913
|
+
* (Mixpanel's maximum). Validator throws if explicitly set above 180.
|
|
914
|
+
*
|
|
915
|
+
* The generator caps funnel runs at `conversionWindowDays * 86400000 - 1` ms (1ms slack
|
|
916
|
+
* to clear the strict-`<` boundary). The verifier (`verifyDungeon`) reads this field
|
|
917
|
+
* and applies it automatically when emulating funnel breakdowns.
|
|
918
|
+
*/
|
|
919
|
+
conversionWindowDays?: number;
|
|
799
920
|
/**
|
|
800
921
|
* funnel properties go onto each event in the funnel and are held constant
|
|
801
922
|
*/
|
|
@@ -834,6 +955,32 @@ export interface Funnel {
|
|
|
834
955
|
* @see AttemptsConfig
|
|
835
956
|
*/
|
|
836
957
|
attempts?: AttemptsConfig;
|
|
958
|
+
|
|
959
|
+
// v1.5.0 — funnel extensions
|
|
960
|
+
/**
|
|
961
|
+
* v1.5.0 — Events that terminate the funnel attempt for non-converters. The generator
|
|
962
|
+
* stamps 1-2 cloned exclusion events between the last completed step and where the next
|
|
963
|
+
* step would have been; the verifier reads this and applies them as exclusionSteps to
|
|
964
|
+
* `evaluateFunnel`. Each entry MUST be declared in `events[]` (schema-first) — the
|
|
965
|
+
* validator throws otherwise.
|
|
966
|
+
*/
|
|
967
|
+
exclusionEvents?: string[];
|
|
968
|
+
/**
|
|
969
|
+
* v1.5.0 — Verifier-only hint. When `true`, the verifier evaluates with reentry
|
|
970
|
+
* enabled (counts every completion). Generator behavior unchanged.
|
|
971
|
+
*/
|
|
972
|
+
reentry?: boolean;
|
|
973
|
+
/**
|
|
974
|
+
* v1.5.0 — Verifier-only hint. Per-step property conditions; the verifier mutates
|
|
975
|
+
* its step list to attach `where`-clauses at the matching index. Generator behavior
|
|
976
|
+
* unchanged.
|
|
977
|
+
*/
|
|
978
|
+
stepFilters?: Record<number, {
|
|
979
|
+
prop: string;
|
|
980
|
+
op: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'not_contains';
|
|
981
|
+
value: unknown;
|
|
982
|
+
}>;
|
|
983
|
+
|
|
837
984
|
/** @internal Resolved experiment config set by config-validator. */
|
|
838
985
|
_experiment?: { name: string; variants: Array<{ name: string; conversionMultiplier: number; ttcMultiplier: number; weight: number }>; startUnix: number | null };
|
|
839
986
|
/** @internal Set by funnels.js during experiment handling. */
|
|
@@ -1053,8 +1200,61 @@ export type Result = {
|
|
|
1053
1200
|
userCount?: number;
|
|
1054
1201
|
groupCount?: number;
|
|
1055
1202
|
avgEPS?: number;
|
|
1203
|
+
/** Progress callback summary. Only present when `onProgress` was provided. */
|
|
1204
|
+
progress?: ProgressSummary;
|
|
1056
1205
|
};
|
|
1057
1206
|
|
|
1207
|
+
// ============= Progress Callback Types =============
|
|
1208
|
+
|
|
1209
|
+
/** Discriminator for progress update types. */
|
|
1210
|
+
export type ProgressPhase = "generation" | "import" | "step";
|
|
1211
|
+
|
|
1212
|
+
/** Progress update emitted during user/event generation (throttled to `progressInterval`). */
|
|
1213
|
+
export interface ProgressGeneration {
|
|
1214
|
+
phase: "generation";
|
|
1215
|
+
users: number;
|
|
1216
|
+
events: number;
|
|
1217
|
+
eps: number;
|
|
1218
|
+
memory: string;
|
|
1219
|
+
elapsed: string;
|
|
1220
|
+
percentComplete: number;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** Progress update emitted during Mixpanel import (throttled to `progressInterval`). */
|
|
1224
|
+
export interface ProgressImport {
|
|
1225
|
+
phase: "import";
|
|
1226
|
+
recordType: string;
|
|
1227
|
+
processed: number;
|
|
1228
|
+
total: number;
|
|
1229
|
+
eps: string;
|
|
1230
|
+
bytesProcessed: number;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Progress update emitted at pipeline step boundaries (not throttled). */
|
|
1234
|
+
export interface ProgressStep {
|
|
1235
|
+
phase: "step";
|
|
1236
|
+
step: string;
|
|
1237
|
+
status: "start" | "complete";
|
|
1238
|
+
/** Milliseconds elapsed (only present on `status: "complete"`). */
|
|
1239
|
+
duration?: number;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Discriminated union of all progress update types. Discriminate on the `phase` field. */
|
|
1243
|
+
export type ProgressUpdate = ProgressGeneration | ProgressImport | ProgressStep;
|
|
1244
|
+
|
|
1245
|
+
/** Convenience type for the `onProgress` callback signature. */
|
|
1246
|
+
export type ProgressCallback = (update: ProgressUpdate) => void;
|
|
1247
|
+
|
|
1248
|
+
/** Summary of progress callback activity, included in the job result. */
|
|
1249
|
+
export interface ProgressSummary {
|
|
1250
|
+
/** Total number of updates successfully delivered to the callback. */
|
|
1251
|
+
updates: number;
|
|
1252
|
+
/** Number of times the callback threw (0-3; disabled after 3). */
|
|
1253
|
+
errors: number;
|
|
1254
|
+
/** True if the callback was disabled due to repeated failures. */
|
|
1255
|
+
disabled: boolean;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1058
1258
|
// ============= Advanced Feature Types =============
|
|
1059
1259
|
|
|
1060
1260
|
/**
|
|
@@ -1550,7 +1750,7 @@ declare module '@ak--47/dungeon-master/hook-patterns' {
|
|
|
1550
1750
|
* | `attributedBy` | `conversionEvent`, `attributionEvent`, `attributionProperty` | `model` (default: `'lastTouch'`) |
|
|
1551
1751
|
*/
|
|
1552
1752
|
export interface EmulateOptions {
|
|
1553
|
-
type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy';
|
|
1753
|
+
type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy' | 'sessionMetrics' | 'retention';
|
|
1554
1754
|
metricEvent?: string;
|
|
1555
1755
|
breakdownByFrequencyOf?: string;
|
|
1556
1756
|
perUser?: boolean;
|
|
@@ -1566,6 +1766,74 @@ export interface EmulateOptions {
|
|
|
1566
1766
|
attributionEvent?: string;
|
|
1567
1767
|
attributionProperty?: string;
|
|
1568
1768
|
model?: 'firstTouch' | 'lastTouch';
|
|
1769
|
+
/**
|
|
1770
|
+
* Pre-built device→user map (e.g. from `buildIdentityMap(profiles)`). When omitted but
|
|
1771
|
+
* `profiles` are supplied with `device_ids`, the map is built automatically. Threaded
|
|
1772
|
+
* through every breakdown type so pre-auth (device_id) events resolve to the same
|
|
1773
|
+
* canonical user as post-auth (user_id) events.
|
|
1774
|
+
*/
|
|
1775
|
+
identityMap?: Map<string, string>;
|
|
1776
|
+
/** v1.5.0 — funnel reentry. After completing all steps, reset and continue scanning. Used by funnelFrequency + timeToConvert sequential modes. */
|
|
1777
|
+
reentry?: boolean;
|
|
1778
|
+
/** v1.5.0 — funnel exclusion steps. If an exclusion event fires between specified steps, terminate the attempt. */
|
|
1779
|
+
exclusionSteps?: Array<{ event: string; afterStep?: number; beforeStep?: number }>;
|
|
1780
|
+
/** v1.5.0 — funnel step property tracking. Captures matched event properties at each step into FunnelResult.stepProperties. */
|
|
1781
|
+
trackStepProperties?: boolean | string[];
|
|
1782
|
+
/** v1.5.0 — partition funnel evaluation by `session_id`; only steps in the same session can complete. */
|
|
1783
|
+
sessionScoped?: boolean;
|
|
1784
|
+
/** v1.5.0 — cross-cutting time-bucketed output. Wraps any breakdown type, returning rows tagged with `period` per UTC bucket. */
|
|
1785
|
+
timeBucket?: 'day' | 'week' | 'month';
|
|
1786
|
+
/**
|
|
1787
|
+
* v1.5.0 — when set with `timeBucket`, enumerates EVERY bucket in `[from, to]`
|
|
1788
|
+
* and emits a `{ period, _empty: true }` marker for buckets with no events
|
|
1789
|
+
* (Mixpanel normal_query.cpp emits zero rows for empty intervals). Without
|
|
1790
|
+
* this, only buckets containing events are returned.
|
|
1791
|
+
*
|
|
1792
|
+
* **Empty-row contract:** consumers MUST filter `r._empty` before any
|
|
1793
|
+
* numerical aggregation. Populated rows have full breakdown fields plus
|
|
1794
|
+
* `period`; empty rows have ONLY `period` and `_empty: true`.
|
|
1795
|
+
*/
|
|
1796
|
+
timeBucketRange?: { from: number | string; to: number | string };
|
|
1797
|
+
/** v1.5.0 — sessionMetrics: filter to sessions containing this event. Omit for all sessions. */
|
|
1798
|
+
metrics?: Array<'count' | 'duration' | 'eventsPerSession'>;
|
|
1799
|
+
|
|
1800
|
+
// v1.5.0 retention extensions
|
|
1801
|
+
/** Retention birth event name. */
|
|
1802
|
+
cohortEvent?: string;
|
|
1803
|
+
/** Retention return event name. */
|
|
1804
|
+
returnEvent?: string;
|
|
1805
|
+
/** Day buckets to check (offsets from birth, ≥1). */
|
|
1806
|
+
dayBuckets?: number[];
|
|
1807
|
+
/** Segment cohort by birth event property value (Mixpanel segment_event=FIRST). */
|
|
1808
|
+
segmentBy?: string;
|
|
1809
|
+
/** CARRY_FORWARD unbounded mode — once retained, counted on all later buckets. */
|
|
1810
|
+
carry_forward?: boolean;
|
|
1811
|
+
/**
|
|
1812
|
+
* v1.5.0 — Mixpanel `birth_can_retain` (retention_query.cpp:1097-1109). Default
|
|
1813
|
+
* `false`: a return event at the EXACT birth ms is NOT counted (strict `<`).
|
|
1814
|
+
* Set `true` to count exact-birth-ms returns (rare; usually a same-event-as-birth
|
|
1815
|
+
* pattern requires COMPOUNDED retention which is not supported here).
|
|
1816
|
+
*/
|
|
1817
|
+
birthCanRetain?: boolean;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** v1.5.0 — config for `emulateBreakdown({ type: 'retention' })`. */
|
|
1821
|
+
export interface RetentionConfig extends EmulateOptions {
|
|
1822
|
+
type: 'retention';
|
|
1823
|
+
cohortEvent: string;
|
|
1824
|
+
returnEvent: string;
|
|
1825
|
+
dayBuckets: number[];
|
|
1826
|
+
segmentBy?: string;
|
|
1827
|
+
carry_forward?: boolean;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/** v1.5.0 — config for `emulateBreakdown({ type: 'sessionMetrics' })`. */
|
|
1831
|
+
export interface SessionMetricsConfig extends EmulateOptions {
|
|
1832
|
+
type: 'sessionMetrics';
|
|
1833
|
+
/** Optional event filter — only sessions containing this event qualify. */
|
|
1834
|
+
event?: string;
|
|
1835
|
+
/** Which metrics to compute. Default: `['count', 'duration', 'eventsPerSession']`. */
|
|
1836
|
+
metrics?: Array<'count' | 'duration' | 'eventsPerSession'>;
|
|
1569
1837
|
}
|
|
1570
1838
|
|
|
1571
1839
|
declare module '@ak--47/dungeon-master/verify' {
|
|
@@ -1573,6 +1841,48 @@ declare module '@ak--47/dungeon-master/verify' {
|
|
|
1573
1841
|
export function verifyDungeon(config: Dungeon, checks: Array<{ name: string; breakdown: EmulateOptions; assert: (rows: Array<Record<string, unknown>>, ctx: { events: EventSchema[]; profiles: UserProfile[] }) => { pass: boolean; detail?: string } }>): Promise<{ pass: boolean; results: Array<{ name: string; pass: boolean; detail?: string; rows?: Array<Record<string, unknown>> }>; schemaReport: SchemaReport }>;
|
|
1574
1842
|
export function deriveExpectedSchema(config: Dungeon): Map<string, Set<string>>;
|
|
1575
1843
|
export function validateSchema(events: EventSchema[], config: Dungeon): SchemaReport;
|
|
1844
|
+
/** Build a `Map<device_id, canonical_user_id>` by inverting each profile's `device_ids` array. */
|
|
1845
|
+
export function buildIdentityMap(profiles: UserProfile[]): Map<string, string>;
|
|
1846
|
+
/** Resolve canonical user id for an event using the identity map (device→user merge). */
|
|
1847
|
+
export function resolveUserId(event: EventSchema | Record<string, unknown>, identityMap?: Map<string, string>): string | undefined;
|
|
1848
|
+
|
|
1849
|
+
/** Funnel step type — string OR `{ event, where? }` for step-level property filters. */
|
|
1850
|
+
export type FunnelStep = string | { event: string; where?: { prop: string; op: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'not_contains'; value: unknown } };
|
|
1851
|
+
/** Exclusion step config — fires between `afterStep` and `beforeStep` to terminate the funnel attempt. */
|
|
1852
|
+
export interface ExclusionStep { event: string; afterStep?: number; beforeStep?: number }
|
|
1853
|
+
/** Greedy single-pass funnel evaluator options. */
|
|
1854
|
+
export interface FunnelOptions {
|
|
1855
|
+
conversionWindowMs?: number;
|
|
1856
|
+
graceperiod?: boolean;
|
|
1857
|
+
reentry?: boolean;
|
|
1858
|
+
exclusionSteps?: ExclusionStep[];
|
|
1859
|
+
trackStepProperties?: boolean | string[];
|
|
1860
|
+
countMode?: 'uniques' | 'totals';
|
|
1861
|
+
sessionScoped?: boolean;
|
|
1862
|
+
}
|
|
1863
|
+
/** Per-attempt funnel result. */
|
|
1864
|
+
export interface FunnelResult {
|
|
1865
|
+
completed: boolean;
|
|
1866
|
+
reached: number;
|
|
1867
|
+
stepEvents: Array<Record<string, unknown> | null>;
|
|
1868
|
+
stepTimes: Array<number | null>;
|
|
1869
|
+
ttcMs: number | null;
|
|
1870
|
+
completions: number;
|
|
1871
|
+
stepProperties?: Array<Record<string, unknown>>;
|
|
1872
|
+
sessionId?: string;
|
|
1873
|
+
}
|
|
1874
|
+
/** Evaluate a funnel against a user's events. Returns FunnelResult or array (totals mode). */
|
|
1875
|
+
export function evaluateFunnel(events: Array<Record<string, unknown>>, steps: FunnelStep[], options?: FunnelOptions): FunnelResult | FunnelResult[];
|
|
1876
|
+
/** Hold Property Constant — runs parallel sub-funnels per unique value of `holdProperty`. */
|
|
1877
|
+
export function evaluateFunnelHPC(events: Array<Record<string, unknown>>, steps: FunnelStep[], holdProperty: string, options?: FunnelOptions): Map<string | number, FunnelResult | FunnelResult[]>;
|
|
1878
|
+
/** Pick a property snapshot from a FunnelResult for the given segment mode. */
|
|
1879
|
+
export function resolveFunnelSegment(result: FunnelResult, mode: 'first' | 'last' | { step: number }): Record<string, unknown> | undefined;
|
|
1880
|
+
/** Normalize a FunnelStep to the `{ event, where? }` canonical shape. */
|
|
1881
|
+
export function normalizeStep(step: FunnelStep): { event: string; where?: { prop: string; op: string; value: unknown } };
|
|
1882
|
+
/** Test whether an event qualifies for a step's where-clause. */
|
|
1883
|
+
export function matchesStepFilter(event: Record<string, unknown>, where?: { prop: string; op: string; value: unknown }): boolean;
|
|
1884
|
+
/** Partition events into UTC `day` / `week` (ISO) / `month` buckets. */
|
|
1885
|
+
export function partitionByTimeBucket(events: Array<Record<string, unknown>>, bucket: 'day' | 'week' | 'month'): Array<{ period: string; events: Array<Record<string, unknown>> }>;
|
|
1576
1886
|
|
|
1577
1887
|
interface SchemaReport {
|
|
1578
1888
|
pass: boolean;
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Smoke-test runner — runs every dungeon at tiny scale in parallel to verify it
|
|
4
|
-
* loads, generates events, and writes output without crashing.
|
|
5
|
-
*
|
|
6
|
-
* Default scale: 100 users, 1000 events per dungeon. NOT for verification —
|
|
7
|
-
* use scripts/verify-runner.mjs at full fidelity for that.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* node scripts/smoke-test-all.mjs
|
|
11
|
-
* node scripts/smoke-test-all.mjs --dir dungeons/vertical # default
|
|
12
|
-
* node scripts/smoke-test-all.mjs --dir dungeons/technical
|
|
13
|
-
* node scripts/smoke-test-all.mjs --dir dungeons # both subdirs
|
|
14
|
-
* node scripts/smoke-test-all.mjs --concurrency 4 # default: cpu count
|
|
15
|
-
* node scripts/smoke-test-all.mjs --users 500 --events 5000 # override scale
|
|
16
|
-
*
|
|
17
|
-
* Output: per-dungeon PASS/FAIL line + a final summary table. Spawns each
|
|
18
|
-
* dungeon as a child node process so a crash in one doesn't abort the run.
|
|
19
|
-
*/
|
|
20
|
-
import { spawn } from 'child_process';
|
|
21
|
-
import { readdirSync, statSync, rmSync, existsSync, mkdirSync } from 'fs';
|
|
22
|
-
import path from 'path';
|
|
23
|
-
import os from 'os';
|
|
24
|
-
|
|
25
|
-
const args = process.argv.slice(2);
|
|
26
|
-
function arg(name, fallback) {
|
|
27
|
-
const i = args.indexOf(`--${name}`);
|
|
28
|
-
return i === -1 ? fallback : args[i + 1];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
32
|
-
const dirArg = arg('dir', 'dungeons/vertical');
|
|
33
|
-
const numUsers = parseInt(arg('users', '100'), 10);
|
|
34
|
-
const numEvents = parseInt(arg('events', '1000'), 10);
|
|
35
|
-
const concurrency = parseInt(arg('concurrency', String(Math.max(2, os.cpus().length))), 10);
|
|
36
|
-
const keep = args.includes('--keep');
|
|
37
|
-
|
|
38
|
-
function findDungeons(dir) {
|
|
39
|
-
const abs = path.resolve(ROOT, dir);
|
|
40
|
-
if (!existsSync(abs)) return [];
|
|
41
|
-
const stat = statSync(abs);
|
|
42
|
-
if (stat.isFile()) return [abs];
|
|
43
|
-
const out = [];
|
|
44
|
-
for (const entry of readdirSync(abs)) {
|
|
45
|
-
const full = path.join(abs, entry);
|
|
46
|
-
const s = statSync(full);
|
|
47
|
-
if (s.isDirectory()) out.push(...findDungeons(full));
|
|
48
|
-
else if (entry.endsWith('.js')) out.push(full);
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const dungeons = findDungeons(dirArg).sort();
|
|
54
|
-
if (dungeons.length === 0) {
|
|
55
|
-
console.error(`No dungeons found under ${dirArg}`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const dataDir = path.join(ROOT, 'data');
|
|
60
|
-
mkdirSync(dataDir, { recursive: true });
|
|
61
|
-
|
|
62
|
-
const RUNNER = `
|
|
63
|
-
import generate from '${path.join(ROOT, 'index.js')}';
|
|
64
|
-
// With node -e, process.argv = [nodePath, ...userArgs] — no script slot.
|
|
65
|
-
const [dungeonPath, name, numUsers, numEvents] = process.argv.slice(1);
|
|
66
|
-
const r = await generate(dungeonPath, {
|
|
67
|
-
numUsers: parseInt(numUsers, 10),
|
|
68
|
-
numEvents: parseInt(numEvents, 10),
|
|
69
|
-
avgEventsPerUserPerDay: undefined, // force numEvents path
|
|
70
|
-
writeToDisk: true,
|
|
71
|
-
name,
|
|
72
|
-
format: 'json',
|
|
73
|
-
verbose: false,
|
|
74
|
-
concurrency: 1,
|
|
75
|
-
token: '',
|
|
76
|
-
serviceAccount: 'fake', serviceSecret: 'fake', projectId: '1',
|
|
77
|
-
});
|
|
78
|
-
console.log(JSON.stringify({ eventCount: r.eventCount, userCount: r.userCount }));
|
|
79
|
-
`;
|
|
80
|
-
|
|
81
|
-
function runOne(dungeonPath) {
|
|
82
|
-
const base = path.basename(dungeonPath, '.js');
|
|
83
|
-
const namePrefix = `smoke-${base}`;
|
|
84
|
-
return new Promise(resolve => {
|
|
85
|
-
const start = Date.now();
|
|
86
|
-
const child = spawn(
|
|
87
|
-
process.execPath,
|
|
88
|
-
['--input-type=module', '-e', RUNNER, dungeonPath, namePrefix, String(numUsers), String(numEvents)],
|
|
89
|
-
{ cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
90
|
-
);
|
|
91
|
-
let out = '';
|
|
92
|
-
let err = '';
|
|
93
|
-
child.stdout.on('data', d => { out += d.toString(); });
|
|
94
|
-
child.stderr.on('data', d => { err += d.toString(); });
|
|
95
|
-
child.on('close', code => {
|
|
96
|
-
const ms = Date.now() - start;
|
|
97
|
-
let result;
|
|
98
|
-
try {
|
|
99
|
-
const lastLine = out.trim().split('\n').filter(Boolean).pop() || '{}';
|
|
100
|
-
result = JSON.parse(lastLine);
|
|
101
|
-
} catch {
|
|
102
|
-
result = {};
|
|
103
|
-
}
|
|
104
|
-
const ok = code === 0 && result.eventCount > 0;
|
|
105
|
-
if (!keep) {
|
|
106
|
-
try {
|
|
107
|
-
for (const f of readdirSync(dataDir)) {
|
|
108
|
-
if (f.startsWith(`${namePrefix}-`)) rmSync(path.join(dataDir, f));
|
|
109
|
-
}
|
|
110
|
-
} catch {}
|
|
111
|
-
}
|
|
112
|
-
resolve({
|
|
113
|
-
dungeon: path.relative(ROOT, dungeonPath),
|
|
114
|
-
ok,
|
|
115
|
-
code,
|
|
116
|
-
ms,
|
|
117
|
-
eventCount: result.eventCount || 0,
|
|
118
|
-
userCount: result.userCount || 0,
|
|
119
|
-
err: ok ? '' : (err.trim().split('\n').slice(-3).join(' | ') || `exit ${code}`),
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
console.log(`Smoke test: ${dungeons.length} dungeons, ${numUsers} users / ${numEvents} events each, concurrency=${concurrency}`);
|
|
126
|
-
const queue = [...dungeons];
|
|
127
|
-
const results = [];
|
|
128
|
-
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
129
|
-
while (queue.length) {
|
|
130
|
-
const d = queue.shift();
|
|
131
|
-
const res = await runOne(d);
|
|
132
|
-
const tag = res.ok ? 'PASS' : 'FAIL';
|
|
133
|
-
console.log(` [${tag}] ${path.basename(res.dungeon)} — ${res.eventCount} events, ${res.userCount} users, ${(res.ms / 1000).toFixed(2)}s${res.ok ? '' : ` — ${res.err}`}`);
|
|
134
|
-
results.push(res);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
await Promise.all(workers);
|
|
138
|
-
|
|
139
|
-
results.sort((a, b) => a.dungeon.localeCompare(b.dungeon));
|
|
140
|
-
const fails = results.filter(r => !r.ok);
|
|
141
|
-
|
|
142
|
-
console.log('\n┌─────────────────────────────────────────────────┬───────┬────────┬───────┬──────┐');
|
|
143
|
-
console.log('│ Dungeon │ State │ Events │ Users │ ms │');
|
|
144
|
-
console.log('├─────────────────────────────────────────────────┼───────┼────────┼───────┼──────┤');
|
|
145
|
-
for (const r of results) {
|
|
146
|
-
const name = r.dungeon.padEnd(47).slice(0, 47);
|
|
147
|
-
const state = (r.ok ? 'PASS' : 'FAIL').padEnd(5);
|
|
148
|
-
const ev = String(r.eventCount).padStart(6);
|
|
149
|
-
const u = String(r.userCount).padStart(5);
|
|
150
|
-
const ms = String(r.ms).padStart(4);
|
|
151
|
-
console.log(`│ ${name} │ ${state} │ ${ev} │ ${u} │ ${ms} │`);
|
|
152
|
-
}
|
|
153
|
-
console.log('└─────────────────────────────────────────────────┴───────┴────────┴───────┴──────┘');
|
|
154
|
-
|
|
155
|
-
console.log(`\n${results.length - fails.length}/${results.length} passed`);
|
|
156
|
-
if (fails.length > 0) {
|
|
157
|
-
console.log('\nFailures:');
|
|
158
|
-
for (const f of fails) {
|
|
159
|
-
console.log(` ✗ ${f.dungeon}: ${f.err}`);
|
|
160
|
-
}
|
|
161
|
-
process.exit(1);
|
|
162
|
-
}
|