@ak--47/dungeon-master 1.6.0 → 1.6.1

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,75 @@
1
+ ---
2
+ name: powertools
3
+ description: Use when any task needs the Mixpanel Power Tools API ("use powertools") — schema export (get-schema), event volumes, project CRUD, query methods, macros, or snapshotting a prod project's schema to copy it into a dungeon. Companion to create-project (which handles provisioning specifically).
4
+ argument-hint: [what to do, e.g. "get schema for project 12345" or "copy project 12345 into a dungeon"]
5
+ ---
6
+
7
+ # Power Tools API
8
+
9
+ Base URL: `https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app`
10
+
11
+ ## Auth model — read this first
12
+
13
+ - **GET any endpoint path = documentation, no auth.** Always `curl -s GET <base><path>` before first use of an unfamiliar endpoint — docs include exact body params and response shapes.
14
+ - **POST = execute.** `Authorization: Bearer <oauth-token>` (employee OAuth from repo `.env` `BEARER_TOKEN`, or a customer's OAuth token) or `Basic base64(service_acct:secret)`.
15
+ - **Customer OAuth tokens are accepted** (verified 2026-07-06): `/auth` and `/macro/get-schema` work with a customer token on projects that token can access. `ai_endpoints_allowed: false` for non-employees — the `ai-*` family stays employee-only.
16
+ - **Every POST body** should include `client_id: "dungeon-master"` and `region` (`US` default).
17
+ - **`/auth` accessibility ≠ data access.** `/auth {project_id}` can report `accessible: true` while `get-schema`/`query/*` on the same project return `HTTP 403: Forbidden` (token lacks data-level access, e.g. an employee token on a customer project). Diagnose with `/auth`, but don't trust it for data endpoints. Prefer the token of an actual project member.
18
+
19
+ ## Tools in this skill
20
+
21
+ ### `pt.mjs` — ad-hoc client
22
+
23
+ ```bash
24
+ node .claude/skills/powertools/pt.mjs <path> ['<json-body>'] [--bearer <token>] [--get] [--region US]
25
+ # examples
26
+ node .claude/skills/powertools/pt.mjs /auth '{}'
27
+ node .claude/skills/powertools/pt.mjs /macro/get-schema '{"project_id":"123","include_metadata":true,"verbose":true}'
28
+ node .claude/skills/powertools/pt.mjs /query/getTopEvents --get # docs, no auth
29
+ ```
30
+
31
+ Bearer defaults to `.env` `BEARER_TOKEN`. Merges `client_id`/`region` into the body. Prints pretty JSON to stdout.
32
+
33
+ ### `snapshot-project.mjs` — schema + relative-volume snapshot
34
+
35
+ ```bash
36
+ node .claude/skills/powertools/snapshot-project.mjs <project_id> --bearer <token> \
37
+ [--region US] [--out snapshot.json]
38
+ ```
39
+
40
+ - Uses `/macro/get-schema` (`include_metadata` + `verbose`) + `/query/getTopEvents`. Token (customer or employee) needs data access to the project.
41
+ - Output shape: `{ projectId, projectName, fetchedAt, totalCount, events: [{ name, count, pct, properties: [{name, type, description}] }], userProps: [{name, type, description}], groups: {} }`, events sorted by count desc.
42
+ - **Never captures property values** — schema + volumes only. Snapshots stay privacy-safe by construction.
43
+
44
+ ## Endpoint catalog (the useful subset)
45
+
46
+ GET the path for full docs. Full list: GET `/` and GET `/macro`.
47
+
48
+ **crud** — `/crud/createProject`, `/crud/deleteProject`, `/crud/getProjects`, `/crud/mintServiceAccount`, `/crud/addGroupKey`, `/crud/setBusinessContext` (all used by the create-project skill's `provision.mjs`).
49
+
50
+ **query** — `/query/getTopEvents` (per-event counts, limit≤100 default), `/query/getEventNames`, `/query/getPropertyValues`, `/query/getTopProperties`, `/query/getSegmentation`, `/query/getFunnel`, `/query/listFunnels`, `/query/listCohorts`, `/query/runJQL`. Rate limits: 5 concurrent / 60 per hour; 1h response cache.
51
+
52
+ **macros** — `/macro/get-schema` (always pass `include_metadata: true, verbose: true`; also `include_density: true` for per-(event,property) coverage % and `include_sdk_defaults: true` to keep `$browser`/`$os`-style SDK props — needed for carbon copies), `/macro/analyze-project` (volumes + cardinality + activity), `/macro/enumerate-project`, `/macro/clone-project`, `/macro/clone-boards`, `/macro/delete-entities`, `/macro/dungeon-master` (runs a dungeon config server-side and ingests), `/macro/ai-e2e-dm4` (demo build from a supplied schema, designed for dungeon-master), plus the `ai-*` family (dashboards, cohorts, metrics, schema naming).
53
+
54
+ ### get-schema field notes (verified on a 2,410-event project, 2026-07-06)
55
+
56
+ - Response nests under `json`: `{ json: { events, properties, users, groups, dependencies }, duration_ms }`. Large projects are slow — 2,410 events took ~13 min; run it in the background.
57
+ - Event counts include custom events (`customEventId > 0`) and merged events. Filter `!merged && !(customEventId > 0)` to match the Lexicon UI event count.
58
+ - `properties` includes `mp_*` internals and `$custom_property:<id>` computed-prop references — exclude both when authoring a dungeon (ingestion stamps `mp_*` itself; computed props can't be tracked).
59
+ - `exampleValue` fields contain REAL customer values — never copy them into a dungeon.
60
+ - **Known issue**: `dependencies` can come back empty (`{events:{},properties:{}}`) on large projects even with `verbose: true` + `include_density: true` — the bulk dependency call silently fails. Retry the call; per-event property mapping is unavailable until it succeeds.
61
+
62
+ ## Recipe: copy a prod project into a dungeon
63
+
64
+ Goal: a purely synthetic dungeon with the same events/props/user-props and matching **relative** event volumes. Safe to share — no customer data.
65
+
66
+ 1. **Snapshot** the source project (`snapshot-project.mjs`, customer token unless the employee token has data access). Sanity-check event count + that counts are non-zero.
67
+ 2. **Author the dungeon** from the snapshot (schema-first, per repo hook rules):
68
+ - Take the top-N events covering ≥95% of total volume (`pct` cumsum); note dropped tail in the OVERVIEW comment.
69
+ - `weight` per event ∝ snapshot `count` (normalize so max ≈ 100, min ≥ 1).
70
+ - Properties per event from snapshot; **invent all values** from name/type/description — never copy real values.
71
+ - `userProps` from snapshot; shared high-frequency props → `superProps`.
72
+ - Funnels: best-effort from event-name semantics (or `/query/listFunnels` + `/query/getFunnel` on the source if accessible).
73
+ 3. **Provision** into our org via the create-project skill: `node .claude/skills/create-project/provision.mjs <dungeon> --dry-run` → confirm → live. Uses `.env` `BEARER_TOKEN` + `ORG_ID`, writes `credentials` back into the dungeon.
74
+ 4. **Run**: `node scripts/run-dungeon.mjs <dungeon>`.
75
+ 5. **Verify** relative volumes: top-10 generated events should rank in the same order as the snapshot's top-10.
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * pt.mjs — thin ad-hoc client for the Mixpanel Power Tools API.
5
+ *
6
+ * Usage:
7
+ * node .claude/skills/powertools/pt.mjs <path> ['<json-body>'] [--bearer <token>] [--get] [--region US]
8
+ *
9
+ * Examples:
10
+ * node .claude/skills/powertools/pt.mjs /auth '{}'
11
+ * node .claude/skills/powertools/pt.mjs /macro/get-schema '{"project_id":"123","include_metadata":true,"verbose":true}'
12
+ * node .claude/skills/powertools/pt.mjs /query/getTopEvents --get # endpoint docs, no auth
13
+ *
14
+ * POST bodies are merged with { client_id: "dungeon-master", region }.
15
+ * Bearer defaults to BEARER_TOKEN in the repo .env (employee OAuth). Customer
16
+ * OAuth tokens are accepted on non-ai endpoints; the ai-* family is
17
+ * employee-only (see SKILL.md).
18
+ */
19
+
20
+ import { dirname, resolve } from 'path';
21
+ import { fileURLToPath } from 'url';
22
+ import dotenv from 'dotenv';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ dotenv.config({ path: resolve(__dirname, '../../../.env') });
26
+
27
+ const BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
28
+ const CLIENT_ID = 'dungeon-master';
29
+
30
+ const args = process.argv.slice(2);
31
+ const getMode = popFlag('--get');
32
+ const bearer = popOpt('--bearer') ?? process.env.BEARER_TOKEN;
33
+ const region = popOpt('--region') ?? 'US';
34
+ const [pathArg, bodyArg] = args;
35
+
36
+ if (!pathArg || !pathArg.startsWith('/')) {
37
+ console.error('Usage: node pt.mjs <path starting with /> [\'<json-body>\'] [--bearer <token>] [--get] [--region US]');
38
+ process.exit(1);
39
+ }
40
+
41
+ if (getMode) {
42
+ const res = await fetch(BASE + pathArg);
43
+ console.log(JSON.stringify(await res.json(), null, 2));
44
+ process.exit(res.ok ? 0 : 1);
45
+ }
46
+
47
+ if (!bearer) {
48
+ console.error('No bearer token: pass --bearer or set BEARER_TOKEN in .env');
49
+ process.exit(1);
50
+ }
51
+
52
+ let body = {};
53
+ if (bodyArg) {
54
+ try {
55
+ body = JSON.parse(bodyArg);
56
+ } catch (err) {
57
+ console.error(`Body is not valid JSON: ${err.message}`);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ const res = await fetch(BASE + pathArg, {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
65
+ body: JSON.stringify({ client_id: CLIENT_ID, region, ...body }),
66
+ });
67
+
68
+ const text = await res.text();
69
+ try {
70
+ console.log(JSON.stringify(JSON.parse(text), null, 2));
71
+ } catch {
72
+ console.log(text);
73
+ }
74
+ if (!res.ok) {
75
+ console.error(`\nHTTP ${res.status} ${res.statusText}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ function popFlag(name) {
80
+ const i = args.indexOf(name);
81
+ if (i === -1) return false;
82
+ args.splice(i, 1);
83
+ return true;
84
+ }
85
+
86
+ function popOpt(name) {
87
+ const i = args.indexOf(name);
88
+ if (i === -1) return undefined;
89
+ const [, value] = args.splice(i, 2);
90
+ return value;
91
+ }
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * snapshot-project.mjs — export a Mixpanel project's schema + relative event
5
+ * volumes into one normalized JSON snapshot, for authoring a synthetic
6
+ * dungeon-master "copy" of the project.
7
+ *
8
+ * Usage:
9
+ * node .claude/skills/powertools/snapshot-project.mjs <project_id> --bearer <token> \
10
+ * [--region US|EU|IN] [--out <file>]
11
+ *
12
+ * Uses the Power Tools API: /macro/get-schema (include_metadata + verbose) +
13
+ * /query/getTopEvents. The bearer token (customer or employee OAuth) needs
14
+ * data access to the project — /auth reporting accessible: true is not enough.
15
+ *
16
+ * Output shape (events sorted by count desc):
17
+ * {
18
+ * projectId, projectName, fetchedAt, region, totalCount,
19
+ * events: [{ name, count, pct, properties: [{ name, type, description }] }],
20
+ * userProps: [{ name, type, description }],
21
+ * groups: {}
22
+ * }
23
+ *
24
+ * Privacy: captures schema + volumes ONLY — never property values.
25
+ */
26
+
27
+ import { writeFileSync } from 'fs';
28
+ import { dirname, resolve } from 'path';
29
+ import { fileURLToPath } from 'url';
30
+ import dotenv from 'dotenv';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ dotenv.config({ path: resolve(__dirname, '../../../.env') });
34
+
35
+ const PT_BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
36
+ const CLIENT_ID = 'dungeon-master';
37
+ const REGIONS = ['US', 'EU', 'IN'];
38
+
39
+ const args = process.argv.slice(2);
40
+ const bearer = popOpt('--bearer') ?? process.env.BEARER_TOKEN;
41
+ const region = (popOpt('--region') ?? 'US').toUpperCase();
42
+ const outArg = popOpt('--out');
43
+ const projectId = args.find((a) => !a.startsWith('--'));
44
+
45
+ if (!projectId || !/^\d+$/.test(projectId)) fail('Usage: snapshot-project.mjs <project_id> --bearer <token> [--region US] [--out file]');
46
+ if (!bearer) fail('No bearer token: pass --bearer or set BEARER_TOKEN in .env');
47
+ if (!REGIONS.includes(region)) fail(`Unknown --region "${region}"`);
48
+
49
+ const outPath = resolve(process.cwd(), outArg ?? `snapshot-${projectId}.json`);
50
+
51
+ const snapshot = await snapshotViaPowertools();
52
+
53
+ snapshot.events.sort((a, b) => b.count - a.count);
54
+ snapshot.totalCount = snapshot.events.reduce((s, e) => s + e.count, 0);
55
+ for (const e of snapshot.events) e.pct = snapshot.totalCount ? +(e.count / snapshot.totalCount * 100).toFixed(4) : 0;
56
+
57
+ writeFileSync(outPath, JSON.stringify(snapshot, null, 2));
58
+
59
+ const zeros = snapshot.events.filter((e) => e.count === 0).length;
60
+ console.log(`✓ snapshot → ${outPath}`);
61
+ console.log(` project: ${snapshot.projectName ?? '(name unknown)'} (${snapshot.projectId})`);
62
+ console.log(` events: ${snapshot.events.length} (${zeros} with zero volume)`);
63
+ console.log(` userProps: ${snapshot.userProps.length}`);
64
+ console.log(` total vol: ${snapshot.totalCount.toLocaleString()} events`);
65
+ console.log(' top 10:');
66
+ for (const e of snapshot.events.slice(0, 10)) console.log(` ${e.pct.toFixed(2).padStart(6)}% ${e.count.toLocaleString().padStart(12)} ${e.name}`);
67
+
68
+ async function snapshotViaPowertools() {
69
+ const schema = await ptPost('/macro/get-schema', { project_id: projectId, include_metadata: true, verbose: true });
70
+ const top = await ptPost('/query/getTopEvents', { project_id: projectId, limit: 500 });
71
+
72
+ const counts = {};
73
+ for (const r of top.results ?? []) counts[r.event] = r.count;
74
+
75
+ // dependencies.events maps eventName → [propertyNames]; property defs live in schema.properties
76
+ const propDefs = new Map();
77
+ for (const p of schema.properties ?? []) propDefs.set(p.name, p);
78
+ const deps = schema.dependencies?.events ?? {};
79
+
80
+ const events = (schema.events ?? []).map((ev) => {
81
+ const name = ev.name ?? ev;
82
+ const propNames = deps[name] ?? [];
83
+ return {
84
+ name,
85
+ count: counts[name] ?? 0,
86
+ pct: 0,
87
+ properties: propNames.map((pn) => {
88
+ const def = propDefs.get(pn) ?? {};
89
+ return { name: pn, type: def.type ?? 'string', description: def.description ?? '' };
90
+ }),
91
+ };
92
+ });
93
+
94
+ const userProps = (schema.users ?? []).map((p) => ({
95
+ name: p.name ?? p,
96
+ type: p.type ?? 'string',
97
+ description: p.description ?? '',
98
+ }));
99
+
100
+ return { projectId, projectName: null, fetchedAt: new Date().toISOString(), region, totalCount: 0, events, userProps, groups: schema.groups ?? {} };
101
+ }
102
+
103
+ async function ptPost(pathname, body) {
104
+ const res = await fetch(PT_BASE + pathname, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bearer}` },
107
+ body: JSON.stringify({ client_id: CLIENT_ID, region, ...body }),
108
+ });
109
+ const json = await res.json().catch(() => ({}));
110
+ if (!res.ok) throw new Error(`POST ${pathname} → HTTP ${res.status}: ${json.error ?? JSON.stringify(json).slice(0, 300)}`);
111
+ return json;
112
+ }
113
+
114
+ function popOpt(name) {
115
+ const i = args.indexOf(name);
116
+ if (i === -1) return undefined;
117
+ const [, value] = args.splice(i, 2);
118
+ return value;
119
+ }
120
+
121
+ function fail(msg) {
122
+ console.error(`✖ ${msg}`);
123
+ process.exit(1);
124
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.6.1 — 2026-07-08
6
+
7
+ ### Fixed
8
+
9
+ - **Unweighted string arrays now produce stable power-law distributions**
10
+ (the long-documented intent finally works). Previously `choose()` rolled a
11
+ fresh random winner on every event — two compounding bugs (`!mostChosenIndex`
12
+ discarding an explicit `0`, plus a fresh `pickAWinner` closure per event) —
13
+ so aggregate breakdowns came out uniform (±edge noise). Now any plain array
14
+ of 3–19 unique strings gets ONE seed-deterministic winner per array per run,
15
+ drawn as ~45% winner / ~25% second / ~15% third / geometric-decay tail
16
+ (`winnerWeights` in `lib/utils/utils.js` is the single tuning point).
17
+ - Existing opt-outs unchanged: arrays containing
18
+ `variant`/`group`/`experiment`/`population`, arrays with explicit
19
+ duplicate entries (dupes honored exactly), arrays of length ≤2 or ≥20,
20
+ and non-string values.
21
+ - Direct `u.pickAWinner([...])` use in dungeon files gets the same stable
22
+ memoized winner for the run; explicit `pickAWinner(arr, 0)` is honored now.
23
+ (Note: `pickAWinner` calls at module-import time in dungeon files execute
24
+ before the run's seed is applied — as before — so those winners are stable
25
+ within a run but not pinned across processes.) Each `pickAWinner` resolver
26
+ now also carries its own expansion — previously `choose()`'s resolver cache
27
+ keyed by function source could serve one property's value list to a
28
+ different property.
29
+ - UTM properties (`utm_campaign` etc.) draw through the same path, so
30
+ campaign values now skew realistically per network instead of splitting
31
+ uniformly.
32
+ - New `resetValueCaches()` clears the per-run winner memo (and the
33
+ weighted-array resolver cache, which previously leaked across in-process
34
+ runs); called automatically by `initChance()` and at every run start.
35
+ - Side effect: ~1 RNG draw per string-array property per event instead of
36
+ ~30, and the seeded RNG stream shifts vs 1.6.0 — same seed no longer
37
+ reproduces 1.6.0 output byte-for-byte (within-version determinism is
38
+ unchanged).
39
+ - **Engine-shape canary and hook-pattern integration tests made truly
40
+ deterministic** — their generating tests now run `describe.sequential`,
41
+ since concurrent in-process `generate()` calls interleave draws (and
42
+ re-seeds) on the shared seeded chance. The hook-pattern negative control
43
+ was recalibrated to 600 users; at 150 it exceeded its own threshold on
44
+ 1.6.0 too and only passed under one lucky interleaving.
45
+
5
46
  ## 1.6.0 — 2026-07-04
6
47
 
7
48
  ### Added
package/README.md CHANGED
@@ -155,6 +155,8 @@ import { createTextGenerator, generateBatch } from '@ak--47/dungeon-master/text'
155
155
 
156
156
  these are the same functions used internally. `pickAWinner` creates weighted distributions, `weighNumRange` generates realistic numeric ranges with configurable skew, and the text generators produce organic-looking strings with sentiment analysis and keyword injection.
157
157
 
158
+ **you usually don't need `pickAWinner`** — as of 1.6.1, any property value that is a plain array of 3–19 unique strings automatically gets a stable power-law distribution: one seed-deterministic winner per array per run (~45% winner / ~25% second / ~15% third / decaying tail). to opt out and get uniform draws, use exactly 2 values, 20+, or include one of the keywords `variant` / `group` / `experiment` / `population` in a value (experiment arms stay balanced). arrays with explicit duplicate entries (`["card", "card", "apple_pay"]`) skip the auto-weighting and honor the duplicates exactly.
159
+
158
160
  ### named exports
159
161
 
160
162
  alongside the default `DUNGEON_MASTER` export, the package root exports loader + interop helpers:
package/index.js CHANGED
@@ -27,7 +27,7 @@ import { makeMirror } from './lib/generators/mirror.js';
27
27
  import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
28
28
 
29
29
  // Utilities
30
- import { initChance, initUserChance, resetUserChance, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
30
+ import { initChance, initUserChance, resetUserChance, resetValueCaches, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
31
31
  import { runWithDataset } from './lib/utils/dataset-context.js';
32
32
 
33
33
  // External dependencies
@@ -130,6 +130,10 @@ async function runDungeon(config) {
130
130
  // Initialize seeded RNG BEFORE validation — config-validator captures a
131
131
  // chance reference for default userProps (spiritAnimal). If we init after,
132
132
  // run 1 binds an unseeded instance while run 2 binds a stale one → non-deterministic.
133
+ // v1.6.1: clear per-run winner/weighted-array caches unconditionally —
134
+ // a prior in-process run must never leak its winners into this one.
135
+ // (initChance also clears them, but only fires when a seed is set.)
136
+ resetValueCaches();
133
137
  if (config.seed) {
134
138
  initChance(config.seed);
135
139
  }
@@ -36,9 +36,35 @@ let globalUserChance;
36
36
  let userChanceInitialized = false;
37
37
 
38
38
  // Module-scoped memoization cache for weighted-array resolvers in `choose()`.
39
- // Lives for the lifetime of the node process; key is the function source string.
39
+ // Key is the function source string; cleared per run by resetValueCaches().
40
+ // Functions tagged `noCache = true` (e.g. pickAWinner closures, whose source
41
+ // strings are identical across instances) bypass this cache entirely.
40
42
  const weightedArrayCache = new Map();
41
43
 
44
+ // v1.6.1: per-run winner memo for unweighted string arrays. Two layers:
45
+ // a WeakMap keyed by array IDENTITY holding the full {idx, weights} entry
46
+ // (property arrays are stable references across a run, so the steady state is
47
+ // a single WeakMap hit per event — no join, no weight rebuild), plus a Map
48
+ // keyed by array CONTENTS so distinct array instances with equal contents
49
+ // share one winner. Guarantees ONE stable winner per array per run so
50
+ // aggregate distributions stay visibly skewed instead of cancelling to
51
+ // uniform. Cleared by resetValueCaches() at run start / initChance so winners
52
+ // never leak across runs or seeds.
53
+ let winnerEntryCache = new WeakMap();
54
+ const winnerCache = new Map();
55
+
56
+ /**
57
+ * Clear the per-run value-resolution caches (winner memo + weighted-array
58
+ * resolver cache). Called by initChance() and at the top of every runDungeon
59
+ * so in-process back-to-back runs (different seeds, different users) never
60
+ * inherit a prior run's winners.
61
+ */
62
+ function resetValueCaches() {
63
+ winnerEntryCache = new WeakMap();
64
+ winnerCache.clear();
65
+ weightedArrayCache.clear();
66
+ }
67
+
42
68
  // v1.5.1: dataset-window state moved to AsyncLocalStorage scope
43
69
  // (`lib/utils/dataset-context.js`). Each `generate()` call wraps the pipeline
44
70
  // in `runWithDataset(begin, now, fn)`, and factory thunks read via the
@@ -146,6 +172,7 @@ function initChance(seed) {
146
172
  if (!seed && process.env.SEED) seed = process.env.SEED;
147
173
  globalChance = new Chance(seed);
148
174
  chanceInitialized = true;
175
+ resetValueCaches();
149
176
  return globalChance;
150
177
  }
151
178
 
@@ -387,10 +414,10 @@ function choose(value) {
387
414
  // check to make sure that each element in the array only occurs once...
388
415
  const uniqueItems = new Set(value);
389
416
  if (uniqueItems.size === value.length) {
390
- // Array has no duplicates, use pickAWinner
391
- const quickList = pickAWinner(value, 0)();
392
- const theChosenOne = chance.pickone(quickList);
393
- return theChosenOne;
417
+ // Array has no duplicates power-law draw with ONE stable
418
+ // seed-deterministic winner per array per run (v1.6.1)
419
+ const entry = getWinnerEntry(/** @type {string[]} */ (value));
420
+ return chance.weighted(value, entry.weights);
394
421
  }
395
422
 
396
423
  }
@@ -408,8 +435,17 @@ function choose(value) {
408
435
  }
409
436
 
410
437
  try {
411
- // Keep resolving the value if it's a function (with caching)
438
+ // Keep resolving the value if it's a function (with caching).
439
+ // Functions tagged noCache (pickAWinner closures) skip the source-string
440
+ // cache: their toString() is identical across instances, so caching by
441
+ // source would hand one property's expansion to every other property.
412
442
  while (typeof value === 'function') {
443
+ if (/** @type {any} */ (value).noCache === true) {
444
+ const result = value();
445
+ if (result instanceof ListValue) return result;
446
+ value = result;
447
+ continue;
448
+ }
413
449
  const funcString = value.toString();
414
450
 
415
451
  if (weightedArrayCache.has(funcString)) {
@@ -452,11 +488,6 @@ function choose(value) {
452
488
  }
453
489
  }
454
490
 
455
- // ["","",""] should pick-a-winner
456
- if (Array.isArray(value) && typeof value[0] === "string") {
457
- value = pickAWinner(value)();
458
- }
459
-
460
491
  // [0,1,2] should pick one
461
492
  if (Array.isArray(value) && typeof value[0] === "number") {
462
493
  return chance.pickone(value);
@@ -988,63 +1019,122 @@ function weighChoices(items) {
988
1019
  };
989
1020
  }
990
1021
 
1022
+ /**
1023
+ * Resolve the stable per-run winner index for an array of items. Memoized in
1024
+ * `winnerCache` keyed by array contents (raw joined string — no hashing, so
1025
+ * no collision risk), so every event drawing from the same array favors the
1026
+ * SAME winner for the whole run. Winner is rolled from the seeded chance →
1027
+ * same seed = same winner; resetValueCaches() clears the memo between runs.
1028
+ *
1029
+ * @param {Array} items - The list of items to pick a winner from.
1030
+ * @returns {number} - The stable winner index for this run.
1031
+ */
1032
+ function getStableWinnerIndex(items) {
1033
+ const key = items.join('\u0000');
1034
+ if (winnerCache.has(key)) return winnerCache.get(key);
1035
+ const chance = getChance();
1036
+ const winner = chance.integer({ min: 0, max: items.length - 1 });
1037
+ winnerCache.set(key, winner);
1038
+ return winner;
1039
+ }
1040
+
1041
+ /**
1042
+ * Full winner entry ({idx, weights}) for an array, memoized by array identity
1043
+ * in a WeakMap. Steady state for the choose() hot path is one WeakMap hit per
1044
+ * event — no join, no weight rebuild. Distinct array instances with equal
1045
+ * contents converge on the same winner via getStableWinnerIndex.
1046
+ *
1047
+ * @param {string[]} items
1048
+ * @returns {{idx: number, weights: number[]}}
1049
+ */
1050
+ function getWinnerEntry(items) {
1051
+ let entry = winnerEntryCache.get(items);
1052
+ if (entry) return entry;
1053
+ const idx = getStableWinnerIndex(items);
1054
+ entry = { idx, weights: winnerWeights(items.length, idx) };
1055
+ winnerEntryCache.set(items, entry);
1056
+ return entry;
1057
+ }
1058
+
1059
+ /**
1060
+ * Build a power-law weight vector aligned to item indices: winner ~45%,
1061
+ * second ~25%, third ~15%, remainder split over the tail with geometric decay.
1062
+ * Rank order follows the rotational convention: second = (winner+1) % n,
1063
+ * third = (winner+2) % n. This is THE place to tune the curve.
1064
+ *
1065
+ * @param {number} n - Number of items.
1066
+ * @param {number} winnerIndex - Index of the winning item.
1067
+ * @returns {number[]} - Weights (unnormalized; chance.weighted normalizes).
1068
+ */
1069
+ function winnerWeights(n, winnerIndex) {
1070
+ if (n <= 0) return [];
1071
+ if (n === 1) return [1];
1072
+ const HEAD = [45, 25, 15];
1073
+ const TAIL_BUDGET = 15;
1074
+ const TAIL_DECAY = 0.7;
1075
+ const tailCount = Math.max(0, n - HEAD.length);
1076
+ let tailWeights = [];
1077
+ if (tailCount > 0) {
1078
+ const raw = [];
1079
+ let w = 1;
1080
+ for (let k = 0; k < tailCount; k++) { raw.push(w); w *= TAIL_DECAY; }
1081
+ const sum = raw.reduce((a, b) => a + b, 0);
1082
+ tailWeights = raw.map(r => (r / sum) * TAIL_BUDGET);
1083
+ }
1084
+ const weights = new Array(n).fill(0);
1085
+ for (let rank = 0; rank < n; rank++) {
1086
+ const idx = (winnerIndex + rank) % n;
1087
+ weights[idx] = rank < HEAD.length ? HEAD[rank] : tailWeights[rank - HEAD.length];
1088
+ }
1089
+ return weights;
1090
+ }
1091
+
991
1092
  /**
992
1093
  * Creates a function that generates a weighted list of items
993
1094
  * with a higher likelihood of picking a specified index and clear second and third place indices.
994
- *
1095
+ *
1096
+ * v1.6.1: the returned closure yields a DETERMINISTIC weighted expansion
1097
+ * (winner ~45%, second ~25%, third ~15%, geometric tail) built once — no
1098
+ * per-call sampling variance. When no index is passed, the winner is the
1099
+ * stable per-run memoized winner for the array (see getStableWinnerIndex),
1100
+ * so direct dungeon-file usage is stable and seed-deterministic too.
1101
+ *
995
1102
  * @param {Array} items - The list of items to pick from.
996
1103
  * @param {number} [mostChosenIndex] - The index of the item to be most favored.
997
1104
  * @returns {function} - A function that returns a weighted list of items.
998
1105
  */
999
1106
  function pickAWinner(items, mostChosenIndex) {
1000
- const chance = getChance();
1001
-
1002
- // Ensure mostChosenIndex is within the bounds of the items array
1003
1107
  if (!items) return () => { return ""; };
1004
1108
  if (!items.length) return () => { return ""; };
1005
- if (!mostChosenIndex) mostChosenIndex = chance.integer({ min: 0, max: items.length - 1 });
1006
- if (mostChosenIndex >= items.length) mostChosenIndex = items.length - 1;
1007
-
1008
- // Calculate second and third most chosen indices
1009
- const secondMostChosenIndex = (mostChosenIndex + 1) % items.length;
1010
- const thirdMostChosenIndex = (mostChosenIndex + 2) % items.length;
1109
+ if (!Number.isFinite(mostChosenIndex)) {
1110
+ // undefined/null/NaN stable per-run memoized winner
1111
+ mostChosenIndex = getStableWinnerIndex(items);
1112
+ } else {
1113
+ mostChosenIndex = Math.floor(mostChosenIndex);
1114
+ if (mostChosenIndex >= items.length) mostChosenIndex = items.length - 1;
1115
+ if (mostChosenIndex < 0) mostChosenIndex = 0;
1116
+ }
1011
1117
 
1012
- // Return a function that generates a weighted list
1013
- return function () {
1014
- const weighted = [];
1015
- for (let i = 0; i < 10; i++) {
1016
- const rand = chance.d10(); // Random number between 1 and 10
1017
-
1018
- // 35% chance to favor the most chosen index
1019
- if (chance.bool({ likelihood: 35 })) {
1020
- // 50% chance to slightly alter the index
1021
- if (chance.bool({ likelihood: 50 })) {
1022
- weighted.push(items[mostChosenIndex]);
1023
- } else {
1024
- const addOrSubtract = chance.bool({ likelihood: 50 }) ? -rand : rand;
1025
- let newIndex = mostChosenIndex + addOrSubtract;
1118
+ const weights = winnerWeights(items.length, mostChosenIndex);
1119
+ const total = weights.reduce((a, b) => a + b, 0);
1120
+ // scale slots with n so large arrays keep the ~45% winner share instead of
1121
+ // having Math.max(1, ...) floors dilute it
1122
+ const SLOTS = Math.max(20, items.length * 4);
1123
+ const expansion = [];
1124
+ items.forEach((item, i) => {
1125
+ // every item keeps at least one slot so no value becomes unreachable
1126
+ const count = Math.max(1, Math.round((weights[i] / total) * SLOTS));
1127
+ for (let j = 0; j < count; j++) expansion.push(item);
1128
+ });
1026
1129
 
1027
- // Ensure newIndex is within bounds
1028
- if (newIndex < 0) newIndex = 0;
1029
- if (newIndex >= items.length) newIndex = items.length - 1;
1030
- weighted.push(items[newIndex]);
1031
- }
1032
- }
1033
- // 25% chance to favor the second most chosen index
1034
- else if (chance.bool({ likelihood: 25 })) {
1035
- weighted.push(items[secondMostChosenIndex]);
1036
- }
1037
- // 15% chance to favor the third most chosen index
1038
- else if (chance.bool({ likelihood: 15 })) {
1039
- weighted.push(items[thirdMostChosenIndex]);
1040
- }
1041
- // Otherwise, pick a random item from the list
1042
- else {
1043
- weighted.push(chance.pickone(items));
1044
- }
1045
- }
1046
- return weighted;
1130
+ const resolver = function () {
1131
+ return expansion;
1047
1132
  };
1133
+ // all pickAWinner closures share one source string; without this tag,
1134
+ // choose()'s weightedArrayCache would cache the first closure's expansion
1135
+ // under that shared key and serve it for every other pickAWinner property
1136
+ resolver.noCache = true;
1137
+ return resolver;
1048
1138
  }
1049
1139
 
1050
1140
  function quickHash(str, seed = 0) {
@@ -1774,6 +1864,7 @@ export {
1774
1864
  getUniqueKeys,
1775
1865
  person,
1776
1866
  pickAWinner,
1867
+ resetValueCaches,
1777
1868
  quickHash,
1778
1869
  weighArray,
1779
1870
  validateEventConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/types.d.ts CHANGED
@@ -2358,6 +2358,8 @@ declare module '@ak--47/dungeon-master/utils' {
2358
2358
  export function objectList(template: Record<string, ValueValid>, options?: { min?: number; max?: number }): () => Array<Record<string, unknown>>;
2359
2359
  export function weighNumRange(min: number, max: number, skew?: number, size?: number): number[];
2360
2360
  export function pickAWinner(items: string[], mostChosenIndex?: number): () => string[];
2361
+ /** Clears the per-run stable-winner + weighted-array caches (also called by initChance and at every run start). */
2362
+ export function resetValueCaches(): void;
2361
2363
  export function initChance(seed?: string): unknown;
2362
2364
  export function initUserChance(seed?: string): unknown;
2363
2365
  export function getUserChance(): unknown;