@ak--47/dungeon-master 1.5.3 → 1.6.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 +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +211 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +3 -2
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Story runner (v1.6, P3.3) — mechanical evaluation of a dungeon's `stories`
|
|
3
|
+
* export against emulator output. The five-tier verdict scale (SPEC P3.2,
|
|
4
|
+
* mirrored in `.claude/skills/verify-dungeon/references/report-format.md`):
|
|
5
|
+
*
|
|
6
|
+
* NAILED — observed within ±10% of `target`
|
|
7
|
+
* STRONG — passes `floor` (or `target` when no floor is given)
|
|
8
|
+
* WEAK — fails floor but effect direction is correct, or any selection's
|
|
9
|
+
* cohort < `minCohort` (population cap: NAILED/STRONG demote to WEAK)
|
|
10
|
+
* NONE — no measurable effect: selection empty, metric not computable,
|
|
11
|
+
* observed sits exactly at the neutral point, or a single-ref
|
|
12
|
+
* metric (no neutral point → no direction test) fails its floor
|
|
13
|
+
* INVERSE — effect direction opposite the assertion
|
|
14
|
+
*
|
|
15
|
+
* Story verdict = worst assertion (INVERSE < NONE < WEAK < STRONG < NAILED).
|
|
16
|
+
*
|
|
17
|
+
* Direction ("effect") is mechanical: ratio metrics have neutral point 1,
|
|
18
|
+
* difference metrics 0, single-ref metrics none. For op '>='/'>' the effect
|
|
19
|
+
* direction is correct when observed > neutral; for '<='/'<' when observed <
|
|
20
|
+
* neutral. For 'between' the wanted side is where the band's midpoint sits
|
|
21
|
+
* relative to the neutral point.
|
|
22
|
+
*
|
|
23
|
+
* Cohort size per named selection: sum of `user_count` over the selected rows
|
|
24
|
+
* when any row carries one, else the row count. The SMALLEST selection is
|
|
25
|
+
* compared against `minCohort`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { emulateBreakdown } from './emulate-breakdown.js';
|
|
29
|
+
import { applyFunnelDefaults } from './verify-dungeon.js';
|
|
30
|
+
|
|
31
|
+
/** Closed archetype enum — MUST match lib/templates/story-spec.schema.json (unit-tested). */
|
|
32
|
+
export const STORY_ARCHETYPES = [
|
|
33
|
+
'cohort-count-scale',
|
|
34
|
+
'cohort-prop-scale',
|
|
35
|
+
'temporal-inflection',
|
|
36
|
+
'funnel-conversion-by-segment',
|
|
37
|
+
'funnel-ttc-by-segment',
|
|
38
|
+
'retention-divergence',
|
|
39
|
+
'frequency-sweet-spot',
|
|
40
|
+
'attribution-bias',
|
|
41
|
+
'experiment-lift',
|
|
42
|
+
'lifecycle-wave',
|
|
43
|
+
'path-share',
|
|
44
|
+
'session-shape',
|
|
45
|
+
'composition-drift',
|
|
46
|
+
'bespoke',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Verdict rank, worst → best. Story verdict = min rank across assertions. */
|
|
50
|
+
export const VERDICT_RANK = { INVERSE: 0, NONE: 1, WEAK: 2, STRONG: 3, NAILED: 4 };
|
|
51
|
+
|
|
52
|
+
const EXPECT_OPS = ['>=', '<=', '>', '<', 'between'];
|
|
53
|
+
const WHERE_OPS = ['==', '!=', '>=', '<=', '>', '<'];
|
|
54
|
+
const NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
55
|
+
const HOOK_RE = /^(?:H|Hook\s*)?(\d+)$/i;
|
|
56
|
+
const NUM_RE = /^[0-9]+(\.[0-9]+)?$/;
|
|
57
|
+
|
|
58
|
+
// Columns that SUM across a multi-row selection. Everything else is
|
|
59
|
+
// value-like: multi-row selection is an authoring error (which row's avg?).
|
|
60
|
+
const COUNT_LIKE = new Set(['count', 'user_count', 'event_count', 'conversions', 'total', 'uniques', 'entries', 'sessions']);
|
|
61
|
+
const isCountLike = (col) => COUNT_LIKE.has(col) || /_count$/.test(col);
|
|
62
|
+
|
|
63
|
+
// ── metric grammar ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse the pinned metric grammar: single ref `'<name>.<col>'`, ratio
|
|
67
|
+
* `'<a>.<col> / <b>.<col>'`, or difference `'<a>.<col> - <b>.<col>'`.
|
|
68
|
+
* Operands are refs or unsigned numeric literals; at most one operator; at
|
|
69
|
+
* least one ref. Identifiers can't contain `/` or `-`, so the first
|
|
70
|
+
* occurrence of either IS the operator.
|
|
71
|
+
* @param {string} metric
|
|
72
|
+
* @returns {{ kind: 'single'|'ratio'|'difference', left: Object, right: Object|null }}
|
|
73
|
+
*/
|
|
74
|
+
export function parseMetric(metric) {
|
|
75
|
+
const s = String(metric || '').trim();
|
|
76
|
+
if (!s) throw new Error('metric is empty');
|
|
77
|
+
let opChar = null, idx = s.indexOf('/');
|
|
78
|
+
if (idx >= 0) opChar = '/';
|
|
79
|
+
else { idx = s.indexOf('-'); if (idx >= 0) opChar = '-'; }
|
|
80
|
+
|
|
81
|
+
const parseOperand = (tok) => {
|
|
82
|
+
const t = tok.trim();
|
|
83
|
+
if (NUM_RE.test(t)) return { literal: Number(t) };
|
|
84
|
+
const dot = t.split('.');
|
|
85
|
+
if (dot.length === 2 && NAME_RE.test(dot[0]) && NAME_RE.test(dot[1])) {
|
|
86
|
+
return { name: dot[0], column: dot[1] };
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`metric operand "${t}" is neither a <name>.<column> ref nor a numeric literal`);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (opChar === null) {
|
|
92
|
+
const left = parseOperand(s);
|
|
93
|
+
if (left.literal !== undefined) throw new Error(`metric "${s}" has no ref — a bare literal asserts nothing`);
|
|
94
|
+
return { kind: 'single', left, right: null };
|
|
95
|
+
}
|
|
96
|
+
const leftTok = s.slice(0, idx);
|
|
97
|
+
const rightTok = s.slice(idx + 1);
|
|
98
|
+
if (rightTok.includes('/') || rightTok.includes('-')) {
|
|
99
|
+
throw new Error(`metric "${s}" has more than one operator — at most one of / or - is allowed`);
|
|
100
|
+
}
|
|
101
|
+
const left = parseOperand(leftTok);
|
|
102
|
+
const right = parseOperand(rightTok);
|
|
103
|
+
if (left.literal !== undefined && right.literal !== undefined) {
|
|
104
|
+
throw new Error(`metric "${s}" has no ref — a pure-literal expression asserts nothing`);
|
|
105
|
+
}
|
|
106
|
+
return { kind: opChar === '/' ? 'ratio' : 'difference', left, right };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── select ──────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function compareValues(rowVal, op, val) {
|
|
112
|
+
switch (op) {
|
|
113
|
+
case '==': return rowVal === val;
|
|
114
|
+
case '!=': return rowVal !== val;
|
|
115
|
+
case '>=': return rowVal >= val;
|
|
116
|
+
case '<=': return rowVal <= val;
|
|
117
|
+
case '>': return rowVal > val;
|
|
118
|
+
case '<': return rowVal < val;
|
|
119
|
+
default: throw new Error(`unknown where op "${op}"`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply a story `select` to breakdown rows → named row-sets. All where-clauses
|
|
125
|
+
* AND together; a plain value means strict equality.
|
|
126
|
+
* @param {Array<Object>} rows
|
|
127
|
+
* @param {Object} select
|
|
128
|
+
* @returns {Record<string, Array<Object>>}
|
|
129
|
+
*/
|
|
130
|
+
export function selectRows(rows, select) {
|
|
131
|
+
/** @type {Record<string, Array<Object>>} */
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [name, spec] of Object.entries(select || {})) {
|
|
134
|
+
const where = (spec && spec.where) || {};
|
|
135
|
+
out[name] = (rows || []).filter(row => {
|
|
136
|
+
for (const [col, cond] of Object.entries(where)) {
|
|
137
|
+
if (cond !== null && typeof cond === 'object') {
|
|
138
|
+
if (!compareValues(row[col], cond.op, cond.value)) return false;
|
|
139
|
+
} else if (row[col] !== cond) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveRef(ref, selected) {
|
|
150
|
+
if (ref.literal !== undefined) return ref.literal;
|
|
151
|
+
const rows = selected[ref.name];
|
|
152
|
+
if (!rows) throw new Error(`metric references "${ref.name}" but select has no such row-set`);
|
|
153
|
+
if (!rows.length) throw new Error(`selection "${ref.name}" is empty`);
|
|
154
|
+
if (rows.length > 1 && !isCountLike(ref.column)) {
|
|
155
|
+
throw new Error(`selection "${ref.name}" matched ${rows.length} rows but "${ref.column}" is value-like — single-row selection required`);
|
|
156
|
+
}
|
|
157
|
+
let sum = 0;
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const v = row[ref.column];
|
|
160
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
161
|
+
throw new Error(`"${ref.name}.${ref.column}" is not a finite number (got ${JSON.stringify(v)})`);
|
|
162
|
+
}
|
|
163
|
+
sum += v;
|
|
164
|
+
}
|
|
165
|
+
return sum;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cohortOf(rows) {
|
|
169
|
+
if (rows.some(r => typeof r.user_count === 'number')) {
|
|
170
|
+
return rows.reduce((s, r) => s + (typeof r.user_count === 'number' ? r.user_count : 0), 0);
|
|
171
|
+
}
|
|
172
|
+
return rows.length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── verdicts ────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function compareOp(obs, op, bound) {
|
|
178
|
+
switch (op) {
|
|
179
|
+
case '>=': return obs >= bound;
|
|
180
|
+
case '<=': return obs <= bound;
|
|
181
|
+
case '>': return obs > bound;
|
|
182
|
+
case '<': return obs < bound;
|
|
183
|
+
default: throw new Error(`unknown op "${op}"`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Verdict for one observed value against an expect spec — the mechanical
|
|
189
|
+
* five-tier scale (see module doc). `neutral` is 1 for ratios, 0 for
|
|
190
|
+
* differences, null for single refs.
|
|
191
|
+
* @param {number} observed
|
|
192
|
+
* @param {import('../../types').StoryExpect} expectSpec
|
|
193
|
+
* @param {number|null} neutral
|
|
194
|
+
* @returns {{ verdict: string, detail: string }}
|
|
195
|
+
*/
|
|
196
|
+
export function verdictFor(observed, expectSpec, neutral) {
|
|
197
|
+
const { op, target, floor } = expectSpec;
|
|
198
|
+
if (op === 'between') {
|
|
199
|
+
const [lo, hi] = /** @type {[number, number]} */ (target);
|
|
200
|
+
if (observed >= lo && observed <= hi) {
|
|
201
|
+
return { verdict: 'NAILED', detail: `observed ${fmt(observed)} within [${lo}, ${hi}]` };
|
|
202
|
+
}
|
|
203
|
+
if (neutral === null || observed === neutral) {
|
|
204
|
+
return { verdict: 'NONE', detail: `observed ${fmt(observed)} outside [${lo}, ${hi}], no direction signal` };
|
|
205
|
+
}
|
|
206
|
+
const mid = (lo + hi) / 2;
|
|
207
|
+
const wantAbove = mid > neutral;
|
|
208
|
+
const isAbove = observed > neutral;
|
|
209
|
+
return wantAbove === isAbove
|
|
210
|
+
? { verdict: 'WEAK', detail: `observed ${fmt(observed)} outside [${lo}, ${hi}] but on the effect side of ${neutral}` }
|
|
211
|
+
: { verdict: 'INVERSE', detail: `observed ${fmt(observed)} on the wrong side of ${neutral} for band [${lo}, ${hi}]` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const targetNum = /** @type {number} */ (target);
|
|
215
|
+
if (Math.abs(observed - targetNum) <= 0.1 * Math.abs(targetNum)) {
|
|
216
|
+
return { verdict: 'NAILED', detail: `observed ${fmt(observed)} within ±10% of target ${targetNum}` };
|
|
217
|
+
}
|
|
218
|
+
const bound = typeof floor === 'number' ? floor : targetNum;
|
|
219
|
+
if (compareOp(observed, op, bound)) {
|
|
220
|
+
return { verdict: 'STRONG', detail: `observed ${fmt(observed)} passes ${op} ${bound}` };
|
|
221
|
+
}
|
|
222
|
+
if (neutral === null || observed === neutral) {
|
|
223
|
+
return { verdict: 'NONE', detail: `observed ${fmt(observed)} fails ${op} ${bound}, no direction signal` };
|
|
224
|
+
}
|
|
225
|
+
const wantAbove = op === '>=' || op === '>';
|
|
226
|
+
const isAbove = observed > neutral;
|
|
227
|
+
return wantAbove === isAbove
|
|
228
|
+
? { verdict: 'WEAK', detail: `observed ${fmt(observed)} fails ${op} ${bound} but direction vs ${neutral} is correct` }
|
|
229
|
+
: { verdict: 'INVERSE', detail: `observed ${fmt(observed)} is on the wrong side of ${neutral}` };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const fmt = (n) => (typeof n === 'number' && Number.isFinite(n)) ? (Math.abs(n) >= 100 ? n.toFixed(0) : n.toPrecision(4)) : String(n);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Evaluate one assertion against breakdown rows. Never throws — authoring or
|
|
236
|
+
* data errors surface as verdict NONE with the error in `detail` (and NONE
|
|
237
|
+
* fails the run, so nothing is silently swallowed).
|
|
238
|
+
* @param {Array<Object>} rows
|
|
239
|
+
* @param {import('../../types').StoryAssertion} assertion
|
|
240
|
+
* @param {Object} [ctx]
|
|
241
|
+
* @returns {{ verdict: string, observed: number|null, detail: string }}
|
|
242
|
+
*/
|
|
243
|
+
export function evaluateAssertion(rows, assertion, ctx) {
|
|
244
|
+
try {
|
|
245
|
+
if (typeof assertion.assert === 'function') {
|
|
246
|
+
/** @type {{ pass?: boolean, detail?: string, verdict?: import('../../types').StoryVerdict }} */
|
|
247
|
+
const res = assertion.assert(rows, ctx) || {};
|
|
248
|
+
if (res.verdict && VERDICT_RANK[res.verdict] !== undefined) {
|
|
249
|
+
return { verdict: res.verdict, observed: null, detail: res.detail || 'custom assert' };
|
|
250
|
+
}
|
|
251
|
+
return res.pass
|
|
252
|
+
? { verdict: 'STRONG', observed: null, detail: res.detail || 'custom assert passed' }
|
|
253
|
+
: { verdict: 'NONE', observed: null, detail: res.detail || 'custom assert failed' };
|
|
254
|
+
}
|
|
255
|
+
const parsed = parseMetric(assertion.expect.metric);
|
|
256
|
+
const selected = selectRows(rows, assertion.select);
|
|
257
|
+
const left = resolveRef(parsed.left, selected);
|
|
258
|
+
let observed, neutral;
|
|
259
|
+
if (parsed.kind === 'single') {
|
|
260
|
+
observed = left; neutral = null;
|
|
261
|
+
} else {
|
|
262
|
+
const right = resolveRef(parsed.right, selected);
|
|
263
|
+
if (parsed.kind === 'ratio') {
|
|
264
|
+
if (right === 0) throw new Error('ratio denominator is 0');
|
|
265
|
+
observed = left / right; neutral = 1;
|
|
266
|
+
} else {
|
|
267
|
+
observed = left - right; neutral = 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
let { verdict, detail } = verdictFor(observed, assertion.expect, neutral);
|
|
271
|
+
if (typeof assertion.minCohort === 'number' && VERDICT_RANK[verdict] > VERDICT_RANK.WEAK) {
|
|
272
|
+
const smallest = Math.min(...Object.values(selected).map(cohortOf));
|
|
273
|
+
if (smallest < assertion.minCohort) {
|
|
274
|
+
verdict = 'WEAK';
|
|
275
|
+
detail += ` — capped: smallest cohort ${smallest} < minCohort ${assertion.minCohort}`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { verdict, observed, detail };
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { verdict: 'NONE', observed: null, detail: `error: ${err.message}` };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── validation (mirrors lib/templates/story-spec.schema.json) ───────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate a `stories` export against the pinned grammar. Pure + dependency-free
|
|
288
|
+
* (no ajv): mirrors the JSON schema; a unit test keeps the two in sync.
|
|
289
|
+
* @param {Array<import('../../types').DungeonStory>} stories
|
|
290
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
291
|
+
*/
|
|
292
|
+
export function validateStories(stories) {
|
|
293
|
+
const errors = [];
|
|
294
|
+
const err = (path, msg) => errors.push(`${path}: ${msg}`);
|
|
295
|
+
if (!Array.isArray(stories) || !stories.length) {
|
|
296
|
+
return { valid: false, errors: ['stories: must be a non-empty array'] };
|
|
297
|
+
}
|
|
298
|
+
const seenIds = new Set();
|
|
299
|
+
stories.forEach((story, si) => {
|
|
300
|
+
const sp = `stories[${si}]`;
|
|
301
|
+
if (!story || typeof story !== 'object') return err(sp, 'must be an object');
|
|
302
|
+
if (!story.id || typeof story.id !== 'string') err(sp, 'id: required non-empty string');
|
|
303
|
+
else if (seenIds.has(story.id)) err(sp, `id: duplicate "${story.id}"`);
|
|
304
|
+
else seenIds.add(story.id);
|
|
305
|
+
if (typeof story.hook !== 'string' || !HOOK_RE.test(story.hook.trim())) {
|
|
306
|
+
err(sp, `hook: must match 'H<n>' / 'Hook <n>' / '<n>' (got ${JSON.stringify(story.hook)})`);
|
|
307
|
+
}
|
|
308
|
+
if (!STORY_ARCHETYPES.includes(story.archetype)) {
|
|
309
|
+
err(sp, `archetype: "${story.archetype}" not in the closed enum`);
|
|
310
|
+
}
|
|
311
|
+
if (!story.narrative || typeof story.narrative !== 'string') err(sp, 'narrative: required non-empty string');
|
|
312
|
+
if (story.intentionalDeviations !== undefined && !Array.isArray(story.intentionalDeviations)) {
|
|
313
|
+
err(sp, 'intentionalDeviations: must be an array of strings');
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(story.assertions) || !story.assertions.length) {
|
|
316
|
+
return err(sp, 'assertions: required non-empty array');
|
|
317
|
+
}
|
|
318
|
+
story.assertions.forEach((a, ai) => {
|
|
319
|
+
const ap = `${sp}.assertions[${ai}]`;
|
|
320
|
+
if (!a || typeof a !== 'object') return err(ap, 'must be an object');
|
|
321
|
+
if (!a.breakdown || typeof a.breakdown !== 'object' || typeof a.breakdown.type !== 'string' || !a.breakdown.type) {
|
|
322
|
+
err(ap, 'breakdown: required object with a string `type`');
|
|
323
|
+
} else if (a.breakdown.type === 'duckdb' && (typeof a.breakdown.sql !== 'string' || !a.breakdown.sql)) {
|
|
324
|
+
err(ap, 'breakdown: type "duckdb" requires a non-empty `sql`');
|
|
325
|
+
}
|
|
326
|
+
if (a.expect === undefined && typeof a.assert !== 'function') {
|
|
327
|
+
err(ap, 'requires `expect` or a function-valued `assert`');
|
|
328
|
+
}
|
|
329
|
+
if (a.assert !== undefined && typeof a.assert !== 'function') {
|
|
330
|
+
err(ap, 'assert: must be a function');
|
|
331
|
+
}
|
|
332
|
+
if (a.minCohort !== undefined && (typeof a.minCohort !== 'number' || a.minCohort <= 0)) {
|
|
333
|
+
err(ap, 'minCohort: must be a positive number');
|
|
334
|
+
}
|
|
335
|
+
const selectNames = new Set();
|
|
336
|
+
if (a.select !== undefined) {
|
|
337
|
+
if (!a.select || typeof a.select !== 'object') err(ap, 'select: must be an object');
|
|
338
|
+
else {
|
|
339
|
+
for (const [name, spec] of Object.entries(a.select)) {
|
|
340
|
+
if (!NAME_RE.test(name)) err(ap, `select: name "${name}" must be an identifier`);
|
|
341
|
+
selectNames.add(name);
|
|
342
|
+
if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object' || !Object.keys(spec.where).length) {
|
|
343
|
+
err(ap, `select.${name}: requires a non-empty \`where\``);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
for (const [col, cond] of Object.entries(spec.where)) {
|
|
347
|
+
if (cond !== null && typeof cond === 'object') {
|
|
348
|
+
if (!WHERE_OPS.includes(cond.op)) err(ap, `select.${name}.where.${col}: op "${cond.op}" not in ${WHERE_OPS.join(' ')}`);
|
|
349
|
+
if (cond.value === undefined) err(ap, `select.${name}.where.${col}: comparison requires \`value\``);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (a.expect !== undefined) {
|
|
356
|
+
const ep = `${ap}.expect`;
|
|
357
|
+
if (!a.expect || typeof a.expect !== 'object') return err(ep, 'must be an object');
|
|
358
|
+
let parsed = null;
|
|
359
|
+
try {
|
|
360
|
+
parsed = parseMetric(a.expect.metric);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
err(ep, `metric: ${e.message}`);
|
|
363
|
+
}
|
|
364
|
+
if (parsed) {
|
|
365
|
+
for (const ref of [parsed.left, parsed.right]) {
|
|
366
|
+
if (ref && ref.name && !selectNames.has(ref.name)) {
|
|
367
|
+
err(ep, `metric references "${ref.name}" — not a select row-set name`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!EXPECT_OPS.includes(a.expect.op)) err(ep, `op: "${a.expect.op}" not in ${EXPECT_OPS.join(' ')}`);
|
|
372
|
+
if (a.expect.op === 'between') {
|
|
373
|
+
if (!Array.isArray(a.expect.target) || a.expect.target.length !== 2
|
|
374
|
+
|| !a.expect.target.every(n => typeof n === 'number' && Number.isFinite(n))
|
|
375
|
+
|| a.expect.target[0] > a.expect.target[1]) {
|
|
376
|
+
err(ep, 'target: op "between" requires [lo, hi] with lo <= hi');
|
|
377
|
+
}
|
|
378
|
+
} else if (typeof a.expect.target !== 'number' || !Number.isFinite(a.expect.target)) {
|
|
379
|
+
err(ep, `target: must be a finite number (array form is between-only)`);
|
|
380
|
+
}
|
|
381
|
+
if (a.expect.floor !== undefined && (typeof a.expect.floor !== 'number' || !Number.isFinite(a.expect.floor))) {
|
|
382
|
+
err(ep, 'floor: must be a finite number');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
return { valid: errors.length === 0, errors };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── adapters ────────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Convert stories into `verifyDungeon` checks for in-memory mode. Each
|
|
394
|
+
* assertion becomes one check named `<storyId>[<i>]`; pass = NAILED or STRONG
|
|
395
|
+
* (the verdict lands in `detail`). duckdb assertions are disk-mode-only —
|
|
396
|
+
* skipped here with a warning. Throws on invalid stories.
|
|
397
|
+
* @param {Array<import('../../types').DungeonStory>} stories
|
|
398
|
+
* @returns {Array<Object>}
|
|
399
|
+
*/
|
|
400
|
+
export function storiesToChecks(stories) {
|
|
401
|
+
const v = validateStories(stories);
|
|
402
|
+
if (!v.valid) {
|
|
403
|
+
throw new Error(`storiesToChecks: invalid stories:\n ${v.errors.join('\n ')}`);
|
|
404
|
+
}
|
|
405
|
+
const checks = [];
|
|
406
|
+
for (const story of stories) {
|
|
407
|
+
story.assertions.forEach((assertion, i) => {
|
|
408
|
+
const name = `${story.id}[${i}]`;
|
|
409
|
+
if (assertion.breakdown.type === 'duckdb') {
|
|
410
|
+
console.warn(`[dungeon-master] ${name}: duckdb assertions only run in disk mode (scripts/verify-stories.mjs) — skipped in-memory.`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
checks.push({
|
|
414
|
+
name,
|
|
415
|
+
breakdown: assertion.breakdown,
|
|
416
|
+
assert: (rows, ctx) => {
|
|
417
|
+
const res = evaluateAssertion(rows, assertion, ctx);
|
|
418
|
+
return {
|
|
419
|
+
pass: VERDICT_RANK[res.verdict] >= VERDICT_RANK.STRONG,
|
|
420
|
+
detail: `${res.verdict} — ${res.detail}`,
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return checks;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Evaluate stories against an already-loaded event set (disk mode). Funnel
|
|
431
|
+
* options auto-thread exactly as in `verifyDungeon` (via `applyFunnelDefaults`).
|
|
432
|
+
* @param {Array<import('../../types').DungeonStory>} stories
|
|
433
|
+
* @param {Array<Object>} events
|
|
434
|
+
* @param {Object} [opts]
|
|
435
|
+
* @param {Array<Object>} [opts.profiles]
|
|
436
|
+
* @param {Array<Object>} [opts.funnels] - VALIDATED dungeon funnels.
|
|
437
|
+
* @param {Map<string,string>} [opts.identityMap] - Pre-built device_id →
|
|
438
|
+
* canonical id map (buildIdentityMap). Threaded into every emulator
|
|
439
|
+
* assertion so pre-auth (device_id-only) events resolve to the same user
|
|
440
|
+
* as post-auth events. Build once at the call site — per-assertion
|
|
441
|
+
* auto-build over large profile sets is wasteful.
|
|
442
|
+
* @param {(sql: string) => Promise<Array<Object>>} [opts.runSql] - duckdb
|
|
443
|
+
* executor (provided by the CLI in disk mode). Absent → duckdb assertions
|
|
444
|
+
* report NONE with an explanatory detail.
|
|
445
|
+
* @returns {Promise<Array<{ id: string, hook: string, archetype: string, verdict: string, assertions: Array<{ name: string, verdict: string, observed: number|null, detail: string }> }>>}
|
|
446
|
+
*/
|
|
447
|
+
export async function evaluateStories(stories, events, opts) {
|
|
448
|
+
const { profiles, funnels, runSql, identityMap } = opts || {};
|
|
449
|
+
const v = validateStories(stories);
|
|
450
|
+
if (!v.valid) {
|
|
451
|
+
throw new Error(`evaluateStories: invalid stories:\n ${v.errors.join('\n ')}`);
|
|
452
|
+
}
|
|
453
|
+
const out = [];
|
|
454
|
+
for (const story of stories) {
|
|
455
|
+
const results = [];
|
|
456
|
+
for (let i = 0; i < story.assertions.length; i++) {
|
|
457
|
+
const assertion = story.assertions[i];
|
|
458
|
+
const name = `${story.id}[${i}]`;
|
|
459
|
+
let rows;
|
|
460
|
+
try {
|
|
461
|
+
if (assertion.breakdown.type === 'duckdb') {
|
|
462
|
+
if (!runSql) {
|
|
463
|
+
results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
rows = await runSql(assertion.breakdown.sql);
|
|
467
|
+
} else {
|
|
468
|
+
const bArgs = applyFunnelDefaults(assertion.breakdown, funnels, profiles);
|
|
469
|
+
if (identityMap && bArgs.identityMap === undefined) bArgs.identityMap = identityMap;
|
|
470
|
+
rows = emulateBreakdown(events, bArgs);
|
|
471
|
+
}
|
|
472
|
+
} catch (err) {
|
|
473
|
+
results.push({ name, verdict: 'NONE', observed: null, detail: `error: ${err.message}` });
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const res = evaluateAssertion(rows, assertion, { events, profiles });
|
|
477
|
+
results.push({ name, ...res });
|
|
478
|
+
}
|
|
479
|
+
const worst = results.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED');
|
|
480
|
+
out.push({ id: story.id, hook: story.hook, archetype: story.archetype, verdict: worst, assertions: results });
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
@@ -52,6 +52,64 @@ function findMatchingFunnel(funnels, steps) {
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Auto-apply funnel-level dungeon config (conversion window, order mode,
|
|
57
|
+
* reentry, exclusion steps, step filters) plus the profiles arg to a breakdown
|
|
58
|
+
* args object, so check/story authors don't thread them by hand. Returns a NEW
|
|
59
|
+
* args object — the input is not mutated. Extracted from `verifyDungeon` in
|
|
60
|
+
* v1.6 so the story runner (P3.3) reuses the exact same threading.
|
|
61
|
+
* @param {Object} breakdownArgs - Args destined for `emulateBreakdown`.
|
|
62
|
+
* @param {Array<Object>} funnels - VALIDATED dungeon funnels (`validateDungeonConfig`
|
|
63
|
+
* mutates funnels in place, so post-run `config.funnels` carries resolved
|
|
64
|
+
* `conversionWindowDays` / `order`).
|
|
65
|
+
* @param {Array<Object>} [profiles] - User profiles, threaded into `timeToConvert`.
|
|
66
|
+
* @returns {Object}
|
|
67
|
+
*/
|
|
68
|
+
export function applyFunnelDefaults(breakdownArgs, funnels, profiles) {
|
|
69
|
+
const args = { ...breakdownArgs };
|
|
70
|
+
if (args.type === 'timeToConvert' && !args.profiles && profiles) {
|
|
71
|
+
args.profiles = profiles;
|
|
72
|
+
}
|
|
73
|
+
// v1.5: auto-apply funnel-level config when the check targets a funnel.
|
|
74
|
+
if (args.type === 'funnelFrequency' || args.type === 'timeToConvert') {
|
|
75
|
+
const targetSteps = args.steps
|
|
76
|
+
|| (args.fromEvent && args.toEvent
|
|
77
|
+
? [args.fromEvent, args.toEvent]
|
|
78
|
+
: null);
|
|
79
|
+
const matched = findMatchingFunnel(Array.isArray(funnels) ? funnels : [], targetSteps);
|
|
80
|
+
if (matched) {
|
|
81
|
+
// An explicit session-count window (`conversionWindow: { unit: 'sessions', n }`)
|
|
82
|
+
// is the author choosing a window — injecting conversionWindowMs on top
|
|
83
|
+
// would trip evaluateFunnel's mutual-exclusion throw. Only inject when
|
|
84
|
+
// NEITHER window form is present.
|
|
85
|
+
if (args.conversionWindowMs === undefined && args.conversionWindow === undefined && Number.isFinite(matched.conversionWindowDays)) {
|
|
86
|
+
args.conversionWindowMs = matched.conversionWindowDays * 86400000;
|
|
87
|
+
}
|
|
88
|
+
if (args.funnelOrder === undefined && matched.order) {
|
|
89
|
+
args.funnelOrder = matched.order;
|
|
90
|
+
}
|
|
91
|
+
// v1.5.0: thread Funnel-level extension hints through to the verifier.
|
|
92
|
+
if (args.reentry === undefined && matched.reentry !== undefined) {
|
|
93
|
+
args.reentry = matched.reentry;
|
|
94
|
+
}
|
|
95
|
+
if (args.exclusionSteps === undefined && Array.isArray(matched.exclusionEvents) && matched.exclusionEvents.length) {
|
|
96
|
+
args.exclusionSteps = matched.exclusionEvents.map(name => ({ event: name }));
|
|
97
|
+
}
|
|
98
|
+
// stepFilters: Record<number, { prop, op, value }> → mutate steps to attach where-clause.
|
|
99
|
+
if (matched.stepFilters && args.steps && Array.isArray(args.steps)) {
|
|
100
|
+
args.steps = args.steps.map((s, i) => {
|
|
101
|
+
const filter = matched.stepFilters[i];
|
|
102
|
+
if (!filter) return s;
|
|
103
|
+
const stepObj = typeof s === 'string' ? { event: s } : { ...s };
|
|
104
|
+
if (!stepObj.where) stepObj.where = filter;
|
|
105
|
+
return stepObj;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return args;
|
|
111
|
+
}
|
|
112
|
+
|
|
55
113
|
/**
|
|
56
114
|
* @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
|
|
57
115
|
* @param {VerifyCheck[]} checks
|
|
@@ -71,43 +129,7 @@ export async function verifyDungeon(config, checks) {
|
|
|
71
129
|
const validatedFunnels = (config && Array.isArray(config.funnels)) ? config.funnels : [];
|
|
72
130
|
for (const check of checks) {
|
|
73
131
|
try {
|
|
74
|
-
const breakdownArgs =
|
|
75
|
-
if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
|
|
76
|
-
breakdownArgs.profiles = profiles;
|
|
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
|
-
}
|
|
132
|
+
const breakdownArgs = applyFunnelDefaults(check.breakdown, validatedFunnels, profiles);
|
|
111
133
|
const rows = emulateBreakdown(events, breakdownArgs);
|
|
112
134
|
const verdict = check.assert(rows, ctx);
|
|
113
135
|
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.6.0",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"!dungeons/user/",
|
|
26
26
|
"!dungeons/capstone/",
|
|
27
27
|
"scripts/",
|
|
28
|
+
"docs/",
|
|
28
29
|
".claude/skills/",
|
|
29
30
|
"package.json",
|
|
30
31
|
"README.md",
|
|
@@ -86,7 +87,7 @@
|
|
|
86
87
|
"dotenv": "^16.4.5",
|
|
87
88
|
"hyparquet-writer": "^0.6.1",
|
|
88
89
|
"mixpanel": "^0.18.0",
|
|
89
|
-
"mixpanel-import": "^3.3.
|
|
90
|
+
"mixpanel-import": "^3.3.2",
|
|
90
91
|
"p-limit": "^3.1.0",
|
|
91
92
|
"pino": "^9.0.0",
|
|
92
93
|
"pino-pretty": "^11.0.0",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
18
|
* node scripts/extract-dungeon-schema.mjs <input.js> [output.json]
|
|
19
|
-
* node scripts/extract-dungeon-schema.mjs dungeons/vertical
|
|
19
|
+
* node scripts/extract-dungeon-schema.mjs dungeons/vertical/<name>/<name>.js ... # batch mode (shell glob OK)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { writeFileSync } from 'fs';
|
|
@@ -29,7 +29,7 @@ const args = process.argv.slice(2);
|
|
|
29
29
|
|
|
30
30
|
if (args.length === 0) {
|
|
31
31
|
console.error('Usage: node scripts/extract-dungeon-schema.mjs <dungeon.js> [output.json]');
|
|
32
|
-
console.error(
|
|
32
|
+
console.error(" node scripts/extract-dungeon-schema.mjs 'dungeons/vertical/*/*.js'");
|
|
33
33
|
process.exit(1);
|
|
34
34
|
}
|
|
35
35
|
|
package/scripts/run-many.mjs
CHANGED
|
@@ -15,8 +15,8 @@ const CONCURRENCY = 10
|
|
|
15
15
|
* Run multiple dungeons concurrently from the command line.
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
|
-
* node scripts/run-many.mjs dungeons/vertical/gaming.js dungeons/vertical/media
|
|
19
|
-
*
|
|
18
|
+
* node scripts/run-many.mjs dungeons/vertical/gaming/gaming.js dungeons/vertical/media/media.js
|
|
19
|
+
* (shell globs expand to file arguments, e.g. dungeons/vertical/<name> folders)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const args = process.argv.slice(2);
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* node scripts/verify-runner.mjs <dungeon-path> [run-name] [--small]
|
|
14
14
|
*
|
|
15
15
|
* Examples:
|
|
16
|
-
* node scripts/verify-runner.mjs dungeons/vertical/gaming.js verify-gaming
|
|
17
|
-
* node scripts/verify-runner.mjs dungeons/vertical/gaming.js verify-gaming --small
|
|
16
|
+
* node scripts/verify-runner.mjs dungeons/vertical/gaming/gaming.js verify-gaming
|
|
17
|
+
* node scripts/verify-runner.mjs dungeons/vertical/gaming/gaming.js verify-gaming --small
|
|
18
18
|
*/
|
|
19
19
|
import generate from '../index.js';
|
|
20
20
|
import path from 'path';
|