Step 10A.12: show the tutorial tradeoff and scripted contracts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0488a84db4
commit
c53910b6e3
|
|
@ -558,3 +558,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The intro card and "Suggested" highlight show only before the first run** (`runsCompleted === 0`); after that the board is a plain choice.
|
- **The intro card and "Suggested" highlight show only before the first run** (`runsCompleted === 0`); after that the board is a plain choice.
|
||||||
- **The suggested contract is the board's first slot,** which the v2 generator always fills with the light tier (fastest, most XP per minute).
|
- **The suggested contract is the board's first slot,** which the v2 generator always fills with the light tier (fastest, most XP per minute).
|
||||||
- **Story names live in client copy:** "Halvorsen Lunar Freight" is presentation text, not game data, so it is not in content.
|
- **Story names live in client copy:** "Halvorsen Lunar Freight" is presentation text, not game data, so it is not in content.
|
||||||
|
|
||||||
|
## Step 10A.12
|
||||||
|
|
||||||
|
- **XP shown on a tutorial contract is sim `deliveryXp` at the current distance**, the same formula resolution uses, so the light-versus-heavy tradeoff the player sees is the real one.
|
||||||
|
- **Scripted contracts show their content name** ("Unmarked crates") in a distinct colour with a short "double pay, no questions asked" line; the summary looks up the flavor message by the summary's `scriptedId`.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { formatCredits, locationName } from "../format";
|
import { formatCredits, locationName } from "../format";
|
||||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
@ -46,6 +47,14 @@ export function AwaySummary() {
|
||||||
</span>
|
</span>
|
||||||
<span>+{s.pilotXp} XP</span>
|
<span>+{s.pilotXp} XP</span>
|
||||||
</div>
|
</div>
|
||||||
|
{s.scriptedId && (
|
||||||
|
<p className="mt-1 italic text-fuchsia-200">
|
||||||
|
{
|
||||||
|
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
|
||||||
|
?.flavorMessage
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{s.discoveredLocationId && (
|
{s.discoveredLocationId && (
|
||||||
<div className="mt-1 text-amber-300">
|
<div className="mt-1 text-amber-300">
|
||||||
Discovered {locationName(s.discoveredLocationId)}!
|
Discovered {locationName(s.discoveredLocationId)}!
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import { loanerModel } from "@deadhead/sim";
|
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, cargoName } from "../format";
|
||||||
import { tripRealMs } from "../game";
|
import { distanceAt, tripRealMs } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
|
||||||
|
|
@ -66,40 +66,79 @@ export function TutorialBoard() {
|
||||||
{board.data.contracts.map((contract, index) => {
|
{board.data.contracts.map((contract, index) => {
|
||||||
// On the first run, suggest the lightest (fastest) contract.
|
// On the first run, suggest the lightest (fastest) contract.
|
||||||
const suggested = firstRun && index === 0;
|
const suggested = firstRun && index === 0;
|
||||||
|
const scripted = contract.scriptedId
|
||||||
|
? content.scriptedTutorialContracts.find((s) => s.id === contract.scriptedId)
|
||||||
|
: undefined;
|
||||||
|
const tripMs = tripRealMs(
|
||||||
|
loaner,
|
||||||
|
contract.originId,
|
||||||
|
contract.destinationId,
|
||||||
|
contract.massTonnes,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const xp = deliveryXp(
|
||||||
|
distanceAt(contract.originId, contract.destinationId, now),
|
||||||
|
contract.tier,
|
||||||
|
content.config,
|
||||||
|
);
|
||||||
|
const border = scripted
|
||||||
|
? "border-fuchsia-500 bg-fuchsia-500/10"
|
||||||
|
: suggested
|
||||||
|
? "border-amber-400 ring-1 ring-amber-400"
|
||||||
|
: "border-slate-800";
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={contract.id}
|
key={contract.id}
|
||||||
data-suggested={suggested || undefined}
|
data-suggested={suggested || undefined}
|
||||||
className={`rounded-lg border p-3 ${suggested ? "border-amber-400 ring-1 ring-amber-400" : "border-slate-800"}`}
|
data-scripted={scripted ? true : undefined}
|
||||||
|
className={`rounded-lg border p-3 ${border}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
{scripted ? (
|
||||||
|
<span className="text-fuchsia-300">{scripted.name}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{suggested && (
|
{suggested && (
|
||||||
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
||||||
Suggested
|
Suggested
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold tabular-nums text-emerald-400">
|
<span className="text-sm text-slate-400">
|
||||||
{formatCredits(contract.payout)}
|
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center justify-between gap-2 text-sm text-slate-400">
|
{scripted && (
|
||||||
<span>
|
<p className="mt-1 text-xs text-fuchsia-200">
|
||||||
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "}
|
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
|
||||||
<span aria-label="Trip time">
|
double pay, no questions asked
|
||||||
{formatDuration(
|
</p>
|
||||||
tripRealMs(
|
)}
|
||||||
loaner,
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
contract.originId,
|
<dl className="grid flex-1 grid-cols-3 text-sm">
|
||||||
contract.destinationId,
|
<div>
|
||||||
contract.massTonnes,
|
<dt className="text-xs text-slate-400">Pay</dt>
|
||||||
now,
|
<dd aria-label="Pay" className="font-semibold tabular-nums text-emerald-400">
|
||||||
),
|
{formatCredits(contract.payout)}
|
||||||
)}
|
</dd>
|
||||||
</span>
|
</div>
|
||||||
</span>
|
<div>
|
||||||
|
<dt className="text-xs text-slate-400">Time</dt>
|
||||||
|
<dd aria-label="Trip time" className="tabular-nums">
|
||||||
|
{formatDuration(tripMs)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-slate-400">XP</dt>
|
||||||
|
<dd aria-label="XP" className="tabular-nums">
|
||||||
|
+{xp}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={runInFlight || fly.isPending}
|
disabled={runInFlight || fly.isPending}
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,110 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract } from "@deadhead/schema";
|
import type { Contract } from "@deadhead/schema";
|
||||||
import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim";
|
import {
|
||||||
|
deliveryXp,
|
||||||
|
MS_PER_HOUR,
|
||||||
|
realToGame,
|
||||||
|
routeDistance,
|
||||||
|
travelTimeGameHours,
|
||||||
|
} from "@deadhead/sim";
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiError } from "../src/api/client";
|
import { ApiError } from "../src/api/client";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatDuration } from "../src/format";
|
import { formatCredits, formatDuration } from "../src/format";
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
|
||||||
|
|
||||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||||
|
const loaner = content.shipModel("company-courier")!;
|
||||||
|
|
||||||
const contract = (id: string, mass: number, payout: number): Contract => ({
|
/** What the sim predicts for the loaner flying this contract now. */
|
||||||
id,
|
function expected(contract: Contract) {
|
||||||
originId: "earth",
|
|
||||||
destinationId: "moon",
|
|
||||||
cargoTypeId: "standard",
|
|
||||||
tier: 0,
|
|
||||||
massTonnes: mass,
|
|
||||||
payout,
|
|
||||||
requiredSkill: null,
|
|
||||||
requiredLongHaulLevel: 0,
|
|
||||||
offeredAt: NOW,
|
|
||||||
expiresAt: NOW + 3_600_000,
|
|
||||||
firstContact: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const board = {
|
|
||||||
contracts: [contract("t1", 12, 230), contract("t2", 18, 345)],
|
|
||||||
periodEndsAt: NOW + 3_600_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The trip time the sim predicts for the Company Courier loaner, Earth→Moon, departing now. */
|
|
||||||
function expectedTripTime(now: number, massTonnes: number) {
|
|
||||||
const distance = routeDistance(
|
const distance = routeDistance(
|
||||||
content.location("earth")!,
|
content.location(contract.originId)!,
|
||||||
content.location("moon")!,
|
content.location(contract.destinationId)!,
|
||||||
realToGame(now, content.config),
|
realToGame(NOW, content.config),
|
||||||
);
|
);
|
||||||
const hours = travelTimeGameHours(
|
const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config);
|
||||||
content.shipModel("company-courier")!,
|
return {
|
||||||
distance,
|
pay: formatCredits(contract.payout),
|
||||||
massTonnes,
|
time: formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression),
|
||||||
content.config,
|
xp: `+${deliveryXp(distance, contract.tier, content.config)}`,
|
||||||
);
|
};
|
||||||
return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("tutorial contract board", () => {
|
describe("tutorial contract board", () => {
|
||||||
beforeEach(() => useClock.setState({ offsetMs: 0, synced: true, now: NOW }));
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it("shows each contract with route, cargo, payout and the sim's trip time", async () => {
|
it("shows pay, trip time and XP side by side for all three contracts, matching the sim", async () => {
|
||||||
const api = fakeApi({
|
const board = sampleTutorialBoard(NOW);
|
||||||
getPlayerState: () => samplePlayerState(),
|
renderApp(
|
||||||
getTutorialBoard: () => board,
|
fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board }),
|
||||||
});
|
"/contracts",
|
||||||
renderApp(api, "/contracts");
|
|
||||||
|
|
||||||
const items = await screen.findAllByRole("listitem");
|
|
||||||
expect(items).toHaveLength(2);
|
|
||||||
expect(items[0]).toHaveTextContent("Earth → Moon");
|
|
||||||
expect(items[0]).toHaveTextContent("Standard · 12 t");
|
|
||||||
expect(items[0]).toHaveTextContent("230 cr");
|
|
||||||
expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent(
|
|
||||||
expectedTripTime(useClock.getState().now, 12),
|
|
||||||
);
|
);
|
||||||
|
const items = await screen.findAllByRole("listitem");
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
const want = expected(board.contracts[i]!);
|
||||||
|
expect(within(item).getByLabelText("Pay")).toHaveTextContent(want.pay);
|
||||||
|
expect(within(item).getByLabelText("Trip time")).toHaveTextContent(want.time);
|
||||||
|
expect(within(item).getByLabelText("XP")).toHaveTextContent(want.xp);
|
||||||
|
});
|
||||||
|
// The heavy run takes longer than the light one.
|
||||||
|
expect(expected(board.contracts[2]!).time).not.toBe(expected(board.contracts[0]!).time);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("styles the scripted contract and shows its name", async () => {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: () => samplePlayerState(),
|
||||||
|
getTutorialBoard: () => sampleTutorialBoard(NOW, { scriptedMedium: true }),
|
||||||
|
}),
|
||||||
|
"/contracts",
|
||||||
|
);
|
||||||
|
const items = await screen.findAllByRole("listitem");
|
||||||
|
expect(items[1]).toHaveAttribute("data-scripted", "true");
|
||||||
|
expect(items[1]).toHaveTextContent("Unmarked crates");
|
||||||
|
expect(items[0]).not.toHaveAttribute("data-scripted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches the tutorial run with Fly it", async () => {
|
it("dispatches the tutorial run with Fly it", async () => {
|
||||||
const api = fakeApi({
|
const api = fakeApi({
|
||||||
getPlayerState: () => samplePlayerState(),
|
getPlayerState: () => samplePlayerState(),
|
||||||
getTutorialBoard: () => board,
|
getTutorialBoard: () => sampleTutorialBoard(NOW),
|
||||||
});
|
});
|
||||||
renderApp(api, "/contracts");
|
renderApp(api, "/contracts");
|
||||||
const [first] = await screen.findAllByRole("listitem");
|
const [, medium] = await screen.findAllByRole("listitem");
|
||||||
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
|
await userEvent.click(within(medium!).getByRole("button", { name: "Fly it" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
||||||
kind: "tutorial",
|
kind: "tutorial",
|
||||||
contractId: "t1",
|
contractId: "tutorial:earth:1:0:1",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables Fly it while a tutorial run is in flight", async () => {
|
it("disables Fly it while a tutorial run is in flight", async () => {
|
||||||
const api = fakeApi({
|
renderApp(
|
||||||
getPlayerState: () =>
|
fakeApi({
|
||||||
samplePlayerState({
|
getPlayerState: () =>
|
||||||
tutorial: {
|
samplePlayerState({
|
||||||
loanerLocationId: "earth",
|
tutorial: {
|
||||||
runInFlight: true,
|
loanerLocationId: "earth",
|
||||||
runsCompleted: 0,
|
runInFlight: true,
|
||||||
dealAvailable: false,
|
runsCompleted: 1,
|
||||||
},
|
dealAvailable: false,
|
||||||
}),
|
},
|
||||||
getTutorialBoard: () => board,
|
}),
|
||||||
});
|
getTutorialBoard: () => sampleTutorialBoard(NOW),
|
||||||
renderApp(api, "/contracts");
|
}),
|
||||||
|
"/contracts",
|
||||||
|
);
|
||||||
await screen.findAllByRole("listitem");
|
await screen.findAllByRole("listitem");
|
||||||
for (const button of screen.getAllByRole("button", { name: "Fly it" })) {
|
for (const button of screen.getAllByRole("button", { name: "Fly it" })) {
|
||||||
expect(button).toBeDisabled();
|
expect(button).toBeDisabled();
|
||||||
|
|
@ -105,14 +113,16 @@ describe("tutorial contract board", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the server's message when dispatch fails", async () => {
|
it("shows the server's message when dispatch fails", async () => {
|
||||||
const api = fakeApi({
|
renderApp(
|
||||||
getPlayerState: () => samplePlayerState(),
|
fakeApi({
|
||||||
getTutorialBoard: () => board,
|
getPlayerState: () => samplePlayerState(),
|
||||||
createDispatch: () => {
|
getTutorialBoard: () => sampleTutorialBoard(NOW),
|
||||||
throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying.");
|
createDispatch: () => {
|
||||||
},
|
throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying.");
|
||||||
});
|
},
|
||||||
renderApp(api, "/contracts");
|
}),
|
||||||
|
"/contracts",
|
||||||
|
);
|
||||||
const [first] = await screen.findAllByRole("listitem");
|
const [first] = await screen.findAllByRole("listitem");
|
||||||
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
|
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
|
@ -120,3 +130,37 @@ describe("tutorial contract board", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scripted delivery summary", () => {
|
||||||
|
it("shows the scripted contract's flavor message after it resolves", async () => {
|
||||||
|
const flavor = content.scriptedTutorialContracts.find(
|
||||||
|
(s) => s.id === "unmarked-crates",
|
||||||
|
)!.flavorMessage;
|
||||||
|
renderApp(
|
||||||
|
fakeApi({
|
||||||
|
getPlayerState: () =>
|
||||||
|
samplePlayerState({
|
||||||
|
summaries: [
|
||||||
|
{
|
||||||
|
dispatchId: "d3",
|
||||||
|
shipId: "loaner",
|
||||||
|
pilotId: "avatar",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
tutorial: true,
|
||||||
|
credits: 461,
|
||||||
|
loanRepayment: 0,
|
||||||
|
pilotXp: 10,
|
||||||
|
discoveredLocationId: null,
|
||||||
|
scriptedId: "unmarked-crates",
|
||||||
|
resolvedAt: NOW,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
acknowledgeSummaries: () => ({ acknowledged: 1 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
|
||||||
|
expect(dialog).toHaveTextContent(flavor);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
||||||
- [x] **Step 10A.9 — Server: loan repayment.** Resolution reads the company loan balance, adds only the net payout and lowers the balance by the repayment (stored in the dispatch resolution, shown in summaries). `POST /api/loan/repay` (`src/game/loan.ts`) moves up to the balance from credits to the loan under the player lock; `409 insufficient-funds` with no changes if credits fall short.
|
- [x] **Step 10A.9 — Server: loan repayment.** Resolution reads the company loan balance, adds only the net payout and lowers the balance by the repayment (stored in the dispatch resolution, shown in summaries). `POST /api/loan/repay` (`src/game/loan.ts`) moves up to the balance from credits to the loan under the player lock; `409 insufficient-funds` with no changes if credits fall short.
|
||||||
- [x] **Step 10A.10 — Server: goals.** `src/game/goals.ts`: `goalProgress` (own-ship deliveries, skill points spent, ships, hires, extra discoveries, deal, loan balance) → `goalStates` (empty during the tutorial) in player state; `POST /api/goals/:goalId/claim` adds the reward once (`goal-not-ready`, `goal-already-claimed`, `404 unknown-goal`).
|
- [x] **Step 10A.10 — Server: goals.** `src/game/goals.ts`: `goalProgress` (own-ship deliveries, skill points spent, ships, hires, extra discoveries, deal, loan balance) → `goalStates` (empty during the tutorial) in player state; `POST /api/goals/:goalId/claim` adds the reward once (`goal-not-ready`, `goal-already-claimed`, `404 unknown-goal`).
|
||||||
- [x] **Step 10A.11 — Client: first launch goes straight to work.** "Start your company" opens Contracts; on the first run the tutorial board shows the Halvorsen intro card and highlights the light contract as "Suggested". Step 8.4 test updated.
|
- [x] **Step 10A.11 — Client: first launch goes straight to work.** "Start your company" opens Contracts; on the first run the tutorial board shows the Halvorsen intro card and highlights the light contract as "Suggested". Step 8.4 test updated.
|
||||||
- [ ] Step 10A.12 — Client: tutorial board shows the tradeoff
|
- [x] **Step 10A.12 — Client: tutorial board shows the tradeoff.** Each tutorial contract shows Pay, Time and XP side by side (time and XP from sim); scripted contracts get their own name and fuchsia styling; their flavor message appears in the delivery summary. Step 9.3 tests updated.
|
||||||
- [ ] Step 10A.13 — Client: the deal
|
- [ ] Step 10A.13 — Client: the deal
|
||||||
- [ ] Step 10A.14 — Client: loan on HQ
|
- [ ] Step 10A.14 — Client: loan on HQ
|
||||||
- [ ] Step 10A.15 — Client: goals on HQ
|
- [ ] Step 10A.15 — Client: goals on HQ
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue