665 lines
27 KiB
TypeScript
665 lines
27 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",
|
||
"orbital-station",
|
||
"surface-base",
|
||
]);
|
||
export type LocationType = z.infer<typeof locationTypeSchema>;
|
||
|
||
/** 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<typeof fixedPositionSchema>;
|
||
|
||
/** Where a place is, for event routes (CR-026): the inner system, the belt, or the Jupiter system. */
|
||
export const regionSchema = z.enum(["inner", "belt", "jupiter"]);
|
||
export type Region = z.infer<typeof regionSchema>;
|
||
|
||
export const locationSchema = z.object({
|
||
id: idSchema,
|
||
name: z.string().min(1),
|
||
/** The inner system, the belt or the Jupiter system, for event routes (CR-026). */
|
||
region: regionSchema,
|
||
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<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(),
|
||
/** 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<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",
|
||
"tankCapacityFu",
|
||
"overheadGameHours",
|
||
"handlingGameHoursPerTonne",
|
||
"hullRating",
|
||
] 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, or a flat amount per level (Hull Plating, decision 0.5.149).
|
||
*/
|
||
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). 0 with `flatPerLevel`. */
|
||
percentPerLevel: z.number(),
|
||
/** Added to the stat per level instead of a percent (Hull Plating: +1 hull rating). */
|
||
flatPerLevel: z.number().optional(),
|
||
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>;
|
||
|
||
const fraction01 = z.number().min(0).max(1);
|
||
const hoursRange = z
|
||
.tuple([z.number(), z.number()])
|
||
.refine(([low, high]) => low <= high, "A range runs low to high");
|
||
|
||
/** What an option does (CR-026 §3): every field optional, all numbers in content. */
|
||
export const eventEffectsSchema = z
|
||
.object({
|
||
/** Game hours added to the trip; negative brings arrival forward (never below the trip's floor). */
|
||
delayGameHours: hoursRange.optional(),
|
||
/** Hull condition lost, in percentage points before the hull rating (decision 0.5.148). */
|
||
hullLoss: hoursRange.optional(),
|
||
/** Cargo pay lost, as a percent of the payout. */
|
||
payLossPct: z.number().positive().optional(),
|
||
/** A fee, as a percent of the payout, at least `feeMin` credits. */
|
||
feePct: z.number().positive().optional(),
|
||
feeMin: z.number().nonnegative().optional(),
|
||
/** Extra pay on delivery, as a percent of the payout. */
|
||
bonusPct: hoursRange.optional(),
|
||
/** Extra XP for the trip, as a percent. */
|
||
xpBonusPct: z.number().positive().optional(),
|
||
/** Extra fuel burned for the leg, as a percent of its fuel. */
|
||
extraFuelPct: z.number().positive().optional(),
|
||
})
|
||
.strict();
|
||
export type EventEffects = z.infer<typeof eventEffectsSchema>;
|
||
|
||
export const eventOptionSchema = z.object({
|
||
id: idSchema,
|
||
label: z.string().min(1),
|
||
/** The cautious choice, taken when a radio call goes unanswered (decision 0.5.144). */
|
||
default: z.boolean(),
|
||
/** Needs this much of the hold free (percent of capacity). */
|
||
requiresFreeHoldPct: z.number().positive().optional(),
|
||
/** Needs the fuel for its extra burn aboard. */
|
||
requiresFuel: z.boolean().optional(),
|
||
/** Always applies when chosen. */
|
||
effects: eventEffectsSchema,
|
||
/** Exclusive outcomes on top; the rest of 100% adds nothing. */
|
||
chances: z.array(z.object({ chance: fraction01, effects: eventEffectsSchema })),
|
||
});
|
||
|
||
/** Which trips an event can happen on (CR-026 §3); every condition given must hold. */
|
||
export const eventWhenSchema = z
|
||
.object({
|
||
minTripGameHours: z.number().positive().optional(),
|
||
/** Legs between regions, which cross or enter the belt. */
|
||
crossesBelt: z.boolean().optional(),
|
||
/** Legs with an end in the inner system or the belt. */
|
||
innerOrBelt: z.boolean().optional(),
|
||
/** Arrivals in one of these regions or at one of these places. */
|
||
arrivalRegions: z.array(regionSchema).optional(),
|
||
arrivalIds: z.array(idSchema).optional(),
|
||
/** Contracts only (pay effects need a payout). */
|
||
contract: z.boolean().optional(),
|
||
/** Contracts of this cargo class. */
|
||
cargoClassId: idSchema.optional(),
|
||
/** Each level of this skill above the job's tier halves the chance. */
|
||
skillHalving: idSchema.optional(),
|
||
/** Priority contracts carrying one of these goods. */
|
||
priorityGoods: z.array(idSchema).optional(),
|
||
/** More likely when the hull is below a level. */
|
||
hullBelowWeightFactor: z
|
||
.object({ belowPct: z.number().positive(), factor: z.number().positive() })
|
||
.optional(),
|
||
})
|
||
.strict();
|
||
|
||
export const tripEventSchema = z
|
||
.object({
|
||
id: idSchema,
|
||
/** Automatic events just happen; radio calls ask the player (decision 0.5.144). */
|
||
kind: z.enum(["automatic", "radio"]),
|
||
good: z.boolean(),
|
||
weight: z.number().positive(),
|
||
title: z.string().min(1),
|
||
/** What happened, with `{pilot}` for the pilot's name. */
|
||
text: z.string().min(1),
|
||
when: eventWhenSchema,
|
||
options: z.array(eventOptionSchema).min(1).max(3),
|
||
})
|
||
.refine((e) => e.options.filter((o) => o.default).length === 1, {
|
||
message: "An event has exactly one default option",
|
||
})
|
||
.refine((e) => e.kind === "radio" || e.options.length === 1, {
|
||
message: "An automatic event has one option",
|
||
})
|
||
.refine((e) => e.kind === "automatic" || e.options.length >= 2, {
|
||
message: "A radio call has two or three options",
|
||
})
|
||
.refine(
|
||
(e) => e.options.every((o) => o.chances.reduce((sum, c) => sum + c.chance, 0) <= 1 + 1e-9),
|
||
{ message: "An option's chances add up to at most 100%" },
|
||
);
|
||
export type TripEventDef = z.infer<typeof tripEventSchema>;
|
||
|
||
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 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(),
|
||
/** Real days logbook entries are kept (decision 0.5.141). */
|
||
logbookRetentionRealDays: 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,
|
||
}),
|
||
/**
|
||
* Fleet sleep (decision 0.5.137): real hours after the last manual decision
|
||
* until Independent pilots stop starting new work, and the choices offered
|
||
* in Settings → Company, shortest first.
|
||
*/
|
||
fleetSleep: z.object({
|
||
defaultHours: z.number().positive(),
|
||
optionsHours: z.array(z.number().positive()).min(1),
|
||
}),
|
||
/** Event pacing (decision 0.5.145). */
|
||
events: z.object({
|
||
/** Chance per trip: min(max, base + perGameHour × trip game hours) × route multipliers. */
|
||
baseChance: z.number().min(0).max(1),
|
||
chancePerGameHour: z.number().min(0),
|
||
maxChance: z.number().min(0).max(1),
|
||
/** Earth↔Moon and legs inside the Jupiter system. */
|
||
localLoopMultiplier: z.number().positive(),
|
||
beltCrossingMultiplier: z.number().positive(),
|
||
/** Arrivals in the Jupiter system or at Venus. */
|
||
stormArrivalMultiplier: z.number().positive(),
|
||
shipCooldownTrips: z.number().int().nonnegative(),
|
||
fleetRadioGapRealMinutes: z.number().nonnegative(),
|
||
graceTripsAfterDeal: z.number().int().nonnegative(),
|
||
firstEventId: z.string().min(1),
|
||
firstEventMinTripGameHours: z.number().positive(),
|
||
/** Where in the trip an event fires, as fractions of it. */
|
||
fireFrom: z.number().min(0).max(1),
|
||
fireTo: z.number().min(0).max(1),
|
||
responseWindowRealMinutes: z.number().positive(),
|
||
/** A speed-up never makes a trip shorter than this share of it. */
|
||
minTripFraction: z.number().min(0).max(1),
|
||
}),
|
||
/** Auto-repair (decision 0.5.157): the default threshold and the choices, in percent. */
|
||
autoRepair: z.object({
|
||
defaultThresholdPct: z.number().min(0).max(100),
|
||
minPct: z.number().min(0).max(100),
|
||
maxPct: z.number().min(0).max(100),
|
||
stepPct: z.number().positive(),
|
||
}),
|
||
/** Hull condition and repairs (decision 0.5.148). */
|
||
hull: z.object({
|
||
floorPct: z.number().min(0).max(100),
|
||
/** Each hull rating point cuts damage chance and amount by this, up to the max. */
|
||
ratingReductionPerPoint: z.number().min(0).max(1),
|
||
maxRatingReduction: z.number().min(0).max(1),
|
||
noDangerousBelowPct: z.number().min(0).max(100),
|
||
needsRepairBelowPct: z.number().min(0).max(100),
|
||
/** Below `needsRepairBelowPct`, damage chances are multiplied by this. */
|
||
damagedChanceMultiplier: z.number().positive(),
|
||
/** Repair cost per missing point, as a fraction of the model's price. */
|
||
repairCostPerPoint: z.number().positive(),
|
||
repairRealMinutesPerPoint: z.number().positive(),
|
||
repairMinRealMinutes: z.number().positive(),
|
||
/** Condition points every hull loses per game hour flown (decision 0.5.152). */
|
||
wearPerGameHour: z.number().nonnegative(),
|
||
}),
|
||
})
|
||
.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"],
|
||
},
|
||
)
|
||
.refine(
|
||
({ fleetSleep: { optionsHours } }) =>
|
||
optionsHours.every((hours, i) => i === 0 || hours > optionsHours[i - 1]!),
|
||
{
|
||
message: "Sleep options must be in rising order, each once",
|
||
path: ["fleetSleep", "optionsHours"],
|
||
},
|
||
)
|
||
.refine(
|
||
({ autoRepair: a }) =>
|
||
a.minPct <= a.defaultThresholdPct &&
|
||
a.defaultThresholdPct <= a.maxPct &&
|
||
(a.defaultThresholdPct - a.minPct) % a.stepPct === 0 &&
|
||
(a.maxPct - a.minPct) % a.stepPct === 0,
|
||
{
|
||
message: "The auto-repair default must be one of the steps from min to max",
|
||
path: ["autoRepair", "defaultThresholdPct"],
|
||
},
|
||
)
|
||
.refine(({ events }) => events.fireFrom < events.fireTo, {
|
||
message: "Events fire inside the trip, early before late",
|
||
path: ["events", "fireFrom"],
|
||
})
|
||
.refine(({ fleetSleep }) => fleetSleep.optionsHours.includes(fleetSleep.defaultHours), {
|
||
message: "The default sleep threshold must be one of the options",
|
||
path: ["fleetSleep", "defaultHours"],
|
||
});
|
||
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 }),
|
||
/** 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<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>;
|
||
|
||
/**
|
||
* 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<typeof welcomeSchema>;
|
||
|
||
const line = z.string().min(1);
|
||
|
||
/**
|
||
* "While you were away" wording (decision 0.5.140). `{name}` marks a value
|
||
* the client fills in: places, goods, pilots, ships, credits and counts.
|
||
*/
|
||
export const awayWordingSchema = z.object({
|
||
title: line,
|
||
totals: z.object({ net: line, deliveries: line, discoveries: line, loanRepaid: line }),
|
||
highlights: z.object({
|
||
discovery: line,
|
||
levelUp: line,
|
||
goalReady: line,
|
||
bestJob: line,
|
||
noJobs: line,
|
||
lowFuel: line,
|
||
emergencyFuel: line,
|
||
belowFloor: line,
|
||
}),
|
||
moreHighlights: line,
|
||
pilotLine: line,
|
||
leveledUp: line,
|
||
morePilots: line,
|
||
idlePilots: line,
|
||
idlePilot: line,
|
||
direct: z.object({
|
||
delivery: line,
|
||
deadhead: line,
|
||
upgrade: line,
|
||
discovery: line,
|
||
levelUp: line,
|
||
}),
|
||
viewLogbook: line,
|
||
done: line,
|
||
});
|
||
export type AwayWording = z.infer<typeof awayWordingSchema>;
|