Step 29.6: sortable jobs table with net per hour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-11-04 18:28:06 -05:00
parent 9aada41e8f
commit d279ac4ec0
17 changed files with 502 additions and 174 deletions

View File

@ -77,7 +77,7 @@ test.describe("end to end (Phase 12)", () => {
// The Lighter's pair can take it in the dispatch sheet.
await page.goto("/contracts?location=moon");
const card = page
.getByRole("listitem")
.getByRole("row")
.filter({ hasText: content.good(job!.goodId)!.name })
.filter({ hasText: `${job!.massTonnes} t` });
await card.getByRole("button", { name: "Dispatch" }).first().click();

View File

@ -9,6 +9,7 @@ import {
MS_PER_HOUR,
routeDistance,
travelTimeGameHours,
pilotShare,
tripFuel,
type EffectiveStats,
type ShipState,
@ -193,6 +194,27 @@ export function readyShips(
);
}
/**
* What a job leaves the company on one ship (decision 0.5.169): the fuel at the
* tank's average price (0.5.98) and a hired pilot's share (CR-010).
*/
export function jobMoney(
ship: Pick<Ship, "modelId" | "upgrades" | "fuelAvgPrice">,
pilot: Pick<Pilot, "isAvatar"> | null | undefined,
contract: Pick<Contract, "originId" | "destinationId" | "payout">,
) {
const fuelFu = tripFuel(
shipStatsOf(ship),
location(contract.originId),
location(contract.destinationId),
content.config,
);
const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice);
const pilotPay =
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
return { fuelFu, fuelCost, pilotPay, netProfit: contract.payout - fuelCost - pilotPay };
}
/**
* 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,

View File

@ -15,7 +15,7 @@ export function Contracts() {
<Screen>
<ScreenHeader
title="Jobs"
eyebrow={state.tutorial ? content.tutorialCompanyName : "Freight market"}
eyebrow={state.tutorial ? content.tutorialCompanyName : undefined}
icon="jobs"
/>
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}

View File

@ -16,18 +16,7 @@ import { formatGameHours, locationName, shipModelName } from "../format";
import { lowFuel } from "../game";
import { ShopRest } from "../components/ShopRest";
import { useLoadedPlayer } from "../state/actions";
import {
Art,
buttonClass,
Eyebrow,
Icon,
Meter,
Panel,
Pill,
Screen,
ScreenHeader,
StatTile,
} from "../ui";
import { Art, buttonClass, Eyebrow, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
/** What the player needs to do next to reach the deal (decision 0.5.30). */
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {

View File

@ -5,12 +5,10 @@ import {
contractTripGameHours,
isInShop,
rangeFrom,
pilotShare,
dutyAfterTrip,
LONG_HAUL_SKILL_ID,
needsRest,
realToGame,
tripFuel,
tripGameHours,
validateDispatch,
wouldStrand,
@ -31,7 +29,7 @@ import {
skillName,
} from "../format";
import { requirementsText } from "./LocationBoards";
import { location, tripRealMs, shipStatsOf, simShip } from "../game";
import { jobMoney, location, tripRealMs, shipStatsOf, simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Sheet } from "../ui";
@ -221,17 +219,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
contract.destinationId,
contract.massTonnes,
);
const fuelFu = tripFuel(
model,
location(contract.originId),
location(contract.destinationId),
content.config,
);
// The fuel used, at the tank's average price (decision 0.5.98).
const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice);
// Profit-share pay for a hired pilot; the avatar is exempt (CR-010).
const pilotPay =
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
const { fuelFu, fuelCost, pilotPay, netProfit } = jobMoney(ship, pilot, contract);
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
let shortOfFuel = false;
@ -292,7 +280,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu },
shortOfFuel,
pilotPay,
netProfit: contract.payout - fuelCost - pilotPay,
netProfit,
arrivalAt: now + tripMs,
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
priority:

View File

@ -0,0 +1,216 @@
import type { Contract } from "@deadhead/schema";
import { Countdown } from "../components/Countdown";
import { ClassChip, goodName, PriorityChip } from "../components/Goods";
import {
formatClock,
formatCredits,
formatGameHours,
formatMillionKm,
locationName,
} from "../format";
import { distanceAt } from "../game";
import { useClock } from "../clock/clock";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences, type JobSort } from "../state/preferences";
import { Pill } from "../ui";
import { jobFigures, type JobFigures } from "./jobFigures";
import { requirementsText } from "./LocationBoards";
const COLUMNS = "hidden px-2 text-[13px] text-ink-300 @2xl:table-cell";
const money = (n: number) => n.toLocaleString("en-US");
function netLabel({ net, estimate }: JobFigures): string {
if (net === null) return "—";
return `${estimate ? "≈" : ""}${net >= 0 ? "+" : "−"}${money(Math.abs(net))}`;
}
/** Jobs at a place, sortable by net or credits per hour (decision 0.5.169). */
export function JobsTable({
contracts,
fresh,
firstLookMs,
pilotsOnTheirOwn,
onSelect,
}: {
contracts: Contract[];
fresh: ReadonlySet<string>;
firstLookMs: number;
pilotsOnTheirOwn: boolean;
onSelect?: (contract: Contract) => void;
}) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const sort = usePreferences((p) => p.jobSort);
const setPreference = usePreferences((p) => p.set);
const value = (f: JobFigures) => (sort === "net" ? f.net : f.perHour) ?? -Infinity;
const rows = contracts
.map((contract) => ({ contract, figures: jobFigures(state, contract, now) }))
.sort((a, b) => value(b.figures) - value(a.figures));
const ready = rows.filter((r) => r.figures.ready);
const blocked = rows.filter((r) => !r.figures.ready);
const header = (key: JobSort, label: string, width: string) => (
<th
scope="col"
aria-sort={sort === key ? "descending" : "none"}
className={`p-0 text-right font-medium ${width}`}
>
<button
type="button"
onClick={() => setPreference({ jobSort: key })}
className={`flex min-h-11 w-full items-center justify-end gap-1 pr-3 text-xs font-semibold ${
sort === key ? "text-ink-50" : "text-ink-300"
}`}
>
{label}
{sort === key && <span aria-hidden="true">↓</span>}
</button>
</th>
);
const row = ({ contract, figures }: (typeof rows)[number]) => {
const requirements = requirementsText(contract);
const firstLook =
pilotsOnTheirOwn && !contract.firstContact && now < contract.offeredAt + firstLookMs;
const hours = figures.hours === null ? null : formatGameHours(figures.hours);
const destination = `To ${locationName(contract.destinationId)}`;
return (
<tr
key={contract.id}
aria-label={goodName(contract)}
className={`relative border-t border-glass-line ${
contract.firstContact ? "bg-action/8" : ""
} ${fresh.has(contract.id) ? "animate-rise" : ""}`}
>
<td className="min-w-0 py-2 pr-1 pl-3">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span
className={`text-[14.5px] leading-snug font-semibold ${figures.ready ? "" : "text-ink-300"}`}
>
{goodName(contract)}
</span>
{fresh.has(contract.id) && (
<Pill tone="cyan" icon="star">
New
</Pill>
)}
<ClassChip contract={contract} />
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
)}
{contract.firstContact && (
<Pill tone="amber" icon="star">
First contact
</Pill>
)}
{firstLook && (
<Pill tone="amber" icon="clock">
<span aria-label="Yours for">
Yours for{" "}
<Countdown
to={contract.offeredAt + firstLookMs}
format={formatClock}
done="now"
/>
</span>
</Pill>
)}
</div>
<div className="text-[12.5px] leading-snug text-ink-300">
{destination}
<span className="@2xl:hidden">
{" "}
· <span>{contract.massTonnes} t</span>
{hours && <span aria-label={`Takes ${hours} of game time`}> · {hours}</span>}
</span>
{requirements && <span className="@2xl:hidden"> · Needs {requirements}</span>}
{figures.note && <span> · {figures.note}</span>}
</div>
{/* The rest of the job: beside the columns on desktop, in the sheet on phones. */}
<div className="hidden text-xs leading-snug text-ink-400 @2xl:block">
Pays <span>{formatCredits(contract.payout)}</span> ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId))} ·{" "}
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
<Countdown to={contract.expiresAt} done="expired" />
</div>
</td>
<td className={COLUMNS}>{contract.shipper}</td>
<td className={`${COLUMNS} tabular-nums`}>{contract.massTonnes} t</td>
<td className={`${COLUMNS} tabular-nums`}>{hours ?? "—"}</td>
<td
aria-label={figures.estimate ? "Net, estimated" : "Net"}
className={`pr-3 text-right text-[15px] font-bold whitespace-nowrap tabular-nums ${
figures.ready ? "text-earned" : "text-ink-300"
}`}
>
{netLabel(figures)}
</td>
<td
aria-label="Credits per hour"
className="pr-3 text-right text-[13.5px] whitespace-nowrap text-ink-300 tabular-nums"
>
{figures.perHour === null ? "—" : money(figures.perHour)}
</td>
{onSelect && (
<td className="w-0 p-0">
{/* The whole row opens the job: the button stretches over it. */}
<button
type="button"
aria-label="Dispatch"
onClick={() => onSelect(contract)}
className="absolute inset-0 rounded-sm hover:bg-white/5 focus-visible:-outline-offset-2"
/>
</td>
)}
</tr>
);
};
return (
<div className="overflow-hidden rounded-2xl border border-glass-line">
<table className="w-full border-collapse text-left">
<caption className="sr-only">Jobs here, best first</caption>
<thead className="bg-black/20">
<tr>
<th scope="col" className="py-0 pl-3 text-xs font-semibold text-ink-300">
Job
</th>
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
Shipper
</th>
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
Weight
</th>
<th scope="col" className={`${COLUMNS} text-xs font-semibold`}>
Hours
</th>
{header("net", "Net", "w-20")}
{header("rate", "cr/h", "w-16")}
{onSelect && (
<td className="w-0 p-0">
<span className="sr-only">Open</span>
</td>
)}
</tr>
</thead>
<tbody>
{ready.map(row)}
{blocked.length > 0 && (
<tr className="border-t border-glass-line bg-black/20">
<th
scope="colgroup"
colSpan={onSelect ? 7 : 6}
className="px-3 py-1.5 text-xs font-semibold text-ink-300"
>
Can&apos;t take yet
</th>
</tr>
)}
{blocked.map(row)}
</tbody>
</table>
</div>
);
}

View File

@ -5,22 +5,14 @@ import { useSearchParams } from "react-router";
import { useApi } from "../api/context";
import { serverNow, useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
import {
formatClock,
formatCredits,
formatGameHours,
formatMillionKm,
locationName,
skillName,
} from "../format";
import { distanceAt, jobGameHours, notReady, readyShips } from "../game";
import { formatClock, locationName, skillName } from "../format";
import { notReady, readyShips } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet";
import { JobsTable } from "./JobsTable";
import { DeadheadPicker } from "./Fleet";
import { content } from "@deadhead/content";
import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui";
import { ClassChip, goodName, PriorityChip } from "../components/Goods";
import { Art, Button, Icon, Panel } from "../ui";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
@ -116,14 +108,14 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
type="button"
aria-pressed={id === locationId}
onClick={() => setParams({ location: id }, { replace: true })}
className="flex shrink-0 items-center gap-2 rounded-full border border-space-600 bg-space-900/70 py-1 pr-3 pl-1 font-display text-sm font-semibold text-ink-300 backdrop-blur transition hover:border-space-500 hover:text-ink-50 aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-100 aria-pressed:shadow-glow-amber"
className="flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border border-glass-line py-1 pr-3 pl-1 text-[13px] font-medium text-ink-300 transition hover:text-ink-50 aria-pressed:border-white/50 aria-pressed:bg-white/14 aria-pressed:text-ink-50"
>
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
{locationName(id)}
{ready > 0 && (
<span
aria-label={`${ready} ${ready === 1 ? "ship" : "ships"} ready`}
className="grid h-5 min-w-5 place-items-center rounded-full bg-cyan-400/20 px-1.5 text-xs text-cyan-200 tabular-nums ring-1 ring-cyan-400/40"
className="grid h-5 min-w-5 place-items-center rounded-full bg-action/20 px-1.5 text-xs font-semibold text-action tabular-nums"
>
{ready}
</span>
@ -174,13 +166,10 @@ function LocationBoard({
);
}
const refresh = (
<p
aria-label="Board refresh"
className="flex items-center gap-1.5 font-display text-xs text-ink-400"
>
<Icon name="clock" className="size-3.5 text-cyan-300" />
<p aria-label="Board refresh" className="flex items-center gap-1.5 text-xs text-ink-300">
<Icon name="clock" className="size-3.5" />
New contracts in{" "}
<span className="text-cyan-200">
<span className="font-semibold text-ink-100 tabular-nums">
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
{fresh.size > 0 && (
@ -214,109 +203,17 @@ function LocationBoard({
<div className="space-y-3">
{refresh}
{stuck}
<ul className="grid gap-3 @2xl:grid-cols-2">
{board.data.contracts.map((contract) => {
const requirements = requirementsText(contract);
return (
<Panel
as="li"
key={contract.id}
tone={contract.firstContact ? "amber" : "default"}
className={`flex flex-col gap-2 ${fresh.has(contract.id) ? "animate-rise" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-display text-sm font-semibold text-ink-300">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
</span>
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
{formatCredits(contract.payout)}
</span>
</div>
<div>
<h3 className="font-display text-lg leading-tight font-bold">
{goodName(contract)}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
{fresh.has(contract.id) && (
<Pill tone="cyan" icon="star">
New
</Pill>
)}
<ClassChip contract={contract} />
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
)}
{contract.firstContact && (
<Pill tone="amber" icon="star">
First contact
</Pill>
)}
{pilotsOnTheirOwn &&
!contract.firstContact &&
now < contract.offeredAt + firstLookMs && (
<Pill tone="amber" icon="clock">
<span aria-label="Yours for">
Yours for{" "}
<Countdown
to={contract.offeredAt + firstLookMs}
format={formatClock}
done="now"
/>
</span>
</Pill>
)}
</div>
</div>
<RouteStrip
originId={contract.originId}
destinationId={contract.destinationId}
size="sm"
/>
<div className="flex items-center gap-1.5 text-sm text-ink-300">
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
<span>
{contract.shipper} · <span>{contract.massTonnes} t</span> ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
<TripHours contract={contract} />
</span>
</div>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
<span className="text-ink-400">
<span className={requirements ? "text-amber-200" : "text-ink-300"}>
{requirements ? `Needs ${requirements}` : "No skills needed"}
</span>{" "}
· expires in <Countdown to={contract.expiresAt} done="expired" />
</span>
{onSelect && (
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
Dispatch
</Button>
)}
</div>
</Panel>
);
})}
</ul>
<JobsTable
contracts={board.data.contracts}
fresh={fresh}
firstLookMs={firstLookMs}
pilotsOnTheirOwn={pilotsOnTheirOwn}
onSelect={onSelect}
/>
</div>
);
}
/** " · 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
* pilots here resting (with who wakes first), or none crewed for the player.
@ -329,8 +226,8 @@ function NoneReady({ locationId, now }: { locationId: string; now: number }) {
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
return (
<p className="flex items-center gap-2 rounded-lg border border-space-700 bg-space-900/60 px-3 py-2 text-sm text-ink-300">
<Icon name={waking ? "rest" : "alert"} className="size-4 shrink-0 text-amber-300" />
<p className="flex items-center gap-2 rounded-xl border border-glass-line px-3 py-2 text-sm text-ink-300">
<Icon name={waking ? "rest" : "alert"} className="size-4 shrink-0" />
{idle.length === 0 ? (
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
) : waking ? (

View File

@ -0,0 +1,82 @@
import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema";
import { contractTripGameHours, permanentFailure } from "@deadhead/sim";
import { locationName } from "../format";
import { jobMoney, shipStatsOf } from "../game";
import { pairOptions } from "./DispatchSheet";
/** What a job row shows (decision 0.5.169). */
export interface JobFigures {
/** Payout minus fuel minus the pilot's share, as in the dispatch sheet. Null: no ship could. */
net: number | null;
/** Net per game hour of the job. */
perHour: number | null;
/** The job's game hours for the ship the figures are for. */
hours: number | null;
/** No ship can take it now: figures are for the best ship the company owns. */
estimate: boolean;
/** A ship can take it now (refuelling first counts: the sheet offers it). */
ready: boolean;
/** Why it can't go now, or what it needs first. */
note: string | null;
}
const perHourOf = (net: number, hours: number) => (hours > 0 ? Math.round(net / hours) : null);
/** The best ship's figures for a job: one that can take it now, else the best the fleet has. */
export function jobFigures(state: PlayerState, contract: Contract, now: number): JobFigures {
const options = pairOptions(state, contract, now);
const able = options.filter((o) => o.problems.length === 0 || o.shortOfFuel);
if (able.length > 0) {
const best = able.reduce((a, b) => (b.netProfit > a.netProfit ? b : a));
const ship = state.ships.find((s) => s.id === best.shipId)!;
const hours = contractTripGameHours(shipStatsOf(ship), contract, content);
return {
net: best.netProfit,
perHour: perHourOf(best.netProfit, hours),
hours,
estimate: false,
ready: true,
note: able.every((o) => o.shortOfFuel) ? "Refuel first" : null,
};
}
// A problem that time will fix says so; a ship here that could never take it isn't the job's
// problem (CR-019: the board only shows jobs the fleet can complete).
const waiting = options.find((o) => {
const ship = state.ships.find((s) => s.id === o.shipId)!;
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
if (!pilot) return true;
const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades };
return permanentFailure(contract, member, undefined, content) === null;
});
const note =
waiting?.problems[0] ??
(options.length > 0
? `Needs another ship at ${locationName(contract.originId)}`
: `No idle ship at ${locationName(contract.originId)}`);
const candidates = 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 [
{
net: jobMoney(ship, pilot, contract).netProfit,
hours: contractTripGameHours(shipStatsOf(ship), contract, content),
},
];
});
if (candidates.length === 0) {
return { net: null, perHour: null, hours: null, estimate: false, ready: false, note };
}
const best = candidates.reduce((a, b) => (b.net > a.net ? b : a));
return {
net: best.net,
perHour: perHourOf(best.net, best.hours),
hours: best.hours,
estimate: true,
ready: false,
note,
};
}

View File

@ -11,6 +11,8 @@ import type { Camera } from "../map/camera";
export type ClockFormat = "locale" | "12h" | "24h";
export type CountdownFormat = "real" | "game" | "both";
export type JobSort = "net" | "rate";
export interface Preferences {
clock: ClockFormat;
countdown: CountdownFormat;
@ -20,6 +22,8 @@ export interface Preferences {
dismissedTips: string[];
/** The map view to restore; null for the default view. */
mapCamera: Camera | null;
/** How the jobs table sorts (decision 0.5.169). */
jobSort: JobSort;
/** Local notifications on iOS and Android (decision 0.5.91). */
notifications: boolean;
}
@ -30,6 +34,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
reducedMotion: null,
dismissedTips: [],
mapCamera: null,
jobSort: "net",
notifications: true,
};
@ -67,6 +72,7 @@ export function loadPreferences(storage: PreferenceStorage | null): Preferences
dismissedTips: Array.isArray(saved.dismissedTips)
? saved.dismissedTips.filter((t): t is string => typeof t === "string")
: [],
jobSort: saved.jobSort === "rate" ? "rate" : "net",
mapCamera:
saved.mapCamera &&
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
@ -111,7 +117,7 @@ export function createPreferencesStore(
): UseBoundStore<StoreApi<PreferencesStore>> {
return create<PreferencesStore>((setState, getState) => {
const persist = () => {
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, notifications } =
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, jobSort, notifications } =
getState();
savePreferences(storage, {
clock,
@ -119,6 +125,7 @@ export function createPreferencesStore(
reducedMotion,
dismissedTips,
mapCamera,
jobSort,
notifications,
});
};

View File

@ -83,7 +83,7 @@ describe("board cards and the empty board (Step 19.4, decision 0.5.114)", () =>
}),
]);
for (const payout of ["9,001 cr", "9,002 cr", "9,003 cr"]) {
const card = (await screen.findByText(payout)).closest("li")!;
const card = (await screen.findByText(payout)).closest("tr")!;
expect(card).not.toHaveTextContent(/too heavy/i);
expect(card).not.toHaveTextContent(/too slow/i);
expect(card).not.toHaveTextContent(/skill too low/i);

View File

@ -91,7 +91,7 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
it("counts down the player's first look, then disappears", async () => {
show(owner([pilot("p1", "Ada")]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31");
act(() => {
@ -103,13 +103,13 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
it("isn't shown without Independent pilots", async () => {
show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
it("isn't shown on a first contact, which is always the player's", async () => {
show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
});

View File

@ -49,15 +49,16 @@ function render(contracts: Contract[], ships: Ship[]) {
);
}
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!;
describe("contract card (CR-007)", () => {
it("shows the good, its class and tier, the shipper, mass and requirement", async () => {
render([job({})], [ship("courier")]);
const c = await card("3,210 cr");
expect(within(c).getByRole("heading", { name: "Medical Supplies" })).toBeInTheDocument();
expect(within(c).getByText("Medical Supplies")).toBeInTheDocument();
expect(c).toHaveTextContent("High-Value · T1");
expect(c).toHaveTextContent("Meridian Health · 6 t");
expect(c).toHaveTextContent("Meridian Health");
expect(c).toHaveTextContent("6 t");
expect(c).toHaveTextContent("Needs Expensive Cargo 1");
expect(c).not.toHaveTextContent("Priority");
});
@ -76,7 +77,7 @@ describe("contract card (CR-007)", () => {
);
const c = await card("3,210 cr");
// A Priority good can have its own name.
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
expect(within(c).getByText("Emergency Parts")).toBeInTheDocument();
// The window follows the countdown preference: real time by default (CR-011).
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
expect(c).not.toHaveTextContent("Too slow");

View File

@ -104,7 +104,7 @@ describe("display options in the app (Step 10K.3)", () => {
}),
"/contracts?location=earth",
);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(card).toHaveTextContent("expires in 4m 16s");
act(() => usePreferences.getState().set({ countdown: "both" }));
expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h");

View File

@ -40,7 +40,7 @@ function render(boards: Contract[][], periodMs = 600_000) {
const remember = (ids: string[]) =>
localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids));
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!;
describe("visible board refresh (CR-008)", () => {
it("tags contracts that weren't on the last board seen here, and says so", async () => {

View File

@ -1,7 +1,7 @@
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 { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatGameHours } from "../src/format";
@ -84,9 +84,10 @@ describe("job length on the board", () => {
);
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();
const row = time.closest("tr")!;
expect(row).toHaveTextContent("Pacific Orbital Logistics");
expect(row).toHaveTextContent("0.38M km");
expect(time.parentElement).toHaveTextContent(`12 t · ${hours}`);
});
it("times the job for the fastest ship that could take it, preferring one at the origin", () => {

View File

@ -0,0 +1,126 @@
import type { Contract, Ship } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { usePreferences } from "../src/state/preferences";
import { pairOptions } from "../src/screens/DispatchSheet";
import { jobFigures } from "../src/screens/jobFigures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.now();
const job = (overrides: Partial<Contract>): Contract =>
({
id: "j1",
locationId: "earth",
originId: "earth",
destinationId: "moon",
goodId: "consumer-goods",
cargoTypeId: "standard",
massTonnes: 10,
payout: 2000,
shipper: "Pacific Orbital Logistics",
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
priority: false,
deliveryWindowGameHours: null,
tier: null,
requiredSkill: null,
requiredLongHaulLevel: 0,
firstContact: false,
...overrides,
}) as Contract;
const ship = (id: string, overrides: Partial<Ship> = {}): Ship => ({
id,
modelId: "courier",
name: id,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
...overrides,
});
function owner(ships: Ship[]) {
const base = samplePlayerState();
return samplePlayerState({
tutorial: null,
bays: [{ id: "b", locationId: "earth" }],
ships,
pilots: [{ ...base.pilots[0]!, assignedShipId: ships[0]?.id ?? null }],
});
}
function show(contracts: Contract[], ships: Ship[]) {
renderApp(
fakeApi({
getPlayerState: () => owner(ships),
getContractBoard: () => ({ contracts, periodEndsAt: NOW + 3_600_000 }),
}),
"/contracts?location=earth",
);
}
const rowNames = () =>
screen
.getAllByRole("row")
.map((r) => r.getAttribute("aria-label"))
.filter(Boolean);
describe("jobs table (decision 0.5.169)", () => {
beforeEach(() => usePreferences.setState({ jobSort: "net" }));
it("shows the same net as the dispatch sheet", () => {
const state = owner([ship("s1")]);
const contract = job({});
const figures = jobFigures(state, contract, NOW);
expect(figures.ready).toBe(true);
expect(figures.estimate).toBe(false);
expect(figures.net).toBe(pairOptions(state, contract, NOW)[0]!.netProfit);
expect(figures.perHour).toBe(Math.round(figures.net! / figures.hours!));
});
it("sorts by net, then by credits per hour, and remembers the choice", async () => {
const big = job({
id: "big",
goodId: "packaged-food",
payout: 3000,
destinationId: "moon",
massTonnes: 18,
});
const small = job({ id: "small", goodId: "mail", payout: 2000, massTonnes: 1 });
show([small, big], [ship("s1")]);
await screen.findByRole("table");
expect(rowNames()).toEqual(["Packaged Food", "Mail & Parcels"]);
const net = screen.getByRole("columnheader", { name: /Net/ });
expect(net).toHaveAttribute("aria-sort", "descending");
await userEvent.click(within(screen.getByRole("table")).getByRole("button", { name: /cr\/h/ }));
expect(screen.getByRole("columnheader", { name: /cr\/h/ })).toHaveAttribute(
"aria-sort",
"descending",
);
expect(net).toHaveAttribute("aria-sort", "none");
expect(usePreferences.getState().jobSort).toBe("rate");
});
it("keeps jobs it can't take below, with the reason and an estimate", async () => {
// The only ship is away: the figures are an estimate, and the row says why.
const away = ship("s1", { status: "in-flight", locationId: null, dispatchId: "d" });
show([job({})], [away]);
await screen.findByRole("table");
expect(screen.getByText("Can't take yet")).toBeInTheDocument();
const row = screen.getByRole("row", { name: "Consumer Goods" });
expect(row).toHaveTextContent("No idle ship at Earth");
expect(within(row).getByLabelText("Net, estimated")).toHaveTextContent(/^≈/);
});
it("opens the job from its row", async () => {
show([job({})], [ship("s1")]);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
});
});

View File

@ -156,21 +156,20 @@ describe("location contract boards", () => {
renderApp(boardApi(), "/contracts?location=earth");
await screen.findByText("1,999 cr");
const items = screen.getAllByRole("listitem");
const [plain, special] = items;
// CR-007 card: good headline, class chip, shipper · mass · distance.
expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument();
const plain = screen.getByRole("row", { name: "Consumer Goods" });
const special = screen.getByRole("row", { name: "Machine Parts" });
// CR-007: good, class chip, shipper, mass and distance.
expect(plain).toHaveTextContent("Standard");
expect(plain).toHaveTextContent("Pacific Orbital Logistics · 50 t");
expect(plain).toHaveTextContent("Pacific Orbital Logistics");
expect(plain).toHaveTextContent("50 t");
expect(plain).toHaveTextContent("No skills needed");
expect(plain).toHaveTextContent("expires in 30m 00s");
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
expect(within(plain).queryByText("First contact")).not.toBeInTheDocument();
expect(special).toHaveTextContent("Earth → Mars");
expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument();
expect(special).toHaveTextContent("To Mars");
expect(special).toHaveTextContent("Heavy · T2");
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
expect(special).toHaveTextContent(/M km/);
expect(within(special!).getByText("First contact")).toBeInTheDocument();
expect(within(special).getByText("First contact")).toBeInTheDocument();
});
});