What changed, and why
The craft verdict is the Judge's one-line reason for a grade — the player's main cue for how to write a sharper message next turn. For longer reasons it was cut off mid-word: …lacks specifics on medi for "mediation". The tail is the actionable part, so losing it reads as a glitch.
The cause was a defensive slice(0, 120) on the reason field in callJudgeAI. That field is structured output, asked to stay "under 90 characters" — but the model overruns that soft hint, and the raw slice then severed whatever word it landed in.
The fix keeps a defensive cap and makes it cut cleanly. A small pure helper trims on a word boundary and marks the elision; the seam calls it in place of the slice. Blast radius is one call site plus a new util — the grade, the roll modifier, and the rest of the pipeline are untouched.
The word-safe trim
trimVerdictReason is a pure function. If the text fits the cap it returns unchanged. Otherwise it finds the last space at or before the cap, cuts there, and appends an ellipsis. One detail is worth a second look: it searches slice(0, cap + 1), so a space sitting exactly at the cap still counts as a boundary.
The cap, then the trim: boundary search, runaway fallback, punctuation strip.
utils/verdict.ts · 44 lines
⋯ 16 lines hidden (lines 1–16)
One line at the judge seam
The only behavioral change at the seam is the reason line (379). The JSON parse, the craft coercion, and the closed-door timing rule just above it are unchanged. The string guard stays too: a non-string reason still falls back to the empty string.
The judge return — line 379 is the only call site changed.
server/utils/openai.ts · 578 lines
⋯ 367 lines hidden (lines 1–367)
⋯ 197 lines hidden (lines 382–578)
Tests pin the behavior
The util's behavior is fixed with small, readable cases: the exact-cap edge, a boundary landing on the cap, the punctuation strip, and the runaway-token fallback. Two cases speak to the bug directly.
Boundary + fallback cases, then the two that mirror the bug.
tests/unit/utils/verdict.spec.ts · 58 lines
⋯ 16 lines hidden (lines 1–16)
⋯ 10 lines hidden (lines 33–42)
The 124-char case is the bug's exact shape: longer than the old 120 cap, within the new one, returned whole. The overlong case asserts the real invariant — the cut falls on a boundary in the original string, so no word is severed — and that the actionable word ("alliance") survives.
The same guarantee, driven end to end through callJudgeAI.