From a068e41c55325b741826f70bd4aba6397c9a9e85 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 14 May 2026 16:39:47 -0500 Subject: [PATCH 1/5] =?UTF-8?q?docs(goal):=20add=20Sprint=202=20mission=20?= =?UTF-8?q?contract=20=E2=80=94=20revised=20onboarding=20canonical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal Packet v2.0 format. Single-mission contract for Codex. Mission: refactor RevisedOnboardingPage to drive the AGM-based revised onboarding flow (getAGMCandidates / getRevisedOnboardingData / applyStaffHires / applyScoutingHire / completeRevisedOnboarding) instead of the Day-One worker surface it currently uses. Then decide what to do with the orphaned Day-One worker methods. Read-first, allowed-write, protected scope, non-negotiables, milestone loop, validation loop, evaluator-visible proof, pause conditions, done criteria, and final report all encoded inline so the slash command can stay thin. Companion to Sprint 1 cleanup (PR #74). Co-Authored-By: Claude Opus 4.7 --- GOAL.md | 290 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 GOAL.md diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..eac4c11 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,290 @@ +# GOAL.md — Sprint 2: Revised Onboarding becomes canonical + +> Single-mission contract for Codex (or any one-shot coding agent). +> Format: Goal Packet v2.0 — Kevin's one-shot ritual. +> Sprint 1 cleanup is **already merged** to `main` ([#74](https://github.com/KevinBigham/MBD/pull/74)). This branch (`goal/sprint-2-revised-onboarding`) is rebased on top. + +## Mission + +Refactor `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx` so that the `/onboarding` route drives the **AGM-based revised onboarding flow** exposed by `useWorker()` — `getAGMCandidates`, `getRevisedOnboardingData`, `applyStaffHires`, `applyScoutingHire`, `completeRevisedOnboarding` — instead of the **Day-One** worker surface it currently uses (`getDayOneSession`, `advanceDayOneIntro`, `chooseDayOneAGM`, `advanceDayOneOrgReview`, `setDayOneSeasonGoal`, `setDayOneBudgetAllocation`, `setDayOneOpeningPlan`, `setDayOneDevelopmentPlan`, `resolveDayOneCrisis`, `finishDayOne`). + +Once the revised flow drives the route, decide what to do with the Day-One worker surface and either remove it from `useWorker.ts` + `sim.worker.onboarding.ts` (preferred if redundant) or keep it documented as a separate path. + +Stop only when every item in **Done When** is satisfied or a **Pause Condition** is hit. + +## Background (why this exists) + +A deep-dive audit found that the AGM-based **revised onboarding API surface** is fully wired in `sim.worker.onboarding.ts` and `useWorker.ts` but has zero UI consumers. The current `/onboarding` page imports `useWorker` and calls the Day-One methods instead. Both flows live in `packages/sim-core/src/onboarding/`. Kevin's product call: the AGM-based revised flow is canonical going forward. + +## Baseline + +- `main` HEAD: `1eb4271` (Sprint 1 cleanup merged via PR #74). +- This branch is rebased on top of that commit. No conflicts expected. +- Sprint 1 changes already on `main`: deleted the legacy procedural `OnboardingPage.tsx` + `useOnboardingState.ts`, removed `@mbd/test-utils`, removed the mailto feedback widget, added a v33 save fixture + round-trip test, surfaced `pnpm playtest` / `pnpm playtest:sample` / `pnpm playtest:calibrate` at root, migrated the bundleConfig journal into `apps/web/docs/BUDGETS.md`. +- Save schema: `CURRENT_GAME_SNAPSHOT_VERSION = 33`. Do not bump. +- Test counts after Sprint 1: 97 web / 137 sim-core / 1 contracts files, **2,248 tests passing**. + +## Read first + +Inspect these before editing. Do not skip. + +**Repo orientation:** +- `README.md` +- `CHANGELOG.md` +- `MASTER_CONTEXT.md` (treat as a snapshot from 2026-04-10 — some facts are stale, but the architecture map and design decisions are still accurate) +- `package.json`, `turbo.json`, `pnpm-workspace.yaml` +- `apps/web/package.json`, `apps/web/vite.config.ts`, `apps/web/tsconfig.json` + +**Onboarding surface (apps/web):** +- `apps/web/src/app/routes/index.tsx` — confirms `/onboarding` mounts `RevisedOnboardingPage` +- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx` — the file you're refactoring +- `apps/web/src/features/onboarding/routes/RevisedOnboardingPage.test.tsx` — existing test coverage to preserve/update +- `apps/web/src/features/onboarding/components/` — full directory: `AGMPanel.tsx`, `AGMRuntimePanel.tsx`, `AGMSelectionPanel.tsx`, `AssessmentPanel.tsx`, `ChapterProgress.tsx`, `ChoiceSelector.tsx`, `OnboardingComplete.tsx`, `TypewriterText.tsx`, `shared.tsx`, and `chapters/*` (OwnerMeetingView, RosterAssessmentView, FarmAssessmentView, StaffEvaluationView, FinancialView, ScoutingBriefingView, SeasonStrategyView, PressConferenceView, HireCoachesView, HireScoutsView) +- `apps/web/src/features/onboarding/nudges/` — guided-start cards used by Setup/Dashboard/Draft; DO NOT touch +- `apps/web/src/features/onboarding/__tests__/guidedStartNudges.test.tsx` — preserve + +**Worker bridge:** +- `apps/web/src/workers/sim.worker.ts` — confirms `onboardingApi` map composition +- `apps/web/src/workers/sim.worker.onboarding.ts` — both Day-One and Revised methods live here +- `apps/web/src/shared/hooks/useWorker.ts` — the `useWorker` Comlink proxy + `mutationMethods` Set (lines ~34-104) + Day-One callbacks (lines ~876-918) + Revised callbacks (lines ~928-949) +- `apps/web/src/workers/snapshot.ts`, `snapshot.onboarding.ts` — snapshot serialization + +**Sim-core onboarding (PROTECTED — read-only):** +- `packages/sim-core/src/onboarding/index.ts` — barrel; see what's exported +- `packages/sim-core/src/onboarding/dayOne.ts` — Day-One state machine +- `packages/sim-core/src/onboarding/agmCandidates.ts` — Marcus Chen / Walt Kowalski / Elena Vargas fixed AGMs +- `packages/sim-core/src/onboarding/flowEngine.ts` — revised flow state engine +- `packages/sim-core/src/onboarding/scriptOrchestrator.ts` +- `packages/sim-core/src/onboarding/staffHiring.ts` + `staffEvaluation.ts` +- `packages/sim-core/src/onboarding/scoutingBriefing.ts` +- `packages/sim-core/src/onboarding/chapterDialogue.ts`, `roundThreeDialogue.ts`, `choiceReactions.ts` +- `packages/sim-core/src/onboarding/rosterAssessment.ts`, `farmAssessment.ts`, `financialPlaybook.ts`, `seasonStrategy.ts`, `ownerMeeting.ts`, `pressConference.ts`, `assistantGM.ts` + +**Save schema (PROTECTED — read-only):** +- `packages/contracts/src/schemas/save.ts` — verify `franchise.dayOne` shape; note any revised-onboarding fields if present +- `packages/contracts/src/schemas/franchise.ts` + +**Tests:** +- `packages/sim-core/tests/agmCandidates.test.ts` +- `packages/sim-core/tests/assistantGMCharacter.test.ts`, `assistantGMOrchestrator.test.ts`, `assistantGMDialogue.test.ts`, `assistantGMChoiceReactions.test.ts`, `assistantGMTips.test.ts` +- Any other `onboarding`-named tests under `packages/sim-core/tests/` +- `apps/web/src/workers/sim.worker.onboarding.test.ts` +- `apps/web/src/workers/snapshot.onboarding.test.ts` + +## Product contract + +Build the smallest complete version that: + +1. Renders the AGM selection screen with all 3 fixed candidates from sim-core's `AGM_CANDIDATES` (Marcus Chen, Walt Kowalski, Elena Vargas), each with their distinct voice/philosophy. +2. Drives the chapter sequence defined by sim-core's `REVISED_CHAPTER_ORDER` (read it from sim-core to know the exact order; do NOT hardcode). +3. Includes staff hiring (calls `applyStaffHires`) and scouting director hiring (calls `applyScoutingHire`). +4. Completes via `completeRevisedOnboarding`, writes the resulting snapshot to IndexedDB through the existing save path, and navigates the user to `/dashboard`. +5. Has loading, error, and success states. Empty state is "no save initialized yet" handled by the Save Hub. +6. Preserves all existing nudges that fire post-onboarding (don't touch `features/onboarding/nudges/`). + +Prefer reusing existing components where they fit (`AGMSelectionPanel`, `AGMRuntimePanel`, `AssessmentPanel`, `ChapterProgress`, `ChoiceSelector`, `OnboardingComplete`, the `chapters/*` views, `HireCoachesView`, `HireScoutsView`). Add new components only when a clean fit is impossible. + +The result must be usable, not a scaffold. + +## Allowed write scope + +Write only inside: +- `apps/web/src/features/onboarding/**` (except `nudges/**` which is protected) +- `apps/web/src/workers/sim.worker.onboarding.ts` +- `apps/web/src/workers/sim.worker.ts` (only the `onboardingApi` map composition, if Day-One removal happens) +- `apps/web/src/shared/hooks/useWorker.ts` (only the onboarding-related callbacks and `mutationMethods` Set entries — see the well-defined Day-One / Revised regions near lines 80-104 and 876-949) +- Test files matching the above paths +- `.logs/goal-progress.md` (create if absent) +- `STATUS.md` (create at repo root) +- `GOAL.md` (this file — only minor edits if absolutely necessary) + +## Protected scope + +Do not modify, even if it looks easier: +- `packages/sim-core/**` — sim-core is the source of truth for both onboarding flows. If a sim-core helper is needed but unexposed, expose it via the worker layer, do not change sim-core. +- `packages/contracts/**` — save schema is v33; no bump in this sprint. +- `apps/web/src/features/onboarding/nudges/**` — used by Setup, Dashboard, Draft; do not touch. +- `apps/web/src/features//**` +- `apps/web/src/workers/sim.worker.actions.ts`, `sim.worker.queries.ts`, `sim.worker.helpers.ts`, `sim.worker.state.ts` — onboarding worker file is the only worker file in scope. +- `apps/web/src/app/routes/index.tsx` — only edit if the route definition itself must change (it shouldn't; `/onboarding` stays at `/onboarding`). +- `apps/web/src/app/layout/**`, `apps/web/src/shared/components/**`, `apps/web/src/shared/lib/**` (except where explicitly listed above) +- `.github/**`, `package.json` (root), `turbo.json`, `pnpm-workspace.yaml` +- `apps/web/src/build/bundleConfig.ts`, `apps/web/docs/BUDGETS.md` — preserve the chunk-budget journal exactly + +## Non-negotiables + +- **Determinism is sacred.** No `Math.random()`, no `Date.now()` inside sim-relevant paths. Use sim-core's seeded PRNG via the worker. Date.now() in UI is fine; in worker logic it is not. +- **Save schema stays v33.** Additive migrations only, and only if absolutely required (which they should NOT be for this sprint — the revised flow already has worker support). +- **No new top-level dependencies.** Reuse what's in `apps/web/package.json` and the workspace packages. +- **No emoji in game UI.** Use lucide-react icons only. +- **Bloomberg Terminal aesthetic.** Match existing typography (Space Grotesk / JetBrains Mono / Bebas Neue), color tokens from `@mbd/design-tokens`, density. +- **Preserve all 3 fixed AGM characters.** Marcus Chen, Walt Kowalski, Elena Vargas — do not invent new AGMs, do not remove any. +- **Do not delete or weaken tests** to make checks pass. Update tests to match new behavior; add new ones for new flows. +- **The `/onboarding` route URL stays at `/onboarding`.** Don't rename. +- **No commits on `main`.** Work on `goal/sprint-2-revised-onboarding`. +- **No `git add -A`** — stage specific files only. + +## Milestone loop + +For each milestone: inspect → state checkpoint → smallest change → smallest validation → fix → log to `.logs/goal-progress.md`. + +Each log entry: timestamp, milestone, files changed, checks run, result, blocker or next step. + +Suggested milestones (you can re-slice, but cover all): + +1. **Inventory** — Read every file in "Read first." Document in `.logs/goal-progress.md`: + - The current Day-One worker methods and what each does. + - The current Revised worker methods and what each does. + - The sim-core revised flow's exported chapter order, step IDs, and the shape of the data each step produces. + - The component map: which existing components (AGMRuntimePanel, AssessmentPanel, chapters/*) can be reused; which gaps need new components. +2. **Refactor RevisedOnboardingPage** — Replace Day-One worker calls with the Revised API. Implement the chapter sequence using sim-core's `REVISED_CHAPTER_ORDER`. Wire AGM selection → assessments → staff hiring → scouting hiring → completion. Reuse existing components; new components only when necessary. +3. **Update RevisedOnboardingPage.test.tsx** — Existing tests likely break. Update them to assert the new flow. Add tests for: AGM selection rendering 3 candidates, staff-hiring step calling `applyStaffHires`, scouting-hiring step calling `applyScoutingHire`, completion calling `completeRevisedOnboarding`, error state when worker fails. +4. **Day-One worker surface decision** — Confirm whether any code outside RevisedOnboardingPage uses the Day-One methods (grep for `getDayOneSession`, `advanceDayOneIntro`, etc. in apps/web/src). If zero callers remain after milestone 2: + - Remove the Day-One methods from `apps/web/src/shared/hooks/useWorker.ts` (the callbacks + the `mutationMethods` Set entries). + - Remove them from the `onboardingApi` map in `apps/web/src/workers/sim.worker.ts`. + - Remove the wrapper functions from `apps/web/src/workers/sim.worker.onboarding.ts`. + - **Leave `packages/sim-core/src/onboarding/dayOne.ts` untouched** — sim-core is protected. + - If a caller remains, document why and keep Day-One. +5. **Verify gate** — Run `pnpm typecheck`, `pnpm test`, `pnpm build` after each milestone and at the end. Fix failures before expanding scope. Capture results in `.logs/goal-progress.md`. +6. **Browser smoke** — Start `pnpm --filter @mbd/web dev`, walk the full flow: Save Hub → pick slot → pick team → land on `/onboarding` → pick AGM → complete all steps → land on `/dashboard` with the new save's data visible. Capture a screenshot at the AGM selection screen and at the dashboard. +7. **STATUS.md** — Write the final report (see "Final report" section below). + +## Validation loop + +Commands (workspace root): + +``` +pnpm install # if node_modules is missing +pnpm typecheck # 9 tasks should pass +pnpm test # 97 web / 137 sim-core / 1 contracts, ~2,247 tests +pnpm build # turbo build → vite build, 118-entry PWA precache +pnpm --filter @mbd/web dev # browser smoke on http://localhost:5173/MBD/ +``` + +Targeted (use these for tight loops while iterating): + +``` +pnpm --filter @mbd/web test src/features/onboarding +pnpm --filter @mbd/contracts test +pnpm --filter @mbd/sim-core test tests/agmCandidates.test.ts tests/assistantGM*.test.ts +``` + +Browser flow to verify by hand: + +1. `pnpm --filter @mbd/web dev` +2. Open `http://localhost:5173/MBD/` (or `5174` if 5173 is taken) +3. Click "New Dynasty" +4. Pick Slot 1, pick a team (try Kansas City BBQ Fountains for the KC fan-loyalty flavor) +5. Confirm landing on `/onboarding` +6. Confirm 3 AGM candidates render with portraits/voices: Marcus Chen, Walt Kowalski, Elena Vargas +7. Pick one (try each in separate runs) +8. Walk every step the revised flow exposes (assessments, hires, etc.) +9. Confirm landing on `/dashboard` after completion +10. Confirm dashboard renders real worker-backed data (standings, schedule, etc.) +11. Reload the page; confirm save loads from IndexedDB and resumes on dashboard +12. Open DevTools → Application → IndexedDB → confirm a save record exists with `schemaVersion: 33` + +## Evaluator-visible proof + +Before declaring done, the transcript and `STATUS.md` must contain: + +- Exact commands run, with their pass/fail result. +- Output summaries (test counts, build duration, bundle sizes). +- Browser steps walked, with screenshot paths committed under `apps/web/docs/screenshots/sprint-2/` (allowed write path — add it). +- A diff summary (`git diff --stat origin/main..HEAD`) showing changes stayed inside allowed scope. +- The Day-One removal decision with grep evidence. +- Known unrelated failures, if any, with reproduction. + +The goal is not complete unless the proof is visible. + +## Autonomy rules + +When sim-core's revised flow exposes choices the UI doesn't fully use (e.g., a dialogue tone not shown to the user), it is fine to pass through reasonable defaults. Log the assumption. + +When existing components don't fit cleanly, prefer composing smaller new components inside `apps/web/src/features/onboarding/components/` over forking large existing ones. + +When unsure between two reasonable implementations, pick the one that: +- Matches existing repo patterns (other feature pages use `useEffect` + `useWorker()` + `useState` with worker-backed data). +- Has the smaller diff. +- Avoids new dependencies. +- Preserves the most existing tests. + +Log assumptions in `.logs/goal-progress.md` and continue. + +## Pause conditions + +Pause and write the blocker into `STATUS.md` only when: + +- The revised flow worker surface in `sim.worker.onboarding.ts` is incomplete (e.g., a step in `REVISED_CHAPTER_ORDER` has no exposed worker method) AND adding a worker wrapper would require new sim-core code. +- Existing tests in `RevisedOnboardingPage.test.tsx` make assertions that fundamentally cannot coexist with the new flow. +- The same validation (typecheck, test, or build) fails 3 times after serious repair attempts. +- A required save-schema field is missing AND adding it would require a v34 bump (out of scope). +- A protected file must be modified to make any further progress. +- The Day-One worker methods turn out to have a caller in a path you can't read (e.g., another feature surfaces them via a hidden import) and removal would regress that path. +- Sprint 1 PR #74's merge introduced something on `main` that materially changes the onboarding surface and the GOAL.md no longer matches reality. + +When pausing, do not delete partial work. Document where the partial state lives and the exact blocker. + +## Done when + +All of the following are true: + +- `/onboarding` route renders the revised flow with all 3 AGM candidates visible. +- The user can complete the flow from AGM pick → assessments → staff hiring → scouting hiring → completion. +- `applyStaffHires`, `applyScoutingHire`, and `completeRevisedOnboarding` are each called at the correct step. +- After completion, the user lands on `/dashboard` with a fresh save loaded. +- Reload preserves the save; the new save has `schemaVersion: 33`. +- Day-One worker surface decision is made and either: + - (a) removed cleanly from `useWorker.ts` + `sim.worker.ts` `onboardingApi` map + `sim.worker.onboarding.ts` wrappers, with grep evidence showing zero remaining callers in `apps/web/src/`, OR + - (b) preserved with a comment block explaining why and a STATUS.md entry citing the caller. +- `pnpm typecheck` clean (all 9 tasks). +- `pnpm test` clean (no test deleted or weakened; new tests added for new behavior). +- `pnpm build` clean (every chunk under its ceiling; bundleBudget.test.ts passes). +- Browser smoke walked end-to-end with at least one screenshot of the AGM selection step and one of the post-completion dashboard. +- `.logs/goal-progress.md` exists with a milestone log. +- `STATUS.md` exists with the final report (see next section). +- The branch is on `goal/sprint-2-revised-onboarding`, not main. + +## Final report + +`STATUS.md` must include, in order: + +1. **What shipped** — one paragraph summary of the user-visible change. +2. **Files changed** — `git diff --stat origin/main..HEAD` output. +3. **Validations run** — exact commands and their results (typecheck, test, build). +4. **Browser evidence** — list of screenshots committed under `apps/web/docs/screenshots/sprint-2/` with one-line captions. +5. **Day-One decision** — kept or removed; with the grep evidence that justifies it. +6. **Known limitations** — anything you noticed but did not fix (out of scope). +7. **Risks** — what could break in production and what to watch. +8. **Rollback notes** — revert the merge commit; the save schema didn't bump, so revert is safe. +9. **Next /goal** — the exact paste-ready `/goal` prompt for the next sprint. (Sprint 3 candidates: News inbox UI; OR wire orphaned player-profile endpoints; OR press conference unification.) + +## Branch + commit hygiene + +- Branch: `goal/sprint-2-revised-onboarding` (already created). +- Stage specific files, never `git add -A`. +- Commit in logical slices (one slice per milestone is a reasonable cadence). Use conventional-commit prefixes that match repo history: `feat(onboarding):`, `refactor(onboarding):`, `test(onboarding):`, `chore(onboarding):`, `docs(onboarding):`. +- Co-author trailer on each commit: + + ``` + Co-Authored-By: Codex GPT-5 + ``` + + (Or whichever attribution Codex normally uses.) +- When done, push and open a PR titled `Sprint 2 — Revised onboarding becomes canonical`. Body should summarize against this GOAL.md and link to PR #74 if Sprint 1 cleanup is still open. + +## Out of scope (do not attempt this sprint) + +- Team logo SVG assets (Sprint 7). +- News inbox UI (Sprint 3). +- Wiring orphaned player-profile endpoints (Sprint 4). +- Press-conference unification (Sprint 5). +- Worker-mode `runInvariantChecks` integration (Sprint 6). +- Moving narrative generation off the main thread (Sprint 6). +- Adding any new sim-core code. +- Any save schema change. +- Any change outside `apps/web/src/features/onboarding/`, `apps/web/src/workers/sim.worker.onboarding.ts`, `apps/web/src/workers/sim.worker.ts` (onboardingApi only), and `apps/web/src/shared/hooks/useWorker.ts` (onboarding callbacks only). + +--- + +*End of GOAL.md. The companion `/goal` slash command lives in the PR description for this branch and in the conversation with Kevin.* From 89e50225493fa577187b27e77c912ed4d12586a2 Mon Sep 17 00:00:00 2001 From: KevinBigham Date: Thu, 14 May 2026 18:54:30 -0500 Subject: [PATCH 2/5] feat(onboarding): refactor /onboarding to drive AGM-based revised flow The `/onboarding` route now loads the three fixed AGM candidates (Marcus Chen, Walter Kowalski, Elena Vargas) through `getAGMCandidates`, hydrates the chosen AGM via `getRevisedOnboardingData`, walks the sim-core `REVISED_CHAPTER_ORDER` end-to-end, applies coaching hires via `applyStaffHires`, applies the scouting director via `applyScoutingHire`, completes via `completeRevisedOnboarding`, then exports the snapshot and persists it through the existing IndexedDB save path before navigating to `/dashboard`. Component touch-ups: - AssessmentPanel: accepts revised chapter IDs alongside legacy ones, reusing the same chapter views. - ChapterProgress: accepts the revised chapter-order labels. The test for the route was rewritten test-first and now covers AGM selection, the staff-hiring step calling `applyStaffHires`, the scouting-hiring step calling `applyScoutingHire`, and the completion step calling `completeRevisedOnboarding`. Save schema stays at v33. Sim-core onboarding modules untouched. Co-Authored-By: Codex GPT-5 Co-Authored-By: Claude Opus 4.7 --- .../onboarding/components/AssessmentPanel.tsx | 13 +- .../onboarding/components/ChapterProgress.tsx | 16 +- .../routes/RevisedOnboardingPage.test.tsx | 808 +++++---- .../routes/RevisedOnboardingPage.tsx | 1454 ++++++----------- 4 files changed, 1001 insertions(+), 1290 deletions(-) diff --git a/apps/web/src/features/onboarding/components/AssessmentPanel.tsx b/apps/web/src/features/onboarding/components/AssessmentPanel.tsx index 4b6d0df..b118ee7 100644 --- a/apps/web/src/features/onboarding/components/AssessmentPanel.tsx +++ b/apps/web/src/features/onboarding/components/AssessmentPanel.tsx @@ -1,4 +1,4 @@ -import type { ChapterScript } from '@mbd/sim-core'; +import type { ChapterScript, RevisedChapterScript } from '@mbd/sim-core'; import { OwnerMeetingView } from './chapters/OwnerMeetingView'; import { RosterAssessmentView } from './chapters/RosterAssessmentView'; import { FarmAssessmentView } from './chapters/FarmAssessmentView'; @@ -9,25 +9,34 @@ import { SeasonStrategyView } from './chapters/SeasonStrategyView'; import { PressConferenceView } from './chapters/PressConferenceView'; interface AssessmentPanelProps { - chapter: ChapterScript; + chapter: ChapterScript | RevisedChapterScript; } export function AssessmentPanel({ chapter }: AssessmentPanelProps) { const data = chapter.assessmentData; + if (data == null) { + return null; + } + const chapterId = chapter.chapter.id; switch (chapterId) { case 'owners_office': return data.owner ? : null; case 'know_your_stars': + case 'roster_review': return data.roster ? : null; case 'the_farm': + case 'farm_system': return data.farm ? : null; case 'coaching_staff': + case 'hire_coaches': return data.staff ? : null; case 'financial_playbook': + case 'financial_plan': return data.financial ? : null; case 'scouting_intel': + case 'hire_scouts': return data.scouting ? : null; case 'season_strategy': return data.strategy ? : null; diff --git a/apps/web/src/features/onboarding/components/ChapterProgress.tsx b/apps/web/src/features/onboarding/components/ChapterProgress.tsx index 464582a..0eca5be 100644 --- a/apps/web/src/features/onboarding/components/ChapterProgress.tsx +++ b/apps/web/src/features/onboarding/components/ChapterProgress.tsx @@ -3,13 +3,19 @@ import { CHAPTER_ORDER } from '@mbd/sim-core'; interface ChapterProgressProps { currentChapter: number; totalChapters?: number; + chapters?: ReadonlyArray<{ + title?: string; + label?: string; + }>; } -export function ChapterProgress({ currentChapter, totalChapters = 8 }: ChapterProgressProps) { +export function ChapterProgress({ currentChapter, totalChapters, chapters = CHAPTER_ORDER }: ChapterProgressProps) { + const chapterCount = totalChapters ?? chapters.length; + return (
- {Array.from({ length: totalChapters }, (_, idx) => { - const chapter = CHAPTER_ORDER[idx]; + {Array.from({ length: chapterCount }, (_, idx) => { + const chapter = chapters[idx]; const isComplete = idx < currentChapter; const isCurrent = idx === currentChapter; @@ -24,10 +30,10 @@ export function ChapterProgress({ currentChapter, totalChapters = 8 }: ChapterPr ? 'bg-accent-primary ring-2 ring-accent-primary/30 ring-offset-1 ring-offset-dynasty-bg' : 'bg-dynasty-border' }`} - title={chapter?.title ?? `Chapter ${idx + 1}`} + title={chapter?.title ?? chapter?.label ?? `Chapter ${idx + 1}`} /> {/* Connector line (except after last) */} - {idx < totalChapters - 1 && ( + {idx < chapterCount - 1 && (
({ + chapter: { id, label, hasChoice: true, isHiring: false, order: 1 }, + intro: [scriptLine(`${label} intro`)], + assessmentData, + reaction: [scriptLine(`${label} reaction`)], + transition: null, + choiceReactions: {}, + candidateIds, + }); -function buildSession(overrides: Partial = {}): DayOneSession { return { - mode: 'full', - currentStep: 'recap', - teamCard: { - teamId: 'nym', - teamName: 'New York Tycoons', - division: 'AL_EAST', - archetype: 'Empire Under Pressure', - franchiseHook: 'The loudest market wants October now.', - whyNow: 'The roster can win if the room is aligned.', - marketSize: 'large', - timeline: 'Win now', - payrollTier: 'Premier', - farmSystemRating: 'B+', - strengths: ['middle-of-order thump', 'rotation depth'], - weaknesses: ['bullpen stability', 'prospect pipeline'], - teamIdentityBlurb: 'Big pressure, real upside.', - projectedRecord: '90-72', - topPlayers: [ - { playerId: 'h1', name: 'Jace Cannon', position: 'CF', overall: 78 }, - ], - divisionRivals: [ - { teamId: 'bos', teamName: 'Boston Noreasters' }, - ], + agm, + greeting: [scriptLine('Marcus is ready to work.')], + chapters: { + agm_selection: chapter('agm_selection', 'Choose Your Assistant', null), + owners_office: chapter('owners_office', "The Owner's Office", { owner: CHAPTER_DATA.owner }), + roster_review: chapter('roster_review', 'Know Your Roster', { roster: CHAPTER_DATA.roster }), + hire_coaches: chapter('hire_coaches', 'Hire Your Staff', null, [ + 'manager-analytics', + 'pitching-development', + 'hitting-approach', + ]), + farm_system: chapter('farm_system', 'The Farm', { farm: CHAPTER_DATA.farm }), + hire_scouts: chapter('hire_scouts', 'Hire Your Scout', null, ['scout-draft']), + financial_plan: chapter('financial_plan', 'The Books', { financial: CHAPTER_DATA.financial }), + season_strategy: chapter('season_strategy', 'The Game Plan', { strategy: CHAPTER_DATA.strategy }), + press_conference: chapter('press_conference', 'Face the Press', { press: CHAPTER_DATA.press }), }, - ownerScene: { - title: 'Welcome To New York Tycoons', - summary: 'Owner summary', - expectation: 'Owner expectation', - stakes: 'Owner stakes', + farewell: [scriptLine('The office is staffed and the plan is live.')], + staffOpinions: { + 'manager-analytics': { + candidateId: 'manager-analytics', + agreementLevel: 'recommend', + lines: [scriptLine('I like this manager for the room.')], + }, + 'pitching-development': { + candidateId: 'pitching-development', + agreementLevel: 'recommend', + lines: [scriptLine('The arms need this teacher.')], + }, + 'hitting-approach': { + candidateId: 'hitting-approach', + agreementLevel: 'recommend', + lines: [scriptLine('The lineup needs this approach.')], + }, }, - stepCopy: { - eyebrow: 'April Watch', - headline: 'The room is already reacting.', - body: 'This is what the AGM thinks comes next.', + scoutOpinions: { + 'scout-draft': { + candidateId: 'scout-draft', + agreementLevel: 'recommend', + lines: [scriptLine('Draft coverage fits the plan.')], + }, }, - agmCandidates: AGMS, - selectedAGM: AGMS[0]!, - orgReview: { - mlbRank: 5, - farmRank: 12, - mlbTier: 'strong', - farmTier: 'average', - strengths: ['middle-of-order thump', 'rotation depth'], - weaknesses: ['bullpen stability', 'prospect pipeline'], - inheritedStory: 'A strong MLB room with a middle-tier pipeline.', - topProspectName: 'Rafael Reyes', - projectedWins: 90, + } as unknown as RevisedOnboardingScript; +} + +const CHAPTER_DATA = { + owner: { + ownerGreeting: 'Welcome to the New York Tycoons.', + ownerPersonality: { + archetype: 'win_now_mogul', + expectationLevel: 'championship', + personalityDescription: 'The owner wants October noise immediately.', }, - projectedImpacts: [], - crisis: null, - recap: { - title: 'The Tycoons Are Yours', - summary: 'The market already has a read on your first day.', - bullets: ['Marcus Chen is now beside you.', 'Budget posture: Balanced.'], + expectations: 'Reach October without mortgaging every prospect.', + budgetOverview: { + totalBudget: 220, + currentPayroll: 180, + availableSpace: 40, + luxuryTaxDistance: 25, + spendingGrade: 'B', + narrativeSummary: 'There is room to support the roster.', }, - teaser: { - headline: 'New York already sounds different after your first day.', - agmReaction: 'Marcus thinks the first ten games will tell the room if your posture is real.', - localPressNote: 'The local read is simple: you invited scrutiny on purpose.', - aprilWatchItems: [ - 'Every late-inning wobble becomes a referendum.', - 'Rafael Reyes is already a promotion headline.', - 'The owner expects urgency without chaos.', - ], - openingDayPrompt: 'Opening Day is waiting with the volume already turned up.', + marketContext: 'Big market, bigger expectations.', + divisionOutlook: 'The division is tight enough to punish hesitation.', + seasonGoalOptions: [ + { id: 'playoff', label: 'Playoff Berth', description: 'Reach October with flexibility.' }, + { id: 'compete', label: 'Compete', description: 'Stay relevant deep into the season.' }, + ], + }, + roster: { + rosterNarrative: 'The lineup can carry a contender if the bullpen holds.', + lineup: { + overallGrade: 'B', + hittersGrade: 'A', + pitchingGrade: 'C', + topStrength: 'middle-order thump', + biggestWeakness: 'late innings', }, - openingPlanView: null, - choices: { - seasonGoal: 'playoff', - budgetAllocation: 'balanced', - developmentStyle: 'balanced', - promotionStance: 'measured', - openingDayPlan: null, + stars: [ + { + playerId: 'star-1', + name: 'Jace Cannon', + position: 'CF', + age: 28, + letterGrade: 'A', + contractYears: 3, + annualSalary: 24, + }, + ], + needs: [ + { position: 'RP', urgency: 'moderate', explanation: 'One more leverage arm changes the room.' }, + ], + }, + farm: { + farmNarrative: 'The farm has one near-ready bat and enough depth to matter.', + pipeline: { + grade: 'B', + readyCount: 1, + developingCount: 4, + rawCount: 3, + positionBalance: 'balanced', + depthDescription: 'Balanced depth.', }, - ...overrides, - }; + topProspects: [ + { + playerId: 'prospect-1', + name: 'Rafael Reyes', + position: 'SS', + age: 21, + level: 'AAA', + overallRating: 62, + ceiling: 82, + ceilingGrade: 'A', + archetype: 'Two-way infielder', + readiness: 'one_year', + breakoutProbability: 0.2, + spotlight: 'Close enough to become a summer question.', + }, + ], + developmentOptions: [ + { id: 'balanced', label: 'Balanced Development', description: 'Promote when tools and production agree.' }, + { id: 'patient', label: 'Patient Development', description: 'Let prospects dominate first.' }, + ], + closestToMLB: null, + highestCeiling: null, + }, + financial: { + payroll: { + totalPayroll: 180, + hitterPayroll: 105, + pitcherPayroll: 75, + topPaidPlayer: { name: 'Jace Cannon', salary: 24 }, + averageSalary: 8, + medianSalary: 6, + }, + extensions: [], + flexibility: { + grade: 'B', + availableSpace: 40, + luxuryTaxRoom: 25, + canAddStar: true, + canAddRole: true, + narrativeSummary: 'The books can support a targeted move.', + }, + spendingOptions: [ + { id: 'balanced', label: 'Balanced', description: 'Spend with intent and protect flexibility.' }, + { id: 'big_spender', label: 'Big Spender', description: 'Use available room now.' }, + ], + }, + staff: {}, + scouting: {}, + strategy: { + competitiveWindow: 'stable_contender', + recommendedSeasonGoal: 'playoff', + recommendedTradeApproach: 'buyer', + priorityList: [ + { + id: 'push_current_window', + title: 'Push the current window', + description: 'Reinforce the roster without losing the next core.', + score: 84, + }, + ], + strategyOptions: [ + { id: 'buyer', label: 'Buyer', description: 'Convert flexibility into help.' }, + { id: 'opportunistic', label: 'Opportunistic', description: 'Let market value decide.' }, + ], + summaryNarrative: 'The Tycoons are a stable contender with a buyer lean.', + }, + press: { + openingStatementOptions: [ + { id: 'confident', label: 'Confident', statement: 'We expect this club to play in October.' }, + { id: 'measured', label: 'Measured', statement: 'We will earn trust with disciplined decisions.' }, + ], + likelyQuestions: ['How quickly will this roster be reinforced?'], + recommendedTone: 'confident', + finalNarrative: 'The market will parse every word from the first press conference.', + }, +}; + +function buildOnboardingData(agm = AGMS[0]!): RevisedOnboardingData { + return { + script: buildScript(agm), + chapterData: CHAPTER_DATA, + staffSlate: STAFF_SLATE, + scoutingSlate: SCOUTING_SLATE, + } as unknown as RevisedOnboardingData; } function mockGameStore(overrides: Partial<{ @@ -245,6 +446,27 @@ function mockGameStore(overrides: Partial<{ mockedUseGameStore.mockImplementation(((selector: (value: typeof state) => unknown) => selector(state)) as typeof useGameStore); } +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +function findButton(container: HTMLElement, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes(text), + ); + expect(button, `Expected button containing "${text}"`).toBeTruthy(); + return button as HTMLButtonElement; +} + +async function clickButton(container: HTMLElement, text: string) { + await act(async () => { + findButton(container, text).dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flush(); +} + describe('RevisedOnboardingPage', () => { let container: HTMLDivElement; let root: Root; @@ -290,129 +512,14 @@ describe('RevisedOnboardingPage', () => { vi.clearAllMocks(); }); - it('renders the recap teaser beat with April watch items', async () => { - mockedUseWorker.mockReturnValue({ - isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(buildSession()), - finishDayOne: vi.fn(), - } as unknown as ReturnType); - - await act(async () => { - root.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toContain('New York already sounds different after your first day.'); - expect(container.textContent).toContain('The local read is simple: you invited scrutiny on purpose.'); - expect(container.textContent).toContain('Every late-inning wobble becomes a referendum.'); - expect(container.textContent).toContain('Opening Day is waiting with the volume already turned up.'); - }); - - it('shows the intro-scroll nudge once for a newly registered save', async () => { - registerGuidedStartSave('save-slot-1'); - mockedUseWorker.mockReturnValue({ + it('loads AGM candidates from the revised worker API and renders all three choices', async () => { + const worker = { isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(buildSession()), - finishDayOne: vi.fn(), - } as unknown as ReturnType); - - await act(async () => { - root.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toContain('The owner handed you the keys'); - const dismissButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent === "Let's go.", - ); - - await act(async () => { - dismissButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(readGuidedStartNudgeRecord('save-slot-1')?.seen.intro_scroll).toBe(true); - expect(container.textContent).not.toContain('The owner handed you the keys'); - - await act(async () => { - root.unmount(); - }); - root = createRoot(container); - await act(async () => { - root.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).not.toContain('The owner handed you the keys'); - }); + getAGMCandidates: vi.fn().mockResolvedValue(AGMS), + getRevisedOnboardingData: vi.fn().mockResolvedValue(buildOnboardingData()), + } as unknown as ReturnType; - it('shows duplicate opening-day warnings and lets the user reset back to AGM recommendations', async () => { - mockedUseWorker.mockReturnValue({ - isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(buildSession({ - currentStep: 'opening_day_plan', - stepCopy: { - eyebrow: 'Opening Day Board', - headline: 'Set the board like you mean it.', - body: 'Marcus wants the leverage roles aligned cleanly.', - }, - recap: null, - teaser: null, - openingPlanView: { - lineup: [], - rotation: [], - bullpen: { - closer: null, - setup: [], - longRelief: null, - }, - lineupOptions: [ - { playerId: 'h1', name: 'Jace Cannon', position: 'CF' }, - { playerId: 'h2', name: 'Luis Vega', position: 'SS' }, - ], - rotationOptions: [ - { playerId: 'sp1', name: 'Cole Mercer', position: 'SP' }, - { playerId: 'sp2', name: 'Troy Hale', position: 'SP' }, - ], - bullpenOptions: [ - { playerId: 'rp1', name: 'Nate Shaw', position: 'CL' }, - { playerId: 'rp2', name: 'Evan Price', position: 'RP' }, - ], - }, - choices: { - seasonGoal: 'playoff', - budgetAllocation: 'balanced', - developmentStyle: 'balanced', - promotionStance: 'measured', - openingDayPlan: { - lineupPlayerIds: ['h1', 'h2'], - rotationPlayerIds: ['sp1', 'sp2'], - bullpen: { - closerId: 'rp1', - setupIds: ['rp2'], - longReliefId: null, - }, - }, - }, - })), - } as unknown as ReturnType); + mockedUseWorker.mockReturnValue(worker); await act(async () => { root.render( @@ -421,50 +528,40 @@ describe('RevisedOnboardingPage', () => { , ); }); - await act(async () => { - await Promise.resolve(); - }); - - const selects = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[]; - expect(container.textContent).toContain('AGM Recommended'); - - await act(async () => { - selects[1]!.value = 'h1'; - selects[1]!.dispatchEvent(new Event('change', { bubbles: true })); - }); - - expect(container.textContent).toContain('Jace Cannon is assigned to multiple lineup spots.'); - expect(container.textContent).toContain('Fix the duplicate role warnings before locking the Opening Day plan.'); + await flush(); - const resetLineupButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes('Reset Lineup'), - ); - - await act(async () => { - resetLineupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(selects[1]!.value).toBe('h2'); - expect(container.textContent).not.toContain('Jace Cannon is assigned to multiple lineup spots.'); + expect(worker.getAGMCandidates).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain('Marcus Chen'); + expect(container.textContent).toContain('Walter Kowalski'); + expect(container.textContent).toContain('Elena Vargas'); + expect(container.textContent).toContain('Day One. First Decision.'); }); - it('persists the completed Day One snapshot before entering the dashboard', async () => { - const session = buildSession(); + it('runs the revised AGM flow through staff, scout, completion, and save persistence', async () => { const worker = { isReady: true, - getDayOneSession: vi.fn().mockResolvedValue(session), - finishDayOne: vi.fn().mockResolvedValue(undefined), + getAGMCandidates: vi.fn().mockResolvedValue(AGMS), + getRevisedOnboardingData: vi.fn().mockResolvedValue(buildOnboardingData()), + applyStaffHires: vi.fn().mockResolvedValue({ success: true, flowStateChanged: false }), + applyScoutingHire: vi.fn().mockResolvedValue({ success: true, flowStateChanged: false }), + completeRevisedOnboarding: vi.fn().mockResolvedValue({ success: true, flowStateChanged: true }), exportSnapshot: vi.fn().mockResolvedValue({ schemaVersion: CURRENT_GAME_SNAPSHOT_VERSION, season: 1, day: 1, phase: 'preseason', franchise: { - dayOne: { - status: 'complete', - currentStep: 'complete', - selectedAGMId: 'marcus_chen', + assistantGMId: 'marcus_chen', + scoutingDirector: { id: 'scout-draft', specialty: 'draft' }, + gmPhilosophy: { + seasonGoal: 'playoff', + developmentStyle: 'balanced', + spendingStyle: 'balanced', + tradeApproach: 'buyer', + scoutingFocus: 'draft', + mediaTone: 'confident', }, + onboarding: { welcomeBriefingSeen: true }, }, }), } as unknown as ReturnType; @@ -495,22 +592,52 @@ describe('RevisedOnboardingPage', () => { , ); }); - await act(async () => { - await Promise.resolve(); + await flush(); + + await clickButton(container, 'Hire Marcus'); + expect(worker.getRevisedOnboardingData).toHaveBeenCalledWith('marcus_chen'); + + await clickButton(container, 'Playoff Berth'); + await clickButton(container, 'Continue Roster Review'); + + await clickButton(container, 'Iris Hale'); + await clickButton(container, 'Cole Mercer'); + await clickButton(container, 'Luis Vega'); + await clickButton(container, 'Confirm Hires'); + expect(worker.applyStaffHires).toHaveBeenCalledWith({ + managerId: 'manager-analytics', + pitchingCoachId: 'pitching-development', + hittingCoachId: 'hitting-approach', }); - const enterFrontOfficeButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes('Enter The Front Office'), - ); - - await act(async () => { - enterFrontOfficeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(worker.finishDayOne).toHaveBeenCalledTimes(1); + await clickButton(container, 'Balanced Development'); + + await clickButton(container, 'Nate Shaw'); + await clickButton(container, 'Confirm Hire'); + expect(worker.applyScoutingHire).toHaveBeenCalledWith('scout-draft'); + + await clickButton(container, 'Balanced'); + await clickButton(container, 'Buyer'); + await clickButton(container, 'Confident'); + await clickButton(container, 'Enter the Front Office'); + + expect(worker.completeRevisedOnboarding).toHaveBeenCalledWith(expect.objectContaining({ + selectedAGMId: 'marcus_chen', + staffHires: { + managerId: 'manager-analytics', + pitchingCoachId: 'pitching-development', + hittingCoachId: 'hitting-approach', + }, + scoutingHire: 'scout-draft', + gmPhilosophy: expect.objectContaining({ + seasonGoal: 'playoff', + developmentStyle: 'balanced', + spendingStyle: 'balanced', + tradeApproach: 'buyer', + scoutingFocus: 'draft', + mediaTone: 'confident', + }), + })); expect(worker.exportSnapshot).toHaveBeenCalledTimes(1); expect(mockedLoadGameById).toHaveBeenCalledWith('save-slot-1'); expect(mockedSaveGameById).toHaveBeenCalledWith( @@ -519,11 +646,8 @@ describe('RevisedOnboardingPage', () => { expect.objectContaining({ schemaVersion: CURRENT_GAME_SNAPSHOT_VERSION, franchise: expect.objectContaining({ - dayOne: expect.objectContaining({ - status: 'complete', - currentStep: 'complete', - selectedAGMId: 'marcus_chen', - }), + assistantGMId: 'marcus_chen', + onboarding: expect.objectContaining({ welcomeBriefingSeen: true }), }), }), expect.objectContaining({ @@ -535,4 +659,46 @@ describe('RevisedOnboardingPage', () => { ); expect(mockedNavigate).toHaveBeenCalledWith('/dashboard'); }); + + it('preserves the intro-scroll guided start nudge on the revised route', async () => { + registerGuidedStartSave('save-slot-1'); + mockedUseWorker.mockReturnValue({ + isReady: true, + getAGMCandidates: vi.fn().mockResolvedValue(AGMS), + getRevisedOnboardingData: vi.fn().mockResolvedValue(buildOnboardingData()), + } as unknown as ReturnType); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + + expect(container.textContent).toContain('The owner handed you the keys'); + await clickButton(container, "Let's go."); + + expect(readGuidedStartNudgeRecord('save-slot-1')?.seen.intro_scroll).toBe(true); + expect(container.textContent).not.toContain('The owner handed you the keys'); + }); + + it('surfaces revised worker load errors', async () => { + mockedUseWorker.mockReturnValue({ + isReady: true, + getAGMCandidates: vi.fn().mockRejectedValue(new Error('AGM API unavailable')), + } as unknown as ReturnType); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + + expect(container.textContent).toContain('AGM API unavailable'); + }); }); diff --git a/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx b/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx index 0b76f53..1fe62f1 100644 --- a/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx +++ b/apps/web/src/features/onboarding/routes/RevisedOnboardingPage.tsx @@ -1,134 +1,102 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { AlertTriangle, ArrowRight, BriefcaseBusiness, - ChartNoAxesCombined, - ShieldAlert, + CheckCircle2, + ClipboardList, Users, - WalletCards, } from 'lucide-react'; -import type { AGMCandidate, GMPhilosophy } from '@mbd/sim-core'; -import type { DayOneSession } from '@/workers/sim.worker.onboarding'; +import { + REVISED_CHAPTER_ORDER, + advanceRevisedChapter, + createRevisedOnboardingState, + getOnboardingResult, + selectAGMInFlow, + setPhilosophyChoiceInFlow, + setScoutingHireInFlow, + setStaffHiresInFlow, + type AGMCandidate, + type AGMCandidateId, + type GMPhilosophy, + type OnboardingFlowState, + type RevisedChapterId, + type RevisedChapterScript, + type StaffHireChoices, +} from '@mbd/sim-core'; import { useGameStore } from '@/shared/hooks/useGameStore'; import { useWorker } from '@/shared/hooks/useWorker'; import { loadGameById, saveGame, saveGameById } from '@/shared/lib/saveSystem'; +import type { RevisedOnboardingData } from '@/workers/sim.worker.onboarding'; import { AGMRuntimePanel } from '../components/AGMRuntimePanel'; +import { AGMSelectionPanel } from '../components/AGMSelectionPanel'; +import { AssessmentPanel } from '../components/AssessmentPanel'; +import { ChapterProgress } from '../components/ChapterProgress'; +import { HireCoachesView } from '../components/chapters/HireCoachesView'; +import { HireScoutsView } from '../components/chapters/HireScoutsView'; import { GuidedStartNudgeCard, useNudges } from '../nudges'; -type OpeningPlanDraft = NonNullable; -type OpeningPlanOption = NonNullable['lineupOptions'][number]; +type ChoiceField = + | 'seasonGoal' + | 'developmentStyle' + | 'spendingStyle' + | 'tradeApproach' + | 'mediaTone'; -interface OpeningPlanWarnings { - lineup: string[]; - rotation: string[]; - bullpen: string[]; +interface ChoiceOption { + id: string; + label: string; + description: string; } -const STEP_LABELS: Record = { - owner_intro: 'Owner Intro', - agm_select: 'Choose AGM', - org_review: 'Org Review', - season_goal: 'Season Goal', - budget: 'Budget Allocation', - opening_day_plan: 'Opening Day Plan', - development: 'Development Philosophy', - crisis: 'First Crisis', - recap: 'Recap', - complete: 'Ready', -}; - -const SEASON_GOAL_OPTIONS: Array<{ - id: GMPhilosophy['seasonGoal']; - title: string; - summary: string; -}> = [ - { id: 'championship', title: 'Championship', summary: 'Treat the roster like a title threat from day one.' }, - { id: 'playoff', title: 'Playoff Push', summary: 'Expect October baseball and manage the room accordingly.' }, - { id: 'compete', title: 'Compete', summary: 'Push for traction without sacrificing every future edge.' }, - { id: 'rebuild', title: 'Rebuild', summary: 'Protect runway and force the long view to stay honest.' }, -]; - -const BUDGET_OPTIONS = [ - { id: 'spend_now' as const, title: 'Spend Now', summary: 'Front-load support and treat the current roster as urgent.' }, - { id: 'balanced' as const, title: 'Balanced', summary: 'Support the club while preserving some in-season maneuvering.' }, - { id: 'future_flex' as const, title: 'Future Flex', summary: 'Keep powder dry for injuries, trades, and opportunistic moves.' }, -]; - -const DEVELOPMENT_OPTIONS: Array<{ - id: NonNullable; - title: string; - summary: string; -}> = [ - { id: 'aggressive', title: 'Aggressive', summary: 'Push prospects fast and tolerate more development risk.' }, - { id: 'balanced', title: 'Balanced', summary: 'Blend urgency and patience without locking into either extreme.' }, - { id: 'patient', title: 'Patient', summary: 'Protect development time and trust the longer runway.' }, -]; - -const PROMOTION_OPTIONS = [ - { id: 'aggressive' as const, title: 'Aggressive Promotions', summary: 'Great minor-league performance triggers fast call-up pressure.' }, - { id: 'measured' as const, title: 'Measured Promotions', summary: 'Promotions stay available, but only when the timing is clean.' }, - { id: 'patient' as const, title: 'Patient Promotions', summary: 'Prospects need clear proof before they move.' }, -]; - -function buildPlanOptionLabel(option: { name: string; position: string }) { - return `${option.name} · ${option.position}`; +interface WorkerMutationResult { + success: boolean; + flowStateChanged: boolean; } -function buildOpeningPlanWarnings( - plan: OpeningPlanDraft | null, - view: DayOneSession['openingPlanView'], -): OpeningPlanWarnings { - if (plan == null || view == null) { - return { lineup: [], rotation: [], bullpen: [] }; - } +function readErrorMessage(caughtError: unknown, fallback: string) { + return caughtError instanceof Error ? caughtError.message : fallback; +} - const optionLookup = new Map(); - for (const option of [...view.lineupOptions, ...view.rotationOptions, ...view.bullpenOptions]) { - optionLookup.set(option.playerId, option); +function requireSnapshotObject(snapshot: unknown): object { + if (snapshot == null || typeof snapshot !== 'object') { + throw new Error('Worker did not return a valid snapshot object.'); } - const playerLabel = (playerId: string) => optionLookup.get(playerId)?.name ?? playerId; - const duplicateLabels = (playerIds: Array) => { - const counts = new Map(); - for (const playerId of playerIds) { - if (!playerId) { - continue; - } - counts.set(playerId, (counts.get(playerId) ?? 0) + 1); - } - return [...counts.entries()] - .filter(([, count]) => count > 1) - .map(([playerId]) => playerLabel(playerId)); - }; + return snapshot; +} - const lineupDuplicates = duplicateLabels(plan.lineupPlayerIds); - const rotationDuplicates = duplicateLabels(plan.rotationPlayerIds); - const bullpenRoles = [ - plan.bullpen?.closerId ?? null, - ...(plan.bullpen?.setupIds ?? []), - plan.bullpen?.longReliefId ?? null, - ]; - const bullpenDuplicates = duplicateLabels(bullpenRoles); - const starterOverlap = [...new Set( - bullpenRoles - .filter((playerId): playerId is string => Boolean(playerId)) - .filter((playerId) => plan.rotationPlayerIds.includes(playerId)), - )].map(playerLabel); +function getCurrentChapter(state: OnboardingFlowState) { + return REVISED_CHAPTER_ORDER[state.currentChapter] ?? REVISED_CHAPTER_ORDER[0]!; +} - return { - lineup: lineupDuplicates.map((label) => `${label} is assigned to multiple lineup spots.`), - rotation: rotationDuplicates.map((label) => `${label} is assigned to multiple rotation spots.`), - bullpen: [ - ...bullpenDuplicates.map((label) => `${label} is assigned to multiple bullpen roles.`), - ...starterOverlap.map((label) => `${label} is assigned in both the rotation and the bullpen.`), - ], - }; +function buildDialogueText(chapter: RevisedChapterScript | null, key: 'intro' | 'reaction') { + return chapter?.[key].map((line) => line.text).join(' ') ?? ''; } -function hasOpeningPlanWarnings(warnings: OpeningPlanWarnings) { - return warnings.lineup.length > 0 || warnings.rotation.length > 0 || warnings.bullpen.length > 0; +function buildFallbackBody(chapterId: RevisedChapterId) { + switch (chapterId) { + case 'owners_office': + return 'The owner is setting the mandate. Pick the operating target your front office will answer to.'; + case 'roster_review': + return 'Review the major-league room before you start assigning people to fix it.'; + case 'hire_coaches': + return 'Fill the manager, pitching coach, and hitting coach offices before the season starts moving.'; + case 'farm_system': + return 'Set the development posture that will shape promotions and prospect patience.'; + case 'hire_scouts': + return 'Pick the scouting director whose specialty becomes the organization acquisition lens.'; + case 'financial_plan': + return 'Choose how aggressively this front office should use payroll flexibility.'; + case 'season_strategy': + return 'Set the trade-market posture for the first competitive window.'; + case 'press_conference': + return 'Decide how directly you want to set public expectations.'; + case 'agm_selection': + default: + return 'Choose the assistant GM who will narrate the rest of Day One.'; + } } export default function RevisedOnboardingPage() { @@ -142,74 +110,42 @@ export default function RevisedOnboardingPage() { saveSlotId, triggers: ['intro_scroll'], }); - const [session, setSession] = useState(null); + + const [candidates, setCandidates] = useState([]); + const [data, setData] = useState(null); + const [flowState, setFlowState] = useState(() => createRevisedOnboardingState()); const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [developmentStyle, setDevelopmentStyle] = useState>('balanced'); - const [promotionStance, setPromotionStance] = useState>('measured'); - const [openingPlanDraft, setOpeningPlanDraft] = useState(null); - const refreshSession = useCallback(async () => { + const hasSaveTarget = Boolean(activeSaveId) || activeSaveSlot != null; + const currentChapter = getCurrentChapter(flowState); + const currentScript = data?.script.chapters[currentChapter.id] ?? null; + const selectedAGM = data?.script.agm ?? candidates.find((candidate) => candidate.id === flowState.selectedAGMId) ?? null; + const isBusy = loading || submitting; + + const loadCandidates = useCallback(async () => { if (!worker.isReady) { return; } + setLoading(true); try { - const nextSession = await worker.getDayOneSession(); - setSession(nextSession); + const nextCandidates = await worker.getAGMCandidates(); + setCandidates(nextCandidates); setError(null); } catch (caughtError) { - setError(caughtError instanceof Error ? caughtError.message : 'Failed to load Day One.'); + setError(readErrorMessage(caughtError, 'Failed to load AGM candidates.')); } finally { setLoading(false); } }, [worker]); useEffect(() => { - void refreshSession(); - }, [refreshSession]); - - useEffect(() => { - if (session?.currentStep !== 'development') { - return; - } - setDevelopmentStyle(session.choices.developmentStyle ?? 'balanced'); - setPromotionStance(session.choices.promotionStance ?? 'measured'); - }, [session]); + void loadCandidates(); + }, [loadCandidates]); - useEffect(() => { - if (session?.currentStep !== 'opening_day_plan' || session.choices.openingDayPlan == null) { - return; - } - setOpeningPlanDraft({ - lineupPlayerIds: [...session.choices.openingDayPlan.lineupPlayerIds], - rotationPlayerIds: [...session.choices.openingDayPlan.rotationPlayerIds], - bullpen: session.choices.openingDayPlan.bullpen == null - ? null - : { - closerId: session.choices.openingDayPlan.bullpen.closerId, - setupIds: [...session.choices.openingDayPlan.bullpen.setupIds], - longReliefId: session.choices.openingDayPlan.bullpen.longReliefId, - }, - }); - }, [session]); - - const performAction = useCallback(async (action: () => Promise) => { - setLoading(true); - try { - await action(); - const nextSession = await worker.getDayOneSession(); - setSession(nextSession); - setError(null); - } catch (caughtError) { - setError(caughtError instanceof Error ? caughtError.message : 'Day One action failed.'); - } finally { - setLoading(false); - } - }, [worker]); - - const persistDayOneCompletion = useCallback(async () => { - const snapshot = await worker.exportSnapshot(); + const persistCompletion = useCallback(async (snapshot: object) => { if (activeSaveId) { const existingSave = await loadGameById(activeSaveId); if (existingSave) { @@ -224,951 +160,545 @@ export default function RevisedOnboardingPage() { } if (activeSaveSlot != null) { - const teamName = session?.teamCard.teamName ?? 'Franchise'; - await saveGame(activeSaveSlot, `${gmName} • ${teamName}`, snapshot); + await saveGame(activeSaveSlot, `${gmName} • Franchise`, snapshot); } - }, [activeSaveId, activeSaveSlot, gmName, session, worker]); + }, [activeSaveId, activeSaveSlot, gmName]); - const enterFrontOffice = useCallback(async () => { - setLoading(true); + const handleSelectAGM = useCallback(async (agmId: AGMCandidateId) => { + setSubmitting(true); try { - await worker.finishDayOne(); - await persistDayOneCompletion(); + const nextData = await worker.getRevisedOnboardingData(agmId); + setData(nextData); + setFlowState(selectAGMInFlow(createRevisedOnboardingState(), agmId)); setError(null); - navigate('/dashboard'); } catch (caughtError) { - setError(caughtError instanceof Error ? caughtError.message : 'Failed to enter the front office.'); + setError(readErrorMessage(caughtError, 'Failed to load revised onboarding.')); } finally { - setLoading(false); + setSubmitting(false); } - }, [navigate, persistDayOneCompletion, worker]); - - const updateOpeningPlanDraft = useCallback((updater: (current: OpeningPlanDraft) => OpeningPlanDraft) => { - setOpeningPlanDraft((current) => { - const baseline = current ?? session?.choices.openingDayPlan; - return baseline ? updater(baseline) : current; - }); - }, [session]); + }, [worker]); - const updateLineupSlot = useCallback((index: number, playerId: string) => { - updateOpeningPlanDraft((current) => { - const next = [...current.lineupPlayerIds]; - next[index] = playerId; - return { - ...current, - lineupPlayerIds: next, - }; - }); - }, [updateOpeningPlanDraft]); + const completeLocalChapter = useCallback((nextState: OnboardingFlowState) => { + setFlowState(nextState); + setError(null); + }, []); - const updateRotationSlot = useCallback((index: number, playerId: string) => { - updateOpeningPlanDraft((current) => { - const next = [...current.rotationPlayerIds]; - next[index] = playerId; - return { - ...current, - rotationPlayerIds: next, - }; - }); - }, [updateOpeningPlanDraft]); + const handleChoice = useCallback((field: K, value: GMPhilosophy[K]) => { + completeLocalChapter(setPhilosophyChoiceInFlow(flowState, field, value)); + }, [completeLocalChapter, flowState]); - const updateBullpenRole = useCallback((role: 'closer' | 'longRelief' | 'setup0' | 'setup1', playerId: string) => { - updateOpeningPlanDraft((current) => { - const bullpen = current.bullpen ?? { - closerId: null, - setupIds: ['', ''], - longReliefId: null, - }; - const nextSetupIds = [...bullpen.setupIds]; - if (role === 'closer') { - return { - ...current, - bullpen: { - ...bullpen, - closerId: playerId, - setupIds: nextSetupIds, - }, - }; - } - if (role === 'longRelief') { - return { - ...current, - bullpen: { - ...bullpen, - longReliefId: playerId, - setupIds: nextSetupIds, - }, - }; - } - nextSetupIds[role === 'setup0' ? 0 : 1] = playerId; - return { - ...current, - bullpen: { - ...bullpen, - setupIds: nextSetupIds, - }, - }; - }); - }, [updateOpeningPlanDraft]); + const handleRosterAdvance = useCallback(() => { + completeLocalChapter(advanceRevisedChapter(flowState)); + }, [completeLocalChapter, flowState]); - const openingPlanWarnings = useMemo( - () => buildOpeningPlanWarnings(openingPlanDraft, session?.openingPlanView ?? null), - [openingPlanDraft, session?.openingPlanView], - ); - const openingPlanHasWarnings = hasOpeningPlanWarnings(openingPlanWarnings); + const handleStaffHires = useCallback(async (hires: StaffHireChoices) => { + setSubmitting(true); + try { + await worker.applyStaffHires(hires) as WorkerMutationResult; + setFlowState(setStaffHiresInFlow(flowState, hires)); + setError(null); + } catch (caughtError) { + setError(readErrorMessage(caughtError, 'Failed to apply staff hires.')); + } finally { + setSubmitting(false); + } + }, [flowState, worker]); - const resetLineupToRecommended = useCallback(() => { - const recommendedPlan = session?.choices.openingDayPlan; - if (recommendedPlan == null) { - return; + const handleScoutingHire = useCallback(async (scoutingDirectorId: string) => { + setSubmitting(true); + try { + await worker.applyScoutingHire(scoutingDirectorId) as WorkerMutationResult; + setFlowState(setScoutingHireInFlow(flowState, scoutingDirectorId)); + setError(null); + } catch (caughtError) { + setError(readErrorMessage(caughtError, 'Failed to apply scouting hire.')); + } finally { + setSubmitting(false); } - updateOpeningPlanDraft((current) => ({ - ...current, - lineupPlayerIds: [...recommendedPlan.lineupPlayerIds], - })); - }, [session?.choices.openingDayPlan, updateOpeningPlanDraft]); + }, [flowState, worker]); - const resetRotationToRecommended = useCallback(() => { - const recommendedPlan = session?.choices.openingDayPlan; - if (recommendedPlan == null) { + const handleEnterFrontOffice = useCallback(async () => { + if (data == null) { + setError('Revised onboarding data is missing.'); return; } - updateOpeningPlanDraft((current) => ({ - ...current, - rotationPlayerIds: [...recommendedPlan.rotationPlayerIds], - })); - }, [session?.choices.openingDayPlan, updateOpeningPlanDraft]); - const resetBullpenToRecommended = useCallback(() => { - const recommendedBullpen = session?.choices.openingDayPlan?.bullpen; - if (recommendedBullpen == null) { - return; + setSubmitting(true); + try { + const result = getOnboardingResult(flowState, data.scoutingSlate); + await worker.completeRevisedOnboarding(result) as WorkerMutationResult; + const snapshot = requireSnapshotObject(await worker.exportSnapshot()); + await persistCompletion(snapshot); + setError(null); + navigate('/dashboard'); + } catch (caughtError) { + setError(readErrorMessage(caughtError, 'Failed to complete revised onboarding.')); + } finally { + setSubmitting(false); } - updateOpeningPlanDraft((current) => ({ - ...current, - bullpen: { - closerId: recommendedBullpen.closerId, - setupIds: [...recommendedBullpen.setupIds], - longReliefId: recommendedBullpen.longReliefId, - }, - })); - }, [session?.choices.openingDayPlan, updateOpeningPlanDraft]); + }, [data, flowState, navigate, persistCompletion, worker]); const agmPanel = useMemo(() => { - if (!session?.selectedAGM) { + if (selectedAGM == null || data == null) { return null; } - const expression = session.currentStep === 'crisis' - ? 'concerned' - : session.currentStep === 'complete' || session.currentStep === 'recap' - ? 'confident' - : session.currentStep === 'opening_day_plan' || session.currentStep === 'development' - ? 'focused' - : 'neutral'; - - const eyebrow = session.currentStep === 'crisis' - ? 'Crisis Beat' - : session.currentStep === 'complete' || session.currentStep === 'recap' - ? 'Day One Recap' - : session.currentStep === 'org_review' - ? 'Org Diagnosis' - : 'Desk-Side AGM'; - - const headline = session.currentStep === 'crisis' - ? (session.crisis?.title ?? 'The room needs a call.') - : session.currentStep === 'recap' || session.currentStep === 'complete' - ? 'The room has its marching orders.' - : session.currentStep === 'org_review' - ? `${session.teamCard.teamName} is ${session.orgReview.mlbTier} now and ${session.orgReview.farmTier} underneath.` - : `${session.selectedAGM.name} is guiding Day One.`; - - const body = session.currentStep === 'crisis' - ? (session.crisis?.summary ?? 'Your first real test is already on the desk.') - : session.currentStep === 'recap' || session.currentStep === 'complete' - ? (session.recap?.summary ?? 'Day One decisions are locked in and the front office is live.') - : session.currentStep === 'org_review' - ? session.orgReview.inheritedStory - : `Your AGM is framing each decision around Season 1 pressure, organizational leverage, and what kind of franchise you want to become.`; + const introText = buildDialogueText(currentScript, 'intro'); + const reactionText = buildDialogueText(currentScript, 'reaction'); return ( ); - }, [session]); + }, [currentChapter, currentScript, data, flowState.isComplete, selectedAGM]); + + const nudgeCard = ( + + ); - if (loading && session == null) { - return ; + if (loading && candidates.length === 0) { + return ( + <> + + {nudgeCard} + + ); } - if (!session) { - return ; + if (!hasSaveTarget) { + return ( + <> + + navigate('/')} + /> + + {nudgeCard} + + ); + } + + if (data == null) { + return ( + <> + +
+ {error ? : null} + + + {nudgeCard} + + ); } return ( -
-
-
-
-
-
- {session.mode === 'quick' ? 'Quick Start' : 'Full Day One'} -
-

- {session.teamCard.teamName} -

-

- {session.teamCard.franchiseHook} -

-
-
-
Current Step
-
- {STEP_LABELS[session.currentStep]} + <> + +
+
Progress
+
+
-
- - {session.projectedImpacts.length > 0 ? ( -
- {session.projectedImpacts.map((impact) => ( - - ))} -
- ) : null} + )} + /> - {error ? ( -
- {error} -
- ) : null} -
+ {error ? : null}
-
- {renderCurrentStep({ - session, - loading, - onAdvanceIntro: () => performAction(() => worker.advanceDayOneIntro()), - onChooseAGM: (agmId) => performAction(() => worker.chooseDayOneAGM(agmId)), - onAdvanceOrgReview: () => performAction(() => worker.advanceDayOneOrgReview()), - onSetSeasonGoal: (seasonGoal) => performAction(() => worker.setDayOneSeasonGoal(seasonGoal)), - onSetBudget: (budgetAllocation) => performAction(() => worker.setDayOneBudgetAllocation(budgetAllocation)), - onLockOpeningPlan: () => performAction(() => worker.setDayOneOpeningPlan(openingPlanDraft ?? session.choices.openingDayPlan!)), - onSetDevelopmentPlan: () => performAction(() => worker.setDayOneDevelopmentPlan({ - developmentStyle, - promotionStance, - })), - onResolveCrisis: (responseId) => performAction(() => worker.resolveDayOneCrisis(responseId)), - onEnterFrontOffice: enterFrontOffice, - developmentStyle, - promotionStance, - openingPlanDraft, - onPickDevelopmentStyle: setDevelopmentStyle, - onPickPromotionStance: setPromotionStance, - onUpdateLineupSlot: updateLineupSlot, - onUpdateRotationSlot: updateRotationSlot, - onUpdateBullpenRole: updateBullpenRole, - onResetLineup: resetLineupToRecommended, - onResetRotation: resetRotationToRecommended, - onResetBullpen: resetBullpenToRecommended, - openingPlanWarnings, - openingPlanHasWarnings, - })} +
+ {flowState.isComplete ? ( + + ) : ( + + )}
{agmPanel}
-
- -
+ + {nudgeCard} + ); } -function renderCurrentStep(args: { - session: DayOneSession; - loading: boolean; - onAdvanceIntro: () => void; - onChooseAGM: (agmId: AGMCandidate['id']) => void; - onAdvanceOrgReview: () => void; - onSetSeasonGoal: (seasonGoal: GMPhilosophy['seasonGoal']) => void; - onSetBudget: (budgetAllocation: 'spend_now' | 'balanced' | 'future_flex') => void; - onLockOpeningPlan: () => void; - onSetDevelopmentPlan: () => void; - onResolveCrisis: (responseId: string) => void; - onEnterFrontOffice: () => void; - developmentStyle: NonNullable; - promotionStance: NonNullable; - openingPlanDraft: OpeningPlanDraft | null; - onPickDevelopmentStyle: (value: NonNullable) => void; - onPickPromotionStance: (value: NonNullable) => void; - onUpdateLineupSlot: (index: number, playerId: string) => void; - onUpdateRotationSlot: (index: number, playerId: string) => void; - onUpdateBullpenRole: (role: 'closer' | 'longRelief' | 'setup0' | 'setup1', playerId: string) => void; - onResetLineup: () => void; - onResetRotation: () => void; - onResetBullpen: () => void; - openingPlanWarnings: OpeningPlanWarnings; - openingPlanHasWarnings: boolean; +function ChapterBody({ + chapterId, + script, + data, + isSubmitting, + onChoice, + onRosterAdvance, + onStaffHires, + onScoutingHire, +}: { + chapterId: RevisedChapterId; + script: RevisedChapterScript | null; + data: RevisedOnboardingData; + isSubmitting: boolean; + onChoice: (field: K, value: GMPhilosophy[K]) => void; + onRosterAdvance: () => void; + onStaffHires: (hires: StaffHireChoices) => void; + onScoutingHire: (scoutingDirectorId: string) => void; }) { - const { - session, - loading, - onAdvanceIntro, - onChooseAGM, - onAdvanceOrgReview, - onSetSeasonGoal, - onSetBudget, - onLockOpeningPlan, - onSetDevelopmentPlan, - onResolveCrisis, - onEnterFrontOffice, - developmentStyle, - promotionStance, - openingPlanDraft, - onPickDevelopmentStyle, - onPickPromotionStance, - onUpdateLineupSlot, - onUpdateRotationSlot, - onUpdateBullpenRole, - onResetLineup, - onResetRotation, - onResetBullpen, - openingPlanWarnings, - openingPlanHasWarnings, - } = args; - - switch (session.currentStep) { - case 'owner_intro': - return ( -
- -
- - -
- -
- ); - case 'agm_select': + switch (chapterId) { + case 'owners_office': return ( -
- + {script ? : null} + onChoice('seasonGoal', id as GMPhilosophy['seasonGoal'])} + disabled={isSubmitting} /> -
- {session.agmCandidates.map((candidate) => ( - - ))} -
-
+ ); - case 'org_review': + case 'roster_review': return ( -
- -
- - - -
-
- - -
- -
+ + {script ? : null} + + + + ); - case 'season_goal': + case 'hire_coaches': return ( -
- + -
- {SEASON_GOAL_OPTIONS.map((option) => ( - onSetSeasonGoal(option.id)} - disabled={loading} - /> - ))} -
-
+ ); - case 'budget': + case 'farm_system': return ( -
- + {script ? : null} + onChoice('developmentStyle', id as GMPhilosophy['developmentStyle'])} + disabled={isSubmitting} /> -
- {BUDGET_OPTIONS.map((option) => ( - onSetBudget(option.id)} - disabled={loading} - /> - ))} -
-
+ ); - case 'opening_day_plan': + case 'hire_scouts': return ( -
- + -
- - - -
-
- Pressure points today: {session.orgReview.weaknesses.join(' · ')}. -
- {openingPlanHasWarnings ? ( -
- Fix the duplicate role warnings before locking the Opening Day plan. -
- ) : null} - -
+ ); - case 'development': + case 'financial_plan': return ( -
- + {script ? : null} + onChoice('spendingStyle', id as GMPhilosophy['spendingStyle'])} + disabled={isSubmitting} /> -
-
-
Development Style
- {DEVELOPMENT_OPTIONS.map((option) => ( - onPickDevelopmentStyle(option.id)} - /> - ))} -
-
-
Promotion Stance
- {PROMOTION_OPTIONS.map((option) => ( - onPickPromotionStance(option.id)} - /> - ))} -
-
- -
+ ); - case 'crisis': + case 'season_strategy': return ( -
- + {script ? : null} + onChoice('tradeApproach', id as GMPhilosophy['tradeApproach'])} + disabled={isSubmitting} /> -
- {(session.crisis?.responseOptions ?? []).map((option) => ( - onResolveCrisis(option.id)} - disabled={loading} - /> - ))} -
-
+ ); - case 'recap': - case 'complete': + case 'press_conference': return ( -
- + {script ? : null} + ({ + id: option.id, + label: option.label, + description: option.statement, + }))} + onSelect={(id) => onChoice('mediaTone', id as GMPhilosophy['mediaTone'])} + disabled={isSubmitting} /> - {session.teaser ? ( - - ) : null} - - -
+ ); + case 'agm_selection': + default: + return null; } } -function LoadingState({ label }: { label: string }) { +function PageShell({ children }: { children: ReactNode }) { return ( -
-
-
MBD
-
{label}
-
+
+
{children}
); } -function StepHeading({ - icon: Icon, +function Header({ + eyebrow, title, body, + aside, }: { - icon: typeof BriefcaseBusiness; + eyebrow: string; title: string; body: string; + aside?: ReactNode; }) { return ( -
-
- - Day One +
+
+
+
+ {eyebrow} +
+

+ {title} +

+

+ {body} +

+
+ {aside}
-

{title}

-

{body}

-
+ ); } -function DecisionCard({ - title, - summary, - disabled, - onClick, -}: { - title: string; - summary: string; - disabled?: boolean; - onClick: () => void; -}) { +function ChapterLayout({ script, children }: { script: RevisedChapterScript | null; children: ReactNode }) { + const intro = buildDialogueText(script, 'intro'); + return ( - +
+ {script ? ( +
+
+ {script.chapter.label} +
+ {intro ? ( +

+ {intro} +

+ ) : null} +
+ ) : null} + {children} +
); } -function ToggleCard({ - active, +function ChoiceGrid({ title, - summary, - onClick, + options, + onSelect, + disabled, }: { - active: boolean; title: string; - summary: string; - onClick: () => void; + options: readonly ChoiceOption[]; + onSelect: (id: string) => void; + disabled: boolean; }) { return ( - - ); -} - -function NarrativeCard({ title, body }: { title: string; body: string }) { - return ( -
-
{title}
-
{body}
-
- ); -} - -function MetricCard({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
+
+
{title}
+
+ {options.map((option) => ( + + ))} +
); } -function PlanSelectControl({ - label, - value, - recommendedPlayerId, - options, - onChange, +function CompletionPanel({ + data, + isSubmitting, + onEnterFrontOffice, }: { - label: string; - value: string | null | undefined; - recommendedPlayerId?: string | null; - options: Array<{ playerId: string; name: string; position: string }>; - onChange: (playerId: string) => void; + data: RevisedOnboardingData; + isSubmitting: boolean; + onEnterFrontOffice: () => void; }) { - const recommendedOption = recommendedPlayerId - ? options.find((option) => option.playerId === recommendedPlayerId) ?? null - : null; + const farewell = data.script.farewell.map((line) => line.text).join(' '); return ( -