213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
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<typeof landingClassSchema>;
|
||
|
||
export const locationTypeSchema = z.enum([
|
||
"atmospheric-world",
|
||
"airless-body",
|
||
"cloud-platform",
|
||
"belt-station",
|
||
]);
|
||
export type LocationType = z.infer<typeof locationTypeSchema>;
|
||
|
||
/** Astronomical body a location's position is derived from. */
|
||
export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]);
|
||
export type AstronomyBody = z.infer<typeof astronomyBodySchema>;
|
||
|
||
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<typeof locationSchema>;
|
||
|
||
export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]);
|
||
export type ShipArchetype = z.infer<typeof shipArchetypeSchema>;
|
||
|
||
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<typeof shipModelSchema>;
|
||
|
||
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<typeof cargoTypeSchema>;
|
||
|
||
export const skillSchema = z.object({
|
||
id: idSchema,
|
||
name: z.string().min(1),
|
||
maxLevel: positiveInt,
|
||
description: z.string().min(1),
|
||
});
|
||
export type Skill = z.infer<typeof skillSchema>;
|
||
|
||
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<typeof gameConfigSchema>;
|
||
|
||
/**
|
||
* 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<typeof scriptedTutorialContractSchema>;
|
||
|
||
/** 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<typeof goalConditionSchema>;
|
||
|
||
/** 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<typeof onboardingGoalSchema>;
|