Step 10L.3: no-strand rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0a0189b3e7
commit
60e29f7b1d
|
|
@ -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 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".
|
||||
|
|
|
|||
|
|
@ -229,11 +229,17 @@ export async function dispatchContract(
|
|||
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
|
||||
const player = await requirePlayer(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 = {
|
||||
ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)),
|
||||
pilot: pilotState(pilot),
|
||||
contract,
|
||||
// The no-strand rule counts only discovered locations (decision 0.5.79).
|
||||
discoveredIds: new Set(discovered.map((d) => d.locationId)),
|
||||
tutorial: false,
|
||||
credits: company.credits,
|
||||
now,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
|
|||
"over-capacity": "The cargo is too heavy for that ship.",
|
||||
"cannot-land": "That ship cannot land at the destination.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
|||
"over-capacity": "Too heavy",
|
||||
"cannot-land": "Can't land there",
|
||||
"out-of-range": "Out of range",
|
||||
"would-strand": "Would be stranded",
|
||||
"too-slow": "Too slow for the Priority window",
|
||||
"cargo-skill-too-low": "Cargo skill too low",
|
||||
"long-haul-too-low": "Long Haul too low",
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ function ShipRow(props: {
|
|||
const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
||||
"cannot-land": "Can't land there",
|
||||
"out-of-range": "Out of range",
|
||||
"would-strand": "Would be stranded",
|
||||
"insufficient-credits": "Not enough credits for fuel",
|
||||
"ship-in-shop": "Ship is in the shop",
|
||||
"pilot-resting": "Pilot is resting",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ Living reference for how the code is organised. Update it after any database sch
|
|||
|
||||
### 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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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.5 — Upgrade caps for the new lineup
|
||||
- [ ] Step 10L.6 — Schema and server
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ export interface DispatchProposal {
|
|||
tutorial: boolean;
|
||||
credits: number;
|
||||
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. */
|
||||
|
|
@ -60,6 +65,7 @@ export const DISPATCH_FAILURES = [
|
|||
"over-capacity",
|
||||
"cannot-land",
|
||||
"out-of-range",
|
||||
"would-strand",
|
||||
"too-slow",
|
||||
"cargo-skill-too-low",
|
||||
"long-haul-too-low",
|
||||
|
|
@ -107,6 +113,32 @@ export function contractTripGameHours(
|
|||
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. */
|
||||
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
|
||||
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);
|
||||
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.
|
||||
if (
|
||||
|
|
@ -252,6 +297,7 @@ export const REPOSITION_FAILURES = [
|
|||
"location-undiscovered",
|
||||
"cannot-land",
|
||||
"out-of-range",
|
||||
"would-strand",
|
||||
"insufficient-credits",
|
||||
] as const;
|
||||
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
|
||||
|
|
@ -284,6 +330,9 @@ export function validateReposition(
|
|||
const departure = realToGame(now, content.config);
|
||||
const route = checkRoute(model, origin, destination, departure);
|
||||
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)) {
|
||||
return failWith("insufficient-credits");
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export {
|
|||
shipStats,
|
||||
tripFuelCost,
|
||||
validateDispatch,
|
||||
wouldStrand,
|
||||
validateReposition,
|
||||
} from "./dispatch";
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue