Step 10D.3: pilot-resting validation and duty in resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6b6753f3b2
commit
055cbceaaa
|
|
@ -772,3 +772,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **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.
|
- **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.
|
- **"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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ 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,
|
||||||
|
// Stored from Step 10D.4; until then nobody rests.
|
||||||
|
restEndsAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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.",
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,16 @@ 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,
|
||||||
|
// Duty is stored from Step 10D.4.
|
||||||
|
pilotDutyHours: 0,
|
||||||
|
pilotSkills: pilot.skills,
|
||||||
|
now,
|
||||||
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
if (result.status !== "resolved") continue;
|
if (result.status !== "resolved") continue;
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ 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",
|
||||||
|
|
@ -140,6 +141,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ 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",
|
||||||
};
|
};
|
||||||
|
|
@ -146,6 +147,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
|
||||||
|
|
||||||
- [x] **Step 10D.1 — Content config.** GameConfig gains `dutyLimitGameHours` (12), `longHaulDutyBonusGameHours` (2) and `restGameHours` (60 = one 60-minute board period at 60× compression). Tests: rest in real time ≥ board period; duty numbers positive.
|
- [x] **Step 10D.1 — Content config.** GameConfig gains `dutyLimitGameHours` (12), `longHaulDutyBonusGameHours` (2) and `restGameHours` (60 = one 60-minute board period at 60× compression). Tests: rest in real time ≥ board period; duty numbers positive.
|
||||||
- [x] **Step 10D.2 — Sim: duty and rest functions.** `packages/sim/src/duty.ts`: `dutyLimit(skills)`, `isResting(restEndsAt, now)`, `tripGameHours(departure, arrival)`, `dutyAfterTrip(duty, hours, tutorial)`, `needsRest(duty, limit)`, `restEndsAt(start)`. Test: `duty.test.ts` (including 2,000 random rest starts all waking in a later board period).
|
- [x] **Step 10D.2 — Sim: duty and rest functions.** `packages/sim/src/duty.ts`: `dutyLimit(skills)`, `isResting(restEndsAt, now)`, `tripGameHours(departure, arrival)`, `dutyAfterTrip(duty, hours, tutorial)`, `needsRest(duty, limit)`, `restEndsAt(start)`. Test: `duty.test.ts` (including 2,000 random rest starts all waking in a later board period).
|
||||||
- [ ] Step 10D.3 — Sim: validation and resolution
|
- [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`.
|
||||||
- [ ] Step 10D.4 — Schema and database
|
- [ ] Step 10D.4 — Schema and database
|
||||||
- [ ] Step 10D.5 — Server: apply duty and rest
|
- [ ] Step 10D.5 — Server: apply duty and rest
|
||||||
- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
|
- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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";
|
||||||
|
|
@ -23,6 +24,8 @@ 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 {
|
||||||
|
|
@ -40,6 +43,7 @@ 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",
|
||||||
|
|
@ -88,6 +92,8 @@ 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) {
|
||||||
|
|
@ -203,6 +209,7 @@ 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",
|
||||||
|
|
@ -228,6 +235,7 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
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 {
|
||||||
|
|
@ -17,6 +19,10 @@ 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 =
|
||||||
|
|
@ -29,16 +35,34 @@ 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 };
|
const base = { shipLocationId: record.destinationId, ...dutyOutcome(input, content) };
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ 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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,7 +12,14 @@ 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: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
|
pilot: {
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,14 @@ 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: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
|
pilot: {
|
||||||
|
id: "p1",
|
||||||
|
isAvatar: false,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
inFlight: false,
|
||||||
|
skills: {},
|
||||||
|
restEndsAt: null,
|
||||||
|
},
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
now,
|
now,
|
||||||
|
|
@ -73,6 +80,8 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,14 @@ 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: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} },
|
pilot: {
|
||||||
|
id: "p1",
|
||||||
|
isAvatar: false,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
inFlight: false,
|
||||||
|
skills: {},
|
||||||
|
restEndsAt: null,
|
||||||
|
},
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
now,
|
now,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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 = {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ 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 },
|
||||||
|
|
@ -42,6 +43,8 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -105,7 +108,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 })).toEqual({
|
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toMatchObject({
|
||||||
credits: 0,
|
credits: 0,
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 0,
|
pilotXp: 0,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue