348 lines
14 KiB
TypeScript
348 lines
14 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(),
|
||
/**
|
||
* Multiplies a ship's range for legs launched from here (decision 0.5.78):
|
||
* low gravity stretches it. Separate from the fuel-cost multiplier.
|
||
*/
|
||
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<typeof locationSchema>;
|
||
|
||
export const shipArchetypeSchema = z.enum([
|
||
"courier",
|
||
"lighter",
|
||
"clipper",
|
||
"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(),
|
||
/** 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<typeof shipModelSchema>;
|
||
|
||
/**
|
||
* 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<typeof shipStatSchema>;
|
||
|
||
/** 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<typeof upgradeSchema>;
|
||
|
||
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>;
|
||
|
||
/** 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<typeof goodSchema>;
|
||
|
||
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(),
|
||
/** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */
|
||
noStrandMarginGameHours: z.number().nonnegative(),
|
||
/**
|
||
* 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,
|
||
/** Hired pilots' share of profit (payout − fuel) per contract delivery (decision 0.5.12, CR-010). */
|
||
pilotProfitShare: fraction,
|
||
})
|
||
.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 }),
|
||
/** Contracts (not deadheads) delivered by the veteran pilot (CR-010). */
|
||
z.object({ kind: z.literal("veteran-contracts"), 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>;
|
||
|
||
/** 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<typeof veteranPilotSchema>;
|