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.
|
||||
- **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.
|
||||
|
||||
- [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.4 — Engine: Independent pilots and catch-up
|
||||
- [ ] Step 27.5 — Client: Service section and tags
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "./dispatch";
|
||||
import { fuelPriceAt, refuel, type Tank } from "./fuel";
|
||||
import { pilotShare } from "./pay";
|
||||
import { bookingAllowsJob, canReachHq, type ServiceTarget } from "./service";
|
||||
|
||||
/** What the player tells an Independent pilot (decision 0.5.124). */
|
||||
export interface PilotOrders {
|
||||
|
|
@ -55,6 +56,8 @@ export interface IndependentInput {
|
|||
emptyPeriods: number;
|
||||
/** The contract the player has open, which pilots leave alone (decision 0.5.126). */
|
||||
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. */
|
||||
|
|
@ -118,6 +121,8 @@ export function independentOptions(input: IndependentInput, content: SimContent)
|
|||
const options: JobOption[] = [];
|
||||
for (const contract of input.board) {
|
||||
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.cargoClassIds.includes(contract.cargoTypeId)) 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
export type PilotAction =
|
||||
| { kind: "take"; contractId: 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" };
|
||||
|
||||
/**
|
||||
|
|
@ -177,6 +224,16 @@ export type PilotAction =
|
|||
* deadhead home if the ship can make it under the fuel rules; else wait.
|
||||
*/
|
||||
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);
|
||||
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };
|
||||
|
||||
|
|
|
|||
|
|
@ -176,3 +176,11 @@ export {
|
|||
repairCost,
|
||||
repairShopMs,
|
||||
} 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