What changed, and why
When the model can't compose an objective, the server falls back to a random curated one so a run never fails to start. That floor is correct, and this change keeps it. The problem was that the fallback was silent, and in one case silence lied: when the player steered (picked an era or theme) and generation fell over, they got a curated objective that ignored their steer, with nothing to say so. In a playtest it read as "steering is broken" — the real cause was the model account hitting zero credit mid-session, which dropped every compose to curated.
The fix surfaces a quiet, non-blocking notice in exactly that case, and stays silent everywhere else. It threads one new fact — did the AI path actually produce this objective, or is it the curated floor? — from the generator, through the endpoint and the store, to a muted line in the briefing. Four small layers, below, each carrying the bit a little further.
The generator reports its provenance
generateObjective used to return a bare GameObjective, which threw away the one thing the caller now needs: whether that objective came from the model or from the curated floor. It returns a small result instead — { objective, composed } — where composed is true only when the live AI path produced it. Every curated path sets it false: the default no-AI path, and every fallback (a generation error, an out-of-bounds result, a de-dup collision).
The success return sits inside the compose loop; the curated return is the function's last line, reached by the default path or any fallback. (The local composed objective was renamed candidate so the boolean field could take the name.)
The result type, and the two return sites: composed=true only on the AI success path.
server/utils/objective-generator.ts · 278 lines
⋯ 29 lines hidden (lines 1–29)
⋯ 30 lines hidden (lines 42–71)
⋯ 194 lines hidden (lines 85–278)
The endpoint combines the steer with the provenance
Only the endpoint knows both halves: whether a steer was actually requested, and whether the AI path fell back. It computes steered from the coerced steer — coerceSteer has already dropped anything off-enum to "no preference", so garbage query params can't masquerade as a steer — and returns fellBackToCurated = steered && !composed. A no-steer fallback returns false, so the common path the player never notices makes no noise.
steered is read off the coerced steer; the flag is the AND of 'steered' and 'not composed'.
server/api/objective.get.ts · 39 lines
⋯ 25 lines hidden (lines 1–25)
The store threads the flag through
fetchAIObjective now returns { objective, fellBackToCurated } instead of a bare objective-or-null. It reads the new wire field and applies one guard: report the dropped steer only when an objective actually came back. A hard failure (no objective at all) is the louder compose-error path, not this quiet notice, so its flag is forced false even if the wire somehow set it. The catch branch returns the same shape, so the UI never has to special-case a throw.
objective ? res.fellBackToCurated === true : false — the flag rides only on a present objective.
stores/game.ts · 1474 lines
⋯ 1219 lines hidden (lines 1–1219)
⋯ 236 lines hidden (lines 1239–1474)
The briefing shows a quiet line — only when earned
MissionSelect gets a second notice ref, composeNotice, kept distinct from the existing composeError. rollFresh clears both at the top of every roll (so nothing lingers across rerolls), destructures the new flag, and sets the muted notice only when fellBackToCurated is true. In the template it renders as a muted, non-blocking line right beside the red error — never instead of it.
The muted compose-degraded line; the dedicated ref; and rollFresh setting it only on a steered fallback.
components/MissionSelect.vue · 255 lines
⋯ 98 lines hidden (lines 1–98)
⋯ 79 lines hidden (lines 106–184)
⋯ 20 lines hidden (lines 193–212)
⋯ 21 lines hidden (lines 235–255)
Tests pin the new behavior
The discriminating assertions live where the flag is decided. At the generator, composed is true on a clean compose and false on every fallback — out-of-bounds, transport failure, de-dup collision. At the component, the muted notice shows on a steered fallback (and the red error does not), and stays silent on a normal compose. The store gets its own pass-through and guard tests. Full unit suite green (944) and nuxt typecheck clean.
composed=true on the clean compose; composed=false on the out-of-bounds and transport fallbacks.
tests/unit/server/utils/objective-generator.spec.ts · 403 lines
⋯ 103 lines hidden (lines 1–103)
⋯ 95 lines hidden (lines 115–209)
⋯ 169 lines hidden (lines 235–403)
The two #127 component tests: notice shown on a steered fallback, silent on a normal compose.