Step 10D.7: rest and duty outlook in the dispatch sheet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
da9e053598
commit
9f699f716e
|
|
@ -800,3 +800,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The confirmation is inline in the pilot card**, not a browser `confirm()`, so it is styled and testable. It names the real rest length (one hour with current settings, computed from config) and says the pilot wakes to new contracts.
|
- **The confirmation is inline in the pilot card**, not a browser `confirm()`, so it is styled and testable. It names the real rest length (one hour with current settings, computed from config) and says the pilot wakes to new contracts.
|
||||||
- **"Rest now" is hidden during the tutorial and for in-flight pilots**, matching what the server allows.
|
- **"Rest now" is hidden during the tutorial and for in-flight pilots**, matching what the server allows.
|
||||||
- **The duty bar caps at full** when a pilot finished a trip over the limit; the text shows the actual hours.
|
- **The duty bar caps at full** when a pilot finished a trip over the limit; the text shows the actual hours.
|
||||||
|
|
||||||
|
## Step 10D.7
|
||||||
|
|
||||||
|
- **The resting line is computed from the clock each render** ("Resting — 12:05"), so it counts down while the sheet is open, and it is the first problem listed.
|
||||||
|
- **Duty after uses the same sim functions the server uses on arrival** (`tripGameHours` from departure and arrival, `dutyAfterTrip`, `needsRest`), so the warning matches what will happen.
|
||||||
|
- **The deadhead picker also respects rest** (it validates with the pilot's real `restEndsAt`), disabling every destination with "Pilot is resting".
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ export function formatDuration(ms: number): string {
|
||||||
return `${seconds}s`;
|
return `${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A countdown as "mm:ss", or "h:mm:ss" from an hour up. */
|
||||||
|
export function formatClock(ms: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const mmss = `${pad(Math.floor((totalSeconds % 3600) / 60))}:${pad(totalSeconds % 60)}`;
|
||||||
|
return hours > 0 ? `${hours}:${mmss}` : mmss;
|
||||||
|
}
|
||||||
|
|
||||||
/** A game-hours duration, e.g. "5.4 h" or "250 h". */
|
/** A game-hours duration, e.g. "5.4 h" or "250 h". */
|
||||||
export function formatGameHours(hours: number): string {
|
export function formatGameHours(hours: number): string {
|
||||||
return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`;
|
return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`;
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,27 @@ 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,
|
||||||
|
dutyAfterTrip,
|
||||||
LONG_HAUL_SKILL_ID,
|
LONG_HAUL_SKILL_ID,
|
||||||
|
needsRest,
|
||||||
realToGame,
|
realToGame,
|
||||||
tripFuelCost,
|
tripFuelCost,
|
||||||
|
tripGameHours,
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
type DispatchFailure,
|
type DispatchFailure,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { formatGameTime } from "../clock/GameClock";
|
import { formatGameTime } from "../clock/GameClock";
|
||||||
import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format";
|
import {
|
||||||
|
cargoName,
|
||||||
|
formatClock,
|
||||||
|
formatCredits,
|
||||||
|
formatDuration,
|
||||||
|
formatGameHours,
|
||||||
|
locationName,
|
||||||
|
skillName,
|
||||||
|
} from "../format";
|
||||||
import { requirementsText } from "./LocationBoards";
|
import { requirementsText } from "./LocationBoards";
|
||||||
import { location, shipModel, tripRealMs } from "../game";
|
import { location, shipModel, tripRealMs } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
@ -47,6 +58,10 @@ export function explainProblems(
|
||||||
now: number,
|
now: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
|
// Resting blocks flying (CR-004): first, with the time left.
|
||||||
|
if (pilot.resting && pilot.restEndsAt !== null) {
|
||||||
|
problems.push(`Resting — ${formatClock(pilot.restEndsAt - now)}`);
|
||||||
|
}
|
||||||
if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
|
if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
|
||||||
if (contract.massTonnes > model.cargoCapacityTonnes) {
|
if (contract.massTonnes > model.cargoCapacityTonnes) {
|
||||||
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
|
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
|
||||||
|
|
@ -100,6 +115,15 @@ export interface PairOption {
|
||||||
fuelCost: number;
|
fuelCost: number;
|
||||||
netProfit: number;
|
netProfit: number;
|
||||||
arrivalAt: number;
|
arrivalAt: number;
|
||||||
|
/** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */
|
||||||
|
duty: { after: number; limit: number; willRest: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pilot's duty after a trip, with the same sim rules the server applies on arrival. */
|
||||||
|
export function dutyOutlook(pilot: Pilot, departureAt: number, arrivalAt: number) {
|
||||||
|
const hours = tripGameHours(departureAt, arrivalAt, content.config);
|
||||||
|
const after = dutyAfterTrip(pilot.dutyHours, hours, false);
|
||||||
|
return { after, limit: pilot.dutyLimit, willRest: needsRest(after, pilot.dutyLimit) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every ship at the contract's origin, with its pilot, numbers and eligibility. */
|
/** Every ship at the contract's origin, with its pilot, numbers and eligibility. */
|
||||||
|
|
@ -141,7 +165,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
assignedShipId: pilot.assignedShipId,
|
assignedShipId: pilot.assignedShipId,
|
||||||
inFlight: pilot.status === "in-flight",
|
inFlight: pilot.status === "in-flight",
|
||||||
skills: pilot.skills,
|
skills: pilot.skills,
|
||||||
restEndsAt: null,
|
restEndsAt: pilot.restEndsAt,
|
||||||
},
|
},
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
|
|
@ -169,6 +193,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
fuelCost,
|
fuelCost,
|
||||||
netProfit: contract.payout - fuelCost - pilotPay,
|
netProfit: contract.payout - fuelCost - pilotPay,
|
||||||
arrivalAt: now + tripMs,
|
arrivalAt: now + tripMs,
|
||||||
|
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -268,8 +293,20 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
</dd>
|
</dd>
|
||||||
<dt>ETA (real)</dt>
|
<dt>ETA (real)</dt>
|
||||||
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
|
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
|
||||||
|
{option.duty && (
|
||||||
|
<>
|
||||||
|
<dt>Duty after</dt>
|
||||||
|
<dd className="tabular-nums">
|
||||||
|
{formatGameHours(option.duty.after)} of{" "}
|
||||||
|
{formatGameHours(option.duty.limit)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
{option.problems.length === 0 && option.duty?.willRest && (
|
||||||
|
<p className="mt-1 text-amber-300">Will need rest on arrival</p>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void })
|
||||||
assignedShipId: pilot ? ship.id : null,
|
assignedShipId: pilot ? ship.id : null,
|
||||||
inFlight: pilot?.status === "in-flight",
|
inFlight: pilot?.status === "in-flight",
|
||||||
skills: pilot?.skills ?? {},
|
skills: pilot?.skills ?? {},
|
||||||
restEndsAt: null,
|
restEndsAt: pilot?.restEndsAt ?? null,
|
||||||
},
|
},
|
||||||
destinationId: id,
|
destinationId: id,
|
||||||
discoveredIds,
|
discoveredIds,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
||||||
|
import { realToGame, routeDistance, travelTimeGameHours } 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 { formatGameHours } from "../src/format";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
const ship = (id: string, name: string, pilotId: string): Ship => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
modelId: "courier",
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
dispatchId: null,
|
||||||
|
lastArrivalAt: 0,
|
||||||
|
pilotId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pilot = (id: string, name: string, shipId: string, extra: Partial<Pilot>): 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,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moonJob: Contract = {
|
||||||
|
id: "c-moon",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: "standard",
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 10,
|
||||||
|
payout: 900,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: NOW,
|
||||||
|
expiresAt: NOW + 3_600_000,
|
||||||
|
firstContact: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = samplePlayerState({
|
||||||
|
tutorial: null,
|
||||||
|
company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 },
|
||||||
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
|
ships: [
|
||||||
|
ship("s-rest", "Sleepy Gull", "p-rest"),
|
||||||
|
ship("s-tired", "Tired Heron", "p-tired"),
|
||||||
|
ship("s-fresh", "Fresh Lark", "p-fresh"),
|
||||||
|
],
|
||||||
|
pilots: [
|
||||||
|
pilot("p-rest", "Ana Resting", "s-rest", {
|
||||||
|
resting: true,
|
||||||
|
restEndsAt: NOW + 12 * 60_000 + 5_000,
|
||||||
|
}),
|
||||||
|
pilot("p-tired", "Bo Tired", "s-tired", { dutyHours: 11 }),
|
||||||
|
pilot("p-fresh", "Cy Fresh", "s-fresh", { dutyHours: 1 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The Courier's game hours for the Moon job, straight from the sim. */
|
||||||
|
const tripHours = () =>
|
||||||
|
travelTimeGameHours(
|
||||||
|
content.shipModel("courier")!,
|
||||||
|
routeDistance(
|
||||||
|
content.location("earth")!,
|
||||||
|
content.location("moon")!,
|
||||||
|
realToGame(NOW, content.config),
|
||||||
|
),
|
||||||
|
moonJob.massTonnes,
|
||||||
|
content.config,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function openSheet() {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: () => state,
|
||||||
|
getContractBoard: () => ({ contracts: [moonJob], 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("duty in the dispatch sheet (Step 10D.7)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("disables a resting pilot's pair with the time left", async () => {
|
||||||
|
const resting = option(await openSheet(), "Sleepy Gull");
|
||||||
|
expect(within(resting).getByRole("radio")).toBeDisabled();
|
||||||
|
expect(resting).toHaveTextContent("Resting — 12:05");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the trip takes the pilot to the limit, with the sim's duty numbers", async () => {
|
||||||
|
const dialog = await openSheet();
|
||||||
|
const tired = option(dialog, "Tired Heron");
|
||||||
|
expect(tired).toHaveTextContent(
|
||||||
|
`Duty after${formatGameHours(11 + tripHours())} of ${formatGameHours(12)}`,
|
||||||
|
);
|
||||||
|
expect(tired).toHaveTextContent("Will need rest on arrival");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no warning when the pilot stays under the limit", async () => {
|
||||||
|
const fresh = option(await openSheet(), "Fresh Lark");
|
||||||
|
expect(fresh).toHaveTextContent(
|
||||||
|
`Duty after${formatGameHours(1 + tripHours())} of ${formatGameHours(12)}`,
|
||||||
|
);
|
||||||
|
expect(fresh).not.toHaveTextContent("Will need rest");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -170,7 +170,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
|
||||||
- [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated.
|
- [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated.
|
||||||
- [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`.
|
- [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`.
|
||||||
- [x] **Step 10D.6 — Client: pilot duty on the Pilots screen.** Each pilot card shows a duty bar ("9.0 h of 14 h", `progressbar` with the hours and limit); resting pilots show "Resting · wakes in …" instead. Idle, non-resting pilots (after the tutorial) have "Rest now" with an inline confirmation stating the rest length and that it covers at least one board refresh, then call `restPilot`. Test: `pilots-duty.test.tsx`.
|
- [x] **Step 10D.6 — Client: pilot duty on the Pilots screen.** Each pilot card shows a duty bar ("9.0 h of 14 h", `progressbar` with the hours and limit); resting pilots show "Resting · wakes in …" instead. Idle, non-resting pilots (after the tutorial) have "Rest now" with an inline confirmation stating the rest length and that it covers at least one board refresh, then call `restPilot`. Test: `pilots-duty.test.tsx`.
|
||||||
- [ ] Step 10D.7 — Client: duty in the dispatch flow
|
- [x] **Step 10D.7 — Client: duty in the dispatch flow.** The dispatch sheet disables a resting pilot's pair with "Resting — mm:ss" (new `formatClock`). Eligible pairs show "Duty after" (sim `tripGameHours` + `dutyAfterTrip`) and "Will need rest on arrival" when `needsRest`. Client validation now passes the pilot's real `restEndsAt` (dispatch sheet and deadhead picker, which shows "Pilot is resting"). Test: `dispatch-duty.test.tsx`.
|
||||||
- [ ] Step 10D.8 — Client: board refresh and the "nothing here" case
|
- [ ] Step 10D.8 — Client: board refresh and the "nothing here" case
|
||||||
- [ ] Step 10D.9 — Client: rest in summaries and on HQ
|
- [ ] Step 10D.9 — Client: rest in summaries and on HQ
|
||||||
- [ ] Step 10D.10 — Plan amendments and regression
|
- [ ] Step 10D.10 — Plan amendments and regression
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue