Step 10M.3: the deal hands over the veteran's Clipper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 11:53:50 -04:00
parent 979a1ad1a8
commit 256fb7c464
5 changed files with 76 additions and 53 deletions

View File

@ -1217,3 +1217,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"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). - **"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. - **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). - **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.

View File

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

View File

@ -35,7 +35,7 @@ async function snapshot(guest: Guest) {
} }
describe("accepting the deal", () => { 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); const guest = await signUp(testApp().app);
await readyForDeal(guest); await readyForDeal(guest);
expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true); expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true);
@ -45,14 +45,23 @@ describe("accepting the deal", () => {
const state = playerStateSchema.parse(response.json()); const state = playerStateSchema.parse(response.json());
expect(state.tutorial).toBeNull(); expect(state.tutorial).toBeNull();
expect(state.ships).toHaveLength(1); // CR-013: the Courier for the avatar and the Clipper for the veteran.
const [ship] = state.ships; expect(state.ships).toHaveLength(2);
const [ship, clipper] = state.ships;
expect(ship).toMatchObject({ expect(ship).toMatchObject({
modelId: "courier", modelId: "courier",
status: "idle", status: "idle",
locationId: "earth", locationId: "earth",
pilotId: guest.avatarId, 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.bays.map((b) => b.locationId)).toEqual(["earth"]);
expect(state.pilots[0]).toMatchObject({ expect(state.pilots[0]).toMatchObject({
isAvatar: true, isAvatar: true,
@ -104,7 +113,7 @@ describe("accepting the deal", () => {
const responses = await Promise.all([accept(guest), accept(guest)]); const responses = await Promise.all([accept(guest), accept(guest)]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const after = await snapshot(guest); const after = await snapshot(guest);
expect(after.ships).toHaveLength(1); expect(after.ships).toHaveLength(2);
expect(after.bays).toHaveLength(1); expect(after.bays).toHaveLength(1);
expect(after.company!.loanBalance).toBe(content.dealLoanAmount); expect(after.company!.loanBalance).toBe(content.dealLoanAmount);
}); });

View File

@ -32,11 +32,12 @@ async function dealt(): Promise<{ guest: Guest; state: PlayerState }> {
} }
describe("the veteran comes with the deal (Step 10J.4)", () => { describe("the veteran comes with the deal (Step 10J.4)", () => {
it("adds the veteran at Earth, unassigned, level 2 with Expensive 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(); const { state } = await dealt();
expect(state.pilots).toHaveLength(2); expect(state.pilots).toHaveLength(2);
const courier = state.ships[0]!; const [courier, clipper] = state.ships;
expect(avatarOf(state)).toMatchObject({ assignedShipId: courier.id, locationId: "earth" }); expect(clipper!.modelId).toBe("clipper");
expect(avatarOf(state)).toMatchObject({ assignedShipId: courier!.id, locationId: "earth" });
const spentPoints = 1; const spentPoints = 1;
expect(veteranOf(state)).toMatchObject({ expect(veteranOf(state)).toMatchObject({
name: content.veteranPilot.name, name: content.veteranPilot.name,
@ -45,7 +46,7 @@ describe("the veteran comes with the deal (Step 10J.4)", () => {
level: 2, level: 2,
skills: { "expensive-cargo": 1 }, skills: { "expensive-cargo": 1 },
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints, skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
assignedShipId: null, assignedShipId: clipper!.id,
locationId: "earth", locationId: "earth",
status: "idle", status: "idle",
}); });
@ -64,40 +65,39 @@ describe("the veteran comes with the deal (Step 10J.4)", () => {
expect(pilots.filter((p) => p.isVeteran)).toHaveLength(1); 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 { app, clock } = testApp();
const { guest, state } = await dealt(); const { guest, state } = await dealt();
const courier = state.ships[0]!; const [courier, clipper] = state.ships;
const veteran = veteranOf(state); const veteran = veteranOf(state);
await setCredits(database().db, guest.playerId, 10_000); await setCredits(database().db, guest.playerId, 10_000);
expect( const forCourier = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/rest`, {})).statusCode, const forClipper = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
).toBe(200); await putBoard(database().db, guest.playerId, [forCourier, forClipper], clock.now);
expect( const send = (contractId: string, shipId: string, pilotId: string) =>
(await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {})).statusCode, call(app, guest, "POST", "/api/dispatches", {
).toBe(200); kind: "contract",
expect( contractId,
(await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id })) shipId,
.statusCode, pilotId,
).toBe(200); });
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 }); const { dispatch } = dispatchResponseSchema.parse(veteranRun.json());
await putBoard(database().db, guest.playerId, [job], clock.now); clock.now = Math.max(
const response = await call(app, guest, "POST", "/api/dispatches", { dispatch.arrivalAt,
kind: "contract", dispatchResponseSchema.parse(avatarRun.json()).dispatch.arrivalAt,
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 after = await getState(app, guest); const after = await getState(app, guest);
const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!; 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(pay).toBeGreaterThan(0);
expect(summary.credits + summary.loanRepayment).toBe(job.payout - pay); expect(summary.pilotPay).toBe(pay);
expect(summary.credits + summary.loanRepayment).toBe(forClipper.payout - pay);
}); });
}); });

View File

@ -289,6 +289,6 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-
- [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.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.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.
- [ ] Step 10M.3 — Server: deal creates the Clipper - [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).
- [ ] Step 10M.4 — Client: deal modal - [ ] Step 10M.4 — Client: deal modal
- [ ] Step 10M.5 — Plan amendments and regression - [ ] Step 10M.5 — Plan amendments and regression