deadhead-space-freight-tycoon/packages/content/src/schemas.ts

331 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
/** 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", "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(),
/** 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(),
/**
* 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>;