Step 10A.11: send new players straight to the contracts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:22:56 -04:00
parent c2278c6ec8
commit 0488a84db4
6 changed files with 117 additions and 41 deletions

View File

@ -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.

View File

@ -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");
},
});

View File

@ -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 <p className="p-4 text-slate-400">Loading contracts…</p>;
if (board.isError) {
@ -36,6 +37,15 @@ export function TutorialBoard() {
return (
<div className="space-y-3">
{firstRun && (
<div
aria-label="Intro"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
>
<p className="font-semibold text-amber-200">Halvorsen Lunar Freight needs a pilot.</p>
<p className="text-slate-300">Their ship, your hands. Get the crates to the Moon.</p>
</div>
)}
<p className="text-sm text-slate-400">
Company runs in the loaner {loaner.name}. You keep a share of each payout.
</p>
@ -53,11 +63,23 @@ export function TutorialBoard() {
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
)}
<ul className="space-y-2">
{board.data.contracts.map((contract) => (
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
{board.data.contracts.map((contract, index) => {
// On the first run, suggest the lightest (fastest) contract.
const suggested = firstRun && index === 0;
return (
<li
key={contract.id}
data-suggested={suggested || undefined}
className={`rounded-lg border p-3 ${suggested ? "border-amber-400 ring-1 ring-amber-400" : "border-slate-800"}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
{suggested && (
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
Suggested
</span>
)}
</span>
<span className="font-semibold tabular-nums text-emerald-400">
{formatCredits(contract.payout)}
@ -88,7 +110,8 @@ export function TutorialBoard() {
</button>
</div>
</li>
))}
);
})}
</ul>
</div>
);

View File

@ -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 () => {

View File

@ -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,
};
}

View File

@ -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