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.
|
||||
- **"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.
|
||||
|
||||
## 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,
|
||||
"tag": "0001_auth",
|
||||
"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),
|
||||
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
||||
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(),
|
||||
});
|
||||
|
||||
|
|
@ -44,6 +48,8 @@ export const companies = pgTable("companies", {
|
|||
name: text("name").notNull(),
|
||||
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
||||
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(
|
||||
|
|
@ -127,11 +133,19 @@ export const contractBoards = pgTable(
|
|||
kind: boardKind("kind").notNull(),
|
||||
locationId: text("location_id").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(),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(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] })],
|
||||
);
|
||||
|
||||
/** 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,
|
||||
ships,
|
||||
} from "../db/schema";
|
||||
import { isDealAvailable } from "./deal";
|
||||
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
|
||||
|
||||
/** Reads everything the client needs about one player. */
|
||||
|
|
@ -49,7 +50,12 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
);
|
||||
|
||||
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) => ({
|
||||
id: s.id,
|
||||
modelId: s.modelId,
|
||||
|
|
@ -69,6 +75,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
? {
|
||||
loanerLocationId: player.loanerLocationId as "earth" | "moon",
|
||||
runInFlight: active.some((d) => d.tutorial),
|
||||
runsCompleted: player.tutorialRunsCompleted,
|
||||
dealAvailable: isDealAvailable(
|
||||
player,
|
||||
active.some((d) => d.tutorial),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
activeDispatches: active.map(toApiDispatch),
|
||||
|
|
@ -80,9 +91,13 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
destinationId: d.record.destinationId,
|
||||
tutorial: d.tutorial,
|
||||
credits: d.resolution?.credits ?? 0,
|
||||
loanRepayment: d.resolution?.loanRepayment ?? 0,
|
||||
pilotXp: d.resolution?.pilotXp ?? 0,
|
||||
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
|
||||
scriptedId: d.record.contract?.scriptedId ?? null,
|
||||
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",
|
||||
"unknown-contract",
|
||||
"unknown-skill",
|
||||
"unknown-goal",
|
||||
]);
|
||||
|
||||
/** A game-rule failure: 404 for unknown things, 409 for everything else. */
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe("end of tutorial", () => {
|
|||
);
|
||||
|
||||
await buyShipAndEarthBay(guest);
|
||||
expect((await getState(app, guest)).tutorial).toEqual({
|
||||
expect((await getState(app, guest)).tutorial).toMatchObject({
|
||||
loanerLocationId: "earth",
|
||||
runInFlight: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe("guest accounts", () => {
|
|||
|
||||
const body = createGuestResponseSchema.parse(response.json());
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ describe("database migrations", () => {
|
|||
"accepted_contracts",
|
||||
"account",
|
||||
"bays",
|
||||
"claimed_goals",
|
||||
"companies",
|
||||
"contract_boards",
|
||||
"discovered_locations",
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe("tutorial dispatch", () => {
|
|||
|
||||
const state = await getState(app, guest);
|
||||
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.activeDispatches.map((d) => d.id)).toEqual([dispatch.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.pilots[0]!.xp).toBeGreaterThan(0);
|
||||
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);
|
||||
expect(next.length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ const summaries: ResolutionSummary[] = [
|
|||
destinationId: "mars",
|
||||
tutorial: false,
|
||||
credits: 51_200,
|
||||
loanRepayment: 0,
|
||||
pilotXp: 32,
|
||||
discoveredLocationId: "mars",
|
||||
scriptedId: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
{
|
||||
|
|
@ -35,8 +37,10 @@ const summaries: ResolutionSummary[] = [
|
|||
destinationId: "earth",
|
||||
tutorial: true,
|
||||
credits: 240,
|
||||
loanRepayment: 0,
|
||||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
resolvedAt: 2,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const marsJob: Contract = {
|
|||
|
||||
const state = samplePlayerState({
|
||||
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" }],
|
||||
ships: [
|
||||
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 () => {
|
||||
const goal = tutorialGoalCost();
|
||||
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 }));
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ describe("HQ screen", () => {
|
|||
|
||||
it("caps tutorial progress at 100%", async () => {
|
||||
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 }));
|
||||
expect(await screen.findByText("100%")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { fakeApi, renderApp, samplePlayerState } from "./render";
|
|||
|
||||
const signedIn = () =>
|
||||
fakeApi({
|
||||
getPlayerState: () => samplePlayerState({ company: { id: "c", name: "D", credits: 1234 } }),
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({ company: { id: "c", name: "D", credits: 1234, loanBalance: 0 } }),
|
||||
});
|
||||
|
||||
describe("app layout", () => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe("locations screen", () => {
|
|||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
company: { id: "c", name: "D", credits: 100_000 },
|
||||
company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 },
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ describe("market screen", () => {
|
|||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({ company: { id: "c", name: "D", credits: courierPrice } }),
|
||||
samplePlayerState({
|
||||
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
|
||||
}),
|
||||
}),
|
||||
"/market",
|
||||
);
|
||||
|
|
@ -51,7 +53,12 @@ describe("market screen", () => {
|
|||
samplePlayerState({
|
||||
tutorial: null,
|
||||
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: () => {
|
||||
owned = [bought];
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ describe("pilots screen", () => {
|
|||
const cost = content.config.pilotHireCost;
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => pilotsState({ company: { id: "c", name: "D", credits: cost - 1 } }),
|
||||
getPlayerState: () =>
|
||||
pilotsState({ company: { id: "c", name: "D", credits: cost - 1, loanBalance: 0 } }),
|
||||
}),
|
||||
"/pilots",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const unauthorized = () => {
|
|||
/** A brand-new tutorial player, as guest sign-up returns it. */
|
||||
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
return {
|
||||
company: { id: "co1", name: "Deadhead Freight", credits: 0 },
|
||||
company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 },
|
||||
ships: [],
|
||||
pilots: [
|
||||
{
|
||||
|
|
@ -58,9 +58,15 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
|||
{ id: "ceres-station", discovered: false },
|
||||
{ id: "jupiter-cloud-platform", discovered: false },
|
||||
],
|
||||
tutorial: { loanerLocationId: "earth", runInFlight: false },
|
||||
tutorial: {
|
||||
loanerLocationId: "earth",
|
||||
runInFlight: false,
|
||||
runsCompleted: 0,
|
||||
dealAvailable: false,
|
||||
},
|
||||
activeDispatches: [],
|
||||
summaries: [],
|
||||
goals: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,14 @@ describe("tutorial contract board", () => {
|
|||
it("disables Fly it while a tutorial run is in flight", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true } }),
|
||||
samplePlayerState({
|
||||
tutorial: {
|
||||
loanerLocationId: "earth",
|
||||
runInFlight: true,
|
||||
runsCompleted: 0,
|
||||
dealAvailable: false,
|
||||
},
|
||||
}),
|
||||
getTutorialBoard: () => board,
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
|
|
|
|||
|
|
@ -24,16 +24,17 @@ Living reference for how the code is organised. Update it after any database sch
|
|||
| Shape | Fields |
|
||||
|---|---|
|
||||
| `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` |
|
||||
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
|
||||
| `Bay` | `id`, `locationId` |
|
||||
| `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` |
|
||||
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits`, `pilotXp`, `discoveredLocationId`, `resolvedAt` |
|
||||
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight` |
|
||||
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries` |
|
||||
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `resolvedAt` |
|
||||
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
|
||||
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
||||
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
||||
|
||||
### 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 |
|
||||
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | 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`)
|
||||
|
||||
|
|
@ -65,15 +69,16 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|
|||
|
||||
| Table | Purpose | Key constraints |
|
||||
|---|---|---|
|
||||
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location | pk `id`, unique `user_id` → `user.id` |
|
||||
| `companies` | Name and credits (the row locked for money changes) | unique `player_id` |
|
||||
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
|
||||
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
|
||||
| `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) |
|
||||
| `bays` | Leased bays | unique (`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`) |
|
||||
| `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. | — |
|
||||
|
||||
### Drizzle schema
|
||||
|
|
@ -115,6 +120,10 @@ export const players = pgTable("players", {
|
|||
tutorialActive: boolean("tutorial_active").notNull().default(true),
|
||||
/** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */
|
||||
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(),
|
||||
});
|
||||
|
||||
|
|
@ -127,6 +136,8 @@ export const companies = pgTable("companies", {
|
|||
name: text("name").notNull(),
|
||||
/** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */
|
||||
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(
|
||||
|
|
@ -210,11 +221,19 @@ export const contractBoards = pgTable(
|
|||
kind: boardKind("kind").notNull(),
|
||||
locationId: text("location_id").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(),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(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] })],
|
||||
);
|
||||
|
||||
/** 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
|
||||
|
|
|
|||
|
|
@ -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.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%.
|
||||
- [ ] 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.8 — Server: accept the deal
|
||||
- [ ] Step 10A.9 — Server: loan repayment
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export const companySchema = z.object({
|
|||
name: z.string().min(1),
|
||||
/** May go negative only through pilot pay on a low payout (see DECISIONS Step 3.17). */
|
||||
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>;
|
||||
|
||||
|
|
@ -113,10 +115,15 @@ export const resolutionSummarySchema = z.object({
|
|||
originId: idSchema,
|
||||
destinationId: idSchema,
|
||||
tutorial: z.boolean(),
|
||||
/** Credits added after pilot pay and loan repayment. */
|
||||
credits,
|
||||
/** Credits the lender took from this payout (decision 0.5.32). */
|
||||
loanRepayment: credits.nonnegative(),
|
||||
pilotXp: z.number().int().nonnegative(),
|
||||
/** Set when the delivery discovered its destination. */
|
||||
discoveredLocationId: idSchema.nullable(),
|
||||
/** The scripted tutorial contract delivered, if any (for its flavor message). */
|
||||
scriptedId: idSchema.nullable(),
|
||||
resolvedAt: timeSchema,
|
||||
});
|
||||
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
||||
|
|
@ -125,9 +132,19 @@ export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
|||
export const tutorialStateSchema = z.object({
|
||||
loanerLocationId: z.enum(["earth", "moon"]),
|
||||
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>;
|
||||
|
||||
/** 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({
|
||||
company: companySchema,
|
||||
ships: z.array(shipSchema),
|
||||
|
|
@ -139,5 +156,7 @@ export const playerStateSchema = z.object({
|
|||
activeDispatches: z.array(dispatchSchema),
|
||||
/** Unacknowledged "while you were away" entries. */
|
||||
summaries: z.array(resolutionSummarySchema),
|
||||
/** Onboarding goals in content order; empty during the tutorial. */
|
||||
goals: z.array(goalStateSchema),
|
||||
});
|
||||
export type PlayerState = z.infer<typeof playerStateSchema>;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export const SERVER_ERROR_CODES = [
|
|||
"skill-maxed",
|
||||
"unknown-skill",
|
||||
"avatar-in-tutorial",
|
||||
"tutorial-active",
|
||||
"deal-not-available",
|
||||
"unknown-goal",
|
||||
"goal-not-ready",
|
||||
"goal-already-claimed",
|
||||
] as const;
|
||||
|
||||
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
baySchema,
|
||||
companySchema,
|
||||
contractSchema,
|
||||
dispatchSchema,
|
||||
idSchema,
|
||||
|
|
@ -63,3 +64,8 @@ export const acknowledgeSummariesRequestSchema = z.object({
|
|||
export const acknowledgeSummariesResponseSchema = z.object({
|
||||
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,
|
||||
bayResponseSchema,
|
||||
buyShipRequestSchema,
|
||||
claimGoalRequestSchema,
|
||||
companyResponseSchema,
|
||||
contractBoardRequestSchema,
|
||||
contractBoardResponseSchema,
|
||||
createDispatchRequestSchema,
|
||||
|
|
@ -15,6 +17,7 @@ import {
|
|||
hirePilotRequestSchema,
|
||||
leaseBayRequestSchema,
|
||||
pilotResponseSchema,
|
||||
repayLoanRequestSchema,
|
||||
shipResponseSchema,
|
||||
spendSkillPointRequestSchema,
|
||||
unassignPilotRequestSchema,
|
||||
|
|
@ -125,6 +128,27 @@ export const routes = {
|
|||
response: acknowledgeSummariesResponseSchema,
|
||||
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>;
|
||||
|
||||
export type Routes = typeof routes;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ describe("route catalog", () => {
|
|||
"unassignPilot",
|
||||
"spendSkillPoint",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
"repayLoan",
|
||||
"claimGoal",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import {
|
|||
acknowledgeSummariesResponseSchema,
|
||||
assignPilotRequestSchema,
|
||||
baySchema,
|
||||
claimGoalRequestSchema,
|
||||
companyResponseSchema,
|
||||
goalStateSchema,
|
||||
repayLoanRequestSchema,
|
||||
bayResponseSchema,
|
||||
buyShipRequestSchema,
|
||||
companySchema,
|
||||
|
|
@ -41,7 +45,7 @@ import {
|
|||
type Dispatch,
|
||||
} 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 = {
|
||||
id: "s1",
|
||||
modelId: "courier",
|
||||
|
|
@ -104,11 +108,19 @@ const summary = {
|
|||
destinationId: "earth",
|
||||
tutorial: true,
|
||||
credits: 240,
|
||||
loanRepayment: 0,
|
||||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: "unmarked-crates",
|
||||
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 = {
|
||||
company,
|
||||
ships: [ship],
|
||||
|
|
@ -118,6 +130,7 @@ const playerState = {
|
|||
tutorial,
|
||||
activeDispatches: [dispatch],
|
||||
summaries: [summary],
|
||||
goals: [goal],
|
||||
};
|
||||
|
||||
const cases: [string, z.ZodType, unknown, unknown][] = [
|
||||
|
|
@ -136,6 +149,15 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
|
||||
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
|
||||
["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 }],
|
||||
[
|
||||
"error",
|
||||
|
|
|
|||
Loading…
Reference in New Issue