deadhead-space-freight-tycoon/packages/sim/test/no-strand.test.ts

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,
);
});
});