What changed, and why
"Compose a fresh objective with AI" had a tell: it almost always handed back Save the Library of Alexandria. That title is one of the strongest counterfactual attractors a language model has, and it happens to be curated objective #3 — so the model was free to, and did, reproduce it. Worse, re-clicking compose could repeat objectives the player had already seen that session. The generator carried no exclusion list and no memory of prior rolls.
The fix has two halves. A session avoid-list (the titles already composed this run) now rides from the mission-select screen all the way into the compose prompt. And a de-dup guard rejects any composed title that collides with the curated pool or a seen title, rerolls once, then falls back to an unseen curated pick. The prompt is the soft steer; the guard is the hard backstop. Nothing else about objective generation changes, and the curated pool is still the always-available floor.
| Layer | File | Role in the fix |
|---|---|---|
| Domain | server/utils/objectives.ts | Normalize key + an avoid-aware curated pick |
| Generator | server/utils/objective-generator.ts | Thread the list, guard, prompt, fall back |
| Boundary | server/utils/validate.ts | Bound the untrusted avoid-list off the wire |
| Route | server/api/objective.get.ts | Read + coerce the avoid query param |
| Client | stores/game.ts · components/MissionSelect.vue | Track seen titles, send them back |
The comparison key and the unseen floor
Two small, pure additions to the client-safe domain module do the load-bearing work. normalizeObjectiveTitle folds a title to a comparison key, so a composed title that merely re-skins a curated one in case, punctuation, or accents still compares equal. Without it, "save the library of alexandria!" would slip straight past a naive string match.
Fold accents, lower-case, collapse non-alphanumerics to one space.
server/utils/objectives.ts · 233 lines
⋯ 163 lines hidden (lines 1–163)
⋯ 56 lines hidden (lines 178–233)
pickCurated gains an optional avoid-set. The fallback must never re-serve an objective the player already saw, yet it must always return something — so if the avoid-list somehow covers the whole pool, it falls back to the full pool. A curated objective always beats none.
Filter out avoided titles; never return nothing.
server/utils/objectives.ts · 233 lines
⋯ 144 lines hidden (lines 1–144)
⋯ 76 lines hidden (lines 158–233)
The generator: thread, guard, fall back
generateObjective takes a third argument now — avoid, the session-seen titles. It builds two sets from them. seen is the normalized session titles alone; dedupe is seen unioned with the whole curated pool. The distinction matters, and the callout below explains why.
Two sets, the reroll-once loop, the unseen-curated fallback.
server/utils/objective-generator.ts · 263 lines
⋯ 40 lines hidden (lines 1–40)
⋯ 194 lines hidden (lines 70–263)
Steering the prompt away from the greatest-hits
The hard guard catches collisions after the fact; the prompt tries to avoid them in the first place. The curated exemplars #87 introduced are now framed as territory already taken — match their tone, scale, and brief shape, but vary away in subject, era, and region. The session avoid-list, when there is one, is listed explicitly so the model steers around its own prior rolls.
Exemplars as vary-away-from, the avoid-list, a different-subject demand.
server/utils/objective-generator.ts · 263 lines
⋯ 208 lines hidden (lines 1–208)
⋯ 44 lines hidden (lines 220–263)
The session clause appears only once something's been rolled.
server/utils/objective-generator.ts · 263 lines
⋯ 244 lines hidden (lines 1–244)
⋯ 12 lines hidden (lines 252–263)
Bounding the untrusted avoid-list
The avoid-list is untrusted client input that reaches a model prompt, so it passes the same boundary discipline the rest of validate.ts applies. coerceAvoidTitles folds a repeated query key (an array) or a single value (a string) into a count-capped, per-title-length-capped list with empties dropped. A crafted request can't stuff the prompt or inflate token cost.
Reuses cleanArray/cleanString + the existing title cap; new count cap.
server/utils/validate.ts · 62 lines
⋯ 49 lines hidden (lines 1–49)
⋯ 1 line hidden (lines 62–62)
The route's single trusted gate: coerce steer + avoid, then generate.
server/api/objective.get.ts · 33 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 1 line hidden (lines 33–33)
Carrying the memory on the client
The server is stateless per request, so the session memory lives on the client. MissionSelect keeps a small list of titles composed this run and pushes each new one onto it. The list resets for free: the component is mounted only while there's no chosen objective, so a new run remounts it empty.
seenTitles, fed into the compose call and grown after each success.
components/MissionSelect.vue · 183 lines
⋯ 127 lines hidden (lines 1–127)
⋯ 22 lines hidden (lines 133–154)
⋯ 10 lines hidden (lines 174–183)
The store action simply forwards the list as a query param when it's non-empty, alongside the existing era/theme steer. A curated fallback's title gets recorded too, so even a fallback won't be re-served on the next reroll.
Optional avoid param, sent only when there's something to avoid.
stores/game.ts · 1297 lines
⋯ 1140 lines hidden (lines 1–1140)
⋯ 146 lines hidden (lines 1152–1297)
What the tests prove
The suite pins each acceptance criterion to a discriminating test — one that would fail if the feature broke, not coverage padding. The gateway is mocked, so the unit tests are deterministic and key-free.
| Claim | Where it's proven |
|---|---|
| The avoid-list reaches the compose prompt | objective-generator.spec.ts — asserts the seen titles appear in the user turn |
| A curated duplicate is rejected and rerolled | same — composes "Save the Library of Alexandria", expects the clean reroll |
| Case/punctuation re-skins still collide | same — composes "save the LIBRARY of alexandria!!!" |
| A session repeat is rejected and rerolled | same — passes a seen title via avoid |
| Both-collide falls back to an unseen curated pick | same — asserts the result is curated and not the seen title |
| The normalize key + avoid-aware pick | objectives.spec.ts — folding, never-re-serve, always-yields |
| The wire input is bounded | objective.spec.ts — coerceAvoidTitles array/string/junk/caps |
| The avoid-list rides the wire on a re-compose | mission-select.spec.ts (e2e) — second compose sends the first title |