Step 26.4: sim: hull condition and repairs
CI / check (push) Successful in 7m52s
Details
CI / check (push) Successful in 7m52s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
331faac3c6
commit
ef7d483b40
|
|
@ -1922,3 +1922,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The Priority check uses the actual trip time after the event** against the delivery window; missing it costs `payout − payout ÷ Priority multiplier`, the bonus only (decision 0.5.146).
|
- **The Priority check uses the actual trip time after the event** against the delivery window; missing it costs `payout − payout ÷ Priority multiplier`, the bonus only (decision 0.5.146).
|
||||||
- **Losses never take the payout below zero, and bonuses come on top** (decision 0.5.147): a vented load and a fee on a small job leave 0, plus any tip.
|
- **Losses never take the payout below zero, and bonuses come on top** (decision 0.5.147): a vented load and a fee on a small job leave 0, plus any tip.
|
||||||
- **The pilot's share is taken from the payout after the event**, like the loan: a salvage bonus is shared with the pilot who flew it, and a loss costs them too.
|
- **The pilot's share is taken from the payout after the event**, like the loan: a salvage bonus is shared with the pilot who flew it, and a loss costs them too.
|
||||||
|
|
||||||
|
## Step 26.4
|
||||||
|
|
||||||
|
- **The hull reason comes after the pilot checks and before expiry and fuel**, like a ship condition the player can fix: the dispatch sheet still lists every problem, and the fuel reason stays last ("Refuel here first" relies on it meaning nothing else is wrong).
|
||||||
|
- **`ShipState.hullConditionPct` is optional** (absent = 100), so the tutorial loaner and older test fixtures need nothing new.
|
||||||
|
- **Condition is kept to one decimal**, as rolled hits are, so repeated hits add up exactly.
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
"too-slow": "Too slow for the Priority window",
|
"too-slow": "Too slow for the Priority window",
|
||||||
"cargo-skill-too-low": "Cargo skill too low",
|
"cargo-skill-too-low": "Cargo skill too low",
|
||||||
"long-haul-too-low": "Long Haul too low",
|
"long-haul-too-low": "Long Haul too low",
|
||||||
|
"hull-too-damaged": "Hull too damaged for hazardous cargo: repair at an HQ",
|
||||||
"contract-expired": "Contract expired",
|
"contract-expired": "Contract expired",
|
||||||
"not-enough-fuel": "Not enough fuel",
|
"not-enough-fuel": "Not enough fuel",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -550,7 +550,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
|
||||||
- [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).
|
- [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).
|
||||||
- [x] **Step 26.2 — Sim: event roll at dispatch.** `rollTripEvent` (`packages/sim/src/events.ts`, pure, seeded from the trip's seed) returns null or a `TripEvent`: its id, kind, fire time (20–70% of the trip), window end (radio: 30 real minutes or arrival, whichever is first; automatic: the fire time), every option's pre-rolled `EventOutcome` (delay in real ms, hull loss after the hull rating, pay lost, fee, bonus, extra XP %, extra fuel), whether each is available (10% free hold, fuel for the burn) and the default (another available option when the cautious one can't be taken). Quiet in the tutorial, the first 3 trips after the deal and a ship's 2 trips after an event; the first event is the derelict pod on the first contract of 6+ game hours; otherwise `eventChance` (decision 0.5.145, capped at 35% after the route multipliers) and a weighted pick among the events allowed on the trip (`eventWeight`: conditions, skill halving, the hull factor); a radio call within 45 real minutes of another in the fleet is dropped. Tests (`packages/sim/test/events.test.ts`): the same trip gives the same event; none in the tutorial or the grace trips; the first event is the pod on 6+ hours only; the cooldown; fleet pacing at 44 and 46 minutes, the window, the fire point; 100,000 trips (Earth↔Moon ≤ 3%, long hauls ≤ 35%, every event only where allowed); Dangerous Cargo two levels above the tier makes leaks under 40% as common.
|
- [x] **Step 26.2 — Sim: event roll at dispatch.** `rollTripEvent` (`packages/sim/src/events.ts`, pure, seeded from the trip's seed) returns null or a `TripEvent`: its id, kind, fire time (20–70% of the trip), window end (radio: 30 real minutes or arrival, whichever is first; automatic: the fire time), every option's pre-rolled `EventOutcome` (delay in real ms, hull loss after the hull rating, pay lost, fee, bonus, extra XP %, extra fuel), whether each is available (10% free hold, fuel for the burn) and the default (another available option when the cautious one can't be taken). Quiet in the tutorial, the first 3 trips after the deal and a ship's 2 trips after an event; the first event is the derelict pod on the first contract of 6+ game hours; otherwise `eventChance` (decision 0.5.145, capped at 35% after the route multipliers) and a weighted pick among the events allowed on the trip (`eventWeight`: conditions, skill halving, the hull factor); a radio call within 45 real minutes of another in the fleet is dropped. Tests (`packages/sim/test/events.test.ts`): the same trip gives the same event; none in the tutorial or the grace trips; the first event is the pod on 6+ hours only; the cooldown; fleet pacing at 44 and 46 minutes, the window, the fire point; 100,000 trips (Earth↔Moon ≤ 3%, long hauls ≤ 35%, every event only where allowed); Dangerous Cargo two levels above the tier makes leaks under 40% as common.
|
||||||
- [x] **Step 26.3 — Sim: applying outcomes.** `applyEventOption` turns an option's pre-rolled outcome into an `AppliedEvent`: the new arrival (the delay added to the original arrival; a speed-up never below half the trip or before the event), hull loss, pay lost, fee, bonus, the Priority bonus lost when the new trip misses the window, extra XP and extra fuel; it refuses an unavailable option. `payoutAfterEvent` takes losses, the fee and a lost Priority bonus off the payout (never below zero) and adds the bonus. `resolveDispatch` takes the applied event: the pilot's share and the loan come from the new payout, and XP gets the extra percent. Tests (`packages/sim/test/events.test.ts`): a 3-hour detour moves arrival by exactly 3 game hours; a tailwind stops at half the trip; pay never below zero, bonus on top; a missed Priority window costs only the bonus; a full hold makes salvage unavailable and refused; an option's outcome is its pre-rolled one; resolution pays the bonus and extra XP and shares the new payout.
|
- [x] **Step 26.3 — Sim: applying outcomes.** `applyEventOption` turns an option's pre-rolled outcome into an `AppliedEvent`: the new arrival (the delay added to the original arrival; a speed-up never below half the trip or before the event), hull loss, pay lost, fee, bonus, the Priority bonus lost when the new trip misses the window, extra XP and extra fuel; it refuses an unavailable option. `payoutAfterEvent` takes losses, the fee and a lost Priority bonus off the payout (never below zero) and adds the bonus. `resolveDispatch` takes the applied event: the pilot's share and the loan come from the new payout, and XP gets the extra percent. Tests (`packages/sim/test/events.test.ts`): a 3-hour detour moves arrival by exactly 3 game hours; a tailwind stops at half the trip; pay never below zero, bonus on top; a missed Priority window costs only the bonus; a full hold makes salvage unavailable and refused; an option's outcome is its pre-rolled one; resolution pays the bonus and extra XP and shares the new payout.
|
||||||
- [ ] Step 26.4 — Sim: hull condition and repairs
|
- [x] **Step 26.4 — Sim: hull condition and repairs.** `packages/sim/src/hull.ts`: `hullRatingReduction` (10% a point, at most 50%), `damageAfterRating`, `damageChance` (cut by the rating, doubled below 25%), `hullAfterDamage` (never below 5%), `repairCost` (missing points × model price × 0.5%) and `repairShopMs` (half a minute a point, at least 5), `hullBarsDangerous` (below 50%). The event roll uses them. `validateDispatch` has a new reason, `hull-too-damaged`, for Dangerous cargo on a hull below 50% (after the skill checks, before expiry and fuel), with messages in the engine and the dispatch sheet; it isn't a permanent check, so boards still show those jobs (CR-019). Tests (`packages/sim/test/hull.test.ts`): rating 3 turns a 20% hit into 14%, and the cut stops at half; chances on a rated and a battered hull; the 5% floor; Dangerous refused at 40%, allowed at 50% and for other cargo at 40%; repair cost and time.
|
||||||
- [ ] Step 26.5 — Engine: events in play and catch-up
|
- [ ] Step 26.5 — Engine: events in play and catch-up
|
||||||
- [ ] Step 26.6 — Client: radio calls
|
- [ ] Step 26.6 — Client: radio calls
|
||||||
- [ ] Step 26.7 — Client: hull and results
|
- [ ] Step 26.7 — Client: hull and results
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
|
||||||
"too-slow": "That ship can't make this Priority delivery's window.",
|
"too-slow": "That ship can't make this Priority delivery's window.",
|
||||||
"cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.",
|
"cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.",
|
||||||
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",
|
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",
|
||||||
|
"hull-too-damaged": "Hull too damaged for hazardous cargo: repair at an HQ.",
|
||||||
"contract-expired": "This contract has expired.",
|
"contract-expired": "This contract has expired.",
|
||||||
"not-enough-fuel": "Not enough fuel on board for this leg. Refuel first.",
|
"not-enough-fuel": "Not enough fuel on board for this leg. Refuel first.",
|
||||||
"already-there": "The ship is already there.",
|
"already-there": "The ship is already there.",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||||
import { deriveSeed, type Seed } from "./rng";
|
import { deriveSeed, type Seed } from "./rng";
|
||||||
import { travelTimeGameHours } from "./travel";
|
import { travelTimeGameHours } from "./travel";
|
||||||
import { effectiveStats, isInShop, type EffectiveStats, type UpgradeLevels } from "./upgrades";
|
import { effectiveStats, isInShop, type EffectiveStats, type UpgradeLevels } from "./upgrades";
|
||||||
|
import { hullBarsDangerous } from "./hull";
|
||||||
|
|
||||||
export interface ShipState {
|
export interface ShipState {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -22,6 +23,8 @@ export interface ShipState {
|
||||||
inShopUntil?: RealTime | null;
|
inShopUntil?: RealTime | null;
|
||||||
/** The ship's fuel (decision 0.5.94); absent for the tutorial loaner, which uses none. */
|
/** The ship's fuel (decision 0.5.94); absent for the tutorial loaner, which uses none. */
|
||||||
tank?: Tank;
|
tank?: Tank;
|
||||||
|
/** Hull condition in percent (CR-026); absent means 100. */
|
||||||
|
hullConditionPct?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
|
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
|
||||||
|
|
@ -70,6 +73,7 @@ export const DISPATCH_FAILURES = [
|
||||||
"too-slow",
|
"too-slow",
|
||||||
"cargo-skill-too-low",
|
"cargo-skill-too-low",
|
||||||
"long-haul-too-low",
|
"long-haul-too-low",
|
||||||
|
"hull-too-damaged",
|
||||||
"contract-expired",
|
"contract-expired",
|
||||||
"not-enough-fuel",
|
"not-enough-fuel",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -234,6 +238,14 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
|
||||||
const pilotCheck = checkPilotEligibility(pilot.skills, contract);
|
const pilotCheck = checkPilotEligibility(pilot.skills, contract);
|
||||||
if (!pilotCheck.ok) return fail(pilotCheck.reason);
|
if (!pilotCheck.ok) return fail(pilotCheck.reason);
|
||||||
|
|
||||||
|
// A badly damaged hull can't carry Dangerous cargo until repaired (decision 0.5.148).
|
||||||
|
if (
|
||||||
|
contract.cargoTypeId === "dangerous" &&
|
||||||
|
hullBarsDangerous(ship.hullConditionPct ?? 100, content.config)
|
||||||
|
) {
|
||||||
|
return fail("hull-too-damaged");
|
||||||
|
}
|
||||||
|
|
||||||
if (now >= contract.expiresAt) return fail("contract-expired");
|
if (now >= contract.expiresAt) return fail("contract-expired");
|
||||||
|
|
||||||
if (!tutorial && !hasFuelFor(ship, model, origin, destination, content)) {
|
if (!tutorial && !hasFuelFor(ship, model, origin, destination, content)) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { MS_PER_HOUR, type RealTime } from "./clock";
|
||||||
import type { DispatchRecord } from "./dispatch";
|
import type { DispatchRecord } from "./dispatch";
|
||||||
import { findLocation } from "./dispatch";
|
import { findLocation } from "./dispatch";
|
||||||
import type { SkillLevels } from "./pilot";
|
import type { SkillLevels } from "./pilot";
|
||||||
|
import { damageAfterRating, damageChance } from "./hull";
|
||||||
import { createRng, deriveSeed, type Rng } from "./rng";
|
import { createRng, deriveSeed, type Rng } from "./rng";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,11 +146,6 @@ export function eventWeight(
|
||||||
return weight;
|
return weight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How much the hull rating cuts damage chances and amounts (decision 0.5.148). */
|
|
||||||
export function hullRatingReduction(hullRating: number, config: GameConfig): number {
|
|
||||||
return Math.min(config.hull.maxRatingReduction, hullRating * config.hull.ratingReductionPerPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
const between = (rng: Rng, [low, high]: readonly [number, number]) =>
|
const between = (rng: Rng, [low, high]: readonly [number, number]) =>
|
||||||
low + rng.next() * (high - low);
|
low + rng.next() * (high - low);
|
||||||
|
|
||||||
|
|
@ -163,12 +159,6 @@ function rollOutcome(
|
||||||
const { config } = content;
|
const { config } = content;
|
||||||
const { record, ship } = input;
|
const { record, ship } = input;
|
||||||
const payout = record.contract?.payout ?? 0;
|
const payout = record.contract?.payout ?? 0;
|
||||||
const reduction = hullRatingReduction(ship.hullRating, config);
|
|
||||||
// A badly damaged hull takes damage twice as often (decision 0.5.148).
|
|
||||||
const damagedFactor =
|
|
||||||
ship.hullConditionPct < config.hull.needsRepairBelowPct
|
|
||||||
? config.hull.damagedChanceMultiplier
|
|
||||||
: 1;
|
|
||||||
const outcome: EventOutcome = {
|
const outcome: EventOutcome = {
|
||||||
delayMs: 0,
|
delayMs: 0,
|
||||||
hullLoss: 0,
|
hullLoss: 0,
|
||||||
|
|
@ -183,7 +173,7 @@ function rollOutcome(
|
||||||
const hours = between(rng, effects.delayGameHours);
|
const hours = between(rng, effects.delayGameHours);
|
||||||
outcome.delayMs += Math.round((hours * MS_PER_HOUR) / config.timeCompression);
|
outcome.delayMs += Math.round((hours * MS_PER_HOUR) / config.timeCompression);
|
||||||
}
|
}
|
||||||
if (effects.hullLoss) outcome.hullLoss += between(rng, effects.hullLoss) * (1 - reduction);
|
if (effects.hullLoss) outcome.hullLoss += between(rng, effects.hullLoss);
|
||||||
if (effects.payLossPct) outcome.payLoss += Math.round((payout * effects.payLossPct) / 100);
|
if (effects.payLossPct) outcome.payLoss += Math.round((payout * effects.payLossPct) / 100);
|
||||||
if (effects.feePct) {
|
if (effects.feePct) {
|
||||||
outcome.fee += Math.max(effects.feeMin ?? 0, Math.round((payout * effects.feePct) / 100));
|
outcome.fee += Math.max(effects.feeMin ?? 0, Math.round((payout * effects.feePct) / 100));
|
||||||
|
|
@ -199,14 +189,16 @@ function rollOutcome(
|
||||||
const draw = rng.next();
|
const draw = rng.next();
|
||||||
let edge = 0;
|
let edge = 0;
|
||||||
for (const bucket of option.chances) {
|
for (const bucket of option.chances) {
|
||||||
const scale = bucket.effects.hullLoss ? (1 - reduction) * damagedFactor : 1;
|
// Hull hits are rarer on a well-rated hull, likelier on a battered one (decision 0.5.148).
|
||||||
edge += Math.min(1, bucket.chance * scale);
|
edge += bucket.effects.hullLoss
|
||||||
|
? damageChance(bucket.chance, ship.hullRating, ship.hullConditionPct, config)
|
||||||
|
: bucket.chance;
|
||||||
if (draw < edge) {
|
if (draw < edge) {
|
||||||
add(bucket.effects);
|
add(bucket.effects);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outcome.hullLoss = Math.round(outcome.hullLoss * 10) / 10;
|
outcome.hullLoss = damageAfterRating(outcome.hullLoss, ship.hullRating, config);
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type { GameConfig, ShipModel } from "@deadhead/content";
|
||||||
|
import { MS_PER_MINUTE } from "./clock";
|
||||||
|
|
||||||
|
/** How much the hull rating cuts damage chances and amounts (decision 0.5.148). */
|
||||||
|
export function hullRatingReduction(hullRating: number, config: GameConfig): number {
|
||||||
|
return Math.min(config.hull.maxRatingReduction, hullRating * config.hull.ratingReductionPerPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An event's hull hit after the hull rating, in condition points (decision 0.5.148). */
|
||||||
|
export function damageAfterRating(rawLoss: number, hullRating: number, config: GameConfig): number {
|
||||||
|
return Math.round(rawLoss * (1 - hullRatingReduction(hullRating, config)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chance of a hull hit after the rating, doubled on a badly damaged hull (decision 0.5.148). */
|
||||||
|
export function damageChance(
|
||||||
|
chance: number,
|
||||||
|
hullRating: number,
|
||||||
|
conditionPct: number,
|
||||||
|
config: GameConfig,
|
||||||
|
): number {
|
||||||
|
const damaged = conditionPct < config.hull.needsRepairBelowPct;
|
||||||
|
const scaled =
|
||||||
|
chance *
|
||||||
|
(1 - hullRatingReduction(hullRating, config)) *
|
||||||
|
(damaged ? config.hull.damagedChanceMultiplier : 1);
|
||||||
|
return Math.min(1, scaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Condition after a hit: never below the floor, so ships are never lost (decision 0.5.147). */
|
||||||
|
export function hullAfterDamage(conditionPct: number, loss: number, config: GameConfig): number {
|
||||||
|
const after = Math.round((conditionPct - loss) * 10) / 10;
|
||||||
|
return Math.max(Math.min(conditionPct, config.hull.floorPct), after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Missing condition points, the thing repairs are priced on. */
|
||||||
|
function missing(conditionPct: number): number {
|
||||||
|
return Math.max(0, 100 - conditionPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A repair to 100%: missing points × the model's price × the cost per point (decision 0.5.148). */
|
||||||
|
export function repairCost(model: ShipModel, conditionPct: number, config: GameConfig): number {
|
||||||
|
return Math.round(missing(conditionPct) * model.price * config.hull.repairCostPerPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A repair's shop time, in real ms: per missing point, at least the minimum. */
|
||||||
|
export function repairShopMs(conditionPct: number, config: GameConfig): number {
|
||||||
|
const { repairRealMinutesPerPoint, repairMinRealMinutes } = config.hull;
|
||||||
|
const minutes = Math.max(repairMinRealMinutes, missing(conditionPct) * repairRealMinutesPerPoint);
|
||||||
|
return Math.round(minutes * MS_PER_MINUTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Below this condition a ship can't take Dangerous cargo (a temporary reason, decision 0.5.148). */
|
||||||
|
export function hullBarsDangerous(conditionPct: number, config: GameConfig): boolean {
|
||||||
|
return conditionPct < config.hull.noDangerousBelowPct;
|
||||||
|
}
|
||||||
|
|
@ -154,7 +154,6 @@ export {
|
||||||
payoutAfterEvent,
|
payoutAfterEvent,
|
||||||
eventChance,
|
eventChance,
|
||||||
eventWeight,
|
eventWeight,
|
||||||
hullRatingReduction,
|
|
||||||
rollTripEvent,
|
rollTripEvent,
|
||||||
routeFacts,
|
routeFacts,
|
||||||
tripGameHoursOf,
|
tripGameHoursOf,
|
||||||
|
|
@ -167,3 +166,12 @@ export type {
|
||||||
EventRollInput,
|
EventRollInput,
|
||||||
TripEvent,
|
TripEvent,
|
||||||
} from "./events";
|
} from "./events";
|
||||||
|
export {
|
||||||
|
damageAfterRating,
|
||||||
|
damageChance,
|
||||||
|
hullAfterDamage,
|
||||||
|
hullBarsDangerous,
|
||||||
|
hullRatingReduction,
|
||||||
|
repairCost,
|
||||||
|
repairShopMs,
|
||||||
|
} from "./hull";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
damageAfterRating,
|
||||||
|
damageChance,
|
||||||
|
hullAfterDamage,
|
||||||
|
repairCost,
|
||||||
|
repairShopMs,
|
||||||
|
validateDispatch,
|
||||||
|
type Contract,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
const { config } = content;
|
||||||
|
const T0 = Date.parse("2100-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
describe("hull condition and repairs (Step 26.4, decisions 0.5.147, 0.5.148)", () => {
|
||||||
|
it("cuts a hit by 10% per hull rating point: rating 3 turns 20% into 14%", () => {
|
||||||
|
expect(damageAfterRating(20, 3, config)).toBe(14);
|
||||||
|
expect(damageAfterRating(20, 0, config)).toBe(20);
|
||||||
|
// At most half, however good the hull.
|
||||||
|
expect(damageAfterRating(20, 9, config)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes hits rarer on a rated hull and twice as likely below 25%", () => {
|
||||||
|
expect(damageChance(0.4, 0, 100, config)).toBeCloseTo(0.4);
|
||||||
|
expect(damageChance(0.4, 2, 100, config)).toBeCloseTo(0.32);
|
||||||
|
expect(damageChance(0.4, 2, 24, config)).toBeCloseTo(0.64);
|
||||||
|
expect(damageChance(0.8, 0, 10, config)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets a hull go below 5%: no ship is ever lost", () => {
|
||||||
|
expect(hullAfterDamage(60, 20, config)).toBe(40);
|
||||||
|
expect(hullAfterDamage(12, 20, config)).toBe(5);
|
||||||
|
expect(hullAfterDamage(5, 50, config)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses Dangerous cargo on a hull below 50%, until repaired", () => {
|
||||||
|
const job = (cargoTypeId: string): Contract => ({
|
||||||
|
id: "c",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId,
|
||||||
|
goodId: cargoTypeId === "dangerous" ? "helium-3" : "consumer-goods",
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: cargoTypeId === "dangerous" ? 1 : 0,
|
||||||
|
massTonnes: 5,
|
||||||
|
payout: 1_000,
|
||||||
|
requiredSkill: cargoTypeId === "dangerous" ? { skillId: "dangerous-cargo", level: 1 } : null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: T0,
|
||||||
|
expiresAt: T0 + 600_000,
|
||||||
|
firstContact: false,
|
||||||
|
});
|
||||||
|
const check = (hullConditionPct: number, cargoTypeId: string) =>
|
||||||
|
validateDispatch(
|
||||||
|
{
|
||||||
|
ship: {
|
||||||
|
id: "s1",
|
||||||
|
modelId: "courier",
|
||||||
|
locationId: "earth",
|
||||||
|
inFlight: false,
|
||||||
|
tank: { fuelFu: 40, avgPrice: 5 },
|
||||||
|
hullConditionPct,
|
||||||
|
},
|
||||||
|
pilot: {
|
||||||
|
id: "p1",
|
||||||
|
isAvatar: true,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
inFlight: false,
|
||||||
|
skills: { "dangerous-cargo": 1 },
|
||||||
|
restEndsAt: null,
|
||||||
|
},
|
||||||
|
contract: job(cargoTypeId),
|
||||||
|
tutorial: false,
|
||||||
|
now: T0,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
expect(check(40, "dangerous")).toEqual({ ok: false, reason: "hull-too-damaged" });
|
||||||
|
expect(check(50, "dangerous")).toEqual({ ok: true });
|
||||||
|
expect(check(40, "standard")).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices a repair on the missing points and times it at a minute per 2%, at least 5 minutes", () => {
|
||||||
|
const courier = content.shipModel("courier")!;
|
||||||
|
expect(repairCost(courier, 60, config)).toBe(Math.round(40 * courier.price * 0.005));
|
||||||
|
expect(repairCost(courier, 100, config)).toBe(0);
|
||||||
|
expect(repairShopMs(60, config)).toBe(20 * 60_000);
|
||||||
|
expect(repairShopMs(98, config)).toBe(5 * 60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue