Show each job's length in game hours on the board
CI / check (push) Failing after 6m28s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-17 16:20:41 -04:00
parent 96336ea26d
commit 936fb9e506
7 changed files with 167 additions and 3 deletions

View File

@ -1978,3 +1978,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **CR-018's completability rule is CR-019's Step 18.1** in this repo: the hull reason is recorded there as a temporary one. - **CR-018's completability rule is CR-019's Step 18.1** in this repo: the hull reason is recorded there as a temporary one.
- **The game design document is left to the designer** (CR-026 §9). - **The game design document is left to the designer** (CR-026 §9).
## Fix: job length on the board
- **A job's length depends on the ship**, so the board shows it for the player's fastest crewed ship that could take the job (the permanent checks: hold, range, landing, skills), preferring ships already at the job's origin, since those are the ones that would fly it. Its label says it's game time, and a tooltip says which ship it's for.
- **It's the whole trip in game hours** (travel plus loading and takeoff/landing), the figure the Priority window and duty use.

View File

@ -1,7 +1,9 @@
import { content, type Location, type ShipModel } from "@deadhead/content"; import { content, type Location, type ShipModel } from "@deadhead/content";
import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { import {
checkRoute, checkRoute,
contractTripGameHours,
permanentFailure,
effectiveStats, effectiveStats,
isInShop, isInShop,
MS_PER_HOUR, MS_PER_HOUR,
@ -190,3 +192,24 @@ export function readyShips(
notReady(state, s, now) === null, notReady(state, s, now) === null,
); );
} }
/**
* How long a job takes in game hours (the trip's travel and handling), for the
* player's fastest ship that could take it: one at the job's origin if any,
* else anywhere in the fleet. Null with no ship that could.
*/
export function jobGameHours(
state: Pick<PlayerState, "ships" | "pilots">,
contract: Contract,
): number | null {
const able = state.ships.flatMap((ship) => {
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
if (!pilot) return [];
const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades };
if (permanentFailure(contract, member, undefined, content) !== null) return [];
return [{ ship, hours: contractTripGameHours(shipStatsOf(ship), contract, content) }];
});
const here = able.filter((a) => a.ship.locationId === contract.originId);
const pool = here.length > 0 ? here : able;
return pool.length > 0 ? Math.min(...pool.map((a) => a.hours)) : null;
}

View File

@ -5,8 +5,15 @@ import { useSearchParams } from "react-router";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import { serverNow, useClock } from "../clock/clock"; import { serverNow, useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format"; import {
import { distanceAt, notReady, readyShips } from "../game"; formatClock,
formatCredits,
formatGameHours,
formatMillionKm,
locationName,
skillName,
} from "../format";
import { distanceAt, jobGameHours, notReady, readyShips } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet"; import { pairOptions } from "./DispatchSheet";
import { DeadheadPicker } from "./Fleet"; import { DeadheadPicker } from "./Fleet";
@ -271,6 +278,7 @@ function LocationBoard({
<span> <span>
{contract.shipper} · <span>{contract.massTonnes} t</span> ·{" "} {contract.shipper} · <span>{contract.massTonnes} t</span> ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId))} {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
<TripHours contract={contract} />
</span> </span>
</div> </div>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm"> <div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
@ -294,6 +302,22 @@ function LocationBoard({
); );
} }
/** " · 5.4 h": the job's length in game hours for the fastest ship that could take it. */
function TripHours({ contract }: { contract: Contract }) {
const state = useLoadedPlayer();
const hours = jobGameHours(state, contract);
if (hours === null) return null;
return (
<span
aria-label={`Takes ${formatGameHours(hours)} of game time`}
title="Game hours, for your fastest ship that can take it"
>
{" "}
· {formatGameHours(hours)}
</span>
);
}
/** /**
* Why no ship here can take a job now (decision 0.5.134): none docked, or the * Why no ship here can take a job now (decision 0.5.134): none docked, or the
* pilots here resting (with who wakes first), or none crewed for the player. * pilots here resting (with who wakes first), or none crewed for the player.

View File

@ -0,0 +1,108 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, Ship } from "@deadhead/schema";
import { contractTripGameHours, effectiveStats } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatGameHours } from "../src/format";
import { jobGameHours } from "../src/game";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const job = (over: Partial<Contract> = {}): Contract => ({
id: "j1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 12,
payout: 1_500,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 30 * 60_000,
firstContact: false,
...over,
});
const ship = (id: string, modelId: string, locationId: string, pilotId: string | null): Ship => ({
id,
modelId,
name: id,
status: "idle",
locationId,
dispatchId: null,
pilotId,
lastArrivalAt: 0,
...shipExtras(modelId),
});
const pilot = (id: string, shipId: string): Pilot => ({
...samplePlayerState().pilots[0]!,
id,
name: id,
isAvatar: id === "avatar",
assignedShipId: shipId,
locationId: null,
...pilotExtras,
});
const hoursFor = (modelId: string, contract: Contract) =>
contractTripGameHours(
effectiveStats(content.shipModel(modelId)!, {}, content),
contract,
content,
);
describe("job length on the board", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows the game hours beside the weight and distance", async () => {
const contract = job();
const state = samplePlayerState({
tutorial: null,
bays: [{ id: "b", locationId: "earth" }],
ships: [ship("s1", "courier", "earth", "avatar")],
pilots: [pilot("avatar", "s1")],
});
renderApp(
fakeApi({
getPlayerState: () => state,
getContractBoard: () => ({ contracts: [contract], periodEndsAt: NOW + 30 * 60_000 }),
}),
"/contracts?location=earth",
);
const hours = formatGameHours(hoursFor("courier", contract));
const time = await screen.findByLabelText(`Takes ${hours} of game time`);
const line = time.parentElement!;
expect(line).toHaveTextContent(`Pacific Orbital Logistics · 12 t · 0.38M km · ${hours}`);
expect(within(line).getByText(/12 t/)).toBeInTheDocument();
});
it("times the job for the fastest ship that could take it, preferring one at the origin", () => {
const contract = job();
const ships = [
ship("slow-here", "freighter", "earth", "p1"),
ship("fast-away", "clipper", "moon", "p2"),
];
const pilots = [pilot("p1", "slow-here"), pilot("p2", "fast-away")];
expect(jobGameHours({ ships, pilots }, contract)).toBeCloseTo(hoursFor("freighter", contract));
// Nobody at Earth: the fastest anywhere.
const away = ships.map((s) => ({ ...s, locationId: "moon" }));
expect(jobGameHours({ ships: away, pilots }, contract)).toBeCloseTo(
Math.min(hoursFor("freighter", contract), hoursFor("clipper", contract)),
);
// No crewed ship that could take it: no time shown.
expect(jobGameHours({ ships, pilots: [] }, contract)).toBeNull();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@ -559,3 +559,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
- [x] **Step 26.10 — Plan amendments and regression.** CR-026 §9 applied: plan 0.3 (random events no longer deferred), 0.5 (0.5.143–0.5.152, recorded with the CR and in Step 26.9), Steps 3.13 and 3.17; CR-009 Step 10F.1 (Hull Plating); CR-014 Steps 10K.6 (events in the loop and catch-up) and 10K.9 (radio-call notifications); CR-019 Step 18.1 (the hull reason isn't a hidden-job rule); CR-021 Step 19.4 (Independent pilots get events); CR-024 Step 22.2 (answer and repair are manual decisions); CR-025 Step 23.2 (event highlights). `game-design-document.md` §13 is the designer's to update. Full suite: typecheck, lint, 1564 unit tests and 11 e2e tests pass. - [x] **Step 26.10 — Plan amendments and regression.** CR-026 §9 applied: plan 0.3 (random events no longer deferred), 0.5 (0.5.143–0.5.152, recorded with the CR and in Step 26.9), Steps 3.13 and 3.17; CR-009 Step 10F.1 (Hull Plating); CR-014 Steps 10K.6 (events in the loop and catch-up) and 10K.9 (radio-call notifications); CR-019 Step 18.1 (the hull reason isn't a hidden-job rule); CR-021 Step 19.4 (Independent pilots get events); CR-024 Step 22.2 (answer and repair are manual decisions); CR-025 Step 23.2 (event highlights). `game-design-document.md` §13 is the designer's to update. Full suite: typecheck, lint, 1564 unit tests and 11 e2e tests pass.
**Phase 26 is complete; stopped for review.** Manual check for the user: on a fresh company, fly past the deal and three more trips, send a ship on a long contract (6+ game hours), and check the first event is the Derelict cargo pod radio call; answer it; later let a radio call run out and check the default applied (its result in the summary and the Logbook); use Developer tools → Damage hull on a ship, then repair it at an HQ from its Upgrades sheet. **Phase 26 is complete; stopped for review.** Manual check for the user: on a fresh company, fly past the deal and three more trips, send a ship on a long contract (6+ game hours), and check the first event is the Derelict cargo pod radio call; answer it; later let a radio call run out and check the default applied (its result in the summary and the Logbook); use Developer tools → Damage hull on a ship, then repair it at an HQ from its Upgrades sheet.
## Fixes after Phase 26
- [x] **Job length on the board.** Location board cards show how long the job takes in game hours beside the weight and distance ("Atlas Heavy Industries · 6 t · 0.38M km · 2.2 h"), for the fastest of the player's crewed ships that could take it, preferring one at the job's origin (`jobGameHours` in `apps/web/src/game.ts`); none is shown if no ship could. The tutorial board already showed its trip time. Tests: `apps/web/test/job-hours.test.tsx`. Screenshots: `docs/screenshots/fix-job-hours-{phone,desktop}.png`.