Compare commits
No commits in common. "3dba7c20190b6f7287c9c0607b87bcc4cccf9274" and "99aebe1dcb97926ddf2af5fd0acb642995ea8af6" have entirely different histories.
3dba7c2019
...
99aebe1dcb
40
DECISIONS.md
40
DECISIONS.md
|
|
@ -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.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -29,13 +29,6 @@
|
|||
"when": 1791053200611,
|
||||
"tag": "0003_cr002_location_boards",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1791078648879,
|
||||
"tag": "0004_cr004_pilot_duty",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
|
|||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
|
|
@ -89,10 +88,6 @@ export const pilots = pgTable(
|
|||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||
locationId: text("location_id"),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
/** Game hours flown since the last rest (decision 0.5.42). */
|
||||
dutyHours: doublePrecision("duty_hours").notNull().default(0),
|
||||
/** Resting until this time; null (or past) when not resting (decision 0.5.45). */
|
||||
restEndsAt: time("rest_ends_at"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ export function pilotState(row: typeof pilots.$inferSelect): PilotState {
|
|||
assignedShipId: row.assignedShipId,
|
||||
inFlight: row.status === "in_flight",
|
||||
skills: row.skills,
|
||||
restEndsAt: row.restEndsAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, Pilot } from "@deadhead/schema";
|
||||
import {
|
||||
dutyLimit,
|
||||
isResting,
|
||||
pilotLevel,
|
||||
skillPointsAvailable,
|
||||
type RealTime,
|
||||
} from "@deadhead/sim";
|
||||
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
|
||||
import type { dispatches, pilots } from "../db/schema";
|
||||
|
||||
/** 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(
|
||||
row: typeof pilots.$inferSelect,
|
||||
assignedShipLocationId: string | null,
|
||||
now: RealTime,
|
||||
): Pilot {
|
||||
const resting = isResting(row.restEndsAt, now);
|
||||
const level = pilotLevel(row.xp, content.config);
|
||||
return {
|
||||
id: row.id,
|
||||
|
|
@ -51,9 +43,5 @@ export function toApiPilot(
|
|||
: row.assignedShipId
|
||||
? assignedShipLocationId
|
||||
: row.locationId,
|
||||
dutyHours: row.dutyHours,
|
||||
dutyLimit: dutyLimit(row.skills, content.config),
|
||||
resting,
|
||||
restEndsAt: resting ? row.restEndsAt : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { DispatchFailure, RepositionFailure } from "@deadhead/sim";
|
|||
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
|
||||
"ship-not-idle": "That ship is already flying.",
|
||||
"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-assigned": "That pilot is not assigned to that ship.",
|
||||
"over-capacity": "The cargo is too heavy for that ship.",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
import type { Pilot } from "@deadhead/schema";
|
||||
import {
|
||||
isResting,
|
||||
pilotLevel,
|
||||
restEndsAt,
|
||||
skillPointsAvailable,
|
||||
type RealTime,
|
||||
} from "@deadhead/sim";
|
||||
import { pilotLevel, skillPointsAvailable, type RealTime } from "@deadhead/sim";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import { bays, pilots, ships } from "../db/schema";
|
||||
|
|
@ -71,7 +65,7 @@ export async function hirePilot(
|
|||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
return toApiPilot(pilot!, null, now);
|
||||
return toApiPilot(pilot!, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,7 +77,6 @@ export async function assignPilot(
|
|||
playerId: string,
|
||||
pilotId: string,
|
||||
shipId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
const ship = await requireOwnShip(tx, playerId, shipId);
|
||||
|
|
@ -118,7 +111,7 @@ export async function assignPilot(
|
|||
.set({ assignedShipId: shipId, locationId: null })
|
||||
.where(eq(pilots.id, pilotId))
|
||||
.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). */
|
||||
|
|
@ -126,7 +119,6 @@ export async function unassignPilot(
|
|||
tx: Queryable,
|
||||
playerId: string,
|
||||
pilotId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
if (pilot.assignedShipId === null) {
|
||||
|
|
@ -145,36 +137,7 @@ export async function unassignPilot(
|
|||
.set({ assignedShipId: null, locationId: ship.locationId })
|
||||
.where(eq(pilots.id, pilotId))
|
||||
.returning();
|
||||
return toApiPilot(updated!, null, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return toApiPilot(updated!, null);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
pilotId: string,
|
||||
skillId: string,
|
||||
now: RealTime,
|
||||
): Promise<Pilot> {
|
||||
const pilot = await requireOwnPilot(tx, playerId, pilotId);
|
||||
const skill = content.skill(skillId);
|
||||
|
|
@ -209,5 +171,5 @@ export async function spendSkillPoint(
|
|||
const [ship] = updated!.assignedShipId
|
||||
? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId))
|
||||
: [];
|
||||
return toApiPilot(updated!, ship?.locationId ?? null, now);
|
||||
return toApiPilot(updated!, ship?.locationId ?? null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { PlayerState } from "@deadhead/schema";
|
||||
import type { RealTime } from "@deadhead/sim";
|
||||
import { and, eq, isNull, max } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
|
|
@ -17,11 +16,7 @@ import { goalStates } from "./goals";
|
|||
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
|
||||
|
||||
/** Reads everything the client needs about one player. */
|
||||
export async function loadPlayerState(
|
||||
db: Queryable,
|
||||
playerId: string,
|
||||
now: RealTime,
|
||||
): Promise<PlayerState> {
|
||||
export async function loadPlayerState(db: Queryable, playerId: string): Promise<PlayerState> {
|
||||
const [player] = await db.select().from(players).where(eq(players.id, playerId));
|
||||
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
|
||||
if (!player || !company) throw new Error(`Player ${playerId} not found`);
|
||||
|
|
@ -79,11 +74,7 @@ export async function loadPlayerState(
|
|||
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
|
||||
})),
|
||||
pilots: pilotRows.map((p) =>
|
||||
toApiPilot(
|
||||
p,
|
||||
p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null,
|
||||
now,
|
||||
),
|
||||
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),
|
||||
),
|
||||
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
|
||||
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,
|
||||
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
|
||||
scriptedId: d.record.contract?.scriptedId ?? null,
|
||||
restEndsAt: d.resolution?.pilotRestEndsAt ?? null,
|
||||
resolvedAt: d.resolvedAt ?? d.arrivalAt,
|
||||
})),
|
||||
goals: await goalStates(db, playerId),
|
||||
|
|
|
|||
|
|
@ -45,15 +45,7 @@ export async function resolveArrivedDispatches(
|
|||
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
|
||||
|
||||
const result = resolveDispatch(
|
||||
{
|
||||
record: dispatch.record,
|
||||
pilotIsAvatar: pilot.isAvatar,
|
||||
discoveredIds,
|
||||
loanBalance,
|
||||
pilotDutyHours: pilot.dutyHours,
|
||||
pilotSkills: pilot.skills,
|
||||
now,
|
||||
},
|
||||
{ record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, loanBalance, now },
|
||||
content,
|
||||
);
|
||||
if (result.status !== "resolved") continue;
|
||||
|
|
@ -81,9 +73,6 @@ export async function resolveArrivedDispatches(
|
|||
.set({
|
||||
xp: sql`${pilots.xp} + ${resolution.pilotXp}`,
|
||||
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.
|
||||
...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { routes } from "@deadhead/schema";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
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 type { AppContext } from "./context";
|
||||
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 }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId, now),
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await unassignPilot(tx, playerId, input.pilotId, now),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.restPilot, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await restPilot(tx, playerId, input.pilotId, now),
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await unassignPilot(tx, playerId, input.pilotId),
|
||||
})),
|
||||
);
|
||||
|
||||
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, now),
|
||||
withPlayer(ctx, playerId, async (tx) => ({
|
||||
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,12 +39,12 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
|
|||
});
|
||||
// Pass Better Auth's session cookie through to the client.
|
||||
reply.header("set-cookie", signIn.headers.getSetCookie());
|
||||
return { playerId, state: await loadPlayerState(db, playerId, clock()) };
|
||||
return { playerId, state: await loadPlayerState(db, playerId) };
|
||||
});
|
||||
|
||||
// Lazy resolution on read (Step 6.1): apply every arrival before returning state.
|
||||
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, (tx, now) => loadPlayerState(tx, playerId, now)),
|
||||
withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, 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 }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => {
|
||||
await acceptDeal(tx, playerId, now);
|
||||
return loadPlayerState(tx, playerId, now);
|
||||
return loadPlayerState(tx, playerId);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -141,7 +141,6 @@ export async function insertDispatch(
|
|||
assignedShipId: pilot!.assignedShipId,
|
||||
inFlight: false,
|
||||
skills: pilot!.skills,
|
||||
restEndsAt: null,
|
||||
};
|
||||
const id = randomUUID();
|
||||
const contract = args.contract === undefined ? testContract() : args.contract;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ async function accept(db: Db, guest: Guest, contract: Contract, now: number) {
|
|||
assignedShipId: null,
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
},
|
||||
contract,
|
||||
tutorial: true,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|||
export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||
"ship-not-idle": "Ship is flying",
|
||||
"ship-not-at-origin": "Not at pickup",
|
||||
"pilot-resting": "Pilot is resting",
|
||||
"pilot-not-idle": "Pilot is flying",
|
||||
"pilot-not-assigned": "No pilot assigned",
|
||||
"over-capacity": "Too heavy",
|
||||
|
|
@ -141,7 +140,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
|||
assignedShipId: pilot.assignedShipId,
|
||||
inFlight: pilot.status === "in-flight",
|
||||
skills: pilot.skills,
|
||||
restEndsAt: null,
|
||||
},
|
||||
contract,
|
||||
tutorial: false,
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
|||
"cannot-land": "Can't land there",
|
||||
"out-of-range": "Out of range",
|
||||
"insufficient-credits": "Not enough credits for fuel",
|
||||
"pilot-resting": "Pilot is resting",
|
||||
"pilot-not-idle": "Pilot is flying",
|
||||
"pilot-not-assigned": "No pilot assigned",
|
||||
};
|
||||
|
|
@ -147,7 +146,6 @@ function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void })
|
|||
assignedShipId: pilot ? ship.id : null,
|
||||
inFlight: pilot?.status === "in-flight",
|
||||
skills: pilot?.skills ?? {},
|
||||
restEndsAt: null,
|
||||
},
|
||||
destinationId: id,
|
||||
discoveredIds,
|
||||
|
|
|
|||
|
|
@ -38,10 +38,6 @@ describe("typed API client", () => {
|
|||
assignedShipId: "s1",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
const fetch = mockFetch(200, { pilot });
|
||||
const api = createApiClient({ fetch });
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ const summaries: ResolutionSummary[] = [
|
|||
pilotXp: 32,
|
||||
discoveredLocationId: "mars",
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
{
|
||||
|
|
@ -43,7 +42,6 @@ const summaries: ResolutionSummary[] = [
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 2,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ describe("the deal", () => {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -41,10 +41,6 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil
|
|||
assignedShipId: shipId,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
});
|
||||
|
||||
const marsJob: Contract = {
|
||||
|
|
|
|||
|
|
@ -154,7 +154,6 @@ describe("fleet screen", () => {
|
|||
assignedShipId: "s-idle",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
},
|
||||
destinationId: "earth",
|
||||
now: NOW,
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ describe("loan on HQ", () => {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: null,
|
||||
restEndsAt: null,
|
||||
resolvedAt: 1,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@ const rae: Pilot = {
|
|||
assignedShipId: null,
|
||||
status: "idle",
|
||||
locationId: "moon",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
|
||||
function pilotsState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
|
|
|
|||
|
|
@ -48,10 +48,6 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
|
|||
assignedShipId: null,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
},
|
||||
],
|
||||
bays: [],
|
||||
|
|
|
|||
|
|
@ -153,7 +153,6 @@ describe("scripted delivery summary", () => {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: "unmarked-crates",
|
||||
restEndsAt: null,
|
||||
resolvedAt: NOW,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ Living reference for how the code is organised. Update it after any database sch
|
|||
| `ServerTime` | `realTime`, `gameTime` |
|
||||
| `Company` | `id`, `name`, `credits`, `loanBalance` |
|
||||
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) |
|
||||
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `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` |
|
||||
| `LocationState` | `id`, `discovered` |
|
||||
| `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) |
|
||||
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
|
||||
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `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` |
|
||||
| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) |
|
||||
| `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 |
|
||||
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
|
||||
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
|
||||
| `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ pilot }` | yes |
|
||||
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
||||
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
|
||||
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
|
||||
|
|
@ -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` |
|
||||
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
|
||||
| `ships` | Owned ships: model, name, status, location (null in flight) | — |
|
||||
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, 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`) |
|
||||
| `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`) |
|
||||
|
|
@ -177,10 +176,6 @@ export const pilots = pgTable(
|
|||
/** Only unassigned pilots store a location (decision 0.5.27). */
|
||||
locationId: text("location_id"),
|
||||
status: activityStatus("status").notNull().default("idle"),
|
||||
/** Game hours flown since the last rest (decision 0.5.42). */
|
||||
dutyHours: doublePrecision("duty_hours").notNull().default(0),
|
||||
/** Resting until this time; null (or past) when not resting (decision 0.5.45). */
|
||||
restEndsAt: time("rest_ends_at"),
|
||||
createdAt: time("created_at").notNull(),
|
||||
},
|
||||
(t) => [index("pilots_player_idx").on(t.playerId)],
|
||||
|
|
|
|||
|
|
@ -1,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.
|
||||
|
|
@ -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.
|
||||
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.
|
||||
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
|
||||
|
||||
### Step 11.1 — Installable PWA
|
||||
|
|
|
|||
|
|
@ -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.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.**
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -24,12 +24,6 @@ export const gameConfig: GameConfig = {
|
|||
// Courier price + Earth bay lease.
|
||||
dealLoanAmount: 5000,
|
||||
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,
|
||||
firstContactPayoutMultiplier: 2,
|
||||
// One band per ship size, so every board has work for every ship (playtest fix):
|
||||
|
|
|
|||
|
|
@ -134,12 +134,6 @@ export const gameConfigSchema = z
|
|||
/** Loan taken on with the deal, in credits (decision 0.5.30). */
|
||||
dealLoanAmount: positiveInt,
|
||||
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,
|
||||
firstContactPayoutMultiplier: z.number().positive(),
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -67,14 +67,4 @@ describe("game config", () => {
|
|||
const total = gameConfig.contractMassBands.reduce((sum, b) => sum + b.count, 0);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,9 +63,6 @@ const validGameConfig = {
|
|||
contractBoardSize: 8,
|
||||
tutorialBoardSize: 50,
|
||||
contractBoardPeriodRealMinutes: 60,
|
||||
dutyLimitGameHours: 12,
|
||||
longHaulDutyBonusGameHours: 2,
|
||||
restGameHours: 60,
|
||||
firstContactChance: 0.1,
|
||||
firstContactPayoutMultiplier: 2,
|
||||
contractMassBands: [
|
||||
|
|
|
|||
|
|
@ -58,12 +58,6 @@ export const pilotSchema = z.object({
|
|||
status: z.enum(["idle", "in-flight"]),
|
||||
/** Where the pilot is (their ship's location if assigned); null while in flight. */
|
||||
locationId: idSchema.nullable(),
|
||||
/** Game hours flown since the last rest, and the limit before mandatory rest (CR-004). */
|
||||
dutyHours: z.number().nonnegative(),
|
||||
dutyLimit: z.number().positive(),
|
||||
/** Resting pilots cannot fly until `restEndsAt` (null when not resting). */
|
||||
resting: z.boolean(),
|
||||
restEndsAt: timeSchema.nullable(),
|
||||
});
|
||||
export type Pilot = z.infer<typeof pilotSchema>;
|
||||
|
||||
|
|
@ -135,8 +129,6 @@ export const resolutionSummarySchema = z.object({
|
|||
discoveredLocationId: idSchema.nullable(),
|
||||
/** The scripted tutorial contract delivered, if any (for its flavor message). */
|
||||
scriptedId: idSchema.nullable(),
|
||||
/** Set when the trip ended in mandatory rest: the pilot rests until then (CR-004). */
|
||||
restEndsAt: timeSchema.nullable(),
|
||||
resolvedAt: timeSchema,
|
||||
});
|
||||
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ export const bayResponseSchema = z.object({ bay: baySchema });
|
|||
export const hirePilotRequestSchema = z.object({ locationId: idSchema });
|
||||
export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema });
|
||||
export const unassignPilotRequestSchema = z.object({ pilotId: idSchema });
|
||||
/** CR-004: rest an idle pilot now, at any duty level. */
|
||||
export const restPilotRequestSchema = z.object({ pilotId: idSchema });
|
||||
export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema });
|
||||
export const pilotResponseSchema = z.object({ pilot: pilotSchema });
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
repayLoanRequestSchema,
|
||||
shipResponseSchema,
|
||||
spendSkillPointRequestSchema,
|
||||
restPilotRequestSchema,
|
||||
unassignPilotRequestSchema,
|
||||
} from "./requests";
|
||||
|
||||
|
|
@ -115,13 +114,6 @@ export const routes = {
|
|||
response: pilotResponseSchema,
|
||||
auth: true,
|
||||
},
|
||||
restPilot: {
|
||||
method: "POST",
|
||||
path: "/api/pilots/:pilotId/rest",
|
||||
request: restPilotRequestSchema,
|
||||
response: pilotResponseSchema,
|
||||
auth: true,
|
||||
},
|
||||
spendSkillPoint: {
|
||||
method: "POST",
|
||||
path: "/api/pilots/:pilotId/skills",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ describe("route catalog", () => {
|
|||
"hirePilot",
|
||||
"assignPilot",
|
||||
"unassignPilot",
|
||||
"restPilot",
|
||||
"spendSkillPoint",
|
||||
"acknowledgeSummaries",
|
||||
"acceptDeal",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import {
|
|||
locationStateSchema,
|
||||
pilotResponseSchema,
|
||||
pilotSchema,
|
||||
restPilotRequestSchema,
|
||||
playerStateSchema,
|
||||
resolutionSummarySchema,
|
||||
serverTimeSchema,
|
||||
|
|
@ -68,10 +67,6 @@ const pilot = {
|
|||
assignedShipId: "s1",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 7.5,
|
||||
dutyLimit: 14,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
const bay = { id: "b1", locationId: "earth" };
|
||||
const location = { id: "mars", discovered: false };
|
||||
|
|
@ -118,7 +113,6 @@ const summary = {
|
|||
pilotXp: 10,
|
||||
discoveredLocationId: null,
|
||||
scriptedId: "unmarked-crates",
|
||||
restEndsAt: null,
|
||||
resolvedAt: 3_900_000,
|
||||
};
|
||||
const tutorial = {
|
||||
|
|
@ -150,25 +144,11 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
["company", companySchema, company, { ...company, credits: 1.5 }],
|
||||
["ship", shipSchema, ship, { ...ship, status: "lost" }],
|
||||
["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }],
|
||||
[
|
||||
"resting pilot",
|
||||
pilotSchema,
|
||||
{ ...pilot, dutyHours: 0, resting: true, restEndsAt: 4_000_000 },
|
||||
{ ...pilot, dutyHours: -1 },
|
||||
],
|
||||
["pilot duty limit", pilotSchema, { ...pilot, dutyLimit: 12 }, { ...pilot, dutyLimit: 0 }],
|
||||
["rest pilot request", restPilotRequestSchema, { pilotId: "p1" }, { pilotId: "" }],
|
||||
["bay", baySchema, bay, { id: "b1" }],
|
||||
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
|
||||
["contract", contractSchema, contract, { ...contract, tier: -1 }],
|
||||
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
|
||||
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
|
||||
[
|
||||
"summary with rest started",
|
||||
resolutionSummarySchema,
|
||||
{ ...summary, restEndsAt: 7_500_000 },
|
||||
{ ...summary, restEndsAt: -1 },
|
||||
],
|
||||
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
|
||||
["goal state", goalStateSchema, goal, { ...goal, status: "done" }],
|
||||
["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { SimContent } from "./board";
|
|||
import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock";
|
||||
import type { Contract } from "./contract";
|
||||
import { routeDistance } from "./distance";
|
||||
import { isResting } from "./duty";
|
||||
import { checkRoute } from "./eligibility";
|
||||
import { fuelCost } from "./fuel";
|
||||
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||
|
|
@ -24,8 +23,6 @@ export interface PilotState {
|
|||
assignedShipId: string | null;
|
||||
inFlight: boolean;
|
||||
skills: SkillLevels;
|
||||
/** Resting until this real time; null when not resting (CR-004). */
|
||||
restEndsAt: RealTime | null;
|
||||
}
|
||||
|
||||
export interface DispatchProposal {
|
||||
|
|
@ -43,7 +40,6 @@ export interface DispatchProposal {
|
|||
export const DISPATCH_FAILURES = [
|
||||
"ship-not-idle",
|
||||
"ship-not-at-origin",
|
||||
"pilot-resting",
|
||||
"pilot-not-idle",
|
||||
"pilot-not-assigned",
|
||||
"over-capacity",
|
||||
|
|
@ -92,8 +88,6 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
|
|||
|
||||
if (ship.inFlight) return fail("ship-not-idle");
|
||||
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");
|
||||
// Decision 0.5.23: during the tutorial the avatar flies only the loaner.
|
||||
if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) {
|
||||
|
|
@ -209,7 +203,6 @@ export interface RepositionProposal {
|
|||
/** Every reason `validateReposition` can fail, in check order. */
|
||||
export const REPOSITION_FAILURES = [
|
||||
"ship-not-idle",
|
||||
"pilot-resting",
|
||||
"pilot-not-idle",
|
||||
"pilot-not-assigned",
|
||||
"already-there",
|
||||
|
|
@ -235,7 +228,6 @@ export function validateReposition(
|
|||
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
||||
|
||||
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.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
||||
if (destinationId === ship.locationId) return failWith("already-there");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -69,5 +69,3 @@ export type { LoanSplit } from "./loan";
|
|||
|
||||
export { resolveDispatch } from "./resolution";
|
||||
export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
|
||||
|
||||
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import type { SimContent } from "./board";
|
||||
import type { RealTime } from "./clock";
|
||||
import type { DispatchRecord } from "./dispatch";
|
||||
import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty";
|
||||
import { splitLoanRepayment } from "./loan";
|
||||
import type { SkillLevels } from "./pilot";
|
||||
import { deliveryXp } from "./xp";
|
||||
|
||||
export interface Resolution {
|
||||
|
|
@ -19,10 +17,6 @@ export interface Resolution {
|
|||
shipLocationId: string;
|
||||
/** True when this delivery discovers the destination. */
|
||||
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 =
|
||||
|
|
@ -35,34 +29,16 @@ export interface ResolveInput {
|
|||
discoveredIds: ReadonlySet<string>;
|
||||
/** The company's loan balance before this resolution. */
|
||||
loanBalance: number;
|
||||
/** The pilot's duty hours before this trip, and skills (Long Haul sets the limit). */
|
||||
pilotDutyHours: number;
|
||||
pilotSkills: SkillLevels;
|
||||
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. */
|
||||
export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult {
|
||||
const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input;
|
||||
if (now < record.arrivalAt) return { status: "in-flight" };
|
||||
|
||||
const { contract } = record;
|
||||
const base = { shipLocationId: record.destinationId, ...dutyOutcome(input, content) };
|
||||
const base = { shipLocationId: record.destinationId };
|
||||
if (contract === null) {
|
||||
// Deadheads earn nothing and only fly to discovered locations.
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ const pilot: PilotState = {
|
|||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
};
|
||||
const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -12,14 +12,7 @@ const record = createRepositionRecord(
|
|||
{
|
||||
id: "r1",
|
||||
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
|
||||
pilot: {
|
||||
id: "p1",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
},
|
||||
pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
|
||||
destinationId: "moon",
|
||||
now: Date.parse("2100-01-01T00:30:00Z"),
|
||||
worldSeed: 7,
|
||||
|
|
|
|||
|
|
@ -57,14 +57,7 @@ describe("resolution with a loan", () => {
|
|||
{
|
||||
id: "d1",
|
||||
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
|
||||
pilot: {
|
||||
id: "p1",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
},
|
||||
pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
|
||||
contract,
|
||||
tutorial: false,
|
||||
now,
|
||||
|
|
@ -80,8 +73,6 @@ describe("resolution with a loan", () => {
|
|||
pilotIsAvatar: false,
|
||||
discoveredIds: new Set(["earth", "moon"]),
|
||||
loanBalance: 5000,
|
||||
pilotDutyHours: 0,
|
||||
pilotSkills: {},
|
||||
now: record.arrivalAt,
|
||||
},
|
||||
content,
|
||||
|
|
|
|||
|
|
@ -26,14 +26,7 @@ const contract: Contract = {
|
|||
const input: RecordInput = {
|
||||
id: "d1",
|
||||
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
|
||||
pilot: {
|
||||
id: "p1",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
},
|
||||
pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
|
||||
contract,
|
||||
tutorial: false,
|
||||
now,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ const pilot: PilotState = {
|
|||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
};
|
||||
/** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */
|
||||
const valid: RepositionProposal = {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ const pilot: PilotState = {
|
|||
assignedShipId: "s1",
|
||||
inFlight: false,
|
||||
skills: {},
|
||||
restEndsAt: null,
|
||||
};
|
||||
const record = createDispatchRecord(
|
||||
{ id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 },
|
||||
|
|
@ -43,8 +42,6 @@ const arrived: ResolveInput = {
|
|||
pilotIsAvatar: false,
|
||||
discoveredIds: new Set(["earth", "moon"]),
|
||||
loanBalance: 0,
|
||||
pilotDutyHours: 0,
|
||||
pilotSkills: {},
|
||||
now: record.arrivalAt,
|
||||
};
|
||||
|
||||
|
|
@ -108,7 +105,7 @@ describe("dispatch resolution", () => {
|
|||
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
|
||||
content,
|
||||
);
|
||||
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toMatchObject({
|
||||
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({
|
||||
credits: 0,
|
||||
loanRepayment: 0,
|
||||
pilotXp: 0,
|
||||
|
|
|
|||
Loading…
Reference in New Issue