Step 27.7: balance and regression
CI / check (push) Successful in 8m21s
Details
CI / check (push) Successful in 8m21s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b354e93acf
commit
1dd30ff9a8
|
|
@ -2034,3 +2034,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Only automatic bookings are logged as "booked"**: the player's own bookings are their decisions, already on screen. Start, finish and waiting for funds are logged for both.
|
||||
- **"Service finished" sits beside the repair and upgrade lines** it covers, so the costs stay where they were (the repair line with the repair's cost, the start line with the whole charge).
|
||||
- **The problems are found from the state** like the others (decision 0.5.140): a booking waiting for funds, or an unstarted one whose HQ is out of reach, wherever the ship is.
|
||||
|
||||
## Step 27.7
|
||||
|
||||
- **Asleep, a service trip flies empty.** The CR lets service trips go on during fleet sleep, but taking a paid job to the HQ would earn while the fleet is quiet, breaking CR-024's week-away bound (it failed for one seed). Awake, the pilot still takes the best job that ends at the HQ.
|
||||
- **"Neglected" means a second trip in a row below 25% while the repair is affordable** above the spending floor: the first such trip is usually the one to the HQ.
|
||||
- **The away median drops from 60% to 49% of active play**: until now the simulator never repaired away hulls, so the old figure left out repair costs and shop time. Still inside 40–80%.
|
||||
- **Amendments go on each amended CR** as "Amended by CR-027" lines (CR-023 and CR-026 get an Amendments section).
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5.
|
|||
- **Test:** All upgrades validate. Every upgrade affects a stat that exists on ShipModel. Tests for decision 0.5.49 (all three inequalities). Tests for decision 0.5.50: sampling Earth–Mars distance monthly over two game years, a maxed Courier is in range on at least one sample and out of range on at least one sample; a Courier with fuel tanks at level 2 is never in range.
|
||||
- **Amended by CR-012:** Caps follow the amended 0.5.49/0.5.50 (this plan's 0.5.67/0.5.68): three more distinctness inequalities, Courier-to-Mars measured from Earth over 16 game years; the Courier's tanks are 450 % per level (Step 10L.5).
|
||||
- **Amended by CR-026:** Hull Plating is added (+1 hull rating a level, three levels, the usual costs and shop times); upgrades may add a flat amount (`flatPerLevel`) instead of a percent.
|
||||
- **Amended by CR-027:** Upgrades (one level a visit) and repairs can be booked from anywhere for an HQ; the work starts, and is charged, when the ship is next idle there.
|
||||
|
||||
### Step 10F.2 — Sim: effective stats
|
||||
- Add a pure function that returns a ship's effective stats from its model and installed upgrade levels.
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ Removes "automation / standing routes and auto-accept rules" from the deferred l
|
|||
- **Test:** Component tests: the chip counts down and disappears when the window ends; the indicator appears for slow catch-ups only; the logbook lists each pilot's jobs, deadheads, rests, XP, and current location, and totals match the sum of entries.
|
||||
- **Amended by CR-024:** The "while you were away" screen opens with a banner when the fleet went quiet ("Your fleet went quiet on [date, time] after [24 h] without orders.") and a Back to work button; sleeping Independent pilots read "Waiting for orders".
|
||||
- **Amended by CR-025:** The per-pilot logbook moves from the pop-up to the Logbook screen, built from logbook entries and covering every pilot (Step 25.4).
|
||||
- **Amended by CR-027:** A booking steers an Independent pilot's decisions: only jobs ending at the booked HQ, else a deadhead there, then waiting there for the work; out of reach, the usual choice.
|
||||
|
||||
### Step 19.8 — Balance
|
||||
- Extend the Phase 12 balance script with an "away" scenario: the player sets all hired pilots to Independent and closes the app for 8 real hours.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ Record in `DECISIONS.md` and `implementation-plan.md` §0.5 with the next free n
|
|||
- Run the full suite.
|
||||
- **Test:** CI is green. Manual check: fly a pilot close to the duty limit, start an upgrade at the HQ, and confirm they're ready to fly the moment the upgrade finishes.
|
||||
|
||||
## Amendments
|
||||
|
||||
- **Amended by CR-027:** Booked service overlaps rest the same way: a rest due on arrival or already running ends with the service, so the pilot is free at the later end.
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
Rest facilities at HQs, shortening shop times, and anything else in `implementation-plan.md` §0.3.
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the nex
|
|||
|
||||
### Step 22.5 — Client: return banner, status, and setting
|
||||
- **Amended by CR-025:** The sleep banner stays at the top of the highlights pop-up, under its title.
|
||||
- **Amended by CR-027:** Service trips go on while the fleet is asleep (flying empty, so the fleet earns nothing), and `bookService`, `cancelBooking` and `setAutoRepair` are manual decisions.
|
||||
|
||||
- The "while you were away" screen shows the sleep banner and "Back to work" button when the fleet went quiet. HQ shows "Fleet asleep — waiting for your orders" with the same button while asleep.
|
||||
- Pilot status lines show "Waiting for orders" for sleeping Independent pilots.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the nex
|
|||
|
||||
### Step 23.2 — Sim: summary builder
|
||||
- **Amended by CR-026:** Events are logbook entries; good ones and any that hurt a hull are highlights just below level-ups, and a short absence lists its trips' events (not counted towards the 3).
|
||||
- **Amended by CR-027:** Service is logged (automatic bookings, starts, finishes, waiting for funds), and booked service waiting for funds or out of reach is a highlight.
|
||||
|
||||
- A pure function that turns the entries for an absence into: duration, totals, up to 3 highlights in priority order with an overflow count, up to 5 pilot lines sorted by net profit with an overflow line, the idle-pilots line, and the "short absence" direct list when there are 3 events or fewer.
|
||||
- **Test:** With 50 pilots' worth of entries, the result has exactly 5 pilot lines and an overflow line whose totals equal the rest. Highlights follow the priority order and cap at 3 with the right overflow count. Totals equal the sum of entries. With 2 events, the result is the direct list. Net matches the delivery-summary calculation.
|
||||
|
|
|
|||
|
|
@ -205,6 +205,10 @@ Removes "random events" from the deferred list (`implementation-plan.md` §0.3).
|
|||
| CR-023 | Event results become highlights. |
|
||||
| `game-design-document.md` §13 | Updated by the designer. |
|
||||
|
||||
## Amendments
|
||||
|
||||
- **Amended by CR-027:** Hull damage and wear that take an Independent crew's hull below the auto-repair threshold (40% by default) book a repair at the nearest reachable HQ; Manual crews see "Repair recommended".
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
Losing ships or pilots, insurance, piracy, reputation effects, multi-step event chains, events at docked locations (depot shortages, price spikes), pilot traits that change defaults, and anything else in `implementation-plan.md` §0.3.
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
155. **Manual crews** (CR-027). A booking on a Manual pilot's ship is a reminder (tags on the ship and in the dispatch sheet); the work starts when the ship is next idle at that HQ.
|
||||
156. **Payment** (CR-027). Player bookings are charged whenever credits cover them; automatic ones only if credits stay at or above the NPC spending floor; a ship that can't pay waits at the HQ ("Waiting for funds"), logged and highlighted, and starts as soon as it can.
|
||||
157. **Auto-repair** (CR-027). Per ship, on at 40% by default (10–90% in steps of 5): with an Independent crew, a hull dropping below it adds a repair booking at the nearest reachable HQ (or to the ship's booking); with a Manual crew it shows "Repair recommended" instead.
|
||||
158. **Sleep exception** (CR-027). Service trips continue while the fleet is asleep; afterwards the pilot waits for orders.
|
||||
158. **Sleep exception** (CR-027). Service trips continue while the fleet is asleep; afterwards the pilot waits for orders. Asleep, the trip flies empty (Step 27.7): a paid job to the HQ would earn while the fleet is quiet (decision 0.5.137).
|
||||
159. **No double waiting** (CR-027). Rest and service run at the same time when they coincide; the pilot is free when the later ends, and a service long enough to count as rest (decision 0.5.135) resets duty and ends any remaining rest.
|
||||
|
||||
### 0.6 Translating change requests written for the server (CR-014 §7)
|
||||
|
|
|
|||
|
|
@ -575,4 +575,4 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-
|
|||
- [x] **Step 27.4 — Engine: Independent pilots and catch-up.** An Independent pilot's decision passes the ship's unstarted booking to `chooseAction`, so they take only jobs ending at the booked HQ, else deadhead there, and wait there for the work; out of reach, they work as usual. A pilot on a service trip (booked, reachable, not there yet) keeps deciding while the fleet is asleep, up to the 7-day cap; once the work starts they're back under the sleep rule. Tests (`packages/engine/test/service-routing.test.ts`): the job to the HQ over a richer one, with the work on landing; the deadhead; the service trip while asleep, and nothing after; an HQ out of reach; the same save caught up at once or an hour at a time.
|
||||
- [x] **Step 27.5 — Client: Service section and tags.** `apps/web/src/components/Service.tsx`: the Service section in the ship's Upgrades sheet (the booking's items, HQ, estimate and status; Book service with a repair box, an upgrade picker (the next level of each) and an HQ picker defaulting to the nearest reachable (`defaultHq`, the sim's `nearestReachableHq`), with the estimate; Change and Cancel booking until the work starts; the auto-repair box and a 10–90% slider in steps of 5). `serviceTag` on the Fleet card and in the dispatch sheet ("Service booked: Engine Tuning 2 at Earth", "Repair recommended"); `serviceStatus` ("Heading to Earth for service", "In the shop at Earth (resting)", "Waiting for funds: Repair at Earth", "Can't reach Earth for service") leads an Independent pilot's status line. Tests (`apps/web/test/service.test.tsx`): booking with the estimate and the nearest HQ; a booking's items and status, Change and Cancel calling the engine; no change once started; the slider and the box; the Fleet tag; each status wording; the pilot line; the dispatch sheet tag. Screenshots: `docs/screenshots/27-5-*` (Repair recommended, the booking form, a booked ship's card and sheet; phone and desktop).
|
||||
- [x] **Step 27.6 — Client: Logbook and summaries.** Logbook lines for `service` entries: "Iron Mule: Repair booked automatically at Earth", "service started at Earth (Repair + Engine Tuning), 3,600 cr", "service finished at Earth (…)", "waiting for funds for Repair at Earth". New away problems from the state (`awayProblems`, schema and sim `AwayProblem`): `service-waiting-funds` and `service-cant-reach`, with wording in `awayWording.highlights` ("Iron Mule is waiting for funds: Repair at Earth", "Iron Mule can't reach Jupiter Cloud Platform for its booked service"). Tests: the engine reports each problem (`packages/engine/test/service.test.ts`); the four logbook lines and both highlights render (`apps/web/test/service.test.tsx`). Screenshots: `docs/screenshots/27-6-logbook-*`.
|
||||
- [ ] Step 27.7 — Balance and regression
|
||||
- [x] **Step 27.7 — Balance and regression.** The balance simulator's away scenario now runs auto-repair at 40% with events on: hull loss that crosses the threshold books a repair at the nearest reachable HQ (`bookAutoRepair`), the pilot's decisions pass the booking to `chooseAction`, a ship idle at its HQ starts the repair when credits stay above the spending floor (`startServices`), and service trips go on while asleep. Results gain `service` (bookings, services, the most trips in a row an Independent ship flew below 25% with its repair affordable); a second such trip is a `hull-neglected` violation. Measured over the 7 balance seeds, 8 hours away (`balance/measure-service.ts`): 3–4 bookings and as many repairs a run, at most 1 trip below 25%, no violations; away earns a median 49% of active play (target 40–80%; 60% before, when away hulls were never repaired). Asleep, a service trip flies empty, so the week-away bound (CR-024) still holds. Amendments (CR-027 §8) added to CR-009, CR-021, CR-023, CR-024, CR-025 and CR-026. Test: `packages/sim/test/balance.test.ts` (no ship more than one trip below 25%, bookings made and done, the away ratio and the week bound), the engine sleep test (an empty flight to the HQ with a paid job home on the board); full suite green.
|
||||
|
|
|
|||
|
|
@ -146,7 +146,9 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri
|
|||
}
|
||||
|
||||
const random = replayRandom(pilot.id, t);
|
||||
const board = locationBoard(save, ship.locationId, random, t).contracts;
|
||||
// Asleep, only a service trip goes on, and it flies empty: no pay while the fleet is quiet (CR-024).
|
||||
const asleep = t >= sleepsAt(save, t);
|
||||
const board = asleep ? [] : locationBoard(save, ship.locationId, random, t).contracts;
|
||||
const action = chooseAction(
|
||||
{
|
||||
ship: { ...shipState(ship), tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice } },
|
||||
|
|
|
|||
|
|
@ -92,9 +92,11 @@ describe("Independent pilots and booked service (Step 27.4, decision 0.5.154)",
|
|||
expect(first.record.destinationId).toBe("earth");
|
||||
});
|
||||
|
||||
it("still flies to booked service while the fleet is asleep, then waits", async () => {
|
||||
it("still flies to booked service while the fleet is asleep, empty, then waits", async () => {
|
||||
const test = await bookedPilot();
|
||||
await putBoard(test.app, [], test.clock.now, "moon");
|
||||
// A paid job home is on the board, but asleep the fleet earns nothing (CR-024).
|
||||
const home = testContract({ id: "moon:home", originId: "moon", destinationId: "earth" });
|
||||
await putBoard(test.app, [home], test.clock.now, "moon");
|
||||
await edit(test.app, (save) => {
|
||||
// An automatic booking (not a manual decision), and the fleet has gone quiet.
|
||||
shipPa(save).booking = {
|
||||
|
|
@ -113,6 +115,7 @@ describe("Independent pilots and booked service (Step 27.4, decision 0.5.154)",
|
|||
await test.app.call("getPlayerState", {});
|
||||
const save = await readSave(test.app);
|
||||
expect(tripsOf(save).map((d) => d.record.destinationId)).toEqual(["earth"]);
|
||||
expect(tripsOf(save)[0]!.record.contract).toBeNull();
|
||||
expect(serviceLog(save)).toEqual(["started", "finished"]);
|
||||
expect(shipPa(save).locationId).toBe("earth");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { simulate } from "./simulate";
|
||||
|
||||
// Away service balance by hand (Step 27.7): `tsx balance/measure-service.ts <hours away>`.
|
||||
// Independent fleets with events on and auto-repair at the default threshold.
|
||||
const SEEDS = [12345, 777, 4242, 99, 2026, 31337, 8];
|
||||
const HOURS = Number(process.argv[2] ?? 8);
|
||||
const window = (worldSeed: number, mode: "active" | "away") =>
|
||||
simulate({
|
||||
worldSeed,
|
||||
policy: "lighter-first",
|
||||
durationMs: (3 + HOURS) * 3_600_000 + 3_600_000,
|
||||
scenario: { atMinute: 180, minutes: HOURS * 60, mode },
|
||||
});
|
||||
for (const seed of SEEDS) {
|
||||
const away = window(seed, "away");
|
||||
const active = window(seed, "active");
|
||||
const ratio = away.scenarioEarnings! / active.scenarioEarnings!;
|
||||
console.log(
|
||||
`seed ${seed}: away ${away.scenarioEarnings} / active ${active.scenarioEarnings} = ${(ratio * 100).toFixed(0)}%, ` +
|
||||
`bookings ${away.service.bookings}, services ${away.service.started}, ` +
|
||||
`most low-hull trips ${away.service.maxLowHullTrips}, repairs ${away.events.windowRepairSpend} cr, ` +
|
||||
`violations ${away.violations.map((v) => `${v.rule} ${v.detail}`).join("; ") || "none"}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -49,6 +49,11 @@ console.log(
|
|||
`ratio ${((away.scenarioEarnings! / active.scenarioEarnings!) * 100).toFixed(0)}% (target 40–80%), ` +
|
||||
`violations ${away.violations.length}`,
|
||||
);
|
||||
console.log(
|
||||
`away service (CR-027): ${away.service.bookings} auto-repair bookings, ${away.service.started} done, ` +
|
||||
`most trips in a row below 25% hull ${away.service.maxLowHullTrips} (≤ 1), ` +
|
||||
`repairs ${away.events.windowRepairSpend} cr`,
|
||||
);
|
||||
|
||||
// A week away (CR-024 Step 24.7): the fleet goes quiet 24 hours into the absence.
|
||||
const absence = (hours: number) =>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { content as defaultContent, type ShipModel } from "@deadhead/content";
|
|||
import {
|
||||
affordableRefuel,
|
||||
applyEventOption,
|
||||
autoRepairBooks,
|
||||
canReachHq,
|
||||
hullAfterDamage,
|
||||
nearestReachableHq,
|
||||
repairCost,
|
||||
repairShopMs,
|
||||
rollTripEvent,
|
||||
|
|
@ -176,6 +179,12 @@ export interface SimulationResult {
|
|||
windowDefaultValue: number;
|
||||
windowBestValue: number;
|
||||
};
|
||||
/**
|
||||
* Away service (CR-027 Step 27.7): automatic repair bookings and services
|
||||
* started, and the most trips in a row an Independent ship flew below
|
||||
* `needsRepairBelowPct` while the credits covered its repair.
|
||||
*/
|
||||
service: { bookings: number; started: number; maxLowHullTrips: number };
|
||||
}
|
||||
|
||||
interface SimShip extends ShipState {
|
||||
|
|
@ -187,6 +196,10 @@ interface SimShip extends ShipState {
|
|||
trips: number;
|
||||
/** Trips since its last event (CR-026 cooldown); absent: past the cooldown. */
|
||||
quietTrips?: number;
|
||||
/** A repair booked by auto-repair (CR-027), at this HQ. */
|
||||
booking?: { hqId: string } | null;
|
||||
/** Trips in a row flown below `needsRepairBelowPct` with the repair affordable. */
|
||||
lowHullTrips?: number;
|
||||
}
|
||||
|
||||
interface SimPilot {
|
||||
|
|
@ -446,6 +459,9 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
windowBestValue: 0,
|
||||
};
|
||||
const inScenario = () => windowOpen;
|
||||
const serviceStats = { bookings: 0, started: 0, maxLowHullTrips: 0 };
|
||||
// Every ship on auto-repair at the default threshold (decision 0.5.157).
|
||||
const autoRepair = { on: true, thresholdPct: config.autoRepair.defaultThresholdPct };
|
||||
|
||||
/** What an option is worth to the player: pay, less losses, hull repairs and time. */
|
||||
function optionValue(record: DispatchRecord, applied: AppliedEvent, ship: SimShip): number {
|
||||
|
|
@ -462,6 +478,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
function launch(ship: SimShip, pilot: SimPilot, record: DispatchRecord) {
|
||||
let flown = record;
|
||||
let applied: AppliedEvent | null = null;
|
||||
if (away && !pilot.isAvatar) countLowHullTrip(ship);
|
||||
eventStats.trips++;
|
||||
if (inScenario()) eventStats.windowTrips++;
|
||||
if (eventMode !== "off" && now >= eventsFrom) {
|
||||
|
|
@ -514,7 +531,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
eventStats.windowBestValue += optionValue(record, bestChoice, ship);
|
||||
}
|
||||
applied = eventMode === "best" ? bestChoice : defaultChoice;
|
||||
loseHull(ship, applied.hullLoss);
|
||||
loseHull(ship, applied.hullLoss, record.destinationId);
|
||||
const extra = Math.min(applied.extraFuelFu, ship.tank.fuelFu);
|
||||
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - extra };
|
||||
flown = {
|
||||
|
|
@ -530,10 +547,14 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
flights.push({ record: flown, shipId: ship.id, pilotId: pilot.id, event: applied });
|
||||
}
|
||||
|
||||
/** A hull hit or wear; in the scenario window, counted at what repairing it costs. */
|
||||
function loseHull(ship: SimShip, points: number) {
|
||||
/**
|
||||
* A hull hit or wear; in the scenario window, counted at what repairing it
|
||||
* costs. Away, an Independent crew's ship books its repair (CR-027).
|
||||
*/
|
||||
function loseHull(ship: SimShip, points: number, place: string) {
|
||||
const before = ship.hullConditionPct ?? 100;
|
||||
ship.hullConditionPct = hullAfterDamage(before, points, config);
|
||||
if (away) bookAutoRepair(ship, before, place);
|
||||
if (inScenario()) {
|
||||
const lost = before - ship.hullConditionPct;
|
||||
eventStats.windowHullCost +=
|
||||
|
|
@ -541,6 +562,64 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
}
|
||||
}
|
||||
|
||||
/** Auto-repair (decision 0.5.157): at the nearest HQ reachable from where the ship lands. */
|
||||
function bookAutoRepair(ship: SimShip, hullBefore: number, place: string) {
|
||||
const pilot = pilots.find((p) => p.assignedShipId === ship.id);
|
||||
if (!pilot || pilot.isAvatar) return;
|
||||
const books = autoRepairBooks({
|
||||
hullBefore,
|
||||
hullAfter: ship.hullConditionPct ?? 100,
|
||||
autoRepair,
|
||||
pilotMode: "independent",
|
||||
booking: ship.booking ? { repair: true } : null,
|
||||
});
|
||||
if (!books) return;
|
||||
const at = { ...ship, locationId: place, inFlight: false };
|
||||
ship.booking = { hqId: nearestReachableHq(at, [...bays], discovered, content) ?? home };
|
||||
serviceStats.bookings++;
|
||||
log(
|
||||
`book repair ${ship.id} ${ship.modelId} at ${ship.booking.hqId} (${ship.hullConditionPct}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A booked repair starts when the ship is idle at its HQ, above the spending floor (decision 0.5.156). */
|
||||
function startServices(): boolean {
|
||||
let acted = false;
|
||||
for (const ship of ships) {
|
||||
const booking = ship.booking;
|
||||
if (!booking || ship.inFlight || ship.locationId !== booking.hqId) continue;
|
||||
if (ship.inShopUntil != null && ship.inShopUntil > now) continue;
|
||||
const hull = ship.hullConditionPct ?? 100;
|
||||
const cost = repairCost(model(ship.modelId), hull, config);
|
||||
if (credits - cost < floor) continue;
|
||||
credits -= cost;
|
||||
eventStats.repairSpend += cost;
|
||||
if (inScenario()) eventStats.windowRepairSpend += cost;
|
||||
ship.inShopUntil = now + repairShopMs(hull, config);
|
||||
ship.hullConditionPct = 100;
|
||||
ship.booking = null;
|
||||
serviceStats.started++;
|
||||
log(`service ${ship.id} ${ship.modelId} from ${hull}% for ${cost} credits ${credits}`);
|
||||
acted = true;
|
||||
}
|
||||
return acted;
|
||||
}
|
||||
|
||||
/** A trip flown below "Needs repair" while the credits would cover the repair (Step 27.7). */
|
||||
function countLowHullTrip(ship: SimShip) {
|
||||
const hull = ship.hullConditionPct ?? 100;
|
||||
const affordable = credits - repairCost(model(ship.modelId), hull, config) >= floor;
|
||||
ship.lowHullTrips =
|
||||
hull < config.hull.needsRepairBelowPct && affordable ? (ship.lowHullTrips ?? 0) + 1 : 0;
|
||||
serviceStats.maxLowHullTrips = Math.max(serviceStats.maxLowHullTrips, ship.lowHullTrips);
|
||||
if (ship.lowHullTrips > 1) {
|
||||
violate(
|
||||
"hull-neglected",
|
||||
`${ship.id} ${ship.modelId} at ${hull}%, trip ${ship.lowHullTrips}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hull repairs at an HQ (decision 0.5.148): below 60%, when the cash is there. */
|
||||
function repairShips(): boolean {
|
||||
let acted = false;
|
||||
|
|
@ -611,9 +690,9 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
const ship = ships.find((s) => s.id === flight.shipId)!;
|
||||
// Flying wears every hull, events or not (decision 0.5.152).
|
||||
const hours = tripGameHours(flight.record.departureAt, flight.record.arrivalAt, config);
|
||||
loseHull(ship, hullWear(hours, config));
|
||||
ship.inFlight = false;
|
||||
ship.locationId = r.shipLocationId;
|
||||
loseHull(ship, hullWear(hours, config), ship.locationId);
|
||||
}
|
||||
if (flight.record.tutorial) {
|
||||
runsCompleted++;
|
||||
|
|
@ -1057,12 +1136,14 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
ship,
|
||||
pilot: pilotState(pilot),
|
||||
orders: defaultOrders(home, content),
|
||||
board: board(ship.locationId),
|
||||
// Asleep, a service trip flies empty (as the engine does).
|
||||
board: now >= sleepAt ? [] : board(ship.locationId),
|
||||
now,
|
||||
credits,
|
||||
spendingFloor: floor,
|
||||
discoveredIds: discovered,
|
||||
emptyPeriods: pilot.emptyPeriods ?? 0,
|
||||
booking: ship.booking ?? null,
|
||||
},
|
||||
content,
|
||||
);
|
||||
|
|
@ -1111,6 +1192,13 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
pilot.nextDecisionAt = null;
|
||||
}
|
||||
|
||||
/** Heading for booked service (CR-027 §2): flies on while the fleet is asleep. */
|
||||
function onServiceTrip(pilot: SimPilot): boolean {
|
||||
const ship = ships.find((x) => x.id === pilot.assignedShipId);
|
||||
if (!ship?.booking || ship.inFlight || ship.locationId === ship.booking.hqId) return false;
|
||||
return canReachHq(ship, ship.booking.hqId, discovered, content);
|
||||
}
|
||||
|
||||
function flyIndependent(): boolean {
|
||||
let acted = false;
|
||||
for (;;) {
|
||||
|
|
@ -1120,7 +1208,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
!p.isAvatar &&
|
||||
p.nextDecisionAt != null &&
|
||||
p.nextDecisionAt <= now &&
|
||||
p.nextDecisionAt < sleepAt,
|
||||
(p.nextDecisionAt < sleepAt || onServiceTrip(p)),
|
||||
)
|
||||
.sort((a, b) => a.nextDecisionAt! - b.nextDecisionAt! || (a.id < b.id ? -1 : 1))[0];
|
||||
if (!due) return acted;
|
||||
|
|
@ -1158,6 +1246,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
let acted = false;
|
||||
if (tutorialActive) acted = playTutorial() || acted;
|
||||
else if (away) {
|
||||
acted = startServices() || acted;
|
||||
acted = flyIndependent() || acted;
|
||||
} else {
|
||||
if (!inWindow) {
|
||||
|
|
@ -1201,5 +1290,6 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
|||
scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null,
|
||||
payoutInFlightAtSleep,
|
||||
events: eventStats,
|
||||
service: serviceStats,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,19 @@ describe("the away scenario (CR-021 Step 21.8)", () => {
|
|||
it("never lets a pilot buy fuel below the spending floor or get stranded", () => {
|
||||
for (const run of awayRuns) expect(run.violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps Independent hulls serviced with events on and auto-repair at 40% (CR-027 Step 27.7)", () => {
|
||||
for (const run of awayRuns) {
|
||||
// No ship flies more than one trip below 25% while its repair is affordable.
|
||||
expect(run.service.maxLowHullTrips).toBeLessThanOrEqual(1);
|
||||
expect(run.violations.filter((v) => v.rule === "hull-neglected")).toEqual([]);
|
||||
}
|
||||
// Auto-repair books repairs, and they get done.
|
||||
const bookings = awayRuns.reduce((n, r) => n + r.service.bookings, 0);
|
||||
const started = awayRuns.reduce((n, r) => n + r.service.started, 0);
|
||||
expect(bookings).toBeGreaterThan(0);
|
||||
expect(started).toBeGreaterThanOrEqual(bookings - awayRuns.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a week away (CR-024 Step 24.7, plan 12.1)", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue