Step 10G.8: contract cards headline the good, with class, Priority and too-slow chips
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4f8ee4a06d
commit
8945972372
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { Contract, PlayerState } from "@deadhead/schema";
|
||||||
|
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cargoName, formatDuration } from "../format";
|
||||||
|
import { Pill, type Tone } from "../ui";
|
||||||
|
|
||||||
|
/** The good's name as shown on a contract: a Priority good may have its own name (CR-007). */
|
||||||
|
export function goodName(contract: Pick<Contract, "goodId" | "priority">): string {
|
||||||
|
const good = content.good(contract.goodId);
|
||||||
|
if (!good) return contract.goodId;
|
||||||
|
return contract.priority && good.priorityName ? good.priorityName : good.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A delivery window in game hours, e.g. "5h" or "2.5h". */
|
||||||
|
export function formatWindow(hours: number): string {
|
||||||
|
const rounded = hours < 10 ? Math.round(hours * 10) / 10 : Math.round(hours);
|
||||||
|
return `${rounded}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real time for a span of game hours, e.g. "5m 12s". */
|
||||||
|
export function realTimeOf(gameHours: number): string {
|
||||||
|
return formatDuration((gameHours * MS_PER_HOUR) / content.config.timeCompression);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASS_TONES: Record<string, Tone> = {
|
||||||
|
heavy: "cyan",
|
||||||
|
"high-value": "emerald",
|
||||||
|
dangerous: "rose",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "HIGH-VALUE · T1": the cargo class, with the tier when it has one. */
|
||||||
|
export function ClassChip({ contract }: { contract: Pick<Contract, "cargoTypeId" | "tier"> }) {
|
||||||
|
return (
|
||||||
|
<Pill tone={CLASS_TONES[contract.cargoTypeId] ?? "default"}>
|
||||||
|
{cargoName(contract.cargoTypeId)}
|
||||||
|
{contract.tier > 0 && ` · T${contract.tier}`}
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "PRIORITY · within 5h" (game hours). Tap to switch to real time; hovering
|
||||||
|
* shows both.
|
||||||
|
*/
|
||||||
|
export function PriorityChip({ windowHours }: { windowHours: number }) {
|
||||||
|
const [real, setReal] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReal(!real)}
|
||||||
|
title={`Within ${formatWindow(windowHours)} game time, about ${realTimeOf(windowHours)} real time`}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<Pill tone="amber" icon="bolt">
|
||||||
|
Priority · within {real ? realTimeOf(windowHours) : formatWindow(windowHours)}
|
||||||
|
</Pill>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a Priority contract has idle ships at its origin and none of them
|
||||||
|
* can make the window (CR-007). Display only; the sim decides at dispatch.
|
||||||
|
*/
|
||||||
|
export function tooSlowForAll(state: PlayerState, contract: Contract, now: number): boolean {
|
||||||
|
if (!contract.priority || contract.deliveryWindowGameHours === null) return false;
|
||||||
|
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId);
|
||||||
|
if (idle.length === 0) return false;
|
||||||
|
return idle.every((ship) => {
|
||||||
|
const model = content.shipModel(ship.modelId);
|
||||||
|
return (
|
||||||
|
!model ||
|
||||||
|
contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours!
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,7 @@ 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, formatCredits, formatMillionKm, locationName, skillName } from "../format";
|
||||||
cargoName,
|
|
||||||
formatClock,
|
|
||||||
formatCredits,
|
|
||||||
formatMillionKm,
|
|
||||||
locationName,
|
|
||||||
skillName,
|
|
||||||
} from "../format";
|
|
||||||
import { distanceAt } from "../game";
|
import { distanceAt } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { pairOptions } from "./DispatchSheet";
|
import { pairOptions } from "./DispatchSheet";
|
||||||
|
|
@ -20,6 +13,7 @@ import { DeadheadPicker } from "./Fleet";
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { RouteStrip } from "../components/RouteStrip";
|
import { RouteStrip } from "../components/RouteStrip";
|
||||||
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
import { Art, Button, Icon, Panel, Pill } from "../ui";
|
||||||
|
import { ClassChip, goodName, PriorityChip, tooSlowForAll } from "../components/Goods";
|
||||||
|
|
||||||
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
||||||
|
|
||||||
|
|
@ -185,18 +179,39 @@ function LocationBoard({
|
||||||
className="flex flex-col gap-2"
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="font-display font-bold tracking-wide">
|
<span className="font-display text-sm font-semibold tracking-wide text-ink-300">
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
{contract.firstContact && (
|
|
||||||
<Pill tone="amber" icon="star" className="ml-2 align-middle">
|
|
||||||
First contact
|
|
||||||
</Pill>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
|
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
|
||||||
{formatCredits(contract.payout)}
|
{formatCredits(contract.payout)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display text-lg leading-tight font-bold tracking-wide">
|
||||||
|
{goodName(contract)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
<ClassChip contract={contract} />
|
||||||
|
{contract.priority && contract.deliveryWindowGameHours !== null && (
|
||||||
|
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
|
||||||
|
)}
|
||||||
|
{contract.firstContact && (
|
||||||
|
<Pill tone="amber" icon="star">
|
||||||
|
First contact
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
{tooHeavy && (
|
||||||
|
<Pill tone="rose" icon="alert">
|
||||||
|
Too heavy
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
{tooSlowForAll(state, contract, now) && (
|
||||||
|
<Pill tone="rose" icon="clock">
|
||||||
|
Too slow
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<RouteStrip
|
<RouteStrip
|
||||||
originId={contract.originId}
|
originId={contract.originId}
|
||||||
destinationId={contract.destinationId}
|
destinationId={contract.destinationId}
|
||||||
|
|
@ -205,18 +220,12 @@ function LocationBoard({
|
||||||
<div className="flex items-center gap-1.5 text-sm text-ink-300">
|
<div className="flex items-center gap-1.5 text-sm text-ink-300">
|
||||||
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
|
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
|
||||||
<span>
|
<span>
|
||||||
{cargoName(contract.cargoTypeId)}
|
{contract.shipper} ·{" "}
|
||||||
{contract.tier > 0 && ` (tier ${contract.tier})`} ·{" "}
|
|
||||||
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
|
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
|
||||||
{contract.massTonnes} t
|
{contract.massTonnes} t
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
||||||
</span>
|
</span>
|
||||||
{tooHeavy && (
|
|
||||||
<Pill tone="rose" icon="alert" className="ml-auto">
|
|
||||||
Too heavy
|
|
||||||
</Pill>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
|
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
|
||||||
<span className="text-ink-400">
|
<span className="text-ink-400">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { deliveryXp, loanerModel } from "@deadhead/sim";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { formatCredits, formatDuration, locationName, cargoName } from "../format";
|
import { formatCredits, formatDuration, locationName } from "../format";
|
||||||
|
import { goodName } from "../components/Goods";
|
||||||
import { distanceAt, tripRealMs } from "../game";
|
import { distanceAt, tripRealMs } 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";
|
||||||
|
|
@ -113,7 +114,7 @@ export function TutorialBoard() {
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
|
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
|
||||||
<Icon name="cargo" className="size-4 text-ink-400" />
|
<Icon name="cargo" className="size-4 text-ink-400" />
|
||||||
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
|
{goodName(contract)} · {contract.massTonnes} t
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{scripted && (
|
{scripted && (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { Contract, Ship } from "@deadhead/schema";
|
||||||
|
import { screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { realTimeOf } from "../src/components/Goods";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
|
const NOW = Date.now();
|
||||||
|
|
||||||
|
const job = (overrides: Partial<Contract>): Contract => ({
|
||||||
|
id: "j",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: "high-value",
|
||||||
|
goodId: "medical-supplies",
|
||||||
|
shipper: "Meridian Health",
|
||||||
|
priority: false,
|
||||||
|
deliveryWindowGameHours: null,
|
||||||
|
tier: 1,
|
||||||
|
massTonnes: 6,
|
||||||
|
payout: 3_210,
|
||||||
|
requiredSkill: { skillId: "expensive-cargo", level: 1 },
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: NOW,
|
||||||
|
expiresAt: NOW + 600_000,
|
||||||
|
firstContact: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ship = (modelId: string): Ship => ({
|
||||||
|
id: modelId,
|
||||||
|
modelId,
|
||||||
|
name: `My ${modelId}`,
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
dispatchId: null,
|
||||||
|
pilotId: null,
|
||||||
|
lastArrivalAt: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(contracts: Contract[], ships: Ship[]) {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: () =>
|
||||||
|
samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], ships }),
|
||||||
|
getContractBoard: () => ({ contracts, periodEndsAt: NOW + 600_000 }),
|
||||||
|
}),
|
||||||
|
"/contracts?location=earth",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
|
||||||
|
|
||||||
|
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(c).toHaveTextContent("High-Value · T1");
|
||||||
|
expect(c).toHaveTextContent("Meridian Health · 6 t");
|
||||||
|
expect(c).toHaveTextContent("Needs Expensive Cargo 1");
|
||||||
|
expect(c).not.toHaveTextContent("Priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a Priority window in game hours, and real time on tap", async () => {
|
||||||
|
render(
|
||||||
|
[
|
||||||
|
job({
|
||||||
|
goodId: "machine-parts",
|
||||||
|
cargoTypeId: "heavy",
|
||||||
|
priority: true,
|
||||||
|
deliveryWindowGameHours: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[ship("courier")],
|
||||||
|
);
|
||||||
|
const c = await card("3,210 cr");
|
||||||
|
// A Priority good can have its own name.
|
||||||
|
expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument();
|
||||||
|
const chip = within(c).getByRole("button", { name: /Priority · within 5h/ });
|
||||||
|
await userEvent.click(chip);
|
||||||
|
expect(chip).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
|
||||||
|
expect(c).not.toHaveTextContent("Too slow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a Priority job too slow when no idle ship here can make the window", async () => {
|
||||||
|
const tight = job({ priority: true, deliveryWindowGameHours: 0.5 });
|
||||||
|
render([tight], [ship("bulk-hauler")]);
|
||||||
|
expect(await card("3,210 cr")).toHaveTextContent("Too slow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -141,19 +141,23 @@ describe("location contract boards", () => {
|
||||||
expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows contract details, requirements, expiry and the first-contact badge", async () => {
|
it("shows the good, class and tier, shipper, mass, requirements, expiry and first contact", async () => {
|
||||||
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 items = screen.getAllByRole("listitem");
|
||||||
const [plain, special] = items;
|
const [plain, special] = items;
|
||||||
expect(plain).toHaveTextContent("Standard · 50 t");
|
// CR-007 card: good headline, class chip, shipper · mass · distance.
|
||||||
|
expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument();
|
||||||
|
expect(plain).toHaveTextContent("Standard");
|
||||||
|
expect(plain).toHaveTextContent("Pacific Orbital Logistics · 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("Earth → Mars");
|
||||||
expect(special).toHaveTextContent("Heavy (tier 2)");
|
expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument();
|
||||||
|
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();
|
||||||
|
|
|
||||||
|
|
@ -209,3 +209,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 /
|
||||||
- [x] **Step 10G.5 — Sim: tutorial goods.** Each tutorial slot carries a Standard good made at the loaner's location and wanted at the other end, chosen (seeded from location, run and period) among goods whose range holds the CR-001 tier mass, else clamped to a good's range; a random shipper from the origin; never Priority. On both routes some good holds 5, 12 and 20 t, so tutorial masses are unchanged; payouts now include the good's pay multiplier (e.g. Mail ×1.2). "Unmarked crates" is unchanged. Test: `tutorial.test.ts` (goods per direction over 6 runs × 20 periods, never Priority, shippers, masses 5/12/20; the existing scripted-run test still passes).
|
- [x] **Step 10G.5 — Sim: tutorial goods.** Each tutorial slot carries a Standard good made at the loaner's location and wanted at the other end, chosen (seeded from location, run and period) among goods whose range holds the CR-001 tier mass, else clamped to a good's range; a random shipper from the origin; never Priority. On both routes some good holds 5, 12 and 20 t, so tutorial masses are unchanged; payouts now include the good's pay multiplier (e.g. Mail ×1.2). "Unmarked crates" is unchanged. Test: `tutorial.test.ts` (goods per direction over 6 runs × 20 periods, never Priority, shippers, masses 5/12/20; the existing scripted-run test still passes).
|
||||||
- [x] **Step 10G.6 — Schema and database.** API `Contract` (since 10G.2) carries `goodId`, `shipper`, `priority`, `deliveryWindowGameHours`; `ResolutionSummary` gains `goodId`, `massTonnes`, `shipper` (null for a deadhead); `too-slow` is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration `0005_cr007_goods` (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). `architecture.md` updated. Tests: `goods-backfill.test.ts` (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), `migrations.test.ts` unchanged, schema cases for Priority windows, empty goods and summary goods.
|
- [x] **Step 10G.6 — Schema and database.** API `Contract` (since 10G.2) carries `goodId`, `shipper`, `priority`, `deliveryWindowGameHours`; `ResolutionSummary` gains `goodId`, `massTonnes`, `shipper` (null for a deadhead); `too-slow` is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration `0005_cr007_goods` (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). `architecture.md` updated. Tests: `goods-backfill.test.ts` (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), `migrations.test.ts` unchanged, schema cases for Priority windows, empty goods and summary goods.
|
||||||
- [x] **Step 10G.7 — Server: pass-through and validation.** No server code changes were needed beyond 10G.4's message and 10G.6's summary fields: boards come from the sim and dispatch validation returns the sim's `too-slow`. Tests (`contract-dispatch.test.ts`): a Priority job whose window is shorter than the Courier's trip is refused with `too-slow` and changes nothing; with a window the Courier makes, it dispatches and the stored contract keeps its Priority good; every generated Earth contract names a catalog good of its class and a shipper from Earth's list.
|
- [x] **Step 10G.7 — Server: pass-through and validation.** No server code changes were needed beyond 10G.4's message and 10G.6's summary fields: boards come from the sim and dispatch validation returns the sim's `too-slow`. Tests (`contract-dispatch.test.ts`): a Priority job whose window is shorter than the Courier's trip is refused with `too-slow` and changes nothing; with a window the Courier makes, it dispatches and the stored contract keeps its Priority good; every generated Earth contract names a catalog good of its class and a shipper from Earth's list.
|
||||||
|
- [x] **Step 10G.8 — Client: contract card redesign.** New `components/Goods.tsx`: `goodName` (a Priority good may use its `priorityName`, e.g. Emergency Parts), `ClassChip` ("High-Value · T1", toned by class), `PriorityChip` ("Priority · within 5h" in game hours; tap switches to real time, hover shows both), `tooSlowForAll` (idle ships at the origin exist and none makes the window, via sim `contractTripGameHours`), `formatWindow`, `realTimeOf`. Location board cards, top to bottom: route and payout; the good as an `h3` headline with class, Priority, First contact, Too heavy and Too slow chips; route strip; shipper · mass · distance; requirement and expiry with Dispatch. Tutorial cards show the good's name instead of the class. Tests: `contract-card.test.tsx` (good, class/tier chip, shipper, mass, requirement; Priority chip in game hours and real time on tap with the Priority name; Too slow with only a Bulk Hauler idle); `location-boards.test.tsx` updated to the new card.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue