Step 10D.4: pilot duty columns, API fields and rest route
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
055cbceaaa
commit
07704e6732
|
|
@ -779,3 +779,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Validation never looks at duty hours** (decision 0.5.44), so a pilot over the limit who is not resting can always be dispatched. Duty only matters when a trip resolves.
|
||||
- **Resolution owns the duty outcome:** it adds the trip's game hours (none for tutorial runs), and at or over the limit returns duty 0 and rest-ends-at = arrival + rest. The resolution time does not matter, so offline resolution matches live.
|
||||
- **Interim wiring:** until the pilots table has the columns (10D.4–10D.5), the server passes `restEndsAt: null` and duty 0, and the client passes `restEndsAt: null`, so behaviour is unchanged.
|
||||
|
||||
## Step 10D.4
|
||||
|
||||
- **`restEndsAt` in the API is null unless the pilot is resting now.** The database keeps the last value (which goes stale harmlessly), but clients only see a time when it matters, next to `resting: true`.
|
||||
- **`dutyLimit` is sent with each pilot** so the client does not recompute Long Haul bonuses for display (it still uses the sim for predictions).
|
||||
- **`pilot-resting` needed no new server code:** the API's error codes include every sim validation reason, and 10D.3 added it there.
|
||||
- **Pilot responses depend on the time** (resting or not), so `toApiPilot`, `loadPlayerState` and the pilot actions now take `now` from `withPlayer`.
|
||||
- **Duty hours are a double** (game hours with fractions, as trip times are). Summaries read `restEndsAt` from the stored resolution, so older resolutions without it show null.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "pilots" ADD COLUMN "duty_hours" double precision DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "pilots" ADD COLUMN "rest_ends_at" bigint;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,6 +29,13 @@
|
|||
"when": 1791053200611,
|
||||
"tag": "0003_cr002_location_boards",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1791078648879,
|
||||
"tag": "0004_cr004_pilot_duty",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
|
|||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
|
|
@ -88,6 +89,10 @@ export const pilots = pgTable(
|
|||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||
locationId: text("location_id"),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
/** Game hours flown since the last rest (decision 0.5.42). */
|
||||
dutyHours: doublePrecision("duty_hours").notNull().default(0),
|
||||
/** Resting until this time; null (or past) when not resting (decision 0.5.45). */
|
||||
restEndsAt: time("rest_ends_at"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, Pilot } from "@deadhead/schema";
|
||||
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
|
||||
import {
|
||||
dutyLimit,
|
||||
isResting,
|
||||
pilotLevel,
|
||||
skillPointsAvailable,
|
||||
type RealTime,
|
||||
} from "@deadhead/sim";
|
||||
import type { dispatches, pilots } from "../db/schema";
|
||||
|
||||
/** Database status values use `in_flight`; the API uses `in-flight`. */
|
||||
|
|
@ -25,7 +31,9 @@ export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch {
|
|||
export function toApiPilot(
|
||||
row: typeof pilots.$inferSelect,
|
||||
assignedShipLocationId: string | null,
|
||||
now: RealTime,
|
||||
): Pilot {
|
||||
const resting = isResting(row.restEndsAt, now);
|
||||
const level = pilotLevel(row.xp, content.config);
|
||||
return {
|
||||
id: row.id,
|
||||
|
|
@ -43,5 +51,9 @@ export function toApiPilot(
|
|||
: row.assignedShipId
|
||||
? assignedShipLocationId
|
||||
: row.locationId,
|
||||
dutyHours: row.dutyHours,
|
||||
dutyLimit: dutyLimit(row.skills, content.config),
|
||||
resting,
|
||||
restEndsAt: resting ? row.restEndsAt : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export async function hirePilot(
|
|||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
return toApiPilot(pilot!, null);
|
||||
return toApiPilot(pilot!, null, now);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -77,6 +77,7 @@ export async function assignPilot(
|
|||
playerId: string,
|
||||
pilotId: string,
|
||||
shipId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
const ship = await requireOwnShip(tx, playerId, shipId);
|
||||
|
|
@ -111,7 +112,7 @@ export async function assignPilot(
|
|||
.set({ assignedShipId: shipId, locationId: null })
|
||||
.where(eq(pilots.id, pilotId))
|
||||
.returning();
|
||||
return toApiPilot(updated!, ship.locationId);
|
||||
return toApiPilot(updated!, ship.locationId, now);
|
||||
}
|
||||
|
||||
/** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */
|
||||
|
|
@ -119,6 +120,7 @@ export async function unassignPilot(
|
|||
tx: Queryable,
|
||||
playerId: string,
|
||||
pilotId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
if (pilot.assignedShipId === null) {
|
||||
|
|
@ -137,7 +139,7 @@ export async function unassignPilot(
|
|||
.set({ assignedShipId: null, locationId: ship.locationId })
|
||||
.where(eq(pilots.id, pilotId))
|
||||
.returning();
|
||||
return toApiPilot(updated!, null);
|
||||
return toApiPilot(updated!, null, now);
|
||||
}
|
||||
|
||||
/** Spends one available skill point on a skill below its max level (Step 6.12). */
|
||||
|
|
@ -146,6 +148,7 @@ export async function spendSkillPoint(
|
|||
playerId: string,
|
||||
pilotId: string,
|
||||
skillId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
const skill = content.skill(skillId);
|
||||
|
|
@ -171,5 +174,5 @@ export async function spendSkillPoint(
|
|||
const [ship] = updated!.assignedShipId
|
||||
? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId))
|
||||
: [];
|
||||
return toApiPilot(updated!, ship?.locationId ?? null);
|
||||
return toApiPilot(updated!, ship?.locationId ?? null, now);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { PlayerState } from "@deadhead/schema";
|
||||
import type { RealTime } from "@deadhead/sim";
|
||||
import { and, eq, isNull, max } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
|
|
@ -16,7 +17,11 @@ import { goalStates } from "./goals";
|
|||
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
|
||||
|
||||
/** Reads everything the client needs about one player. */
|
||||
export async function loadPlayerState(db: Queryable, playerId: string): Promise<PlayerState> {
|
||||
export async function loadPlayerState(
|
||||
db: Queryable,
|
||||
playerId: string,
|
||||
now: RealTime,
|
||||
): Promise<PlayerState> {
|
||||
const [player] = await db.select().from(players).where(eq(players.id, playerId));
|
||||
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
|
||||
if (!player || !company) throw new Error(`Player ${playerId} not found`);
|
||||
|
|
@ -74,7 +79,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
|
||||
})),
|
||||
pilots: pilotRows.map((p) =>
|
||||
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),
|
||||
toApiPilot(
|
||||
p,
|
||||
p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null,
|
||||
now,
|
||||
),
|
||||
),
|
||||
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
|
||||
locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })),
|
||||
|
|
@ -103,6 +112,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
pilotXp: d.resolution?.pilotXp ?? 0,
|
||||
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
|
||||
scriptedId: d.record.contract?.scriptedId ?? null,
|
||||
restEndsAt: d.resolution?.pilotRestEndsAt ?? null,
|
||||
resolvedAt: d.resolvedAt ?? d.arrivalAt,
|
||||
})),
|
||||
goals: await goalStates(db, playerId),
|
||||
|
|
|
|||
|
|
@ -27,20 +27,20 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void
|
|||
);
|
||||
|
||||
registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId),
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId, now),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await unassignPilot(tx, playerId, input.pilotId),
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await unassignPilot(tx, playerId, input.pilotId, now),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId),
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, now),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,12 +39,12 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
|
|||
});
|
||||
// Pass Better Auth's session cookie through to the client.
|
||||
reply.header("set-cookie", signIn.headers.getSetCookie());
|
||||
return { playerId, state: await loadPlayerState(db, playerId) };
|
||||
return { playerId, state: await loadPlayerState(db, playerId, clock()) };
|
||||
});
|
||||
|
||||
// Lazy resolution on read (Step 6.1): apply every arrival before returning state.
|
||||
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, playerId)),
|
||||
withPlayer(ctx, playerId, (tx, now) => loadPlayerState(tx, playerId, now)),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) =>
|
||||
|
|
@ -56,7 +56,7 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
|
|||
registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => {
|
||||
await acceptDeal(tx, playerId, now);
|
||||
return loadPlayerState(tx, playerId);
|
||||
return loadPlayerState(tx, playerId, now);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ describe("typed API client", () => {
|
|||
assignedShipId: "s1",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
const fetch = mockFetch(200, { pilot });
|
||||
const api = createApiClient({ fetch });
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const summaries: ResolutionSummary[] = [
|
|||
pilotXp: 32,
|
||||
discoveredLocationId: "mars",
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
{
|
||||
|
|
@ -42,6 +43,7 @@ const summaries: ResolutionSummary[] = [
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 2,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ describe("the deal", () => {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil
|
|||
assignedShipId: shipId,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
});
|
||||
|
||||
const marsJob: Contract = {
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ describe("loan on HQ", () => {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ const rae: Pilot = {
|
|||
assignedShipId: null,
|
||||
status: "idle",
|
||||
locationId: "moon",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
|
||||
function pilotsState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
|||
assignedShipId: null,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
},
|
||||
],
|
||||
bays: [],
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ describe("scripted delivery summary", () => {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: "unmarked-crates",
|
||||
restEndsAt: null,
|
||||
resolvedAt: NOW,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ 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) |
|
||||
| `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), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) |
|
||||
| `Bay` | `id`, `locationId` |
|
||||
| `LocationState` | `id`, `discovered` |
|
||||
| `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` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `resolvedAt` |
|
||||
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `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) |
|
||||
|
|
@ -57,6 +57,7 @@ The `routes` catalog is the single source for both server and client. Each entry
|
|||
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
|
||||
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
|
||||
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
|
||||
| `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ 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 |
|
||||
|
|
@ -72,7 +73,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
|
|||
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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`) |
|
||||
|
|
@ -176,6 +177,10 @@ export const pilots = pgTable(
|
|||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||
locationId: text("location_id"),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
/** Game hours flown since the last rest (decision 0.5.42). */
|
||||
dutyHours: doublePrecision("duty_hours").notNull().default(0),
|
||||
/** Resting until this time; null (or past) when not resting (decision 0.5.45). */
|
||||
restEndsAt: time("rest_ends_at"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
|
|||
- [x] **Step 10D.1 — Content config.** GameConfig gains `dutyLimitGameHours` (12), `longHaulDutyBonusGameHours` (2) and `restGameHours` (60 = one 60-minute board period at 60× compression). Tests: rest in real time ≥ board period; duty numbers positive.
|
||||
- [x] **Step 10D.2 — Sim: duty and rest functions.** `packages/sim/src/duty.ts`: `dutyLimit(skills)`, `isResting(restEndsAt, now)`, `tripGameHours(departure, arrival)`, `dutyAfterTrip(duty, hours, tutorial)`, `needsRest(duty, limit)`, `restEndsAt(start)`. Test: `duty.test.ts` (including 2,000 random rest starts all waking in a later board period).
|
||||
- [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`.
|
||||
- [ ] Step 10D.4 — Schema and database
|
||||
- [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated.
|
||||
- [ ] Step 10D.5 — Server: apply duty and rest
|
||||
- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
|
||||
- [ ] Step 10D.7 — Client: duty in the dispatch flow
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export const pilotSchema = z.object({
|
|||
status: z.enum(["idle", "in-flight"]),
|
||||
/** Where the pilot is (their ship's location if assigned); null while in flight. */
|
||||
locationId: idSchema.nullable(),
|
||||
/** Game hours flown since the last rest, and the limit before mandatory rest (CR-004). */
|
||||
dutyHours: z.number().nonnegative(),
|
||||
dutyLimit: z.number().positive(),
|
||||
/** Resting pilots cannot fly until `restEndsAt` (null when not resting). */
|
||||
resting: z.boolean(),
|
||||
restEndsAt: timeSchema.nullable(),
|
||||
});
|
||||
export type Pilot = z.infer<typeof pilotSchema>;
|
||||
|
||||
|
|
@ -129,6 +135,8 @@ export const resolutionSummarySchema = z.object({
|
|||
discoveredLocationId: idSchema.nullable(),
|
||||
/** The scripted tutorial contract delivered, if any (for its flavor message). */
|
||||
scriptedId: idSchema.nullable(),
|
||||
/** Set when the trip ended in mandatory rest: the pilot rests until then (CR-004). */
|
||||
restEndsAt: timeSchema.nullable(),
|
||||
resolvedAt: timeSchema,
|
||||
});
|
||||
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export const bayResponseSchema = z.object({ bay: baySchema });
|
|||
export const hirePilotRequestSchema = z.object({ locationId: idSchema });
|
||||
export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema });
|
||||
export const unassignPilotRequestSchema = z.object({ pilotId: idSchema });
|
||||
/** CR-004: rest an idle pilot now, at any duty level. */
|
||||
export const restPilotRequestSchema = z.object({ pilotId: idSchema });
|
||||
export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema });
|
||||
export const pilotResponseSchema = z.object({ pilot: pilotSchema });
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
repayLoanRequestSchema,
|
||||
shipResponseSchema,
|
||||
spendSkillPointRequestSchema,
|
||||
restPilotRequestSchema,
|
||||
unassignPilotRequestSchema,
|
||||
} from "./requests";
|
||||
|
||||
|
|
@ -114,6 +115,13 @@ export const routes = {
|
|||
response: pilotResponseSchema,
|
||||
auth: true,
|
||||
},
|
||||
restPilot: {
|
||||
method: "POST",
|
||||
path: "/api/pilots/:pilotId/rest",
|
||||
request: restPilotRequestSchema,
|
||||
response: pilotResponseSchema,
|
||||
auth: true,
|
||||
},
|
||||
spendSkillPoint: {
|
||||
method: "POST",
|
||||
path: "/api/pilots/:pilotId/skills",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ describe("route catalog", () => {
|
|||
"hirePilot",
|
||||
"assignPilot",
|
||||
"unassignPilot",
|
||||
"restPilot",
|
||||
"spendSkillPoint",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
locationStateSchema,
|
||||
pilotResponseSchema,
|
||||
pilotSchema,
|
||||
restPilotRequestSchema,
|
||||
playerStateSchema,
|
||||
resolutionSummarySchema,
|
||||
serverTimeSchema,
|
||||
|
|
@ -67,6 +68,10 @@ const pilot = {
|
|||
assignedShipId: "s1",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 7.5,
|
||||
dutyLimit: 14,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
const bay = { id: "b1", locationId: "earth" };
|
||||
const location = { id: "mars", discovered: false };
|
||||
|
|
@ -113,6 +118,7 @@ const summary = {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: "unmarked-crates",
|
||||
restEndsAt: null,
|
||||
resolvedAt: 3_900_000,
|
||||
};
|
||||
const tutorial = {
|
||||
|
|
@ -144,11 +150,25 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
["company", companySchema, company, { ...company, credits: 1.5 }],
|
||||
["ship", shipSchema, ship, { ...ship, status: "lost" }],
|
||||
["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }],
|
||||
[
|
||||
"resting pilot",
|
||||
pilotSchema,
|
||||
{ ...pilot, dutyHours: 0, resting: true, restEndsAt: 4_000_000 },
|
||||
{ ...pilot, dutyHours: -1 },
|
||||
],
|
||||
["pilot duty limit", pilotSchema, { ...pilot, dutyLimit: 12 }, { ...pilot, dutyLimit: 0 }],
|
||||
["rest pilot request", restPilotRequestSchema, { pilotId: "p1" }, { pilotId: "" }],
|
||||
["bay", baySchema, bay, { id: "b1" }],
|
||||
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
|
||||
["contract", contractSchema, contract, { ...contract, tier: -1 }],
|
||||
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
|
||||
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
|
||||
[
|
||||
"summary with rest started",
|
||||
resolutionSummarySchema,
|
||||
{ ...summary, restEndsAt: 7_500_000 },
|
||||
{ ...summary, restEndsAt: -1 },
|
||||
],
|
||||
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
|
||||
["goal state", goalStateSchema, goal, { ...goal, status: "done" }],
|
||||
["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],
|
||||
|
|
|
|||
Loading…
Reference in New Issue