@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.
- package/.claude/skills/powertools/SKILL.md +75 -0
- package/.claude/skills/powertools/pt.mjs +91 -0
- package/.claude/skills/powertools/snapshot-project.mjs +124 -0
- package/CHANGELOG.md +41 -0
- package/README.md +2 -0
- package/index.js +5 -1
- package/lib/utils/utils.js +146 -55
- package/package.json +1 -1
- package/types.d.ts +2 -0
|
@@ -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
|
}
|
package/lib/utils/utils.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
return
|
|
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)
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
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
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;
|