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:
parent
9aada41e8f
commit
d279ac4ec0
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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't take yet
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
{blocked.map(row)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue