From bfda781601ecb58d2ac738f548786a13887da81b Mon Sep 17 00:00:00 2001
From: fireshaper
Date: Sat, 3 Oct 2026 10:47:52 -0400
Subject: [PATCH] Step 10A.1: add loaner ship, tutorial and loan config, goals
Co-Authored-By: Claude Opus 5.5
---
DECISIONS.md | 10 ++++
apps/server/src/game/fleet.ts | 2 +-
apps/server/test/buy-ship.test.ts | 8 +++
apps/web/src/screens/Market.tsx | 2 +-
apps/web/test/market.test.tsx | 3 +-
memory-bank/progress.md | 2 +-
packages/content/src/bundle.ts | 6 +++
packages/content/src/data/config.ts | 15 ++++--
packages/content/src/data/onboarding.ts | 54 +++++++++++++++++++
packages/content/src/data/ships.ts | 19 +++++++
packages/content/src/index.ts | 1 +
packages/content/src/schemas.ts | 66 +++++++++++++++++++++++-
packages/content/test/bundle.test.ts | 3 +-
packages/content/test/config.test.ts | 34 ++++++++----
packages/content/test/onboarding.test.ts | 62 ++++++++++++++++++++++
packages/content/test/schemas.test.ts | 37 +++++++++++++
packages/content/test/ships.test.ts | 7 ++-
17 files changed, 311 insertions(+), 20 deletions(-)
create mode 100644 packages/content/src/data/onboarding.ts
create mode 100644 packages/content/test/onboarding.test.ts
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() {
)}
- {content.shipModels.map((model) => {
+ {content.purchasableShipModels.map((model) => {
const affordable = state.company.credits >= model.price;
return (
-
diff --git a/apps/web/test/market.test.tsx b/apps/web/test/market.test.tsx
index 662394e..ee803fb 100644
--- a/apps/web/test/market.test.tsx
+++ b/apps/web/test/market.test.tsx
@@ -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");
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index ca78a7b..54d9b01 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -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
diff --git a/packages/content/src/bundle.ts b/packages/content/src/bundle.ts
index a123ea1..570a521 100644
--- a/packages/content/src/bundle.ts
+++ b/packages/content/src/bundle.ts
@@ -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(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;
diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts
index 77844b0..a6bf077 100644
--- a/packages/content/src/data/config.ts
+++ b/packages/content/src/data/config.ts
@@ -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,
diff --git a/packages/content/src/data/onboarding.ts b/packages/content/src/data/onboarding.ts
new file mode 100644
index 0000000..17efc2b
--- /dev/null
+++ b/packages/content/src/data/onboarding.ts
@@ -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,
+ },
+];
diff --git a/packages/content/src/data/ships.ts b/packages/content/src/data/ships.ts
index 5fb0e53..5a1bc99 100644
--- a/packages/content/src/data/ships.ts
+++ b/packages/content/src/data/ships.ts
@@ -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,
},
];
diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts
index 469ffa1..1cad3bc 100644
--- a/packages/content/src/index.ts
+++ b/packages/content/src/index.ts
@@ -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";
diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts
index f307e3b..9f2e331 100644
--- a/packages/content/src/schemas.ts
+++ b/packages/content/src/schemas.ts
@@ -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;
@@ -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;
+
+/**
+ * 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;
+
+/** 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;
+
+/** 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;
diff --git a/packages/content/test/bundle.test.ts b/packages/content/test/bundle.test.ts
index c0d5740..5cfd4ec 100644
--- a/packages/content/test/bundle.test.ts
+++ b/packages/content/test/bundle.test.ts
@@ -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);
diff --git a/packages/content/test/config.test.ts b/packages/content/test/config.test.ts
index 93942dd..d793d56 100644
--- a/packages/content/test/config.test.ts
+++ b/packages/content/test/config.test.ts
@@ -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);
});
});
diff --git a/packages/content/test/onboarding.test.ts b/packages/content/test/onboarding.test.ts
new file mode 100644
index 0000000..f728ee4
--- /dev/null
+++ b/packages/content/test/onboarding.test.ts
@@ -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",
+ ]);
+ });
+});
diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts
index f65ae49..fbe427d 100644
--- a/packages/content/test/schemas.test.ts
+++ b/packages/content/test/schemas.test.ts
@@ -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][] = [
@@ -75,6 +100,8 @@ const cases: [string, z.ZodType, Record][] = [
["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);
diff --git a/packages/content/test/ships.test.ts b/packages/content/test/ships.test.ts
index bcd5191..8be9fa6 100644
--- a/packages/content/test/ships.test.ts
+++ b/packages/content/test/ships.test.ts
@@ -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");
}