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.
|
- **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.
|
- **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.
|
- **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,
|
"when": 1791053200611,
|
||||||
"tag": "0003_cr002_location_boards",
|
"tag": "0003_cr002_location_boards",
|
||||||
"breakpoints": true
|
"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 {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
boolean,
|
boolean,
|
||||||
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
|
|
@ -88,6 +89,10 @@ export const pilots = pgTable(
|
||||||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||||
locationId: text("location_id"),
|
locationId: text("location_id"),
|
||||||
status: activityStatus("status").notNull().default("idle"),
|
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(),
|
createdAt: time("created_at").notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
(t) => [index("pilots_player_idx").on(t.playerId)],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, Pilot } from "@deadhead/schema";
|
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";
|
import type { dispatches, pilots } from "../db/schema";
|
||||||
|
|
||||||
/** Database status values use `in_flight`; the API uses `in-flight`. */
|
/** 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(
|
export function toApiPilot(
|
||||||
row: typeof pilots.$inferSelect,
|
row: typeof pilots.$inferSelect,
|
||||||
assignedShipLocationId: string | null,
|
assignedShipLocationId: string | null,
|
||||||
|
now: RealTime,
|
||||||
): Pilot {
|
): Pilot {
|
||||||
|
const resting = isResting(row.restEndsAt, now);
|
||||||
const level = pilotLevel(row.xp, content.config);
|
const level = pilotLevel(row.xp, content.config);
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
@ -43,5 +51,9 @@ export function toApiPilot(
|
||||||
: row.assignedShipId
|
: row.assignedShipId
|
||||||
? assignedShipLocationId
|
? assignedShipLocationId
|
||||||
: row.locationId,
|
: 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,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return toApiPilot(pilot!, null);
|
return toApiPilot(pilot!, null, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -77,6 +77,7 @@ export async function assignPilot(
|
||||||
playerId: string,
|
playerId: string,
|
||||||
pilotId: string,
|
pilotId: string,
|
||||||
shipId: string,
|
shipId: string,
|
||||||
|
now: RealTime,
|
||||||
): Promise<Pilot> {
|
): Promise<Pilot> {
|
||||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||||
const ship = await requireOwnShip(tx, playerId, shipId);
|
const ship = await requireOwnShip(tx, playerId, shipId);
|
||||||
|
|
@ -111,7 +112,7 @@ export async function assignPilot(
|
||||||
.set({ assignedShipId: shipId, locationId: null })
|
.set({ assignedShipId: shipId, locationId: null })
|
||||||
.where(eq(pilots.id, pilotId))
|
.where(eq(pilots.id, pilotId))
|
||||||
.returning();
|
.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). */
|
/** 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,
|
tx: Queryable,
|
||||||
playerId: string,
|
playerId: string,
|
||||||
pilotId: string,
|
pilotId: string,
|
||||||
|
now: RealTime,
|
||||||
): Promise<Pilot> {
|
): Promise<Pilot> {
|
||||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||||
if (pilot.assignedShipId === null) {
|
if (pilot.assignedShipId === null) {
|
||||||
|
|
@ -137,7 +139,7 @@ export async function unassignPilot(
|
||||||
.set({ assignedShipId: null, locationId: ship.locationId })
|
.set({ assignedShipId: null, locationId: ship.locationId })
|
||||||
.where(eq(pilots.id, pilotId))
|
.where(eq(pilots.id, pilotId))
|
||||||
.returning();
|
.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). */
|
/** 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,
|
playerId: string,
|
||||||
pilotId: string,
|
pilotId: string,
|
||||||
skillId: string,
|
skillId: string,
|
||||||
|
now: RealTime,
|
||||||
): Promise<Pilot> {
|
): Promise<Pilot> {
|
||||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||||
const skill = content.skill(skillId);
|
const skill = content.skill(skillId);
|
||||||
|
|
@ -171,5 +174,5 @@ export async function spendSkillPoint(
|
||||||
const [ship] = updated!.assignedShipId
|
const [ship] = updated!.assignedShipId
|
||||||
? await tx.select().from(ships).where(eq(ships.id, 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 { content } from "@deadhead/content";
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
import type { PlayerState } from "@deadhead/schema";
|
||||||
|
import type { RealTime } from "@deadhead/sim";
|
||||||
import { and, eq, isNull, max } from "drizzle-orm";
|
import { and, eq, isNull, max } from "drizzle-orm";
|
||||||
import type { Queryable } from "../db/client";
|
import type { Queryable } from "../db/client";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,7 +17,11 @@ import { goalStates } from "./goals";
|
||||||
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. */
|
||||||
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 [player] = await db.select().from(players).where(eq(players.id, playerId));
|
||||||
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
|
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
|
||||||
if (!player || !company) throw new Error(`Player ${playerId} not found`);
|
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,
|
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
|
||||||
})),
|
})),
|
||||||
pilots: pilotRows.map((p) =>
|
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 })),
|
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
|
||||||
locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })),
|
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,
|
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,
|
scriptedId: d.record.contract?.scriptedId ?? null,
|
||||||
|
restEndsAt: d.resolution?.pilotRestEndsAt ?? null,
|
||||||
resolvedAt: d.resolvedAt ?? d.arrivalAt,
|
resolvedAt: d.resolvedAt ?? d.arrivalAt,
|
||||||
})),
|
})),
|
||||||
goals: await goalStates(db, playerId),
|
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 }) =>
|
registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx) => ({
|
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||||
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId),
|
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId, now),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
|
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx) => ({
|
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||||
pilot: await unassignPilot(tx, playerId, input.pilotId),
|
pilot: await unassignPilot(tx, playerId, input.pilotId, now),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
|
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx) => ({
|
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||||
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId),
|
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.
|
// Pass Better Auth's session cookie through to the client.
|
||||||
reply.header("set-cookie", signIn.headers.getSetCookie());
|
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.
|
// Lazy resolution on read (Step 6.1): apply every arrival before returning state.
|
||||||
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
|
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 }) =>
|
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 }) =>
|
registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx, now) => {
|
withPlayer(ctx, playerId, async (tx, now) => {
|
||||||
await acceptDeal(tx, playerId, 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",
|
assignedShipId: "s1",
|
||||||
status: "idle",
|
status: "idle",
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
|
dutyHours: 0,
|
||||||
|
dutyLimit: 12,
|
||||||
|
resting: false,
|
||||||
|
restEndsAt: null,
|
||||||
};
|
};
|
||||||
const fetch = mockFetch(200, { pilot });
|
const fetch = mockFetch(200, { pilot });
|
||||||
const api = createApiClient({ fetch });
|
const api = createApiClient({ fetch });
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
scriptedId: null,
|
scriptedId: null,
|
||||||
|
restEndsAt: null,
|
||||||
resolvedAt: 1,
|
resolvedAt: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const summaries: ResolutionSummary[] = [
|
||||||
pilotXp: 32,
|
pilotXp: 32,
|
||||||
discoveredLocationId: "mars",
|
discoveredLocationId: "mars",
|
||||||
scriptedId: null,
|
scriptedId: null,
|
||||||
|
restEndsAt: null,
|
||||||
resolvedAt: 1,
|
resolvedAt: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -42,6 +43,7 @@ const summaries: ResolutionSummary[] = [
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
scriptedId: null,
|
scriptedId: null,
|
||||||
|
restEndsAt: null,
|
||||||
resolvedAt: 2,
|
resolvedAt: 2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ describe("the deal", () => {
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
scriptedId: null,
|
scriptedId: null,
|
||||||
|
restEndsAt: null,
|
||||||
resolvedAt: 1,
|
resolvedAt: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil
|
||||||
assignedShipId: shipId,
|
assignedShipId: shipId,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
|
dutyHours: 0,
|
||||||
|
dutyLimit: 12,
|
||||||
|
resting: false,
|
||||||
|
restEndsAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const marsJob: Contract = {
|
const marsJob: Contract = {
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ describe("loan on HQ", () => {
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
scriptedId: null,
|
scriptedId: null,
|
||||||
|
restEndsAt: null,
|
||||||
resolvedAt: 1,
|
resolvedAt: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ const rae: Pilot = {
|
||||||
assignedShipId: null,
|
assignedShipId: null,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
locationId: "moon",
|
locationId: "moon",
|
||||||
|
dutyHours: 0,
|
||||||
|
dutyLimit: 12,
|
||||||
|
resting: false,
|
||||||
|
restEndsAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function pilotsState(overrides: Partial<PlayerState> = {}): PlayerState {
|
function pilotsState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
||||||
assignedShipId: null,
|
assignedShipId: null,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
|
dutyHours: 0,
|
||||||
|
dutyLimit: 12,
|
||||||
|
resting: false,
|
||||||
|
restEndsAt: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bays: [],
|
bays: [],
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ describe("scripted delivery summary", () => {
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
scriptedId: "unmarked-crates",
|
scriptedId: "unmarked-crates",
|
||||||
|
restEndsAt: null,
|
||||||
resolvedAt: NOW,
|
resolvedAt: NOW,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,12 @@ Living reference for how the code is organised. Update it after any database sch
|
||||||
| `ServerTime` | `realTime`, `gameTime` |
|
| `ServerTime` | `realTime`, `gameTime` |
|
||||||
| `Company` | `id`, `name`, `credits`, `loanBalance` |
|
| `Company` | `id`, `name`, `credits`, `loanBalance` |
|
||||||
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) |
|
| `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` |
|
| `Bay` | `id`, `locationId` |
|
||||||
| `LocationState` | `id`, `discovered` |
|
| `LocationState` | `id`, `discovered` |
|
||||||
| `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial 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` (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` |
|
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` |
|
||||||
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
||||||
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) |
|
| `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 |
|
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
|
||||||
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
|
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
|
||||||
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ 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 |
|
| `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 |
|
| `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` |
|
| `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` |
|
| `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, duty hours, rest-ends-at (CR-004) | unique `assigned_ship_id` (one pilot per ship) |
|
||||||
| `bays` | Leased bays | unique (`player_id`, `location_id`) |
|
| `bays` | Leased bays | unique (`player_id`, `location_id`) |
|
||||||
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
|
| `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) |
|
||||||
| `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |
|
| `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) |
|
||||||
|
|
@ -176,6 +177,10 @@ export const pilots = pgTable(
|
||||||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||||
locationId: text("location_id"),
|
locationId: text("location_id"),
|
||||||
status: activityStatus("status").notNull().default("idle"),
|
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(),
|
createdAt: time("created_at").notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
(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.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.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`.
|
- [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.5 — Server: apply duty and rest
|
||||||
- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
|
- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
|
||||||
- [ ] Step 10D.7 — Client: duty in the dispatch flow
|
- [ ] Step 10D.7 — Client: duty in the dispatch flow
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@ export const pilotSchema = z.object({
|
||||||
status: z.enum(["idle", "in-flight"]),
|
status: z.enum(["idle", "in-flight"]),
|
||||||
/** Where the pilot is (their ship's location if assigned); null while in flight. */
|
/** Where the pilot is (their ship's location if assigned); null while in flight. */
|
||||||
locationId: idSchema.nullable(),
|
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>;
|
export type Pilot = z.infer<typeof pilotSchema>;
|
||||||
|
|
||||||
|
|
@ -129,6 +135,8 @@ export const resolutionSummarySchema = z.object({
|
||||||
discoveredLocationId: idSchema.nullable(),
|
discoveredLocationId: idSchema.nullable(),
|
||||||
/** The scripted tutorial contract delivered, if any (for its flavor message). */
|
/** The scripted tutorial contract delivered, if any (for its flavor message). */
|
||||||
scriptedId: idSchema.nullable(),
|
scriptedId: idSchema.nullable(),
|
||||||
|
/** Set when the trip ended in mandatory rest: the pilot rests until then (CR-004). */
|
||||||
|
restEndsAt: timeSchema.nullable(),
|
||||||
resolvedAt: timeSchema,
|
resolvedAt: timeSchema,
|
||||||
});
|
});
|
||||||
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
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 hirePilotRequestSchema = z.object({ locationId: idSchema });
|
||||||
export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema });
|
export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema });
|
||||||
export const unassignPilotRequestSchema = z.object({ pilotId: 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 spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema });
|
||||||
export const pilotResponseSchema = z.object({ pilot: pilotSchema });
|
export const pilotResponseSchema = z.object({ pilot: pilotSchema });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
repayLoanRequestSchema,
|
repayLoanRequestSchema,
|
||||||
shipResponseSchema,
|
shipResponseSchema,
|
||||||
spendSkillPointRequestSchema,
|
spendSkillPointRequestSchema,
|
||||||
|
restPilotRequestSchema,
|
||||||
unassignPilotRequestSchema,
|
unassignPilotRequestSchema,
|
||||||
} from "./requests";
|
} from "./requests";
|
||||||
|
|
||||||
|
|
@ -114,6 +115,13 @@ export const routes = {
|
||||||
response: pilotResponseSchema,
|
response: pilotResponseSchema,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
restPilot: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/pilots/:pilotId/rest",
|
||||||
|
request: restPilotRequestSchema,
|
||||||
|
response: pilotResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
spendSkillPoint: {
|
spendSkillPoint: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/pilots/:pilotId/skills",
|
path: "/api/pilots/:pilotId/skills",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ describe("route catalog", () => {
|
||||||
"hirePilot",
|
"hirePilot",
|
||||||
"assignPilot",
|
"assignPilot",
|
||||||
"unassignPilot",
|
"unassignPilot",
|
||||||
|
"restPilot",
|
||||||
"spendSkillPoint",
|
"spendSkillPoint",
|
||||||
"acknowledgeSummaries",
|
"acknowledgeSummaries",
|
||||||
"acceptDeal",
|
"acceptDeal",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
locationStateSchema,
|
locationStateSchema,
|
||||||
pilotResponseSchema,
|
pilotResponseSchema,
|
||||||
pilotSchema,
|
pilotSchema,
|
||||||
|
restPilotRequestSchema,
|
||||||
playerStateSchema,
|
playerStateSchema,
|
||||||
resolutionSummarySchema,
|
resolutionSummarySchema,
|
||||||
serverTimeSchema,
|
serverTimeSchema,
|
||||||
|
|
@ -67,6 +68,10 @@ const pilot = {
|
||||||
assignedShipId: "s1",
|
assignedShipId: "s1",
|
||||||
status: "idle",
|
status: "idle",
|
||||||
locationId: "earth",
|
locationId: "earth",
|
||||||
|
dutyHours: 7.5,
|
||||||
|
dutyLimit: 14,
|
||||||
|
resting: false,
|
||||||
|
restEndsAt: null,
|
||||||
};
|
};
|
||||||
const bay = { id: "b1", locationId: "earth" };
|
const bay = { id: "b1", locationId: "earth" };
|
||||||
const location = { id: "mars", discovered: false };
|
const location = { id: "mars", discovered: false };
|
||||||
|
|
@ -113,6 +118,7 @@ const summary = {
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
scriptedId: "unmarked-crates",
|
scriptedId: "unmarked-crates",
|
||||||
|
restEndsAt: null,
|
||||||
resolvedAt: 3_900_000,
|
resolvedAt: 3_900_000,
|
||||||
};
|
};
|
||||||
const tutorial = {
|
const tutorial = {
|
||||||
|
|
@ -144,11 +150,25 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
||||||
["company", companySchema, company, { ...company, credits: 1.5 }],
|
["company", companySchema, company, { ...company, credits: 1.5 }],
|
||||||
["ship", shipSchema, ship, { ...ship, status: "lost" }],
|
["ship", shipSchema, ship, { ...ship, status: "lost" }],
|
||||||
["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }],
|
["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" }],
|
["bay", baySchema, bay, { id: "b1" }],
|
||||||
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
|
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
|
||||||
["contract", contractSchema, contract, { ...contract, tier: -1 }],
|
["contract", contractSchema, contract, { ...contract, tier: -1 }],
|
||||||
["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 }],
|
||||||
|
[
|
||||||
|
"summary with rest started",
|
||||||
|
resolutionSummarySchema,
|
||||||
|
{ ...summary, restEndsAt: 7_500_000 },
|
||||||
|
{ ...summary, restEndsAt: -1 },
|
||||||
|
],
|
||||||
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
|
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
|
||||||
["goal state", goalStateSchema, goal, { ...goal, status: "done" }],
|
["goal state", goalStateSchema, goal, { ...goal, status: "done" }],
|
||||||
["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],
|
["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue