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

213 lines
8.3 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,
/** 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>;