What changed, and why

On the mission-select briefing you can pick a curated objective or have one composed by AI. The composed one dropped in at the top of the list, pushing the curated pool down and away from the "Compose a fresh objective with AI" button you had just pressed. Your eyes were at the button; the result landed somewhere else.

This moves it to the bottom, right above that button, so the thing you composed appears where you are looking, already selected and ready to begin.

The whole behaviour is one expression. The list is rendered straight from a computed array, so its order is that array's order. The fix reorders one spread; nothing else about compose, selection, or the curated pool moves. A new test pins the placement so it can't quietly regress.

The one-line reorder

options is the array the template walks with v-for to draw the rows. It is just the curated pool, with the freshly composed objective (if any) spliced in. Putting fresh.value first made it lead; putting it last makes it trail. That single position is the entire change to behaviour — the two comments above it and in the template were updated to match.

The template renders options in array order, so the spread order is the display order.

components/MissionSelect.vue · 462 lines
components/MissionSelect.vue462 lines · Vue
⋯ 87 lines hidden (lines 1–87)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="ew-fade-in max-w-4xl mx-auto">
4 <!-- Replay (issue #89): seeded from a shared run's objective. A focused view — this
5 one objective, ready to play — not the full browse/compose ledger. -->
6 <template v-if="replay">
7 <div class="mb-7">
8 <p class="ew-label">Replay a shared objective</p>
9 <h2
10 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight"
11 >
12 Take on this very objective
13 </h2>
14 <RemixCreditLine :credit="replayCredit" class="mt-2" />
15 </div>
16 
17 <div
18 data-testid="replay-objective"
19 class="border-t border-b ew-line py-4 flex items-start gap-3 ew-tint"
20 :style="{ borderLeft: '2px solid var(--ew-accent)' }"
21 >
22 <span
23 class="w-6 shrink-0 text-center text-lg leading-none select-none"
24 aria-hidden="true"
25 >{{ replay.objective.icon }}</span
26 >
27 <span class="min-w-0 flex-1">
28 <span class="flex items-center gap-2 flex-wrap">
29 <h3 class="ew-fg font-semibold">{{ replay.objective.title }}</h3>
30 <span v-if="replay.objective.era" class="ew-label">{{
31 replay.objective.era
32 }}</span>
33 </span>
34 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (#82). -->
35 <span
36 class="block ew-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line"
37 >{{ replay.objective.description }}</span
38 >
39 </span>
40 </div>
41 
42 <div
43 class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
44 >
45 <button
46 type="button"
47 data-testid="replay-exit"
48 class="ew-tap ew-hover-fg text-xs ew-mono uppercase tracking-wide"
49 @click="exitReplay"
50 >
51 ← Choose a different objective
52 </button>
53 <button
54 type="button"
55 data-testid="begin-button"
56 class="ew-btn ew-btn--primary"
57 :disabled="beginning"
58 @click="begin"
59 >
60 {{ beginning ? "Charting the timeline…" : "Play this objective" }}
61 <span aria-hidden="true"></span>
62 </button>
63 </div>
64 <p
65 v-if="beginError"
66 data-testid="begin-error"
67 class="text-xs ew-bad mt-2"
68 >
69 {{ beginError }}
70 </p>
71 </template>
72 
73 <!-- Normal briefing: choose a curated objective, or compose a fresh one. -->
74 <template v-else>
75 <div class="mb-7">
76 <p class="ew-label">Choose your crusade</p>
77 <h2
78 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight"
79 >
80 What will you set right?
81 </h2>
82 <p class="ew-muted text-sm mt-2 max-w-xl">
83 Five messages. Anyone in history. One world to remake. Pick the cause
84 that will define your run — or have a fresh one composed.
85 </p>
86 </div>
87 
88 <!-- Objectives as compact hairline rows: a one-line hook keeps the whole list
89 scannable at a glance (you see many at once, not two). The full brief unfolds
90 only on the chosen row, so the page stays short until you commit to reading
91 one. A freshly composed objective is appended last, above the compose button. -->
⋯ 276 lines hidden (lines 92–367)
92 <div
93 role="radiogroup"
94 aria-label="Choose an objective"
95 class="border-t ew-line"
96 >
97 <button
98 v-for="obj in options"
99 :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title"
100 type="button"
101 data-testid="objective-option"
102 role="radio"
103 :aria-checked="isSelected(obj)"
104 class="w-full text-left flex items-start gap-3 pr-2 pl-3 border-b ew-line transition ew-hover"
105 :class="isSelected(obj) ? 'ew-tint py-3.5' : 'py-2.5'"
106 :style="{
107 borderLeft: `2px solid ${isSelected(obj) ? 'var(--ew-accent)' : 'transparent'}`,
108 }"
109 @click="select(obj)"
110 >
111 <span
112 class="ew-dot mt-1.5"
113 :class="isSelected(obj) ? 'ew-dot--accent' : 'ew-dot--hollow'"
114 aria-hidden="true"
115 />
116 <span
117 class="w-6 shrink-0 text-center text-lg leading-none select-none"
118 aria-hidden="true"
119 >{{ obj.icon }}</span
120 >
121 <span class="min-w-0 flex-1">
122 <span class="flex items-center gap-2 flex-wrap">
123 <h3 class="ew-fg font-semibold">{{ obj.title }}</h3>
124 <span v-if="obj.era" class="ew-label">{{ obj.era }}</span>
125 <span
126 v-if="obj === fresh"
127 data-testid="fresh-badge"
128 class="ew-accent text-[10px] uppercase tracking-wider"
129 >
130 ✨ freshly composed
131 </span>
132 </span>
133 <!-- Collapsed: a single-line hook (truncate is the backstop; hookFor already
134 trims to ~70 chars). Chosen: the full brief, whitespace-pre-line for the
135 newline-separated "• " bullet lines (issue #82), faded in as it unfolds. -->
136 <span
137 v-if="!isSelected(obj)"
138 data-testid="objective-hook"
139 class="block ew-muted text-sm mt-0.5 leading-snug truncate"
140 >{{ hookFor(obj) }}</span
141 >
142 <span
143 v-else
144 data-testid="objective-brief"
145 class="ew-fade-in block ew-muted text-sm mt-1 leading-relaxed whitespace-pre-line"
146 >{{ obj.description }}</span
147 >
148 </span>
149 </button>
150 </div>
151 
152 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
153 "No preference" leaves an axis to the model. Closed enums only (no free
154 text), so an out-of-bounds objective can't be steered into existence. -->
155 <fieldset class="mt-6 pt-4 border-t ew-line">
156 <legend class="ew-label">
157 Steer a fresh composition <span class="ew-faint">· optional</span>
158 </legend>
159 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
160 <label class="flex-1 min-w-0 text-sm">
161 <span class="ew-label block mb-1">Era</span>
162 <select
163 v-model="steerEra"
164 data-testid="steer-era"
165 class="ew-select text-sm"
166 aria-label="Steer the era"
167 >
168 <option value="">No preference</option>
169 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">
170 {{ era }}
171 </option>
172 </select>
173 </label>
174 <label class="flex-1 min-w-0 text-sm">
175 <span class="ew-label block mb-1">Theme</span>
176 <select
177 v-model="steerTheme"
178 data-testid="steer-theme"
179 class="ew-select text-sm"
180 aria-label="Steer the theme"
181 >
182 <option value="">No preference</option>
183 <option
184 v-for="theme in OBJECTIVE_THEMES"
185 :key="theme"
186 :value="theme"
187 >
188 {{ theme }}
189 </option>
190 </select>
191 </label>
192 </div>
193 </fieldset>
194 
195 <!-- Actions: compose a fresh objective · begin the run -->
196 <div
197 class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
198 >
199 <div class="flex flex-col items-start gap-1">
200 <button
201 type="button"
202 data-testid="compose-objective"
203 class="ew-btn text-sm"
204 :disabled="composing"
205 @click="rollFresh"
206 >
207 <span aria-hidden="true"></span>
208 {{
209 composing
210 ? "Composing a fresh fate…"
211 : "Compose a fresh objective with AI"
212 }}
213 </button>
214 <p
215 v-if="activeSteer"
216 data-testid="active-steer"
217 class="text-xs ew-muted"
218 >
219 Steering: {{ activeSteer }}
220 </p>
221 <p
222 v-if="composeError"
223 data-testid="compose-error"
224 class="text-xs ew-bad"
225 >
226 {{ composeError }}
227 </p>
228 <!-- Quiet degrade (#127): a steered compose fell back to curated. Non-blocking
229 and muted (not the ew-bad error tone) — the player has a valid objective
230 ready; this just tells the truth that the steer was dropped. -->
231 <p
232 v-if="composeNotice"
233 data-testid="compose-degraded"
234 class="text-xs ew-muted"
235 >
236 {{ composeNotice }}
237 </p>
238 </div>
239 
240 <button
241 type="button"
242 data-testid="begin-button"
243 class="ew-btn ew-btn--primary"
244 :disabled="!selected || beginning"
245 @click="begin"
246 >
247 {{ beginning ? "Charting the timeline…" : "Begin rewriting history" }}
248 <span aria-hidden="true"></span>
249 </button>
250 <p
251 v-if="beginError"
252 data-testid="begin-error"
253 class="text-xs ew-bad mt-2"
254 >
255 {{ beginError }}
256 </p>
257 </div>
258 </template>
259 
260 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
261 the refused commit; this persistent notice lets the player reopen it if they
262 dismissed it. -->
263 <div
264 v-if="gameStore.outOfRuns"
265 data-testid="paywall"
266 class="mt-4 pt-4 border-t ew-line"
267 >
268 <p class="text-sm ew-fg font-semibold">
269 You're out of runs — your free run is spent.
270 </p>
271 <p class="ew-muted text-xs mt-0.5">
272 Grab a pack to keep rewriting history. One-time, never expires.
273 </p>
274 <button
275 type="button"
276 data-testid="open-packs"
277 class="ew-btn ew-btn--primary mt-3"
278 @click="economyStore.openBuyModal()"
279 >
280 See run packs <span aria-hidden="true"></span>
281 </button>
282 </div>
283 
284 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
285 <p
286 v-else-if="gameStore.atCapacity"
287 data-testid="at-capacity"
288 class="text-sm ew-bad mt-3"
289 >
290 The timeline is at capacity right now — please try again shortly.
291 </p>
292 
293 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
294 Policy: AI disclosure at session start; outputs may be inaccurate; an
295 adults-only posture). -->
296 <p
297 data-testid="ai-disclosure"
298 class="ew-faint text-xs leading-relaxed mt-8 pt-4 border-t ew-line max-w-2xl"
299 >
300 The historical figures here are played by AI, not real people, and every
301 reply is AI-generated
302 <span class="ew-fg"
303 >alternate history — dramatized fiction, not historical fact</span
304 >; don't rely on it as accurate. Intended for adults (18+).
305 </p>
306 </div>
307</template>
308 
309<script setup lang="ts">
310/**
311 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
312 * borderless hairline rows; selection uses identity, not titles. Nothing commits
313 * until Begin, so a freshly composed objective can be previewed and reconsidered.
314 */
315import { ref, shallowRef, computed } from "vue";
316import { useGameStore } from "~/stores/game";
317import { useEconomyStore } from "~/stores/economy";
318import type { RemixCredit } from "~/utils/run-snapshot";
319import {
320 listCuratedObjectives,
321 OBJECTIVE_ERAS,
322 OBJECTIVE_THEMES,
323 type GameObjective,
324 type ObjectiveEra,
325 type ObjectiveTheme,
326} from "~/utils/objectives";
327 
328const gameStore = useGameStore();
329const economyStore = useEconomyStore();
330 
331// Replay mode (issue #89): set when seeded from a shared run's objective. The briefing
332// shows just that objective, ready to play; Begin commits it verbatim (no generation).
333const replay = computed(() => gameStore.replayState);
334// The "remixed from" line above the seeded objective. The title is omitted (the objective
335// is shown right below), so it reads "Remixed from <name>" / "…an anonymous run", linking
336// back to the source when still shared.
337const replayCredit = computed<RemixCredit>(() => ({
338 attribution: replay.value?.sourceAttribution ?? null,
339 objectiveTitle: "",
340 sourceToken: replay.value?.sourceToken ?? null,
341}));
342 
343const curated = listCuratedObjectives();
344// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
345// curated options are plain objects. A deep ref would return a *reactive proxy* on
346// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
347// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
348// value as-is, keeping identity intact.
349const fresh = shallowRef<GameObjective | null>(null);
350const selected = shallowRef<GameObjective | null>(null);
351// Titles composed this session, fed back as the avoid-list so each reroll lands
352// somewhere new (#95). Resets naturally: MissionSelect is mounted only while there's
353// no objective (v-if in play.vue), so a new run remounts it with an empty list.
354const seenTitles = ref<string[]>([]);
355const composing = ref(false);
356// Hard-failure surface: no objective came back at all (red, ew-bad).
357const composeError = ref<string | null>(null);
358// Quiet-degrade surface (#127): a steered compose fell back to curated, so the
359// steer was dropped. Distinct from composeError — the player still has a valid
360// objective; this notice is muted and informational, not an error.
361const composeNotice = ref<string | null>(null);
362const beginning = ref(false);
363 
364// Composition steer (closed enums; '' = no preference, left to the model).
365const steerEra = ref<ObjectiveEra | "">("");
366const steerTheme = ref<ObjectiveTheme | "">("");
367 
368// A freshly composed objective appends to the END, so it surfaces right above the
369// compose button that produced it rather than displacing the curated pool. Selection
370// is identity-based (isSelected uses ===), so it stays selected wherever it sits.
371const options = computed(() =>
372 fresh.value ? [...curated, fresh.value] : curated,
373);
⋯ 89 lines hidden (lines 374–462)
374 
375// A plain-language echo of the active steer, surfaced by the compose button.
376const activeSteer = computed(
377 () => [steerEra.value, steerTheme.value].filter(Boolean).join(" · ") || null,
378);
379 
380// A one-line hook for the collapsed row — the first brief line, bullet marker
381// stripped, trimmed to ~70 chars at a word boundary. The full description (the "• "
382// bullet list, #82) is only revealed on the chosen row, keeping the list scannable.
383function hookFor(obj: GameObjective): string {
384 const first =
385 (obj.description || "")
386 .split("\n")
387 .map((l) => l.replace(/^[•\-\s]+/, "").trim())
388 .find(Boolean) || "";
389 if (first.length <= 72) return first;
390 const cut = first.slice(0, 70);
391 const lastSpace = cut.lastIndexOf(" ");
392 return (lastSpace > 40 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
394 
395function isSelected(obj: GameObjective) {
396 return selected.value === obj;
398 
399function select(obj: GameObjective) {
400 selected.value = obj;
402 
403async function rollFresh() {
404 composing.value = true;
405 composeError.value = null;
406 composeNotice.value = null;
407 const { objective, fellBackToCurated } = await gameStore.fetchAIObjective(
408 {
409 era: steerEra.value || undefined,
410 theme: steerTheme.value || undefined,
411 },
412 seenTitles.value,
413 );
414 composing.value = false;
415 
416 if (objective) {
417 fresh.value = objective;
418 selected.value = objective; // auto-select the fresh one so Begin is one tap away
419 // Remember it so the next reroll avoids it (a curated fallback lands here too,
420 // so even a fallback won't be re-served).
421 if (!seenTitles.value.includes(objective.title))
422 seenTitles.value.push(objective.title);
423 // The steer was set but generation fell back to curated (#127): say so quietly,
424 // so a dropped steer doesn't read as "steering is broken".
425 if (fellBackToCurated)
426 composeNotice.value =
427 "Couldn't compose a fresh objective right now — here's a curated one.";
428 } else {
429 composeError.value =
430 "The archives went quiet — pick one above, or try composing again.";
431 }
433 
434// Leave replay mode for the full browse/compose briefing — this run becomes an original.
435function exitReplay() {
436 gameStore.clearReplayState();
437 selected.value = null;
439 
440const beginError = ref<string | null>(null);
441 
442async function begin() {
443 // In replay mode the seeded objective IS the choice (no browse list rendered);
444 // otherwise it's whatever the player selected. Either way chooseObjective commits
445 // it verbatim — replay runs no generation, just reuses the captured objective.
446 const objective = replay.value?.objective ?? selected.value;
447 if (!objective || beginning.value) return;
448 beginError.value = null;
449 beginning.value = true;
450 // Charges one run from the balance. If out of runs, chooseObjective raises the
451 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
452 const started = await gameStore.chooseObjective(objective);
453 beginning.value = false;
454 // A false return that ISN'T the paywall (outOfRuns) or capacity gate is a real
455 // failure — ensureRunId/run-commit threw and set gameStore.error. Surface it here,
456 // or the button just flashes 'Charting the timeline…' and reverts silently (#233).
457 if (!started && !gameStore.outOfRuns && !gameStore.atCapacity) {
458 beginError.value =
459 gameStore.error || "Could not start the run. Please try again.";
460 }
462</script>

Identity-based selection: stored as the object, compared with ===, auto-set on compose.

components/MissionSelect.vue · 462 lines
components/MissionSelect.vue462 lines · Vue
⋯ 349 lines hidden (lines 1–349)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="ew-fade-in max-w-4xl mx-auto">
4 <!-- Replay (issue #89): seeded from a shared run's objective. A focused view — this
5 one objective, ready to play — not the full browse/compose ledger. -->
6 <template v-if="replay">
7 <div class="mb-7">
8 <p class="ew-label">Replay a shared objective</p>
9 <h2
10 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight"
11 >
12 Take on this very objective
13 </h2>
14 <RemixCreditLine :credit="replayCredit" class="mt-2" />
15 </div>
16 
17 <div
18 data-testid="replay-objective"
19 class="border-t border-b ew-line py-4 flex items-start gap-3 ew-tint"
20 :style="{ borderLeft: '2px solid var(--ew-accent)' }"
21 >
22 <span
23 class="w-6 shrink-0 text-center text-lg leading-none select-none"
24 aria-hidden="true"
25 >{{ replay.objective.icon }}</span
26 >
27 <span class="min-w-0 flex-1">
28 <span class="flex items-center gap-2 flex-wrap">
29 <h3 class="ew-fg font-semibold">{{ replay.objective.title }}</h3>
30 <span v-if="replay.objective.era" class="ew-label">{{
31 replay.objective.era
32 }}</span>
33 </span>
34 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (#82). -->
35 <span
36 class="block ew-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line"
37 >{{ replay.objective.description }}</span
38 >
39 </span>
40 </div>
41 
42 <div
43 class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
44 >
45 <button
46 type="button"
47 data-testid="replay-exit"
48 class="ew-tap ew-hover-fg text-xs ew-mono uppercase tracking-wide"
49 @click="exitReplay"
50 >
51 ← Choose a different objective
52 </button>
53 <button
54 type="button"
55 data-testid="begin-button"
56 class="ew-btn ew-btn--primary"
57 :disabled="beginning"
58 @click="begin"
59 >
60 {{ beginning ? "Charting the timeline…" : "Play this objective" }}
61 <span aria-hidden="true"></span>
62 </button>
63 </div>
64 <p
65 v-if="beginError"
66 data-testid="begin-error"
67 class="text-xs ew-bad mt-2"
68 >
69 {{ beginError }}
70 </p>
71 </template>
72 
73 <!-- Normal briefing: choose a curated objective, or compose a fresh one. -->
74 <template v-else>
75 <div class="mb-7">
76 <p class="ew-label">Choose your crusade</p>
77 <h2
78 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight"
79 >
80 What will you set right?
81 </h2>
82 <p class="ew-muted text-sm mt-2 max-w-xl">
83 Five messages. Anyone in history. One world to remake. Pick the cause
84 that will define your run — or have a fresh one composed.
85 </p>
86 </div>
87 
88 <!-- Objectives as compact hairline rows: a one-line hook keeps the whole list
89 scannable at a glance (you see many at once, not two). The full brief unfolds
90 only on the chosen row, so the page stays short until you commit to reading
91 one. A freshly composed objective is appended last, above the compose button. -->
92 <div
93 role="radiogroup"
94 aria-label="Choose an objective"
95 class="border-t ew-line"
96 >
97 <button
98 v-for="obj in options"
99 :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title"
100 type="button"
101 data-testid="objective-option"
102 role="radio"
103 :aria-checked="isSelected(obj)"
104 class="w-full text-left flex items-start gap-3 pr-2 pl-3 border-b ew-line transition ew-hover"
105 :class="isSelected(obj) ? 'ew-tint py-3.5' : 'py-2.5'"
106 :style="{
107 borderLeft: `2px solid ${isSelected(obj) ? 'var(--ew-accent)' : 'transparent'}`,
108 }"
109 @click="select(obj)"
110 >
111 <span
112 class="ew-dot mt-1.5"
113 :class="isSelected(obj) ? 'ew-dot--accent' : 'ew-dot--hollow'"
114 aria-hidden="true"
115 />
116 <span
117 class="w-6 shrink-0 text-center text-lg leading-none select-none"
118 aria-hidden="true"
119 >{{ obj.icon }}</span
120 >
121 <span class="min-w-0 flex-1">
122 <span class="flex items-center gap-2 flex-wrap">
123 <h3 class="ew-fg font-semibold">{{ obj.title }}</h3>
124 <span v-if="obj.era" class="ew-label">{{ obj.era }}</span>
125 <span
126 v-if="obj === fresh"
127 data-testid="fresh-badge"
128 class="ew-accent text-[10px] uppercase tracking-wider"
129 >
130 ✨ freshly composed
131 </span>
132 </span>
133 <!-- Collapsed: a single-line hook (truncate is the backstop; hookFor already
134 trims to ~70 chars). Chosen: the full brief, whitespace-pre-line for the
135 newline-separated "• " bullet lines (issue #82), faded in as it unfolds. -->
136 <span
137 v-if="!isSelected(obj)"
138 data-testid="objective-hook"
139 class="block ew-muted text-sm mt-0.5 leading-snug truncate"
140 >{{ hookFor(obj) }}</span
141 >
142 <span
143 v-else
144 data-testid="objective-brief"
145 class="ew-fade-in block ew-muted text-sm mt-1 leading-relaxed whitespace-pre-line"
146 >{{ obj.description }}</span
147 >
148 </span>
149 </button>
150 </div>
151 
152 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
153 "No preference" leaves an axis to the model. Closed enums only (no free
154 text), so an out-of-bounds objective can't be steered into existence. -->
155 <fieldset class="mt-6 pt-4 border-t ew-line">
156 <legend class="ew-label">
157 Steer a fresh composition <span class="ew-faint">· optional</span>
158 </legend>
159 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
160 <label class="flex-1 min-w-0 text-sm">
161 <span class="ew-label block mb-1">Era</span>
162 <select
163 v-model="steerEra"
164 data-testid="steer-era"
165 class="ew-select text-sm"
166 aria-label="Steer the era"
167 >
168 <option value="">No preference</option>
169 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">
170 {{ era }}
171 </option>
172 </select>
173 </label>
174 <label class="flex-1 min-w-0 text-sm">
175 <span class="ew-label block mb-1">Theme</span>
176 <select
177 v-model="steerTheme"
178 data-testid="steer-theme"
179 class="ew-select text-sm"
180 aria-label="Steer the theme"
181 >
182 <option value="">No preference</option>
183 <option
184 v-for="theme in OBJECTIVE_THEMES"
185 :key="theme"
186 :value="theme"
187 >
188 {{ theme }}
189 </option>
190 </select>
191 </label>
192 </div>
193 </fieldset>
194 
195 <!-- Actions: compose a fresh objective · begin the run -->
196 <div
197 class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
198 >
199 <div class="flex flex-col items-start gap-1">
200 <button
201 type="button"
202 data-testid="compose-objective"
203 class="ew-btn text-sm"
204 :disabled="composing"
205 @click="rollFresh"
206 >
207 <span aria-hidden="true"></span>
208 {{
209 composing
210 ? "Composing a fresh fate…"
211 : "Compose a fresh objective with AI"
212 }}
213 </button>
214 <p
215 v-if="activeSteer"
216 data-testid="active-steer"
217 class="text-xs ew-muted"
218 >
219 Steering: {{ activeSteer }}
220 </p>
221 <p
222 v-if="composeError"
223 data-testid="compose-error"
224 class="text-xs ew-bad"
225 >
226 {{ composeError }}
227 </p>
228 <!-- Quiet degrade (#127): a steered compose fell back to curated. Non-blocking
229 and muted (not the ew-bad error tone) — the player has a valid objective
230 ready; this just tells the truth that the steer was dropped. -->
231 <p
232 v-if="composeNotice"
233 data-testid="compose-degraded"
234 class="text-xs ew-muted"
235 >
236 {{ composeNotice }}
237 </p>
238 </div>
239 
240 <button
241 type="button"
242 data-testid="begin-button"
243 class="ew-btn ew-btn--primary"
244 :disabled="!selected || beginning"
245 @click="begin"
246 >
247 {{ beginning ? "Charting the timeline…" : "Begin rewriting history" }}
248 <span aria-hidden="true"></span>
249 </button>
250 <p
251 v-if="beginError"
252 data-testid="begin-error"
253 class="text-xs ew-bad mt-2"
254 >
255 {{ beginError }}
256 </p>
257 </div>
258 </template>
259 
260 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
261 the refused commit; this persistent notice lets the player reopen it if they
262 dismissed it. -->
263 <div
264 v-if="gameStore.outOfRuns"
265 data-testid="paywall"
266 class="mt-4 pt-4 border-t ew-line"
267 >
268 <p class="text-sm ew-fg font-semibold">
269 You're out of runs — your free run is spent.
270 </p>
271 <p class="ew-muted text-xs mt-0.5">
272 Grab a pack to keep rewriting history. One-time, never expires.
273 </p>
274 <button
275 type="button"
276 data-testid="open-packs"
277 class="ew-btn ew-btn--primary mt-3"
278 @click="economyStore.openBuyModal()"
279 >
280 See run packs <span aria-hidden="true"></span>
281 </button>
282 </div>
283 
284 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
285 <p
286 v-else-if="gameStore.atCapacity"
287 data-testid="at-capacity"
288 class="text-sm ew-bad mt-3"
289 >
290 The timeline is at capacity right now — please try again shortly.
291 </p>
292 
293 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
294 Policy: AI disclosure at session start; outputs may be inaccurate; an
295 adults-only posture). -->
296 <p
297 data-testid="ai-disclosure"
298 class="ew-faint text-xs leading-relaxed mt-8 pt-4 border-t ew-line max-w-2xl"
299 >
300 The historical figures here are played by AI, not real people, and every
301 reply is AI-generated
302 <span class="ew-fg"
303 >alternate history — dramatized fiction, not historical fact</span
304 >; don't rely on it as accurate. Intended for adults (18+).
305 </p>
306 </div>
307</template>
308 
309<script setup lang="ts">
310/**
311 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
312 * borderless hairline rows; selection uses identity, not titles. Nothing commits
313 * until Begin, so a freshly composed objective can be previewed and reconsidered.
314 */
315import { ref, shallowRef, computed } from "vue";
316import { useGameStore } from "~/stores/game";
317import { useEconomyStore } from "~/stores/economy";
318import type { RemixCredit } from "~/utils/run-snapshot";
319import {
320 listCuratedObjectives,
321 OBJECTIVE_ERAS,
322 OBJECTIVE_THEMES,
323 type GameObjective,
324 type ObjectiveEra,
325 type ObjectiveTheme,
326} from "~/utils/objectives";
327 
328const gameStore = useGameStore();
329const economyStore = useEconomyStore();
330 
331// Replay mode (issue #89): set when seeded from a shared run's objective. The briefing
332// shows just that objective, ready to play; Begin commits it verbatim (no generation).
333const replay = computed(() => gameStore.replayState);
334// The "remixed from" line above the seeded objective. The title is omitted (the objective
335// is shown right below), so it reads "Remixed from <name>" / "…an anonymous run", linking
336// back to the source when still shared.
337const replayCredit = computed<RemixCredit>(() => ({
338 attribution: replay.value?.sourceAttribution ?? null,
339 objectiveTitle: "",
340 sourceToken: replay.value?.sourceToken ?? null,
341}));
342 
343const curated = listCuratedObjectives();
344// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
345// curated options are plain objects. A deep ref would return a *reactive proxy* on
346// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
347// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
348// value as-is, keeping identity intact.
349const fresh = shallowRef<GameObjective | null>(null);
350const selected = shallowRef<GameObjective | null>(null);
⋯ 44 lines hidden (lines 351–394)
351// Titles composed this session, fed back as the avoid-list so each reroll lands
352// somewhere new (#95). Resets naturally: MissionSelect is mounted only while there's
353// no objective (v-if in play.vue), so a new run remounts it with an empty list.
354const seenTitles = ref<string[]>([]);
355const composing = ref(false);
356// Hard-failure surface: no objective came back at all (red, ew-bad).
357const composeError = ref<string | null>(null);
358// Quiet-degrade surface (#127): a steered compose fell back to curated, so the
359// steer was dropped. Distinct from composeError — the player still has a valid
360// objective; this notice is muted and informational, not an error.
361const composeNotice = ref<string | null>(null);
362const beginning = ref(false);
363 
364// Composition steer (closed enums; '' = no preference, left to the model).
365const steerEra = ref<ObjectiveEra | "">("");
366const steerTheme = ref<ObjectiveTheme | "">("");
367 
368// A freshly composed objective appends to the END, so it surfaces right above the
369// compose button that produced it rather than displacing the curated pool. Selection
370// is identity-based (isSelected uses ===), so it stays selected wherever it sits.
371const options = computed(() =>
372 fresh.value ? [...curated, fresh.value] : curated,
373);
374 
375// A plain-language echo of the active steer, surfaced by the compose button.
376const activeSteer = computed(
377 () => [steerEra.value, steerTheme.value].filter(Boolean).join(" · ") || null,
378);
379 
380// A one-line hook for the collapsed row — the first brief line, bullet marker
381// stripped, trimmed to ~70 chars at a word boundary. The full description (the "• "
382// bullet list, #82) is only revealed on the chosen row, keeping the list scannable.
383function hookFor(obj: GameObjective): string {
384 const first =
385 (obj.description || "")
386 .split("\n")
387 .map((l) => l.replace(/^[•\-\s]+/, "").trim())
388 .find(Boolean) || "";
389 if (first.length <= 72) return first;
390 const cut = first.slice(0, 70);
391 const lastSpace = cut.lastIndexOf(" ");
392 return (lastSpace > 40 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
394 
395function isSelected(obj: GameObjective) {
396 return selected.value === obj;
398 
399function select(obj: GameObjective) {
⋯ 14 lines hidden (lines 400–413)
400 selected.value = obj;
402 
403async function rollFresh() {
404 composing.value = true;
405 composeError.value = null;
406 composeNotice.value = null;
407 const { objective, fellBackToCurated } = await gameStore.fetchAIObjective(
408 {
409 era: steerEra.value || undefined,
410 theme: steerTheme.value || undefined,
411 },
412 seenTitles.value,
413 );
414 composing.value = false;
415 
416 if (objective) {
417 fresh.value = objective;
418 selected.value = objective; // auto-select the fresh one so Begin is one tap away
419 // Remember it so the next reroll avoids it (a curated fallback lands here too,
⋯ 43 lines hidden (lines 420–462)
420 // so even a fallback won't be re-served).
421 if (!seenTitles.value.includes(objective.title))
422 seenTitles.value.push(objective.title);
423 // The steer was set but generation fell back to curated (#127): say so quietly,
424 // so a dropped steer doesn't read as "steering is broken".
425 if (fellBackToCurated)
426 composeNotice.value =
427 "Couldn't compose a fresh objective right now — here's a curated one.";
428 } else {
429 composeError.value =
430 "The archives went quiet — pick one above, or try composing again.";
431 }
433 
434// Leave replay mode for the full browse/compose briefing — this run becomes an original.
435function exitReplay() {
436 gameStore.clearReplayState();
437 selected.value = null;
439 
440const beginError = ref<string | null>(null);
441 
442async function begin() {
443 // In replay mode the seeded objective IS the choice (no browse list rendered);
444 // otherwise it's whatever the player selected. Either way chooseObjective commits
445 // it verbatim — replay runs no generation, just reuses the captured objective.
446 const objective = replay.value?.objective ?? selected.value;
447 if (!objective || beginning.value) return;
448 beginError.value = null;
449 beginning.value = true;
450 // Charges one run from the balance. If out of runs, chooseObjective raises the
451 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
452 const started = await gameStore.chooseObjective(objective);
453 beginning.value = false;
454 // A false return that ISN'T the paywall (outOfRuns) or capacity gate is a real
455 // failure — ensureRunId/run-commit threw and set gameStore.error. Surface it here,
456 // or the button just flashes 'Charting the timeline…' and reverts silently (#233).
457 if (!started && !gameStore.outOfRuns && !gameStore.atCapacity) {
458 beginError.value =
459 gameStore.error || "Could not start the run. Please try again.";
460 }
462</script>

A test only the fix can pass

The existing compose tests assert the option count grows by one and that the "freshly composed" badge exists, but neither checks where the row lands — so a top-vs-bottom regression would sail through. This new test closes that gap: it composes, then asserts the last row is the badged, selected one, and that the first row is still curated.

It is discriminating by construction: on the old [fresh, ...curated] ordering the last row is a curated objective with no badge, so the badge assertion fails. Verified by reverting the source line and watching only this test go red. The mock mints a run id for /api/run because compose sends an x-run-id header.

Composes, then checks the fresh row is last, badged, and selected — and the first row is not the fresh one.

tests/unit/components/MissionSelect.spec.ts · 430 lines
tests/unit/components/MissionSelect.spec.ts430 lines · TypeScript
⋯ 189 lines hidden (lines 1–189)
1import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2import { mount, flushPromises } from "@vue/test-utils";
3import { setActivePinia, createPinia } from "pinia";
4import MissionSelect from "../../../components/MissionSelect.vue";
5import { useGameStore } from "../../../stores/game";
6import { useEconomyStore } from "../../../stores/economy";
7import { listCuratedObjectives } from "../../../server/utils/objectives";
8 
9const FRESH = {
10 title: "Chart the Southern Stars",
11 description: "Map the unknown skies a century early and redraw every voyage.",
12 era: "Age of sail",
13 icon: "🌌",
14};
15 
16describe("MissionSelect", () => {
17 let gameStore: ReturnType<typeof useGameStore>;
18 let economyStore: ReturnType<typeof useEconomyStore>;
19 let fetchMock: ReturnType<typeof vi.fn>;
20 
21 beforeEach(() => {
22 setActivePinia(createPinia());
23 gameStore = useGameStore();
24 economyStore = useEconomyStore();
25 fetchMock = vi.fn();
26 vi.stubGlobal("$fetch", fetchMock);
27 });
28 
29 afterEach(() => {
30 vi.unstubAllGlobals();
31 vi.restoreAllMocks();
32 });
33 
34 it("offers every curated objective to choose from", () => {
35 const wrapper = mount(MissionSelect);
36 const options = wrapper.findAll('[data-testid="objective-option"]');
37 expect(options.length).toBe(listCuratedObjectives().length);
38 });
39 
40 it("renders the chosen brief newline-aware so bullet lines do not collapse (issue #82)", async () => {
41 const wrapper = mount(MissionSelect);
42 const firstOption = wrapper.findAll('[data-testid="objective-option"]')[0];
43 // The full brief unfolds only on the chosen row (compact list + expand), so
44 // select it first; the grounded briefs are multi-line "• " bullets and this
45 // class keeps them on separate rows at mission select (the issue's render site).
46 await firstOption.trigger("click");
47 const brief = wrapper
48 .findAll('[data-testid="objective-option"]')[0]
49 .find(".whitespace-pre-line");
50 expect(brief.exists()).toBe(true);
51 expect(brief.text()).toContain("• ");
52 });
53 
54 it("keeps the list compact: a one-line hook per row, the full brief only when chosen", async () => {
55 const wrapper = mount(MissionSelect);
56 const options = wrapper.findAll('[data-testid="objective-option"]');
57 // Nothing chosen yet — every row is a scannable single-line hook, no full brief.
58 expect(wrapper.findAll('[data-testid="objective-hook"]').length).toBe(
59 options.length,
60 );
61 expect(wrapper.findAll('[data-testid="objective-brief"]').length).toBe(0);
62 
63 await options[0].trigger("click");
64 // The chosen row swaps its hook for the brief; the rest stay collapsed hooks.
65 const after = wrapper.findAll('[data-testid="objective-option"]');
66 expect(after[0].find('[data-testid="objective-brief"]').exists()).toBe(
67 true,
68 );
69 expect(after[0].find('[data-testid="objective-hook"]').exists()).toBe(
70 false,
71 );
72 expect(after[1].find('[data-testid="objective-hook"]').exists()).toBe(true);
73 expect(after[1].find('[data-testid="objective-brief"]').exists()).toBe(
74 false,
75 );
76 // The hook is a short single line, not the full multi-bullet brief.
77 const hook = after[1].find('[data-testid="objective-hook"]');
78 expect(hook.text().length).toBeLessThanOrEqual(72);
79 expect(hook.text()).not.toContain("• ");
80 });
81 
82 it("disables Begin until an objective is selected", async () => {
83 const wrapper = mount(MissionSelect);
84 expect(
85 wrapper.find('[data-testid="begin-button"]').attributes("disabled"),
86 ).toBeDefined();
87 
88 await wrapper
89 .findAll('[data-testid="objective-option"]')[0]
90 .trigger("click");
91 expect(
92 wrapper.find('[data-testid="begin-button"]').attributes("disabled"),
93 ).toBeUndefined();
94 });
95 
96 it("marks the clicked objective as selected — aria-checked + the radio dot", async () => {
97 // Regression: a plain ref returns a reactive PROXY on .value, so the identity
98 // check `selected.value === obj` failed for the raw curated objects — the row
99 // tinted on hover but the radio never registered. selection must visibly stick.
100 const wrapper = mount(MissionSelect);
101 const options = wrapper.findAll('[data-testid="objective-option"]');
102 expect(options[1].attributes("aria-checked")).toBe("false");
103 
104 await options[1].trigger("click");
105 
106 expect(options[1].attributes("aria-checked")).toBe("true");
107 expect(options[0].attributes("aria-checked")).toBe("false");
108 expect(options[1].find(".ew-dot").classes()).toContain("ew-dot--accent");
109 expect(options[0].find(".ew-dot").classes()).toContain("ew-dot--hollow");
110 });
111 
112 it("commits the chosen curated objective on Begin", async () => {
113 const curated = listCuratedObjectives();
114 // begin() now fails closed: /api/run must mint, then run-commit must succeed.
115 fetchMock.mockImplementation((url: string) =>
116 url === "/api/run"
117 ? Promise.resolve({ runId: "r-1" })
118 : Promise.resolve({}),
119 );
120 const wrapper = mount(MissionSelect);
121 
122 await wrapper
123 .findAll('[data-testid="objective-option"]')[0]
124 .trigger("click");
125 await wrapper.find('[data-testid="begin-button"]').trigger("click");
126 await flushPromises(); // begin() commits the run (async) before the objective lands
127 
128 expect(gameStore.currentObjective?.title).toBe(curated[0].title);
129 expect(gameStore.objectiveProgress).toBe(0);
130 });
131 
132 it("surfaces a run-commit failure at Begin instead of reverting silently (#233)", async () => {
133 // /api/run mints, but the commit fails with a plain server error — not the paywall
134 // or capacity gate — so chooseObjective sets gameStore.error and returns false.
135 fetchMock.mockImplementation((url: string) =>
136 url === "/api/run"
137 ? Promise.resolve({ runId: "r-1" })
138 : Promise.reject(new Error("boom")),
139 );
140 const wrapper = mount(MissionSelect);
141 
142 await wrapper
143 .findAll('[data-testid="objective-option"]')[0]
144 .trigger("click");
145 await wrapper.find('[data-testid="begin-button"]').trigger("click");
146 await flushPromises();
147 
148 // The run did not start, and the failure is now VISIBLE (it was silent before #233).
149 expect(gameStore.currentObjective).toBeNull();
150 const err = wrapper.find('[data-testid="begin-error"]');
151 expect(err.exists()).toBe(true);
152 expect(err.text()).toContain("Could not start the run");
153 // The button reverted from its 'Charting the timeline…' loading label.
154 expect(wrapper.find('[data-testid="begin-button"]').text()).toContain(
155 "Begin rewriting history",
156 );
157 });
158 
159 it("composes a fresh objective, features it, and begins with it", async () => {
160 fetchMock.mockImplementation((url: string) => {
161 if (url === "/api/objective")
162 return Promise.resolve({ success: true, objective: FRESH });
163 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
164 return Promise.resolve({}); // run-commit
165 });
166 const wrapper = mount(MissionSelect);
167 const before = wrapper.findAll('[data-testid="objective-option"]').length;
168 
169 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
170 await flushPromises();
171 
172 expect(fetchMock).toHaveBeenCalledWith(
173 "/api/objective",
174 expect.objectContaining({
175 method: "GET",
176 headers: expect.objectContaining({ "x-run-id": expect.any(String) }),
177 }),
178 );
179 expect(wrapper.findAll('[data-testid="objective-option"]').length).toBe(
180 before + 1,
181 );
182 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true);
183 
184 // The fresh objective auto-selects, so Begin commits it directly.
185 await wrapper.find('[data-testid="begin-button"]').trigger("click");
186 await flushPromises(); // begin() commits the run (async) before the objective lands
187 expect(gameStore.currentObjective?.title).toBe(FRESH.title);
188 });
189 
190 it("appends the composed objective to the bottom of the list, auto-selected", async () => {
191 // It lands last (just above the compose button), not at the top — and auto-selects,
192 // so Begin is one tap away regardless of where it sits in the list.
193 fetchMock.mockImplementation((url: string) => {
194 if (url === "/api/objective")
195 return Promise.resolve({ success: true, objective: FRESH });
196 if (url === "/api/run") return Promise.resolve({ runId: "r-1" }); // aiHeaders mints a run id
197 return Promise.resolve({});
198 });
199 const wrapper = mount(MissionSelect);
200 const before = wrapper.findAll('[data-testid="objective-option"]').length;
201 
202 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
203 await flushPromises();
204 
205 const options = wrapper.findAll('[data-testid="objective-option"]');
206 const last = options[options.length - 1];
207 // The fresh row is the LAST one, carries the badge, and is the selected row.
208 expect(options.length).toBe(before + 1);
209 expect(last.find('[data-testid="fresh-badge"]').exists()).toBe(true);
210 expect(last.text()).toContain(FRESH.title);
211 expect(last.attributes("aria-checked")).toBe("true");
212 // The curated rows keep their lead; none of them is the fresh one.
213 expect(options[0].find('[data-testid="fresh-badge"]').exists()).toBe(false);
214 });
⋯ 216 lines hidden (lines 215–430)
215 
216 it("renders era + theme steer pickers, defaulting to no preference", () => {
217 const wrapper = mount(MissionSelect);
218 const era = wrapper.find('[data-testid="steer-era"]');
219 const theme = wrapper.find('[data-testid="steer-theme"]');
220 expect(era.exists()).toBe(true);
221 expect(theme.exists()).toBe(true);
222 expect((era.element as HTMLSelectElement).value).toBe("");
223 expect((theme.element as HTMLSelectElement).value).toBe("");
224 // Nothing surfaced until an axis is picked.
225 expect(wrapper.find('[data-testid="active-steer"]').exists()).toBe(false);
226 });
227 
228 it("forwards the chosen era + theme as query params when composing, and surfaces them", async () => {
229 fetchMock.mockImplementation((url: string) => {
230 if (url === "/api/objective")
231 return Promise.resolve({ success: true, objective: FRESH });
232 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
233 return Promise.resolve({});
234 });
235 const wrapper = mount(MissionSelect);
236 await wrapper.find('[data-testid="steer-era"]').setValue("Antiquity");
237 await wrapper
238 .find('[data-testid="steer-theme"]')
239 .setValue("Science & Medicine");
240 
241 // The active steer is surfaced once set.
242 expect(wrapper.find('[data-testid="active-steer"]').text()).toContain(
243 "Antiquity · Science & Medicine",
244 );
245 
246 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
247 await flushPromises();
248 
249 expect(fetchMock).toHaveBeenCalledWith(
250 "/api/objective",
251 expect.objectContaining({
252 method: "GET",
253 query: { era: "Antiquity", theme: "Science & Medicine" },
254 }),
255 );
256 });
257 
258 it('omits an axis left at "no preference"', async () => {
259 fetchMock.mockImplementation((url: string) => {
260 if (url === "/api/objective")
261 return Promise.resolve({ success: true, objective: FRESH });
262 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
263 return Promise.resolve({});
264 });
265 const wrapper = mount(MissionSelect);
266 await wrapper.find('[data-testid="steer-era"]').setValue("Medieval");
267 
268 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
269 await flushPromises();
270 
271 expect(fetchMock).toHaveBeenCalledWith(
272 "/api/objective",
273 expect.objectContaining({
274 query: { era: "Medieval" },
275 }),
276 );
277 });
278 
279 it("shows an error and leaves the run unstarted when composing fails", async () => {
280 fetchMock.mockResolvedValue({ success: false });
281 const wrapper = mount(MissionSelect);
282 
283 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
284 await flushPromises();
285 
286 expect(wrapper.find('[data-testid="compose-error"]').exists()).toBe(true);
287 expect(gameStore.currentObjective).toBeNull();
288 });
289 
290 it("shows a quiet notice (not an error) when a steered compose falls back to curated (#127)", async () => {
291 // The server returns a valid objective AND flags the dropped steer; the
292 // briefing surfaces a muted, non-blocking line — never the red compose-error.
293 fetchMock.mockImplementation((url: string) => {
294 if (url === "/api/objective")
295 return Promise.resolve({
296 success: true,
297 objective: FRESH,
298 fellBackToCurated: true,
299 });
300 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
301 return Promise.resolve({});
302 });
303 const wrapper = mount(MissionSelect);
304 await wrapper.find('[data-testid="steer-era"]').setValue("Antiquity");
305 
306 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
307 await flushPromises();
308 
309 // A usable objective still landed and auto-selected — the notice is non-blocking.
310 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true);
311 expect(
312 wrapper.find('[data-testid="begin-button"]').attributes("disabled"),
313 ).toBeUndefined();
314 // The quiet notice shows (muted tone); the red error does NOT.
315 const notice = wrapper.find('[data-testid="compose-degraded"]');
316 expect(notice.exists()).toBe(true);
317 expect(notice.text()).toContain("curated one");
318 expect(notice.classes()).toContain("ew-muted");
319 expect(wrapper.find('[data-testid="compose-error"]').exists()).toBe(false);
320 });
321 
322 it("shows no dropped-steer notice on a normal successful compose (#127)", async () => {
323 // No flag (or false) → the common path stays silent, no noise.
324 fetchMock.mockImplementation((url: string) => {
325 if (url === "/api/objective")
326 return Promise.resolve({ success: true, objective: FRESH });
327 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
328 return Promise.resolve({});
329 });
330 const wrapper = mount(MissionSelect);
331 
332 await wrapper.find('[data-testid="compose-objective"]').trigger("click");
333 await flushPromises();
334 
335 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true);
336 expect(wrapper.find('[data-testid="compose-degraded"]').exists()).toBe(
337 false,
338 );
339 });
340 
341 it("shows the paywall and does not start the run when out of runs (402)", async () => {
342 // Begin succeeds (run minted), then the commit is refused 402 → paywall.
343 fetchMock.mockImplementation((url: string) => {
344 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
345 if (url === "/api/run-commit") return Promise.reject({ statusCode: 402 });
346 return Promise.resolve({});
347 });
348 const wrapper = mount(MissionSelect);
349 
350 await wrapper
351 .findAll('[data-testid="objective-option"]')[0]
352 .trigger("click");
353 await wrapper.find('[data-testid="begin-button"]').trigger("click");
354 await flushPromises();
355 
356 expect(wrapper.find('[data-testid="paywall"]').exists()).toBe(true);
357 expect(gameStore.currentObjective).toBeNull();
358 });
359 
360 it("shows the at-capacity notice and does not start when the spend cap is hit (503)", async () => {
361 fetchMock.mockImplementation((url: string) => {
362 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
363 if (url === "/api/run-commit")
364 return Promise.reject({ statusCode: 503, data: { atCapacity: true } });
365 return Promise.resolve({});
366 });
367 const wrapper = mount(MissionSelect);
368 
369 await wrapper
370 .findAll('[data-testid="objective-option"]')[0]
371 .trigger("click");
372 await wrapper.find('[data-testid="begin-button"]').trigger("click");
373 await flushPromises();
374 
375 expect(wrapper.find('[data-testid="at-capacity"]').exists()).toBe(true);
376 expect(wrapper.find('[data-testid="paywall"]').exists()).toBe(false);
377 expect(gameStore.currentObjective).toBeNull();
378 });
379 
380 it("opens the run-packs modal automatically when out of runs (402)", async () => {
381 // The buy buttons now live in RunPacksModal; the refused commit opens it and
382 // loads the catalog. The paywall keeps a "See run packs" button to reopen it.
383 fetchMock.mockImplementation((url: string) => {
384 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
385 if (url === "/api/run-commit") return Promise.reject({ statusCode: 402 });
386 if (url === "/api/packs")
387 return Promise.resolve({
388 packs: [
389 { id: "courier", label: "Courier", priceCents: 500, runs: 7 },
390 ],
391 });
392 return Promise.resolve({});
393 });
394 const wrapper = mount(MissionSelect);
395 
396 await wrapper
397 .findAll('[data-testid="objective-option"]')[0]
398 .trigger("click");
399 await wrapper.find('[data-testid="begin-button"]').trigger("click");
400 await flushPromises();
401 
402 expect(wrapper.find('[data-testid="open-packs"]').exists()).toBe(true);
403 expect(economyStore.buyModalOpen).toBe(true);
404 expect(economyStore.packs).toEqual([
405 { id: "courier", label: "Courier", priceCents: 500, runs: 7 },
406 ]);
407 });
408 
409 it("reopens the run-packs modal from the paywall button", async () => {
410 fetchMock.mockImplementation((url: string) => {
411 if (url === "/api/run") return Promise.resolve({ runId: "r-1" });
412 if (url === "/api/run-commit") return Promise.reject({ statusCode: 402 });
413 if (url === "/api/packs") return Promise.resolve({ packs: [] });
414 return Promise.resolve({});
415 });
416 const wrapper = mount(MissionSelect);
417 await wrapper
418 .findAll('[data-testid="objective-option"]')[0]
419 .trigger("click");
420 await wrapper.find('[data-testid="begin-button"]').trigger("click");
421 await flushPromises();
422 
423 // Dismiss, then reopen via the persistent paywall button.
424 economyStore.closeBuyModal();
425 expect(economyStore.buyModalOpen).toBe(false);
426 await wrapper.find('[data-testid="open-packs"]').trigger("click");
427 await flushPromises();
428 expect(economyStore.buyModalOpen).toBe(true);
429 });
430});