Step 27.2: sim: routing and triggers
CI / check (push) Successful in 8m37s
Details
CI / check (push) Successful in 8m37s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bd2a8de100
commit
58ce4f09e6
|
|
@ -2000,3 +2000,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
|
|
||||||
- **The booking lives on the ship** (one per ship, as the CR has one booking per ship), with its timings: `startedAt` once the work is in the shop (the booking clears at the shop end), `waitingSince` while it waits for funds at the HQ.
|
- **The booking lives on the ship** (one per ship, as the CR has one booking per ship), with its timings: `startedAt` once the work is in the shop (the booking clears at the shop end), `waitingSince` while it waits for funds at the HQ.
|
||||||
- **One upgrade level per booking** (`upgradeId`: the next level of that upgrade when the work starts), as the CR allows one level per visit.
|
- **One upgrade level per booking** (`upgradeId`: the next level of that upgrade when the work starts), as the CR allows one level per visit.
|
||||||
|
|
||||||
|
## Step 27.2
|
||||||
|
|
||||||
|
- **"Reachable" is one leg**, with a full tank where the ship stands if fuel is sold there (the CR's "even with refueling"), else the fuel aboard, and the no-strand rule. Multi-leg routes aren't in the game (plan 0.3).
|
||||||
|
- **The auto-repair trigger fires on crossing the threshold** (from at or above it to below it), not on every hit while below: a player who cancels an automatic booking isn't re-booked by the next scratch.
|
||||||
|
- **Repair recommended is a state**, not an event: a Manual crew's hull below the threshold with auto-repair on and no repair booked.
|
||||||
|
- **The pilot tests book at Mars**: a Clipper can't reach Ceres from Earth in one leg, which the reach test checks.
|
||||||
|
|
|
||||||
|
|
@ -570,7 +570,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
|
||||||
Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-025 … Phase 25"). New decisions recorded as plan §0.5.153–0.5.159.
|
Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-025 … Phase 25"). New decisions recorded as plan §0.5.153–0.5.159.
|
||||||
|
|
||||||
- [x] **Step 27.1 — Content and save.** Config `autoRepair` (default 40%, 10–90% in steps of 5; the default must be a step). Save version 12: each ship has `booking` (repair, upgrade, HQ, by player or auto, created, started, waiting for funds since; null when none) and `autoRepair` (on, threshold); new ships start on at 40% with no booking; the 11 → 12 migration does the same for existing ships. Tests: `packages/content/test/config.test.ts`; `packages/schema/test/save.test.ts` (11 → 12); `packages/engine/test/trip-events.test.ts` (a bought ship).
|
- [x] **Step 27.1 — Content and save.** Config `autoRepair` (default 40%, 10–90% in steps of 5; the default must be a step). Save version 12: each ship has `booking` (repair, upgrade, HQ, by player or auto, created, started, waiting for funds since; null when none) and `autoRepair` (on, threshold); new ships start on at 40% with no booking; the 11 → 12 migration does the same for existing ships. Tests: `packages/content/test/config.test.ts`; `packages/schema/test/save.test.ts` (11 → 12); `packages/engine/test/trip-events.test.ts` (a bought ship).
|
||||||
- [ ] Step 27.2 — Sim: routing and triggers
|
- [x] **Step 27.2 — Sim: routing and triggers.** `packages/sim/src/service.ts`: `canReachHq` (one leg under the fuel rules: the route, landing, no-strand, a full tank where fuel is sold, else the fuel aboard), `nearestReachableHq`, `bookingAllowsJob`, `autoRepairBooks` (crossing below the threshold, on, Independent crew, no repair already booked) and `repairRecommended` (a Manual crew below the threshold). `chooseAction` takes an optional booking: at the HQ the pilot waits for the work; with the HQ reachable, only jobs ending there, else `deadhead-service` straight there (under the fuel rules and spending floor), else wait; out of reach, the usual choice. Tests (`packages/sim/test/service.test.ts`): reach (Mars yes, Ceres no for a Clipper from Earth); only jobs to the booked HQ; the deadhead there; waiting at the HQ; normal work when it's out of reach; the nearest HQ; the auto-repair trigger for each case, adding a repair to an upgrade booking; Repair recommended.
|
||||||
- [ ] Step 27.3 — Engine: booking commands and service start
|
- [ ] Step 27.3 — Engine: booking commands and service start
|
||||||
- [ ] Step 27.4 — Engine: Independent pilots and catch-up
|
- [ ] Step 27.4 — Engine: Independent pilots and catch-up
|
||||||
- [ ] Step 27.5 — Client: Service section and tags
|
- [ ] Step 27.5 — Client: Service section and tags
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "./dispatch";
|
} from "./dispatch";
|
||||||
import { fuelPriceAt, refuel, type Tank } from "./fuel";
|
import { fuelPriceAt, refuel, type Tank } from "./fuel";
|
||||||
import { pilotShare } from "./pay";
|
import { pilotShare } from "./pay";
|
||||||
|
import { bookingAllowsJob, canReachHq, type ServiceTarget } from "./service";
|
||||||
|
|
||||||
/** What the player tells an Independent pilot (decision 0.5.124). */
|
/** What the player tells an Independent pilot (decision 0.5.124). */
|
||||||
export interface PilotOrders {
|
export interface PilotOrders {
|
||||||
|
|
@ -55,6 +56,8 @@ export interface IndependentInput {
|
||||||
emptyPeriods: number;
|
emptyPeriods: number;
|
||||||
/** The contract the player has open, which pilots leave alone (decision 0.5.126). */
|
/** The contract the player has open, which pilots leave alone (decision 0.5.126). */
|
||||||
viewedContractId?: string | null;
|
viewedContractId?: string | null;
|
||||||
|
/** Service booked at an HQ (decision 0.5.154): the pilot works their way there. */
|
||||||
|
booking?: ServiceTarget | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuel to buy before a leg: what it burns beyond what's aboard, rounded up to 0.001 fu. */
|
/** Fuel to buy before a leg: what it burns beyond what's aboard, rounded up to 0.001 fu. */
|
||||||
|
|
@ -118,6 +121,8 @@ export function independentOptions(input: IndependentInput, content: SimContent)
|
||||||
const options: JobOption[] = [];
|
const options: JobOption[] = [];
|
||||||
for (const contract of input.board) {
|
for (const contract of input.board) {
|
||||||
if (contract.firstContact || contract.id === input.viewedContractId) continue;
|
if (contract.firstContact || contract.id === input.viewedContractId) continue;
|
||||||
|
// Heading for service: only jobs that end at the booked HQ (decision 0.5.154).
|
||||||
|
if (input.booking && !bookingAllowsJob(contract, input.booking)) continue;
|
||||||
if (orders.destinationIds && !orders.destinationIds.includes(contract.destinationId)) continue;
|
if (orders.destinationIds && !orders.destinationIds.includes(contract.destinationId)) continue;
|
||||||
if (!orders.cargoClassIds.includes(contract.cargoTypeId)) continue;
|
if (!orders.cargoClassIds.includes(contract.cargoTypeId)) continue;
|
||||||
if (contract.priority && !orders.takePriority) continue;
|
if (contract.priority && !orders.takePriority) continue;
|
||||||
|
|
@ -165,10 +170,52 @@ export function compareOptions(a: JobOption, b: JobOption): number {
|
||||||
return a.contract.id < b.contract.id ? -1 : a.contract.id > b.contract.id ? 1 : 0;
|
return a.contract.id < b.contract.id ? -1 : a.contract.id > b.contract.id ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With service booked at a reachable HQ (decision 0.5.154): the best job that
|
||||||
|
* ends there, else a deadhead straight there if the fuel rules allow, else wait.
|
||||||
|
*/
|
||||||
|
function serviceAction(input: IndependentInput, hqId: string, content: SimContent): PilotAction {
|
||||||
|
const [best] = independentOptions(input, content).sort(compareOptions);
|
||||||
|
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };
|
||||||
|
const deadhead = deadheadTo(input, hqId, content);
|
||||||
|
return deadhead ? { kind: "deadhead-service", ...deadhead } : { kind: "wait" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deadhead to `destinationId` under the fuel rules, with the fuel to buy first; null if it can't go. */
|
||||||
|
function deadheadTo(
|
||||||
|
input: IndependentInput,
|
||||||
|
destinationId: string,
|
||||||
|
content: SimContent,
|
||||||
|
): { destinationId: string; buyFu: number } | null {
|
||||||
|
const { ship } = input;
|
||||||
|
const model = shipStats(ship, content);
|
||||||
|
const burnFu = tripFuel(
|
||||||
|
model,
|
||||||
|
findLocation(ship.locationId, content),
|
||||||
|
findLocation(destinationId, content),
|
||||||
|
content.config,
|
||||||
|
);
|
||||||
|
const fuel = legRefuel(input, burnFu, content);
|
||||||
|
if (!fuel) return null;
|
||||||
|
const check = validateReposition(
|
||||||
|
{
|
||||||
|
ship: { ...ship, tank: fuel.tank },
|
||||||
|
pilot: input.pilot,
|
||||||
|
destinationId,
|
||||||
|
discoveredIds: input.discoveredIds,
|
||||||
|
now: input.now,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
return check.ok ? { destinationId, buyFu: fuel.buyFu } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** What an Independent pilot does when they can act (CR-021 §2). */
|
/** What an Independent pilot does when they can act (CR-021 §2). */
|
||||||
export type PilotAction =
|
export type PilotAction =
|
||||||
| { kind: "take"; contractId: string; buyFu: number }
|
| { kind: "take"; contractId: string; buyFu: number }
|
||||||
| { kind: "deadhead-home"; destinationId: string; buyFu: number }
|
| { kind: "deadhead-home"; destinationId: string; buyFu: number }
|
||||||
|
/** Straight to the booked HQ for service (decision 0.5.154). */
|
||||||
|
| { kind: "deadhead-service"; destinationId: string; buyFu: number }
|
||||||
| { kind: "wait" };
|
| { kind: "wait" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -177,6 +224,16 @@ export type PilotAction =
|
||||||
* deadhead home if the ship can make it under the fuel rules; else wait.
|
* deadhead home if the ship can make it under the fuel rules; else wait.
|
||||||
*/
|
*/
|
||||||
export function chooseAction(input: IndependentInput, content: SimContent): PilotAction {
|
export function chooseAction(input: IndependentInput, content: SimContent): PilotAction {
|
||||||
|
const booking = input.booking ?? null;
|
||||||
|
if (booking) {
|
||||||
|
// At the HQ, the engine starts the work; the pilot stays put.
|
||||||
|
if (input.ship.locationId === booking.hqId) return { kind: "wait" };
|
||||||
|
// Out of reach, the booking waits and the pilot works as usual (decision 0.5.154).
|
||||||
|
if (canReachHq(input.ship, booking.hqId, input.discoveredIds, content)) {
|
||||||
|
return serviceAction(input, booking.hqId, content);
|
||||||
|
}
|
||||||
|
return chooseAction({ ...input, booking: null }, content);
|
||||||
|
}
|
||||||
const [best] = independentOptions(input, content).sort(compareOptions);
|
const [best] = independentOptions(input, content).sort(compareOptions);
|
||||||
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };
|
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,3 +176,11 @@ export {
|
||||||
repairCost,
|
repairCost,
|
||||||
repairShopMs,
|
repairShopMs,
|
||||||
} from "./hull";
|
} from "./hull";
|
||||||
|
export {
|
||||||
|
autoRepairBooks,
|
||||||
|
bookingAllowsJob,
|
||||||
|
canReachHq,
|
||||||
|
nearestReachableHq,
|
||||||
|
repairRecommended,
|
||||||
|
} from "./service";
|
||||||
|
export type { AutoRepair, ServiceTarget } from "./service";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import type { SimContent } from "./board";
|
||||||
|
import type { Contract } from "./contract";
|
||||||
|
import { findLocation, shipStats, tripFuel, wouldStrand, type ShipState } from "./dispatch";
|
||||||
|
import { routeDistance } from "./distance";
|
||||||
|
import { checkRoute } from "./eligibility";
|
||||||
|
import type { Tank } from "./fuel";
|
||||||
|
|
||||||
|
/** A booking as routing sees it (decision 0.5.153): where the service is. */
|
||||||
|
export interface ServiceTarget {
|
||||||
|
hqId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a ship can get from where it is to an HQ in one leg under the fuel
|
||||||
|
* rules (decision 0.5.154): the route, landing and the no-strand rule, with a
|
||||||
|
* full tank where its location sells fuel, else the fuel aboard. Already
|
||||||
|
* there counts as reachable.
|
||||||
|
*/
|
||||||
|
export function canReachHq(
|
||||||
|
ship: ShipState & { tank: Tank },
|
||||||
|
hqId: string,
|
||||||
|
discoveredIds: ReadonlySet<string>,
|
||||||
|
content: SimContent,
|
||||||
|
): boolean {
|
||||||
|
if (ship.locationId === hqId) return true;
|
||||||
|
if (!discoveredIds.has(hqId)) return false;
|
||||||
|
const model = shipStats(ship, content);
|
||||||
|
const origin = findLocation(ship.locationId, content);
|
||||||
|
const destination = findLocation(hqId, content);
|
||||||
|
if (!checkRoute(model, origin, destination, content.config).ok) return false;
|
||||||
|
const available = origin.sellsFuel ? model.tankCapacityFu : ship.tank.fuelFu;
|
||||||
|
const burn = tripFuel(model, origin, destination, content.config);
|
||||||
|
if (burn > available) return false;
|
||||||
|
return !wouldStrand(model, destination, available - burn, discoveredIds, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nearest HQ the ship can reach (decision 0.5.153); null when none can be reached. */
|
||||||
|
export function nearestReachableHq(
|
||||||
|
ship: ShipState & { tank: Tank },
|
||||||
|
hqIds: readonly string[],
|
||||||
|
discoveredIds: ReadonlySet<string>,
|
||||||
|
content: SimContent,
|
||||||
|
): string | null {
|
||||||
|
const reachable = hqIds
|
||||||
|
.filter((id) => canReachHq(ship, id, discoveredIds, content))
|
||||||
|
.map((id) => ({
|
||||||
|
id,
|
||||||
|
distance: routeDistance(findLocation(ship.locationId, content), findLocation(id, content)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.distance - b.distance || (a.id < b.id ? -1 : 1));
|
||||||
|
return reachable[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With a booking, a pilot takes only jobs ending at the booked HQ (decision 0.5.154). */
|
||||||
|
export function bookingAllowsJob(contract: Contract, booking: ServiceTarget | null): boolean {
|
||||||
|
return booking === null || contract.destinationId === booking.hqId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ship's auto-repair setting (decision 0.5.157). */
|
||||||
|
export interface AutoRepair {
|
||||||
|
on: boolean;
|
||||||
|
thresholdPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a hull change books a repair (decision 0.5.157): only when it
|
||||||
|
* crosses below the threshold, with auto-repair on, an Independent crew, and
|
||||||
|
* no repair already booked. An existing booking gets the repair added.
|
||||||
|
*/
|
||||||
|
export function autoRepairBooks(input: {
|
||||||
|
hullBefore: number;
|
||||||
|
hullAfter: number;
|
||||||
|
autoRepair: AutoRepair;
|
||||||
|
pilotMode: "manual" | "independent" | null;
|
||||||
|
booking: { repair: boolean } | null;
|
||||||
|
}): boolean {
|
||||||
|
const { hullBefore, hullAfter, autoRepair, pilotMode, booking } = input;
|
||||||
|
if (!autoRepair.on || pilotMode !== "independent" || booking?.repair) return false;
|
||||||
|
return hullBefore >= autoRepair.thresholdPct && hullAfter < autoRepair.thresholdPct;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Repair recommended" (decision 0.5.157): a Manual crew's hull below the auto-repair threshold. */
|
||||||
|
export function repairRecommended(input: {
|
||||||
|
hullPct: number;
|
||||||
|
autoRepair: AutoRepair;
|
||||||
|
pilotMode: "manual" | "independent" | null;
|
||||||
|
booking: { repair: boolean } | null;
|
||||||
|
}): boolean {
|
||||||
|
const { hullPct, autoRepair, pilotMode, booking } = input;
|
||||||
|
return (
|
||||||
|
autoRepair.on && pilotMode === "manual" && hullPct < autoRepair.thresholdPct && !booking?.repair
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
autoRepairBooks,
|
||||||
|
boardPeriodMs,
|
||||||
|
canReachHq,
|
||||||
|
chooseAction,
|
||||||
|
defaultOrders,
|
||||||
|
fuelPriceAt,
|
||||||
|
independentOptions,
|
||||||
|
MS_PER_MINUTE,
|
||||||
|
nearestReachableHq,
|
||||||
|
repairRecommended,
|
||||||
|
type Contract,
|
||||||
|
type IndependentInput,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
const { config } = content;
|
||||||
|
const clipper = content.shipModel("clipper")!;
|
||||||
|
const PERIOD = boardPeriodMs(config);
|
||||||
|
const PERIOD_START = 1000 * PERIOD;
|
||||||
|
const AFTER_LOOK =
|
||||||
|
PERIOD_START + (config.independentPilots.firstLookRealMinutes + 1) * MS_PER_MINUTE;
|
||||||
|
const EARTH_PRICE = fuelPriceAt(content.location("earth")!, config);
|
||||||
|
const DISCOVERED = new Set(["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]);
|
||||||
|
|
||||||
|
const job = (id: string, destinationId: string): Contract => ({
|
||||||
|
id,
|
||||||
|
originId: "earth",
|
||||||
|
destinationId,
|
||||||
|
cargoTypeId: "standard",
|
||||||
|
goodId: "consumer-goods",
|
||||||
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 10,
|
||||||
|
payout: 30_000,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: PERIOD_START,
|
||||||
|
expiresAt: PERIOD_START + PERIOD,
|
||||||
|
firstContact: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function input(board: Contract[], over: Partial<IndependentInput> = {}): IndependentInput {
|
||||||
|
return {
|
||||||
|
ship: {
|
||||||
|
id: "s",
|
||||||
|
modelId: "clipper",
|
||||||
|
locationId: "earth",
|
||||||
|
inFlight: false,
|
||||||
|
tank: { fuelFu: clipper.tankCapacityFu, avgPrice: EARTH_PRICE },
|
||||||
|
},
|
||||||
|
pilot: {
|
||||||
|
id: "p",
|
||||||
|
isAvatar: false,
|
||||||
|
assignedShipId: "s",
|
||||||
|
inFlight: false,
|
||||||
|
skills: { "long-haul": 3 },
|
||||||
|
restEndsAt: null,
|
||||||
|
},
|
||||||
|
orders: defaultOrders("earth", content),
|
||||||
|
board,
|
||||||
|
now: AFTER_LOOK,
|
||||||
|
credits: 1_000_000,
|
||||||
|
spendingFloor: config.independentPilots.defaultSpendingFloor,
|
||||||
|
discoveredIds: DISCOVERED,
|
||||||
|
emptyPeriods: 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mars is one leg from Earth for a Clipper (CR-015); Ceres isn't.
|
||||||
|
const mars = { hqId: "mars" };
|
||||||
|
|
||||||
|
describe("service routing (Step 27.2, decisions 0.5.153, 0.5.154)", () => {
|
||||||
|
it("can reach Mars but not Ceres from Earth in a Clipper", () => {
|
||||||
|
expect(canReachHq(input([]).ship, "mars", DISCOVERED, content)).toBe(true);
|
||||||
|
expect(canReachHq(input([]).ship, "ceres-station", DISCOVERED, content)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with a booking at Mars, considers only jobs that end at Mars", () => {
|
||||||
|
const board = [job("a", "moon"), job("b", "ceres-station"), job("c", "mars")];
|
||||||
|
const options = independentOptions(input(board, { booking: mars }), content);
|
||||||
|
expect(options.map((o) => o.contract.destinationId)).toEqual(["mars"]);
|
||||||
|
expect(chooseAction(input(board, { booking: mars }), content)).toMatchObject({
|
||||||
|
kind: "take",
|
||||||
|
contractId: "c",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deadheads straight to Mars when no job goes there", () => {
|
||||||
|
const board = [job("a", "moon")];
|
||||||
|
expect(chooseAction(input(board, { booking: mars }), content)).toMatchObject({
|
||||||
|
kind: "deadhead-service",
|
||||||
|
destinationId: "mars",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits at the booked HQ for the work to start", () => {
|
||||||
|
const at = input([job("a", "moon")], { booking: mars });
|
||||||
|
const there = { ...at, ship: { ...at.ship, locationId: "mars" } };
|
||||||
|
expect(chooseAction(there, content)).toEqual({ kind: "wait" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works as usual when the booked HQ is out of reach", () => {
|
||||||
|
// A Courier can't reach Jupiter from Earth.
|
||||||
|
const courier = content.shipModel("courier")!;
|
||||||
|
const base = input([job("a", "moon")], { booking: { hqId: "jupiter-cloud-platform" } });
|
||||||
|
const small = {
|
||||||
|
...base,
|
||||||
|
ship: {
|
||||||
|
...base.ship,
|
||||||
|
modelId: "courier",
|
||||||
|
tank: { fuelFu: courier.tankCapacityFu, avgPrice: 5 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(canReachHq(small.ship, "jupiter-cloud-platform", DISCOVERED, content)).toBe(false);
|
||||||
|
expect(chooseAction(small, content)).toMatchObject({ kind: "take", contractId: "a" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the nearest reachable HQ", () => {
|
||||||
|
const ship = input([]).ship;
|
||||||
|
expect(nearestReachableHq(ship, ["mars", "moon"], DISCOVERED, content)).toBe("moon");
|
||||||
|
expect(nearestReachableHq(ship, ["earth", "moon"], DISCOVERED, content)).toBe("earth");
|
||||||
|
const courier = { ...ship, modelId: "courier", tank: { fuelFu: 40, avgPrice: 5 } };
|
||||||
|
expect(nearestReachableHq(courier, ["jupiter-cloud-platform"], DISCOVERED, content)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auto-repair (Step 27.2, decision 0.5.157)", () => {
|
||||||
|
const on = { on: true, thresholdPct: 40 };
|
||||||
|
const drop = { hullBefore: 45, hullAfter: 35, autoRepair: on, booking: null };
|
||||||
|
|
||||||
|
it("books a repair when an Independent crew's hull drops below the threshold", () => {
|
||||||
|
expect(autoRepairBooks({ ...drop, pilotMode: "independent" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't with a Manual crew, with auto-repair off, or without crossing the threshold", () => {
|
||||||
|
expect(autoRepairBooks({ ...drop, pilotMode: "manual" })).toBe(false);
|
||||||
|
expect(
|
||||||
|
autoRepairBooks({ ...drop, pilotMode: "independent", autoRepair: { ...on, on: false } }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(autoRepairBooks({ ...drop, pilotMode: "independent", hullBefore: 38 })).toBe(false);
|
||||||
|
expect(autoRepairBooks({ ...drop, pilotMode: "independent", hullAfter: 41 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the repair to an upgrade booking, but not a second repair", () => {
|
||||||
|
expect(autoRepairBooks({ ...drop, pilotMode: "independent", booking: { repair: false } })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(autoRepairBooks({ ...drop, pilotMode: "independent", booking: { repair: true } })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recommends a repair for a Manual crew below the threshold", () => {
|
||||||
|
const base = { hullPct: 35, autoRepair: on, booking: null };
|
||||||
|
expect(repairRecommended({ ...base, pilotMode: "manual" })).toBe(true);
|
||||||
|
expect(repairRecommended({ ...base, pilotMode: "independent" })).toBe(false);
|
||||||
|
expect(repairRecommended({ ...base, pilotMode: "manual", hullPct: 50 })).toBe(false);
|
||||||
|
expect(repairRecommended({ ...base, pilotMode: "manual", booking: { repair: true } })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue