deadhead-space-freight-tycoon/packages/sim/balance/simulate.ts

753 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Balance simulation (plan Step 12.1, amended by CR-001 to CR-013): a simulated
* player plays from "Start your company" using only the sim and content, the
* way the server applies them, and the run records when each milestone was
* reached plus any breach of the game's invariants.
*
* The player is always active: it acts the moment something is possible, so
* times are "real time, active play". Pure and deterministic: same options,
* same result.
*/
import { content as defaultContent, type ShipModel } from "@deadhead/content";
import {
affordableRefuel,
boardPeriodIndex,
fuelPriceAt,
type Tank,
fullTankReach,
boardPeriodMs,
checkRoute,
completedGoals,
contractTripGameHours,
createDispatchRecord,
createRepositionRecord,
effectiveStats,
generateContractBoard,
generateTutorialBoard,
isResting,
loanerModel,
MS_PER_HOUR,
MS_PER_MINUTE,
nextUpgradeLevel,
pilotLevel,
pilotShare,
resolveDispatch,
restEndsAt,
skillPointsAvailable,
tripFuel,
validateDispatch,
validateReposition,
validateUpgrade,
type Contract,
type DispatchRecord,
type GoalProgress,
type PilotState,
type RealTime,
type ShipState,
type SimContent,
type UpgradeLevels,
} from "../src/index";
type Content = SimContent & {
onboardingGoals: typeof defaultContent.onboardingGoals;
veteranPilot: typeof defaultContent.veteranPilot;
dealLoanAmount: number;
};
/** The first two ships the player buys from the Market, in order (CR-013). */
export type PurchasePolicy = "lighter-first" | "freighter-first";
export interface SimulationOptions {
worldSeed: number;
policy: PurchasePolicy;
/** Real time to simulate from the start, in ms. */
durationMs: number;
/** When to stop early: every milestone reached. Default true. */
stopWhenDone?: boolean;
content?: Content;
/** Called with one line per event, for tracing a run. */
log?: (line: string) => void;
}
export interface Sample {
/** Real minutes since the start. */
minute: number;
credits: number;
loanBalance: number;
ships: number;
/** Level of each pilot, in hiring order (avatar first). */
pilotLevels: number[];
}
/** Real minutes from the start until each milestone; null if never reached. */
export interface Milestones {
dealAccepted: number | null;
firstMarketPurchase: number | null;
firstDiscovery: number | null;
loanPaidOff: number | null;
ownFourShips: number | null;
}
export interface SimulationResult {
policy: PurchasePolicy;
milestones: Milestones;
/** Every 10 real minutes. */
samples: Sample[];
tutorialRuns: number;
/** The player's tutorial share over all tutorial runs (Step 12.2). */
tutorialShareEarned: number;
/** Credits on hand the moment the deal is accepted. */
creditsAtDeal: number | null;
contractsDelivered: number;
/** Contracts the veteran delivered in the first two hours (CR-010 check). */
veteranContractsFirstSession: number;
/** Invariant breaches, each with the real minute it happened. */
violations: { minute: number; rule: string; detail: string }[];
/** Ships bought, in order. */
purchases: string[];
}
interface SimShip extends ShipState {
/** Every simulated ship has a tank, on Auto top-up (decision 0.5.97). */
tank: Tank;
upgrades: UpgradeLevels;
inShopUntil: RealTime | null;
fromDeal: boolean;
trips: number;
}
interface SimPilot {
id: string;
isAvatar: boolean;
isVeteran: boolean;
xp: number;
skills: Record<string, number>;
spent: number;
assignedShipId: string | null;
dutyHours: number;
restEndsAt: RealTime | null;
inFlight: boolean;
}
interface Flight {
record: DispatchRecord;
shipId: string | null;
pilotId: string;
}
const PURCHASE_ORDER: Record<PurchasePolicy, string[]> = {
"lighter-first": ["lighter", "freighter"],
"freighter-first": ["freighter", "lighter"],
};
const FIRST_SESSION_MS = 2 * 60 * MS_PER_MINUTE;
const SAMPLE_EVERY_MS = 10 * MS_PER_MINUTE;
/** Cash kept back for fuel after buying a ship, so the fleet can keep flying. */
const FUEL_RESERVE = 5_000;
/** Upgrades only from spare cash: never more than this share of credits on hand. */
const UPGRADE_CASH_SHARE = 0.1;
export function simulate(options: SimulationOptions): SimulationResult {
const content = options.content ?? (defaultContent as Content);
const { config } = content;
const start = Date.parse(config.epoch.realStart);
let now: RealTime = start;
const minute = (t: RealTime = now) => Math.round(((t - start) / MS_PER_MINUTE) * 10) / 10;
const purchaseOrder = PURCHASE_ORDER[options.policy];
// --- State, as a new guest has it (server new-player). ---
let credits = config.startingCredits;
let loanBalance = 0;
let tutorialActive = true;
let runsCompleted = 0;
let loanerLocationId: "earth" | "moon" = "earth";
const discovered = new Set(content.locations.filter((l) => l.startsDiscovered).map((l) => l.id));
const bays = new Set<string>();
const ships: SimShip[] = [];
const pilots: SimPilot[] = [
{
id: "avatar",
isAvatar: true,
isVeteran: false,
xp: 0,
skills: {},
spent: 0,
assignedShipId: null,
dutyHours: 0,
restEndsAt: null,
inFlight: false,
},
];
const flights: Flight[] = [];
const accepted = new Set<string>();
const boards = new Map<string, Contract[]>();
const claimed = new Set<string>();
let nextId = 0;
const id = (prefix: string) => `${prefix}-${++nextId}`;
const milestones: Milestones = {
dealAccepted: null,
firstMarketPurchase: null,
firstDiscovery: null,
loanPaidOff: null,
ownFourShips: null,
};
const samples: Sample[] = [];
const violations: SimulationResult["violations"] = [];
const purchases: string[] = [];
let contractsDelivered = 0;
let tutorialShareEarned = 0;
let creditsAtDeal: number | null = null;
let priorityDeliveries = 0;
let veteranContractsFirstSession = 0;
const seenViolations = new Set<string>();
/** Records a breach once per rule, subject and board period. */
const violate = (rule: string, detail: string) => {
const key = `${rule}:${detail}:${boardPeriodIndex(now, config)}`;
if (seenViolations.has(key)) return;
seenViolations.add(key);
violations.push({ minute: minute(), rule, detail });
};
const model = (modelId: string): ShipModel => content.shipModels.find((m) => m.id === modelId)!;
const loc = (locationId: string) => content.locations.find((l) => l.id === locationId)!;
const pilotOf = (ship: SimShip) => pilots.find((p) => p.assignedShipId === ship.id) ?? null;
const pilotState = (p: SimPilot): PilotState => ({
id: p.id,
isAvatar: p.isAvatar,
assignedShipId: p.assignedShipId,
inFlight: p.inFlight,
skills: p.skills,
restEndsAt: p.restEndsAt,
});
const fleet = () =>
ships.map((s) => ({
modelId: s.modelId,
skills: pilotOf(s)?.skills ?? {},
upgrades: s.upgrades,
}));
/** The location board for the current period, generated once per period (as the server stores it). */
function board(locationId: string): Contract[] {
const period = boardPeriodIndex(now, config);
const key = `${locationId}:${period}`;
let contracts = boards.get(key);
if (!contracts) {
contracts = generateContractBoard(
{
location: loc(locationId),
discoveredIds: discovered,
periodIndex: period,
worldSeed: options.worldSeed,
fleet: fleet(),
},
content,
);
boards.set(key, contracts);
}
return contracts.filter((c) => !accepted.has(c.id));
}
/** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */
function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number {
const origin = loc(contract.originId);
const fuel =
tripFuel(stats, origin, loc(contract.destinationId), config) * fuelPriceAt(origin, config);
const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config);
const tripMs =
(contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression;
return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE);
}
/** A ship as it would be with a full tank (for "is there any way out" checks). */
function filled(ship: SimShip): SimShip {
const capacity = effectiveStats(model(ship.modelId), ship.upgrades, content).tankCapacityFu;
return { ...ship, tank: { ...ship.tank, fuelFu: capacity } };
}
/** Contracts this crew may take now, with the fuel on board (or a full tank). */
function eligibleContracts(ship: SimShip, pilot: SimPilot, fullTank = false): Contract[] {
if (!discovered.has(ship.locationId)) return [];
return board(ship.locationId).filter(
(contract) =>
validateDispatch(
{
ship: fullTank ? filled(ship) : ship,
pilot: pilotState(pilot),
contract,
tutorial: false,
now,
discoveredIds: discovered,
},
content,
).ok,
);
}
const log = (line: string) => options.log?.(`${String(minute()).padStart(6)} ${line}`);
function dispatch(ship: SimShip, pilot: SimPilot, contract: Contract) {
const record = createDispatchRecord(
{
id: id("d"),
ship,
pilot: pilotState(pilot),
contract,
tutorial: false,
now,
worldSeed: options.worldSeed,
},
content,
);
if (isResting(pilot.restEndsAt, now)) violate("no-dispatch-while-resting", pilot.id);
accepted.add(contract.id);
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
log(
`${ship.modelId} ${contract.originId}→${contract.destinationId} ${contract.goodId} ${contract.massTonnes}t pays ${contract.payout}${contract.firstContact ? " FIRST CONTACT" : ""} fuel ${record.fuelCost} arrives +${Math.round((record.arrivalAt - now) / MS_PER_MINUTE)}m credits ${credits}`,
);
ship.inFlight = true;
ship.trips++;
pilot.inFlight = true;
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
}
// --- Arrivals (server resolve.ts). ---
function resolveArrivals() {
flights.sort((a, b) => a.record.arrivalAt - b.record.arrivalAt);
while (flights.length > 0 && flights[0]!.record.arrivalAt <= now) {
const flight = flights.shift()!;
const pilot = pilots.find((p) => p.id === flight.pilotId)!;
const flightShip = flight.shipId ? ships.find((s) => s.id === flight.shipId)! : undefined;
const result = resolveDispatch(
{
record: flight.record,
pilotIsAvatar: pilot.isAvatar,
discoveredIds: discovered,
loanBalance,
pilotDutyHours: pilot.dutyHours,
pilotSkills: pilot.skills,
now,
fuel: flightShip
? {
tank: flightShip.tank,
capacityFu: effectiveStats(model(flightShip.modelId), flightShip.upgrades, content)
.tankCapacityFu,
refuelPolicy: "auto",
credits,
}
: undefined,
},
content,
);
if (result.status !== "resolved") throw new Error("arrived flight did not resolve");
const r = result.resolution;
credits += r.credits;
loanBalance -= r.loanRepayment;
if (r.topUp && flightShip) {
credits -= r.topUp.cost;
flightShip.tank = r.topUp.tank;
}
pilot.xp += r.pilotXp;
pilot.inFlight = false;
pilot.dutyHours = r.pilotDutyHours;
if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt;
if (flight.shipId) {
const ship = ships.find((s) => s.id === flight.shipId)!;
ship.inFlight = false;
ship.locationId = r.shipLocationId;
}
if (flight.record.tutorial) {
runsCompleted++;
tutorialShareEarned += r.credits;
loanerLocationId = r.shipLocationId as "earth" | "moon";
} else if (flight.record.contract) {
contractsDelivered++;
if (flight.record.contract.priority) priorityDeliveries++;
if (pilot.isVeteran && flight.record.arrivalAt - start <= FIRST_SESSION_MS) {
veteranContractsFirstSession++;
}
}
if (flight.record.contract && !flight.record.tutorial) {
log(
` arrived ${flight.record.destinationId}: +${r.credits} (repaid ${r.loanRepayment}, pilot ${r.pilotPay}) credits ${credits}`,
);
}
if (r.discoversDestination) {
discovered.add(flight.record.destinationId);
milestones.firstDiscovery ??= minute(flight.record.arrivalAt);
}
if (!tutorialActive && loanBalance <= 0)
milestones.loanPaidOff ??= minute(flight.record.arrivalAt);
}
}
// --- Tutorial and the deal (server dispatch.ts, deal.ts). ---
function playTutorial(): boolean {
if (flights.some((f) => f.record.tutorial)) return false;
if (runsCompleted >= config.tutorialRunsBeforeDeal && loanerLocationId === "earth") {
acceptDeal();
return true;
}
const contracts = generateTutorialBoard(
{ loanerLocationId, runsCompleted, periodIndex: boardPeriodIndex(now, config) },
content,
).filter((c) => !accepted.has(c.id));
const loaner: ShipState = {
id: "loaner",
modelId: loanerModel(content).id,
locationId: loanerLocationId,
inFlight: false,
};
const avatar = pilots[0]!;
// Tutorial runs use no fuel; the player keeps the tutorial share: best share per minute.
const best = contracts
.filter(
(c) =>
validateDispatch(
{ ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, now },
content,
).ok,
)
.map((c) => {
const record = createDispatchRecord(
{
id: id("t"),
ship: loaner,
pilot: pilotState(avatar),
contract: c,
tutorial: true,
now,
worldSeed: options.worldSeed,
},
content,
);
return { record, value: c.payout / (record.arrivalAt - now) };
})
.sort((a, b) => b.value - a.value)[0];
if (!best) return false;
accepted.add(best.record.contract!.id);
avatar.inFlight = true;
flights.push({ record: best.record, shipId: null, pilotId: avatar.id });
return true;
}
function acceptDeal() {
const { avatarShipModelId, veteranShipModelId, bayLocationId } = config.deal;
const courier = newShip(avatarShipModelId, bayLocationId, true);
const clipper = newShip(veteranShipModelId, bayLocationId, true);
bays.add(bayLocationId);
pilots[0]!.assignedShipId = courier.id;
const veteran = content.veteranPilot;
pilots.push({
id: "veteran",
isAvatar: false,
isVeteran: true,
xp: veteran.level === 0 ? 0 : config.pilotLevelXpThresholds[veteran.level - 1]!,
skills: { ...veteran.skills },
spent: Object.values(veteran.skills).reduce((a, b) => a + b, 0),
assignedShipId: clipper.id,
dutyHours: 0,
restEndsAt: null,
inFlight: false,
});
loanBalance = content.dealLoanAmount;
tutorialActive = false;
creditsAtDeal = credits;
milestones.dealAccepted = minute();
}
function newShip(modelId: string, locationId: string, fromDeal: boolean): SimShip {
const ship: SimShip = {
id: id("s"),
modelId,
locationId,
inFlight: false,
tank: {
// Market ships come full; the deal's ships with a quarter tank (CR-015).
fuelFu: model(modelId).tankCapacityFu * (fromDeal ? config.deal.tankShare : 1),
avgPrice: fuelPriceAt(loc(locationId), config),
},
upgrades: {},
inShopUntil: null,
fromDeal,
trips: 0,
};
ships.push(ship);
return ship;
}
// --- Owner decisions. ---
function claimGoals(): boolean {
const progress: GoalProgress = {
ownShipDeliveries: contractsDelivered,
skillPointsSpent: pilots.reduce((n, p) => n + p.spent, 0),
shipsOwned: ships.length,
marketShipsBought: ships.filter((s) => !s.fromDeal).length,
pilotsHired: pilots.filter((p) => !p.isAvatar).length,
priorityDeliveries,
locationsDiscovered: [...discovered].filter((d) => !loc(d).startsDiscovered).length,
dealAccepted: !tutorialActive,
loanBalance,
};
let any = false;
for (const goalId of completedGoals(content.onboardingGoals, progress)) {
if (claimed.has(goalId)) continue;
claimed.add(goalId);
credits += content.onboardingGoals.find((g) => g.id === goalId)!.reward;
any = true;
}
return any;
}
/**
* Skill points (owner's choice): Long Haul when the pilot's ship can fly
* further than the first Long Haul cut-off and Long Haul is behind; otherwise
* the cargo skill that suits the ship (big holds: Heavy; small, fast: Expensive).
*/
function spendSkillPoints() {
for (const pilot of pilots) {
const ship = ships.find((s) => s.id === pilot.assignedShipId);
if (!ship) continue;
const stats = effectiveStats(model(ship.modelId), ship.upgrades, content);
while (skillPointsAvailable(pilotLevel(pilot.xp, config), pilot.spent, config) > 0) {
const cargoSkill = stats.cargoCapacityTonnes >= 60 ? "heavy-cargo" : "expensive-cargo";
const longHaul = pilot.skills["long-haul"] ?? 0;
const cargo = pilot.skills[cargoSkill] ?? 0;
const wantsLongHaul =
fullTankReach(stats, loc("earth"), config) > config.longHaulCutoffsMillionKm[0]! &&
longHaul <= cargo;
const skill = wantsLongHaul ? "long-haul" : cargoSkill;
pilot.skills[skill] = (pilot.skills[skill] ?? 0) + 1;
pilot.spent++;
}
}
}
/** The next planned Market ship, bought at the Earth bay with a pilot hired for it. */
function buyShips(): boolean {
const bought = ships.filter((s) => !s.fromDeal).length;
const next = purchaseOrder[bought];
if (!next) return false;
const price = model(next).price + config.pilotHireCost;
if (credits < price + FUEL_RESERVE) return false;
credits -= price;
const ship = newShip(next, config.deal.bayLocationId, false);
pilots.push({
id: id("p"),
isAvatar: false,
isVeteran: false,
xp: 0,
skills: {},
spent: 0,
assignedShipId: ship.id,
dutyHours: 0,
restEndsAt: null,
inFlight: false,
});
purchases.push(next);
log(`BUY ${next} credits ${credits}`);
milestones.firstMarketPurchase ??= minute();
if (ships.length >= 4) milestones.ownFourShips ??= minute();
return true;
}
/**
* Upgrades (CR-009 amendment): cargo bay, then engines, on the most-used ship
* idle at an HQ, paid only from spare cash so saving for ships isn't stalled.
*/
function buyUpgrade(): boolean {
const candidates = ships
.filter((s) => !s.inFlight && bays.has(s.locationId))
.sort((a, b) => b.trips - a.trips);
const ship = candidates[0];
if (!ship) return false;
for (const upgradeId of ["cargo-bay", "engines"]) {
const upgrade = content.upgrades.find((u) => u.id === upgradeId)!;
if (nextUpgradeLevel(upgrade, ship.modelId, ship.upgrades) === null) continue;
const check = validateUpgrade(
{ ship, upgrade, atHq: true, tutorialActive, credits, now },
content,
);
if (!check.ok || check.cost > credits * UPGRADE_CASH_SHARE) continue;
credits -= check.cost;
ship.upgrades = { ...ship.upgrades, [upgrade.id]: check.level };
ship.inShopUntil = check.shopUntil;
return true;
}
return false;
}
/** Fly every free ship: best contract here, else a deadhead to work, else rest the pilot. */
function flyShips(): boolean {
let acted = false;
for (const ship of ships) {
const pilot = pilotOf(ship);
if (!pilot || ship.inFlight || pilot.inFlight || isResting(pilot.restEndsAt, now)) continue;
if (ship.inShopUntil !== null && ship.inShopUntil > now) continue;
const stats = effectiveStats(model(ship.modelId), ship.upgrades, content);
topUpHere(ship, stats.tankCapacityFu);
const choices = eligibleContracts(ship, pilot)
.map((c) => ({ c, value: rate(c, stats, pilot) }))
.filter((o) => o.value > 0)
.sort((a, b) => b.value - a.value);
if (choices[0]) {
dispatch(ship, pilot, choices[0].c);
acted = true;
continue;
}
const canDeadhead = (to: string, fullTank: boolean) =>
to !== ship.locationId &&
validateReposition(
{
ship: fullTank ? filled(ship) : ship,
pilot: pilotState(pilot),
destinationId: to,
discoveredIds: discovered,
now,
},
content,
).ok;
const deadheads = [...discovered].filter((to) => canDeadhead(to, false));
// A dead end is no work and no way out even on a full tank (CR-002);
// running short of cash for fuel is the soft-lock check's business.
if (
eligibleContracts(ship, pilot, true).length === 0 &&
![...discovered].some((to) => canDeadhead(to, true))
) {
violate("idle-ship-with-nowhere-to-go", `${ship.id} ${ship.modelId} at ${ship.locationId}`);
}
// Deadhead only to a place with paying work for this ship right now.
const target = deadheads
.map((to) => {
const best = board(to)
.filter((c) => checkRoute(stats, loc(to), loc(c.destinationId), content.config).ok)
.map((c) => c.payout)
.sort((a, b) => b - a)[0];
return { to, best: best ?? 0 };
})
.filter((t) => t.best > 0)
.sort((a, b) => b.best - a.best)[0];
if (target) {
const record = createRepositionRecord(
{
id: id("r"),
ship,
pilot: pilotState(pilot),
destinationId: target.to,
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 });
log(
`${ship.modelId} deadhead ${ship.locationId}→${target.to} fuel ${record.fuelCost} credits ${credits}`,
);
acted = true;
} else if (pilot.dutyHours > 0) {
// Nothing worth flying: rest now so the pilot is fresh for the next board (CR-004).
pilot.dutyHours = 0;
pilot.restEndsAt = restEndsAt(now, config);
log(`${ship.modelId} pilot rests at ${ship.locationId}`);
acted = true;
}
}
return acted;
}
/** Tops the tank up where the ship is, as far as credits go (the player's "Refuel here first"). */
function topUpHere(ship: SimShip, capacityFu: number) {
const location = loc(ship.locationId);
if (!location.sellsFuel || ship.tank.fuelFu >= capacityFu) return;
const bought = affordableRefuel(ship.tank, capacityFu, fuelPriceAt(location, config), credits);
if (bought.boughtFu <= 0) return;
credits -= bought.cost;
ship.tank = bought.tank;
}
function checkCashflow() {
if (tutorialActive || flights.length > 0) return;
const affordable = ships.some((ship) => {
const pilot = pilotOf(ship);
return (
pilot !== null &&
discovered.has(ship.locationId) &&
board(ship.locationId).some(
(c) =>
tripFuel(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
ship.tank.fuelFu + credits / fuelPriceAt(loc(ship.locationId), config),
)
);
});
if (!affordable) violate("soft-lock", `no flight and no affordable fuel with ${credits} cr`);
}
function sample() {
samples.push({
minute: minute(),
credits,
loanBalance,
ships: ships.length,
pilotLevels: pilots.map((p) => pilotLevel(p.xp, config)),
});
}
/** The next moment anything can change. */
function nextEvent(): RealTime {
const period = boardPeriodMs(config);
const times = [
(boardPeriodIndex(now, config) + 1) * period,
...flights.map((f) => f.record.arrivalAt),
...pilots.flatMap((p) => (p.restEndsAt !== null && p.restEndsAt > now ? [p.restEndsAt] : [])),
...ships.flatMap((s) =>
s.inShopUntil !== null && s.inShopUntil > now ? [s.inShopUntil] : [],
),
];
return Math.min(...times);
}
// --- Main loop. ---
const end = start + options.durationMs;
let nextSample = start;
while (now <= end) {
resolveArrivals();
for (let guard = 0; guard < 50; guard++) {
let acted = false;
if (tutorialActive) acted = playTutorial() || acted;
else {
acted = claimGoals() || acted;
spendSkillPoints();
acted = buyShips() || acted;
if (purchaseOrder[ships.filter((s) => !s.fromDeal).length] === undefined) {
acted = buyUpgrade() || acted;
}
acted = flyShips() || acted;
}
if (!acted) break;
}
checkCashflow();
while (nextSample <= now) {
sample();
nextSample += SAMPLE_EVERY_MS;
}
const done = Object.values(milestones).every((m) => m !== null);
if (done && (options.stopWhenDone ?? true)) break;
now = Math.max(now + 1, nextEvent());
}
return {
policy: options.policy,
milestones,
samples,
tutorialRuns: runsCompleted,
tutorialShareEarned,
creditsAtDeal,
contractsDelivered,
veteranContractsFirstSession,
violations,
purchases,
};
}