import { z } from "zod"; /** Lowercase kebab-case identifier, e.g. `earth`, `bulk-hauler`. */ export const idSchema = z.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/); const positiveInt = z.number().int().positive(); const nonNegativeInt = z.number().int().nonnegative(); const fraction = z.number().min(0).max(1); /** Strictly increasing list of positive numbers. */ const increasingList = z .array(z.number().positive()) .refine((list) => list.every((value, i) => i === 0 || value > list[i - 1]!), { message: "Values must be strictly increasing", }); /** Ordered from least to most capable: a ship can land wherever its class or a lower one is required. */ export const landingClassSchema = z.enum(["vacuum", "atmospheric", "hardened"]); export type LandingClass = z.infer; export const locationTypeSchema = z.enum([ "atmospheric-world", "airless-body", "cloud-platform", "belt-station", ]); export type LocationType = z.infer; /** Astronomical body a location's position is derived from. */ export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]); export type AstronomyBody = z.infer; export const locationSchema = z.object({ id: idSchema, name: z.string().min(1), type: locationTypeSchema, landingRequirement: landingClassSchema, body: astronomyBodySchema, /** Multiplies fuel cost for launches from this location. */ launchCostMultiplier: z.number().positive(), startsDiscovered: z.boolean(), /** Credits. */ bayLeasePrice: nonNegativeInt, /** Cargo type ids. */ produces: z.array(idSchema), /** Cargo type ids. */ demands: z.array(idSchema), }); export type Location = z.infer; export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]); export type ShipArchetype = z.infer; export const shipModelSchema = z.object({ id: idSchema, name: z.string().min(1), archetype: shipArchetypeSchema, /** Credits. */ price: positiveInt, cruiseSpeedKmPerGameHour: z.number().positive(), cargoCapacityTonnes: z.number().positive(), fuelRangeMillionKm: z.number().positive(), /** Stored only; no effect in the base game (decision 0.5.13). */ hullRating: nonNegativeInt, landingClass: landingClassSchema, /** Fixed takeoff + landing time added to every trip. */ 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; export const cargoTypeSchema = z.object({ id: idSchema, name: z.string().min(1), /** Credits per tonne per million km. */ basePayPerTonnePerMillionKm: z.number().positive(), /** Pay multiplier applied according to the contract's tier (Standard is tier 0, others 1–5). */ tierPayMultiplier: z.number().positive(), /** Skill whose level must meet the contract's tier; null for Standard. */ requiredSkillId: idSchema.nullable(), }); export type CargoType = z.infer; export const skillSchema = z.object({ id: idSchema, name: z.string().min(1), maxLevel: positiveInt, description: z.string().min(1), }); export type Skill = z.infer; export const gameConfigSchema = z .object({ /** Game hours per real hour (60 means 1 real minute = 1 game hour). */ timeCompression: z.number().positive(), /** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */ epoch: z.object({ realStart: z.iso.datetime(), ephemerisDate: z.iso.datetime(), }), startingCredits: nonNegativeInt, /** Fraction of a normal payout the player receives for a tutorial run. */ tutorialPayoutShare: fraction, contractBoardSize: positiveInt, /** 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(), /** * Contract mass bands, one per ship size class: each board has `count` * contracts with a whole-tonne mass in [min, max]. Counts add up to the * board size (see the refinement below). */ contractMassBands: z .array( z .object({ min: z.number().int().positive(), max: z.number().int().positive(), count: positiveInt, }) .refine((band) => band.min <= band.max, { message: "min must not exceed max" }), ) .min(1), /** One cut-off per Long Haul level (decision 0.5.7). */ longHaulCutoffsMillionKm: increasingList, xpPerDelivery: z.object({ base: z.number().nonnegative(), perMillionKm: z.number().nonnegative(), perCargoTier: z.number().nonnegative(), }), /** Total XP needed to reach level 1, 2, 3, ... */ pilotLevelXpThresholds: increasingList, skillPointsPerLevel: positiveInt, /** Credits. */ pilotHireCost: nonNegativeInt, /** Credits per delivery; the avatar is exempt (decision 0.5.12). */ pilotPayPerDelivery: nonNegativeInt, }) .refine( (config) => config.contractMassBands.reduce((sum, band) => sum + band.count, 0) === config.contractBoardSize, { message: "Mass band counts must add up to contractBoardSize", path: ["contractMassBands"] }, ); 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;