Compare commits

..

6 Commits

Author SHA1 Message Date
fireshaper 60069b14f6 Step 10M.5: CR-013 plan amendments and regression
CI / check (push) Successful in 5m10s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:02:57 -04:00
fireshaper f78b488baa Step 10M.4: deal modal lists the Clipper and the veteran
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:57:45 -04:00
fireshaper 256fb7c464 Step 10M.3: the deal hands over the veteran's Clipper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:53:50 -04:00
fireshaper 979a1ad1a8 Step 10M.2: goals for a Market purchase and a Priority delivery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:43:38 -04:00
fireshaper b8ff9ba47a Step 10M.1: veteran skill and deal loan from content
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:34:23 -04:00
fireshaper 038f09bb50 CR-013: record the veteran's Clipper change request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:26:22 -04:00
35 changed files with 1954 additions and 171 deletions

View File

@ -1199,3 +1199,38 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The Lighter-first / Clipper-first balance check waits for the balance script** (plan Step 12.1, not built yet); the amendment is recorded on Step 12.1.
- **The manual check found a hop needs Long Haul**: every Mars→Ceres leg is over 100M km, so it needs Long Haul 1. A fresh veteran (Heavy Cargo 1, 3 unspent points) qualifies once a point goes into Long Haul. This is existing skill gating, not a range problem, but it makes the Clipper's hop to Ceres a mid-game move.
- **Clipper fuel is expensive by design** (600 cr/M km): a ~190M km hop costs about 110,000 credits, in line with a Freighter's long runs. The check topped up credits with the developer tool. Payouts for those legs are tuned elsewhere and were not changed here.
## CR-013 — The Veteran's Clipper (Phase 10M)
- **Renumbered on arrival:** titled "CR-010 … Phase 10J"; CR-010 to CR-012 and Phases 10J–10L already exist, so it is **CR-013**, done as **Phase 10M** (its 10J.n are 10M.n).
- **Decisions:** the CR's 0.5.64 and 0.5.65 are **0.5.82** and **0.5.83**; its amended "0.5.52 Veteran skill" is this plan's **0.5.70**. Its "CR-007" (veteran, steps 10G.n) is **CR-010** (10J.n); its "CR-009" (new ships, 10I.11) is **CR-012** (10L.11).
## Step 10M.1
- **The deal's contents are config** (`deal.avatarShipModelId`, `veteranShipModelId`, `bayLocationId`) so the loan formula and the server's deal both read the same ids; the stored `dealLoanAmount` number is gone.
- **The loan is a pure function of content** (`dealLoanAmount`), computed once into the bundle. Tests call it with altered prices to show it follows them.
- **The loan grows from 5,000 to 66,600 credits.** At the 30 % repayment share, paying it off takes far more deliveries than before; the "under 4 hours of active play" target is for the balance script (Step 12.1) to check.
## Step 10M.2
- **Goal ids changed with their meaning**: `second-ship` → `market-ship`, `veteran-contract` → `priority-contract`. A claim recorded under an old id no longer matches any goal; the CR allows resetting databases, and a stale claim only hides nothing.
- **"Bought from the Market" is a stored flag** (`ships.from_deal`) rather than "ships owned minus two", so it stays right if the deal's contents change. The migration marks each existing player's first ship as the deal's (it always is: the loaner isn't a row).
- **The old `veteran-contracts` condition is removed**, not kept unused. `ships-owned` and `pilots-hired` stay as general-purpose conditions.
- **A Priority delivery counts by the contract snapshot** (`record.contract.priority`), for any pilot, excluding tutorial runs (which are never Priority anyway).
## Step 10M.3
- **The veteran is stored like any assigned pilot** (location null, following the Clipper), so state shows them at Earth through the ship, as it does the avatar.
- **The Clipper is named with the next seeded ship name**, after the Courier, the same way bought ships are.
- **The old rest-and-swap test is replaced** by the CR's "both ships can be dispatched immediately" (both in flight at once); the profit-share check moved onto the veteran's Clipper run.
## Step 10M.4
- **"Twelve years" lives in the bio, not the sentence**: the CR's example sentence ("who's flown that Clipper for twelve years") is split so the sentence uses only names (veteran, ships, bay) and the bio carries the history, which also shows on the pilot card.
- **The loan shown is `content.dealLoanAmount`**; before the deal the player has no loan in state, so "matching state" is tested as the offer equalling the balance the after-deal state reports.
## Step 10M.5
- **Open balance issue, not changed here (needs a design call):** right after the deal a fresh player has ~890 cr, but fuelling both ships from Earth to the Moon costs ~921 cr (Courier 0.384 × 200 × 3 = 230; Clipper 0.384 × 600 × 3 = 691). So "dispatch the Courier and the Clipper at the same time" isn't possible on a fresh account without first earning more. The scripted check used a 1,000 cr developer top-up. Candidate fixes: more cash with the deal, a lower Clipper fuel rate, or a smaller Earth launch multiplier.
- **Short Clipper hops can lose money:** in the check, a Priority Mail job to the Moon paid 373 cr against 691 cr of fuel (pilot share 0). Another run (Medical Supplies) paid 1,083. The Clipper's 600 cr/M km fuel with Earth's ×3 launch cost makes Earth→Moon marginal; its payoff is long, fast legs. Left for the balance pass (Step 12.1).
- **The balance-script checks wait for the script** (Step 12.1), where the amended targets and the Lighter-first / Freighter-first policies are recorded.

View File

@ -0,0 +1,5 @@
ALTER TABLE "ships" ADD COLUMN "from_deal" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Backfill: each player's first ship came with the deal (CR-001), not from the Market.
UPDATE "ships" SET "from_deal" = true WHERE "id" IN (
SELECT DISTINCT ON ("player_id") "id" FROM "ships" ORDER BY "player_id", "created_at", "id"
);

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,13 @@
"when": 1791171500580,
"tag": "0008_cr011_backup_codes",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1791214535055,
"tag": "0009_cr013_ship_from_deal",
"breakpoints": true
}
]
}

View File

@ -76,6 +76,8 @@ export const ships = pgTable(
*/
shopUpgradeId: text("shop_upgrade_id"),
shopLevel: integer("shop_level"),
/** Handed over in the deal rather than bought from the Market (CR-013 goal 3). */
fromDeal: boolean("from_deal").notNull().default(false),
createdAt: time("created_at").notNull(),
},
(t) => [index("ships_player_idx").on(t.playerId)],

View File

@ -7,9 +7,10 @@ import { bays, companies, dispatches, pilots, players, ships } from "../db/schem
import { gameError } from "../http/errors";
import { shipName } from "./names";
/** The ship model and bay location the deal hands over (decision 0.5.30). */
export const DEAL_SHIP_MODEL_ID = "courier";
export const DEAL_BAY_LOCATION_ID = "earth";
/** What the deal hands over (decisions 0.5.30, 0.5.82), from content. */
const { avatarShipModelId, veteranShipModelId, bayLocationId } = content.config.deal;
export const DEAL_SHIP_MODEL_ID = avatarShipModelId;
export const DEAL_BAY_LOCATION_ID = bayLocationId;
/**
* The deal can be accepted now (decision 0.5.30): tutorial on, enough runs
@ -29,9 +30,9 @@ export function isDealAvailable(
}
/**
* Accepts the deal (Step 10A.8): the loaner goes back to the company; the
* player gets the old Courier at Earth crewed by the avatar, the Earth bay, and
* the loan; the tutorial ends. Run inside the player lock, so two simultaneous
* Accepts the deal (Steps 10A.8, 10M.3): the loaner goes back to the company;
* the player gets the old Courier at Earth crewed by the avatar, the Clipper
* crewed by the veteran, the Earth bay, and the loan; the tutorial ends. Run inside the player lock, so two simultaneous
* accepts produce one deal.
*/
export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
@ -53,14 +54,20 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime)
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
const shipId = randomUUID();
await tx.insert(ships).values({
id: shipId,
playerId,
modelId: DEAL_SHIP_MODEL_ID,
name: shipName(player.worldSeed, owned!.n),
locationId: DEAL_BAY_LOCATION_ID,
createdAt: now,
});
const clipperId = randomUUID();
await tx.insert(ships).values(
[
{ id: shipId, modelId: avatarShipModelId },
{ id: clipperId, modelId: veteranShipModelId },
].map((ship, i) => ({
...ship,
playerId,
name: shipName(player.worldSeed, owned!.n + i),
locationId: bayLocationId,
fromDeal: true,
createdAt: now,
})),
);
await tx.insert(bays).values({
id: randomUUID(),
playerId,
@ -71,8 +78,8 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime)
.update(pilots)
.set({ assignedShipId: shipId, locationId: null, status: "idle" })
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
// Halvorsen's veteran comes with the business (decision 0.5.70): at Earth,
// unassigned, at their level with their skills bought and the rest unspent.
// Halvorsen's veteran comes with the business (decisions 0.5.70, 0.5.82),
// crewing his Clipper, at their level with their skills bought and the rest unspent.
const veteran = content.veteranPilot;
const xpForLevel =
veteran.level === 0 ? 0 : content.config.pilotLevelXpThresholds[veteran.level - 1]!;
@ -84,12 +91,14 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime)
xp: xpForLevel,
skills: veteran.skills,
skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0),
locationId: DEAL_BAY_LOCATION_ID,
assignedShipId: clipperId,
// Assigned pilots are where their ship is (as for the avatar).
locationId: null,
createdAt: now,
});
await tx
.update(companies)
.set({ loanBalance: content.config.dealLoanAmount })
.set({ loanBalance: content.dealLoanAmount })
.where(eq(companies.playerId, playerId));
await tx
.update(players)

View File

@ -32,18 +32,16 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
),
);
// Contracts (not deadheads or tutorial runs) the veteran has delivered (CR-010).
const [veteranContracts] = await tx
// Priority contracts delivered, by any pilot (CR-013).
const [priorityDeliveries] = await tx
.select({ n: count() })
.from(dispatches)
.innerJoin(pilots, eq(pilots.id, dispatches.pilotId))
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "resolved"),
eq(dispatches.tutorial, false),
eq(pilots.isVeteran, true),
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
sql`(${dispatches.record} -> 'contract' ->> 'priority') = 'true'`,
),
);
const [spent] = await tx
@ -51,6 +49,11 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
.from(pilots)
.where(eq(pilots.playerId, playerId));
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
// The deal's ships don't count as Market purchases (CR-013).
const [bought] = await tx
.select({ n: count() })
.from(ships)
.where(and(eq(ships.playerId, playerId), eq(ships.fromDeal, false)));
const [hired] = await tx
.select({ n: count() })
.from(pilots)
@ -68,8 +71,9 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
ownShipDeliveries: deliveries!.n,
skillPointsSpent: Number(spent?.n ?? 0),
shipsOwned: owned!.n,
marketShipsBought: bought!.n,
pilotsHired: hired!.n,
veteranContracts: veteranContracts!.n,
priorityDeliveries: priorityDeliveries!.n,
locationsDiscovered: discovered!.n,
dealAccepted: player.dealAccepted,
loanBalance: company.loanBalance,

View File

@ -105,6 +105,8 @@ export async function makeOwner(
modelId: "courier",
name: "Test Courier",
locationId: "earth",
// Stands in for the deal's Courier (not a Market purchase, CR-013).
fromDeal: true,
createdAt: TEST_NOW,
});
await tx

View File

@ -35,7 +35,7 @@ async function snapshot(guest: Guest) {
}
describe("accepting the deal", () => {
it("hands over a crewed Courier at Earth, an Earth bay and the loan, and ends the tutorial", async () => {
it("hands over a crewed Courier and Clipper at Earth, an Earth bay and the loan, and ends the tutorial", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true);
@ -45,21 +45,30 @@ describe("accepting the deal", () => {
const state = playerStateSchema.parse(response.json());
expect(state.tutorial).toBeNull();
expect(state.ships).toHaveLength(1);
const [ship] = state.ships;
// CR-013: the Courier for the avatar and the Clipper for the veteran.
expect(state.ships).toHaveLength(2);
const [ship, clipper] = state.ships;
expect(ship).toMatchObject({
modelId: "courier",
status: "idle",
locationId: "earth",
pilotId: guest.avatarId,
});
const veteran = state.pilots.find((p) => p.isVeteran)!;
expect(clipper).toMatchObject({
modelId: "clipper",
status: "idle",
locationId: "earth",
pilotId: veteran.id,
});
expect(veteran).toMatchObject({ assignedShipId: clipper!.id, locationId: "earth" });
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
assignedShipId: ship!.id,
locationId: "earth",
});
expect(state.company.loanBalance).toBe(content.config.dealLoanAmount);
expect(state.company.loanBalance).toBe(content.dealLoanAmount);
const after = await snapshot(guest);
expect(after.player).toMatchObject({
@ -104,8 +113,8 @@ describe("accepting the deal", () => {
const responses = await Promise.all([accept(guest), accept(guest)]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const after = await snapshot(guest);
expect(after.ships).toHaveLength(1);
expect(after.ships).toHaveLength(2);
expect(after.bays).toHaveLength(1);
expect(after.company!.loanBalance).toBe(content.config.dealLoanAmount);
expect(after.company!.loanBalance).toBe(content.dealLoanAmount);
});
});

View File

@ -10,6 +10,7 @@ import {
insertDispatch,
signUp,
signUpOwner,
testContract,
useTestApp,
type Guest,
} from "./game";
@ -48,7 +49,7 @@ describe("onboarding goals", () => {
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
// Paid-off loan: the test owner has no balance.
expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready");
expect(goals.find((g) => g.id === "veteran-contract")?.status).toBe("incomplete");
expect(goals.find((g) => g.id === "priority-contract")?.status).toBe("incomplete");
});
it("becomes ready when its condition is met: a delivery with the player's own ship", async () => {
@ -65,6 +66,37 @@ describe("onboarding goals", () => {
expect(await status(owner, "first-own-delivery")).toBe("ready");
});
it("counts a ship bought from the Market, not the deal's (CR-013)", async () => {
const owner = await dealOwner();
expect(await status(owner, "market-ship")).toBe("incomplete");
await setCredits(database().db, owner.playerId, content.shipModel("lighter")!.price);
const response = await call(testApp().app, owner, "POST", "/api/ships", {
modelId: "lighter",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
expect(await status(owner, "market-ship")).toBe("ready");
});
it("counts a Priority delivery, not a Standard one (CR-013)", async () => {
const { clock } = testApp();
const owner = await dealOwner();
const fly = async (priority: boolean) => {
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ priority, deliveryWindowGameHours: priority ? 1000 : null }),
});
clock.now = record.arrivalAt;
};
await fly(false);
expect(await status(owner, "priority-contract")).toBe("incomplete");
await fly(true);
expect(await status(owner, "priority-contract")).toBe("ready");
});
it("counts skill points spent", async () => {
const owner = await dealOwner();
await database()
@ -93,7 +125,7 @@ describe("onboarding goals", () => {
it("refuses an incomplete goal and an unknown one", async () => {
const owner = await dealOwner();
await setCredits(database().db, owner.playerId, 100);
const response = await claim(owner, "veteran-contract");
const response = await claim(owner, "priority-contract");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("goal-not-ready");
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);

View File

@ -8,16 +8,7 @@ import {
import { pilotShare } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUp,
testContract,
useTestApp,
type Guest,
} from "./game";
import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game";
import { flyTutorialRun } from "./tutorial-flow";
const database = useTestDatabase();
@ -41,20 +32,21 @@ async function dealt(): Promise<{ guest: Guest; state: PlayerState }> {
}
describe("the veteran comes with the deal (Step 10J.4)", () => {
it("adds the veteran at Earth, unassigned, level 2 with Heavy Cargo 1", async () => {
it("adds the veteran crewing the Clipper at Earth, level 2 with Expensive Cargo 1 (CR-013)", async () => {
const { state } = await dealt();
expect(state.pilots).toHaveLength(2);
const courier = state.ships[0]!;
expect(avatarOf(state)).toMatchObject({ assignedShipId: courier.id, locationId: "earth" });
const [courier, clipper] = state.ships;
expect(clipper!.modelId).toBe("clipper");
expect(avatarOf(state)).toMatchObject({ assignedShipId: courier!.id, locationId: "earth" });
const spentPoints = 1;
expect(veteranOf(state)).toMatchObject({
name: content.veteranPilot.name,
isAvatar: false,
isVeteran: true,
level: 2,
skills: { "heavy-cargo": 1 },
skills: { "expensive-cargo": 1 },
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
assignedShipId: null,
assignedShipId: clipper!.id,
locationId: "earth",
status: "idle",
});
@ -73,75 +65,39 @@ describe("the veteran comes with the deal (Step 10J.4)", () => {
expect(pilots.filter((p) => p.isVeteran)).toHaveLength(1);
});
it("swaps in the veteran while the avatar rests, and pays the veteran a profit share", async () => {
it("dispatches the Courier and the Clipper at once, paying the veteran a profit share", async () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const courier = state.ships[0]!;
const [courier, clipper] = state.ships;
const veteran = veteranOf(state);
await setCredits(database().db, guest.playerId, 10_000);
expect(
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/rest`, {})).statusCode,
).toBe(200);
expect(
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {})).statusCode,
).toBe(200);
expect(
(await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id }))
.statusCode,
).toBe(200);
const forCourier = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
const forClipper = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
await putBoard(database().db, guest.playerId, [forCourier, forClipper], clock.now);
const send = (contractId: string, shipId: string, pilotId: string) =>
call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId,
shipId,
pilotId,
});
const avatarRun = await send(forCourier.id, courier!.id, guest.avatarId);
const veteranRun = await send(forClipper.id, clipper!.id, veteran.id);
expect(avatarRun.statusCode).toBe(200);
expect(veteranRun.statusCode).toBe(200);
expect((await getState(app, guest)).activeDispatches).toHaveLength(2);
const job = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
await putBoard(database().db, guest.playerId, [job], clock.now);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: courier.id,
pilotId: veteran.id,
});
expect(response.statusCode).toBe(200);
const { dispatch } = dispatchResponseSchema.parse(response.json());
clock.now = dispatch.arrivalAt;
const { dispatch } = dispatchResponseSchema.parse(veteranRun.json());
clock.now = Math.max(
dispatch.arrivalAt,
dispatchResponseSchema.parse(avatarRun.json()).dispatch.arrivalAt,
);
const after = await getState(app, guest);
const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!;
const pay = pilotShare(job.payout, dispatch.fuelCost, content.config);
const pay = pilotShare(forClipper.payout, dispatch.fuelCost, content.config);
expect(pay).toBeGreaterThan(0);
expect(summary.credits + summary.loanRepayment).toBe(job.payout - pay);
expect(after.goals.find((g) => g.id === "veteran-contract")?.status).toBe("ready");
});
it("counts only contracts the veteran flew toward goal 4", async () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const courier = state.ships[0]!;
const veteran = veteranOf(state);
const goal = async () =>
(await getState(app, guest)).goals.find((g) => g.id === "veteran-contract")?.status;
// The avatar's delivery doesn't count.
const byAvatar = await insertDispatch(database().db, {
playerId: guest.playerId,
shipId: courier.id,
pilotId: guest.avatarId,
departAt: clock.now,
contract: testContract({ expiresAt: FAR_FUTURE }),
});
clock.now = byAvatar.arrivalAt;
expect(await goal()).toBe("incomplete");
// A deadhead by the veteran doesn't count either.
await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {});
await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id });
const deadhead = await insertDispatch(database().db, {
playerId: guest.playerId,
shipId: courier.id,
pilotId: veteran.id,
departAt: clock.now,
contract: null,
destinationId: "earth",
});
clock.now = deadhead.arrivalAt;
expect(await goal()).toBe("incomplete");
expect(summary.pilotPay).toBe(pay);
expect(summary.credits + summary.loanRepayment).toBe(forClipper.payout - pay);
});
});

View File

@ -27,8 +27,12 @@ export function DealModal() {
if (!state.tutorial?.dealAvailable || state.summaries.length > 0) return null;
const { dealLoanAmount, loanRepaymentShare } = content.config;
const courier = content.shipModel("courier")!;
const { dealLoanAmount } = content;
const { loanRepaymentShare } = content.config;
// The deal's ships (decisions 0.5.30, 0.5.82).
const courier = content.shipModel(content.config.deal.avatarShipModelId)!;
const clipper = content.shipModel(content.config.deal.veteranShipModelId)!;
const bay = content.location(content.config.deal.bayLocationId)!;
const veteran = content.veteranPilot;
return (
@ -40,17 +44,23 @@ export function DealModal() {
layer="z-30"
>
<div className="mt-3 flex items-center gap-3">
<Art sprite={courier.sprite} className="size-20 shrink-0 -rotate-12" />
<div className="flex shrink-0 -space-x-6">
<Art sprite={courier.sprite} className="size-16 -rotate-12" />
<Art sprite={clipper.sprite} className="size-16 rotate-12" />
</div>
<p className="text-sm text-ink-200">
Old Halvorsen is retiring. He&apos;ll sell you his Courier and his Earth bay, and his
pilot comes with them. Pay him back out of every job.
Old Halvorsen is retiring. He&apos;ll sell you his {courier.name}, his {clipper.name}, and
his {bay.name} bay — and {veteran.name}, who flies that {clipper.name}. Pay him back out
of every job.
</p>
</div>
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-space-950/60 p-3 text-sm ring-1 ring-amber-400/25">
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
You get
</dt>
<dd>A {courier.name} and a bay at Earth</dd>
<dd aria-label="Ships and bay">
A {courier.name} for you, a {clipper.name} for {veteran.name}, and a bay at {bay.name}
</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Pilot</dt>
<dd aria-label="Veteran pilot">
…and {veteran.name}. {veteran.bio}

View File

@ -18,7 +18,7 @@ export function LoanCard() {
const repay = useGameAction("repayLoan");
const [input, setInput] = useState("");
const { credits, loanBalance } = state.company;
const total = content.config.dealLoanAmount;
const total = content.dealLoanAmount;
const paidOff = Math.max(0, Math.min(100, Math.floor(((total - loanBalance) / total) * 100)));
const amount = Number(input);
const problem = input === "" ? null : payDownProblem(amount, credits, loanBalance);

View File

@ -14,7 +14,7 @@ const offered = () =>
},
});
/** State the server returns after the deal: tutorial over, a Courier, a bay, the loan. */
/** State the server returns after the deal: tutorial over, the Courier and Clipper, a bay, the loan. */
const afterDeal = () =>
samplePlayerState({
tutorial: null,
@ -22,7 +22,7 @@ const afterDeal = () =>
id: "co1",
name: "Deadhead Freight",
credits: 800,
loanBalance: content.config.dealLoanAmount,
loanBalance: content.dealLoanAmount,
},
ships: [
{
@ -36,6 +36,17 @@ const afterDeal = () =>
...shipExtras("courier"),
pilotId: "avatar",
},
{
id: "s2",
modelId: "clipper",
name: "Swift Heron",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("clipper"),
pilotId: "veteran",
},
],
bays: [{ id: "b1", locationId: "earth" }],
});
@ -61,9 +72,16 @@ describe("the deal", () => {
"/contracts",
);
const dialog = await screen.findByRole("dialog", { name: "An offer from Halvorsen" });
expect(dialog).toHaveTextContent("Courier and a bay at Earth");
// Both ships and the veteran (CR-013), names from content.
const veteran = content.veteranPilot;
expect(screen.getByLabelText("Ships and bay")).toHaveTextContent(
`A Courier for you, a Clipper for ${veteran.name}, and a bay at Earth`,
);
expect(dialog).toHaveTextContent("his Courier, his Clipper, and his Earth bay");
expect(screen.getByLabelText("Veteran pilot")).toHaveTextContent(veteran.name);
expect(screen.getByLabelText("Veteran pilot")).toHaveTextContent(veteran.bio);
expect(screen.getByLabelText("Loan amount")).toHaveTextContent(
`${content.config.dealLoanAmount.toLocaleString("en-US")} cr`,
`${content.dealLoanAmount.toLocaleString("en-US")} cr`,
);
expect(screen.getByLabelText("Repayment share")).toHaveTextContent(
`${Math.round(content.config.loanRepaymentShare * 100)}% of every payout`,
@ -119,6 +137,10 @@ describe("the deal", () => {
},
});
renderApp(api, "/contracts");
// The loan offered is the loan the server books (CR-013: Courier + Clipper + bay).
expect(await screen.findByLabelText("Loan amount")).toHaveTextContent(
`${afterDeal().company.loanBalance.toLocaleString("en-US")} cr`,
);
await userEvent.click(await screen.findByRole("button", { name: "Shake on it" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("acceptDeal", {}));
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();

View File

@ -31,7 +31,7 @@ describe("goals on HQ", () => {
expect(items[0]).toHaveTextContent(
`+${content.onboardingGoal("first-own-delivery")!.reward.toLocaleString("en-US")} cr`,
);
expect(items[2]).toHaveTextContent(title("second-ship"));
expect(items[2]).toHaveTextContent(title("market-ship"));
});
it("shows Claim only on ready goals", async () => {
@ -56,11 +56,11 @@ describe("goals on HQ", () => {
});
renderApp(api);
const card = await screen.findByRole("region", { name: "Goals" });
expect(card).not.toHaveTextContent(title("veteran-contract"));
expect(card).not.toHaveTextContent(title("priority-contract"));
await userEvent.click(within(card).getByRole("button", { name: "Claim" }));
expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" });
await waitFor(() => expect(card).toHaveTextContent(title("veteran-contract")));
await waitFor(() => expect(card).toHaveTextContent(title("priority-contract")));
expect(card).not.toHaveTextContent(title("first-own-delivery"));
});

View File

@ -83,7 +83,7 @@ describe("HQ screen", () => {
});
describe("loan on HQ", () => {
const total = content.config.dealLoanAmount;
const total = content.dealLoanAmount;
const owner = (credits: number, loanBalance: number): PlayerState =>
samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance } });

View File

@ -80,7 +80,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|---|---|---|
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — |
| `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009), `from_deal` (handed over in the deal, not bought; migration `0009_cr013_ship_from_deal` backfills each player's first ship; CR-013) | — |
| `player_logins` | Extra sign-ins per player: a guest user created by restoring with a backup code (CR-011) | PK `user_id` |
| `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004), `is_veteran` (CR-010) | unique `assigned_ship_id` (one pilot per ship) |

View File

@ -22,8 +22,9 @@ This change keeps the employee opening but cuts it to about four quick runs, giv
| First dispatch | Under 30 seconds from "Start your company" |
| First real choice between contracts | Under 2 minutes |
| Own a ship (deal accepted) | Under 10 minutes |
| Buy a second ship *(changed by CR-010; was: Hire first pilot)* | 30–45 minutes |
| Buy a ship from the Market *(changed by CR-013; was: Buy a second ship (CR-010), Hire first pilot)* | 45–75 minutes |
| First discovery | Within 90 minutes |
| Loan paid off *(added by CR-013)* | Within roughly the first two sessions (under 4 hours of active play) |
### New opening, in order
1. Player taps "Start your company" and lands straight on the tutorial contract board with a two-line intro.
@ -100,6 +101,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
6. Pay off your loan.
- **Test:** For each goal, a state summary just short of the condition reports it incomplete, and a summary that meets it reports it complete. Results are identical for identical inputs.
- **Amended by CR-010:** Goal 4 "Hire your first pilot" is now "Fly a contract with [veteran's name]" (`veteran-contract`, Step 10J.3).
- **Amended by CR-013:** Goal 3 is "Buy a ship from the Market" (ships from the deal don't count) and goal 4 is "Complete a Priority contract" (Step 10M.2).
### Step 10A.6 — Schema and database
- Add to the database: company loan balance; tutorial runs completed; a deal-accepted flag; claimed goals per player; the repayment amount on each dispatch record; the `purchasable` flag reflected wherever ship models are listed.
@ -123,6 +125,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
- This replaces Step 6.7. Remove the old automatic tutorial-end rule and its tests.
- **Test:** Accepting when available produces exactly that state: one owned Courier at Earth crewed by the avatar, one Earth bay, the configured loan balance, tutorial off, no loaner. Accepting when not available fails with no changes. Accepting twice fails the second time. Two simultaneous accepts produce exactly one deal.
- **Amended by CR-010:** Accepting the deal also creates the veteran pilot at Earth, unassigned (decision 0.5.70, Step 10J.4).
- **Amended by CR-013:** The deal also creates the Clipper at Earth, crewed by the veteran; the loan is Courier + Clipper + Earth bay lease (Steps 10M.1, 10M.3).
### Step 10A.9 — Server: loan repayment
- Resolution applies the repayment from Step 10A.4: reduce the loan balance, add only the net to credits, and store the repayment on the dispatch.
@ -150,6 +153,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
- After accepting, go to HQ.
- **Test:** Component test: the modal appears only when state says the deal is available; it shows the configured loan amount and share; the button calls accept and navigates to HQ.
- **Amended by CR-010:** The deal modal lists the veteran by name with their bio line (Step 10J.5).
- **Amended by CR-013:** The modal lists the Courier, the Clipper, the Earth bay and the veteran, with the larger loan (Step 10M.4).
### Step 10A.14 — Client: loan on HQ
- Replace the old "save up for your first ship" goal on HQ (Step 9.1) with the loan: balance, a paid-off progress bar, and a "Pay down" action with an amount input.

View File

@ -65,6 +65,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- Replace the GameConfig field "pilot pay per delivery (flat fee)" with "pilot profit share" (default 0.25, must be between 0 and 1).
- Add a veteran pilot entry: name, short bio line, starting level (2), and starting skill allocation (Heavy Cargo 1).
- **Test:** Config validates; the profit share is between 0 and 1. The veteran's starting skill allocation does not exceed the skill points a level-2 pilot has. The veteran's skill references an existing skill.
- **Amended by CR-013:** The veteran's starting skill is Expensive Cargo 1 (Step 10M.1).
### Step 10G.2 — Sim: profit-share pay
- Replace the flat fee in dispatch resolution with the profit-share formula (decision 0.5.12) and apply the deduction order (decision 0.5.51).
@ -73,10 +74,12 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
### Step 10G.3 — Sim: goal change
- Change goal 4 in the onboarding goals (CR-001 Step 10A.5) to "Fly a contract with the veteran": complete when any contract delivered by the veteran has resolved.
- **Test:** The goal is incomplete when only the avatar has delivered and complete after one resolved contract flown by the veteran. A deadhead by the veteran does not complete it.
- **Amended by CR-013:** Goal 4 is now "Complete a Priority contract", by any pilot (Step 10M.2).
### Step 10G.4 — Server: veteran on the deal
- Accepting the deal (CR-001 Step 10A.8) also creates the veteran per §2, in the same transaction.
- **Test:** After accepting, state has exactly two pilots at Earth: the avatar crewing the Courier, and the veteran unassigned at level 2 with Heavy Cargo 1 and the correct number of unspent skill points. The veteran is not exempt from pilot pay. A failed or repeated accept creates no veteran. When the avatar must rest at Earth, the avatar can be unassigned and the veteran assigned to the Courier and dispatched immediately.
- **Amended by CR-013:** The veteran starts crewing the deal's Clipper, not unassigned (Step 10M.3).
### Step 10G.5 — Client: deal modal and pilot card
- The deal modal (CR-001 Step 10A.13) lists the veteran by name with their bio line, for example "…and Rosa Kell, who's flown for me for twelve years."
@ -92,6 +95,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- Apply the amendments in §6 to `implementation-plan.md` and CR-001's text.
- Run the full suite.
- **Test:** CI is green. Manual check: on a fresh account, accept the deal and confirm the veteran is at Earth; fly the avatar until mandatory rest at Earth; swap in the veteran, take a Heavy contract the avatar couldn't, and confirm the summary shows the pilot share and goal 4 becomes claimable.
- **Amended by CR-013:** The rest-and-swap manual check is replaced by CR-013's: accept the deal on a fresh account, dispatch the Courier and the Clipper at the same time, and complete a Priority contract with the Clipper to unlock goal 4 (Step 10M.5).
## 6. Amendments

View File

@ -136,6 +136,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- Apply the amendments in §7 to `implementation-plan.md` and earlier change requests.
- Run the full suite.
- **Test:** CI is green. The balance script (once it exists) runs two policies, Lighter-first and Clipper-first for the second ship; both reach "own three ships" within 25% of each other's time. Manual check: buy a Clipper, discover Mars, deadhead or take work to Mars, then take a first-contact contract to Ceres from the Mars board on a date when it's in range.
- **Amended by CR-013:** The second-ship balance check (Lighter-first vs Clipper-first) is replaced by CR-013's first-purchase check: Lighter-first vs Freighter-first, both reaching "own four ships" within 25 % of each other's time (Step 10M.5).
## 7. Amendments

View File

@ -0,0 +1,93 @@
# Change Request CR-013 — The Veteran's Clipper
> **Numbering note (added when implemented):** this request arrived titled "CR-010 — The Veteran's Clipper" for "Phase 10J", applying to "CR-001 through CR-009". CR-010 to CR-012 and Phases 10J–10L already existed, so it is stored as **CR-013** and done as **Phase 10M**: every "10J.n" below means **10M.n**. Its decisions 0.5.64 and 0.5.65 are recorded as **0.5.82 The veteran's Clipper** and **0.5.83 Deal loan**; its amended "0.5.52 Veteran skill" is this plan's **0.5.70 Veteran pilot**. Its "CR-007" (veteran pilot, steps 10G.n) is this repo's **CR-010** (steps 10J.n); its "CR-009" (new ships and range, step 10I.11) is **CR-012** (step 10L.11). The loan decision "CR-001 0.5.24" is plan §0.5.32.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10 and CR-001 through CR-009 complete
**Do this:** as **Phase 10J**, after Phase 10I and before Phase 11
**Related docs:** `change-request-faster-start.md` (CR-001), `change-request-veteran-pilot.md` (CR-007), `change-request-new-ships-and-range.md` (CR-009), `implementation-plan.md`, `game-design-document.md`, `DECISIONS.md`
---
## 1. Why
The player takes over the retiring owner's business, and CR-007 gave them his veteran pilot. Now that the Clipper exists (CR-009), the veteran brings the ship they fly. After the deal, the player has two crewed ships at Earth: the Courier flown by the avatar and the Clipper flown by the veteran.
This also changes a few things that assumed the player starts with one ship:
- The veteran's skill becomes **Expensive Cargo 1**. The Clipper's small hold can't carry most Heavy goods, but it suits small, valuable cargo (Medical Supplies, Electronics, Low-G Pharmaceuticals) flown fast.
- The Clipper is **part of the loan**: the player is buying the whole business. A free Mars-capable ship would be too big a shortcut.
- Two first-session goals and one timing target change, because "buy a second ship" and "fly with the veteran" no longer make sense.
- The first real buying decision becomes **Lighter or Freighter**.
## 2. The deal after this change
| Item | Before | After |
|---|---|---|
| Ships | Courier (avatar) | Courier (avatar) **and Clipper (veteran)**, both at Earth |
| Veteran | Unassigned at Earth, level 2, Heavy Cargo 1 | **Crewing the Clipper** at Earth, level 2, **Expensive Cargo 1** |
| HQ | Earth bay | Earth bay (unchanged) |
| Loan | Courier + Earth bay lease | **Courier + Clipper + Earth bay lease** |
The veteran remains a normal hired pilot in every other respect: profit-share pay (CR-007), duty and rest (CR-004), and assigning or unassigning only at a bay.
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers and names live in `packages/content`.
- Resetting the dev and test databases is fine.
- Update existing tests whose expectations change. Update `progress.md` after every step.
- Stop for the user's review at the end of Phase 10J.
## 4. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
- **0.5.64 The veteran's Clipper.** Accepting the deal also creates an owned Clipper at Earth with the veteran assigned to it.
- **0.5.52 (amended) Veteran skill.** The veteran starts at level 2 with **Expensive Cargo 1** (was Heavy Cargo 1).
- **0.5.65 Deal loan.** The deal loan amount is Courier price + Clipper price + Earth bay lease.
- **CR-001 goals (amended).** Goal 3 "Buy a second ship" becomes "Buy a ship from the Market". Goal 4 "Fly a contract with [veteran]" (CR-007) becomes "Complete a Priority contract".
- **CR-001 timing targets (amended).** "Buy a second ship: 30–45 minutes" (CR-007) becomes "Buy a ship from the Market: 45–75 minutes". "First discovery: under 90 minutes" stays. Add: the loan is paid off within roughly the first two sessions of play (target: under 4 hours of active play).
## 5. Steps — Phase 10J
### Step 10J.1 — Content: veteran skill and loan
- Change the veteran's starting skill to Expensive Cargo 1.
- Set the deal loan amount to Courier + Clipper + Earth bay lease (computed from content prices, not hard-coded).
- **Test:** The veteran's starting skill allocation is Expensive Cargo 1 and still fits a level-2 pilot's skill points. The deal loan amount equals the sum of the Courier price, Clipper price, and Earth bay lease, and changes automatically if any of those prices change.
### Step 10J.2 — Sim: goal changes
- Goal 3 completes when the player buys any ship from the Market (ships received in the deal don't count).
- Goal 4 completes when a Priority contract resolves, flown by any pilot.
- **Test:** Accepting the deal does not complete goal 3; buying a Lighter does. Resolving a non-Priority contract does not complete goal 4; resolving a Priority contract does.
### Step 10J.3 — Server: deal creates the Clipper
- In the accept-deal transaction (CR-001 Step 10A.8, CR-007 Step 10G.4), also create an owned Clipper at Earth and assign the veteran to it. The veteran is no longer left unassigned.
- **Test:** After accepting the deal, state has exactly two owned ships at Earth (Courier crewed by the avatar, Clipper crewed by the veteran), one Earth bay, the new loan amount, and the veteran at level 2 with Expensive Cargo 1. Both ships can be dispatched immediately. A failed or repeated accept creates no extra ships.
### Step 10J.4 — Client: deal modal
- The deal modal lists both ships and the veteran, for example: "Old Halvorsen is retiring. He'll sell you his Courier, his Clipper, and his Earth bay — and Rosa Kell, who's flown that Clipper for twelve years. Pay him back out of every job." Names come from content. Show the new loan amount.
- **Test:** Component test: the modal shows both ships, the veteran's name and bio, and a loan amount matching state.
### Step 10J.5 — Plan amendments and regression
- Apply the amendments in §6.
- Run the full suite.
- **Test:** CI is green. The balance script (once it exists) meets the amended timing targets, including the loan payoff target, and runs two policies for the first Market purchase, Lighter-first and Freighter-first; both reach "own four ships" within 25% of each other's time. Manual check: accept the deal on a fresh account, dispatch the Courier and the Clipper at the same time, and complete a Priority contract with the Clipper to unlock goal 4.
## 6. Amendments
| Where | Change |
|---|---|
| Plan 0.5 | Add 0.5.64 and 0.5.65; amend 0.5.52. |
| CR-001 10A.5 | Goal 3 text and condition changed (10J.2). |
| CR-001 10A.8, 10A.13 | Deal also includes the Clipper; larger loan; modal text (10J.3, 10J.4). |
| CR-001 §2 targets | First Market purchase target 45–75 minutes; loan payoff target added. |
| CR-007 10G.1 | Veteran's starting skill is Expensive Cargo 1 (10J.1). |
| CR-007 10G.3 | Goal 4 is now "Complete a Priority contract" (10J.2). |
| CR-007 10G.4 | Veteran starts crewing the Clipper, not unassigned (10J.3). |
| CR-007 10G.7 manual check | Replace the rest-and-swap walkthrough with the 10J.5 manual check. |
| CR-009 10I.11 | The second-ship balance check (Lighter-first vs Clipper-first) is replaced by the first-purchase check in 10J.5. |
| Plan 12.1 | Re-check all timing targets with the larger loan and two starting ships. |
## 7. Out of scope
Choosing which ship comes with the deal, starting with more than two ships, changing the Courier's role, and anything else in `implementation-plan.md` §0.3.

View File

@ -125,7 +125,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
67. **Models stay distinct** (CR-009). Content caps guarantee: a maxed Courier's capacity is below a base Freighter's; a maxed Bulk Hauler's speed is below a base Freighter's; a maxed Freighter's range is below a base Bulk Hauler's (so only Bulk Haulers reach Jupiter at its farthest). *(Amended by CR-012:)* also a maxed Clipper's capacity is below a base Freighter's; a maxed Courier's speed is below a base Clipper's; a maxed Lighter can't reach Mars from Earth or the Moon on any sampled date; "only Bulk Haulers reach Jupiter at its farthest" is measured from Earth.
68. **Courier to Mars** (CR-009). A Courier with maxed fuel tanks can reach Mars at and around closest approach, but not at Mars's farthest distance. Base and lower-level Couriers still can't reach Mars at all. *(Amended by CR-012:)* measured from Earth; from the Moon it reaches Mars more often, which is fine.
69. **Deduction order** (CR-010). On resolution: pilot pay is taken from the payout first; loan repayment (0.5.32) is calculated on what remains.
70. **Veteran pilot** (CR-010). Accepting the deal also creates the veteran (CR-010 §2): at Earth, unassigned, level 2 with Heavy Cargo 1. The veteran is a normal hired pilot in every respect except how they were acquired.
70. **Veteran pilot** (CR-010). Accepting the deal also creates the veteran (CR-010 §2): at Earth, unassigned, level 2 with Heavy Cargo 1. The veteran is a normal hired pilot in every respect except how they were acquired. *(Amended by CR-013:)* the veteran starts with **Expensive Cargo 1** (was Heavy Cargo 1) and crewing the deal's Clipper (0.5.82), not unassigned.
71. **No accounts yet** (CR-011). Players remain guests. Account linking (email, Apple, Google) stays deferred (§0.3).
72. **Backup code** (CR-011). Each player can have one backup code: random, at least 80 bits of entropy, shown as groups of letters and digits that avoid look-alike characters (no 0/O, 1/I/L). The server stores only a hash. Generating a new code invalidates the old one. Entering a valid code on any device signs that device into the same player. Restore attempts are rate-limited per IP and per device.
73. **Several devices** (CR-011). More than one device may be signed into the same player at once; the server is authoritative, so this is safe.
@ -137,6 +137,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
79. **No-strand rule** (CR-012). A dispatch (contract or deadhead) is rejected with `would-strand` if, at arrival plus a configured margin (48 game hours), the ship could not reach any other discovered location from the destination with its effective range from there.
80. **New ships** (CR-012). Lighter (short-haul heavy lifter) and Clipper (fast long-legged runner), purchasable, atmospheric.
81. **Reach targets** (CR-012). CR-012 §3's reach table is the source of truth for ship range values; ship ranges (not location factors) are adjusted so every cell holds.
82. **The veteran's Clipper** (CR-013). Accepting the deal also creates an owned Clipper at Earth with the veteran assigned to it.
83. **Deal loan** (CR-013). The deal loan amount is Courier price + Clipper price + Earth bay lease, computed from content.
---
@ -636,6 +638,12 @@ Steps 10L.1–10L.11 are specified in `memory-bank/change-requests/CR-012-lighte
---
## Phase 10M — The Veteran's Clipper (CR-013)
Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veterans-clipper.md` §5 (written there as 10J.1–10J.5), with its §6 amendments. Done after Phase 10L and before Phase 11; stop for review at the end.
---
## Phase 11 — PWA
### Step 11.1 — Installable PWA
@ -663,7 +671,8 @@ Steps 10L.1–10L.11 are specified in `memory-bank/change-requests/CR-012-lighte
- **Amended by CR-007:** The simulated player considers Priority contracts and skips ones its ships are too slow for. Re-check all timing targets; adjust content values, not thresholds.
- **Amended by CR-009:** The simulated player may buy upgrades: when it can't yet afford its next planned ship, it buys the cheapest available upgrade on its most-used ship, preferring cargo bay and engines. Re-check all timing targets; adjust content values, not thresholds.
- **Amended by CR-010:** Replace "first pilot hired between 30 and 45 minutes" with "second ship bought between 30 and 45 minutes". Add a check that the veteran flies at least one contract in the first session. Re-check all targets with profit-share pay; adjust content values, not thresholds.
- **Amended by CR-012:** The script runs two second-ship policies, Lighter-first and Clipper-first; both must reach "own three ships" within 25 % of each other's time.
- **Amended by CR-012:** The script runs two second-ship policies, Lighter-first and Clipper-first; both must reach "own three ships" within 25 % of each other's time. *(Replaced by CR-013, below.)*
- **Amended by CR-013:** The player starts with two ships (Courier and Clipper) and a 66,600 cr loan; re-check every timing target with them. Targets: first Market purchase 45–75 minutes, first discovery under 90 minutes, loan paid off in under 4 hours of active play. Run two first-purchase policies, Lighter-first and Freighter-first; both must reach "own four ships" within 25 % of each other's time.
### Step 12.2 — Revisit the tutorial economy test
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.

View File

@ -282,3 +282,15 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as
- [x] **Step 10L.11 — Plan amendments and regression.** CR-012 §7 applied: plan 0.2 (five models), 2.1, 2.3, 3.7, 3.12, 9.8 and 12.1 (Lighter-first and Clipper-first policies, for when the balance script exists); CR-001 10A.16, CR-003 10C.1 and CR-009 10F.1 notes (decisions 0.5.67, 0.5.68 and 0.5.25 were amended in 10L.4–10L.5). Full suite 938 passing, sim lines 99.75 %. Scripted manual check (temporary server): after the deal, buy a Clipper at Earth, crew it with the veteran, wait for a first-contact Earth→Mars job in range (62–95M km), deliver it (Mars discovered), spend the veteran's points on Long Haul, then take a first-contact Mars→Ceres job (186M km, inside 198 × 1.15) and deliver it (Ceres discovered).
**Phase 10L complete — waiting for review.**
## Phase 10M — The Veteran's Clipper (CR-013)
Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-010 … Phase 10J"). New decisions recorded as plan §0.5.82–0.5.83; §0.5.70 amended.
- [x] **Step 10M.1 — Content: veteran skill and loan.** The veteran starts with **Expensive Cargo 1**. Config `dealLoanAmount` (5,000) is replaced by `deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }`, and the loan is computed by `dealLoanAmount({ config, shipModels, locations })` (`packages/content/src/deal.ts`), exposed as `content.dealLoanAmount`: 4,000 + 61,600 + 1,000 = **66,600 cr**. All users (server deal, loan card, deal modal, tests) read `content.dealLoanAmount`. Tests: config (deal ids; loan = Courier + Clipper + Earth lease; it moves by exactly the change when either price or the lease changes), veteran skills (content and server).
- [x] **Step 10M.2 — Sim: goal changes.** Goal 3 is now `market-ship` "Buy a ship from the Market" (condition `market-ships-bought` 1) and goal 4 `priority-contract` "Complete a Priority contract" (`priority-deliveries` 1, any pilot); the `veteran-contracts` condition is gone. `GoalProgress` gains `marketShipsBought` and `priorityDeliveries` (drops `veteranContracts`). Server: `ships.from_deal` (migration 0009, backfilling each player's first ship), set on the deal's ship; goal progress counts non-deal ships and resolved non-tutorial dispatches whose contract is Priority. Tests: sim (the deal's two ships don't complete goal 3, a bought Lighter does; Standard deliveries don't complete goal 4, a Priority one does), server goals (buying a Lighter; a Standard then a Priority delivery), web goal list ids.
- [x] **Step 10M.3 — Server: deal creates the Clipper.** `acceptDeal` reads `config.deal` and inserts both deal ships at Earth (Courier, then Clipper, consecutive seeded names, `from_deal`), assigns the avatar to the Courier and the veteran to the Clipper (location follows the ship), sets the 66,600 cr loan. Tests: `deal.test.ts` (two crewed ships at Earth, one bay, the loan; two simultaneous accepts still give two ships, not four; refused and repeated accepts change nothing), `veteran.test.ts` (veteran crewing the Clipper, level 2, Expensive Cargo 1; the Courier and the Clipper dispatched at the same time, the veteran's run paying their profit share).
- [x] **Step 10M.4 — Client: deal modal.** The modal shows both ship sprites and reads "Old Halvorsen is retiring. He'll sell you his Courier, his Clipper, and his Earth bay — and Rosa Kell, who flies that Clipper. Pay him back out of every job."; "You get" lists "A Courier for you, a Clipper for Rosa Kell, and a bay at Earth"; the Pilot row keeps name and bio. Ship, bay and pilot names come from content (`config.deal`, `veteranPilot`); the veteran's bio is now "Has flown Halvorsen's Clipper for twelve years." Tests (`deal-modal.test.tsx`): both ships, the veteran's name and bio, and the loan offered equal to the loan in the after-deal state.
- [x] **Step 10M.5 — Plan amendments and regression.** CR-013 §6 applied: CR-001 §2 targets (first Market purchase 45–75 min, loan payoff under 4 h) and Steps 10A.5, 10A.8, 10A.13; CR-010 Steps 10G.1, 10G.3, 10G.4, 10G.7 (manual check replaced); CR-012 Step 10I.11 (balance policies replaced); plan Step 12.1 (re-check targets with two ships and the larger loan; Lighter-first vs Freighter-first to "own four ships"). Full suite 941 passing. Scripted manual check on a fresh account: deal gives Courier (avatar) + Clipper (Rosa Kell, Expensive Cargo 1) and a 66,600 cr loan; goals 3 and 4 incomplete; Courier and Clipper in flight at the same time (Clipper on a Priority Mail job to the Moon); on delivery goal 4 is ready. **Needed a 1,000 cr developer top-up:** after the deal the player has ~890 cr, and fuel for both ships Earth→Moon is ~921 cr (Courier 230 + Clipper 691).
**Phase 10M complete — waiting for review.**

View File

@ -1,5 +1,6 @@
import { cargoTypes, skills } from "./data/cargo";
import { gameConfig } from "./data/config";
import { dealLoanAmount } from "./deal";
import { goods } from "./data/goods";
import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts, veteranPilot } from "./data/onboarding";
@ -26,6 +27,8 @@ export const content = {
onboardingGoals,
veteranPilot,
upgrades,
/** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */
dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }),
location: byId(locations),
shipModel: byId(shipModels),
cargoType: byId(cargoTypes),

View File

@ -21,8 +21,9 @@ export const gameConfig: GameConfig = {
// 20 t adds 0.4 game hours (24 real seconds) of loading and unloading.
handlingGameHoursPerTonne: 0.02,
loanRepaymentShare: 0.3,
// Courier price + Earth bay lease.
dealLoanAmount: 5000,
// The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the
// veteran, and the Earth bay. The loan is computed from their prices.
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
contractBoardPeriodRealMinutes: 10,
// A ship must be able to fly out of its destination two game days after arriving (CR-012).
noStrandMarginGameHours: 48,

View File

@ -15,14 +15,15 @@ export const scriptedTutorialContracts: ScriptedTutorialContract[] = [
/** First-session goals, in the order they are shown (decision 0.5.34). Rewards are first guesses. */
/**
* Halvorsen's last hire, who comes with the deal (decision 0.5.70): level 2,
* one point already in Heavy Cargo, the rest unspent.
* Halvorsen's last hire, who comes with the deal crewing his Clipper
* (decisions 0.5.70, 0.5.82): level 2, one point already in Expensive Cargo
* (small, valuable loads suit the Clipper's hold), the rest unspent.
*/
export const veteranPilot: VeteranPilot = {
name: "Rosa Kell",
bio: "Halvorsen's pilot for twelve years.",
bio: "Has flown Halvorsen's Clipper for twelve years.",
level: 2,
skills: { "heavy-cargo": 1 },
skills: { "expensive-cargo": 1 },
};
export const onboardingGoals: OnboardingGoal[] = [
@ -39,16 +40,17 @@ export const onboardingGoals: OnboardingGoal[] = [
reward: 300,
},
{
id: "second-ship",
title: "Buy a second ship",
condition: { kind: "ships-owned", count: 2 },
// Was "Buy a second ship"; the deal now brings two (CR-013).
id: "market-ship",
title: "Buy a ship from the Market",
condition: { kind: "market-ships-bought", count: 1 },
reward: 1000,
},
{
// Replaces "Hire your first pilot" (CR-010): the veteran comes with the deal.
id: "veteran-contract",
title: `Fly a contract with ${veteranPilot.name}`,
condition: { kind: "veteran-contracts", count: 1 },
// Was "Fly a contract with" the veteran (CR-010, replaced by CR-013).
id: "priority-contract",
title: "Complete a Priority contract",
condition: { kind: "priority-deliveries", count: 1 },
reward: 1000,
},
{

View File

@ -0,0 +1,21 @@
import type { GameConfig, Location, ShipModel } from "./schemas";
/**
* The deal's loan (decision 0.5.83): the price of every ship it hands over plus
* the lease of its bay, read from content so it follows any price change.
*/
export function dealLoanAmount(data: {
config: Pick<GameConfig, "deal">;
shipModels: readonly ShipModel[];
locations: readonly Location[];
}): number {
const { avatarShipModelId, veteranShipModelId, bayLocationId } = data.config.deal;
const price = (id: string) => {
const model = data.shipModels.find((m) => m.id === id);
if (!model) throw new Error(`Unknown deal ship model ${id}`);
return model.price;
};
const bay = data.locations.find((l) => l.id === bayLocationId);
if (!bay) throw new Error(`Unknown deal bay location ${bayLocationId}`);
return price(avatarShipModelId) + price(veteranShipModelId) + bay.bayLeasePrice;
}

View File

@ -7,3 +7,4 @@ export * from "./data/config";
export * from "./data/onboarding";
export * from "./data/upgrades";
export * from "./bundle";
export * from "./deal";

View File

@ -210,8 +210,15 @@ export const gameConfigSchema = z
handlingGameHoursPerTonne: z.number().nonnegative(),
/** Share of each positive payout the lender takes until the loan is repaid (0.5.32). */
loanRepaymentShare: fraction,
/** Loan taken on with the deal, in credits (decision 0.5.30). */
dealLoanAmount: positiveInt,
/**
* What the deal hands over (decisions 0.5.30, 0.5.82): the avatar's ship, the
* veteran's ship and the bay. The loan is their prices plus the lease (0.5.83).
*/
deal: z.object({
avatarShipModelId: z.string().min(1),
veteranShipModelId: z.string().min(1),
bayLocationId: z.string().min(1),
}),
contractBoardPeriodRealMinutes: z.number().positive(),
/** No-strand check: the ship must have a way out this long after arrival (decision 0.5.79). */
noStrandMarginGameHours: z.number().nonnegative(),
@ -315,9 +322,11 @@ export const goalConditionSchema = z.discriminatedUnion("kind", [
/** Skill points spent across all pilots. */
z.object({ kind: z.literal("skill-points-spent"), count: positiveInt }),
z.object({ kind: z.literal("ships-owned"), count: positiveInt }),
/** Ships bought from the Market; the deal's ships don't count (CR-013). */
z.object({ kind: z.literal("market-ships-bought"), count: positiveInt }),
z.object({ kind: z.literal("pilots-hired"), count: positiveInt }),
/** Contracts (not deadheads) delivered by the veteran pilot (CR-010). */
z.object({ kind: z.literal("veteran-contracts"), count: positiveInt }),
/** Priority contracts delivered, by any pilot (CR-013). */
z.object({ kind: z.literal("priority-deliveries"), count: positiveInt }),
/** Locations discovered beyond the ones that start discovered. */
z.object({ kind: z.literal("locations-discovered"), count: positiveInt }),
/** The deal's loan is fully repaid. */

View File

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
cargoTypes,
content,
dealLoanAmount,
gameConfig,
gameConfigSchema,
locations,
@ -51,11 +53,33 @@ describe("game config", () => {
expect(gameConfig.tutorialMassTonnes.full).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
});
it("lends the Courier price plus an Earth bay lease in the deal", () => {
const courier = shipModels.find((s) => s.id === "courier")!;
it("lends the Courier and Clipper prices plus an Earth bay lease in the deal (0.5.83)", () => {
const price = (id: string) => shipModels.find((s) => s.id === id)!.price;
const earth = locations.find((l) => l.id === "earth")!;
expect(gameConfig.dealLoanAmount).toBeGreaterThan(0);
expect(gameConfig.dealLoanAmount).toBe(courier.price + earth.bayLeasePrice);
expect(gameConfig.deal).toEqual({
avatarShipModelId: "courier",
veteranShipModelId: "clipper",
bayLocationId: "earth",
});
expect(content.dealLoanAmount).toBe(price("courier") + price("clipper") + earth.bayLeasePrice);
});
it("recomputes the deal loan when any of those prices change", () => {
const base = dealLoanAmount({ config: gameConfig, shipModels, locations });
const bump = (id: string) =>
shipModels.map((s) => (s.id === id ? { ...s, price: s.price + 100 } : s));
expect(dealLoanAmount({ config: gameConfig, shipModels: bump("courier"), locations })).toBe(
base + 100,
);
expect(dealLoanAmount({ config: gameConfig, shipModels: bump("clipper"), locations })).toBe(
base + 100,
);
const pricierEarth = locations.map((l) =>
l.id === "earth" ? { ...l, bayLeasePrice: l.bayLeasePrice + 7 } : l,
);
expect(dealLoanAmount({ config: gameConfig, shipModels, locations: pricierEarth })).toBe(
base + 7,
);
});
it("has a contract mass band each purchasable ship can carry", () => {

View File

@ -53,8 +53,8 @@ describe("onboarding goals", () => {
expect(onboardingGoals.map((g) => g.condition.kind)).toEqual([
"own-ship-deliveries",
"skill-points-spent",
"ships-owned",
"veteran-contracts",
"market-ships-bought",
"priority-deliveries",
"locations-discovered",
"loan-paid-off",
]);

View File

@ -109,7 +109,7 @@ const validGameConfig = {
handlingGameHoursPerTonne: 0.02,
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
loanRepaymentShare: 0.3,
dealLoanAmount: 5000,
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
};
const validScripted = {

View File

@ -28,6 +28,6 @@ describe("veteran pilot and profit share (CR-010)", () => {
expect(skill, skillId).toBeDefined();
expect(level).toBeLessThanOrEqual(skill!.maxLevel);
}
expect(veteranPilot.skills).toEqual({ "heavy-cargo": 1 });
expect(veteranPilot.skills).toEqual({ "expensive-cargo": 1 });
});
});

View File

@ -7,10 +7,12 @@ export interface GoalProgress {
/** Skill points spent across all pilots. */
skillPointsSpent: number;
shipsOwned: number;
/** Ships bought from the Market, not received in the deal (CR-013). */
marketShipsBought: number;
/** Pilots hired (the avatar excluded). */
pilotsHired: number;
/** Resolved contract deliveries (not deadheads) flown by the veteran (CR-010). */
veteranContracts: number;
/** Resolved Priority contract deliveries, by any pilot (CR-013). */
priorityDeliveries: number;
/** Locations discovered beyond the ones that start discovered. */
locationsDiscovered: number;
dealAccepted: boolean;
@ -27,8 +29,10 @@ export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgr
return progress.shipsOwned >= condition.count;
case "pilots-hired":
return progress.pilotsHired >= condition.count;
case "veteran-contracts":
return progress.veteranContracts >= condition.count;
case "market-ships-bought":
return progress.marketShipsBought >= condition.count;
case "priority-deliveries":
return progress.priorityDeliveries >= condition.count;
case "locations-discovered":
return progress.locationsDiscovered >= condition.count;
case "loan-paid-off":

View File

@ -4,16 +4,17 @@ import { completedGoals, type GoalProgress } from "../src/index";
const goals = content.onboardingGoals;
/** Just after the deal: a ship, an Earth bay and the full loan. */
/** Just after the deal: the Courier and the Clipper, an Earth bay and the full loan (CR-013). */
const fresh: GoalProgress = {
ownShipDeliveries: 0,
skillPointsSpent: 0,
shipsOwned: 1,
pilotsHired: 0,
veteranContracts: 0,
shipsOwned: 2,
marketShipsBought: 0,
pilotsHired: 1,
priorityDeliveries: 0,
locationsDiscovered: 0,
dealAccepted: true,
loanBalance: content.config.dealLoanAmount,
loanBalance: content.dealLoanAmount,
};
const isComplete = (id: string, progress: GoalProgress) =>
@ -23,8 +24,8 @@ const isComplete = (id: string, progress: GoalProgress) =>
const cases: [string, Partial<GoalProgress>, Partial<GoalProgress>][] = [
["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }],
["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }],
["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }],
["veteran-contract", { veteranContracts: 0 }, { veteranContracts: 1 }],
["market-ship", { marketShipsBought: 0 }, { marketShipsBought: 1, shipsOwned: 3 }],
["priority-contract", { priorityDeliveries: 0 }, { priorityDeliveries: 1 }],
["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }],
["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }],
];
@ -42,13 +43,20 @@ describe("onboarding goal evaluation", () => {
},
);
it("needs a contract flown by the veteran, not just the avatar's deliveries (CR-010)", () => {
it("doesn't count the deal's two ships as a Market purchase; a bought Lighter does (CR-013)", () => {
expect(isComplete("market-ship", fresh)).toBe(false);
expect(goals.find((g) => g.id === "market-ship")?.title).toBe("Buy a ship from the Market");
// After buying a Lighter: three ships, one of them from the Market.
expect(isComplete("market-ship", { ...fresh, shipsOwned: 3, marketShipsBought: 1 })).toBe(true);
});
it("needs a Priority delivery, not just any deliveries (CR-013)", () => {
expect(isComplete("priority-contract", { ...fresh, ownShipDeliveries: 12 })).toBe(false);
expect(
isComplete("veteran-contract", { ...fresh, ownShipDeliveries: 12, pilotsHired: 1 }),
).toBe(false);
expect(isComplete("veteran-contract", { ...fresh, veteranContracts: 1 })).toBe(true);
expect(goals.find((g) => g.id === "veteran-contract")?.title).toBe(
`Fly a contract with ${content.veteranPilot.name}`,
isComplete("priority-contract", { ...fresh, ownShipDeliveries: 1, priorityDeliveries: 1 }),
).toBe(true);
expect(goals.find((g) => g.id === "priority-contract")?.title).toBe(
"Complete a Priority contract",
);
});
@ -59,8 +67,8 @@ describe("onboarding goal evaluation", () => {
});
it("returns completed goals in content order, identically for identical inputs", () => {
const progress: GoalProgress = { ...fresh, veteranContracts: 3, ownShipDeliveries: 5 };
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "veteran-contract"]);
const progress: GoalProgress = { ...fresh, priorityDeliveries: 3, ownShipDeliveries: 5 };
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "priority-contract"]);
expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress }));
});
});