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:
parent
b8ff9ba47a
commit
979a1ad1a8
|
|
@ -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 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 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.
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -64,6 +64,13 @@
|
||||||
"when": 1791171500580,
|
"when": 1791171500580,
|
||||||
"tag": "0008_cr011_backup_codes",
|
"tag": "0008_cr011_backup_codes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791214535055,
|
||||||
|
"tag": "0009_cr013_ship_from_deal",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +76,8 @@ export const ships = pgTable(
|
||||||
*/
|
*/
|
||||||
shopUpgradeId: text("shop_upgrade_id"),
|
shopUpgradeId: text("shop_upgrade_id"),
|
||||||
shopLevel: integer("shop_level"),
|
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(),
|
createdAt: time("created_at").notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index("ships_player_idx").on(t.playerId)],
|
(t) => [index("ships_player_idx").on(t.playerId)],
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime)
|
||||||
modelId: DEAL_SHIP_MODEL_ID,
|
modelId: DEAL_SHIP_MODEL_ID,
|
||||||
name: shipName(player.worldSeed, owned!.n),
|
name: shipName(player.worldSeed, owned!.n),
|
||||||
locationId: DEAL_BAY_LOCATION_ID,
|
locationId: DEAL_BAY_LOCATION_ID,
|
||||||
|
fromDeal: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
await tx.insert(bays).values({
|
await tx.insert(bays).values({
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,16 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
|
||||||
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
|
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Contracts (not deadheads or tutorial runs) the veteran has delivered (CR-010).
|
// Priority contracts delivered, by any pilot (CR-013).
|
||||||
const [veteranContracts] = await tx
|
const [priorityDeliveries] = await tx
|
||||||
.select({ n: count() })
|
.select({ n: count() })
|
||||||
.from(dispatches)
|
.from(dispatches)
|
||||||
.innerJoin(pilots, eq(pilots.id, dispatches.pilotId))
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(dispatches.playerId, playerId),
|
eq(dispatches.playerId, playerId),
|
||||||
eq(dispatches.status, "resolved"),
|
eq(dispatches.status, "resolved"),
|
||||||
eq(dispatches.tutorial, false),
|
eq(dispatches.tutorial, false),
|
||||||
eq(pilots.isVeteran, true),
|
sql`(${dispatches.record} -> 'contract' ->> 'priority') = 'true'`,
|
||||||
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const [spent] = await tx
|
const [spent] = await tx
|
||||||
|
|
@ -51,6 +49,11 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
|
||||||
.from(pilots)
|
.from(pilots)
|
||||||
.where(eq(pilots.playerId, playerId));
|
.where(eq(pilots.playerId, playerId));
|
||||||
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));
|
||||||
|
// 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
|
const [hired] = await tx
|
||||||
.select({ n: count() })
|
.select({ n: count() })
|
||||||
.from(pilots)
|
.from(pilots)
|
||||||
|
|
@ -68,8 +71,9 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
|
||||||
ownShipDeliveries: deliveries!.n,
|
ownShipDeliveries: deliveries!.n,
|
||||||
skillPointsSpent: Number(spent?.n ?? 0),
|
skillPointsSpent: Number(spent?.n ?? 0),
|
||||||
shipsOwned: owned!.n,
|
shipsOwned: owned!.n,
|
||||||
|
marketShipsBought: bought!.n,
|
||||||
pilotsHired: hired!.n,
|
pilotsHired: hired!.n,
|
||||||
veteranContracts: veteranContracts!.n,
|
priorityDeliveries: priorityDeliveries!.n,
|
||||||
locationsDiscovered: discovered!.n,
|
locationsDiscovered: discovered!.n,
|
||||||
dealAccepted: player.dealAccepted,
|
dealAccepted: player.dealAccepted,
|
||||||
loanBalance: company.loanBalance,
|
loanBalance: company.loanBalance,
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ export async function makeOwner(
|
||||||
modelId: "courier",
|
modelId: "courier",
|
||||||
name: "Test Courier",
|
name: "Test Courier",
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
|
// Stands in for the deal's Courier (not a Market purchase, CR-013).
|
||||||
|
fromDeal: true,
|
||||||
createdAt: TEST_NOW,
|
createdAt: TEST_NOW,
|
||||||
});
|
});
|
||||||
await tx
|
await tx
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
insertDispatch,
|
insertDispatch,
|
||||||
signUp,
|
signUp,
|
||||||
signUpOwner,
|
signUpOwner,
|
||||||
|
testContract,
|
||||||
useTestApp,
|
useTestApp,
|
||||||
type Guest,
|
type Guest,
|
||||||
} from "./game";
|
} from "./game";
|
||||||
|
|
@ -48,7 +49,7 @@ describe("onboarding goals", () => {
|
||||||
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
|
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
|
||||||
// Paid-off loan: the test owner has no balance.
|
// 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 === "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 () => {
|
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");
|
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 () => {
|
it("counts skill points spent", async () => {
|
||||||
const owner = await dealOwner();
|
const owner = await dealOwner();
|
||||||
await database()
|
await database()
|
||||||
|
|
@ -93,7 +125,7 @@ describe("onboarding goals", () => {
|
||||||
it("refuses an incomplete goal and an unknown one", async () => {
|
it("refuses an incomplete goal and an unknown one", async () => {
|
||||||
const owner = await dealOwner();
|
const owner = await dealOwner();
|
||||||
await setCredits(database().db, owner.playerId, 100);
|
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(response.statusCode).toBe(409);
|
||||||
expect(errorCode(response)).toBe("goal-not-ready");
|
expect(errorCode(response)).toBe("goal-not-ready");
|
||||||
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);
|
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,7 @@ import {
|
||||||
import { pilotShare } from "@deadhead/sim";
|
import { pilotShare } from "@deadhead/sim";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { setCredits, useTestDatabase } from "./db";
|
import { setCredits, useTestDatabase } from "./db";
|
||||||
import {
|
import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game";
|
||||||
call,
|
|
||||||
getState,
|
|
||||||
insertDispatch,
|
|
||||||
putBoard,
|
|
||||||
signUp,
|
|
||||||
testContract,
|
|
||||||
useTestApp,
|
|
||||||
type Guest,
|
|
||||||
} from "./game";
|
|
||||||
import { flyTutorialRun } from "./tutorial-flow";
|
import { flyTutorialRun } from "./tutorial-flow";
|
||||||
|
|
||||||
const database = useTestDatabase();
|
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);
|
const pay = pilotShare(job.payout, dispatch.fuelCost, content.config);
|
||||||
expect(pay).toBeGreaterThan(0);
|
expect(pay).toBeGreaterThan(0);
|
||||||
expect(summary.credits + summary.loanRepayment).toBe(job.payout - pay);
|
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe("goals on HQ", () => {
|
||||||
expect(items[0]).toHaveTextContent(
|
expect(items[0]).toHaveTextContent(
|
||||||
`+${content.onboardingGoal("first-own-delivery")!.reward.toLocaleString("en-US")} cr`,
|
`+${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 () => {
|
it("shows Claim only on ready goals", async () => {
|
||||||
|
|
@ -56,11 +56,11 @@ describe("goals on HQ", () => {
|
||||||
});
|
});
|
||||||
renderApp(api);
|
renderApp(api);
|
||||||
const card = await screen.findByRole("region", { name: "Goals" });
|
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" }));
|
await userEvent.click(within(card).getByRole("button", { name: "Claim" }));
|
||||||
expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" });
|
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"));
|
expect(card).not.toHaveTextContent(title("first-own-delivery"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
| `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` |
|
| `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` |
|
| `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) |
|
| `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) |
|
| `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) |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.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.3 — Server: deal creates the Clipper
|
||||||
- [ ] 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
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,17 @@ export const onboardingGoals: OnboardingGoal[] = [
|
||||||
reward: 300,
|
reward: 300,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "second-ship",
|
// Was "Buy a second ship"; the deal now brings two (CR-013).
|
||||||
title: "Buy a second ship",
|
id: "market-ship",
|
||||||
condition: { kind: "ships-owned", count: 2 },
|
title: "Buy a ship from the Market",
|
||||||
|
condition: { kind: "market-ships-bought", count: 1 },
|
||||||
reward: 1000,
|
reward: 1000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Replaces "Hire your first pilot" (CR-010): the veteran comes with the deal.
|
// Was "Fly a contract with" the veteran (CR-010, replaced by CR-013).
|
||||||
id: "veteran-contract",
|
id: "priority-contract",
|
||||||
title: `Fly a contract with ${veteranPilot.name}`,
|
title: "Complete a Priority contract",
|
||||||
condition: { kind: "veteran-contracts", count: 1 },
|
condition: { kind: "priority-deliveries", count: 1 },
|
||||||
reward: 1000,
|
reward: 1000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -322,9 +322,11 @@ export const goalConditionSchema = z.discriminatedUnion("kind", [
|
||||||
/** Skill points spent across all pilots. */
|
/** Skill points spent across all pilots. */
|
||||||
z.object({ kind: z.literal("skill-points-spent"), count: positiveInt }),
|
z.object({ kind: z.literal("skill-points-spent"), count: positiveInt }),
|
||||||
z.object({ kind: z.literal("ships-owned"), 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 }),
|
z.object({ kind: z.literal("pilots-hired"), count: positiveInt }),
|
||||||
/** Contracts (not deadheads) delivered by the veteran pilot (CR-010). */
|
/** Priority contracts delivered, by any pilot (CR-013). */
|
||||||
z.object({ kind: z.literal("veteran-contracts"), count: positiveInt }),
|
z.object({ kind: z.literal("priority-deliveries"), count: positiveInt }),
|
||||||
/** Locations discovered beyond the ones that start discovered. */
|
/** Locations discovered beyond the ones that start discovered. */
|
||||||
z.object({ kind: z.literal("locations-discovered"), count: positiveInt }),
|
z.object({ kind: z.literal("locations-discovered"), count: positiveInt }),
|
||||||
/** The deal's loan is fully repaid. */
|
/** The deal's loan is fully repaid. */
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ describe("onboarding goals", () => {
|
||||||
expect(onboardingGoals.map((g) => g.condition.kind)).toEqual([
|
expect(onboardingGoals.map((g) => g.condition.kind)).toEqual([
|
||||||
"own-ship-deliveries",
|
"own-ship-deliveries",
|
||||||
"skill-points-spent",
|
"skill-points-spent",
|
||||||
"ships-owned",
|
"market-ships-bought",
|
||||||
"veteran-contracts",
|
"priority-deliveries",
|
||||||
"locations-discovered",
|
"locations-discovered",
|
||||||
"loan-paid-off",
|
"loan-paid-off",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ export interface GoalProgress {
|
||||||
/** Skill points spent across all pilots. */
|
/** Skill points spent across all pilots. */
|
||||||
skillPointsSpent: number;
|
skillPointsSpent: number;
|
||||||
shipsOwned: number;
|
shipsOwned: number;
|
||||||
|
/** Ships bought from the Market, not received in the deal (CR-013). */
|
||||||
|
marketShipsBought: number;
|
||||||
/** Pilots hired (the avatar excluded). */
|
/** Pilots hired (the avatar excluded). */
|
||||||
pilotsHired: number;
|
pilotsHired: number;
|
||||||
/** Resolved contract deliveries (not deadheads) flown by the veteran (CR-010). */
|
/** Resolved Priority contract deliveries, by any pilot (CR-013). */
|
||||||
veteranContracts: number;
|
priorityDeliveries: number;
|
||||||
/** Locations discovered beyond the ones that start discovered. */
|
/** Locations discovered beyond the ones that start discovered. */
|
||||||
locationsDiscovered: number;
|
locationsDiscovered: number;
|
||||||
dealAccepted: boolean;
|
dealAccepted: boolean;
|
||||||
|
|
@ -27,8 +29,10 @@ export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgr
|
||||||
return progress.shipsOwned >= condition.count;
|
return progress.shipsOwned >= condition.count;
|
||||||
case "pilots-hired":
|
case "pilots-hired":
|
||||||
return progress.pilotsHired >= condition.count;
|
return progress.pilotsHired >= condition.count;
|
||||||
case "veteran-contracts":
|
case "market-ships-bought":
|
||||||
return progress.veteranContracts >= condition.count;
|
return progress.marketShipsBought >= condition.count;
|
||||||
|
case "priority-deliveries":
|
||||||
|
return progress.priorityDeliveries >= condition.count;
|
||||||
case "locations-discovered":
|
case "locations-discovered":
|
||||||
return progress.locationsDiscovered >= condition.count;
|
return progress.locationsDiscovered >= condition.count;
|
||||||
case "loan-paid-off":
|
case "loan-paid-off":
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import { completedGoals, type GoalProgress } from "../src/index";
|
||||||
|
|
||||||
const goals = content.onboardingGoals;
|
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 = {
|
const fresh: GoalProgress = {
|
||||||
ownShipDeliveries: 0,
|
ownShipDeliveries: 0,
|
||||||
skillPointsSpent: 0,
|
skillPointsSpent: 0,
|
||||||
shipsOwned: 1,
|
shipsOwned: 2,
|
||||||
pilotsHired: 0,
|
marketShipsBought: 0,
|
||||||
veteranContracts: 0,
|
pilotsHired: 1,
|
||||||
|
priorityDeliveries: 0,
|
||||||
locationsDiscovered: 0,
|
locationsDiscovered: 0,
|
||||||
dealAccepted: true,
|
dealAccepted: true,
|
||||||
loanBalance: content.dealLoanAmount,
|
loanBalance: content.dealLoanAmount,
|
||||||
|
|
@ -23,8 +24,8 @@ const isComplete = (id: string, progress: GoalProgress) =>
|
||||||
const cases: [string, Partial<GoalProgress>, Partial<GoalProgress>][] = [
|
const cases: [string, Partial<GoalProgress>, Partial<GoalProgress>][] = [
|
||||||
["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }],
|
["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }],
|
||||||
["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }],
|
["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }],
|
||||||
["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }],
|
["market-ship", { marketShipsBought: 0 }, { marketShipsBought: 1, shipsOwned: 3 }],
|
||||||
["veteran-contract", { veteranContracts: 0 }, { veteranContracts: 1 }],
|
["priority-contract", { priorityDeliveries: 0 }, { priorityDeliveries: 1 }],
|
||||||
["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }],
|
["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }],
|
||||||
["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }],
|
["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(
|
expect(
|
||||||
isComplete("veteran-contract", { ...fresh, ownShipDeliveries: 12, pilotsHired: 1 }),
|
isComplete("priority-contract", { ...fresh, ownShipDeliveries: 1, priorityDeliveries: 1 }),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
expect(isComplete("veteran-contract", { ...fresh, veteranContracts: 1 })).toBe(true);
|
expect(goals.find((g) => g.id === "priority-contract")?.title).toBe(
|
||||||
expect(goals.find((g) => g.id === "veteran-contract")?.title).toBe(
|
"Complete a Priority contract",
|
||||||
`Fly a contract with ${content.veteranPilot.name}`,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -59,8 +67,8 @@ describe("onboarding goal evaluation", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns completed goals in content order, identically for identical inputs", () => {
|
it("returns completed goals in content order, identically for identical inputs", () => {
|
||||||
const progress: GoalProgress = { ...fresh, veteranContracts: 3, ownShipDeliveries: 5 };
|
const progress: GoalProgress = { ...fresh, priorityDeliveries: 3, ownShipDeliveries: 5 };
|
||||||
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "veteran-contract"]);
|
expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "priority-contract"]);
|
||||||
expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress }));
|
expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue