Step 20.2: sim: eligibility and job choice
CI / check (push) Successful in 6m27s
Details
CI / check (push) Successful in 6m27s
Details
Pure functions for Independent pilots: the jobs they may take, profit per hour, fuel bought only for the leg above the spending floor, and the chosen action (decisions 0.5.120-0.5.124). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
83cb2ae306
commit
382bcc74f4
|
|
@ -1674,3 +1674,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
|
||||
- **The settings live in one `independentPilots` group** in GameConfig.
|
||||
- **The Dispatch Office costs exactly a Lighter (36,000 cr)**, the CR's "start around the price of a Lighter", until Step 20.8's balance pass says otherwise.
|
||||
|
||||
## Step 20.2
|
||||
|
||||
- **"Stay above the floor" is `credits − fuel cost ≥ floor`**: a purchase may leave exactly the floor.
|
||||
- **A pilot buys exactly the leg's shortfall**, rounded up to 0.001 fu so the tank never ends a hair short. Where the origin sells no fuel, only jobs flyable on what's aboard count.
|
||||
- **Minimum profit compares the job's profit** (payout − fuel cost − pilot share), the same figure profit per hour divides.
|
||||
- **Pilots run every `validateDispatch` check** on the tank as it would be after buying, so they obey the no-strand rule, skills, Priority windows and expiry exactly as the player must.
|
||||
- **Contract ids compare as strings** for the last tie-break; board ids are `origin:period:n`, so on one board that is slot order.
|
||||
- **Deadheading home uses the same fuel rules**, then `validateReposition`.
|
||||
|
|
|
|||
|
|
@ -463,7 +463,7 @@ Spec: `memory-bank/change-requests/CR-020-bridge-dashboard.md`. New decisions re
|
|||
Spec: `memory-bank/change-requests/CR-020-independent-pilots.md` (received as "CR-019 … Phase 19"). New decisions recorded as plan §0.5.118–0.5.126.
|
||||
|
||||
- [x] **Step 20.1 — Content and config.** `config.independentPilots`: `dispatchOfficePrice` 36,000 (a Lighter), `firstLookRealMinutes` 3, `turnaroundGameMinutes` 15 (15 real seconds at ×60), `emptyPeriodsBeforeHome` 3, `maxCatchUpRealDays` 7 and `defaultSpendingFloor` 500. All must be positive, and the first-look window must be shorter than the board period. Two stale quarter-tank comments from CR-015 are gone. Tests (`packages/content/test/config.test.ts`): the defaults, with the Dispatch Office at the Lighter's price; zero and negative values rejected; a first-look window as long as the board period rejected.
|
||||
- [ ] Step 20.2 — Sim: eligibility and job choice
|
||||
- [x] **Step 20.2 — Sim: eligibility and job choice.** `packages/sim/src/independent.ts`: `PilotOrders` and `defaultOrders(home)`; `pastFirstLook(now)`; `legRefuel` (exactly the fuel a leg needs beyond what's aboard, rounded up to 0.001 fu, only where fuel is sold and only if credits stay at or above the spending floor); `independentOptions` (past the first-look window, not first contacts or the job the player has open, within the orders, passing `validateDispatch` with the leg's fuel bought, making at least the minimum profit). Fuel aboard is priced at the tank's average and bought fuel at the local price, less the pilot's share, then divided by trip game hours. `compareOptions`: highest profit per hour, then the shorter trip, then the lower id. `chooseAction` returns take, deadhead home (after `emptyPeriodsBeforeHome` empty periods away, when the fuel rules allow), or wait. Tests (`packages/sim/test/independent.test.ts`): first contacts never taken; nothing in the first-look window, the same job after; the open job skipped; each order (destination, class, Priority, minimum profit at the boundary); profit per hour beats total profit (Moon over Mars); fuel and share arithmetic; tie-breaks; a pilot short of credits above the floor takes the job flyable on fuel aboard; waiting, heading home after the third empty period, and not when home is out of reach.
|
||||
- [ ] Step 20.3 — Engine: Dispatch Office, modes, orders
|
||||
- [ ] Step 20.4 — Engine: event-queue catch-up
|
||||
- [ ] Step 20.5 — Engine: live play
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
import type { SimContent } from "./board";
|
||||
import { boardPeriodIndex, boardPeriodMs } from "./board";
|
||||
import { MS_PER_MINUTE, type RealTime } from "./clock";
|
||||
import type { Contract } from "./contract";
|
||||
import {
|
||||
contractTripGameHours,
|
||||
findLocation,
|
||||
shipStats,
|
||||
tripFuel,
|
||||
validateDispatch,
|
||||
validateReposition,
|
||||
type PilotState,
|
||||
type ShipState,
|
||||
} from "./dispatch";
|
||||
import { fuelPriceAt, refuel, type Tank } from "./fuel";
|
||||
import { pilotShare } from "./pay";
|
||||
|
||||
/** What the player tells an Independent pilot (decision 0.5.120). */
|
||||
export interface PilotOrders {
|
||||
/** Destinations allowed; null means every discovered location. */
|
||||
destinationIds: readonly string[] | null;
|
||||
/** Cargo classes allowed (the pilot's skills limit them anyway). */
|
||||
cargoClassIds: readonly string[];
|
||||
takePriority: boolean;
|
||||
/** Credits: the least profit (payout − fuel − pilot share) a job must make. */
|
||||
minProfit: number;
|
||||
/** Where the pilot heads after empty boards away (decision 0.5.124). */
|
||||
homeLocationId: string;
|
||||
}
|
||||
|
||||
/** The orders a pilot starts with (CR-020 §2): anything goes, home where hired. */
|
||||
export function defaultOrders(homeLocationId: string, content: SimContent): PilotOrders {
|
||||
return {
|
||||
destinationIds: null,
|
||||
cargoClassIds: content.cargoTypes.map((c) => c.id),
|
||||
takePriority: true,
|
||||
minProfit: 0,
|
||||
homeLocationId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface IndependentInput {
|
||||
/** The pilot's ship, idle at the board's location, with its tank. */
|
||||
ship: ShipState & { tank: Tank };
|
||||
pilot: PilotState;
|
||||
orders: PilotOrders;
|
||||
/** The board where the ship is: completable for the fleet, minus jobs already taken. */
|
||||
board: readonly Contract[];
|
||||
now: RealTime;
|
||||
credits: number;
|
||||
/** Credits pilots never spend the company below (decision 0.5.123). */
|
||||
spendingFloor: number;
|
||||
discoveredIds: ReadonlySet<string>;
|
||||
/** Board periods in a row this pilot has found nothing to take (decision 0.5.124). */
|
||||
emptyPeriods: number;
|
||||
/** The contract the player has open, which pilots leave alone (decision 0.5.122). */
|
||||
viewedContractId?: string | null;
|
||||
}
|
||||
|
||||
/** Fuel to buy before a leg: what it burns beyond what's aboard, rounded up to 0.001 fu. */
|
||||
function fuelToBuy(burnFu: number, tank: Tank): number {
|
||||
const short = burnFu - tank.fuelFu;
|
||||
return short > 0 ? Math.ceil(short * 1000) / 1000 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fuel a pilot may buy for a leg (decision 0.5.123): none if the tank holds
|
||||
* enough; otherwise exactly what the leg needs, if the location sells it and
|
||||
* credits stay at or above the spending floor. Null when they can't.
|
||||
*/
|
||||
export function legRefuel(
|
||||
input: Pick<IndependentInput, "ship" | "credits" | "spendingFloor">,
|
||||
burnFu: number,
|
||||
content: SimContent,
|
||||
): { buyFu: number; cost: number; tank: Tank } | null {
|
||||
const { ship } = input;
|
||||
const buyFu = fuelToBuy(burnFu, ship.tank);
|
||||
if (buyFu === 0) return { buyFu: 0, cost: 0, tank: ship.tank };
|
||||
const location = findLocation(ship.locationId, content);
|
||||
if (!location.sellsFuel) return null;
|
||||
const capacity = shipStats(ship, content).tankCapacityFu;
|
||||
if (ship.tank.fuelFu + buyFu > capacity + 1e-9) return null;
|
||||
const bought = refuel(ship.tank, capacity, buyFu, fuelPriceAt(location, content.config));
|
||||
if (input.credits - bought.cost < input.spendingFloor) return null;
|
||||
return { buyFu, cost: bought.cost, tank: bought.tank };
|
||||
}
|
||||
|
||||
/** A job an Independent pilot could take, with its fuel and profit (decision 0.5.121). */
|
||||
export interface JobOption {
|
||||
contract: Contract;
|
||||
/** Fuel bought first (0 if the tank holds enough). */
|
||||
buyFu: number;
|
||||
/** Fuel aboard at its average price, plus fuel bought at the local price. */
|
||||
fuelCost: number;
|
||||
pilotShare: number;
|
||||
profit: number;
|
||||
tripGameHours: number;
|
||||
profitPerHour: number;
|
||||
}
|
||||
|
||||
/** Whether the first-look window of the current board period is over (decision 0.5.122). */
|
||||
export function pastFirstLook(now: RealTime, content: SimContent): boolean {
|
||||
const periodStart = boardPeriodIndex(now, content.config) * boardPeriodMs(content.config);
|
||||
return now - periodStart >= content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The jobs on the board this pilot may take now (decisions 0.5.120–0.5.123):
|
||||
* past the first-look window, not first contacts, not the one the player has
|
||||
* open, within the orders, flyable by this pair once the leg's fuel is bought
|
||||
* (every dispatch check), and making at least the minimum profit.
|
||||
*/
|
||||
export function independentOptions(input: IndependentInput, content: SimContent): JobOption[] {
|
||||
const { ship, pilot, orders, now } = input;
|
||||
if (!pastFirstLook(now, content)) return [];
|
||||
const model = shipStats(ship, content);
|
||||
const origin = findLocation(ship.locationId, content);
|
||||
const options: JobOption[] = [];
|
||||
for (const contract of input.board) {
|
||||
if (contract.firstContact || contract.id === input.viewedContractId) continue;
|
||||
if (orders.destinationIds && !orders.destinationIds.includes(contract.destinationId)) continue;
|
||||
if (!orders.cargoClassIds.includes(contract.cargoTypeId)) continue;
|
||||
if (contract.priority && !orders.takePriority) continue;
|
||||
|
||||
const destination = findLocation(contract.destinationId, content);
|
||||
const burnFu = tripFuel(model, origin, destination, content.config);
|
||||
const fuel = legRefuel(input, burnFu, content);
|
||||
if (!fuel) continue;
|
||||
const check = validateDispatch(
|
||||
{
|
||||
ship: { ...ship, tank: fuel.tank },
|
||||
pilot,
|
||||
contract,
|
||||
tutorial: false,
|
||||
now,
|
||||
discoveredIds: input.discoveredIds,
|
||||
},
|
||||
content,
|
||||
);
|
||||
if (!check.ok) continue;
|
||||
|
||||
const fromTank = Math.min(burnFu, ship.tank.fuelFu);
|
||||
const fuelCost = Math.round(fromTank * ship.tank.avgPrice + fuel.cost);
|
||||
const share = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuelCost, content.config);
|
||||
const profit = contract.payout - fuelCost - share;
|
||||
if (profit < orders.minProfit) continue;
|
||||
const tripGameHours = contractTripGameHours(model, contract, content);
|
||||
options.push({
|
||||
contract,
|
||||
buyFu: fuel.buyFu,
|
||||
fuelCost,
|
||||
pilotShare: share,
|
||||
profit,
|
||||
tripGameHours,
|
||||
profitPerHour: profit / tripGameHours,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Best first: highest profit per hour, then the shorter trip, then the lower contract id. */
|
||||
export function compareOptions(a: JobOption, b: JobOption): number {
|
||||
if (a.profitPerHour !== b.profitPerHour) return b.profitPerHour - a.profitPerHour;
|
||||
if (a.tripGameHours !== b.tripGameHours) return a.tripGameHours - b.tripGameHours;
|
||||
return a.contract.id < b.contract.id ? -1 : a.contract.id > b.contract.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/** What an Independent pilot does when they can act (CR-020 §2). */
|
||||
export type PilotAction =
|
||||
| { kind: "take"; contractId: string; buyFu: number }
|
||||
| { kind: "deadhead-home"; destinationId: string; buyFu: number }
|
||||
| { kind: "wait" };
|
||||
|
||||
/**
|
||||
* An Independent pilot's decision (decisions 0.5.121, 0.5.124): the best job
|
||||
* on the board; with none, after enough empty periods away from home, a
|
||||
* deadhead home if the ship can make it under the fuel rules; else wait.
|
||||
*/
|
||||
export function chooseAction(input: IndependentInput, content: SimContent): PilotAction {
|
||||
const [best] = independentOptions(input, content).sort(compareOptions);
|
||||
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };
|
||||
|
||||
const { ship, orders } = input;
|
||||
const home = orders.homeLocationId;
|
||||
if (
|
||||
ship.locationId !== home &&
|
||||
input.emptyPeriods >= content.config.independentPilots.emptyPeriodsBeforeHome
|
||||
) {
|
||||
const model = shipStats(ship, content);
|
||||
const burnFu = tripFuel(
|
||||
model,
|
||||
findLocation(ship.locationId, content),
|
||||
findLocation(home, content),
|
||||
content.config,
|
||||
);
|
||||
const fuel = legRefuel(input, burnFu, content);
|
||||
if (
|
||||
fuel &&
|
||||
validateReposition(
|
||||
{
|
||||
ship: { ...ship, tank: fuel.tank },
|
||||
pilot: input.pilot,
|
||||
destinationId: home,
|
||||
discoveredIds: input.discoveredIds,
|
||||
now: input.now,
|
||||
},
|
||||
content,
|
||||
).ok
|
||||
) {
|
||||
return { kind: "deadhead-home", destinationId: home, buyFu: fuel.buyFu };
|
||||
}
|
||||
}
|
||||
return { kind: "wait" };
|
||||
}
|
||||
|
|
@ -124,3 +124,13 @@ export type {
|
|||
} from "./upgrades";
|
||||
|
||||
export { pilotShare } from "./pay";
|
||||
|
||||
export {
|
||||
chooseAction,
|
||||
compareOptions,
|
||||
defaultOrders,
|
||||
independentOptions,
|
||||
legRefuel,
|
||||
pastFirstLook,
|
||||
} from "./independent";
|
||||
export type { IndependentInput, JobOption, PilotAction, PilotOrders } from "./independent";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardPeriodMs,
|
||||
chooseAction,
|
||||
compareOptions,
|
||||
defaultOrders,
|
||||
fuelPriceAt,
|
||||
independentOptions,
|
||||
MS_PER_MINUTE,
|
||||
tripFuel,
|
||||
type Contract,
|
||||
type IndependentInput,
|
||||
type JobOption,
|
||||
} from "../src/index";
|
||||
|
||||
const { config } = content;
|
||||
const loc = (id: string) => content.location(id)!;
|
||||
const clipper = content.shipModel("clipper")!;
|
||||
const PERIOD = boardPeriodMs(config);
|
||||
const PERIOD_START = 1000 * PERIOD;
|
||||
const AFTER_LOOK =
|
||||
PERIOD_START + (config.independentPilots.firstLookRealMinutes + 1) * MS_PER_MINUTE;
|
||||
const EARTH_PRICE = fuelPriceAt(loc("earth"), config);
|
||||
|
||||
const job = (id: string, over: Partial<Contract> = {}): Contract => ({
|
||||
id,
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
goodId: "consumer-goods",
|
||||
shipper: "Pacific Orbital Logistics",
|
||||
priority: false,
|
||||
deliveryWindowGameHours: null,
|
||||
tier: 0,
|
||||
massTonnes: 10,
|
||||
payout: 1_000,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: PERIOD_START,
|
||||
expiresAt: PERIOD_START + PERIOD,
|
||||
firstContact: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function input(board: Contract[], over: Partial<IndependentInput> = {}): IndependentInput {
|
||||
return {
|
||||
ship: {
|
||||
id: "s",
|
||||
modelId: "clipper",
|
||||
locationId: "earth",
|
||||
inFlight: false,
|
||||
tank: { fuelFu: clipper.tankCapacityFu, avgPrice: EARTH_PRICE },
|
||||
},
|
||||
pilot: {
|
||||
id: "p",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s",
|
||||
inFlight: false,
|
||||
skills: { "long-haul": 1 },
|
||||
restEndsAt: null,
|
||||
},
|
||||
orders: defaultOrders("earth", content),
|
||||
board,
|
||||
now: AFTER_LOOK,
|
||||
credits: 100_000,
|
||||
spendingFloor: config.independentPilots.defaultSpendingFloor,
|
||||
discoveredIds: new Set(["earth", "moon", "mars"]),
|
||||
emptyPeriods: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("independent pilots' job choice (Step 20.2, decisions 0.5.121–0.5.124)", () => {
|
||||
it("never takes a first-contact job", () => {
|
||||
const discovery = job("a", { firstContact: true, payout: 50_000 });
|
||||
expect(chooseAction(input([discovery]), content)).toEqual({ kind: "wait" });
|
||||
expect(chooseAction(input([discovery, job("b")]), content)).toMatchObject({
|
||||
kind: "take",
|
||||
contractId: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the board alone in the first-look window, then takes the job", () => {
|
||||
const early =
|
||||
PERIOD_START + (config.independentPilots.firstLookRealMinutes - 1) * MS_PER_MINUTE;
|
||||
expect(chooseAction(input([job("a")], { now: early }), content)).toEqual({ kind: "wait" });
|
||||
expect(chooseAction(input([job("a")]), content)).toMatchObject({
|
||||
kind: "take",
|
||||
contractId: "a",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the job the player has open", () => {
|
||||
const board = [job("a", { payout: 2_000 }), job("b")];
|
||||
expect(chooseAction(input(board, { viewedContractId: "a" }), content)).toMatchObject({
|
||||
contractId: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps to the orders: destinations, cargo classes, Priority and minimum profit", () => {
|
||||
const orders = defaultOrders("earth", content);
|
||||
const toMoon = [job("a")];
|
||||
expect(
|
||||
chooseAction(input(toMoon, { orders: { ...orders, destinationIds: ["mars"] } }), content),
|
||||
).toEqual({ kind: "wait" });
|
||||
expect(
|
||||
chooseAction(input(toMoon, { orders: { ...orders, cargoClassIds: ["heavy"] } }), content),
|
||||
).toEqual({ kind: "wait" });
|
||||
const rush = [job("r", { priority: true, deliveryWindowGameHours: 100 })];
|
||||
expect(chooseAction(input(rush), content)).toMatchObject({ contractId: "r" });
|
||||
expect(
|
||||
chooseAction(input(rush, { orders: { ...orders, takePriority: false } }), content),
|
||||
).toEqual({ kind: "wait" });
|
||||
const [option] = independentOptions(input(toMoon), content);
|
||||
expect(
|
||||
chooseAction(
|
||||
input(toMoon, { orders: { ...orders, minProfit: option!.profit + 1 } }),
|
||||
content,
|
||||
),
|
||||
).toEqual({ kind: "wait" });
|
||||
expect(
|
||||
chooseAction(input(toMoon, { orders: { ...orders, minProfit: option!.profit } }), content),
|
||||
).toMatchObject({ contractId: "a" });
|
||||
});
|
||||
|
||||
it("takes the job with the highest profit per hour, not the highest payout", () => {
|
||||
// Mars pays more in total, but the Moon run pays more per hour.
|
||||
const mars = job("mars", { destinationId: "mars", payout: 20_000, requiredLongHaulLevel: 1 });
|
||||
const moon = job("moon", { payout: 1_500 });
|
||||
const options = independentOptions(input([mars, moon]), content).sort(compareOptions);
|
||||
expect(options.map((o) => o.contract.id)).toEqual(["moon", "mars"]);
|
||||
expect(options[0]!.profitPerHour).toBeGreaterThan(options[1]!.profitPerHour);
|
||||
expect(options[1]!.profit).toBeGreaterThan(options[0]!.profit);
|
||||
expect(chooseAction(input([mars, moon]), content)).toMatchObject({ contractId: "moon" });
|
||||
});
|
||||
|
||||
it("prices fuel aboard at the tank's average, bought fuel at the local price, and pays the pilot's share", () => {
|
||||
const moonFu = tripFuel(clipper, loc("earth"), loc("moon"), config);
|
||||
const aboard = { fuelFu: moonFu / 2, avgPrice: 3 };
|
||||
const [option] = independentOptions(
|
||||
input([job("a")], { ship: { ...input([]).ship, tank: aboard } }),
|
||||
content,
|
||||
);
|
||||
expect(option!.buyFu).toBeCloseTo(moonFu / 2, 2);
|
||||
const bought = Math.min(
|
||||
Math.round(option!.buyFu * EARTH_PRICE),
|
||||
Math.ceil(option!.buyFu * EARTH_PRICE),
|
||||
);
|
||||
expect(option!.fuelCost).toBe(Math.round((moonFu / 2) * 3 + bought));
|
||||
expect(option!.pilotShare).toBe(
|
||||
Math.round((1_000 - option!.fuelCost) * config.pilotProfitShare),
|
||||
);
|
||||
expect(option!.profit).toBe(1_000 - option!.fuelCost - option!.pilotShare);
|
||||
});
|
||||
|
||||
it("breaks ties on the shorter trip, then the lower contract id", () => {
|
||||
const option = (id: string, profitPerHour: number, tripGameHours: number) =>
|
||||
({ contract: job(id), profitPerHour, tripGameHours }) as JobOption;
|
||||
expect(
|
||||
[option("b", 10, 5), option("a", 10, 6), option("c", 11, 9)]
|
||||
.sort(compareOptions)
|
||||
.map((o) => o.contract.id),
|
||||
).toEqual(["c", "b", "a"]);
|
||||
expect(
|
||||
[option("b", 10, 5), option("a", 10, 5)].sort(compareOptions).map((o) => o.contract.id),
|
||||
).toEqual(["a", "b"]);
|
||||
// Identical jobs on a real board: the lower id wins.
|
||||
expect(chooseAction(input([job("z"), job("y")]), content)).toMatchObject({ contractId: "y" });
|
||||
});
|
||||
|
||||
it("flies on the fuel aboard when buying more would go below the spending floor", () => {
|
||||
const moonFu = tripFuel(clipper, loc("earth"), loc("moon"), config);
|
||||
const marsFu = tripFuel(clipper, loc("earth"), loc("mars"), config);
|
||||
// Enough aboard for the Moon, not for Mars; Mars pays far more per hour.
|
||||
const tank = { fuelFu: moonFu * 2, avgPrice: EARTH_PRICE };
|
||||
const ship = { ...input([]).ship, tank };
|
||||
const mars = job("mars", { destinationId: "mars", payout: 500_000, requiredLongHaulLevel: 1 });
|
||||
const moon = job("moon");
|
||||
const marsCost = Math.ceil((marsFu - tank.fuelFu) * EARTH_PRICE);
|
||||
const floor = config.independentPilots.defaultSpendingFloor;
|
||||
expect(
|
||||
chooseAction(input([mars, moon], { ship, credits: floor + marsCost + 10 }), content),
|
||||
).toMatchObject({ kind: "take", contractId: "mars" });
|
||||
const broke = chooseAction(
|
||||
input([mars, moon], { ship, credits: floor + marsCost - 10 }),
|
||||
content,
|
||||
);
|
||||
expect(broke).toEqual({ kind: "take", contractId: "moon", buyFu: 0 });
|
||||
});
|
||||
|
||||
it("waits with nothing to take, and heads home after enough empty periods away", () => {
|
||||
const atMoon = { ...input([]).ship, locationId: "moon" };
|
||||
const away = (emptyPeriods: number) =>
|
||||
chooseAction(input([], { ship: atMoon, emptyPeriods }), content);
|
||||
const needed = config.independentPilots.emptyPeriodsBeforeHome;
|
||||
expect(away(needed - 1)).toEqual({ kind: "wait" });
|
||||
expect(away(needed)).toEqual({ kind: "deadhead-home", destinationId: "earth", buyFu: 0 });
|
||||
// At home there's nowhere to head: keep waiting.
|
||||
expect(chooseAction(input([], { emptyPeriods: needed }), content)).toEqual({ kind: "wait" });
|
||||
// Home out of reach (a Courier can't fly Mars → Earth): keep waiting.
|
||||
const courierAtMars = {
|
||||
...atMoon,
|
||||
modelId: "courier",
|
||||
locationId: "mars",
|
||||
tank: { fuelFu: content.shipModel("courier")!.tankCapacityFu, avgPrice: EARTH_PRICE },
|
||||
};
|
||||
expect(chooseAction(input([], { ship: courierAtMars, emptyPeriods: needed }), content)).toEqual(
|
||||
{ kind: "wait" },
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue