/**
 * `studio-ops-v1` — running a studio, not walking a robot around one.
 *
 * The five environments before this one each isolate a single controller: a
 * car, a walker, a robot, a crow, an aircraft. Each is honest and each is
 * narrow, and a policy that solves one has learned one thing. This is the
 * environment the whole build is framed as, and its point is that the variables
 * are *coupled*: a robot working a job, the microphone and speaker on the desk
 * it passes, the weather over the roof, the car on the apron waiting to leave,
 * the aircraft crossing overhead, and one shared energy reserve that all of
 * them draw from.
 *
 * ### It wraps the simulators the renderer drives
 *
 * Non-negotiable, and the reason this environment can be believed at all:
 *
 * | Wrapped | Where the browser uses it |
 * |---|---|
 * | `Plan(LUMBRIDGE_HQ \| MATEO_COURT)` | `interiors/officeScene.ts` builds the room from it |
 * | `resolveRobotOperations` + `createRobotActivity` | the robots a visitor watches |
 * | `createSimulatedDevices` | the mic and speaker panel, and the LEDs on the hardware |
 * | `createSimulatedVehicleTelemetry` | the Model X parked on the apron |
 * | `solarPosition` | `engine/atmosphere.ts`'s light rig |
 *
 * Not one line of physics is restated here. A headless reimplementation "for
 * the trainer" would be a simulation nobody can look at, trained against a
 * picture nobody can reproduce; the entire value of an environment built inside
 * a renderer is that the thing being optimised is the thing being shown.
 *
 * ### Determinism, and the arithmetic it forbids
 *
 * `step()` reads no clock, no network and no `Math.random`. Everything that
 * moves is a pure function of (scenario parameters, step index) or of a seeded
 * simulator that this file advances by exactly one fixed step.
 *
 * That is necessary and not sufficient, because this environment is the first
 * to put transcendental arithmetic — a solar position, a set of slant ranges —
 * into a state that is compared for *exact equality* by a verifier that may be
 * running on entirely different hardware. `Math.sin` is not required to be
 * correctly rounded by IEEE-754 or by ECMA-262; `+`, `-`, `*`, `/` and
 * `Math.sqrt` are. So:
 *
 *   - every transcendental result is passed through `quantizeObservable`
 *     *before* it is observed, rewarded on, or fed forward;
 *   - everything downstream of one uses only the exact operations;
 *   - `Math.hypot` is avoided in favour of `Math.sqrt(a * a + b * b)`, because
 *     hypot is a library function with no correctly-rounded guarantee and the
 *     two-line version has one.
 *
 * `checksum.ts` explains the rest of the argument and holds the second half of
 * the defence.
 *
 * ### The scenario is the weather
 *
 * Weather, time of day and the traffic overhead are **scenario parameters with
 * a deterministic in-environment evolution**, never a fetch. A real NWS
 * observation may be the *source* of a scenario — captured once, frozen into
 * `cloudCoverBase`, `windKphBase` and the rest, and marked `weatherReported` —
 * which is how an operator gets partially-real inputs without giving up
 * replay. A network read inside `step()` would make `replay()` impossible and
 * would make every published trace unverifiable a day later. None of the
 * scenarios shipped here is a capture: they are invented profiles, and
 * `weatherReported` is `false` on every one of them, which is the honest thing
 * for that flag to say until somebody actually freezes an observation into it.
 */

import { chargeTaper, createSimulatedVehicleTelemetry } from "../transport/vehicleTelemetry.ts";
import type { SimulatedVehicleTelemetry } from "../transport/vehicleTelemetry.ts";
import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../devices/types.ts";
import { createSimulatedDevices, type SimulatedDevices } from "../devices/sim.ts";
import { solarPosition } from "../engine/solar.ts";
import { Plan } from "../interiors/plan.ts";
import type { Office } from "../interiors/types.ts";
import {
  createRobotActivity,
  type RobotActivityController,
  type RobotActivitySnapshot,
  type RobotActivityState,
} from "../interiors/robotActivity.ts";
import {
  resolveRobotOperations,
  type ResolvedRobotOperations,
  type RobotJobKind,
} from "../interiors/robotOperations.ts";
import { LUMBRIDGE_HQ } from "../offices/lumbridge-hq.ts";
import { MATEO_COURT } from "../offices/mateo-court.ts";
import { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "../offices/operations/lumbridge-hq.ts";
import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { quantizeToPlaces } from "./checksum.ts";
import { deriveArenaSeed } from "./random.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import {
  ARENA_API_VERSION,
  type ArenaFieldSpec,
  type ArenaManifest,
  type ArenaScenario,
} from "./types.ts";

// ---- Action ---------------------------------------------------------------

/**
 * One struct, nine fields, every one of them clamped in `normalizeAction`.
 *
 * Note what is *not* here: device power. A studio's hardware being switched on
 * is a fact about the episode rather than a decision inside it — the rig is
 * live when the day starts — and the interesting choices are the ones an
 * operator actually makes with a live rig: what is muted, how much gain, what
 * is playing and how loud. Both devices are powered at `reset` and the mute is
 * the lever. It also keeps the action space honest: an environment whose
 * optimal policy begins with two mandatory "turn it on" presses is one whose
 * first two steps carry no information.
 */
export interface StudioOpsAction {
  /** Planar drive demand for the robot, normalized to the unit disc. */
  x: number;
  z: number;
  /** Work the current job station. Only legal inside the station radius. */
  interact: boolean;
  /** Microphone gain setpoint, dB, clamped to `DEVICE_RANGES.gain`. */
  micGain: number;
  micMute: boolean;
  /** Speaker volume setpoint, 0..1, clamped to `DEVICE_RANGES.volume`. */
  speakerVolume: number;
  speakerPlay: boolean;
  /** Run the car's climate control toward its comfort setpoint. */
  vehiclePrecondition: boolean;
  /** Plug the car in. It draws from the same reserve everything else does. */
  vehicleCharge: boolean;
}

// ---- Observation ----------------------------------------------------------

/**
 * Forty-four flat, JSON-safe fields in five groups.
 *
 * Two of them are additions to the field list the build spec fixed, and both
 * exist for the same reason: a terminal an agent cannot see coming is not a
 * task, it is a trap. `energyReservePct` is what `battery-depleted` counts down
 * and `stepsToDeparture` is what `departure-missed` counts down, and without
 * them a policy could only learn those two failures as unexplained episode
 * endings. Everything else is exactly as specified.
 */
export interface StudioOpsObservation {
  // -- the agent and its job
  officeId: string;
  levelId: string;
  x: number;
  z: number;
  mode: string;
  phase: string;
  jobKind: RobotJobKind | "none";
  payload: "parcel" | null;
  battery: number;
  jobProgress: number;
  nextStationId: string;
  nextX: number;
  nextZ: number;
  deltaX: number;
  deltaZ: number;
  distanceToNextM: number;
  canInteract: boolean;
  blockedStreak: number;

  // -- the sky over the roof
  /** Local *mean solar* hours, 0..24. See `hourOfDay` below for why solar. */
  hourOfDay: number;
  sunAltitudeDeg: number;
  sunAzimuthDeg: number;
  cloudCover: number;
  precipitation: number;
  visibilityKm: number;
  windKph: number;
  windDirDeg: number;
  weatherCondition: string;
  /** True only where the scenario was frozen from a real observation. */
  weatherReported: boolean;

  // -- the hardware on the desk
  micPowered: boolean;
  micGainDb: number;
  micLevelDb: number;
  micMuted: boolean;
  speakerPowered: boolean;
  speakerVolume: number;
  speakerPlaying: boolean;
  /** Whether anything is at the desk this microphone serves. */
  deskOccupied: boolean;

  // -- the car on the apron
  vehicleSocPct: number;
  vehicleCabinC: number;
  vehiclePluggedIn: boolean;
  vehicleReadyByDeparture: boolean;

  // -- traffic overhead
  aircraftOverheadCount: number;
  nearestAircraftSlantM: number;

  // -- the shared reserve and the clock on the departure
  energyReservePct: number;
  stepsToDeparture: number;
}

// ---- Reward ---------------------------------------------------------------

/**
 * Thirteen components, summed by `base.ts` and never authored as a total.
 *
 * The counterweighting is the design. Every dense positive here has something
 * pulling the other way, because a reward with only progress in it is a reward
 * whose optimum is a behaviour nobody asked for:
 *
 * - `navigation` and `job` push toward the station; `time` and `control` make
 *   dawdling and thrashing cost.
 * - `audioReady` pays for a live microphone at an occupied desk; `audioWaste`
 *   charges for a hot one at an empty desk, so "unmute and forget" is not a
 *   strategy. Both cost `energy`.
 * - `energy` charges for everything drawn, priced up as `cloudCover` rises so
 *   a sunny hour is genuinely cheaper than an overcast one — the reserve is
 *   physically replenished by the roof, and the marginal cost of drawing from
 *   it depends on whether the sun is doing that replenishing.
 * - `vehicleReady` is **potential-based**: it pays the *change* in a bounded
 *   readiness rather than the level, so it telescopes over an episode and
 *   cannot be farmed by plugging and unplugging. Charging is very cheap in
 *   time and expensive in reserve, which is the whole decision.
 * - `noise` is the cross-variable term and the reason this is one environment
 *   rather than five: playing the speaker is worth something at an occupied
 *   desk and costs more the closer an aircraft is, the harder the wind blows,
 *   and the more of it a live microphone is picking up. A policy cannot decide
 *   whether to press play without reading the sky.
 * - `collision`, `interaction` and `safety` are the failure costs.
 *
 * Nothing here is unbounded, and no component can be driven by an action that
 * costs nothing.
 */
export type StudioOpsReward = Record<
  | "navigation" | "job" | "success" | "audioReady" | "audioWaste" | "energy"
  | "vehicleReady" | "noise" | "time" | "control" | "collision" | "interaction" | "safety",
  number
>;

// ---- Scenario parameters --------------------------------------------------

export interface StudioOpsScenarioParameters {
  officeId: "lumbridge-hq" | "mateo-court";
  robotId: string;
  jobId: string;
  /** Which microphone and speaker this episode's operator is holding. */
  micId: string;
  speakerId: string;

  /** Simulated wall-clock at step 0, epoch ms. Drives the solar position. */
  startEpochMs: number;
  /** A label for the weather profile. Never parsed; it names the fixture. */
  weatherProfileId: string;
  /** True only if the profile below was captured from a real observation. */
  weatherReported: boolean;
  cloudCoverBase: number;
  precipitationBase: number;
  windKphBase: number;
  windDirDeg: number;
  visibilityKm: number;
  ambientC: number;
  /** Phases of the four weather oscillators, 0..1. Drawn by the sampler. */
  cloudPhase: number;
  precipitationPhase: number;
  windPhase: number;
  windDirPhase: number;

  /** Seeds the overflight schedule. Jittered by the sampler. */
  aircraftScheduleSeed: number;

  /** The car, pinned rather than seeded, so a departure target is reachable. */
  vehicleInitialSocPct: number;
  vehiclePluggedInAtStart: boolean;
  departureStep: number;
  /** Derived by the sampler as `vehicleInitialSocPct + SOC_TARGET_MARGIN_PCT`. */
  departureSocPct: number;

  /** The studio's stored energy allowance for this episode, kWh. */
  energyReserveKWh: number;
}

interface StudioOpsSimulationSnapshot {
  activity: RobotActivitySnapshot;
  devices: unknown;
  vehicle: unknown;
  elapsedSteps: number;
  energyReserveKWh: number;
  previousDistanceM: number;
  previousProgress: number;
  previousReadiness: number;
  blockedStreak: number;
  wrongInteractions: number;
  initialCompletedJobs: number;
  departureResolved: boolean;
}

// ---- Constants ------------------------------------------------------------

const FIXED_STEP = 0.1;
const MAX_STEPS = 1200;

/**
 * Decimals kept on anything a transcendental produced.
 *
 * Six, which for a solar altitude in degrees is a micro-degree — four
 * millimetres of arc at the sun's distance, and about eight orders of magnitude
 * coarser than the last-place disagreement two engines can have about
 * `Math.sin`. Fine enough that no reward shaper can see the rounding; coarse
 * enough that the rounding is what two runs agree on.
 */
const OBSERVABLE_DECIMALS = 6;

/** Inside `robotActivity`'s 0.30 m arrival radius, as `office-jobs-v1` argues. */
const INTERACTION_RADIUS_M = 0.295;
const WRONG_INTERACTION_LIMIT = 8;

/**
 * How close the robot has to be to a seat before the desk reads as occupied.
 *
 * A metre and a fifth: a body's reach of a desk, and comfortably outside the
 * robot's own 0.28 m radius so arriving at a station next to a seat is not the
 * same event as sitting at it.
 *
 * **This is an observation, not a claim about a person.** The only body in this
 * building is the robot the policy is driving, and occupancy is fed to
 * `createSimulatedDevices` as the input that module documents it as — the same
 * `setOccupancy` a deployment with a real presence source would use. The
 * microphone's meter responds to whether something is at the desk in front of
 * it, which is what a microphone does.
 */
const OCCUPANCY_RADIUS_M = 1.2;

// -- the studio's energy reserve
//
// The reserve is the episode's energy *allowance*, not a claim about a
// building's battery. Two minutes of simulated time is far too short for a real
// site battery to matter, so the allowance is sized deliberately: a studio at
// rest cannot come close to exhausting it, and a studio charging a car off it
// runs out with a couple of hundred steps to spare. That is a design decision
// about where the trade-off should bite, stated here rather than dressed up as
// a specification.

/** Full charge of the reserve, kWh, and its value at step 0. */
const SITE_ENERGY_CAPACITY_KWH = 3;
/** Lights, network, air handling and everything else that is simply on. */
const SITE_BASE_LOAD_KW = 0.42;
/** Building climate: a fixed cost plus work proportional to the gap outside. */
const SITE_HVAC_BASE_KW = 0.35;
const SITE_HVAC_KW_PER_KELVIN = 0.12;
const SITE_HVAC_MAX_KW = 3.5;
const SITE_COMFORT_C = 21;
/** A powered microphone, and a powered speaker plus its programme at full. */
const MIC_DRAW_KW = 0.012;
const SPEAKER_IDLE_KW = 0.03;
const SPEAKER_PROGRAMME_KW = 0.22;
/** Peak roof generation, kW, and how much of it an overcast sky takes away. */
const PV_PEAK_KW = 14;
const PV_CLOUD_LOSS = 0.86;

// -- weather evolution
//
// Periods in seconds, chosen to be mutually prime-ish and short enough that
// something visibly moves inside a 120-second episode. An episode where the
// weather is a constant is an episode where four of the observation's fields
// are a fixed vector.
const CLOUD_PERIOD_S = 73;
const CLOUD_SWING = 0.18;
const PRECIPITATION_PERIOD_S = 51;
const WIND_PERIOD_S = 37;
const WIND_SWING_KPH = 9;
const WIND_DIR_PERIOD_S = 61;
const WIND_DIR_SWING_DEG = 12;

// -- overflights
/** How many tracks the schedule carries. */
const AIRCRAFT_TRACKS = 6;
/** Slant range inside which an aircraft counts as overhead, metres. */
const AIRCRAFT_OVERHEAD_M = 5000;
/** Reported ceiling for the nearest track, so the field has a finite bound. */
const AIRCRAFT_FAR_M = 60_000;

// -- reward weights
const NAVIGATION_SCALE = 0.18;
const NAVIGATION_CLAMP_M = 0.3;
const JOB_SCALE = 1.2;
const JOB_CLAMP = 0.5;
const SUCCESS_BONUS = 10;
const AUDIO_CAPTURE_PER_STEP = 0.006;
const AUDIO_MONITOR_PER_STEP = 0.004;
const AUDIO_HOT_MIC_PER_STEP = 0.012;
const AUDIO_IDLE_PLAYBACK_PER_STEP = 0.008;
/** The gain band a take is usable in, dB. Outside it the mic is not "ready". */
const AUDIO_GAIN_MIN_DB = 6;
const AUDIO_GAIN_MAX_DB = 24;
/** Volume below which the programme is not audibly monitoring anything. */
const AUDIO_MONITOR_VOLUME = 0.2;
/**
 * What draining the whole reserve costs, and how much dearer an overcast sky
 * makes every kilowatt-hour of it.
 *
 * Nine is about the size of the success bonus on purpose: burning the studio's
 * entire allowance should read as roughly as bad as completing the job is good.
 */
const ENERGY_WEIGHT = 9;
const ENERGY_CLOUD_SLOPE = 1.4;
/** Potential-based vehicle shaping. Telescopes to at most this over an episode. */
const VEHICLE_SHAPING = 2;
const VEHICLE_READY_PER_STEP = 0.006;
const VEHICLE_UNREADY_PER_STEP = 0.001;
const DEPARTURE_READY_BONUS = 1.5;
/** Cabin comfort band for readiness, and the span the potential is graded over. */
const CABIN_READY_K = 3;
const CABIN_POTENTIAL_K = 12;
/**
 * The three noise weights, and why they are the sizes they are.
 *
 * Read them against `AUDIO_MONITOR_PER_STEP` (0.004), which is what monitoring
 * pays, because the whole point is that the comparison has an answer:
 *
 * - bleed, at 0.009 a unit of volume, is cheaper than monitoring is worth at
 *   the 0.2 threshold and dearer than it is worth at full volume — so there is
 *   a real optimum in the volume knob rather than a binary;
 * - an aircraft directly overhead, at 0.05, buries the monitoring gain at any
 *   volume, so the answer is "stop the playback until it has gone";
 * - saturated wind, at 0.03, does the same a little more gently.
 *
 * None of them fires at all when the speaker is silent, which is what makes
 * "do not play" always available and always safe.
 */
const NOISE_AIRCRAFT = 0.05;
const NOISE_WIND = 0.03;
const NOISE_BLEED = 0.009;
/** Wind at which the noise penalty starts, and where it saturates, kph. */
const NOISE_WIND_FLOOR_KPH = 25;
const NOISE_WIND_SPAN_KPH = 40;
const TIME_PER_STEP = 0.008;
const CONTROL_SCALE = 0.001;
const CONTROL_PER_COMMAND = 0.0006;
const COLLISION_PER_STEP = 0.08;
const INTERACTION_PENALTY = 0.12;
const SAFETY_STALL = 2;
const SAFETY_INTERACTION = 2;
const SAFETY_ENERGY = 3;
const SAFETY_DEPARTURE = 3;

/**
 * The condition vocabulary, mirroring `SkyCondition` in `engine/atmosphere.ts`
 * and `WeatherCondition` in `server/wire.ts` member for member.
 *
 * Stated here rather than imported, for exactly the reason `atmosphere.ts`
 * declines to import the wire: `src/arena/` may not reach three.js, and
 * `atmosphere.ts` is the light rig. Three copies of an eight-member vocabulary
 * is a real cost, and it is smaller than the cost of the arena's type graph
 * depending on the renderer's.
 *
 * `snow` is in the list and is not produced by either shipped site, which is
 * the correct shape for a vocabulary: the encoding is the union's, not this
 * pack's.
 */
const WEATHER_CONDITIONS = [
  "clear", "partly-cloudy", "cloudy", "overcast", "fog", "rain", "snow", "thunderstorm",
] as const;

// ---- Wrapped simulators, resolved once ------------------------------------

const SF_PLAN = new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false });
const LA_PLAN = new Plan(MATEO_COURT, { depth: "public", warn: false });
const SF_OPERATIONS = resolveRobotOperations(SF_PLAN, LUMBRIDGE_HQ_ROBOT_OPERATIONS);
const LA_OPERATIONS = resolveRobotOperations(LA_PLAN, MATEO_COURT_ROBOT_OPERATIONS);

interface StudioSetup {
  plan: Plan;
  operations: ResolvedRobotOperations;
  /** Only the declarations the plan actually resolved. */
  declarations: readonly DeviceDeclaration[];
  lat: number;
  lng: number;
}

/**
 * The declarations a plan accepted, in pack order.
 *
 * Filtered through `plan.device(id)` rather than taken straight off the level,
 * because `Plan` is the one place a device is validated and a declaration it
 * dropped has no coordinate, no room and no seat. Simulating a device the
 * renderer refused to place would be the arena and the browser disagreeing
 * about what is in the building, which is the single property this environment
 * exists to guarantee.
 */
function resolvedDeclarations(plan: Plan, office: Office): DeviceDeclaration[] {
  const declared = office.levels.flatMap((level) => level.floorplan.devices ?? []);
  return declared.filter((declaration) => plan.device(declaration.id) !== null);
}

function setupFor(officeId: StudioOpsScenarioParameters["officeId"]): StudioSetup {
  const sf = officeId === "lumbridge-hq";
  const plan = sf ? SF_PLAN : LA_PLAN;
  const office = sf ? LUMBRIDGE_HQ : MATEO_COURT;
  const site = office.site;
  if (!site) throw new Error(`${officeId} has no site; studio-ops needs a latitude`);
  return {
    plan,
    operations: sf ? SF_OPERATIONS : LA_OPERATIONS,
    declarations: resolvedDeclarations(plan, office),
    lat: site.lat,
    lng: site.lng,
  };
}

// ---- Pure environment maths -----------------------------------------------

/**
 * Everything a transcendental touched, rounded before anybody looks at it.
 *
 * The single most important line in this file. See the module header and
 * `checksum.ts`: this is the point at which a cross-runtime disagreement about
 * the last bit of `Math.sin` stops being able to propagate.
 */
export function quantizeObservable(value: number): number {
  return quantizeToPlaces(value, OBSERVABLE_DECIMALS);
}

function clamp(value: number, low: number, high: number): number {
  return Math.min(high, Math.max(low, value));
}

function clamp01(value: number): number {
  return clamp(value, 0, 1);
}

/** A 0..1 fraction from a label and a seed, for a phase or a draw. */
function unitHash(seed: number, label: string): number {
  return deriveArenaSeed(seed, label) / 4_294_967_296;
}

export interface StudioWeather {
  cloudCover: number;
  precipitation: number;
  visibilityKm: number;
  windKph: number;
  windDirDeg: number;
  condition: string;
}

/**
 * The weather at an elapsed time, as a pure function of the scenario.
 *
 * Four independent oscillators over the frozen base values, at periods short
 * enough to move inside a two-minute episode and long enough not to read as
 * noise. Every `Math.sin` result is quantised on the way out; nothing
 * downstream of this function uses anything but exact arithmetic on what it
 * returns.
 */
export function studioWeatherAt(
  parameters: Readonly<StudioOpsScenarioParameters>,
  elapsedSeconds: number,
): StudioWeather {
  const wave = (period: number, phase: number): number =>
    quantizeObservable(Math.sin(2 * Math.PI * (elapsedSeconds / period + phase)));

  const cloudCover = quantizeObservable(
    clamp01(parameters.cloudCoverBase + CLOUD_SWING * wave(CLOUD_PERIOD_S, parameters.cloudPhase)),
  );
  // Precipitation modulates its own base rather than swinging around it: a dry
  // profile must stay dry. A base of zero can never rain, which is the correct
  // behaviour for "the fixture says it is not raining".
  const precipitation = quantizeObservable(
    clamp01(
      parameters.precipitationBase *
        (0.6 + 0.6 * wave(PRECIPITATION_PERIOD_S, parameters.precipitationPhase)),
    ),
  );
  const windKph = quantizeObservable(
    Math.max(0, parameters.windKphBase + WIND_SWING_KPH * wave(WIND_PERIOD_S, parameters.windPhase)),
  );
  const windDirDeg = quantizeObservable(
    ((parameters.windDirDeg +
      WIND_DIR_SWING_DEG * wave(WIND_DIR_PERIOD_S, parameters.windDirPhase)) % 360 + 360) % 360,
  );
  // Rain shortens the view. The same relationship `atmosphere.ts` applies when
  // a station reported no visibility at all, and for the same reason: a wet
  // afternoon that renders as thirty clear kilometres is a wrong picture.
  const visibilityKm = quantizeObservable(
    Math.max(0.2, parameters.visibilityKm * (1 - 0.45 * precipitation)),
  );

  return {
    cloudCover,
    precipitation,
    visibilityKm,
    windKph,
    windDirDeg,
    condition: weatherConditionOf(cloudCover, precipitation, visibilityKm, windKph),
  };
}

/**
 * The condition word for a set of readings.
 *
 * Ordered by what dominates what a person would say: fog beats everything
 * because you cannot see the cloud through it, precipitation beats cloud cover
 * because rain is the fact about a cloudy day, and the cloud thresholds are the
 * ordinary octa boundaries.
 */
export function weatherConditionOf(
  cloudCover: number,
  precipitation: number,
  visibilityKm: number,
  windKph: number,
): string {
  if (visibilityKm < 1) return "fog";
  if (precipitation > 0.55 && windKph > 55) return "thunderstorm";
  if (precipitation > 0.05) return "rain";
  if (cloudCover > 0.85) return "overcast";
  if (cloudCover > 0.55) return "cloudy";
  if (cloudCover > 0.2) return "partly-cloudy";
  return "clear";
}

/**
 * One aircraft's track past the studio, reduced to the four numbers that decide
 * whether it can be heard.
 *
 * Not a wrap of `engine/flights.ts`, and that is a boundary decision rather
 * than a preference: `flights.ts` imports three.js on its first line, and
 * `src/arena/` may not. What is here is deliberately *less* than that module —
 * no callsign, no registration, no route, nothing that could be mistaken for a
 * claim about a real flight — because the only thing the reward needs is a
 * slant range. An operator who wants real traffic freezes an ADS-B capture into
 * a scenario the same way they would a weather observation.
 */
export interface OverflightTrack {
  /** Elapsed seconds at which the track is closest to the studio. */
  passSecond: number;
  /** Horizontal miss distance at that instant, metres. */
  offsetM: number;
  altitudeM: number;
  speedMps: number;
}

export function studioOverflights(
  parameters: Readonly<StudioOpsScenarioParameters>,
): OverflightTrack[] {
  const seed = parameters.aircraftScheduleSeed;
  const tracks: OverflightTrack[] = [];
  for (let index = 0; index < AIRCRAFT_TRACKS; index += 1) {
    // Alternating rather than uniformly drawn, and that is the difference
    // between a sky that matters and one that does not. Drawing altitude
    // uniformly from 400 m to 11 km puts almost every track's closest approach
    // beyond audible range, so the noise coupling would exist in the code and
    // never fire in an episode. Real traffic is not uniform either: it is
    // arrivals and departures low over the city and everything else at cruise,
    // which is exactly two populations. Three of each.
    const low = index % 2 === 0;
    tracks.push({
      // Spread across the episode and a little beyond either end, so a track is
      // sometimes already receding at step 0 and sometimes never arrives.
      passSecond: -20 + unitHash(seed, `pass:${index}`) * (MAX_STEPS * FIXED_STEP + 40),
      offsetM: low
        ? 100 + unitHash(seed, `offset:${index}`) * 3400
        : 500 + unitHash(seed, `offset:${index}`) * 8500,
      altitudeM: low
        ? 300 + unitHash(seed, `altitude:${index}`) * 1900
        : 7000 + unitHash(seed, `altitude:${index}`) * 4500,
      // Approach speeds for the low ones, cruise for the high ones.
      speedMps: low
        ? 70 + unitHash(seed, `speed:${index}`) * 60
        : 200 + unitHash(seed, `speed:${index}`) * 60,
    });
  }
  return tracks;
}

export interface StudioSky {
  overhead: number;
  nearestSlantM: number;
}

/**
 * How much traffic is overhead, and how close the closest of it is.
 *
 * `Math.sqrt` rather than `Math.hypot`: sqrt is required to be correctly
 * rounded and hypot is not, and this number is inside a checksum.
 */
export function studioSkyAt(
  tracks: readonly OverflightTrack[],
  elapsedSeconds: number,
): StudioSky {
  let overhead = 0;
  let nearest = AIRCRAFT_FAR_M;
  for (const track of tracks) {
    const along = track.speedMps * (elapsedSeconds - track.passSecond);
    const slant = Math.sqrt(
      along * along + track.offsetM * track.offsetM + track.altitudeM * track.altitudeM,
    );
    if (slant <= AIRCRAFT_OVERHEAD_M) overhead += 1;
    if (slant < nearest) nearest = slant;
  }
  return { overhead, nearestSlantM: quantizeObservable(Math.min(nearest, AIRCRAFT_FAR_M)) };
}

/** Local mean solar hours. No timezone database, for the reason `solar.ts` gives. */
export function localSolarHour(epochMs: number, lng: number): number {
  const hours = epochMs / 3_600_000 + lng / 15;
  return quantizeObservable(((hours % 24) + 24) % 24);
}

/**
 * What the roof is making, kW.
 *
 * Proportional to the sine of the sun's altitude — the cosine of the angle of
 * incidence on a flat array — and cut by cloud. Below the horizon it is zero
 * rather than negative, which is not a detail: `Math.sin` of a negative
 * altitude is negative, and an array that consumed power at night would make
 * the reserve grow at dusk.
 */
export function studioSolarKw(sunAltitudeDeg: number, cloudCover: number): number {
  const altitudeRad = (sunAltitudeDeg * Math.PI) / 180;
  const incidence = quantizeObservable(Math.max(0, Math.sin(altitudeRad)));
  return PV_PEAK_KW * incidence * (1 - PV_CLOUD_LOSS * clamp01(cloudCover));
}

/** What the building's climate plant is drawing to hold 21 °C against `ambientC`. */
export function studioHvacKw(ambientC: number): number {
  const gap = Math.abs(ambientC - SITE_COMFORT_C);
  return Math.min(SITE_HVAC_MAX_KW, SITE_HVAC_BASE_KW + gap * SITE_HVAC_KW_PER_KELVIN);
}

/** What the declared hardware is drawing, kW, across every device in the pack. */
export function studioDeviceKw(states: readonly DeviceState[]): number {
  let kw = 0;
  for (const state of states) {
    if (!state.powered) continue;
    if (state.kind === "mic") {
      kw += MIC_DRAW_KW;
      continue;
    }
    kw += SPEAKER_IDLE_KW;
    if (state.playing === true) kw += SPEAKER_PROGRAMME_KW * clamp01(state.volume ?? 0);
  }
  return kw;
}

/**
 * The energy penalty for one step's draw.
 *
 * Priced as a fraction of the whole reserve rather than at a tariff, so the
 * number means "how much of the studio's allowance did that cost" — which is
 * the quantity the agent is actually trading against, and the only scaling
 * under which a 150 kW charge post and a 12 W microphone are commensurable
 * within a two-minute episode.
 *
 * Strictly increasing in `cloudCover` by construction: the multiplier is
 * `1 + slope · cloud` with a positive slope and the load is strictly positive,
 * because `SITE_BASE_LOAD_KW` is always on. That strictness is asserted in
 * `src/test/arena/studioOps.test.ts` and is the coupling this component exists
 * to demonstrate.
 */
export function studioOpsEnergyPenalty(loadKw: number, cloudCover: number): number {
  const kwh = Math.max(0, loadKw) * (FIXED_STEP / 3600);
  if (kwh <= 0) return 0;
  const price = ENERGY_WEIGHT * (1 + ENERGY_CLOUD_SLOPE * clamp01(cloudCover));
  return -(kwh / SITE_ENERGY_CAPACITY_KWH) * price;
}

export interface StudioNoiseInput {
  speakerPlaying: boolean;
  speakerVolume: number;
  nearestAircraftSlantM: number;
  windKph: number;
  /** A live, unmuted microphone at an occupied desk — the bleed path. */
  micLive: boolean;
  deskOccupied: boolean;
}

/**
 * The cross-variable penalty, and the reason this is one environment.
 *
 * Zero unless the speaker is playing, because a silent speaker cannot be heard
 * on a take no matter what is overhead. When it is playing, the cost rises with
 * the volume and with three independent things the agent does not control: how
 * close the nearest aircraft is, how hard the wind is blowing, and whether a
 * live microphone is in the room to pick any of it up.
 *
 * Strictly increasing as an aircraft approaches while playing — the acceptance
 * this component is written against — because the proximity factor is
 * `1 - slant/overheadRange` clamped at zero and multiplied by a positive volume.
 */
export function studioOpsNoisePenalty(input: StudioNoiseInput): number {
  if (!input.speakerPlaying) return 0;
  const volume = clamp01(input.speakerVolume);
  if (volume <= 0) return 0;
  const proximity = clamp01(1 - input.nearestAircraftSlantM / AIRCRAFT_OVERHEAD_M);
  const gust = clamp01((input.windKph - NOISE_WIND_FLOOR_KPH) / NOISE_WIND_SPAN_KPH);
  const bleed = input.micLive && input.deskOccupied ? NOISE_BLEED : 0;
  const severity = NOISE_AIRCRAFT * proximity + NOISE_WIND * gust + bleed;
  // Returned as a positive zero rather than `-volume * 0`. A negative zero is
  // equal to zero under `===` and *not* under `Object.is`, prints as "-0" in a
  // failure message, and would be the only field in a reward vector whose sign
  // bit depended on whether a speaker happened to be playing.
  return severity <= 0 ? 0 : -volume * severity;
}

/**
 * Vehicle readiness as a bounded 0..1 potential.
 *
 * Half charge, half cabin comfort, both graded rather than thresholded so the
 * shaping term has a gradient to follow. `vehicleReadyByDeparture` is the
 * *thresholded* version of the same two facts and is what the departure check
 * uses; a potential that jumped from 0 to 1 at the threshold would give a
 * policy nothing to climb.
 */
export function studioVehicleReadiness(
  socPct: number,
  cabinC: number,
  requiredSocPct: number,
): number {
  const charge = requiredSocPct <= 0 ? 1 : clamp01(socPct / requiredSocPct);
  const comfort = clamp01(1 - Math.abs(cabinC - SITE_COMFORT_C) / CABIN_POTENTIAL_K);
  return 0.5 * charge + 0.5 * comfort;
}

// ---- Scenarios ------------------------------------------------------------

/**
 * Five public scenarios: three train, two dev, across both studios, five
 * weather profiles and both microphones in the LA pack.
 *
 * `startEpochMs` values are chosen so the solar altitude is genuinely different
 * between them — a dawn, a morning, two afternoons and an evening, spanning
 * +3 to +41 degrees — because a sun that is always at the same height makes
 * three of the observation's fields constant and the roof's generation a fixed
 * number rather than a variable.
 *
 * Two of them run the same authored job (`la-l1-inspect-directory`) under
 * different weather and against a different microphone, which is deliberate:
 * holding the robot's task fixed is what makes the *rest* of the observation
 * the thing that distinguishes them.
 *
 * `weatherReported` is `false` on every one of them. None of these was captured
 * from a station; they are invented profiles, and the flag says so. The field
 * is here so that an operator who *does* freeze an NWS observation into a
 * scenario has somewhere honest to record it.
 */
const DEFINITIONS = [
  {
    id: "train-sf-clear-morning-desk-check",
    split: "train" as const,
    parameters: {
      officeId: "lumbridge-hq" as const,
      robotId: "sf-studio-activity-01",
      // The monitor inspection, not the display one, and the choice is
      // load-bearing: `sf-studio-monitor` stands 0.10 m from the seat
      // `sf-desk-mic` serves, so the robot working this job *is* the desk being
      // occupied. That is the coupling this environment is named for, and it is
      // the only station in either shipped pack that sits at a microphone's
      // seat. See the note above `OCCUPANCY_RADIUS_M`.
      jobId: "sf-studio-inspect-monitor",
      micId: "sf-desk-mic",
      speakerId: "sf-desk-speaker",
      // 2026-03-17 18:09 UTC — 10:00 local solar time over the Transbay, sun
      // at +41 degrees. Every `startEpochMs` here is chosen for the *altitude*
      // it produces, and the five differ by a good forty degrees between them:
      // a sun at a fixed height would make three observation fields constant
      // and the roof's generation a number rather than a variable.
      startEpochMs: 1_773_770_940_000,
      weatherProfileId: "clear-morning",
      weatherReported: false,
      cloudCoverBase: 0.12,
      precipitationBase: 0,
      windKphBase: 11,
      windDirDeg: 265,
      visibilityKm: 24,
      ambientC: 19.5,
      cloudPhase: 0,
      precipitationPhase: 0,
      windPhase: 0,
      windDirPhase: 0,
      aircraftScheduleSeed: 8_731,
      vehicleInitialSocPct: 61,
      vehiclePluggedInAtStart: false,
      departureStep: 1000,
      departureSocPct: 61.6,
      energyReserveKWh: SITE_ENERGY_CAPACITY_KWH,
    },
  },
  {
    id: "train-la-overcast-inspection",
    split: "train" as const,
    parameters: {
      officeId: "mateo-court" as const,
      robotId: "la-office-activity-01",
      jobId: "la-l1-inspect-directory",
      micId: "la-front-mic",
      speakerId: "la-front-speaker",
      // 2026-01-22 22:23 UTC — 14:30 local solar time, a low winter sun at
      // +27 degrees behind nine tenths of cloud.
      startEpochMs: 1_769_120_580_000,
      weatherProfileId: "overcast-drizzle",
      weatherReported: false,
      cloudCoverBase: 0.93,
      precipitationBase: 0.12,
      windKphBase: 16,
      windDirDeg: 210,
      visibilityKm: 12,
      ambientC: 17,
      cloudPhase: 0,
      precipitationPhase: 0,
      windPhase: 0,
      windDirPhase: 0,
      aircraftScheduleSeed: 4_477,
      vehicleInitialSocPct: 58,
      vehiclePluggedInAtStart: true,
      departureStep: 1000,
      departureSocPct: 58.6,
      energyReserveKWh: SITE_ENERGY_CAPACITY_KWH,
    },
  },
  {
    id: "train-la-hot-afternoon-studio",
    split: "train" as const,
    parameters: {
      officeId: "mateo-court" as const,
      robotId: "la-office-activity-01",
      jobId: "la-l1-inspect-directory",
      micId: "mic-yeti",
      speakerId: "speaker",
      // 2026-08-14 23:53 UTC — 16:00 local solar time, sun at +34 degrees and
      // the hot end of an August afternoon.
      startEpochMs: 1_786_751_580_000,
      weatherProfileId: "hot-clear-afternoon",
      weatherReported: false,
      cloudCoverBase: 0.06,
      precipitationBase: 0,
      windKphBase: 8,
      windDirDeg: 250,
      visibilityKm: 18,
      ambientC: 34,
      cloudPhase: 0,
      precipitationPhase: 0,
      windPhase: 0,
      windDirPhase: 0,
      aircraftScheduleSeed: 20_617,
      vehicleInitialSocPct: 47,
      vehiclePluggedInAtStart: false,
      departureStep: 1000,
      departureSocPct: 47.6,
      energyReserveKWh: SITE_ENERGY_CAPACITY_KWH,
    },
  },
  {
    id: "dev-sf-windy-evening-delivery",
    split: "dev" as const,
    parameters: {
      officeId: "lumbridge-hq" as const,
      robotId: "sf-studio-activity-01",
      jobId: "sf-studio-deliver",
      micId: "sf-desk-mic",
      speakerId: "sf-desk-speaker",
      // 2026-05-03 02:39 UTC — 18:30 local solar time, sun at +3 degrees and
      // about to go. The roof makes almost nothing, which is the point.
      startEpochMs: 1_777_775_940_000,
      weatherProfileId: "windy-evening",
      weatherReported: false,
      cloudCoverBase: 0.62,
      precipitationBase: 0.05,
      windKphBase: 46,
      windDirDeg: 290,
      visibilityKm: 20,
      ambientC: 13,
      cloudPhase: 0,
      precipitationPhase: 0,
      windPhase: 0,
      windDirPhase: 0,
      aircraftScheduleSeed: 1_781,
      vehicleInitialSocPct: 66,
      vehiclePluggedInAtStart: false,
      departureStep: 1000,
      departureSocPct: 66.6,
      energyReserveKWh: SITE_ENERGY_CAPACITY_KWH,
    },
  },
  {
    id: "dev-la-marine-layer-loft-delivery",
    split: "dev" as const,
    parameters: {
      officeId: "mateo-court" as const,
      robotId: "la-office-activity-02",
      jobId: "la-l2-deliver",
      // The loft job runs on level 2 and every LA device is on level 1, so this
      // scenario's desk is never occupied. That is a real operating case and a
      // deliberate one: the correct play is to mute and get on with the job,
      // and a policy that has only ever seen a reachable desk has not learned
      // the difference between "unmute when somebody arrives" and "unmute".
      micId: "mic-yeti",
      speakerId: "speaker",
      // 2026-06-19 15:23 UTC — 07:30 local solar time under the marine layer,
      // sun at +31 degrees and almost none of it reaching the ground.
      startEpochMs: 1_781_882_580_000,
      weatherProfileId: "marine-layer",
      weatherReported: false,
      cloudCoverBase: 0.88,
      precipitationBase: 0.02,
      windKphBase: 7,
      windDirDeg: 200,
      visibilityKm: 0.8,
      ambientC: 16,
      cloudPhase: 0,
      precipitationPhase: 0,
      windPhase: 0,
      windDirPhase: 0,
      aircraftScheduleSeed: 6_112,
      vehicleInitialSocPct: 52,
      vehiclePluggedInAtStart: true,
      departureStep: 1000,
      departureSocPct: 52.6,
      energyReserveKWh: SITE_ENERGY_CAPACITY_KWH,
    },
  },
] as const;

/**
 * How far above the starting charge the departure target sits, percentage
 * points.
 *
 * Six tenths of a point, which at the apron post's tapered output is about
 * fifteen seconds of charging and a fifth of the studio's whole reserve. Small
 * on purpose: 150 kW into a 95 kWh pack moves the charge about five points in
 * the two minutes an episode lasts, so a target further away than that would
 * not be a decision, it would be a wall. The number that matters is the *ratio*
 * — reaching the target costs a fifth of the allowance and buys the departure —
 * and that ratio is what the agent is trading.
 */
const SOC_TARGET_MARGIN_PCT = 0.6;

/**
 * Seeded materialization.
 *
 * The four oscillator phases and the overflight seed are drawn here rather than
 * inside the environment, which is what makes the weather and the traffic a
 * *property of the scenario* — they land in `scenario.parameters`, they are
 * covered by `scenario.hash`, and anybody holding a trace can read exactly what
 * sky it was flown under. Drawing them inside `step()` would put the same
 * numbers somewhere nobody could audit.
 *
 * The jitter on the bases is small on purpose: enough that two seeds are not
 * the same episode, not so much that a scenario stops being the profile it is
 * named after. An `overcast-drizzle` that samples clear is a mislabelled
 * fixture.
 */
export const STUDIO_OPS_SCENARIOS = new ArenaScenarioRegistry<StudioOpsScenarioParameters>(
  "studio-ops-v1",
  DEFINITIONS,
  (base, random) => {
    // Drawn before the literal because the departure target has to follow the
    // jittered start charge. Keeping "can this be reached inside an episode" a
    // property of the task rather than of the draw is the whole point.
    const vehicleInitialSocPct = clamp(
      base.vehicleInitialSocPct + random.between(-3, 3),
      5,
      95,
    );
    return {
      ...base,
      cloudCoverBase: clamp01(base.cloudCoverBase + random.between(-0.05, 0.05)),
      precipitationBase: clamp01(base.precipitationBase * random.between(0.8, 1.2)),
      windKphBase: Math.max(0, base.windKphBase + random.between(-3, 3)),
      windDirDeg: ((base.windDirDeg + random.between(-8, 8)) % 360 + 360) % 360,
      visibilityKm: Math.max(0.2, base.visibilityKm * random.between(0.9, 1.1)),
      ambientC: base.ambientC + random.between(-1.2, 1.2),
      cloudPhase: random.next(),
      precipitationPhase: random.next(),
      windPhase: random.next(),
      windDirPhase: random.next(),
      aircraftScheduleSeed:
        (base.aircraftScheduleSeed + Math.floor(random.between(0, 1_000_000))) >>> 0,
      vehicleInitialSocPct,
      departureSocPct: vehicleInitialSocPct + SOC_TARGET_MARGIN_PCT,
    };
  },
);

// ---- Manifest -------------------------------------------------------------

const ACTION_SPACE: readonly ArenaFieldSpec[] = [
  { name: "x", kind: "float", low: -1, high: 1, unit: "normalized drive demand" },
  { name: "z", kind: "float", low: -1, high: 1, unit: "normalized drive demand" },
  { name: "interact", kind: "bool" },
  {
    name: "micGain",
    kind: "float",
    low: DEVICE_RANGES.gain.min,
    high: DEVICE_RANGES.gain.max,
    unit: "dB",
  },
  { name: "micMute", kind: "bool" },
  {
    name: "speakerVolume",
    kind: "float",
    low: DEVICE_RANGES.volume.min,
    high: DEVICE_RANGES.volume.max,
    unit: "fraction",
  },
  { name: "speakerPlay", kind: "bool" },
  { name: "vehiclePrecondition", kind: "bool" },
  { name: "vehicleCharge", kind: "bool" },
];

const OBSERVATION_SPACE: readonly ArenaFieldSpec[] = [
  { name: "officeId", kind: "enum", values: ["lumbridge-hq", "mateo-court"] },
  { name: "levelId", kind: "enum", values: ["level-1", "level-2"] },
  { name: "x", kind: "float", low: 0, high: 40, unit: "m" },
  { name: "z", kind: "float", low: 0, high: 40, unit: "m" },
  {
    name: "mode",
    kind: "enum",
    values: ["idle", "patrol", "deliver", "inspect", "charge", "blocked-recovery"],
  },
  {
    name: "phase",
    kind: "enum",
    values: [
      "scheduled-idle", "patrolling", "patrol-check", "to-pickup", "loading-parcel",
      "to-dropoff", "delivering-parcel", "to-inspection", "inspecting", "to-charge",
      "charging", "awaiting-interaction", "replanning", "backoff-and-replan", "terminal",
    ],
  },
  { name: "jobKind", kind: "enum", values: ["none", "patrol", "deliver", "inspect", "charge"] },
  { name: "payload", kind: "enum", values: ["parcel"] },
  { name: "battery", kind: "float", low: 0, high: 1, unit: "fraction" },
  { name: "jobProgress", kind: "float", low: 0, high: 1, unit: "fraction" },
  { name: "nextStationId", kind: "id" },
  { name: "nextX", kind: "float", low: 0, high: 40, unit: "m" },
  { name: "nextZ", kind: "float", low: 0, high: 40, unit: "m" },
  { name: "deltaX", kind: "float", low: -40, high: 40, unit: "m" },
  { name: "deltaZ", kind: "float", low: -40, high: 40, unit: "m" },
  { name: "distanceToNextM", kind: "float", low: 0, high: 60, unit: "m" },
  { name: "canInteract", kind: "bool" },
  { name: "blockedStreak", kind: "float", low: 0, high: MAX_STEPS, unit: "steps" },
  { name: "hourOfDay", kind: "float", low: 0, high: 24, unit: "local solar hours" },
  { name: "sunAltitudeDeg", kind: "float", low: -90, high: 90, unit: "deg above horizon" },
  { name: "sunAzimuthDeg", kind: "float", low: 0, high: 360, unit: "deg from true north" },
  { name: "cloudCover", kind: "float", low: 0, high: 1, unit: "fraction" },
  { name: "precipitation", kind: "float", low: 0, high: 1, unit: "intensity" },
  { name: "visibilityKm", kind: "float", low: 0, high: 40, unit: "km" },
  { name: "windKph", kind: "float", low: 0, high: 120, unit: "kph" },
  { name: "windDirDeg", kind: "float", low: 0, high: 360, unit: "deg the wind is from" },
  { name: "weatherCondition", kind: "enum", values: WEATHER_CONDITIONS },
  { name: "weatherReported", kind: "bool" },
  { name: "micPowered", kind: "bool" },
  {
    name: "micGainDb",
    kind: "float",
    low: DEVICE_RANGES.gain.min,
    high: DEVICE_RANGES.gain.max,
    unit: "dB",
  },
  {
    name: "micLevelDb",
    kind: "float",
    low: DEVICE_RANGES.level.min,
    high: DEVICE_RANGES.level.max,
    unit: "dBFS",
  },
  { name: "micMuted", kind: "bool" },
  { name: "speakerPowered", kind: "bool" },
  {
    name: "speakerVolume",
    kind: "float",
    low: DEVICE_RANGES.volume.min,
    high: DEVICE_RANGES.volume.max,
    unit: "fraction",
  },
  { name: "speakerPlaying", kind: "bool" },
  { name: "deskOccupied", kind: "bool" },
  { name: "vehicleSocPct", kind: "float", low: 0, high: 100, unit: "percent" },
  { name: "vehicleCabinC", kind: "float", low: -20, high: 60, unit: "deg C" },
  { name: "vehiclePluggedIn", kind: "bool" },
  { name: "vehicleReadyByDeparture", kind: "bool" },
  { name: "aircraftOverheadCount", kind: "float", low: 0, high: AIRCRAFT_TRACKS, unit: "count" },
  { name: "nearestAircraftSlantM", kind: "float", low: 0, high: AIRCRAFT_FAR_M, unit: "m" },
  { name: "energyReservePct", kind: "float", low: 0, high: 100, unit: "percent" },
  { name: "stepsToDeparture", kind: "float", low: 0, high: MAX_STEPS, unit: "steps" },
];

export const STUDIO_OPS_MANIFEST: ArenaManifest = Object.freeze({
  apiVersion: ARENA_API_VERSION,
  id: "studio-ops-v1",
  version: 1,
  title: "Studio operations",
  description:
    "Multi-variable operation of Lumbridge's two simulated studios: a robot job, the mic and " +
    "speaker on the desk, the weather over the roof, the car on the apron, the traffic " +
    "overhead, and one shared energy reserve.",
  simulator:
    "Plan + robotActivity + createSimulatedDevices + createSimulatedVehicleTelemetry + solarPosition",
  fixedStepSeconds: FIXED_STEP,
  maxSteps: MAX_STEPS,
  actionFields: ACTION_SPACE.map((spec) => spec.name),
  observationFields: OBSERVATION_SPACE.map((spec) => spec.name),
  actionSpace: ACTION_SPACE,
  observationSpace: OBSERVATION_SPACE,
  rewardComponents: {
    navigation: "Bounded reduction in distance to the current authored job station.",
    job: "Dense progress through the assigned job's phases.",
    success: "Sparse bonus for completing the assigned job.",
    audioReady: "A live microphone, and monitoring, at an occupied desk.",
    audioWaste: "A hot microphone or a playing speaker at an empty desk.",
    energy: "Device, HVAC and charging draw as a share of the reserve, priced up by cloud cover.",
    vehicleReady: "Potential-based progress toward a charged, comfortable car, plus the departure bonus.",
    noise: "Playback under an aircraft, in wind, or into a live microphone.",
    time: "Per-step pressure that keeps inaction below zero.",
    control: "Planar action magnitude and device command churn.",
    collision: "Commanded movement that fails against resolved office collision.",
    interaction: "Interaction away from the current work station.",
    safety: "Terminal stall, invalid-interaction, reserve-exhaustion or missed-departure penalty.",
  },
  safetyTerminals: [
    "collision-stall",
    "wrong-interaction-limit",
    "battery-depleted",
    "departure-missed",
  ],
  scenarioIds: {
    train: STUDIO_OPS_SCENARIOS.ids("train"),
    dev: STUDIO_OPS_SCENARIOS.ids("dev"),
  },
  baselines: {
    inaction:
      "Zero drive, no interaction and an unmuted microphone: the mic runs hot at an empty desk, " +
      "the job never starts, and the departure is missed.",
    scripted:
      "Follow the exposed route waypoint, interact only inside the station radius, mute the " +
      "microphone unless the desk is occupied, monitor only when the sky and the wind are quiet, " +
      "and charge only until the departure target is met.",
  },
});

export const STUDIO_OPS_INACTION: Readonly<StudioOpsAction> = Object.freeze({
  x: 0,
  z: 0,
  interact: false,
  micGain: 0,
  micMute: false,
  speakerVolume: 0,
  speakerPlay: false,
  vehiclePrecondition: false,
  vehicleCharge: false,
});

/**
 * The smoke-proof baseline. Not an optimal policy, and not trying to be.
 *
 * It exists so that a reward regression or an impossible task fails in CI
 * before anybody spends a training budget, and so the claims in the manifest's
 * `baselines` block are executable rather than aspirational. Every branch in it
 * is readable straight off the observation, which is also a check on the
 * observation: a baseline that needed a field the environment does not publish
 * would be evidence the task is unfairly partial.
 */
export function studioOpsScriptedBaseline(observation: StudioOpsObservation): StudioOpsAction {
  const occupied = observation.deskOccupied;
  const skyQuiet = observation.nearestAircraftSlantM > AIRCRAFT_OVERHEAD_M;
  const windQuiet = observation.windKph < NOISE_WIND_FLOOR_KPH;
  // Charge only while short of the departure target: the post draws from the
  // same reserve the studio does, so leaving it plugged in past the target is
  // paying for nothing.
  const wantsCharge = !observation.vehicleReadyByDeparture &&
    observation.energyReservePct > 25;
  const base = {
    interact: false,
    micGain: 12,
    micMute: !occupied,
    // Just above the threshold that counts as monitoring, because the bleed
    // into a live microphone is charged per unit of volume and the reward for
    // monitoring is not. Louder is strictly worse here; see the noise weights.
    speakerVolume: 0.22,
    speakerPlay: occupied && skyQuiet && windQuiet,
    // Precondition only where the cabin is within reach of comfort. On a 34 °C
    // afternoon it is not, and running the climate at it would spend charge on
    // a target the episode is too short to hit.
    vehiclePrecondition: Math.abs(observation.vehicleCabinC - SITE_COMFORT_C) > 1 &&
      Math.abs(observation.vehicleCabinC - SITE_COMFORT_C) < 8,
    vehicleCharge: wantsCharge,
  };

  if (observation.canInteract) return { ...base, x: 0, z: 0, interact: true };
  const length = Math.sqrt(
    observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ,
  );
  if (length <= 1e-9) return { ...base, x: 0, z: 0 };
  const magnitude = Math.min(1, length / (1.05 * FIXED_STEP));
  return {
    ...base,
    x: (observation.deltaX / length) * magnitude,
    z: (observation.deltaZ / length) * magnitude,
  };
}

// ---- The environment ------------------------------------------------------

export class StudioOpsEnvironment extends BaseArenaEnvironment<
  StudioOpsAction,
  StudioOpsObservation,
  StudioOpsReward,
  StudioOpsSimulationSnapshot,
  StudioOpsScenarioParameters
> {
  readonly manifest = STUDIO_OPS_MANIFEST;
  protected readonly registry = STUDIO_OPS_SCENARIOS;
  protected readonly sourceHashes = ARENA_SOURCE_HASHES["studio-ops-v1"]!;

  private setup: StudioSetup | null = null;
  private activity: RobotActivityController | null = null;
  private devices: SimulatedDevices | null = null;
  private vehicle: SimulatedVehicleTelemetry | null = null;
  private tracks: readonly OverflightTrack[] = [];
  private micSeat: { levelId: string; x: number; z: number } | null = null;

  private robotId = "";
  /**
   * Steps taken since `reset`, and the environment's own clock.
   *
   * Not `BaseArenaEnvironment.currentStep()`, and the difference is a real bug
   * rather than a style choice: the base class increments its step index
   * *after* `advanceSimulation` returns, so inside that call `currentStep()` is
   * still the index of the step that has already been taken. Building the
   * returned observation from it reported the sun, the weather and the sky of
   * one step ago while every simulator had already advanced — an observation
   * describing a moment the reward was not computed at, which is the kind of
   * skew a policy learns around and a reader never sees.
   */
  private elapsedSteps = 0;
  private energyReserveKWh = 0;
  private previousDistanceM = 0;
  private previousProgress = 0;
  private previousReadiness = 0;
  private blockedStreak = 0;
  private wrongInteractions = 0;
  private initialCompletedJobs = 0;
  private departureResolved = false;

  // ---- lifecycle ----------------------------------------------------------

  protected resetSimulation(
    scenario: ArenaScenario<StudioOpsScenarioParameters>,
  ): StudioOpsObservation {
    const parameters = scenario.parameters;
    const setup = setupFor(parameters.officeId);
    this.setup = setup;
    this.robotId = parameters.robotId;

    this.activity = createRobotActivity(setup.plan, setup.operations, {
      seed: scenario.seed,
      robotIds: [this.robotId],
      controlledRobotIds: [this.robotId],
      initialJobIds: { [this.robotId]: parameters.jobId },
    });

    // `epochMs: 0` on both simulators, so every timestamp in a snapshot is an
    // elapsed count rather than a wall clock. A replayed rollout that carried
    // `Date.now()` in one field would be the one thing that could never match.
    this.devices = createSimulatedDevices(setup.declarations, {
      seed: scenario.seed,
      fixedStepSeconds: FIXED_STEP,
      epochMs: 0,
    });
    // The rig is live when the day starts. See `StudioOpsAction` for why power
    // is not an action.
    for (const declaration of setup.declarations) {
      this.devices.command({ deviceId: declaration.id, op: "power", value: true });
    }

    this.vehicle = createSimulatedVehicleTelemetry({
      seed: scenario.seed,
      fixedStepSeconds: FIXED_STEP,
      ambientC: parameters.ambientC,
      epochMs: 0,
      initialSocPct: parameters.vehicleInitialSocPct,
    });
    // The seeded start has the car plugged in or not depending on the draw.
    // Pinned from the scenario instead, because whether the episode begins with
    // 150 kW flowing out of the reserve is a property of the task and not
    // something to discover from a hash.
    this.vehicle.command({ op: "charge", value: parameters.vehiclePluggedInAtStart });
    this.vehicle.command({ op: "climate", value: false });

    this.tracks = studioOverflights(parameters);
    this.micSeat = this.locateMicSeat(setup, parameters.micId);
    this.energyReserveKWh = parameters.energyReserveKWh;
    this.elapsedSteps = 0;
    this.blockedStreak = 0;
    this.wrongInteractions = 0;
    this.departureResolved = false;

    const robot = this.robot();
    this.applyOccupancy(robot);
    this.initialCompletedJobs = robot.completedJobs;
    this.previousDistanceM = this.distanceToNext(robot);
    this.previousProgress = robot.progress;
    const vehicle = this.vehicle.current();
    this.previousReadiness = studioVehicleReadiness(
      vehicle.socPct,
      vehicle.cabinC,
      this.requiredSocPct(),
    );
    return this.observation();
  }

  protected normalizeAction(action: StudioOpsAction): StudioOpsAction {
    const finite = (value: unknown, fallback = 0): number =>
      typeof value === "number" && Number.isFinite(value) ? value : fallback;
    const x = finite(action?.x);
    const z = finite(action?.z);
    // The unit disc, not the unit square: a diagonal demand of (1, 1) would
    // otherwise be 41 % faster than a straight one, which is a bug every
    // twin-stick controller has shipped at least once.
    const length = Math.sqrt(x * x + z * z);
    return {
      x: length > 1 ? x / length : x,
      z: length > 1 ? z / length : z,
      interact: action?.interact === true,
      // Rounded to the precision the instrument actually reports — two decimals
      // of a decibel, three of a volume fraction, exactly as `DeviceState`
      // rounds them on the way out. Without this a setpoint of 12.005 dB is
      // read back as 12.01 dB, compares unequal to the action that set it, and
      // is re-commanded on every step for the rest of the episode: a control
      // that never settles, and a churn cost charged for standing still.
      micGain: quantizeToPlaces(
        clamp(
          finite(action?.micGain, DEVICE_RANGES.gain.initial),
          DEVICE_RANGES.gain.min,
          DEVICE_RANGES.gain.max,
        ),
        2,
      ),
      micMute: action?.micMute === true,
      speakerVolume: quantizeToPlaces(
        clamp(
          finite(action?.speakerVolume, DEVICE_RANGES.volume.initial),
          DEVICE_RANGES.volume.min,
          DEVICE_RANGES.volume.max,
        ),
        3,
      ),
      speakerPlay: action?.speakerPlay === true,
      vehiclePrecondition: action?.vehiclePrecondition === true,
      vehicleCharge: action?.vehicleCharge === true,
    };
  }

  protected advanceSimulation(
    action: StudioOpsAction,
  ): SimulationTransition<StudioOpsObservation, StudioOpsReward> {
    const parameters = this.currentScenario().parameters;
    const before = this.robot();
    const beforePosition = { ...before.position };
    const canInteract = this.canInteract(before);
    if (action.interact && !canInteract) this.wrongInteractions += 1;

    // -- hardware and vehicle commands, issued only where something changes.
    // `normalizeDeviceCommand` and `normalizeVehicleTelemetryCommand` both
    // refuse rather than throw, and neither simulator draws randomness on a
    // command, so this is a courtesy to the reward's churn term rather than a
    // determinism requirement.
    const commands = this.applyDeviceCommands(action) + this.applyVehicleCommands(action);

    // -- advance every simulator by exactly one fixed step, in a fixed order.
    const after = this.requireActivity().step({
      [this.robotId]: { x: action.x, z: action.z, interact: action.interact },
    }).robots[0]!;
    this.applyOccupancy(after);
    const devices = this.requireDevices();
    devices.stepFixed();
    const vehicleSource = this.requireVehicle();
    vehicleSource.stepFixed();

    const deviceStates = devices.current();
    const vehicle = vehicleSource.current();
    this.elapsedSteps += 1;
    const step = this.elapsedSteps;
    const elapsedSeconds = step * FIXED_STEP;
    const weather = studioWeatherAt(parameters, elapsedSeconds);
    const sun = this.sunAt(elapsedSeconds);
    const sky = studioSkyAt(this.tracks, elapsedSeconds);

    // -- energy: what was drawn, what the roof made, what is left.
    const chargeKw = vehicle.pluggedIn && vehicle.socPct < 100
      ? 150 * chargeTaper(vehicle.socPct)
      : 0;
    const loadKw = SITE_BASE_LOAD_KW + studioHvacKw(parameters.ambientC) +
      studioDeviceKw(deviceStates) + chargeKw;
    const solarKw = studioSolarKw(sun.altitudeDeg, weather.cloudCover);
    this.energyReserveKWh = clamp(
      this.energyReserveKWh + (solarKw - loadKw) * (FIXED_STEP / 3600),
      0,
      parameters.energyReserveKWh,
    );

    // -- audio, read off the pair this scenario's operator is holding.
    const mic = this.micState(deviceStates);
    const speaker = this.speakerState(deviceStates);
    const deskOccupied = this.deskOccupied(after);
    const micLive = mic?.powered === true && mic.muted !== true;
    const micInBand = (mic?.gainDb ?? 0) >= AUDIO_GAIN_MIN_DB &&
      (mic?.gainDb ?? 0) <= AUDIO_GAIN_MAX_DB;
    const speakerPlaying = speaker?.playing === true;
    const speakerVolume = speaker?.volume ?? 0;

    const audioReady =
      (deskOccupied && micLive && micInBand ? AUDIO_CAPTURE_PER_STEP : 0) +
      (deskOccupied && speakerPlaying && speakerVolume >= AUDIO_MONITOR_VOLUME
        ? AUDIO_MONITOR_PER_STEP
        : 0);
    const audioWaste =
      (micLive && !deskOccupied ? -AUDIO_HOT_MIC_PER_STEP : 0) +
      (speakerPlaying && !deskOccupied ? -AUDIO_IDLE_PLAYBACK_PER_STEP : 0);

    // -- vehicle readiness, as a bounded potential and as a threshold.
    const requiredSoc = this.requiredSocPct();
    const readiness = studioVehicleReadiness(vehicle.socPct, vehicle.cabinC, requiredSoc);
    const readyNow = this.vehicleReady(vehicle.socPct, vehicle.cabinC, requiredSoc);
    let vehicleReward = VEHICLE_SHAPING * (readiness - this.previousReadiness) +
      (readyNow ? VEHICLE_READY_PER_STEP : -VEHICLE_UNREADY_PER_STEP);
    this.previousReadiness = readiness;

    // -- the departure deadline, checked exactly once.
    let departureMissed = false;
    if (!this.departureResolved && step >= parameters.departureStep) {
      this.departureResolved = true;
      if (readyNow) vehicleReward += DEPARTURE_READY_BONUS;
      else departureMissed = true;
    }

    // -- movement, collision and the job.
    const movedX = after.position.x - beforePosition.x;
    const movedZ = after.position.z - beforePosition.z;
    const moved = Math.sqrt(movedX * movedX + movedZ * movedZ);
    const demand = Math.sqrt(action.x * action.x + action.z * action.z);
    const blocked = demand > 0.2 && moved < 0.012;
    const control = CONTROL_SCALE * demand * demand + CONTROL_PER_COMMAND * commands;
    this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
    const distance = this.distanceToNext(after);
    const navigation = clamp(
      this.previousDistanceM - distance,
      -NAVIGATION_CLAMP_M,
      NAVIGATION_CLAMP_M,
    );
    const jobProgress = clamp(after.progress - this.previousProgress, -JOB_CLAMP, JOB_CLAMP);
    this.previousDistanceM = distance;
    this.previousProgress = after.progress;

    // -- terminals, in the order a reader would rank them.
    const success = after.completedJobs > this.initialCompletedJobs;
    const controllerFailure = after.terminalReason;
    const batteryDepleted = controllerFailure === "battery-depleted" ||
      (this.energyReserveKWh <= 0 && loadKw > solarKw);
    const collisionStall = after.mode === "blocked-recovery" ||
      controllerFailure === "blocked-unrecoverable";
    const wrongInteractionFailure = this.wrongInteractions >= WRONG_INTERACTION_LIMIT;
    const terminalReason = success
      ? "job-complete"
      : batteryDepleted
        ? "battery-depleted"
        : collisionStall
          ? "collision-stall"
          : wrongInteractionFailure
            ? "wrong-interaction-limit"
            : departureMissed
              ? "departure-missed"
              : undefined;

    const safety = success
      ? 0
      : batteryDepleted
        ? -SAFETY_ENERGY
        : collisionStall
          ? -SAFETY_STALL
          : wrongInteractionFailure
            ? -SAFETY_INTERACTION
            : departureMissed
              ? -SAFETY_DEPARTURE
              : 0;

    return {
      observation: this.observation(),
      rewardComponents: {
        navigation: navigation * NAVIGATION_SCALE,
        job: jobProgress * JOB_SCALE,
        success: success ? SUCCESS_BONUS : 0,
        audioReady,
        audioWaste,
        energy: studioOpsEnergyPenalty(loadKw, weather.cloudCover),
        vehicleReady: vehicleReward,
        noise: studioOpsNoisePenalty({
          speakerPlaying,
          speakerVolume,
          nearestAircraftSlantM: sky.nearestSlantM,
          windKph: weather.windKph,
          micLive,
          deskOccupied,
        }),
        time: -TIME_PER_STEP,
        // Assembled positive and negated once, so a step that demanded nothing
        // and commanded nothing reports `0` rather than `-0`. The checksum
        // collapses the two, but a reward vector where one component's sign bit
        // flips on an unrelated condition is a needless surprise to a reader.
        control: control <= 0 ? 0 : -control,
        collision: blocked ? -COLLISION_PER_STEP : 0,
        interaction: action.interact && !canInteract ? -INTERACTION_PENALTY : 0,
        safety,
      },
      terminated: terminalReason !== undefined,
      terminalReason,
    };
  }

  protected simulationSnapshot(): StudioOpsSimulationSnapshot {
    return {
      activity: this.requireActivity().snapshot(),
      devices: this.requireDevices().snapshot(),
      vehicle: this.requireVehicle().snapshot(),
      elapsedSteps: this.elapsedSteps,
      energyReserveKWh: this.energyReserveKWh,
      previousDistanceM: this.previousDistanceM,
      previousProgress: this.previousProgress,
      previousReadiness: this.previousReadiness,
      blockedStreak: this.blockedStreak,
      wrongInteractions: this.wrongInteractions,
      initialCompletedJobs: this.initialCompletedJobs,
      departureResolved: this.departureResolved,
    };
  }

  protected restoreSimulation(snapshot: StudioOpsSimulationSnapshot): StudioOpsObservation {
    // Rebuild from the scenario first, so a restore lands on a simulator this
    // environment constructed rather than on whatever the previous episode left
    // behind. The same order `office-jobs-v1` uses.
    this.resetSimulation(this.currentScenario());
    if (
      !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 ||
      snapshot.elapsedSteps > MAX_STEPS ||
      !Number.isFinite(snapshot.energyReserveKWh) || snapshot.energyReserveKWh < 0 ||
      !Number.isFinite(snapshot.previousDistanceM) ||
      !Number.isFinite(snapshot.previousProgress) ||
      !Number.isFinite(snapshot.previousReadiness) ||
      !Number.isSafeInteger(snapshot.blockedStreak) || snapshot.blockedStreak < 0 ||
      !Number.isSafeInteger(snapshot.wrongInteractions) || snapshot.wrongInteractions < 0 ||
      !Number.isSafeInteger(snapshot.initialCompletedJobs) || snapshot.initialCompletedJobs < 0 ||
      typeof snapshot.departureResolved !== "boolean"
    ) throw new Error("studio ops simulation snapshot is invalid");
    this.requireActivity().restore(snapshot.activity);
    this.requireDevices().restore(snapshot.devices);
    this.requireVehicle().restore(snapshot.vehicle);
    this.elapsedSteps = snapshot.elapsedSteps;
    this.energyReserveKWh = snapshot.energyReserveKWh;
    this.previousDistanceM = snapshot.previousDistanceM;
    this.previousProgress = snapshot.previousProgress;
    this.previousReadiness = snapshot.previousReadiness;
    this.blockedStreak = snapshot.blockedStreak;
    this.wrongInteractions = snapshot.wrongInteractions;
    this.initialCompletedJobs = snapshot.initialCompletedJobs;
    this.departureResolved = snapshot.departureResolved;
    return this.observation();
  }

  // ---- observation --------------------------------------------------------

  private observation(): StudioOpsObservation {
    const parameters = this.currentScenario().parameters;
    const robot = this.robot();
    const station = this.nextStation(robot);
    const waypoint = robot.route[robot.routeIndex] ?? station?.position ?? robot.position;
    const elapsedSeconds = this.elapsedSteps * FIXED_STEP;
    const weather = studioWeatherAt(parameters, elapsedSeconds);
    const sun = this.sunAt(elapsedSeconds);
    const sky = studioSkyAt(this.tracks, elapsedSeconds);
    const deviceStates = this.requireDevices().current();
    const mic = this.micState(deviceStates);
    const speaker = this.speakerState(deviceStates);
    const vehicle = this.requireVehicle().current();
    const requiredSoc = this.requiredSocPct();

    return {
      officeId: this.requireSetup().operations.definition.officeId,
      levelId: robot.levelId,
      x: robot.position.x,
      z: robot.position.z,
      mode: robot.mode,
      phase: robot.phase,
      jobKind: robot.activeJobKind ?? "none",
      payload: robot.payload,
      battery: robot.battery,
      jobProgress: robot.progress,
      nextStationId: station?.id ?? "",
      nextX: waypoint.x,
      nextZ: waypoint.z,
      deltaX: waypoint.x - robot.position.x,
      deltaZ: waypoint.z - robot.position.z,
      distanceToNextM: station ? this.distanceToNext(robot) : 0,
      canInteract: this.canInteract(robot),
      blockedStreak: this.blockedStreak,

      hourOfDay: localSolarHour(parameters.startEpochMs + elapsedSeconds * 1000, this.requireSetup().lng),
      sunAltitudeDeg: sun.altitudeDeg,
      sunAzimuthDeg: sun.azimuthDeg,
      cloudCover: weather.cloudCover,
      precipitation: weather.precipitation,
      visibilityKm: weather.visibilityKm,
      windKph: weather.windKph,
      windDirDeg: weather.windDirDeg,
      weatherCondition: weather.condition,
      weatherReported: parameters.weatherReported,

      micPowered: mic?.powered === true,
      micGainDb: mic?.gainDb ?? DEVICE_RANGES.gain.initial,
      micLevelDb: mic?.levelDb ?? DEVICE_RANGES.level.min,
      micMuted: mic?.muted === true,
      speakerPowered: speaker?.powered === true,
      speakerVolume: speaker?.volume ?? 0,
      speakerPlaying: speaker?.playing === true,
      deskOccupied: this.deskOccupied(robot),

      vehicleSocPct: quantizeObservable(vehicle.socPct),
      vehicleCabinC: quantizeObservable(vehicle.cabinC),
      vehiclePluggedIn: vehicle.pluggedIn,
      vehicleReadyByDeparture: this.vehicleReady(vehicle.socPct, vehicle.cabinC, requiredSoc),

      aircraftOverheadCount: sky.overhead,
      nearestAircraftSlantM: sky.nearestSlantM,

      energyReservePct: quantizeObservable(
        (this.energyReserveKWh / parameters.energyReserveKWh) * 100,
      ),
      stepsToDeparture: Math.max(0, parameters.departureStep - this.elapsedSteps),
    };
  }

  // ---- helpers ------------------------------------------------------------

  /**
   * The sun, quantised.
   *
   * `new Date(...)` from a fixed epoch is arithmetic, not a clock read — and it
   * never enters the state, which is just as well: `canonicalJson` throws on a
   * `Date` precisely so that one cannot be smuggled into a checksum.
   */
  private sunAt(elapsedSeconds: number): { altitudeDeg: number; azimuthDeg: number } {
    const setup = this.requireSetup();
    const parameters = this.currentScenario().parameters;
    const when = new Date(parameters.startEpochMs + elapsedSeconds * 1000);
    const position = solarPosition(setup.lat, setup.lng, when);
    return {
      altitudeDeg: quantizeObservable(position.elevation),
      azimuthDeg: quantizeObservable(position.azimuth),
    };
  }

  /** The target charge. Materialized by the sampler, a fixed margin above the start. */
  private requiredSocPct(): number {
    return this.currentScenario().parameters.departureSocPct;
  }

  private vehicleReady(socPct: number, cabinC: number, requiredSocPct: number): boolean {
    return socPct >= requiredSocPct && Math.abs(cabinC - SITE_COMFORT_C) <= CABIN_READY_K;
  }

  private locateMicSeat(
    setup: StudioSetup,
    micId: string,
  ): { levelId: string; x: number; z: number } | null {
    const device = setup.plan.device(micId);
    if (!device?.seatId) return null;
    const seat = setup.plan.seat(device.seatId);
    return seat ? { levelId: seat.levelId, x: seat.position.x, z: seat.position.z } : null;
  }

  /**
   * Tell the device simulator who is at the desk.
   *
   * Called on every step *including* reset, and never conditionally: the first
   * call switches `createSimulatedDevices` off its own occupancy schedule for
   * good, and a run that made that call at step 40 rather than step 0 would be
   * a different run. See the note on `setOccupancy` in `devices/sim.ts`.
   */
  private applyOccupancy(robot: RobotActivityState): void {
    const seatIds: string[] = [];
    if (this.micSeat && this.nearMicSeat(robot)) {
      const device = this.requireSetup().plan.device(this.currentScenario().parameters.micId);
      if (device?.seatId) seatIds.push(device.seatId);
    }
    this.requireDevices().setOccupancy(seatIds);
  }

  private nearMicSeat(robot: RobotActivityState): boolean {
    const seat = this.micSeat;
    if (!seat || seat.levelId !== robot.levelId) return false;
    const dx = seat.x - robot.position.x;
    const dz = seat.z - robot.position.z;
    return Math.sqrt(dx * dx + dz * dz) <= OCCUPANCY_RADIUS_M;
  }

  private deskOccupied(robot: RobotActivityState): boolean {
    return this.nearMicSeat(robot);
  }

  private micState(states: readonly DeviceState[]): DeviceState | undefined {
    const id = this.currentScenario().parameters.micId;
    return states.find((state) => state.id === id);
  }

  private speakerState(states: readonly DeviceState[]): DeviceState | undefined {
    const id = this.currentScenario().parameters.speakerId;
    return states.find((state) => state.id === id);
  }

  /** Issues only the device commands that change something. Returns how many. */
  private applyDeviceCommands(action: StudioOpsAction): number {
    const devices = this.requireDevices();
    const parameters = this.currentScenario().parameters;
    const states = devices.current();
    const mic = this.micState(states);
    const speaker = this.speakerState(states);
    let issued = 0;

    if (mic && mic.muted !== action.micMute) {
      devices.command({ deviceId: parameters.micId, op: "mute", value: action.micMute });
      issued += 1;
    }
    if (mic && mic.gainDb !== action.micGain) {
      devices.command({ deviceId: parameters.micId, op: "gain", value: action.micGain });
      issued += 1;
    }
    if (speaker && speaker.volume !== action.speakerVolume) {
      devices.command({
        deviceId: parameters.speakerId,
        op: "volume",
        value: action.speakerVolume,
      });
      issued += 1;
    }
    if (speaker && speaker.playing !== action.speakerPlay) {
      devices.command({
        deviceId: parameters.speakerId,
        op: "playback",
        value: action.speakerPlay,
      });
      issued += 1;
    }
    return issued;
  }

  private applyVehicleCommands(action: StudioOpsAction): number {
    const vehicle = this.requireVehicle();
    const state = vehicle.current();
    let issued = 0;
    if (state.pluggedIn !== action.vehicleCharge) {
      vehicle.command({ op: "charge", value: action.vehicleCharge });
      issued += 1;
    }
    if (state.climateOn !== action.vehiclePrecondition) {
      vehicle.command({ op: "climate", value: action.vehiclePrecondition });
      issued += 1;
    }
    return issued;
  }

  private nextStation(robot: RobotActivityState) {
    const id = robot.activeStationIds[robot.stationIndex];
    return id ? this.requireSetup().operations.stations.get(id) : undefined;
  }

  private distanceToNext(robot: RobotActivityState): number {
    const station = this.nextStation(robot);
    if (!station) return 0;
    const dx = station.position.x - robot.position.x;
    const dz = station.position.z - robot.position.z;
    return Math.sqrt(dx * dx + dz * dz);
  }

  private canInteract(robot: RobotActivityState): boolean {
    return this.distanceToNext(robot) <= INTERACTION_RADIUS_M;
  }

  private robot(): RobotActivityState {
    const robot = this.requireActivity().states()[0];
    if (!robot) throw new Error("studio ops environment has no controlled robot");
    return robot;
  }

  private requireSetup(): StudioSetup {
    if (!this.setup) throw new Error("studio ops environment has not been reset");
    return this.setup;
  }

  private requireActivity(): RobotActivityController {
    if (!this.activity) throw new Error("studio ops environment has not been reset");
    return this.activity;
  }

  private requireDevices(): SimulatedDevices {
    if (!this.devices) throw new Error("studio ops environment has not been reset");
    return this.devices;
  }

  private requireVehicle(): SimulatedVehicleTelemetry {
    if (!this.vehicle) throw new Error("studio ops environment has not been reset");
    return this.vehicle;
  }
}
