Step 10B.2: deadhead to any discovered location
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dff2c576cf
commit
553983590e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue