Fix playtest issues: explain dispatch problems, tutorial hint
CI / check (push) Failing after 47s
Details
CI / check (push) Failing after 47s
Details
The dispatch sheet now names the job and lists every problem with numbers, HQ explains when one more run back to Earth is needed, and an onboarding test race is fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7e122dcee7
commit
9b7b51b455
|
|
@ -592,9 +592,15 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
|
|
||||||
- **The summary lists every goal currently ready to claim,** not only ones that became ready since the last visit. The server does not track when a goal became ready, and an unclaimed ready goal is worth pointing out on any return.
|
- **The summary lists every goal currently ready to claim,** not only ones that became ready since the last visit. The server does not track when a goal became ready, and an unclaimed ready goal is worth pointing out on any return.
|
||||||
- **"Claim on HQ" acknowledges the deliveries and navigates to `/#goals`.**
|
- **"Claim on HQ" acknowledges the deliveries and navigates to `/#goals`.**
|
||||||
- **Web tests wait up to 3 s for elements** (`asyncUtilTimeout`); the 1 s default occasionally ran out under a fully parallel run.
|
- **Web tests wait up to 3 s for elements** (`asyncUtilTimeout`). *Correction:* the intermittent onboarding failure this was meant to fix was really a race in the test (it checked the intro card before the tutorial board had loaded); that is fixed in the playtest fixes below. The longer wait is kept as harmless headroom.
|
||||||
|
|
||||||
## Step 10A.18
|
## Step 10A.18
|
||||||
|
|
||||||
- **Plan amendments are appended to each affected step** as "Amended by CR-001: …" lines, keeping the original text readable next to the change.
|
- **Plan amendments are appended to each affected step** as "Amended by CR-001: …" lines, keeping the original text readable next to the change.
|
||||||
- **Known issue found in the regression (not fixed in this phase):** after the deal the player owns only a Courier (20 t), but bay boards draw contract mass uniformly from 5–400 t, so about three boards in four have nothing a Courier can carry. Before CR-001 the player chose their first ship, which hid this; now every player starts with a Courier and would usually have no flyable contract. Fixing it changes contract generation, so it is left for the user to decide.
|
- **Known issue found in the regression (not fixed in this phase):** after the deal the player owns only a Courier (20 t), but bay boards draw contract mass uniformly from 5–400 t, so about three boards in four have nothing a Courier can carry. Before CR-001 the player chose their first ship, which hid this; now every player starts with a Courier and would usually have no flyable contract. Fixing it changes contract generation, so it is left for the user to decide.
|
||||||
|
|
||||||
|
## Playtest fixes after Phase 10A
|
||||||
|
|
||||||
|
- **"4 of 4" with no deal:** the user's account played through code being changed mid-phase (the dev server reloads), so its first tutorial run was flown before the run counter existed and the count ran one behind; after the fourth counted run the loaner was at the Moon. On finished code the deal appears after run 4. HQ now explains the remaining step when enough runs are done but the loaner is not at Earth (`tutorialHint`).
|
||||||
|
- **The dispatch sheet looked "stuck" on the first job:** it did change per job, but showed only "Earth → Moon" and the first failing check, so every over-heavy job looked identical. The sheet now shows the job's cargo, tier, mass and required skills, and lists every problem with numbers ("Too heavy: 228 t (holds 20 t)", "Needs Expensive Cargo 2 (pilot has 0)", range, Long Haul, fuel). Eligibility is still decided by sim `validateDispatch`.
|
||||||
|
- **Still open:** most post-deal boards have no contract a Courier can carry (see Step 10A.18).
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content, type ShipModel } from "@deadhead/content";
|
||||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
import type { Contract, Pilot, PlayerState } from "@deadhead/schema";
|
||||||
import { realToGame, tripFuelCost, validateDispatch, type DispatchFailure } from "@deadhead/sim";
|
import {
|
||||||
|
checkRoute,
|
||||||
|
LONG_HAUL_SKILL_ID,
|
||||||
|
realToGame,
|
||||||
|
tripFuelCost,
|
||||||
|
validateDispatch,
|
||||||
|
type DispatchFailure,
|
||||||
|
} from "@deadhead/sim";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { formatGameTime } from "../clock/GameClock";
|
import { formatGameTime } from "../clock/GameClock";
|
||||||
import { formatCredits, formatDuration, locationName } from "../format";
|
import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format";
|
||||||
|
import { requirementsText } from "./BayBoards";
|
||||||
import { location, shipModel, tripRealMs } from "../game";
|
import { location, shipModel, tripRealMs } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
|
|
@ -23,6 +31,57 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
"insufficient-credits": "Not enough credits for fuel",
|
"insufficient-credits": "Not enough credits for fuel",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every reason a ship and pilot cannot take a contract, in plain words with the
|
||||||
|
* numbers involved, e.g. "Too heavy: 228 t (holds 20 t)". Display only: the
|
||||||
|
* sim's validateDispatch decides eligibility.
|
||||||
|
*/
|
||||||
|
export function explainProblems(
|
||||||
|
state: PlayerState,
|
||||||
|
contract: Contract,
|
||||||
|
model: ShipModel,
|
||||||
|
pilot: Pilot,
|
||||||
|
fuelCost: number,
|
||||||
|
gameNow: number,
|
||||||
|
now: number,
|
||||||
|
): string[] {
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
|
||||||
|
if (contract.massTonnes > model.cargoCapacityTonnes) {
|
||||||
|
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
|
||||||
|
}
|
||||||
|
const route = checkRoute(
|
||||||
|
model,
|
||||||
|
location(contract.originId),
|
||||||
|
location(contract.destinationId),
|
||||||
|
gameNow,
|
||||||
|
);
|
||||||
|
if (!route.ok) {
|
||||||
|
problems.push(
|
||||||
|
route.reason === "out-of-range"
|
||||||
|
? `Out of range right now (${model.name} reaches ${model.fuelRangeMillionKm}M km)`
|
||||||
|
: INELIGIBLE_REASONS["cannot-land"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.requiredSkill) {
|
||||||
|
const have = pilot.skills[contract.requiredSkill.skillId] ?? 0;
|
||||||
|
if (have < contract.requiredSkill.level) {
|
||||||
|
problems.push(
|
||||||
|
`Needs ${skillName(contract.requiredSkill.skillId)} ${contract.requiredSkill.level} (pilot has ${have})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const longHaul = pilot.skills[LONG_HAUL_SKILL_ID] ?? 0;
|
||||||
|
if (longHaul < contract.requiredLongHaulLevel) {
|
||||||
|
problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`);
|
||||||
|
}
|
||||||
|
if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]);
|
||||||
|
if (state.company.credits < fuelCost) {
|
||||||
|
problems.push(`Not enough credits for fuel (${formatCredits(fuelCost)})`);
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
/** Real-world arrival time on the device, e.g. "14:05". */
|
/** Real-world arrival time on the device, e.g. "14:05". */
|
||||||
export function formatLocalTime(time: number): string {
|
export function formatLocalTime(time: number): string {
|
||||||
return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
|
@ -34,7 +93,8 @@ export interface PairOption {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
pilotId: string | null;
|
pilotId: string | null;
|
||||||
pilotName: string | null;
|
pilotName: string | null;
|
||||||
reason: string | null;
|
/** Why this pair cannot take the contract; empty when it can. */
|
||||||
|
problems: string[];
|
||||||
tripMs: number;
|
tripMs: number;
|
||||||
fuelCost: number;
|
fuelCost: number;
|
||||||
netProfit: number;
|
netProfit: number;
|
||||||
|
|
@ -64,7 +124,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
);
|
);
|
||||||
const pilotPay = pilot && !pilot.isAvatar ? content.config.pilotPayPerDelivery : 0;
|
const pilotPay = pilot && !pilot.isAvatar ? content.config.pilotPayPerDelivery : 0;
|
||||||
|
|
||||||
let reason: string | null = INELIGIBLE_REASONS["pilot-not-assigned"];
|
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
|
||||||
if (pilot) {
|
if (pilot) {
|
||||||
const check = validateDispatch(
|
const check = validateDispatch(
|
||||||
{
|
{
|
||||||
|
|
@ -88,7 +148,13 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
reason = check.ok ? null : INELIGIBLE_REASONS[check.reason];
|
// The sim decides eligibility (as the server will); the list explains every problem.
|
||||||
|
if (check.ok) {
|
||||||
|
problems = [];
|
||||||
|
} else {
|
||||||
|
const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now);
|
||||||
|
problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
shipId: ship.id,
|
shipId: ship.id,
|
||||||
|
|
@ -96,7 +162,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
modelName: model.name,
|
modelName: model.name,
|
||||||
pilotId: pilot?.id ?? null,
|
pilotId: pilot?.id ?? null,
|
||||||
pilotName: pilot?.name ?? null,
|
pilotName: pilot?.name ?? null,
|
||||||
reason,
|
problems,
|
||||||
tripMs,
|
tripMs,
|
||||||
fuelCost,
|
fuelCost,
|
||||||
netProfit: contract.payout - fuelCost - pilotPay,
|
netProfit: contract.payout - fuelCost - pilotPay,
|
||||||
|
|
@ -112,9 +178,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
const dispatch = useGameAction("createDispatch");
|
const dispatch = useGameAction("createDispatch");
|
||||||
const options = pairOptions(state, contract, now);
|
const options = pairOptions(state, contract, now);
|
||||||
const [chosen, setChosen] = useState<string | null>(
|
const [chosen, setChosen] = useState<string | null>(
|
||||||
options.find((o) => o.reason === null)?.shipId ?? null,
|
options.find((o) => o.problems.length === 0)?.shipId ?? null,
|
||||||
);
|
);
|
||||||
const selected = options.find((o) => o.shipId === chosen && o.reason === null);
|
const selected = options.find((o) => o.shipId === chosen && o.problems.length === 0);
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!selected?.pilotId) return;
|
if (!selected?.pilotId) return;
|
||||||
|
|
@ -140,7 +206,13 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
<h2 id="dispatch-title" className="text-lg font-semibold">
|
<h2 id="dispatch-title" className="text-lg font-semibold">
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-400">Payout {formatCredits(contract.payout)}</p>
|
<p aria-label="Job details" className="text-sm text-slate-400">
|
||||||
|
{cargoName(contract.cargoTypeId)}
|
||||||
|
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
|
||||||
|
{formatCredits(contract.payout)}
|
||||||
|
{" · "}
|
||||||
|
{requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
|
||||||
|
</p>
|
||||||
|
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<p className="mt-3 text-sm text-slate-400">
|
<p className="mt-3 text-sm text-slate-400">
|
||||||
|
|
@ -151,9 +223,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<li key={option.shipId}>
|
<li key={option.shipId}>
|
||||||
<label
|
<label
|
||||||
aria-disabled={option.reason !== null}
|
aria-disabled={option.problems.length > 0}
|
||||||
className={`block rounded-lg border p-3 text-sm ${
|
className={`block rounded-lg border p-3 text-sm ${
|
||||||
option.reason
|
option.problems.length > 0
|
||||||
? "border-slate-800 opacity-60"
|
? "border-slate-800 opacity-60"
|
||||||
: chosen === option.shipId
|
: chosen === option.shipId
|
||||||
? "border-amber-400"
|
? "border-amber-400"
|
||||||
|
|
@ -165,7 +237,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
type="radio"
|
type="radio"
|
||||||
name="pair"
|
name="pair"
|
||||||
value={option.shipId}
|
value={option.shipId}
|
||||||
disabled={option.reason !== null}
|
disabled={option.problems.length > 0}
|
||||||
checked={chosen === option.shipId}
|
checked={chosen === option.shipId}
|
||||||
onChange={() => setChosen(option.shipId)}
|
onChange={() => setChosen(option.shipId)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -174,8 +246,12 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
{option.modelName} · {option.pilotName ?? "no pilot"}
|
{option.modelName} · {option.pilotName ?? "no pilot"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{option.reason ? (
|
{option.problems.length > 0 ? (
|
||||||
<p className="mt-1 text-red-400">{option.reason}</p>
|
<ul aria-label="Problems" className="mt-1 list-disc pl-5 text-red-400">
|
||||||
|
{option.problems.map((p) => (
|
||||||
|
<li key={p}>{p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<dl className="mt-1 grid grid-cols-2 gap-x-3 text-slate-300">
|
<dl className="mt-1 grid grid-cols-2 gap-x-3 text-slate-300">
|
||||||
<dt>Trip</dt>
|
<dt>Trip</dt>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
|
import type { TutorialState } from "@deadhead/schema";
|
||||||
import { realToGame } from "@deadhead/sim";
|
import { realToGame } from "@deadhead/sim";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { formatGameTime } from "../clock/GameClock";
|
import { formatGameTime } from "../clock/GameClock";
|
||||||
|
|
@ -8,6 +9,18 @@ import { LoanCard } from "../components/LoanCard";
|
||||||
import { formatCredits, locationName } from "../format";
|
import { formatCredits, locationName } from "../format";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
|
/** What the player needs to do next to reach the deal (decision 0.5.30). */
|
||||||
|
export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
|
||||||
|
if (tutorial.dealAvailable) return "Halvorsen is waiting at Earth with an offer.";
|
||||||
|
if (tutorial.runsCompleted < runsBeforeDeal) {
|
||||||
|
return "Keep flying — the old man has an offer for you.";
|
||||||
|
}
|
||||||
|
// Enough runs, but the deal is made at Earth.
|
||||||
|
return tutorial.runInFlight
|
||||||
|
? "Halvorsen will talk once you land at Earth."
|
||||||
|
: "One more run: bring the ship back to Earth — Halvorsen is waiting there.";
|
||||||
|
}
|
||||||
|
|
||||||
export function HQ() {
|
export function HQ() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
|
|
@ -50,8 +63,8 @@ export function HQ() {
|
||||||
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
|
||||||
{runsBeforeDeal}
|
{runsBeforeDeal}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p aria-label="Next step" className="mt-1 text-xs text-slate-400">
|
||||||
Keep flying — the old man has an offer for you.
|
{tutorialHint(state.tutorial, runsBeforeDeal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import type { Contract, PlayerState } from "@deadhead/schema";
|
||||||
|
import { screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useClock } from "../src/clock/clock";
|
||||||
|
import { tutorialHint } from "../src/screens/HQ";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
const job = (
|
||||||
|
id: string,
|
||||||
|
massTonnes: number,
|
||||||
|
skill: { skillId: string; level: number } | null,
|
||||||
|
): Contract => ({
|
||||||
|
id,
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: skill ? "high-value" : "standard",
|
||||||
|
tier: skill?.level ?? 0,
|
||||||
|
massTonnes,
|
||||||
|
payout: 1000,
|
||||||
|
requiredSkill: skill,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: NOW,
|
||||||
|
expiresAt: NOW + 3_600_000,
|
||||||
|
firstContact: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Just after the deal: one crewed Courier at Earth, as in the user's report. */
|
||||||
|
function afterDeal(): PlayerState {
|
||||||
|
const base = samplePlayerState({ tutorial: null });
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
company: { ...base.company, credits: 1652, loanBalance: 5000 },
|
||||||
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
|
ships: [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
modelId: "courier",
|
||||||
|
name: "Copper Wren",
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
dispatchId: null,
|
||||||
|
pilotId: "avatar",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pilots: [{ ...base.pilots[0]!, assignedShipId: "s1" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dispatch sheet explains each job", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("shows each job's own details and every problem, so different jobs look different", async () => {
|
||||||
|
const board = [
|
||||||
|
job("skill", 228, { skillId: "expensive-cargo", level: 2 }),
|
||||||
|
job("heavy", 69, null),
|
||||||
|
];
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: afterDeal,
|
||||||
|
getContractBoard: () => ({ contracts: board, periodEndsAt: NOW + 3_600_000 }),
|
||||||
|
}),
|
||||||
|
"/contracts",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [skillJob, heavyJob] = await screen.findAllByRole("button", { name: "Dispatch" });
|
||||||
|
await userEvent.click(skillJob!);
|
||||||
|
let sheet = screen.getByRole("dialog");
|
||||||
|
expect(within(sheet).getByLabelText("Job details")).toHaveTextContent(
|
||||||
|
"High-Value (tier 2) · 228 t",
|
||||||
|
);
|
||||||
|
expect(within(sheet).getByLabelText("Job details")).toHaveTextContent(
|
||||||
|
"needs Expensive Cargo 2",
|
||||||
|
);
|
||||||
|
const problems = within(sheet).getByRole("list", { name: "Problems" });
|
||||||
|
expect(problems).toHaveTextContent("Too heavy: 228 t (holds 20 t)");
|
||||||
|
expect(problems).toHaveTextContent("Needs Expensive Cargo 2 (pilot has 0)");
|
||||||
|
await userEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
await userEvent.click(heavyJob!);
|
||||||
|
sheet = screen.getByRole("dialog");
|
||||||
|
expect(within(sheet).getByLabelText("Job details")).toHaveTextContent("Standard · 69 t");
|
||||||
|
expect(within(sheet).getByLabelText("Job details")).toHaveTextContent("no skills needed");
|
||||||
|
const heavyProblems = within(sheet).getByRole("list", { name: "Problems" });
|
||||||
|
expect(heavyProblems).toHaveTextContent("Too heavy: 69 t (holds 20 t)");
|
||||||
|
expect(heavyProblems).not.toHaveTextContent("Expensive Cargo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a job the Courier can carry be confirmed", async () => {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: afterDeal,
|
||||||
|
getContractBoard: () => ({
|
||||||
|
contracts: [job("ok", 15, null)],
|
||||||
|
periodEndsAt: NOW + 3_600_000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"/contracts",
|
||||||
|
);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
||||||
|
const sheet = screen.getByRole("dialog");
|
||||||
|
expect(within(sheet).queryByRole("list", { name: "Problems" })).not.toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByRole("button", { name: "Confirm" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tutorial hint on HQ", () => {
|
||||||
|
const tutorial = (runsCompleted: number, loaner: "earth" | "moon", extra = {}) => ({
|
||||||
|
loanerLocationId: loaner,
|
||||||
|
runInFlight: false,
|
||||||
|
runsCompleted,
|
||||||
|
dealAvailable: false,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for one more run back to Earth when enough runs are done at the Moon", () => {
|
||||||
|
expect(tutorialHint(tutorial(4, "moon"), 4)).toMatch(
|
||||||
|
/One more run: bring the ship back to Earth/,
|
||||||
|
);
|
||||||
|
expect(tutorialHint(tutorial(4, "moon", { runInFlight: true }), 4)).toMatch(/land at Earth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says keep flying before enough runs, and points at the offer when it is ready", () => {
|
||||||
|
expect(tutorialHint(tutorial(2, "earth"), 4)).toMatch(/Keep flying/);
|
||||||
|
expect(tutorialHint(tutorial(4, "earth", { dealAvailable: true }), 4)).toMatch(/offer/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,7 +25,7 @@ describe("guest onboarding", () => {
|
||||||
|
|
||||||
// Lands on Contracts with the intro and the light contract suggested.
|
// Lands on Contracts with the intro and the light contract suggested.
|
||||||
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Intro")).toHaveTextContent(
|
expect(await screen.findByLabelText("Intro")).toHaveTextContent(
|
||||||
"Halvorsen Lunar Freight needs a pilot.",
|
"Halvorsen Lunar Freight needs a pilot.",
|
||||||
);
|
);
|
||||||
const suggested = (await screen.findByText("Suggested")).closest("li")!;
|
const suggested = (await screen.findByText("Suggested")).closest("li")!;
|
||||||
|
|
|
||||||
|
|
@ -124,3 +124,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
||||||
- [x] **Step 10A.16 — Client: whole map from the start.** The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (`src/map/reach.ts`).
|
- [x] **Step 10A.16 — Client: whole map from the start.** The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (`src/map/reach.ts`).
|
||||||
- [x] **Step 10A.17 — Client: something waiting on return.** The "while you were away" dialog lists goals ready to claim, with a "Claim on HQ" link that dismisses it and opens HQ's goals.
|
- [x] **Step 10A.17 — Client: something waiting on return.** The "while you were away" dialog lists goals ready to claim, with a "Claim on HQ" link that dismisses it and opens HQ's goals.
|
||||||
- [x] **Step 10A.18 — Plan amendments and regression.** CR-001 §6 amendments added to `implementation-plan.md` (steps 2.5, 3.5, 3.10, 6.3–6.8, 8.4, 9.1, 9.3, 12.1–12.3). Full suite and CI green. End-to-end smoke against the real server: four light tutorial runs take 1.9 real minutes of flying, the deal appears at Earth and produces the expected state. **Open issue:** after the deal the Earth board usually has no contract a Courier (20 t) can carry, because bay contracts draw mass uniformly from 5–400 t; awaiting the user's decision. Manual check pending.
|
- [x] **Step 10A.18 — Plan amendments and regression.** CR-001 §6 amendments added to `implementation-plan.md` (steps 2.5, 3.5, 3.10, 6.3–6.8, 8.4, 9.1, 9.3, 12.1–12.3). Full suite and CI green. End-to-end smoke against the real server: four light tutorial runs take 1.9 real minutes of flying, the deal appears at Earth and produces the expected state. **Open issue:** after the deal the Earth board usually has no contract a Courier (20 t) can carry, because bay contracts draw mass uniformly from 5–400 t; awaiting the user's decision. Manual check pending.
|
||||||
|
|
||||||
|
### Playtest fixes after Phase 10A
|
||||||
|
|
||||||
|
- [x] HQ tutorial hint when enough runs are done but the loaner is away from Earth; dispatch sheet shows job details and lists every problem with numbers; onboarding test race fixed. Open: post-deal boards rarely have Courier-sized contracts (awaiting decision).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue