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, launchRangeFactor: 1, 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, role: "Fast and cheap.", 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, noStrandMarginGameHours: 48, 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, boardRetentionPeriods: 24, savedDispatchRetentionRealHours: 168, economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, }; 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][] = [ ["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); }); });