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:
parent
8acc81bc10
commit
9eae5ae64d
|
|
@ -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.
|
||||
- **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).
|
||||
|
||||
## 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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -50,6 +50,13 @@
|
|||
"when": 1791155978006,
|
||||
"tag": "0006_cr009_ship_upgrades",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1791164145228,
|
||||
"tag": "0007_cr010_veteran_pilot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -100,6 +100,8 @@ export const pilots = pgTable(
|
|||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
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),
|
||||
skills: jsonb("skills").$type<Record<string, number>>().notNull().default({}),
|
||||
skillPointsSpent: integer("skill_points_spent").notNull().default(0),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,22 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime)
|
|||
.update(pilots)
|
||||
.set({ assignedShipId: shipId, locationId: null, status: "idle" })
|
||||
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
|
||||
// Halvorsen's veteran comes with the business (decision 0.5.70): at Earth,
|
||||
// unassigned, at their level with their skills bought and the rest unspent.
|
||||
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
|
||||
.update(companies)
|
||||
.set({ loanBalance: content.config.dealLoanAmount })
|
||||
|
|
|
|||
|
|
@ -32,6 +32,20 @@ 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
|
||||
.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
|
||||
.select({ n: sum(pilots.skillPointsSpent) })
|
||||
.from(pilots)
|
||||
|
|
@ -55,8 +69,7 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise<Goa
|
|||
skillPointsSpent: Number(spent?.n ?? 0),
|
||||
shipsOwned: owned!.n,
|
||||
pilotsHired: hired!.n,
|
||||
// Counted once the veteran is stored (Step 10J.4).
|
||||
veteranContracts: 0,
|
||||
veteranContracts: veteranContracts!.n,
|
||||
locationsDiscovered: discovered!.n,
|
||||
dealAccepted: player.dealAccepted,
|
||||
loanBalance: company.loanBalance,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export function toApiPilot(
|
|||
id: row.id,
|
||||
name: row.name,
|
||||
isAvatar: row.isAvatar,
|
||||
isVeteran: row.isVeteran,
|
||||
xp: row.xp,
|
||||
level,
|
||||
skills: row.skills,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -31,6 +31,7 @@ describe("typed API client", () => {
|
|||
id: "p1",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ describe("navigation attention badges (Step 10E.3)", () => {
|
|||
...avatar,
|
||||
id: "p2",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
assignedShipId: "s2",
|
||||
resting: true,
|
||||
restEndsAt: 1,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const rae: Pilot = {
|
|||
id: "p1",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const pilot = (id: string, name: string, shipId: string, extra: Partial<Pilot>):
|
|||
id,
|
||||
name,
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const pilot = (id: string, name: string, shipId: string): Pilot => ({
|
|||
id,
|
||||
name,
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil
|
|||
id,
|
||||
name,
|
||||
isAvatar,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const pilot: Pilot = {
|
|||
id: "p1",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ function fleetState(overrides: Partial<PlayerState> = {}): PlayerState {
|
|||
id: "p2",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
assignedShipId: "s-fly",
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function mapState(): PlayerState {
|
|||
id: "p-rae",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
assignedShipId: "s1",
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const pilot = (overrides: Partial<Pilot>): Pilot => ({
|
|||
id: "p",
|
||||
name: "Pilot",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const rae: Pilot = {
|
|||
id: "p-rae",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 260,
|
||||
level: 2,
|
||||
skills: { "heavy-cargo": 1 },
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
|||
id: "avatar",
|
||||
name: "Captain",
|
||||
isAvatar: true,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const pilot = (id: string, name: string, extra: Partial<Pilot> = {}): Pilot => (
|
|||
id,
|
||||
name,
|
||||
isAvatar: false,
|
||||
isVeteran: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Living reference for how the code is organised. Update it after any database sch
|
|||
| `ServerTime` | `realTime`, `gameTime` |
|
||||
| `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) |
|
||||
| `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` |
|
||||
| `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) |
|
||||
|
|
@ -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` |
|
||||
| `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) |
|
||||
| `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`) |
|
||||
| `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`) |
|
||||
|
|
|
|||
|
|
@ -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.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.
|
||||
- [ ] 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.6 — Client: pilot share in dispatch and summaries
|
||||
- [ ] Step 10J.7 — Plan amendments and regression
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ export const pilotSchema = z.object({
|
|||
id: idSchema,
|
||||
name: z.string().min(1),
|
||||
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(),
|
||||
level: z.number().int().nonnegative(),
|
||||
/** Skill levels by skill id; a missing skill is level 0. */
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ const pilot = {
|
|||
id: "p1",
|
||||
name: "Avery Kade",
|
||||
isAvatar: true,
|
||||
isVeteran: false,
|
||||
xp: 120,
|
||||
level: 1,
|
||||
skills: { "long-haul": 1 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue