diff --git a/DECISIONS.md b/DECISIONS.md index 0434335..5d7eeb0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **"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. + +## 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. diff --git a/apps/web/src/screens/ShipUpgrades.tsx b/apps/web/src/screens/ShipUpgrades.tsx index 93622a1..d32983b 100644 --- a/apps/web/src/screens/ShipUpgrades.tsx +++ b/apps/web/src/screens/ShipUpgrades.tsx @@ -33,6 +33,10 @@ const STAT_LABELS: Record `${formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h`, }, + hullRating: { + label: "Hull rating", + format: (s) => String(s.hullRating), + }, cargoCapacityTonnes: { label: "Capacity", format: (s) => `${Math.round(s.cargoCapacityTonnes * 10) / 10} t`, diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 191f1bf..adbb412 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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. -- [ ] 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.3 — Sim: applying outcomes - [ ] Step 26.4 — Sim: hull condition and repairs diff --git a/packages/content/src/bundle.ts b/packages/content/src/bundle.ts index 1a575cb..de40ee3 100644 --- a/packages/content/src/bundle.ts +++ b/packages/content/src/bundle.ts @@ -13,6 +13,7 @@ import { } from "./data/onboarding"; import { shipModels } from "./data/ships"; import { upgrades } from "./data/upgrades"; +import { tripEvents } from "./data/events"; /** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */ function byId(items: readonly T[]): (id: string) => T | undefined { @@ -39,6 +40,9 @@ export const content = { /** "While you were away" wording (CR-025). */ awayWording, upgrades, + /** Random events (CR-026). */ + tripEvents, + tripEvent: byId(tripEvents), /** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */ dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }), location: byId(locations), diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 3e464ec..27732b6 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -99,4 +99,32 @@ export const gameConfig: GameConfig = { defaultSpendingFloor: 500, }, 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, + }, }; diff --git a/packages/content/src/data/events.ts b/packages/content/src/data/events.ts new file mode 100644 index 0000000..9e65aa5 --- /dev/null +++ b/packages/content/src/data/events.ts @@ -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: [] }, + ], + }, +]; diff --git a/packages/content/src/data/locations.ts b/packages/content/src/data/locations.ts index 6edae3e..a116ae0 100644 --- a/packages/content/src/data/locations.ts +++ b/packages/content/src/data/locations.ts @@ -29,6 +29,7 @@ export function offsetPosition(parent: FixedPosition, offset: number): FixedPosi export const locations: Location[] = [ { id: "earth", + region: "inner", name: "Earth", type: "atmospheric-world", landingRequirement: "atmospheric", @@ -51,6 +52,7 @@ export const locations: Location[] = [ }, { id: "moon", + region: "inner", name: "Moon", type: "airless-body", landingRequirement: "vacuum", @@ -72,6 +74,7 @@ export const locations: Location[] = [ }, { id: "mars", + region: "inner", name: "Mars", type: "atmospheric-world", landingRequirement: "atmospheric", @@ -89,6 +92,7 @@ export const locations: Location[] = [ }, { id: "ceres-station", + region: "belt", name: "Ceres Station", type: "belt-station", landingRequirement: "vacuum", @@ -106,6 +110,7 @@ export const locations: Location[] = [ }, { id: "jupiter-cloud-platform", + region: "jupiter", name: "Jupiter Cloud Platform", type: "cloud-platform", landingRequirement: "atmospheric", @@ -126,6 +131,7 @@ export const locations: Location[] = [ // short hops from Jupiter's platform. All undiscovered at the start. { id: "venus-cloud-city", + region: "inner", name: "Venus Cloud City", type: "cloud-platform", landingRequirement: "atmospheric", @@ -148,6 +154,7 @@ export const locations: Location[] = [ }, { id: "io-sulfur-works", + region: "jupiter", name: "Io Sulfur Works", type: "orbital-station", // An orbital station: no Hardened landing class needed (decision 0.5.101). @@ -170,6 +177,7 @@ export const locations: Location[] = [ }, { id: "europa-ice-station", + region: "jupiter", name: "Europa Ice Station", type: "surface-base", landingRequirement: "vacuum", diff --git a/packages/content/src/data/upgrades.ts b/packages/content/src/data/upgrades.ts index fd2aeb0..7e00169 100644 --- a/packages/content/src/data/upgrades.ts +++ b/packages/content/src/data/upgrades.ts @@ -44,6 +44,15 @@ export const upgrades: Upgrade[] = [ percentPerLevel: -15, 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", name: "Cargo Handling Gear", diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index f9c3d84..a4a4c81 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -6,5 +6,6 @@ export * from "./data/goods"; export * from "./data/config"; export * from "./data/onboarding"; export * from "./data/upgrades"; +export * from "./data/events"; export * from "./bundle"; export * from "./deal"; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index ef732c2..f9b8a7b 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -35,9 +35,15 @@ export const fixedPositionSchema = z.object({ }); export type FixedPosition = z.infer; +/** 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; + 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). */ @@ -130,17 +136,23 @@ export const SHIP_STATS = [ "tankCapacityFu", "overheadGameHours", "handlingGameHoursPerTonne", + "hullRating", ] as const; export const shipStatSchema = z.enum(SHIP_STATS); export type ShipStat = z.infer; -/** 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({ 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"), + /** 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 @@ -152,6 +164,102 @@ export const upgradeSchema = z.object({ }); export type Upgrade = z.infer; +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; + +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; + export const cargoTypeSchema = z.object({ id: idSchema, name: z.string().min(1), @@ -355,6 +463,44 @@ export const gameConfigSchema = z 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), + }), + /** 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( (config) => @@ -378,6 +524,10 @@ export const gameConfigSchema = z 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), { message: "The default sleep threshold must be one of the options", path: ["fleetSleep", "defaultHours"], diff --git a/packages/content/test/events.test.ts b/packages/content/test/events.test.ts new file mode 100644 index 0000000..e477097 --- /dev/null +++ b/packages/content/test/events.test.ts @@ -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, + }); + }); +}); diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 0c1de5a..30f5f26 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -14,6 +14,7 @@ import { const validLocation = { id: "earth", name: "Earth", + region: "inner", type: "atmospheric-world", landingRequirement: "atmospheric", position: { radiusMillionKm: 150, angleDegrees: 210 }, @@ -113,6 +114,34 @@ const validGameConfig = { defaultSpendingFloor: 500, }, 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", tutorialRunsBeforeDeal: 5, tutorialXpMultiplier: 2, diff --git a/packages/content/test/upgrades.test.ts b/packages/content/test/upgrades.test.ts index 07f1dad..9274698 100644 --- a/packages/content/test/upgrades.test.ts +++ b/packages/content/test/upgrades.test.ts @@ -20,13 +20,14 @@ const maxed = (modelId: string, stat: Exclude { - 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); expect(upgrades.map((u) => u.id)).toEqual([ "engines", "cargo-bay", "fuel-tanks", "landing-thrusters", + "hull-plating", "handling-gear", ]); for (const upgrade of upgrades) expect(upgrade.maxLevel).toBe(3); diff --git a/packages/sim/src/upgrades.ts b/packages/sim/src/upgrades.ts index 5f38797..13037b9 100644 --- a/packages/sim/src/upgrades.ts +++ b/packages/sim/src/upgrades.ts @@ -39,7 +39,10 @@ export function effectiveStats( const level = Math.min(levels[upgrade.id] ?? 0, upgradeMaxLevel(upgrade, model.id)); if (level <= 0) continue; 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; } diff --git a/packages/sim/test/effective-stats.test.ts b/packages/sim/test/effective-stats.test.ts index f941c01..2aa36cd 100644 --- a/packages/sim/test/effective-stats.test.ts +++ b/packages/sim/test/effective-stats.test.ts @@ -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, + ); + }); +});