@ak--47/dungeon-master 1.6.4 → 1.7.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 +201 -0
- package/HOOKS.md +49 -0
- package/README.md +129 -8
- package/docs/guides/1.7.0-upgrade-guide.md +154 -0
- package/index.js +16 -1
- package/lib/core/config-validator.js +243 -13
- package/lib/core/context.js +39 -0
- package/lib/core/dungeon-loader.js +4 -1
- package/lib/generators/events.js +53 -7
- package/lib/generators/funnels.js +85 -11
- package/lib/generators/profiles.js +9 -4
- package/lib/orchestrators/mixpanel-sender.js +13 -1
- package/lib/orchestrators/user-loop.js +239 -9
- package/lib/utils/conditions.js +62 -0
- package/lib/utils/json-evaluator.js +12 -2
- package/lib/utils/utils.js +78 -7
- package/lib/verify/schema-validator.js +8 -0
- package/package.json +2 -10
- package/types.d.ts +303 -34
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funnel `conditions` matching (v1.7.0 — P0-1).
|
|
3
|
+
*
|
|
4
|
+
* A funnel with `conditions` is offered only to users whose profile satisfies
|
|
5
|
+
* every key (AND across keys). Each value is either:
|
|
6
|
+
*
|
|
7
|
+
* - a scalar → strict equality (`profile[key] === value`), unchanged since 1.3
|
|
8
|
+
* - an operator map → every operator in the map must hold (AND within a key)
|
|
9
|
+
*
|
|
10
|
+
* Operators: `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`.
|
|
11
|
+
* `in` / `nin` take arrays; the rest take scalars. `gt`/`gte`/`lt`/`lte` use
|
|
12
|
+
* JavaScript's `<` / `>` so numbers and ISO date strings both compare.
|
|
13
|
+
*
|
|
14
|
+
* A missing profile key never satisfies `eq`, `in`, or an ordering operator;
|
|
15
|
+
* it does satisfy `neq` and `nin` (the value is not the excluded one).
|
|
16
|
+
*
|
|
17
|
+
* The validator (`validateFunnelConditions`) rejects functions, bare arrays,
|
|
18
|
+
* unknown operators, and `in`/`nin` without an array — those shapes used to
|
|
19
|
+
* silently never match and hand the author an empty funnel.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const CONDITION_OPERATORS = Object.freeze(['eq', 'neq', 'in', 'nin', 'gt', 'gte', 'lt', 'lte']);
|
|
23
|
+
const OPERATOR_SET = new Set(CONDITION_OPERATORS);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True when `value` is an operator map (plain object, not array/Date).
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @returns {value is Record<string, unknown>}
|
|
29
|
+
*/
|
|
30
|
+
export function isOperatorMap(value) {
|
|
31
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Record<string, unknown>} profile
|
|
36
|
+
* @param {Record<string, unknown>} conditions
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function matchConditions(profile, conditions) {
|
|
40
|
+
if (!conditions) return true;
|
|
41
|
+
for (const [key, cond] of Object.entries(conditions)) {
|
|
42
|
+
const actual = profile ? profile[key] : undefined;
|
|
43
|
+
if (!isOperatorMap(cond)) {
|
|
44
|
+
if (actual !== cond) return false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const [op, expected] of Object.entries(cond)) {
|
|
48
|
+
if (!OPERATOR_SET.has(op)) return false; // validator throws earlier; defensive
|
|
49
|
+
switch (op) {
|
|
50
|
+
case 'eq': if (actual !== expected) return false; break;
|
|
51
|
+
case 'neq': if (actual === expected) return false; break;
|
|
52
|
+
case 'in': if (!Array.isArray(expected) || !expected.includes(actual)) return false; break;
|
|
53
|
+
case 'nin': if (Array.isArray(expected) && expected.includes(actual)) return false; break;
|
|
54
|
+
case 'gt': if (!(/** @type {any} */ (actual) > /** @type {any} */ (expected))) return false; break;
|
|
55
|
+
case 'gte': if (!(/** @type {any} */ (actual) >= /** @type {any} */ (expected))) return false; break;
|
|
56
|
+
case 'lt': if (!(/** @type {any} */ (actual) < /** @type {any} */ (expected))) return false; break;
|
|
57
|
+
case 'lte': if (!(/** @type {any} */ (actual) <= /** @type {any} */ (expected))) return false; break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
@@ -48,9 +48,19 @@ export function evaluateFunctionCall(funcCall) {
|
|
|
48
48
|
|
|
49
49
|
const { functionName, args, body } = funcCall;
|
|
50
50
|
|
|
51
|
-
// Special handling for arrow functions
|
|
51
|
+
// Special handling for arrow functions.
|
|
52
|
+
// v1.7.0 (P1-1): emit `(ctx) => body` so serialized dungeons can read the
|
|
53
|
+
// value context (`ctx.profile`, `ctx.event`, `ctx.time`, `ctx.config`).
|
|
54
|
+
// Bodies that ignore `ctx` behave exactly as before.
|
|
52
55
|
if (functionName === 'arrow') {
|
|
53
|
-
|
|
56
|
+
// `dungeon-to-json` stores a whole function source as the body
|
|
57
|
+
// (`(ctx) => ctx.profile.plan`, `function () { … }`). Emit it as-is so the
|
|
58
|
+
// revived value is that function, not a thunk returning it.
|
|
59
|
+
const trimmed = String(body).trim();
|
|
60
|
+
const isWholeFunction = /^(async\s+)?function\b/.test(trimmed)
|
|
61
|
+
|| /^(async\s*)?\([^)]*\)\s*=>/.test(trimmed)
|
|
62
|
+
|| /^(async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(trimmed);
|
|
63
|
+
return isWholeFunction ? `(${trimmed})` : `(ctx) => ${body}`;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
// Handle chance.* functions
|
package/lib/utils/utils.js
CHANGED
|
@@ -63,6 +63,51 @@ function resetValueCaches() {
|
|
|
63
63
|
winnerEntryCache = new WeakMap();
|
|
64
64
|
winnerCache.clear();
|
|
65
65
|
weightedArrayCache.clear();
|
|
66
|
+
autoPowerLawEnabled = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// v1.7.0 (P2-1): run-level switch for the implicit power-law draw on 3–19-item
|
|
70
|
+
// unique-string arrays. `choose()` is a free function with no config access, so
|
|
71
|
+
// the orchestrator sets this from `validatedConfig.autoPowerLaw` at run start
|
|
72
|
+
// (mirrors how the seeded chance singleton is managed). resetValueCaches()
|
|
73
|
+
// restores the default so a prior run's opt-out never leaks.
|
|
74
|
+
let autoPowerLawEnabled = true;
|
|
75
|
+
|
|
76
|
+
/** @param {boolean} enabled */
|
|
77
|
+
function setAutoPowerLaw(enabled) {
|
|
78
|
+
autoPowerLawEnabled = enabled !== false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {boolean} */
|
|
82
|
+
function getAutoPowerLaw() {
|
|
83
|
+
return autoPowerLawEnabled;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* v1.7.0 (P1-1): true for a value function that DECLARES a parameter (`(ctx) => …`)
|
|
88
|
+
* and is not a bound native (`chance.animal.bind(chance)` reports the native's
|
|
89
|
+
* arity but cannot read ctx). Context-aware functions skip the source-string
|
|
90
|
+
* cache and, on funnel steps, resolve inside makeEvent with the real event ctx.
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @returns {value is (ctx?: any) => any}
|
|
93
|
+
*/
|
|
94
|
+
function isContextAware(value) {
|
|
95
|
+
return typeof value === 'function' && value.length >= 1 && !Function.prototype.toString.call(value).includes('[native code]');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* v1.7.0 (P2-1): `{ __weights: { free: 60, pro: 30, enterprise: 10 } }` — the
|
|
100
|
+
* declarative weighted form. True when `value` carries a non-empty `__weights`
|
|
101
|
+
* object whose values are finite, non-negative numbers.
|
|
102
|
+
* @param {unknown} value
|
|
103
|
+
* @returns {value is { __weights: Record<string, number> }}
|
|
104
|
+
*/
|
|
105
|
+
function isWeightsForm(value) {
|
|
106
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
107
|
+
const w = /** @type {any} */ (value).__weights;
|
|
108
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) return false;
|
|
109
|
+
const entries = Object.entries(w);
|
|
110
|
+
return entries.length > 0 && entries.every(([, n]) => typeof n === 'number' && Number.isFinite(n) && n >= 0);
|
|
66
111
|
}
|
|
67
112
|
|
|
68
113
|
// v1.5.1: dataset-window state moved to AsyncLocalStorage scope
|
|
@@ -400,15 +445,24 @@ function objectList(template, options = {}) {
|
|
|
400
445
|
* similar to pick
|
|
401
446
|
* @param {ValueValid} value
|
|
402
447
|
*/
|
|
403
|
-
function choose(value) {
|
|
448
|
+
function choose(value, ctx = undefined) {
|
|
404
449
|
if (value instanceof ListValue) return value;
|
|
405
450
|
const chance = getChance();
|
|
406
451
|
|
|
407
|
-
//
|
|
452
|
+
// v1.7.0 (P2-1): declarative weights. The author's numbers ARE the
|
|
453
|
+
// distribution — no power law, no winner memo. Zero-weight keys never draw.
|
|
454
|
+
if (isWeightsForm(value)) {
|
|
455
|
+
const entries = Object.entries(value.__weights).filter(([, n]) => n > 0);
|
|
456
|
+
if (!entries.length) return "";
|
|
457
|
+
return chance.weighted(entries.map(([k]) => k), entries.map(([, n]) => n));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// most of the time this will receive a list of strings;
|
|
408
461
|
// when that is the case, we need to ensure some 'keywords' like 'variant' or 'test' aren't in the array
|
|
409
462
|
// next we want to see if the array is unweighted ... i.e. no dupe strings and each string only occurs once ['a', 'b', 'c', 'd']
|
|
410
463
|
// if all these are true we will pickAWinner(value)()
|
|
411
|
-
|
|
464
|
+
// v1.7.0 (P2-1): `autoPowerLaw: false` skips this branch → uniform pickone below.
|
|
465
|
+
if (autoPowerLawEnabled && Array.isArray(value) && value.length > 2 && value.length < 20 && value.every(item => typeof item === 'string')) {
|
|
412
466
|
// ensure terms 'variant' 'group' 'experiment' or 'population' are NOT in any of the items
|
|
413
467
|
if (!value.some(item => item.includes('variant') || item.includes('group') || item.includes('experiment') || item.includes('population'))) {
|
|
414
468
|
// check to make sure that each element in the array only occurs once...
|
|
@@ -439,21 +493,32 @@ function choose(value) {
|
|
|
439
493
|
// Functions tagged noCache (pickAWinner closures) skip the source-string
|
|
440
494
|
// cache: their toString() is identical across instances, so caching by
|
|
441
495
|
// source would hand one property's expansion to every other property.
|
|
496
|
+
//
|
|
497
|
+
// v1.7.0 (P1-1): value functions receive a `ValueContext` (`{ profile,
|
|
498
|
+
// event, time, config }`, members optional). Any function with declared
|
|
499
|
+
// arity >= 1 is context-aware and ALSO skips the source-string cache —
|
|
500
|
+
// its result legitimately differs per user/event, so caching by source
|
|
501
|
+
// would freeze the first evaluation and hand it to every later caller.
|
|
502
|
+
// Zero-arity functions keep the pre-1.7 cache behavior exactly.
|
|
442
503
|
while (typeof value === 'function') {
|
|
443
|
-
|
|
444
|
-
|
|
504
|
+
// Bound natives (`chance.animal.bind(chance)`) report the native's arity
|
|
505
|
+
// and cannot read ctx; call them exactly as before (no argument) so the
|
|
506
|
+
// options object chance methods take never sees the context.
|
|
507
|
+
const funcString = value.toString();
|
|
508
|
+
const isNative = funcString.includes('[native code]');
|
|
509
|
+
if (/** @type {any} */ (value).noCache === true || (value.length >= 1 && !isNative)) {
|
|
510
|
+
const result = isNative ? value() : value(ctx);
|
|
445
511
|
if (result instanceof ListValue) return result;
|
|
446
512
|
value = result;
|
|
447
513
|
continue;
|
|
448
514
|
}
|
|
449
|
-
const funcString = value.toString();
|
|
450
515
|
|
|
451
516
|
if (weightedArrayCache.has(funcString)) {
|
|
452
517
|
value = weightedArrayCache.get(funcString);
|
|
453
518
|
break;
|
|
454
519
|
}
|
|
455
520
|
|
|
456
|
-
const result = value();
|
|
521
|
+
const result = isNative ? value() : value(ctx);
|
|
457
522
|
if (result instanceof ListValue) return result;
|
|
458
523
|
if (Array.isArray(result) && result.length > 10) {
|
|
459
524
|
// Cache large arrays (likely weighted arrays)
|
|
@@ -463,6 +528,8 @@ function choose(value) {
|
|
|
463
528
|
}
|
|
464
529
|
|
|
465
530
|
if (value instanceof ListValue) return value;
|
|
531
|
+
// A function may return the weighted form.
|
|
532
|
+
if (isWeightsForm(value)) return choose(value, ctx);
|
|
466
533
|
|
|
467
534
|
if (Array.isArray(value) && value.length === 0) {
|
|
468
535
|
return ""; // Return empty string if the array is empty
|
|
@@ -1864,6 +1931,10 @@ export {
|
|
|
1864
1931
|
person,
|
|
1865
1932
|
pickAWinner,
|
|
1866
1933
|
resetValueCaches,
|
|
1934
|
+
setAutoPowerLaw,
|
|
1935
|
+
getAutoPowerLaw,
|
|
1936
|
+
isWeightsForm,
|
|
1937
|
+
isContextAware,
|
|
1867
1938
|
quickHash,
|
|
1868
1939
|
weighArray,
|
|
1869
1940
|
validateEventConfig,
|
|
@@ -41,6 +41,14 @@ export function deriveExpectedSchema(config) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// v1.7.0 (P1-2): stickyEventProps are engine-stamped on every event of every
|
|
45
|
+
// user — legal on every event type, not flag stamping.
|
|
46
|
+
if (Array.isArray(config.stickyEventProps)) {
|
|
47
|
+
for (const key of config.stickyEventProps) {
|
|
48
|
+
if (typeof key === 'string' && key) globalKeys.add(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
if (config.hasLocation) {
|
|
45
53
|
for (const k of LOCATION_KEYS) globalKeys.add(k);
|
|
46
54
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ak--47/dungeon-master",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -48,15 +48,7 @@
|
|
|
48
48
|
"dungeon:run": "node ./scripts/run-dungeon.mjs",
|
|
49
49
|
"dungeon:to-json": "node ./scripts/dungeon-to-json.mjs",
|
|
50
50
|
"dungeon:from-json": "node ./scripts/json-to-dungeon.mjs",
|
|
51
|
-
"dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs"
|
|
52
|
-
"kodiak:deploy": "gcloud builds submit --config dungeons/user/kodiak/cloudbuild.yaml",
|
|
53
|
-
"kodiak:plan": "node dungeons/user/kodiak/kickoff.mjs --plan-only",
|
|
54
|
-
"kodiak:smoke:local": "bash dungeons/user/kodiak/smoke-local.sh",
|
|
55
|
-
"kodiak:smoke": "node dungeons/user/kodiak/kickoff.mjs --total-events 1000000000 --chunk-cap 10",
|
|
56
|
-
"kodiak:generate": "node dungeons/user/kodiak/kickoff.mjs --no-wait",
|
|
57
|
-
"kodiak:status": "node dungeons/user/kodiak/status.mjs",
|
|
58
|
-
"kodiak:run": "bash dungeons/user/kodiak/run-all.sh",
|
|
59
|
-
"kodiak:load": "node dungeons/user/kodiak/load-bq.mjs --replace --no-wait"
|
|
51
|
+
"dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs"
|
|
60
52
|
},
|
|
61
53
|
"repository": {
|
|
62
54
|
"type": "git",
|