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, /** Good ids made here (CR-007), derived from the goods catalog. */ produces: z.array(idSchema), /** Good ids wanted here (CR-007), derived from the goods catalog. */ demands: z.array(idSchema), /** * Cargo class ids that steer a board's class mix (decision 0.5.57): a * contract's class is one produced here and demanded at its destination. */ cargoClassMix: z.object({ produces: z.array(idSchema), demands: z.array(idSchema) }), /** Flavor company names for contracts from here (decision 0.5.58). */ shippers: z.array(z.string().min(1)).min(1), /** Map art asset id (CR-003), e.g. `body-earth`; see the web app's art manifest. */ sprite: idSchema, /** Optional map badge asset id drawn on the body's edge (CR-003). */ badge: idSchema.optional(), }); 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(), /** Map art asset id (CR-003), e.g. `ship-freighter`. */ sprite: idSchema, }); export type ShipModel = z.infer; /** * Ship stats an upgrade can change (CR-009). The first four are ShipModel * fields; handling time per tonne is a game-wide base (`handlingGameHoursPerTonne`) * that becomes per ship once upgraded. */ export const SHIP_STATS = [ "cruiseSpeedKmPerGameHour", "cargoCapacityTonnes", "fuelRangeMillionKm", "overheadGameHours", "handlingGameHoursPerTonne", ] as const; export const shipStatSchema = z.enum(SHIP_STATS); export type ShipStat = z.infer; /** One upgrade (decision 0.5.63): a stat change per level, as a percent of the base stat. */ export const upgradeSchema = z.object({ id: idSchema, name: z.string().min(1), stat: shipStatSchema, /** Percent of the model's base stat per level; negative lowers it (e.g. −15 overhead). */ percentPerLevel: z.number().refine((p) => p !== 0, "An upgrade must change its stat"), maxLevel: positiveInt, /** Per ship model overrides (decisions 0.5.67, 0.5.68). */ modelOverrides: z .record( idSchema, z.object({ percentPerLevel: z.number().optional(), maxLevel: positiveInt.optional() }), ) .optional(), }); export type Upgrade = 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; /** A named good (CR-007, decision 0.5.54). */ export const goodSchema = z .object({ id: idSchema, name: z.string().min(1), /** Cargo class (cargo type) id. */ cargoClassId: idSchema, massTonnes: z.object({ min: z.number().positive(), max: z.number().positive() }), /** Multiplies the class's base pay. */ payMultiplier: z.number().positive(), /** High-Value and Dangerous goods only: the required skill level (decision 0.5.55). */ tier: z.number().int().min(1).max(5).optional(), priorityEligible: z.boolean(), /** Shown instead of the name on a Priority contract, e.g. "Emergency Parts". */ priorityName: z.string().min(1).optional(), producedAt: z.array(idSchema).min(1), demandedAt: z.array(idSchema).min(1), }) .refine((g) => g.massTonnes.min <= g.massTonnes.max, { message: "Mass min must not exceed max", path: ["massTonnes"], }); export type Good = 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(), /** * Upgrade levels 1, 2, 3, …: cost as a percent of the ship model's price and * real minutes in the shop (CR-009). */ upgradeLevels: z .array( z.object({ costPercentOfPrice: z.number().positive(), shopRealMinutes: z.number().positive(), }), ) .min(1), /** Game hours a pilot may fly before mandatory rest, at Long Haul 0 (decision 0.5.43). */ dutyLimitGameHours: z.number().positive(), /** Extra duty game hours per Long Haul level (decision 0.5.43). */ longHaulDutyBonusGameHours: z.number().positive(), /** How long a rest lasts, in game hours; at least one board period (decision 0.5.46). */ restGameHours: z.number().positive(), firstContactChance: fraction, firstContactPayoutMultiplier: z.number().positive(), /** Each board contract's pay rate varies within ± this share of 1 (decision 0.5.60). */ contractPayVariance: fraction, /** Share of Priority-eligible contracts that become Priority (decision 0.5.56). */ priorityChance: fraction, /** Payout multiplier for a Priority contract. */ priorityPayoutMultiplier: z.number().positive(), /** Ship model whose trip time sets a Priority contract's delivery window. */ priorityReferenceShipModelId: idSchema, /** Multiplies the reference trip time to give the window. */ priorityWindowFactor: 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), /** * Contracts kept in a mass band the player's fleet can't carry: a preview * of bigger ships' work (decision 0.5.52). The rest move to flyable bands. */ boardPreviewSlots: nonNegativeInt, /** Contracts per flyable band left open (skill-gated, first contact) rather than fit (0.5.52). */ boardOpenSlotsPerBand: nonNegativeInt, /** 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;