Step 3.17: add dispatch resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a685235e90
commit
50b038047f
|
|
@ -169,3 +169,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Delivery XP** = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record.
|
- **Delivery XP** = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record.
|
||||||
- **Pilot level** is the number of `pilotLevelXpThresholds` the total XP has reached (0 below the first threshold), so levels cap at the list length (10).
|
- **Pilot level** is the number of `pilotLevelXpThresholds` the total XP has reached (0 below the first threshold), so levels cap at the list length (10).
|
||||||
- **Skill points available** = level × points per level − points spent, floored at 0.
|
- **Skill points available** = level × points per level − points spent, floored at 0.
|
||||||
|
|
||||||
|
## Step 3.17
|
||||||
|
|
||||||
|
- **Resolution credits = contract payout − pilot pay.** Fuel is not included, because it is charged when the ship departs (the record's `fuelCost`). Credits can be negative if a payout is below pilot pay; the server applies the number as given.
|
||||||
|
- **Tutorial runs grant XP to the avatar** like any delivery; the plan does not exclude it. They credit the stored tutorial payout with no pilot pay.
|
||||||
|
- **Repositions resolve to 0 credits and 0 XP** and never discover anything, since they only fly to bays.
|
||||||
|
- **The caller passes `pilotIsAvatar` and the current discovered set**, so the function stays pure. "Exactly once" is the server's job: it must store the resolution and the acknowledged-at state transactionally.
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,5 @@
|
||||||
- [x] **Step 3.14 — Reposition dispatch.** `validateReposition` and `createRepositionRecord` in `src/dispatch.ts`: empty flight to a player bay, range/landing/fuel only, zero payout. Zero XP is covered by resolution (Step 3.17).
|
- [x] **Step 3.14 — Reposition dispatch.** `validateReposition` and `createRepositionRecord` in `src/dispatch.ts`: empty flight to a player bay, range/landing/fuel only, zero payout. Zero XP is covered by resolution (Step 3.17).
|
||||||
- [x] **Step 3.15 — In-flight position and progress.** `src/flight.ts`: `dispatchProgress` (clamped 0–1) and `dispatchPosition` (straight line from origin at departure to destination at arrival).
|
- [x] **Step 3.15 — In-flight position and progress.** `src/flight.ts`: `dispatchProgress` (clamped 0–1) and `dispatchPosition` (straight line from origin at departure to destination at arrival).
|
||||||
- [x] **Step 3.16 — XP and leveling.** `src/xp.ts`: `deliveryXp`, `pilotLevel`, `skillPointsAvailable`.
|
- [x] **Step 3.16 — XP and leveling.** `src/xp.ts`: `deliveryXp`, `pilotLevel`, `skillPointsAvailable`.
|
||||||
- [ ] Step 3.17 — Dispatch resolution
|
- [x] **Step 3.17 — Dispatch resolution.** `src/resolution.ts`: `resolveDispatch` returns `in-flight` or a `Resolution` (credits after pilot pay, XP, new ship location, discovery).
|
||||||
- [ ] Step 3.18 — Sim package public API and coverage
|
- [ ] Step 3.18 — Sim package public API and coverage
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ export * from "./pilot";
|
||||||
export * from "./dispatch";
|
export * from "./dispatch";
|
||||||
export * from "./flight";
|
export * from "./flight";
|
||||||
export * from "./xp";
|
export * from "./xp";
|
||||||
|
export * from "./resolution";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { SimContent } from "./board";
|
||||||
|
import type { RealTime } from "./clock";
|
||||||
|
import type { DispatchRecord } from "./dispatch";
|
||||||
|
import { deliveryXp } from "./xp";
|
||||||
|
|
||||||
|
export interface Resolution {
|
||||||
|
/** Credits to add: payout minus pilot pay. Fuel was already charged at departure. */
|
||||||
|
credits: number;
|
||||||
|
pilotXp: number;
|
||||||
|
/** Where the ship (or tutorial loaner) is now. */
|
||||||
|
shipLocationId: string;
|
||||||
|
/** True when this delivery discovers the destination. */
|
||||||
|
discoversDestination: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolveResult =
|
||||||
|
{ status: "in-flight" } | { status: "resolved"; resolution: Resolution };
|
||||||
|
|
||||||
|
export interface ResolveInput {
|
||||||
|
record: DispatchRecord;
|
||||||
|
/** The avatar is exempt from pilot pay (decision 0.5.12). */
|
||||||
|
pilotIsAvatar: boolean;
|
||||||
|
discoveredIds: ReadonlySet<string>;
|
||||||
|
now: RealTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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, now } = input;
|
||||||
|
if (now < record.arrivalAt) return { status: "in-flight" };
|
||||||
|
|
||||||
|
const { contract } = record;
|
||||||
|
const base = { shipLocationId: record.destinationId };
|
||||||
|
if (contract === null) {
|
||||||
|
// Repositions earn nothing and only fly to bays, which are already discovered.
|
||||||
|
return {
|
||||||
|
status: "resolved",
|
||||||
|
resolution: { ...base, credits: 0, pilotXp: 0, discoversDestination: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery;
|
||||||
|
return {
|
||||||
|
status: "resolved",
|
||||||
|
resolution: {
|
||||||
|
...base,
|
||||||
|
// Tutorial contracts already store exactly the tutorial share as their payout.
|
||||||
|
credits: contract.payout - pilotPay,
|
||||||
|
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),
|
||||||
|
discoversDestination: !discoveredIds.has(record.destinationId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createDispatchRecord,
|
||||||
|
createRepositionRecord,
|
||||||
|
resolveDispatch,
|
||||||
|
type Contract,
|
||||||
|
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: {},
|
||||||
|
};
|
||||||
|
const record = createDispatchRecord(
|
||||||
|
{ id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 },
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
const arrived: ResolveInput = {
|
||||||
|
record,
|
||||||
|
pilotIsAvatar: false,
|
||||||
|
discoveredIds: new Set(["earth", "moon"]),
|
||||||
|
now: record.arrivalAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = (input: ResolveInput) => {
|
||||||
|
const result = resolveDispatch(input, content);
|
||||||
|
if (result.status !== "resolved") throw new Error("expected a resolution");
|
||||||
|
return result.resolution;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("dispatch resolution", () => {
|
||||||
|
it("reports in flight before arrival", () => {
|
||||||
|
expect(resolveDispatch({ ...arrived, now: record.arrivalAt - 1 }, content)).toEqual({
|
||||||
|
status: "in-flight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves at and after arrival, moving the ship to the destination", () => {
|
||||||
|
expect(resolve(arrived).shipLocationId).toBe("moon");
|
||||||
|
expect(resolveDispatch({ ...arrived, now: record.arrivalAt + 60_000 }, content).status).toBe(
|
||||||
|
"resolved",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deducts pilot pay for a hired pilot and grants XP", () => {
|
||||||
|
const resolution = resolve(arrived);
|
||||||
|
expect(resolution.credits).toBe(2000 - content.config.pilotPayPerDelivery);
|
||||||
|
expect(resolution.pilotXp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deducts no pilot pay when the avatar flies", () => {
|
||||||
|
expect(resolve({ ...arrived, pilotIsAvatar: true }).credits).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pays exactly the stored tutorial share on a tutorial run", () => {
|
||||||
|
const tutorialRecord = createDispatchRecord(
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
ship: { ...ship, id: "loaner" },
|
||||||
|
pilot: { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null },
|
||||||
|
contract: { ...contract, payout: 1000 },
|
||||||
|
tutorial: true,
|
||||||
|
now,
|
||||||
|
worldSeed: 7,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
const resolution = resolve({ ...arrived, record: tutorialRecord, pilotIsAvatar: true });
|
||||||
|
expect(resolution.credits).toBe(1000);
|
||||||
|
expect(resolution.shipLocationId).toBe("moon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an undiscovered destination as discovered", () => {
|
||||||
|
expect(resolve(arrived).discoversDestination).toBe(false);
|
||||||
|
expect(resolve({ ...arrived, discoveredIds: new Set(["earth"]) }).discoversDestination).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives no credits or XP for a reposition", () => {
|
||||||
|
const reposition = createRepositionRecord(
|
||||||
|
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({
|
||||||
|
credits: 0,
|
||||||
|
pilotXp: 0,
|
||||||
|
shipLocationId: "moon",
|
||||||
|
discoversDestination: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns identical results when resolving the same dispatch twice", () => {
|
||||||
|
expect(resolveDispatch(arrived, content)).toEqual(resolveDispatch(arrived, content));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue