diff --git a/DECISIONS.md b/DECISIONS.md index 252e7e5..31aeec4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -481,3 +481,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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. diff --git a/apps/server/src/game/fleet.ts b/apps/server/src/game/fleet.ts index ccbf2b6..511668d 100644 --- a/apps/server/src/game/fleet.ts +++ b/apps/server/src/game/fleet.ts @@ -83,7 +83,7 @@ export async function buyShip( now: RealTime, ): Promise { 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}`); const player = await requirePlayer(tx, playerId); diff --git a/apps/server/test/buy-ship.test.ts b/apps/server/test/buy-ship.test.ts index 5fbc44f..a149ec3 100644 --- a/apps/server/test/buy-ship.test.ts +++ b/apps/server/test/buy-ship.test.ts @@ -47,6 +47,14 @@ describe("buy a ship", () => { expect(state.ships).toHaveLength(0); }); + it("refuses to sell the company loaner", 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: "company-courier" }); + expect(response.statusCode).toBe(404); + }); + it("rejects an unknown model", async () => { const { app } = testApp(); const guest = await signUp(app); diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index e6e9513..5669af5 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -24,7 +24,7 @@ export function Market() {

)}