diff --git a/DECISIONS.md b/DECISIONS.md index b452fbc..dc6855a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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). - **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. diff --git a/apps/server/src/game/deal.ts b/apps/server/src/game/deal.ts index 2a5b8c4..2590f55 100644 --- a/apps/server/src/game/deal.ts +++ b/apps/server/src/game/deal.ts @@ -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 { @@ -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 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, - fromDeal: true, - 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, @@ -72,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]!; @@ -85,7 +91,9 @@ 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 diff --git a/apps/server/test/deal.test.ts b/apps/server/test/deal.test.ts index 3dd8b3e..cc32bb6 100644 --- a/apps/server/test/deal.test.ts +++ b/apps/server/test/deal.test.ts @@ -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,14 +45,23 @@ 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, @@ -104,7 +113,7 @@ 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.dealLoanAmount); }); diff --git a/apps/server/test/veteran.test.ts b/apps/server/test/veteran.test.ts index 475d611..9dc1ac5 100644 --- a/apps/server/test/veteran.test.ts +++ b/apps/server/test/veteran.test.ts @@ -32,11 +32,12 @@ 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 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(); 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, @@ -45,7 +46,7 @@ describe("the veteran comes with the deal (Step 10J.4)", () => { level: 2, skills: { "expensive-cargo": 1 }, skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints, - assignedShipId: null, + assignedShipId: clipper!.id, locationId: "earth", status: "idle", }); @@ -64,40 +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(summary.pilotPay).toBe(pay); + expect(summary.credits + summary.loanRepayment).toBe(forClipper.payout - pay); }); }); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index f4b6e93..8e3c89e 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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.5 — Plan amendments and regression