@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.
@@ -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
- return `() => ${body}`;
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
@@ -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
- // most of the time this will receive a list of strings;
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
- if (Array.isArray(value) && value.length > 2 && value.length < 20 && value.every(item => typeof item === 'string')) {
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
- if (/** @type {any} */ (value).noCache === true) {
444
- const result = value();
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.6.4",
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",