Step 10B.2: deadhead to any discovered location

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 14:46:30 -04:00
parent dff2c576cf
commit 553983590e
7 changed files with 53 additions and 36 deletions

View File

@ -621,3 +621,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 10B.1
- **The board generator's input is `location`** (the freight market's location). Sim never knew about bays for boards; the old field name `origin` was the only "bay-board" naming, and comments now say "location board". Generation rules and seeds (`board:<location>:<period>`) are unchanged.
## Step 10B.2
- **Deadhead checks, in order:** ship idle, pilot idle, pilot assigned to the ship, destination not the current location (`already-there`), destination discovered (`location-undiscovered`), landing, range, fuel credits. Bays play no part.
- **`no-bay-at-destination` is gone** from sim reasons and API error codes; `location-undiscovered` (already a server code) is now also a sim reason.
- **The server change landed in this step** (it must pass `discoveredIds` to compile), so the Step 6.11 "no bay at destination" test became "undiscovered destination" here; Step 10B.5 adds the stranded-ship scenario.

View File

@ -17,9 +17,9 @@ import { and, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
bays,
companies,
contractBoards,
discoveredLocations,
dispatches,
pilots,
players,
@ -246,7 +246,7 @@ export async function setInFlight(tx: Queryable, shipId: string, pilotId: string
.where(eq(pilots.id, pilotId));
}
/** Flies a ship and its pilot empty to another player bay, paying fuel only (Step 6.11). */
/** Deadhead: flies a ship and its pilot empty to another discovered location, paying fuel only (Steps 6.11, 10B.2). */
export async function dispatchReposition(
tx: Queryable,
playerId: string,
@ -257,16 +257,16 @@ export async function dispatchReposition(
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const bayRows = await tx
.select({ locationId: bays.locationId })
.from(bays)
.where(eq(bays.playerId, playerId));
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const proposal = {
ship: shipState(ship),
pilot: pilotState(pilot),
destinationId: input.destinationId,
bayLocationIds: new Set(bayRows.map((b) => b.locationId)),
discoveredIds: new Set(discovered.map((d) => d.locationId)),
credits: company.credits,
now,
};

View File

@ -14,5 +14,5 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
"contract-expired": "This contract has expired.",
"insufficient-credits": "Not enough credits for fuel.",
"already-there": "The ship is already there.",
"no-bay-at-destination": "You have no bay at that destination.",
"location-undiscovered": "You can only fly empty to places you have discovered.",
};

View File

@ -48,17 +48,17 @@ describe("reposition dispatch", () => {
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
});
it("refuses a destination without a player bay and changes nothing", async () => {
it("refuses an undiscovered destination and changes nothing", async () => {
const { app } = testApp();
const { guest, shipId } = await ownerWithMoonBay(false);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "reposition",
shipId,
pilotId: guest.avatarId,
destinationId: "moon",
destinationId: "mars",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-destination");
expect(errorResponseSchema.parse(response.json()).error.code).toBe("location-undiscovered");
const state = await getState(app, guest);
expect(state.company.credits).toBe(10_000);
expect(state.ships[0]!.status).toBe("idle");

View File

@ -135,7 +135,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions recorded as plan §0.5.35–0.5.36.
- [x] **Step 10B.1 — Sim: boards at any location.** `BoardInput.location` (was `origin`) is the board's location; no bay notion in sim board generation. Moon board test added.
- [ ] Step 10B.2 — Sim: deadhead validation
- [x] **Step 10B.2 — Sim: deadhead validation.** `validateReposition` takes `discoveredIds` (not bays): any origin, destination discovered (`location-undiscovered`) and not the current location, range and landing. `REPOSITION_FAILURES` swaps `no-bay-at-destination` for `location-undiscovered`. Server passes the player's discovered locations.
- [ ] Step 10B.3 — Database: boards keyed by location
- [ ] Step 10B.4 — Server: board endpoint for any discovered location
- [ ] Step 10B.5 — Server: no more stranded ships

View File

@ -194,8 +194,8 @@ export interface RepositionProposal {
ship: ShipState;
pilot: PilotState;
destinationId: string;
/** Locations where the player has a bay (bays exist only at discovered locations). */
bayLocationIds: ReadonlySet<string>;
/** Locations the player has discovered; only these can be deadheaded to (CR-002). */
discoveredIds: ReadonlySet<string>;
credits: number;
now: RealTime;
}
@ -206,7 +206,7 @@ export const REPOSITION_FAILURES = [
"pilot-not-idle",
"pilot-not-assigned",
"already-there",
"no-bay-at-destination",
"location-undiscovered",
"cannot-land",
"out-of-range",
"insufficient-credits",
@ -216,21 +216,22 @@ export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
/**
* Validates an empty flight to one of the player's bays. Only range and landing
* apply; no skills are needed (decision 0.5.24).
* Validates a deadhead (CR-002): an empty flight from wherever the ship is to
* any other discovered location. Only range and landing apply; no skills are
* needed (decision 0.5.24) and bays play no part.
*/
export function validateReposition(
proposal: RepositionProposal,
content: SimContent,
): RepositionCheck {
const { ship, pilot, destinationId, bayLocationIds, credits, now } = proposal;
const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal;
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
if (ship.inFlight) return failWith("ship-not-idle");
if (pilot.inFlight) return failWith("pilot-not-idle");
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
if (destinationId === ship.locationId) return failWith("already-there");
if (!bayLocationIds.has(destinationId)) return failWith("no-bay-at-destination");
if (!discoveredIds.has(destinationId)) return failWith("location-undiscovered");
const model = findShipModel(ship.modelId, content);
const origin = findLocation(ship.locationId, content);

View File

@ -9,7 +9,7 @@ import {
} from "../src/index";
const now = Date.parse("2100-01-01T00:30:00Z");
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const atEarth: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const pilot: PilotState = {
id: "p1",
isAvatar: false,
@ -17,11 +17,12 @@ const pilot: PilotState = {
inFlight: false,
skills: {},
};
/** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */
const valid: RepositionProposal = {
ship,
ship: atEarth,
pilot,
destinationId: "moon",
bayLocationIds: new Set(["earth", "moon"]),
discoveredIds: new Set(["earth", "moon"]),
credits: 10_000,
now,
};
@ -31,25 +32,34 @@ const reason = (proposal: RepositionProposal) => {
return result.ok ? "valid" : result.reason;
};
describe("reposition", () => {
it("accepts an empty flight to a bay in range, with no skills needed", () => {
describe("deadhead (fly empty)", () => {
it("lets a Courier idle at the Moon, with no Moon bay, deadhead to Earth", () => {
const atMoon = { ...atEarth, locationId: "moon" };
expect(reason({ ...valid, ship: atMoon, destinationId: "earth" })).toBe("valid");
});
it("needs no skills", () => {
expect(reason(valid)).toBe("valid");
});
it("fails validation when the destination has no player bay", () => {
expect(reason({ ...valid, bayLocationIds: new Set(["earth"]) })).toBe("no-bay-at-destination");
it("refuses an undiscovered destination with location-undiscovered", () => {
expect(reason({ ...valid, destinationId: "mars" })).toBe("location-undiscovered");
});
it("still applies range and fuel checks", () => {
expect(
reason({ ...valid, destinationId: "mars", bayLocationIds: new Set(["earth", "mars"]) }),
).toBe("out-of-range");
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
});
it("rejects flying to where the ship already is, or with a busy or unassigned crew", () => {
it("refuses the ship's own location", () => {
expect(reason({ ...valid, destinationId: "earth" })).toBe("already-there");
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle");
});
it("refuses an out-of-range destination with the out-of-range reason", () => {
const discovered = new Set(["earth", "moon", "mars"]);
expect(reason({ ...valid, destinationId: "mars", discoveredIds: discovered })).toBe(
"out-of-range",
);
});
it("still needs fuel credits, an idle crew and an assigned pilot", () => {
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
expect(reason({ ...valid, ship: { ...atEarth, inFlight: true } })).toBe("ship-not-idle");
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
"pilot-not-assigned",
@ -58,7 +68,7 @@ describe("reposition", () => {
it("creates a record with no contract, zero payout and a fuel charge", () => {
const record = createRepositionRecord(
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
{ id: "r1", ship: atEarth, pilot, destinationId: "moon", now, worldSeed: 7 },
content,
);
expect(record.contract).toBeNull();