Compare commits

..

No commits in common. "3dba7c20190b6f7287c9c0607b87bcc4cccf9274" and "99aebe1dcb97926ddf2af5fd0acb642995ea8af6" have entirely different histories.

53 changed files with 27 additions and 2202 deletions

View File

@ -754,43 +754,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Step 11.1's amendment names the generated folder explicitly** (`public/assets/map-gen/`), since the source art is dropped from the build and must not be precached. - **Step 11.1's amendment names the generated folder explicitly** (`public/assets/map-gen/`), since the source art is dropped from the build and must not be precached.
- **The "map with undiscovered locations" review shot is the tutorial map**, which shows Mars, Ceres and Jupiter locked; the mid-game shot has Mars discovered and Ceres and Jupiter still locked. - **The "map with undiscovered locations" review shot is the tutorial map**, which shows Mars, Ceres and Jupiter locked; the mid-game shot has Mars discovered and Ceres and Jupiter still locked.
- **Screenshot size:** phone shots are full-resolution (DPR 3) PNGs, about 2 MB each; `docs/screenshots/` is about 24 MB. They are kept as-is for the review; trimming or moving them out of the repository is a later cleanup. - **Screenshot size:** phone shots are full-resolution (DPR 3) PNGs, about 2 MB each; `docs/screenshots/` is about 24 MB. They are kept as-is for the review; trimming or moving them out of the repository is a later cleanup.
## CR-004 — Pilot Duty and Rest (Phase 10D)
- **Change request CR-004 is stored in `memory-bank/change-requests/`** and inserted as Phase 10D, after Phase 10C and before Phase 11.
- **Decision numbering:** the CR's 0.5.34–0.5.38 are recorded as **0.5.42 Duty clock, 0.5.43 Duty limit, 0.5.44 Finish the job, 0.5.45 Rest, 0.5.46 Rest covers a board refresh**, since 34–41 were already in use.
- **Error code:** the CR's `pilot_resting` is `pilot-resting`, matching the API's kebab-case codes.
- **Rest length vs. the CR's example:** the CR's rule sets the default rest to one board period (60 real minutes = 60 game hours at the current compression), but its worked example says a pilot "rests about 15 real minutes". The rule is explicit and test-enforced (decision 0.5.46), so it wins: default rest is 60 game hours (one real hour). Shortening rest would mean shortening the board period, which is a balance change for Phase 12.
## Step 10D.1
- **Field names:** `dutyLimitGameHours`, `longHaulDutyBonusGameHours`, `restGameHours`. All in game hours, like other trip times.
- **The "rest ≥ board period" rule is a content test, not a schema refinement**, as the CR asks. The schema only requires the three numbers to be positive.
## Step 10D.2
- **Trip duty is measured from the dispatch's real departure and arrival times** (`tripGameHours`), so it is exactly the travel time the player saw, handling time included.
- **"Contains a board boundary" is tested as "wakes in a later board period"**: `boardPeriodIndex(restEndsAt(start)) > boardPeriodIndex(start)`, which is what "wakes to a fresh board" means.
- **Resting ends exactly at rest-ends-at** (`now < restEndsAt`), the same convention as dispatch arrival.
## Step 10D.3
- **Deadheads can also be refused with `pilot-resting`**: they are flights, and rest blocks flying. The reason is first among the pilot checks in both validators, after the ship checks.
- **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.
## Step 10D.5
- **One code path for both resolutions:** lazy reads and the pg-boss arrival job both call `resolveArrivedDispatches`, so duty and rest are written in one place. `rest_ends_at` is only written when a trip starts mandatory rest, so a voluntary rest is never cut short by an unrelated resolution.
- **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot.
- **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes.
- **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away.

View File

@ -1,2 +0,0 @@
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

View File

@ -29,13 +29,6 @@
"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
} }
] ]
} }

View File

@ -2,7 +2,6 @@ import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
import { import {
bigint, bigint,
boolean, boolean,
doublePrecision,
index, index,
integer, integer,
jsonb, jsonb,
@ -89,10 +88,6 @@ 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)],

View File

@ -78,7 +78,6 @@ export function pilotState(row: typeof pilots.$inferSelect): PilotState {
assignedShipId: row.assignedShipId, assignedShipId: row.assignedShipId,
inFlight: row.status === "in_flight", inFlight: row.status === "in_flight",
skills: row.skills, skills: row.skills,
restEndsAt: row.restEndsAt,
}; };
} }

View File

@ -1,12 +1,6 @@
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 { import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
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`. */
@ -31,9 +25,7 @@ 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,
@ -51,9 +43,5 @@ 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,
}; };
} }

View File

@ -4,7 +4,6 @@ import type { DispatchFailure, RepositionFailure } from "@deadhead/sim";
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = { export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
"ship-not-idle": "That ship is already flying.", "ship-not-idle": "That ship is already flying.",
"ship-not-at-origin": "That ship is not at the contract's pickup location.", "ship-not-at-origin": "That ship is not at the contract's pickup location.",
"pilot-resting": "That pilot is resting and cannot fly until their rest ends.",
"pilot-not-idle": "That pilot is already flying.", "pilot-not-idle": "That pilot is already flying.",
"pilot-not-assigned": "That pilot is not assigned to that ship.", "pilot-not-assigned": "That pilot is not assigned to that ship.",
"over-capacity": "The cargo is too heavy for that ship.", "over-capacity": "The cargo is too heavy for that ship.",

View File

@ -1,13 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema"; import type { Pilot } from "@deadhead/schema";
import { import { pilotLevel, skillPointsAvailable, type RealTime } from "@deadhead/sim";
isResting,
pilotLevel,
restEndsAt,
skillPointsAvailable,
type RealTime,
} from "@deadhead/sim";
import { and, count, eq } from "drizzle-orm"; import { and, count, eq } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { bays, pilots, ships } from "../db/schema"; import { bays, pilots, ships } from "../db/schema";
@ -71,7 +65,7 @@ export async function hirePilot(
createdAt: now, createdAt: now,
}) })
.returning(); .returning();
return toApiPilot(pilot!, null, now); return toApiPilot(pilot!, null);
} }
/** /**
@ -83,7 +77,6 @@ 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);
@ -118,7 +111,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, now); return toApiPilot(updated!, ship.locationId);
} }
/** 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). */
@ -126,7 +119,6 @@ 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) {
@ -145,36 +137,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, now); return toApiPilot(updated!, null);
}
/**
* Rests an idle pilot now, at any duty level (CR-004): duty resets and they
* cannot fly until the rest ends. No bay is needed; crew swaps stay allowed.
*/
export async function restPilot(
tx: Queryable,
playerId: string,
pilotId: string,
now: RealTime,
): Promise<Pilot> {
await requireTutorialOver(tx, playerId, "resting a pilot");
const pilot = await requireOwnPilot(tx, playerId, pilotId);
if (pilot.status === "in_flight") {
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
}
if (isResting(pilot.restEndsAt, now)) {
throw gameError("pilot-resting", "That pilot is already resting.");
}
const [updated] = await tx
.update(pilots)
.set({ dutyHours: 0, restEndsAt: restEndsAt(now, content.config) })
.where(eq(pilots.id, pilotId))
.returning();
const ship = updated!.assignedShipId
? await requireOwnShip(tx, playerId, updated!.assignedShipId)
: null;
return toApiPilot(updated!, ship?.locationId ?? 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). */
@ -183,7 +146,6 @@ 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);
@ -209,5 +171,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, now); return toApiPilot(updated!, ship?.locationId ?? null);
} }

View File

@ -1,6 +1,5 @@
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 {
@ -17,11 +16,7 @@ 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( export async function loadPlayerState(db: Queryable, playerId: string): Promise<PlayerState> {
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`);
@ -79,11 +74,7 @@ export async function loadPlayerState(
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt, lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
})), })),
pilots: pilotRows.map((p) => pilots: pilotRows.map((p) =>
toApiPilot( toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),
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) })),
@ -112,7 +103,6 @@ export async function loadPlayerState(
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),

View File

@ -45,15 +45,7 @@ export async function resolveArrivedDispatches(
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
const result = resolveDispatch( const result = resolveDispatch(
{ { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, now },
record: dispatch.record,
pilotIsAvatar: pilot.isAvatar,
discoveredIds,
loanBalance,
pilotDutyHours: pilot.dutyHours,
pilotSkills: pilot.skills,
now,
},
content, content,
); );
if (result.status !== "resolved") continue; if (result.status !== "resolved") continue;
@ -81,9 +73,6 @@ export async function resolveArrivedDispatches(
.set({ .set({
xp: sql`${pilots.xp} + ${resolution.pilotXp}`, xp: sql`${pilots.xp} + ${resolution.pilotXp}`,
status: "idle", status: "idle",
// Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45).
dutyHours: resolution.pilotDutyHours,
...(resolution.pilotRestEndsAt !== null ? { restEndsAt: resolution.pilotRestEndsAt } : {}),
// Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar. // Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar.
...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}), ...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}),
}) })

View File

@ -1,7 +1,7 @@
import { routes } from "@deadhead/schema"; import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { buyShip, leaseBay } from "../game/fleet"; import { buyShip, leaseBay } from "../game/fleet";
import { assignPilot, hirePilot, restPilot, spendSkillPoint, unassignPilot } from "../game/pilots"; import { assignPilot, hirePilot, spendSkillPoint, unassignPilot } from "../game/pilots";
import { registerRoute } from "../http/route"; import { registerRoute } from "../http/route";
import type { AppContext } from "./context"; import type { AppContext } from "./context";
import { withPlayer } from "./with-player"; import { withPlayer } from "./with-player";
@ -27,26 +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, now) => ({ withPlayer(ctx, playerId, async (tx) => ({
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId, now), pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId),
})), })),
); );
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) => registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({ withPlayer(ctx, playerId, async (tx) => ({
pilot: await unassignPilot(tx, playerId, input.pilotId, now), pilot: await unassignPilot(tx, playerId, input.pilotId),
})),
);
registerRoute(app, routes.restPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await restPilot(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, now) => ({ withPlayer(ctx, playerId, async (tx) => ({
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, now), pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId),
})), })),
); );
} }

View File

@ -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, clock()) }; return { playerId, state: await loadPlayerState(db, playerId) };
}); });
// 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, now) => loadPlayerState(tx, playerId, now)), withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, playerId)),
); );
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, now); return loadPlayerState(tx, playerId);
}), }),
); );

View File

@ -1,158 +0,0 @@
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
pilotResponseSchema,
type PlayerState,
} from "@deadhead/schema";
import { restEndsAt } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUp,
signUpOwner,
testContract,
useTestApp,
type Guest,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const avatar = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** Flies the owner's Courier Earth↔Moon until a trip ends in mandatory rest. */
async function chainUntilRest(owner: Guest & { shipId: string }) {
const { clock } = testApp();
let at = "earth";
for (let trip = 0; trip < 20; trip++) {
const to = at === "earth" ? "moon" : "earth";
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ originId: at, destinationId: to, expiresAt: FAR_FUTURE }),
});
clock.now = record.arrivalAt;
const state = await getState(testApp().app, owner);
at = to;
if (avatar(state).resting) return { state, record, at };
expect(avatar(state).dutyHours).toBeGreaterThan(0);
}
throw new Error("never needed rest");
}
describe("pilot duty and rest on the server (Step 10D.5)", () => {
it("rests a pilot after chained runs, refuses them until rest ends, then dispatches", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 50_000);
const { state, record, at } = await chainUntilRest(owner);
const rested = avatar(state);
expect(rested).toMatchObject({ dutyHours: 0, resting: true });
// Rest starts at the arrival time (decision 0.5.45).
expect(rested.restEndsAt).toBe(restEndsAt(record.arrivalAt, content.config));
expect(state.summaries.at(-1)!.restEndsAt).toBe(rested.restEndsAt);
const job = testContract({
originId: at,
destinationId: at === "earth" ? "moon" : "earth",
expiresAt: FAR_FUTURE,
});
await putBoard(database().db, owner.playerId, [job], clock.now, at);
const dispatch = () =>
call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: owner.shipId,
pilotId: owner.avatarId,
});
const refused = await dispatch();
expect(refused.statusCode).toBe(409);
expect(errorCode(refused)).toBe("pilot-resting");
clock.now = rested.restEndsAt!;
const accepted = await dispatch();
expect(accepted.statusCode).toBe(200);
dispatchResponseSchema.parse(accepted.json());
});
it("rests an idle pilot by hand, and refuses resting one who is flying or already resting", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const response = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
expect(response.statusCode).toBe(200);
expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({
dutyHours: 0,
resting: true,
restEndsAt: restEndsAt(clock.now, content.config),
});
const again = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
expect(again.statusCode).toBe(409);
expect(errorCode(again)).toBe("pilot-resting");
const other = await signUpOwner(app, database().db);
await insertDispatch(database().db, {
playerId: other.playerId,
shipId: other.shipId,
pilotId: other.avatarId,
departAt: clock.now,
});
const flying = await call(app, other, "POST", `/api/pilots/${other.avatarId}/rest`, {});
expect(flying.statusCode).toBe(409);
expect(errorCode(flying)).toBe("pilot-not-idle");
});
it("lets a resting pilot leave the ship at a bay and a fresh pilot fly it at once", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 50_000);
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
const unassign = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {});
expect(unassign.statusCode).toBe(200);
const hired = pilotResponseSchema.parse(
(await call(app, owner, "POST", "/api/pilots", { locationId: "earth" })).json(),
).pilot;
const assign = await call(app, owner, "POST", `/api/pilots/${hired.id}/assign`, {
shipId: owner.shipId,
});
expect(assign.statusCode).toBe(200);
const job = testContract({ expiresAt: FAR_FUTURE });
await putBoard(database().db, owner.playerId, [job], clock.now);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: owner.shipId,
pilotId: hired.id,
});
expect(response.statusCode).toBe(200);
});
it("does not rest the avatar during the tutorial", async () => {
const guest = await signUp(testApp().app);
const response = await call(
testApp().app,
guest,
"POST",
`/api/pilots/${guest.avatarId}/rest`,
{},
);
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
});
});

View File

@ -141,7 +141,6 @@ export async function insertDispatch(
assignedShipId: pilot!.assignedShipId, assignedShipId: pilot!.assignedShipId,
inFlight: false, inFlight: false,
skills: pilot!.skills, skills: pilot!.skills,
restEndsAt: null,
}; };
const id = randomUUID(); const id = randomUUID();
const contract = args.contract === undefined ? testContract() : args.contract; const contract = args.contract === undefined ? testContract() : args.contract;

View File

@ -31,7 +31,6 @@ async function accept(db: Db, guest: Guest, contract: Contract, now: number) {
assignedShipId: null, assignedShipId: null,
inFlight: false, inFlight: false,
skills: {}, skills: {},
restEndsAt: null,
}, },
contract, contract,
tutorial: true, tutorial: true,

View File

@ -20,7 +20,6 @@ import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = { export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"ship-not-idle": "Ship is flying", "ship-not-idle": "Ship is flying",
"ship-not-at-origin": "Not at pickup", "ship-not-at-origin": "Not at pickup",
"pilot-resting": "Pilot is resting",
"pilot-not-idle": "Pilot is flying", "pilot-not-idle": "Pilot is flying",
"pilot-not-assigned": "No pilot assigned", "pilot-not-assigned": "No pilot assigned",
"over-capacity": "Too heavy", "over-capacity": "Too heavy",
@ -141,7 +140,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
assignedShipId: pilot.assignedShipId, assignedShipId: pilot.assignedShipId,
inFlight: pilot.status === "in-flight", inFlight: pilot.status === "in-flight",
skills: pilot.skills, skills: pilot.skills,
restEndsAt: null,
}, },
contract, contract,
tutorial: false, tutorial: false,

View File

@ -115,7 +115,6 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
"cannot-land": "Can't land there", "cannot-land": "Can't land there",
"out-of-range": "Out of range", "out-of-range": "Out of range",
"insufficient-credits": "Not enough credits for fuel", "insufficient-credits": "Not enough credits for fuel",
"pilot-resting": "Pilot is resting",
"pilot-not-idle": "Pilot is flying", "pilot-not-idle": "Pilot is flying",
"pilot-not-assigned": "No pilot assigned", "pilot-not-assigned": "No pilot assigned",
}; };
@ -147,7 +146,6 @@ function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void })
assignedShipId: pilot ? ship.id : null, assignedShipId: pilot ? ship.id : null,
inFlight: pilot?.status === "in-flight", inFlight: pilot?.status === "in-flight",
skills: pilot?.skills ?? {}, skills: pilot?.skills ?? {},
restEndsAt: null,
}, },
destinationId: id, destinationId: id,
discoveredIds, discoveredIds,

View File

@ -38,10 +38,6 @@ 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 });

View File

@ -36,7 +36,6 @@ function returningPlayer(acknowledged: boolean): PlayerState {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
], ],

View File

@ -28,7 +28,6 @@ const summaries: ResolutionSummary[] = [
pilotXp: 32, pilotXp: 32,
discoveredLocationId: "mars", discoveredLocationId: "mars",
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
{ {
@ -43,7 +42,6 @@ const summaries: ResolutionSummary[] = [
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 2, resolvedAt: 2,
}, },
]; ];

View File

@ -86,7 +86,6 @@ describe("the deal", () => {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
], ],

View File

@ -41,10 +41,6 @@ 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 = {

View File

@ -154,7 +154,6 @@ describe("fleet screen", () => {
assignedShipId: "s-idle", assignedShipId: "s-idle",
inFlight: false, inFlight: false,
skills: {}, skills: {},
restEndsAt: null,
}, },
destinationId: "earth", destinationId: "earth",
now: NOW, now: NOW,

View File

@ -150,7 +150,6 @@ describe("loan on HQ", () => {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null,
resolvedAt: 1, resolvedAt: 1,
}, },
], ],

View File

@ -29,10 +29,6 @@ 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 {

View File

@ -48,10 +48,6 @@ 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: [],

View File

@ -153,7 +153,6 @@ describe("scripted delivery summary", () => {
pilotXp: 10, pilotXp: 10,
discoveredLocationId: null, discoveredLocationId: null,
scriptedId: "unmarked-crates", scriptedId: "unmarked-crates",
restEndsAt: null,
resolvedAt: NOW, resolvedAt: NOW,
}, },
], ],

View File

@ -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), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | | `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
| `Bay` | `id`, `locationId` | | `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` | | `LocationState` | `id`, `discovered` |
| `Contract` | mirrors sim `Contract` (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`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` | | `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `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,7 +57,6 @@ 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 |
@ -73,7 +72,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, duty hours, rest-ends-at (CR-004) | unique `assigned_ship_id` (one pilot per ship) | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | 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`) |
@ -177,10 +176,6 @@ 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)],

View File

@ -1,132 +0,0 @@
# Change Request CR-004 — Pilot Duty and Rest
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phases 1–10, CR-001, CR-002, and CR-003 complete
**Do this:** as **Phase 10D**, after Phase 10C and before Phase 11
**Related docs:** `implementation-plan.md`, `change-request-faster-start.md`, `change-request-open-markets.md`, `change-request-map-art.md`, `DECISIONS.md`
> **Numbering note (added when implemented):** the plan's §0.5 already uses 34–41. This CR's decisions 0.5.34–0.5.38 are recorded as **0.5.42 Duty clock**, **0.5.43 Duty limit**, **0.5.44 Finish the job**, **0.5.45 Rest** and **0.5.46 Rest covers a board refresh**. The error code `pilot_resting` is `pilot-resting` (the API's kebab-case convention).
---
## 1. Why
Pilots can currently fly back-to-back forever, so there is no rhythm and no reason to run more than one pilot per ship. When a location's board has nothing useful, the only options are to wait with nothing to show for it or to deadhead.
This adds an ATS-style hours-of-service rule:
- Each pilot has a **duty clock** that fills up as they fly.
- When it's full, the pilot must **rest** for a fixed time on arrival. The ship stays put unless another pilot takes it.
- The player can also **rest a pilot early**. Rest always lasts at least one contract board period, so **a rested pilot always wakes up to a fresh board.**
- The **Long Haul** skill extends a pilot's duty limit, so leveling it changes the math, not just which jobs unlock.
## 2. How it works
| Rule | Detail |
|---|---|
| Duty clock | Each pilot tracks duty hours (game hours flown since their last rest). |
| What counts | Every contract and deadhead trip adds its full travel time. Tutorial runs do not count. |
| Duty limit | Base limit from config, plus a bonus per Long Haul level. |
| Starting a trip | Allowed whenever the pilot is not resting, even if the trip will take them over the limit. Pilots always finish the job they're on. |
| Mandatory rest | If duty hours are at or over the limit when a trip resolves, rest starts automatically at the arrival time. |
| Voluntary rest | The player can rest any idle pilot who isn't in flight, at any duty level. |
| Rest | Lasts the configured rest duration. When it starts, duty hours reset to zero; the pilot can't fly until it ends. Rest happens wherever the pilot is; no bay is needed. |
| Crew swap | A resting pilot can be unassigned (at a bay, per decision 0.5.2), and a rested pilot can be assigned to the same ship. Rest blocks the pilot, not the ship. |
| Fresh board | The rest duration in real time is at least the contract board period, so any rest crosses at least one board refresh. |
Example with defaults (12-hour limit, 1 real minute = 1 game hour): a pilot flies three ~4-hour Moon runs, rests about 15 real minutes, and wakes to a new board. A Mars run (60+ game hours) always ends in a rest, which is negligible next to the trip itself.
## 3. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass, `packages/sim` stays pure, all numbers live in `packages/content`.
- Rest is derived from stored timestamps, like dispatches. **No background job is needed to end a rest**: a pilot is resting if the current time is before their rest-ends-at time.
- Resetting the dev and test databases is fine. Schema migrations are still required.
- Update existing tests whose expectations change. Update `architecture.md` for schema or API changes and `progress.md` after every step.
- Stop for the user's review at the end of Phase 10D.
## 4. Decisions
Record these in `DECISIONS.md`.
- **0.5.34 Duty clock.** Pilots accrue duty hours equal to each trip's travel time in game hours, for contracts and deadheads. Tutorial runs accrue nothing.
- **0.5.35 Duty limit.** Base duty limit plus a per-level Long Haul bonus, both in config.
- **0.5.36 Finish the job.** Dispatch is blocked only while a pilot is resting. A trip that will exceed the limit is allowed; mandatory rest starts on arrival.
- **0.5.37 Rest.** Starting rest sets duty hours to zero and sets rest-ends-at to start time plus the rest duration. Mandatory rest starts at the trip's arrival time, so it is correct even if resolution happens later (offline).
- **0.5.38 Rest covers a board refresh.** The rest duration converted to real time must be at least the contract board period. This is enforced by a content test.
## 5. Steps — Phase 10D
### Step 10D.1 — Content config
- Add GameConfig fields: base duty limit (game hours, default 12), Long Haul duty bonus per level (game hours, default 2), rest duration (game hours; default equal to the board period converted to game hours).
- **Test:** Config validates. A test converts the rest duration to real time with the current compression and asserts it is at least the board period. Both duty numbers are positive.
### Step 10D.2 — Sim: duty and rest functions
- Add pure functions:
- Duty limit for a pilot, from their Long Haul level.
- Whether a pilot is resting at a given real time.
- Duty hours after a trip (current duty plus trip travel time, unless it's a tutorial run).
- Whether a pilot needs mandatory rest after a trip.
- Rest window from a start time (rest-ends-at).
- **Test:** Duty limit rises by the bonus for each Long Haul level. A pilot is resting one second before rest-ends-at and not resting at it. A tutorial trip leaves duty unchanged. A pilot at 10 of 12 hours who flies a 4-hour trip needs rest; one at 4 of 12 who flies a 4-hour trip does not. For many random start times, a rest window always contains at least one contract board period boundary.
### Step 10D.3 — Sim: validation and resolution
- Dispatch validation gains a `pilot_resting` reason, checked before the other pilot checks.
- Dispatch resolution returns the pilot's new duty hours and, if mandatory rest applies, a rest-ends-at time computed from the arrival time.
- **Test:** A resting pilot fails validation with `pilot_resting`. A pilot whose duty is over the limit but not resting can still be dispatched. Resolving a trip that pushes duty over the limit returns duty zero and rest-ends-at equal to arrival time plus rest duration, even when resolved hours after arrival. Deadheads accrue duty; tutorial runs don't.
### Step 10D.4 — Schema and database
- Add to pilots: duty hours and rest-ends-at (nullable).
- Add the `pilot_resting` error code and a "rest pilot" endpoint to the route catalog.
- Player state shows, per pilot: duty hours, duty limit, resting (yes/no), and rest-ends-at.
- Dispatch summaries include a "rest started" note when a trip ended in mandatory rest.
- Update `architecture.md`.
- **Test:** Migrations run on a fresh database and are a no-op the second time. New and changed schemas accept valid examples and reject invalid ones. The route catalog test still passes.
### Step 10D.5 — Server: apply duty and rest
- Resolution (lazy and job-based) writes the pilot's new duty hours and rest-ends-at.
- The rest endpoint starts rest now for an idle pilot who is not in flight and not already resting. It works at any duty level.
- Dispatch returns `pilot_resting` for a resting pilot.
- Assigning and unassigning ignore rest (crew swaps are allowed); only flying is blocked.
- **Test:** A pilot who chains Moon runs until over the limit is resting after the last one resolves, and dispatch with them fails with `pilot_resting`. After the rest window passes, the same dispatch succeeds. Resting a pilot by hand sets duty zero and the correct rest-ends-at; resting an in-flight or already-resting pilot fails. A resting pilot can be unassigned at a bay, and a rested pilot assigned to that ship can dispatch immediately.
### Step 10D.6 — Client: pilot duty on the Pilots screen
- Each pilot shows a duty bar (hours used of limit). Resting pilots show "Resting" with a countdown instead.
- Idle, non-resting pilots have a "Rest now" action with a confirmation that says rest lasts at least one board refresh.
- **Test:** Component test: the duty bar matches state; a resting pilot shows the countdown and no Rest now action; Rest now calls the endpoint.
### Step 10D.7 — Client: duty in the dispatch flow
- In the dispatch sheet, pairs with a resting pilot are disabled with "Resting — mm:ss".
- For other pairs, show the pilot's duty after the trip. If it reaches the limit, show "Will need rest on arrival".
- **Test:** Component test: a resting pilot's pair is disabled with the countdown; a trip that takes a pilot over the limit shows the warning; one that doesn't, doesn't. Numbers match the sim.
### Step 10D.8 — Client: board refresh and the "nothing here" case
- Every contract board shows "New contracts in mm:ss" until the next board period.
- If a location has an idle ship but no contract that ship and its pilot are eligible for, show a short prompt with both options: "Rest [pilot] — wakes to a new board" and "Deadhead (fly empty)".
- **Test:** Component test: the countdown matches the next period boundary for the mocked time; with no eligible contracts, the prompt shows both actions and each calls the right endpoint or opens the deadhead picker.
### Step 10D.9 — Client: rest in summaries and on HQ
- The "while you were away" modal shows "Pilot X is resting until …" for trips that ended in mandatory rest.
- HQ shows the count of resting pilots and the next pilot to wake up, with a countdown.
- **Test:** Component test with seeded state: the modal line appears for a summary with rest started; HQ shows the correct count and the soonest wake time.
### Step 10D.10 — Plan amendments and regression
- Apply the amendments in §6 to `implementation-plan.md`.
- Run the full suite.
- **Test:** CI is green. Manual check: on a fresh account after the CR-001 deal, chain Moon runs with the avatar until mandatory rest starts; confirm the ship's location board shows a new set of contracts when the avatar wakes.
## 6. Amendments to `implementation-plan.md`
| Step | Change |
|---|---|
| 0.5 | Add decisions 0.5.34–0.5.38. |
| 2.1 | GameConfig gains duty limit, Long Haul duty bonus, and rest duration (10D.1). |
| 3.12 | Validation gains `pilot_resting` (10D.3). |
| 3.17 | Resolution returns duty hours and rest-ends-at (10D.3). |
| 5.3 | Pilots table gains duty hours and rest-ends-at (10D.4). |
| 9.5, 9.7 | Duty and rest in the dispatch sheet and Pilots screen (10D.6, 10D.7). |
| 12.1 | The simulated player follows rest rules: it rests a pilot when no eligible contract exists at its location and deadheading isn't better. Re-check all CR-001 timing targets with rest in place; adjust content values, not thresholds. Add a check that no pilot is ever dispatched while resting. |
| 12.6 | After leveling Long Haul, confirm the pilot's duty limit increased. |
## 7. Out of scope
Fatigue penalties, accidents, or slower flying when tired; rest facilities or bay amenities that shorten rest; pilot morale; and anything else in `implementation-plan.md` §0.3.

View File

@ -97,11 +97,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
39. **Child bodies** (CR-003). The Moon (and any future location whose position is "parent plus small offset") is drawn at a fixed screen-space offset from its parent, not at its scaled real position, so it never overlaps the parent at any zoom. 39. **Child bodies** (CR-003). The Moon (and any future location whose position is "parent plus small offset") is drawn at a fixed screen-space offset from its parent, not at its scaled real position, so it never overlaps the parent at any zoom.
40. **Background** (CR-003). The background is not tiled. It is scaled to cover the viewport with a margin and moves with gentle parallax, clamped so its edges never show. 40. **Background** (CR-003). The background is not tiled. It is scaled to cover the viewport with a margin and moves with gentle parallax, clamped so its edges never show.
41. **Fallback** (CR-003). If any asset is missing or fails to load, the map draws the old placeholder for that item and logs one warning. The map never fails to render because of art. 41. **Fallback** (CR-003). If any asset is missing or fails to load, the map draws the old placeholder for that item and logs one warning. The map never fails to render because of art.
42. **Duty clock** (CR-004). Pilots accrue duty hours equal to each trip's travel time in game hours, for contracts and deadheads. Tutorial runs accrue nothing.
43. **Duty limit** (CR-004). Base duty limit plus a per-level Long Haul bonus, both in config.
44. **Finish the job** (CR-004). Dispatch is blocked only while a pilot is resting. A trip that will exceed the limit is allowed; mandatory rest starts on arrival.
45. **Rest** (CR-004). Starting rest sets duty hours to zero and sets rest-ends-at to start time plus the rest duration. Mandatory rest starts at the trip's arrival time, so it is correct even if resolution happens later (offline).
46. **Rest covers a board refresh** (CR-004). The rest duration converted to real time must be at least the contract board period. This is enforced by a content test.
--- ---
@ -508,12 +503,6 @@ Steps 10C.1–10C.10 are specified in `memory-bank/change-requests/CR-003-map-ar
--- ---
## Phase 10D — Pilot Duty and Rest (CR-004)
Steps 10D.1–10D.10 are specified in `memory-bank/change-requests/CR-004-pilot-duty-rest.md` §5, with its §6 amendments to earlier steps. Done after Phase 10C and before Phase 11; stop for review at the end.
---
## Phase 11 — PWA ## Phase 11 — PWA
### Step 11.1 — Installable PWA ### Step 11.1 — Installable PWA

View File

@ -159,18 +159,3 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as
- [x] **Step 10C.8 — Ships in flight.** Each flight draws its model's sprite rotated by `shipRotation` (art points up: right = 90°, down = 180°), positioned by sim progress every frame. Routes are faint end to end and brighter ahead of the ship. `separateShips` spreads ships closer than 44 px apart, perpendicular to their route and 44 px apart; hit-testing uses the same positions. The selected ship or location gets an amber ring (`MapCanvas` `selected` prop). Test: `map-ships.test.ts`. **Manual check:** `docs/screenshots/10c8-ships-midgame-{desktop,desktop-zoomed,phone}.png`. - [x] **Step 10C.8 — Ships in flight.** Each flight draws its model's sprite rotated by `shipRotation` (art points up: right = 90°, down = 180°), positioned by sim progress every frame. Routes are faint end to end and brighter ahead of the ship. `separateShips` spreads ships closer than 44 px apart, perpendicular to their route and 44 px apart; hit-testing uses the same positions. The selected ship or location gets an amber ring (`MapCanvas` `selected` prop). Test: `map-ships.test.ts`. **Manual check:** `docs/screenshots/10c8-ships-midgame-{desktop,desktop-zoomed,phone}.png`.
- [x] **Step 10C.9 — Performance.** `map/ticker.ts` `runWhileVisible`: the PixiJS ticker stops when the page is hidden, restarts when visible, and stops on unmount. Canvas resolution capped at 2×. Labels restyle only on change. Big ship crowds (> 3) pack on a hexagonal grid (`hexSpiral`) instead of a long line. Tests: `map-ticker.test.ts`, crowd test in `map-ships.test.ts`. **Measured** (headless Chrome, software WebGL, 10 ships in flight, panning): 58.5 fps at DPR 1 even with CPU throttled 4×; 11 fps at DPR 3 before the 2× cap, 22 fps after — fill-rate bound in software rendering, not JavaScript. **Manual check for the user:** pan and zoom on a real mid-range phone (GPU rendering). Screenshots: `docs/screenshots/10c9-busy-busy-{desktop,desktop-zoomed,phone}.png`. - [x] **Step 10C.9 — Performance.** `map/ticker.ts` `runWhileVisible`: the PixiJS ticker stops when the page is hidden, restarts when visible, and stops on unmount. Canvas resolution capped at 2×. Labels restyle only on change. Big ship crowds (> 3) pack on a hexagonal grid (`hexSpiral`) instead of a long line. Tests: `map-ticker.test.ts`, crowd test in `map-ships.test.ts`. **Measured** (headless Chrome, software WebGL, 10 ships in flight, panning): 58.5 fps at DPR 1 even with CPU throttled 4×; 11 fps at DPR 3 before the 2× cap, 22 fps after — fill-rate bound in software rendering, not JavaScript. **Manual check for the user:** pan and zoom on a real mid-range phone (GPU rendering). Screenshots: `docs/screenshots/10c9-busy-busy-{desktop,desktop-zoomed,phone}.png`.
- [x] **Step 10C.10 — Plan amendments and regression.** CR-003 §6 amendments added to plan steps 10.1, 10.2, 10.3 and 11.1, and to CR-001 Step 10A.16 (§0.5.37–41 were added with the CR). Full suite (629 tests), coverage, lint and web build green. **Review screenshots:** `docs/screenshots/10c10-final-{tutorial,midgame}-{desktop,phone}.png` (the tutorial map also shows the undiscovered locations), plus the per-step shots from 10C.5–10C.9. **Phase 10C complete — stopped for review.** - [x] **Step 10C.10 — Plan amendments and regression.** CR-003 §6 amendments added to plan steps 10.1, 10.2, 10.3 and 11.1, and to CR-001 Step 10A.16 (§0.5.37–41 were added with the CR). Full suite (629 tests), coverage, lint and web build green. **Review screenshots:** `docs/screenshots/10c10-final-{tutorial,midgame}-{desktop,phone}.png` (the tutorial map also shows the undiscovered locations), plus the per-step shots from 10C.5–10C.9. **Phase 10C complete — stopped for review.**
## Phase 10D — Pilot Duty and Rest (CR-004)
Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions recorded as plan §0.5.42–0.5.46.
- [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`.
- [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.
- [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`.
- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
- [ ] Step 10D.7 — Client: duty in the dispatch flow
- [ ] Step 10D.8 — Client: board refresh and the "nothing here" case
- [ ] Step 10D.9 — Client: rest in summaries and on HQ
- [ ] Step 10D.10 — Plan amendments and regression

View File

@ -24,12 +24,6 @@ export const gameConfig: GameConfig = {
// Courier price + Earth bay lease. // Courier price + Earth bay lease.
dealLoanAmount: 5000, dealLoanAmount: 5000,
contractBoardPeriodRealMinutes: 60, contractBoardPeriodRealMinutes: 60,
// Duty and rest (CR-004): about three Moon round trips, then rest.
dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2,
// One board period (60 real minutes at 60× compression), so a rested pilot
// always wakes to a fresh board (decision 0.5.46).
restGameHours: 60,
firstContactChance: 0.1, firstContactChance: 0.1,
firstContactPayoutMultiplier: 2, firstContactPayoutMultiplier: 2,
// One band per ship size, so every board has work for every ship (playtest fix): // One band per ship size, so every board has work for every ship (playtest fix):

View File

@ -134,12 +134,6 @@ export const gameConfigSchema = z
/** Loan taken on with the deal, in credits (decision 0.5.30). */ /** Loan taken on with the deal, in credits (decision 0.5.30). */
dealLoanAmount: positiveInt, dealLoanAmount: positiveInt,
contractBoardPeriodRealMinutes: z.number().positive(), contractBoardPeriodRealMinutes: z.number().positive(),
/** Game hours a pilot may fly before mandatory rest, at Long Haul 0 (decision 0.5.43). */
dutyLimitGameHours: z.number().positive(),
/** Extra duty game hours per Long Haul level (decision 0.5.43). */
longHaulDutyBonusGameHours: z.number().positive(),
/** How long a rest lasts, in game hours; at least one board period (decision 0.5.46). */
restGameHours: z.number().positive(),
firstContactChance: fraction, firstContactChance: fraction,
firstContactPayoutMultiplier: z.number().positive(), firstContactPayoutMultiplier: z.number().positive(),
/** /**

View File

@ -67,14 +67,4 @@ describe("game config", () => {
const total = gameConfig.contractMassBands.reduce((sum, b) => sum + b.count, 0); const total = gameConfig.contractMassBands.reduce((sum, b) => sum + b.count, 0);
expect(total).toBe(gameConfig.contractBoardSize); expect(total).toBe(gameConfig.contractBoardSize);
}); });
it("rests a pilot for at least one contract board period of real time (CR-004)", () => {
const restRealMinutes = (gameConfig.restGameHours * 60) / gameConfig.timeCompression;
expect(restRealMinutes).toBeGreaterThanOrEqual(gameConfig.contractBoardPeriodRealMinutes);
});
it("has positive duty numbers (CR-004)", () => {
expect(gameConfig.dutyLimitGameHours).toBeGreaterThan(0);
expect(gameConfig.longHaulDutyBonusGameHours).toBeGreaterThan(0);
});
}); });

View File

@ -63,9 +63,6 @@ const validGameConfig = {
contractBoardSize: 8, contractBoardSize: 8,
tutorialBoardSize: 50, tutorialBoardSize: 50,
contractBoardPeriodRealMinutes: 60, contractBoardPeriodRealMinutes: 60,
dutyLimitGameHours: 12,
longHaulDutyBonusGameHours: 2,
restGameHours: 60,
firstContactChance: 0.1, firstContactChance: 0.1,
firstContactPayoutMultiplier: 2, firstContactPayoutMultiplier: 2,
contractMassBands: [ contractMassBands: [

View File

@ -58,12 +58,6 @@ 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>;
@ -135,8 +129,6 @@ 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>;

View File

@ -56,8 +56,6 @@ 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 });

View File

@ -20,7 +20,6 @@ import {
repayLoanRequestSchema, repayLoanRequestSchema,
shipResponseSchema, shipResponseSchema,
spendSkillPointRequestSchema, spendSkillPointRequestSchema,
restPilotRequestSchema,
unassignPilotRequestSchema, unassignPilotRequestSchema,
} from "./requests"; } from "./requests";
@ -115,13 +114,6 @@ 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",

View File

@ -19,7 +19,6 @@ describe("route catalog", () => {
"hirePilot", "hirePilot",
"assignPilot", "assignPilot",
"unassignPilot", "unassignPilot",
"restPilot",
"spendSkillPoint", "spendSkillPoint",
"acknowledgeSummaries", "acknowledgeSummaries",
"acceptDeal", "acceptDeal",

View File

@ -33,7 +33,6 @@ import {
locationStateSchema, locationStateSchema,
pilotResponseSchema, pilotResponseSchema,
pilotSchema, pilotSchema,
restPilotRequestSchema,
playerStateSchema, playerStateSchema,
resolutionSummarySchema, resolutionSummarySchema,
serverTimeSchema, serverTimeSchema,
@ -68,10 +67,6 @@ 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 };
@ -118,7 +113,6 @@ 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 = {
@ -150,25 +144,11 @@ 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 }],

View File

@ -3,7 +3,6 @@ import type { SimContent } from "./board";
import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock"; import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock";
import type { Contract } from "./contract"; import type { Contract } from "./contract";
import { routeDistance } from "./distance"; import { routeDistance } from "./distance";
import { isResting } from "./duty";
import { checkRoute } from "./eligibility"; import { checkRoute } from "./eligibility";
import { fuelCost } from "./fuel"; import { fuelCost } from "./fuel";
import { checkPilotEligibility, type SkillLevels } from "./pilot"; import { checkPilotEligibility, type SkillLevels } from "./pilot";
@ -24,8 +23,6 @@ export interface PilotState {
assignedShipId: string | null; assignedShipId: string | null;
inFlight: boolean; inFlight: boolean;
skills: SkillLevels; skills: SkillLevels;
/** Resting until this real time; null when not resting (CR-004). */
restEndsAt: RealTime | null;
} }
export interface DispatchProposal { export interface DispatchProposal {
@ -43,7 +40,6 @@ export interface DispatchProposal {
export const DISPATCH_FAILURES = [ export const DISPATCH_FAILURES = [
"ship-not-idle", "ship-not-idle",
"ship-not-at-origin", "ship-not-at-origin",
"pilot-resting",
"pilot-not-idle", "pilot-not-idle",
"pilot-not-assigned", "pilot-not-assigned",
"over-capacity", "over-capacity",
@ -92,8 +88,6 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
if (ship.inFlight) return fail("ship-not-idle"); if (ship.inFlight) return fail("ship-not-idle");
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin"); if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
// Rest blocks flying only (decision 0.5.44); checked before the other pilot checks.
if (isResting(pilot.restEndsAt, now)) return fail("pilot-resting");
if (pilot.inFlight) return fail("pilot-not-idle"); if (pilot.inFlight) return fail("pilot-not-idle");
// Decision 0.5.23: during the tutorial the avatar flies only the loaner. // Decision 0.5.23: during the tutorial the avatar flies only the loaner.
if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) { if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) {
@ -209,7 +203,6 @@ export interface RepositionProposal {
/** Every reason `validateReposition` can fail, in check order. */ /** Every reason `validateReposition` can fail, in check order. */
export const REPOSITION_FAILURES = [ export const REPOSITION_FAILURES = [
"ship-not-idle", "ship-not-idle",
"pilot-resting",
"pilot-not-idle", "pilot-not-idle",
"pilot-not-assigned", "pilot-not-assigned",
"already-there", "already-there",
@ -235,7 +228,6 @@ export function validateReposition(
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason }); const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
if (ship.inFlight) return failWith("ship-not-idle"); if (ship.inFlight) return failWith("ship-not-idle");
if (isResting(pilot.restEndsAt, now)) return failWith("pilot-resting");
if (pilot.inFlight) return failWith("pilot-not-idle"); if (pilot.inFlight) return failWith("pilot-not-idle");
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned"); if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
if (destinationId === ship.locationId) return failWith("already-there"); if (destinationId === ship.locationId) return failWith("already-there");

View File

@ -1,43 +0,0 @@
import type { GameConfig } from "@deadhead/content";
import { MS_PER_HOUR, type RealTime } from "./clock";
import { LONG_HAUL_SKILL_ID, skillLevel, type SkillLevels } from "./pilot";
/**
* Pilot duty and rest (CR-004, decisions 0.5.42–0.5.46). Rest is derived from a
* stored rest-ends-at time, so nothing has to run when it ends.
*/
/** Game hours a pilot may fly before mandatory rest: base plus a Long Haul bonus. */
export function dutyLimit(skills: SkillLevels, config: GameConfig): number {
const longHaul = skillLevel(skills, LONG_HAUL_SKILL_ID);
return config.dutyLimitGameHours + longHaul * config.longHaulDutyBonusGameHours;
}
/** Whether a pilot is resting at real time `now`: until (not at) rest-ends-at. */
export function isResting(restEndsAt: RealTime | null, now: RealTime): boolean {
return restEndsAt !== null && now < restEndsAt;
}
/** Game hours of a trip from its real departure and arrival times. */
export function tripGameHours(
departureAt: RealTime,
arrivalAt: RealTime,
config: GameConfig,
): number {
return ((arrivalAt - departureAt) * config.timeCompression) / MS_PER_HOUR;
}
/** Duty hours after a trip: tutorial runs add nothing (decision 0.5.42). */
export function dutyAfterTrip(dutyHours: number, tripHours: number, tutorial: boolean): number {
return tutorial ? dutyHours : dutyHours + tripHours;
}
/** Whether duty at or over the limit forces rest on arrival (decision 0.5.44). */
export function needsRest(dutyHours: number, limit: number): boolean {
return dutyHours >= limit;
}
/** When a rest starting at real time `start` ends (decision 0.5.45). */
export function restEndsAt(start: RealTime, config: GameConfig): RealTime {
return start + (config.restGameHours * MS_PER_HOUR) / config.timeCompression;
}

View File

@ -69,5 +69,3 @@ export type { LoanSplit } from "./loan";
export { resolveDispatch } from "./resolution"; export { resolveDispatch } from "./resolution";
export type { Resolution, ResolveInput, ResolveResult } from "./resolution"; export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";

View File

@ -1,9 +1,7 @@
import type { SimContent } from "./board"; import type { SimContent } from "./board";
import type { RealTime } from "./clock"; import type { RealTime } from "./clock";
import type { DispatchRecord } from "./dispatch"; import type { DispatchRecord } from "./dispatch";
import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty";
import { splitLoanRepayment } from "./loan"; import { splitLoanRepayment } from "./loan";
import type { SkillLevels } from "./pilot";
import { deliveryXp } from "./xp"; import { deliveryXp } from "./xp";
export interface Resolution { export interface Resolution {
@ -19,10 +17,6 @@ export interface Resolution {
shipLocationId: string; shipLocationId: string;
/** True when this delivery discovers the destination. */ /** True when this delivery discovers the destination. */
discoversDestination: boolean; discoversDestination: boolean;
/** The pilot's duty hours after this trip: 0 if it ended in mandatory rest (CR-004). */
pilotDutyHours: number;
/** Mandatory rest from the arrival time, or null (decision 0.5.45). */
pilotRestEndsAt: RealTime | null;
} }
export type ResolveResult = export type ResolveResult =
@ -35,34 +29,16 @@ export interface ResolveInput {
discoveredIds: ReadonlySet<string>; discoveredIds: ReadonlySet<string>;
/** The company's loan balance before this resolution. */ /** The company's loan balance before this resolution. */
loanBalance: number; loanBalance: number;
/** The pilot's duty hours before this trip, and skills (Long Haul sets the limit). */
pilotDutyHours: number;
pilotSkills: SkillLevels;
now: RealTime; now: RealTime;
} }
/**
* The pilot's duty after a trip (decisions 0.5.42–0.5.45): the trip's game
* hours are added (tutorial runs add none); at or over the limit, rest starts
* at the arrival time and duty resets, however late the trip is resolved.
*/
function dutyOutcome(input: ResolveInput, content: SimContent) {
const { record, pilotDutyHours, pilotSkills } = input;
const hours = tripGameHours(record.departureAt, record.arrivalAt, content.config);
const duty = dutyAfterTrip(pilotDutyHours, hours, record.tutorial);
if (!record.tutorial && needsRest(duty, dutyLimit(pilotSkills, content.config))) {
return { pilotDutyHours: 0, pilotRestEndsAt: restEndsAt(record.arrivalAt, content.config) };
}
return { pilotDutyHours: duty, pilotRestEndsAt: null };
}
/** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */
export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult {
const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input; const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input;
if (now < record.arrivalAt) return { status: "in-flight" }; if (now < record.arrivalAt) return { status: "in-flight" };
const { contract } = record; const { contract } = record;
const base = { shipLocationId: record.destinationId, ...dutyOutcome(input, content) }; const base = { shipLocationId: record.destinationId };
if (contract === null) { if (contract === null) {
// Deadheads earn nothing and only fly to discovered locations. // Deadheads earn nothing and only fly to discovered locations.
return { return {

View File

@ -43,7 +43,6 @@ const pilot: PilotState = {
assignedShipId: "s1", assignedShipId: "s1",
inFlight: false, inFlight: false,
skills: {}, skills: {},
restEndsAt: null,
}; };
const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };

View File

@ -1,134 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
MS_PER_HOUR,
createDispatchRecord,
createRepositionRecord,
dutyLimit,
resolveDispatch,
restEndsAt,
tripGameHours,
validateDispatch,
validateReposition,
type Contract,
type DispatchProposal,
type DispatchRecord,
type PilotState,
type ResolveInput,
type ShipState,
} from "../src/index";
const now = Date.parse("2100-01-01T00:30:00Z");
const contract: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: Date.parse("2100-01-01T00:00:00Z"),
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
firstContact: false,
};
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const pilot: PilotState = {
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
};
const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
const limit = dutyLimit({}, content.config);
const resolveAt = (record: DispatchRecord, dutyBefore: number, at = record.arrivalAt) => {
const input: ResolveInput = {
record,
pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]),
loanBalance: 0,
pilotDutyHours: dutyBefore,
pilotSkills: {},
now: at,
};
const result = resolveDispatch(input, content);
if (result.status !== "resolved") throw new Error("not resolved");
return result.resolution;
};
describe("duty in validation (Step 10D.3)", () => {
it("refuses a resting pilot with pilot-resting, before other pilot checks", () => {
const resting = { ...pilot, restEndsAt: now + 60_000, inFlight: true, assignedShipId: null };
expect(validateDispatch({ ...proposal, pilot: resting }, content)).toEqual({
ok: false,
reason: "pilot-resting",
});
expect(
validateReposition(
{
ship,
pilot: { ...pilot, restEndsAt: now + 1 },
destinationId: "moon",
discoveredIds: new Set(["earth", "moon"]),
credits: 10_000,
now,
},
content,
),
).toEqual({ ok: false, reason: "pilot-resting" });
});
it("lets a pilot whose rest has ended fly", () => {
expect(
validateDispatch({ ...proposal, pilot: { ...pilot, restEndsAt: now } }, content),
).toEqual({
ok: true,
});
});
});
describe("duty in resolution (Step 10D.3)", () => {
const record = createDispatchRecord({ ...proposal, id: "d1", worldSeed: 7 }, content);
const hours = tripGameHours(record.departureAt, record.arrivalAt, content.config);
it("adds the trip's hours to duty when under the limit", () => {
expect(resolveAt(record, 1)).toMatchObject({
pilotDutyHours: 1 + hours,
pilotRestEndsAt: null,
});
});
it("starts rest at the arrival time when duty reaches the limit, even resolved hours later", () => {
const late = record.arrivalAt + 5 * MS_PER_HOUR;
expect(resolveAt(record, limit - hours / 2, late)).toMatchObject({
pilotDutyHours: 0,
pilotRestEndsAt: restEndsAt(record.arrivalAt, content.config),
});
expect(restEndsAt(record.arrivalAt, content.config)).toBe(
record.arrivalAt +
(content.config.restGameHours * MS_PER_HOUR) / content.config.timeCompression,
);
});
it("counts deadheads but not tutorial runs", () => {
const deadhead = createRepositionRecord(
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
content,
);
const deadheadHours = tripGameHours(deadhead.departureAt, deadhead.arrivalAt, content.config);
expect(resolveAt(deadhead, 2).pilotDutyHours).toBeCloseTo(2 + deadheadHours);
const tutorial = createDispatchRecord(
{ ...proposal, tutorial: true, pilot: { ...pilot, isAvatar: true }, id: "t1", worldSeed: 7 },
content,
);
expect(resolveAt(tutorial, limit - 0.1)).toMatchObject({
pilotDutyHours: limit - 0.1,
pilotRestEndsAt: null,
});
});
});

View File

@ -1,57 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
boardPeriodIndex,
createRng,
dutyAfterTrip,
dutyLimit,
isResting,
needsRest,
restEndsAt,
tripGameHours,
} from "../src/index";
const config = content.config;
describe("pilot duty and rest (CR-004)", () => {
it("raises the duty limit by the bonus for each Long Haul level", () => {
expect(dutyLimit({}, config)).toBe(config.dutyLimitGameHours);
for (let level = 1; level <= 5; level++) {
expect(dutyLimit({ "long-haul": level }, config)).toBe(
config.dutyLimitGameHours + level * config.longHaulDutyBonusGameHours,
);
}
// Other skills do not count.
expect(dutyLimit({ "heavy-cargo": 3 }, config)).toBe(config.dutyLimitGameHours);
});
it("is resting until, but not at, rest-ends-at", () => {
const ends = Date.parse("2100-01-01T12:00:00Z");
expect(isResting(ends, ends - 1000)).toBe(true);
expect(isResting(ends, ends)).toBe(false);
expect(isResting(null, ends)).toBe(false);
});
it("adds a trip's hours to duty, except for tutorial runs", () => {
expect(dutyAfterTrip(3, 4, false)).toBe(7);
expect(dutyAfterTrip(3, 4, true)).toBe(3);
// One real minute is one game hour at 60× compression.
expect(tripGameHours(0, 4 * 60_000, config)).toBeCloseTo(4);
});
it("needs rest at or over the limit", () => {
expect(needsRest(dutyAfterTrip(10, 4, false), 12)).toBe(true);
expect(needsRest(dutyAfterTrip(4, 4, false), 12)).toBe(false);
expect(needsRest(12, 12)).toBe(true);
});
it("always wakes a rested pilot in a later board period", () => {
const rng = createRng(2026);
for (let i = 0; i < 2000; i++) {
const start = Math.floor(rng.next() * 1e13);
expect(boardPeriodIndex(restEndsAt(start, config), config)).toBeGreaterThan(
boardPeriodIndex(start, config),
);
}
});
});

View File

@ -12,14 +12,7 @@ const record = createRepositionRecord(
{ {
id: "r1", id: "r1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
pilot: { pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
},
destinationId: "moon", destinationId: "moon",
now: Date.parse("2100-01-01T00:30:00Z"), now: Date.parse("2100-01-01T00:30:00Z"),
worldSeed: 7, worldSeed: 7,

View File

@ -57,14 +57,7 @@ describe("resolution with a loan", () => {
{ {
id: "d1", id: "d1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
pilot: { pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
},
contract, contract,
tutorial: false, tutorial: false,
now, now,
@ -80,8 +73,6 @@ describe("resolution with a loan", () => {
pilotIsAvatar: false, pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]), discoveredIds: new Set(["earth", "moon"]),
loanBalance: 5000, loanBalance: 5000,
pilotDutyHours: 0,
pilotSkills: {},
now: record.arrivalAt, now: record.arrivalAt,
}, },
content, content,

View File

@ -26,14 +26,7 @@ const contract: Contract = {
const input: RecordInput = { const input: RecordInput = {
id: "d1", id: "d1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
pilot: { pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
},
contract, contract,
tutorial: false, tutorial: false,
now, now,

View File

@ -16,7 +16,6 @@ const pilot: PilotState = {
assignedShipId: "s1", assignedShipId: "s1",
inFlight: false, inFlight: false,
skills: {}, skills: {},
restEndsAt: null,
}; };
/** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */ /** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */
const valid: RepositionProposal = { const valid: RepositionProposal = {

View File

@ -32,7 +32,6 @@ const pilot: PilotState = {
assignedShipId: "s1", assignedShipId: "s1",
inFlight: false, inFlight: false,
skills: {}, skills: {},
restEndsAt: null,
}; };
const record = createDispatchRecord( const record = createDispatchRecord(
{ id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 }, { id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 },
@ -43,8 +42,6 @@ const arrived: ResolveInput = {
pilotIsAvatar: false, pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]), discoveredIds: new Set(["earth", "moon"]),
loanBalance: 0, loanBalance: 0,
pilotDutyHours: 0,
pilotSkills: {},
now: record.arrivalAt, now: record.arrivalAt,
}; };
@ -108,7 +105,7 @@ describe("dispatch resolution", () => {
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 }, { id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
content, content,
); );
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toMatchObject({ expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({
credits: 0, credits: 0,
loanRepayment: 0, loanRepayment: 0,
pilotXp: 0, pilotXp: 0,