Step 10L.3: no-strand rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 01:27:41 -04:00
parent 0a0189b3e7
commit 60e29f7b1d
10 changed files with 204 additions and 2 deletions

View File

@ -1140,3 +1140,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The factor applies to the whole leg from the origin** (decision 0.5.78): `checkRoute` is the single range check, so dispatch, reposition, the board's eligibility and the map pick it up together. The destination's factor plays no part in that leg. - **The factor applies to the whole leg from the origin** (decision 0.5.78): `checkRoute` is the single range check, so dispatch, reposition, the board's eligibility and the map pick it up together. The destination's factor plays no part in that leg.
- **The dispatch sheet quotes range from the origin**, rounded, e.g. "reaches 130M km from Moon", so the number matches what the check used. - **The dispatch sheet quotes range from the origin**, rounded, e.g. "reaches 130M km from Moon", so the number matches what the check used.
## Step 10L.3
- **The check runs after range and landing, before Priority, skills, expiry and credits** (the CR says after range and landing). The arrival time is the trip's own travel time (cruise, overhead, handling for the contract's mass; empty for a deadhead).
- **"Other discovered location" includes the origin**, so a ship can always be allowed to fly back where it came from if that is still in range.
- **`discoveredIds` is optional on a contract proposal**; absent means every location counts. Tutorial runs and older callers keep working, and the client's eligibility (which doesn't pass it yet) is lenient until 10L.9; the server's check is the one that counts.
- **The rule only looks at arrival plus margin**, as the CR says. A ship may land somewhere it can't leave at once but can within the margin: with today's ranges a Clipper flying Moon→Mars can land on a date when it can't leave Mars right away but can 48 hours later. The exhaustive test checks the margin time, not the arrival.
- **Tests use a fixture** (a Moon with factor 0.5 and a "moon-station" sharing its orbit) because no real ship and date strands cleanly enough to also show "passes once another location is discovered".

View File

@ -229,11 +229,17 @@ export async function dispatchContract(
const pilot = await requireOwnPilot(tx, playerId, input.pilotId); const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId); const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId); const company = await requireCompany(tx, playerId);
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const proposal = { const proposal = {
ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)), ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)),
pilot: pilotState(pilot), pilot: pilotState(pilot),
contract, contract,
// The no-strand rule counts only discovered locations (decision 0.5.79).
discoveredIds: new Set(discovered.map((d) => d.locationId)),
tutorial: false, tutorial: false,
credits: company.credits, credits: company.credits,
now, now,

View File

@ -11,6 +11,8 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
"over-capacity": "The cargo is too heavy for that ship.", "over-capacity": "The cargo is too heavy for that ship.",
"cannot-land": "That ship cannot land at the destination.", "cannot-land": "That ship cannot land at the destination.",
"out-of-range": "The destination is out of that ship's range right now.", "out-of-range": "The destination is out of that ship's range right now.",
"would-strand":
"That ship couldn't fly anywhere else from the destination, so it would be stranded there.",
"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.",

View File

@ -52,6 +52,7 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"over-capacity": "Too heavy", "over-capacity": "Too heavy",
"cannot-land": "Can't land there", "cannot-land": "Can't land there",
"out-of-range": "Out of range", "out-of-range": "Out of range",
"would-strand": "Would be stranded",
"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",

View File

@ -187,6 +187,7 @@ function ShipRow(props: {
const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = { const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
"cannot-land": "Can't land there", "cannot-land": "Can't land there",
"out-of-range": "Out of range", "out-of-range": "Out of range",
"would-strand": "Would be stranded",
"insufficient-credits": "Not enough credits for fuel", "insufficient-credits": "Not enough credits for fuel",
"ship-in-shop": "Ship is in the shop", "ship-in-shop": "Ship is in the shop",
"pilot-resting": "Pilot is resting", "pilot-resting": "Pilot is resting",

View File

@ -38,7 +38,7 @@ Living reference for how the code is organised. Update it after any database sch
### Errors ### Errors
Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...). Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window; CR-012 adds `would-strand`: from the destination, at arrival plus `noStrandMarginGameHours`, the ship couldn't reach any other discovered location), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).
## API routes (`packages/schema/src/routes.ts`) ## API routes (`packages/schema/src/routes.ts`)

View File

@ -271,7 +271,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as
- [x] **Step 10L.1 — Content: range factors and new ships.** `Location.launchRangeFactor` (Earth 1, Moon 1.3, Mars 1.15, Ceres 1.3, Jupiter 1). `ShipModel.role` (all six) and optional `ShipModel.handlingGameHoursPerTonne` (the sim's effective stats now start from it). New models: **Lighter** (42,400 cr, 1.6M km/h, 80 t, range 10, overhead 2 h, handling 0.012 h/t, fuel 350/M km) and **Clipper** (61,600 cr, 6.5M km/h, 30 t, range 198, overhead 2 h, fuel 600/M km); archetypes `lighter`, `clipper`. Config `noStrandMarginGameHours` 48. Manifest lists `ship-lighter` / `ship-clipper` as pending art. Tests: the CR's orderings replace Step 2.3's (`ships.test.ts`), range factors (`locations.test.ts`), Lighter handling (`effective-stats.test.ts`). - [x] **Step 10L.1 — Content: range factors and new ships.** `Location.launchRangeFactor` (Earth 1, Moon 1.3, Mars 1.15, Ceres 1.3, Jupiter 1). `ShipModel.role` (all six) and optional `ShipModel.handlingGameHoursPerTonne` (the sim's effective stats now start from it). New models: **Lighter** (42,400 cr, 1.6M km/h, 80 t, range 10, overhead 2 h, handling 0.012 h/t, fuel 350/M km) and **Clipper** (61,600 cr, 6.5M km/h, 30 t, range 198, overhead 2 h, fuel 600/M km); archetypes `lighter`, `clipper`. Config `noStrandMarginGameHours` 48. Manifest lists `ship-lighter` / `ship-clipper` as pending art. Tests: the CR's orderings replace Step 2.3's (`ships.test.ts`), range factors (`locations.test.ts`), Lighter handling (`effective-stats.test.ts`).
- [x] **Step 10L.2 — Sim: effective range from origin.** `rangeFrom(ship, from)` = range × origin's `launchRangeFactor`; `checkRoute` compares the leg against it (dispatch, reposition, board eligibility and the map all go through it). The dispatch sheet quotes range from the origin. Tests (`range-from.test.ts`): 100 → 130 from the Moon and 115 from Mars; a leg between 1× and 1.3× fails from Earth and passes from the Moon; with every factor 1 the result matches the old check over 16 years of samples. - [x] **Step 10L.2 — Sim: effective range from origin.** `rangeFrom(ship, from)` = range × origin's `launchRangeFactor`; `checkRoute` compares the leg against it (dispatch, reposition, board eligibility and the map all go through it). The dispatch sheet quotes range from the origin. Tests (`range-from.test.ts`): 100 → 130 from the Moon and 115 from Mars; a leg between 1× and 1.3× fails from Earth and passes from the Moon; with every factor 1 the result matches the old check over 16 years of samples.
- [ ] Step 10L.3 — Sim: no-strand rule - [x] **Step 10L.3 — Sim: no-strand rule.** `wouldStrand(stats, origin, destination, mass, departure, discoveredIds, content)`: at arrival plus `noStrandMarginGameHours` (48), can the ship reach any other discovered location from the destination with its range from there? `validateDispatch` (new optional `discoveredIds`; absent = all) and `validateReposition` fail with `would-strand` after range and landing. The server passes the player's discovered ids for contract dispatches; server and web reason maps gained the code (full wording in 10L.6/10L.9). Tests (`no-strand.test.ts`): a fixture heavy-Moon hop is refused, then passes once a reachable location is discovered; the same for a contract; across all six models, every ordered pair and monthly samples over 16 years, an allowed trip always leaves somewhere to fly at arrival plus margin.
- [ ] Step 10L.4 — Content tuning: reach table - [ ] Step 10L.4 — Content tuning: reach table
- [ ] Step 10L.5 — Upgrade caps for the new lineup - [ ] Step 10L.5 — Upgrade caps for the new lineup
- [ ] Step 10L.6 — Schema and server - [ ] Step 10L.6 — Schema and server

View File

@ -47,6 +47,11 @@ export interface DispatchProposal {
tutorial: boolean; tutorial: boolean;
credits: number; credits: number;
now: RealTime; now: RealTime;
/**
* Locations the player has discovered, for the no-strand rule (decision 0.5.79).
* Absent: every location counts.
*/
discoveredIds?: ReadonlySet<string>;
} }
/** Every reason `validateDispatch` can fail, in check order. */ /** Every reason `validateDispatch` can fail, in check order. */
@ -60,6 +65,7 @@ export const DISPATCH_FAILURES = [
"over-capacity", "over-capacity",
"cannot-land", "cannot-land",
"out-of-range", "out-of-range",
"would-strand",
"too-slow", "too-slow",
"cargo-skill-too-low", "cargo-skill-too-low",
"long-haul-too-low", "long-haul-too-low",
@ -107,6 +113,32 @@ export function contractTripGameHours(
return travelTimeGameHours(model, distance, contract.massTonnes, content.config); return travelTimeGameHours(model, distance, contract.massTonnes, content.config);
} }
/**
* The no-strand rule (decision 0.5.79): true when, at arrival plus the configured
* margin, the ship could not fly from the destination to any other discovered
* location. `discoveredIds` absent means every location counts.
*/
export function wouldStrand(
model: EffectiveStats,
origin: Location,
destination: Location,
massTonnes: number,
departure: GameTime,
discoveredIds: ReadonlySet<string> | undefined,
content: SimContent,
): boolean {
const { config } = content;
const distance = routeDistance(origin, destination, departure);
const tripHours = travelTimeGameHours(model, distance, massTonnes, config);
const at = departure + (tripHours + config.noStrandMarginGameHours) * MS_PER_HOUR;
return !content.locations.some(
(next) =>
next.id !== destination.id &&
(discoveredIds?.has(next.id) ?? true) &&
checkRoute(model, destination, next, at).ok,
);
}
/** Validates a proposed contract dispatch, returning the first failing reason. */ /** Validates a proposed contract dispatch, returning the first failing reason. */
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck { export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
const { ship, pilot, contract, tutorial, credits, now } = proposal; const { ship, pilot, contract, tutorial, credits, now } = proposal;
@ -129,6 +161,19 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
const route = checkRoute(model, origin, destination, departure); const route = checkRoute(model, origin, destination, departure);
if (!route.ok) return fail(route.reason); if (!route.ok) return fail(route.reason);
if (
wouldStrand(
model,
origin,
destination,
contract.massTonnes,
departure,
proposal.discoveredIds,
content,
)
) {
return fail("would-strand");
}
// Priority (decision 0.5.56): the trip must fit the delivery window. // Priority (decision 0.5.56): the trip must fit the delivery window.
if ( if (
@ -252,6 +297,7 @@ export const REPOSITION_FAILURES = [
"location-undiscovered", "location-undiscovered",
"cannot-land", "cannot-land",
"out-of-range", "out-of-range",
"would-strand",
"insufficient-credits", "insufficient-credits",
] as const; ] as const;
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number]; export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
@ -284,6 +330,9 @@ export function validateReposition(
const departure = realToGame(now, content.config); const departure = realToGame(now, content.config);
const route = checkRoute(model, origin, destination, departure); const route = checkRoute(model, origin, destination, departure);
if (!route.ok) return failWith(route.reason); if (!route.ok) return failWith(route.reason);
if (wouldStrand(model, origin, destination, 0, departure, discoveredIds, content)) {
return failWith("would-strand");
}
if (credits < tripFuelCost(model, origin, destination, departure)) { if (credits < tripFuelCost(model, origin, destination, departure)) {
return failWith("insufficient-credits"); return failWith("insufficient-credits");

View File

@ -44,6 +44,7 @@ export {
shipStats, shipStats,
tripFuelCost, tripFuelCost,
validateDispatch, validateDispatch,
wouldStrand,
validateReposition, validateReposition,
} from "./dispatch"; } from "./dispatch";
export type { export type {

View File

@ -0,0 +1,134 @@
import { content, type Location, type ShipModel } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
checkRoute,
effectiveStats,
MS_PER_HOUR,
routeDistance,
travelTimeGameHours,
realToGame,
validateDispatch,
validateReposition,
wouldStrand,
type Contract,
type PilotState,
type RepositionProposal,
type ShipState,
type SimContent,
} from "../src/index";
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 orbit, 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", fuelRangeMillionKm: 0.45 },
],
};
const hopper: ShipState = { id: "s1", modelId: "hopper", locationId: "earth", inFlight: false };
const proposal: RepositionProposal = {
ship: hopper,
pilot,
destinationId: "moon",
discoveredIds: new Set(["earth", "moon"]),
credits: 100_000,
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, credits: 100_000, 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 strands a base ship on a trip it is allowed to take between the five locations", () => {
const start = realToGame(Date.parse(content.config.epoch.realStart), content.config);
const month = 30.44 * 24 * 3_600_000;
const ids = content.locations.map((l) => l.id);
const canLeave = (model: ShipModel, at: Location, t: number) =>
content.locations.some((next) => next.id !== at.id && checkRoute(model, at, next, t).ok);
for (const base of content.shipModels) {
const model = effectiveStats(base, {}, content);
for (const from of ids) {
for (const to of ids) {
if (from === to) continue;
for (let i = 0; i <= 16 * 12; i++) {
const t = start + i * month;
if (!checkRoute(model, loc(from), loc(to), t).ok) continue;
if (wouldStrand(model, loc(from), loc(to), 0, t, undefined, content)) continue;
const hours = travelTimeGameHours(
model,
routeDistance(loc(from), loc(to), t),
0,
content.config,
);
const arrival = t + hours * MS_PER_HOUR;
const label = `${model.id} ${from}→${to} month ${i}`;
expect(
canLeave(
model,
loc(to),
arrival + content.config.noStrandMarginGameHours * MS_PER_HOUR,
),
label,
).toBe(true);
}
}
}
}
});
});