Step 10A.6: add loan, tutorial run and goal schema and routes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b3ed6ef71d
commit
aea768bebf
|
|
@ -516,3 +516,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Goals are evaluated against a small `GoalProgress` summary** (own-ship deliveries, skill points spent, ships owned, pilots hired, extra locations discovered, deal accepted, loan balance) rather than full player state. The server builds it from the database, and the evaluator stays independent of storage.
|
- **Goals are evaluated against a small `GoalProgress` summary** (own-ship deliveries, skill points spent, ships owned, pilots hired, extra locations discovered, deal accepted, loan balance) rather than full player state. The server builds it from the database, and the evaluator stays independent of storage.
|
||||||
- **"Pay off your loan" requires the deal to have been accepted**, so a player who never had a loan does not complete it by default.
|
- **"Pay off your loan" requires the deal to have been accepted**, so a player who never had a loan does not complete it by default.
|
||||||
- **The evaluator takes the goal list as an argument** (not part of `SimContent`), since only goal code needs it.
|
- **The evaluator takes the goal list as an argument** (not part of `SimContent`), since only goal code needs it.
|
||||||
|
|
||||||
|
## Step 10A.6
|
||||||
|
|
||||||
|
- **The repayment is stored in each dispatch's `resolution` JSON** (`loanRepayment`), next to the other applied effects, rather than as a separate column.
|
||||||
|
- **`purchasable` lives only in content.** Ship models are not stored in the database; the Market lists `content.purchasableShipModels`, and the server refuses non-purchasable models (Step 10A.1).
|
||||||
|
- **Tutorial boards are keyed by run** (`contract_boards.tutorial_run`, 0 for bay boards) as well as period and location (see CR-001 notes).
|
||||||
|
- **New state fields:** `company.loanBalance`; `tutorial.runsCompleted` and `tutorial.dealAvailable` (computed by `isDealAvailable`: tutorial on, deal not yet taken, runs ≥ `tutorialRunsBeforeDeal`, loaner at Earth, no run in flight); `goals` as `{ id, status }`, empty until Step 10A.10 fills it; summaries gain `loanRepayment` and `scriptedId`.
|
||||||
|
- **New routes:** `POST /api/deal/accept` returns the whole new `PlayerState`, since the deal changes almost everything; `POST /api/loan/repay` and `POST /api/goals/:goalId/claim` return `{ company }`.
|
||||||
|
- **Error codes:** `tutorial-active` (purchases during the tutorial), `deal-not-available`, `unknown-goal` (404), `goal-not-ready`, `goal-already-claimed`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
CREATE TABLE "claimed_goals" (
|
||||||
|
"player_id" text NOT NULL,
|
||||||
|
"goal_id" text NOT NULL,
|
||||||
|
"claimed_at" bigint NOT NULL,
|
||||||
|
CONSTRAINT "claimed_goals_player_id_goal_id_pk" PRIMARY KEY("player_id","goal_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP INDEX "contract_boards_unique_idx";--> statement-breakpoint
|
||||||
|
ALTER TABLE "companies" ADD COLUMN "loan_balance" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "contract_boards" ADD COLUMN "tutorial_run" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "players" ADD COLUMN "tutorial_runs_completed" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "players" ADD COLUMN "deal_accepted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "claimed_goals" ADD CONSTRAINT "claimed_goals_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "contract_boards_unique_idx" ON "contract_boards" USING btree ("player_id","kind","location_id","period_index","tutorial_run");
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,6 +15,13 @@
|
||||||
"when": 1790991751364,
|
"when": 1790991751364,
|
||||||
"tag": "0001_auth",
|
"tag": "0001_auth",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791039630016,
|
||||||
|
"tag": "0002_cr001_faster_start",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +32,10 @@ export const players = pgTable("players", {
|
||||||
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
||||||
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
||||||
loanerLocationId: text("loaner_location_id"),
|
loanerLocationId: text("loaner_location_id"),
|
||||||
|
/** Tutorial runs resolved so far (CR-001). */
|
||||||
|
tutorialRunsCompleted: integer("tutorial_runs_completed").notNull().default(0),
|
||||||
|
/** The one-time deal has been accepted (decision 0.5.30). */
|
||||||
|
dealAccepted: boolean("deal_accepted").notNull().default(false),
|
||||||
createdAt: time("created_at").notNull(),
|
createdAt: time("created_at").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -44,6 +48,8 @@ export const companies = pgTable("companies", {
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
||||||
credits: bigint("credits", { mode: "number" }).notNull(),
|
credits: bigint("credits", { mode: "number" }).notNull(),
|
||||||
|
/** Owed to the lender (decision 0.5.32). */
|
||||||
|
loanBalance: bigint("loan_balance", { mode: "number" }).notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ships = pgTable(
|
export const ships = pgTable(
|
||||||
|
|
@ -127,11 +133,19 @@ export const contractBoards = pgTable(
|
||||||
kind: boardKind("kind").notNull(),
|
kind: boardKind("kind").notNull(),
|
||||||
locationId: text("location_id").notNull(),
|
locationId: text("location_id").notNull(),
|
||||||
periodIndex: integer("period_index").notNull(),
|
periodIndex: integer("period_index").notNull(),
|
||||||
|
/** Tutorial boards only: runs completed when generated (0 for bay boards). */
|
||||||
|
tutorialRun: integer("tutorial_run").notNull().default(0),
|
||||||
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
|
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
|
||||||
createdAt: time("created_at").notNull(),
|
createdAt: time("created_at").notNull(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex),
|
uniqueIndex("contract_boards_unique_idx").on(
|
||||||
|
t.playerId,
|
||||||
|
t.kind,
|
||||||
|
t.locationId,
|
||||||
|
t.periodIndex,
|
||||||
|
t.tutorialRun,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -178,3 +192,16 @@ export const acceptedContracts = pgTable(
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
|
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Onboarding goals whose reward has been claimed (decision 0.5.34). */
|
||||||
|
export const claimedGoals = pgTable(
|
||||||
|
"claimed_goals",
|
||||||
|
{
|
||||||
|
playerId: text("player_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => players.id, { onDelete: "cascade" }),
|
||||||
|
goalId: text("goal_id").notNull(),
|
||||||
|
claimedAt: time("claimed_at").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { players } from "../db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deal can be accepted now (decision 0.5.30): tutorial on, enough runs
|
||||||
|
* completed, the loaner back at Earth, and no tutorial run in flight.
|
||||||
|
*/
|
||||||
|
export function isDealAvailable(
|
||||||
|
player: typeof players.$inferSelect,
|
||||||
|
tutorialRunInFlight: boolean,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
player.tutorialActive &&
|
||||||
|
!player.dealAccepted &&
|
||||||
|
player.tutorialRunsCompleted >= content.config.tutorialRunsBeforeDeal &&
|
||||||
|
player.loanerLocationId === "earth" &&
|
||||||
|
!tutorialRunInFlight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
players,
|
players,
|
||||||
ships,
|
ships,
|
||||||
} from "../db/schema";
|
} from "../db/schema";
|
||||||
|
import { isDealAvailable } from "./deal";
|
||||||
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
|
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
|
||||||
|
|
||||||
/** Reads everything the client needs about one player. */
|
/** Reads everything the client needs about one player. */
|
||||||
|
|
@ -49,7 +50,12 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
company: { id: company.id, name: company.name, credits: company.credits },
|
company: {
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
credits: company.credits,
|
||||||
|
loanBalance: company.loanBalance,
|
||||||
|
},
|
||||||
ships: shipRows.map((s) => ({
|
ships: shipRows.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
modelId: s.modelId,
|
modelId: s.modelId,
|
||||||
|
|
@ -69,6 +75,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
||||||
? {
|
? {
|
||||||
loanerLocationId: player.loanerLocationId as "earth" | "moon",
|
loanerLocationId: player.loanerLocationId as "earth" | "moon",
|
||||||
runInFlight: active.some((d) => d.tutorial),
|
runInFlight: active.some((d) => d.tutorial),
|
||||||
|
runsCompleted: player.tutorialRunsCompleted,
|
||||||
|
dealAvailable: isDealAvailable(
|
||||||
|
player,
|
||||||
|
active.some((d) => d.tutorial),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
activeDispatches: active.map(toApiDispatch),
|
activeDispatches: active.map(toApiDispatch),
|
||||||
|
|
@ -80,9 +91,13 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
||||||
destinationId: d.record.destinationId,
|
destinationId: d.record.destinationId,
|
||||||
tutorial: d.tutorial,
|
tutorial: d.tutorial,
|
||||||
credits: d.resolution?.credits ?? 0,
|
credits: d.resolution?.credits ?? 0,
|
||||||
|
loanRepayment: d.resolution?.loanRepayment ?? 0,
|
||||||
pilotXp: d.resolution?.pilotXp ?? 0,
|
pilotXp: d.resolution?.pilotXp ?? 0,
|
||||||
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
|
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
|
||||||
|
scriptedId: d.record.contract?.scriptedId ?? null,
|
||||||
resolvedAt: d.resolvedAt ?? d.arrivalAt,
|
resolvedAt: d.resolvedAt ?? d.arrivalAt,
|
||||||
})),
|
})),
|
||||||
|
// Goal statuses arrive with Step 10A.10.
|
||||||
|
goals: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const NOT_FOUND_CODES: ReadonlySet<ErrorCode> = new Set([
|
||||||
"not-found",
|
"not-found",
|
||||||
"unknown-contract",
|
"unknown-contract",
|
||||||
"unknown-skill",
|
"unknown-skill",
|
||||||
|
"unknown-goal",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** A game-rule failure: 404 for unknown things, 409 for everything else. */
|
/** A game-rule failure: 404 for unknown things, 409 for everything else. */
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ describe("end of tutorial", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await buyShipAndEarthBay(guest);
|
await buyShipAndEarthBay(guest);
|
||||||
expect((await getState(app, guest)).tutorial).toEqual({
|
expect((await getState(app, guest)).tutorial).toMatchObject({
|
||||||
loanerLocationId: "earth",
|
loanerLocationId: "earth",
|
||||||
runInFlight: true,
|
runInFlight: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ describe("guest accounts", () => {
|
||||||
|
|
||||||
const body = createGuestResponseSchema.parse(response.json());
|
const body = createGuestResponseSchema.parse(response.json());
|
||||||
expect(body.state.company.credits).toBe(content.config.startingCredits);
|
expect(body.state.company.credits).toBe(content.config.startingCredits);
|
||||||
expect(body.state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: false });
|
expect(body.state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the new player's state for that session", async () => {
|
it("returns the new player's state for that session", async () => {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ describe("database migrations", () => {
|
||||||
"accepted_contracts",
|
"accepted_contracts",
|
||||||
"account",
|
"account",
|
||||||
"bays",
|
"bays",
|
||||||
|
"claimed_goals",
|
||||||
"companies",
|
"companies",
|
||||||
"contract_boards",
|
"contract_boards",
|
||||||
"discovered_locations",
|
"discovered_locations",
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ describe("tutorial dispatch", () => {
|
||||||
|
|
||||||
const state = await getState(app, guest);
|
const state = await getState(app, guest);
|
||||||
expect(state.company.credits).toBe(content.config.startingCredits);
|
expect(state.company.credits).toBe(content.config.startingCredits);
|
||||||
expect(state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: true });
|
expect(state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: true });
|
||||||
expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null });
|
expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null });
|
||||||
expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]);
|
expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]);
|
||||||
expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id);
|
expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id);
|
||||||
|
|
@ -84,7 +84,7 @@ describe("tutorial dispatch", () => {
|
||||||
expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout);
|
expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout);
|
||||||
expect(state.pilots[0]!.xp).toBeGreaterThan(0);
|
expect(state.pilots[0]!.xp).toBeGreaterThan(0);
|
||||||
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" });
|
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" });
|
||||||
expect(state.tutorial).toEqual({ loanerLocationId: "moon", runInFlight: false });
|
expect(state.tutorial).toMatchObject({ loanerLocationId: "moon", runInFlight: false });
|
||||||
|
|
||||||
const next = await tutorialBoard(guest);
|
const next = await tutorialBoard(guest);
|
||||||
expect(next.length).toBeGreaterThan(0);
|
expect(next.length).toBeGreaterThan(0);
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,10 @@ const summaries: ResolutionSummary[] = [
|
||||||
destinationId: "mars",
|
destinationId: "mars",
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: 51_200,
|
credits: 51_200,
|
||||||
|
loanRepayment: 0,
|
||||||
pilotXp: 32,
|
pilotXp: 32,
|
||||||
discoveredLocationId: "mars",
|
discoveredLocationId: "mars",
|
||||||
|
scriptedId: null,
|
||||||
resolvedAt: 1,
|
resolvedAt: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -35,8 +37,10 @@ const summaries: ResolutionSummary[] = [
|
||||||
destinationId: "earth",
|
destinationId: "earth",
|
||||||
tutorial: true,
|
tutorial: true,
|
||||||
credits: 240,
|
credits: 240,
|
||||||
|
loanRepayment: 0,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
scriptedId: null,
|
||||||
resolvedAt: 2,
|
resolvedAt: 2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ const marsJob: Contract = {
|
||||||
|
|
||||||
const state = samplePlayerState({
|
const state = samplePlayerState({
|
||||||
tutorial: null,
|
tutorial: null,
|
||||||
company: { id: "c", name: "D", credits: 1_000_000 },
|
company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
|
||||||
bays: [{ id: "b1", locationId: "earth" }],
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
ships: [
|
ships: [
|
||||||
ship("s-courier", "Dusty Mule", "courier", "avatar"),
|
ship("s-courier", "Dusty Mule", "courier", "avatar"),
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ describe("HQ screen", () => {
|
||||||
it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => {
|
it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => {
|
||||||
const goal = tutorialGoalCost();
|
const goal = tutorialGoalCost();
|
||||||
const state = samplePlayerState({
|
const state = samplePlayerState({
|
||||||
company: { id: "c", name: "D", credits: Math.round(goal * 0.4) },
|
company: { id: "c", name: "D", credits: Math.round(goal * 0.4), loanBalance: 0 },
|
||||||
});
|
});
|
||||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||||
|
|
||||||
|
|
@ -84,7 +84,7 @@ describe("HQ screen", () => {
|
||||||
|
|
||||||
it("caps tutorial progress at 100%", async () => {
|
it("caps tutorial progress at 100%", async () => {
|
||||||
const state = samplePlayerState({
|
const state = samplePlayerState({
|
||||||
company: { id: "c", name: "D", credits: tutorialGoalCost() * 3 },
|
company: { id: "c", name: "D", credits: tutorialGoalCost() * 3, loanBalance: 0 },
|
||||||
});
|
});
|
||||||
renderApp(fakeApi({ getPlayerState: () => state }));
|
renderApp(fakeApi({ getPlayerState: () => state }));
|
||||||
expect(await screen.findByText("100%")).toBeInTheDocument();
|
expect(await screen.findByText("100%")).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
const signedIn = () =>
|
const signedIn = () =>
|
||||||
fakeApi({
|
fakeApi({
|
||||||
getPlayerState: () => samplePlayerState({ company: { id: "c", name: "D", credits: 1234 } }),
|
getPlayerState: () =>
|
||||||
|
samplePlayerState({ company: { id: "c", name: "D", credits: 1234, loanBalance: 0 } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("app layout", () => {
|
describe("app layout", () => {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe("locations screen", () => {
|
||||||
const api = fakeApi({
|
const api = fakeApi({
|
||||||
getPlayerState: () =>
|
getPlayerState: () =>
|
||||||
samplePlayerState({
|
samplePlayerState({
|
||||||
company: { id: "c", name: "D", credits: 100_000 },
|
company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 },
|
||||||
bays: [{ id: "b1", locationId: "earth" }],
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ describe("market screen", () => {
|
||||||
renderApp(
|
renderApp(
|
||||||
fakeApi({
|
fakeApi({
|
||||||
getPlayerState: () =>
|
getPlayerState: () =>
|
||||||
samplePlayerState({ company: { id: "c", name: "D", credits: courierPrice } }),
|
samplePlayerState({
|
||||||
|
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
"/market",
|
"/market",
|
||||||
);
|
);
|
||||||
|
|
@ -51,7 +53,12 @@ describe("market screen", () => {
|
||||||
samplePlayerState({
|
samplePlayerState({
|
||||||
tutorial: null,
|
tutorial: null,
|
||||||
ships: owned,
|
ships: owned,
|
||||||
company: { id: "c", name: "D", credits: owned.length ? 1 : freighterPrice },
|
company: {
|
||||||
|
id: "c",
|
||||||
|
name: "D",
|
||||||
|
credits: owned.length ? 1 : freighterPrice,
|
||||||
|
loanBalance: 0,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
buyShip: () => {
|
buyShip: () => {
|
||||||
owned = [bought];
|
owned = [bought];
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,8 @@ describe("pilots screen", () => {
|
||||||
const cost = content.config.pilotHireCost;
|
const cost = content.config.pilotHireCost;
|
||||||
renderApp(
|
renderApp(
|
||||||
fakeApi({
|
fakeApi({
|
||||||
getPlayerState: () => pilotsState({ company: { id: "c", name: "D", credits: cost - 1 } }),
|
getPlayerState: () =>
|
||||||
|
pilotsState({ company: { id: "c", name: "D", credits: cost - 1, loanBalance: 0 } }),
|
||||||
}),
|
}),
|
||||||
"/pilots",
|
"/pilots",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export const unauthorized = () => {
|
||||||
/** A brand-new tutorial player, as guest sign-up returns it. */
|
/** A brand-new tutorial player, as guest sign-up returns it. */
|
||||||
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||||
return {
|
return {
|
||||||
company: { id: "co1", name: "Deadhead Freight", credits: 0 },
|
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
|
||||||
ships: [],
|
ships: [],
|
||||||
pilots: [
|
pilots: [
|
||||||
{
|
{
|
||||||
|
|
@ -58,9 +58,15 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
||||||
{ id: "ceres-station", discovered: false },
|
{ id: "ceres-station", discovered: false },
|
||||||
{ id: "jupiter-cloud-platform", discovered: false },
|
{ id: "jupiter-cloud-platform", discovered: false },
|
||||||
],
|
],
|
||||||
tutorial: { loanerLocationId: "earth", runInFlight: false },
|
tutorial: {
|
||||||
|
loanerLocationId: "earth",
|
||||||
|
runInFlight: false,
|
||||||
|
runsCompleted: 0,
|
||||||
|
dealAvailable: false,
|
||||||
|
},
|
||||||
activeDispatches: [],
|
activeDispatches: [],
|
||||||
summaries: [],
|
summaries: [],
|
||||||
|
goals: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,14 @@ describe("tutorial contract board", () => {
|
||||||
it("disables Fly it while a tutorial run is in flight", async () => {
|
it("disables Fly it while a tutorial run is in flight", async () => {
|
||||||
const api = fakeApi({
|
const api = fakeApi({
|
||||||
getPlayerState: () =>
|
getPlayerState: () =>
|
||||||
samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true } }),
|
samplePlayerState({
|
||||||
|
tutorial: {
|
||||||
|
loanerLocationId: "earth",
|
||||||
|
runInFlight: true,
|
||||||
|
runsCompleted: 0,
|
||||||
|
dealAvailable: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
getTutorialBoard: () => board,
|
getTutorialBoard: () => board,
|
||||||
});
|
});
|
||||||
renderApp(api, "/contracts");
|
renderApp(api, "/contracts");
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,17 @@ Living reference for how the code is organised. Update it after any database sch
|
||||||
| Shape | Fields |
|
| Shape | Fields |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ServerTime` | `realTime`, `gameTime` |
|
| `ServerTime` | `realTime`, `gameTime` |
|
||||||
| `Company` | `id`, `name`, `credits` |
|
| `Company` | `id`, `name`, `credits`, `loanBalance` |
|
||||||
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
|
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
|
||||||
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
|
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
|
||||||
| `Bay` | `id`, `locationId` |
|
| `Bay` | `id`, `locationId` |
|
||||||
| `LocationState` | `id`, `discovered` |
|
| `LocationState` | `id`, `discovered` |
|
||||||
| `Contract` | mirrors sim `Contract` |
|
| `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) |
|
||||||
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
|
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
|
||||||
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits`, `pilotXp`, `discoveredLocationId`, `resolvedAt` |
|
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `resolvedAt` |
|
||||||
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight` |
|
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
|
||||||
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries` |
|
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
||||||
|
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
|
|
@ -58,6 +59,9 @@ The `routes` catalog is the single source for both server and client. Each entry
|
||||||
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
|
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
|
||||||
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
||||||
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
|
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
|
||||||
|
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
|
||||||
|
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
|
||||||
|
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
|
||||||
|
|
||||||
## Database (`apps/server/src/db`)
|
## Database (`apps/server/src/db`)
|
||||||
|
|
||||||
|
|
@ -65,15 +69,16 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|
||||||
|
|
||||||
| Table | Purpose | Key constraints |
|
| Table | Purpose | Key constraints |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location | 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 and credits (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) | — |
|
| `ships` | Owned ships: model, name, status, location (null in flight) | — |
|
||||||
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) |
|
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | 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 bay boards and tutorial boards per period (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`) |
|
| `contract_boards` | Stored bay boards and tutorial boards per period (tutorial boards also per run) (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |
|
||||||
| `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) |
|
| `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) |
|
||||||
| `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) |
|
| `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) |
|
||||||
|
| `claimed_goals` | Onboarding goals whose reward was claimed | pk (`player_id`, `goal_id`) |
|
||||||
| `user`, `session`, `account`, `verification` | Better Auth tables (`src/db/auth-schema.ts`, generated by its CLI; `user.is_anonymous` from the anonymous plugin). Timestamps here are Postgres `timestamp`, Better Auth's convention. | — |
|
| `user`, `session`, `account`, `verification` | Better Auth tables (`src/db/auth-schema.ts`, generated by its CLI; `user.is_anonymous` from the anonymous plugin). Timestamps here are Postgres `timestamp`, Better Auth's convention. | — |
|
||||||
|
|
||||||
### Drizzle schema
|
### Drizzle schema
|
||||||
|
|
@ -115,6 +120,10 @@ export const players = pgTable("players", {
|
||||||
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
||||||
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
||||||
loanerLocationId: text("loaner_location_id"),
|
loanerLocationId: text("loaner_location_id"),
|
||||||
|
/** Tutorial runs resolved so far (CR-001). */
|
||||||
|
tutorialRunsCompleted: integer("tutorial_runs_completed").notNull().default(0),
|
||||||
|
/** The one-time deal has been accepted (decision 0.5.30). */
|
||||||
|
dealAccepted: boolean("deal_accepted").notNull().default(false),
|
||||||
createdAt: time("created_at").notNull(),
|
createdAt: time("created_at").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -127,6 +136,8 @@ export const companies = pgTable("companies", {
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
||||||
credits: bigint("credits", { mode: "number" }).notNull(),
|
credits: bigint("credits", { mode: "number" }).notNull(),
|
||||||
|
/** Owed to the lender (decision 0.5.32). */
|
||||||
|
loanBalance: bigint("loan_balance", { mode: "number" }).notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ships = pgTable(
|
export const ships = pgTable(
|
||||||
|
|
@ -210,11 +221,19 @@ export const contractBoards = pgTable(
|
||||||
kind: boardKind("kind").notNull(),
|
kind: boardKind("kind").notNull(),
|
||||||
locationId: text("location_id").notNull(),
|
locationId: text("location_id").notNull(),
|
||||||
periodIndex: integer("period_index").notNull(),
|
periodIndex: integer("period_index").notNull(),
|
||||||
|
/** Tutorial boards only: runs completed when generated (0 for bay boards). */
|
||||||
|
tutorialRun: integer("tutorial_run").notNull().default(0),
|
||||||
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
|
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
|
||||||
createdAt: time("created_at").notNull(),
|
createdAt: time("created_at").notNull(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex),
|
uniqueIndex("contract_boards_unique_idx").on(
|
||||||
|
t.playerId,
|
||||||
|
t.kind,
|
||||||
|
t.locationId,
|
||||||
|
t.periodIndex,
|
||||||
|
t.tutorialRun,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -261,6 +280,19 @@ export const acceptedContracts = pgTable(
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
|
(t) => [primaryKey({ columns: [t.playerId, t.contractId] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Onboarding goals whose reward has been claimed (decision 0.5.34). */
|
||||||
|
export const claimedGoals = pgTable(
|
||||||
|
"claimed_goals",
|
||||||
|
{
|
||||||
|
playerId: text("player_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => players.id, { onDelete: "cascade" }),
|
||||||
|
goalId: text("goal_id").notNull(),
|
||||||
|
claimedAt: time("claimed_at").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
||||||
- [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7.
|
- [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7.
|
||||||
- [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9.
|
- [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9.
|
||||||
- [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%.
|
- [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%.
|
||||||
- [ ] Step 10A.6 — Schema and database
|
- [x] **Step 10A.6 — Schema and database.** Migration `0002_cr001_faster_start`: `companies.loan_balance`, `players.tutorial_runs_completed` / `deal_accepted`, `contract_boards.tutorial_run` (in the unique key), table `claimed_goals`; repayment stored in each dispatch's resolution JSON. API: `company.loanBalance`, `tutorial.runsCompleted` / `dealAvailable`, `goals`, summary `loanRepayment` / `scriptedId`; routes `acceptDeal`, `repayLoan`, `claimGoal`; error codes `tutorial-active`, `deal-not-available`, `unknown-goal`, `goal-not-ready`, `goal-already-claimed`. `architecture.md` updated.
|
||||||
- [ ] Step 10A.7 — Server: tutorial flow v2
|
- [ ] Step 10A.7 — Server: tutorial flow v2
|
||||||
- [ ] Step 10A.8 — Server: accept the deal
|
- [ ] Step 10A.8 — Server: accept the deal
|
||||||
- [ ] Step 10A.9 — Server: loan repayment
|
- [ ] Step 10A.9 — Server: loan repayment
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ export const companySchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
/** May go negative only through pilot pay on a low payout (see DECISIONS Step 3.17). */
|
/** May go negative only through pilot pay on a low payout (see DECISIONS Step 3.17). */
|
||||||
credits,
|
credits,
|
||||||
|
/** Owed to the lender from the deal (decision 0.5.32); 0 when paid off or never taken. */
|
||||||
|
loanBalance: credits.nonnegative(),
|
||||||
});
|
});
|
||||||
export type Company = z.infer<typeof companySchema>;
|
export type Company = z.infer<typeof companySchema>;
|
||||||
|
|
||||||
|
|
@ -113,10 +115,15 @@ export const resolutionSummarySchema = z.object({
|
||||||
originId: idSchema,
|
originId: idSchema,
|
||||||
destinationId: idSchema,
|
destinationId: idSchema,
|
||||||
tutorial: z.boolean(),
|
tutorial: z.boolean(),
|
||||||
|
/** Credits added after pilot pay and loan repayment. */
|
||||||
credits,
|
credits,
|
||||||
|
/** Credits the lender took from this payout (decision 0.5.32). */
|
||||||
|
loanRepayment: credits.nonnegative(),
|
||||||
pilotXp: z.number().int().nonnegative(),
|
pilotXp: z.number().int().nonnegative(),
|
||||||
/** Set when the delivery discovered its destination. */
|
/** Set when the delivery discovered its destination. */
|
||||||
discoveredLocationId: idSchema.nullable(),
|
discoveredLocationId: idSchema.nullable(),
|
||||||
|
/** The scripted tutorial contract delivered, if any (for its flavor message). */
|
||||||
|
scriptedId: idSchema.nullable(),
|
||||||
resolvedAt: timeSchema,
|
resolvedAt: timeSchema,
|
||||||
});
|
});
|
||||||
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
||||||
|
|
@ -125,9 +132,19 @@ export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
||||||
export const tutorialStateSchema = z.object({
|
export const tutorialStateSchema = z.object({
|
||||||
loanerLocationId: z.enum(["earth", "moon"]),
|
loanerLocationId: z.enum(["earth", "moon"]),
|
||||||
runInFlight: z.boolean(),
|
runInFlight: z.boolean(),
|
||||||
|
runsCompleted: z.number().int().nonnegative(),
|
||||||
|
/** The one-time deal can be accepted now (decision 0.5.30). */
|
||||||
|
dealAvailable: z.boolean(),
|
||||||
});
|
});
|
||||||
export type TutorialState = z.infer<typeof tutorialStateSchema>;
|
export type TutorialState = z.infer<typeof tutorialStateSchema>;
|
||||||
|
|
||||||
|
/** An onboarding goal's progress (decision 0.5.34). Static details come from content. */
|
||||||
|
export const goalStateSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
status: z.enum(["incomplete", "ready", "claimed"]),
|
||||||
|
});
|
||||||
|
export type GoalState = z.infer<typeof goalStateSchema>;
|
||||||
|
|
||||||
export const playerStateSchema = z.object({
|
export const playerStateSchema = z.object({
|
||||||
company: companySchema,
|
company: companySchema,
|
||||||
ships: z.array(shipSchema),
|
ships: z.array(shipSchema),
|
||||||
|
|
@ -139,5 +156,7 @@ export const playerStateSchema = z.object({
|
||||||
activeDispatches: z.array(dispatchSchema),
|
activeDispatches: z.array(dispatchSchema),
|
||||||
/** Unacknowledged "while you were away" entries. */
|
/** Unacknowledged "while you were away" entries. */
|
||||||
summaries: z.array(resolutionSummarySchema),
|
summaries: z.array(resolutionSummarySchema),
|
||||||
|
/** Onboarding goals in content order; empty during the tutorial. */
|
||||||
|
goals: z.array(goalStateSchema),
|
||||||
});
|
});
|
||||||
export type PlayerState = z.infer<typeof playerStateSchema>;
|
export type PlayerState = z.infer<typeof playerStateSchema>;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ export const SERVER_ERROR_CODES = [
|
||||||
"skill-maxed",
|
"skill-maxed",
|
||||||
"unknown-skill",
|
"unknown-skill",
|
||||||
"avatar-in-tutorial",
|
"avatar-in-tutorial",
|
||||||
|
"tutorial-active",
|
||||||
|
"deal-not-available",
|
||||||
|
"unknown-goal",
|
||||||
|
"goal-not-ready",
|
||||||
|
"goal-already-claimed",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];
|
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
baySchema,
|
baySchema,
|
||||||
|
companySchema,
|
||||||
contractSchema,
|
contractSchema,
|
||||||
dispatchSchema,
|
dispatchSchema,
|
||||||
idSchema,
|
idSchema,
|
||||||
|
|
@ -63,3 +64,8 @@ export const acknowledgeSummariesRequestSchema = z.object({
|
||||||
export const acknowledgeSummariesResponseSchema = z.object({
|
export const acknowledgeSummariesResponseSchema = z.object({
|
||||||
acknowledged: z.number().int().nonnegative(),
|
acknowledged: z.number().int().nonnegative(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Pay the loan down by up to `amount` credits (decision 0.5.32). */
|
||||||
|
export const repayLoanRequestSchema = z.object({ amount: z.number().int().positive() });
|
||||||
|
export const claimGoalRequestSchema = z.object({ goalId: idSchema });
|
||||||
|
export const companyResponseSchema = z.object({ company: companySchema });
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
assignPilotRequestSchema,
|
assignPilotRequestSchema,
|
||||||
bayResponseSchema,
|
bayResponseSchema,
|
||||||
buyShipRequestSchema,
|
buyShipRequestSchema,
|
||||||
|
claimGoalRequestSchema,
|
||||||
|
companyResponseSchema,
|
||||||
contractBoardRequestSchema,
|
contractBoardRequestSchema,
|
||||||
contractBoardResponseSchema,
|
contractBoardResponseSchema,
|
||||||
createDispatchRequestSchema,
|
createDispatchRequestSchema,
|
||||||
|
|
@ -15,6 +17,7 @@ import {
|
||||||
hirePilotRequestSchema,
|
hirePilotRequestSchema,
|
||||||
leaseBayRequestSchema,
|
leaseBayRequestSchema,
|
||||||
pilotResponseSchema,
|
pilotResponseSchema,
|
||||||
|
repayLoanRequestSchema,
|
||||||
shipResponseSchema,
|
shipResponseSchema,
|
||||||
spendSkillPointRequestSchema,
|
spendSkillPointRequestSchema,
|
||||||
unassignPilotRequestSchema,
|
unassignPilotRequestSchema,
|
||||||
|
|
@ -125,6 +128,27 @@ export const routes = {
|
||||||
response: acknowledgeSummariesResponseSchema,
|
response: acknowledgeSummariesResponseSchema,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
acceptDeal: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/deal/accept",
|
||||||
|
request: emptyRequestSchema,
|
||||||
|
response: playerStateSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
repayLoan: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/loan/repay",
|
||||||
|
request: repayLoanRequestSchema,
|
||||||
|
response: companyResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
claimGoal: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/goals/:goalId/claim",
|
||||||
|
request: claimGoalRequestSchema,
|
||||||
|
response: companyResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
} as const satisfies Record<string, RouteDef>;
|
} as const satisfies Record<string, RouteDef>;
|
||||||
|
|
||||||
export type Routes = typeof routes;
|
export type Routes = typeof routes;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ describe("route catalog", () => {
|
||||||
"unassignPilot",
|
"unassignPilot",
|
||||||
"spendSkillPoint",
|
"spendSkillPoint",
|
||||||
"acknowledgeSummaries",
|
"acknowledgeSummaries",
|
||||||
|
"acceptDeal",
|
||||||
|
"repayLoan",
|
||||||
|
"claimGoal",
|
||||||
].sort(),
|
].sort(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import {
|
||||||
acknowledgeSummariesResponseSchema,
|
acknowledgeSummariesResponseSchema,
|
||||||
assignPilotRequestSchema,
|
assignPilotRequestSchema,
|
||||||
baySchema,
|
baySchema,
|
||||||
|
claimGoalRequestSchema,
|
||||||
|
companyResponseSchema,
|
||||||
|
goalStateSchema,
|
||||||
|
repayLoanRequestSchema,
|
||||||
bayResponseSchema,
|
bayResponseSchema,
|
||||||
buyShipRequestSchema,
|
buyShipRequestSchema,
|
||||||
companySchema,
|
companySchema,
|
||||||
|
|
@ -41,7 +45,7 @@ import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
|
|
||||||
const company = { id: "co1", name: "Deadhead Freight", credits: 5000 };
|
const company = { id: "co1", name: "Deadhead Freight", credits: 5000, loanBalance: 3500 };
|
||||||
const ship = {
|
const ship = {
|
||||||
id: "s1",
|
id: "s1",
|
||||||
modelId: "courier",
|
modelId: "courier",
|
||||||
|
|
@ -104,11 +108,19 @@ const summary = {
|
||||||
destinationId: "earth",
|
destinationId: "earth",
|
||||||
tutorial: true,
|
tutorial: true,
|
||||||
credits: 240,
|
credits: 240,
|
||||||
|
loanRepayment: 0,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
|
scriptedId: "unmarked-crates",
|
||||||
resolvedAt: 3_900_000,
|
resolvedAt: 3_900_000,
|
||||||
};
|
};
|
||||||
const tutorial = { loanerLocationId: "earth", runInFlight: false };
|
const tutorial = {
|
||||||
|
loanerLocationId: "earth",
|
||||||
|
runInFlight: false,
|
||||||
|
runsCompleted: 2,
|
||||||
|
dealAvailable: false,
|
||||||
|
};
|
||||||
|
const goal = { id: "first-hire", status: "ready" };
|
||||||
const playerState = {
|
const playerState = {
|
||||||
company,
|
company,
|
||||||
ships: [ship],
|
ships: [ship],
|
||||||
|
|
@ -118,6 +130,7 @@ const playerState = {
|
||||||
tutorial,
|
tutorial,
|
||||||
activeDispatches: [dispatch],
|
activeDispatches: [dispatch],
|
||||||
summaries: [summary],
|
summaries: [summary],
|
||||||
|
goals: [goal],
|
||||||
};
|
};
|
||||||
|
|
||||||
const cases: [string, z.ZodType, unknown, unknown][] = [
|
const cases: [string, z.ZodType, unknown, unknown][] = [
|
||||||
|
|
@ -136,6 +149,15 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
||||||
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
|
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
|
||||||
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
|
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
|
||||||
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
|
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
|
||||||
|
["goal state", goalStateSchema, goal, { ...goal, status: "done" }],
|
||||||
|
["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],
|
||||||
|
["claim goal request", claimGoalRequestSchema, { goalId: "first-hire" }, {}],
|
||||||
|
[
|
||||||
|
"company response",
|
||||||
|
companyResponseSchema,
|
||||||
|
{ company },
|
||||||
|
{ company: { ...company, loanBalance: -1 } },
|
||||||
|
],
|
||||||
["player state", playerStateSchema, playerState, { ...playerState, ships: null }],
|
["player state", playerStateSchema, playerState, { ...playerState, ships: null }],
|
||||||
[
|
[
|
||||||
"error",
|
"error",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue