Step 10A.1: add loaner ship, tutorial and loan config, goals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 10:47:52 -04:00
parent b1dde3696f
commit bfda781601
17 changed files with 311 additions and 20 deletions

View File

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

View File

@ -83,7 +83,7 @@ export async function buyShip(
now: RealTime,
): Promise<Ship> {
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);

View File

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

View File

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

View File

@ -12,7 +12,8 @@ describe("market screen", () => {
it("lists every ship model with its stats", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/market");
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]!;
expect(courier).toHaveTextContent("Courier");
expect(courier).toHaveTextContent("3.00M km/h");

View File

@ -106,7 +106,7 @@
Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded as plan §0.5.29–0.5.34.
- [ ] Step 10A.1 — Content: new config and loaner ship
- [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.
- [ ] Step 10A.2 — Sim: handling time
- [ ] Step 10A.3 — Sim: tutorial board v2
- [ ] Step 10A.4 — Sim: loan repayment

View File

@ -1,6 +1,7 @@
import { cargoTypes, skills } from "./data/cargo";
import { gameConfig } from "./data/config";
import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts } from "./data/onboarding";
import { shipModels } from "./data/ships";
/** 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 = {
locations,
shipModels,
/** Ship models players can buy (excludes company ships such as the loaner). */
purchasableShipModels: shipModels.filter((s) => s.purchasable),
cargoTypes,
skills,
config: gameConfig,
scriptedTutorialContracts,
onboardingGoals,
location: byId(locations),
shipModel: byId(shipModels),
cargoType: byId(cargoTypes),
skill: byId(skills),
onboardingGoal: byId(onboardingGoals),
};
export type Content = typeof content;

View File

@ -9,11 +9,20 @@ export const gameConfig: GameConfig = {
realStart: "2026-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,
contractBoardSize: 8,
// A tutorial run takes about 2 real minutes, so one hour-long period allows ~30.
tutorialBoardSize: 40,
// One tutorial contract per mass tier (CR-001).
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,
firstContactChance: 0.1,
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",
overheadGameHours: 2,
fuelCostPerMillionKm: 200,
purchasable: true,
},
{
id: "freighter",
@ -33,6 +34,7 @@ export const shipModels: ShipModel[] = [
landingClass: "atmospheric",
overheadGameHours: 4,
fuelCostPerMillionKm: 400,
purchasable: true,
},
{
id: "bulk-hauler",
@ -47,5 +49,22 @@ export const shipModels: ShipModel[] = [
landingClass: "atmospheric",
overheadGameHours: 8,
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/cargo";
export * from "./data/config";
export * from "./data/onboarding";
export * from "./bundle";

View File

@ -67,6 +67,8 @@ export const shipModelSchema = z.object({
overheadGameHours: z.number().nonnegative(),
/** Credits per million km, before the origin's launch multiplier. */
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>;
@ -102,8 +104,28 @@ export const gameConfigSchema = z.object({
/** Fraction of a normal payout the player receives for a tutorial run. */
tutorialPayoutShare: fraction,
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,
/** 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(),
firstContactChance: fraction,
firstContactPayoutMultiplier: z.number().positive(),
@ -126,3 +148,45 @@ export const gameConfigSchema = z.object({
pilotPayPerDelivery: nonNegativeInt,
});
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", () => {
it("contains every kind of content", () => {
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.skills).toHaveLength(4);
expect(content.config.timeCompression).toBe(60);

View File

@ -8,9 +8,7 @@ import {
skills,
} from "../src/index";
// Placeholder average tutorial payout until Phase 12 uses real sim numbers:
// Standard cargo over ~0.384M km (Earth–Moon), at the average mass a Courier
// can carry from the configured range, times the tutorial share.
// The Moon sits a fixed 0.384M km from Earth (Step 3.3).
const EARTH_MOON_MILLION_KM = 0.384;
describe("game config", () => {
@ -27,20 +25,36 @@ describe("game config", () => {
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 earth = locations.find((l) => l.id === "earth")!;
const standard = cargoTypes.find((c) => c.id === "standard")!;
const averageMass = (gameConfig.contractMassTonnes.min + courier.cargoCapacityTonnes) / 2;
const averageTutorialPayout =
const lightTutorialPayout =
standard.basePayPerTonnePerMillionKm *
averageMass *
gameConfig.tutorialMassTonnes.light *
EARTH_MOON_MILLION_KM *
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;
expect(runs).toBeGreaterThanOrEqual(15);
expect(runs).toBeLessThanOrEqual(25);
it("sizes the tutorial board and loaner for the three mass tiers", () => {
expect(gameConfig.tutorialBoardSize).toBe(3);
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,
gameConfigSchema,
locationSchema,
onboardingGoalSchema,
scriptedTutorialContractSchema,
shipModelSchema,
skillSchema,
} from "../src/index";
@ -33,6 +35,7 @@ const validShipModel = {
landingClass: "atmospheric",
overheadGameHours: 2,
fuelCostPerMillionKm: 100,
purchasable: true,
};
const validCargoType = {
@ -67,6 +70,28 @@ const validGameConfig = {
skillPointsPerLevel: 1,
pilotHireCost: 1000,
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>][] = [
@ -75,6 +100,8 @@ const cases: [string, z.ZodType, Record<string, unknown>][] = [
["CargoType", cargoTypeSchema, validCargoType],
["Skill", skillSchema, validSkill],
["GameConfig", gameConfigSchema, validGameConfig],
["ScriptedTutorialContract", scriptedTutorialContractSchema, validScripted],
["OnboardingGoal", onboardingGoalSchema, validGoal],
];
describe.each(cases)("%s schema", (_name, schema, valid) => {
@ -90,6 +117,16 @@ describe.each(cases)("%s schema", (_name, schema, valid) => {
});
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", () => {
const config = { ...validGameConfig, contractMassTonnes: { min: 50, max: 10 } };
expect(gameConfigSchema.safeParse(config).success).toBe(false);

View File

@ -11,7 +11,12 @@ describe("ship models", () => {
});
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) {
expect(model.landingClass).toBe("atmospheric");
}