Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/calibration.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 9 additions & 2 deletions docs/learning-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions game/assets/shared/player-progression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions game/assets/src/battle/BattleScreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
QuestionTurn,
correctAnswerDamage,
createUniqueHunt,
formatPlayerLevel,
formatPlayerProgress,
formatPlayerXpGain,
isOrdering,
playerXpForTurn,
prizeMoney,
Expand Down Expand Up @@ -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;
Expand All @@ -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",
Expand Down
13 changes: 10 additions & 3 deletions game/assets/src/world/WorldScreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions shared/player-progression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions shared/tests/calibration-baseline.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof loadConfiguredCalibration>>) {
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);
});
Loading
Loading