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:
parent
c2278c6ec8
commit
0488a84db4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue