diff --git a/DECISIONS.md b/DECISIONS.md index 7cb0cd8..aec0596 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -552,3 +552,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **"Own-ship deliveries" = resolved, non-tutorial dispatches that carried a contract** (repositions store a null contract and do not count). - **A goal stays claimable whenever its condition holds at claim time:** status is computed fresh from current state, so a goal is not "ready" because of something that has since reversed (for example, discovered locations never reverse, but the counts are re-read each time). - **Claims during the tutorial fail with `goal-not-ready`,** since goals do not exist until the tutorial is over. + +## Step 10A.11 + +- **The intro card and "Suggested" highlight show only before the first run** (`runsCompleted === 0`); after that the board is a plain choice. +- **The suggested contract is the board's first slot,** which the v2 generator always fills with the light tier (fastest, most XP per minute). +- **Story names live in client copy:** "Halvorsen Lunar Freight" is presentation text, not game data, so it is not in content. diff --git a/apps/web/src/screens/StartScreen.tsx b/apps/web/src/screens/StartScreen.tsx index 66e2897..10c5401 100644 --- a/apps/web/src/screens/StartScreen.tsx +++ b/apps/web/src/screens/StartScreen.tsx @@ -13,7 +13,8 @@ export function StartScreen() { onSuccess: ({ state }) => { // The session cookie is now set; the response already holds the new state. queryClient.setQueryData(PLAYER_STATE_KEY, state); - void navigate("/"); + // Straight to work: the first company run is one tap away (CR-001). + void navigate("/contracts"); }, }); diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index 933873d..f52b829 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -21,6 +21,7 @@ export function TutorialBoard() { const fly = useGameAction("createDispatch"); const runInFlight = state.tutorial?.runInFlight ?? false; const loaner = loanerModel(content); + const firstRun = (state.tutorial?.runsCompleted ?? 0) === 0; if (board.isPending) return

Loading contracts…

; if (board.isError) { @@ -36,6 +37,15 @@ export function TutorialBoard() { return (
+ {firstRun && ( +
+

Halvorsen Lunar Freight needs a pilot.

+

Their ship, your hands. Get the crates to the Moon.

+
+ )}

Company runs in the loaner {loaner.name}. You keep a share of each payout.

@@ -53,42 +63,55 @@ export function TutorialBoard() {

No company runs left this period. Check back soon.

)}
+
+ + {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "} + + {formatDuration( + tripRealMs( + loaner, + contract.originId, + contract.destinationId, + contract.massTonnes, + now, + ), + )} + + + +
+ + ); + })} ); diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index aab9f92..aad93fb 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -1,11 +1,11 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { ApiError } from "../src/api/client"; -import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render"; +import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, unauthorized } from "./render"; describe("guest onboarding", () => { - it("shows the start screen without a session; the button creates a guest and opens HQ", async () => { + it("shows the start screen without a session; starting goes straight to the contracts with an intro", async () => { // Like the real server: no session until the guest is created. let signedUp = false; const api = fakeApi({ @@ -14,15 +14,31 @@ describe("guest onboarding", () => { signedUp = true; return { playerId: "p1", state: samplePlayerState() }; }, + getTutorialBoard: () => sampleTutorialBoard(), }); renderApp(api, "/fleet"); const start = await screen.findByRole("button", { name: "Start your company" }); expect(screen.queryByRole("navigation", { name: "Main" })).not.toBeInTheDocument(); await userEvent.click(start); - - expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); expect(api.call).toHaveBeenCalledWith("createGuest", {}); + + // Lands on Contracts with the intro and the light contract suggested. + expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument(); + expect(screen.getByLabelText("Intro")).toHaveTextContent( + "Halvorsen Lunar Freight needs a pilot.", + ); + const suggested = (await screen.findByText("Suggested")).closest("li")!; + expect(suggested).toHaveTextContent("5 t"); + + // One tap sends the first run. + await userEvent.click(within(suggested).getByRole("button", { name: "Fly it" })); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("createDispatch", { + kind: "tutorial", + contractId: "tutorial:earth:1:0:0", + }), + ); }); it("skips the start screen when a stored session is still valid", async () => { diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index 3692c49..9928d2b 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -87,3 +87,33 @@ export function renderApp(api: ApiClient, path = "/") { ); return { ...result, queryClient }; } + +/** A tutorial board like the server's: light, medium and full Earth→Moon runs. */ +export function sampleTutorialBoard( + now = Date.parse("2100-01-01T00:00:00Z"), + options: { scriptedMedium?: boolean } = {}, +) { + const contract = (slot: number, massTonnes: number, payout: number) => ({ + id: `tutorial:earth:1:0:${slot}`, + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes, + payout, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: now, + expiresAt: now + 3_600_000, + firstContact: false, + }); + const medium = contract(1, 12, options.scriptedMedium ? 461 : 230); + return { + contracts: [ + contract(0, 5, 96), + options.scriptedMedium ? { ...medium, scriptedId: "unmarked-crates" } : medium, + contract(2, 20, 384), + ], + periodEndsAt: now + 3_600_000, + }; +} diff --git a/memory-bank/progress.md b/memory-bank/progress.md index f720c3d..598d4ec 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -116,7 +116,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.8 — Server: accept the deal.** `POST /api/deal/accept` (`acceptDeal` in `src/game/deal.ts`) in one locked transaction: Courier at Earth (named from the seed) crewed by the avatar, Earth bay, loan = `dealLoanAmount`, tutorial off, loaner removed, deal accepted; returns the new player state. `409 deal-not-available` otherwise. Old `maybeEndTutorial` removed (replaces Step 6.7). - [x] **Step 10A.9 — Server: loan repayment.** Resolution reads the company loan balance, adds only the net payout and lowers the balance by the repayment (stored in the dispatch resolution, shown in summaries). `POST /api/loan/repay` (`src/game/loan.ts`) moves up to the balance from credits to the loan under the player lock; `409 insufficient-funds` with no changes if credits fall short. - [x] **Step 10A.10 — Server: goals.** `src/game/goals.ts`: `goalProgress` (own-ship deliveries, skill points spent, ships, hires, extra discoveries, deal, loan balance) → `goalStates` (empty during the tutorial) in player state; `POST /api/goals/:goalId/claim` adds the reward once (`goal-not-ready`, `goal-already-claimed`, `404 unknown-goal`). -- [ ] Step 10A.11 — Client: first launch goes straight to work +- [x] **Step 10A.11 — Client: first launch goes straight to work.** "Start your company" opens Contracts; on the first run the tutorial board shows the Halvorsen intro card and highlights the light contract as "Suggested". Step 8.4 test updated. - [ ] Step 10A.12 — Client: tutorial board shows the tradeoff - [ ] Step 10A.13 — Client: the deal - [ ] Step 10A.14 — Client: loan on HQ