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:
parent
979a1ad1a8
commit
256fb7c464
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
[
|
||||||
|
{ id: shipId, modelId: avatarShipModelId },
|
||||||
|
{ id: clipperId, modelId: veteranShipModelId },
|
||||||
|
].map((ship, i) => ({
|
||||||
|
...ship,
|
||||||
playerId,
|
playerId,
|
||||||
modelId: DEAL_SHIP_MODEL_ID,
|
name: shipName(player.worldSeed, owned!.n + i),
|
||||||
name: shipName(player.worldSeed, owned!.n),
|
locationId: bayLocationId,
|
||||||
locationId: DEAL_BAY_LOCATION_ID,
|
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: now,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
expect(
|
|
||||||
(await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id }))
|
|
||||||
.statusCode,
|
|
||||||
).toBe(200);
|
|
||||||
|
|
||||||
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",
|
kind: "contract",
|
||||||
contractId: job.id,
|
contractId,
|
||||||
shipId: courier.id,
|
shipId,
|
||||||
pilotId: veteran.id,
|
pilotId,
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(200);
|
const avatarRun = await send(forCourier.id, courier!.id, guest.avatarId);
|
||||||
const { dispatch } = dispatchResponseSchema.parse(response.json());
|
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);
|
||||||
|
|
||||||
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 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue