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"); }