What changed, and why
This PR does two things at once, and they're bundled because the first can't run without the second.
The feature: a model-behavior eval suite under evals/. Every other test in the repo mocks the AI; this is the only place the real model behavior — the Character, Timeline, and Chronicler layers — is observed and scored against the invariants the game design rests on. It runs via npm run eval, needs an OpenAI key, is kept off CI, and prints a scorecard.
The fix it forced: the test toolchain was upgraded — vitest 3.2.6 → 4.1.8, @nuxt/test-utils 3 → 4, happy-dom 17 → 20. This isn't gold-plating. On a fresh install the committed lockfile resolved vite 6.4.2, which broke vitest's environment loader outright, so vitest run collected zero tests. CI was red on any clean checkout. vitest 4 fixes that root-and-branch, so the upgrade is the price of a green suite, and the eval rides on top of it.
Read the toolchain change first (it's the load-bearing one), then the eval design.
Why CI was red, and the bumps that fix it
The dependency bumps are four lines, but they carry the whole CI fix. Under vitest 3.2.6, the separate vite-node package couldn't resolve vite's internal /@vite/env module against vite 6.4.2 — the loader threw before any test ran. vitest 4 folded vite-node in-process and resolves it cleanly, which is why a version bump (not a config tweak) was the fix. With v4 in place, the temporary overrides pin on vite that an earlier attempt added is gone — no longer needed.
The two new eval scripts, and the four test-toolchain bumps.
package.json · 52 lines
⋯ 12 lines hidden (lines 1–12)
⋯ 27 lines hidden (lines 15–41)
Splitting environments: the vitest.config.ts migration
vitest 4 removed environmentMatchGlobs, the old way this repo routed component tests to the Nuxt environment and everything else to node. The replacement is test.projects: two named projects, each pinned to one environment.
Per @nuxt/test-utils v4, the Nuxt project must be wrapped in defineVitestProject (it can't sit inside defineVitestConfig). The node project is plain — which means it loses the Nuxt vite plugin that normally supplies the ~/@ aliases, so those are set by hand from the project root.
Two projects: a plain node one with hand-set aliases, and the Nuxt one via defineVitestProject.
vitest.config.ts · 47 lines
⋯ 16 lines hidden (lines 1–16)
⋯ 7 lines hidden (lines 19–25)
The eval harness: sample, grade, scorecard
The harness is the toolkit the eval cases share, and its design choice is stated up front: these are not pass/fail tests. They sample each model layer N times, score an invariant in aggregate, and print a scorecard. The run always exits 0 — a behavioral weather report, not a gate. Noise is tamed by sampling and thresholds, never by asserting on a single stochastic call.
RUN gates everything: a real run needs a key, and EVAL_DRY_RUN forces the keyless scaffold path (used to verify wiring without spending tokens). sample runs N calls concurrently and keeps only the fulfilled ones, so one flaky call is dropped, not fatal.
The run gate, and the fault-tolerant sampler.
evals/harness.ts · 124 lines
⋯ 11 lines hidden (lines 1–11)
⋯ 19 lines hidden (lines 16–34)
⋯ 82 lines hidden (lines 43–124)
Qualitative invariants (does a figure stay in character? does the chronicle contradict the ledger?) can't be checked numerically, so they're graded by a second model — LLM-as-judge — on a cheaper model than the game itself. A grade that errors returns null and drops out of the denominator rather than skewing the score.
The judge: a strict yes/no verdict via structured output; hardened against instructions inside the graded text.
evals/harness.ts · 124 lines
⋯ 48 lines hidden (lines 1–48)
⋯ 51 lines hidden (lines 74–124)
The scorecard is the deliverable, so how it prints matters. It's registered as an afterAll and writes straight to process.stdout — because vitest 4 intercepts console.log inside hooks and would swallow it.
Render the rows, tally a verdict line, write directly to stdout.
evals/harness.ts · 124 lines
⋯ 98 lines hidden (lines 1–98)
The keystone: does the dice band actually drive the swing?
This is the case that earns the suite. The whole game leans on the dice band moving the Timeline Engine's progressChange; if that coupling is weak, the dice are decorative. So the test holds the scenario fixed and varies only the band across all five outcomes, then confirms the mean swing rises monotonically from critical-failure to critical-success. It's purely numeric — no judge needed.
Vary the band, average the swing per band, assert the means come out ordered.
evals/behavior.eval.ts · 210 lines
⋯ 31 lines hidden (lines 1–31)
⋯ 135 lines hidden (lines 76–210)
On the live run this comes out means [-36, -10, 6, 23, 42] — a clean monotonic curve, stable across runs. That's the empirical green light for the message-judge mechanic still to come: tilting the band really does move outcomes.
The production seam, the eval config, and how it's verified
One change touches shipping code. getOpenAIClient read the key via useRuntimeConfig(), which only resolves inside a Nuxt request — called from the eval (server utils running directly in node) it threw "[nuxt] instance unavailable" before reaching the existing process.env fallback. The fix wraps that call so it degrades to the env outside a Nuxt context. Inside a real request the behavior is unchanged.
Try useRuntimeConfig(), fall back to process.env — usable in both a request and plain node.
server/utils/openai.ts · 482 lines
⋯ 19 lines hidden (lines 1–19)
⋯ 438 lines hidden (lines 45–482)
The eval runs under its own config, deliberately separate from the unit one: it makes real API calls, costs tokens, and must never touch CI. It's a plain node config (not defineVitestConfig, which would drag in *.nuxt.test.ts files), with the aliases set by hand and unhandled errors tolerated so the report always exits 0.
Node env, hand-set aliases, never-fail.