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:
parent
b1dde3696f
commit
bfda781601
10
DECISIONS.md
10
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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue