Step 10M.2: goals for a Market purchase and a Priority delivery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 11:43:38 -04:00
parent b8ff9ba47a
commit 979a1ad1a8
18 changed files with 1612 additions and 87 deletions

View File

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

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

@ -59,6 +59,7 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime)
modelId: DEAL_SHIP_MODEL_ID,
name: shipName(player.worldSeed, owned!.n),
locationId: DEAL_BAY_LOCATION_ID,
fromDeal: true,
createdAt: now,
});
await tx.insert(bays).values({

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

@ -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();
@ -108,40 +99,5 @@ describe("the veteran comes with the deal (Step 10J.4)", () => {
const pay = pilotShare(job.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");
});
});

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

@ -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

@ -288,7 +288,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as
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).
- [ ] Step 10M.2 — Sim: goal changes
- [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
- [ ] Step 10M.4 — Client: deal modal
- [ ] Step 10M.5 — Plan amendments and regression

View File

@ -40,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

@ -322,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

@ -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

@ -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,13 +4,14 @@ 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.dealLoanAmount,
@ -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 }));
});
});