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.
|
// The Lighter's pair can take it in the dispatch sheet.
|
||||||
await page.goto("/contracts?location=moon");
|
await page.goto("/contracts?location=moon");
|
||||||
const card = page
|
const card = page
|
||||||
.getByRole("listitem")
|
.getByRole("row")
|
||||||
.filter({ hasText: content.good(job!.goodId)!.name })
|
.filter({ hasText: content.good(job!.goodId)!.name })
|
||||||
.filter({ hasText: `${job!.massTonnes} t` });
|
.filter({ hasText: `${job!.massTonnes} t` });
|
||||||
await card.getByRole("button", { name: "Dispatch" }).first().click();
|
await card.getByRole("button", { name: "Dispatch" }).first().click();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
MS_PER_HOUR,
|
MS_PER_HOUR,
|
||||||
routeDistance,
|
routeDistance,
|
||||||
travelTimeGameHours,
|
travelTimeGameHours,
|
||||||
|
pilotShare,
|
||||||
tripFuel,
|
tripFuel,
|
||||||
type EffectiveStats,
|
type EffectiveStats,
|
||||||
type ShipState,
|
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
|
* 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,
|
* player's fastest ship that could take it: one at the job's origin if any,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export function Contracts() {
|
||||||
<Screen>
|
<Screen>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title="Jobs"
|
title="Jobs"
|
||||||
eyebrow={state.tutorial ? content.tutorialCompanyName : "Freight market"}
|
eyebrow={state.tutorial ? content.tutorialCompanyName : undefined}
|
||||||
icon="jobs"
|
icon="jobs"
|
||||||
/>
|
/>
|
||||||
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,7 @@ import { formatGameHours, locationName, shipModelName } from "../format";
|
||||||
import { lowFuel } from "../game";
|
import { lowFuel } from "../game";
|
||||||
import { ShopRest } from "../components/ShopRest";
|
import { ShopRest } from "../components/ShopRest";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
import {
|
import { Art, buttonClass, Eyebrow, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
|
||||||
Art,
|
|
||||||
buttonClass,
|
|
||||||
Eyebrow,
|
|
||||||
Icon,
|
|
||||||
Meter,
|
|
||||||
Panel,
|
|
||||||
Pill,
|
|
||||||
Screen,
|
|
||||||
ScreenHeader,
|
|
||||||
StatTile,
|
|
||||||
} from "../ui";
|
|
||||||
|
|
||||||
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
||||||
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
|
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,10 @@ import {
|
||||||
contractTripGameHours,
|
contractTripGameHours,
|
||||||
isInShop,
|
isInShop,
|
||||||
rangeFrom,
|
rangeFrom,
|
||||||
pilotShare,
|
|
||||||
dutyAfterTrip,
|
dutyAfterTrip,
|
||||||
LONG_HAUL_SKILL_ID,
|
LONG_HAUL_SKILL_ID,
|
||||||
needsRest,
|
needsRest,
|
||||||
realToGame,
|
realToGame,
|
||||||
tripFuel,
|
|
||||||
tripGameHours,
|
tripGameHours,
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
wouldStrand,
|
wouldStrand,
|
||||||
|
|
@ -31,7 +29,7 @@ import {
|
||||||
skillName,
|
skillName,
|
||||||
} from "../format";
|
} from "../format";
|
||||||
import { requirementsText } from "./LocationBoards";
|
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 { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { RouteStrip } from "../components/RouteStrip";
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
import { Art, Button, Sheet } from "../ui";
|
import { Art, Button, Sheet } from "../ui";
|
||||||
|
|
@ -221,17 +219,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
contract.destinationId,
|
contract.destinationId,
|
||||||
contract.massTonnes,
|
contract.massTonnes,
|
||||||
);
|
);
|
||||||
const fuelFu = tripFuel(
|
const { fuelFu, fuelCost, pilotPay, netProfit } = jobMoney(ship, pilot, contract);
|
||||||
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;
|
|
||||||
|
|
||||||
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
|
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
|
||||||
let shortOfFuel = false;
|
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 },
|
fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu },
|
||||||
shortOfFuel,
|
shortOfFuel,
|
||||||
pilotPay,
|
pilotPay,
|
||||||
netProfit: contract.payout - fuelCost - pilotPay,
|
netProfit,
|
||||||
arrivalAt: now + tripMs,
|
arrivalAt: now + tripMs,
|
||||||
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
|
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
|
||||||
priority:
|
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 { 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 {
|
import { formatClock, locationName, skillName } from "../format";
|
||||||
formatClock,
|
import { notReady, readyShips } from "../game";
|
||||||
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 { JobsTable } from "./JobsTable";
|
||||||
import { DeadheadPicker } from "./Fleet";
|
import { DeadheadPicker } from "./Fleet";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { RouteStrip } from "../components/RouteStrip";
|
import { Art, Button, Icon, Panel } from "../ui";
|
||||||
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
|
||||||
import { ClassChip, goodName, PriorityChip } from "../components/Goods";
|
|
||||||
|
|
||||||
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
||||||
|
|
||||||
|
|
@ -116,14 +108,14 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) =
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={id === locationId}
|
aria-pressed={id === locationId}
|
||||||
onClick={() => setParams({ location: id }, { replace: true })}
|
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}
|
{sprite ? <Art sprite={sprite} className="size-7" /> : null}
|
||||||
{locationName(id)}
|
{locationName(id)}
|
||||||
{ready > 0 && (
|
{ready > 0 && (
|
||||||
<span
|
<span
|
||||||
aria-label={`${ready} ${ready === 1 ? "ship" : "ships"} ready`}
|
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}
|
{ready}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -174,13 +166,10 @@ function LocationBoard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const refresh = (
|
const refresh = (
|
||||||
<p
|
<p aria-label="Board refresh" className="flex items-center gap-1.5 text-xs text-ink-300">
|
||||||
aria-label="Board refresh"
|
<Icon name="clock" className="size-3.5" />
|
||||||
className="flex items-center gap-1.5 font-display text-xs text-ink-400"
|
|
||||||
>
|
|
||||||
<Icon name="clock" className="size-3.5 text-cyan-300" />
|
|
||||||
New contracts in{" "}
|
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" />
|
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
|
||||||
</span>
|
</span>
|
||||||
{fresh.size > 0 && (
|
{fresh.size > 0 && (
|
||||||
|
|
@ -214,109 +203,17 @@ function LocationBoard({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{refresh}
|
{refresh}
|
||||||
{stuck}
|
{stuck}
|
||||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
<JobsTable
|
||||||
{board.data.contracts.map((contract) => {
|
contracts={board.data.contracts}
|
||||||
const requirements = requirementsText(contract);
|
fresh={fresh}
|
||||||
return (
|
firstLookMs={firstLookMs}
|
||||||
<Panel
|
pilotsOnTheirOwn={pilotsOnTheirOwn}
|
||||||
as="li"
|
onSelect={onSelect}
|
||||||
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>
|
|
||||||
</div>
|
</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
|
* 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.
|
||||||
|
|
@ -329,8 +226,8 @@ function NoneReady({ locationId, now }: { locationId: string; now: number }) {
|
||||||
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
|
.flatMap((r) => (r?.reason === "resting" ? [r.pilot] : []))
|
||||||
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
|
.sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0];
|
||||||
return (
|
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">
|
<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 text-amber-300" />
|
<Icon name={waking ? "rest" : "alert"} className="size-4 shrink-0" />
|
||||||
{idle.length === 0 ? (
|
{idle.length === 0 ? (
|
||||||
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
|
"No idle ships here. Deadhead one in from the Fleet screen to take these jobs."
|
||||||
) : waking ? (
|
) : 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 ClockFormat = "locale" | "12h" | "24h";
|
||||||
export type CountdownFormat = "real" | "game" | "both";
|
export type CountdownFormat = "real" | "game" | "both";
|
||||||
|
|
||||||
|
export type JobSort = "net" | "rate";
|
||||||
|
|
||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
clock: ClockFormat;
|
clock: ClockFormat;
|
||||||
countdown: CountdownFormat;
|
countdown: CountdownFormat;
|
||||||
|
|
@ -20,6 +22,8 @@ export interface Preferences {
|
||||||
dismissedTips: string[];
|
dismissedTips: string[];
|
||||||
/** The map view to restore; null for the default view. */
|
/** The map view to restore; null for the default view. */
|
||||||
mapCamera: Camera | null;
|
mapCamera: Camera | null;
|
||||||
|
/** How the jobs table sorts (decision 0.5.169). */
|
||||||
|
jobSort: JobSort;
|
||||||
/** Local notifications on iOS and Android (decision 0.5.91). */
|
/** Local notifications on iOS and Android (decision 0.5.91). */
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +34,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
|
||||||
reducedMotion: null,
|
reducedMotion: null,
|
||||||
dismissedTips: [],
|
dismissedTips: [],
|
||||||
mapCamera: null,
|
mapCamera: null,
|
||||||
|
jobSort: "net",
|
||||||
notifications: true,
|
notifications: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -67,6 +72,7 @@ export function loadPreferences(storage: PreferenceStorage | null): Preferences
|
||||||
dismissedTips: Array.isArray(saved.dismissedTips)
|
dismissedTips: Array.isArray(saved.dismissedTips)
|
||||||
? saved.dismissedTips.filter((t): t is string => typeof t === "string")
|
? saved.dismissedTips.filter((t): t is string => typeof t === "string")
|
||||||
: [],
|
: [],
|
||||||
|
jobSort: saved.jobSort === "rate" ? "rate" : "net",
|
||||||
mapCamera:
|
mapCamera:
|
||||||
saved.mapCamera &&
|
saved.mapCamera &&
|
||||||
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
|
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
|
||||||
|
|
@ -111,7 +117,7 @@ export function createPreferencesStore(
|
||||||
): UseBoundStore<StoreApi<PreferencesStore>> {
|
): UseBoundStore<StoreApi<PreferencesStore>> {
|
||||||
return create<PreferencesStore>((setState, getState) => {
|
return create<PreferencesStore>((setState, getState) => {
|
||||||
const persist = () => {
|
const persist = () => {
|
||||||
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, notifications } =
|
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, jobSort, notifications } =
|
||||||
getState();
|
getState();
|
||||||
savePreferences(storage, {
|
savePreferences(storage, {
|
||||||
clock,
|
clock,
|
||||||
|
|
@ -119,6 +125,7 @@ export function createPreferencesStore(
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
dismissedTips,
|
dismissedTips,
|
||||||
mapCamera,
|
mapCamera,
|
||||||
|
jobSort,
|
||||||
notifications,
|
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"]) {
|
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 heavy/i);
|
||||||
expect(card).not.toHaveTextContent(/too slow/i);
|
expect(card).not.toHaveTextContent(/too slow/i);
|
||||||
expect(card).not.toHaveTextContent(/skill too low/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 () => {
|
it("counts down the player's first look, then disappears", async () => {
|
||||||
show(owner([pilot("p1", "Ada")]), [job()]);
|
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");
|
expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -103,13 +103,13 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
|
||||||
|
|
||||||
it("isn't shown without Independent pilots", async () => {
|
it("isn't shown without Independent pilots", async () => {
|
||||||
show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]);
|
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();
|
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isn't shown on a first contact, which is always the player's", async () => {
|
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" })]);
|
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();
|
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)", () => {
|
describe("contract card (CR-007)", () => {
|
||||||
it("shows the good, its class and tier, the shipper, mass and requirement", async () => {
|
it("shows the good, its class and tier, the shipper, mass and requirement", async () => {
|
||||||
render([job({})], [ship("courier")]);
|
render([job({})], [ship("courier")]);
|
||||||
const c = await card("3,210 cr");
|
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("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).toHaveTextContent("Needs Expensive Cargo 1");
|
||||||
expect(c).not.toHaveTextContent("Priority");
|
expect(c).not.toHaveTextContent("Priority");
|
||||||
});
|
});
|
||||||
|
|
@ -76,7 +77,7 @@ describe("contract card (CR-007)", () => {
|
||||||
);
|
);
|
||||||
const c = await card("3,210 cr");
|
const c = await card("3,210 cr");
|
||||||
// A Priority good can have its own name.
|
// 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).
|
// The window follows the countdown preference: real time by default (CR-011).
|
||||||
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
|
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
|
||||||
expect(c).not.toHaveTextContent("Too slow");
|
expect(c).not.toHaveTextContent("Too slow");
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ describe("display options in the app (Step 10K.3)", () => {
|
||||||
}),
|
}),
|
||||||
"/contracts?location=earth",
|
"/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");
|
expect(card).toHaveTextContent("expires in 4m 16s");
|
||||||
act(() => usePreferences.getState().set({ countdown: "both" }));
|
act(() => usePreferences.getState().set({ countdown: "both" }));
|
||||||
expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h");
|
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[]) =>
|
const remember = (ids: string[]) =>
|
||||||
localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids));
|
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)", () => {
|
describe("visible board refresh (CR-008)", () => {
|
||||||
it("tags contracts that weren't on the last board seen here, and says so", async () => {
|
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 { content } from "@deadhead/content";
|
||||||
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
||||||
import { contractTripGameHours, effectiveStats } from "@deadhead/sim";
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatGameHours } from "../src/format";
|
import { formatGameHours } from "../src/format";
|
||||||
|
|
@ -84,9 +84,10 @@ describe("job length on the board", () => {
|
||||||
);
|
);
|
||||||
const hours = formatGameHours(hoursFor("courier", contract));
|
const hours = formatGameHours(hoursFor("courier", contract));
|
||||||
const time = await screen.findByLabelText(`Takes ${hours} of game time`);
|
const time = await screen.findByLabelText(`Takes ${hours} of game time`);
|
||||||
const line = time.parentElement!;
|
const row = time.closest("tr")!;
|
||||||
expect(line).toHaveTextContent(`Pacific Orbital Logistics · 12 t · 0.38M km · ${hours}`);
|
expect(row).toHaveTextContent("Pacific Orbital Logistics");
|
||||||
expect(within(line).getByText(/12 t/)).toBeInTheDocument();
|
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", () => {
|
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");
|
renderApp(boardApi(), "/contracts?location=earth");
|
||||||
|
|
||||||
await screen.findByText("1,999 cr");
|
await screen.findByText("1,999 cr");
|
||||||
const items = screen.getAllByRole("listitem");
|
const plain = screen.getByRole("row", { name: "Consumer Goods" });
|
||||||
const [plain, special] = items;
|
const special = screen.getByRole("row", { name: "Machine Parts" });
|
||||||
// CR-007 card: good headline, class chip, shipper · mass · distance.
|
// CR-007: good, class chip, shipper, mass and distance.
|
||||||
expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument();
|
|
||||||
expect(plain).toHaveTextContent("Standard");
|
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("No skills needed");
|
||||||
expect(plain).toHaveTextContent("expires in 30m 00s");
|
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(special).toHaveTextContent("To Mars");
|
||||||
expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument();
|
|
||||||
expect(special).toHaveTextContent("Heavy · T2");
|
expect(special).toHaveTextContent("Heavy · T2");
|
||||||
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
||||||
expect(special).toHaveTextContent(/M km/);
|
expect(special).toHaveTextContent(/M km/);
|
||||||
expect(within(special!).getByText("First contact")).toBeInTheDocument();
|
expect(within(special).getByText("First contact")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue