Step 10G.9: Priority trip vs window in the dispatch sheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 17:20:17 -04:00
parent 8945972372
commit e96cd6ab2b
3 changed files with 149 additions and 1 deletions

View File

@ -2,6 +2,7 @@ import { content, type ShipModel } from "@deadhead/content";
import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import type { Contract, Pilot, PlayerState } from "@deadhead/schema";
import { import {
checkRoute, checkRoute,
contractTripGameHours,
dutyAfterTrip, dutyAfterTrip,
LONG_HAUL_SKILL_ID, LONG_HAUL_SKILL_ID,
needsRest, needsRest,
@ -28,6 +29,7 @@ import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip"; import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Sheet } from "../ui"; import { Art, Button, Sheet } from "../ui";
import { formatWindow, goodName, PriorityChip } from "../components/Goods";
/** Short player-facing reasons a ship+pilot cannot take a contract. */ /** Short player-facing reasons a ship+pilot cannot take a contract. */
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = { export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
@ -82,6 +84,14 @@ export function explainProblems(
: INELIGIBLE_REASONS["cannot-land"], : INELIGIBLE_REASONS["cannot-land"],
); );
} }
// Priority (CR-007): the trip must fit the delivery window.
const window = contract.priority ? contract.deliveryWindowGameHours : null;
if (window !== null) {
const trip = contractTripGameHours(model, contract, now, content);
if (trip > window) {
problems.push(`Too slow — ${formatWindow(trip)} vs ${formatWindow(window)} window`);
}
}
if (contract.requiredSkill) { if (contract.requiredSkill) {
const have = pilot.skills[contract.requiredSkill.skillId] ?? 0; const have = pilot.skills[contract.requiredSkill.skillId] ?? 0;
if (have < contract.requiredSkill.level) { if (have < contract.requiredSkill.level) {
@ -122,6 +132,8 @@ export interface PairOption {
arrivalAt: number; arrivalAt: number;
/** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */ /** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */
duty: { after: number; limit: number; willRest: boolean } | null; duty: { after: number; limit: number; willRest: boolean } | null;
/** Priority only (CR-007): this ship's trip against the window, in game hours. */
priority: { tripHours: number; windowHours: number } | null;
} }
/** A pilot's duty after a trip, with the same sim rules the server applies on arrival. */ /** A pilot's duty after a trip, with the same sim rules the server applies on arrival. */
@ -200,6 +212,13 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
netProfit: contract.payout - fuelCost - pilotPay, netProfit: contract.payout - fuelCost - pilotPay,
arrivalAt: now + tripMs, arrivalAt: now + tripMs,
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
priority:
contract.priority && contract.deliveryWindowGameHours !== null
? {
tripHours: contractTripGameHours(model, contract, now, content),
windowHours: contract.deliveryWindowGameHours,
}
: null,
}; };
}); });
} }
@ -235,12 +254,17 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`} title={`${locationName(contract.originId)} → ${locationName(contract.destinationId)}`}
> >
<p aria-label="Job details" className="mt-1 text-sm text-ink-300"> <p aria-label="Job details" className="mt-1 text-sm text-ink-300">
{cargoName(contract.cargoTypeId)} {goodName(contract)} for {contract.shipper} · {cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "} {contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
{formatCredits(contract.payout)} {formatCredits(contract.payout)}
{" · "} {" · "}
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"} {requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
</p> </p>
{contract.priority && contract.deliveryWindowGameHours !== null && (
<div className="mt-2">
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
</div>
)}
<div className="mt-3"> <div className="mt-3">
<RouteStrip originId={contract.originId} destinationId={contract.destinationId} /> <RouteStrip originId={contract.originId} destinationId={contract.destinationId} />
</div> </div>
@ -315,6 +339,13 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
)} )}
</dl> </dl>
)} )}
{!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200">
Priority: {formatWindow(option.priority.tripHours)} trip,{" "}
{formatWindow(option.priority.windowHours - option.priority.tripHours)} to
spare in the {formatWindow(option.priority.windowHours)} window
</p>
)}
{!blocked && option.duty?.willRest && ( {!blocked && option.duty?.willRest && (
<p className="mt-2 text-amber-300">Will need rest on arrival</p> <p className="mt-2 text-amber-300">Will need rest on arrival</p>
)} )}

View File

@ -0,0 +1,116 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, Ship } from "@deadhead/schema";
import { contractTripGameHours, priorityWindow } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatWindow } from "../src/components/Goods";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const ship = (id: string, name: string, modelId: string, pilotId: string): Ship => ({
id,
name,
modelId,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId,
});
const pilot = (id: string, name: string, shipId: string): Pilot => ({
id,
name,
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: shipId,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
});
/** Priority mail to the Moon, with the Freighter-reference window the sim would set. */
const rush: Contract = {
id: "c-rush",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "mail",
shipper: "Pacific Orbital Logistics",
priority: true,
deliveryWindowGameHours: priorityWindow(earth, moon, 6, NOW, content),
tier: 0,
massTonnes: 6,
payout: 1_500,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
};
const trip = (modelId: string) =>
contractTripGameHours(content.shipModel(modelId)!, rush, NOW, content);
async function openSheet() {
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 },
bays: [{ id: "b", locationId: "earth" }],
ships: [
ship("s-fast", "Quick Finch", "courier", "p-fast"),
ship("s-slow", "Old Barge", "bulk-hauler", "p-slow"),
],
pilots: [pilot("p-fast", "Ana Ruiz", "s-fast"), pilot("p-slow", "Bo Lind", "s-slow")],
}),
getContractBoard: () => ({ contracts: [rush], periodEndsAt: NOW + 3_600_000 }),
}),
"/contracts?location=earth",
);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
return screen.getByRole("dialog");
}
const option = (dialog: HTMLElement, shipName: string) =>
within(dialog).getByText(shipName).closest("li")!;
describe("Priority in the dispatch sheet (CR-007)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("enables a ship fast enough for the window and shows its margin", async () => {
const window = rush.deliveryWindowGameHours!;
const fast = option(await openSheet(), "Quick Finch");
expect(within(fast).getByRole("radio")).toBeEnabled();
expect(within(fast).getByLabelText("Priority margin")).toHaveTextContent(
`Priority: ${formatWindow(trip("courier"))} trip, ${formatWindow(window - trip("courier"))} to spare in the ${formatWindow(window)} window`,
);
});
it("disables a too-slow ship with its trip and the window from the sim", async () => {
const window = rush.deliveryWindowGameHours!;
expect(trip("bulk-hauler")).toBeGreaterThan(window);
const slow = option(await openSheet(), "Old Barge");
expect(within(slow).getByRole("radio")).toBeDisabled();
expect(within(slow).getByRole("list", { name: "Problems" })).toHaveTextContent(
`Too slow — ${formatWindow(trip("bulk-hauler"))} vs ${formatWindow(window)} window`,
);
});
});

View File

@ -210,3 +210,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 /
- [x] **Step 10G.6 — Schema and database.** API `Contract` (since 10G.2) carries `goodId`, `shipper`, `priority`, `deliveryWindowGameHours`; `ResolutionSummary` gains `goodId`, `massTonnes`, `shipper` (null for a deadhead); `too-slow` is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration `0005_cr007_goods` (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). `architecture.md` updated. Tests: `goods-backfill.test.ts` (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), `migrations.test.ts` unchanged, schema cases for Priority windows, empty goods and summary goods. - [x] **Step 10G.6 — Schema and database.** API `Contract` (since 10G.2) carries `goodId`, `shipper`, `priority`, `deliveryWindowGameHours`; `ResolutionSummary` gains `goodId`, `massTonnes`, `shipper` (null for a deadhead); `too-slow` is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration `0005_cr007_goods` (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). `architecture.md` updated. Tests: `goods-backfill.test.ts` (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), `migrations.test.ts` unchanged, schema cases for Priority windows, empty goods and summary goods.
- [x] **Step 10G.7 — Server: pass-through and validation.** No server code changes were needed beyond 10G.4's message and 10G.6's summary fields: boards come from the sim and dispatch validation returns the sim's `too-slow`. Tests (`contract-dispatch.test.ts`): a Priority job whose window is shorter than the Courier's trip is refused with `too-slow` and changes nothing; with a window the Courier makes, it dispatches and the stored contract keeps its Priority good; every generated Earth contract names a catalog good of its class and a shipper from Earth's list. - [x] **Step 10G.7 — Server: pass-through and validation.** No server code changes were needed beyond 10G.4's message and 10G.6's summary fields: boards come from the sim and dispatch validation returns the sim's `too-slow`. Tests (`contract-dispatch.test.ts`): a Priority job whose window is shorter than the Courier's trip is refused with `too-slow` and changes nothing; with a window the Courier makes, it dispatches and the stored contract keeps its Priority good; every generated Earth contract names a catalog good of its class and a shipper from Earth's list.
- [x] **Step 10G.8 — Client: contract card redesign.** New `components/Goods.tsx`: `goodName` (a Priority good may use its `priorityName`, e.g. Emergency Parts), `ClassChip` ("High-Value · T1", toned by class), `PriorityChip` ("Priority · within 5h" in game hours; tap switches to real time, hover shows both), `tooSlowForAll` (idle ships at the origin exist and none makes the window, via sim `contractTripGameHours`), `formatWindow`, `realTimeOf`. Location board cards, top to bottom: route and payout; the good as an `h3` headline with class, Priority, First contact, Too heavy and Too slow chips; route strip; shipper · mass · distance; requirement and expiry with Dispatch. Tutorial cards show the good's name instead of the class. Tests: `contract-card.test.tsx` (good, class/tier chip, shipper, mass, requirement; Priority chip in game hours and real time on tap with the Priority name; Too slow with only a Bulk Hauler idle); `location-boards.test.tsx` updated to the new card. - [x] **Step 10G.8 — Client: contract card redesign.** New `components/Goods.tsx`: `goodName` (a Priority good may use its `priorityName`, e.g. Emergency Parts), `ClassChip` ("High-Value · T1", toned by class), `PriorityChip` ("Priority · within 5h" in game hours; tap switches to real time, hover shows both), `tooSlowForAll` (idle ships at the origin exist and none makes the window, via sim `contractTripGameHours`), `formatWindow`, `realTimeOf`. Location board cards, top to bottom: route and payout; the good as an `h3` headline with class, Priority, First contact, Too heavy and Too slow chips; route strip; shipper · mass · distance; requirement and expiry with Dispatch. Tutorial cards show the good's name instead of the class. Tests: `contract-card.test.tsx` (good, class/tier chip, shipper, mass, requirement; Priority chip in game hours and real time on tap with the Priority name; Too slow with only a Bulk Hauler idle); `location-boards.test.tsx` updated to the new card.
- [x] **Step 10G.9 — Client: Priority in the dispatch sheet.** `explainProblems` adds "Too slow — 7h vs 5h window" (sim `contractTripGameHours` vs the window) so a slow pair is disabled with the numbers; `PairOption.priority` carries each pair's trip and the window, and eligible pairs show "Priority: 2.1h trip, 1.4h to spare in the 3.5h window". The sheet's job details now start with the good and shipper ("Mail & Parcels for Pacific Orbital Logistics · Standard · 6 t …") and Priority jobs show the window chip. Test: `dispatch-priority.test.tsx` (Courier enabled with its margin, Bulk Hauler disabled with "Too slow — …", numbers from the sim).