Step 10J.4: the veteran pilot comes with the deal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 21:40:14 -04:00
parent 8acc81bc10
commit 9eae5ae64d
26 changed files with 1624 additions and 5 deletions

View File

@ -1012,3 +1012,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **New goal id `veteran-contract`** (was `first-hire`), with a new condition kind `veteran-contracts`. A new id keeps any old "first hire" claim from counting as this different goal; `pilots-hired` stays available as a condition kind but no goal uses it now. - **New goal id `veteran-contract`** (was `first-hire`), with a new condition kind `veteran-contracts`. A new id keeps any old "first hire" claim from counting as this different goal; `pilots-hired` stays available as a condition kind but no goal uses it now.
- **The title is built from content** (`Fly a contract with ${veteranPilot.name}`), so renaming the veteran renames the goal. - **The title is built from content** (`Fly a contract with ${veteranPilot.name}`), so renaming the veteran renames the goal.
- **Counting is the server's job**: the sim only compares `veteranContracts` to the goal; which deliveries count (contracts, not deadheads or tutorial runs, flown by the veteran) is decided where they are stored (10J.4). - **Counting is the server's job**: the sim only compares `veteranContracts` to the goal; which deliveries count (contracts, not deadheads or tutorial runs, flown by the veteran) is decided where they are stored (10J.4).
## Step 10J.4
- **The veteran is a pilots row with `is_veteran`**, otherwise identical to a hire (pay, duty, rest, assignment rules). The flag only identifies them for goal 4 and the bio line.
- **Level 2 is stored as XP**: the veteran starts with exactly level 2's threshold (250 XP), so levels and skill points come from the normal rules; one point is recorded as spent on Heavy Cargo 1.
- **Created inside `acceptDeal`**, after the deal check, in the player-locked transaction: a refused or repeated accept creates nothing (tested).

View File

@ -0,0 +1 @@
ALTER TABLE "pilots" ADD COLUMN "is_veteran" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,13 @@
"when": 1791155978006, "when": 1791155978006,
"tag": "0006_cr009_ship_upgrades", "tag": "0006_cr009_ship_upgrades",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1791164145228,
"tag": "0007_cr010_veteran_pilot",
"breakpoints": true
} }
] ]
} }

View File

@ -100,6 +100,8 @@ export const pilots = pgTable(
.references(() => players.id, { onDelete: "cascade" }), .references(() => players.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
isAvatar: boolean("is_avatar").notNull().default(false), isAvatar: boolean("is_avatar").notNull().default(false),
/** The veteran who came with the deal (decision 0.5.70); otherwise a normal hired pilot. */
isVeteran: boolean("is_veteran").notNull().default(false),
xp: integer("xp").notNull().default(0), xp: integer("xp").notNull().default(0),
skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}), skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}),
skillPointsSpent: integer("skill_points_spent").notNull().default(0), skillPointsSpent: integer("skill_points_spent").notNull().default(0),

View File

@ -71,6 +71,22 @@ 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,
// unassigned, 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]!;
await tx.insert(pilots).values({
id: randomUUID(),
playerId,
name: veteran.name,
isVeteran: true,
xp: xpForLevel,
skills: veteran.skills,
skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0),
locationId: DEAL_BAY_LOCATION_ID,
createdAt: now,
});
await tx await tx
.update(companies) .update(companies)
.set({ loanBalance: content.config.dealLoanAmount }) .set({ loanBalance: content.config.dealLoanAmount })

View File

@ -32,6 +32,20 @@ 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).
const [veteranContracts] = 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`,
),
);
const [spent] = await tx const [spent] = await tx
.select({ n: sum(pilots.skillPointsSpent) }) .select({ n: sum(pilots.skillPointsSpent) })
.from(pilots) .from(pilots)
@ -55,8 +69,7 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
skillPointsSpent: Number(spent?.n ?? 0), skillPointsSpent: Number(spent?.n ?? 0),
shipsOwned: owned!.n, shipsOwned: owned!.n,
pilotsHired: hired!.n, pilotsHired: hired!.n,
// Counted once the veteran is stored (Step 10J.4). veteranContracts: veteranContracts!.n,
veteranContracts: 0,
locationsDiscovered: discovered!.n, locationsDiscovered: discovered!.n,
dealAccepted: player.dealAccepted, dealAccepted: player.dealAccepted,
loanBalance: company.loanBalance, loanBalance: company.loanBalance,

View File

@ -41,6 +41,7 @@ export function toApiPilot(
id: row.id, id: row.id,
name: row.name, name: row.name,
isAvatar: row.isAvatar, isAvatar: row.isAvatar,
isVeteran: row.isVeteran,
xp: row.xp, xp: row.xp,
level, level,
skills: row.skills, skills: row.skills,

View File

@ -0,0 +1,147 @@
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
playerStateSchema,
type PlayerState,
} from "@deadhead/schema";
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 { flyTutorialRun } from "./tutorial-flow";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const veteranOf = (state: PlayerState) => state.pilots.find((p) => p.isVeteran)!;
const avatarOf = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** A guest who flies the tutorial runs and accepts the deal. */
async function dealt(): Promise<{ guest: Guest; state: PlayerState }> {
const guest = await signUp(testApp().app);
for (let run = 0; run < content.config.tutorialRunsBeforeDeal; run++) {
await flyTutorialRun(testApp(), guest);
}
const response = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(response.statusCode).toBe(200);
return { guest, state: playerStateSchema.parse(response.json()) };
}
describe("the veteran comes with the deal (Step 10J.4)", () => {
it("adds the veteran at Earth, unassigned, level 2 with Heavy Cargo 1", 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 spentPoints = 1;
expect(veteranOf(state)).toMatchObject({
name: content.veteranPilot.name,
isAvatar: false,
isVeteran: true,
level: 2,
skills: { "heavy-cargo": 1 },
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
assignedShipId: null,
locationId: "earth",
status: "idle",
});
});
it("creates no veteran on a refused or repeated accept", async () => {
const guest = await signUp(testApp().app);
const early = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(errorCode(early)).toBe("deal-not-available");
expect((await getState(testApp().app, guest)).pilots).toHaveLength(1);
const { guest: owner } = await dealt();
const again = await call(testApp().app, owner, "POST", "/api/deal/accept", {});
expect(again.statusCode).toBe(409);
const pilots = (await getState(testApp().app, owner)).pilots;
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 () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const courier = state.ships[0]!;
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 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 after = await getState(app, guest);
const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!;
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,6 +31,7 @@ describe("typed API client", () => {
id: "p1", id: "p1",
name: "Rae Okafor", name: "Rae Okafor",
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -43,6 +43,7 @@ describe("navigation attention badges (Step 10E.3)", () => {
...avatar, ...avatar,
id: "p2", id: "p2",
isAvatar: false, isAvatar: false,
isVeteran: false,
assignedShipId: "s2", assignedShipId: "s2",
resting: true, resting: true,
restEndsAt: 1, restEndsAt: 1,

View File

@ -28,6 +28,7 @@ const rae: Pilot = {
id: "p1", id: "p1",
name: "Rae Okafor", name: "Rae Okafor",
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -26,6 +26,7 @@ const pilot = (id: string, name: string, shipId: string, extra: Partial<Pilot>):
id, id,
name, name,
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -28,6 +28,7 @@ const pilot = (id: string, name: string, shipId: string): Pilot => ({
id, id,
name, name,
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -36,6 +36,7 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil
id, id,
name, name,
isAvatar, isAvatar,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -26,6 +26,7 @@ const pilot: Pilot = {
id: "p1", id: "p1",
name: "Rae Okafor", name: "Rae Okafor",
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -68,6 +68,7 @@ function fleetState(overrides: Partial<PlayerState> = {}): PlayerState {
id: "p2", id: "p2",
name: "Rae Okafor", name: "Rae Okafor",
isAvatar: false, isAvatar: false,
isVeteran: false,
assignedShipId: "s-fly", assignedShipId: "s-fly",
status: "in-flight", status: "in-flight",
locationId: null, locationId: null,

View File

@ -53,6 +53,7 @@ function mapState(): PlayerState {
id: "p-rae", id: "p-rae",
name: "Rae Okafor", name: "Rae Okafor",
isAvatar: false, isAvatar: false,
isVeteran: false,
assignedShipId: "s1", assignedShipId: "s1",
status: "in-flight", status: "in-flight",
locationId: null, locationId: null,

View File

@ -11,6 +11,7 @@ const pilot = (overrides: Partial<Pilot>): Pilot => ({
id: "p", id: "p",
name: "Pilot", name: "Pilot",
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -23,6 +23,7 @@ const rae: Pilot = {
id: "p-rae", id: "p-rae",
name: "Rae Okafor", name: "Rae Okafor",
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 260, xp: 260,
level: 2, level: 2,
skills: { "heavy-cargo": 1 }, skills: { "heavy-cargo": 1 },

View File

@ -68,6 +68,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
id: "avatar", id: "avatar",
name: "Captain", name: "Captain",
isAvatar: true, isAvatar: true,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -11,6 +11,7 @@ const pilot = (id: string, name: string, extra: Partial<Pilot> = {}): Pilot => (
id, id,
name, name,
isAvatar: false, isAvatar: false,
isVeteran: false,
xp: 0, xp: 0,
level: 0, level: 0,
skills: {}, skills: {},

View File

@ -26,7 +26,7 @@ Living reference for how the code is organised. Update it after any database sch
| `ServerTime` | `realTime`, `gameTime` | | `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` | | `Company` | `id`, `name`, `credits`, `loanBalance` |
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) | | `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | | `Pilot` | `id`, `name`, `isAvatar`, `isVeteran` (the deal's veteran, CR-010), `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) |
| `Bay` | `id`, `locationId` | | `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` | | `LocationState` | `id`, `discovered` |
| `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) | | `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) |
@ -75,7 +75,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| `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) | — |
| `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) | 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) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) | | `bays` | Leased bays | unique (`player_id`, `location_id`) |
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
| `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) | | `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |

View File

@ -243,7 +243,7 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv
- [x] **Step 10J.1 — Content: config and veteran.** Config `pilotProfitShare` 0.25 (a fraction 0–1); the flat `pilotPayPerDelivery` is removed in 10J.2 when the sim switches to the share. `veteranPilotSchema` and `content.veteranPilot`: Rosa Kell, "Halvorsen's pilot for twelve years.", level 2, Heavy Cargo 1. Test: `content/test/veteran.test.ts`. - [x] **Step 10J.1 — Content: config and veteran.** Config `pilotProfitShare` 0.25 (a fraction 0–1); the flat `pilotPayPerDelivery` is removed in 10J.2 when the sim switches to the share. `veteranPilotSchema` and `content.veteranPilot`: Rosa Kell, "Halvorsen's pilot for twelve years.", level 2, Heavy Cargo 1. Test: `content/test/veteran.test.ts`.
- [x] **Step 10J.2 — Sim: profit-share pay.** `sim/src/pay.ts` `pilotShare(payout, fuel)` = round(max(0, payout − fuel) × `pilotProfitShare`). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; `Resolution.pilotPay` records it. The flat `pilotPayPerDelivery` is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: `profit-share.test.ts` (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated. - [x] **Step 10J.2 — Sim: profit-share pay.** `sim/src/pay.ts` `pilotShare(payout, fuel)` = round(max(0, payout − fuel) × `pilotProfitShare`). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; `Resolution.pilotPay` records it. The flat `pilotPayPerDelivery` is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: `profit-share.test.ts` (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated.
- [x] **Step 10J.3 — Sim: goal change.** Goal 4 is now `veteran-contract`, "Fly a contract with Rosa Kell" (title from `content.veteranPilot.name`), condition `veteran-contracts` ≥ 1; `GoalProgress.veteranContracts`. The server reports 0 until the veteran is stored (10J.4). Tests: `goals.test.ts` (avatar deliveries alone don't complete it; one veteran contract does) and the goal-id updates in server and web goal tests. - [x] **Step 10J.3 — Sim: goal change.** Goal 4 is now `veteran-contract`, "Fly a contract with Rosa Kell" (title from `content.veteranPilot.name`), condition `veteran-contracts` ≥ 1; `GoalProgress.veteranContracts`. The server reports 0 until the veteran is stored (10J.4). Tests: `goals.test.ts` (avatar deliveries alone don't complete it; one veteran contract does) and the goal-id updates in server and web goal tests.
- [ ] Step 10J.4 — Server: veteran on the deal - [x] **Step 10J.4 — Server: veteran on the deal.** Migration `0007_cr010_veteran_pilot`: `pilots.is_veteran`. `acceptDeal` inserts the veteran in the same transaction: content name, XP at level 2's threshold, Heavy Cargo 1 bought (1 point spent, 3 unspent), at Earth, unassigned. API `Pilot.isVeteran`. Goal progress counts resolved contract deliveries (not deadheads or tutorial runs) by the veteran. Test: `veteran.test.ts` (state after the deal; no veteran on a refused or repeated accept; avatar rests, is swapped out, the veteran flies at once and is paid a profit share, goal 4 ready; avatar deliveries and a veteran deadhead don't count).
- [ ] Step 10J.5 — Client: deal modal and pilot card - [ ] Step 10J.5 — Client: deal modal and pilot card
- [ ] Step 10J.6 — Client: pilot share in dispatch and summaries - [ ] Step 10J.6 — Client: pilot share in dispatch and summaries
- [ ] Step 10J.7 — Plan amendments and regression - [ ] Step 10J.7 — Plan amendments and regression

View File

@ -72,6 +72,8 @@ export const pilotSchema = z.object({
id: idSchema, id: idSchema,
name: z.string().min(1), name: z.string().min(1),
isAvatar: z.boolean(), isAvatar: z.boolean(),
/** Halvorsen's veteran, who came with the deal (CR-010); paid like any hired pilot. */
isVeteran: z.boolean(),
xp: z.number().int().nonnegative(), xp: z.number().int().nonnegative(),
level: z.number().int().nonnegative(), level: z.number().int().nonnegative(),
/** Skill levels by skill id; a missing skill is level 0. */ /** Skill levels by skill id; a missing skill is level 0. */

View File

@ -72,6 +72,7 @@ const pilot = {
id: "p1", id: "p1",
name: "Avery Kade", name: "Avery Kade",
isAvatar: true, isAvatar: true,
isVeteran: false,
xp: 120, xp: 120,
level: 1, level: 1,
skills: { "long-haul": 1 }, skills: { "long-haul": 1 },