Compare commits

..

19 Commits

Author SHA1 Message Date
fireshaper 7e122dcee7 Step 10A.18: apply CR-001 plan amendments
CI / check (push) Successful in 2m49s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:38:38 -04:00
fireshaper f2f719415d Step 10A.17: list ready goals in the away summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:35:27 -04:00
fireshaper 28cb919c05 Step 10A.16: lock undiscovered locations and show reachability
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:31:35 -04:00
fireshaper afdd41635e Step 10A.15: show onboarding goals on HQ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:30:19 -04:00
fireshaper 6b46400f0a Step 10A.14: show the loan on HQ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:29:16 -04:00
fireshaper 53b637521c Step 10A.13: add the deal dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:27:01 -04:00
fireshaper c53910b6e3 Step 10A.12: show the tutorial tradeoff and scripted contracts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:25:52 -04:00
fireshaper 0488a84db4 Step 10A.11: send new players straight to the contracts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:22:56 -04:00
fireshaper c2278c6ec8 Step 10A.10: add onboarding goals to the server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:21:21 -04:00
fireshaper 45ab0f3339 Step 10A.9: apply loan repayment and add paying down the loan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:17:42 -04:00
fireshaper 63e52ea4aa Step 10A.8: add accepting the deal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:15:20 -04:00
fireshaper 64fb944e8d Step 10A.7: switch the server to tutorial flow v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:12:53 -04:00
fireshaper aea768bebf Step 10A.6: add loan, tutorial run and goal schema and routes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:04:57 -04:00
fireshaper b3ed6ef71d Step 10A.5: add onboarding goal evaluation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:59:25 -04:00
fireshaper 401914e76b Step 10A.4: add loan repayment to resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:57:08 -04:00
fireshaper dd0b26813f Step 10A.3: add tutorial board v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:54:44 -04:00
fireshaper 957c54cc2c Step 10A.2: add cargo handling time to travel time
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:50:15 -04:00
fireshaper bfda781601 Step 10A.1: add loaner ship, tutorial and loan config, goals
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:47:52 -04:00
fireshaper b1dde3696f CR-001: record change request and decisions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:42:06 -04:00
99 changed files with 4604 additions and 519 deletions

View File

@ -474,3 +474,127 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Taps are detected in the canvas's own pointer handling:** one pointer, moved less than 8 px, then released. Hit-testing is a pure function over the same scene the canvas draws, with a 22 px reach (about a fingertip); ship markers win close calls because they are drawn on top. Tapping empty space closes the panel. - **Taps are detected in the canvas's own pointer handling:** one pointer, moved less than 8 px, then released. Hit-testing is a pure function over the same scene the canvas draws, with a 22 px reach (about a fingertip); ship markers win close calls because they are drawn on top. Tapping empty space closes the panel.
- **Panels are React components over the canvas**, so they work like the rest of the UI. The location panel's board link uses `/contracts?bay=<id>`; the Contracts screen now preselects the bay from that parameter. - **Panels are React components over the canvas**, so they work like the rest of the UI. The location panel's board link uses `/contracts?bay=<id>`; the Contracts screen now preselects the bay from that parameter.
- **Component tests "tap" through the canvas test double**, which renders one button per tappable item from the real `buildScene` and calls the same `onSelect`. Hit-testing itself is unit-tested with screen coordinates. - **Component tests "tap" through the canvas test double**, which renders one button per tappable item from the real `buildScene` and calls the same `onSelect`. Hit-testing itself is unit-tested with screen coordinates.
## CR-001 — Faster Start (Phase 10A)
- **Change request CR-001 is stored in `memory-bank/change-requests/`** and inserted as Phase 10A, before Phase 11.
- **Decision numbering:** the plan's §0.5 already used 21–28, so the CR's new decisions 0.5.21–0.5.26 are recorded as **0.5.29–0.5.34** (Loaner ship, The deal, Handling time, Loan, Scripted tutorial contracts, Onboarding goals). Decisions 0.5.4 and 0.5.16 are replaced, 0.5.3 amended, and 0.5.21 amended (see below).
- **Error code `tutorial-active`** (kebab-case like every other code) implements the CR's `tutorial_active`.
- **Tutorial boards are keyed by run number as well as period and location.** With three contracts per board and accepted contracts hidden, a period-only key would empty the board after three runs and strand the player until the next period. Keying by tutorial runs completed gives every run a fresh board (needed anyway for the run-3 scripted contract); a new period also gives a fresh board, so a contract cannot expire with no replacement.
## Step 10A.1
- **Company Courier:** 6M km/h (twice the Courier), 0.3 h overhead (Courier 2 h), 20 t, same range, fuel and landing as the Courier, `purchasable: false`. Its `price` is kept only because the schema requires one; it is never sold.
- **`tutorialLoanerShipModelId`** is a new config field naming the loaner model; the CR's field list did not include it, but the sim needs to know which model the loaner is (it used "first Courier archetype" before).
- **Tutorial mass tiers 5 / 12 / 20 t** and **handling 0.02 game h per tonne**: a full 20 t Company Courier run Earth→Moon is about 0.76 game hours (~46 real seconds), a light one about 0.46 h (~28 s).
- **Starting credits 500** (a little over two Courier fuel loads from Earth at ~230 each). The replacement Step 2.5 test checks that the light tier's tutorial pay over the pre-deal runs, plus starting credits, covers three Courier Earth→Moon fuel bills.
- **`dealLoanAmount` 5,000** = Courier price + Earth bay lease, and a test keeps it equal to that sum.
- **Goal conditions are data** (a discriminated union: `own-ship-deliveries`, `skill-points-spent`, `ships-owned`, `pilots-hired`, `locations-discovered`, `loan-paid-off`), so Step 10A.5's evaluator needs no per-goal code. Rewards are first guesses (300–2,500).
- **`content.purchasableShipModels`** is what the Market lists; buying a non-purchasable model returns `404 not-found`, like an unknown model.
## Step 10A.2
- **`travelTimeGameHours` takes mass and config as arguments**, like the other sim functions, rather than reading a default bundle. Dispatch records use the contract's mass, or 0 for a reposition, so a reposition's time is exactly the old formula's. Arrival times already stored are unaffected (CR-001 §3).
- **Client trip-time predictions pass the contract's mass,** so the dispatch sheet and tutorial board match what the server will record.
## Step 10A.3
- **The tutorial board has no randomness:** location, run number and period fully determine it, so the world-seed input was dropped. Contract ids are `tutorial:<location>:<run>:<period>:<slot>` (run is 1-based), unique per run and period.
- **Slot order is light, medium, full;** a scripted contract keyed to the upcoming run (`runsCompleted + 1`) replaces the medium slot, keeps the medium mass, uses its own cargo, and pays `round(normal payout × tutorial share × pay multiplier)`.
- **Contracts carry an optional `scriptedId`** (sim `Contract` and API `contractSchema`) so the client can style scripted contracts and look up their flavor message.
- **`SimContent` includes `scriptedTutorialContracts`;** the content bundle already provides it.
- **Temporary:** the server passes `runsCompleted: 0` until Step 10A.7 adds the counter, so every server-generated board is a first-run board for now.
## Step 10A.4
- **The lender's cut is rounded down to whole credits:** repayment = `min(balance, floor(credits × share))`, or 0 when the balance is 0 or the credits after pilot pay are not positive. Credits are whole numbers everywhere, and rounding down favours the player by under one credit.
- **`Resolution.credits` is now the net amount** (after pilot pay and repayment); `loanRepayment` is reported separately for the server to apply and the summary to show.
- **Temporary:** the server resolves with `loanBalance: 0` until the balance column exists (Steps 10A.6 and 10A.9).
## Step 10A.5
- **Goals are evaluated against a small `GoalProgress` summary** (own-ship deliveries, skill points spent, ships owned, pilots hired, extra locations discovered, deal accepted, loan balance) rather than full player state. The server builds it from the database, and the evaluator stays independent of storage.
- **"Pay off your loan" requires the deal to have been accepted**, so a player who never had a loan does not complete it by default.
- **The evaluator takes the goal list as an argument** (not part of `SimContent`), since only goal code needs it.
## Step 10A.6
- **The repayment is stored in each dispatch's `resolution` JSON** (`loanRepayment`), next to the other applied effects, rather than as a separate column.
- **`purchasable` lives only in content.** Ship models are not stored in the database; the Market lists `content.purchasableShipModels`, and the server refuses non-purchasable models (Step 10A.1).
- **Tutorial boards are keyed by run** (`contract_boards.tutorial_run`, 0 for bay boards) as well as period and location (see CR-001 notes).
- **New state fields:** `company.loanBalance`; `tutorial.runsCompleted` and `tutorial.dealAvailable` (computed by `isDealAvailable`: tutorial on, deal not yet taken, runs ≥ `tutorialRunsBeforeDeal`, loaner at Earth, no run in flight); `goals` as `{ id, status }`, empty until Step 10A.10 fills it; summaries gain `loanRepayment` and `scriptedId`.
- **New routes:** `POST /api/deal/accept` returns the whole new `PlayerState`, since the deal changes almost everything; `POST /api/loan/repay` and `POST /api/goals/:goalId/claim` return `{ company }`.
- **Error codes:** `tutorial-active` (purchases during the tutorial), `deal-not-available`, `unknown-goal` (404), `goal-not-ready`, `goal-already-claimed`.
## Step 10A.7
- **The run counter increments when a tutorial dispatch resolves** (in the same locked resolution pass), not when it is dispatched, so "runs completed" means runs that landed.
- **Purchase blocking happens first** in buy-ship, lease-bay and hire-pilot (`requireTutorialOver`), so a tutorial player gets `tutorial-active` before any other check and nothing changes.
- **The Step 6.7 tests were removed one step early.** They bought a ship and leased an Earth bay during the tutorial, which this step forbids, so they could no longer run. CR-001 §6 replaces Step 6.7 with Step 10A.8, which also removes the old automatic rule.
- **Tests for purchases, races and pilot assignment now start from an owner** (`signUpOwner`), since only owners can buy. The "avatar can't be assigned during the tutorial" test puts a ship in the database directly.
- **`tutorialRunsBeforeDeal = 3` is tested by mocking content** in its own file, like the earlier `cannot-land` test.
## Step 10A.8
- **The deal hands over a content `courier` model at Earth and an Earth bay.** Constants `DEAL_SHIP_MODEL_ID` and `DEAL_BAY_LOCATION_ID` are server code, since they describe the story beat ("his old Courier and his Earth bay"), not tuning.
- **The endpoint returns the full new `PlayerState`** so the client can switch straight to the post-tutorial UI.
- **Exactly once:** the player lock serializes accepts, and `isDealAvailable` requires `deal_accepted` to be false, so a second or simultaneous accept fails with `deal-not-available` and changes nothing.
- **The old automatic tutorial end (`maybeEndTutorial`, Step 6.7) is deleted;** accepting the deal is now the only way the tutorial ends (decision 0.5.4 as replaced).
## Step 10A.9
- **Within one resolution pass the balance carries forward:** each arrival repays against what the previous one left, so several arrivals resolving together never repay more than the balance.
- **Repaying is checked against the amount actually taken:** `payment = min(amount, balance)`; if that exceeds credits the request fails with `insufficient-funds` and nothing changes. With a zero balance, a repay request succeeds and moves nothing.
- **The repay endpoint returns `{ company }`** (credits and balance) and runs inside `withPlayer` like every money change.
## Step 10A.10
- **"Own-ship deliveries" = resolved, non-tutorial dispatches that carried a contract** (repositions store a null contract and do not count).
- **Goal status is computed fresh from current state on every read and every claim;** nothing is cached, so a claim checks the same condition the player saw.
- **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.
## Step 10A.12
- **XP shown on a tutorial contract is sim `deliveryXp` at the current distance**, the same formula resolution uses, so the light-versus-heavy tradeoff the player sees is the real one.
- **Scripted contracts show their content name** ("Unmarked crates") in a distinct colour with a short "double pay, no questions asked" line; the summary looks up the flavor message by the summary's `scriptedId`.
## Step 10A.13
- **The deal dialog waits for the delivery summary:** the fourth run's landing also produces a "while you were away" entry, so the deal appears once that is dismissed, instead of two dialogs stacking.
- **The accept response is the new player state,** written straight into the query cache before navigating to HQ, so HQ shows the ship, bay and loan without another request.
- **The dialog has a single button** ("Shake on it"); there is no decline (CR-001 §7).
## Step 10A.14
- **During the tutorial HQ shows run progress toward the deal** ("Company runs: n of 4") in place of the old savings goal, so HQ still points at the next milestone.
- **The loan card shows while a balance remains** and disappears at zero; the "Pay off your loan" goal (Step 10A.15) marks the moment.
- **Paid-off progress is measured against `dealLoanAmount`.**
- **The UI blocks bad pay-down amounts before sending** (not a whole positive number, above the balance, above credits) with a short reason; the server still enforces the same rules.
## Step 10A.15
- **HQ shows the next three unclaimed goals in content order,** whether ready or not, so the player always sees what comes next; claimed goals drop out and the following goal takes their place.
- **Goal titles and rewards come from content;** the API only sends each goal's id and status.
## Step 10A.16
- **The map was already reachable during the tutorial** (its tab exists from the first screen); this step adds the lock and reachability.
- **Locked locations are labelled "🔒 Name"** on the canvas (they were dimmed already), replacing the earlier "Name?".
- **Reachability uses sim `checkRoute` (range and landing) from Earth at the current server time,** over `content.purchasableShipModels`, so it changes as planets move. When nothing can reach it the panel says so.
## Step 10A.17
- **The summary lists every goal currently ready to claim,** not only ones that became ready since the last visit. The server does not track when a goal became ready, and an unclaimed ready goal is worth pointing out on any return.
- **"Claim on HQ" acknowledges the deliveries and navigates to `/#goals`.**
- **Web tests wait up to 3 s for elements** (`asyncUtilTimeout`); the 1 s default occasionally ran out under a fully parallel run.
## Step 10A.18
- **Plan amendments are appended to each affected step** as "Amended by CR-001: …" lines, keeping the original text readable next to the change.
- **Known issue found in the regression (not fixed in this phase):** after the deal the player owns only a Courier (20 t), but bay boards draw contract mass uniformly from 5–400 t, so about three boards in four have nothing a Courier can carry. Before CR-001 the player chose their first ship, which hid this; now every player starts with a Courier and would usually have no flyable contract. Fixing it changes contract generation, so it is left for the user to decide.

View File

@ -0,0 +1,14 @@
CREATE TABLE "claimed_goals" (
"player_id" text NOT NULL,
"goal_id" text NOT NULL,
"claimed_at" bigint NOT NULL,
CONSTRAINT "claimed_goals_player_id_goal_id_pk" PRIMARY KEY("player_id","goal_id")
);
--> statement-breakpoint
DROP INDEX "contract_boards_unique_idx";--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "loan_balance" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "contract_boards" ADD COLUMN "tutorial_run" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "tutorial_runs_completed" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "deal_accepted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "claimed_goals" ADD CONSTRAINT "claimed_goals_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "contract_boards_unique_idx" ON "contract_boards" USING btree ("player_id","kind","location_id","period_index","tutorial_run");

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,13 @@
"when": 1790991751364, "when": 1790991751364,
"tag": "0001_auth", "tag": "0001_auth",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1791039630016,
"tag": "0002_cr001_faster_start",
"breakpoints": true
} }
] ]
} }

View File

@ -32,6 +32,10 @@ export const players = pgTable("players", {
tutorialActive: boolean("tutorial_active").notNull().default(true), tutorialActive: boolean("tutorial_active").notNull().default(true),
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */ /** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
loanerLocationId: text("loaner_location_id"), loanerLocationId: text("loaner_location_id"),
/** Tutorial runs resolved so far (CR-001). */
tutorialRunsCompleted: integer("tutorial_runs_completed").notNull().default(0),
/** The one-time deal has been accepted (decision 0.5.30). */
dealAccepted: boolean("deal_accepted").notNull().default(false),
createdAt: time("created_at").notNull(), createdAt: time("created_at").notNull(),
}); });
@ -44,6 +48,8 @@ export const companies = pgTable("companies", {
name: text("name").notNull(), name: text("name").notNull(),
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */ /** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
credits: bigint("credits", { mode: "number" }).notNull(), credits: bigint("credits", { mode: "number" }).notNull(),
/** Owed to the lender (decision 0.5.32). */
loanBalance: bigint("loan_balance", { mode: "number" }).notNull().default(0),
}); });
export const ships = pgTable( export const ships = pgTable(
@ -127,11 +133,19 @@ export const contractBoards = pgTable(
kind: boardKind("kind").notNull(), kind: boardKind("kind").notNull(),
locationId: text("location_id").notNull(), locationId: text("location_id").notNull(),
periodIndex: integer("period_index").notNull(), periodIndex: integer("period_index").notNull(),
/** Tutorial boards only: runs completed when generated (0 for bay boards). */
tutorialRun: integer("tutorial_run").notNull().default(0),
contracts: jsonb("contracts").$type<Contract[]>().notNull(), contracts: jsonb("contracts").$type<Contract[]>().notNull(),
createdAt: time("created_at").notNull(), createdAt: time("created_at").notNull(),
}, },
(t) => [ (t) => [
uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex), uniqueIndex("contract_boards_unique_idx").on(
t.playerId,
t.kind,
t.locationId,
t.periodIndex,
t.tutorialRun,
),
], ],
); );
@ -178,3 +192,16 @@ export const acceptedContracts = pgTable(
}, },
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })], (t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
); );
/** Onboarding goals whose reward has been claimed (decision 0.5.34). */
export const claimedGoals = pgTable(
"claimed_goals",
{
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
goalId: text("goal_id").notNull(),
claimedAt: time("claimed_at").notNull(),
},
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
);

View File

@ -31,7 +31,14 @@ export interface BoardView {
*/ */
export async function getOrCreateBoard( export async function getOrCreateBoard(
tx: Queryable, tx: Queryable,
key: { playerId: string; kind: "bay" | "tutorial"; locationId: string; periodIndex: number }, key: {
playerId: string;
kind: "bay" | "tutorial";
locationId: string;
periodIndex: number;
/** Tutorial boards: runs completed (CR-001). Bay boards: 0. */
tutorialRun?: number;
},
generate: () => Contract[] | Promise<Contract[]>, generate: () => Contract[] | Promise<Contract[]>,
now: RealTime, now: RealTime,
): Promise<Contract[]> { ): Promise<Contract[]> {
@ -40,13 +47,20 @@ export async function getOrCreateBoard(
eq(contractBoards.kind, key.kind), eq(contractBoards.kind, key.kind),
eq(contractBoards.locationId, key.locationId), eq(contractBoards.locationId, key.locationId),
eq(contractBoards.periodIndex, key.periodIndex), eq(contractBoards.periodIndex, key.periodIndex),
eq(contractBoards.tutorialRun, key.tutorialRun ?? 0),
); );
const [existing] = await tx.select().from(contractBoards).where(where); const [existing] = await tx.select().from(contractBoards).where(where);
if (existing) return existing.contracts; if (existing) return existing.contracts;
await tx await tx
.insert(contractBoards) .insert(contractBoards)
.values({ id: randomUUID(), ...key, contracts: await generate(), createdAt: now }) .values({
id: randomUUID(),
...key,
tutorialRun: key.tutorialRun ?? 0,
contracts: await generate(),
createdAt: now,
})
.onConflictDoNothing(); .onConflictDoNothing();
const [stored] = await tx.select().from(contractBoards).where(where); const [stored] = await tx.select().from(contractBoards).where(where);
return stored!.contracts; return stored!.contracts;
@ -74,7 +88,7 @@ export async function boardView(
}; };
} }
/** The tutorial board for the current period at the loaner's location (Step 6.3). */ /** The tutorial board for the current run and period at the loaner's location (Steps 6.3, 10A.7). */
export async function tutorialBoard( export async function tutorialBoard(
tx: Queryable, tx: Queryable,
playerId: string, playerId: string,
@ -86,14 +100,18 @@ export async function tutorialBoard(
} }
const loanerLocationId = player.loanerLocationId as "earth" | "moon"; const loanerLocationId = player.loanerLocationId as "earth" | "moon";
const periodIndex = boardPeriodIndex(now, content.config); const periodIndex = boardPeriodIndex(now, content.config);
const runsCompleted = player.tutorialRunsCompleted;
// One board per run (and per period), so every run gets three fresh contracts.
const contracts = await getOrCreateBoard( const contracts = await getOrCreateBoard(
tx, tx,
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex }, {
() => playerId,
generateTutorialBoard( kind: "tutorial",
{ loanerLocationId, periodIndex, worldSeed: player.worldSeed }, locationId: loanerLocationId,
content, periodIndex,
), tutorialRun: runsCompleted,
},
() => generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex }, content),
now, now,
); );
return boardView(tx, playerId, contracts, periodIndex); return boardView(tx, playerId, contracts, periodIndex);

View File

@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { RealTime } from "@deadhead/sim";
import { and, count, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, companies, dispatches, pilots, players, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { shipName } from "./names";
/** The ship model and bay location the deal hands over (decision 0.5.30). */
export const DEAL_SHIP_MODEL_ID = "courier";
export const DEAL_BAY_LOCATION_ID = "earth";
/**
* The deal can be accepted now (decision 0.5.30): tutorial on, enough runs
* completed, the loaner back at Earth, and no tutorial run in flight.
*/
export function isDealAvailable(
player: typeof players.$inferSelect,
tutorialRunInFlight: boolean,
): boolean {
return (
player.tutorialActive &&
!player.dealAccepted &&
player.tutorialRunsCompleted >= content.config.tutorialRunsBeforeDeal &&
player.loanerLocationId === "earth" &&
!tutorialRunInFlight
);
}
/**
* Accepts the deal (Step 10A.8): the loaner goes back to the company; the
* player gets the old Courier at Earth crewed by the avatar, the Earth bay, and
* the loan; the tutorial ends. Run inside the player lock, so two simultaneous
* accepts produce one deal.
*/
export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player) throw new Error(`Player ${playerId} not found`);
const [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (!isDealAvailable(player, running !== undefined)) {
throw gameError("deal-not-available", "There is no deal on the table right now.");
}
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
const shipId = randomUUID();
await tx.insert(ships).values({
id: shipId,
playerId,
modelId: DEAL_SHIP_MODEL_ID,
name: shipName(player.worldSeed, owned!.n),
locationId: DEAL_BAY_LOCATION_ID,
createdAt: now,
});
await tx.insert(bays).values({
id: randomUUID(),
playerId,
locationId: DEAL_BAY_LOCATION_ID,
createdAt: now,
});
await tx
.update(pilots)
.set({ assignedShipId: shipId, locationId: null, status: "idle" })
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
await tx
.update(companies)
.set({ loanBalance: content.config.dealLoanAmount })
.where(eq(companies.playerId, playerId));
await tx
.update(players)
.set({ tutorialActive: false, loanerLocationId: null, dealAccepted: true })
.where(eq(players.id, playerId));
}

View File

@ -23,6 +23,14 @@ export async function requirePlayer(tx: Queryable, playerId: string) {
return player; return player;
} }
/** Purchases are blocked while the tutorial runs (decision 0.5.16, CR-001). */
export async function requireTutorialOver(tx: Queryable, playerId: string, what: string) {
const player = await requirePlayer(tx, playerId);
if (player.tutorialActive) {
throw gameError("tutorial-active", `Finish the company runs before ${what}.`);
}
}
/** Deducts credits, or throws `insufficient-funds` without changing anything. */ /** Deducts credits, or throws `insufficient-funds` without changing anything. */
export async function spend(tx: Queryable, playerId: string, amount: number, what: string) { export async function spend(tx: Queryable, playerId: string, amount: number, what: string) {
const company = await requireCompany(tx, playerId); const company = await requireCompany(tx, playerId);
@ -42,6 +50,7 @@ export async function leaseBay(
locationId: string, locationId: string,
now: RealTime, now: RealTime,
): Promise<Bay> { ): Promise<Bay> {
await requireTutorialOver(tx, playerId, "leasing a bay");
const location = content.location(locationId); const location = content.location(locationId);
if (!location) throw gameError("not-found", `No location "${locationId}".`); if (!location) throw gameError("not-found", `No location "${locationId}".`);
@ -82,8 +91,9 @@ export async function buyShip(
modelId: string, modelId: string,
now: RealTime, now: RealTime,
): Promise<Ship> { ): Promise<Ship> {
await requireTutorialOver(tx, playerId, "buying a ship");
const model = content.shipModel(modelId); const model = content.shipModel(modelId);
if (!model) throw gameError("not-found", `No ship model "${modelId}".`); if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`);
await spend(tx, playerId, model.price, `a ${model.name}`); await spend(tx, playerId, model.price, `a ${model.name}`);
const player = await requirePlayer(tx, playerId); const player = await requirePlayer(tx, playerId);

View File

@ -0,0 +1,112 @@
import { content } from "@deadhead/content";
import type { Company, GoalState } from "@deadhead/schema";
import { completedGoals, type GoalProgress, type RealTime } from "@deadhead/sim";
import { and, count, eq, notInArray, sql, sum } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
claimedGoals,
companies,
discoveredLocations,
dispatches,
pilots,
ships,
} from "../db/schema";
import { gameError } from "../http/errors";
import { requireCompany, requirePlayer } from "./fleet";
const START_LOCATION_IDS = content.locations.filter((l) => l.startsDiscovered).map((l) => l.id);
/** The facts goals are checked against, read from the database (Step 10A.10). */
export async function goalProgress(tx: Queryable, playerId: string): Promise<GoalProgress> {
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const [deliveries] = await tx
.select({ n: count() })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "resolved"),
eq(dispatches.tutorial, false),
// Repositions store a null contract.
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
),
);
const [spent] = await tx
.select({ n: sum(pilots.skillPointsSpent) })
.from(pilots)
.where(eq(pilots.playerId, playerId));
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
const [hired] = await tx
.select({ n: count() })
.from(pilots)
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false)));
const [discovered] = await tx
.select({ n: count() })
.from(discoveredLocations)
.where(
and(
eq(discoveredLocations.playerId, playerId),
notInArray(discoveredLocations.locationId, START_LOCATION_IDS),
),
);
return {
ownShipDeliveries: deliveries!.n,
skillPointsSpent: Number(spent?.n ?? 0),
shipsOwned: owned!.n,
pilotsHired: hired!.n,
locationsDiscovered: discovered!.n,
dealAccepted: player.dealAccepted,
loanBalance: company.loanBalance,
};
}
async function claimedIds(tx: Queryable, playerId: string): Promise<Set<string>> {
const rows = await tx
.select({ goalId: claimedGoals.goalId })
.from(claimedGoals)
.where(eq(claimedGoals.playerId, playerId));
return new Set(rows.map((r) => r.goalId));
}
/** Every goal's status in content order; none while the tutorial runs (decision 0.5.34). */
export async function goalStates(tx: Queryable, playerId: string): Promise<GoalState[]> {
const player = await requirePlayer(tx, playerId);
if (player.tutorialActive) return [];
const complete = new Set(
completedGoals(content.onboardingGoals, await goalProgress(tx, playerId)),
);
const claimed = await claimedIds(tx, playerId);
return content.onboardingGoals.map((goal) => ({
id: goal.id,
status: claimed.has(goal.id) ? "claimed" : complete.has(goal.id) ? "ready" : "incomplete",
}));
}
/** Claims a completed goal's reward, once. Run inside the player lock. */
export async function claimGoal(
tx: Queryable,
playerId: string,
goalId: string,
now: RealTime,
): Promise<Company> {
const goal = content.onboardingGoal(goalId);
if (!goal) throw gameError("unknown-goal", `No goal "${goalId}".`);
const status = (await goalStates(tx, playerId)).find((g) => g.id === goalId)?.status;
if (status === "claimed")
throw gameError("goal-already-claimed", "You already claimed that reward.");
if (status !== "ready") throw gameError("goal-not-ready", `Not done yet: ${goal.title}.`);
await tx.insert(claimedGoals).values({ playerId, goalId, claimedAt: now });
const [company] = await tx
.update(companies)
.set({ credits: sql`${companies.credits} + ${goal.reward}` })
.where(eq(companies.playerId, playerId))
.returning();
return {
id: company!.id,
name: company!.name,
credits: company!.credits,
loanBalance: company!.loanBalance,
};
}

View File

@ -0,0 +1,33 @@
import type { Company } from "@deadhead/schema";
import { eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies } from "../db/schema";
import { gameError } from "../http/errors";
import { requireCompany } from "./fleet";
/**
* Pays the loan down by up to `amount` credits (decision 0.5.32): only the
* balance is taken, and paying more than the player has fails with no change.
* Run inside the player lock.
*/
export async function repayLoan(tx: Queryable, playerId: string, amount: number): Promise<Company> {
const company = await requireCompany(tx, playerId);
const payment = Math.min(amount, company.loanBalance);
if (payment > company.credits) {
throw gameError("insufficient-funds", `You only have ${company.credits} credits.`);
}
const [updated] = await tx
.update(companies)
.set({
credits: sql`${companies.credits} - ${payment}`,
loanBalance: sql`${companies.loanBalance} - ${payment}`,
})
.where(eq(companies.playerId, playerId))
.returning();
return {
id: updated!.id,
name: updated!.name,
credits: updated!.credits,
loanBalance: updated!.loanBalance,
};
}

View File

@ -6,7 +6,7 @@ import { and, count, eq } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { bays, pilots, ships } from "../db/schema"; import { bays, pilots, ships } from "../db/schema";
import { gameError } from "../http/errors"; import { gameError } from "../http/errors";
import { requirePlayer, spend } from "./fleet"; import { requirePlayer, requireTutorialOver, spend } from "./fleet";
import { toApiPilot } from "./mappers"; import { toApiPilot } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages"; import { VALIDATION_MESSAGES } from "./messages";
import { pilotName } from "./names"; import { pilotName } from "./names";
@ -44,6 +44,7 @@ export async function hirePilot(
locationId: string, locationId: string,
now: RealTime, now: RealTime,
): Promise<Pilot> { ): Promise<Pilot> {
await requireTutorialOver(tx, playerId, "hiring pilots");
if (!(await hasBayAt(tx, playerId, locationId))) { if (!(await hasBayAt(tx, playerId, locationId))) {
throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays."); throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays.");
} }

View File

@ -11,6 +11,8 @@ import {
players, players,
ships, ships,
} from "../db/schema"; } from "../db/schema";
import { isDealAvailable } from "./deal";
import { goalStates } from "./goals";
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
/** Reads everything the client needs about one player. */ /** Reads everything the client needs about one player. */
@ -49,7 +51,12 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
); );
return { return {
company: { id: company.id, name: company.name, credits: company.credits }, company: {
id: company.id,
name: company.name,
credits: company.credits,
loanBalance: company.loanBalance,
},
ships: shipRows.map((s) => ({ ships: shipRows.map((s) => ({
id: s.id, id: s.id,
modelId: s.modelId, modelId: s.modelId,
@ -69,6 +76,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
? { ? {
loanerLocationId: player.loanerLocationId as "earth" | "moon", loanerLocationId: player.loanerLocationId as "earth" | "moon",
runInFlight: active.some((d) => d.tutorial), runInFlight: active.some((d) => d.tutorial),
runsCompleted: player.tutorialRunsCompleted,
dealAvailable: isDealAvailable(
player,
active.some((d) => d.tutorial),
),
} }
: null, : null,
activeDispatches: active.map(toApiDispatch), activeDispatches: active.map(toApiDispatch),
@ -80,9 +92,12 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
destinationId: d.record.destinationId, destinationId: d.record.destinationId,
tutorial: d.tutorial, tutorial: d.tutorial,
credits: d.resolution?.credits ?? 0, credits: d.resolution?.credits ?? 0,
loanRepayment: d.resolution?.loanRepayment ?? 0,
pilotXp: d.resolution?.pilotXp ?? 0, pilotXp: d.resolution?.pilotXp ?? 0,
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null, discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
scriptedId: d.record.contract?.scriptedId ?? null,
resolvedAt: d.resolvedAt ?? d.arrivalAt, resolvedAt: d.resolvedAt ?? d.arrivalAt,
})), })),
goals: await goalStates(db, playerId),
}; };
} }

View File

@ -3,15 +3,14 @@ import { resolveDispatch, type RealTime } from "@deadhead/sim";
import { and, asc, eq, lte, sql } from "drizzle-orm"; import { and, asc, eq, lte, sql } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema"; import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema";
import { maybeEndTutorial } from "./tutorial";
/** /**
* Resolves every in-flight dispatch of a player that has arrived by `now`, in * Resolves every in-flight dispatch of a player that has arrived by `now`, in
* arrival order, applying credits, XP, ship/pilot location and discovery. Run * arrival order, applying credits (net of loan repayment), the repayment, XP,
* ship/pilot location and discovery. Run
* it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved * it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved
* exactly once: the status update is conditional, and effects are applied only * exactly once: the status update is conditional, and effects are applied only
* when it changed a row. Ends the tutorial if its last run just landed * when it changed a row. Returns how many dispatches were resolved.
* (Step 6.7). Returns how many dispatches were resolved.
*/ */
export async function resolveArrivedDispatches( export async function resolveArrivedDispatches(
tx: Queryable, tx: Queryable,
@ -36,6 +35,9 @@ export async function resolveArrivedDispatches(
.from(discoveredLocations) .from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId)); .where(eq(discoveredLocations.playerId, playerId));
const discoveredIds = new Set(discovered.map((d) => d.locationId)); const discoveredIds = new Set(discovered.map((d) => d.locationId));
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
// Carried forward so each arrival repays against the balance left by the previous one.
let loanBalance = company?.loanBalance ?? 0;
let resolvedCount = 0; let resolvedCount = 0;
for (const dispatch of due) { for (const dispatch of due) {
@ -43,7 +45,7 @@ export async function resolveArrivedDispatches(
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
const result = resolveDispatch( const result = resolveDispatch(
{ record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, now }, { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, now },
content, content,
); );
if (result.status !== "resolved") continue; if (result.status !== "resolved") continue;
@ -56,10 +58,14 @@ export async function resolveArrivedDispatches(
.returning({ id: dispatches.id }); .returning({ id: dispatches.id });
if (claimed.length === 0) continue; if (claimed.length === 0) continue;
resolvedCount++; resolvedCount++;
loanBalance -= resolution.loanRepayment;
await tx await tx
.update(companies) .update(companies)
.set({ credits: sql`${companies.credits} + ${resolution.credits}` }) .set({
credits: sql`${companies.credits} + ${resolution.credits}`,
loanBalance: sql`${companies.loanBalance} - ${resolution.loanRepayment}`,
})
.where(eq(companies.playerId, playerId)); .where(eq(companies.playerId, playerId));
await tx await tx
@ -81,7 +87,10 @@ export async function resolveArrivedDispatches(
if (dispatch.tutorial) { if (dispatch.tutorial) {
await tx await tx
.update(players) .update(players)
.set({ loanerLocationId: resolution.shipLocationId }) .set({
loanerLocationId: resolution.shipLocationId,
tutorialRunsCompleted: sql`${players.tutorialRunsCompleted} + 1`,
})
.where(eq(players.id, playerId)); .where(eq(players.id, playerId));
} }
if (resolution.discoversDestination) { if (resolution.discoversDestination) {
@ -92,6 +101,5 @@ export async function resolveArrivedDispatches(
discoveredIds.add(dispatch.record.destinationId); discoveredIds.add(dispatch.record.destinationId);
} }
} }
if (resolvedCount > 0) await maybeEndTutorial(tx, playerId);
return resolvedCount; return resolvedCount;
} }

View File

@ -1,49 +0,0 @@
import { and, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, dispatches, pilots, players, ships } from "../db/schema";
/** Where the avatar ends up when the tutorial ends (decision 0.5.4). */
export const TUTORIAL_END_LOCATION_ID = "earth";
/**
* Ends the tutorial once the player owns a ship, has an Earth bay, and has no
* tutorial run in flight (decision 0.5.4): the loaner is removed and the avatar
* becomes a normal, unassigned pilot at Earth. Returns whether it ended now.
*/
export async function maybeEndTutorial(tx: Queryable, playerId: string): Promise<boolean> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player?.tutorialActive) return false;
const [ship] = await tx
.select({ id: ships.id })
.from(ships)
.where(eq(ships.playerId, playerId))
.limit(1);
if (!ship) return false;
const [earthBay] = await tx
.select({ id: bays.id })
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, TUTORIAL_END_LOCATION_ID)));
if (!earthBay) return false;
const [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (running) return false;
await tx
.update(players)
.set({ tutorialActive: false, loanerLocationId: null })
.where(eq(players.id, playerId));
await tx
.update(pilots)
.set({ locationId: TUTORIAL_END_LOCATION_ID, assignedShipId: null, status: "idle" })
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
return true;
}

View File

@ -16,6 +16,7 @@ const NOT_FOUND_CODES: ReadonlySet<ErrorCode> = new Set([
"not-found", "not-found",
"unknown-contract", "unknown-contract",
"unknown-skill", "unknown-skill",
"unknown-goal",
]); ]);
/** A game-rule failure: 404 for unknown things, 409 for everything else. */ /** A game-rule failure: 404 for unknown things, 409 for everything else. */

View File

@ -4,6 +4,9 @@ import { routes } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim"; import { realToGame } from "@deadhead/sim";
import { fromNodeHeaders } from "better-auth/node"; import { fromNodeHeaders } from "better-auth/node";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { acceptDeal } from "../game/deal";
import { claimGoal } from "../game/goals";
import { repayLoan } from "../game/loan";
import { createPlayer } from "../game/new-player"; import { createPlayer } from "../game/new-player";
import { loadPlayerState } from "../game/player-state"; import { loadPlayerState } from "../game/player-state";
import { acknowledgeSummaries } from "../game/summaries"; import { acknowledgeSummaries } from "../game/summaries";
@ -49,4 +52,23 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now), acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now),
})), })),
); );
registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
await acceptDeal(tx, playerId, now);
return loadPlayerState(tx, playerId);
}),
);
registerRoute(app, routes.repayLoan, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
company: await repayLoan(tx, playerId, input.amount),
})),
);
registerRoute(app, routes.claimGoal, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
company: await claimGoal(tx, playerId, input.goalId, now),
})),
);
} }

View File

@ -1,13 +1,11 @@
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { withPlayerLock } from "../game/lock"; import { withPlayerLock } from "../game/lock";
import { resolveArrivedDispatches } from "../game/resolve"; import { resolveArrivedDispatches } from "../game/resolve";
import { maybeEndTutorial } from "../game/tutorial";
import type { AppContext } from "./context"; import type { AppContext } from "./context";
/** /**
* Runs a game action for a player: takes the player lock, applies any arrivals * Runs a game action for a player: takes the player lock, applies any arrivals
* that are due (so the action sees current state), runs `fn`, then ends the * that are due (so the action sees current state), then runs `fn`.
* tutorial if the action completed its conditions (Step 6.7).
*/ */
export function withPlayer<T>( export function withPlayer<T>(
ctx: AppContext, ctx: AppContext,
@ -17,8 +15,6 @@ export function withPlayer<T>(
const now = ctx.clock(); const now = ctx.clock();
return withPlayerLock(ctx.db, playerId, async (tx) => { return withPlayerLock(ctx.db, playerId, async (tx) => {
await resolveArrivedDispatches(tx, playerId, now); await resolveArrivedDispatches(tx, playerId, now);
const result = await fn(tx, now); return fn(tx, now);
await maybeEndTutorial(tx, playerId);
return result;
}); });
} }

View File

@ -3,19 +3,22 @@ import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { shipName } from "../src/game/names"; import { shipName } from "../src/game/names";
import { setCredits, useTestDatabase } from "./db"; import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game"; import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase(); const database = useTestDatabase();
const testApp = useTestApp(database); const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
describe("buy a ship", () => { describe("buy a ship", () => {
it("deducts the price and adds an idle, uncrewed ship at Earth", async () => { it("deducts the price and adds an idle, uncrewed ship at Earth", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const owner = await signUpOwner(app, database().db);
const price = content.shipModel("courier")!.price; const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price + 500); await setCredits(database().db, owner.playerId, price + 500);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" }); const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json()); const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({ expect(ship).toMatchObject({
@ -27,32 +30,54 @@ describe("buy a ship", () => {
}); });
expect(ship.name.length).toBeGreaterThan(0); expect(ship.name.length).toBeGreaterThan(0);
const state = await getState(app, guest); const state = await getState(app, owner);
expect(state.company.credits).toBe(500); expect(state.company.credits).toBe(500);
expect(state.ships.map((s) => s.id)).toEqual([ship.id]); expect(state.ships.map((s) => s.id)).toEqual([owner.shipId, ship.id]);
}); });
it("returns insufficient-funds and changes nothing without enough credits", async () => { it("returns insufficient-funds and changes nothing without enough credits", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const owner = await signUpOwner(app, database().db);
const price = content.shipModel("freighter")!.price; const price = content.shipModel("freighter")!.price;
await setCredits(database().db, guest.playerId, price - 1); await setCredits(database().db, owner.playerId, price - 1);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "freighter" }); const response = await call(app, owner, "POST", "/api/ships", { modelId: "freighter" });
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds"); expect(errorCode(response)).toBe("insufficient-funds");
const state = await getState(app, owner);
expect(state.company.credits).toBe(price - 1);
expect(state.ships).toHaveLength(1);
});
it("returns tutorial-active during the tutorial and changes nothing", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
const state = await getState(app, guest); const state = await getState(app, guest);
expect(state.company.credits).toBe(price - 1); expect(state.company.credits).toBe(1_000_000);
expect(state.ships).toHaveLength(0); expect(state.ships).toHaveLength(0);
}); });
it("refuses to sell the company loaner", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "company-courier" });
expect(response.statusCode).toBe(404);
});
it("rejects an unknown model", async () => { it("rejects an unknown model", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const owner = await signUpOwner(app, database().db);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "x-wing" }); const response = await call(app, owner, "POST", "/api/ships", { modelId: "x-wing" });
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-found"); expect(errorCode(response)).toBe("not-found");
}); });
}); });

View File

@ -1,7 +1,7 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { makeOwner, setCredits, useTestDatabase } from "./db"; import { makeOwner, setCredits, useTestDatabase } from "./db";
import { call, getState, putBoard, signUp, testContract, useTestApp } from "./game"; import { call, getState, putBoard, signUp, signUpOwner, testContract, useTestApp } from "./game";
const database = useTestDatabase(); const database = useTestDatabase();
const testApp = useTestApp(database); const testApp = useTestApp(database);
@ -9,7 +9,7 @@ const testApp = useTestApp(database);
describe("concurrency and money safety", () => { describe("concurrency and money safety", () => {
it("lets only one of two simultaneous purchases through when only one is affordable", async () => { it("lets only one of two simultaneous purchases through when only one is affordable", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const guest = await signUpOwner(app, database().db);
const price = content.shipModel("courier")!.price; const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price + price / 2); await setCredits(database().db, guest.playerId, price + price / 2);
@ -20,13 +20,14 @@ describe("concurrency and money safety", () => {
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest); const state = await getState(app, guest);
expect(state.ships).toHaveLength(1); // The owner already had one ship.
expect(state.ships).toHaveLength(2);
expect(state.company.credits).toBe(price / 2); expect(state.company.credits).toBe(price / 2);
}); });
it("never goes negative under many simultaneous purchases", async () => { it("never goes negative under many simultaneous purchases", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const guest = await signUpOwner(app, database().db);
const price = content.shipModel("courier")!.price; const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price * 3); await setCredits(database().db, guest.playerId, price * 3);
@ -37,7 +38,7 @@ describe("concurrency and money safety", () => {
); );
expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3); expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3);
const state = await getState(app, guest); const state = await getState(app, guest);
expect(state.ships).toHaveLength(3); expect(state.ships).toHaveLength(1 + 3);
expect(state.company.credits).toBe(0); expect(state.company.credits).toBe(0);
}); });
@ -63,7 +64,7 @@ describe("concurrency and money safety", () => {
it("leases one bay when the same lease is sent twice at once", async () => { it("leases one bay when the same lease is sent twice at once", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const guest = await signUpOwner(app, database().db);
await setCredits(database().db, guest.playerId, 100_000); await setCredits(database().db, guest.playerId, 100_000);
const responses = await Promise.all([ const responses = await Promise.all([
call(app, guest, "POST", "/api/bays", { locationId: "moon" }), call(app, guest, "POST", "/api/bays", { locationId: "moon" }),
@ -71,7 +72,7 @@ describe("concurrency and money safety", () => {
]); ]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest); const state = await getState(app, guest);
expect(state.bays).toHaveLength(1); expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice); expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice);
}); });
}); });

View File

@ -0,0 +1,111 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, playerStateSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { bays, companies, pilots, players, ships } from "../src/db/schema";
import { useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** Fast-forwards a new guest to "four runs done, loaner back at Earth". */
async function readyForDeal(guest: Guest) {
await database()
.db.update(players)
.set({
tutorialRunsCompleted: content.config.tutorialRunsBeforeDeal,
loanerLocationId: "earth",
})
.where(eq(players.id, guest.playerId));
}
const accept = (guest: Guest) => call(testApp().app, guest, "POST", "/api/deal/accept");
/** Everything the deal touches, read straight from the database. */
async function snapshot(guest: Guest) {
const { db } = database();
return {
player: (await db.select().from(players).where(eq(players.id, guest.playerId)))[0],
company: (await db.select().from(companies).where(eq(companies.playerId, guest.playerId)))[0],
ships: await db.select().from(ships).where(eq(ships.playerId, guest.playerId)),
bays: await db.select().from(bays).where(eq(bays.playerId, guest.playerId)),
pilots: await db.select().from(pilots).where(eq(pilots.playerId, guest.playerId)),
};
}
describe("accepting the deal", () => {
it("hands over a crewed Courier at Earth, an Earth bay and the loan, and ends the tutorial", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true);
const response = await accept(guest);
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
expect(state.tutorial).toBeNull();
expect(state.ships).toHaveLength(1);
const [ship] = state.ships;
expect(ship).toMatchObject({
modelId: "courier",
status: "idle",
locationId: "earth",
pilotId: guest.avatarId,
});
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
assignedShipId: ship!.id,
locationId: "earth",
});
expect(state.company.loanBalance).toBe(content.config.dealLoanAmount);
const after = await snapshot(guest);
expect(after.player).toMatchObject({
tutorialActive: false,
dealAccepted: true,
loanerLocationId: null,
});
});
it("fails with no changes when the deal is not available", async () => {
const guest = await signUp(testApp().app);
const before = await snapshot(guest);
const response = await accept(guest);
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("deal-not-available");
expect(await snapshot(guest)).toEqual(before);
});
it("fails while the loaner is at the Moon", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
await database()
.db.update(players)
.set({ loanerLocationId: "moon" })
.where(eq(players.id, guest.playerId));
expect((await accept(guest)).statusCode).toBe(409);
});
it("fails the second time", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
expect((await accept(guest)).statusCode).toBe(200);
const before = await snapshot(guest);
const again = await accept(guest);
expect(errorResponseSchema.parse(again.json()).error.code).toBe("deal-not-available");
expect(await snapshot(guest)).toEqual(before);
});
it("produces exactly one deal from two simultaneous accepts", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
const responses = await Promise.all([accept(guest), accept(guest)]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const after = await snapshot(guest);
expect(after.ships).toHaveLength(1);
expect(after.bays).toHaveLength(1);
expect(after.company!.loanBalance).toBe(content.config.dealLoanAmount);
});
});

View File

@ -1,108 +0,0 @@
import { content } from "@deadhead/content";
import {
contractBoardResponseSchema,
dispatchResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { pilots, players } from "../src/db/schema";
import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function buyShipAndEarthBay(guest: Guest) {
const { app } = testApp();
expect((await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).statusCode).toBe(
200,
);
expect((await call(app, guest, "POST", "/api/bays", { locationId: "earth" })).statusCode).toBe(
200,
);
}
describe("end of tutorial", () => {
it("ends when the player owns a ship and an Earth bay, moving the avatar to Earth unassigned", async () => {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
await setCredits(db, guest.playerId, 1_000_000);
// Put the loaner and avatar at the Moon.
await db
.update(players)
.set({ loanerLocationId: "moon" })
.where(eq(players.id, guest.playerId));
await db.update(pilots).set({ locationId: "moon" }).where(eq(pilots.id, guest.avatarId));
await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
expect((await getState(app, guest)).tutorial).not.toBeNull();
await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
const state = await getState(app, guest);
expect(state.tutorial).toBeNull();
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
status: "idle",
locationId: "earth",
assignedShipId: null,
});
const board = await call(app, guest, "GET", "/api/tutorial/board");
expect(board.statusCode).toBe(409);
expect(errorResponseSchema.parse(board.json()).error.code).toBe("not-in-tutorial");
});
it("waits for an in-flight tutorial run to land before ending", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const [contract] = contractBoardResponseSchema.parse(
(await call(app, guest, "GET", "/api/tutorial/board")).json(),
).contracts;
const { dispatch } = dispatchResponseSchema.parse(
(
await call(app, guest, "POST", "/api/dispatches", {
kind: "tutorial",
contractId: contract!.id,
})
).json(),
);
await buyShipAndEarthBay(guest);
expect((await getState(app, guest)).tutorial).toEqual({
loanerLocationId: "earth",
runInFlight: true,
});
clock.now = dispatch.arrivalAt;
const state = await getState(app, guest);
expect(state.tutorial).toBeNull();
// The run still paid out, and the avatar was brought back to Earth.
expect(state.company.credits).toBe(
1_000_000 -
content.shipModel("courier")!.price -
content.location("earth")!.bayLeasePrice +
contract!.payout,
);
expect(state.pilots[0]).toMatchObject({
locationId: "earth",
status: "idle",
assignedShipId: null,
});
});
it("does not end with only a ship, or only an Earth bay", async () => {
const { app } = testApp();
const shipOnly = await signUp(app);
await setCredits(database().db, shipOnly.playerId, 1_000_000);
await call(app, shipOnly, "POST", "/api/ships", { modelId: "courier" });
expect((await getState(app, shipOnly)).tutorial).not.toBeNull();
const bayOnly = await signUp(app);
await setCredits(database().db, bayOnly.playerId, 1_000_000);
await call(app, bayOnly, "POST", "/api/bays", { locationId: "earth" });
expect((await getState(app, bayOnly)).tutorial).not.toBeNull();
});
});

View File

@ -14,7 +14,7 @@ import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue"; import { createRecordingQueue } from "../src/jobs/queue";
import type { Database, Db } from "../src/db/client"; import type { Database, Db } from "../src/db/client";
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema"; import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
import { TEST_NOW } from "./db"; import { makeOwner, TEST_NOW } from "./db";
import { testConfig } from "./helpers"; import { testConfig } from "./helpers";
/** A clock tests can move. Starts at `TEST_NOW`. */ /** A clock tests can move. Starts at `TEST_NOW`. */
@ -198,3 +198,13 @@ export async function putBoard(db: Db, playerId: string, contracts: Contract[],
createdAt: now, createdAt: now,
}); });
} }
/** Signs up a guest and turns them into an owner: tutorial over, a Courier at Earth crewed by the avatar, an Earth bay. */
export async function signUpOwner(
app: TestApp["app"],
db: Db,
): Promise<Guest & { shipId: string }> {
const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
return { ...guest, shipId };
}

View File

@ -0,0 +1,107 @@
import { content } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { pilots, players } from "../src/db/schema";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUp,
signUpOwner,
useTestApp,
type Guest,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const claim = (guest: Guest, goalId: string) =>
call(testApp().app, guest, "POST", `/api/goals/${goalId}/claim`);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
/** An owner who has accepted the deal (so "pay off your loan" can apply). */
async function dealOwner() {
const owner = await signUpOwner(testApp().app, database().db);
await database()
.db.update(players)
.set({ dealAccepted: true })
.where(eq(players.id, owner.playerId));
return owner;
}
const status = async (guest: Guest, goalId: string) =>
(await getState(testApp().app, guest)).goals.find((g) => g.id === goalId)?.status;
describe("onboarding goals", () => {
it("are absent from state during the tutorial", async () => {
const guest = await signUp(testApp().app);
expect((await getState(testApp().app, guest)).goals).toEqual([]);
});
it("are listed in content order once the tutorial is over", async () => {
const owner = await dealOwner();
const goals = (await getState(testApp().app, owner)).goals;
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
// Paid-off loan: the test owner has no balance.
expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready");
expect(goals.find((g) => g.id === "first-hire")?.status).toBe("incomplete");
});
it("becomes ready when its condition is met: a delivery with the player's own ship", async () => {
const { clock } = testApp();
const owner = await dealOwner();
expect(await status(owner, "first-own-delivery")).toBe("incomplete");
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
});
clock.now = record.arrivalAt;
expect(await status(owner, "first-own-delivery")).toBe("ready");
});
it("counts skill points spent", async () => {
const owner = await dealOwner();
await database()
.db.update(pilots)
.set({ skillPointsSpent: 1 })
.where(eq(pilots.id, owner.avatarId));
expect(await status(owner, "first-skill-point")).toBe("ready");
});
it("claiming a completed goal adds its reward once", async () => {
const owner = await dealOwner();
await setCredits(database().db, owner.playerId, 100);
const reward = content.onboardingGoal("loan-paid-off")!.reward;
const response = await claim(owner, "loan-paid-off");
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(100 + reward);
expect(await status(owner, "loan-paid-off")).toBe("claimed");
const again = await claim(owner, "loan-paid-off");
expect(again.statusCode).toBe(409);
expect(errorCode(again)).toBe("goal-already-claimed");
expect((await getState(testApp().app, owner)).company.credits).toBe(100 + reward);
});
it("refuses an incomplete goal and an unknown one", async () => {
const owner = await dealOwner();
await setCredits(database().db, owner.playerId, 100);
const response = await claim(owner, "first-hire");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("goal-not-ready");
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);
expect((await getState(testApp().app, owner)).company.credits).toBe(100);
});
it("refuses claims during the tutorial", async () => {
const guest = await signUp(testApp().app);
expect(errorCode(await claim(guest, "loan-paid-off"))).toBe("goal-not-ready");
});
});

View File

@ -32,7 +32,7 @@ describe("guest accounts", () => {
const body = createGuestResponseSchema.parse(response.json()); const body = createGuestResponseSchema.parse(response.json());
expect(body.state.company.credits).toBe(content.config.startingCredits); expect(body.state.company.credits).toBe(content.config.startingCredits);
expect(body.state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: false }); expect(body.state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: false });
}); });
it("returns the new player's state for that session", async () => { it("returns the new player's state for that session", async () => {

View File

@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema"; import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db"; import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game"; import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase(); const database = useTestDatabase();
const testApp = useTestApp(database); const testApp = useTestApp(database);
@ -11,53 +11,64 @@ const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code; errorResponseSchema.parse(response.json()).error.code;
describe("lease a bay", () => { describe("lease a bay", () => {
it("leases a bay at Earth and deducts the price", async () => { it("leases a bay at the Moon and deducts the price", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const owner = await signUpOwner(app, database().db);
const price = content.location("earth")!.bayLeasePrice; const price = content.location("moon")!.bayLeasePrice;
await setCredits(database().db, guest.playerId, price + 100); await setCredits(database().db, owner.playerId, price + 100);
const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" }); const response = await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const { bay } = bayResponseSchema.parse(response.json()); const { bay } = bayResponseSchema.parse(response.json());
expect(bay.locationId).toBe("earth"); expect(bay.locationId).toBe("moon");
const state = await getState(app, guest); const state = await getState(app, owner);
expect(state.company.credits).toBe(100); expect(state.company.credits).toBe(100);
expect(state.bays).toEqual([bay]); expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
}); });
it("refuses an undiscovered location", async () => { it("refuses an undiscovered location", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const owner = await signUpOwner(app, database().db);
await setCredits(database().db, guest.playerId, 1_000_000); await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/bays", { locationId: "mars" }); const response = await call(app, owner, "POST", "/api/bays", { locationId: "mars" });
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("location-undiscovered"); expect(errorCode(response)).toBe("location-undiscovered");
expect((await getState(app, guest)).company.credits).toBe(1_000_000); expect((await getState(app, owner)).company.credits).toBe(1_000_000);
}); });
it("refuses a second bay at the same location", async () => { it("refuses a second bay at the same location", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const owner = await signUpOwner(app, database().db);
await setCredits(database().db, guest.playerId, 1_000_000); await setCredits(database().db, owner.playerId, 1_000_000);
await call(app, guest, "POST", "/api/bays", { locationId: "moon" }); const response = await call(app, owner, "POST", "/api/bays", { locationId: "earth" });
const response = await call(app, guest, "POST", "/api/bays", { locationId: "moon" });
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("bay-already-leased"); expect(errorCode(response)).toBe("bay-already-leased");
const state = await getState(app, guest); const state = await getState(app, owner);
expect(state.bays).toHaveLength(1); expect(state.bays).toHaveLength(1);
expect(state.company.credits).toBe(1_000_000 - content.location("moon")!.bayLeasePrice); expect(state.company.credits).toBe(1_000_000);
}); });
it("refuses when the player cannot afford it, and unknown locations", async () => { it("refuses when the player cannot afford it, and unknown locations", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const owner = await signUpOwner(app, database().db);
await setCredits(database().db, guest.playerId, 0); await setCredits(database().db, owner.playerId, 0);
expect(errorCode(await call(app, guest, "POST", "/api/bays", { locationId: "earth" }))).toBe( expect(errorCode(await call(app, owner, "POST", "/api/bays", { locationId: "moon" }))).toBe(
"insufficient-funds", "insufficient-funds",
); );
const unknown = await call(app, guest, "POST", "/api/bays", { locationId: "pluto" }); const unknown = await call(app, owner, "POST", "/api/bays", { locationId: "pluto" });
expect(unknown.statusCode).toBe(404); expect(unknown.statusCode).toBe(404);
}); });
it("returns tutorial-active during the tutorial and changes nothing", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
const state = await getState(app, guest);
expect(state.bays).toEqual([]);
expect(state.company.credits).toBe(1_000_000);
});
}); });

View File

@ -0,0 +1,131 @@
import { content } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { companies } from "../src/db/schema";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUpOwner,
testContract,
useTestApp,
type Guest,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function setLoan(guest: Guest, loanBalance: number) {
await database()
.db.update(companies)
.set({ loanBalance })
.where(eq(companies.playerId, guest.playerId));
}
const share = content.config.loanRepaymentShare;
describe("loan repayment on delivery", () => {
it("adds the net to credits, lowers the balance by the repayment, and records it", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1000);
await setLoan(owner, 5000);
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
// The avatar flew it, so there is no pilot pay.
const repayment = Math.floor(2000 * share);
expect(state.company).toMatchObject({
credits: 1000 + 2000 - repayment,
loanBalance: 5000 - repayment,
});
expect(state.summaries[0]).toMatchObject({
credits: 2000 - repayment,
loanRepayment: repayment,
});
});
it("repays only what is owed when the balance is small", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 0);
await setLoan(owner, 50);
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
expect(state.company).toMatchObject({ credits: 1950, loanBalance: 0 });
});
it("puts the full amount into credits when there is no balance", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 0);
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
expect(state.company).toMatchObject({ credits: 2000, loanBalance: 0 });
expect(state.summaries[0]!.loanRepayment).toBe(0);
});
});
describe("paying down the loan", () => {
const repay = (guest: Guest, amount: number) =>
call(testApp().app, guest, "POST", "/api/loan/repay", { amount });
it("moves the amount from credits to the loan", async () => {
const owner = await signUpOwner(testApp().app, database().db);
await setCredits(database().db, owner.playerId, 3000);
await setLoan(owner, 5000);
const response = await repay(owner, 1200);
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company).toMatchObject({
credits: 1800,
loanBalance: 3800,
});
});
it("takes only the balance when asked to repay more", async () => {
const owner = await signUpOwner(testApp().app, database().db);
await setCredits(database().db, owner.playerId, 3000);
await setLoan(owner, 400);
const { company } = companyResponseSchema.parse((await repay(owner, 2500)).json());
expect(company).toMatchObject({ credits: 2600, loanBalance: 0 });
});
it("fails with no changes when repaying more than the player has", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 300);
await setLoan(owner, 5000);
const response = await repay(owner, 1000);
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds");
expect((await getState(app, owner)).company).toMatchObject({ credits: 300, loanBalance: 5000 });
});
it("rejects a non-positive amount", async () => {
const owner = await signUpOwner(testApp().app, database().db);
expect((await repay(owner, 0)).statusCode).toBe(400);
});
});

View File

@ -35,6 +35,7 @@ describe("database migrations", () => {
"accepted_contracts", "accepted_contracts",
"account", "account",
"bays", "bays",
"claimed_goals",
"companies", "companies",
"contract_boards", "contract_boards",
"discovered_locations", "discovered_locations",

View File

@ -134,12 +134,18 @@ describe("assigning pilots", () => {
it("refuses to assign the avatar during the tutorial", async () => { it("refuses to assign the avatar during the tutorial", async () => {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 100_000); // Ships cannot be bought during the tutorial, so put one at Earth directly.
const ship = (await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).json() as { const shipId = randomUUID();
ship: { id: string }; await database().db.insert(ships).values({
}; id: shipId,
playerId: guest.playerId,
modelId: "courier",
name: "Gift Courier",
locationId: "earth",
createdAt: TEST_NOW,
});
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, { const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, {
shipId: ship.ship.id, shipId,
}); });
expect(errorCode(response)).toBe("avatar-in-tutorial"); expect(errorCode(response)).toBe("avatar-in-tutorial");
}); });

View File

@ -42,14 +42,19 @@ describe("tutorial dispatch", () => {
expectedPayout: contract!.payout, expectedPayout: contract!.payout,
}); });
const travelMs = const travelMs =
(travelTimeGameHours(content.shipModel("courier")!, dispatch.distanceMillionKm) * (travelTimeGameHours(
content.shipModel("company-courier")!,
dispatch.distanceMillionKm,
contract!.massTonnes,
content.config,
) *
MS_PER_HOUR) / MS_PER_HOUR) /
content.config.timeCompression; content.config.timeCompression;
expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs)); expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs));
const state = await getState(app, guest); const state = await getState(app, guest);
expect(state.company.credits).toBe(content.config.startingCredits); expect(state.company.credits).toBe(content.config.startingCredits);
expect(state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: true }); expect(state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: true });
expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null }); expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null });
expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]); expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]);
expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id); expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id);
@ -79,7 +84,7 @@ describe("tutorial dispatch", () => {
expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout); expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout);
expect(state.pilots[0]!.xp).toBeGreaterThan(0); expect(state.pilots[0]!.xp).toBeGreaterThan(0);
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" }); expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" });
expect(state.tutorial).toEqual({ loanerLocationId: "moon", runInFlight: false }); expect(state.tutorial).toMatchObject({ loanerLocationId: "moon", runInFlight: false });
const next = await tutorialBoard(guest); const next = await tutorialBoard(guest);
expect(next.length).toBeGreaterThan(0); expect(next.length).toBeGreaterThan(0);

View File

@ -0,0 +1,31 @@
import {
contractBoardResponseSchema,
dispatchResponseSchema,
type PlayerState,
} from "@deadhead/schema";
import { call, getState, type Guest, type TestApp } from "./game";
/** The tutorial board the player sees now. */
export async function tutorialBoard(app: TestApp["app"], guest: Guest) {
const response = await call(app, guest, "GET", "/api/tutorial/board");
if (response.statusCode !== 200) throw new Error(`board failed: ${response.body}`);
return contractBoardResponseSchema.parse(response.json()).contracts;
}
/** Flies one tutorial run (the given slot, light by default) and lets it land. */
export async function flyTutorialRun(
testApp: TestApp,
guest: Guest,
slot = 0,
): Promise<PlayerState> {
const { app, clock } = testApp;
const contract = (await tutorialBoard(app, guest))[slot]!;
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "tutorial",
contractId: contract.id,
});
if (response.statusCode !== 200) throw new Error(`dispatch failed: ${response.body}`);
const { dispatch } = dispatchResponseSchema.parse(response.json());
clock.now = dispatch.arrivalAt;
return getState(app, guest);
}

View File

@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { useTestDatabase } from "./db";
import { getState, signUp, useTestApp } from "./game";
import { flyTutorialRun } from "./tutorial-flow";
// The deal after three runs instead of four: the loaner is then at the Moon.
vi.mock("@deadhead/content", async (importOriginal) => {
const original = await importOriginal<typeof import("@deadhead/content")>();
return {
...original,
content: {
...original.content,
config: { ...original.content.config, tutorialRunsBeforeDeal: 3 },
},
};
});
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("deal availability with tutorialRunsBeforeDeal = 3", () => {
it("waits until a fourth run returns the loaner to Earth", async () => {
const guest = await signUp(testApp().app);
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
expect((await getState(testApp().app, guest)).tutorial).toMatchObject({
runsCompleted: 3,
loanerLocationId: "moon",
dealAvailable: false,
});
const state = await flyTutorialRun(testApp(), guest);
expect(state.tutorial).toMatchObject({
runsCompleted: 4,
loanerLocationId: "earth",
dealAvailable: true,
});
});
});

View File

@ -0,0 +1,80 @@
import { content } from "@deadhead/content";
import { errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game";
import { flyTutorialRun, tutorialBoard } from "./tutorial-flow";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("tutorial flow v2", () => {
it("starts a guest with the starting cushion, the Company Courier loaner at Earth and no runs", async () => {
const { app } = testApp();
const guest = await signUp(app);
const state = await getState(app, guest);
expect(state.company).toMatchObject({
credits: content.config.startingCredits,
loanBalance: 0,
});
expect(state.tutorial).toEqual({
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 0,
dealAvailable: false,
});
expect(state.pilots[0]).toMatchObject({ isAvatar: true, locationId: "earth" });
expect(state.goals).toEqual([]);
});
it("counts each resolved run and gives every run a fresh three-contract board", async () => {
const guest = await signUp(testApp().app);
const first = await tutorialBoard(testApp().app, guest);
expect(first).toHaveLength(3);
const state = await flyTutorialRun(testApp(), guest);
expect(state.tutorial).toMatchObject({ runsCompleted: 1, loanerLocationId: "moon" });
const second = await tutorialBoard(testApp().app, guest);
expect(second).toHaveLength(3);
for (const c of second) expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
expect(second.map((c) => c.id)).not.toEqual(first.map((c) => c.id));
});
it("offers Unmarked crates on the third run's board", async () => {
const guest = await signUp(testApp().app);
await flyTutorialRun(testApp(), guest);
await flyTutorialRun(testApp(), guest);
const third = await tutorialBoard(testApp().app, guest);
expect(third.find((c) => c.scriptedId === "unmarked-crates")).toBeDefined();
});
it("makes the deal available after four runs ending at Earth, not after three", async () => {
const guest = await signUp(testApp().app);
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
const afterThree = await getState(testApp().app, guest);
expect(afterThree.tutorial).toMatchObject({
runsCompleted: 3,
loanerLocationId: "moon",
dealAvailable: false,
});
const afterFour = await flyTutorialRun(testApp(), guest);
expect(afterFour.tutorial).toMatchObject({
runsCompleted: 4,
loanerLocationId: "earth",
runInFlight: false,
dealAvailable: true,
});
});
it("blocks hiring pilots during the tutorial", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/pilots", { locationId: "earth" });
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("tutorial-active");
expect((await getState(app, guest)).pilots).toHaveLength(1);
});
});

View File

@ -3,6 +3,7 @@ import { Route, Routes } from "react-router";
import { useApi } from "./api/context"; import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock"; import { startClockTicker, syncClock } from "./clock/clock";
import { AwaySummary } from "./components/AwaySummary"; import { AwaySummary } from "./components/AwaySummary";
import { DealModal } from "./components/DealModal";
import { formatCredits } from "./format"; import { formatCredits } from "./format";
import { Layout } from "./layout/Layout"; import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes"; import { SCREENS } from "./routes";
@ -101,6 +102,7 @@ export function App() {
</Route> </Route>
</Routes> </Routes>
<AwaySummary /> <AwaySummary />
<DealModal />
</> </>
); );
} }

View File

@ -1,4 +1,6 @@
import { content } from "@deadhead/content";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { useGameAction, useLoadedPlayer } from "../state/actions"; import { useGameAction, useLoadedPlayer } from "../state/actions";
@ -9,7 +11,13 @@ export function AwaySummary() {
// Hide as soon as the player dismisses; the refetch clears the list for real. // Hide as soon as the player dismisses; the refetch clears the list for real.
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set()); const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
const navigate = useNavigate();
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId)); const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
// Goals waiting to be claimed (Step 10A.17).
const readyGoals = state.goals
.filter((g) => g.status === "ready")
.map((g) => content.onboardingGoal(g.id))
.filter((g) => g !== undefined);
if (summaries.length === 0) return null; if (summaries.length === 0) return null;
const shipName = (shipId: string, tutorial: boolean) => const shipName = (shipId: string, tutorial: boolean) =>
@ -46,6 +54,19 @@ export function AwaySummary() {
</span> </span>
<span>+{s.pilotXp} XP</span> <span>+{s.pilotXp} XP</span>
</div> </div>
{s.loanRepayment > 0 && (
<div className="mt-1 text-slate-400">
{formatCredits(s.loanRepayment)} went to the lender
</div>
)}
{s.scriptedId && (
<p className="mt-1 italic text-fuchsia-200">
{
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
?.flavorMessage
}
</p>
)}
{s.discoveredLocationId && ( {s.discoveredLocationId && (
<div className="mt-1 text-amber-300"> <div className="mt-1 text-amber-300">
Discovered {locationName(s.discoveredLocationId)}! Discovered {locationName(s.discoveredLocationId)}!
@ -54,6 +75,28 @@ export function AwaySummary() {
</li> </li>
))} ))}
</ul> </ul>
{readyGoals.length > 0 && (
<div className="mt-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<h3 className="font-semibold text-emerald-300">Goals ready to claim</h3>
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
{readyGoals.map((goal) => (
<li key={goal.id}>
{goal.title} <span className="text-slate-400">+{formatCredits(goal.reward)}</span>
</li>
))}
</ul>
<button
type="button"
onClick={() => {
dismiss();
void navigate({ pathname: "/", hash: "#goals" });
}}
className="mt-2 font-medium text-emerald-300 underline"
>
Claim on HQ
</button>
</div>
)}
<button <button
type="button" type="button"
onClick={dismiss} onClick={dismiss}

View File

@ -0,0 +1,75 @@
import { content } from "@deadhead/content";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { formatCredits } from "../format";
import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
/**
* The one-time deal (decision 0.5.30): shown when the server says it is
* available, after any delivery summary has been dismissed. There is no
* decline option.
*/
export function DealModal() {
const state = useLoadedPlayer();
const api = useApi();
const queryClient = useQueryClient();
const navigate = useNavigate();
const accept = useMutation({
mutationFn: () => api.call("acceptDeal", {}),
onSuccess: (newState) => {
queryClient.setQueryData(PLAYER_STATE_KEY, newState);
void navigate("/");
},
});
if (!state.tutorial?.dealAvailable || state.summaries.length > 0) return null;
const { dealLoanAmount, loanRepaymentShare } = content.config;
const courier = content.shipModel("courier")!;
return (
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="deal-title"
className="w-full max-w-md rounded-xl border border-amber-500/50 bg-slate-900 p-4"
>
<h2 id="deal-title" className="text-lg font-semibold text-amber-300">
An offer from Halvorsen
</h2>
<p className="mt-2 text-sm text-slate-300">
Old Halvorsen is retiring. He&apos;ll sell you his Courier and his Earth bay. Pay him back
out of every job.
</p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
<dt className="text-slate-400">You get</dt>
<dd>A {courier.name} and a bay at Earth</dd>
<dt className="text-slate-400">Loan</dt>
<dd aria-label="Loan amount" className="tabular-nums">
{formatCredits(dealLoanAmount)}
</dd>
<dt className="text-slate-400">Repayment</dt>
<dd aria-label="Repayment share">
{Math.round(loanRepaymentShare * 100)}% of every payout until it&apos;s paid off
</dd>
</dl>
{accept.isError && (
<p role="alert" className="mt-3 text-sm text-red-400">
{errorMessage(accept.error)}
</p>
)}
<button
type="button"
onClick={() => accept.mutate()}
disabled={accept.isPending}
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-60"
>
Shake on it
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { content } from "@deadhead/content";
import { formatCredits } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** How many unclaimed goals HQ shows at once (Step 10A.15). */
export const GOALS_SHOWN = 3;
/** The next few onboarding goals, with rewards; ready ones can be claimed. */
export function GoalsCard() {
const state = useLoadedPlayer();
const claim = useGameAction("claimGoal");
const next = state.goals.filter((g) => g.status !== "claimed").slice(0, GOALS_SHOWN);
if (next.length === 0) return null;
return (
<section
id="goals"
aria-labelledby="goals-title"
className="rounded-lg border border-slate-800 p-3"
>
<h2 id="goals-title" className="font-semibold">
Goals
</h2>
<ul className="mt-2 space-y-2">
{next.map((goal) => {
const details = content.onboardingGoal(goal.id);
if (!details) return null;
const ready = goal.status === "ready";
return (
<li key={goal.id} className="flex items-center justify-between gap-2 text-sm">
<span className={ready ? "text-emerald-300" : "text-slate-300"}>
{details.title}
<span className="ml-2 text-xs text-slate-400">
+{formatCredits(details.reward)}
</span>
</span>
{ready && (
<button
type="button"
disabled={claim.isPending}
onClick={() => claim.mutate({ goalId: goal.id })}
className="shrink-0 rounded-md bg-emerald-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
>
Claim
</button>
)}
</li>
);
})}
</ul>
{claim.isError && (
<p role="alert" className="mt-2 text-xs text-red-400">
{errorMessage(claim.error)}
</p>
)}
</section>
);
}

View File

@ -0,0 +1,83 @@
import { content } from "@deadhead/content";
import { useState } from "react";
import { formatCredits } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** Why a pay-down amount cannot be sent, or null if it can. */
export function payDownProblem(amount: number, credits: number, balance: number): string | null {
if (!Number.isInteger(amount) || amount <= 0) return "Enter a whole number of credits.";
if (amount > balance) return `You only owe ${formatCredits(balance)}.`;
if (amount > credits) return `You only have ${formatCredits(credits)}.`;
return null;
}
/** The deal's loan on HQ: balance, paid-off progress and paying it down (Step 10A.14). */
export function LoanCard() {
const state = useLoadedPlayer();
const repay = useGameAction("repayLoan");
const [input, setInput] = useState("");
const { credits, loanBalance } = state.company;
const total = content.config.dealLoanAmount;
const paidOff = Math.max(0, Math.min(100, Math.floor(((total - loanBalance) / total) * 100)));
const amount = Number(input);
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);
return (
<div className="rounded-lg border border-slate-700 p-3">
<div className="flex items-baseline justify-between gap-2">
<h2 className="font-semibold">Loan from Halvorsen</h2>
<span aria-label="Loan balance" className="tabular-nums">
{formatCredits(loanBalance)} left
</span>
</div>
<p className="mt-1 text-xs text-slate-400">
{Math.round(content.config.loanRepaymentShare * 100)}% of every payout goes to the lender
until it&apos;s paid off.
</p>
<div
role="progressbar"
aria-label="Loan paid off"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={paidOff}
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-emerald-400" style={{ width: `${paidOff}%` }} />
</div>
<p className="mt-1 text-right text-xs text-slate-400">{paidOff}% paid off</p>
<form
className="mt-2 flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
if (input === "" || problem) return;
repay.mutate({ amount }, { onSuccess: () => setInput("") });
}}
>
<input
type="number"
inputMode="numeric"
min={1}
aria-label="Pay down amount"
placeholder="Amount"
value={input}
onChange={(e) => setInput(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
/>
<button
type="submit"
disabled={input === "" || problem !== null || repay.isPending}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-40"
>
Pay down
</button>
</form>
{problem && <p className="mt-1 text-xs text-red-400">{problem}</p>}
{repay.isError && (
<p role="alert" className="mt-1 text-xs text-red-400">
{errorMessage(repay.error)}
</p>
)}
</div>
);
}

View File

@ -24,8 +24,19 @@ export function distanceAt(originId: string, destinationId: string, now: number)
); );
} }
/** Trip duration in real milliseconds for a ship departing at `now`. */ /** Trip duration in real milliseconds for a ship carrying `massTonnes`, departing at `now`. */
export function tripRealMs(model: ShipModel, originId: string, destinationId: string, now: number) { export function tripRealMs(
const hours = travelTimeGameHours(model, distanceAt(originId, destinationId, now)); model: ShipModel,
originId: string,
destinationId: string,
massTonnes: number,
now: number,
) {
const hours = travelTimeGameHours(
model,
distanceAt(originId, destinationId, now),
massTonnes,
content.config,
);
return (hours * MS_PER_HOUR) / content.config.timeCompression; return (hours * MS_PER_HOUR) / content.config.timeCompression;
} }

View File

@ -78,7 +78,8 @@ export function MapCanvas({
labels.set(location.id, label); labels.set(location.id, label);
app.stage.addChild(label); app.stage.addChild(label);
} }
label.text = location.discovered ? location.name : `${location.name}?`; // Undiscovered locations are locked until a delivery reaches them (Step 10A.16).
label.text = location.discovered ? location.name : `🔒 ${location.name}`;
label.style.fill = color; label.style.fill = color;
label.position.set(p.x + 8, p.y - 7); label.position.set(p.x + 8, p.y - 7);

16
apps/web/src/map/reach.ts Normal file
View File

@ -0,0 +1,16 @@
import { content, type ShipModel } from "@deadhead/content";
import { checkRoute, realToGame } from "@deadhead/sim";
/**
* Purchasable ship models that can fly from Earth to `locationId` at real time
* `now` (range and landing, via the sim's route check) (Step 10A.16).
*/
export function modelsReachingFromEarth(locationId: string, now: number): ShipModel[] {
const earth = content.location("earth");
const target = content.location(locationId);
if (!earth || !target) return [];
const gameTime = realToGame(now, content.config);
return content.purchasableShipModels.filter(
(model) => checkRoute(model, earth, target, gameTime).ok,
);
}

View File

@ -49,7 +49,13 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
.map((ship) => { .map((ship) => {
const model = shipModel(ship.modelId); const model = shipModel(ship.modelId);
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null; const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
const tripMs = tripRealMs(model, contract.originId, contract.destinationId, now); const tripMs = tripRealMs(
model,
contract.originId,
contract.destinationId,
contract.massTonnes,
now,
);
const fuelCost = tripFuelCost( const fuelCost = tripFuelCost(
model, model,
location(contract.originId), location(contract.originId),

View File

@ -3,25 +3,19 @@ import { realToGame } from "@deadhead/sim";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock"; import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { GoalsCard } from "../components/GoalsCard";
import { LoanCard } from "../components/LoanCard";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
/** Cost of the tutorial goal: the cheapest ship plus an Earth bay. */
export function tutorialGoalCost(): number {
const cheapest = Math.min(...content.shipModels.map((s) => s.price));
return cheapest + (content.location("earth")?.bayLeasePrice ?? 0);
}
export function HQ() { export function HQ() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const idle = state.ships.filter((s) => s.status === "idle").length; const idle = state.ships.filter((s) => s.status === "idle").length;
const flying = state.ships.filter((s) => s.status === "in-flight").length; const flying = state.ships.filter((s) => s.status === "in-flight").length;
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0]; const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
const goal = tutorialGoalCost();
const progress = Math.min(100, Math.floor((state.company.credits / goal) * 100));
return ( return (
<section className="space-y-4 p-4"> <section className="space-y-4 p-4">
<h1 className="text-xl font-semibold">HQ</h1> <h1 className="text-xl font-semibold">HQ</h1>
@ -51,25 +45,20 @@ export function HQ() {
{state.tutorial && ( {state.tutorial && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3"> <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
<h2 className="font-semibold text-amber-300"> <h2 className="font-semibold text-amber-300">Flying for Halvorsen Lunar Freight</h2>
Save up for your first ship and an Earth bay <p aria-label="Company runs" className="mt-1 text-sm text-slate-300">
</h2> Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
<p className="mt-1 text-sm text-slate-300"> {runsBeforeDeal}
{formatCredits(state.company.credits)} of {formatCredits(goal)} </p>
<p className="mt-1 text-xs text-slate-400">
Keep flying — the old man has an offer for you.
</p> </p>
<div
role="progressbar"
aria-label="Tutorial goal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-amber-400" style={{ width: `${progress}%` }} />
</div>
<p className="mt-1 text-right text-xs text-slate-400">{progress}%</p>
</div> </div>
)} )}
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</section> </section>
); );
} }

View File

@ -10,6 +10,7 @@ import { distanceAt } from "../game";
import { DEFAULT_CAMERA, useCamera } from "../map/camera"; import { DEFAULT_CAMERA, useCamera } from "../map/camera";
import type { MapSelection } from "../map/hitTest"; import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas"; import { MapCanvas } from "../map/MapCanvas";
import { modelsReachingFromEarth } from "../map/reach";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
const TYPE_LABELS: Record<Location["type"], string> = { const TYPE_LABELS: Record<Location["type"], string> = {
@ -78,6 +79,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
const location = content.location(locationId)!; const location = content.location(locationId)!;
const discovered = state.locations.some((l) => l.id === locationId && l.discovered); const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
const hasBay = state.bays.some((b) => b.locationId === locationId); const hasBay = state.bays.some((b) => b.locationId === locationId);
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
return ( return (
<Panel title={location.name} onClose={onClose}> <Panel title={location.name} onClose={onClose}>
@ -105,7 +107,14 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
)} )}
</> </>
) : ( ) : (
<>
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p> <p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
<p aria-label="Reachable with" className="mt-1 text-slate-300">
{reachable.length > 0
? `Reachable with: ${reachable.map((m) => m.name).join(", ")}`
: "No ship can reach it from Earth right now."}
</p>
</>
)} )}
</Panel> </Panel>
); );

View File

@ -24,7 +24,7 @@ export function Market() {
</p> </p>
)} )}
<ul className="space-y-2"> <ul className="space-y-2">
{content.shipModels.map((model) => { {content.purchasableShipModels.map((model) => {
const affordable = state.company.credits >= model.price; const affordable = state.company.credits >= model.price;
return ( return (
<li key={model.id} className="rounded-lg border border-slate-800 p-3 text-sm"> <li key={model.id} className="rounded-lg border border-slate-800 p-3 text-sm">

View File

@ -13,7 +13,8 @@ export function StartScreen() {
onSuccess: ({ state }) => { onSuccess: ({ state }) => {
// The session cookie is now set; the response already holds the new state. // The session cookie is now set; the response already holds the new state.
queryClient.setQueryData(PLAYER_STATE_KEY, 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

@ -1,10 +1,10 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { loanerModel } from "@deadhead/sim"; import { deliveryXp, loanerModel } from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatCredits, formatDuration, locationName, cargoName } from "../format"; import { formatCredits, formatDuration, locationName, cargoName } from "../format";
import { tripRealMs } from "../game"; import { distanceAt, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const; export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
@ -21,6 +21,7 @@ export function TutorialBoard() {
const fly = useGameAction("createDispatch"); const fly = useGameAction("createDispatch");
const runInFlight = state.tutorial?.runInFlight ?? false; const runInFlight = state.tutorial?.runInFlight ?? false;
const loaner = loanerModel(content); 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.isPending) return <p className="p-4 text-slate-400">Loading contracts…</p>;
if (board.isError) { if (board.isError) {
@ -36,6 +37,15 @@ export function TutorialBoard() {
return ( return (
<div className="space-y-3"> <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"> <p className="text-sm text-slate-400">
Company runs in the loaner {loaner.name}. You keep a share of each payout. Company runs in the loaner {loaner.name}. You keep a share of each payout.
</p> </p>
@ -53,25 +63,82 @@ export function TutorialBoard() {
<p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p> <p className="text-sm text-slate-400">No company runs left this period. Check back soon.</p>
)} )}
<ul className="space-y-2"> <ul className="space-y-2">
{board.data.contracts.map((contract) => ( {board.data.contracts.map((contract, index) => {
<li key={contract.id} className="rounded-lg border border-slate-800 p-3"> // On the first run, suggest the lightest (fastest) contract.
const suggested = firstRun && index === 0;
const scripted = contract.scriptedId
? content.scriptedTutorialContracts.find((s) => s.id === contract.scriptedId)
: undefined;
const tripMs = tripRealMs(
loaner,
contract.originId,
contract.destinationId,
contract.massTonnes,
now,
);
const xp = deliveryXp(
distanceAt(contract.originId, contract.destinationId, now),
contract.tier,
content.config,
);
const border = scripted
? "border-fuchsia-500 bg-fuchsia-500/10"
: suggested
? "border-amber-400 ring-1 ring-amber-400"
: "border-slate-800";
return (
<li
key={contract.id}
data-suggested={suggested || undefined}
data-scripted={scripted ? true : undefined}
className={`rounded-lg border p-3 ${border}`}
>
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="font-medium"> <span className="font-medium">
{scripted ? (
<span className="text-fuchsia-300">{scripted.name}</span>
) : (
<>
{locationName(contract.originId)} → {locationName(contract.destinationId)} {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)}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-sm text-slate-400">
<span>
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "}
<span aria-label="Trip time">
{formatDuration(
tripRealMs(loaner, contract.originId, contract.destinationId, now),
)} )}
</span> </span>
<span className="text-sm text-slate-400">
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
</span> </span>
</div>
{scripted && (
<p className="mt-1 text-xs text-fuchsia-200">
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
double pay, no questions asked
</p>
)}
<div className="mt-2 flex items-center justify-between gap-2">
<dl className="grid flex-1 grid-cols-3 text-sm">
<div>
<dt className="text-xs text-slate-400">Pay</dt>
<dd aria-label="Pay" className="font-semibold tabular-nums text-emerald-400">
{formatCredits(contract.payout)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400">Time</dt>
<dd aria-label="Trip time" className="tabular-nums">
{formatDuration(tripMs)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400">XP</dt>
<dd aria-label="XP" className="tabular-nums">
+{xp}
</dd>
</div>
</dl>
<button <button
type="button" type="button"
disabled={runInFlight || fly.isPending} disabled={runInFlight || fly.isPending}
@ -82,7 +149,8 @@ export function TutorialBoard() {
</button> </button>
</div> </div>
</li> </li>
))} );
})}
</ul> </ul>
</div> </div>
); );

View File

@ -0,0 +1,85 @@
import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render";
function returningPlayer(acknowledged: boolean): PlayerState {
return samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 900, loanBalance: 4000 },
ships: [
{
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: "avatar",
},
],
summaries: acknowledged
? []
: [
{
dispatchId: "d1",
shipId: "s1",
pilotId: "avatar",
originId: "earth",
destinationId: "moon",
tutorial: false,
credits: 538,
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
resolvedAt: 1,
},
],
goals: content.onboardingGoals.map((g) => ({
id: g.id,
status: g.id === "first-own-delivery" ? "ready" : "incomplete",
})),
});
}
describe("something waiting on return", () => {
it("lists ready goals alongside the delivery, and the link opens HQ's goals", async () => {
let acknowledged = false;
const api = fakeApi({
getPlayerState: () => returningPlayer(acknowledged),
acknowledgeSummaries: () => {
acknowledged = true;
return { acknowledged: 1 };
},
});
renderApp(api, "/fleet");
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(dialog).toHaveTextContent("Dusty Mule");
const ready = within(dialog).getByRole("list", { name: "Ready goals" });
expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title);
await userEvent.click(within(dialog).getByRole("button", { name: "Claim on HQ" }));
expect(screen.queryByRole("dialog", { name: "While you were away" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
const goals = screen.getByRole("region", { name: "Goals" });
expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument();
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1"] }),
);
});
it("does not list goals when none are ready", async () => {
const state = returningPlayer(false);
const quiet = {
...state,
goals: state.goals.map((g) => ({ ...g, status: "incomplete" as const })),
};
renderApp(fakeApi({ getPlayerState: () => quiet }));
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(within(dialog).queryByRole("list", { name: "Ready goals" })).not.toBeInTheDocument();
});
});

View File

@ -23,8 +23,10 @@ const summaries: ResolutionSummary[] = [
destinationId: "mars", destinationId: "mars",
tutorial: false, tutorial: false,
credits: 51_200, credits: 51_200,
loanRepayment: 0,
pilotXp: 32, pilotXp: 32,
discoveredLocationId: "mars", discoveredLocationId: "mars",
scriptedId: null,
resolvedAt: 1, resolvedAt: 1,
}, },
{ {
@ -35,8 +37,10 @@ const summaries: ResolutionSummary[] = [
destinationId: "earth", destinationId: "earth",
tutorial: true, tutorial: true,
credits: 240, credits: 240,
loanRepayment: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null,
resolvedAt: 2, resolvedAt: 2,
}, },
]; ];

View File

@ -0,0 +1,120 @@
import { content } from "@deadhead/content";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const offered = () =>
samplePlayerState({
tutorial: {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 4,
dealAvailable: true,
},
});
/** State the server returns after the deal: tutorial over, a Courier, a bay, the loan. */
const afterDeal = () =>
samplePlayerState({
tutorial: null,
company: {
id: "co1",
name: "Deadhead Freight",
credits: 800,
loanBalance: content.config.dealLoanAmount,
},
ships: [
{
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: "avatar",
},
],
bays: [{ id: "b1", locationId: "earth" }],
});
describe("the deal", () => {
it("does not appear when the deal is not available", async () => {
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState(),
getTutorialBoard: () => sampleTutorialBoard(),
}),
"/contracts",
);
await screen.findByRole("heading", { level: 1, name: "Contracts" });
expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument();
});
it("appears when available, showing the configured loan and repayment share", async () => {
renderApp(
fakeApi({ getPlayerState: offered, getTutorialBoard: () => sampleTutorialBoard() }),
"/contracts",
);
const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
expect(dialog).toHaveTextContent("Courier and a bay at Earth");
expect(screen.getByLabelText("Loan amount")).toHaveTextContent(
`${content.config.dealLoanAmount.toLocaleString("en-US")} cr`,
);
expect(screen.getByLabelText("Repayment share")).toHaveTextContent(
`${Math.round(content.config.loanRepaymentShare * 100)}% of every payout`,
);
expect(screen.getAllByRole("button").filter((b) => dialog.contains(b))).toHaveLength(1);
});
it("waits until the delivery summary is dismissed", async () => {
const withSummary = () => ({
...offered(),
summaries: [
{
dispatchId: "d4",
shipId: "loaner",
pilotId: "avatar",
originId: "moon",
destinationId: "earth",
tutorial: true,
credits: 96,
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
resolvedAt: 1,
},
],
});
renderApp(
fakeApi({ getPlayerState: withSummary, getTutorialBoard: () => sampleTutorialBoard() }),
"/contracts",
);
await screen.findByRole("dialog", { name: "While you were away" });
expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument();
});
it("Shake on it accepts the deal and goes to HQ", async () => {
let accepted = false;
const api = fakeApi({
getPlayerState: () => (accepted ? afterDeal() : offered()),
getTutorialBoard: () => sampleTutorialBoard(),
acceptDeal: () => {
accepted = true;
return afterDeal();
},
});
renderApp(api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument();
});
});

View File

@ -59,7 +59,7 @@ const marsJob: Contract = {
const state = samplePlayerState({ const state = samplePlayerState({
tutorial: null, tutorial: null,
company: { id: "c", name: "D", credits: 1_000_000 }, company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }], bays: [{ id: "b1", locationId: "earth" }],
ships: [ ships: [
ship("s-courier", "Dusty Mule", "courier", "avatar"), ship("s-courier", "Dusty Mule", "courier", "avatar"),
@ -106,7 +106,13 @@ describe("dispatch sheet", () => {
const mars = content.location("mars")!; const mars = content.location("mars")!;
const gameNow = realToGame(NOW, content.config); const gameNow = realToGame(NOW, content.config);
const tripMs = const tripMs =
(travelTimeGameHours(model, routeDistance(earth, mars, gameNow)) * MS_PER_HOUR) / (travelTimeGameHours(
model,
routeDistance(earth, mars, gameNow),
marsJob.massTonnes,
content.config,
) *
MS_PER_HOUR) /
content.config.timeCompression; content.config.timeCompression;
const fuel = tripFuelCost(model, earth, mars, gameNow); const fuel = tripFuelCost(model, earth, mars, gameNow);
const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery; const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery;

View File

@ -0,0 +1,72 @@
import { content } from "@deadhead/content";
import type { GoalState } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const title = (id: string) => content.onboardingGoal(id)!.title;
function statesWith(ready: string[], claimed: string[] = []): GoalState[] {
return content.onboardingGoals.map((g) => ({
id: g.id,
status: claimed.includes(g.id) ? "claimed" : ready.includes(g.id) ? "ready" : "incomplete",
}));
}
const owner = (goals: GoalState[]) =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 100, loanBalance: 4000 },
goals,
});
describe("goals on HQ", () => {
it("shows only the next three unclaimed goals, with rewards", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(statesWith([])) }));
const card = await screen.findByRole("region", { name: "Goals" });
const items = within(card).getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent(title("first-own-delivery"));
expect(items[0]).toHaveTextContent(
`+${content.onboardingGoal("first-own-delivery")!.reward.toLocaleString("en-US")} cr`,
);
expect(items[2]).toHaveTextContent(title("second-ship"));
});
it("shows Claim only on ready goals", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(statesWith(["first-skill-point"])) }));
const card = await screen.findByRole("region", { name: "Goals" });
const buttons = within(card).getAllByRole("button", { name: "Claim" });
expect(buttons).toHaveLength(1);
expect(buttons[0]!.closest("li")).toHaveTextContent(title("first-skill-point"));
});
it("claims through the endpoint, and the next goal moves into view", async () => {
let claimed = false;
const api = fakeApi({
getPlayerState: () =>
owner(
claimed ? statesWith([], ["first-own-delivery"]) : statesWith(["first-own-delivery"]),
),
claimGoal: () => {
claimed = true;
return { company: { id: "c", name: "D", credits: 400, loanBalance: 4000 } };
},
});
renderApp(api);
const card = await screen.findByRole("region", { name: "Goals" });
expect(card).not.toHaveTextContent(title("first-hire"));
await userEvent.click(within(card).getByRole("button", { name: "Claim" }));
expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" });
await waitFor(() => expect(card).toHaveTextContent(title("first-hire")));
expect(card).not.toHaveTextContent(title("first-own-delivery"));
});
it("is not shown during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
expect(screen.queryByRole("region", { name: "Goals" })).not.toBeInTheDocument();
});
});

View File

@ -1,8 +1,9 @@
import type { Dispatch, Ship } from "@deadhead/schema"; import { content } from "@deadhead/content";
import { act, screen, within } from "@testing-library/react"; import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { tutorialGoalCost } from "../src/screens/HQ";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -60,33 +61,102 @@ describe("HQ screen", () => {
expect(within(next).getByText("1m 05s")).toBeInTheDocument(); expect(within(next).getByText("1m 05s")).toBeInTheDocument();
act(() => vi.advanceTimersByTime(10_000)); act(() => vi.advanceTimersByTime(10_000));
expect(within(next).getByText("55s")).toBeInTheDocument(); expect(within(next).getByText("55s")).toBeInTheDocument();
expect(screen.queryByText(/Save up for your first ship/)).not.toBeInTheDocument();
}); });
it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => { it("shows tutorial run progress toward the deal during the tutorial", async () => {
const goal = tutorialGoalCost();
const state = samplePlayerState({ const state = samplePlayerState({
company: { id: "c", name: "D", credits: Math.round(goal * 0.4) }, tutorial: {
loanerLocationId: "moon",
runInFlight: false,
runsCompleted: 3,
dealAvailable: false,
},
}); });
renderApp(fakeApi({ getPlayerState: () => state })); renderApp(fakeApi({ getPlayerState: () => state }));
expect(await screen.findByLabelText("Company runs")).toHaveTextContent(
expect( `Company runs: 3 of ${content.config.tutorialRunsBeforeDeal}`,
await screen.findByText("Save up for your first ship and an Earth bay"),
).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "Tutorial goal" })).toHaveAttribute(
"aria-valuenow",
"40",
); );
expect(screen.getByText("40%")).toBeInTheDocument(); expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
expect(screen.getByText("Nothing in flight.")).toBeInTheDocument(); });
}); });
it("caps tutorial progress at 100%", async () => { describe("loan on HQ", () => {
const state = samplePlayerState({ const total = content.config.dealLoanAmount;
company: { id: "c", name: "D", credits: tutorialGoalCost() * 3 }, const owner = (credits: number, loanBalance: number): PlayerState =>
}); samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance } });
renderApp(fakeApi({ getPlayerState: () => state }));
expect(await screen.findByText("100%")).toBeInTheDocument(); it("shows the balance and a paid-off progress bar matching state", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, total * 0.75) }));
expect(await screen.findByLabelText("Loan balance")).toHaveTextContent(
`${(total * 0.75).toLocaleString("en-US")} cr left`,
);
expect(screen.getByRole("progressbar", { name: "Loan paid off" })).toHaveAttribute(
"aria-valuenow",
"25",
);
});
it("pays down with the entered amount", async () => {
const api = fakeApi({
getPlayerState: () => owner(2000, 4000),
repayLoan: ({ amount }) => ({
company: { id: "c", name: "D", credits: 2000 - amount, loanBalance: 4000 - amount },
}),
});
renderApp(api);
await userEvent.type(await screen.findByLabelText("Pay down amount"), "750");
await userEvent.click(screen.getByRole("button", { name: "Pay down" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("repayLoan", { amount: 750 }));
});
it("blocks an amount above the balance", async () => {
const api = fakeApi({ getPlayerState: () => owner(5000, 300) });
renderApp(api);
await userEvent.type(await screen.findByLabelText("Pay down amount"), "400");
expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled();
expect(screen.getByText("You only owe 300 cr.")).toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything());
});
it("blocks an amount above the player's credits", async () => {
const api = fakeApi({ getPlayerState: () => owner(200, 3000) });
renderApp(api);
await userEvent.type(await screen.findByLabelText("Pay down amount"), "250");
expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled();
expect(screen.getByText("You only have 200 cr.")).toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything());
});
it("is hidden once the loan is paid off", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
});
it("shows the lender's cut in a delivery summary", async () => {
const state: PlayerState = {
...owner(2000, 4000),
summaries: [
{
dispatchId: "d1",
shipId: "s1",
pilotId: "avatar",
originId: "earth",
destinationId: "moon",
tutorial: false,
credits: 538,
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
resolvedAt: 1,
},
],
};
renderApp(
fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }),
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(dialog).toHaveTextContent("230 cr went to the lender");
}); });
}); });

View File

@ -6,7 +6,8 @@ import { fakeApi, renderApp, samplePlayerState } from "./render";
const signedIn = () => const signedIn = () =>
fakeApi({ fakeApi({
getPlayerState: () => samplePlayerState({ company: { id: "c", name: "D", credits: 1234 } }), getPlayerState: () =>
samplePlayerState({ company: { id: "c", name: "D", credits: 1234, loanBalance: 0 } }),
}); });
describe("app layout", () => { describe("app layout", () => {

View File

@ -31,7 +31,7 @@ describe("locations screen", () => {
const api = fakeApi({ const api = fakeApi({
getPlayerState: () => getPlayerState: () =>
samplePlayerState({ samplePlayerState({
company: { id: "c", name: "D", credits: 100_000 }, company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }], bays: [{ id: "b1", locationId: "earth" }],
}), }),
}); });

View File

@ -19,6 +19,7 @@ export function MapCanvas({
{scene.locations.map((l) => ( {scene.locations.map((l) => (
<button key={l.id} type="button" onClick={() => onSelect?.({ kind: "location", id: l.id })}> <button key={l.id} type="button" onClick={() => onSelect?.({ kind: "location", id: l.id })}>
Tap {l.name} Tap {l.name}
{l.discovered ? "" : " (locked)"}
</button> </button>
))} ))}
{scene.flights.map((f) => ( {scene.flights.map((f) => (

View File

@ -120,7 +120,7 @@ describe("map panels", () => {
it("tapping an undiscovered location hides its details", async () => { it("tapping an undiscovered location hides its details", async () => {
const canvas = await openMap(); const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars" })); await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars (locked)" }));
expect(screen.getByRole("dialog", { name: "Mars" })).toHaveTextContent( expect(screen.getByRole("dialog", { name: "Mars" })).toHaveTextContent(
"Undiscovered — deliver here to unlock.", "Undiscovered — deliver here to unlock.",
); );

View File

@ -0,0 +1,76 @@
import { content } from "@deadhead/content";
import { checkRoute, realToGame } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
describe("whole map from the start", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
const openMap = async () => {
// A brand-new tutorial player.
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState(),
getTutorialBoard: () => sampleTutorialBoard(),
}),
"/map",
);
return screen.findByTestId("map-canvas");
};
it("is available during the tutorial and shows every location", async () => {
const canvas = await openMap();
expect(within(canvas).getAllByRole("button", { name: /^Tap / })).toHaveLength(
content.locations.length + 1,
);
});
it("renders undiscovered locations as locked and discovered ones normally", async () => {
const canvas = await openMap();
for (const id of ["mars", "ceres-station", "jupiter-cloud-platform"]) {
const name = content.location(id)!.name;
expect(
within(canvas).getByRole("button", { name: `Tap ${name} (locked)` }),
).toBeInTheDocument();
}
expect(within(canvas).getByRole("button", { name: "Tap Earth" })).toBeInTheDocument();
expect(within(canvas).getByRole("button", { name: "Tap Moon" })).toBeInTheDocument();
});
it("lists the purchasable ships that can reach Mars from Earth now, per the sim's range check", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars (locked)" }));
const panel = screen.getByRole("dialog", { name: "Mars" });
expect(panel).toHaveTextContent("Undiscovered — deliver here to unlock.");
const gameTime = realToGame(NOW, content.config);
const expected = content.purchasableShipModels
.filter(
(m) => checkRoute(m, content.location("earth")!, content.location("mars")!, gameTime).ok,
)
.map((m) => m.name);
expect(expected.length).toBeGreaterThan(0);
expect(expected).not.toContain("Courier");
expect(screen.getByLabelText("Reachable with")).toHaveTextContent(
`Reachable with: ${expected.join(", ")}`,
);
});
it("shows details, not reachability, for a discovered location", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
const panel = screen.getByRole("dialog", { name: "Moon" });
expect(panel).toHaveTextContent("Airless body");
expect(screen.queryByLabelText("Reachable with")).not.toBeInTheDocument();
});
});

View File

@ -12,7 +12,8 @@ describe("market screen", () => {
it("lists every ship model with its stats", async () => { it("lists every ship model with its stats", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/market"); renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/market");
const items = await screen.findAllByRole("listitem"); const items = await screen.findAllByRole("listitem");
expect(items).toHaveLength(content.shipModels.length); expect(items).toHaveLength(content.purchasableShipModels.length);
expect(screen.queryByText("Company Courier")).not.toBeInTheDocument();
const courier = items[0]!; const courier = items[0]!;
expect(courier).toHaveTextContent("Courier"); expect(courier).toHaveTextContent("Courier");
expect(courier).toHaveTextContent("3.00M km/h"); expect(courier).toHaveTextContent("3.00M km/h");
@ -25,7 +26,9 @@ describe("market screen", () => {
renderApp( renderApp(
fakeApi({ fakeApi({
getPlayerState: () => getPlayerState: () =>
samplePlayerState({ company: { id: "c", name: "D", credits: courierPrice } }), samplePlayerState({
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
}),
}), }),
"/market", "/market",
); );
@ -50,7 +53,12 @@ describe("market screen", () => {
samplePlayerState({ samplePlayerState({
tutorial: null, tutorial: null,
ships: owned, ships: owned,
company: { id: "c", name: "D", credits: owned.length ? 1 : freighterPrice }, company: {
id: "c",
name: "D",
credits: owned.length ? 1 : freighterPrice,
loanBalance: 0,
},
}), }),
buyShip: () => { buyShip: () => {
owned = [bought]; owned = [bought];

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 userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ApiError } from "../src/api/client"; import { ApiError } from "../src/api/client";
import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render"; import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, unauthorized } from "./render";
describe("guest onboarding", () => { 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. // Like the real server: no session until the guest is created.
let signedUp = false; let signedUp = false;
const api = fakeApi({ const api = fakeApi({
@ -14,15 +14,31 @@ describe("guest onboarding", () => {
signedUp = true; signedUp = true;
return { playerId: "p1", state: samplePlayerState() }; return { playerId: "p1", state: samplePlayerState() };
}, },
getTutorialBoard: () => sampleTutorialBoard(),
}); });
renderApp(api, "/fleet"); renderApp(api, "/fleet");
const start = await screen.findByRole("button", { name: "Start your company" }); const start = await screen.findByRole("button", { name: "Start your company" });
expect(screen.queryByRole("navigation", { name: "Main" })).not.toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Main" })).not.toBeInTheDocument();
await userEvent.click(start); await userEvent.click(start);
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(api.call).toHaveBeenCalledWith("createGuest", {}); 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 () => { it("skips the start screen when a stored session is still valid", async () => {

View File

@ -97,7 +97,8 @@ describe("pilots screen", () => {
const cost = content.config.pilotHireCost; const cost = content.config.pilotHireCost;
renderApp( renderApp(
fakeApi({ fakeApi({
getPlayerState: () => pilotsState({ company: { id: "c", name: "D", credits: cost - 1 } }), getPlayerState: () =>
pilotsState({ company: { id: "c", name: "D", credits: cost - 1, loanBalance: 0 } }),
}), }),
"/pilots", "/pilots",
); );

View File

@ -34,7 +34,7 @@ export const unauthorized = () => {
/** A brand-new tutorial player, as guest sign-up returns it. */ /** A brand-new tutorial player, as guest sign-up returns it. */
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState { export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return { return {
company: { id: "co1", name: "Deadhead Freight", credits: 0 }, company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
ships: [], ships: [],
pilots: [ pilots: [
{ {
@ -58,9 +58,15 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
{ id: "ceres-station", discovered: false }, { id: "ceres-station", discovered: false },
{ id: "jupiter-cloud-platform", discovered: false }, { id: "jupiter-cloud-platform", discovered: false },
], ],
tutorial: { loanerLocationId: "earth", runInFlight: false }, tutorial: {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 0,
dealAvailable: false,
},
activeDispatches: [], activeDispatches: [],
summaries: [], summaries: [],
goals: [],
...overrides, ...overrides,
}; };
} }
@ -81,3 +87,33 @@ export function renderApp(api: ApiClient, path = "/") {
); );
return { ...result, queryClient }; 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

@ -1,9 +1,12 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest"; import { afterEach, vi } from "vitest";
afterEach(() => cleanup()); afterEach(() => cleanup());
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
configure({ asyncUtilTimeout: 3000 });
// PixiJS needs WebGL, which jsdom lacks. Component tests use a stand-in canvas; // PixiJS needs WebGL, which jsdom lacks. Component tests use a stand-in canvas;
// the map's logic (projection, camera, scene, hit-testing) is unit-tested directly. // the map's logic (projection, camera, scene, hit-testing) is unit-tested directly.
vi.mock("../src/map/MapCanvas", () => import("./map-canvas-double")); vi.mock("../src/map/MapCanvas", () => import("./map-canvas-double"));

View File

@ -1,90 +1,110 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Contract } from "@deadhead/schema"; import type { Contract } from "@deadhead/schema";
import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; import {
deliveryXp,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
} from "@deadhead/sim";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../src/api/client"; import { ApiError } from "../src/api/client";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { formatDuration } from "../src/format"; import { formatCredits, formatDuration } from "../src/format";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
const loaner = content.shipModel("company-courier")!;
const contract = (id: string, mass: number, payout: number): Contract => ({ /** What the sim predicts for the loaner flying this contract now. */
id, function expected(contract: Contract) {
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
tier: 0,
massTonnes: mass,
payout,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
});
const board = {
contracts: [contract("t1", 12, 230), contract("t2", 18, 345)],
periodEndsAt: NOW + 3_600_000,
};
/** The trip time the sim predicts for the Courier loaner, Earth→Moon, departing now. */
function expectedTripTime(now: number) {
const distance = routeDistance( const distance = routeDistance(
content.location("earth")!, content.location(contract.originId)!,
content.location("moon")!, content.location(contract.destinationId)!,
realToGame(now, content.config), realToGame(NOW, content.config),
); );
const hours = travelTimeGameHours(content.shipModel("courier")!, distance); const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config);
return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression); return {
pay: formatCredits(contract.payout),
time: formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression),
xp: `+${deliveryXp(distance, contract.tier, content.config)}`,
};
} }
describe("tutorial contract board", () => { describe("tutorial contract board", () => {
beforeEach(() => useClock.setState({ offsetMs: 0, synced: true, now: NOW })); beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
it("shows each contract with route, cargo, payout and the sim's trip time", async () => { vi.setSystemTime(NOW);
const api = fakeApi({ useClock.setState({ offsetMs: 0, synced: true, now: NOW });
getPlayerState: () => samplePlayerState(),
getTutorialBoard: () => board,
}); });
renderApp(api, "/contracts"); afterEach(() => vi.useRealTimers());
const items = await screen.findAllByRole("listitem"); it("shows pay, trip time and XP side by side for all three contracts, matching the sim", async () => {
expect(items).toHaveLength(2); const board = sampleTutorialBoard(NOW);
expect(items[0]).toHaveTextContent("Earth → Moon"); renderApp(
expect(items[0]).toHaveTextContent("Standard · 12 t"); fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board }),
expect(items[0]).toHaveTextContent("230 cr"); "/contracts",
expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent(
expectedTripTime(useClock.getState().now),
); );
const items = await screen.findAllByRole("listitem");
expect(items).toHaveLength(3);
items.forEach((item, i) => {
const want = expected(board.contracts[i]!);
expect(within(item).getByLabelText("Pay")).toHaveTextContent(want.pay);
expect(within(item).getByLabelText("Trip time")).toHaveTextContent(want.time);
expect(within(item).getByLabelText("XP")).toHaveTextContent(want.xp);
});
// The heavy run takes longer than the light one.
expect(expected(board.contracts[2]!).time).not.toBe(expected(board.contracts[0]!).time);
});
it("styles the scripted contract and shows its name", async () => {
renderApp(
fakeApi({
getPlayerState: () => samplePlayerState(),
getTutorialBoard: () => sampleTutorialBoard(NOW, { scriptedMedium: true }),
}),
"/contracts",
);
const items = await screen.findAllByRole("listitem");
expect(items[1]).toHaveAttribute("data-scripted", "true");
expect(items[1]).toHaveTextContent("Unmarked crates");
expect(items[0]).not.toHaveAttribute("data-scripted");
}); });
it("dispatches the tutorial run with Fly it", async () => { it("dispatches the tutorial run with Fly it", async () => {
const api = fakeApi({ const api = fakeApi({
getPlayerState: () => samplePlayerState(), getPlayerState: () => samplePlayerState(),
getTutorialBoard: () => board, getTutorialBoard: () => sampleTutorialBoard(NOW),
}); });
renderApp(api, "/contracts"); renderApp(api, "/contracts");
const [first] = await screen.findAllByRole("listitem"); const [, medium] = await screen.findAllByRole("listitem");
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" })); await userEvent.click(within(medium!).getByRole("button", { name: "Fly it" }));
await waitFor(() => await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("createDispatch", { expect(api.call).toHaveBeenCalledWith("createDispatch", {
kind: "tutorial", kind: "tutorial",
contractId: "t1", contractId: "tutorial:earth:1:0:1",
}), }),
); );
}); });
it("disables Fly it while a tutorial run is in flight", async () => { it("disables Fly it while a tutorial run is in flight", async () => {
const api = fakeApi({ renderApp(
fakeApi({
getPlayerState: () => getPlayerState: () =>
samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true } }), samplePlayerState({
getTutorialBoard: () => board, tutorial: {
}); loanerLocationId: "earth",
renderApp(api, "/contracts"); runInFlight: true,
runsCompleted: 1,
dealAvailable: false,
},
}),
getTutorialBoard: () => sampleTutorialBoard(NOW),
}),
"/contracts",
);
await screen.findAllByRole("listitem"); await screen.findAllByRole("listitem");
for (const button of screen.getAllByRole("button", { name: "Fly it" })) { for (const button of screen.getAllByRole("button", { name: "Fly it" })) {
expect(button).toBeDisabled(); expect(button).toBeDisabled();
@ -93,14 +113,16 @@ describe("tutorial contract board", () => {
}); });
it("shows the server's message when dispatch fails", async () => { it("shows the server's message when dispatch fails", async () => {
const api = fakeApi({ renderApp(
fakeApi({
getPlayerState: () => samplePlayerState(), getPlayerState: () => samplePlayerState(),
getTutorialBoard: () => board, getTutorialBoard: () => sampleTutorialBoard(NOW),
createDispatch: () => { createDispatch: () => {
throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying."); throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying.");
}, },
}); }),
renderApp(api, "/contracts"); "/contracts",
);
const [first] = await screen.findAllByRole("listitem"); const [first] = await screen.findAllByRole("listitem");
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" })); await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
@ -108,3 +130,37 @@ describe("tutorial contract board", () => {
); );
}); });
}); });
describe("scripted delivery summary", () => {
it("shows the scripted contract's flavor message after it resolves", async () => {
const flavor = content.scriptedTutorialContracts.find(
(s) => s.id === "unmarked-crates",
)!.flavorMessage;
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
summaries: [
{
dispatchId: "d3",
shipId: "loaner",
pilotId: "avatar",
originId: "earth",
destinationId: "moon",
tutorial: true,
credits: 461,
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: "unmarked-crates",
resolvedAt: NOW,
},
],
}),
acknowledgeSummaries: () => ({ acknowledged: 1 }),
}),
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(dialog).toHaveTextContent(flavor);
});
});

View File

@ -24,16 +24,17 @@ Living reference for how the code is organised. Update it after any database sch
| Shape | Fields | | Shape | Fields |
|---|---| |---|---|
| `ServerTime` | `realTime`, `gameTime` | | `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits` | | `Company` | `id`, `name`, `credits`, `loanBalance` |
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` | | `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) | | `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
| `Bay` | `id`, `locationId` | | `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` | | `LocationState` | `id`, `discovered` |
| `Contract` | mirrors sim `Contract` | | `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) |
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` | | `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits`, `pilotXp`, `discoveredLocationId`, `resolvedAt` | | `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `resolvedAt` |
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight` | | `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries` | | `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
### Errors ### Errors
@ -58,6 +59,9 @@ The `routes` catalog is the single source for both server and client. Each entry
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes | | `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes | | `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes | | `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
## Database (`apps/server/src/db`) ## Database (`apps/server/src/db`)
@ -65,15 +69,16 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| Table | Purpose | Key constraints | | Table | Purpose | Key constraints |
|---|---|---| |---|---|---|
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location | pk `id`, unique `user_id` → `user.id` | | `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name and credits (the row locked for money changes) | unique `player_id` | | `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight) | — | | `ships` | Owned ships: model, name, status, location (null in flight) | — |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) | | `bays` | Leased bays | unique (`player_id`, `location_id`) |
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
| `contract_boards` | Stored bay boards and tutorial boards per period (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`) | | `contract_boards` | Stored bay boards and tutorial boards per period (tutorial boards also per run) (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |
| `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) | | `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) |
| `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) | | `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) |
| `claimed_goals` | Onboarding goals whose reward was claimed | pk (`player_id`, `goal_id`) |
| `user`, `session`, `account`, `verification` | Better Auth tables (`src/db/auth-schema.ts`, generated by its CLI; `user.is_anonymous` from the anonymous plugin). Timestamps here are Postgres `timestamp`, Better Auth's convention. | — | | `user`, `session`, `account`, `verification` | Better Auth tables (`src/db/auth-schema.ts`, generated by its CLI; `user.is_anonymous` from the anonymous plugin). Timestamps here are Postgres `timestamp`, Better Auth's convention. | — |
### Drizzle schema ### Drizzle schema
@ -115,6 +120,10 @@ export const players = pgTable("players", {
tutorialActive: boolean("tutorial_active").notNull().default(true), tutorialActive: boolean("tutorial_active").notNull().default(true),
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */ /** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
loanerLocationId: text("loaner_location_id"), loanerLocationId: text("loaner_location_id"),
/** Tutorial runs resolved so far (CR-001). */
tutorialRunsCompleted: integer("tutorial_runs_completed").notNull().default(0),
/** The one-time deal has been accepted (decision 0.5.30). */
dealAccepted: boolean("deal_accepted").notNull().default(false),
createdAt: time("created_at").notNull(), createdAt: time("created_at").notNull(),
}); });
@ -127,6 +136,8 @@ export const companies = pgTable("companies", {
name: text("name").notNull(), name: text("name").notNull(),
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */ /** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
credits: bigint("credits", { mode: "number" }).notNull(), credits: bigint("credits", { mode: "number" }).notNull(),
/** Owed to the lender (decision 0.5.32). */
loanBalance: bigint("loan_balance", { mode: "number" }).notNull().default(0),
}); });
export const ships = pgTable( export const ships = pgTable(
@ -210,11 +221,19 @@ export const contractBoards = pgTable(
kind: boardKind("kind").notNull(), kind: boardKind("kind").notNull(),
locationId: text("location_id").notNull(), locationId: text("location_id").notNull(),
periodIndex: integer("period_index").notNull(), periodIndex: integer("period_index").notNull(),
/** Tutorial boards only: runs completed when generated (0 for bay boards). */
tutorialRun: integer("tutorial_run").notNull().default(0),
contracts: jsonb("contracts").$type<Contract[]>().notNull(), contracts: jsonb("contracts").$type<Contract[]>().notNull(),
createdAt: time("created_at").notNull(), createdAt: time("created_at").notNull(),
}, },
(t) => [ (t) => [
uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex), uniqueIndex("contract_boards_unique_idx").on(
t.playerId,
t.kind,
t.locationId,
t.periodIndex,
t.tutorialRun,
),
], ],
); );
@ -261,6 +280,19 @@ export const acceptedContracts = pgTable(
}, },
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })], (t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
); );
/** Onboarding goals whose reward has been claimed (decision 0.5.34). */
export const claimedGoals = pgTable(
"claimed_goals",
{
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
goalId: text("goal_id").notNull(),
claimedAt: time("claimed_at").notNull(),
},
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
);
``` ```
```ts ```ts

View File

@ -0,0 +1,192 @@
# Change Request CR-001 — Faster Start
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 complete
**Do this:** as a new **Phase 10A**, before starting Phase 11
**Related docs:** `implementation-plan.md`, `game-design-document.md`, `DECISIONS.md`
> **Numbering note (added when implemented):** the plan's §0.5 already used decisions 21–28. The new decisions below numbered 0.5.21–0.5.26 are recorded in the plan as **0.5.29–0.5.34** in the same order (21→29 Loaner ship, 22→30 The deal, 23→31 Handling time, 24→32 Loan, 25→33 Scripted tutorial contracts, 26→34 Onboarding goals). The error code `tutorial_active` is implemented as `tutorial-active`, matching the existing kebab-case codes.
---
## 1. Why
Playtesting shows the opening is slow and has no hook. The current tutorial asks the player to fly the same Earth→Moon run 15–25 times (45–120 real minutes) before they own anything. There are no choices, no stakes, and the interesting systems (owning ships, discovery, hiring) are an hour or more away.
This change keeps the employee opening but cuts it to about four quick runs, gives the player a ship on credit right after, puts a real choice on the board from the first minute, shows the whole map from the start, and adds a short list of first-session goals.
## 2. Target experience
| Milestone | Target (real time, active play) |
|---|---|
| First dispatch | Under 30 seconds from "Start your company" |
| First real choice between contracts | Under 2 minutes |
| Own a ship (deal accepted) | Under 10 minutes |
| Hire first pilot | 30–45 minutes |
| First discovery | Within 90 minutes |
### New opening, in order
1. Player taps "Start your company" and lands straight on the tutorial contract board with a two-line intro.
2. The board offers **three** contracts with different mass, pay, trip time, and XP. The loaner ship is a fast company Courier, so each run takes under a minute.
3. On the third run, the board includes one scripted special contract ("Unmarked crates — double pay, no questions asked").
4. After four runs (back at Earth), the company owner offers to sell the player his old Courier **and** his Earth bay lease **on credit**.
5. The player accepts. They now own a ship and an Earth bay, owe a loan, and the tutorial is over. A cut of every payout goes to the lender until the loan is paid.
6. HQ shows a short list of goals with credit rewards to guide the first session.
7. From the first screen, the map shows every location; undiscovered ones are locked, and each one says which ship can reach it.
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 still apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
- **Do not write data migrations for old tutorial state.** There are no production players. Resetting the dev and test databases is fine. Schema migrations are still required.
- Dispatches already stored keep their stored arrival times. Changes to the travel-time formula only affect new dispatches.
- Update existing tests whose expectations change. Do not delete a test unless §6 says the step is replaced.
- Update `architecture.md` for every schema or API change and `progress.md` after every step.
- Stop for the user's review at the end of Phase 10A.
## 4. Decision changes
Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
**Replaced**
- **0.5.4 (Tutorial end)** → The tutorial ends when the player accepts the deal (see 0.5.22). Buying a ship and leasing an Earth bay no longer ends it.
- **0.5.16 (Tutorial purchases)** → During the tutorial, buying ships, leasing bays, and hiring pilots are blocked with a `tutorial_active` error.
**Amended**
- **0.5.3 (Tutorial movement)** → Unchanged, except the tutorial board now has three contracts with different masses (0.5.23), and the loaner is a dedicated content ship (0.5.21).
**New**
- **0.5.21 Loaner ship.** The loaner is its own ship model, "Company Courier", marked not purchasable. It is faster and has lower overhead than the player's Courier so tutorial runs take under one real minute.
- **0.5.22 The deal.** Once the player has completed at least the configured number of tutorial runs and the loaner is at Earth with nothing in flight, a one-time deal becomes available. Accepting it removes the loaner, gives the player a Courier and an Earth bay, assigns the avatar to that Courier, sets the loan balance, and ends the tutorial. There is no decline option.
- **0.5.23 Handling time.** Travel time gains a loading/unloading term: `travel time = distance ÷ cruise speed + overhead + cargo mass × handling time per tonne`. Repositions carry zero mass. Because XP is per delivery and pay is per tonne, light contracts earn more XP per minute and heavy contracts earn more credits per minute. That is the tradeoff on every board.
- **0.5.24 Loan.** The company has a loan balance. When a dispatch resolves with positive credits (after pilot pay), the lender takes `min(balance, credits × repayment share)`. The player can also pay down any amount up to the balance at any time. No interest, no missed payments, no fail state.
- **0.5.25 Scripted tutorial contracts.** Content can define special tutorial contracts keyed to a tutorial run number. A scripted contract replaces one board slot on that run and can carry a custom name, pay multiplier, and a flavor message shown after delivery.
- **0.5.26 Onboarding goals.** A fixed, ordered list of goals in content, each with a condition evaluated from player state and a one-time credit reward the player claims. Goals appear after the tutorial ends.
## 5. Steps — Phase 10A
### Step 10A.1 — Content: new config and loaner ship
- Add a `purchasable` flag to ShipModel. Add a "Company Courier" ship model with `purchasable` false, faster than the Courier, with low overhead and enough capacity for the tutorial board's heaviest contract.
- Add GameConfig fields: tutorial runs before the deal (default 4); tutorial board size (3); tutorial mass tiers (light, medium, full); handling time per tonne (game hours); loan repayment share (default 0.3); deal loan amount (default: Courier price + Earth bay lease). Lower starting credits to a small cushion (a few fuel loads).
- Add a scripted tutorial contract entry: run 3, name "Unmarked crates", Standard cargo, pay multiplier 2, and a flavor message after delivery (for example: "Nobody asked what was inside. You got paid. Licensed hazardous-cargo pilots earn this kind of money every run.").
- Add the onboarding goals list (see Step 10A.5 for conditions) with rewards.
- **Test:** All content still validates through its schemas. The Market's list of purchasable ships does not include Company Courier. Every scripted contract and goal references valid ids. Deal loan amount is greater than zero.
### Step 10A.2 — Sim: handling time
- Add the handling term to the travel-time function (decision 0.5.23).
- Update the Step 3.5 hand-calculated test to include handling time.
- **Test:** On the same route with the same ship, a heavier contract takes longer than a lighter one. A reposition (zero mass) gives the same time as the old formula. Company Courier Earth→Moon at full tutorial load takes under 1 game hour (under 1 real minute at compression 60).
### Step 10A.3 — Sim: tutorial board v2
- The tutorial board generator takes one more input: the number of tutorial runs completed.
- It returns three Standard contracts from the loaner's current location to the other end of the Earth–Moon route, at the light, medium, and full mass tiers, each paying the tutorial share.
- If a scripted contract is keyed to the upcoming run number, it replaces the medium slot.
- Update the Step 3.10 tests to match.
- **Test:** The board has exactly three contracts with three different masses, all within Company Courier capacity. Same inputs give an identical board. With two runs completed, the "Unmarked crates" contract appears at double pay; with any other count, it does not. The light contract gives more XP per real minute than the full one, and the full one gives more credits per real minute than the light one. The sum of the four slowest possible tutorial trip times is under 6 real minutes.
### Step 10A.4 — Sim: loan repayment
- Add a pure function that splits a credit amount into repayment and net, given the loan balance and repayment share.
- Dispatch resolution now returns the repayment amount alongside the credits to add.
- **Test:** Repayment equals `min(balance, credits × share)`. Repayment is zero when the balance is zero or the credits are zero or negative. Net plus repayment equals the original credits. Repayment never exceeds the balance.
### Step 10A.5 — Sim: onboarding goal evaluation
- Add a pure function that takes a player state summary and returns which goals are complete.
- Goals, in order:
1. Deliver a contract with your own ship.
2. Spend your first skill point.
3. Buy a second ship.
4. Hire your first pilot.
5. Discover a new location.
6. Pay off your loan.
- **Test:** For each goal, a state summary just short of the condition reports it incomplete, and a summary that meets it reports it complete. Results are identical for identical inputs.
### Step 10A.6 — Schema and database
- Add to the database: company loan balance; tutorial runs completed; a deal-accepted flag; claimed goals per player; the repayment amount on each dispatch record; the `purchasable` flag reflected wherever ship models are listed.
- Add API endpoints to the route catalog: accept deal, repay loan (amount), claim goal (goal id).
- Player state gains: loan balance, tutorial runs completed, deal available (yes/no), and goals with status (incomplete, ready to claim, claimed).
- Add the `tutorial_active` error code.
- Update `architecture.md`.
- **Test:** Migrations run on a fresh database and are a no-op the second time. New schemas accept valid examples and reject invalid ones. The route catalog test (unique method and path, every endpoint has schemas) still passes.
### Step 10A.7 — Server: tutorial flow v2
- Guest creation: small starting credits, Company Courier loaner at Earth, avatar at Earth, tutorial runs completed = 0.
- The tutorial board endpoint uses the v2 generator.
- Each resolved tutorial dispatch increments tutorial runs completed.
- Deal available = tutorial is on, runs completed ≥ configured count, loaner is at Earth, and no tutorial run is in flight.
- Buying ships, leasing bays, and hiring pilots return `tutorial_active` while the tutorial is on.
- Update the Step 6.3, 6.4, 6.5, 6.6, and 6.8 tests to match.
- **Test:** After three runs, the deal is not available. After four runs ending at Earth, it is. With the configured count set to 3, the deal is not available until a fourth run returns the loaner to Earth. Buying a ship during the tutorial returns `tutorial_active` and changes nothing.
### Step 10A.8 — Server: accept the deal
- In one transaction: remove the loaner; create an owned Courier at Earth; create an Earth bay; assign the avatar to the Courier; set the loan balance to the deal loan amount; turn the tutorial off; set deal accepted.
- This replaces Step 6.7. Remove the old automatic tutorial-end rule and its tests.
- **Test:** Accepting when available produces exactly that state: one owned Courier at Earth crewed by the avatar, one Earth bay, the configured loan balance, tutorial off, no loaner. Accepting when not available fails with no changes. Accepting twice fails the second time. Two simultaneous accepts produce exactly one deal.
### Step 10A.9 — Server: loan repayment
- Resolution applies the repayment from Step 10A.4: reduce the loan balance, add only the net to credits, and store the repayment on the dispatch.
- The repay endpoint moves any amount up to the balance from credits to the loan. It uses the same row lock as Step 6.14.
- **Test:** A delivery with a loan balance increases credits by net and reduces the balance by the repayment. With no balance, the full amount goes to credits. Repaying more than the balance only takes the balance. Repaying more than available credits fails with no changes.
### Step 10A.10 — Server: goals
- Player state includes goal statuses evaluated with the Step 10A.5 function. Goals are only shown once the tutorial is over.
- The claim endpoint adds the reward once and marks the goal claimed.
- **Test:** Claiming a completed goal adds its reward. Claiming it again fails. Claiming an incomplete goal fails. During the tutorial, state contains no goals.
### Step 10A.11 — Client: first launch goes straight to work
- After "Start your company", navigate directly to the Contracts screen with a short intro card, for example: "Halvorsen Lunar Freight needs a pilot. Their ship, your hands. Get the crates to the Moon." Highlight the light contract as the suggested first pick.
- Update the Step 8.4 test.
- **Test:** Component test: after create-guest succeeds, the Contracts screen shows the intro card and a highlighted contract, and one tap on "Fly it" sends a dispatch.
### Step 10A.12 — Client: tutorial board shows the tradeoff
- Each tutorial contract shows pay, trip time, and XP side by side, so the light-versus-heavy choice is visible.
- The scripted contract has distinct styling. After it resolves, its flavor message appears in the delivery summary.
- Update the Step 9.3 tests.
- **Test:** Component test: three contracts show pay, time, and XP matching the sim. The scripted contract renders with its special style. A resolved scripted delivery shows its flavor message.
### Step 10A.13 — Client: the deal
- When the deal is available, show a modal with a short story beat (for example: "Old Halvorsen is retiring. He'll sell you his Courier and his Earth bay. Pay him back out of every job."), the ship and bay included, the loan amount, the repayment share, and a single "Shake on it" button.
- After accepting, go to HQ.
- **Test:** Component test: the modal appears only when state says the deal is available; it shows the configured loan amount and share; the button calls accept and navigates to HQ.
### Step 10A.14 — Client: loan on HQ
- Replace the old "save up for your first ship" goal on HQ (Step 9.1) with the loan: balance, a paid-off progress bar, and a "Pay down" action with an amount input.
- Delivery summaries show the repayment line when there is one.
- **Test:** Component test: the progress bar matches state; paying down calls the endpoint with the entered amount; amounts above credits or balance are blocked in the UI; a summary with a repayment shows it.
### Step 10A.15 — Client: goals on HQ
- Show the next three unclaimed goals with their rewards. Completed goals show a "Claim" button.
- **Test:** Component test: only three goals show; a ready goal shows Claim; claiming calls the endpoint and the next goal moves into view.
### Step 10A.16 — Client: whole map from the start
- The map is available from the first screen, including during the tutorial.
- Undiscovered locations are drawn dimmed with a lock. Tapping one shows "Undiscovered — deliver here to unlock" and which ship models can currently reach it from Earth (using the sim range check over purchasable models), for example "Reachable with: Freighter, Bulk Hauler".
- **Test:** Component test: undiscovered locations render as locked; the reachability list for Mars matches the sim range check for the mocked time; discovered locations render normally.
### Step 10A.17 — Client: something waiting on return
- If goals became ready while the player was away, list them in the "while you were away" modal with a link to claim.
- **Test:** Component test: with a seeded ready goal and a completed delivery, the modal shows both; the link navigates to HQ goals.
### Step 10A.18 — Plan amendments and regression
- Apply the amendments in §6 to `implementation-plan.md`.
- Run the full suite.
- **Test:** CI is green. Manual check: on a fresh guest account, the deal is accepted in under 10 real minutes of play, and the first contract after the deal shows a loan repayment in its summary.
## 6. Amendments to `implementation-plan.md`
| Step | Change |
|---|---|
| 2.5 | Replace the 15–25 runs test. New test: the total tutorial share earned over the configured tutorial runs, plus starting credits, covers fuel for at least three Courier Earth→Moon runs (no soft-lock after the deal). |
| 3.5 | Formula includes handling time (10A.2). |
| 3.10 | Superseded by 10A.3. |
| 6.3, 6.4 | Board size 3, v2 generator, runs-completed counter (10A.7). |
| 6.5, 6.6, 6.8 | Blocked during tutorial with `tutorial_active` (10A.7). |
| 6.7 | Replaced by 10A.8. |
| 8.4 | Lands on Contracts, not HQ (10A.11). |
| 9.1 | Tutorial savings goal replaced by loan and goals (10A.14, 10A.15). |
| 9.3 | Shows pay, time, and XP (10A.12). |
| 12.1 | Not built yet. Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds. |
| 12.2 | Replaced: use the balance script's measured numbers in the new Step 2.5 test. |
| 12.3 | Rewritten: guest → four tutorial runs → accept deal → state shows one owned Courier at Earth crewed by the avatar, one Earth bay, a loan balance, and tutorial off → one contract dispatched and resolved with a repayment applied. |
## 7. Out of scope
Interest, missed-payment penalties, or anything that can bankrupt the player. A decline option on the deal. Random events beyond the one scripted contract. Urgent/deadline contracts. Anything else in `implementation-plan.md` §0.3.

View File

@ -56,8 +56,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
1. **All three base ships are atmospheric-rated.** In the base game, ships are gated by range, capacity and speed. The landing-class check stays in the sim (it matters once Hardened ships arrive) and is tested with a test-only vacuum ship. 1. **All three base ships are atmospheric-rated.** In the base game, ships are gated by range, capacity and speed. The landing-class check stays in the sim (it matters once Hardened ships arrive) and is tested with a test-only vacuum ship.
2. **Pilot locations.** An assigned pilot is wherever their ship is. An unassigned pilot stays where they were unassigned. Pilots travel only by flying a ship. Unassigning is allowed only at a location with a player bay. New hires appear at a player bay the player chooses. 2. **Pilot locations.** An assigned pilot is wherever their ship is. An unassigned pilot stays where they were unassigned. Pilots travel only by flying a ship. Unassigning is allowed only at a location with a player bay. New hires appear at a player bay the player chooses.
3. **Tutorial movement.** The avatar and loaner move with each tutorial run. The tutorial board starts at the loaner's current location (Earth→Moon or Moon→Earth). The company pays fuel and pilot pay; the player receives only the tutorial share. 3. **Tutorial movement.** The avatar and loaner move with each tutorial run. The tutorial board starts at the loaner's current location (Earth→Moon or Moon→Earth). The company pays fuel and pilot pay; the player receives only the tutorial share. *(Amended by CR-001: the board has three contracts of different masses (0.5.31), and the loaner is a dedicated content ship (0.5.29).)*
4. **Tutorial end.** The tutorial ends when the player owns a ship, has an Earth bay, and has no tutorial run in flight. The loaner is removed and the avatar moves to Earth, unassigned. 4. **Tutorial end.** *(Replaced by CR-001.)* The tutorial ends when the player accepts the deal (0.5.30). Buying a ship and leasing an Earth bay no longer end it.
5. **Contract boards are stored.** A bay's board is generated on the first request in a period and stored. Later requests in that period return the stored board, so a discovery mid-period does not change it. Every contract expires at the end of its board period; nothing carries over. 5. **Contract boards are stored.** A bay's board is generated on the first request in a period and stored. Later requests in that period return the stored board, so a discovery mid-period does not change it. Every contract expires at the end of its board period; nothing carries over.
6. **Cargo tier.** Standard is tier 0. A non-Standard contract has a tier from 1 to 5, and that tier is the required level in the cargo's skill. 6. **Cargo tier.** Standard is tier 0. A non-Standard contract has a tier from 1 to 5, and that tier is the required level in the cargo's skill.
7. **Long Haul.** A config list of distance cut-offs, one per Long Haul level. The required Long Haul level is the number of cut-offs the route distance exceeds. 7. **Long Haul.** A config list of distance cut-offs, one per Long Haul level. The required Long Haul level is the number of cut-offs the route distance exceeds.
@ -69,12 +69,12 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
13. **Hull rating** is stored but has no effect in the base game. 13. **Hull rating** is stored but has no effect in the base game.
14. **Ship names** are generated from the world seed. No renaming. 14. **Ship names** are generated from the world seed. No renaming.
15. **Bays** have no ship limit. 15. **Bays** have no ship limit.
16. **Tutorial purchases.** The player may buy or lease anything they can afford during the tutorial. 16. **Tutorial purchases.** *(Replaced by CR-001.)* During the tutorial, buying ships, leasing bays and hiring pilots are blocked with a `tutorial-active` error.
17. **Cargo fallback.** If no cargo type is both produced at the origin and demanded at the destination, use Standard. 17. **Cargo fallback.** If no cargo type is both produced at the origin and demanded at the destination, use Standard.
18. **Testable clock.** The server reads time through an injectable clock. Tests call job handlers directly; E2E tests use a test-only clock-offset endpoint that does not exist in production. 18. **Testable clock.** The server reads time through an injectable clock. Tests call job handlers directly; E2E tests use a test-only clock-offset endpoint that does not exist in production.
19. **Summaries.** Dispatches have an acknowledged-at column; resolved dispatches without one form the "while you were away" summary. 19. **Summaries.** Dispatches have an acknowledged-at column; resolved dispatches without one form the "while you were away" summary.
20. **Same domain.** In production, the client and API share one domain, with the API under `/api`, so the session cookie is first-party (iOS Safari blocks cross-site cookies). 20. **Same domain.** In production, the client and API share one domain, with the API under `/api`, so the session cookie is first-party (iOS Safari blocks cross-site cookies).
21. **Tutorial boards per location.** The tutorial board is stored per period *and* per loaner location (one Earth→Moon board, one Moon→Earth board). The tutorial board size is set large enough that a player cannot exhaust it within one period. 21. **Tutorial boards per location.** The tutorial board is stored per period *and* per loaner location (one Earth→Moon board, one Moon→Earth board). *(Amended by CR-001: boards are also keyed by the number of tutorial runs completed, so each run gets a fresh three-contract board; the "large enough" size rule no longer applies.)*
22. **Pilot endpoints.** The route catalog includes "unassign pilot", and "hire pilot" takes the bay location to hire at. 22. **Pilot endpoints.** The route catalog includes "unassign pilot", and "hire pilot" takes the bay location to hire at.
23. **Avatar during tutorial.** The avatar is locked to the loaner and cannot be assigned to an owned ship until the tutorial ends. 23. **Avatar during tutorial.** The avatar is locked to the loaner and cannot be assigned to an owned ship until the tutorial ends.
24. **Repositions need no skills.** A reposition checks range and landing only; no Long Haul or cargo skill is required. 24. **Repositions need no skills.** A reposition checks range and landing only; no Long Haul or cargo skill is required.
@ -82,6 +82,12 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
26. **Loaner in player state.** Player state has a `tutorial` object with the loaner's location and whether a run is in flight. The loaner is not in the ships list. 26. **Loaner in player state.** Player state has a `tutorial` object with the loaner's location and whether a run is in flight. The loaner is not in the ships list.
27. **Pilot location storage.** Only unassigned pilots store a location; an assigned pilot's location is derived from their ship. 27. **Pilot location storage.** Only unassigned pilots store a location; an assigned pilot's location is derived from their ship.
28. **Git hosting.** The repo is on a self-hosted Gitea instance: `http://192.168.1.107:3000/rory/deadhead-space-freight-tycoon`. CI uses Gitea Actions. 28. **Git hosting.** The repo is on a self-hosted Gitea instance: `http://192.168.1.107:3000/rory/deadhead-space-freight-tycoon`. CI uses Gitea Actions.
29. **Loaner ship** (CR-001). The loaner is its own ship model, "Company Courier", marked not purchasable. It is faster and has lower overhead than the player's Courier so tutorial runs take under one real minute.
30. **The deal** (CR-001). Once the player has completed at least the configured number of tutorial runs and the loaner is at Earth with nothing in flight, a one-time deal becomes available. Accepting it removes the loaner, gives the player a Courier and an Earth bay, assigns the avatar to that Courier, sets the loan balance, and ends the tutorial. There is no decline option.
31. **Handling time** (CR-001). Travel time = distance ÷ cruise speed + overhead + cargo mass × handling time per tonne. Repositions carry zero mass. Light contracts earn more XP per minute; heavy contracts earn more credits per minute.
32. **Loan** (CR-001). The company has a loan balance. When a dispatch resolves with positive credits (after pilot pay), the lender takes min(balance, credits × repayment share). The player can pay down any amount up to the balance at any time. No interest, no missed payments, no fail state.
33. **Scripted tutorial contracts** (CR-001). Content can define special tutorial contracts keyed to a tutorial run number. A scripted contract replaces one board slot on that run and can carry a custom name, pay multiplier, and a flavor message shown after delivery.
34. **Onboarding goals** (CR-001). A fixed, ordered list of goals in content, each with a condition evaluated from player state and a one-time credit reward the player claims. Goals appear after the tutorial ends.
--- ---
@ -148,6 +154,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Set time compression to 60 (1 real minute = 1 game hour). - Set time compression to 60 (1 real minute = 1 game hour).
- Choose starting credits, tutorial payout share, and ship prices so that the cheapest ship plus an Earth bay lease is affordable after roughly 15–25 tutorial runs. - Choose starting credits, tutorial payout share, and ship prices so that the cheapest ship plus an Earth bay lease is affordable after roughly 15–25 tutorial runs.
- **Test:** A test validates the config through the schema. A second test computes (Courier price + Earth bay lease) ÷ average tutorial payout (using a fixed placeholder average defined in the test) and asserts it falls between 15 and 25. This test will be revisited in Phase 12 with real sim numbers. - **Test:** A test validates the config through the schema. A second test computes (Courier price + Earth bay lease) ÷ average tutorial payout (using a fixed placeholder average defined in the test) and asserts it falls between 15 and 25. This test will be revisited in Phase 12 with real sim numbers.
- **Amended by CR-001:** Replace the 15–25 runs test. New test: the total tutorial share earned over the configured tutorial runs, plus starting credits, covers fuel for at least three Courier Earth→Moon runs (no soft-lock after the deal).
### Step 2.6 — Export a single content bundle ### Step 2.6 — Export a single content bundle
- Export one `content` object containing all locations, ships, cargo types, skills, and config, plus lookup helpers by id. - Export one `content` object containing all locations, ships, cargo types, skills, and config, plus lookup helpers by id.
@ -177,6 +184,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 3.5 — Travel time ### Step 3.5 — Travel time
- Add a function: travel time = distance ÷ ship cruise speed + ship takeoff/landing overhead. Distance is measured at departure time (no mid-flight recalculation). - Add a function: travel time = distance ÷ ship cruise speed + ship takeoff/landing overhead. Distance is measured at departure time (no mid-flight recalculation).
- **Test:** For fixed inputs, the result matches a hand-calculated value. A faster ship always arrives sooner on the same route. Overhead dominates on Earth→Moon (overhead is more than half of total time for the Courier). - **Test:** For fixed inputs, the result matches a hand-calculated value. A faster ship always arrives sooner on the same route. Overhead dominates on Earth→Moon (overhead is more than half of total time for the Courier).
- **Amended by CR-001:** Travel time also includes cargo handling: + mass × handling time per tonne (Step 10A.2, decision 0.5.31).
### Step 3.6 — Fuel cost ### Step 3.6 — Fuel cost
- Add a function: fuel cost = distance × ship fuel cost per million km × origin location's launch multiplier. - Add a function: fuel cost = distance × ship fuel cost per million km × origin location's launch multiplier.
@ -199,6 +207,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 3.10 — Tutorial contract board ### Step 3.10 — Tutorial contract board
- Add a separate generator for the tutorial "company board": Earth↔Moon only, Standard cargo only, payout reduced by the tutorial payout share, always flown with the company loaner Courier. Contracts start at the loaner's current location: Earth→Moon when it is at Earth, Moon→Earth when it is at the Moon. - Add a separate generator for the tutorial "company board": Earth↔Moon only, Standard cargo only, payout reduced by the tutorial payout share, always flown with the company loaner Courier. Contracts start at the loaner's current location: Earth→Moon when it is at Earth, Moon→Earth when it is at the Moon.
- **Test:** With the loaner at Earth, every tutorial contract is Earth→Moon; with it at the Moon, every one is Moon→Earth. All use Standard cargo and pay exactly the tutorial share of the equivalent normal payout. - **Test:** With the loaner at Earth, every tutorial contract is Earth→Moon; with it at the Moon, every one is Moon→Earth. All use Standard cargo and pay exactly the tutorial share of the equivalent normal payout.
- **Amended by CR-001:** Superseded by Step 10A.3 (tutorial board v2: three mass tiers, scripted contracts, runs-completed input).
### Step 3.11 — Pilot eligibility ### Step 3.11 — Pilot eligibility
- Add a function that decides if a pilot can take a contract given their skill levels: the pilot's level in the contract's required skill must meet the required level, and the pilot's Long Haul level must meet the contract's required Long Haul level (decision 0.5.7). - Add a function that decides if a pilot can take a contract given their skill levels: the pilot's level in the contract's required skill must meet the required level, and the pilot's Long Haul level must meet the contract's required Long Haul level (decision 0.5.7).
@ -293,26 +302,32 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 6.3 — Tutorial board endpoint ### Step 6.3 — Tutorial board endpoint
- Return the tutorial board for the current period and the loaner's current location, stored on first request per (period, location) (decision 0.5.21), excluding contracts this player has already accepted. Only available while tutorial mode is on. - Return the tutorial board for the current period and the loaner's current location, stored on first request per (period, location) (decision 0.5.21), excluding contracts this player has already accepted. Only available while tutorial mode is on.
- **Test:** A tutorial player at Earth gets a board of the configured size, all Earth→Moon. A non-tutorial player gets an error. Accepted contracts disappear from the board. - **Test:** A tutorial player at Earth gets a board of the configured size, all Earth→Moon. A non-tutorial player gets an error. Accepted contracts disappear from the board.
- **Amended by CR-001:** Board size 3, v2 generator, stored per run as well as per period and location (Step 10A.7).
### Step 6.4 — Tutorial dispatch ### Step 6.4 — Tutorial dispatch
- Allow a tutorial player to dispatch their avatar pilot with the company loaner Courier on a tutorial contract. The loaner is not owned and cannot be used for anything else. Only one tutorial dispatch may be in flight at a time. - Allow a tutorial player to dispatch their avatar pilot with the company loaner Courier on a tutorial contract. The loaner is not owned and cannot be used for anything else. Only one tutorial dispatch may be in flight at a time.
- **Test:** A tutorial dispatch succeeds, charges no fuel, and creates an in-flight dispatch with the correct arrival time. A second tutorial dispatch while one is in flight is rejected. After arrival, state shows credits increased by exactly the tutorial share, pilot XP increased, and the loaner and avatar at the Moon; the next tutorial board is Moon→Earth. - **Test:** A tutorial dispatch succeeds, charges no fuel, and creates an in-flight dispatch with the correct arrival time. A second tutorial dispatch while one is in flight is rejected. After arrival, state shows credits increased by exactly the tutorial share, pilot XP increased, and the loaner and avatar at the Moon; the next tutorial board is Moon→Earth.
- **Amended by CR-001:** Each resolved tutorial run increments the runs-completed counter (Step 10A.7).
### Step 6.5 — Buy a ship ### Step 6.5 — Buy a ship
- Allow buying any ship model if the player has enough credits. A new ship appears idle at Earth with no pilot assigned. - Allow buying any ship model if the player has enough credits. A new ship appears idle at Earth with no pilot assigned.
- **Test:** Buying with enough credits deducts the price and adds the ship. Buying without enough credits returns the insufficient-funds error and changes nothing. - **Test:** Buying with enough credits deducts the price and adds the ship. Buying without enough credits returns the insufficient-funds error and changes nothing.
- **Amended by CR-001:** Blocked during the tutorial with `tutorial-active` (Step 10A.7, decision 0.5.16 as replaced).
### Step 6.6 — Lease a bay ### Step 6.6 — Lease a bay
- Allow leasing a bay at any discovered location where the player does not already have one, for that location's one-time lease price. - Allow leasing a bay at any discovered location where the player does not already have one, for that location's one-time lease price.
- **Test:** Leasing at Earth succeeds and deducts the price. Leasing at an undiscovered location fails. Leasing a second bay at the same location fails. - **Test:** Leasing at Earth succeeds and deducts the price. Leasing at an undiscovered location fails. Leasing a second bay at the same location fails.
- **Amended by CR-001:** Blocked during the tutorial with `tutorial-active` (Step 10A.7).
### Step 6.7 — End tutorial ### Step 6.7 — End tutorial
- When the player owns at least one ship, has a bay at Earth, and has no tutorial run in flight, tutorial mode turns off automatically. The loaner is removed and the avatar moves to Earth as a normal, unassigned pilot. - When the player owns at least one ship, has a bay at Earth, and has no tutorial run in flight, tutorial mode turns off automatically. The loaner is removed and the avatar moves to Earth as a normal, unassigned pilot.
- **Test:** With the avatar at the Moon, buying a ship and leasing an Earth bay turns tutorial mode off, removes the loaner, and leaves the avatar unassigned at Earth. If a tutorial run is in flight when the conditions are met, the tutorial ends when that run resolves. Afterward, the tutorial board endpoint returns an error. - **Test:** With the avatar at the Moon, buying a ship and leasing an Earth bay turns tutorial mode off, removes the loaner, and leaves the avatar unassigned at Earth. If a tutorial run is in flight when the conditions are met, the tutorial ends when that run resolves. Afterward, the tutorial board endpoint returns an error.
- **Amended by CR-001:** Replaced by Step 10A.8 (accepting the deal ends the tutorial; the automatic rule is removed).
### Step 6.8 — Hire and assign pilots ### Step 6.8 — Hire and assign pilots
- Hiring a pilot costs the configured fee and creates a level-0 pilot with a generated name (from the player seed) at a player bay the player chooses. Pilots can be assigned to an idle, uncrewed ship at the same location. Unassigning is allowed only at a location with a player bay. One pilot per ship (decision 0.5.2). - Hiring a pilot costs the configured fee and creates a level-0 pilot with a generated name (from the player seed) at a player bay the player chooses. Pilots can be assigned to an idle, uncrewed ship at the same location. Unassigning is allowed only at a location with a player bay. One pilot per ship (decision 0.5.2).
- **Test:** Hiring at the Moon bay deducts credits and places the pilot at the Moon; hiring at a location without a bay fails. Assigning to an idle ship at the same location succeeds; assigning to a ship at another location, to a crewed ship, or while in flight fails. Unassigning at a location without a bay fails. - **Test:** Hiring at the Moon bay deducts credits and places the pilot at the Moon; hiring at a location without a bay fails. Assigning to an idle ship at the same location succeeds; assigning to a ship at another location, to a crewed ship, or while in flight fails. Unassigning at a location without a bay fails.
- **Amended by CR-001:** Hiring is blocked during the tutorial with `tutorial-active` (Step 10A.7).
### Step 6.9 — Bay contract board endpoint ### Step 6.9 — Bay contract board endpoint
- Return the contract board for one of the player's bays for the current period. On the first request in a period, generate it with the sim using the player's discovered locations at that moment and store it in contract_boards; later requests in the period return the stored board. Exclude already-accepted contracts. Contracts from earlier periods are never shown. - Return the contract board for one of the player's bays for the current period. On the first request in a period, generate it with the sim using the player's discovered locations at that moment and store it in contract_boards; later requests in the period return the stored board. Exclude already-accepted contracts. Contracts from earlier periods are never shown.
@ -374,6 +389,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 8.4 — Guest onboarding ### Step 8.4 — Guest onboarding
- On first launch with no session, show a start screen with a "Start your company" button that creates a guest account. Persist the session. - On first launch with no session, show a start screen with a "Start your company" button that creates a guest account. Persist the session.
- **Test:** Component test: clicking the button calls create-guest and navigates to HQ. Reloading with a stored session skips the start screen. - **Test:** Component test: clicking the button calls create-guest and navigates to HQ. Reloading with a stored session skips the start screen.
- **Amended by CR-001:** After creating the guest, the client lands on Contracts, not HQ (Step 10A.11).
### Step 8.5 — Player state store ### Step 8.5 — Player state store
- Use TanStack Query to load player state, refetching when the app regains focus and when any in-flight dispatch's arrival time passes. - Use TanStack Query to load player state, refetching when the app regains focus and when any in-flight dispatch's arrival time passes.
@ -386,6 +402,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 9.1 — HQ screen ### Step 9.1 — HQ screen
- Show credits, game date/time, counts of ships idle/in flight, and the next arrival with a countdown. During tutorial, show a clear goal: "Save up for your first ship and an Earth bay," with progress toward the combined cost. - Show credits, game date/time, counts of ships idle/in flight, and the next arrival with a countdown. During tutorial, show a clear goal: "Save up for your first ship and an Earth bay," with progress toward the combined cost.
- **Test:** Component test with seeded state shows correct counts and countdown. Tutorial goal progress shows the correct percentage. - **Test:** Component test with seeded state shows correct counts and countdown. Tutorial goal progress shows the correct percentage.
- **Amended by CR-001:** The tutorial savings goal is replaced by tutorial run progress, the loan card and goals (Steps 10A.14, 10A.15).
### Step 9.2 — "While you were away" summary ### Step 9.2 — "While you were away" summary
- When state contains unacknowledged summaries, show a modal listing each completed delivery (ship, route, credits, XP, discoveries). Dismissing acknowledges them. - When state contains unacknowledged summaries, show a modal listing each completed delivery (ship, route, credits, XP, discoveries). Dismissing acknowledges them.
@ -394,6 +411,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 9.3 — Tutorial contract board ### Step 9.3 — Tutorial contract board
- During tutorial, the Contracts screen shows the tutorial board: route, cargo, payout, estimated trip time (computed with sim), and a "Fly it" button. The button is disabled while a tutorial run is in flight. - During tutorial, the Contracts screen shows the tutorial board: route, cargo, payout, estimated trip time (computed with sim), and a "Fly it" button. The button is disabled while a tutorial run is in flight.
- **Test:** Component test shows the board with trip times matching the sim. "Fly it" calls dispatch; it is disabled while a run is in flight. - **Test:** Component test shows the board with trip times matching the sim. "Fly it" calls dispatch; it is disabled while a run is in flight.
- **Amended by CR-001:** Each tutorial contract shows pay, trip time and XP; scripted contracts are styled (Step 10A.12).
### Step 9.4 — Bay contract boards ### Step 9.4 — Bay contract boards
- After tutorial, the Contracts screen has a selector for the player's bays and shows each bay's board. Each contract shows route, cargo type, mass, distance, payout, required skill, expiry countdown, and a first-contact badge where applicable. - After tutorial, the Contracts screen has a selector for the player's bays and shows each bay's board. Each contract shows route, cargo type, mass, distance, payout, required skill, expiry countdown, and a first-contact badge where applicable.
@ -442,6 +460,12 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
--- ---
## Phase 10A — Faster Start (CR-001)
Steps 10A.1–10A.18 are specified in `memory-bank/change-requests/CR-001-faster-start.md` §5, with its §6 amendments to earlier steps. Done before Phase 11; stop for review at the end.
---
## Phase 11 — PWA ## Phase 11 — PWA
### Step 11.1 — Installable PWA ### Step 11.1 — Installable PWA
@ -460,14 +484,17 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Add a script in `packages/sim` that simulates a player making reasonable choices (always take the highest-paying eligible contract, buy the cheapest ship when affordable, lease Earth bay, then hire pilots) over a configurable span of real time, using only sim functions. - Add a script in `packages/sim` that simulates a player making reasonable choices (always take the highest-paying eligible contract, buy the cheapest ship when affordable, lease Earth bay, then hire pilots) over a configurable span of real time, using only sim functions.
- Output: real time to finish the tutorial, real time to own 3 ships, credits over time, and pilot levels over time. - Output: real time to finish the tutorial, real time to own 3 ships, credits over time, and pilot levels over time.
- **Test:** Running the script with a fixed seed produces identical output twice. With default content, tutorial completion falls between 45 and 120 real minutes of active play. (Adjust content values, not the test thresholds, if it fails; record changes in `DECISIONS.md`.) - **Test:** Running the script with a fixed seed produces identical output twice. With default content, tutorial completion falls between 45 and 120 real minutes of active play. (Adjust content values, not the test thresholds, if it fails; record changes in `DECISIONS.md`.)
- **Amended by CR-001:** Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds.
### Step 12.2 — Revisit the tutorial economy test ### Step 12.2 — Revisit the tutorial economy test
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script. - Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.
- **Test:** The updated Step 2.5 test passes with real sim numbers. - **Test:** The updated Step 2.5 test passes with real sim numbers.
- **Amended by CR-001:** Replaced: use the balance script's measured numbers in the new Step 2.5 test.
### Step 12.3 — End-to-end: tutorial to first ship ### Step 12.3 — End-to-end: tutorial to first ship
- Playwright test against a real server and test database, with the server clock controllable via a test-only setting: start as guest → fly tutorial runs (advancing the clock) until affordable → buy a Courier → lease Earth bay → tutorial ends. - Playwright test against a real server and test database, with the server clock controllable via a test-only setting: start as guest → fly tutorial runs (advancing the clock) until affordable → buy a Courier → lease Earth bay → tutorial ends.
- **Test:** The E2E test passes and final state shows one owned ship, one Earth bay, and tutorial mode off. - **Test:** The E2E test passes and final state shows one owned ship, one Earth bay, and tutorial mode off.
- **Amended by CR-001:** Rewritten: guest → four tutorial runs → accept deal → state shows one owned Courier at Earth crewed by the avatar, one Earth bay, a loan balance, and tutorial off → one contract dispatched and resolved with a repayment applied.
### Step 12.4 — End-to-end: offline progress ### Step 12.4 — End-to-end: offline progress
- Playwright test: dispatch a contract, close the page, advance the server clock past arrival, reopen. - Playwright test: dispatch a contract, close the page, advance the server clock past arrival, reopen.

View File

@ -101,3 +101,26 @@
- [x] **Step 10.1 — PixiJS map component.** Map tab (`/map`, lazy-loaded) with a PixiJS v8 canvas (`src/map/MapCanvas.tsx`): Sun, orbit rings, five locations (dimmed if undiscovered, ring if a bay), drag to pan, pinch or wheel to zoom, "Reset view". Square-root radial scale and screen projection in `src/map/projection.ts`, camera in `src/map/camera.ts`. Tests: scale monotonic, locations fit a 375 px map for 12 years, zoom keeps the anchor fixed. Manual phone check pending. - [x] **Step 10.1 — PixiJS map component.** Map tab (`/map`, lazy-loaded) with a PixiJS v8 canvas (`src/map/MapCanvas.tsx`): Sun, orbit rings, five locations (dimmed if undiscovered, ring if a bay), drag to pan, pinch or wheel to zoom, "Reset view". Square-root radial scale and screen projection in `src/map/projection.ts`, camera in `src/map/camera.ts`. Tests: scale monotonic, locations fit a 375 px map for 12 years, zoom keeps the anchor fixed. Manual phone check pending.
- [x] **Step 10.2 — Ships on the map.** `flightMarker` / `buildScene` (`src/map/scene.ts`): each in-flight dispatch draws a route line (origin at departure → destination at arrival) with a marker placed by sim progress every frame; idle ships (tutorial loaner included) show as a count badge at their location. Test: marker at 50% lies halfway along the route line on screen. Manual smoothness check pending. - [x] **Step 10.2 — Ships on the map.** `flightMarker` / `buildScene` (`src/map/scene.ts`): each in-flight dispatch draws a route line (origin at departure → destination at arrival) with a marker placed by sim progress every frame; idle ships (tutorial loaner included) show as a count badge at their location. Test: marker at 50% lies halfway along the route line on screen. Manual smoothness check pending.
- [x] **Step 10.3 — Map interactions.** Taps (single pointer, under 8 px of movement) are hit-tested (`src/map/hitTest.ts`, 22 px reach, ships win ties). A location panel shows details (or "Undiscovered") and, with a bay, a link to `/contracts?bay=…` which preselects that board; a ship panel shows route, pilot and ETA. Component tests tap through a canvas test double that uses the real scene. - [x] **Step 10.3 — Map interactions.** Taps (single pointer, under 8 px of movement) are hit-tested (`src/map/hitTest.ts`, 22 px reach, ships win ties). A location panel shows details (or "Undiscovered") and, with a bay, a link to `/contracts?bay=…` which preselects that board; a ship panel shows route, pilot and ETA. Component tests tap through a canvas test double that uses the real scene.
## Phase 10A — Faster Start (CR-001)
Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded as plan §0.5.29–0.5.34.
- [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner.
- [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated.
- [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7.
- [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9.
- [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%.
- [x] **Step 10A.6 — Schema and database.** Migration `0002_cr001_faster_start`: `companies.loan_balance`, `players.tutorial_runs_completed` / `deal_accepted`, `contract_boards.tutorial_run` (in the unique key), table `claimed_goals`; repayment stored in each dispatch's resolution JSON. API: `company.loanBalance`, `tutorial.runsCompleted` / `dealAvailable`, `goals`, summary `loanRepayment` / `scriptedId`; routes `acceptDeal`, `repayLoan`, `claimGoal`; error codes `tutorial-active`, `deal-not-available`, `unknown-goal`, `goal-not-ready`, `goal-already-claimed`. `architecture.md` updated.
- [x] **Step 10A.7 — Server: tutorial flow v2.** Tutorial boards come from the v2 generator, stored per run and period; each resolved tutorial run increments `tutorial_runs_completed`; `dealAvailable` in state; buying ships, leasing bays and hiring pilots return `409 tutorial-active` during the tutorial. Tests for 6.5/6.6/6.8/6.14 now start from an owner; old Step 6.7 tests removed (their trigger is now impossible; the rule itself goes in 10A.8).
- [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`).
- [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.
- [x] **Step 10A.12 — Client: tutorial board shows the tradeoff.** Each tutorial contract shows Pay, Time and XP side by side (time and XP from sim); scripted contracts get their own name and fuchsia styling; their flavor message appears in the delivery summary. Step 9.3 tests updated.
- [x] **Step 10A.13 — Client: the deal.** `src/components/DealModal.tsx`: when `tutorial.dealAvailable` (and no delivery summary is open), a dialog with the Halvorsen story beat, the Courier and Earth bay, the loan amount and repayment share, and one "Shake on it" button that calls `acceptDeal`, stores the returned state and goes to HQ.
- [x] **Step 10A.14 — Client: loan on HQ.** HQ's savings goal is replaced: during the tutorial a "Company runs: n of 4" card; afterwards `LoanCard` (balance, paid-off bar, "Pay down" with amounts above credits or balance blocked in the UI). Delivery summaries show "X cr went to the lender". Step 9.1 tutorial-goal tests replaced.
- [x] **Step 10A.15 — Client: goals on HQ.** `src/components/GoalsCard.tsx` (`#goals`): the next three unclaimed goals with rewards; ready goals have "Claim", which calls `claimGoal`; the refreshed state brings the next goal into view. Hidden during the tutorial.
- [x] **Step 10A.16 — Client: whole map from the start.** The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (`src/map/reach.ts`).
- [x] **Step 10A.17 — Client: something waiting on return.** The "while you were away" dialog lists goals ready to claim, with a "Claim on HQ" link that dismisses it and opens HQ's goals.
- [x] **Step 10A.18 — Plan amendments and regression.** CR-001 §6 amendments added to `implementation-plan.md` (steps 2.5, 3.5, 3.10, 6.3–6.8, 8.4, 9.1, 9.3, 12.1–12.3). Full suite and CI green. End-to-end smoke against the real server: four light tutorial runs take 1.9 real minutes of flying, the deal appears at Earth and produces the expected state. **Open issue:** after the deal the Earth board usually has no contract a Courier (20 t) can carry, because bay contracts draw mass uniformly from 5–400 t; awaiting the user's decision. Manual check pending.

View File

@ -1,6 +1,7 @@
import { cargoTypes, skills } from "./data/cargo"; import { cargoTypes, skills } from "./data/cargo";
import { gameConfig } from "./data/config"; import { gameConfig } from "./data/config";
import { locations } from "./data/locations"; import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding";
import { shipModels } from "./data/ships"; import { shipModels } from "./data/ships";
/** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */ /** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */
@ -13,12 +14,17 @@ function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T
export const content = { export const content = {
locations, locations,
shipModels, shipModels,
/** Ship models players can buy (excludes company ships such as the loaner). */
purchasableShipModels: shipModels.filter((s) => s.purchasable),
cargoTypes, cargoTypes,
skills, skills,
config: gameConfig, config: gameConfig,
scriptedTutorialContracts,
onboardingGoals,
location: byId(locations), location: byId(locations),
shipModel: byId(shipModels), shipModel: byId(shipModels),
cargoType: byId(cargoTypes), cargoType: byId(cargoTypes),
skill: byId(skills), skill: byId(skills),
onboardingGoal: byId(onboardingGoals),
}; };
export type Content = typeof content; export type Content = typeof content;

View File

@ -9,11 +9,20 @@ export const gameConfig: GameConfig = {
realStart: "2026-01-01T00:00:00Z", realStart: "2026-01-01T00:00:00Z",
ephemerisDate: "2100-01-01T00:00:00Z", ephemerisDate: "2100-01-01T00:00:00Z",
}, },
startingCredits: 0, // A small cushion: a couple of Courier fuel loads from Earth (CR-001).
startingCredits: 500,
tutorialPayoutShare: 0.5, tutorialPayoutShare: 0.5,
contractBoardSize: 8, contractBoardSize: 8,
// A tutorial run takes about 2 real minutes, so one hour-long period allows ~30. // One tutorial contract per mass tier (CR-001).
tutorialBoardSize: 40, tutorialBoardSize: 3,
tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 4,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
// 20 t adds 0.4 game hours (24 real seconds) of loading and unloading.
handlingGameHoursPerTonne: 0.02,
loanRepaymentShare: 0.3,
// Courier price + Earth bay lease.
dealLoanAmount: 5000,
contractBoardPeriodRealMinutes: 60, contractBoardPeriodRealMinutes: 60,
firstContactChance: 0.1, firstContactChance: 0.1,
firstContactPayoutMultiplier: 2, firstContactPayoutMultiplier: 2,

View File

@ -0,0 +1,54 @@
import type { OnboardingGoal, ScriptedTutorialContract } from "../schemas";
/** Special tutorial contracts by run (decision 0.5.33). */
export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
{
id: "unmarked-crates",
run: 3,
name: "Unmarked crates",
cargoTypeId: "standard",
payMultiplier: 2,
flavorMessage:
"Nobody asked what was inside. You got paid. Licensed hazardous-cargo pilots earn this kind of money every run.",
},
];
/** First-session goals, in the order they are shown (decision 0.5.34). Rewards are first guesses. */
export const onboardingGoals: OnboardingGoal[] = [
{
id: "first-own-delivery",
title: "Deliver a contract with your own ship",
condition: { kind: "own-ship-deliveries", count: 1 },
reward: 300,
},
{
id: "first-skill-point",
title: "Spend your first skill point",
condition: { kind: "skill-points-spent", count: 1 },
reward: 300,
},
{
id: "second-ship",
title: "Buy a second ship",
condition: { kind: "ships-owned", count: 2 },
reward: 1000,
},
{
id: "first-hire",
title: "Hire your first pilot",
condition: { kind: "pilots-hired", count: 1 },
reward: 1000,
},
{
id: "first-discovery",
title: "Discover a new location",
condition: { kind: "locations-discovered", count: 1 },
reward: 2000,
},
{
id: "loan-paid-off",
title: "Pay off your loan",
condition: { kind: "loan-paid-off" },
reward: 2500,
},
];

View File

@ -19,6 +19,7 @@ export const shipModels: ShipModel[] = [
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
fuelCostPerMillionKm: 200, fuelCostPerMillionKm: 200,
purchasable: true,
}, },
{ {
id: "freighter", id: "freighter",
@ -33,6 +34,7 @@ export const shipModels: ShipModel[] = [
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 4, overheadGameHours: 4,
fuelCostPerMillionKm: 400, fuelCostPerMillionKm: 400,
purchasable: true,
}, },
{ {
id: "bulk-hauler", id: "bulk-hauler",
@ -47,5 +49,22 @@ export const shipModels: ShipModel[] = [
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 8, overheadGameHours: 8,
fuelCostPerMillionKm: 800, fuelCostPerMillionKm: 800,
purchasable: true,
},
{
// The company's tutorial loaner (decision 0.5.29): not for sale. Faster with
// less overhead than the Courier, so a full Earth–Moon run is under a real minute.
id: "company-courier",
name: "Company Courier",
archetype: "courier",
price: 4000,
cruiseSpeedKmPerGameHour: 6_000_000,
cargoCapacityTonnes: 20,
fuelRangeMillionKm: 5,
hullRating: 1,
landingClass: "atmospheric",
overheadGameHours: 0.3,
fuelCostPerMillionKm: 200,
purchasable: false,
}, },
]; ];

View File

@ -3,4 +3,5 @@ export * from "./data/locations";
export * from "./data/ships"; export * from "./data/ships";
export * from "./data/cargo"; export * from "./data/cargo";
export * from "./data/config"; export * from "./data/config";
export * from "./data/onboarding";
export * from "./bundle"; export * from "./bundle";

View File

@ -67,6 +67,8 @@ export const shipModelSchema = z.object({
overheadGameHours: z.number().nonnegative(), overheadGameHours: z.number().nonnegative(),
/** Credits per million km, before the origin's launch multiplier. */ /** Credits per million km, before the origin's launch multiplier. */
fuelCostPerMillionKm: z.number().nonnegative(), fuelCostPerMillionKm: z.number().nonnegative(),
/** False for company-only ships such as the tutorial loaner (decision 0.5.29). */
purchasable: z.boolean(),
}); });
export type ShipModel = z.infer<typeof shipModelSchema>; export type ShipModel = z.infer<typeof shipModelSchema>;
@ -102,8 +104,28 @@ export const gameConfigSchema = z.object({
/** Fraction of a normal payout the player receives for a tutorial run. */ /** Fraction of a normal payout the player receives for a tutorial run. */
tutorialPayoutShare: fraction, tutorialPayoutShare: fraction,
contractBoardSize: positiveInt, contractBoardSize: positiveInt,
/** Large enough that a player cannot exhaust it within one period (decision 0.5.21). */ /** Contracts per tutorial board: one per mass tier (CR-001). */
tutorialBoardSize: positiveInt, tutorialBoardSize: positiveInt,
/** Ship model the company lends during the tutorial (decision 0.5.29). */
tutorialLoanerShipModelId: idSchema,
/** Tutorial runs completed before the deal is offered (decision 0.5.30). */
tutorialRunsBeforeDeal: positiveInt,
/** Cargo mass of the three tutorial contracts (decision 0.5.31). */
tutorialMassTonnes: z
.object({
light: z.number().positive(),
medium: z.number().positive(),
full: z.number().positive(),
})
.refine((t) => t.light < t.medium && t.medium < t.full, {
message: "Tiers must increase: light < medium < full",
}),
/** Loading and unloading time per tonne of cargo, in game hours (decision 0.5.31). */
handlingGameHoursPerTonne: z.number().nonnegative(),
/** Share of each positive payout the lender takes until the loan is repaid (0.5.32). */
loanRepaymentShare: fraction,
/** Loan taken on with the deal, in credits (decision 0.5.30). */
dealLoanAmount: positiveInt,
contractBoardPeriodRealMinutes: z.number().positive(), contractBoardPeriodRealMinutes: z.number().positive(),
firstContactChance: fraction, firstContactChance: fraction,
firstContactPayoutMultiplier: z.number().positive(), firstContactPayoutMultiplier: z.number().positive(),
@ -126,3 +148,45 @@ export const gameConfigSchema = z.object({
pilotPayPerDelivery: nonNegativeInt, pilotPayPerDelivery: nonNegativeInt,
}); });
export type GameConfig = z.infer<typeof gameConfigSchema>; export type GameConfig = z.infer<typeof gameConfigSchema>;
/**
* A special tutorial contract on one run (decision 0.5.33). It replaces the
* medium slot of that run's board.
*/
export const scriptedTutorialContractSchema = z.object({
id: idSchema,
/** The tutorial run (1-based) whose board carries this contract. */
run: positiveInt,
name: z.string().min(1),
cargoTypeId: idSchema,
/** Multiplies the normal tutorial payout. */
payMultiplier: z.number().positive(),
/** Shown in the delivery summary once the contract is delivered. */
flavorMessage: z.string().min(1),
});
export type ScriptedTutorialContract = z.infer<typeof scriptedTutorialContractSchema>;
/** What completes an onboarding goal, checked against a player state summary. */
export const goalConditionSchema = z.discriminatedUnion("kind", [
/** Contracts delivered by the player's own ships (not tutorial runs). */
z.object({ kind: z.literal("own-ship-deliveries"), count: positiveInt }),
/** Skill points spent across all pilots. */
z.object({ kind: z.literal("skill-points-spent"), count: positiveInt }),
z.object({ kind: z.literal("ships-owned"), count: positiveInt }),
z.object({ kind: z.literal("pilots-hired"), count: positiveInt }),
/** Locations discovered beyond the ones that start discovered. */
z.object({ kind: z.literal("locations-discovered"), count: positiveInt }),
/** The deal's loan is fully repaid. */
z.object({ kind: z.literal("loan-paid-off") }),
]);
export type GoalCondition = z.infer<typeof goalConditionSchema>;
/** One onboarding goal with a one-time credit reward (decision 0.5.34). */
export const onboardingGoalSchema = z.object({
id: idSchema,
title: z.string().min(1),
condition: goalConditionSchema,
/** Credits, claimed once. */
reward: positiveInt,
});
export type OnboardingGoal = z.infer<typeof onboardingGoalSchema>;

View File

@ -4,7 +4,8 @@ import { content } from "../src/index";
describe("content bundle", () => { describe("content bundle", () => {
it("contains every kind of content", () => { it("contains every kind of content", () => {
expect(content.locations).toHaveLength(5); expect(content.locations).toHaveLength(5);
expect(content.shipModels).toHaveLength(3); expect(content.shipModels).toHaveLength(4);
expect(content.purchasableShipModels).toHaveLength(3);
expect(content.cargoTypes).toHaveLength(4); expect(content.cargoTypes).toHaveLength(4);
expect(content.skills).toHaveLength(4); expect(content.skills).toHaveLength(4);
expect(content.config.timeCompression).toBe(60); expect(content.config.timeCompression).toBe(60);

View File

@ -8,9 +8,7 @@ import {
skills, skills,
} from "../src/index"; } from "../src/index";
// Placeholder average tutorial payout until Phase 12 uses real sim numbers: // The Moon sits a fixed 0.384M km from Earth (Step 3.3).
// Standard cargo over ~0.384M km (Earth–Moon), at the average mass a Courier
// can carry from the configured range, times the tutorial share.
const EARTH_MOON_MILLION_KM = 0.384; const EARTH_MOON_MILLION_KM = 0.384;
describe("game config", () => { describe("game config", () => {
@ -27,20 +25,36 @@ describe("game config", () => {
expect(gameConfig.longHaulCutoffsMillionKm).toHaveLength(longHaul.maxLevel); expect(gameConfig.longHaulCutoffsMillionKm).toHaveLength(longHaul.maxLevel);
}); });
it("makes a Courier plus an Earth bay affordable after 15–25 tutorial runs", () => { it("covers fuel for at least three Courier runs from Earth after the deal (no soft-lock)", () => {
// Amended Step 2.5 (CR-001). Assume the player always flies the lowest-paying
// (light) tutorial contract; Phase 12 replaces these with measured numbers.
const courier = shipModels.find((s) => s.id === "courier")!; const courier = shipModels.find((s) => s.id === "courier")!;
const earth = locations.find((l) => l.id === "earth")!; const earth = locations.find((l) => l.id === "earth")!;
const standard = cargoTypes.find((c) => c.id === "standard")!; const standard = cargoTypes.find((c) => c.id === "standard")!;
const averageMass = (gameConfig.contractMassTonnes.min + courier.cargoCapacityTonnes) / 2; const lightTutorialPayout =
const averageTutorialPayout =
standard.basePayPerTonnePerMillionKm * standard.basePayPerTonnePerMillionKm *
averageMass * gameConfig.tutorialMassTonnes.light *
EARTH_MOON_MILLION_KM * EARTH_MOON_MILLION_KM *
gameConfig.tutorialPayoutShare; gameConfig.tutorialPayoutShare;
const earned =
lightTutorialPayout * gameConfig.tutorialRunsBeforeDeal + gameConfig.startingCredits;
const fuelFromEarth =
EARTH_MOON_MILLION_KM * courier.fuelCostPerMillionKm * earth.launchCostMultiplier;
expect(earned).toBeGreaterThanOrEqual(3 * fuelFromEarth);
});
const runs = (courier.price + earth.bayLeasePrice) / averageTutorialPayout; it("sizes the tutorial board and loaner for the three mass tiers", () => {
expect(runs).toBeGreaterThanOrEqual(15); expect(gameConfig.tutorialBoardSize).toBe(3);
expect(runs).toBeLessThanOrEqual(25); const loaner = shipModels.find((s) => s.id === gameConfig.tutorialLoanerShipModelId)!;
expect(loaner.purchasable).toBe(false);
expect(gameConfig.tutorialMassTonnes.full).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
});
it("lends the Courier price plus an Earth bay lease in the deal", () => {
const courier = shipModels.find((s) => s.id === "courier")!;
const earth = locations.find((l) => l.id === "earth")!;
expect(gameConfig.dealLoanAmount).toBeGreaterThan(0);
expect(gameConfig.dealLoanAmount).toBe(courier.price + earth.bayLeasePrice);
}); });
}); });

View File

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
content,
onboardingGoalSchema,
onboardingGoals,
scriptedTutorialContractSchema,
scriptedTutorialContracts,
} from "../src/index";
describe("loaner ship", () => {
it("is not in the Market's purchasable list", () => {
const ids = content.purchasableShipModels.map((s) => s.id);
expect(ids).not.toContain("company-courier");
expect(ids).toEqual(["courier", "freighter", "bulk-hauler"]);
});
it("is faster with less overhead than the Courier", () => {
const loaner = content.shipModel("company-courier")!;
const courier = content.shipModel("courier")!;
expect(loaner.cruiseSpeedKmPerGameHour).toBeGreaterThan(courier.cruiseSpeedKmPerGameHour);
expect(loaner.overheadGameHours).toBeLessThan(courier.overheadGameHours);
});
});
describe("scripted tutorial contracts", () => {
it("validate and reference real cargo and a tutorial run before the deal", () => {
for (const scripted of scriptedTutorialContracts) {
expect(() => scriptedTutorialContractSchema.parse(scripted)).not.toThrow();
expect(content.cargoType(scripted.cargoTypeId)).toBeDefined();
expect(scripted.run).toBeLessThanOrEqual(content.config.tutorialRunsBeforeDeal);
}
});
it("include Unmarked crates on run 3 at double pay", () => {
expect(scriptedTutorialContracts).toContainEqual(
expect.objectContaining({
name: "Unmarked crates",
run: 3,
payMultiplier: 2,
cargoTypeId: "standard",
}),
);
});
});
describe("onboarding goals", () => {
it("validate, have unique ids and are in the specified order", () => {
for (const goal of onboardingGoals) {
expect(() => onboardingGoalSchema.parse(goal)).not.toThrow();
expect(content.onboardingGoal(goal.id)).toBe(goal);
}
expect(new Set(onboardingGoals.map((g) => g.id)).size).toBe(onboardingGoals.length);
expect(onboardingGoals.map((g) => g.condition.kind)).toEqual([
"own-ship-deliveries",
"skill-points-spent",
"ships-owned",
"pilots-hired",
"locations-discovered",
"loan-paid-off",
]);
});
});

View File

@ -4,6 +4,8 @@ import {
cargoTypeSchema, cargoTypeSchema,
gameConfigSchema, gameConfigSchema,
locationSchema, locationSchema,
onboardingGoalSchema,
scriptedTutorialContractSchema,
shipModelSchema, shipModelSchema,
skillSchema, skillSchema,
} from "../src/index"; } from "../src/index";
@ -33,6 +35,7 @@ const validShipModel = {
landingClass: "atmospheric", landingClass: "atmospheric",
overheadGameHours: 2, overheadGameHours: 2,
fuelCostPerMillionKm: 100, fuelCostPerMillionKm: 100,
purchasable: true,
}; };
const validCargoType = { const validCargoType = {
@ -67,6 +70,28 @@ const validGameConfig = {
skillPointsPerLevel: 1, skillPointsPerLevel: 1,
pilotHireCost: 1000, pilotHireCost: 1000,
pilotPayPerDelivery: 100, pilotPayPerDelivery: 100,
tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 4,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
handlingGameHoursPerTonne: 0.02,
loanRepaymentShare: 0.3,
dealLoanAmount: 5000,
};
const validScripted = {
id: "unmarked-crates",
run: 3,
name: "Unmarked crates",
cargoTypeId: "standard",
payMultiplier: 2,
flavorMessage: "Nobody asked.",
};
const validGoal = {
id: "first-hire",
title: "Hire your first pilot",
condition: { kind: "pilots-hired", count: 1 },
reward: 1000,
}; };
const cases: [string, z.ZodType, Record<string, unknown>][] = [ const cases: [string, z.ZodType, Record<string, unknown>][] = [
@ -75,6 +100,8 @@ const cases: [string, z.ZodType, Record<string, unknown>][] = [
["CargoType", cargoTypeSchema, validCargoType], ["CargoType", cargoTypeSchema, validCargoType],
["Skill", skillSchema, validSkill], ["Skill", skillSchema, validSkill],
["GameConfig", gameConfigSchema, validGameConfig], ["GameConfig", gameConfigSchema, validGameConfig],
["ScriptedTutorialContract", scriptedTutorialContractSchema, validScripted],
["OnboardingGoal", onboardingGoalSchema, validGoal],
]; ];
describe.each(cases)("%s schema", (_name, schema, valid) => { describe.each(cases)("%s schema", (_name, schema, valid) => {
@ -90,6 +117,16 @@ describe.each(cases)("%s schema", (_name, schema, valid) => {
}); });
describe("GameConfig refinements", () => { describe("GameConfig refinements", () => {
it("rejects tutorial mass tiers that do not increase", () => {
const config = { ...validGameConfig, tutorialMassTonnes: { light: 12, medium: 12, full: 20 } };
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
it("rejects a goal condition of an unknown kind", () => {
const goal = { ...validGoal, condition: { kind: "win-the-game" } };
expect(onboardingGoalSchema.safeParse(goal).success).toBe(false);
});
it("rejects a mass range with min above max", () => { it("rejects a mass range with min above max", () => {
const config = { ...validGameConfig, contractMassTonnes: { min: 50, max: 10 } }; const config = { ...validGameConfig, contractMassTonnes: { min: 50, max: 10 } };
expect(gameConfigSchema.safeParse(config).success).toBe(false); expect(gameConfigSchema.safeParse(config).success).toBe(false);

View File

@ -11,7 +11,12 @@ describe("ship models", () => {
}); });
it("has the three archetypes, all atmospheric", () => { it("has the three archetypes, all atmospheric", () => {
expect(shipModels.map((s) => s.id)).toEqual(["courier", "freighter", "bulk-hauler"]); expect(shipModels.map((s) => s.id)).toEqual([
"courier",
"freighter",
"bulk-hauler",
"company-courier",
]);
for (const model of shipModels) { for (const model of shipModels) {
expect(model.landingClass).toBe("atmospheric"); expect(model.landingClass).toBe("atmospheric");
} }

View File

@ -22,6 +22,8 @@ export const companySchema = z.object({
name: z.string().min(1), name: z.string().min(1),
/** May go negative only through pilot pay on a low payout (see DECISIONS Step 3.17). */ /** May go negative only through pilot pay on a low payout (see DECISIONS Step 3.17). */
credits, credits,
/** Owed to the lender from the deal (decision 0.5.32); 0 when paid off or never taken. */
loanBalance: credits.nonnegative(),
}); });
export type Company = z.infer<typeof companySchema>; export type Company = z.infer<typeof companySchema>;
@ -80,6 +82,8 @@ export const contractSchema = z.object({
offeredAt: timeSchema, offeredAt: timeSchema,
expiresAt: timeSchema, expiresAt: timeSchema,
firstContact: z.boolean(), firstContact: z.boolean(),
/** Set on a scripted tutorial contract (decision 0.5.33). */
scriptedId: idSchema.optional(),
}); });
export type Contract = z.infer<typeof contractSchema>; export type Contract = z.infer<typeof contractSchema>;
@ -111,10 +115,15 @@ export const resolutionSummarySchema = z.object({
originId: idSchema, originId: idSchema,
destinationId: idSchema, destinationId: idSchema,
tutorial: z.boolean(), tutorial: z.boolean(),
/** Credits added after pilot pay and loan repayment. */
credits, credits,
/** Credits the lender took from this payout (decision 0.5.32). */
loanRepayment: credits.nonnegative(),
pilotXp: z.number().int().nonnegative(), pilotXp: z.number().int().nonnegative(),
/** Set when the delivery discovered its destination. */ /** Set when the delivery discovered its destination. */
discoveredLocationId: idSchema.nullable(), discoveredLocationId: idSchema.nullable(),
/** The scripted tutorial contract delivered, if any (for its flavor message). */
scriptedId: idSchema.nullable(),
resolvedAt: timeSchema, resolvedAt: timeSchema,
}); });
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>; export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
@ -123,9 +132,19 @@ export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
export const tutorialStateSchema = z.object({ export const tutorialStateSchema = z.object({
loanerLocationId: z.enum(["earth", "moon"]), loanerLocationId: z.enum(["earth", "moon"]),
runInFlight: z.boolean(), runInFlight: z.boolean(),
runsCompleted: z.number().int().nonnegative(),
/** The one-time deal can be accepted now (decision 0.5.30). */
dealAvailable: z.boolean(),
}); });
export type TutorialState = z.infer<typeof tutorialStateSchema>; export type TutorialState = z.infer<typeof tutorialStateSchema>;
/** An onboarding goal's progress (decision 0.5.34). Static details come from content. */
export const goalStateSchema = z.object({
id: idSchema,
status: z.enum(["incomplete", "ready", "claimed"]),
});
export type GoalState = z.infer<typeof goalStateSchema>;
export const playerStateSchema = z.object({ export const playerStateSchema = z.object({
company: companySchema, company: companySchema,
ships: z.array(shipSchema), ships: z.array(shipSchema),
@ -137,5 +156,7 @@ export const playerStateSchema = z.object({
activeDispatches: z.array(dispatchSchema), activeDispatches: z.array(dispatchSchema),
/** Unacknowledged "while you were away" entries. */ /** Unacknowledged "while you were away" entries. */
summaries: z.array(resolutionSummarySchema), summaries: z.array(resolutionSummarySchema),
/** Onboarding goals in content order; empty during the tutorial. */
goals: z.array(goalStateSchema),
}); });
export type PlayerState = z.infer<typeof playerStateSchema>; export type PlayerState = z.infer<typeof playerStateSchema>;

View File

@ -28,6 +28,11 @@ export const SERVER_ERROR_CODES = [
"skill-maxed", "skill-maxed",
"unknown-skill", "unknown-skill",
"avatar-in-tutorial", "avatar-in-tutorial",
"tutorial-active",
"deal-not-available",
"unknown-goal",
"goal-not-ready",
"goal-already-claimed",
] as const; ] as const;
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number]; export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];

View File

@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { import {
baySchema, baySchema,
companySchema,
contractSchema, contractSchema,
dispatchSchema, dispatchSchema,
idSchema, idSchema,
@ -63,3 +64,8 @@ export const acknowledgeSummariesRequestSchema = z.object({
export const acknowledgeSummariesResponseSchema = z.object({ export const acknowledgeSummariesResponseSchema = z.object({
acknowledged: z.number().int().nonnegative(), acknowledged: z.number().int().nonnegative(),
}); });
/** Pay the loan down by up to `amount` credits (decision 0.5.32). */
export const repayLoanRequestSchema = z.object({ amount: z.number().int().positive() });
export const claimGoalRequestSchema = z.object({ goalId: idSchema });
export const companyResponseSchema = z.object({ company: companySchema });

View File

@ -6,6 +6,8 @@ import {
assignPilotRequestSchema, assignPilotRequestSchema,
bayResponseSchema, bayResponseSchema,
buyShipRequestSchema, buyShipRequestSchema,
claimGoalRequestSchema,
companyResponseSchema,
contractBoardRequestSchema, contractBoardRequestSchema,
contractBoardResponseSchema, contractBoardResponseSchema,
createDispatchRequestSchema, createDispatchRequestSchema,
@ -15,6 +17,7 @@ import {
hirePilotRequestSchema, hirePilotRequestSchema,
leaseBayRequestSchema, leaseBayRequestSchema,
pilotResponseSchema, pilotResponseSchema,
repayLoanRequestSchema,
shipResponseSchema, shipResponseSchema,
spendSkillPointRequestSchema, spendSkillPointRequestSchema,
unassignPilotRequestSchema, unassignPilotRequestSchema,
@ -125,6 +128,27 @@ export const routes = {
response: acknowledgeSummariesResponseSchema, response: acknowledgeSummariesResponseSchema,
auth: true, auth: true,
}, },
acceptDeal: {
method: "POST",
path: "/api/deal/accept",
request: emptyRequestSchema,
response: playerStateSchema,
auth: true,
},
repayLoan: {
method: "POST",
path: "/api/loan/repay",
request: repayLoanRequestSchema,
response: companyResponseSchema,
auth: true,
},
claimGoal: {
method: "POST",
path: "/api/goals/:goalId/claim",
request: claimGoalRequestSchema,
response: companyResponseSchema,
auth: true,
},
} as const satisfies Record<string, RouteDef>; } as const satisfies Record<string, RouteDef>;
export type Routes = typeof routes; export type Routes = typeof routes;

View File

@ -21,6 +21,9 @@ describe("route catalog", () => {
"unassignPilot", "unassignPilot",
"spendSkillPoint", "spendSkillPoint",
"acknowledgeSummaries", "acknowledgeSummaries",
"acceptDeal",
"repayLoan",
"claimGoal",
].sort(), ].sort(),
); );
}); });

View File

@ -11,6 +11,10 @@ import {
acknowledgeSummariesResponseSchema, acknowledgeSummariesResponseSchema,
assignPilotRequestSchema, assignPilotRequestSchema,
baySchema, baySchema,
claimGoalRequestSchema,
companyResponseSchema,
goalStateSchema,
repayLoanRequestSchema,
bayResponseSchema, bayResponseSchema,
buyShipRequestSchema, buyShipRequestSchema,
companySchema, companySchema,
@ -41,7 +45,7 @@ import {
type Dispatch, type Dispatch,
} from "../src/index"; } from "../src/index";
const company = { id: "co1", name: "Deadhead Freight", credits: 5000 }; const company = { id: "co1", name: "Deadhead Freight", credits: 5000, loanBalance: 3500 };
const ship = { const ship = {
id: "s1", id: "s1",
modelId: "courier", modelId: "courier",
@ -104,11 +108,19 @@ const summary = {
destinationId: "earth", destinationId: "earth",
tutorial: true, tutorial: true,
credits: 240, credits: 240,
loanRepayment: 0,
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: "unmarked-crates",
resolvedAt: 3_900_000, resolvedAt: 3_900_000,
}; };
const tutorial = { loanerLocationId: "earth", runInFlight: false }; const tutorial = {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 2,
dealAvailable: false,
};
const goal = { id: "first-hire", status: "ready" };
const playerState = { const playerState = {
company, company,
ships: [ship], ships: [ship],
@ -118,6 +130,7 @@ const playerState = {
tutorial, tutorial,
activeDispatches: [dispatch], activeDispatches: [dispatch],
summaries: [summary], summaries: [summary],
goals: [goal],
}; };
const cases: [string, z.ZodType, unknown, unknown][] = [ const cases: [string, z.ZodType, unknown, unknown][] = [
@ -136,6 +149,15 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }], ["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }], ["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }], ["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
["goal state", goalStateSchema, goal, { ...goal, status: "done" }],
["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],
["claim goal request", claimGoalRequestSchema, { goalId: "first-hire" }, {}],
[
"company response",
companyResponseSchema,
{ company },
{ company: { ...company, loanBalance: -1 } },
],
["player state", playerStateSchema, playerState, { ...playerState, ships: null }], ["player state", playerStateSchema, playerState, { ...playerState, ships: null }],
[ [
"error", "error",

View File

@ -1,4 +1,11 @@
import type { CargoType, GameConfig, Location, ShipModel, Skill } from "@deadhead/content"; import type {
CargoType,
GameConfig,
Location,
ScriptedTutorialContract,
ShipModel,
Skill,
} from "@deadhead/content";
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock"; import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
import { contractPayout, type Contract } from "./contract"; import { contractPayout, type Contract } from "./contract";
import { routeDistance } from "./distance"; import { routeDistance } from "./distance";
@ -10,6 +17,7 @@ export interface SimContent {
cargoTypes: readonly CargoType[]; cargoTypes: readonly CargoType[];
skills: readonly Skill[]; skills: readonly Skill[];
shipModels: readonly ShipModel[]; shipModels: readonly ShipModel[];
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
config: GameConfig; config: GameConfig;
} }

View File

@ -18,6 +18,8 @@ export interface Contract {
/** End of the board period the contract was offered in. */ /** End of the board period the contract was offered in. */
expiresAt: RealTime; expiresAt: RealTime;
firstContact: boolean; firstContact: boolean;
/** Set on a scripted tutorial contract (decision 0.5.33): its content id. */
scriptedId?: string;
} }
export interface PayoutInput { export interface PayoutInput {

View File

@ -161,7 +161,8 @@ function buildRecord(
const destination = findLocation(args.destinationId, content); const destination = findLocation(args.destinationId, content);
const departure = realToGame(now, config); const departure = realToGame(now, config);
const distance = routeDistance(origin, destination, departure); const distance = routeDistance(origin, destination, departure);
const travelGameMs = travelTimeGameHours(model, distance) * MS_PER_HOUR; const mass = contract?.massTonnes ?? 0;
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
return { return {
id, id,

39
packages/sim/src/goals.ts Normal file
View File

@ -0,0 +1,39 @@
import type { GoalCondition, OnboardingGoal } from "@deadhead/content";
/** The facts about a player that onboarding goals are checked against. */
export interface GoalProgress {
/** Contracts delivered by the player's own ships (tutorial runs excluded). */
ownShipDeliveries: number;
/** Skill points spent across all pilots. */
skillPointsSpent: number;
shipsOwned: number;
/** Pilots hired (the avatar excluded). */
pilotsHired: number;
/** Locations discovered beyond the ones that start discovered. */
locationsDiscovered: number;
dealAccepted: boolean;
loanBalance: number;
}
export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgress): boolean {
switch (condition.kind) {
case "own-ship-deliveries":
return progress.ownShipDeliveries >= condition.count;
case "skill-points-spent":
return progress.skillPointsSpent >= condition.count;
case "ships-owned":
return progress.shipsOwned >= condition.count;
case "pilots-hired":
return progress.pilotsHired >= condition.count;
case "locations-discovered":
return progress.locationsDiscovered >= condition.count;
case "loan-paid-off":
// Only a loan that was taken can be paid off.
return progress.dealAccepted && progress.loanBalance <= 0;
}
}
/** Ids of the goals whose conditions are met, in the goals' order (decision 0.5.34). */
export function completedGoals(goals: readonly OnboardingGoal[], progress: GoalProgress): string[] {
return goals.filter((goal) => isGoalConditionMet(goal.condition, progress)).map((g) => g.id);
}

View File

@ -61,5 +61,11 @@ export { dispatchPosition, dispatchProgress } from "./flight";
export { deliveryXp, pilotLevel, skillPointsAvailable } from "./xp"; export { deliveryXp, pilotLevel, skillPointsAvailable } from "./xp";
export { completedGoals, isGoalConditionMet } from "./goals";
export type { GoalProgress } from "./goals";
export { splitLoanRepayment } from "./loan";
export type { LoanSplit } from "./loan";
export { resolveDispatch } from "./resolution"; export { resolveDispatch } from "./resolution";
export type { Resolution, ResolveInput, ResolveResult } from "./resolution"; export type { Resolution, ResolveInput, ResolveResult } from "./resolution";

17
packages/sim/src/loan.ts Normal file
View File

@ -0,0 +1,17 @@
export interface LoanSplit {
/** Credits that go to the lender. */
repayment: number;
/** Credits the player keeps. */
net: number;
}
/**
* Splits a payout between the lender and the player (decision 0.5.32): the
* lender takes `min(balance, credits × share)`, rounded down to whole credits,
* and nothing when there is no balance or no positive payout.
*/
export function splitLoanRepayment(credits: number, balance: number, share: number): LoanSplit {
if (credits <= 0 || balance <= 0) return { repayment: 0, net: credits };
const repayment = Math.min(balance, Math.floor(credits * share));
return { repayment, net: credits - repayment };
}

View File

@ -1,11 +1,17 @@
import type { SimContent } from "./board"; import type { SimContent } from "./board";
import type { RealTime } from "./clock"; import type { RealTime } from "./clock";
import type { DispatchRecord } from "./dispatch"; import type { DispatchRecord } from "./dispatch";
import { splitLoanRepayment } from "./loan";
import { deliveryXp } from "./xp"; import { deliveryXp } from "./xp";
export interface Resolution { export interface Resolution {
/** Credits to add: payout minus pilot pay. Fuel was already charged at departure. */ /**
* Credits to add: payout minus pilot pay, minus the loan repayment.
* Fuel was already charged at departure.
*/
credits: number; credits: number;
/** Credits that went to the lender out of this payout (decision 0.5.32). */
loanRepayment: number;
pilotXp: number; pilotXp: number;
/** Where the ship (or tutorial loaner) is now. */ /** Where the ship (or tutorial loaner) is now. */
shipLocationId: string; shipLocationId: string;
@ -21,12 +27,14 @@ export interface ResolveInput {
/** The avatar is exempt from pilot pay (decision 0.5.12). */ /** The avatar is exempt from pilot pay (decision 0.5.12). */
pilotIsAvatar: boolean; pilotIsAvatar: boolean;
discoveredIds: ReadonlySet<string>; discoveredIds: ReadonlySet<string>;
/** The company's loan balance before this resolution. */
loanBalance: number;
now: RealTime; now: RealTime;
} }
/** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */
export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult {
const { record, pilotIsAvatar, discoveredIds, now } = input; const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input;
if (now < record.arrivalAt) return { status: "in-flight" }; if (now < record.arrivalAt) return { status: "in-flight" };
const { contract } = record; const { contract } = record;
@ -35,17 +43,29 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
// Repositions earn nothing and only fly to bays, which are already discovered. // Repositions earn nothing and only fly to bays, which are already discovered.
return { return {
status: "resolved", status: "resolved",
resolution: { ...base, credits: 0, pilotXp: 0, discoversDestination: false }, resolution: {
...base,
credits: 0,
loanRepayment: 0,
pilotXp: 0,
discoversDestination: false,
},
}; };
} }
const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery; const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery;
// Tutorial contracts already store exactly the tutorial share as their payout.
const { repayment, net } = splitLoanRepayment(
contract.payout - pilotPay,
loanBalance,
content.config.loanRepaymentShare,
);
return { return {
status: "resolved", status: "resolved",
resolution: { resolution: {
...base, ...base,
// Tutorial contracts already store exactly the tutorial share as their payout. credits: net,
credits: contract.payout - pilotPay, loanRepayment: repayment,
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),
discoversDestination: !discoveredIds.has(record.destinationId), discoversDestination: !discoveredIds.has(record.destinationId),
}, },

View File

@ -1,10 +1,18 @@
import type { ShipModel } from "@deadhead/content"; import type { GameConfig, ShipModel } from "@deadhead/content";
/** /**
* Trip duration in game hours: cruise time plus the ship's fixed takeoff/landing * Trip duration in game hours: cruise time, the ship's fixed takeoff/landing
* overhead. Pass the distance at departure; it is not recalculated mid-flight. * overhead, and loading/unloading time for the cargo (decision 0.5.31). Pass
* the distance at departure; it is not recalculated mid-flight. Repositions
* carry zero mass.
*/ */
export function travelTimeGameHours(ship: ShipModel, distanceMillionKm: number): number { export function travelTimeGameHours(
ship: ShipModel,
distanceMillionKm: number,
massTonnes: number,
config: GameConfig,
): number {
const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour; const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour;
return cruiseHours + ship.overheadGameHours; const handlingHours = massTonnes * config.handlingGameHoursPerTonne;
return cruiseHours + ship.overheadGameHours + handlingHours;
} }

View File

@ -1,13 +1,13 @@
import type { Location, ShipModel } from "@deadhead/content"; import type { Location, ShipModel } from "@deadhead/content";
import { buildContract, standardCargo, type SimContent } from "./board"; import { buildContract, standardCargo, type SimContent } from "./board";
import type { Contract } from "./contract"; import type { Contract } from "./contract";
import { createRng, deriveSeed, type Seed } from "./rng";
/** The tutorial loaner is the company's Courier. */ /** The tutorial loaner: the content's company ship (decision 0.5.29). */
export function loanerModel(content: SimContent): ShipModel { export function loanerModel(content: SimContent): ShipModel {
const courier = content.shipModels.find((s) => s.archetype === "courier"); const id = content.config.tutorialLoanerShipModelId;
if (!courier) throw new Error("Content has no Courier ship model"); const loaner = content.shipModels.find((s) => s.id === id);
return courier; if (!loaner) throw new Error(`Content has no loaner ship model "${id}"`);
return loaner;
} }
export type TutorialLocationId = "earth" | "moon"; export type TutorialLocationId = "earth" | "moon";
@ -15,8 +15,9 @@ export type TutorialLocationId = "earth" | "moon";
export interface TutorialBoardInput { export interface TutorialBoardInput {
/** Where the loaner currently is; contracts start here (decision 0.5.3). */ /** Where the loaner currently is; contracts start here (decision 0.5.3). */
loanerLocationId: TutorialLocationId; loanerLocationId: TutorialLocationId;
/** Tutorial runs already flown; the board is for run `runsCompleted + 1`. */
runsCompleted: number;
periodIndex: number; periodIndex: number;
worldSeed: Seed;
} }
function location(id: string, content: SimContent): Location { function location(id: string, content: SimContent): Location {
@ -26,33 +27,47 @@ function location(id: string, content: SimContent): Location {
} }
/** /**
* The tutorial "company board": Earth↔Moon only, Standard cargo sized to fit * The tutorial "company board" (CR-001, Step 10A.3): three Standard contracts
* the loaner, starting at the loaner's location. Each payout is the tutorial * from the loaner's location to the other end of the Earth–Moon route, at the
* share of the equivalent normal payout, rounded to whole credits. * light, medium and full mass tiers, each paying the tutorial share. A scripted
* contract for the upcoming run (decision 0.5.33) replaces the medium slot.
* The board is fully determined by its inputs.
*/ */
export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] { export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] {
const { loanerLocationId, periodIndex, worldSeed } = input; const { loanerLocationId, runsCompleted, periodIndex } = input;
const { config } = content; const { config } = content;
const origin = location(loanerLocationId, content); const origin = location(loanerLocationId, content);
const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content); const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content);
const cargoType = standardCargo(content); const tiers = config.tutorialMassTonnes;
const maxMass = Math.min(config.contractMassTonnes.max, loanerModel(content).cargoCapacityTonnes); const run = runsCompleted + 1;
const rng = createRng(deriveSeed(worldSeed, `tutorial:${loanerLocationId}:${periodIndex}`)); const scripted = content.scriptedTutorialContracts.find((s) => s.run === run);
return Array.from({ length: config.tutorialBoardSize }, (_, i) => { const slot = (i: number, massTonnes: number): Contract => {
const special = i === 1 ? scripted : undefined;
const cargoType = special
? content.cargoTypes.find((c) => c.id === special.cargoTypeId)
: standardCargo(content);
if (!cargoType) throw new Error(`Unknown cargo ${special?.cargoTypeId}`);
const contract = buildContract( const contract = buildContract(
{ {
id: `tutorial:${loanerLocationId}:${periodIndex}:${i}`, id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
origin, origin,
destination, destination,
cargoType, cargoType,
tier: 0, tier: 0,
massTonnes: rng.int(config.contractMassTonnes.min, maxMass), massTonnes,
firstContact: false, firstContact: false,
periodIndex, periodIndex,
}, },
content, content,
); );
return { ...contract, payout: Math.round(contract.payout * config.tutorialPayoutShare) }; const share = contract.payout * config.tutorialPayoutShare * (special?.payMultiplier ?? 1);
}); return {
...contract,
payout: Math.round(share),
...(special ? { scriptedId: special.id } : {}),
};
};
return [slot(0, tiers.light), slot(1, tiers.medium), slot(2, tiers.full)];
} }

View File

@ -0,0 +1,55 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { completedGoals, type GoalProgress } from "../src/index";
const goals = content.onboardingGoals;
/** Just after the deal: a ship, an Earth bay and the full loan. */
const fresh: GoalProgress = {
ownShipDeliveries: 0,
skillPointsSpent: 0,
shipsOwned: 1,
pilotsHired: 0,
locationsDiscovered: 0,
dealAccepted: true,
loanBalance: content.config.dealLoanAmount,
};
const isComplete = (id: string, progress: GoalProgress) =>
completedGoals(goals, progress).includes(id);
/** For each goal: a summary just short of its condition, and one that meets it. */
const cases: [string, Partial<GoalProgress>, Partial<GoalProgress>][] = [
["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }],
["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }],
["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }],
["first-hire", { pilotsHired: 0 }, { pilotsHired: 1 }],
["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }],
["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }],
];
describe("onboarding goal evaluation", () => {
it("covers every goal in content, in order", () => {
expect(cases.map(([id]) => id)).toEqual(goals.map((g) => g.id));
});
it.each(cases)(
"%s: incomplete just short of the condition, complete once met",
(id, short, met) => {
expect(isComplete(id, { ...fresh, ...short })).toBe(false);
expect(isComplete(id, { ...fresh, ...met })).toBe(true);
},
);
it("does not count a loan as paid off before the deal", () => {
expect(isComplete("loan-paid-off", { ...fresh, dealAccepted: false, loanBalance: 0 })).toBe(
false,
);
});
it("returns completed goals in content order, identically for identical inputs", () => {
const progress: GoalProgress = { ...fresh, pilotsHired: 3, ownShipDeliveries: 5 };
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "first-hire"]);
expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress }));
});
});

View File

@ -0,0 +1,88 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
createDispatchRecord,
resolveDispatch,
splitLoanRepayment,
type Contract,
} from "../src/index";
describe("loan repayment split", () => {
it("takes min(balance, credits × share), rounded down to whole credits", () => {
expect(splitLoanRepayment(1000, 5000, 0.3)).toEqual({ repayment: 300, net: 700 });
expect(splitLoanRepayment(1001, 5000, 0.3)).toEqual({ repayment: 300, net: 701 });
expect(splitLoanRepayment(1000, 120, 0.3)).toEqual({ repayment: 120, net: 880 });
});
it("takes nothing when there is no balance, or no positive payout", () => {
expect(splitLoanRepayment(1000, 0, 0.3)).toEqual({ repayment: 0, net: 1000 });
expect(splitLoanRepayment(0, 5000, 0.3)).toEqual({ repayment: 0, net: 0 });
expect(splitLoanRepayment(-60, 5000, 0.3)).toEqual({ repayment: 0, net: -60 });
});
it("always splits the whole amount and never repays more than the balance", () => {
for (const credits of [-500, -1, 0, 1, 7, 99, 1000, 123_457]) {
for (const balance of [0, 1, 50, 299, 5000, 1_000_000]) {
for (const share of [0, 0.1, 0.3, 1]) {
const { repayment, net } = splitLoanRepayment(credits, balance, share);
expect(net + repayment).toBe(credits);
expect(repayment).toBeLessThanOrEqual(balance);
expect(repayment).toBeGreaterThanOrEqual(0);
expect(repayment).toBe(
credits > 0 && balance > 0 ? Math.min(balance, Math.floor(credits * share)) : 0,
);
}
}
}
});
});
describe("resolution with a loan", () => {
const now = Date.parse("2100-01-01T00:30:00Z");
const contract: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: now,
expiresAt: now + 3_600_000,
firstContact: false,
};
const record = createDispatchRecord(
{
id: "d1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
contract,
tutorial: false,
now,
worldSeed: 7,
},
content,
);
it("returns the repayment alongside the credits to add, after pilot pay", () => {
const result = resolveDispatch(
{
record,
pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]),
loanBalance: 5000,
now: record.arrivalAt,
},
content,
);
if (result.status !== "resolved") throw new Error("expected a resolution");
const afterPay = 2000 - content.config.pilotPayPerDelivery;
const repayment = Math.floor(afterPay * content.config.loanRepaymentShare);
expect(result.resolution).toMatchObject({
loanRepayment: repayment,
credits: afterPay - repayment,
});
});
});

View File

@ -37,7 +37,9 @@ describe("dispatch record", () => {
it("arrives after exactly the travel time converted to real time", () => { it("arrives after exactly the travel time converted to real time", () => {
const record = createDispatchRecord(input, content); const record = createDispatchRecord(input, content);
const courier = content.shipModel("courier")!; const courier = content.shipModel("courier")!;
const travelGameMs = travelTimeGameHours(courier, record.distanceMillionKm) * MS_PER_HOUR; const travelGameMs =
travelTimeGameHours(courier, record.distanceMillionKm, contract.massTonnes, content.config) *
MS_PER_HOUR;
expect(record.arrivalAt - record.departureAt).toBe( expect(record.arrivalAt - record.departureAt).toBe(
Math.round(travelGameMs / content.config.timeCompression), Math.round(travelGameMs / content.config.timeCompression),
); );

View File

@ -41,6 +41,7 @@ const arrived: ResolveInput = {
record, record,
pilotIsAvatar: false, pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]), discoveredIds: new Set(["earth", "moon"]),
loanBalance: 0,
now: record.arrivalAt, now: record.arrivalAt,
}; };
@ -106,6 +107,7 @@ describe("dispatch resolution", () => {
); );
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({ expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({
credits: 0, credits: 0,
loanRepayment: 0,
pilotXp: 0, pilotXp: 0,
shipLocationId: "moon", shipLocationId: "moon",
discoversDestination: false, discoversDestination: false,

View File

@ -2,27 +2,55 @@ import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { routeDistance, travelTimeGameHours } from "../src/index"; import { routeDistance, travelTimeGameHours } from "../src/index";
const config = content.config;
const courier = content.shipModel("courier")!; const courier = content.shipModel("courier")!;
const freighter = content.shipModel("freighter")!; const freighter = content.shipModel("freighter")!;
const loaner = content.shipModel("company-courier")!;
const earthMoon = () =>
routeDistance(
content.location("earth")!,
content.location("moon")!,
Date.parse("2100-01-01T00:00:00Z"),
);
describe("travel time", () => { describe("travel time", () => {
it("matches a hand calculation", () => { it("matches a hand calculation, including handling time", () => {
// 150M km at 2M km/h = 75 h cruise, plus 4 h overhead. // 150M km at 2M km/h = 75 h cruise, plus 4 h overhead, plus 50 t × 0.02 h/t = 1 h handling.
expect(travelTimeGameHours(freighter, 150)).toBe(79); expect(travelTimeGameHours(freighter, 150, 50, config)).toBe(80);
}); });
it("gets a faster ship there sooner on the same route", () => { it("gets a faster ship there sooner on the same route", () => {
for (const distance of [0.384, 50, 400]) { for (const distance of [0.384, 50, 400]) {
expect(travelTimeGameHours(courier, distance)).toBeLessThan( expect(travelTimeGameHours(courier, distance, 10, config)).toBeLessThan(
travelTimeGameHours(freighter, distance), travelTimeGameHours(freighter, distance, 10, config),
); );
} }
}); });
it("is mostly overhead for a Courier on Earth→Moon", () => { it("is mostly overhead for a Courier on Earth→Moon", () => {
const t = Date.parse("2100-01-01T00:00:00Z"); const total = travelTimeGameHours(courier, earthMoon(), 10, config);
const distance = routeDistance(content.location("earth")!, content.location("moon")!, t);
const total = travelTimeGameHours(courier, distance);
expect(courier.overheadGameHours).toBeGreaterThan(total / 2); expect(courier.overheadGameHours).toBeGreaterThan(total / 2);
}); });
}); });
describe("handling time (decision 0.5.31)", () => {
it("makes a heavier contract take longer than a lighter one on the same route and ship", () => {
const distance = earthMoon();
expect(travelTimeGameHours(courier, distance, 20, config)).toBeGreaterThan(
travelTimeGameHours(courier, distance, 5, config),
);
});
it("gives a reposition (zero mass) the old formula's time", () => {
const distance = 150;
const oldFormula =
(distance * 1e6) / freighter.cruiseSpeedKmPerGameHour + freighter.overheadGameHours;
expect(travelTimeGameHours(freighter, distance, 0, config)).toBe(oldFormula);
});
it("keeps a full Company Courier tutorial run Earth→Moon under 1 game hour (1 real minute)", () => {
const hours = travelTimeGameHours(loaner, earthMoon(), config.tutorialMassTonnes.full, config);
expect(hours).toBeLessThan(1);
expect(config.timeCompression).toBe(60);
});
});

View File

@ -2,67 +2,128 @@ import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
boardPeriodIndex, boardPeriodIndex,
contractPayout,
deliveryXp,
generateTutorialBoard, generateTutorialBoard,
loanerModel, loanerModel,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
type Contract,
type TutorialBoardInput, type TutorialBoardInput,
} from "../src/index"; } from "../src/index";
import { buildContract } from "../src/board";
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config); const config = content.config;
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", periodIndex, worldSeed: 99 }; const START = Date.parse("2100-01-01T00:00:00Z");
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon" }; const periodIndex = boardPeriodIndex(START, config);
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", runsCompleted: 0, periodIndex };
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon", runsCompleted: 1 };
const loaner = loanerModel(content);
describe("tutorial board", () => { function distanceOf(contract: Contract): number {
it("offers only Earth→Moon when the loaner is at Earth", () => { return routeDistance(
for (const c of generateTutorialBoard(atEarth, content)) { content.location(contract.originId)!,
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]); content.location(contract.destinationId)!,
realToGame(contract.offeredAt, config),
);
}
/** The normal (non-tutorial) payout for the same Standard cargo and route. */
function normalPayout(contract: Contract): number {
return contractPayout(
{
cargoType: content.cargoType("standard")!,
tier: 0,
massTonnes: contract.massTonnes,
distanceMillionKm: distanceOf(contract),
firstContact: false,
},
config,
);
}
/** Real minutes for the loaner to fly a contract, departing at its offer time. */
function tripMinutes(contract: Contract): number {
const hours = travelTimeGameHours(loaner, distanceOf(contract), contract.massTonnes, config);
return (hours * MS_PER_HOUR) / config.timeCompression / 60_000;
}
function xpPerMinute(contract: Contract): number {
return deliveryXp(distanceOf(contract), contract.tier, config) / tripMinutes(contract);
}
describe("tutorial board v2", () => {
it("uses the Company Courier as the loaner", () => {
expect(loaner.id).toBe("company-courier");
});
it("has exactly three Standard contracts at three different masses within the loaner's capacity", () => {
const board = generateTutorialBoard(atEarth, content);
expect(board).toHaveLength(3);
expect(board.map((c) => c.massTonnes)).toEqual([
config.tutorialMassTonnes.light,
config.tutorialMassTonnes.medium,
config.tutorialMassTonnes.full,
]);
expect(new Set(board.map((c) => c.massTonnes)).size).toBe(3);
for (const c of board) {
expect(c.cargoTypeId).toBe("standard");
expect(c.tier).toBe(0);
expect(c.requiredSkill).toBeNull();
expect(c.massTonnes).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
} }
}); });
it("offers only Moon→Earth when the loaner is at the Moon", () => { it("runs from the loaner's location to the other end of the Earth–Moon route", () => {
for (const c of generateTutorialBoard(atEarth, content)) {
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
}
for (const c of generateTutorialBoard(atMoon, content)) { for (const c of generateTutorialBoard(atMoon, content)) {
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]); expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
} }
}); });
it("has the configured size, Standard cargo only, sized to fit the loaner", () => { it("pays the tutorial share of the normal payout", () => {
const board = generateTutorialBoard(atEarth, content); for (const c of generateTutorialBoard(atEarth, content)) {
expect(board).toHaveLength(content.config.tutorialBoardSize); expect(c.payout).toBe(Math.round(normalPayout(c) * config.tutorialPayoutShare));
for (const c of board) {
expect(c.cargoTypeId).toBe("standard");
expect(c.tier).toBe(0);
expect(c.requiredSkill).toBeNull();
expect(c.requiredLongHaulLevel).toBe(0);
expect(c.firstContact).toBe(false);
expect(c.massTonnes).toBeLessThanOrEqual(loanerModel(content).cargoCapacityTonnes);
} }
}); });
it("pays exactly the tutorial share of the equivalent normal payout", () => { it("gives an identical board for the same inputs, and a new one per run", () => {
for (const input of [atEarth, atMoon]) {
for (const c of generateTutorialBoard(input, content)) {
const normal = buildContract(
{
id: c.id,
origin: content.location(c.originId)!,
destination: content.location(c.destinationId)!,
cargoType: content.cargoType("standard")!,
tier: 0,
massTonnes: c.massTonnes,
firstContact: false,
periodIndex,
},
content,
);
expect(c.payout).toBe(Math.round(normal.payout * content.config.tutorialPayoutShare));
expect(c.payout).toBeLessThan(normal.payout);
}
}
});
it("is deterministic", () => {
expect(generateTutorialBoard(atEarth, content)).toEqual( expect(generateTutorialBoard(atEarth, content)).toEqual(
generateTutorialBoard(atEarth, content), generateTutorialBoard(atEarth, content),
); );
const nextRun = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
expect(nextRun.map((c) => c.id)).not.toEqual(
generateTutorialBoard(atEarth, content).map((c) => c.id),
);
});
it("puts Unmarked crates in the medium slot at double pay with two runs completed, and never otherwise", () => {
const [, crates] = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
expect(crates).toMatchObject({
scriptedId: "unmarked-crates",
massTonnes: config.tutorialMassTonnes.medium,
});
expect(crates!.payout).toBe(Math.round(normalPayout(crates!) * config.tutorialPayoutShare * 2));
for (const runsCompleted of [0, 1, 3, 4, 7]) {
const board = generateTutorialBoard({ ...atEarth, runsCompleted }, content);
expect(board.some((c) => c.scriptedId !== undefined)).toBe(false);
}
});
it("trades XP per minute (light) against credits per minute (full)", () => {
const [light, , full] = generateTutorialBoard(atEarth, content);
expect(xpPerMinute(light!)).toBeGreaterThan(xpPerMinute(full!));
expect(full!.payout / tripMinutes(full!)).toBeGreaterThan(light!.payout / tripMinutes(light!));
});
it("keeps the four slowest possible tutorial runs under 6 real minutes in total", () => {
const slowest = Math.max(
...[atEarth, atMoon].map((input) => tripMinutes(generateTutorialBoard(input, content)[2]!)),
);
expect(4 * slowest).toBeLessThan(6);
}); });
}); });