// Headless lifetime simulator for BaoLife.
//
// Drives a real PlayerSession with a fake WebSocket, bypassing setInterval by
// calling advanceMinute() directly. Runs full lifetimes in milliseconds and
// surfaces invariant violations and thrown errors.
//
// Mocks (for database, notifications, and AI-touching subsystems) MUST be
// declared at the top of the test file that imports this harness. See
// smoke.test.ts for the required vi.mock() calls.

import type { WebSocket } from 'ws';
import { Person } from '../../src/models/Person.js';
import { Player } from '../../src/models/Player.js';
import { PlayerSession } from '../../src/game/PlayerSession.js';
import { getEventRuntime } from '../../src/events/v2/runtime.js';
import { eventCatalog } from '../../src/events/v2/catalog/index.js';
import { config } from '../../src/config.js';
import {
  getOccupations,
  applyForJob,
  setJobFocus,
} from '../../src/services/jobs/job_manager.js';
import type {
  EventPromptEnvelope,
  EventChoice,
  EventDefinition,
} from '../../src/events/v2/types.js';

// The prompt envelope sent to the client (EventEngine.promptNext) only carries
// each choice's top-level `energyCost` / `moneyCost` / `diamondCost` — it does
// NOT include `effects.resources.money`. Many events (e.g. `unexpectedBill`)
// encode their money cost in `effects.resources.money`, which the envelope hides
// but the authoritative gate (EventResponder, reading the registry definition)
// still enforces. So affordability MUST be computed from the full registry
// definition, not the envelope, or the simulator would pick a money choice it
// can't actually afford and the gate would reject it. Build a lookup once.
const definitionChoicesById = new Map<string, EventDefinition['choices']>();
for (const def of eventCatalog) {
  definitionChoicesById.set(def.id, def.choices);
}

export interface SimulatorOptions {
  seed: number;
  characterName?: string;
  characterAge?: number;
  characterSex?: 'Male' | 'Female';
  /** Safety cap on simulated minutes. Defaults to 100 in-game years. */
  maxMinutes?: number;
  /** How to answer any event prompts that arrive. Defaults to the first choice. */
  questionResponder?: (prompt: EventPromptEnvelope) => string;
  /** If true, captures every sent message in `result.sentMessages` (expensive). */
  captureAllMessages?: boolean;
  /**
   * Drive the autonomous character into employment so the economy is actually
   * exercised (the simulator never auto-gets a job otherwise, so money pins at
   * 0 and the finance.ts curve is unmeasured). When enabled (default true):
   *   - at `employmentAge` the character is given the `employmentEducation`
   *     credential and applies for a job via the REAL applyForJob path, and
   *   - if it is ever fired (performance crashes), it re-applies the next year,
   * so it holds employment across its working life and weekly wages / rent /
   * lifestyle / savings run through finance.ts.
   * The job is chosen by title (`employmentJobTitle`) so the curve is
   * deterministic across seeds.
   */
  driveEmployment?: boolean;
  /** Age (years) at which the character first applies for a job. Default 22. */
  employmentAge?: number;
  /** Age (years) at which the character retires (quits earning). Default 65. */
  retirementAge?: number;
  /** Education credential granted before applying. Default 'bachelors_degree'. */
  employmentEducation?: string;
  /** Occupation title to apply for (must satisfy the credential). Default 'Accountant'. */
  employmentJobTitle?: string;
  /**
   * OPT-IN engaged-worker model. When set, the simulator drives the REAL
   * `setJobFocus` handler right after a successful `applyForJob`, so the weekly
   * `handleJob` promotion mechanic actually fires (Work Hard = +2 performance
   * modifier per week → performance climbs past the >90 promotion ceiling →
   * raises). When LEFT UNDEFINED (the default) the behavior is byte-for-byte
   * the PASSIVE worker the metrics.test.ts economy assertions are calibrated
   * against — no focus is set, so promotions random-walk around 0 and salary
   * stays frozen at the entry tier. Do NOT default this to a value: the passive
   * floor must stay unchanged.
   */
  workFocus?: 'Work Hard' | 'Balanced' | 'Slack Off';
}

export interface SentMessageTrace {
  tick: number;
  type: string;
  raw: unknown;
}

export interface ErrorTrace {
  tick: number;
  phase: 'advance' | 'respond' | 'invariant';
  error: Error;
}

export interface InvariantViolation {
  tick: number;
  rule: string;
  detail: string;
}

/** Min/max/mean summary for a sampled scalar. */
export interface StatBand {
  min: number;
  max: number;
  mean: number;
  /** Number of samples taken (one per in-game day). */
  samples: number;
}

/** A per-in-game-year sample of money / net worth. */
export interface YearlySample {
  /** In-game year index (0 = first simulated year). */
  year: number;
  ageYears: number;
  money: number;
  /**
   * Net worth = money + summed resale value of owned items. The sim does not
   * buy prestige items, so today netWorth == money, but the field is the
   * forward-looking economy signal the metrics assertion bands against.
   */
  netWorth: number;
  diamonds: number;
  prestige: number;
  /** Occupation at sample time ('work' | 'retired' | '' …) for curve context. */
  occupation: string;
  /** Monthly salary at sample time (0 when not earning). */
  salary: number;
}

/**
 * Event density across the life. We track TWO views:
 *
 *  - `interactive*` counts ONLY `event_prompt` messages (decisions the player
 *    must answer). Kept for continuity with the original baseline numbers.
 *  - `total*` / `peakSingleDay` / `weekly*` count the FELT cadence: every
 *    player-facing event beat — both `event_prompt` (interactive) AND
 *    `event_resolved` (passive, auto-resolved ambient/life-texture events).
 *    Both are surfaced to the player as a modal/notification, so the
 *    moment-to-moment "is something happening?" feel is driven by their sum.
 *
 * Buckets are by in-game day (peak burst), week (steady-state cadence target),
 * month (dead-air detection), and year.
 */
export interface PromptDensity {
  /** Felt-cadence total: event_prompt + event_resolved across the life. */
  totalPrompts: number;
  /** Interactive-only total (event_prompt) — original baseline metric. */
  interactivePrompts: number;
  /** In-game days that elapsed (>= 1). */
  totalDays: number;
  perDayMean: number;
  perWeekMean: number;
  perMonthMean: number;
  perYearMean: number;
  /** Most felt events seen in any single in-game day (burst detector). */
  peakSingleDay: number;
  /**
   * Number of in-game MONTHS in MID-LIFE (ages 25-65) that saw ZERO felt
   * events. The dead-air detector: the target is 0.
   */
  midlifeZeroMonths: number;
  /** Total mid-life months observed (denominator for the above). */
  midlifeMonths: number;
}

/**
 * Reward cadence over the life. Counts of reward-flavored WS message types the
 * loop emitted (lifeGoal updates, quest progress/claims, achievement unlocks,
 * daily-reward / diamond awards). A coarse proxy for "how often does the player
 * get a win" — later waves tune the actual cadence.
 */
export interface RewardCadence {
  lifeGoalUpdates: number;
  questEvents: number;
  achievementUnlocks: number;
  diamondAwards: number;
  /** Sum of the above — total reward-flavored signals over the life. */
  total: number;
}

export interface TrajectorySummary {
  hunger: StatBand;
  thirst: StatBand;
  energy: StatBand;
  calcEnergy: StatBand;
  health: StatBand;
  /** In-game hours observed at calcEnergy <= 0 (sampled once per day). */
  daysAtZeroCalcEnergy: number;
  /** Money / net worth sampled once per in-game year. */
  yearlyMoney: YearlySample[];
  promptDensity: PromptDensity;
  rewardCadence: RewardCadence;
}

export interface SimulatorResult {
  seed: number;
  finalAge: number;
  minutesSimulated: number;
  completed: boolean;
  deathCause?: string;
  eventsFired: string[];
  /** Counts of each event type (e.g. 'event_prompt', 'u', etc.) observed in the fake WS sink. */
  messageTypeCounts: Record<string, number>;
  errors: ErrorTrace[];
  invariantViolations: InvariantViolation[];
  sentMessages?: SentMessageTrace[];
  /** Per-life trajectory metrics (sampled once per in-game day to stay cheap). */
  trajectory: TrajectorySummary;
}

/** Running min/max/sum/count accumulator for a sampled scalar. */
class BandAccumulator {
  min = Infinity;
  max = -Infinity;
  sum = 0;
  count = 0;
  add(v: number | undefined | null): void {
    if (typeof v !== 'number' || Number.isNaN(v)) return;
    if (v < this.min) this.min = v;
    if (v > this.max) this.max = v;
    this.sum += v;
    this.count += 1;
  }
  finish(): StatBand {
    if (this.count === 0) {
      return { min: 0, max: 0, mean: 0, samples: 0 };
    }
    return {
      min: this.min,
      max: this.max,
      mean: this.sum / this.count,
      samples: this.count,
    };
  }
}

/**
 * Deterministic PRNG (mulberry32). Replaces Math.random for the duration of
 * the simulation so each seed produces a reproducible life.
 */
function mulberry32(seed: number): () => number {
  let t = seed >>> 0;
  return function () {
    t = (t + 0x6d2b79f5) >>> 0;
    let r = Math.imul(t ^ (t >>> 15), 1 | t);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}

function makePlayer(opts: SimulatorOptions): Player {
  const character = new Person({
    id: 'sim-char-1',
    firstname: opts.characterName ?? 'Sim',
    lastname: 'Runner',
    sex: opts.characterSex ?? 'Female',
    ageYears: opts.characterAge ?? 5,
    ageDays: (opts.characterAge ?? 5) * 365,
    ageHours: (opts.characterAge ?? 5) * 365 * 24,
    energy: 100,
    hunger: 10,
    thirst: 10,
    peakEnergy: 0,
    calcEnergy: 100,
    health: 100,
    mood: 70,
    money: 0,
    status: 'alive',
  } as any);

  return new Player({
    userId: `sim-${opts.seed}`,
    character,
    status: 'playing',
    controller: 'active',
    connection: 'connected',
    hourOfDay: 8,
    minuteOfHour: 0,
    dayOfYear: 1,
    dayOfWeek: 1,
    monthOfYear: 1,
    date: '01-01',
    season: 'winter',
    gameSpeed: 10,
  });
}

function makeFakeWebSocket(
  sink: SentMessageTrace[],
  captureAll: boolean,
  tickRef: { n: number }
): WebSocket {
  return {
    readyState: 1, // ws.OPEN
    send: (data: unknown) => {
      let parsed: unknown = data;
      if (typeof data === 'string') {
        try {
          parsed = JSON.parse(data);
        } catch {
          parsed = data;
        }
      }
      const type =
        (parsed && typeof parsed === 'object' && 'type' in (parsed as object)
          ? String((parsed as { type: unknown }).type)
          : 'unknown') ?? 'unknown';
      if (captureAll || type === 'event_prompt' || type === 'event_resolved') {
        sink.push({ tick: tickRef.n, type, raw: parsed });
      }
    },
  } as unknown as WebSocket;
}

/**
 * Save/load round-trip check. Serializes the player via toJSON(), parses it
 * through JSON.stringify+parse (simulates DB write+read), constructs a new
 * Player, and asserts critical fields are preserved.
 *
 * This is deliberately STRICT on fields that must survive a server restart.
 * Fields that are game-loop-transient (like messageQueue contents) are not
 * checked. Fields that are semantic state (age, money, events Set, stats)
 * MUST be identical.
 */
function checkSaveLoadRoundTrip(
  player: Player,
  tickN: number
): InvariantViolation[] {
  const violations: InvariantViolation[] = [];
  let rehydrated: Player;
  try {
    const json = player.toJSON();
    // Simulate MySQL serialization path: object → JSON string → parsed → new Player.
    const serialized = JSON.stringify(json);
    const parsed = JSON.parse(serialized);
    rehydrated = new Player(parsed as any);
  } catch (err) {
    violations.push({
      tick: tickN,
      rule: 'save_load_error',
      detail: `toJSON/ctor threw: ${err instanceof Error ? err.message : String(err)}`,
    });
    return violations;
  }

  const fieldsToCheck: Array<[string, unknown, unknown]> = [
    ['userId', player.userId, rehydrated.userId],
    ['status', player.status, rehydrated.status],
    ['controller', player.controller, rehydrated.controller],
    ['hourOfDay', player.hourOfDay, rehydrated.hourOfDay],
    ['minuteOfHour', player.minuteOfHour, rehydrated.minuteOfHour],
    ['dayOfYear', player.dayOfYear, rehydrated.dayOfYear],
    ['dayOfWeek', player.dayOfWeek, rehydrated.dayOfWeek],
    ['season', player.season, rehydrated.season],
    ['weather', player.weather, rehydrated.weather],
    ['c.ageYears', player.c?.ageYears, rehydrated.c?.ageYears],
    ['c.ageDays', player.c?.ageDays, rehydrated.c?.ageDays],
    ['c.ageHours', player.c?.ageHours, rehydrated.c?.ageHours],
    ['c.energy', player.c?.energy, rehydrated.c?.energy],
    ['c.hunger', player.c?.hunger, rehydrated.c?.hunger],
    ['c.thirst', player.c?.thirst, rehydrated.c?.thirst],
    ['c.health', player.c?.health, rehydrated.c?.health],
    ['c.money', player.c?.money, rehydrated.c?.money],
    ['c.status', player.c?.status, rehydrated.c?.status],
    ['events.size', player.events.size, rehydrated.events.size],
    ['askedQuestions.size', player.askedQuestions.size, rehydrated.askedQuestions.size],
  ];

  for (const [name, before, after] of fieldsToCheck) {
    if (before !== after) {
      violations.push({
        tick: tickN,
        rule: 'save_load_roundtrip',
        detail: `${name}: before=${JSON.stringify(before)} after=${JSON.stringify(after)}`,
      });
    }
  }

  // Events Set identity: every entry in before must be in after.
  for (const fname of player.events) {
    if (!rehydrated.events.has(fname)) {
      violations.push({
        tick: tickN,
        rule: 'save_load_events_missing',
        detail: `event fname "${fname}" lost in round-trip`,
      });
    }
  }

  return violations;
}

function checkInvariants(
  player: Player,
  tickN: number
): InvariantViolation[] {
  const violations: InvariantViolation[] = [];

  if (!player.c) {
    violations.push({
      tick: tickN,
      rule: 'character_exists',
      detail: 'player.c is null/undefined',
    });
    return violations; // everything else assumes player.c
  }

  const numeric = (name: string, value: unknown): number | null => {
    if (value === undefined || value === null) return null;
    if (typeof value !== 'number') {
      violations.push({
        tick: tickN,
        rule: 'numeric_type',
        detail: `${name} is ${typeof value}: ${String(value)}`,
      });
      return null;
    }
    if (Number.isNaN(value)) {
      violations.push({
        tick: tickN,
        rule: 'no_nan',
        detail: `${name} is NaN`,
      });
      return null;
    }
    return value;
  };

  const stats: Array<[string, unknown]> = [
    ['hunger', player.c.hunger],
    ['thirst', player.c.thirst],
    ['health', player.c.health],
    ['mood', player.c.mood],
    ['energy', player.c.energy],
  ];
  for (const [name, raw] of stats) {
    const v = numeric(`c.${name}`, raw);
    if (v === null) continue;
    if (v < 0 || v > 100) {
      violations.push({
        tick: tickN,
        rule: 'stat_bounds',
        detail: `c.${name} = ${v} (expected 0..100)`,
      });
    }
  }

  // calcEnergy can legitimately exceed peakEnergy offset range; just require non-negative and non-NaN.
  numeric('c.calcEnergy', player.c.calcEnergy);

  // Money has no upper bound but should be a finite number.
  numeric('c.money', player.c.money);

  // player.events must be a Set
  if (!(player.events instanceof Set)) {
    violations.push({
      tick: tickN,
      rule: 'events_set',
      detail: `player.events is not a Set (got ${typeof player.events})`,
    });
  }

  return violations;
}

function pickChoice(
  prompt: EventPromptEnvelope,
  responder: SimulatorOptions['questionResponder'],
  currentMoney: number
): string | null {
  if (!prompt.choices || prompt.choices.length === 0) return null;

  // Affordability is computed from the FULL registry definition (which carries
  // `effects.resources.money`), NOT the envelope (which only carries top-level
  // moneyCost). See the definitionChoicesById comment above. Fall back to the
  // envelope choice if a definition isn't found (defensive — shouldn't happen).
  const defChoices = definitionChoicesById.get(prompt.eventId);
  const fullChoiceFor = (choiceId: string | undefined): EventChoice | undefined => {
    const fromDef = defChoices?.find((c) => c.choiceId === choiceId);
    return fromDef ?? prompt.choices.find((c: EventChoice) => c.choiceId === choiceId);
  };

  // A choice is affordable when its NET money delta wouldn't drive the balance
  // negative. Computed exactly like the EventResponder INSUFFICIENT_FUNDS gate
  // (respond.ts): prefer effects.resources.money, else -moneyCost, else 0.
  // Mirrors the iOS client which disables unaffordable money choices, so the
  // simulator only "taps" choices a real player could tap.
  const isAffordable = (choiceId: string | undefined): boolean => {
    const choice = fullChoiceFor(choiceId);
    if (!choice) return false;
    const moneyDelta =
      choice.effects?.resources?.money !== undefined
        ? choice.effects.resources.money
        : choice.moneyCost
          ? -choice.moneyCost
          : 0;
    return !(moneyDelta < 0 && currentMoney + moneyDelta < 0);
  };

  if (responder) {
    try {
      const chosen = responder(prompt);
      const match = prompt.choices.find((c: EventChoice) => c.choiceId === chosen);
      // Only honor the responder's pick if the player can actually afford it.
      if (match && isAffordable(match.choiceId)) return match.choiceId;
    } catch {
      // fall through to default
    }
  }

  // Default to the first affordable choice (dilemmas should always include a
  // free/energy option). If somehow none are affordable, fall back to the first
  // choice and let the gate reject it — that's a genuine content gap worth
  // surfacing as an error, not something to silently hide.
  const affordable = prompt.choices.find((c: EventChoice) => isAffordable(c.choiceId));
  return (affordable ?? prompt.choices[0]).choiceId;
}

/**
 * Simulate a single lifetime under the given options.
 *
 * This function:
 *   1. Creates a Player and a fake WebSocket
 *   2. Seeds Math.random from opts.seed (deterministic)
 *   3. Drives advanceMinute() in a loop until death, max-minutes, or an error
 *   4. Answers every event_prompt it sees via questionResponder (or first choice)
 *   5. Runs invariant checks after every tick
 */
export async function simulateLifetime(
  opts: SimulatorOptions
): Promise<SimulatorResult> {
  const sink: SentMessageTrace[] = [];
  const errors: ErrorTrace[] = [];
  const invariantViolations: InvariantViolation[] = [];
  const tickRef = { n: 0 };
  const captureAll = opts.captureAllMessages ?? false;

  const maxMinutes = opts.maxMinutes ?? 100 * 365 * 24 * 60;

  // Deterministic PRNG for this run. Restored in finally.
  const rand = mulberry32(opts.seed);
  const originalRandom = Math.random;
  Math.random = rand;

  const messageTypeCounts: Record<string, number> = {};

  try {
    const player = makePlayer(opts);
    const ws = makeFakeWebSocket(sink, captureAll, tickRef);
    const session = new PlayerSession(ws, player);

    // ── Trajectory accumulators (sampled once per in-game day) ──
    const hungerBand = new BandAccumulator();
    const thirstBand = new BandAccumulator();
    const energyBand = new BandAccumulator();
    const calcEnergyBand = new BandAccumulator();
    const healthBand = new BandAccumulator();
    let daysAtZeroCalcEnergy = 0;
    const yearlyMoney: YearlySample[] = [];
    let lastSampledYear = -1;
    const MINUTES_PER_DAY = 24 * 60;

    // ── Event density ──
    // Felt cadence = event_prompt (interactive) + event_resolved (passive).
    // `feltByDay` buckets ALL felt beats by in-game day (peak/burst detector).
    // `feltByMonth` buckets by a monotonic in-game month index (30-day months),
    // and `monthAge` records the character age at each month so we can classify
    // mid-life (25-65) dead-air. `interactivePrompts` keeps the original
    // event_prompt-only count for baseline continuity.
    const feltByDay = new Map<number, number>();
    const feltByMonth = new Map<number, number>();
    const monthAge = new Map<number, number>();
    let totalPrompts = 0; // felt cadence (both kinds)
    let interactivePrompts = 0; // event_prompt only
    const MINUTES_PER_MONTH = MINUTES_PER_DAY * 30;

    // ── Reward cadence counters (reward-flavored WS message types) ──
    const reward = { lifeGoalUpdates: 0, questEvents: 0, achievementUnlocks: 0, diamondAwards: 0 };

    // Always-on counting sink — wraps the fake WS send to count by type without
    // keeping the raw payloads (captureAll controls that separately).
    const originalSend = (ws as any).send.bind(ws);
    (ws as any).send = (data: unknown) => {
      let parsed: unknown = data;
      if (typeof data === 'string') {
        try {
          parsed = JSON.parse(data);
        } catch {
          parsed = data;
        }
      }
      const t =
        (parsed && typeof parsed === 'object' && 'type' in (parsed as object)
          ? String((parsed as { type: unknown }).type)
          : 'unknown') ?? 'unknown';
      messageTypeCounts[t] = (messageTypeCounts[t] ?? 0) + 1;

      // Event density: a "felt" beat is any player-facing event modal —
      // event_prompt (interactive) OR event_resolved (passive ambient). Bucket
      // by in-game day (burst) and month (dead-air), recording age per month.
      if (t === 'event_prompt' || t === 'event_resolved') {
        totalPrompts += 1;
        if (t === 'event_prompt') interactivePrompts += 1;
        const day = Math.floor(tickRef.n / MINUTES_PER_DAY);
        feltByDay.set(day, (feltByDay.get(day) ?? 0) + 1);
        const month = Math.floor(tickRef.n / MINUTES_PER_MONTH);
        feltByMonth.set(month, (feltByMonth.get(month) ?? 0) + 1);
        if (!monthAge.has(month)) {
          monthAge.set(month, player.c?.ageYears ?? 0);
        }
      }

      // Reward cadence: count reward-flavored message types.
      if (t === 'lifeGoalsUpdate') reward.lifeGoalUpdates += 1;
      else if (t === 'questProgress' || t === 'questRewardClaimed') reward.questEvents += 1;
      else if (t === 'achievementUnlocked') reward.achievementUnlocks += 1;
      else if (t === 'dailyRewardClaimed' || t === 'diamondsAwarded' || t === 'diamondAward')
        reward.diamondAwards += 1;

      originalSend(data);
    };

    const runtime = getEventRuntime();
    const eventsFired = new Set<string>();
    let pendingPromptAt = -1;

    // ── Employment driver (Part 1) ───────────────────────────────────────────
    // The autonomous character never applies for a job on its own, so without
    // this the economy is never exercised (money pins at 0). Once per in-game
    // day we nudge it into / out of the workforce via the REAL career path:
    //   - From employmentAge until retirementAge: if it has no job (first time,
    //     or after being fired), grant the credential and apply via applyForJob.
    //   - At retirementAge: it stops re-applying; if still employed it retires
    //     (occupation 'retired', salary 0) so retirement-era cashflow is the
    //     drawdown of accumulated savings (no wages, rent floor still owed).
    const driveEmployment = opts.driveEmployment ?? true;
    const employmentAge = opts.employmentAge ?? 22;
    const retirementAge = opts.retirementAge ?? 65;
    const employmentEducation = opts.employmentEducation ?? 'bachelors_degree';
    const employmentJobTitle = opts.employmentJobTitle ?? 'Accountant';
    let retired = false;
    const maybeDriveEmployment = (): void => {
      if (!driveEmployment) return;
      const c = player.c;
      if (!c) return;
      const age = c.ageYears ?? 0;

      // Retirement: once past the retirement age, stop working. Clearing the
      // job means weekly finances stop paying wages but the rent floor still
      // applies, so retirement is a realistic savings drawdown.
      if (age >= retirementAge) {
        if (!retired) {
          retired = true;
          c.occupation = 'retired';
          c.salary = 0;
          c.job = null;
        }
        return;
      }

      if (age < employmentAge) return;

      // Already holding a job? Nothing to do (handleJob runs promotions/firing
      // through the real weekly path).
      const hasJob = !!c.job && c.occupation === 'work';
      if (hasJob) return;

      // (Re)enter the workforce via the REAL application path. Ensure the
      // credential is present so applyForJob's education gate passes; this
      // mirrors a graduate applying for work.
      if (!c.education || c.education === '' || c.education === 'none') {
        c.education = employmentEducation;
      }
      const occupation =
        getOccupations().find((o) => o.title === employmentJobTitle) ?? getOccupations()[0];
      if (occupation) {
        // applyForJob reads player.c.education and sets job/occupation/salary,
        // creates coworkers, and tracks retention stats — the same code the
        // iOS client triggers through the applyForJob handler.
        applyForJob(player, occupation.id);

        // OPT-IN engaged-worker model. Default (workFocus undefined) leaves the
        // passive behavior untouched. When set, persist the focus on the freshly
        // created JobRecord via the REAL handler so the weekly handleJob path
        // applies the perf modifier (Work Hard = +2) and promotions/raises fire.
        if (opts.workFocus) {
          setJobFocus(c, opts.workFocus);
        }
      }
    };

    // Build the trajectory summary from the running accumulators. Called at each
    // return site so death and max-minute exits both carry full metrics.
    const buildTrajectory = (minutesSimulated: number): TrajectorySummary => {
      const totalDays = Math.max(1, Math.ceil(minutesSimulated / MINUTES_PER_DAY));
      const peakSingleDay = feltByDay.size ? Math.max(...feltByDay.values()) : 0;

      // Mid-life dead-air: iterate every observed month (monthAge is stamped at
      // daily sampling for ALL months, including silent ones). A mid-life month
      // (age 25-65) with zero felt events counts as dead-air.
      //
      // Exclude the FINAL partial month — the one truncated by death (or the sim
      // horizon). The character didn't live that month to its end, so its event
      // count is artificially low and must not register as dead-air. (Driving
      // employment shifts the RNG stream and can shorten a life, leaving a
      // partial last month; that truncation is not a content gap.)
      const finalMonth = Math.floor(Math.max(0, minutesSimulated - 1) / MINUTES_PER_MONTH);
      let midlifeZeroMonths = 0;
      let midlifeMonths = 0;
      for (const [month, age] of monthAge) {
        if (age < 25 || age > 65) continue;
        if (month === finalMonth) continue; // partial (death/horizon) month
        midlifeMonths += 1;
        if ((feltByMonth.get(month) ?? 0) === 0) midlifeZeroMonths += 1;
      }

      return {
        hunger: hungerBand.finish(),
        thirst: thirstBand.finish(),
        energy: energyBand.finish(),
        calcEnergy: calcEnergyBand.finish(),
        health: healthBand.finish(),
        daysAtZeroCalcEnergy,
        yearlyMoney,
        promptDensity: {
          totalPrompts,
          interactivePrompts,
          totalDays,
          perDayMean: totalPrompts / totalDays,
          perWeekMean: totalPrompts / (totalDays / 7),
          perMonthMean: totalPrompts / (totalDays / 30),
          perYearMean: totalPrompts / (totalDays / 365),
          peakSingleDay,
          midlifeZeroMonths,
          midlifeMonths,
        },
        rewardCadence: {
          lifeGoalUpdates: reward.lifeGoalUpdates,
          questEvents: reward.questEvents,
          achievementUnlocks: reward.achievementUnlocks,
          diamondAwards: reward.diamondAwards,
          total:
            reward.lifeGoalUpdates +
            reward.questEvents +
            reward.achievementUnlocks +
            reward.diamondAwards,
        },
      };
    };

    for (let minute = 0; minute < maxMinutes; minute++) {
      tickRef.n = minute;

      // Advance one game minute directly (bypasses setInterval).
      try {
        await (session as unknown as { advanceMinute(): Promise<void> | void }).advanceMinute();
      } catch (err) {
        errors.push({
          tick: minute,
          phase: 'advance',
          error: err instanceof Error ? err : new Error(String(err)),
        });
        break;
      }

      // Invariant check (every minute — these are cheap)
      try {
        const v = checkInvariants(player, minute);
        if (v.length > 0) {
          invariantViolations.push(...v);
          // Don't break — keep collecting so we see all issues in one pass.
        }
      } catch (err) {
        errors.push({
          tick: minute,
          phase: 'invariant',
          error: err instanceof Error ? err : new Error(String(err)),
        });
      }

      // Save/load round-trip check (once per game day — expensive).
      // Catches state that can't survive a server restart.
      if (minute > 0 && minute % (24 * 60) === 0) {
        try {
          const v = checkSaveLoadRoundTrip(player, minute);
          if (v.length > 0) {
            invariantViolations.push(...v);
          }
        } catch (err) {
          errors.push({
            tick: minute,
            phase: 'invariant',
            error: err instanceof Error ? err : new Error(String(err)),
          });
        }
      }

      // Trajectory sampling (once per in-game day — cheap, keeps runtime flat).
      // Sample at the END of the simulated day so values reflect a full day's
      // hunger/thirst build-up and meal sink.
      if (minute % MINUTES_PER_DAY === 0) {
        const c = player.c;
        // Drive employment via the real career path so the economy is actually
        // exercised (otherwise the character never works and money pins at 0).
        maybeDriveEmployment();
        // Stamp the age for this in-game month so the dead-air detector can
        // classify EVERY month (including silent ones) as mid-life or not.
        const monthIdx = Math.floor(minute / MINUTES_PER_MONTH);
        if (!monthAge.has(monthIdx)) {
          monthAge.set(monthIdx, c.ageYears ?? 0);
        }
        hungerBand.add(c.hunger);
        thirstBand.add(c.thirst);
        energyBand.add(c.energy);
        calcEnergyBand.add(c.calcEnergy);
        healthBand.add(c.health);
        if ((c.calcEnergy ?? 0) <= 0) daysAtZeroCalcEnergy += 1;

        // Money / net worth sampled once per in-game year (365 days).
        const year = Math.floor(minute / (MINUTES_PER_DAY * 365));
        if (year !== lastSampledYear) {
          lastSampledYear = year;
          const money = c.money ?? 0;
          let itemValue = 0;
          for (const item of (c as unknown as { items?: Array<{ value?: number; price?: number }> }).items ?? []) {
            itemValue += item.value ?? item.price ?? 0;
          }
          yearlyMoney.push({
            year,
            ageYears: c.ageYears ?? 0,
            money,
            netWorth: money + itemValue,
            diamonds: (c as unknown as { diamonds?: number }).diamonds ?? 0,
            prestige: (c as unknown as { prestige?: number }).prestige ?? 0,
            occupation: (c as unknown as { occupation?: string }).occupation ?? '',
            salary: (c as unknown as { salary?: number }).salary ?? 0,
          });
        }
      }

      // If any event_prompt landed in the sink, answer it (newest first).
      for (let i = sink.length - 1; i >= 0; i--) {
        const msg = sink[i];
        if (msg.tick === pendingPromptAt) break;
        if (msg.type !== 'event_prompt') continue;

        const envelope = msg.raw as EventPromptEnvelope;
        const currentMoney =
          typeof player.c.money === 'number' && Number.isFinite(player.c.money)
            ? player.c.money
            : Number(player.c.money) || 0;
        const choiceId = pickChoice(envelope, opts.questionResponder, currentMoney);
        if (!choiceId) break;

        try {
          // EventResponseInput expects `choiceId`, not `selectedChoiceId`.
          // Mismatching keys silently returns event_error and leaves the
          // instance pending forever, which suppresses all future events.
          const response = await runtime.respond(player as any, {
            eventId: envelope.eventId,
            choiceId,
          } as any);
          if (response && 'type' in response && response.type === 'event_resolved') {
            eventsFired.add(envelope.eventId);
            // The online path pauses the loop when a prompt is sent
            // (PlayerSession sets gameSpeed = SPEED_QUESTION_PAUSE). Production
            // resumes in handlers/events.ts on event_resolved; this harness
            // calls runtime.respond directly, so it must mirror that resume or
            // the loop stays paused and no further events ever fire.
            if (player.previousGameSpeed !== undefined) {
              player.gameSpeed = player.previousGameSpeed;
            } else {
              player.gameSpeed = config.SPEED_DEFAULT;
            }
          } else if (response && 'type' in response && response.type === 'event_error') {
            errors.push({
              tick: minute,
              phase: 'respond',
              error: new Error(
                `event_error: ${(response as any).code} — ${(response as any).message}`
              ),
            });
          }
        } catch (err) {
          errors.push({
            tick: minute,
            phase: 'respond',
            error: err instanceof Error ? err : new Error(String(err)),
          });
        }

        pendingPromptAt = msg.tick;
        break; // handle one prompt per minute, consistent with real UI
      }

      // Termination conditions
      if (player.c.status === 'dead') {
        return {
          seed: opts.seed,
          finalAge: player.c.ageYears ?? 0,
          minutesSimulated: minute + 1,
          completed: true,
          deathCause: 'natural',
          eventsFired: [...eventsFired],
          messageTypeCounts,
          errors,
          invariantViolations,
          sentMessages: captureAll ? sink : undefined,
          trajectory: buildTrajectory(minute + 1),
        };
      }
    }

    return {
      seed: opts.seed,
      finalAge: player.c.ageYears ?? 0,
      minutesSimulated: maxMinutes,
      completed: false,
      eventsFired: [...eventsFired],
      messageTypeCounts,
      errors,
      invariantViolations,
      sentMessages: captureAll ? sink : undefined,
      trajectory: buildTrajectory(maxMinutes),
    };
  } finally {
    Math.random = originalRandom;
  }
}
