Step 21.8: balance
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
An away scenario in the balance simulator: hired pilots fly Independent for 8 hours and earn a median 56% of active play with the same fleet (target 40-80%), never below the spending floor or stranded (CR-021). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a75a39e01f
commit
8b1f6428db
|
|
@ -1729,3 +1729,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The logbook replaces per-trip cards for pilots' own trips**: an hour away is a dozen trips per pilot. Each pilot is a collapsible section, open when there's only one.
|
- **The logbook replaces per-trip cards for pilots' own trips**: an hour away is a dozen trips per pilot. Each pilot is a collapsible section, open when there's only one.
|
||||||
- **A logbook job's payout is credits + pilot share + loan repayment**, rebuilt from the summary: what the contract paid before deductions.
|
- **A logbook job's payout is credits + pilot share + loan repayment**, rebuilt from the summary: what the contract paid before deductions.
|
||||||
- **The indicator follows the loop's own pass**, via `onBusy`, not a separate catch-up call, so it covers the first pass on opening and each resume.
|
- **The indicator follows the loop's own pass**, via `onBusy`, not a separate catch-up call, so it covers the first pass on opening and each resume.
|
||||||
|
|
||||||
|
## Step 21.8
|
||||||
|
|
||||||
|
- **"The same fleet" means the window buys and upgrades nothing in either mode**, and claims no goals, so only who flies differs. In active mode the player still spends skill points as they're earned; Independent pilots don't (decision 0.5.122).
|
||||||
|
- **Earnings are credits gained plus loan repaid** over the window: repayment is income that went to the lender.
|
||||||
|
- **The window opens at minute 180**, when every world owns four ships. Seed 777 (0.34) and 99 (0.38) fall below 40% on their own; the target is checked on the median, like the other balance targets.
|
||||||
|
- **No tuning**: the default 15-game-minute turnaround and 3-minute first look give a median of 56%.
|
||||||
|
- **A stranded Independent pilot is a violation** when the company has money above the floor but the ship can't reach anywhere. None occurred.
|
||||||
|
|
|
||||||
|
|
@ -480,5 +480,5 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C
|
||||||
- **Pilot logbook.** The API summary carries `independent`. On the "While you were away" sheet, `pilotLogbook` groups Independent trips by pilot: jobs (good, route, payout, share, fuel), deadheads, rests, XP, and what the pilot is doing now. Company totals sit at the top. The player's own trips stay as cards.
|
- **Pilot logbook.** The API summary carries `independent`. On the "While you were away" sheet, `pilotLogbook` groups Independent trips by pilot: jobs (good, route, payout, share, fuel), deadheads, rests, XP, and what the pilot is doing now. Company totals sit at the top. The player's own trips stay as cards.
|
||||||
|
|
||||||
Screenshots: `docs/screenshots/21-7-logbook-phone.png`, `21-7-logbook-desktop.png` (Rosa, an hour away: 11–12 jobs, two rests), `21-7-first-look-desktop.png`. Tests (`apps/web/test/board-logbook.test.tsx`): the chip counts down from 02:31 and disappears after the window; no chip without Independent pilots or on a first contact; the indicator only after 0.5 s and never for a quick pass; the logbook's entries, XP and current status per pilot; totals that are the sums of the entries; the sheet shows the logbook and the player's own trip as a card. Summary fixtures gained `independent`.
|
Screenshots: `docs/screenshots/21-7-logbook-phone.png`, `21-7-logbook-desktop.png` (Rosa, an hour away: 11–12 jobs, two rests), `21-7-first-look-desktop.png`. Tests (`apps/web/test/board-logbook.test.tsx`): the chip counts down from 02:31 and disappears after the window; no chip without Independent pilots or on a first contact; the indicator only after 0.5 s and never for a quick pass; the logbook's entries, XP and current status per pilot; totals that are the sums of the entries; the sheet shows the logbook and the player's own trip as a card. Summary fixtures gained `independent`.
|
||||||
- [ ] Step 21.8 — Balance
|
- [x] **Step 21.8 — Balance.** `simulate` takes `scenario: { atMinute, minutes, mode }`. It plays as usual until `atMinute` (180: four ships), then for `minutes` neither buys, upgrades nor claims goals. In `active` mode the usual policy flies every ship. In `away` mode the avatar stays put and hired pilots act at their decision points through the sim's `chooseAction`, as the engine's catch-up does: turnaround, first-look window, the spending floor, waiting and heading home. `scenarioEarnings` is the window's credits gained plus loan repaid. The balance script prints the comparison. Results with default content (7 worlds, 8 hours): away / active ratios 0.34–0.62, median **0.56** (target 40–80%), so no tuning was needed; no pilot bought fuel below the floor or was stranded. Tests (`packages/sim/test/balance.test.ts`): the median ratio is within 40–80%; the away runs have no violations.
|
||||||
- [ ] Step 21.9 — Plan amendments and regression
|
- [ ] Step 21.9 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,20 @@ for (const policy of ["lighter-first", "freighter-first"] as PurchasePolicy[]) {
|
||||||
|
|
||||||
console.log("\n=== fuel share of median payout, per ship and route (CR-018) ===");
|
console.log("\n=== fuel share of median payout, per ship and route (CR-018) ===");
|
||||||
console.log(formatFuelShares(fuelShares(content)));
|
console.log(formatFuelShares(fuelShares(content)));
|
||||||
|
|
||||||
|
// The away scenario (CR-021 Step 21.8): 3 hours of play, then 8 hours either way.
|
||||||
|
const window = (mode: "active" | "away") =>
|
||||||
|
simulate({
|
||||||
|
worldSeed,
|
||||||
|
policy: "lighter-first",
|
||||||
|
durationMs: 12 * MS_PER_HOUR,
|
||||||
|
scenario: { atMinute: 180, minutes: 8 * 60, mode },
|
||||||
|
});
|
||||||
|
const active = window("active");
|
||||||
|
const away = window("away");
|
||||||
|
console.log("\n=== away scenario: 8 h with Independent pilots vs active play (CR-021) ===");
|
||||||
|
console.log(
|
||||||
|
`active ${active.scenarioEarnings} cr, away ${away.scenarioEarnings} cr, ` +
|
||||||
|
`ratio ${((away.scenarioEarnings! / active.scenarioEarnings!) * 100).toFixed(0)}% (target 40–80%), ` +
|
||||||
|
`violations ${away.violations.length}`,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import {
|
||||||
fullTankReach,
|
fullTankReach,
|
||||||
boardPeriodMs,
|
boardPeriodMs,
|
||||||
checkRoute,
|
checkRoute,
|
||||||
|
chooseAction,
|
||||||
|
defaultOrders,
|
||||||
|
refuel,
|
||||||
completedGoals,
|
completedGoals,
|
||||||
contractTripGameHours,
|
contractTripGameHours,
|
||||||
createDispatchRecord,
|
createDispatchRecord,
|
||||||
|
|
@ -70,6 +73,13 @@ export interface SimulationOptions {
|
||||||
content?: Content;
|
content?: Content;
|
||||||
/** Called with one line per event, for tracing a run. */
|
/** Called with one line per event, for tracing a run. */
|
||||||
log?: (line: string) => void;
|
log?: (line: string) => void;
|
||||||
|
/**
|
||||||
|
* The away scenario (CR-021 Step 21.8): play as usual until `atMinute`, then
|
||||||
|
* for `minutes` either keep playing actively with the same fleet ("active"),
|
||||||
|
* or leave the hired pilots to fly Independent while the player is away
|
||||||
|
* ("away"). Neither buys, upgrades nor claims goals in that window.
|
||||||
|
*/
|
||||||
|
scenario?: { atMinute: number; minutes: number; mode: "active" | "away" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Sample {
|
export interface Sample {
|
||||||
|
|
@ -111,6 +121,8 @@ export interface SimulationResult {
|
||||||
/** Board jobs offered to the player (CR-019): those shown, and those no qualifying pair could complete. */
|
/** Board jobs offered to the player (CR-019): those shown, and those no qualifying pair could complete. */
|
||||||
jobsShown: number;
|
jobsShown: number;
|
||||||
uncompletableShown: number;
|
uncompletableShown: number;
|
||||||
|
/** The scenario window's earnings: credits gained plus loan repaid (Step 21.8); null without one. */
|
||||||
|
scenarioEarnings: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimShip extends ShipState {
|
interface SimShip extends ShipState {
|
||||||
|
|
@ -133,6 +145,9 @@ interface SimPilot {
|
||||||
dutyHours: number;
|
dutyHours: number;
|
||||||
restEndsAt: RealTime | null;
|
restEndsAt: RealTime | null;
|
||||||
inFlight: boolean;
|
inFlight: boolean;
|
||||||
|
/** Independent pilots (away scenario): the next decision point and empty periods away. */
|
||||||
|
nextDecisionAt?: RealTime | null;
|
||||||
|
emptyPeriods?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Flight {
|
interface Flight {
|
||||||
|
|
@ -393,6 +408,10 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
}
|
}
|
||||||
pilot.xp += r.pilotXp;
|
pilot.xp += r.pilotXp;
|
||||||
pilot.inFlight = false;
|
pilot.inFlight = false;
|
||||||
|
if (away) {
|
||||||
|
pilot.nextDecisionAt =
|
||||||
|
Math.max(flight.record.arrivalAt, r.pilotRestEndsAt ?? 0) + turnaroundMs;
|
||||||
|
}
|
||||||
pilot.dutyHours = r.pilotDutyHours;
|
pilot.dutyHours = r.pilotDutyHours;
|
||||||
if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt;
|
if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt;
|
||||||
if (flight.shipId) {
|
if (flight.shipId) {
|
||||||
|
|
@ -788,25 +807,156 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
...ships.flatMap((s) =>
|
...ships.flatMap((s) =>
|
||||||
s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [],
|
s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [],
|
||||||
),
|
),
|
||||||
|
...pilots.flatMap((p) =>
|
||||||
|
p.nextDecisionAt != null && p.nextDecisionAt > now ? [p.nextDecisionAt] : [],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
return Math.min(...times);
|
return Math.min(...times);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The away scenario (CR-021 Step 21.8). ---
|
||||||
|
const scenario = options.scenario;
|
||||||
|
const windowStart = scenario ? start + scenario.atMinute * MS_PER_MINUTE : Infinity;
|
||||||
|
const turnaroundMs =
|
||||||
|
(config.independentPilots.turnaroundGameMinutes * MS_PER_MINUTE) / config.timeCompression;
|
||||||
|
const firstLookMs = config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE;
|
||||||
|
const floor = config.independentPilots.defaultSpendingFloor;
|
||||||
|
let away = false;
|
||||||
|
let windowCredits = 0;
|
||||||
|
let windowLoan = 0;
|
||||||
|
// Every simulated pilot is hired at the Earth bay: that is their home HQ.
|
||||||
|
const home = config.deal.bayLocationId;
|
||||||
|
let windowOpen = false;
|
||||||
|
|
||||||
|
/** One Independent pilot's decision point, as the engine's catch-up makes it. */
|
||||||
|
function decideIndependent(pilot: SimPilot) {
|
||||||
|
const ship = ships.find((x) => x.id === pilot.assignedShipId);
|
||||||
|
if (!ship || ship.inFlight || pilot.inFlight) {
|
||||||
|
pilot.nextDecisionAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const period = boardPeriodMs(config);
|
||||||
|
const lookEnd = boardPeriodIndex(now, config) * period + firstLookMs;
|
||||||
|
if (pilot.restEndsAt !== null && pilot.restEndsAt > now) {
|
||||||
|
pilot.nextDecisionAt = pilot.restEndsAt + turnaroundMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ship.inShopUntil !== null && ship.inShopUntil > now) {
|
||||||
|
pilot.nextDecisionAt = ship.inShopUntil + turnaroundMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (now < lookEnd) {
|
||||||
|
pilot.nextDecisionAt = lookEnd;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = chooseAction(
|
||||||
|
{
|
||||||
|
ship,
|
||||||
|
pilot: pilotState(pilot),
|
||||||
|
orders: defaultOrders(home, content),
|
||||||
|
board: board(ship.locationId),
|
||||||
|
now,
|
||||||
|
credits,
|
||||||
|
spendingFloor: floor,
|
||||||
|
discoveredIds: discovered,
|
||||||
|
emptyPeriods: pilot.emptyPeriods ?? 0,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
if (action.kind === "wait") {
|
||||||
|
if (ship.locationId !== home) pilot.emptyPeriods = (pilot.emptyPeriods ?? 0) + 1;
|
||||||
|
pilot.nextDecisionAt = lookEnd + period;
|
||||||
|
// Never stuck: with money above the floor, a pilot can always buy a way out.
|
||||||
|
if (credits > floor && emergencyFuel({ ship, credits, discoveredIds: discovered }, content)) {
|
||||||
|
violate("independent-stranded", `${ship.id} ${ship.modelId} at ${ship.locationId}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.buyFu > 0) {
|
||||||
|
const capacity = effectiveStats(model(ship.modelId), ship.upgrades, content).tankCapacityFu;
|
||||||
|
const bought = refuel(
|
||||||
|
ship.tank,
|
||||||
|
capacity,
|
||||||
|
action.buyFu,
|
||||||
|
fuelPriceAt(loc(ship.locationId), config),
|
||||||
|
);
|
||||||
|
credits -= bought.cost;
|
||||||
|
ship.tank = bought.tank;
|
||||||
|
if (credits < floor) violate("below-spending-floor", `${pilot.id} left ${credits} cr`);
|
||||||
|
}
|
||||||
|
if (action.kind === "take") {
|
||||||
|
const contract = board(ship.locationId).find((c) => c.id === action.contractId)!;
|
||||||
|
dispatch(ship, pilot, contract);
|
||||||
|
} else {
|
||||||
|
const record = createRepositionRecord(
|
||||||
|
{
|
||||||
|
id: id("r"),
|
||||||
|
ship,
|
||||||
|
pilot: pilotState(pilot),
|
||||||
|
destinationId: action.destinationId,
|
||||||
|
now,
|
||||||
|
worldSeed: options.worldSeed,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
|
||||||
|
ship.inFlight = true;
|
||||||
|
pilot.inFlight = true;
|
||||||
|
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
|
||||||
|
}
|
||||||
|
pilot.emptyPeriods = 0;
|
||||||
|
pilot.nextDecisionAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flyIndependent(): boolean {
|
||||||
|
let acted = false;
|
||||||
|
for (;;) {
|
||||||
|
const due = pilots
|
||||||
|
.filter((p) => !p.isAvatar && p.nextDecisionAt != null && p.nextDecisionAt <= now)
|
||||||
|
.sort((a, b) => a.nextDecisionAt! - b.nextDecisionAt! || (a.id < b.id ? -1 : 1))[0];
|
||||||
|
if (!due) return acted;
|
||||||
|
const before = due.nextDecisionAt;
|
||||||
|
decideIndependent(due);
|
||||||
|
acted = acted || due.nextDecisionAt !== before;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main loop. ---
|
// --- Main loop. ---
|
||||||
const end = start + options.durationMs;
|
const end = scenario
|
||||||
|
? windowStart + scenario.minutes * MS_PER_MINUTE
|
||||||
|
: start + options.durationMs;
|
||||||
let nextSample = start;
|
let nextSample = start;
|
||||||
while (now <= end) {
|
while (now <= end) {
|
||||||
|
if (scenario && !windowOpen && now >= windowStart) {
|
||||||
|
windowOpen = true;
|
||||||
|
// The window opens: from here the fleet is fixed.
|
||||||
|
windowCredits = credits;
|
||||||
|
windowLoan = loanBalance;
|
||||||
|
if (scenario.mode === "away") {
|
||||||
|
away = true;
|
||||||
|
for (const pilot of pilots) {
|
||||||
|
if (!pilot.isAvatar) pilot.nextDecisionAt = now + turnaroundMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inWindow = scenario !== undefined && now >= windowStart;
|
||||||
resolveArrivals();
|
resolveArrivals();
|
||||||
for (let guard = 0; guard < 50; guard++) {
|
for (let guard = 0; guard < 50; guard++) {
|
||||||
let acted = false;
|
let acted = false;
|
||||||
if (tutorialActive) acted = playTutorial() || acted;
|
if (tutorialActive) acted = playTutorial() || acted;
|
||||||
else {
|
else if (away) {
|
||||||
|
acted = flyIndependent() || acted;
|
||||||
|
} else {
|
||||||
|
if (!inWindow) {
|
||||||
acted = claimGoals() || acted;
|
acted = claimGoals() || acted;
|
||||||
spendSkillPoints();
|
spendSkillPoints();
|
||||||
acted = buyShips() || acted;
|
acted = buyShips() || acted;
|
||||||
if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) {
|
if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) {
|
||||||
acted = buyUpgrade() || acted;
|
acted = buyUpgrade() || acted;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
spendSkillPoints();
|
||||||
|
}
|
||||||
acted = flyShips() || acted;
|
acted = flyShips() || acted;
|
||||||
}
|
}
|
||||||
if (!acted) break;
|
if (!acted) break;
|
||||||
|
|
@ -817,8 +967,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
nextSample += SAMPLE_EVERY_MS;
|
nextSample += SAMPLE_EVERY_MS;
|
||||||
}
|
}
|
||||||
const done = Object.values(milestones).every((m) => m !== null);
|
const done = Object.values(milestones).every((m) => m !== null);
|
||||||
if (done && (options.stopWhenDone ?? true)) break;
|
if (!scenario && done && (options.stopWhenDone ?? true)) break;
|
||||||
now = Math.max(now + 1, nextEvent());
|
now = Math.max(now + 1, Math.min(nextEvent(), scenario && !inWindow ? windowStart : Infinity));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -834,5 +984,6 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
purchases,
|
purchases,
|
||||||
jobsShown,
|
jobsShown,
|
||||||
uncompletableShown,
|
uncompletableShown,
|
||||||
|
scenarioEarnings: scenario ? credits - windowCredits + (windowLoan - loanBalance) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,33 @@ describe("balance simulation (Step 12.1)", () => {
|
||||||
expect(run!.samples.at(-1)!.pilotLevels.length).toBeGreaterThanOrEqual(2);
|
expect(run!.samples.at(-1)!.pilotLevels.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the away scenario (CR-021 Step 21.8)", () => {
|
||||||
|
// Three hours of play (four ships), then eight hours either way.
|
||||||
|
const scenario = (worldSeed: number, mode: "active" | "away") =>
|
||||||
|
simulate({
|
||||||
|
worldSeed,
|
||||||
|
policy: "lighter-first",
|
||||||
|
durationMs: 12 * MS_PER_HOUR,
|
||||||
|
scenario: { atMinute: 180, minutes: 8 * 60, mode },
|
||||||
|
});
|
||||||
|
let ratios: number[];
|
||||||
|
let awayRuns: SimulationResult[];
|
||||||
|
beforeAll(() => {
|
||||||
|
awayRuns = SEEDS.map((seed) => scenario(seed, "away"));
|
||||||
|
ratios = SEEDS.map(
|
||||||
|
(seed, i) => awayRuns[i]!.scenarioEarnings! / scenario(seed, "active").scenarioEarnings!,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("earns 40–80% of active play with the same fleet over 8 hours away", () => {
|
||||||
|
const sorted = [...ratios].sort((a, b) => a - b);
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)]!;
|
||||||
|
expect(median).toBeGreaterThanOrEqual(0.4);
|
||||||
|
expect(median).toBeLessThanOrEqual(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets a pilot buy fuel below the spending floor or get stranded", () => {
|
||||||
|
for (const run of awayRuns) expect(run.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue