Step 26.1: content
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-16 23:50:50 -04:00
parent 83df241656
commit f7a39b5921
15 changed files with 705 additions and 6 deletions

View File

@ -1898,3 +1898,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Numbering:** received as "CR-024 — Random Events, Radio Calls, and Hull Condition" for "Phase 24"; stored as **CR-026**, built as **Phase 26** (its 24.n = 26.n), since CR-024 (Fleet Sleep) and CR-025 (Short Away Summary) exist. Its decisions CR-024.D1–D9 are **0.5.143–0.5.151**. Its references: CR-005 → CR-007, CR-006 → CR-009, CR-011 → CR-014, CR-014 → CR-015, CR-015 → CR-016, CR-018 → CR-019, CR-019 → CR-021, CR-021 → CR-023, CR-022 → CR-024, CR-023 → CR-025; CR-001, CR-004 and plan Steps 3.13, 3.17 and 12 are unchanged. - **Numbering:** received as "CR-024 — Random Events, Radio Calls, and Hull Condition" for "Phase 24"; stored as **CR-026**, built as **Phase 26** (its 24.n = 26.n), since CR-024 (Fleet Sleep) and CR-025 (Short Away Summary) exist. Its decisions CR-024.D1–D9 are **0.5.143–0.5.151**. Its references: CR-005 → CR-007, CR-006 → CR-009, CR-011 → CR-014, CR-014 → CR-015, CR-015 → CR-016, CR-018 → CR-019, CR-019 → CR-021, CR-021 → CR-023, CR-022 → CR-024, CR-023 → CR-025; CR-001, CR-004 and plan Steps 3.13, 3.17 and 12 are unchanged.
- **"HQ" is the Bridge**, as in CR-021, CR-024 and CR-025. - **"HQ" is the Bridge**, as in CR-021, CR-024 and CR-025.
- **The catalog has 14 events**: nine bad or neutral and five good, as the CR's tables list them. - **The catalog has 14 events**: nine bad or neutral and five good, as the CR's tables list them.
## Step 26.1
- **Places get a region** (inner, belt, jupiter), so event conditions read from content: a leg between regions crosses or enters the belt; Earth↔Moon and legs inside the Jupiter system are the "local loop"; legs with an end in the inner system or belt can meet spot freight.
- **Events whose effects are a share of the payout need a contract**: the derelict pod, distress beacon, spot freight and customs hold, as well as the grateful client. On a deadhead they would be worth nothing.
- **An option is "always" effects plus exclusive chances**: push through the debris field is 35% hull damage, 10% lost pay, otherwise nothing; seal the hazmat leak is +2 h always and 30% hull damage.
- **Weights:** bad 70 (flare 10, micrometeor 8, hiccup 10, debris 10, storm 8, customs 8, hazmat 6, load shift 6, spoilage 4), good 36 (tailwind 10, grateful client 10, pod 6, beacon 4, spot freight 6): 34% good. Step 26.9 tunes them against the balance targets.
- **Hull Plating adds a flat +1 a level** (`flatPerLevel`), since a percent of a rating of 1–3 would be fractions; its cost and shop time follow the usual upgrade levels.

View File

@ -33,6 +33,10 @@ const STAT_LABELS: Record<ShipStat, { label: string; format: (s: EffectiveStats)
label: "Speed", label: "Speed",
format: (s) => `${formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h`, format: (s) => `${formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h`,
}, },
hullRating: {
label: "Hull rating",
format: (s) => String(s.hullRating),
},
cargoCapacityTonnes: { cargoCapacityTonnes: {
label: "Capacity", label: "Capacity",
format: (s) => `${Math.round(s.cargoCapacityTonnes * 10) / 10} t`, format: (s) => `${Math.round(s.cargoCapacityTonnes * 10) / 10} t`,

View File

@ -547,7 +547,7 @@ Spec: `memory-bank/change-requests/CR-025-short-away-summary.md` (received as "C
Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "CR-024 … Phase 24"). New decisions recorded as plan §0.5.143–0.5.151. Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "CR-024 … Phase 24"). New decisions recorded as plan §0.5.143–0.5.151.
- [ ] Step 26.1 — Content - [x] **Step 26.1 — Content.** `content.tripEvents` (`packages/content/src/data/events.ts`, `tripEventSchema`): the 14 events of CR-026 §3, each with kind (automatic or radio), good flag, weight, title, text, when it can happen (trip length, belt crossings, inner/belt legs, arrival regions or places, contracts, cargo class with skill halving, Priority perishables, a hull-below weight factor) and options (label, default, effects, exclusive chances, free hold or fuel needed). Config `events` (pacing, decision 0.5.145) and `hull` (decision 0.5.148). Every location has a `region` (inner, belt, jupiter). Hull Plating (`hull-plating`, +1 hull rating a level, three levels): upgrades can now add a flat amount (`flatPerLevel`), and `hullRating` is an upgradable stat. Tests: `packages/content/test/events.test.ts` (the 14 valid events; one default per radio call, and two refused; chances at most 100%, and over refused; good weight 34%, within 28–38%; ids that exist; the config numbers and regions; Hull Plating); `packages/sim/test/effective-stats.test.ts` (Hull Plating adds 1 a level).
- [ ] Step 26.2 — Sim: event roll at dispatch - [ ] Step 26.2 — Sim: event roll at dispatch
- [ ] Step 26.3 — Sim: applying outcomes - [ ] Step 26.3 — Sim: applying outcomes
- [ ] Step 26.4 — Sim: hull condition and repairs - [ ] Step 26.4 — Sim: hull condition and repairs

View File

@ -13,6 +13,7 @@ import {
} from "./data/onboarding"; } from "./data/onboarding";
import { shipModels } from "./data/ships"; import { shipModels } from "./data/ships";
import { upgrades } from "./data/upgrades"; import { upgrades } from "./data/upgrades";
import { tripEvents } from "./data/events";
/** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */ /** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */
function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T | undefined { function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T | undefined {
@ -39,6 +40,9 @@ export const content = {
/** "While you were away" wording (CR-025). */ /** "While you were away" wording (CR-025). */
awayWording, awayWording,
upgrades, upgrades,
/** Random events (CR-026). */
tripEvents,
tripEvent: byId(tripEvents),
/** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */ /** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */
dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }), dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }),
location: byId(locations), location: byId(locations),

View File

@ -99,4 +99,32 @@ export const gameConfig: GameConfig = {
defaultSpendingFloor: 500, defaultSpendingFloor: 500,
}, },
fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] }, fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] },
events: {
baseChance: 0.02,
chancePerGameHour: 0.004,
maxChance: 0.35,
localLoopMultiplier: 0.5,
beltCrossingMultiplier: 1.3,
stormArrivalMultiplier: 1.2,
shipCooldownTrips: 2,
fleetRadioGapRealMinutes: 45,
graceTripsAfterDeal: 3,
firstEventId: "derelict-pod",
firstEventMinTripGameHours: 6,
fireFrom: 0.2,
fireTo: 0.7,
responseWindowRealMinutes: 30,
minTripFraction: 0.5,
},
hull: {
floorPct: 5,
ratingReductionPerPoint: 0.1,
maxRatingReduction: 0.5,
noDangerousBelowPct: 50,
needsRepairBelowPct: 25,
damagedChanceMultiplier: 2,
repairCostPerPoint: 0.005,
repairRealMinutesPerPoint: 0.5,
repairMinRealMinutes: 5,
},
}; };

View File

@ -0,0 +1,323 @@
import type { TripEventDef } from "../schemas";
/**
* Random events (CR-026 §3, decision 0.5.146). Hull losses are percentage
* points of condition before the hull rating cuts them (decision 0.5.148);
* pay effects are percentages of the job's payout. Each option's `chances` are
* exclusive outcomes; what's left of 100% is "nothing more happens". Weights
* give about one good event for two bad ones.
*/
export const tripEvents: TripEventDef[] = [
// --- Bad and neutral ---
{
id: "solar-flare",
kind: "automatic",
good: false,
weight: 10,
title: "Solar flare",
text: "{pilot} sheltered from a solar flare behind the ship's shielding.",
when: { minTripGameHours: 24 },
options: [
{
id: "shelter",
label: "Shelter",
default: true,
effects: { delayGameHours: [2, 4] },
chances: [],
},
],
},
{
id: "micrometeor-strike",
kind: "automatic",
good: false,
weight: 8,
title: "Micrometeor strike",
text: "A micrometeor shower peppered the hull.",
when: { minTripGameHours: 24 },
options: [
{ id: "absorb", label: "Absorb", default: true, effects: { hullLoss: [5, 10] }, chances: [] },
],
},
{
id: "engine-hiccup",
kind: "automatic",
good: false,
weight: 10,
title: "Engine hiccup",
text: "An engine stalled and {pilot} had to restart it in flight.",
when: { hullBelowWeightFactor: { belowPct: 50, factor: 2 } },
options: [
{
id: "restart",
label: "Restart",
default: true,
effects: { delayGameHours: [1, 2] },
chances: [],
},
],
},
{
id: "debris-field",
kind: "radio",
good: false,
weight: 10,
title: "Debris field ahead",
text: "{pilot} reports a field of rock and wreckage across the route.",
when: { crossesBelt: true },
options: [
{
id: "go-around",
label: "Go around",
default: true,
effects: { delayGameHours: [3, 3] },
chances: [],
},
{
id: "push-through",
label: "Push through",
default: false,
effects: {},
chances: [
{ chance: 0.35, effects: { hullLoss: [10, 20] } },
{ chance: 0.1, effects: { payLossPct: 10 } },
],
},
],
},
{
id: "storm-approach",
kind: "radio",
good: false,
weight: 8,
title: "Storm approaching",
text: "A storm is building over the landing site, {pilot} says.",
when: { arrivalRegions: ["jupiter"], arrivalIds: ["venus-cloud-city"] },
options: [
{
id: "hold",
label: "Hold in orbit",
default: true,
effects: { delayGameHours: [3, 3] },
chances: [],
},
{
id: "land-now",
label: "Land now",
default: false,
effects: {},
chances: [{ chance: 0.4, effects: { hullLoss: [10, 15] } }],
},
],
},
{
id: "customs-hold",
kind: "radio",
good: false,
weight: 8,
title: "Customs hold",
text: "Customs wants to inspect {pilot}'s cargo before landing.",
when: { arrivalIds: ["earth", "mars"], contract: true },
options: [
{
id: "pay-fee",
label: "Pay the inspection fee",
default: true,
effects: { feePct: 3, feeMin: 100 },
chances: [],
},
{
id: "wait",
label: "Wait it out",
default: false,
effects: { delayGameHours: [5, 5] },
chances: [],
},
],
},
{
id: "hazmat-leak",
kind: "radio",
good: false,
weight: 6,
title: "Hazmat leak",
text: "A container in the hold is leaking, {pilot} reports.",
when: { cargoClassId: "dangerous", skillHalving: "dangerous-cargo" },
options: [
{
id: "vent",
label: "Vent part of the load",
default: true,
effects: { payLossPct: 15 },
chances: [],
},
{
id: "seal",
label: "Seal it and slow down",
default: false,
effects: { delayGameHours: [2, 2] },
chances: [{ chance: 0.3, effects: { hullLoss: [10, 10] } }],
},
],
},
{
id: "load-shift",
kind: "radio",
good: false,
weight: 6,
title: "Load shift",
text: "The load has shifted in the hold, {pilot} says.",
when: { cargoClassId: "heavy", skillHalving: "heavy-cargo" },
options: [
{
id: "re-secure",
label: "Re-secure it",
default: true,
effects: { delayGameHours: [2, 2] },
chances: [],
},
{
id: "keep-going",
label: "Keep going",
default: false,
effects: {},
chances: [{ chance: 0.25, effects: { payLossPct: 10 } }],
},
],
},
{
id: "spoilage-risk",
kind: "radio",
good: false,
weight: 4,
title: "Spoilage risk",
text: "The cargo's cooling is struggling, {pilot} reports.",
when: {
priorityGoods: [
"hydroponic-produce",
"cloud-botanicals",
"medical-supplies",
"ocean-samples",
],
},
options: [
{
id: "push",
label: "Push the engines",
default: true,
requiresFuel: true,
effects: { extraFuelPct: 15 },
chances: [],
},
{
id: "ease-off",
label: "Ease off",
default: false,
effects: { delayGameHours: [2, 2] },
chances: [],
},
],
},
// --- Good ---
{
id: "solar-tailwind",
kind: "automatic",
good: true,
weight: 10,
title: "Solar tailwind",
text: "A strong solar wind carried {pilot} along.",
when: { minTripGameHours: 12 },
options: [
{
id: "ride",
label: "Ride it",
default: true,
effects: { delayGameHours: [-2, -1] },
chances: [],
},
],
},
{
id: "grateful-client",
kind: "automatic",
good: true,
weight: 10,
title: "Grateful client",
text: "The client was so pleased with {pilot}'s delivery they added a tip.",
when: { contract: true },
options: [
{ id: "tip", label: "Tip", default: true, effects: { bonusPct: [10, 10] }, chances: [] },
],
},
{
id: "derelict-pod",
kind: "radio",
good: true,
weight: 6,
title: "Derelict cargo pod",
text: "{pilot} has spotted a drifting cargo pod with no beacon.",
when: { minTripGameHours: 6, contract: true },
options: [
{
id: "salvage",
label: "Salvage it",
default: false,
requiresFreeHoldPct: 10,
effects: { delayGameHours: [1, 1], bonusPct: [15, 40] },
chances: [],
},
{
id: "log-it",
label: "Log it and fly on",
default: true,
effects: { bonusPct: [5, 5] },
chances: [],
},
],
},
{
id: "distress-beacon",
kind: "radio",
good: true,
weight: 4,
title: "Distress beacon",
text: "{pilot} has picked up a distress beacon near the route.",
when: { minTripGameHours: 12, contract: true },
options: [
{
id: "help",
label: "Stop and help",
default: false,
effects: { delayGameHours: [4, 4], bonusPct: [20, 50], xpBonusPct: 50 },
chances: [],
},
{
id: "relay",
label: "Relay the coordinates",
default: true,
effects: { bonusPct: [5, 5] },
chances: [],
},
],
},
{
id: "spot-freight",
kind: "radio",
good: true,
weight: 6,
title: "Spot freight",
text: "A trader on the route offers {pilot} a small extra load.",
when: { innerOrBelt: true, contract: true },
options: [
{
id: "take",
label: "Take on the load",
default: false,
requiresFreeHoldPct: 10,
effects: { delayGameHours: [1, 1], bonusPct: [15, 25] },
chances: [],
},
{ id: "pass", label: "Pass", default: true, effects: {}, chances: [] },
],
},
];

View File

@ -29,6 +29,7 @@ export function offsetPosition(parent: FixedPosition, offset: number): FixedPosi
export const locations: Location[] = [ export const locations: Location[] = [
{ {
id: "earth", id: "earth",
region: "inner",
name: "Earth", name: "Earth",
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
@ -51,6 +52,7 @@ export const locations: Location[] = [
}, },
{ {
id: "moon", id: "moon",
region: "inner",
name: "Moon", name: "Moon",
type: "airless-body", type: "airless-body",
landingRequirement: "vacuum", landingRequirement: "vacuum",
@ -72,6 +74,7 @@ export const locations: Location[] = [
}, },
{ {
id: "mars", id: "mars",
region: "inner",
name: "Mars", name: "Mars",
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
@ -89,6 +92,7 @@ export const locations: Location[] = [
}, },
{ {
id: "ceres-station", id: "ceres-station",
region: "belt",
name: "Ceres Station", name: "Ceres Station",
type: "belt-station", type: "belt-station",
landingRequirement: "vacuum", landingRequirement: "vacuum",
@ -106,6 +110,7 @@ export const locations: Location[] = [
}, },
{ {
id: "jupiter-cloud-platform", id: "jupiter-cloud-platform",
region: "jupiter",
name: "Jupiter Cloud Platform", name: "Jupiter Cloud Platform",
type: "cloud-platform", type: "cloud-platform",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
@ -126,6 +131,7 @@ export const locations: Location[] = [
// short hops from Jupiter's platform. All undiscovered at the start. // short hops from Jupiter's platform. All undiscovered at the start.
{ {
id: "venus-cloud-city", id: "venus-cloud-city",
region: "inner",
name: "Venus Cloud City", name: "Venus Cloud City",
type: "cloud-platform", type: "cloud-platform",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
@ -148,6 +154,7 @@ export const locations: Location[] = [
}, },
{ {
id: "io-sulfur-works", id: "io-sulfur-works",
region: "jupiter",
name: "Io Sulfur Works", name: "Io Sulfur Works",
type: "orbital-station", type: "orbital-station",
// An orbital station: no Hardened landing class needed (decision 0.5.101). // An orbital station: no Hardened landing class needed (decision 0.5.101).
@ -170,6 +177,7 @@ export const locations: Location[] = [
}, },
{ {
id: "europa-ice-station", id: "europa-ice-station",
region: "jupiter",
name: "Europa Ice Station", name: "Europa Ice Station",
type: "surface-base", type: "surface-base",
landingRequirement: "vacuum", landingRequirement: "vacuum",

View File

@ -44,6 +44,15 @@ export const upgrades: Upgrade[] = [
percentPerLevel: -15, percentPerLevel: -15,
maxLevel: 3, maxLevel: 3,
}, },
{
// Deferred in CR-009, added by CR-026 (decision 0.5.149): +1 hull rating a level.
id: "hull-plating",
name: "Hull Plating",
stat: "hullRating",
percentPerLevel: 0,
flatPerLevel: 1,
maxLevel: 3,
},
{ {
id: "handling-gear", id: "handling-gear",
name: "Cargo Handling Gear", name: "Cargo Handling Gear",

View File

@ -6,5 +6,6 @@ export * from "./data/goods";
export * from "./data/config"; export * from "./data/config";
export * from "./data/onboarding"; export * from "./data/onboarding";
export * from "./data/upgrades"; export * from "./data/upgrades";
export * from "./data/events";
export * from "./bundle"; export * from "./bundle";
export * from "./deal"; export * from "./deal";

View File

@ -35,9 +35,15 @@ export const fixedPositionSchema = z.object({
}); });
export type FixedPosition = z.infer<typeof fixedPositionSchema>; 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({ export const locationSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1), name: z.string().min(1),
/** The inner system, the belt or the Jupiter system, for event routes (CR-026). */
region: regionSchema,
type: locationTypeSchema, type: locationTypeSchema,
landingRequirement: landingClassSchema, landingRequirement: landingClassSchema,
/** Where it sits, for good (decision 0.5.93). */ /** Where it sits, for good (decision 0.5.93). */
@ -130,17 +136,23 @@ export const SHIP_STATS = [
"tankCapacityFu", "tankCapacityFu",
"overheadGameHours", "overheadGameHours",
"handlingGameHoursPerTonne", "handlingGameHoursPerTonne",
"hullRating",
] as const; ] as const;
export const shipStatSchema = z.enum(SHIP_STATS); export const shipStatSchema = z.enum(SHIP_STATS);
export type ShipStat = z.infer<typeof shipStatSchema>; 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. */ /**
* 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({ export const upgradeSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1), name: z.string().min(1),
stat: shipStatSchema, stat: shipStatSchema,
/** Percent of the model's base stat per level; negative lowers it (e.g. −15 overhead). */ /** Percent of the model's base stat per level; negative lowers it (e.g. −15 overhead). 0 with `flatPerLevel`. */
percentPerLevel: z.number().refine((p) => p !== 0, "An upgrade must change its stat"), percentPerLevel: z.number(),
/** Added to the stat per level instead of a percent (Hull Plating: +1 hull rating). */
flatPerLevel: z.number().optional(),
maxLevel: positiveInt, maxLevel: positiveInt,
/** Per ship model overrides (decisions 0.5.67, 0.5.68). */ /** Per ship model overrides (decisions 0.5.67, 0.5.68). */
modelOverrides: z modelOverrides: z
@ -152,6 +164,102 @@ export const upgradeSchema = z.object({
}); });
export type Upgrade = z.infer<typeof upgradeSchema>; 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({ export const cargoTypeSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1), name: z.string().min(1),
@ -355,6 +463,44 @@ export const gameConfigSchema = z
defaultHours: z.number().positive(), defaultHours: z.number().positive(),
optionsHours: z.array(z.number().positive()).min(1), 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),
}),
/** 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(),
}),
}) })
.refine( .refine(
(config) => (config) =>
@ -378,6 +524,10 @@ export const gameConfigSchema = z
path: ["fleetSleep", "optionsHours"], path: ["fleetSleep", "optionsHours"],
}, },
) )
.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), { .refine(({ fleetSleep }) => fleetSleep.optionsHours.includes(fleetSleep.defaultHours), {
message: "The default sleep threshold must be one of the options", message: "The default sleep threshold must be one of the options",
path: ["fleetSleep", "defaultHours"], path: ["fleetSleep", "defaultHours"],

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { content, gameConfig, tripEventSchema, tripEvents } from "../src/index";
describe("random events content (Step 26.1, decisions 0.5.145–0.5.149)", () => {
it("has the catalog's fourteen events, all valid, with unique ids", () => {
for (const event of tripEvents) {
const parsed = tripEventSchema.safeParse(event);
expect(parsed.success, event.id).toBe(true);
}
expect(tripEvents.map((e) => e.id)).toEqual([
"solar-flare",
"micrometeor-strike",
"engine-hiccup",
"debris-field",
"storm-approach",
"customs-hold",
"hazmat-leak",
"load-shift",
"spoilage-risk",
"solar-tailwind",
"grateful-client",
"derelict-pod",
"distress-beacon",
"spot-freight",
]);
expect(content.tripEvent("derelict-pod")?.kind).toBe("radio");
});
it("gives every radio call two or three options and exactly one default", () => {
for (const event of tripEvents.filter((e) => e.kind === "radio")) {
expect(event.options.length, event.id).toBeGreaterThanOrEqual(2);
expect(
event.options.filter((o) => o.default),
event.id,
).toHaveLength(1);
}
const twoDefaults = {
...content.tripEvent("debris-field")!,
options: content.tripEvent("debris-field")!.options.map((o) => ({ ...o, default: true })),
};
expect(tripEventSchema.safeParse(twoDefaults).success).toBe(false);
});
it("keeps every option's chances to at most 100%", () => {
for (const event of tripEvents) {
for (const option of event.options) {
const total = option.chances.reduce((sum, c) => sum + c.chance, 0);
expect(total, `${event.id}/${option.id}`).toBeLessThanOrEqual(1);
}
}
const tooMuch = {
...content.tripEvent("debris-field")!,
options: [
content.tripEvent("debris-field")!.options[0]!,
{
...content.tripEvent("debris-field")!.options[1]!,
chances: [
{ chance: 0.7, effects: {} },
{ chance: 0.4, effects: {} },
],
},
],
};
expect(tripEventSchema.safeParse(tooMuch).success).toBe(false);
});
it("weights about one good event for every two bad (28–38% good)", () => {
const total = tripEvents.reduce((sum, e) => sum + e.weight, 0);
const good = tripEvents.filter((e) => e.good).reduce((sum, e) => sum + e.weight, 0);
expect(good / total).toBeGreaterThanOrEqual(0.28);
expect(good / total).toBeLessThanOrEqual(0.38);
});
it("only names places, goods and skills that exist", () => {
for (const event of tripEvents) {
const { arrivalIds, priorityGoods, skillHalving, cargoClassId } = event.when;
for (const id of arrivalIds ?? []) expect(content.location(id), id).toBeDefined();
for (const id of priorityGoods ?? []) expect(content.good(id), id).toBeDefined();
if (skillHalving) expect(content.skill(skillHalving), skillHalving).toBeDefined();
if (cargoClassId) expect(content.cargoTypes.some((c) => c.id === cargoClassId)).toBe(true);
}
expect(content.tripEvent(gameConfig.events.firstEventId)?.good).toBe(true);
});
it("sets the CR's pacing, hull and repair numbers, and a region for every place", () => {
expect(gameConfig.events).toMatchObject({
baseChance: 0.02,
chancePerGameHour: 0.004,
maxChance: 0.35,
shipCooldownTrips: 2,
fleetRadioGapRealMinutes: 45,
graceTripsAfterDeal: 3,
responseWindowRealMinutes: 30,
});
expect(gameConfig.hull).toMatchObject({
floorPct: 5,
noDangerousBelowPct: 50,
needsRepairBelowPct: 25,
});
expect(content.locations.map((l) => [l.id, l.region])).toEqual([
["earth", "inner"],
["moon", "inner"],
["mars", "inner"],
["ceres-station", "belt"],
["jupiter-cloud-platform", "jupiter"],
["venus-cloud-city", "inner"],
["io-sulfur-works", "jupiter"],
["europa-ice-station", "jupiter"],
]);
});
it("adds Hull Plating: +1 hull rating a level, three levels", () => {
expect(content.upgrade("hull-plating")).toMatchObject({
stat: "hullRating",
flatPerLevel: 1,
maxLevel: 3,
});
});
});

View File

@ -14,6 +14,7 @@ import {
const validLocation = { const validLocation = {
id: "earth", id: "earth",
name: "Earth", name: "Earth",
region: "inner",
type: "atmospheric-world", type: "atmospheric-world",
landingRequirement: "atmospheric", landingRequirement: "atmospheric",
position: { radiusMillionKm: 150, angleDegrees: 210 }, position: { radiusMillionKm: 150, angleDegrees: 210 },
@ -113,6 +114,34 @@ const validGameConfig = {
defaultSpendingFloor: 500, defaultSpendingFloor: 500,
}, },
fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] }, fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] },
events: {
baseChance: 0.02,
chancePerGameHour: 0.004,
maxChance: 0.35,
localLoopMultiplier: 0.5,
beltCrossingMultiplier: 1.3,
stormArrivalMultiplier: 1.2,
shipCooldownTrips: 2,
fleetRadioGapRealMinutes: 45,
graceTripsAfterDeal: 3,
firstEventId: "derelict-pod",
firstEventMinTripGameHours: 6,
fireFrom: 0.2,
fireTo: 0.7,
responseWindowRealMinutes: 30,
minTripFraction: 0.5,
},
hull: {
floorPct: 5,
ratingReductionPerPoint: 0.1,
maxRatingReduction: 0.5,
noDangerousBelowPct: 50,
needsRepairBelowPct: 25,
damagedChanceMultiplier: 2,
repairCostPerPoint: 0.005,
repairRealMinutesPerPoint: 0.5,
repairMinRealMinutes: 5,
},
tutorialLoanerShipModelId: "company-courier", tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 5, tutorialRunsBeforeDeal: 5,
tutorialXpMultiplier: 2, tutorialXpMultiplier: 2,

View File

@ -20,13 +20,14 @@ const maxed = (modelId: string, stat: Exclude<ShipStat, "handlingGameHoursPerTon
}; };
describe("ship upgrades content (CR-009)", () => { describe("ship upgrades content (CR-009)", () => {
it("has the five upgrades, all valid, three levels each", () => { it("has the six upgrades (Hull Plating since CR-026), all valid, three levels each", () => {
for (const upgrade of upgrades) expect(upgradeSchema.safeParse(upgrade).success).toBe(true); for (const upgrade of upgrades) expect(upgradeSchema.safeParse(upgrade).success).toBe(true);
expect(upgrades.map((u) => u.id)).toEqual([ expect(upgrades.map((u) => u.id)).toEqual([
"engines", "engines",
"cargo-bay", "cargo-bay",
"fuel-tanks", "fuel-tanks",
"landing-thrusters", "landing-thrusters",
"hull-plating",
"handling-gear", "handling-gear",
]); ]);
for (const upgrade of upgrades) expect(upgrade.maxLevel).toBe(3); for (const upgrade of upgrades) expect(upgrade.maxLevel).toBe(3);

View File

@ -39,7 +39,10 @@ export function effectiveStats(
const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id)); const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id));
if (level <= 0) continue; if (level <= 0) continue;
const base = stats[upgrade.stat]; const base = stats[upgrade.stat];
stats[upgrade.stat] = base * (1 + (upgradePercentPerLevel(upgrade, model.id) * level) / 100); stats[upgrade.stat] =
upgrade.flatPerLevel !== undefined
? base + upgrade.flatPerLevel * level
: base * (1 + (upgradePercentPerLevel(upgrade, model.id) * level) / 100);
} }
return stats; return stats;
} }

View File

@ -136,3 +136,15 @@ describe("per-model handling (CR-012)", () => {
); );
}); });
}); });
describe("Hull Plating (Step 26.1, decision 0.5.149)", () => {
it("adds 1 hull rating a level, flat", () => {
const courier = content.shipModel("courier")!;
expect(effectiveStats(courier, { "hull-plating": 3 }, content).hullRating).toBe(
courier.hullRating + 3,
);
expect(effectiveStats(courier, { "hull-plating": 1 }, content).hullRating).toBe(
courier.hullRating + 1,
);
});
});