168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
import { content, type Location } from "@deadhead/content";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
checkRoute,
|
|
economicDistance,
|
|
tripFuel,
|
|
effectiveStats,
|
|
validateDispatch,
|
|
validateReposition,
|
|
wouldStrand,
|
|
type Contract,
|
|
type PilotState,
|
|
type RepositionProposal,
|
|
type ShipState,
|
|
type SimContent,
|
|
} from "../src/index";
|
|
|
|
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
|
|
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
|
|
|
|
const loc = (id: string) => content.location(id)!;
|
|
const now = Date.parse("2100-01-01T00:30:00Z");
|
|
const pilot: PilotState = {
|
|
id: "p1",
|
|
isAvatar: false,
|
|
assignedShipId: "s1",
|
|
inFlight: false,
|
|
skills: {},
|
|
restEndsAt: null,
|
|
};
|
|
|
|
/**
|
|
* Fixture: a short-legged ship and a heavy Moon (factor 0.5), so the ship can
|
|
* reach the Moon from Earth but can't climb back out. "moon-station" shares the
|
|
* Moon's position, so it is always in reach from there.
|
|
*/
|
|
const heavyMoon: Location = { ...loc("moon"), launchRangeFactor: 0.5 };
|
|
const station: Location = { ...heavyMoon, id: "moon-station", name: "Moon Station" };
|
|
const fixture: SimContent = {
|
|
...content,
|
|
locations: [...content.locations.filter((l) => l.id !== "moon"), heavyMoon, station],
|
|
shipModels: [
|
|
...content.shipModels,
|
|
{
|
|
...content.shipModel("courier")!,
|
|
id: "hopper",
|
|
// A full tank reaches 0.45M km from Earth (decision 0.5.95).
|
|
tankCapacityFu: 20 * economicDistance(0.45, content.config),
|
|
burnFuPerMillionKm: 20,
|
|
},
|
|
],
|
|
};
|
|
const hopper: ShipState = {
|
|
id: "s1",
|
|
modelId: "hopper",
|
|
locationId: "earth",
|
|
inFlight: false,
|
|
tank: PLENTY,
|
|
};
|
|
const proposal: RepositionProposal = {
|
|
ship: hopper,
|
|
pilot,
|
|
destinationId: "moon",
|
|
discoveredIds: new Set(["earth", "moon"]),
|
|
now,
|
|
};
|
|
|
|
const contract: Contract = {
|
|
id: "c1",
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
cargoTypeId: "standard",
|
|
goodId: "consumer-goods",
|
|
shipper: "Pacific Orbital Logistics",
|
|
priority: false,
|
|
deliveryWindowGameHours: null,
|
|
tier: 0,
|
|
massTonnes: 10,
|
|
payout: 2000,
|
|
requiredSkill: null,
|
|
requiredLongHaulLevel: 0,
|
|
offeredAt: Date.parse("2100-01-01T00:00:00Z"),
|
|
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
|
firstContact: false,
|
|
};
|
|
|
|
describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => {
|
|
it("rejects a deadhead that would leave the ship unable to fly anywhere", () => {
|
|
expect(validateReposition(proposal, fixture)).toEqual({ ok: false, reason: "would-strand" });
|
|
});
|
|
|
|
it("passes the same trip once a reachable location is discovered", () => {
|
|
const discoveredIds = new Set(["earth", "moon", "moon-station"]);
|
|
expect(validateReposition({ ...proposal, discoveredIds }, fixture)).toEqual({ ok: true });
|
|
});
|
|
|
|
it("applies to contract dispatches too", () => {
|
|
const base = { ship: hopper, pilot, contract, tutorial: false, now };
|
|
expect(
|
|
validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture),
|
|
).toEqual({ ok: false, reason: "would-strand" });
|
|
expect(
|
|
validateDispatch(
|
|
{ ...base, discoveredIds: new Set(["earth", "moon", "moon-station"]) },
|
|
fixture,
|
|
),
|
|
).toEqual({ ok: true });
|
|
});
|
|
|
|
it("never blocks a base ship on a leg it can fly between the five locations (Step 15.5)", () => {
|
|
// Every base location sells fuel, so a full tank is always on offer at the destination.
|
|
const ids = content.locations.map((l) => l.id);
|
|
for (const base of content.shipModels) {
|
|
const model = effectiveStats(base, {}, content);
|
|
for (const from of ids) {
|
|
for (const to of ids) {
|
|
if (from === to || !checkRoute(model, loc(from), loc(to), content.config).ok) continue;
|
|
expect(
|
|
wouldStrand(model, loc(to), 0, undefined, content),
|
|
`${model.id} ${from}→${to}`,
|
|
).toBe(false);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("no-strand rule with a dry destination (Step 15.5, CR-015 §3)", () => {
|
|
// Fixture: Mars sells no fuel, so a Clipper must leave Earth with enough to get out again.
|
|
const dryMars: Location = { ...loc("mars"), sellsFuel: false };
|
|
const dry: SimContent = {
|
|
...content,
|
|
locations: [...content.locations.filter((l) => l.id !== "mars"), dryMars],
|
|
};
|
|
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
|
|
const discoveredIds = new Set(["earth", "moon", "mars"]);
|
|
const ship = (fuelFu: number): ShipState => ({
|
|
id: "s1",
|
|
modelId: "clipper",
|
|
locationId: "earth",
|
|
inFlight: false,
|
|
tank: { fuelFu, avgPrice: 16 },
|
|
});
|
|
const deadhead = (fuelFu: number, at: SimContent) =>
|
|
validateReposition(
|
|
{ ship: ship(fuelFu), pilot, destinationId: "mars", discoveredIds, now },
|
|
at,
|
|
);
|
|
|
|
it("blocks a leg that would leave too little fuel to get out", () => {
|
|
// A full tank flies Earth → Mars but leaves too little to leave Mars.
|
|
expect(deadhead(clipper.tankCapacityFu, dry)).toEqual({ ok: false, reason: "would-strand" });
|
|
});
|
|
|
|
it("allows the same leg where the destination sells fuel", () => {
|
|
expect(deadhead(clipper.tankCapacityFu, content)).toEqual({ ok: true });
|
|
});
|
|
|
|
it("allows it with fuel to spare after the leg, as an upgraded tank would have", () => {
|
|
const toMars = tripFuel(clipper, loc("earth"), dryMars, content.config);
|
|
const outOfMars = tripFuel(clipper, dryMars, loc("earth"), content.config);
|
|
expect(wouldStrand(clipper, dryMars, outOfMars, discoveredIds, dry)).toBe(false);
|
|
expect(wouldStrand(clipper, dryMars, clipper.tankCapacityFu - toMars, discoveredIds, dry)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|