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", "orbital-station", "surface-base", ]); export type LocationType = z.infer; /** A fixed point around the Sun (decision 0.5.93): radius in million km, angle in degrees. */ export const fixedPositionSchema = z.object({ radiusMillionKm: z.number().nonnegative(), angleDegrees: z.number(), }); export type FixedPosition = z.infer; export const locationSchema = z.object({ id: idSchema, name: z.string().min(1), type: locationTypeSchema, landingRequirement: landingClassSchema, /** Where it sits, for good (decision 0.5.93). */ position: fixedPositionSchema, /** * Set for a body that rides with another (the Moon with Earth): its position * is the parent's moved straight out from the Sun by the offset. */ orbitParent: z .object({ locationId: idSchema, offsetMillionKm: z.number().positive() }) .optional(), /** Fuel price here as a multiple of the base price (decision 0.5.96). */ fuelPrice: z.number().positive(), /** Whether ships can buy fuel here (all base locations do; decision 0.5.96). */ sellsFuel: z.boolean(), /** * Multiplies a ship's reach for legs launched from here (decision 0.5.78): * low gravity stretches it, so a leg from here burns less fuel (0.5.95). */ launchRangeFactor: 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", "lighter", "clipper", "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(), /** Fuel the tank holds, in fu (decision 0.5.94). */ tankCapacityFu: z.number().positive(), /** * fu burned per million km of economic distance (decisions 0.5.84, 0.5.95): * a leg burns `burn × economicDistance(distance ÷ origin range factor)`. */ burnFuPerMillionKm: 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(), /** False for company-only ships such as the tutorial loaner (decision 0.5.29). */ purchasable: z.boolean(), /** One line on what the ship is for, shown in the Market (CR-012). */ role: z.string().min(1), /** Loading time per tonne; defaults to the game-wide `handlingGameHoursPerTonne` (CR-012). */ handlingGameHoursPerTonne: z.number().positive().optional(), /** 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", "tankCapacityFu", "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 shown at that instant (only a displayed date since the map is fixed, 0.5.93). */ epoch: z.object({ realStart: z.iso.datetime(), gameStart: 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 (decisions 0.5.30, 0.5.106). */ tutorialRunsBeforeDeal: positiveInt, /** Tutorial runs earn normal XP × this (decision 0.5.107). */ tutorialXpMultiplier: z.number().min(1), /** 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, /** * What the deal hands over (decisions 0.5.30, 0.5.82): the avatar's ship, the * veteran's ship and the bay. The loan is their prices plus the lease (0.5.83). */ deal: z.object({ avatarShipModelId: z.string().min(1), veteranShipModelId: z.string().min(1), bayLocationId: z.string().min(1), }), contractBoardPeriodRealMinutes: z.number().positive(), /** Past board periods whose boards and accepted contracts are kept (Step 7.3). */ boardRetentionPeriods: positiveInt, /** Resolved, seen dispatches older than this are pruned from the save (CR-014). */ savedDispatchRetentionRealHours: z.number().positive(), /** * Pay and fuel use `reference × (distance / reference)^exponent` (decision * 0.5.84), so long legs aren't worth hundreds of short ones. */ /** Credits per fu before a location's price multiple (decision 0.5.96). */ baseFuelPricePerFu: z.number().positive(), /** Emergency fuel costs this many times the local price (decision 0.5.99). */ emergencyFuelMultiplier: z.number().min(1), economicDistance: z.object({ referenceMillionKm: z.number().positive(), exponent: z.number().positive().max(1), }), /** * 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, /** Candidate contracts a board tries before stopping short (decision 0.5.114). */ boardMaxAttempts: positiveInt, /** 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, /** Hired pilots' share of profit (payout − fuel) per contract delivery (decision 0.5.12, CR-010). */ pilotProfitShare: fraction, /** Independent pilots (CR-021, decisions 0.5.123–0.5.129). */ independentPilots: z.object({ /** Credits: the one-time Dispatch Office (0.5.123). */ dispatchOfficePrice: positiveInt, /** Real minutes into each board period that its jobs are the player's alone (0.5.126). */ firstLookRealMinutes: z.number().positive(), /** Game minutes from a pilot becoming able to act to their next job (CR-021 §2). */ turnaroundGameMinutes: z.number().positive(), /** Empty board periods in a row away from home before heading home (0.5.128). */ emptyPeriodsBeforeHome: positiveInt, /** Real days of absence catch-up replays at most (0.5.129). */ maxCatchUpRealDays: z.number().positive(), /** Credits pilots never spend the company below, by default (0.5.127). */ defaultSpendingFloor: positiveInt, }), }) .refine( (config) => config.contractMassBands.reduce((sum, band) => sum + band.count, 0) === config.contractBoardSize, { message: "Mass band counts must add up to contractBoardSize", path: ["contractMassBands"] }, ) .refine( (config) => config.independentPilots.firstLookRealMinutes < config.contractBoardPeriodRealMinutes, { message: "The first-look window must be shorter than the board period", path: ["independentPilots", "firstLookRealMinutes"], }, ); 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 }), /** Ships bought from the Market; the deal's ships don't count (CR-013). */ z.object({ kind: z.literal("market-ships-bought"), count: positiveInt }), z.object({ kind: z.literal("pilots-hired"), count: positiveInt }), /** Priority contracts delivered, by any pilot (CR-013). */ z.object({ kind: z.literal("priority-deliveries"), 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; /** The veteran pilot who comes with the deal (decision 0.5.70, CR-010). */ export const veteranPilotSchema = z.object({ name: z.string().min(1), /** One line shown on the deal and the pilot card. */ bio: z.string().min(1), level: positiveInt, /** Skill levels already bought, by skill id. */ skills: z.record(idSchema, positiveInt), }); export type VeteranPilot = z.infer; /** * The welcome cards (decisions 0.5.131, 0.5.132): what the game is, how it * plays and where you start. `{company}` in a body is the tutorial employer's name. */ export const welcomeSchema = z.object({ cards: z.array(z.object({ title: z.string().min(1), body: z.string().min(1) })).min(1), next: z.string().min(1), skip: z.string().min(1), /** The last card's button for a new company. */ start: z.string().min(1), /** The last card's button when reopened from Settings → Help. */ close: z.string().min(1), }); export type Welcome = z.infer;