210 lines
6.2 KiB
TypeScript
210 lines
6.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import type { z } from "zod";
|
||
import {
|
||
cargoTypeSchema,
|
||
gameConfigSchema,
|
||
goodSchema,
|
||
locationSchema,
|
||
onboardingGoalSchema,
|
||
scriptedTutorialContractSchema,
|
||
shipModelSchema,
|
||
skillSchema,
|
||
} from "../src/index";
|
||
|
||
const validLocation = {
|
||
id: "earth",
|
||
name: "Earth",
|
||
type: "atmospheric-world",
|
||
landingRequirement: "atmospheric",
|
||
body: "earth",
|
||
launchCostMultiplier: 3,
|
||
startsDiscovered: true,
|
||
bayLeasePrice: 5000,
|
||
sprite: "body-mars",
|
||
produces: ["mail"],
|
||
demands: ["lunar-titanium"],
|
||
cargoClassMix: { produces: ["standard"], demands: ["heavy"] },
|
||
shippers: ["Pacific Orbital Logistics"],
|
||
};
|
||
|
||
/** Optional fields (tier, priorityName) are left out so every key here is required. */
|
||
const validGood = {
|
||
id: "mail",
|
||
name: "Mail & Parcels",
|
||
cargoClassId: "standard",
|
||
massTonnes: { min: 1, max: 8 },
|
||
payMultiplier: 1.2,
|
||
priorityEligible: true,
|
||
producedAt: ["earth"],
|
||
demandedAt: ["moon"],
|
||
};
|
||
|
||
const validShipModel = {
|
||
id: "courier",
|
||
name: "Courier",
|
||
archetype: "courier",
|
||
price: 20000,
|
||
cruiseSpeedKmPerGameHour: 300000,
|
||
cargoCapacityTonnes: 10,
|
||
fuelRangeMillionKm: 5,
|
||
hullRating: 1,
|
||
landingClass: "atmospheric",
|
||
overheadGameHours: 2,
|
||
fuelCostPerMillionKm: 100,
|
||
purchasable: true,
|
||
sprite: "ship-courier",
|
||
};
|
||
|
||
const validCargoType = {
|
||
id: "standard",
|
||
name: "Standard",
|
||
basePayPerTonnePerMillionKm: 10,
|
||
tierPayMultiplier: 1,
|
||
requiredSkillId: null,
|
||
};
|
||
|
||
const validSkill = {
|
||
id: "long-haul",
|
||
name: "Long Haul",
|
||
maxLevel: 5,
|
||
description: "Access to longer routes.",
|
||
};
|
||
|
||
const validGameConfig = {
|
||
timeCompression: 60,
|
||
epoch: { realStart: "2026-01-01T00:00:00Z", ephemerisDate: "2100-01-01T00:00:00Z" },
|
||
startingCredits: 0,
|
||
tutorialPayoutShare: 0.2,
|
||
contractBoardSize: 8,
|
||
tutorialBoardSize: 50,
|
||
contractBoardPeriodRealMinutes: 60,
|
||
dutyLimitGameHours: 12,
|
||
longHaulDutyBonusGameHours: 2,
|
||
restGameHours: 60,
|
||
firstContactChance: 0.1,
|
||
firstContactPayoutMultiplier: 2,
|
||
contractPayVariance: 0.15,
|
||
priorityChance: 0.15,
|
||
priorityPayoutMultiplier: 1.5,
|
||
priorityReferenceShipModelId: "freighter",
|
||
priorityWindowFactor: 1,
|
||
contractMassBands: [
|
||
{ min: 1, max: 20, count: 4 },
|
||
{ min: 21, max: 100, count: 4 },
|
||
],
|
||
boardPreviewSlots: 1,
|
||
boardOpenSlotsPerBand: 1,
|
||
longHaulCutoffsMillionKm: [100, 200, 300, 400, 500],
|
||
xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 },
|
||
pilotLevelXpThresholds: [100, 250, 500],
|
||
skillPointsPerLevel: 1,
|
||
pilotHireCost: 1000,
|
||
pilotProfitShare: 0.25,
|
||
tutorialLoanerShipModelId: "company-courier",
|
||
tutorialRunsBeforeDeal: 4,
|
||
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
|
||
handlingGameHoursPerTonne: 0.02,
|
||
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
|
||
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>][] = [
|
||
["Location", locationSchema, validLocation],
|
||
["ShipModel", shipModelSchema, validShipModel],
|
||
["CargoType", cargoTypeSchema, validCargoType],
|
||
["Good", goodSchema, validGood],
|
||
["Skill", skillSchema, validSkill],
|
||
["GameConfig", gameConfigSchema, validGameConfig],
|
||
["ScriptedTutorialContract", scriptedTutorialContractSchema, validScripted],
|
||
["OnboardingGoal", onboardingGoalSchema, validGoal],
|
||
];
|
||
|
||
describe.each(cases)("%s schema", (_name, schema, valid) => {
|
||
it("accepts a valid example", () => {
|
||
expect(schema.safeParse(valid).success).toBe(true);
|
||
});
|
||
|
||
it.each(Object.keys(valid))("rejects an object missing %s", (field) => {
|
||
const missing = { ...valid };
|
||
delete missing[field];
|
||
expect(schema.safeParse(missing).success).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("Good refinements (CR-007)", () => {
|
||
it("rejects a mass range whose min exceeds its max", () => {
|
||
const good = { ...validGood, massTonnes: { min: 9, max: 8 } };
|
||
expect(goodSchema.safeParse(good).success).toBe(false);
|
||
});
|
||
|
||
it("rejects a tier outside 1–5 and a shipperless location", () => {
|
||
expect(goodSchema.safeParse({ ...validGood, tier: 6 }).success).toBe(false);
|
||
expect(goodSchema.safeParse({ ...validGood, tier: 0 }).success).toBe(false);
|
||
expect(locationSchema.safeParse({ ...validLocation, shippers: [] }).success).toBe(false);
|
||
});
|
||
});
|
||
|
||
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 band with min above max", () => {
|
||
const config = {
|
||
...validGameConfig,
|
||
contractMassBands: [
|
||
{ min: 50, max: 10, count: 4 },
|
||
{ min: 21, max: 100, count: 4 },
|
||
],
|
||
};
|
||
expect(gameConfigSchema.safeParse(config).success).toBe(false);
|
||
});
|
||
|
||
it("rejects mass band counts that do not add up to the board size", () => {
|
||
const config = { ...validGameConfig, contractMassBands: [{ min: 1, max: 20, count: 3 }] };
|
||
expect(gameConfigSchema.safeParse(config).success).toBe(false);
|
||
});
|
||
|
||
it("rejects negative or fractional board slot counts (CR-006)", () => {
|
||
for (const bad of [-1, 0.5]) {
|
||
expect(
|
||
gameConfigSchema.safeParse({ ...validGameConfig, boardPreviewSlots: bad }).success,
|
||
).toBe(false);
|
||
expect(
|
||
gameConfigSchema.safeParse({ ...validGameConfig, boardOpenSlotsPerBand: bad }).success,
|
||
).toBe(false);
|
||
}
|
||
expect(
|
||
gameConfigSchema.safeParse({ ...validGameConfig, boardOpenSlotsPerBand: 0 }).success,
|
||
).toBe(true);
|
||
});
|
||
|
||
it("rejects Long Haul cut-offs that are not increasing", () => {
|
||
const config = { ...validGameConfig, longHaulCutoffsMillionKm: [100, 100, 300] };
|
||
expect(gameConfigSchema.safeParse(config).success).toBe(false);
|
||
});
|
||
});
|