From 512978cae13a11ea7a8d67c23f52e5bd7c7bda36 Mon Sep 17 00:00:00 2001 From: Season Date: Mon, 20 Jul 2026 16:06:28 +0800 Subject: [PATCH] Add calibration report and lock the shipped XP baseline (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the configured-vs-observed calibration report with an evidence gate that keeps runtime constants until every signal is reviewable. Pin levels 1–30, encounter tables, catch curve, question ranks, and reward display strings with a baseline fixture; document the desktop playtest worksheet. --- docs/calibration.md | 130 +++++ docs/learning-events.md | 11 +- game/assets/shared/player-progression.ts | 17 + game/assets/src/battle/BattleScreen.ts | 9 +- game/assets/src/world/WorldScreen.ts | 13 +- package.json | 1 + shared/player-progression.ts | 17 + shared/tests/calibration-baseline.test.ts | 43 ++ .../fixtures/calibration-baseline.v1.json | 470 ++++++++++++++++++ shared/tests/player-progression.test.ts | 11 + tools/prepare-calibration-save.mjs | 34 ++ tools/prepare-calibration-save.test.mjs | 28 ++ tools/render-calibration-report.mjs | 373 ++++++++++++++ tools/render-calibration-report.test.mjs | 95 ++++ 14 files changed, 1244 insertions(+), 8 deletions(-) create mode 100644 docs/calibration.md create mode 100644 shared/tests/calibration-baseline.test.ts create mode 100644 shared/tests/fixtures/calibration-baseline.v1.json create mode 100644 tools/prepare-calibration-save.mjs create mode 100644 tools/prepare-calibration-save.test.mjs create mode 100644 tools/render-calibration-report.mjs create mode 100644 tools/render-calibration-report.test.mjs diff --git a/docs/calibration.md b/docs/calibration.md new file mode 100644 index 0000000..63d0380 --- /dev/null +++ b/docs/calibration.md @@ -0,0 +1,130 @@ +# Calibration (#25) + +Tune XP, level-gap, question rank, encounter effort, and capture from +**observed play** — never from intuition. This doc is the operator guide for +the report, the evidence gate, and the desktop playtest that feeds both. + +## Guardrails (non-negotiable) + +- Nonzero XP floor: a correct answer always awards at least 1 XP. +- Calm ordinary capture: no flee clock, no throw pressure for common / + uncommon / rare creatures. +- Unlimited thinking time: no answer-speed XP and no duration telemetry. +- Aggregate-only: cells with fewer than 5 events are suppressed; user ids, + event ids, and answers never print. + +## Run the report + +```sh +# Local or remote D1 — same shape either way. +cd worker +npx wrangler d1 execute pokemath-db --remote --json \ + --command "SELECT name, occurred_at, props_json FROM events" > /tmp/events.json +cd .. +npm run report:calibration -- /tmp/events.json +``` + +`npm run report:learning` remains the pure learning-quality view. Calibration +extends that with the **configured** side: levels 1–30, weaker / equal-level / +stronger reward displays, live Meadow encounter tables, catch curve, and +question-rank distribution from the active manifest. + +## Evidence gate + +The report opens with a hard decision: + +| status | meaning | +| --- | --- | +| `INSUFFICIENT EVIDENCE — KEEP SHIPPED BASELINE` | At least one signal is below the aggregate threshold. **No constant may change.** | +| `READY FOR STRUCTURED HUMAN REVIEW` | Every signal has n≥5. Season reviews before/after examples; only then may constants change. | + +Signals: + +| signal | threshold | +| --- | ---: | +| correctness by question rank | ≥2 TP bands with n≥5 | +| battle abandonment | ≥5 outcomes | +| delayed review | ≥5 answers *(mechanic not shipped yet — gate stays closed)* | +| healthy stopping | ≥5 session ends | +| completed captures | ≥5 captures | + +Passing a gate permits review only. It never auto-tunes. + +## Baseline lock + +The shipped baseline is pinned by +[`shared/tests/fixtures/calibration-baseline.v1.json`](../shared/tests/fixtures/calibration-baseline.v1.json) +and `shared/tests/calibration-baseline.test.ts`. Any approved constant change +must update that fixture in the same PR, with before/after examples for +weaker, equal-level, and stronger players printed by the report. + +Display strings (`+N XP`, `Lv N`, `into/span`) are pure formatters in +`shared/player-progression.ts`, shared by the HUD, result card, and +calibration fixtures so approved numbers equal on-screen numbers. + +## Variants + +Wild encounters are **normal** only. Alt encounter rate is not implemented — +there is no variant constant to tune. Collection still records `variants` +when an alt is owned; that is inventory state, not an encounter roll. + +## Desktop playtest worksheet (levels 1–30) + +Local only (`docs/local-testing.md`). Never mutate production saves for +calibration. + +### Seed a level + +```sh +# Authenticated local session (cookie jar from local-testing.md). +curl -s -b /tmp/dev-cookies.txt http://localhost:8799/api/save > /tmp/save.json +node tools/prepare-calibration-save.mjs /tmp/save.json 8 > /tmp/put.json +curl -s -b /tmp/dev-cookies.txt -X PUT http://localhost:8799/api/save \ + -H 'content-type: application/json' \ + --data-binary @/tmp/put.json +# Reload the game so the HUD reads the new level. +``` + +`prepare-calibration-save` sets `player.level` and `player.totalXp` to the +exact curve boundary for levels 1–30 and returns a CAS body (`baseVersion` +from the GET). + +### Representative loop (per stratum) + +Run at **player levels 1, 8, and 20** (weaker / mid / stronger relative to +level-1 wilds and level-3 bosses): + +1. **Dock → Woolly.** Confirm HUD shows `Lv N · into/span` matching the seed. +2. **2–4 ordinary battles.** Answer deliberately slow once — XP must not care. +3. **Capture once at high HP, once at low HP.** Ordinary capture must stay + calm (no countdown). +4. **Flee once mid-battle.** Abandonment is a healthy-stopping signal. +5. **One boss if available** (level 3 wild). Confirm gap modifier pays better + than a level-1 wild at the same player level. +6. **Stop outside battle** (sign-out or close tab after a payoff, not mid + question). + +Expected on-screen XP for a correct TP2 +/− turn against a level-1 wild +(from the report's levels table): + +| player level | XP / correct turn | correct turns to next level | +| ---: | ---: | ---: | +| 1 | 6 | 4 | +| 8 | 2 | 45 | +| 20 | 2 | 105 | +| 30 | 2 | 155 | + +### After the session + +```sh +cd worker +npx wrangler d1 execute pokemath-db --local --json \ + --command "SELECT name, occurred_at, props_json FROM events" > /tmp/events.json +cd .. +npm run report:calibration -- /tmp/events.json +``` + +If the gate still reads insufficient, keep playing. If it reads ready, +Season reviews each proposed constant with the report's before/after +examples — weaker, equal-level, stronger — and only then lands a constants +PR that updates the baseline fixture. diff --git a/docs/learning-events.md b/docs/learning-events.md index 7d885d0..430ce8b 100644 --- a/docs/learning-events.md +++ b/docs/learning-events.md @@ -84,13 +84,20 @@ land first. npx wrangler d1 execute pokemath-db --remote --json \ --command "SELECT name, occurred_at, props_json FROM events" > /tmp/events.json npm run report:learning -- /tmp/events.json +npm run report:calibration -- /tmp/events.json ``` -The report compares **predicted difficulty** (TP level ordering) with +`report:learning` compares **predicted difficulty** (TP level ordering) with **observed outcomes** (correct rates by operation/topic/TP, battle abandonment, collection variety, voluntary stopping) and flags TP inversions — where a higher-TP band is answered *more* correctly than a -lower one, the first place #25 calibration should look. +lower one. + +`report:calibration` (#25) pairs the same events with the **configured** +XP curve, level-gap, encounter tables, capture rates, and question-rank +distribution, and opens with an evidence gate that keeps the shipped +baseline until every signal is reviewable. Operator guide and desktop +playtest worksheet: [calibration.md](calibration.md). ## Deploying the migration diff --git a/game/assets/shared/player-progression.ts b/game/assets/shared/player-progression.ts index 233fec3..5f0ab46 100644 --- a/game/assets/shared/player-progression.ts +++ b/game/assets/shared/player-progression.ts @@ -50,6 +50,23 @@ export interface PlayerLevelInfo { span: number; } +/** Pure display formatters shared by the HUD, result card, and calibration + * fixtures so the numbers approved in review are exactly what players see. */ +export function formatPlayerXpGain(gain: number): string { + if (!Number.isInteger(gain) || gain < 0) { + throw new Error(`formatPlayerXpGain: invalid gain ${gain}`); + } + return `+${gain} XP`; +} + +export function formatPlayerLevel(info: PlayerLevelInfo): string { + return `Lv ${info.level}`; +} + +export function formatPlayerProgress(info: PlayerLevelInfo): string { + return `${info.intoLevel}/${info.span}`; +} + /** * Inverse of totalXpForLevel: given a total XP total, return the resulting * level and how far it is into that level (for the XP bar). total=0 → level 1. diff --git a/game/assets/src/battle/BattleScreen.ts b/game/assets/src/battle/BattleScreen.ts index ad771c9..ae81f66 100644 --- a/game/assets/src/battle/BattleScreen.ts +++ b/game/assets/src/battle/BattleScreen.ts @@ -12,6 +12,9 @@ import { QuestionTurn, correctAnswerDamage, createUniqueHunt, + formatPlayerLevel, + formatPlayerProgress, + formatPlayerXpGain, isOrdering, playerXpForTurn, prizeMoney, @@ -612,7 +615,7 @@ export class BattleScreen { rowY -= 26; } - const xpText = makeLabel(panel, `+${result.xpGain} XP`, result.prize === null ? 0 : -70, rowY, { + const xpText = makeLabel(panel, formatPlayerXpGain(result.xpGain), result.prize === null ? 0 : -70, rowY, { fontSize: 21, }); xpText.color = PALETTE.xp; @@ -624,13 +627,13 @@ export class BattleScreen { // Level progress AFTER the award: Lv tag, bar, and into/span numbers — // the same truth the world HUD bar shows. const { after } = result.award; - makeLabel(panel, `Lv ${after.level}`, -184, rowY, { fontSize: 15, align: "left" }); + makeLabel(panel, formatPlayerLevel(after), -184, rowY, { fontSize: 15, align: "left" }); makeRect(panel, -10, rowY, 240, 12, new Color(221, 221, 221, 255), 6); if (after.intoLevel > 0) { const width = 240 * Math.min(1, after.intoLevel / after.span); makeRect(panel, -130 + width / 2, rowY, width, 12, PALETTE.xp, 6); } - makeLabel(panel, `${after.intoLevel}/${after.span}`, 184, rowY, { + makeLabel(panel, formatPlayerProgress(after), 184, rowY, { fontSize: 13, color: PALETTE.sub, align: "right", diff --git a/game/assets/src/world/WorldScreen.ts b/game/assets/src/world/WorldScreen.ts index 497034b..b3ace19 100644 --- a/game/assets/src/world/WorldScreen.ts +++ b/game/assets/src/world/WorldScreen.ts @@ -43,7 +43,14 @@ import { regionW, tileAt, } from "./regions/index"; -import { Creature, SPECIES_BY_ID, pickEncounter, rollEncounter } from "../../shared/index"; +import { + Creature, + SPECIES_BY_ID, + formatPlayerLevel, + formatPlayerProgress, + pickEncounter, + rollEncounter, +} from "../../shared/index"; import { ArcCritter, arcCrittersFor, fernDialogFor, patchRegionForArc } from "./arc"; import { TrailClue, @@ -1360,13 +1367,13 @@ export class WorldScreen { lineWidth: 2, }, ); - makeLabel(strip, `Lv ${info.level}`, -90, 0, { fontSize: 11, align: "left" }); + makeLabel(strip, formatPlayerLevel(info), -90, 0, { fontSize: 11, align: "left" }); makeRect(strip, -4, 0, 110, 8, new Color(221, 221, 221, 255), 4); if (info.intoLevel > 0) { const width = 110 * Math.min(1, info.intoLevel / info.span); makeRect(strip, -59 + width / 2, 0, width, 8, PALETTE.xp, 4); } - makeLabel(strip, `${info.intoLevel}/${info.span}`, 90, 0, { + makeLabel(strip, formatPlayerProgress(info), 90, 0, { fontSize: 10, color: PALETTE.sub, align: "right", diff --git a/package.json b/package.json index d27433e..741a624 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "review:question-batch": "node tools/review-question-batch.mjs", "import:questions": "node tools/import-question-batch.mjs", "report:learning": "node tools/render-learning-report.mjs", + "report:calibration": "node tools/render-calibration-report.mjs", "generate:creature": "node tools/generate-creature.mjs", "setup-art": "node tools/art-registry.mjs setup", "publish-art": "node tools/art-registry.mjs publish", diff --git a/shared/player-progression.ts b/shared/player-progression.ts index 233fec3..5f0ab46 100644 --- a/shared/player-progression.ts +++ b/shared/player-progression.ts @@ -50,6 +50,23 @@ export interface PlayerLevelInfo { span: number; } +/** Pure display formatters shared by the HUD, result card, and calibration + * fixtures so the numbers approved in review are exactly what players see. */ +export function formatPlayerXpGain(gain: number): string { + if (!Number.isInteger(gain) || gain < 0) { + throw new Error(`formatPlayerXpGain: invalid gain ${gain}`); + } + return `+${gain} XP`; +} + +export function formatPlayerLevel(info: PlayerLevelInfo): string { + return `Lv ${info.level}`; +} + +export function formatPlayerProgress(info: PlayerLevelInfo): string { + return `${info.intoLevel}/${info.span}`; +} + /** * Inverse of totalXpForLevel: given a total XP total, return the resulting * level and how far it is into that level (for the XP bar). total=0 → level 1. diff --git a/shared/tests/calibration-baseline.test.ts b/shared/tests/calibration-baseline.test.ts new file mode 100644 index 0000000..af2864b --- /dev/null +++ b/shared/tests/calibration-baseline.test.ts @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { loadConfiguredCalibration } from "../../tools/render-calibration-report.mjs"; + +const fixtureUrl = new URL("fixtures/calibration-baseline.v1.json", import.meta.url); + +function serializable(configured: Awaited>) { + return { + schemaVersion: 1, + disposition: "retain-shipped-baseline-until-evidence-gate", + xp: configured.xp, + levels: configured.levels, + rewards: configured.rewards, + encounters: configured.encounters, + effort: configured.effort, + capture: configured.capture, + ranks: { + manifestVersion: configured.ranks.manifestVersion, + banks: configured.ranks.banks, + questions: configured.ranks.questions, + byTp: Object.fromEntries([...configured.ranks.byTp.entries()].sort(([a], [b]) => a - b)), + }, + variants: configured.variants, + }; +} + +test("calibration baseline locks levels 1–30, active encounters, rank, capture, and reward displays", async () => { + const fixture = JSON.parse(await readFile(fixtureUrl, "utf8")); + assert.deepEqual(serializable(await loadConfiguredCalibration()), fixture); +}); + +test("calibration baseline keeps the three non-negotiable guardrails", async () => { + const configured = await loadConfiguredCalibration(); + assert.ok(configured.xp.minimumPerTurn > 0, "correct answers retain nonzero XP"); + assert.ok(configured.capture.fullHp > 0, "ordinary capture is possible immediately"); + assert.ok(configured.capture.tenPercentHp < 1, "ordinary capture stays calm rather than guaranteed"); + // Thinking time cannot appear in the pure configured snapshot: pressure is + // counted only in committed Unique answers, never seconds or milliseconds. + assert.equal("timeLimit" in configured.capture, false); + assert.equal("answerSpeed" in configured.xp, false); +}); diff --git a/shared/tests/fixtures/calibration-baseline.v1.json b/shared/tests/fixtures/calibration-baseline.v1.json new file mode 100644 index 0000000..5b02fdf --- /dev/null +++ b/shared/tests/fixtures/calibration-baseline.v1.json @@ -0,0 +1,470 @@ +{ + "schemaVersion": 1, + "disposition": "retain-shipped-baseline-until-evidence-gate", + "xp": { + "levelBase": 20, + "levelGrowth": 10, + "turnBase": 5, + "hardBonus": 2, + "tpBonusCap": 2, + "gapStep": 0.15, + "gapFloor": 0.25, + "minimumPerTurn": 1 + }, + "levels": [ + { + "level": 1, + "totalToReach": 0, + "toNext": 20, + "routineTp2PerTurn": 6, + "correctTurnsToNext": 4 + }, + { + "level": 2, + "totalToReach": 20, + "toNext": 30, + "routineTp2PerTurn": 5, + "correctTurnsToNext": 6 + }, + { + "level": 3, + "totalToReach": 50, + "toNext": 40, + "routineTp2PerTurn": 4, + "correctTurnsToNext": 10 + }, + { + "level": 4, + "totalToReach": 90, + "toNext": 50, + "routineTp2PerTurn": 3, + "correctTurnsToNext": 17 + }, + { + "level": 5, + "totalToReach": 140, + "toNext": 60, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 30 + }, + { + "level": 6, + "totalToReach": 200, + "toNext": 70, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 35 + }, + { + "level": 7, + "totalToReach": 270, + "toNext": 80, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 40 + }, + { + "level": 8, + "totalToReach": 350, + "toNext": 90, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 45 + }, + { + "level": 9, + "totalToReach": 440, + "toNext": 100, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 50 + }, + { + "level": 10, + "totalToReach": 540, + "toNext": 110, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 55 + }, + { + "level": 11, + "totalToReach": 650, + "toNext": 120, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 60 + }, + { + "level": 12, + "totalToReach": 770, + "toNext": 130, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 65 + }, + { + "level": 13, + "totalToReach": 900, + "toNext": 140, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 70 + }, + { + "level": 14, + "totalToReach": 1040, + "toNext": 150, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 75 + }, + { + "level": 15, + "totalToReach": 1190, + "toNext": 160, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 80 + }, + { + "level": 16, + "totalToReach": 1350, + "toNext": 170, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 85 + }, + { + "level": 17, + "totalToReach": 1520, + "toNext": 180, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 90 + }, + { + "level": 18, + "totalToReach": 1700, + "toNext": 190, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 95 + }, + { + "level": 19, + "totalToReach": 1890, + "toNext": 200, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 100 + }, + { + "level": 20, + "totalToReach": 2090, + "toNext": 210, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 105 + }, + { + "level": 21, + "totalToReach": 2300, + "toNext": 220, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 110 + }, + { + "level": 22, + "totalToReach": 2520, + "toNext": 230, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 115 + }, + { + "level": 23, + "totalToReach": 2750, + "toNext": 240, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 120 + }, + { + "level": 24, + "totalToReach": 2990, + "toNext": 250, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 125 + }, + { + "level": 25, + "totalToReach": 3240, + "toNext": 260, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 130 + }, + { + "level": 26, + "totalToReach": 3500, + "toNext": 270, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 135 + }, + { + "level": 27, + "totalToReach": 3770, + "toNext": 280, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 140 + }, + { + "level": 28, + "totalToReach": 4050, + "toNext": 290, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 145 + }, + { + "level": 29, + "totalToReach": 4340, + "toNext": 300, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 150 + }, + { + "level": 30, + "totalToReach": 4640, + "toNext": 310, + "routineTp2PerTurn": 2, + "correctTurnsToNext": 155 + } + ], + "rewards": [ + { + "label": "weaker", + "playerLevel": 1, + "wildLevel": 3, + "gain": 6, + "gainDisplay": "+6 XP", + "levelDisplay": "Lv 1", + "progressDisplay": "6/20" + }, + { + "label": "equal-level", + "playerLevel": 3, + "wildLevel": 3, + "gain": 6, + "gainDisplay": "+6 XP", + "levelDisplay": "Lv 3", + "progressDisplay": "6/40" + }, + { + "label": "stronger", + "playerLevel": 8, + "wildLevel": 3, + "gain": 2, + "gainDisplay": "+2 XP", + "levelDisplay": "Lv 8", + "progressDisplay": "2/90" + } + ], + "encounters": [ + { + "region": "woolly", + "rate": 0.2, + "entries": [ + { + "speciesId": "woolly/fluffball", + "weight": 65, + "rarity": "common", + "share": 0.65 + }, + { + "speciesId": "woolly/hare", + "weight": 27, + "rarity": "uncommon", + "share": 0.27 + }, + { + "speciesId": "woolly/ram", + "weight": 8, + "rarity": "rare", + "share": 0.08 + } + ] + }, + { + "region": "orchard", + "rate": 0.2, + "entries": [ + { + "speciesId": "meadow/plumelet", + "weight": 60, + "rarity": "common", + "share": 0.6 + }, + { + "speciesId": "meadow/blossomfox", + "weight": 25, + "rarity": "uncommon", + "share": 0.25 + }, + { + "speciesId": "meadow/pufftail", + "weight": 15, + "rarity": "common", + "share": 0.15 + } + ] + }, + { + "region": "gardens", + "rate": 0.2, + "entries": [ + { + "speciesId": "meadow/mothling", + "weight": 60, + "rarity": "common", + "share": 0.6 + }, + { + "speciesId": "woolly/hare", + "weight": 25, + "rarity": "uncommon", + "share": 0.25 + }, + { + "speciesId": "meadow/pufftail", + "weight": 15, + "rarity": "common", + "share": 0.15 + } + ] + }, + { + "region": "barn", + "rate": 0.2, + "entries": [ + { + "speciesId": "meadow/plumelet", + "weight": 45, + "rarity": "common", + "share": 0.45 + }, + { + "speciesId": "meadow/barnpup", + "weight": 30, + "rarity": "uncommon", + "share": 0.3 + }, + { + "speciesId": "meadow/pufftail", + "weight": 25, + "rarity": "common", + "share": 0.25 + } + ] + }, + { + "region": "ticktock", + "rate": 0.2, + "entries": [ + { + "speciesId": "meadow/owlet", + "weight": 40, + "rarity": "uncommon", + "share": 0.4 + }, + { + "speciesId": "meadow/pufftail", + "weight": 35, + "rarity": "common", + "share": 0.35 + }, + { + "speciesId": "meadow/blossomfox", + "weight": 25, + "rarity": "uncommon", + "share": 0.25 + } + ] + }, + { + "region": "festival", + "rate": 0.2, + "entries": [ + { + "speciesId": "meadow/pufftail", + "weight": 44, + "rarity": "common", + "share": 0.44 + }, + { + "speciesId": "woolly/fluffball", + "weight": 20, + "rarity": "common", + "share": 0.2 + }, + { + "speciesId": "meadow/mothling", + "weight": 14, + "rarity": "common", + "share": 0.14 + }, + { + "speciesId": "meadow/plumelet", + "weight": 14, + "rarity": "common", + "share": 0.14 + }, + { + "speciesId": "meadow/petalfae", + "weight": 8, + "rarity": "rare", + "share": 0.08 + } + ] + }, + { + "region": "stones", + "rate": 0.2, + "entries": [ + { + "speciesId": "woolly/ram", + "weight": 100, + "rarity": "rare", + "share": 1 + } + ] + } + ], + "effort": [ + { + "rarity": "common", + "minHp": 13, + "maxHp": 13, + "minTurns": 3, + "maxTurns": 4 + }, + { + "rarity": "uncommon", + "minHp": 15, + "maxHp": 15, + "minTurns": 3, + "maxTurns": 4 + }, + { + "rarity": "rare", + "minHp": 18, + "maxHp": 18, + "minTurns": 3, + "maxTurns": 5 + } + ], + "capture": { + "fullHp": 0.3, + "halfHp": 0.6, + "tenPercentHp": 0.8400000000000001, + "uniqueActions": 5, + "uniqueTrust": 3 + }, + "ranks": { + "manifestVersion": 8, + "banks": 7, + "questions": 132, + "byTp": { + "1": 5, + "2": 69, + "3": 52, + "4": 6 + } + }, + "variants": { + "wild": "normal", + "altEncounterRate": null + } +} diff --git a/shared/tests/player-progression.test.ts b/shared/tests/player-progression.test.ts index ceafe62..33fa895 100644 --- a/shared/tests/player-progression.test.ts +++ b/shared/tests/player-progression.test.ts @@ -6,6 +6,9 @@ import { PLAYER_LEVEL_FLOOR, PLAYER_XP_BASE, PLAYER_XP_GROWTH, + formatPlayerLevel, + formatPlayerProgress, + formatPlayerXpGain, legacyToPlayerProgress, levelForTotalXp, mintCreatureId, @@ -96,3 +99,11 @@ test("mintCreatureId returns a unique non-empty string", () => { test("PLAYER_LEVEL_FLOOR is 1", () => { assert.equal(PLAYER_LEVEL_FLOOR, 1); }); + +test("reward display formatters match the HUD and result card", () => { + const info = levelForTotalXp(23); // L2, 3/30 + assert.equal(formatPlayerXpGain(6), "+6 XP"); + assert.equal(formatPlayerLevel(info), "Lv 2"); + assert.equal(formatPlayerProgress(info), "3/30"); + assert.throws(() => formatPlayerXpGain(-1)); +}); diff --git a/tools/prepare-calibration-save.mjs b/tools/prepare-calibration-save.mjs new file mode 100644 index 0000000..b937774 --- /dev/null +++ b/tools/prepare-calibration-save.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Prepare an authenticated GET /api/save response for a local calibration +// PUT. This never talks to production and only changes player progression. + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { totalXpForLevel } from "../shared/player-progression.ts"; + +export function prepareCalibrationSave(envelope, level) { + if (!Number.isInteger(level) || level < 1 || level > 30) { + throw new Error(`level must be an integer in [1, 30], got ${level}`); + } + if (!envelope || typeof envelope !== "object" || !envelope.save || !Number.isInteger(envelope.saveVersion)) { + throw new Error("input must be a GET /api/save response with save and saveVersion"); + } + return { + save: { + ...envelope.save, + player: { level, totalXp: totalXpForLevel(level) }, + }, + baseVersion: envelope.saveVersion, + }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const [file, rawLevel] = process.argv.slice(2); + if (!file || rawLevel === undefined) { + console.error("usage: node tools/prepare-calibration-save.mjs "); + process.exit(1); + } + const body = prepareCalibrationSave(JSON.parse(await readFile(file, "utf8")), Number(rawLevel)); + console.log(JSON.stringify(body)); +} diff --git a/tools/prepare-calibration-save.test.mjs b/tools/prepare-calibration-save.test.mjs new file mode 100644 index 0000000..5e68113 --- /dev/null +++ b/tools/prepare-calibration-save.test.mjs @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { prepareCalibrationSave } from "./prepare-calibration-save.mjs"; + +const envelope = { + saveVersion: 7, + save: { + schemaVersion: 2, + player: { level: 2, totalXp: 20 }, + money: 200, + }, +}; + +test("prepares a CAS body at the exact level-curve boundary", () => { + const body = prepareCalibrationSave(envelope, 30); + assert.equal(body.baseVersion, 7); + assert.deepEqual(body.save.player, { level: 30, totalXp: 4640 }); + assert.equal(body.save.money, 200); + assert.deepEqual(envelope.save.player, { level: 2, totalXp: 20 }, "input stays untouched"); +}); + +test("rejects invalid levels and malformed API responses", () => { + for (const level of [0, 31, 1.5, NaN]) { + assert.throws(() => prepareCalibrationSave(envelope, level), /\[1, 30\]/); + } + assert.throws(() => prepareCalibrationSave({}, 1), /GET \/api\/save/); +}); diff --git a/tools/render-calibration-report.mjs b/tools/render-calibration-report.mjs new file mode 100644 index 0000000..d35161c --- /dev/null +++ b/tools/render-calibration-report.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +// Issue #25: reproducible configured-vs-observed calibration report. +// +// Runtime values are read from the shipped shared modules, active question +// manifest/banks, and live Meadow region tables. Observed values come from the +// aggregate-only event export documented in docs/learning-events.md. Sparse +// cells stay suppressed and can never justify a constants change. + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { + PLAYER_XP_BASE_PER_TURN, + PLAYER_XP_GAP_FLOOR, + PLAYER_XP_GAP_STEP, + PLAYER_XP_HARD_OP_BONUS, + PLAYER_XP_MIN_PER_TURN, + PLAYER_XP_TP_BONUS_CAP, + UNIQUE_FLEE_ACTIONS, + UNIQUE_TRUST_MAX, + playerXpForTurn, +} from "../shared/battle-rules.ts"; +import { Creature, MEADOW_SPECIES } from "../shared/creature.ts"; +import { + PLAYER_XP_BASE, + PLAYER_XP_GROWTH, + awardPlayerXp, + formatPlayerLevel, + formatPlayerProgress, + formatPlayerXpGain, + playerXpRequirement, + totalXpForLevel, +} from "../shared/player-progression.ts"; +import { turnsOf } from "../shared/question-engine.ts"; +import { MIN_CELL, parseRows } from "./render-learning-report.mjs"; + +const ROOT = new URL("../", import.meta.url); +const BANK_ROOT = new URL("game/assets/resources/question-banks/", ROOT); + +const REGION_FILES = [ + "meadow-woolly.ts", + "meadow-orchard.ts", + "meadow-gardens.ts", + "meadow-barn.ts", + "meadow-ticktock.ts", + "meadow-festival.ts", + "meadow-stones.ts", +]; + +function propsOf(row) { + try { + return JSON.parse(row.props_json); + } catch { + return {}; + } +} + +function bump(map, key, correct) { + const cell = map.get(key) ?? { answered: 0, correct: 0 }; + cell.answered++; + if (correct) cell.correct++; + map.set(key, cell); +} + +/** Aggregate only the bounded event properties needed for calibration. */ +export function aggregateCalibration(rows) { + const byTp = new Map(); + const outcomes = new Map(); + const sessions = { total: 0, healthy: 0, duringBattle: 0 }; + let questions = 0; + let reviews = 0; + let captures = 0; + let minAt = null; + let maxAt = null; + + for (const row of rows) { + if (typeof row.occurred_at === "string") { + if (minAt === null || row.occurred_at < minAt) minAt = row.occurred_at; + if (maxAt === null || row.occurred_at > maxAt) maxAt = row.occurred_at; + } + const props = propsOf(row); + if (row.name === "question_answered") { + questions++; + if (Number.isInteger(props.tp)) bump(byTp, props.tp, props.correct === true); + } else if (row.name === "review_question_answered") { + reviews++; + } else if (row.name === "battle_outcome") { + const key = `${props.battle}|${props.outcome}`; + outcomes.set(key, (outcomes.get(key) ?? 0) + 1); + } else if (row.name === "creature_captured") { + captures++; + } else if (row.name === "session_ended") { + sessions.total++; + if (props.duringBattle === true) sessions.duringBattle++; + else sessions.healthy++; + } + } + + return { + total: rows.length, + questions, + reviews, + captures, + byTp, + outcomes, + sessions, + window: { minAt, maxAt }, + }; +} + +function turn(tp = 2, operation = "addition") { + return turnsOf({ + id: 1, + question_zh: "1 + 1 = ?", + question_en: "1 + 1 = ?", + operation, + expression: "1 + 1", + answer: 2, + tp_level: tp, + })[0]; +} + +function rewardExample(label, playerLevel, wildLevel) { + const gain = playerXpForTurn(turn(), playerLevel, wildLevel); + const award = awardPlayerXp( + { level: playerLevel, totalXp: totalXpForLevel(playerLevel) }, + gain, + ); + return { + label, + playerLevel, + wildLevel, + gain, + gainDisplay: formatPlayerXpGain(gain), + levelDisplay: formatPlayerLevel(award.after), + progressDisplay: formatPlayerProgress(award.after), + }; +} + +function catchChanceAt(hp, maxHp = 100) { + const creature = new Creature({ + name: "Calibration creature", + color: "#000000", + maxHp, + hp, + attack: 1, + level: 1, + xp: 0, + boss: false, + speciesId: null, + }); + return creature.catchChance; +} + +async function loadQuestionRanks() { + const active = JSON.parse(await readFile(new URL("active-manifest.json", BANK_ROOT), "utf8")); + const manifestName = active.manifest.replace(/^question-banks\//, "") + ".json"; + const manifest = JSON.parse(await readFile(new URL(manifestName, BANK_ROOT), "utf8")); + const byTp = new Map(); + let questions = 0; + + for (const entry of manifest.entries) { + const path = entry.path.replace(/^question-banks\//, "") + ".json"; + const bank = JSON.parse(await readFile(new URL(path, BANK_ROOT), "utf8")); + for (const question of bank.questions) { + questions++; + byTp.set(question.tp_level, (byTp.get(question.tp_level) ?? 0) + 1); + } + } + return { manifestVersion: manifest.version, banks: manifest.entries.length, questions, byTp }; +} + +function parseEncounterTable(source, file) { + const block = source.match(/encounters:\s*{\s*rate:\s*([0-9.]+),\s*entries:\s*\[([\s\S]*?)\]\s*,?\s*}/); + if (!block) throw new Error(`cannot parse encounters from ${file}`); + const entries = []; + const row = /speciesId:\s*"([^"]+)",\s*weight:\s*([0-9.]+),\s*rarity:\s*"([^"]+)"/g; + for (const match of block[2].matchAll(row)) { + entries.push({ speciesId: match[1], weight: Number(match[2]), rarity: match[3] }); + } + if (entries.length === 0) throw new Error(`cannot parse encounter entries from ${file}`); + const totalWeight = entries.reduce((sum, entry) => sum + entry.weight, 0); + return { + region: file.replace(/^meadow-/, "").replace(/\.ts$/, ""), + rate: Number(block[1]), + entries: entries.map((entry) => ({ ...entry, share: entry.weight / totalWeight })), + }; +} + +async function loadEncounters() { + const dir = new URL("game/assets/src/world/regions/", ROOT); + return Promise.all( + REGION_FILES.map(async (file) => parseEncounterTable(await readFile(new URL(file, dir), "utf8"), file)), + ); +} + +function encounterEffort() { + const ordinary = MEADOW_SPECIES.filter((species) => species.rarity !== "guardian"); + const byRarity = new Map(); + for (const species of ordinary) { + const current = byRarity.get(species.rarity) ?? []; + current.push(species.maxHp); + byRarity.set(species.rarity, current); + } + // Two of three starters have attack 4. A routine +/− answer therefore + // deals 4..6 after rollDamage; the table reports deterministic bounds. + return [...byRarity.entries()].map(([rarity, hps]) => { + const minHp = Math.min(...hps); + const maxHp = Math.max(...hps); + return { + rarity, + minHp, + maxHp, + minTurns: Math.ceil(minHp / 6), + maxTurns: Math.ceil(maxHp / 4), + }; + }); +} + +/** Read the shipped configuration directly; no copied tuning constants. */ +export async function loadConfiguredCalibration() { + return { + xp: { + levelBase: PLAYER_XP_BASE, + levelGrowth: PLAYER_XP_GROWTH, + turnBase: PLAYER_XP_BASE_PER_TURN, + hardBonus: PLAYER_XP_HARD_OP_BONUS, + tpBonusCap: PLAYER_XP_TP_BONUS_CAP, + gapStep: PLAYER_XP_GAP_STEP, + gapFloor: PLAYER_XP_GAP_FLOOR, + minimumPerTurn: PLAYER_XP_MIN_PER_TURN, + }, + levels: Array.from({ length: 30 }, (_, index) => { + const level = index + 1; + const toNext = playerXpRequirement(level); + const routineTp2PerTurn = playerXpForTurn(turn(), level, 1); + return { + level, + totalToReach: totalXpForLevel(level), + toNext, + routineTp2PerTurn, + correctTurnsToNext: Math.ceil(toNext / routineTp2PerTurn), + }; + }), + rewards: [ + rewardExample("weaker", 1, 3), + rewardExample("equal-level", 3, 3), + rewardExample("stronger", 8, 3), + ], + encounters: await loadEncounters(), + effort: encounterEffort(), + capture: { + fullHp: catchChanceAt(100), + halfHp: catchChanceAt(50), + tenPercentHp: catchChanceAt(10), + uniqueActions: UNIQUE_FLEE_ACTIONS, + uniqueTrust: UNIQUE_TRUST_MAX, + }, + ranks: await loadQuestionRanks(), + variants: { wild: "normal", altEncounterRate: null }, + }; +} + +/** Minimum aggregate evidence gate. Passing permits review, never auto-tuning. */ +export function calibrationEvidence(observed) { + const tpBands = [...observed.byTp.values()].filter((cell) => cell.answered >= MIN_CELL).length; + const battles = [...observed.outcomes.values()].reduce((sum, count) => sum + count, 0); + const checks = [ + { signal: "correctness by question rank", sample: `${tpBands} TP bands with n≥${MIN_CELL}`, pass: tpBands >= 2 }, + { signal: "battle abandonment", sample: `${battles} outcomes`, pass: battles >= MIN_CELL }, + { signal: "delayed review", sample: `${observed.reviews} answers`, pass: observed.reviews >= MIN_CELL }, + { signal: "healthy stopping", sample: `${observed.sessions.total} stops`, pass: observed.sessions.total >= MIN_CELL }, + { signal: "completed captures", sample: `${observed.captures} captures`, pass: observed.captures >= MIN_CELL }, + ]; + return { checks, ready: checks.every((check) => check.pass) }; +} + +const pct = (value) => `${Math.round(value * 100)}%`; +const observedPct = (part, whole) => (whole === 0 ? "—" : `${Math.round((part / whole) * 100)}%`); + +function renderSuppressedRate(cell) { + return cell.answered < MIN_CELL + ? `suppressed (n<${MIN_CELL})` + : observedPct(cell.correct, cell.answered); +} + +export function renderCalibrationMarkdown(configured, observed) { + const evidence = calibrationEvidence(observed); + const out = ["# PokeMath calibration report", ""]; + out.push(`**Decision: ${evidence.ready ? "READY FOR STRUCTURED HUMAN REVIEW" : "INSUFFICIENT EVIDENCE — KEEP SHIPPED BASELINE"}.**`, ""); + out.push(`Events: ${observed.total} · window: ${observed.window.minAt ?? "—"} → ${observed.window.maxAt ?? "—"}`); + out.push(`Aggregate only; cells with n<${MIN_CELL} are suppressed. Passing a gate permits Season's review—it never changes constants automatically.`, ""); + + out.push("## Evidence gate", "", "| signal | observed sample | status |", "| --- | ---: | --- |"); + for (const check of evidence.checks) { + out.push(`| ${check.signal} | ${check.sample} | ${check.pass ? "reviewable" : "insufficient"} |`); + } + out.push("", "Capture outcomes do **not** measure throw success: current telemetry has no capture-attempt denominator. Delayed review is defined but is not emitted until that mechanic ships.", ""); + + out.push("## Observed learning and stopping", "", "| TP | answered | correct rate |", "| ---: | ---: | ---: |"); + if (observed.byTp.size === 0) out.push("| — | 0 | — |"); + for (const [tp, cell] of [...observed.byTp.entries()].sort(([a], [b]) => a - b)) { + out.push(`| ${tp} | ${cell.answered} | ${renderSuppressedRate(cell)} |`); + } + const battles = [...observed.outcomes.values()].reduce((sum, count) => sum + count, 0); + const fled = [...observed.outcomes.entries()] + .filter(([key]) => key.endsWith("|fled")) + .reduce((sum, [, count]) => sum + count, 0); + out.push("", `Battle outcomes: ${battles}; abandonment rate: ${battles < MIN_CELL ? `suppressed (n<${MIN_CELL})` : observedPct(fled, battles)}.`); + out.push(`Delayed-review answers: ${observed.reviews}. Completed captures: ${observed.captures}.`); + out.push(`Stops outside battle: ${observed.sessions.healthy}; during battle: ${observed.sessions.duringBattle}; stopping split: ${observed.sessions.total < MIN_CELL ? `suppressed (n<${MIN_CELL})` : observedPct(observed.sessions.healthy, observed.sessions.total) + " outside battle"}.`, ""); + + out.push("## Configured XP and level-gap baseline", ""); + const xp = configured.xp; + out.push(`Level requirement: ${xp.levelBase} + ${xp.levelGrowth} × (level−1) XP.`); + out.push(`Turn reward: base ${xp.turnBase}; hard-operation bonus +${xp.hardBonus}; TP bonus capped at +${xp.tpBonusCap}.`); + out.push(`Over-level modifier: max(${xp.gapFloor}, 1 − ${xp.gapStep} × positive gap); absolute floor ${xp.minimumPerTurn} XP per correct turn.`, ""); + out.push("### Before/after review examples", "", "No constant change is proposed: current and candidate-after values are identical pending representative evidence.", ""); + out.push("| relationship | player / wild | current | candidate after | displayed result from level boundary |", "| --- | ---: | ---: | ---: | --- |"); + for (const reward of configured.rewards) { + const display = `${reward.gainDisplay} · ${reward.levelDisplay} · ${reward.progressDisplay}`; + out.push(`| ${reward.label} | L${reward.playerLevel} / L${reward.wildLevel} | ${reward.gain} XP | ${reward.gain} XP | ${display} |`); + } + + out.push("", "### Levels 1–30", "", "Routine comparison uses a correct TP2 +/− turn against a live level-1 wild creature.", ""); + out.push("| level | total XP to reach | XP to next | routine XP / correct turn | correct turns to next |", "| ---: | ---: | ---: | ---: | ---: |"); + for (const level of configured.levels) { + out.push(`| ${level.level} | ${level.totalToReach} | ${level.toNext} | ${level.routineTp2PerTurn} | ${level.correctTurnsToNext} |`); + } + + out.push("", "## Configured question rank", ""); + out.push(`Active manifest v${configured.ranks.manifestVersion}: ${configured.ranks.banks} banks, ${configured.ranks.questions} questions.`); + out.push("", "| TP | configured questions | observed correct rate |", "| ---: | ---: | ---: |"); + for (const [tp, count] of [...configured.ranks.byTp.entries()].sort(([a], [b]) => a - b)) { + const cell = observed.byTp.get(tp); + out.push(`| ${tp} | ${count} | ${cell ? renderSuppressedRate(cell) : "no data"} |`); + } + + out.push("", "## Configured encounter rate and roster", "", "All live Meadow grass tables currently use a 20% roll per grass step (5 grass steps per encounter in expectation).", ""); + out.push("| region | per-step rate | weighted roster |", "| --- | ---: | --- |"); + for (const table of configured.encounters) { + const roster = table.entries.map((entry) => `${entry.speciesId} ${pct(entry.share)}`).join(", "); + out.push(`| ${table.region} | ${pct(table.rate)} | ${roster} |`); + } + + out.push("", "### Ordinary encounter effort", "", "Bounds assume a representative attack-4 starter and routine +/− questions (4–6 damage per correct answer). They are deterministic review bounds, not a claim about child skill.", ""); + out.push("| rarity | wild HP | correct-answer turns |", "| --- | ---: | ---: |"); + for (const effort of configured.effort) { + out.push(`| ${effort.rarity} | ${effort.minHp}–${effort.maxHp} | ${effort.minTurns}–${effort.maxTurns} |`); + } + + out.push("", "## Configured capture and variants", ""); + out.push(`Ordinary capture: ${pct(configured.capture.fullHp)} at full HP, ${pct(configured.capture.halfHp)} at half HP, ${pct(configured.capture.tenPercentHp)} at 10% HP.`); + out.push("Ordinary capture has no action countdown and no thinking timer. Unlimited question thinking time is unchanged."); + out.push(`The authored Unique path uses ${configured.capture.uniqueTrust} trust from ${configured.capture.uniqueActions} committed answers; wall-clock time is absent.`); + out.push("Wild variant baseline: normal only. Alt encounter rate is not implemented, so there is no variant constant to tune or approve yet.", ""); + + out.push("## Approval record", ""); + out.push("- Runtime constants changed: **none**."); + out.push("- Baseline disposition: retain until the evidence gate and representative desktop worksheet are complete."); + out.push("- Guardrails retained: nonzero XP floor; calm ordinary capture; unlimited question thinking time.", ""); + return out.join("\n"); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const file = process.argv[2]; + if (!file) { + console.error("usage: node tools/render-calibration-report.mjs "); + process.exit(1); + } + const rows = parseRows(JSON.parse(await readFile(file, "utf8"))); + console.log(renderCalibrationMarkdown(await loadConfiguredCalibration(), aggregateCalibration(rows))); +} diff --git a/tools/render-calibration-report.test.mjs b/tools/render-calibration-report.test.mjs new file mode 100644 index 0000000..e443902 --- /dev/null +++ b/tools/render-calibration-report.test.mjs @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + aggregateCalibration, + calibrationEvidence, + loadConfiguredCalibration, + renderCalibrationMarkdown, +} from "./render-calibration-report.mjs"; + +function row(name, props, at = "2026-07-20T10:00:00.000Z") { + return { name, occurred_at: at, props_json: JSON.stringify(props) }; +} + +function repeated(count, make) { + return Array.from({ length: count }, (_, index) => make(index)); +} + +test("configured calibration reads the shipped levels, question ranks, encounters, and display strings", async () => { + const configured = await loadConfiguredCalibration(); + assert.equal(configured.levels.length, 30); + assert.deepEqual(configured.levels[0], { + level: 1, + totalToReach: 0, + toNext: 20, + routineTp2PerTurn: 6, + correctTurnsToNext: 4, + }); + assert.deepEqual(configured.levels[29], { + level: 30, + totalToReach: 4640, + toNext: 310, + routineTp2PerTurn: 2, + correctTurnsToNext: 155, + }); + assert.equal(configured.ranks.questions, 132); + assert.equal(configured.encounters.length, 7); + assert.ok(configured.encounters.every((table) => table.rate === 0.2)); + assert.deepEqual( + configured.rewards.map(({ label, gainDisplay, levelDisplay, progressDisplay }) => ({ + label, + gainDisplay, + levelDisplay, + progressDisplay, + })), + [ + { label: "weaker", gainDisplay: "+6 XP", levelDisplay: "Lv 1", progressDisplay: "6/20" }, + { label: "equal-level", gainDisplay: "+6 XP", levelDisplay: "Lv 3", progressDisplay: "6/40" }, + { label: "stronger", gainDisplay: "+2 XP", levelDisplay: "Lv 8", progressDisplay: "2/90" }, + ], + ); +}); + +test("sparse smoke data is suppressed and keeps the shipped baseline", async () => { + const rows = [ + ...repeated(4, () => row("question_answered", { battle: "wild", operation: "addition", tp: 2, correct: true })), + row("battle_outcome", { battle: "wild", outcome: "won", asked: 4, correct: 4 }), + row("session_ended", { reason: "page_unload", duringBattle: false }), + ]; + const observed = aggregateCalibration(rows); + assert.equal(calibrationEvidence(observed).ready, false); + const markdown = renderCalibrationMarkdown(await loadConfiguredCalibration(), observed); + assert.match(markdown, /INSUFFICIENT EVIDENCE — KEEP SHIPPED BASELINE/); + assert.match(markdown, /\| 2 \| 4 \| suppressed \(n<5\) \|/); + assert.match(markdown, /Runtime constants changed: \*\*none\*\*/); +}); + +test("every evidence cell must reach the aggregate threshold before human review", () => { + const rows = [ + ...repeated(5, (i) => row("question_answered", { battle: "wild", operation: "counting", tp: 1, correct: i < 4 })), + ...repeated(5, (i) => row("question_answered", { battle: "wild", operation: "addition", tp: 2, correct: i < 3 })), + ...repeated(5, (i) => row("battle_outcome", { battle: "wild", outcome: i === 0 ? "fled" : "won", asked: 2, correct: 1 })), + ...repeated(5, (i) => row("review_question_answered", { operation: "addition", tp: 2, correct: i < 3 })), + ...repeated(5, () => row("session_ended", { reason: "sign_out", duringBattle: false })), + ...repeated(5, (i) => row("creature_captured", { speciesId: `meadow/species-${i}` })), + ]; + assert.equal(calibrationEvidence(aggregateCalibration(rows)).ready, true); +}); + +test("report remains aggregate-only and explains telemetry limits", async () => { + const observed = aggregateCalibration([ + { + user_id: "secret-user", + event_id: "secret-event", + name: "creature_captured", + occurred_at: "2026-07-20T10:00:00.000Z", + props_json: '{"speciesId":"woolly/fluffball"}', + }, + ]); + const markdown = renderCalibrationMarkdown(await loadConfiguredCalibration(), observed); + assert.doesNotMatch(markdown, /secret-user|secret-event/); + assert.match(markdown, /no capture-attempt denominator/); + assert.match(markdown, /Alt encounter rate is not implemented/); + assert.match(markdown, /Unlimited question thinking time is unchanged/); +});