shrike/flow.py¶

Part of SHRIKE.

   1"""The run lifecycle: the menu, the run, the Gate, the collapse and the ledger.
   2
   3This module owns everything outside a single sector's second-to-second play:
   4
   5* **Scene switching.** Every screen is a root scene handed to
   6  ``tree.change_scene``: :class:`MainMenuScene`, :class:`RunSetupScene`,
   7  :class:`DoctrineScene`, :class:`RunScene`, :class:`LedgerScene` and
   8  :class:`SettingsScene`. Each one
   9  declares ``input_actions`` so the action map survives every swap, and the
  10  meta-scoped nodes (the save system, the doctrine profile) are tree singletons
  11  that outlive them all. :class:`PauseScene` and :class:`DepotScene` are the
  12  exceptions that prove it: Escape and undocking both have to be able to come
  13  back, so the pause menu and the shop are overlays inside the live run rather
  14  than screens that replace it.
  15* **Run setup.** A hull and at most ``balance.DOCTRINE_MAX_ACTIVE_KEYSTONES``
  16  keystones, chosen before launch and frozen into a :class:`RunConfig`.
  17* **Depot delivery.** The depot sells; the run receives. Docking raises
  18  :class:`DepotScene`, the shop that shows the six items and the pity slot with
  19  their categories, prices and effects, the standing scrap offer at its honest
  20  rate, and the reroll at its doubling price. A purchase gate refuses, before
  21  payment, anything the hull cannot take, and every paid ``item_purchased``
  22  lands its goods: modules socket themselves, weapons mount or stow,
  23  consumables apply to the run state on the spot.
  24* **Spending Cores.** :class:`DoctrineScene` is the sixty-node tree drawn as
  25  one grid of five branches, reachable from the front door and from the ledger
  26  that pays the Cores out. Buying and the free respec both go through
  27  ``meta.MetaProfile`` and are written to the profile at once.
  28* **The two endings and the collapse.** Extraction through the Deep Gate pays
  29  ``balance.CONVERT_RATE_EXTRACTION``; death pays the punitive rate; and the
  30  Last Stand, offered the moment no warp on the chart is affordable, pays
  31  ``balance.LAST_STAND_CONVERT_RATE`` on scrap earned after the trigger, capped
  32  at ``balance.LAST_STAND_CORES_CAP`` Cores, while everything carried into the
  33  collapse still settles at the death rate. Both clauses are load-bearing: a
  34  collapse that paid the full rate on the whole hold would beat extraction and
  35  skip the Gate's toll.
  36* **The ledger.** Death is a deposit and a lesson, never a fail screen. The
  37  ledger itemises the conversion, pays the milestone bounties, scores the run,
  38  compares it against the hull's personal best, and prints one generated
  39  sentence naming the bet that ended it.
  40* **Settings.** Remapping, the shake, flash and hit-stop sliders, the
  41  photosensitivity mode, UI scale, hold-versus-toggle, and the Steady Wake
  42  assist panel, all persisted on the meta profile and all marked on the ledger.
  43
  44``shrike.onboarding`` owns the staged first three runs; this module mounts its
  45director and asks it what is unlocked.
  46"""
  47
  48from __future__ import annotations
  49
  50import math
  51import random
  52from dataclasses import dataclass, field
  53
  54from simvx.core import (
  55    AnchorPreset,
  56    Button,
  57    CheckBox,
  58    Colour,
  59    Control,
  60    DirectionalLight3D,
  61    Input,
  62    InputBinding,
  63    JoyButton,
  64    Key,
  65    Label,
  66    MouseButton,
  67    Node,
  68    Panel,
  69    Property,
  70    Signal,
  71    Slider,
  72    UpdateMode,
  73    VBoxContainer,
  74    Vec2,
  75    Vec3,
  76)
  77from simvx.core.text.measure import LOGICAL_EM, measure_text_width
  78from simvx.core.ui import SizingMode
  79
  80from . import artkit, balance, roost, runtime, save
  81from .bounty import Notoriety
  82from .chart import ChartGraph, StarChart
  83from .combat import DamageRouter
  84from .events import SignalEventDeck
  85from .hud import CONTROLS_BAR_SECOND_ROW, CONTROLS_BAR_TEXT, Hud
  86from .hunter import LANTERN_BLOW_ID, Hunter, HunterDirector, killing_blow
  87from .juice import JuiceDirector
  88from .meta import BRANCHES, DOCTRINE_TREE, HULLS, DoctrineNode, MetaProfile, branch_nodes, prerequisites_of
  89from .modules import MODULE_CATALOGUE, ModulePool, SocketRack, module_spec, sockets_for_hull
  90from .onboarding import Lessons as OnboardingLessons
  91from .onboarding import Onboarding, OnboardingDirector
  92from .power import Generator, PowerSystem, SignalWiring, SolarWings
  93from .runtime import PLANE_Y, CameraRig, Groups, Services, SignalNames, from_plane
  94from .sector import Sector
  95from .ship import PlayerShip
  96from .signature import SignatureMeter
  97from .trading import (
  98    CONSUMABLE_EFFECTS,
  99    CONSUMABLES,
 100    INTERACT_SELL_HOLD_S,
 101    REFIT_PREVIEW_PICK_MOUNT,
 102    REFIT_PREVIEW_SLOT_BARE,
 103    REFIT_PREVIEW_SLOT_FULL,
 104    REFIT_REFUSE_GUN_IN_SOCKET,
 105    REFIT_REFUSE_MODULE_ON_HARDPOINT,
 106    REFIT_REFUSE_TOO_LARGE,
 107    REFIT_REFUSE_TURRETS_FULL,
 108    REFIT_SLOT_EMPTY,
 109    Broker,
 110    Depot,
 111    Economy,
 112    category_label,
 113    effect_line,
 114    fit_panel_lines,
 115    item_name,
 116    item_summary,
 117    refit_preview,
 118)
 119from .waves import WaveComposer
 120from .weapons import HARDPOINT_OFFSETS, WeaponRack
 121
 122# ============================================================================
 123# Module-local constants
 124#
 125# Everything here is a number the design names but balance.py does not yet
 126# price. They are declared once, at the top, rather than inlined.
 127# ============================================================================
 128
 129#: Run score's depth term: the design's "sector-depth bonus", per sector reached.
 130SCORE_DEPTH_BONUS_PER_SECTOR = 25
 131
 132#: Seconds a stranding must hold before the HUD offers the Last Stand, so a
 133#: momentarily empty tank beside a fuel comet is not a death sentence.
 134LAST_STAND_OFFER_GRACE_S = 3.0
 135#: Seconds after the offer before it accepts itself. A stranded run must not be
 136#: able to sit in an unwinnable crawl, which is the whole point of the rule.
 137LAST_STAND_AUTO_ACCEPT_S = 15.0
 138
 139#: Photosensitivity mode's ceiling on bloom intensity, and its damping of the
 140#: warp distortion and every flash the juice layer drives.
 141PHOTOSENSITIVE_BLOOM_INTENSITY = 0.30
 142PHOTOSENSITIVE_FLASH = 0.25
 143
 144#: UI scale slider bounds (section 10 promises 150 percent).
 145UI_SCALE_MIN = 1.0
 146UI_SCALE_MAX = 1.5
 147
 148#: The three mix sliders, as fractions of full scale; ``shrike.audio`` owns the
 149#: decibel arithmetic they land as. Eight tenths rather than full, because the
 150#: bank is baked at a level that leaves headroom for the klaxon and a player
 151#: who wants the game louder than the room has somewhere to go.
 152VOLUME_DEFAULT = 0.8
 153VOLUME_MASTER = "volume_master"
 154VOLUME_SFX = "volume_sfx"
 155VOLUME_MUSIC = "volume_music"
 156VOLUME_SETTINGS: tuple[tuple[str, str], ...] = (
 157    (VOLUME_MASTER, "Master volume"),
 158    (VOLUME_SFX, "SFX volume"),
 159    (VOLUME_MUSIC, "Music volume"),
 160)
 161
 162#: The resolved-modifier key, and the doctrine node behind it, that lift the
 163#: death rate from ``balance.CONVERT_RATE_DEATH`` to the upgraded rate. Both
 164#: names are meta.py's; they are read here so a run launched without a resolved
 165#: effect bag still settles at the rate the player bought.
 166DEATH_RATE_MODIFIER = "death_convert_rate"
 167DEATH_RATE_NODE_ID = "salvage_underwriting"
 168
 169#: The starter fit. Two hardpoints, one gun and the harvest tool.
 170STARTER_WEAPONS: tuple[str, ...] = ("pulse_blaster", "mining_laser")
 171
 172#: Starter power, in preference order: the first module that fits the hull is
 173#: socketed on the stern-most mount that takes it. Wings for any hull with a
 174#: large socket (they boot retracted; unfolding them is the game's first
 175#: posture choice), the RTG trickle for a hull too small to carry them.
 176STARTER_POWER: tuple[str, ...] = ("solar_wings", "rtg")
 177
 178#: The sun that lights every sector.
 179SUN_DIRECTION = Vec3(-0.45, -1.0, -0.35)
 180SUN_COLOUR = (0.86, 0.90, 1.00)
 181SUN_INTENSITY = 1.15
 182
 183#: Menu look. Near-black panels under warm text, the same contract the run uses.
 184MENU_BACKDROP = (0.01, 0.02, 0.03, 1.0)
 185MENU_PANEL = (0.06, 0.08, 0.11, 0.96)
 186MENU_TITLE_COLOUR = (1.00, 0.86, 0.42, 1.0)
 187MENU_TEXT_COLOUR = (0.84, 0.88, 0.95, 1.0)
 188MENU_DIM_COLOUR = (0.55, 0.60, 0.70, 1.0)
 189MENU_WIDTH_PX = 620.0
 190MENU_ROW_HEIGHT_PX = 30.0
 191#: Rough advance width of one glyph as a share of its point size, used to wrap
 192#: a panel's prose to the width of its own buttons before the labels exist.
 193MENU_GLYPH_ADVANCE_FRACTION = 0.56
 194MENU_SEPARATION_PX = 6.0
 195#: Pixels of panel around the column of rows, on all four sides.
 196MENU_PADDING_PX = 16.0
 197#: The gutter between a panel and the edge of the window. A panel that reaches
 198#: the edge reads as a screen that has already overflowed.
 199MENU_WINDOW_MARGIN_PX = 8.0
 200#: How far a column may be shrunk to fit a small window before the type stops
 201#: being worth reading. A screen that still overflows at the floor keeps the
 202#: floor: losing size is recoverable, losing the buttons off the edge is not.
 203MENU_MIN_FIT_SCALE = 0.5
 204#: How many times the fit is measured and re-applied. Type has minimum sizes
 205#: of its own, so a column scaled by a half does not come out half as tall, so
 206#: the fit converges rather than solving. Five passes left the settings screen
 207#: 714 pixels tall in a 720-pixel window: it stopped a pass short of its own
 208#: gutter and read as a screen that had already overflowed. The extra passes
 209#: cost a few measurements once, at build time, and nothing per frame.
 210MENU_FIT_PASSES = 9
 211#: The window a panel assumes when it is measured with no backdrop and no
 212#: tree behind it, which only a test that builds one by hand ever does.
 213MENU_FALLBACK_WINDOW_PX = (1280.0, 720.0)
 214#: The focus ring, drawn as type. A row the cursor stands on is bracketed and
 215#: warmed: both, rather than either, so the selection survives a greyscale
 216#: screenshot and a player who cannot separate those two colours.
 217MENU_CURSOR_LEFT = ">"
 218MENU_CURSOR_RIGHT = "<"
 219MENU_SELECTED_COLOUR = (1.00, 0.86, 0.42, 1.0)
 220#: The footer every menu screen prints, so the keyboard is never a secret. The
 221#: second line is for the screens Escape actually backs out of.
 222MENU_CONTROLS_LINE = "MOVE: W/S OR ARROWS   CHOOSE: F, ENTER OR CLICK"
 223MENU_CONTROLS_LINE_BACK = f"{MENU_CONTROLS_LINE}   BACK: ESC"
 224#: The doctrine grid navigates on two axes, so it gets its own legend rather
 225#: than the column screens' one. It had none at all, on the one screen in the
 226#: game where the controls are not the controls every other screen uses.
 227DOCTRINE_CONTROLS_LINE = "MOVE: WASD OR ARROWS   BUY: F, ENTER OR CLICK   BACK: ESC"
 228
 229#: The run setup screen's hull row, and what pressing it says.
 230#:
 231#: The row used to be one per unlocked hull, which on a fresh profile is one
 232#: row that looks exactly like a selector, brackets and all, and answers the
 233#: confirm key with nothing at all: the blind gate read "> HULL: VAGRANT <"
 234#: as a control that was broken. It is one row now, and the row cycles. With a
 235#: single hull unlocked it cannot cycle, so it says so and names what would
 236#: unlock the others, which are feats rather than a rank: the screen also
 237#: prints them under the row, because the answer to "can I fly something else"
 238#: should not need a keypress to find.
 239SETUP_HULL_ROW = "HULL: {name} ({sockets} SOCKETS)"
 240SETUP_HULL_ROW_CYCLE = "HULL: {name} ({sockets} SOCKETS)   F: NEXT OF {count}"
 241SETUP_HULL_ONLY = "THE {name} IS YOUR ONLY HULL: THE OTHER {count} ARE EARNED IN FLIGHT, NOT BOUGHT"
 242SETUP_HULL_ONLY_ONE = "THE {name} IS THE ONLY HULL IN THE GAME"
 243SETUP_HULL_PICKED = "{name}: {description}"
 244SETUP_HULL_LOCKED_HEAD = "LOCKED HULLS, AND WHAT EARNS THEM:"
 245SETUP_HULL_LOCKED_ROW = "{name}: {feat}"
 246
 247#: Settings rows that carry a value. A row that names only its verb
 248#: ("DAMAGE TAKEN: CYCLE") or only its name ("Screen shake") leaves the player
 249#: pressing it repeatedly to find out what it is set to.
 250SETTINGS_DAMAGE_ROW = "DAMAGE TAKEN: {percent:.0f}%   (CYCLE)"
 251SETTINGS_SLIDER_ROW = "{name}: {percent:.0f}%"
 252SETTINGS_SCALE_ROW = "{name}: {value:.2f}x"
 253
 254#: The frame cap the player can choose, in frames per second. Zero follows the
 255#: display, which is what an uncapped vsync-gated loop does. Uncapped is not
 256#: free: a 144 Hz panel does two and a half times the work of a sixty-frame cap
 257#: for a twin-stick game that reads no better for it, and spends the difference
 258#: in heat and battery.
 259FRAME_CAP_OPTIONS: tuple[int, ...] = (0, 30, 60, 90, 120)
 260#: The row used to say NEXT LAUNCH, because the engine snapshotted its frame
 261#: budget before the loop started and offered no setter, so a cap chosen here
 262#: could only be stored and read back at the next boot. ``App.target_fps`` is
 263#: read/write mid-run now, so the cap applies on the next frame like every other
 264#: row on this screen, and the warning would be a lie.
 265SETTINGS_FRAME_CAP_ROW = "FRAME CAP: {value}   (CYCLE)"
 266SETTINGS_FRAME_CAP_FOLLOW = "FOLLOW DISPLAY"
 267
 268#: Boot straight into a run instead of the main menu. The game opens on its
 269#: front door, which is what a player expects and what the menu is for; the
 270#: switch survives because a debug session that wants a hull in the air one
 271#: keypress sooner should not have to edit the entry point to get one.
 272BOOT_STRAIGHT_INTO_RUN = False
 273
 274#: What the HUD says when the chart is asked for in a run that has not earned
 275#: one yet, when the drive is spooled with nowhere to spool to, and when the
 276#: only depot in reach is a jump away. All three answer a key press, so they go
 277#: on the toast line rather than into the caption queue a kill chime shares.
 278#: The refusal used to be "THE CHART IS OFFLINE THIS RUN", which told a pilot
 279#: neither why nor what to do instead. Run one flies a surveyed route: the
 280#: drive already holds it, so the answer to M is the key that takes it.
 281#: It names the flight and never a jump, because the gate is the run counter
 282#: and nothing else (:meth:`shrike.onboarding.Onboarding.chart_open`). The
 283#: wording before this one promised a chart "AFTER YOUR FIRST JUMP", so a pilot
 284#: who did exactly what the refusal asked was refused again in the same words,
 285#: for the whole run: a condition run one cannot meet must never be printed as
 286#: one it can. It borrows its verb from the other schedule refusal the pilot
 287#: meets, :data:`shrike.power.SILENT_RUNNING_LOCKED_TOAST`, so the two locked
 288#: keys read as one schedule rather than as two unrelated excuses.
 289CHART_OFFLINE_TOAST = "STAR CHART COMES ONLINE NEXT FLIGHT: R FLIES THIS RUN'S SURVEYED ROUTE"
 290WARP_NO_DESTINATION_TOAST = "SELECT A DESTINATION: M"
 291WARP_UNAFFORDABLE_TOAST = "NOT ENOUGH FUEL FOR THAT JUMP: M"
 292DEPOT_ONE_JUMP_TOAST = "DEPOT: 1 jump (M)"
 293#: The affordance channel's fallback lines. Every other prompt belongs to a
 294#: thing in the world; these belong to the hull, so they are offered wherever
 295#: the ship is whenever a breach is open and nothing nearer wants the same key.
 296#: The second is the one the pilot with an empty hold needs: the patch channel
 297#: is priced, and an unpayable price used to answer a one-second hold with
 298#: nothing whatever.
 299PATCH_PROMPT = f"HOLD F: PATCH BREACH, {balance.BREACH_PATCH_SCRAP:.0f} scrap"
 300PATCH_PROMPT_BROKE = f"BREACH OPEN: patching needs {balance.BREACH_PATCH_SCRAP:.0f} scrap"
 301PATCH_PROMPT_RUNNING = "PATCHING BREACH: hold still"
 302
 303#: The warp chip's states, in the order a pilot meets them. The last is run
 304#: one's: with no chart to pick from, the chip must not name the chart's key.
 305#: The ready line names the destination as well as the price, because every
 306#: arrival arms one: a chip quoting a bare number left the pilot no way to
 307#: know where the drive key was about to take them without opening the chart
 308#: to look, and the first line is the only place the answer can live.
 309WARP_STATUS_PICK = "WARP: pick destination (M)"
 310WARP_STATUS_READY = "WARP READY (R): {name} for {fuel} fuel"
 311#: The dry tank names where more comes from, because this is the one line the
 312#: pilot reads at the moment the tank is the problem. The blind gate flew a run
 313#: to a stop without ever finding a screen that said fuel is for sale: a
 314#: refusal that quotes the shortfall and stops there is a wall with the way
 315#: round it written somewhere else.
 316WARP_FUEL_SOURCE = "depots sell fuel cells"
 317WARP_STATUS_NO_FUEL = f"NO FUEL FOR WARP: need {{need}}, have {{have:.0f}} ({WARP_FUEL_SOURCE})"
 318WARP_STATUS_SURVEYED = "WARP READY (R): the route is already surveyed"
 319#: A jump's price, itemised whenever it is more than the base fare. A quoted
 320#: 22 where the base fare is 15 is a number a pilot cannot check and cannot
 321#: plan against: the surcharge is the whole reason to jump before the hunter
 322#: arrives rather than after it, and it was charged without ever being named.
 323WARP_PRICE_PLAIN = "{total:.0f}"
 324WARP_PRICE_ITEMISED = "{total:.0f} ({base:.0f}{parts})"
 325WARP_PRICE_HUNTED = " +{extra:.0f} HUNTED"
 326WARP_PRICE_SKIP = " +{extra:.0f} SKIP"
 327WARP_PRICE_TOLL = " +{extra:.0f} TOLL"
 328
 329#: The objective channel: one sentence per sector saying what the run wants of
 330#: this one. Run one is scripted because the pilot has no chart to plan with;
 331#: after that the line is read off the state the run is really in.
 332OBJECTIVE_RUN_1 = "OBJECTIVE: MINE THE GLOWING VEINS, SCOOP THE SCRAP, THEN PRESS R TO JUMP ON"
 333OBJECTIVE_BANK = "OBJECTIVE: BANK YOUR SCRAP AT THE DEPOT BEFORE THE SIGNATURE LOCKS"
 334OBJECTIVE_GATE = "OBJECTIVE: THE DEEP GATE IS ONE JUMP EAST, {fuel:.0f} FUEL. GET OUT WITH THE HOLD"
 335OBJECTIVE_ONWARD = "OBJECTIVE: FILL THE HOLD, THEN JUMP EAST. {columns} SECTORS TO THE DEEP GATE"
 336#: Scrap in the hold that makes banking the sector's objective.
 337OBJECTIVE_BANK_SCRAP = 60.0
 338
 339#: How the HUD's key strip separates its entries, so flow can filter one out
 340#: without restating the strip.
 341CONTROLS_BAR_SEPARATOR = "   "
 342#: How the strip spells silent running before run three unlocks the posture.
 343#: The key stays on the bar, marked, rather than appearing out of nowhere on
 344#: run three: a strip that grows entries is a strip nobody reads twice.
 345CONTROLS_BAR_SILENT_LOCKED = "C SILENT (locked)"
 346
 347#: How the pause panel joins the HUD's resource cells into one line.
 348PAUSE_RESOURCE_SEPARATOR = "   "
 349
 350#: The action the persistent controls bar is hidden and shown with.
 351CONTROLS_BAR_ACTION = "toggle_controls"
 352CONTROLS_BAR_ON_TOAST = "CONTROLS BAR ON"
 353CONTROLS_BAR_OFF_TOAST = "CONTROLS BAR OFF: H BRINGS IT BACK"
 354
 355#: Every bound gameplay key answers within :data:`KEY_ANSWER_WINDOW_S`, with an
 356#: effect or with a refusal that says why. A second blind playtest pressed X, Z,
 357#: Q, E, Tab, Shift, F and G in an empty sector and got nothing back from any of
 358#: them, and concluded the game was broken: a key that presses into silence is
 359#: indistinguishable from a key that is not bound. The run scene owns the
 360#: answers, because it is the one place that can see every system at once.
 361#:
 362#: The wings and the generator move real state, so their answers name the state
 363#: they moved to (including "already out", which is the answer a redundant press
 364#: earns and the one the playtest most needed). The scoop is passive and always
 365#: on; the answer says so rather than pretending the key toggles something.
 366KEY_ANSWER_WINDOW_S = 0.2
 367WINGS_DEPLOYING_TOAST = "SOLAR WINGS UNFOLDING"
 368WINGS_RETRACTING_TOAST = "SOLAR WINGS FOLDING IN"
 369WINGS_ALREADY_OUT_TOAST = "WINGS ALREADY OUT: FOLDING THEM IN"
 370WINGS_NONE_TOAST = "NO SOLAR WINGS FITTED: depots stock them"
 371GENERATOR_ON_TOAST = "GENERATOR RUNNING: loud, and it burns fuel"
 372GENERATOR_OFF_TOAST = "GENERATOR OFF"
 373GENERATOR_NONE_TOAST = "NO GENERATOR FITTED: depots stock them"
 374GENERATOR_REFUSED_TOAST = "GENERATOR WILL NOT START: no fuel, or running silent"
 375SCOOP_PASSIVE_TOAST = "TRACTOR SCOOP IS ALWAYS ON: fly over scrap to take it aboard"
 376SHIELD_MOVED_TOAST = "SHIELD PAD: Q AND E SWING IT ROUND THE HULL"
 377AFTERBURNER_TOAST = "AFTERBURNER: burns capacitor while it is held"
 378AFTERBURNER_FLAT_TOAST = "AFTERBURNER NEEDS POWER: the capacitor is flat"
 379INTERACT_NOTHING_TOAST = "NOTHING IN REACH"
 380JETTISON_NOTHING_TOAST = "NOTHING TO JETTISON: the hold is empty"
 381JETTISON_NO_HUNTER_TOAST = "NOTHING OUT THERE TO BAIT: jettisoned scrap only buys time from the Shrike"
 382JETTISON_DONE_TOAST = "SCRAP JETTISONED AS BAIT"
 383
 384#: The sector arrival card: the place's name and one line about what it is for.
 385#: A warp used to land the ship in an unnamed field of rocks indistinguishable
 386#: from the last one, so nothing about travelling read as travel. The name is
 387#: derived from the biome id the way the chart derives its own labels; only the
 388#: flavour is authored, and it is authored here because balance.py's biome notes
 389#: are written for whoever is tuning the drop tables.
 390SECTOR_FLAVOUR: dict[str, str] = {
 391    "debris_field": "old wrecks, loose scrap, nothing that was not already broken",
 392    "solar_shallows": "open starlight: the wings pay double and make you a target",
 393    "ice_field": "frozen air in the rocks; poor scrap, full lungs",
 394    "nebula": "no light for the wings and no bearing on what is coming",
 395    "wreck_graveyard": "a fleet died here; the hulls are still worth taking apart",
 396    "vent_field": "fuel and air venting from the seams, and mines drifting in them",
 397    "broker_claim": "someone else's market, guarded, and it deals in what depots will not",
 398    "roost": "the nest. There is no jumping out of this one",
 399}
 400
 401#: What the Wake's swept ground costs, once the chart lets a pilot back into
 402#: it. Red columns are jumpable at the ordinary price and the bill falls here
 403#: instead: the meter starts most of the way to a lock, and whatever the
 404#: arrival count, the Shrike gives the short warning. Both numbers live in this
 405#: module rather than in balance.py because they are the run scene's reading of
 406#: a chart state, not a table the sector generator or the ladder consults. The
 407#: signature start sits above every act floor (0 / 20 / 40) so the price is
 408#: real in the Shallows and still real in the Deep, and the telegraph matches
 409#: the nebula's, which is the shortest warning the game already gives.
 410WAKE_TERRITORY_SIGNATURE_START = 65.0
 411WAKE_TERRITORY_TELEGRAPH_S = balance.SHRIKE_TELEGRAPH_NEBULA_S
 412#: How the arrival reads. The stamp is the moment, the flavour is the arrival
 413#: card's second line, and the toast is the sentence that names the deal.
 414WAKE_TERRITORY_STAMP = "SHRIKE TERRITORY"
 415WAKE_TERRITORY_FLAVOUR = "swept ground: it has already hunted here, and it knows the shape of these rocks"
 416WAKE_TERRITORY_TOAST = "SHRIKE TERRITORY: IT IS WAITING. SIGNATURE HIGH, WARNING SHORT"
 417#: And the same deal arriving the other way round: the front catching a pilot
 418#: who spent their jumps going backwards, in the sector they are standing in.
 419WAKE_CAUGHT_STAMP = "THE WAKE HAS YOU"
 420WAKE_CAUGHT_TOAST = "THE WAKE HAS TAKEN THIS SECTOR: JUMP ON, OR MEET IT HERE"
 421
 422#: Seconds of scooped motes one "+N SCRAP" label speaks for. A scoop pass over
 423#: a kill's confetti is one pickup to the player and a dozen to the sector, so
 424#: the labels merge inside this window rather than stacking.
 425SCRAP_TOAST_WINDOW_S = 0.6
 426
 427#: The label floated when a whole box's worth of scooped rounds is stowed.
 428AMMO_BOX_FLOAT = "+1 AMMO BOX ({name})"
 429
 430#: Signature past this fraction of the lock threshold makes a death with an
 431#: affordable exit an "overstayed" story on the ledger. Below it the meter was
 432#: never the bet that lost, so the recap tells the combat story instead.
 433RECAP_OVERSTAY_SIGNATURE_FRACTION = 0.5
 434
 435#: Seconds a hit stays attributable: past this, a death is not that hit's fault.
 436KILLER_ATTRIBUTION_WINDOW_S = 3.0
 437#: How closely an enemy has to sit on the bearing of the last hit taken before
 438#: the ledger will name it. About 57 degrees either side of the bearing, which
 439#: is wide enough for a mover and narrow enough to never name the wrong side.
 440KILLER_BEARING_COS = 0.55
 441#: How close an enemy has to be, in world units, to be named for a killing blow
 442#: no earlier hit set a bearing for. Inside this it was on top of the hull.
 443KILLER_NEAR_UNITS = 25.0
 444#: What the ledger calls a killer it could not name.
 445KILLER_UNKNOWN = "enemy_fire"
 446
 447#: How long the world keeps running after the hull reaches zero, before the
 448#: ledger replaces it. The run used to settle inside ``Ship.apply_damage``, on
 449#: the same frame the blow landed, so a pilot killed by the Shrike's lantern
 450#: never saw the burn: the last frame of the game was the pink pre-burn cone and
 451#: the next one was a table of numbers. Long enough to read the blow that did
 452#: it, short enough that nobody sits waiting for a screen they have earned.
 453DEATH_SETTLE_S = 0.6
 454
 455#: Headlines for the deaths that are not a named archetype. Everything else is
 456#: "DESTROYED BY <NAME>": the pilot must always leave knowing what did it.
 457DEATH_HEADLINES: dict[str, str] = {
 458    "suffocation": "LIFE SUPPORT EXHAUSTED",
 459    "hunter": "THE SHRIKE TOOK YOU",
 460    LANTERN_BLOW_ID: "THE SHRIKE'S LANTERN BURNED YOU",
 461    "abandoned": "RUN ABANDONED",
 462    KILLER_UNKNOWN: "DESTROYED BY ENEMY FIRE",
 463}
 464#: The ledger's headline and recap type, in pixels. The headline is the first
 465#: thing read and the recap the second, so neither is a menu row.
 466LEDGER_HEADLINE_FONT_PX = 40.0
 467LEDGER_RECAP_FONT_PX = 18.0
 468#: The standard panel title size, which the ledger's headline overrides.
 469MENU_TITLE_FONT_PX = 24.0
 470
 471# ============================================================================
 472# The doctrine screen
 473#
 474# Sixty nodes in five branches of twelve, drawn as a tree. It used to be a
 475# grid: five columns of twelve labels stacked in reading order, which is a page
 476# of buttons rather than a map, and it hid the one thing the tree is for. The
 477# branch shape is a spine and a shoulder, the spine being the cheap route to a
 478# keystone, and nothing on a grid said so, because a grid cannot draw the line
 479# between a node and the node that unlocks it.
 480#
 481# So the branches are drawn as they are shaped: a lane per fork, a row per
 482# tier, and a connector between every node and its prerequisite. Depth down the
 483# screen is depth into the branch, and a locked node is locked by something the
 484# eye can follow back up the line.
 485# ============================================================================
 486
 487DOCTRINE_CELL_FONT_PX = 10.0
 488DOCTRINE_DETAIL_FONT_PX = 13.0
 489#: One node on the tree, and the gaps that make the shape readable: between the
 490#: two lanes of one branch, between branches, and between tiers.
 491DOCTRINE_NODE_WIDTH_PX = 140.0
 492#: A node is two lines of type tall (see DOCTRINE_CELL_LINES). The plate is
 493#: deliberately taller than the two lines need, because a caption laid tight
 494#: against the plate's own edge loses its bottom pixels to the plate's border
 495#: ring: see BUGS.md, bug-a-repainted-panel-draws-over-a-later-sibling-label.
 496DOCTRINE_NODE_HEIGHT_PX = 38.0
 497DOCTRINE_LANE_GAP_PX = 6.0
 498#: The two gaps below are what the tree spends to buy the type its captions are
 499#: really drawn at. The engine will not draw text below
 500#: :data:`DOCTRINE_CELL_MIN_FONT_PX`, so the drawn scale a node needs before a
 501#: priced line fits between its borders is fixed rather than negotiable, and
 502#: reaching it inside a 1280x720 window meant giving the gaps back to the
 503#: nodes. Both are still wider than a connector, which is all either of them
 504#: has to carry, and the branch gap stays the larger of the two so five
 505#: branches still read as five. Round 15, finding 45.
 506DOCTRINE_BRANCH_GAP_PX = 20.0
 507DOCTRINE_TIER_GAP_PX = 7.0
 508#: Clear space inside a node's plate, either side of its line of type. Kept
 509#: small on purpose: ten lanes of nodes across a 1280-pixel window leave a node
 510#: a little over a hundred drawn pixels of type at the one size the renderer
 511#: will honour, and every pixel of padding is a character of the title.
 512DOCTRINE_NODE_PAD_PX = 3.0
 513#: The branch heading strip above the first tier, and how much louder than a
 514#: cell its type is. The multiplier is applied to the size the cells are really
 515#: drawn at, so the heading stays the larger of the two at every draw scale
 516#: instead of both landing on the renderer's own floor.
 517DOCTRINE_HEADING_HEIGHT_PX = 22.0
 518DOCTRINE_HEADING_FONT_MULT = 1.1
 519#: Connector thickness, and the ring drawn round a node.
 520DOCTRINE_LINK_WIDTH_PX = 2.0
 521DOCTRINE_BORDER_PX = 2.0
 522#: A cell's caption is two lines, not one. A node holds a couple of dozen
 523#: characters at the one size the renderer will draw, and a title with its
 524#: price beside it is longer than that often enough that a single line spent a
 525#: third of the branch on "Ammunition Sov... 40": the vocabulary the tree is
 526#: read for was the part being thrown away. The plate has the height for two
 527#: lines, so the title wraps onto the second and the cost rides with it, and
 528#: only a title too long for both is cut at all. Both lines live in one label,
 529#: because ``Label`` stacks an embedded newline on a 1.2 pitch and centres the
 530#: block in its rect: two controls would have to be kept vertically in step
 531#: with the plate at every draw scale, and the first attempt at that hung the
 532#: second line over the plate's bottom border. Characters the state marks and
 533#: their space take off the first line, which the second line is normally
 534#: indented by to sit under the title rather than under the mark. Normally,
 535#: because that indent is the first thing to give way when the second line is
 536#: full: see :func:`_cell_indent`.
 537DOCTRINE_CELL_MARK_CHARS = 3
 538#: The smallest type the renderer will actually put on the glass. Below it the
 539#: draw call raises the size it draws at, so a caption laid out at anything
 540#: under this floor is painted a quarter wider than the rect it was placed in:
 541#: the tree budgeted its cells at ten points times the draw scale, about seven
 542#: and a half, and "Sovereignty 40 Cores" was drawn straight out through the
 543#: plate's own border while the layout believed it had room to spare. Every
 544#: measurement of a cell is taken at this size, and every cell is drawn at it,
 545#: so the two cannot disagree again. Round 15, finding 45.
 546DOCTRINE_CELL_MIN_FONT_PX = 10.0
 547#: The widest caption budget a cell is ever asked to hold, in characters. The
 548#: budget itself is measured rather than assumed (:meth:`DoctrineScene.cell_chars`);
 549#: this is only where the search for it starts, set well past the longest
 550#: priced caption the tree can produce so that a wide window is answered by the
 551#: type rather than clipped by this ceiling.
 552DOCTRINE_CELL_CHARS_MAX = 40
 553#: Vertical room the caption is given beyond its plate, centred on it. A label
 554#: is drawn inside the bounds it was placed at, and two lines of the smallest
 555#: type the engine will draw come to more than a node's plate at the smallest
 556#: scale the tree is drawn at: without the overhang the second line lost its
 557#: bottom third to the plate's own edge, which is the truncation this was
 558#: meant to end wearing a different hat.
 559DOCTRINE_CELL_ROOM_PX = 12.0
 560#: The smallest the tree is ever drawn at. Below this the panel stops shrinking
 561#: the drawing and starts panning it under the cursor instead: a tree fitted
 562#: into a 500-pixel window by scale alone is a diagram nobody can read, and the
 563#: point of drawing it as a tree is that it can be read. The floor is set by
 564#: the type rather than by taste: a cell drawn smaller than this cannot hold a
 565#: priced line between its own borders at :data:`DOCTRINE_CELL_MIN_FONT_PX`,
 566#: which is the one size the renderer will honour, so below it the tree would
 567#: be shrinking the plates around type that is not shrinking with them.
 568DOCTRINE_MIN_DRAW_SCALE = 0.80
 569
 570#: Bought, out of reach, and the two states between. Every one of them also
 571#: carries a text mark, so the state survives a greyscale screenshot and a
 572#: player who cannot separate those colours.
 573DOCTRINE_OWNED_COLOUR = (0.56, 0.94, 0.62, 1.0)
 574DOCTRINE_BLOCKED_COLOUR = (0.42, 0.46, 0.55, 1.0)
 575DOCTRINE_COSTLY_COLOUR = (0.78, 0.70, 0.50, 1.0)
 576DOCTRINE_KEYSTONE_COLOUR = (0.88, 0.70, 1.00, 1.0)
 577#: The node plates, by state, and the ring the cursor draws round one.
 578DOCTRINE_NODE_OWNED_FILL = (0.09, 0.22, 0.15, 1.0)
 579DOCTRINE_NODE_OPEN_FILL = (0.10, 0.13, 0.18, 1.0)
 580DOCTRINE_NODE_LOCKED_FILL = (0.05, 0.06, 0.08, 1.0)
 581DOCTRINE_NODE_EDGE_COLOUR = (0.28, 0.34, 0.44, 1.0)
 582DOCTRINE_CURSOR_COLOUR = (1.00, 0.86, 0.42, 1.0)
 583#: A connector, and a connector whose upper end is already bought: the lit line
 584#: is the route the Cores have actually opened.
 585DOCTRINE_LINK_COLOUR = (0.20, 0.25, 0.32, 1.0)
 586DOCTRINE_LINK_LIT_COLOUR = (0.34, 0.66, 0.46, 1.0)
 587
 588#: The marks every cell carries. The cursor mark and one state mark, so the
 589#: four states are told apart without colour: bought, buyable now, priced out
 590#: of the purse, and locked behind a prerequisite further up the branch.
 591DOCTRINE_MARK_CURSOR = ">"
 592DOCTRINE_MARK_OWNED = "*"
 593DOCTRINE_MARK_BUYABLE = "+"
 594DOCTRINE_MARK_COSTLY = "$"
 595DOCTRINE_MARK_LOCKED = "-"
 596DOCTRINE_LEGEND_LINE = f"{DOCTRINE_MARK_OWNED} OWNED   {DOCTRINE_MARK_BUYABLE} BUY NOW   "
 597DOCTRINE_LEGEND_LINE += f"{DOCTRINE_MARK_COSTLY} COSTS MORE THAN YOU HAVE   {DOCTRINE_MARK_LOCKED} LOCKED"
 598#: The four states, spelled once each way: the mark, the type colour and the
 599#: plate under it. Three channels rather than one, because the difference
 600#: between "not yet affordable" and "locked further up the branch" is the
 601#: difference between saving and buying something else first.
 602DOCTRINE_STATE_OWNED_ID = "owned"
 603DOCTRINE_STATE_BUYABLE_ID = "buyable"
 604DOCTRINE_STATE_COSTLY_ID = "costly"
 605DOCTRINE_STATE_LOCKED_ID = "locked"
 606DOCTRINE_STATE_MARKS: dict[str, str] = {
 607    DOCTRINE_STATE_OWNED_ID: DOCTRINE_MARK_OWNED,
 608    DOCTRINE_STATE_BUYABLE_ID: DOCTRINE_MARK_BUYABLE,
 609    DOCTRINE_STATE_COSTLY_ID: DOCTRINE_MARK_COSTLY,
 610    DOCTRINE_STATE_LOCKED_ID: DOCTRINE_MARK_LOCKED,
 611}
 612DOCTRINE_STATE_COLOURS: dict[str, tuple[float, float, float, float]] = {
 613    DOCTRINE_STATE_OWNED_ID: DOCTRINE_OWNED_COLOUR,
 614    DOCTRINE_STATE_BUYABLE_ID: MENU_TEXT_COLOUR,
 615    DOCTRINE_STATE_COSTLY_ID: DOCTRINE_COSTLY_COLOUR,
 616    DOCTRINE_STATE_LOCKED_ID: DOCTRINE_BLOCKED_COLOUR,
 617}
 618DOCTRINE_STATE_FILLS: dict[str, tuple[float, float, float, float]] = {
 619    DOCTRINE_STATE_OWNED_ID: DOCTRINE_NODE_OWNED_FILL,
 620    DOCTRINE_STATE_BUYABLE_ID: DOCTRINE_NODE_OPEN_FILL,
 621    DOCTRINE_STATE_COSTLY_ID: DOCTRINE_NODE_OPEN_FILL,
 622    DOCTRINE_STATE_LOCKED_ID: DOCTRINE_NODE_LOCKED_FILL,
 623}
 624#: The detail block's prerequisite line, which is the answer to the question a
 625#: locked node raises and the grid could never answer.
 626DOCTRINE_REQUIRES_LINE = "REQUIRES: {names}"
 627DOCTRINE_REQUIRES_NONE = "NO PREREQUISITE: THE BRANCH OPENS HERE"
 628#: Said only while the drawing is bigger than the window it is drawn in.
 629DOCTRINE_PAN_NOTE = "THE TREE IS WIDER THAN THIS WINDOW: THE VIEW FOLLOWS THE CURSOR"
 630
 631#: The three rows under the grid, and the two doors onto the screen.
 632DOCTRINE_ACTION_BUY = "BUY THE SELECTED NODE"
 633DOCTRINE_ACTION_RESPEC = "FREE RESPEC: REFUND THE WHOLE TREE"
 634DOCTRINE_ACTION_BACK = "BACK"
 635DOCTRINE_MENU_ENTRY = "FLEET DOCTRINE ({cores} Cores)"
 636#: Front-door rows, by the prefix a returning screen names them with. The
 637#: doctrine row carries a live Cores count, so it is named by its prefix.
 638MENU_ENTRY_SETTINGS = "SETTINGS"
 639MENU_ENTRY_DOCTRINE = "FLEET DOCTRINE"
 640MENU_ENTRY_RESUME = "RESUME RUN"
 641MENU_ENTRY_NEW_RUN = "NEW RUN"
 642LEDGER_DOCTRINE_ENTRY = "SPEND YOUR CORES: FLEET DOCTRINE"
 643#: The price a node's cell prints, as one token. The digits and their unit
 644#: travel together or not at all: a cell that shed the unit to make room read
 645#: "Sovereignty 40" under a column of siblings reading "NN Cores", which is a
 646#: stat, not a price. Round 14, finding 39.
 647DOCTRINE_CELL_COST = "{cost} Cores"
 648#: The doctrine screen's own lines: what a node is worth, and what just happened.
 649DOCTRINE_STATE_OWNED = "OWNED"
 650DOCTRINE_STATE_BUYABLE = "F: BUY FOR {cost} CORES"
 651#: The state line for a node whose only blocker is an unowned prerequisite.
 652#: The REQUIRES line right below already names what is missing, so echoing the
 653#: blocker there printed the same fact twice and cost the pane the one thing
 654#: the prerequisite line cannot say: the price against the purse.
 655DOCTRINE_STATE_PRICED = "COSTS {cost} CORES, YOU HAVE {cores}"
 656DOCTRINE_BOUGHT = "BOUGHT {title} FOR {cost} CORES"
 657DOCTRINE_RESPEC_DONE = "TREE REFUNDED: {refund} CORES BACK IN THE PURSE"
 658DOCTRINE_RESPEC_EMPTY = "NOTHING BOUGHT YET: THE PURSE IS ALREADY WHOLE"
 659DOCTRINE_SUBTITLE = "{cores} Cores unspent, {spent} spent, keystones {active}/{max_active} armed of {owned} bought"
 660#: Where Cores come from, printed on both screens that show a Core balance.
 661#:
 662#: The blind gate reached the tree with nothing to spend, was told the cheapest
 663#: node costs fifteen, and found no sentence anywhere in the game naming a
 664#: single source. Every number here is read off ``balance``'s conversion rates
 665#: rather than written out, so a rebalance moves the sentence with it: scrap
 666#: banked at a post, scrap run through a Refinery in flight, scrap carried out
 667#: through the Deep Gate, and the fraction of a hold a death still pays. All
 668#: four are the rates ``settle_conversion``, :mod:`shrike.trading` and
 669#: :mod:`shrike.modules` actually charge. The milestone bounties are the one
 670#: source that is not a conversion, so they are named rather than priced.
 671#: The standard menu label draws at fourteen pixels, which is what these wrap at.
 672MENU_LINE_FONT_PX = 14.0
 673CORES_SOURCE_LINE = (
 674    "Cores are banked scrap: sell the hold at a depot ({post:.1f} Cores per {unit:.0f} scrap), "
 675    "run a Refinery in flight ({refinery:.1f}, and it is loud), carry it out through the "
 676    "Deep Gate ({gate:.1f}), or die holding it and keep {death:.1f}. "
 677    "First-time milestones pay a bounty on top."
 678)
 679
 680# ============================================================================
 681# The dock screen
 682#
 683# What the bay sells, what it pays for a hold, and the way out. It is an
 684# overlay inside the run rather than a scene, for the same reason the pause
 685# menu is: undocking has to put the pilot back exactly where they docked.
 686# ============================================================================
 687
 688#: A shelf row is the widest line in the game: a mark, a name, a category, a
 689#: price, the fit the depot folded in, the mechanical summary and a state
 690#: mark, all on one line. The row is cut to :data:`DEPOT_ROW_CHARS` so it can
 691#: never run out through both sides of its own button, which is what the
 692#: longest module row was doing.
 693DEPOT_ROW_WIDTH_PX = 1100.0
 694DEPOT_ROW_HEIGHT_PX = 22.0
 695DEPOT_ROW_FONT_PX = 13.0
 696DEPOT_ROW_CHARS = int(DEPOT_ROW_WIDTH_PX / (DEPOT_ROW_FONT_PX * MENU_GLYPH_ADVANCE_FRACTION))
 697DEPOT_ROW_ELLIPSIS = "..."
 698
 699#: The shelf itself is a grid of cards rather than a stack of sentences. A row
 700#: that carried a name, a category, a price, a fit, a mechanical summary and a
 701#: state mark on one line was a line nobody read to the end of: the price sat
 702#: in the middle of it and the reason a thing could not be bought sat at the
 703#: far right, past a hundred characters of prose. A card puts the three
 704#: questions on three lines -- what is it and what does it cost, what does it
 705#: do, and can I have it -- and two of them fit side by side.
 706DEPOT_GRID_COLUMNS = 2
 707DEPOT_CARD_GAP_PX = 12.0
 708DEPOT_CARD_PAD_PX = 6.0
 709DEPOT_CARD_LINE_PX = 17.0
 710DEPOT_CARD_LINES = 3
 711DEPOT_CARD_HEIGHT_PX = DEPOT_CARD_LINES * DEPOT_CARD_LINE_PX + 2.0 * DEPOT_CARD_PAD_PX
 712DEPOT_CARD_BORDER_PX = 2.0
 713DEPOT_CARD_HEAD_FONT_PX = 13.0
 714DEPOT_CARD_BODY_FONT_PX = 12.0
 715#: The plate the whole shop stands on, which the shelf needs and the other
 716#: screens do not. :data:`MENU_PANEL` is a shade of blue-grey at ninety-six
 717#: percent, which is right over a menu nebula and wrong over a docking bay: the
 718#: barge is a lit object a few metres away, so the four percent came through as
 719#: a drum and a mooring ring drawn across the cards, and the cards themselves
 720#: were a slightly different blue-grey on top of it. The bay gets its own
 721#: plate, opaque and nearly black, so a card is the brightest thing on the
 722#: screen rather than one of three greys competing.
 723DEPOT_PANEL_FILL = (0.014, 0.020, 0.030, 1.0)
 724#: The card plates. A card the hold cannot pay for is drawn on a darker plate
 725#: in a dimmer type *and* says how short it is: the grey alone would only tell
 726#: a player who already knew the shelf greys what it cannot sell.
 727DEPOT_CARD_FILL = (0.10, 0.14, 0.20, 1.0)
 728DEPOT_CARD_SHORT_FILL = (0.04, 0.05, 0.07, 1.0)
 729DEPOT_CARD_SOLD_FILL = (0.05, 0.11, 0.08, 1.0)
 730DEPOT_CARD_EDGE_COLOUR = (0.36, 0.44, 0.56, 1.0)
 731DEPOT_CARD_FOCUS_COLOUR = (1.00, 0.86, 0.42, 1.0)
 732DEPOT_CARD_BUY_COLOUR = (0.72, 0.92, 0.78, 1.0)
 733DEPOT_CARD_SHORT_COLOUR = (0.94, 0.66, 0.54, 1.0)
 734DEPOT_CARD_SOLD_COLOUR = (0.56, 0.80, 0.62, 1.0)
 735#: The card's middle line. It used to be drawn in :data:`MENU_DIM_COLOUR`, the
 736#: shade the footers and the FIT block are written in, which put the one line
 737#: saying what a thing *does* in the quietest type on the screen.
 738DEPOT_CARD_BODY_COLOUR = (0.78, 0.84, 0.93, 1.0)
 739#: The card's three lines. The head carries the mark, the name, the price and
 740#: the category; the body is the mechanical effect; the foot is the answer to
 741#: "can I buy this", which is a verb, a shortfall or a refusal, never blank.
 742DEPOT_CARD_HEAD = "{mark}{flag}{name}   {price:.0f} SCRAP   [{category}]"
 743DEPOT_CARD_BUY = "F: BUY FOR {price:.0f} SCRAP"
 744DEPOT_CARD_SOLD = "SOLD, ALREADY ABOARD"
 745DEPOT_CARD_SHORT = "NEED {short:.0f} MORE SCRAP"
 746#: The one line under the grid: everything the focused card had to cut.
 747DEPOT_DETAIL_PREFIX = "SELECTED: "
 748#: The shortest window that prints the FIT panel under the shelf. Below it the
 749#: shelf and the fit block together cannot both be readable, and the shelf is
 750#: the half that spends scrap; the pause overlay mirrors the fit either way.
 751DEPOT_FIT_PANEL_MIN_WINDOW_PX = 600.0
 752DEPOT_FIT_ELSEWHERE = "FIT: ESC SHOWS WHAT IS BOLTED WHERE"
 753DEPOT_TITLE = "DRIFT DEPOT"
 754BROKER_TITLE = "BROKER BARGE"
 755#: The bay's own legend. The shelf is a grid now, so it has a second axis and
 756#: needs a line that says so; the screen had no legend at all before.
 757DEPOT_CONTROLS_LINE = "MOVE: W/S OR ARROWS   ROW: A/D   BUY: F, ENTER OR CLICK   SELL: HOLD F   LEAVE: ESC"
 758DEPOT_HOLD_SUBTITLE = "{scrap:.0f} scrap in the hold"
 759DEPOT_SELL_ROW = "SELL THE HOLD: {scrap:.0f} scrap for {cores:.1f} Cores at rate {rate:.1f} (or hold F)"
 760DEPOT_SELL_EMPTY_ROW = "SELL THE HOLD: nothing aboard to sell"
 761#: The door onto the refit panel. It carries the locker count because that is
 762#: what a pilot is deciding on: a bay with nothing stowed still refits (a gun
 763#: can come off), but a bay with three things waiting is a bay worth opening.
 764DEPOT_REFIT_ROW = "REFIT THE HULL: mount guns and modules ({stowed} stowed)"
 765#: The same row once a purchase has actually put something in the locker. A
 766#: gun that is bought and stowed does nothing at all until it is mounted, and
 767#: the bay is the only place it can be, so the door onto the refit panel says
 768#: so in its own label rather than leaving the news on a status line that the
 769#: next press will overwrite.
 770DEPOT_REFIT_ROW_WAITING = "REFIT THE HULL: {stowed} IN THE LOCKER WAITING FOR A MOUNT, FIT THEM HERE"
 771DEPOT_REROLL_ROW = "REROLL THE SHELF: {price} scrap"
 772DEPOT_UNDOCK_ROW = "UNDOCK AND FLY ON"
 773#: The shelf's per-row state marks.
 774DEPOT_MARK_SOLD = "SOLD"
 775DEPOT_MARK_SHORT = "NEED {short:.0f} MORE"
 776#: The canister's flag, the subtitle's warning, and what the bay says when the
 777#: air is short and this visit's ration is already spent. A shelf without a
 778#: canister on it at all is no longer a state the game can reach: air and fuel
 779#: are ``trading.GUARANTEED_CONSUMABLES`` and every bay carries both.
 780DEPOT_O2_ITEM_ID = "o2_canister"
 781DEPOT_MARK_AIR = "AIR"
 782DEPOT_LOW_AIR_SUBTITLE = "AIR LOW, {seconds:.0f} seconds left"
 783DEPOT_NO_CANISTER = "this visit's canister is spent; ice fields refill"
 784#: The same three for the other clock. A tank is read in jumps rather than in
 785#: units, because the number a pilot is deciding with is how many more times
 786#: the drive will fire, and the jump price is the one the chart is quoting.
 787DEPOT_FUEL_ITEM_ID = "fuel_cell"
 788DEPOT_MARK_FUEL = "FUEL"
 789DEPOT_LOW_FUEL_SUBTITLE = "FUEL LOW, {jumps:.0f} jumps left"
 790DEPOT_NO_FUEL_CELL = "this visit's cells are spent"
 791#: Jumps left in the tank at or under which the bay says so. One is a bay you
 792#: must buy at; two is a bay you should.
 793DEPOT_LOW_FUEL_JUMPS = 2.0
 794#: What a refused action says on the screen's own status line.
 795DEPOT_REROLL_REFUSED = "NOT ENOUGH SCRAP TO REROLL"
 796DEPOT_BUY_SHORT = "NOT ENOUGH SCRAP FOR THE {name}"
 797DEPOT_BUY_SOLD = "THE {name} IS ALREADY SOLD"
 798DEPOT_BUY_DONE = "BOUGHT THE {name} FOR {price:.0f} SCRAP"
 799#: What a purchase says once it has landed. A receipt that only names the price
 800#: leaves the pilot to go and look for what changed; these name the change
 801#: itself, read off the hull before and after the payment, so a gun that went
 802#: straight onto a bare hardpoint and a gun that went into the locker are two
 803#: different sentences rather than the same one.
 804DEPOT_BUY_MOUNTED = "BOUGHT THE {name} FOR {price:.0f} SCRAP: MOUNTED ON HARDPOINT {hardpoint}"
 805DEPOT_BUY_INSTALLED = "BOUGHT THE {name} FOR {price:.0f} SCRAP: INSTALLED IN THE {slot}"
 806DEPOT_BUY_STOWED = "BOUGHT THE {name} FOR {price:.0f} SCRAP: IN THE LOCKER, MOUNT IT AT REFIT THE HULL"
 807DEPOT_BUY_APPLIED = "BOUGHT THE {name} FOR {price:.0f} SCRAP: {effect}"
 808DEPOT_SOLD_HOLD = "SOLD THE HOLD FOR {cores:.1f} CORES"
 809DEPOT_SOLD_NOTHING = "THE HOLD IS EMPTY"
 810DEPOT_REROLLED = "FRESH SHELF, NEXT REROLL {price} SCRAP"
 811#: What a committed mount reports. Written here rather than on the panel that
 812#: shows it, because the depot's own ``swap_weapon`` is the other way a gun
 813#: reaches a hardpoint and both have to report themselves in the same words.
 814DEPOT_SWAP_DONE = "MOUNTED THE {name} ON HARDPOINT {hardpoint}"
 815DEPOT_SWAP_DONE_DISPLACED = "MOUNTED THE {name} ON HARDPOINT {hardpoint}, {displaced} STOWED"
 816
 817#: The Doctrine Kits: the free pick the first dock of a profile prints above
 818#: its shelf, one of three, per design section 5. The choice is the run's first
 819#: build fork; it is taken exactly once per profile and delivery rides the same
 820#: paths a paid purchase takes, so a kit part obeys every fitting rule.
 821KIT_SOLAR = "solar"
 822KIT_FORGE = "forge"
 823KIT_ARSENAL = "arsenal"
 824#: The contents table is balance data (``balance.KIT_CONTENTS``); this module
 825#: owns only the delivery and the wording.
 826KIT_CONTENTS: dict[str, tuple[str, ...]] = dict(balance.KIT_CONTENTS)
 827#: Boxes riding with the Arsenal Kit's cannon, keyed to it in the locker even
 828#: while the cannon waits stowed for a refit.
 829KIT_ARSENAL_AMMO_BOXES = balance.KIT_ARSENAL_FLAK_ROUNDS // balance.AMMO_BOX_ROUNDS
 830KIT_NAMES: dict[str, str] = {KIT_SOLAR: "SOLAR KIT", KIT_FORGE: "FORGE KIT", KIT_ARSENAL: "ARSENAL KIT"}
 831#: What one kit contains. Written as a list of parts and never as a choice
 832#: between them: the row used to read "FREE, PICK ONE: solar wings <> signature
 833#: dampener", and a blind pilot read the separator as the fork rather than the
 834#: three rows above it. A kit is one free box with everything in it in; the
 835#: pick is which box.
 836KIT_EFFECTS: dict[str, str] = {
 837    KIT_SOLAR: "solar wings AND a signature dampener",
 838    KIT_FORGE: "a generator AND a sentry turret",
 839    KIT_ARSENAL: f"a flak cannon AND {balance.KIT_ARSENAL_FLAK_ROUNDS} rounds for it",
 840}
 841KIT_ROW = "{name} [doctrine kit] FREE, ONE KIT OF THE THREE, BOTH PARTS: {effects}"
 842KIT_TAKEN = "{name} ABOARD: {effects}. THE DOCTRINE PICK IS MADE"
 843
 844
 845# ============================================================================
 846# The refit panel
 847#
 848# Where a fit is changed. It used to be a block of sentences under the shelf,
 849# one row per stowed weapon per hardpoint, and it read as a cursor moving over
 850# prose rather than as a ship: nothing on it said where hardpoint 2 was, a
 851# socket could not be reached at all, and eight rows said the same four words.
 852#
 853# This is the hull drawn as a hull. The mounts are boxes where they really sit
 854# on the plan, each carrying what is bolted to it; the locker is a column beside
 855# it; and the two are joined by picking one of each. Every geometry number here
 856# is in hull units (the same frame the sockets and hardpoints are laid out in)
 857# or a fraction of the panel, so one scale factor fits the whole screen to the
 858# window and nothing has to be re-authored for a small one.
 859# ============================================================================
 860
 861#: The size the panel is authored at, and the floor it may be shrunk to. Both
 862#: axes are fitted by the same factor, because the schematic is a drawing: a
 863#: hull squashed on one axis is a different ship.
 864REFIT_BASE_WIDTH_PX = 1180.0
 865REFIT_BASE_HEIGHT_PX = 660.0
 866REFIT_MIN_SCALE = 0.5
 867#: Panel padding, the gap between the two columns, and the type, at full scale.
 868REFIT_PAD_PX = 16.0
 869REFIT_GAP_PX = 18.0
 870REFIT_TITLE_FONT_PX = 22.0
 871REFIT_ROW_FONT_PX = 14.0
 872REFIT_SLOT_FONT_PX = 12.0
 873REFIT_LINE_HEIGHT_PX = 26.0
 874#: The smallest type the engine will draw: a label asked for less is silently
 875#: given this, so a character budget worked out from the smaller number would
 876#: promise room the text does not have. Everything here is fitted to this floor
 877#: rather than to what the scale asked for.
 878REFIT_MIN_FONT_PX = 8.0
 879#: How much of the body the schematic takes; the locker column takes the rest.
 880REFIT_DIAGRAM_FRACTION = 0.56
 881#: A mount's box on the plan, and the ring drawn round the focused one.
 882REFIT_MARKER_PX = 15.0
 883REFIT_BORDER_PX = 2.0
 884#: Hull units of clear space either side of the drawing, for the mount labels.
 885#: The schematic is scaled to fit its box *including* this, so a label can be
 886#: cut to the room it has rather than run out through the side of the panel.
 887REFIT_LABEL_ROOM_UNITS = 1.9
 888#: A mount within this of the centreline is drawn as a bow or stern mount, with
 889#: its label above or below rather than out to one side.
 890REFIT_CENTRELINE_UNITS = 0.25
 891
 892#: The hull, as centred boxes in hull units: ``(x, z, width, depth)``. Nose
 893#: along -Z, starboard along +X, the same frame ``modules.Socket.offset`` and
 894#: ``weapons.HARDPOINT_OFFSETS`` are written in, so the mounts land on the
 895#: drawing without a second convention to keep in step.
 896REFIT_HULL_SHAPES: tuple[tuple[float, float, float, float], ...] = (
 897    (0.0, -1.35, 0.60, 1.20),
 898    (0.0, 0.00, 1.15, 3.10),
 899    (0.0, 1.45, 1.70, 0.90),
 900    (-1.25, -0.10, 1.10, 1.45),
 901    (1.25, -0.10, 1.10, 1.45),
 902)
 903
 904#: The look. The hull is a plate under the mounts, so a mount reads as a thing
 905#: bolted on rather than as a box floating in the dark.
 906REFIT_HULL_COLOUR = (0.13, 0.17, 0.23, 1.0)
 907REFIT_HULL_EDGE_COLOUR = (0.28, 0.36, 0.46, 1.0)
 908REFIT_SLOT_BARE_COLOUR = (0.10, 0.13, 0.18, 1.0)
 909REFIT_SLOT_FILLED_COLOUR = (0.30, 0.45, 0.40, 1.0)
 910REFIT_SLOT_EDGE_COLOUR = (0.45, 0.52, 0.62, 1.0)
 911REFIT_ROW_COLOUR = (0.09, 0.12, 0.16, 1.0)
 912#: The focus ring and the armed pick. Two colours *and* two text marks, so the
 913#: state survives a greyscale screenshot and a player who cannot separate them.
 914REFIT_FOCUS_COLOUR = (1.00, 0.86, 0.42, 1.0)
 915REFIT_ARMED_COLOUR = (0.45, 0.90, 0.70, 1.0)
 916REFIT_MARK_ARMED = "*"
 917REFIT_REFUSED_COLOUR = (0.90, 0.55, 0.45, 1.0)
 918
 919REFIT_TITLE = "REFIT BAY"
 920REFIT_SUBTITLE = "PICK A MOUNT, THEN PICK WHAT GOES ON IT"
 921#: How a mount is named in a sentence. The socket keeps the hull's own word for
 922#: it ("PORT QUARTER SOCKET"), which is the word the shelf already promises a
 923#: purchase to, so a row and a mount cannot be read as two different places.
 924REFIT_HARDPOINT_LABEL = "HARDPOINT {index}"
 925REFIT_SOCKET_LABEL = "{label} SOCKET"
 926REFIT_HULL_HEADING = "THE HULL"
 927REFIT_LOCKER_HEADING = "IN THE LOCKER"
 928REFIT_LOCKER_EMPTY = "NOTHING STOWED: UNMOUNT SOMETHING, OR BUY IT ON THE SHELF"
 929REFIT_CONTROLS_LINE = "MOVE: W/S OR ARROWS   HULL/LOCKER: A/D   CHOOSE: F, ENTER OR CLICK   BACK: ESC"
 930#: The locker column's rows, and the two action rows under everything.
 931REFIT_LOCKER_ROW = "{name} [{kind}] {summary}"
 932REFIT_ACTION_UNMOUNT = "UNMOUNT THE PICKED MOUNT"
 933REFIT_ACTION_DONE = "DONE: BACK TO THE SHELF"
 934REFIT_ACTIONS_ELLIPSIS = "..."
 935#: What the panel says once a press has actually moved something. The mounting
 936#: lines are the depot's, so a refit made here and a refit made from a script
 937#: report themselves identically.
 938REFIT_INSTALL_DONE = "INSTALLED THE {name} IN THE {slot}"
 939REFIT_INSTALL_DISPLACED = "INSTALLED THE {name} IN THE {slot}, {displaced} STOWED"
 940REFIT_UNMOUNT_DONE = "{name} OFF {slot} AND INTO THE LOCKER"
 941REFIT_UNMOUNT_BARE = "{slot} IS ALREADY BARE"
 942REFIT_UNMOUNT_NOTHING = "PICK A MOUNT FIRST: THE UNMOUNT ROW STRIPS IT"
 943REFIT_REFUSED = "THE BAY CANNOT MAKE THAT REFIT"
 944
 945
 946# ============================================================================
 947# Settings: accessibility, remapping and the Steady Wake panel
 948# ============================================================================
 949
 950#: The chart action, and the pause action split out of it.
 951#:
 952#: ``star_chart`` once bound Escape and M together, so the key every other
 953#: game uses to back out of something was a sector jump taken with no
 954#: confirmation and no way back. ``runtime.INPUT_ACTIONS`` now carries the
 955#: split: Escape (Start on the pad) raises the pause overlay, M (Back) opens
 956#: the chart. These names are how this module refers to the two halves.
 957CHART_ACTION = "star_chart"
 958PAUSE_ACTION = "pause_menu"
 959
 960
 961#: Menu navigation, on the keys a player reaches for without being told.
 962#:
 963#: The thrust actions move a menu cursor and ``interact`` commits, which is
 964#: what the pad wants and what the run's own keys are; nobody arriving at a
 965#: title screen guesses either. These three actions carry the arrows, Enter and
 966#: the d-pad alongside them; the bindings live in ``runtime.INPUT_ACTIONS``
 967#: with every other action, these names are how this module refers to them.
 968MENU_UP_ACTION = "menu_up"
 969MENU_DOWN_ACTION = "menu_down"
 970MENU_CONFIRM_ACTION = "menu_confirm"
 971
 972
 973def base_input_actions() -> dict[str, list]:
 974    """A fresh copy of ``runtime.INPUT_ACTIONS``, safe for remapping to edit."""
 975    return {name: list(bindings) for name, bindings in runtime.INPUT_ACTIONS.items()}
 976
 977
 978#: Actions the remapping screen offers. The stick and trigger axes are not on
 979#: it: they are bound to the pad's geometry, not to a choice.
 980REMAPPABLE_ACTIONS: tuple[str, ...] = (
 981    "thrust_up",
 982    "thrust_down",
 983    "thrust_left",
 984    "thrust_right",
 985    "fire_primary",
 986    "mining_beam",
 987    "shield_left",
 988    "shield_right",
 989    "afterburner",
 990    "silent_running",
 991    "solar_wings",
 992    "generator",
 993    "interact",
 994    "jettison_scrap",
 995    "warp_spool",
 996    "tractor_scoop",
 997    CHART_ACTION,
 998    PAUSE_ACTION,
 999    CONTROLS_BAR_ACTION,
1000)
1001
1002#: Actions that may be held or toggled, per section 10's accessibility list.
1003HOLD_OR_TOGGLE_ACTIONS: tuple[str, ...] = ("fire_primary", "shield_left", "silent_running")
1004
1005_BINDING_TYPES = {"Key": Key, "MouseButton": MouseButton, "JoyButton": JoyButton}
1006
1007
1008def encode_binding(binding) -> str | None:
1009    """A binding as a profile-safe token, or None when it does not encode.
1010
1011    Axis bindings (:class:`InputBinding`) describe pad geometry rather than a
1012    choice, so they are not encoded; :func:`resolve_input_actions` keeps them
1013    from the defaults instead of losing them.
1014    """
1015    for name, enum_type in _BINDING_TYPES.items():
1016        if isinstance(binding, enum_type):
1017            return f"{name}.{binding.name}"
1018    return None
1019
1020
1021def decode_binding(token: str):
1022    """The binding a :func:`encode_binding` token names.
1023
1024    Raises ``ValueError`` on anything it does not recognise: a settings file
1025    that has drifted should say so, not silently unbind a control.
1026    """
1027    kind, _, member = str(token).partition(".")
1028    enum_type = _BINDING_TYPES.get(kind)
1029    if enum_type is None or not member:
1030        raise ValueError(f"Unknown binding token {token!r}")
1031    try:
1032        return enum_type[member]
1033    except KeyError:
1034        raise ValueError(f"{kind} has no member {member!r}") from None
1035
1036
1037@dataclass
1038class Settings:
1039    """Everything the options screens own, as one persisted record.
1040
1041    The three sliders are 0 to 1 multipliers; ``damage_taken`` is one of
1042    ``balance.ASSIST_DAMAGE_TAKEN_STEPS``. ``bindings`` maps an action to the
1043    keyboard or mouse bindings that replace its defaults; anything absent keeps
1044    ``runtime.INPUT_ACTIONS``.
1045    """
1046
1047    # Accessibility
1048    shake: float = 1.0
1049    flash: float = 1.0
1050    hit_stop: float = 1.0
1051    ui_scale: float = UI_SCALE_MIN
1052    # The mix, as fractions of full scale
1053    volume_master: float = VOLUME_DEFAULT
1054    volume_sfx: float = VOLUME_DEFAULT
1055    volume_music: float = VOLUME_DEFAULT
1056    photosensitive: bool = False
1057    aim_assist: bool = True
1058    #: The persistent controls bar along the bottom of the HUD. On by default,
1059    #: for every run and every profile: the onboarding lessons stop after run
1060    #: three and never come back, and the keys still have to be somewhere.
1061    controls_bar: bool = True
1062    hold_actions: dict[str, bool] = field(default_factory=lambda: dict.fromkeys(HOLD_OR_TOGGLE_ACTIONS, True))
1063    bindings: dict[str, list[str]] = field(default_factory=dict)
1064
1065    # Display. The cap is read off disk at boot by main.resolve_frame_cap,
1066    # because the engine fixes its frame budget before the loop starts; vsync
1067    # is live, through App.set_vsync.
1068    frame_cap: int = 0
1069    vsync: bool = True
1070
1071    # The Steady Wake assist panel
1072    slow_signature: bool = False
1073    damage_taken: float = 1.0
1074    slow_lantern: bool = False
1075    spool_immunity: bool = False
1076
1077    # -- assists -----------------------------------------------------------
1078
1079    def assist_settings(self) -> balance.AssistSettings:
1080        """The panel as the record ship.py and hunter.py already read."""
1081        return balance.AssistSettings(
1082            slow_signature=self.slow_signature,
1083            damage_taken_mult=self.damage_taken,
1084            slow_lantern=self.slow_lantern,
1085            spool_immunity=self.spool_immunity,
1086        )
1087
1088    def assists_in_use(self) -> tuple[str, ...]:
1089        """Assist ids currently on, in the order the ledger prints them."""
1090        on: list[str] = []
1091        if self.slow_signature:
1092            on.append("slow_signature")
1093        if self.damage_taken < 1.0:
1094            on.append(f"damage_taken_{int(round(self.damage_taken * 100))}")
1095        if self.slow_lantern:
1096            on.append("slow_lantern")
1097        if self.spool_immunity:
1098            on.append("spool_immunity")
1099        return tuple(on)
1100
1101    # -- the mix -----------------------------------------------------------
1102
1103    def bus_volumes(self) -> tuple[float, float, float]:
1104        """The three sliders as ``(master, sfx, music)`` fractions of full scale."""
1105        return (_clamp01(self.volume_master), _clamp01(self.volume_sfx), _clamp01(self.volume_music))
1106
1107    def apply_volumes(self, audio) -> None:
1108        """Hand the mix sliders to *audio*, the run's live ``AudioDirector``.
1109
1110        The director owns the decibel arithmetic: its ``set_bus_volumes`` lays
1111        the sliders over the authored cue gains and its own music duck rather
1112        than writing the shared bus layout, so neither side can clobber the
1113        other. With no director live there is nothing playing to trim; the
1114        values persist on the profile and land at the next run start.
1115        """
1116        if audio is not None:
1117            audio.set_bus_volumes(*self.bus_volumes())
1118
1119    @property
1120    def effective_flash(self) -> float:
1121        """The flash multiplier after the photosensitivity clamp."""
1122        return min(self.flash, PHOTOSENSITIVE_FLASH) if self.photosensitive else self.flash
1123
1124    def hold_to_use(self, action: str) -> bool:
1125        """Whether *action* is held (True) or toggled (False)."""
1126        return bool(self.hold_actions.get(action, True))
1127
1128    # -- persistence -------------------------------------------------------
1129
1130    def as_dict(self) -> dict:
1131        return {
1132            "shake": self.shake,
1133            "flash": self.flash,
1134            "hit_stop": self.hit_stop,
1135            "ui_scale": self.ui_scale,
1136            "volume_master": self.volume_master,
1137            "volume_sfx": self.volume_sfx,
1138            "volume_music": self.volume_music,
1139            "photosensitive": self.photosensitive,
1140            "aim_assist": self.aim_assist,
1141            "controls_bar": self.controls_bar,
1142            "hold_actions": dict(self.hold_actions),
1143            "bindings": {action: list(tokens) for action, tokens in self.bindings.items()},
1144            "frame_cap": self.frame_cap,
1145            "vsync": self.vsync,
1146            "slow_signature": self.slow_signature,
1147            "damage_taken": self.damage_taken,
1148            "slow_lantern": self.slow_lantern,
1149            "spool_immunity": self.spool_immunity,
1150        }
1151
1152    @classmethod
1153    def from_dict(cls, payload: dict | None) -> Settings:
1154        payload = payload or {}
1155        blank = cls()
1156        return cls(
1157            shake=_clamp01(payload.get("shake", blank.shake)),
1158            flash=_clamp01(payload.get("flash", blank.flash)),
1159            hit_stop=_clamp01(payload.get("hit_stop", blank.hit_stop)),
1160            ui_scale=min(UI_SCALE_MAX, max(UI_SCALE_MIN, float(payload.get("ui_scale", blank.ui_scale)))),
1161            volume_master=_clamp01(payload.get("volume_master", blank.volume_master)),
1162            volume_sfx=_clamp01(payload.get("volume_sfx", blank.volume_sfx)),
1163            volume_music=_clamp01(payload.get("volume_music", blank.volume_music)),
1164            photosensitive=bool(payload.get("photosensitive", blank.photosensitive)),
1165            aim_assist=bool(payload.get("aim_assist", blank.aim_assist)),
1166            controls_bar=bool(payload.get("controls_bar", blank.controls_bar)),
1167            hold_actions={**blank.hold_actions, **dict(payload.get("hold_actions", {}))},
1168            bindings={str(k): [str(t) for t in v] for k, v in dict(payload.get("bindings", {})).items()},
1169            frame_cap=_frame_cap_of(payload.get("frame_cap", blank.frame_cap)),
1170            vsync=bool(payload.get("vsync", blank.vsync)),
1171            slow_signature=bool(payload.get("slow_signature", blank.slow_signature)),
1172            damage_taken=_nearest_step(payload.get("damage_taken", blank.damage_taken)),
1173            slow_lantern=bool(payload.get("slow_lantern", blank.slow_lantern)),
1174            spool_immunity=bool(payload.get("spool_immunity", blank.spool_immunity)),
1175        )
1176
1177    @classmethod
1178    def from_profile(cls, profile: dict | None) -> Settings:
1179        return cls.from_dict((profile or {}).get("settings"))
1180
1181    def store(self, profile: dict) -> dict:
1182        """Write these settings onto *profile* and hand it back."""
1183        profile["settings"] = self.as_dict()
1184        return profile
1185
1186    # -- application -------------------------------------------------------
1187
1188    def input_actions(self) -> dict[str, list]:
1189        """The effective action map: the defaults with the remapping applied."""
1190        return resolve_input_actions(self)
1191
1192    def apply_to_run(self, scene: RunScene) -> None:
1193        """Push every setting into the live run that consumes it."""
1194        assists = self.assist_settings()
1195        self.apply_volumes(scene.audio)
1196        if scene.ship is not None:
1197            scene.ship.assists = assists
1198        if scene.juice is not None:
1199            scene.juice.shake_scale = self.shake
1200            scene.juice.hit_stop_scale = self.hit_stop
1201        if scene.signature is not None:
1202            multiplier = balance.ASSIST_SIGNATURE_FILL_MULT if self.slow_signature else 1.0
1203            scene.signature.set_gain_multiplier("assist", multiplier)
1204        if scene.hud is not None:
1205            scene.hud.ui_scale = self.ui_scale
1206            scene.hud.controls_bar_visible = self.controls_bar
1207        if scene.chart_screen is not None:
1208            scene.chart_screen.ui_scale = self.ui_scale
1209        if scene.environment is not None:
1210            # Every flash in this game is an emissive picked up by bloom, so
1211            # the flash slider is the bloom's gain and photosensitivity mode
1212            # is a hard ceiling on top of it.
1213            intensity = float(scene.environment.bloom_intensity) * self.effective_flash
1214            if self.photosensitive:
1215                intensity = min(intensity, PHOTOSENSITIVE_BLOOM_INTENSITY)
1216            scene.environment.bloom_intensity = intensity
1217
1218
1219def resolve_input_actions(settings: Settings | None) -> dict[str, list]:
1220    """:func:`base_input_actions` with *settings*' remapping folded in.
1221
1222    A remapped action keeps every axis binding it had (those are pad geometry,
1223    not a choice) and replaces the rest with the player's tokens, so remapping
1224    the keyboard never costs the pad its stick.
1225    """
1226    actions = base_input_actions()
1227    if settings is None:
1228        return actions
1229    for action, tokens in settings.bindings.items():
1230        if action not in actions:
1231            raise ValueError(f"Cannot rebind unknown action {action!r}")
1232        axes = [b for b in actions[action] if isinstance(b, InputBinding)]
1233        actions[action] = [decode_binding(token) for token in tokens] + axes
1234    return actions
1235
1236
1237def _clamp01(value: float) -> float:
1238    return min(1.0, max(0.0, float(value)))
1239
1240
1241def _nearest_step(value: float) -> float:
1242    """The nearest damage-taken step, so a drifted file cannot invent one."""
1243    value = float(value)
1244    return min(balance.ASSIST_DAMAGE_TAKEN_STEPS, key=lambda step: abs(step - value))
1245
1246
1247def _apply_vsync(on: bool) -> None:
1248    """Push vsync at the running window, where there is one.
1249
1250    Guarded rather than assumed: every test and every headless probe runs this
1251    module with no App at all, and a settings screen must work in both.
1252    """
1253    try:
1254        from simvx.graphics import App
1255    except Exception:
1256        return
1257    app = App.current()
1258    if app is not None:
1259        app.set_vsync(bool(on))
1260
1261
1262def _apply_frame_cap(cap: int) -> None:
1263    """Push a frame cap at the running window, where there is one.
1264
1265    Guarded the same way as :func:`_apply_vsync`, and for the same reason: this
1266    module runs under tests and headless probes with no ``App`` at all. Zero is
1267    this game's FOLLOW DISPLAY, which is ``None`` in the engine's vocabulary,
1268    where a cap must be ``>= 1``.
1269    """
1270    try:
1271        from simvx.graphics import App
1272    except Exception:
1273        return
1274    app = App.current()
1275    if app is not None:
1276        app.target_fps = int(cap) or None
1277
1278
1279def _frame_cap_of(value) -> int:
1280    """A stored frame cap, read defensively: anything unreadable follows the display.
1281
1282    A cap the game cannot parse must never stop it booting, and a negative one
1283    must never reach ``App(target_fps=...)``, so the fallback is zero, which is
1284    the same thing as having chosen FOLLOW DISPLAY.
1285    """
1286    try:
1287        return max(0, int(value or 0))
1288    except (TypeError, ValueError):
1289        return 0
1290
1291
1292# ============================================================================
1293# The ledger
1294# ============================================================================
1295
1296
1297@dataclass(frozen=True)
1298class ConversionEntry:
1299    """One line of the death screen's conversion breakdown."""
1300
1301    label: str
1302    scrap: float
1303    rate: float
1304    cores: float
1305    capped: bool = False
1306
1307    def as_dict(self) -> dict:
1308        return {
1309            "label": self.label,
1310            "scrap": self.scrap,
1311            "rate": self.rate,
1312            "cores": self.cores,
1313            "capped": self.capped,
1314        }
1315
1316    def line(self) -> str:
1317        """The row as the screen prints it."""
1318        text = f"{self.label}: {self.scrap:.0f} scrap at {self.rate:.1f} per 10 = {self.cores:.1f} Cores"
1319        return f"{text} (capped)" if self.capped else text
1320
1321
1322@dataclass
1323class RunLedger:
1324    """Everything a finished run is judged on, and everything it teaches.
1325
1326    Filled from the live services as the run ends. ``scrap_carried`` is what
1327    was in the hold at the end, ``scrap_after_last_stand`` the part of it earned
1328    after the collapse trigger, and the two are what the conversion breakdown
1329    prices apart.
1330    """
1331
1332    run_number: int = 1
1333    hull_id: str = save.STARTER_HULL
1334    seed: int = 0
1335    outcome: str = ""
1336    killed_by: str = ""
1337    elapsed_s: float = 0.0
1338
1339    scrap_earned: float = 0.0
1340    scrap_carried: float = 0.0
1341    scrap_after_last_stand: float = 0.0
1342    cores_banked_in_run: float = 0.0
1343
1344    kills: int = 0
1345    elite_kills: int = 0
1346    vaults_cracked: int = 0
1347    arrivals_survived: int = 0
1348    quills: int = 0
1349    deepest_sector: int = 1
1350    sectors_visited: int = 1
1351    notoriety: int = 0
1352
1353    hull_left: float = 0.0
1354    fuel_left: float = 0.0
1355    o2_left: float = 0.0
1356    open_breaches: int = 0
1357    signature: float = 0.0
1358    loudest_gain: str = ""
1359    cheapest_exit_fuel: float = 0.0
1360    last_stand: bool = False
1361
1362    conversion: list[ConversionEntry] = field(default_factory=list)
1363    cores_from_conversion: float = 0.0
1364    cores_from_milestones: float = 0.0
1365    milestones: list[str] = field(default_factory=list)
1366    assists: tuple[str, ...] = ()
1367    score: int = 0
1368    best_score: int = 0
1369    new_best: bool = False
1370    recap: str = ""
1371
1372    @property
1373    def cores_total(self) -> float:
1374        """Every Core the run produced: in-run banking, settlement, bounties."""
1375        return self.cores_banked_in_run + self.cores_from_conversion + self.cores_from_milestones
1376
1377    def as_dict(self) -> dict:
1378        payload = {
1379            key: value for key, value in self.__dict__.items() if key not in ("conversion", "assists", "milestones")
1380        }
1381        payload["conversion"] = [entry.as_dict() for entry in self.conversion]
1382        payload["assists"] = list(self.assists)
1383        payload["milestones"] = list(self.milestones)
1384        payload["cores_total"] = self.cores_total
1385        return payload
1386
1387
1388def death_conversion_rate(profile: dict | None, modifiers: dict | None = None) -> float:
1389    """The Cores-per-10 a death pays, after the training-wheels doctrine node."""
1390    if modifiers and DEATH_RATE_MODIFIER in modifiers:
1391        return float(modifiers[DEATH_RATE_MODIFIER])
1392    owned = ((profile or {}).get("doctrine") or {}).get("owned") or []
1393    return balance.CONVERT_RATE_DEATH_UPGRADED if DEATH_RATE_NODE_ID in owned else balance.CONVERT_RATE_DEATH
1394
1395
1396def settle_conversion(ledger: RunLedger, *, death_rate: float = balance.CONVERT_RATE_DEATH) -> list[ConversionEntry]:
1397    """Price the hold at the end of a run, itemised the way the screen shows it.
1398
1399    Extraction pays ``balance.CONVERT_RATE_EXTRACTION`` on everything carried.
1400    A Last Stand pays ``balance.LAST_STAND_CONVERT_RATE`` on scrap earned after
1401    the trigger, up to ``balance.LAST_STAND_CORES_CAP`` Cores, and *death rate*
1402    on the rest, which is what stops an engineered collapse from beating the
1403    Deep Gate. Any other death pays the death rate on the whole hold.
1404    """
1405    carried = max(0.0, float(ledger.scrap_carried))
1406    if carried <= 0.0:
1407        return []
1408
1409    if ledger.outcome == "extraction":
1410        rate = balance.CONVERT_RATE_EXTRACTION
1411        return [ConversionEntry("Extracted", carried, rate, balance.cores_from_scrap(carried, rate))]
1412
1413    entries: list[ConversionEntry] = []
1414    remainder = carried
1415    if ledger.last_stand:
1416        rate = balance.LAST_STAND_CONVERT_RATE
1417        post = min(max(0.0, float(ledger.scrap_after_last_stand)), carried)
1418        cap_scrap = balance.LAST_STAND_CORES_CAP * balance.CONVERT_SCRAP_UNIT / rate
1419        paid = min(post, cap_scrap)
1420        if paid > 0.0:
1421            entries.append(
1422                ConversionEntry(
1423                    "Last Stand",
1424                    paid,
1425                    rate,
1426                    balance.cores_from_scrap(paid, rate),
1427                    capped=post > cap_scrap,
1428                )
1429            )
1430            remainder = carried - paid
1431    if remainder > 0.0:
1432        entries.append(
1433            ConversionEntry("Carried", remainder, death_rate, balance.cores_from_scrap(remainder, death_rate))
1434        )
1435    return entries
1436
1437
1438def run_score(ledger: RunLedger) -> int:
1439    """Scrap earned, plus Cores by ten, plus quills by a hundred, plus depth."""
1440    return int(
1441        round(
1442            ledger.scrap_earned
1443            + ledger.cores_total * balance.SCORE_CORES_WEIGHT
1444            + ledger.quills * balance.SCORE_QUILLS_WEIGHT
1445            + ledger.deepest_sector * SCORE_DEPTH_BONUS_PER_SECTOR
1446        )
1447    )
1448
1449
1450def recap_sentence(ledger: RunLedger) -> str:
1451    """One generated sentence naming the bet that ended the run.
1452
1453    The rules run most specific first, so the sentence always names the thing
1454    the player could have done differently rather than restating the obvious.
1455    """
1456    sector = ledger.deepest_sector
1457    if ledger.outcome == "extraction":
1458        return (
1459            f"You made the Gate from sector {sector} with {ledger.scrap_carried:.0f} scrap "
1460            f"and {ledger.fuel_left:.0f} fuel to spare."
1461        )
1462    if ledger.last_stand:
1463        return (
1464            f"You stranded yourself in sector {sector} with {ledger.fuel_left:.0f} fuel; "
1465            f"the cheapest way out wanted {ledger.cheapest_exit_fuel:.0f}, so the Last Stand paid "
1466            f"{ledger.cores_from_conversion:.0f} Cores."
1467        )
1468    if ledger.killed_by == "suffocation":
1469        return (
1470            f"Your air ran out in sector {sector} with {ledger.open_breaches} unpatched "
1471            f"breach{'es' if ledger.open_breaches != 1 else ''} bleeding it."
1472        )
1473    if ledger.killed_by == LANTERN_BLOW_ID:
1474        return (
1475            f"The Shrike's lantern burned you down in sector {sector}; the cone holds dim "
1476            f"for a full second before it fires, and that second is the dodge."
1477        )
1478    if ledger.killed_by == "hunter":
1479        return (
1480            f"The Shrike caught you in sector {sector} at signature {ledger.signature:.0f}, "
1481            f"{ledger.scrap_carried:.0f} scrap still unbanked."
1482        )
1483    overstayed = (
1484        ledger.cheapest_exit_fuel > 0.0
1485        and ledger.fuel_left >= ledger.cheapest_exit_fuel
1486        and ledger.signature >= balance.SIGNATURE_MAX * RECAP_OVERSTAY_SIGNATURE_FRACTION
1487    )
1488    if overstayed:
1489        loud = ledger.loudest_gain or "the harvest"
1490        return (
1491            f"You overstayed sector {sector} with {ledger.fuel_left:.0f} fuel; the safe exit cost "
1492            f"{ledger.cheapest_exit_fuel:.0f} and {loud} was what filled the meter."
1493        )
1494    killer = (ledger.killed_by or "the sector").replace("_", " ").capitalize()
1495    if ledger.open_breaches > 0:
1496        plural = "es" if ledger.open_breaches != 1 else ""
1497        return (
1498            f"{killer} finished you in sector {sector} with {ledger.open_breaches} open "
1499            f"breach{plural} already bleeding the air."
1500        )
1501    return f"{killer} finished you in sector {sector} at signature {ledger.signature:.0f}."
1502
1503
1504def personal_best(profile: dict | None, hull_id: str) -> int:
1505    """The stored best score for *hull_id*, zero when the hull has no run yet."""
1506    return int(((profile or {}).get("best_scores") or {}).get(hull_id, 0))
1507
1508
1509def record_best(profile: dict, hull_id: str, score: int) -> tuple[int, bool]:
1510    """Store *score* if it beats the hull's best. Returns ``(best, is_new)``."""
1511    bests = profile.setdefault("best_scores", {})
1512    previous = int(bests.get(hull_id, 0))
1513    if int(score) > previous:
1514        bests[hull_id] = int(score)
1515        return int(score), True
1516    return previous, False
1517
1518
1519def earned_milestones(ledger: RunLedger, profile: dict) -> list[str]:
1520    """Milestone ids this run earned that the profile has not yet paid."""
1521    flags = profile.get("milestones") or {}
1522    reached = {
1523        "first_extraction": ledger.outcome == "extraction",
1524        "first_vault": ledger.vaults_cracked > 0,
1525        "first_elite_kill": ledger.elite_kills > 0,
1526        "first_act3_entry": ledger.deepest_sector >= balance.ACT_3_FIRST_SECTOR,
1527        "first_arrival_survived": ledger.arrivals_survived > 0,
1528    }
1529    return [name for name, hit in reached.items() if hit and not flags.get(name, False)]
1530
1531
1532# ============================================================================
1533# Run configuration and state
1534# ============================================================================
1535
1536
1537@dataclass
1538class RunConfig:
1539    """The frozen choices a run launches with."""
1540
1541    run_number: int = 1
1542    seed: int = 0
1543    hull_id: str = save.STARTER_HULL
1544    keystones: tuple[str, ...] = ()
1545    modifiers: dict = field(default_factory=dict)
1546    deep_insertion: bool = False
1547
1548    def as_dict(self) -> dict:
1549        return {
1550            "run_number": self.run_number,
1551            "seed": self.seed,
1552            "hull_id": self.hull_id,
1553            "keystones": list(self.keystones),
1554            "modifiers": dict(self.modifiers),
1555            "deep_insertion": self.deep_insertion,
1556        }
1557
1558    @classmethod
1559    def from_dict(cls, payload: dict | None) -> RunConfig:
1560        payload = payload or {}
1561        return cls(
1562            run_number=int(payload.get("run_number", 1)),
1563            seed=int(payload.get("seed", 0)),
1564            hull_id=str(payload.get("hull_id", save.STARTER_HULL)),
1565            keystones=tuple(payload.get("keystones", ()))[: balance.DOCTRINE_MAX_ACTIVE_KEYSTONES],
1566            modifiers=dict(payload.get("modifiers", {})),
1567            deep_insertion=bool(payload.get("deep_insertion", False)),
1568        )
1569
1570
1571class RunState:
1572    """The live run: where it is on the chart, and what it has done so far.
1573
1574    Also the object :class:`~shrike.chart.StarChart` reads, which is why
1575    ``chart``, ``current_node_id``, ``jumps_taken``, ``notoriety`` and ``fuel``
1576    are spelled exactly as it expects them.
1577    """
1578
1579    def __init__(self, config: RunConfig):
1580        self.config = config
1581        self.seed = int(config.seed)
1582        self.run_number = int(config.run_number)
1583        self.chart = ChartGraph(self.seed)
1584        self.current_node_id = self.chart.start_id
1585        self.jumps_taken = 0
1586        self.sector_index = 1
1587        self.biome_id = self.chart.nodes[self.current_node_id].biome
1588        self.elapsed_s = 0.0
1589        self.kit = ""
1590        self.deck = SignalEventDeck(self.seed, set())
1591        self.ledger = RunLedger(run_number=self.run_number, hull_id=config.hull_id, seed=self.seed)
1592        #: Mirror of the run tally, so the chart prices routes with no service up.
1593        self.notoriety = 0
1594        #: Mirror of the tank, same reason.
1595        self.fuel = balance.FUEL_MAX
1596        #: The suspend payload a resume still has to apply to the live world
1597        #: (meters, hold, hull, harvest progress); None on a fresh run.
1598        self.restore: dict | None = None
1599        #: The lane the drive was pointed at when the run was written out, so a
1600        #: resume comes back holding the pilot's own choice rather than the
1601        #: default an arrival would arm. The live value is the chart's own
1602        #: selection; this is only the carrier across the suspend slot.
1603        self.armed_node_id = ""
1604        #: Every chart node this run has already *left behind*. Lanes run both
1605        #: ways, so a second arrival has to be tellable from a first one, and
1606        #: departure rather than arrival is what makes that true across a
1607        #: suspend: the sector a saved run is sitting in has not been left, so
1608        #: resuming into it deals the layout it saved rather than a stripped
1609        #: one, while a node it really did leave and fly back to stays stripped
1610        #: whether or not the pilot quit in between.
1611        self.visited: set[str] = set()
1612        #: What has already been bought at each node's dock, by node id. A
1613        #: revisited depot is the same depot: its shelf is stable from the same
1614        #: seed, and the counter that marks a row sold has to be too, or a
1615        #: round trip re-stocks the once-per-visit O2 canister.
1616        self.depot_purchases: dict[str, dict[str, int]] = {}
1617
1618    @property
1619    def act(self) -> int:
1620        return balance.act_for_sector(self.sector_index)
1621
1622    def sector_seed(self) -> int:
1623        """A stable per-node seed, so a node's danger and payout are learnable.
1624
1625        Keyed on the node rather than on the sector index, for two reasons the
1626        mirrored chart made load-bearing. Walking back into a node has to deal
1627        the place the pilot remembers, not whatever the index says now that
1628        escalation has stopped following the column. And two nodes in the same
1629        column used to share a seed, so a fork offered the identical layout
1630        twice under two biome names.
1631        """
1632        node = self.chart.nodes.get(self.current_node_id)
1633        key = (node.column + 1) * 7919 + node.row * 104_729 if node is not None else self.sector_index * 7919
1634        return (self.seed * 1_000_003 + key) & 0x7FFF_FFFF
1635
1636    def as_suspend(self) -> dict:
1637        """This run as a ``save.blank_suspend`` payload, ready to write."""
1638        payload = save.blank_suspend()
1639        payload["run"].update(
1640            {
1641                "run_number": self.run_number,
1642                "seed": self.seed,
1643                "elapsed_s": self.elapsed_s,
1644                "kit": self.kit,
1645                "keystones": list(self.config.keystones),
1646                "sector_index": self.sector_index,
1647                "sector_seed": self.sector_seed(),
1648                "biome_id": self.biome_id,
1649            }
1650        )
1651        payload["ship"]["hull_id"] = self.config.hull_id
1652        payload["chart"].update(
1653            {
1654                "seed": self.seed,
1655                "current_node": self.current_node_id,
1656                "wake_column": self.chart.wake_column,
1657                "jumps_taken": self.jumps_taken,
1658                "visited": sorted(self.visited),
1659                "armed_target": self.armed_node_id,
1660            }
1661        )
1662        payload["notoriety"]["value"] = int(self.notoriety)
1663        payload["ledger"].update(
1664            {
1665                "scrap_earned": self.ledger.scrap_earned,
1666                "kills": self.ledger.kills,
1667                "deepest_sector": self.ledger.deepest_sector,
1668            }
1669        )
1670        return payload
1671
1672    @classmethod
1673    def from_suspend(cls, payload: dict) -> RunState:
1674        """Rebuild a run from a suspend save. The chart regenerates from its seed."""
1675        run = payload.get("run") or {}
1676        chart = payload.get("chart") or {}
1677        config = RunConfig(
1678            run_number=int(run.get("run_number", 1)),
1679            seed=int(run.get("seed", chart.get("seed", 0))),
1680            hull_id=str((payload.get("ship") or {}).get("hull_id", save.STARTER_HULL)),
1681            keystones=tuple(run.get("keystones", ())),
1682        )
1683        state = cls(config)
1684        state.sector_index = max(1, int(run.get("sector_index", 1)))
1685        state.elapsed_s = float(run.get("elapsed_s", 0.0))
1686        state.kit = str(run.get("kit", ""))
1687        state.jumps_taken = int(chart.get("jumps_taken", 0))
1688        node_id = str(chart.get("current_node", "")) or state.chart.start_id
1689        if node_id in state.chart.nodes:
1690            state.current_node_id = node_id
1691            state.biome_id = state.chart.nodes[node_id].biome
1692        state.visited = {str(v) for v in chart.get("visited", ()) if str(v) in state.chart.nodes}
1693        armed = str(chart.get("armed_target", ""))
1694        state.armed_node_id = armed if armed in state.chart.nodes else ""
1695        here = state.chart.nodes.get(state.current_node_id)
1696        state.chart.advance_wake(
1697            state.jumps_taken,
1698            int((payload.get("notoriety") or {}).get("value", 0)),
1699            pilot_column=here.column if here is not None else None,
1700        )
1701        state.notoriety = int((payload.get("notoriety") or {}).get("value", 0))
1702        # Replay the event deck to just before the suspended sector's own draw,
1703        # so regenerating that sector deals the card it actually held.
1704        draws = int((payload.get("sector") or {}).get("event_draws", 0))
1705        for _ in range(max(0, draws - 1)):
1706            state.deck.draw()
1707        ledger = payload.get("ledger") or {}
1708        state.ledger.scrap_earned = float(ledger.get("scrap_earned", 0.0))
1709        state.ledger.kills = int(ledger.get("kills", 0))
1710        state.ledger.deepest_sector = max(state.sector_index, int(ledger.get("deepest_sector", 0)), 1)
1711        state.restore = payload
1712        return state
1713
1714
1715# ============================================================================
1716# Menu furniture
1717# ============================================================================
1718
1719
1720class PressLatch:
1721    """One action's press and release edges, deaf to a press it did not see start.
1722
1723    A screen raised by a key press is mounted while that key is still down, so
1724    the first frame's ``is_action_just_pressed`` belongs to whatever opened the
1725    screen rather than to the player answering it: read naively, the doctrine
1726    grid buys a node the instant it appears and the dock screen sells the hold
1727    on the press that docked. The latch is primed with the action's current
1728    state when it is made, so the first edge it ever reports is a press made on
1729    the screen that owns it.
1730
1731    :attr:`hold_s` measures the press that is down, and survives the release
1732    frame, which is what lets the dock screen tell a tap from the three-second
1733    sell hold on the same button.
1734    """
1735
1736    def __init__(self, action: str):
1737        self.action = action
1738        self.held = Input.is_action_pressed(action)
1739        self.hold_s = 0.0
1740        self._seen = False
1741
1742    def poll(self, dt: float) -> tuple[bool, bool]:
1743        """This frame's ``(pressed, released)`` edges. Call once per update."""
1744        held = Input.is_action_pressed(self.action)
1745        pressed = released = False
1746        if held and not self.held:
1747            self._seen = True
1748            self.hold_s = 0.0
1749            pressed = True
1750        elif held:
1751            self.hold_s += dt
1752        elif self.held:
1753            released = self._seen
1754            self._seen = False
1755        self.held = held
1756        return pressed, released
1757
1758
1759class MenuList:
1760    """Keyboard, pad and mouse selection over a column of buttons.
1761
1762    One dispatch path, not two. The rows are buttons, but a button's own press
1763    signal is not what runs them: the list reads the pointer and the action map
1764    itself and runs the row the pointer is actually over. The two paths used to
1765    disagree. A click is a press and a release with a frame or two between
1766    them, which is every click a person makes, and the press fired the row the
1767    keyboard cursor happened to be on: on a cold boot SETTINGS and FLEET
1768    DOCTRINE both opened the run setup screen, and the pause menu's SETTINGS
1769    resumed the run, because that cursor was always still on row one.
1770
1771    So the cursor follows a moving pointer, which makes the highlighted row and
1772    the row a click runs the same row by construction, and a click over no row
1773    at all chooses nothing. Selection is bracketed and warmed rather than only
1774    warmed, so a player who never touches the mouse can see where they are.
1775    """
1776
1777    def __init__(self, box: VBoxContainer):
1778        self.box = box
1779        self.entries: list[tuple[str, object]] = []
1780        self.buttons: list[Button] = []
1781        self.index = 0
1782        self._click = PressLatch("fire_primary")
1783        self._commit = PressLatch("interact")
1784        self._confirm = PressLatch(MENU_CONFIRM_ACTION)
1785        self._pointer: tuple[float, float] | None = None
1786        self._marked: int | None = None
1787
1788    def add(self, text: str, action) -> Button:
1789        """Append a row running *action* when it is chosen."""
1790        button = self.box.add_child(Button(text, size_y=MENU_ROW_HEIGHT_PX))
1791        button.size_x = MENU_WIDTH_PX
1792        self.entries.append((text, action))
1793        self.buttons.append(button)
1794        self._marked = None
1795        self.mark()
1796        return button
1797
1798    @property
1799    def selected(self) -> str:
1800        return self.entries[self.index][0] if self.entries else ""
1801
1802    def move(self, step: int) -> None:
1803        if self.entries:
1804            self.index = (self.index + int(step)) % len(self.entries)
1805
1806    def activate(self) -> bool:
1807        """Run the selected row's action. False when the list is empty."""
1808        if not self.entries:
1809            return False
1810        self.entries[self.index][1]()
1811        return True
1812
1813    def relabel(self, index: int, text: str) -> None:
1814        """Rewrite one row's label in place, keeping its action and the cursor.
1815
1816        A row whose label carries a value ("DAMAGE TAKEN: 80%") has to be able
1817        to say the new one the moment the row changes it, or the setting is
1818        readable only by looking somewhere else for it.
1819        """
1820        _old, action = self.entries[index]
1821        self.entries[index] = (str(text), action)
1822        self._marked = None
1823        self.mark()
1824
1825    def focus(self, text: str) -> bool:
1826        """Put the cursor on the row labelled *text*. False when there is none.
1827
1828        Matches a whole label or a prefix of one, because the rows that carry a
1829        live value in their label ("FLEET DOCTRINE (12 Cores)") are named by a
1830        caller that does not know the value.
1831        """
1832        if not text:
1833            return False
1834        for index, (label, _action) in enumerate(self.entries):
1835            if label == text or label.startswith(text):
1836                self.index = index
1837                self.mark()
1838                return True
1839        return False
1840
1841    def reset(self) -> None:
1842        """Put the cursor on row one and re-prime the press latches.
1843
1844        An overlay is raised in the middle of a run, with whatever the pilot
1845        was holding still held. Priming the latches at that moment is what
1846        stops the trigger that was down when Escape was pressed from choosing
1847        the first thing the pause menu offers.
1848        """
1849        self.index = 0
1850        self._click = PressLatch("fire_primary")
1851        self._commit = PressLatch("interact")
1852        self._confirm = PressLatch(MENU_CONFIRM_ACTION)
1853        self._pointer = None
1854        self.mark()
1855
1856    def pointed_at(self) -> tuple[int | None, bool]:
1857        """The row under the pointer, and whether the pointer has just moved.
1858
1859        Only a pointer that moved takes the cursor: a mouse left resting over a
1860        row must not fight the keyboard for it. A click takes it either way.
1861        """
1862        position = Input.mouse_position
1863        point = (float(position.x), float(position.y))
1864        moved = self._pointer is not None and point != self._pointer
1865        self._pointer = point
1866        for index, button in enumerate(self.buttons):
1867            if _rect_holds(button.get_global_rect(), point):
1868                return index, moved
1869        return None, moved
1870
1871    def mark(self) -> None:
1872        """Draw the focus ring on the selected row, when it has moved."""
1873        if self._marked == self.index:
1874            return
1875        self._marked = self.index
1876        for index, (text, _) in enumerate(self.entries):
1877            button = self.buttons[index]
1878            chosen = index == self.index
1879            _set_text(button, f"{MENU_CURSOR_LEFT} {text} {MENU_CURSOR_RIGHT}" if chosen else text)
1880            button.text_colour = Colour.coerce(MENU_SELECTED_COLOUR if chosen else MENU_TEXT_COLOUR, name="menu_row")
1881
1882    def poll(self, dt: float = 0.0) -> None:
1883        """Read this frame's navigation input and run a chosen row.
1884
1885        Call from ``on_update``, passing that frame's *dt*.
1886        """
1887        if Input.is_action_just_pressed("thrust_down") or Input.is_action_just_pressed(MENU_DOWN_ACTION):
1888            self.move(1)
1889        elif Input.is_action_just_pressed("thrust_up") or Input.is_action_just_pressed(MENU_UP_ACTION):
1890            self.move(-1)
1891
1892        under, moved = self.pointed_at()
1893        if under is not None and moved:
1894            self.index = under
1895        clicked, _ = self._click.poll(dt)
1896        committed, _ = self._commit.poll(dt)
1897        confirmed, _ = self._confirm.poll(dt)
1898        if clicked and under is not None:
1899            self.index = under
1900        self.mark()
1901        if committed or confirmed or (clicked and under is not None):
1902            self.activate()
1903
1904
1905class MenuPanel(Panel):
1906    """A panel sized to its column, placed before that column is ever laid out.
1907
1908    The column is built detached and mounted by :meth:`settle` once the panel
1909    knows the rect it will keep. That order is load-bearing rather than tidy. A
1910    control's retained draw bakes the absolute transform it was first captured
1911    at, and the engine does not rewrite a child's transform row when an
1912    *ancestor* moves, so a panel that resizes and recentres after its rows are
1913    already placed leaves every one of them painted where it used to be: the
1914    screens grew a blank gap under the subtitle, the buttons slid out through
1915    the bottom of the panel, and the footer drew over what was left. Measuring
1916    the finished column, placing the panel, and only then mounting the column
1917    means nothing under a menu ever moves after it is built.
1918
1919    ``AnchorPreset.CENTER`` pins all four anchors to the middle of the parent,
1920    which puts the panel's *top-left corner* on the centre rather than the
1921    panel: the preset on its own laid the whole column out in the right half of
1922    the window with its rows running to the edge. Placing it is the preset plus
1923    a margin pair that offsets the rect on each axis, and the engine reads the
1924    width of a collapsed-anchor rect from ``margin_right - margin_left``, so
1925    the pair has to describe the whole rect rather than one edge of it.
1926
1927    A column too big for the window is shrunk uniformly to fit
1928    (:data:`MENU_MIN_FIT_SCALE` is the floor) and then placed against the top
1929    gutter, so a long shelf loses type size rather than losing its title above
1930    the window and its buttons below it.
1931    """
1932
1933    def __init__(self, **kwargs):
1934        super().__init__(**kwargs)
1935        self.column: VBoxContainer | None = None
1936        self.padding = MENU_PADDING_PX
1937        self.set_anchor_preset(AnchorPreset.CENTER)
1938        #: What the type and the row heights were fitted by; 1.0 when they fit.
1939        self.fit_scale = 1.0
1940        #: What the row widths were fitted by. Never below :attr:`fit_scale`, so
1941        #: shrinking a column never costs a row characters it used to hold.
1942        self.fit_width_scale = 1.0
1943
1944    # -- mounting ----------------------------------------------------------
1945
1946    def settle(self) -> None:
1947        """Fit and place the panel, then mount the finished column on it."""
1948        column = self.column
1949        if column is None:
1950            return
1951        if column.parent is not self:
1952            self._shrink_to_fit(column)
1953            self.recentre()
1954            self.add_child(column)
1955            tree = self.tree
1956            if tree is not None:
1957                # Containers reflow on the tree's dirty set, which drains at the
1958                # end of the tick. Draining it here keeps that reflow on the
1959                # near side of the first draw.
1960                tree.flush_layout()
1961        # Mounting is the first time the rows are measured in the tree, and a
1962        # grid measures differently once its cells are laid out there, so the
1963        # panel is fitted again to what the column really came out at. Both
1964        # passes land before the frame is drawn: nothing is ever painted at a
1965        # rect it is about to leave.
1966        self.recentre()
1967
1968    def _shrink_to_fit(self, column: VBoxContainer) -> None:
1969        """Scale *column* down until it fits the window, or until the floor.
1970
1971        Iterated rather than solved: type has minimum sizes of its own, so a
1972        column scaled by a half does not come out half as tall, and one pass
1973        leaves a shelf still hanging out of the window it was fitted to.
1974
1975        The two axes are fitted separately, because a column that is too tall
1976        is not thereby too wide: a settings screen shrunk on both axes to fit
1977        its own height would sit as a narrow strip in the middle of a window
1978        with four hundred spare pixels either side of it. Row widths never
1979        shrink further than the type does, so a row can only ever gain
1980        characters by being fitted, never lose them.
1981        """
1982        for _ in range(MENU_FIT_PASSES):
1983            width_step, height_step = self.fit_to_window()
1984            step = max(min(1.0, width_step, height_step), MENU_MIN_FIT_SCALE / self.fit_scale)
1985            width_step = max(min(1.0, width_step), step)
1986            if step >= 1.0 and width_step >= 1.0:
1987                return
1988            _scale_controls(column, step, width_step)
1989            self.padding *= step
1990            self.fit_scale *= step
1991            self.fit_width_scale *= width_step
1992
1993    @property
1994    def settled(self) -> bool:
1995        """Whether the column has been measured, placed and mounted."""
1996        return self.column is not None and self.column.parent is self
1997
1998    def on_update(self, dt: float):
1999        del dt
2000        if not self.visible_in_tree:
2001            # The pause overlay is mounted for the whole run with visible=False,
2002            # and this panel used to re-measure and re-place its column sixty
2003            # times a second behind the flying game for it: recentre() calls
2004            # fitted_size(), which calls get_minimum_size() on the whole column.
2005            # Nothing is owed while it is down, because PauseScene._build
2006            # rebuilds and settles the panel on every open.
2007            return
2008        if not self.settled:
2009            # The safety net for a screen that forgot to settle its own panel:
2010            # this still runs before the frame is drawn, so the rows are never
2011            # painted at anything but their final coordinates.
2012            self.settle()
2013            return
2014        self.recentre()
2015
2016    # -- geometry ----------------------------------------------------------
2017
2018    def window_rect(self) -> tuple[float, float, float, float]:
2019        """The rect the panel has to sit inside: its backdrop, or the screen."""
2020        parent = self.parent
2021        if isinstance(parent, Control):
2022            return tuple(parent.get_global_rect())
2023        size = getattr(self.tree, "screen_size", None) or MENU_FALLBACK_WINDOW_PX
2024        return (0.0, 0.0, float(size[0]), float(size[1]))
2025
2026    def fit_to_window(self) -> tuple[float, float]:
2027        """The shrink each axis still needs for this column to fit the window."""
2028        width, height = self.fitted_size()
2029        _, _, room_width, room_height = self.window_rect()
2030        room_width = max(1.0, room_width - 2.0 * MENU_WINDOW_MARGIN_PX)
2031        room_height = max(1.0, room_height - 2.0 * MENU_WINDOW_MARGIN_PX)
2032        return (room_width / max(1.0, width), room_height / max(1.0, height))
2033
2034    def recentre(self) -> None:
2035        """Fit the panel to its column and place it inside the window."""
2036        width, height = self.fitted_size()
2037        _, _, room_width, room_height = self.window_rect()
2038        self.size_x, self.size_y = width, height
2039        self.margin_left = _placed(room_width, width) - room_width / 2.0
2040        self.margin_right = self.margin_left + width
2041        self.margin_top = _placed(room_height, height) - room_height / 2.0
2042        self.margin_bottom = self.margin_top + height
2043
2044    def fitted_size(self) -> tuple[float, float]:
2045        """The size the column wants, plus :attr:`padding` on all four sides."""
2046        pad = 2.0 * self.padding
2047        if self.column is None:
2048            return (MENU_WIDTH_PX + pad, MENU_ROW_HEIGHT_PX + pad)
2049        measured = self.column.get_minimum_size()
2050        return (max(MENU_WIDTH_PX * self.fit_width_scale, float(measured.x)) + pad, float(measured.y) + pad)
2051
2052
2053def _placed(room: float, extent: float) -> float:
2054    """Where *extent* sits in *room*: centred, or against the top-left gutter.
2055
2056    A panel that has outgrown the window is pinned rather than centred, so what
2057    falls off the end is the bottom of a list and never the title above it.
2058
2059    A panel that fits the window but not the window's margins is placed to fit
2060    the window: the gutter is a preference and staying on screen is not, and
2061    the column's shrink-to-fit stops at a readability floor, so a full screen
2062    can come out a few pixels taller than the margins would like. Pinning it at
2063    the gutter then pushed the last row off the bottom of a panel that fitted.
2064    """
2065    if extent >= room:
2066        return MENU_WINDOW_MARGIN_PX
2067    return min(max(MENU_WINDOW_MARGIN_PX, (room - extent) / 2.0), room - extent)
2068
2069
2070def _scale_controls(root: Control, scale: float, width_scale: float) -> None:
2071    """Shrink *root* and every control under it: type by *scale*, widths by
2072    *width_scale*.
2073
2074    Type, row heights and the gaps between rows move together, because a row's
2075    height is its type; widths are a container measurement and are fitted on
2076    their own axis. A column too big for the window loses size rather than
2077    losing its ends off the edges. Applied while the column is still detached,
2078    so it costs one layout rather than a reflow per control.
2079    """
2080    root.margin_left *= scale
2081    root.margin_top *= scale
2082    stack: list[Control] = [root]
2083    while stack:
2084        control = stack.pop()
2085        if control.size_x > 0.0:
2086            control.size_x *= width_scale
2087        if control.size_y > 0.0:
2088            control.size_y *= scale
2089        font = getattr(control, "font_size", 0.0)
2090        if font:
2091            control.font_size = float(font) * scale
2092        separation = getattr(control, "separation", 0.0)
2093        if separation:
2094            control.separation = float(separation) * scale
2095        stack.extend(child for child in control.children if isinstance(child, Control))
2096
2097
2098def build_menu_panel(
2099    parent,
2100    title: str,
2101    subtitle: str = "",
2102    *,
2103    title_size: float = MENU_TITLE_FONT_PX,
2104    width: float = MENU_WIDTH_PX,
2105) -> VBoxContainer:
2106    """Mount the standard backdrop and panel on *parent*, and open its column.
2107
2108    Returns the column to fill with rows. The column comes back **detached**:
2109    it is laid out for the first time by :func:`settle_menu_panel`, once every
2110    row it will ever hold is in it and the panel knows its final rect. Shared
2111    by every flow screen and by the in-run pause overlay, which is a
2112    :class:`Control` rather than a scene and so cannot inherit
2113    :meth:`ShrikeScene.build_panel`. *title_size* is the one thing a screen may
2114    raise its voice with: the death ledger names the killer in type nobody has
2115    to look for. *width* is the column's own width, which the wide screens (the
2116    doctrine grid, the dock shelf) set here rather than after the fact.
2117    """
2118    backdrop = parent.add_child(Panel(name="Backdrop"))
2119    backdrop.set_anchor_preset(AnchorPreset.FULL_RECT)
2120    backdrop.bg_colour = MENU_BACKDROP
2121
2122    panel = backdrop.add_child(MenuPanel(name="Panel"))
2123    panel.bg_colour = MENU_PANEL
2124
2125    box = VBoxContainer(name="Rows")
2126    box.sizing = SizingMode.CONTENT
2127    box.separation = MENU_SEPARATION_PX
2128    box.size_x = float(width)
2129    box.margin_left = box.margin_top = MENU_PADDING_PX
2130    panel.column = box
2131
2132    heading = box.add_child(Label(title, name="Title"))
2133    heading.font_size = float(title_size)
2134    heading.text_colour = Colour.coerce(MENU_TITLE_COLOUR, name="title")
2135    if subtitle:
2136        add_menu_line(box, subtitle, dim=True)
2137    return box
2138
2139
2140def menu_panel_of(root) -> MenuPanel | None:
2141    """The live panel a screen built on *root*, or None before it has one.
2142
2143    A rebuilt screen (the dock, the pause) destroys its old backdrop and mounts
2144    a new one in the same breath, and the engine's removal is deferred to the
2145    end of the frame, so both are children for the rest of it. The one on its
2146    way out is skipped: settling it, or hanging a reference on it, would put
2147    the screen's rows on a panel that is about to be carried away.
2148    """
2149    for backdrop in root.children:
2150        if getattr(backdrop, "destroying", False):
2151            continue
2152        for child in getattr(backdrop, "children", ()):
2153            if isinstance(child, MenuPanel):
2154                return child
2155    return None
2156
2157
2158def settle_menu_panel(root) -> None:
2159    """Place *root*'s panel and mount the column it has finished filling.
2160
2161    Called once, at the end of building a screen. Everything the column holds
2162    is laid out at that moment, at the coordinates it keeps for the life of the
2163    screen: a row is never drawn at a position it is about to leave.
2164    """
2165    panel = menu_panel_of(root)
2166    if panel is not None:
2167        panel.settle()
2168
2169
2170def add_menu_line(box: VBoxContainer, text: str, *, dim: bool = False) -> Label:
2171    """One text row in a panel column."""
2172    label = box.add_child(Label(text))
2173    label.text_colour = Colour.coerce(MENU_DIM_COLOUR if dim else MENU_TEXT_COLOUR, name="line")
2174    return label
2175
2176
2177def warp_price(quote) -> str:
2178    """A jump's fuel price, itemised whenever it is more than the base fare.
2179
2180    "22 fuel" where every other quote in the game says 15 is a number the
2181    pilot cannot check, and the surcharge it hides is a decision: a jump made
2182    before the hunter tears in costs the base fare, and the same jump made
2183    after it costs half as much again. So the chip prints the sum whenever
2184    there is one, and each part says what charged it.
2185
2186    The multiplier is the awkward one, because it is applied to the drive half
2187    of the price rather than added to it; quoting it as the difference it
2188    makes is the form a pilot can add up.
2189    """
2190    base = float(getattr(quote, "base", 0.0))
2191    skip = float(getattr(quote, "skip_surcharge", 0.0))
2192    toll = float(getattr(quote, "toll", 0.0))
2193    total = float(getattr(quote, "total", base))
2194    hunted = (base + skip) * (balance.EMERGENCY_WARP_FUEL_MULT - 1.0) if getattr(quote, "emergency", False) else 0.0
2195    parts = ""
2196    if skip >= 0.5:
2197        parts += WARP_PRICE_SKIP.format(extra=skip)
2198    if hunted >= 0.5:
2199        parts += WARP_PRICE_HUNTED.format(extra=hunted)
2200    if toll >= 0.5:
2201        parts += WARP_PRICE_TOLL.format(extra=toll)
2202    if not parts:
2203        return WARP_PRICE_PLAIN.format(total=total)
2204    return WARP_PRICE_ITEMISED.format(total=total, base=base, parts=parts)
2205
2206
2207def cores_source_line(profile: dict | None = None) -> str:
2208    """The one sentence naming where Cores come from, at this profile's rates.
2209
2210    Printed by both screens that show a Core balance: the tree, where the
2211    balance is what a purchase is measured against, and the ledger, where it
2212    has just been paid. The death rate is the profile's own, because the
2213    doctrine tree sells a node that raises it and a screen quoting the base
2214    rate to a player who has bought that node is quoting a stale number.
2215    """
2216    return CORES_SOURCE_LINE.format(
2217        post=balance.CONVERT_RATE_TRADING_POST,
2218        unit=balance.CONVERT_SCRAP_UNIT,
2219        refinery=balance.CONVERT_RATE_REFINERY,
2220        gate=balance.CONVERT_RATE_EXTRACTION,
2221        death=death_conversion_rate(profile),
2222    )
2223
2224
2225def wrap_prose(text: str, font_px: float, width_px: float = MENU_WIDTH_PX) -> list[str]:
2226    """*text* broken into lines that fit *width_px* at *font_px*.
2227
2228    A panel grows to fit its widest child, so one long generated sentence used
2229    to stretch the death ledger's panel out past its own buttons: the recap ran
2230    off both sides of the column it was supposed to be a caption for. The
2231    advance is estimated rather than measured because the wrap has to be
2232    decidable before the labels exist, and a slightly conservative estimate
2233    costs a wrap point rather than a clipped word.
2234    """
2235    limit = max(1, int(width_px / max(1.0, font_px * MENU_GLYPH_ADVANCE_FRACTION)))
2236    lines: list[str] = []
2237    current = ""
2238    for word in str(text).split():
2239        candidate = f"{current} {word}" if current else word
2240        if len(candidate) <= limit or not current:
2241            current = candidate
2242        else:
2243            lines.append(current)
2244            current = word
2245    if current:
2246        lines.append(current)
2247    return lines
2248
2249
2250def ellipsise(text: str, limit: int) -> str:
2251    """*text* cut to *limit* characters, marked where it was cut.
2252
2253    The blunt companion to :func:`wrap_prose`, for the rows that cannot wrap:
2254    a button is one line tall, and a line that outgrows it does not clip, it
2255    runs out through both sides of the button and over whatever is beside it.
2256    """
2257    text = str(text)
2258    if limit <= 0 or len(text) <= limit:
2259        return text
2260    if limit <= len(DEPOT_ROW_ELLIPSIS):
2261        return text[:limit]
2262    return text[: limit - len(DEPOT_ROW_ELLIPSIS)].rstrip() + DEPOT_ROW_ELLIPSIS
2263
2264
2265def _cell_indent(tail: str, chars: int) -> int:
2266    """How far a doctrine cell's second line is set in, given what it carries.
2267
2268    The first line spends :data:`DOCTRINE_CELL_MARK_CHARS` on the state mark
2269    and its space, and the second is set in by the same amount so the wrapped
2270    half of a title reads under the first half rather than under the mark.
2271    That alignment is a nicety and the price on the line is not, so when the
2272    two compete the indent is what gives: a cell that dropped a word to keep
2273    its left edge would have traded the thing it is read for against a thing
2274    nobody would have noticed.
2275    """
2276    return max(0, min(DOCTRINE_CELL_MARK_CHARS, int(chars) - len(tail)))
2277
2278
2279def _set_text(control, text: str) -> None:
2280    """Write *text* onto a control, and only when it has actually changed.
2281
2282    The two live screens redraw their rows every frame from the profile and the
2283    hold, and an unconditional write would dirty sixty controls a frame for a
2284    tree nobody is spending in.
2285    """
2286    if control.text != text:
2287        control.text = text
2288
2289
2290def _strip_without(row: str, word: str) -> str:
2291    """*row* with every entry naming *word* taken out of it."""
2292    parts = row.split(CONTROLS_BAR_SEPARATOR)
2293    return CONTROLS_BAR_SEPARATOR.join(part for part in parts if word not in part.upper())
2294
2295
2296def _strip_relabel(row: str, word: str, entry: str) -> str:
2297    """*row* with the entry naming *word* rewritten as *entry*, in place."""
2298    parts = row.split(CONTROLS_BAR_SEPARATOR)
2299    return CONTROLS_BAR_SEPARATOR.join(entry if word in part.upper() else part for part in parts)
2300
2301
2302def controls_bar_without_chart() -> str:
2303    """The HUD's key strip with the chart key taken out of it.
2304
2305    Run one has no chart, and a strip that offers M anyway is the one place a
2306    pilot is certain to read the key off. Filtered rather than restated, so the
2307    strip stays the HUD's to write.
2308    """
2309    return _strip_without(CONTROLS_BAR_TEXT, "CHART")
2310
2311
2312def controls_bar_rows(schedule: Onboarding) -> tuple[str, str]:
2313    """Both key-strip rows, as the schedule has them at this point in the game.
2314
2315    The chart key goes with the chart: before run two there is none, and a
2316    strip is the one place a pilot is certain to read a key off, so it must
2317    never name one whose answer is "not yet".
2318
2319    Silent running is a run-3 unlock, and it is *marked* rather than dropped.
2320    A key that vanishes from the strip and reappears three runs later reads as
2321    a bar that cannot be trusted; a key marked locked reads as something to
2322    come back for, and C still answers on the toast line meanwhile.
2323
2324    The scoop leaves the strip outright. It is passive, always on and has
2325    nothing to press: naming Tab told a pilot to toggle a thing that does not
2326    toggle, which is the one job a key strip must not do.
2327    """
2328    first = CONTROLS_BAR_TEXT if schedule.chart_open() else controls_bar_without_chart()
2329    second = _strip_without(CONTROLS_BAR_SECOND_ROW, "SCOOP")
2330    if not schedule.silent_running_available():
2331        second = _strip_relabel(second, "SILENT", CONTROLS_BAR_SILENT_LOCKED)
2332    return first, second
2333
2334
2335def _rect_holds(rect, point: tuple[float, float]) -> bool:
2336    """Whether a global rect contains a screen point."""
2337    x, y, width, height = rect
2338    return x <= point[0] <= x + width and y <= point[1] <= y + height
2339
2340
2341class ShrikeScene(Node):
2342    """Base for every flow screen: settings, the profile, and scene switching.
2343
2344    Every screen declares the action map so a swap never leaves the game
2345    unbound, and reaches the save system through the tree singleton this class
2346    creates on first use.
2347    """
2348
2349    input_actions = base_input_actions()
2350
2351    def __init__(self, *, settings: Settings | None = None, profile: dict | None = None, **kwargs):
2352        super().__init__(**kwargs)
2353        self._settings = settings
2354        self._profile = profile
2355
2356    # -- meta services -----------------------------------------------------
2357
2358    def save_system(self) -> save.SaveSystem:
2359        """The tree's save singleton, created on first use."""
2360        tree = self.tree
2361        system = tree.singletons.get(Services.SAVE)
2362        if not isinstance(system, save.SaveSystem):
2363            system = save.SaveSystem(name="SaveSystem")
2364            tree.add_singleton(Services.SAVE, system)
2365        return system
2366
2367    def meta_profile(self) -> MetaProfile:
2368        """The doctrine singleton, created on first use.
2369
2370        The save system is created first because the profile node loads through
2371        it the moment it is mounted.
2372        """
2373        tree = self.tree
2374        node = tree.singletons.get(Services.META)
2375        if not isinstance(node, MetaProfile):
2376            self.save_system()
2377            node = MetaProfile(name="MetaProfile")
2378            tree.add_singleton(Services.META, node)
2379        return node
2380
2381    def profile(self) -> dict:
2382        """The one live profile dictionary, owned by :class:`meta.MetaProfile`.
2383
2384        A profile handed to this screen's constructor (a test, a debug console)
2385        is adopted by the singleton rather than kept beside it, so there is
2386        never a second dictionary for Cores to be banked into.
2387        """
2388        node = self.meta_profile()
2389        if self._profile is not None and self._profile is not node.profile:
2390            node.adopt(self._profile)
2391        self._profile = node.profile
2392        return self._profile
2393
2394    def settings(self) -> Settings:
2395        if self._settings is None:
2396            self._settings = Settings.from_profile(self.profile())
2397        return self._settings
2398
2399    def modifiers(self) -> dict:
2400        """The resolved effect bag the run reads at launch."""
2401        return dict(self.meta_profile().modifiers())
2402
2403    # -- switching ---------------------------------------------------------
2404
2405    def go(self, scene: Node) -> None:
2406        """Swap to *scene*, carrying the settings and profile across.
2407
2408        A screen owned by another module (the Roost) still gets the action map,
2409        so a swap never leaves the game unbound.
2410        """
2411        if isinstance(scene, ShrikeScene):
2412            scene._settings = self.settings()
2413            scene._profile = self.profile()
2414        scene.input_actions = self.settings().input_actions()
2415        self.tree.change_scene(scene)
2416
2417    def quit(self) -> None:
2418        """Leave the game the way the engine expects."""
2419        app = self.app
2420        if app is not None:
2421            app.quit()
2422
2423    # -- layout ------------------------------------------------------------
2424
2425    def build_panel(
2426        self,
2427        title: str,
2428        subtitle: str = "",
2429        *,
2430        title_size: float = MENU_TITLE_FONT_PX,
2431        width: float = MENU_WIDTH_PX,
2432    ) -> VBoxContainer:
2433        """A column under a title, detached until :meth:`settle_panel` mounts it."""
2434        return build_menu_panel(self, title, subtitle, title_size=title_size, width=width)
2435
2436    def add_line(self, box: VBoxContainer, text: str, *, dim: bool = False) -> Label:
2437        """One text row in a panel column."""
2438        return add_menu_line(box, text, dim=dim)
2439
2440    def settle_panel(self) -> None:
2441        """Place the panel and mount the column, once the rows are all in it."""
2442        settle_menu_panel(self)
2443
2444    def menu_panel(self) -> MenuPanel | None:
2445        """This screen's centred panel, or None before it is built."""
2446        return menu_panel_of(self)
2447
2448
2449# ============================================================================
2450# The screens
2451# ============================================================================
2452
2453
2454class MainMenuScene(ShrikeScene):
2455    """The front door: launch, resume, settings, quit.
2456
2457    The nebula behind it is the same procedural sky the run uses, which is what
2458    a Roost kill permanently mutates.
2459    """
2460
2461    def __init__(self, *, focus_entry: str = "", **kwargs):
2462        kwargs.setdefault("name", "MainMenu")
2463        super().__init__(**kwargs)
2464        self.menu: MenuList | None = None
2465        #: The row to open on, named by whichever screen sent the player back
2466        #: here. Backing out of a submenu onto row one is how a player loses
2467        #: their place in a menu they have been in three times this session.
2468        self.focus_entry = str(focus_entry)
2469        #: Escape leaves the game from here, and Escape is also how the screens
2470        #: behind this one are backed out of. The latch is what keeps the press
2471        #: that returned to the front door from walking straight out of it.
2472        self._back: PressLatch | None = None
2473
2474    def on_ready(self):
2475        self._back = PressLatch(PAUSE_ACTION)
2476        profile = self.profile()
2477        cores = int(profile.get("cores", 0))
2478        rank = int(profile.get("hunt_rank", 0))
2479        box = self.build_panel("SHRIKE", f"{cores} Cores banked, Hunt Rank {rank}")
2480        self.menu = MenuList(box)
2481        if self.save_system().has_suspend():
2482            self.menu.add("RESUME RUN", self.resume_run)
2483        self.menu.add("NEW RUN", self.new_run)
2484        self.menu.add(DOCTRINE_MENU_ENTRY.format(cores=cores), self.open_doctrine)
2485        self.menu.add("SETTINGS", self.open_settings)
2486        self.menu.add("QUIT", self.quit)
2487        self.add_line(box, MENU_CONTROLS_LINE, dim=True)
2488        self.menu.focus(self.focus_entry)
2489        self.settle_panel()
2490
2491    def on_update(self, dt: float):
2492        if self._back is not None and self._back.poll(dt)[0]:
2493            self.quit()
2494            return
2495        if self.menu is not None:
2496            self.menu.poll(dt)
2497
2498    # -- entries -----------------------------------------------------------
2499
2500    def new_run(self) -> None:
2501        self.go(RunSetupScene())
2502
2503    def resume_run(self) -> None:
2504        """Take the one suspend slot and drop straight back into the run."""
2505        payload = self.save_system().take_suspend()
2506        if payload is None:
2507            self.new_run()
2508            return
2509        state = RunState.from_suspend(payload)
2510        # The effect bag is not stored in the slot; it re-resolves from the
2511        # profile, which still carries the doctrine the run launched with.
2512        state.config.modifiers = self.modifiers()
2513        scene = RunScene(state.config.as_dict())
2514        scene.resume_state = state
2515        self.go(scene)
2516
2517    def open_settings(self) -> None:
2518        self.go(SettingsScene(back_entry=MENU_ENTRY_SETTINGS))
2519
2520    def open_doctrine(self) -> None:
2521        """The Cores door. Named with the balance, so the currency has a purpose."""
2522        self.go(DoctrineScene(back_factory=lambda: MainMenuScene(focus_entry=MENU_ENTRY_DOCTRINE)))
2523
2524
2525class RunSetupScene(ShrikeScene):
2526    """Hull and keystone choice, the two decisions a run launches with."""
2527
2528    def __init__(self, **kwargs):
2529        kwargs.setdefault("name", "RunSetup")
2530        super().__init__(**kwargs)
2531        self.hull_id = save.STARTER_HULL
2532        self.keystones: list[str] = []
2533        self.menu: MenuList | None = None
2534        #: The answer to the last press, which the hull row needs and the
2535        #: keystone rows have always wanted: a refusal has to be readable.
2536        self.status = ""
2537        self._status: Label | None = None
2538        self._message: Label | None = None
2539        self._back: PressLatch | None = None
2540
2541    # -- choices -----------------------------------------------------------
2542
2543    def unlocked_hulls(self) -> list[str]:
2544        hulls = (self.profile().get("hulls") or {}).get("unlocked") or [save.STARTER_HULL]
2545        return [hull for hull in hulls if hull in balance.HULL_SOCKETS]
2546
2547    def available_keystones(self) -> list[str]:
2548        """Keystones the doctrine tree has actually bought."""
2549        owned = set((self.profile().get("doctrine") or {}).get("owned") or [])
2550        return [node for node in balance.DOCTRINE_KEYSTONES.values() if node in owned]
2551
2552    def locked_hulls(self) -> list[str]:
2553        """The hulls this profile has not earned yet, in catalogue order."""
2554        unlocked = set(self.unlocked_hulls())
2555        return [hull for hull in balance.HULL_SOCKETS if hull not in unlocked and hull in HULLS]
2556
2557    def select_hull(self, hull_id: str) -> None:
2558        if hull_id not in self.unlocked_hulls():
2559            raise ValueError(f"hull {hull_id!r} is not unlocked")
2560        self.hull_id = hull_id
2561        self._refresh()
2562
2563    def cycle_hull(self) -> bool:
2564        """Step to the next unlocked hull. False, and a reason, when there is one.
2565
2566        The row is a selector, so it has to answer the key that selects. A
2567        profile with a second hull cycles onto it; a profile with one gets the
2568        sentence that explains why, because a control that does nothing and
2569        says nothing reads as a broken control rather than as a locked one.
2570        """
2571        hulls = self.unlocked_hulls()
2572        if len(hulls) <= 1:
2573            name = HULLS[self.hull_id].title.upper() if self.hull_id in HULLS else self.hull_id.upper()
2574            locked = len(self.locked_hulls())
2575            self.status = (
2576                SETUP_HULL_ONLY.format(name=name, count=locked) if locked else SETUP_HULL_ONLY_ONE.format(name=name)
2577            )
2578            self._refresh()
2579            return False
2580        index = hulls.index(self.hull_id) if self.hull_id in hulls else -1
2581        self.hull_id = hulls[(index + 1) % len(hulls)]
2582        spec = HULLS.get(self.hull_id)
2583        if spec is not None:
2584            self.status = SETUP_HULL_PICKED.format(name=spec.title.upper(), description=spec.description)
2585        self._refresh()
2586        return True
2587
2588    def hull_row_text(self) -> str:
2589        """The selector row: the hull it is on, and whether F can move it."""
2590        hulls = self.unlocked_hulls()
2591        spec = HULLS.get(self.hull_id)
2592        name = spec.title.upper() if spec is not None else self.hull_id.upper()
2593        sockets = hull_socket_count(self.hull_id)
2594        if len(hulls) <= 1:
2595            return SETUP_HULL_ROW.format(name=name, sockets=sockets)
2596        return SETUP_HULL_ROW_CYCLE.format(name=name, sockets=sockets, count=len(hulls))
2597
2598    def toggle_keystone(self, node_id: str) -> bool:
2599        """Arm or disarm a keystone. False when the two slots are already full."""
2600        if node_id in self.keystones:
2601            self.keystones.remove(node_id)
2602            self._refresh()
2603            return True
2604        if len(self.keystones) >= balance.DOCTRINE_MAX_ACTIVE_KEYSTONES:
2605            self._refresh()
2606            return False
2607        self.keystones.append(node_id)
2608        self._refresh()
2609        return True
2610
2611    def config(self) -> RunConfig:
2612        """Freeze the choices, committing them to the doctrine profile first.
2613
2614        The effect bag is resolved against the selected hull and the armed
2615        keystones, so the choice has to reach the profile before it is read.
2616        """
2617        profile = self.profile()
2618        meta = self.meta_profile()
2619        meta.select_hull(self.hull_id)
2620        meta.set_active_keystones(self.keystones)
2621        return RunConfig(
2622            run_number=int(profile.get("runs_started", 0)) + 1,
2623            seed=random.randrange(1, 2**31),
2624            hull_id=self.hull_id,
2625            keystones=tuple(self.keystones),
2626            modifiers=self.modifiers(),
2627        )
2628
2629    def launch(self) -> None:
2630        self.go(RunScene(self.config().as_dict()))
2631
2632    # -- screen ------------------------------------------------------------
2633
2634    def on_ready(self):
2635        self._back = PressLatch(PAUSE_ACTION)
2636        hulls = self.unlocked_hulls()
2637        self.hull_id = (self.profile().get("hulls") or {}).get("selected", hulls[0])
2638        if self.hull_id not in hulls:
2639            self.hull_id = hulls[0]
2640        box = self.build_panel("PREPARE THE RUN", f"at most {balance.DOCTRINE_MAX_ACTIVE_KEYSTONES} keystones")
2641        self.menu = MenuList(box)
2642        # One row for the hull, whatever the profile has unlocked. It cycles.
2643        self.menu.add(self.hull_row_text(), self.cycle_hull)
2644        for keystone in self.available_keystones():
2645            self.menu.add(f"KEYSTONE: {keystone.upper()}", _bind(self.toggle_keystone, keystone))
2646        self.menu.add("LAUNCH", self.launch)
2647        self.menu.add("BACK", self.back)
2648        self._status = self.add_line(box, "", dim=True)
2649        self._message = self.add_line(box, "", dim=True)
2650        locked = self.locked_hulls()
2651        if locked:
2652            self.add_line(box, SETUP_HULL_LOCKED_HEAD, dim=True)
2653            for hull in locked:
2654                spec = HULLS[hull]
2655                row = SETUP_HULL_LOCKED_ROW.format(name=spec.title.upper(), feat=spec.feat or "")
2656                self.add_line(box, row, dim=True)
2657        self.add_line(box, MENU_CONTROLS_LINE_BACK, dim=True)
2658        self._refresh()
2659        self.settle_panel()
2660
2661    def back(self) -> None:
2662        """Return to the front door with the cursor on the row we came in by."""
2663        self.go(MainMenuScene(focus_entry=MENU_ENTRY_NEW_RUN))
2664
2665    def on_update(self, dt: float):
2666        if self._back is not None and self._back.poll(dt)[0]:
2667            self.back()
2668            return
2669        if self.menu is not None:
2670            self.menu.poll(dt)
2671
2672    def _refresh(self) -> None:
2673        if self.menu is not None and self.menu.entries:
2674            self.menu.relabel(0, self.hull_row_text())
2675        if self._status is not None:
2676            armed = ", ".join(self.keystones) if self.keystones else "none"
2677            self._status.text = f"hull {self.hull_id}, keystones: {armed}"
2678        if self._message is not None:
2679            _set_text(self._message, ellipsise(self.status, DEPOT_ROW_CHARS))
2680
2681
2682class DrawCanvas(Control):
2683    """A fixed-size surface inside a menu column, drawn on by absolute rects.
2684
2685    A menu column lays its rows out one under another and measures each of them
2686    by what it says it needs. That is exactly right for a list and no use at
2687    all for a diagram, so a screen that wants to *draw* puts one of these in the
2688    column and places its own controls inside it at coordinates it works out
2689    itself.
2690
2691    The one thing this has to do that a bare :class:`Control` does not is
2692    report its own size as its minimum. Both halves matter. The column sizes a
2693    row by its minimum, so a canvas that reported nothing would be laid out
2694    with no height at all; and the panel's fit pass shrinks a column by scaling
2695    every ``size`` under it, so a minimum read off ``size`` shrinks with it,
2696    which is what lets a diagram be fitted to a small window by the same one
2697    pass that fits the rows above and below it.
2698
2699    Children are added *after* the panel has settled, at the rect the canvas
2700    finished with. That order is the same one :class:`MenuPanel` documents: a
2701    control's retained draw bakes the transform it was captured at, so nothing
2702    may be placed until the thing it is placed inside has stopped moving.
2703    """
2704
2705    def __init__(self, **kwargs):
2706        super().__init__(**kwargs)
2707        # A diagram drawn bigger than its window is panned, not shrunk, so the
2708        # part hanging past the edge has to be cut off rather than drawn over
2709        # the rows beneath.
2710        self.clip_contents = True
2711
2712    def get_minimum_size(self) -> Vec2:
2713        return Vec2(max(self.size_x, self.min_size_x), max(self.size_y, self.min_size_y))
2714
2715    def clear(self) -> None:
2716        """Drop everything drawn on the canvas, ready to be drawn again.
2717
2718        Hidden before it is destroyed, because the engine's removal is deferred
2719        to the end of the frame: a redrawn canvas would otherwise paint the old
2720        drawing and the new one over each other for exactly one frame, which on
2721        a panning view is a ghost of where it just was.
2722        """
2723        for child in list(self.children):
2724            if isinstance(child, Control):
2725                child.visible = False
2726            child.destroy()
2727
2728
2729def branch_layout(branch_id: str) -> dict[int, tuple[int, int]]:
2730    """Where each slot of *branch_id* sits on the tree: ``slot -> (tier, lane)``.
2731
2732    Derived from the prerequisites rather than authored beside them, so the
2733    drawing cannot disagree with the tree it is drawing. A node's tier is one
2734    past the deepest thing it requires, which puts every connector on the
2735    screen pointing down; its lane is 0 when it lies on the route to the
2736    branch's keystone (the spine) and the next free lane of its tier otherwise
2737    (the shoulder), which is the fork the branch is actually built around.
2738    """
2739    nodes = branch_nodes(branch_id)
2740    slot_of = {node.id: slot for slot, node in enumerate(nodes)}
2741    tier: dict[int, int] = {}
2742    for slot, node in enumerate(nodes):
2743        tier[slot] = 1 + max((tier[slot_of[required]] for required in node.requires), default=-1)
2744    keystone = next((slot for slot, node in enumerate(nodes) if node.kind == "keystone"), len(nodes) - 1)
2745    spine = {slot_of[node_id] for node_id in prerequisites_of(nodes[keystone].id)} | {keystone}
2746    lane: dict[int, int] = {}
2747    taken: dict[int, int] = {}
2748    # The spine first, so it always owns lane 0 and reads as one straight line
2749    # down the middle of its branch however the shoulder is shaped.
2750    for slot in sorted(range(len(nodes)), key=lambda index: (tier[index], index not in spine, index)):
2751        row = tier[slot]
2752        lane[slot] = 0 if slot in spine else max(1, taken.get(row, 0))
2753        taken[row] = lane[slot] + 1
2754    return {slot: (tier[slot], lane[slot]) for slot in range(len(nodes))}
2755
2756
2757class DoctrineScene(ShrikeScene):
2758    """The Fleet Doctrine tree: five branches, sixty nodes, drawn as a tree.
2759
2760    This is where Cores stop being a number on the front door and become a
2761    build. It used to be a grid: five columns of twelve labels, stacked in
2762    reading order, which is a page of buttons rather than a map. Every node
2763    looked exactly as reachable as every other one, the shape of a branch was
2764    invisible, and the only way to find out why a node would not sell was to
2765    walk the cursor onto it and read a refusal.
2766
2767    So the branches are drawn the shape they are built: a spine down the
2768    cheapest route to the branch's keystone, a shoulder beside it for the rest,
2769    a row per tier, and a drawn connector from every node to the node that
2770    unlocks it. The connector lights once its upper end is bought, so the route
2771    the purse has actually opened is a lit path down the screen. Every node
2772    carries a state mark as well as a state colour, and the detail block under
2773    the tree names the cost, the effect and the prerequisite of whatever the
2774    cursor is on, before a single Core is spent.
2775
2776    The whole tree is on one screen wherever there is room for it. Where there
2777    is not, the drawing stops shrinking at :data:`DOCTRINE_MIN_DRAW_SCALE` and
2778    the view pans under the cursor instead: a tree fitted into a small window by
2779    scale alone is a diagram nobody can read, and reading it is the point.
2780
2781    That floor is set by the type and not by taste. The renderer will not put
2782    text on the glass below :data:`DOCTRINE_CELL_MIN_FONT_PX`, so a plate drawn
2783    any smaller is a plate shrinking around a caption that is not shrinking with
2784    it, and the price walks out through the border. Every cell is measured and
2785    drawn at that one size, the plates are kept wide enough to hold a priced
2786    line at it, and the gaps between branches and tiers are what paid for the
2787    width.
2788
2789    Every control the rest of the game uses works here and nothing new is
2790    bound. The thrust actions move the cursor, left and right across branches
2791    and up and down through tiers and on into the three action rows beneath;
2792    ``interact`` buys what is under the cursor; the pointer selects whatever it
2793    is over and ``fire_primary`` commits that; and Escape is the way back.
2794
2795    A purchase is written through the save system the moment it clears, and so
2796    is a respec, which is free and total by design: the tree is a loadout
2797    language, not a ratchet.
2798    """
2799
2800    #: The rows under the tree, in the order they are drawn.
2801    ACTIONS: tuple[str, ...] = (DOCTRINE_ACTION_BUY, DOCTRINE_ACTION_RESPEC, DOCTRINE_ACTION_BACK)
2802
2803    #: The four states a node is in, which are the four things a player needs
2804    #: to be able to tell apart at a glance.
2805    STATE_OWNED = DOCTRINE_STATE_OWNED_ID
2806    STATE_BUYABLE = DOCTRINE_STATE_BUYABLE_ID
2807    STATE_COSTLY = DOCTRINE_STATE_COSTLY_ID
2808    STATE_LOCKED = DOCTRINE_STATE_LOCKED_ID
2809
2810    def __init__(self, *, back_factory=None, **kwargs):
2811        kwargs.setdefault("name", "Doctrine")
2812        super().__init__(**kwargs)
2813        #: What BACK and Escape return to. The ledger hands in a factory that
2814        #: rebuilds itself, so spending Cores on the way out of a run does not
2815        #: cost the player the ledger they were still reading.
2816        self.back_factory = back_factory or MainMenuScene
2817        self.branch_ids: list[str] = list(BRANCHES)
2818        self.slots = len(branch_nodes(self.branch_ids[0]))
2819        #: Where every slot of every branch sits, read off the prerequisites.
2820        self.layout: dict[str, dict[int, tuple[int, int]]] = {
2821            branch_id: branch_layout(branch_id) for branch_id in self.branch_ids
2822        }
2823        places = [place for branch in self.layout.values() for place in branch.values()]
2824        self.tiers = 1 + max(tier for tier, _lane in places)
2825        self.lanes = 1 + max(lane for _tier, lane in places)
2826        self.branch_index = 0
2827        #: The tree row the cursor last stood on, kept while the cursor is down
2828        #: among the action rows so BUY still knows what it is buying.
2829        self.slot_index = 0
2830        self.cursor = 0
2831        self.status = ""
2832
2833        self._canvas: DrawCanvas | None = None
2834        self._cells: dict[tuple[int, int], Label] = {}
2835        self._plates: dict[tuple[int, int], Panel] = {}
2836        #: Every drawn connector, with the node whose purchase lights it.
2837        self._links: list[tuple[Panel, str]] = []
2838        self._headings: list[Label] = []
2839        self._action_rows: list[Button] = []
2840        self._detail: list[Label] = []
2841        self._subtitle: Label | None = None
2842        self._prereq: Label | None = None
2843        self._status_row: Label | None = None
2844        self._pan_row: Label | None = None
2845        #: How far the drawing is slid inside the canvas, and what the drawing
2846        #: standing on the canvas right now was placed for. A pan is a rebuild,
2847        #: never a move: a control's retained draw bakes the transform it was
2848        #: captured at, so a diagram that slid would paint where it used to be.
2849        self._pan: tuple[float, float] = (0.0, 0.0)
2850        self._drawn: tuple[float, float, float, float] | None = None
2851        #: The measured caption budget, and the room and type size it was
2852        #: measured for. Sixty cells at every budget is a real walk of the
2853        #: whole tree, and the tree asks for the answer once a frame.
2854        self._chars = 1
2855        self._chars_for: tuple[float, float] | None = None
2856        self._commit = PressLatch("interact")
2857        self._confirm = PressLatch(MENU_CONFIRM_ACTION)
2858        self._click = PressLatch("fire_primary")
2859        self._pointer: tuple[float, float] | None = None
2860        self._leaving = False
2861
2862    # -- the tree ----------------------------------------------------------
2863
2864    @property
2865    def rows(self) -> int:
2866        """Cursor positions: one per slot, then one per action row."""
2867        return self.slots + len(self.ACTIONS)
2868
2869    def content_size(self) -> tuple[float, float]:
2870        """The tree's own size, in the pixels it is authored at."""
2871        branch_w = self.lanes * DOCTRINE_NODE_WIDTH_PX + (self.lanes - 1) * DOCTRINE_LANE_GAP_PX
2872        width = len(self.branch_ids) * branch_w + (len(self.branch_ids) - 1) * DOCTRINE_BRANCH_GAP_PX
2873        height = (
2874            DOCTRINE_HEADING_HEIGHT_PX + self.tiers * DOCTRINE_NODE_HEIGHT_PX + (self.tiers - 1) * DOCTRINE_TIER_GAP_PX
2875        )
2876        return width, height
2877
2878    def grid_width(self) -> float:
2879        """How wide the tree comes out, which is the column's own width."""
2880        return self.content_size()[0]
2881
2882    def branch_origin(self, branch_index: int) -> float:
2883        """The left edge of one branch's lanes, in authored pixels."""
2884        branch_w = self.lanes * DOCTRINE_NODE_WIDTH_PX + (self.lanes - 1) * DOCTRINE_LANE_GAP_PX
2885        return branch_index * (branch_w + DOCTRINE_BRANCH_GAP_PX)
2886
2887    def node_box(self, branch_index: int, slot: int) -> tuple[float, float, float, float]:
2888        """One node's rect on the drawing, in authored pixels."""
2889        tier, lane = self.layout[self.branch_ids[branch_index]][slot]
2890        x = self.branch_origin(branch_index) + lane * (DOCTRINE_NODE_WIDTH_PX + DOCTRINE_LANE_GAP_PX)
2891        y = DOCTRINE_HEADING_HEIGHT_PX + tier * (DOCTRINE_NODE_HEIGHT_PX + DOCTRINE_TIER_GAP_PX)
2892        return x, y, DOCTRINE_NODE_WIDTH_PX, DOCTRINE_NODE_HEIGHT_PX
2893
2894    def branch_id(self) -> str:
2895        return self.branch_ids[self.branch_index]
2896
2897    def node_at(self, branch_index: int, slot: int) -> DoctrineNode:
2898        """The node in one branch's slot, which is where the tree draws it."""
2899        return branch_nodes(self.branch_ids[branch_index])[slot]
2900
2901    def selected_node(self) -> DoctrineNode:
2902        """The node the detail block is describing and BUY would buy."""
2903        return self.node_at(self.branch_index, self.slot_index)
2904
2905    def selected_action(self) -> str:
2906        """The action row under the cursor, empty while the cursor is on the tree."""
2907        if self.cursor < self.slots:
2908            return ""
2909        return self.ACTIONS[self.cursor - self.slots]
2910
2911    def cores(self) -> int:
2912        return self.meta_profile().cores()
2913
2914    def cell_lines(self, node: DoctrineNode, chars: int | None = None) -> tuple[str, str]:
2915        """*node*'s caption over the two lines its own cell prints it on.
2916
2917        Ten lanes of nodes across a 1280-pixel window leave a couple of dozen
2918        characters a cell at the one size the renderer will draw, and a priced
2919        title runs past that often enough to matter. One line meant cutting
2920        them ("Ammunition Sov... 40"), which throws away the branch's own
2921        vocabulary and keeps its punctuation. So the title wraps: whole words
2922        onto the first line, the rest onto the second with the price after it.
2923
2924        The whole name goes on the first line whenever it fits, which leaves
2925        the price the second line to itself and is how all but the longest
2926        title in the tree reads: a name, then "NN Cores" under it. A title too
2927        long for one line takes its last words down with the price beside them,
2928        because the alternative at this size is cutting the name.
2929
2930        The price is a token, never a loose number: "40 Cores" goes on whole or
2931        the line makes room for it. Reserving the digits alone was enough to
2932        strand one cell reading "Sovereignty 40" in a column of siblings all
2933        reading "NN Cores", which prices nothing and looks like a stat. The
2934        room comes out of the second line's indent first (:func:`_cell_indent`)
2935        and only then out of the name, which is why nothing in this tree is cut
2936        at either 1280x720 or the 800x500 the pan floor takes over at.
2937        """
2938        if chars is None:
2939            chars = self.cell_chars()
2940        cost = DOCTRINE_CELL_COST.format(cost=node.cost)
2941        head_room = max(1, chars - DOCTRINE_CELL_MARK_CHARS)
2942        head = ""
2943        rest: list[str] = []
2944        for word in str(node.title).split():
2945            candidate = f"{head} {word}" if head else word
2946            if rest or (head and len(candidate) > head_room):
2947                rest.append(word)
2948            else:
2949                head = candidate
2950        tail = " ".join(rest)
2951        if not tail:
2952            # A title that fitted on one line leaves the price the whole
2953            # second, so it says what the number is rather than sitting there
2954            # as one.
2955            return ellipsise(head, head_room), cost
2956        # The name gives way to the price and not the other way round, and it
2957        # gives way against the whole cell, because the indent has already gone
2958        # by the time this bites.
2959        return ellipsise(head, head_room), f"{ellipsise(tail, max(1, chars - len(cost) - 1))} {cost}"
2960
2961    def cell_text(self, branch_index: int, slot: int) -> str:
2962        """One cell's whole caption, both its lines joined, as it reads on screen."""
2963        cell = self._cells.get((branch_index, slot))
2964        if cell is None:
2965            return ""
2966        return " ".join(line.strip() for line in str(cell.text).split("\n") if line.strip())
2967
2968    def cell_font(self) -> float:
2969        """The size a cell's caption is laid out at, which is the size it is drawn at.
2970
2971        The renderer will not put type on the glass below
2972        :data:`DOCTRINE_CELL_MIN_FONT_PX`: asked for less it silently draws
2973        that instead. A tree that asked for ten points times its draw scale was
2974        therefore measuring its captions a quarter narrower than they were
2975        painted, which is how a price ended up drawn through a plate's border
2976        while the layout believed the line had room. The floor is taken here
2977        instead, so what the cell budgets and what the cell shows are one size.
2978        """
2979        return max(DOCTRINE_CELL_MIN_FONT_PX, DOCTRINE_CELL_FONT_PX * self.draw_scale())
2980
2981    def cell_room(self) -> float:
2982        """The drawn width a caption has inside its plate, in pixels."""
2983        return max(1.0, (DOCTRINE_NODE_WIDTH_PX - 2.0 * DOCTRINE_NODE_PAD_PX) * self.draw_scale())
2984
2985    def cell_chars(self) -> int:
2986        """How many characters a node's cell holds at the drawn scale.
2987
2988        Measured against the font rather than estimated from an average
2989        advance. The estimate was wrong in both directions at once: it took the
2990        type to be smaller than the renderer will draw and every glyph to be
2991        wider than this face's letters are, and the two errors did not cancel.
2992        What the cell needs to know is whether the widest line the whole tree
2993        would print at a given budget fits between one plate's borders, and the
2994        font can answer exactly that, so the budget is the largest one whose
2995        answer is yes.
2996
2997        Searched downward from :data:`DOCTRINE_CELL_CHARS_MAX` rather than
2998        solved, because the caption's own wrapping is not monotonic in the
2999        budget: one more character can lift a whole word onto the first line
3000        and leave the second line shorter than it was. The answer is cached
3001        against the room and the size it was found for, since it changes only
3002        when the window does and the tree asks for it on every frame.
3003        """
3004        room, font = self.cell_room(), self.cell_font()
3005        key = (round(room, 3), round(font, 3))
3006        if self._chars_for != key:
3007            self._chars_for = key
3008            self._chars = self._fitted_chars(room, font)
3009        return self._chars
3010
3011    def _fitted_chars(self, room: float, font: float) -> int:
3012        """The widest caption budget every cell in the tree still fits inside."""
3013        marks = max(
3014            (f"{cursor}{mark}" for cursor in (DOCTRINE_MARK_CURSOR, " ") for mark in DOCTRINE_STATE_MARKS.values()),
3015            key=lambda text: measure_text_width(text, font / LOGICAL_EM),
3016        )
3017        for chars in range(DOCTRINE_CELL_CHARS_MAX, 1, -1):
3018            if self._caption_width(chars, marks, font) <= room:
3019                return chars
3020        return 1
3021
3022    def _caption_width(self, chars: int, marks: str, font: float) -> float:
3023        """The widest line any cell would draw at this budget, in pixels."""
3024        widest = 0.0
3025        for branch_index in range(len(self.branch_ids)):
3026            for slot in range(self.slots):
3027                head, tail = self.cell_lines(self.node_at(branch_index, slot), chars)
3028                for line in (f"{marks} {head}", f"{' ' * _cell_indent(tail, chars)}{tail}"):
3029                    widest = max(widest, measure_text_width(line, font / LOGICAL_EM))
3030        return widest
3031
3032    def node_state(self, node: DoctrineNode) -> str:
3033        """Which of the four states *node* is in, for the mark and the colour.
3034
3035        The rules are ``meta.MetaProfile``'s; all that is decided here is which
3036        of its refusals is worth its own colour. "You cannot afford this yet"
3037        and "this is locked behind something else" are different problems with
3038        different answers, and a screen that greyed both the same way was
3039        telling a player with a full purse to go and find more Cores.
3040        """
3041        meta = self.meta_profile()
3042        if meta.is_owned(node.id):
3043            return self.STATE_OWNED
3044        if not meta.feat_met(node.feat) or any(not meta.is_owned(required) for required in node.requires):
3045            return self.STATE_LOCKED
3046        return self.STATE_BUYABLE if meta.cores() >= node.cost else self.STATE_COSTLY
3047
3048    # -- moving ------------------------------------------------------------
3049
3050    def move_cursor(self, step: int) -> None:
3051        """Step the cursor down (or up) through tiers and the action rows."""
3052        self.cursor = (self.cursor + int(step)) % self.rows
3053        if self.cursor < self.slots:
3054            self.slot_index = self.cursor
3055
3056    def move_branch(self, step: int) -> None:
3057        """Step sideways to another branch, keeping the tier."""
3058        self.branch_index = (self.branch_index + int(step)) % len(self.branch_ids)
3059
3060    def place_cursor(self, branch_index: int, cursor: int) -> None:
3061        """Put the cursor exactly here, which is what a pointer does."""
3062        self.branch_index = max(0, min(len(self.branch_ids) - 1, int(branch_index)))
3063        self.cursor = max(0, min(self.rows - 1, int(cursor)))
3064        if self.cursor < self.slots:
3065            self.slot_index = self.cursor
3066
3067    # -- spending ----------------------------------------------------------
3068
3069    def buy_selected(self) -> bool:
3070        """Buy the node under the cursor. False, and a reason, when it refuses."""
3071        node = self.selected_node()
3072        meta = self.meta_profile()
3073        blocker = meta.purchase_blocker(node.id)
3074        if blocker is not None:
3075            self.status = f"{node.title.upper()}: {blocker}"
3076            self._refresh()
3077            return False
3078        meta.buy_node(node.id)
3079        self.save_system().save_profile(self.profile())
3080        self.status = DOCTRINE_BOUGHT.format(title=node.title.upper(), cost=node.cost)
3081        self._refresh()
3082        return True
3083
3084    def respec_all(self) -> int:
3085        """Refund the whole tree. Free, total, and written out at once."""
3086        refund = self.meta_profile().respec()
3087        self.save_system().save_profile(self.profile())
3088        self.status = DOCTRINE_RESPEC_DONE.format(refund=refund) if refund > 0 else DOCTRINE_RESPEC_EMPTY
3089        self._refresh()
3090        return refund
3091
3092    def back(self) -> None:
3093        """Leave for whatever opened this screen."""
3094        self._leaving = True
3095        self.go(self.back_factory())
3096
3097    def commit(self) -> bool:
3098        """Run whatever the cursor is on."""
3099        action = self.selected_action()
3100        if action in ("", DOCTRINE_ACTION_BUY):
3101            return self.buy_selected()
3102        if action == DOCTRINE_ACTION_RESPEC:
3103            self.respec_all()
3104            return True
3105        self.back()
3106        return True
3107
3108    # -- screen ------------------------------------------------------------
3109
3110    def on_ready(self):
3111        # Adopt any handed-in profile before a single node is priced: the tree
3112        # is drawn from Cores, and Cores live on the profile.
3113        self.profile()
3114        # A column lays its children out at its own width, so the drawing's
3115        # width has to reach the column rather than only the panel around it.
3116        box = self.build_panel("FLEET DOCTRINE", width=self.grid_width())
3117        self._subtitle = self.add_line(box, "", dim=True)
3118        # Directly under the balance, because this is the screen a player
3119        # reaches holding nought and is quoted fifteen for the cheapest node.
3120        # Wrapped to the grid rather than laid out as one row: a column grows to
3121        # fit its widest child, and one long generated sentence is enough to
3122        # push the whole panel out through the side of a small window.
3123        for line in wrap_prose(cores_source_line(self.profile()), MENU_LINE_FONT_PX, self.grid_width()):
3124            self.add_line(box, line, dim=True)
3125
3126        width, height = self.content_size()
3127        self._canvas = box.add_child(DrawCanvas(name="Tree", size_x=width, size_y=height))
3128
3129        for _ in range(2):
3130            line = self.add_line(box, "")
3131            line.font_size = DOCTRINE_DETAIL_FONT_PX
3132            self._detail.append(line)
3133        self._prereq = self.add_line(box, "", dim=True)
3134        self._prereq.font_size = DOCTRINE_DETAIL_FONT_PX
3135        state = self.add_line(box, "")
3136        state.font_size = DOCTRINE_DETAIL_FONT_PX
3137        self._detail.append(state)
3138
3139        for action in self.ACTIONS:
3140            row = box.add_child(Button(action, size_x=self.grid_width(), size_y=MENU_ROW_HEIGHT_PX))
3141            self._action_rows.append(row)
3142        self._status_row = self.add_line(box, "", dim=True)
3143        self.add_line(box, DOCTRINE_LEGEND_LINE, dim=True)
3144        self._pan_row = self.add_line(box, "", dim=True)
3145        self.add_line(box, DOCTRINE_CONTROLS_LINE, dim=True)
3146        self.settle_panel()
3147        # Only now is the canvas standing at the rect it will keep, which is
3148        # the rect the tree is drawn against.
3149        self._draw_tree()
3150
3151    def on_update(self, dt: float):
3152        if self._leaving:
3153            return
3154        if Input.is_action_just_pressed(PAUSE_ACTION):
3155            self.back()
3156            return
3157
3158        if Input.is_action_just_pressed("thrust_down") or Input.is_action_just_pressed(MENU_DOWN_ACTION):
3159            self.move_cursor(1)
3160        elif Input.is_action_just_pressed("thrust_up") or Input.is_action_just_pressed(MENU_UP_ACTION):
3161            self.move_cursor(-1)
3162        if Input.is_action_just_pressed("thrust_right"):
3163            self.move_branch(1)
3164        elif Input.is_action_just_pressed("thrust_left"):
3165            self.move_branch(-1)
3166
3167        under, moved = self._pointed_at()
3168        if under is not None and moved:
3169            self.place_cursor(*under)
3170        pressed, _ = self._commit.poll(dt)
3171        confirmed, _ = self._confirm.poll(dt)
3172        clicked, _ = self._click.poll(dt)
3173        if clicked and under is not None:
3174            # A click commits what the pointer is over, whatever the keyboard
3175            # cursor was doing: the mouse does not have to move first.
3176            self.place_cursor(*under)
3177        if pressed or confirmed or (clicked and under is not None):
3178            self.commit()
3179        if self._leaving:
3180            return
3181        self._draw_tree()
3182        self._refresh()
3183
3184    # -- pointing ----------------------------------------------------------
3185
3186    def _pointed_at(self) -> tuple[tuple[int, int] | None, bool]:
3187        """What the pointer is over, and whether it has moved since last frame.
3188
3189        Only a pointer that moved takes the cursor: a mouse left resting over a
3190        node must not fight the keyboard for it. A click commits what the
3191        pointer is over either way. A node half off the edge of a panned view
3192        answers only over the part of it that is actually drawn.
3193        """
3194        position = Input.mouse_position
3195        point = (float(position.x), float(position.y))
3196        moved = self._pointer is not None and point != self._pointer
3197        self._pointer = point
3198        canvas = self._canvas
3199        if canvas is not None and _rect_holds(canvas.get_global_rect(), point):
3200            for (branch_index, slot), plate in self._plates.items():
3201                if _rect_holds(plate.get_global_rect(), point):
3202                    return (branch_index, slot), moved
3203        for index, row in enumerate(self._action_rows):
3204            if _rect_holds(row.get_global_rect(), point):
3205                return (self.branch_index, self.slots + index), moved
3206        return None, moved
3207
3208    # -- drawing -----------------------------------------------------------
3209
3210    def draw_scale(self) -> float:
3211        """What the tree is drawn at: the canvas's own scale, with a floor.
3212
3213        The panel has already fitted the whole column to the window, and the
3214        canvas came out of that fit at some fraction of the size it was
3215        authored at. Down to :data:`DOCTRINE_MIN_DRAW_SCALE` the drawing simply
3216        takes that fraction. Past it the drawing keeps the floor and hangs out
3217        of its own canvas, which is what the pan is for.
3218        """
3219        canvas = self._canvas
3220        if canvas is None:
3221            return 1.0
3222        _, _, view_w, view_h = (float(value) for value in canvas.get_global_rect())
3223        width, height = self.content_size()
3224        fitted = min(view_w / max(1.0, width), view_h / max(1.0, height))
3225        return max(min(1.0, fitted), DOCTRINE_MIN_DRAW_SCALE)
3226
3227    def panning(self) -> bool:
3228        """Whether the drawing is larger than the canvas it is drawn on."""
3229        canvas = self._canvas
3230        if canvas is None:
3231            return False
3232        _, _, view_w, view_h = (float(value) for value in canvas.get_global_rect())
3233        scale = self.draw_scale()
3234        width, height = self.content_size()
3235        return width * scale > view_w + 1.0 or height * scale > view_h + 1.0
3236
3237    def _pan_for_cursor(self, scale: float, view_w: float, view_h: float) -> tuple[float, float]:
3238        """Where the drawing has to sit for the focused node to be on screen."""
3239        width, height = (value * scale for value in self.content_size())
3240        x, y, node_w, node_h = self.node_box(self.branch_index, self.slot_index)
3241        pan_x = self._slide(self._pan[0], x * scale, (x + node_w) * scale, view_w, width)
3242        pan_y = self._slide(self._pan[1], y * scale, (y + node_h) * scale, view_h, height)
3243        return pan_x, pan_y
3244
3245    @staticmethod
3246    def _slide(pan: float, near: float, far: float, view: float, content: float) -> float:
3247        """One axis of the pan: the least movement that shows ``near..far``."""
3248        if content <= view:
3249            return 0.0
3250        if -pan > near:
3251            pan = -near
3252        if -pan + view < far:
3253            pan = view - far
3254        return min(0.0, max(view - content, pan))
3255
3256    def _draw_tree(self) -> None:
3257        """Place the branches, the connectors and the nodes on the canvas.
3258
3259        Called once on mount and again only when the canvas has resized or the
3260        pan has moved, because it destroys everything it drew last time: a
3261        diagram is rebuilt rather than slid, for the same reason a menu column
3262        is placed once and never moves.
3263        """
3264        canvas = self._canvas
3265        if canvas is None:
3266            return
3267        _, _, view_w, view_h = (float(value) for value in canvas.get_global_rect())
3268        if view_w <= 1.0 or view_h <= 1.0:
3269            return
3270        scale = self.draw_scale()
3271        pan = self._pan_for_cursor(scale, view_w, view_h)
3272        drawn = (view_w, view_h, pan[0], pan[1])
3273        if drawn == self._drawn:
3274            return
3275        self._pan = pan
3276        self._drawn = drawn
3277        canvas.clear()
3278        self._cells.clear()
3279        self._plates.clear()
3280        self._links.clear()
3281        self._headings.clear()
3282
3283        font = self.cell_font()
3284        heading_font = font * DOCTRINE_HEADING_FONT_MULT
3285        branch_w = (self.lanes * DOCTRINE_NODE_WIDTH_PX + (self.lanes - 1) * DOCTRINE_LANE_GAP_PX) * scale
3286        for branch_index, branch_id in enumerate(self.branch_ids):
3287            x = self.branch_origin(branch_index) * scale + pan[0]
3288            heading = self._text(
3289                BRANCHES[branch_id].title.upper(),
3290                x,
3291                pan[1],
3292                branch_w,
3293                DOCTRINE_HEADING_HEIGHT_PX * scale,
3294                heading_font,
3295                MENU_TITLE_COLOUR,
3296            )
3297            heading.alignment = "center"
3298            self._headings.append(heading)
3299            self._draw_links(branch_index, scale, pan)
3300            self._draw_nodes(branch_index, scale, pan, font, view_w, view_h)
3301        self._refresh()
3302
3303    def _draw_links(self, branch_index: int, scale: float, pan: tuple[float, float]) -> None:
3304        """A line from every node to the node that unlocks it."""
3305        nodes = branch_nodes(self.branch_ids[branch_index])
3306        slot_of = {node.id: slot for slot, node in enumerate(nodes)}
3307        thickness = max(1.0, DOCTRINE_LINK_WIDTH_PX * scale)
3308        for slot, node in enumerate(nodes):
3309            x, y, width, _height = (value * scale for value in self.node_box(branch_index, slot))
3310            head_x, head_y = x + width / 2.0 + pan[0], y + pan[1]
3311            for required in node.requires:
3312                px, py, pw, ph = (value * scale for value in self.node_box(branch_index, slot_of[required]))
3313                foot_x, foot_y = px + pw / 2.0 + pan[0], py + ph + pan[1]
3314                middle = (foot_y + head_y) / 2.0
3315                self._link(foot_x - thickness / 2.0, foot_y, thickness, middle - foot_y, required)
3316                if abs(head_x - foot_x) > thickness:
3317                    left, right = min(head_x, foot_x), max(head_x, foot_x)
3318                    self._link(left, middle - thickness / 2.0, right - left, thickness, required)
3319                self._link(head_x - thickness / 2.0, middle, thickness, head_y - middle, required)
3320
3321    def _draw_nodes(
3322        self, branch_index: int, scale: float, pan: tuple[float, float], font: float, view_w: float, view_h: float
3323    ) -> None:
3324        """One plate and one line of type per node, where the branch puts it."""
3325        for slot in range(self.slots):
3326            x, y, width, height = (value * scale for value in self.node_box(branch_index, slot))
3327            x, y = x + pan[0], y + pan[1]
3328            if x < -0.5 or y < -0.5 or x + width > view_w + 0.5 or y + height > view_h + 0.5:
3329                # Off the canvas, or only half on it. Not drawn at all: a
3330                # control outside its parent's rect is still a control the
3331                # pointer can find, and half a node hanging over the edge is a
3332                # plate painted across whatever the canvas was standing on.
3333                # The pan always brings the focused node wholly inside first,
3334                # so nothing the cursor can reach is dropped by this.
3335                continue
3336            plate = self._place(Panel(), x, y, width, height)
3337            plate.bg_colour = DOCTRINE_NODE_OPEN_FILL
3338            plate.border_colour = DOCTRINE_NODE_EDGE_COLOUR
3339            plate.border_width = max(1.0, DOCTRINE_BORDER_PX * scale)
3340            pad = DOCTRINE_NODE_PAD_PX * scale
3341            # One label, two lines: the engine stacks an embedded newline on a
3342            # 1.2 pitch and centres the block, so the caption sits on the plate
3343            # at any scale without a second control to keep in step. The label
3344            # is taller than the plate and centred on it, per DOCTRINE_CELL_ROOM_PX.
3345            room = height + DOCTRINE_CELL_ROOM_PX * scale
3346            label = self._text(
3347                "", x + pad, y - (room - height) / 2.0, max(1.0, width - 2.0 * pad), room, font, MENU_TEXT_COLOUR
3348            )
3349            self._plates[(branch_index, slot)] = plate
3350            self._cells[(branch_index, slot)] = label
3351
3352    def _link(self, x: float, y: float, width: float, height: float, required: str) -> None:
3353        """One segment of a connector, remembered by what lights it."""
3354        if width <= 0.0 or height <= 0.0:
3355            return
3356        panel = self._place(Panel(), x, y, width, height)
3357        panel.bg_colour = DOCTRINE_LINK_COLOUR
3358        self._links.append((panel, required))
3359
3360    def _place(self, control: Control, x: float, y: float, width: float, height: float) -> Control:
3361        """Mount *control* on the canvas at a rect it keeps for its whole life."""
3362        control.margin_left, control.margin_top = float(x), float(y)
3363        control.size_x, control.size_y = float(width), float(height)
3364        return self._canvas.add_child(control)
3365
3366    def _text(self, text: str, x: float, y: float, width: float, height: float, font: float, colour=None) -> Label:
3367        """One placed line of type, never smaller than the engine will draw."""
3368        label = Label(text, size_x=width, size_y=height)
3369        self._place(label, x, y, width, height)
3370        label.font_size = _refit_font(font)
3371        label.text_colour = Colour.coerce(colour or MENU_TEXT_COLOUR, name="doctrine")
3372        return label
3373
3374    def _refresh(self) -> None:
3375        meta = self.meta_profile()
3376        if self._subtitle is not None:
3377            _set_text(
3378                self._subtitle,
3379                DOCTRINE_SUBTITLE.format(
3380                    cores=meta.cores(),
3381                    spent=meta.spent_cores(),
3382                    active=len(meta.active_keystones()),
3383                    max_active=balance.DOCTRINE_MAX_ACTIVE_KEYSTONES,
3384                    owned=len(meta.owned_keystones()),
3385                ),
3386            )
3387        chars = self.cell_chars()
3388        for (branch_index, slot), cell in self._cells.items():
3389            node = self.node_at(branch_index, slot)
3390            state = self.node_state(node)
3391            on_cursor = branch_index == self.branch_index and self.cursor == slot
3392            marks = (DOCTRINE_MARK_CURSOR if on_cursor else " ") + DOCTRINE_STATE_MARKS[state]
3393            head, tail = self.cell_lines(node, chars)
3394            # The second line is indented past the marks, so it sits under the
3395            # title rather than under the state mark that owns the first, and
3396            # it gives that indent back rather than the price when it is full.
3397            _set_text(cell, f"{marks} {head}\n{' ' * _cell_indent(tail, chars)}{tail}")
3398            cell.text_colour = Colour.coerce(DOCTRINE_STATE_COLOURS[state], name="node")
3399            plate = self._plates[(branch_index, slot)]
3400            plate.bg_colour = DOCTRINE_STATE_FILLS[state]
3401            if on_cursor:
3402                plate.border_colour = DOCTRINE_CURSOR_COLOUR
3403            elif node.kind == "keystone":
3404                plate.border_colour = DOCTRINE_KEYSTONE_COLOUR
3405            else:
3406                plate.border_colour = DOCTRINE_NODE_EDGE_COLOUR
3407        for panel, required in self._links:
3408            panel.bg_colour = DOCTRINE_LINK_LIT_COLOUR if meta.is_owned(required) else DOCTRINE_LINK_COLOUR
3409
3410        node = self.selected_node()
3411        blocker = meta.purchase_blocker(node.id)
3412        branch = BRANCHES[self.branch_ids[self.branch_index]]
3413        if self._detail:
3414            _set_text(self._detail[0], f"{node.title} ({branch.title}, {node.kind}, {node.cost} Cores)")
3415            _set_text(self._detail[1], node.description)
3416            if meta.is_owned(node.id):
3417                state = DOCTRINE_STATE_OWNED
3418            elif blocker is None:
3419                state = DOCTRINE_STATE_BUYABLE.format(cost=node.cost)
3420            elif meta.feat_met(node.feat) and any(not meta.is_owned(required) for required in node.requires):
3421                state = DOCTRINE_STATE_PRICED.format(cost=node.cost, cores=meta.cores())
3422            else:
3423                state = blocker.upper()
3424            _set_text(self._detail[2], state)
3425        if self._prereq is not None:
3426            names = ", ".join(DOCTRINE_TREE[required].title for required in node.requires)
3427            _set_text(self._prereq, DOCTRINE_REQUIRES_LINE.format(names=names) if names else DOCTRINE_REQUIRES_NONE)
3428        for index, row in enumerate(self._action_rows):
3429            on_cursor = self.cursor == self.slots + index
3430            _set_text(row, f"{DOCTRINE_MARK_CURSOR if on_cursor else ' '} {self.ACTIONS[index]}")
3431        if self._status_row is not None:
3432            _set_text(self._status_row, self.status)
3433        if self._pan_row is not None:
3434            _set_text(self._pan_row, DOCTRINE_PAN_NOTE if self.panning() else "")
3435
3436
3437class PauseScene(Control):
3438    """The in-run pause overlay: resume, settings, or abandon the run.
3439
3440    An overlay rather than a root scene, despite the name the review gave it:
3441    the run has to still be standing behind it for Resume to mean anything, so
3442    this is a child of :class:`RunScene` that holds the tree still, exactly the
3443    way :class:`~shrike.chart.StarChart` does. It runs on ``UpdateMode.ALWAYS``
3444    for the same reason the chart does: the screen that owns the pause is the
3445    only one that can lift it.
3446
3447    Escape raises it and Escape lifts it. Nothing here ends the run except
3448    Abandon Run, which is worded as what it is and settles the hold at the
3449    death rate on the ledger, so backing out of the game can no longer cost a
3450    sector jump the player never asked for.
3451    """
3452
3453    update_mode = Property(
3454        UpdateMode.ALWAYS,
3455        hint="Processing behaviour while the tree is paused",
3456        on_change="_invalidate_update_mode_cache",
3457    )
3458
3459    def __init__(self, run: RunScene, **kwargs):
3460        kwargs.setdefault("name", "Pause")
3461        super().__init__(**kwargs)
3462        self.run = run
3463        self.menu: MenuList | None = None
3464        self._open = False
3465        self._box: VBoxContainer | None = None
3466        self._backdrop: Panel | None = None
3467        self._fit_rows: list[Label] = []
3468        #: The chart's update mode while this is not holding it shut.
3469        self._chart_update_mode: UpdateMode | None = None
3470
3471    def on_ready(self):
3472        self.set_anchor_preset(AnchorPreset.FULL_RECT)
3473        self._build()
3474        self.visible = False
3475
3476    def _build(self) -> None:
3477        """Mount the panel for this pause, rows and fit panel together.
3478
3479        Rebuilt on every open rather than topped up: the fit changes between
3480        pauses, and a panel that grows after its rows are placed moves every
3481        one of them out from under its own retained draw.
3482        """
3483        if self._backdrop is not None:
3484            self._backdrop.destroy()
3485        self._fit_rows = []
3486        box = build_menu_panel(self, "PAUSED", "the run is holding its breath")
3487        self._backdrop = menu_panel_of(self).parent
3488        self.menu = MenuList(box)
3489        self.menu.add("RESUME", self.close)
3490        self.menu.add("SETTINGS", self.open_settings)
3491        self.menu.add("ABANDON RUN", self.abandon)
3492        add_menu_line(box, MENU_CONTROLS_LINE_BACK, dim=True)
3493        self._box = box
3494        self._show_fit()
3495        settle_menu_panel(self)
3496
3497    def resource_line(self) -> str:
3498        """The HUD's resource strip, as one line, or empty when there is none.
3499
3500        Read off the HUD rather than off the tanks: the paused panel and the
3501        flying HUD must be two views of one reading, never two readings.
3502        """
3503        hud = self.run.hud
3504        cells = hud.resource_lines() if hud is not None else ()
3505        return PAUSE_RESOURCE_SEPARATOR.join(cells)
3506
3507    def _show_fit(self) -> None:
3508        """Mirror the dock's FIT panel, read-only, under the menu rows.
3509
3510        The pause is the one place a pilot can see what is bolted to their
3511        ship without a bay in the sector, so the same three blocks the dock
3512        prints are written here on every open: the fit changes between pauses
3513        and the block lengths change with it.
3514
3515        The hold, the tank and the air go above it, because the pause is also
3516        where a pilot works out whether the next jump is affordable, and the
3517        panel used to itemise every bolt on the hull without ever saying how
3518        much scrap was in it.
3519        """
3520        if self._box is None:
3521            return
3522        run = self.run
3523        resources = self.resource_line()
3524        if resources:
3525            label = add_menu_line(self._box, resources)
3526            label.font_size = DEPOT_ROW_FONT_PX
3527            self._fit_rows.append(label)
3528        for line in fit_panel_lines(run.rack, run.sockets, run.stowed_weapons, run.stowed_modules):
3529            label = add_menu_line(self._box, line, dim=True)
3530            label.font_size = DEPOT_ROW_FONT_PX
3531            self._fit_rows.append(label)
3532
3533    @property
3534    def is_open(self) -> bool:
3535        return self._open
3536
3537    # -- opening and closing ----------------------------------------------
3538
3539    def open(self) -> None:
3540        """Raise the overlay and halt the simulation behind it."""
3541        if self._open:
3542            return
3543        self._open = True
3544        self.visible = True
3545        self._build()
3546        if self.menu is not None:
3547            self.menu.reset()
3548        chart = self.run.chart_screen
3549        if chart is not None:
3550            # The chart also runs while paused and also answers to a key. It
3551            # must not act on M from underneath this, so it is shut for the
3552            # duration and put back exactly as it was found: run one leaves it
3553            # disabled and a resume has to leave it disabled too.
3554            self._chart_update_mode = chart.update_mode
3555            chart.update_mode = UpdateMode.DISABLED
3556        if self.tree is not None:
3557            self.tree.paused = True
3558
3559    def close(self) -> None:
3560        """Drop the overlay and let the run breathe again."""
3561        if not self._open:
3562            return
3563        self._open = False
3564        self.visible = False
3565        chart = self.run.chart_screen
3566        if chart is not None and self._chart_update_mode is not None:
3567            chart.update_mode = self._chart_update_mode
3568        self._chart_update_mode = None
3569        if self.tree is not None:
3570            self.tree.paused = False
3571
3572    def toggle(self) -> None:
3573        self.close() if self._open else self.open()
3574
3575    # -- entries -----------------------------------------------------------
3576
3577    def open_settings(self) -> None:
3578        """Park the run in its suspend slot and open the options screen.
3579
3580        The run cannot survive a ``change_scene``, so the way back to it is the
3581        slot the menu already resumes from: settings, then Back, then Resume
3582        Run puts the pilot down where they left off with the hold intact.
3583        """
3584        self.close()
3585        self.run.suspend()
3586        # Backing out of settings lands on the front door, and the row the
3587        # pilot wants there is the one that puts them back in the run.
3588        self.run.leave_for(SettingsScene(back_entry=MENU_ENTRY_RESUME))
3589
3590    def abandon(self) -> None:
3591        """End the run deliberately. The ledger prices the hold as a death."""
3592        self.close()
3593        self.run.end_run("death", killed_by="abandoned")
3594
3595    # -- frame -------------------------------------------------------------
3596
3597    def on_update(self, dt: float):
3598        chart = self.run.chart_screen
3599        shop = self.run.depot_screen
3600        refit = self.run.refit_screen
3601        if Input.is_action_just_pressed(PAUSE_ACTION):
3602            if not self._open and refit is not None and refit.is_open:
3603                # The refit panel backs out to the shelf it was raised from,
3604                # and closes itself on the same press: nothing else may read it.
3605                return
3606            if not self._open and shop is not None and shop.is_open:
3607                # Escape backs out of whatever is up, and the dock screen is.
3608                # Backing out of a bay means leaving it, not abandoning the
3609                # pilot inside a shop with nothing on the screen.
3610                shop.undock()
3611                return
3612            if not self._open and chart is not None and chart.is_open:
3613                # Escape backs out of whatever is up, and the chart is up.
3614                chart.close()
3615                return
3616            self.toggle()
3617            return
3618        if self._open and self.menu is not None:
3619            self.menu.poll(dt)
3620
3621
3622class DepotScene(Control):
3623    """The dock screen: the shelf as a grid of cards, the offer, and the way out.
3624
3625    Docking used to open nothing. The bay had six items and a pity slot rolled
3626    against the build being flown, a reroll that doubles, and a standing offer
3627    on the hold, and a pilot could reach exactly one of those three by holding
3628    a button and hoping. Then it opened a list, which was better and still not
3629    a shop: every item was one sentence a hundred and fifty characters long,
3630    carrying a name, a category, a price, the mount it would take, its
3631    mechanical summary and its state, in that order, on one line. The price sat
3632    in the middle of the line and the reason a thing could not be bought sat at
3633    the end of it, past everything else.
3634
3635    So the shelf is a grid of cards, two across. A card answers the three
3636    questions in the order they are asked: what is this and what does it cost,
3637    what does it do, and can I have it. The third line is never blank -- it is
3638    a verb, a shortfall in scrap, or the hull's own refusal -- and a card the
3639    hold cannot pay for is drawn on a darker plate in dimmer type as well as
3640    saying how short it is, because a grey card alone only informs a player who
3641    already knows what the greying means.
3642
3643    A purchase says what it changed rather than what it cost. The hull is read
3644    before and after the payment, so a gun that went onto a bare hardpoint, a
3645    module that went into a socket and a gun that went into the locker are
3646    three different sentences; and the locker case names the refit row, which
3647    grows a label of its own until the thing is mounted. That is the handoff
3648    the shelf owes: a weapon that is bought and stowed does nothing at all
3649    until somebody mounts it, and the bay is the only place it can be mounted.
3650
3651    Under everything sits the FIT panel, the full statement of what is bolted
3652    where, so a card promising a mount can be checked in place.
3653
3654    An overlay rather than a scene, like :class:`PauseScene`: the run has to be
3655    standing behind it for undocking to put the pilot back where they were. It
3656    holds the tree still and drives the depot's own interact ladder by hand
3657    while it does, so the three-second sell hold the world spent the run
3658    teaching keeps working with the screen up; a tap of the same key is the
3659    screen's confirm, and the bay is left through its own row or through
3660    Escape.
3661    """
3662
3663    # It runs while it holds the tree still, for the same reason the chart does:
3664    # the screen that owns the pause is the only one that can lift it.
3665    update_mode = Property(
3666        UpdateMode.ALWAYS,
3667        hint="Processing behaviour while the tree is paused",
3668        on_change="_invalidate_update_mode_cache",
3669    )
3670
3671    #: The rows under the shelf, in the order they are drawn.
3672    ACTION_REFIT = "refit"
3673    ACTION_SELL = "sell"
3674    ACTION_REROLL = "reroll"
3675    ACTION_UNDOCK = "undock"
3676    ACTIONS: tuple[str, ...] = (ACTION_REFIT, ACTION_SELL, ACTION_REROLL, ACTION_UNDOCK)
3677
3678    def __init__(self, run: RunScene, **kwargs):
3679        kwargs.setdefault("name", "DepotScreen")
3680        super().__init__(**kwargs)
3681        self.run = run
3682        # It reads the pointer itself and never eats a shot while it is closed.
3683        self.mouse_filter = False
3684        self.depot: Depot | None = None
3685        #: The Doctrine Kit choice, above the shelf until the profile has picked.
3686        self.kits: list[str] = []
3687        self.items: list[dict] = []
3688        self.cursor = 0
3689        self.status = ""
3690
3691        self._open = False
3692        self._rebuild = False
3693        self._backdrop: Panel | None = None
3694        self._subtitle: Label | None = None
3695        self._detail_row: Label | None = None
3696        self._status_row: Label | None = None
3697        self._kit_rows: list[Button] = []
3698        #: One plate per shelf card, and the three lines written on it.
3699        self._item_rows: list[Panel] = []
3700        self._item_lines: list[tuple[Label, Label, Label]] = []
3701        self._shelf: DrawCanvas | None = None
3702        self._action_rows: list[Button] = []
3703        self._fit_rows: list[Label] = []
3704        #: How many characters a card's line holds, at the size it came out.
3705        self._card_chars = DEPOT_ROW_CHARS // DEPOT_GRID_COLUMNS
3706        #: Whether this window had the room to print the FIT panel at all.
3707        self._fit_visible = True
3708        #: Whether a purchase this visit has put something in the locker, which
3709        #: is what turns the refit row into an instruction rather than a door.
3710        self._refit_waiting = False
3711        self._commit = PressLatch("interact")
3712        self._click = PressLatch("fire_primary")
3713        self._confirm = PressLatch(MENU_CONFIRM_ACTION)
3714        self._pointer: tuple[float, float] | None = None
3715        self._banked_mark = 0.0
3716        #: The chart's update mode while this is not holding it shut.
3717        self._chart_update_mode: UpdateMode | None = None
3718
3719    def on_ready(self):
3720        self.set_anchor_preset(AnchorPreset.FULL_RECT)
3721        self.visible = False
3722
3723    @property
3724    def is_open(self) -> bool:
3725        return self._open
3726
3727    # -- opening and closing ----------------------------------------------
3728
3729    def open(self, depot: Depot) -> None:
3730        """Show *depot*'s shelf and hold the run still behind it."""
3731        if self._open:
3732            return
3733        self.depot = depot
3734        self.kits = self.run.kit_offers()
3735        self.items = self.shelf()
3736        self.cursor = 0
3737        self.status = ""
3738        self._refit_waiting = False
3739        self._open = True
3740        self._rebuild = True
3741        self.visible = True
3742        # Primed now, so the press that docked cannot also buy row one.
3743        self.reprime()
3744        economy = self.run.economy
3745        self._banked_mark = float(getattr(economy, "cores_banked_this_run", 0.0)) if economy else 0.0
3746        chart = self.run.chart_screen
3747        if chart is not None:
3748            # The chart also runs while paused and also answers to a key. Left
3749            # live it would open over the shelf and, worse, lift the pause on
3750            # its way out and set the run flying behind a screen still up. It
3751            # is shut for the duration and put back exactly as it was found.
3752            self._chart_update_mode = chart.update_mode
3753            chart.update_mode = UpdateMode.DISABLED
3754        if self.tree is not None:
3755            self.tree.paused = True
3756
3757    def reprime(self) -> None:
3758        """Re-arm the press latches to whatever is held right now.
3759
3760        Called on every open, and again whenever the refit panel hands the bay
3761        back: the key that closed the panel is still down on the frame the
3762        shelf resumes reading it, and unprimed it would buy row one.
3763        """
3764        self._commit = PressLatch("interact")
3765        self._click = PressLatch("fire_primary")
3766        self._confirm = PressLatch(MENU_CONFIRM_ACTION)
3767        self._pointer = None
3768
3769    def close(self) -> None:
3770        """Drop the screen and let the run breathe again."""
3771        if not self._open:
3772            return
3773        refit = self.run.refit_screen
3774        if refit is not None:
3775            refit.close()
3776        self._open = False
3777        self.visible = False
3778        self.depot = None
3779        self.kits = []
3780        self.items = []
3781        chart = self.run.chart_screen
3782        if chart is not None and self._chart_update_mode is not None:
3783            chart.update_mode = self._chart_update_mode
3784        self._chart_update_mode = None
3785        if self._backdrop is not None:
3786            self._backdrop.destroy()
3787            self._backdrop = None
3788        self._kit_rows.clear()
3789        self._item_rows.clear()
3790        self._item_lines.clear()
3791        self._action_rows.clear()
3792        self._fit_rows.clear()
3793        self._shelf = None
3794        self._subtitle = self._status_row = self._detail_row = None
3795        if self.tree is not None:
3796            self.tree.paused = False
3797
3798    def undock(self) -> None:
3799        """Close the bay, which closes this screen through the depot's signal."""
3800        depot = self.depot
3801        if depot is not None:
3802            depot.undock()
3803        self.close()
3804
3805    # -- the shelf ---------------------------------------------------------
3806
3807    def shelf(self) -> list[dict]:
3808        """Roll this visit's stock. Only opening the bay and rerolling do this."""
3809        depot = self.depot
3810        if depot is None:
3811            return []
3812        return self._ordered(depot.stock_for(self.run.build_profile()))
3813
3814    def _ordered(self, items: list[dict]) -> list[dict]:
3815        """The running-out supply first, and otherwise the shelf as it rolled.
3816
3817        Prominence rather than charity. Both supplies are always on the shelf
3818        (``trading.GUARANTEED_CONSUMABLES``) and the depot already puts them at
3819        the head of it; this only moves the one whose clock is actually running
3820        down to the very top, and the row carries a mark to say which clock.
3821        Air outranks fuel when both are short: a tank at zero strands a run and
3822        an empty lung ends it.
3823        """
3824        urgent = [DEPOT_FUEL_ITEM_ID] if self.low_fuel() else []
3825        if self.low_air():
3826            urgent = [DEPOT_O2_ITEM_ID, *urgent]
3827        if not urgent:
3828            return list(items)
3829        rank = {item_id: index for index, item_id in enumerate(urgent)}
3830        return sorted(items, key=lambda item: rank.get(str(item.get("id", "")), len(rank)))
3831
3832    def _resync(self) -> None:
3833        """Re-read what the visit has sold without re-rolling what it stocks.
3834
3835        A shelf is read while it is being bought from, and asking the depot for
3836        stock again would roll a fresh one against a build that has just
3837        changed: the card under the cursor would become a different item between
3838        the press and the release. The entries stay; only their state moves.
3839        """
3840        depot = self.depot
3841        if depot is None:
3842            return
3843        self.items = self._ordered([depot.priced(item) for item in self.items])
3844        self._sync_fit()
3845
3846    def fit_lines(self) -> list[str]:
3847        """The FIT panel: every hardpoint, every socket, and the stowage."""
3848        run = self.run
3849        return fit_panel_lines(run.rack, run.sockets, run.stowed_weapons, run.stowed_modules)
3850
3851    def _sync_fit(self) -> None:
3852        """Rebuild when the FIT block's line count has moved under the shelf.
3853
3854        A purchase that stows and a refit made next door both change what the
3855        FIT panel lists, and a panel is a fixed set of controls, so a changed
3856        count means mounting a fresh one. The cursor is clamped rather than
3857        reset: the cards above the fit block have not moved.
3858        """
3859        self.cursor = min(self.cursor, max(0, self.rows - 1))
3860        if self._fit_visible and len(self.fit_lines()) != len(self._fit_rows):
3861            self._rebuild = True
3862
3863    def air_seconds(self) -> float:
3864        """Seconds of air left at the hull's current drain."""
3865        power = self.run.power
3866        if power is None:
3867            return float("inf")
3868        breaches = int(getattr(self.run.ship, "open_breaches", 0) or 0)
3869        drain = balance.O2_DRAIN_PER_S + breaches * balance.O2_DRAIN_PER_BREACH_PER_S
3870        return float(power.o2) / drain if drain > 0.0 else float("inf")
3871
3872    def low_air(self) -> bool:
3873        """Whether the life-support clock is inside its warning window."""
3874        return self.air_seconds() <= balance.O2_LOW_WARNING_SECONDS
3875
3876    def jumps_left(self) -> float:
3877        """Ordinary jumps the tank still covers, at ``balance.WARP_FUEL_BASE``.
3878
3879        The base fare rather than the cheapest route on the chart: a skip or a
3880        jump made with the hunter in the sector costs more, so a tank measured
3881        against the base is the optimistic reading, and the bay warns before
3882        even that runs out. It is also the number the pilot can check, since
3883        the base fare is what the chart quotes for a hop to the next column.
3884        """
3885        power = self.run.power
3886        if power is None:
3887            return float("inf")
3888        return float(power.fuel) / max(1e-6, float(balance.WARP_FUEL_BASE))
3889
3890    def low_fuel(self) -> bool:
3891        """Whether the tank is down to :data:`DEPOT_LOW_FUEL_JUMPS` or fewer."""
3892        return self.jumps_left() <= DEPOT_LOW_FUEL_JUMPS
3893
3894    def scrap(self) -> float:
3895        economy = self.run.economy
3896        return float(getattr(economy, "scrap", 0.0)) if economy is not None else 0.0
3897
3898    # -- moving ------------------------------------------------------------
3899
3900    @property
3901    def rows(self) -> int:
3902        return len(self.kits) + len(self.items) + len(self.ACTIONS)
3903
3904    def move_cursor(self, step: int) -> None:
3905        """Step the cursor one place through the whole screen, in reading order.
3906
3907        Reading order and not grid order, even though the shelf is a grid: the
3908        cards run left to right and then down, and one key walking every kit,
3909        every card and every action row in that order is the shape a menu has
3910        everywhere else in the game. A grid's second axis is :meth:`move_row`.
3911        """
3912        self.cursor = (self.cursor + int(step)) % max(1, self.rows)
3913
3914    def move_row(self, step: int) -> None:
3915        """Jump a whole card row, which is what left and right do on the shelf.
3916
3917        Only the shelf has a second axis, so this does nothing on a kit row or
3918        an action row, and it clamps rather than wrapping: a pilot pressing D
3919        on the bottom card wants the bottom card, not the top of the shelf.
3920        """
3921        first = len(self.kits)
3922        index = self.cursor - first
3923        if not 0 <= index < len(self.items):
3924            return
3925        moved = index + int(step) * DEPOT_GRID_COLUMNS
3926        if 0 <= moved < len(self.items):
3927            self.cursor = first + moved
3928
3929    def selected_kit(self) -> str | None:
3930        """The Doctrine Kit row under the cursor, None while it is elsewhere."""
3931        return self.kits[self.cursor] if self.cursor < len(self.kits) else None
3932
3933    def selected_item(self) -> dict | None:
3934        """The shelf card under the cursor, None while it is on any other row."""
3935        index = self.cursor - len(self.kits)
3936        return self.items[index] if 0 <= index < len(self.items) else None
3937
3938    def selected_action(self) -> str:
3939        """The action row under the cursor, empty while it is on the shelf."""
3940        index = self.cursor - len(self.kits) - len(self.items)
3941        if index < 0:
3942            return ""
3943        return self.ACTIONS[index]
3944
3945    # -- spending ----------------------------------------------------------
3946
3947    def take_kit(self, index: int) -> bool:
3948        """Take the Doctrine Kit row at *index*. False when the pick is spent.
3949
3950        The delivery and the once-per-profile record both belong to the run;
3951        this screen only drops the rows and says what came aboard.
3952        """
3953        if not (0 <= index < len(self.kits)):
3954            return False
3955        kit_id = self.kits[index]
3956        if not self.run.grant_kit(kit_id):
3957            self.kits = self.run.kit_offers()
3958            self._rebuild = True
3959            return False
3960        self.status = KIT_TAKEN.format(name=KIT_NAMES[kit_id], effects=KIT_EFFECTS[kit_id].upper())
3961        self.kits = self.run.kit_offers()
3962        self.cursor = 0
3963        self._rebuild = True
3964        self._resync()
3965        return True
3966
3967    def buy(self, index: int) -> bool:
3968        """Buy the shelf card at *index*. False, and a reason, when it refuses."""
3969        depot = self.depot
3970        if depot is None or not (0 <= index < len(self.items)):
3971            return False
3972        item = self.items[index]
3973        name = _item_name(str(item.get("id", "")))
3974        if item.get("sold"):
3975            self.status = DEPOT_BUY_SOLD.format(name=name)
3976            return False
3977        price = float(item.get("price", 0.0))
3978        if self.scrap() + 1e-9 < price:
3979            self.status = DEPOT_BUY_SHORT.format(name=name)
3980            return False
3981        before = self.fit_snapshot()
3982        if not depot.buy(item):
3983            # The purchase gate refused before payment and named its reason on
3984            # the toast line; echo it here so the screen is not silent.
3985            hud = self.run.hud
3986            self.status = str(getattr(hud, "toast", "") or "") or DEPOT_BUY_SOLD.format(name=name)
3987            return False
3988        self.status = self.purchase_report(item, price, before, self.fit_snapshot())
3989        self._resync()
3990        return True
3991
3992    def fit_snapshot(self) -> tuple[tuple[str, ...], tuple[str, ...], int]:
3993        """What is bolted where and how full the locker is, right now.
3994
3995        Taken either side of a payment so the receipt can name the change
3996        rather than the price. Reading the racks is the only honest way to do
3997        it: the delivery runs through the run scene's own mutators off a
3998        signal, and the shelf is not told which of them fired.
3999        """
4000        run = self.run
4001        rack, sockets = run.rack, run.sockets
4002        guns = tuple(
4003            str(weapon.spec.id) if weapon is not None else ""
4004            for weapon in (rack.hardpoints if rack is not None else ())
4005        )
4006        modules: tuple[str, ...] = ()
4007        if sockets is not None:
4008            modules = tuple(str(sockets.module_id_at(index) or "") for index in range(len(sockets.sockets)))
4009        return guns, modules, len(run.stowed_weapons) + len(run.stowed_modules)
4010
4011    def purchase_report(self, item: dict, price: float, before: tuple, after: tuple) -> str:
4012        """What the purchase actually did to the hull, in one sentence."""
4013        item_id = str(item.get("id", ""))
4014        name = _item_name(item_id)
4015        guns_before, modules_before, locker_before = before
4016        guns_after, modules_after, locker_after = after
4017        for index, (was, now) in enumerate(zip(guns_before, guns_after, strict=False)):
4018            if now != was and now == item_id:
4019                return DEPOT_BUY_MOUNTED.format(name=name, price=price, hardpoint=index + 1)
4020        sockets = self.run.sockets
4021        for index, (was, now) in enumerate(zip(modules_before, modules_after, strict=False)):
4022            if now != was and now == item_id:
4023                label = str(sockets.sockets[index].label).upper() if sockets is not None else str(index + 1)
4024                return DEPOT_BUY_INSTALLED.format(name=name, price=price, slot=REFIT_SOCKET_LABEL.format(label=label))
4025        if locker_after > locker_before:
4026            self._refit_waiting = True
4027            return DEPOT_BUY_STOWED.format(name=name, price=price)
4028        effect = CONSUMABLE_EFFECTS.get(item_id, "")
4029        if effect:
4030            return DEPOT_BUY_APPLIED.format(name=name, price=price, effect=effect.upper())
4031        return DEPOT_BUY_DONE.format(name=name, price=price)
4032
4033    def open_refit(self) -> bool:
4034        """Raise the refit panel over the shelf. False when there is none."""
4035        refit = self.run.refit_screen
4036        if refit is None or self.depot is None:
4037            return False
4038        refit.open(self.depot)
4039        return True
4040
4041    def refit_closed(self) -> None:
4042        """Take the bay back from the refit panel.
4043
4044        The fit has moved, so the shelf's promises ("mounts hardpoint 2") and
4045        the FIT block are both stale, and the key that shut the panel is still
4046        down: re-read the one, re-prime the other. Whatever was waiting for a
4047        mount has had its chance, so the refit row goes back to being a door.
4048        """
4049        self.reprime()
4050        self._refit_waiting = False
4051        self._resync()
4052        self._refresh()
4053
4054    def sell(self) -> float:
4055        """Sell the whole hold at the post's rate. Returns Cores banked.
4056
4057        A sale that pays says so through :meth:`_banked_since`, which is the
4058        one path: the same hold can also be sold by holding the interact key,
4059        and a screen that only announced its own row would go quiet for the
4060        verb the world actually taught.
4061        """
4062        depot = self.depot
4063        if depot is None:
4064            return 0.0
4065        cores = depot.sell_hold()
4066        if cores <= 0.0:
4067            self.status = DEPOT_SOLD_NOTHING
4068        return cores
4069
4070    def _banked_since(self) -> None:
4071        """Announce any Cores banked at this bay, whoever asked for them."""
4072        economy = self.run.economy
4073        if economy is None:
4074            return
4075        banked = float(economy.cores_banked_this_run)
4076        if banked > self._banked_mark + 1e-9:
4077            self.status = DEPOT_SOLD_HOLD.format(cores=banked - self._banked_mark)
4078        self._banked_mark = banked
4079
4080    def reroll(self) -> bool:
4081        """Buy a fresh shelf. False, and a reason, when the hold is short."""
4082        depot = self.depot
4083        if depot is None:
4084            return False
4085        if not depot.reroll():
4086            self.status = DEPOT_REROLL_REFUSED
4087            return False
4088        self.items = self.shelf()
4089        self.cursor = min(self.cursor, max(0, self.rows - 1))
4090        self.status = DEPOT_REROLLED.format(price=depot.reroll_price())
4091        self._rebuild = True
4092        return True
4093
4094    def commit(self) -> bool:
4095        """Run whatever the cursor is on."""
4096        action = self.selected_action()
4097        if not action:
4098            if self.cursor < len(self.kits):
4099                return self.take_kit(self.cursor)
4100            return self.buy(self.cursor - len(self.kits))
4101        if action == self.ACTION_REFIT:
4102            return self.open_refit()
4103        if action == self.ACTION_SELL:
4104            self.sell()
4105            return True
4106        if action == self.ACTION_REROLL:
4107            return self.reroll()
4108        self.undock()
4109        return True
4110
4111    # -- frame -------------------------------------------------------------
4112
4113    def on_update(self, dt: float):
4114        if not self._open:
4115            return
4116        refit = self.run.refit_screen
4117        if refit is not None and refit.is_open:
4118            # The refit panel is up. It owns every key and the pointer while it
4119            # is, including the interact key the bay's sell hold shares, so the
4120            # shelf reads nothing at all until it is handed back.
4121            return
4122        depot = self.depot
4123        if depot is None or not depot.is_docked:
4124            self.close()
4125            return
4126        # The bay is paused under this screen, so its ladder is driven by hand.
4127        # Taps belong to the screen; the sell hold still belongs to the bay.
4128        depot.tick_interact(dt, taps_toggle_bay=False)
4129        if not depot.is_docked:
4130            self.close()
4131            return
4132        hud = self.run.hud
4133        if hud is not None:
4134            # The run is paused underneath, so nobody else is pushing the verb
4135            # line and whatever it last said would stand frozen under the
4136            # panel. The screen owns every verb while it is up.
4137            hud.set_affordance("")
4138        if self._rebuild:
4139            self._build()
4140
4141        if Input.is_action_just_pressed("thrust_down") or Input.is_action_just_pressed(MENU_DOWN_ACTION):
4142            self.move_cursor(1)
4143        elif Input.is_action_just_pressed("thrust_up") or Input.is_action_just_pressed(MENU_UP_ACTION):
4144            self.move_cursor(-1)
4145        if Input.is_action_just_pressed("thrust_right"):
4146            self.move_row(1)
4147        elif Input.is_action_just_pressed("thrust_left"):
4148            self.move_row(-1)
4149
4150        under, moved = self._pointed_at()
4151        if under is not None and moved:
4152            self.cursor = under
4153        _, released = self._commit.poll(dt)
4154        confirmed, _ = self._confirm.poll(dt)
4155        clicked, _ = self._click.poll(dt)
4156        if clicked and under is not None:
4157            self.cursor = under
4158        # A tap of interact confirms; a long press of the same key belongs to
4159        # the bay's sell rung and must not also buy whatever the cursor is on.
4160        tapped = released and self._commit.hold_s < INTERACT_SELL_HOLD_S
4161        if tapped or confirmed or (clicked and under is not None):
4162            self.commit()
4163        if not self._open:
4164            return
4165        self._banked_since()
4166        self._refresh()
4167
4168    def _pointed_at(self) -> tuple[int | None, bool]:
4169        """The row or card under the pointer, and whether the pointer has moved."""
4170        position = Input.mouse_position
4171        point = (float(position.x), float(position.y))
4172        moved = self._pointer is not None and point != self._pointer
4173        self._pointer = point
4174        for index, row in enumerate([*self._kit_rows, *self._item_rows, *self._action_rows]):
4175            if _rect_holds(row.get_global_rect(), point):
4176                return index, moved
4177        return None, moved
4178
4179    # -- drawing -----------------------------------------------------------
4180
4181    def _build(self) -> None:
4182        """Mount the panel for this visit's shelf."""
4183        self._rebuild = False
4184        if self._backdrop is not None:
4185            self._backdrop.destroy()
4186        self._kit_rows.clear()
4187        self._item_rows.clear()
4188        self._item_lines.clear()
4189        self._action_rows.clear()
4190        self._fit_rows.clear()
4191
4192        title = BROKER_TITLE if isinstance(self.depot, Broker) else DEPOT_TITLE
4193        # A column lays its children out at its own width, so the shelf's width
4194        # has to reach the column and not only the panel around it.
4195        box = build_menu_panel(self, title, width=DEPOT_ROW_WIDTH_PX)
4196        # Rows sit in the column, the column in the panel, the panel on the
4197        # backdrop: the backdrop is what has to go when the bay closes.
4198        panel = menu_panel_of(self)
4199        # The shop's own plate, opaque and dark: see DEPOT_PANEL_FILL. Every
4200        # other screen keeps the shared one, which is over a nebula rather than
4201        # over a lit barge four metres off the bow.
4202        panel.bg_colour = DEPOT_PANEL_FILL
4203        self._backdrop = panel.parent
4204        self._subtitle = add_menu_line(box, "", dim=True)
4205        # The Doctrine Kit pick stands above the shelf: it is free exactly once,
4206        # so it outranks everything the bay wants paying for.
4207        for _ in self.kits:
4208            row = box.add_child(Button("", size_x=DEPOT_ROW_WIDTH_PX, size_y=DEPOT_ROW_HEIGHT_PX))
4209            row.font_size = DEPOT_ROW_FONT_PX
4210            self._kit_rows.append(row)
4211        self._shelf = box.add_child(DrawCanvas(name="Shelf", size_x=DEPOT_ROW_WIDTH_PX, size_y=self.shelf_height()))
4212        # The one line under the grid: the focused card's whole row, including
4213        # everything three short lines had to leave out.
4214        self._detail_row = add_menu_line(box, "", dim=True)
4215        self._detail_row.font_size = DEPOT_ROW_FONT_PX
4216        for _ in self.ACTIONS:
4217            row = box.add_child(Button("", size_x=DEPOT_ROW_WIDTH_PX, size_y=MENU_ROW_HEIGHT_PX))
4218            row.font_size = DEPOT_ROW_FONT_PX
4219            self._action_rows.append(row)
4220        self._status_row = add_menu_line(box, "", dim=True)
4221        # The FIT panel under everything spendable: what is bolted where, so a
4222        # card promising "hardpoint 2" or "bow socket" can be checked in place.
4223        self._fit_visible = self.shows_fit_panel()
4224        lines = self.fit_lines() if self._fit_visible else [DEPOT_FIT_ELSEWHERE]
4225        for line in lines:
4226            label = add_menu_line(box, ellipsise(line, DEPOT_ROW_CHARS), dim=True)
4227            label.font_size = DEPOT_ROW_FONT_PX
4228            if self._fit_visible:
4229                self._fit_rows.append(label)
4230        legend = add_menu_line(box, DEPOT_CONTROLS_LINE, dim=True)
4231        legend.font_size = DEPOT_ROW_FONT_PX
4232        settle_menu_panel(self)
4233        # Only now is the shelf canvas standing at the rect it will keep.
4234        self._draw_cards()
4235        self._refresh()
4236
4237    def shelf_rows(self) -> int:
4238        """How many card rows this visit's shelf comes to."""
4239        return max(1, -(-len(self.items) // DEPOT_GRID_COLUMNS))
4240
4241    def shelf_height(self) -> float:
4242        """The grid's authored height, which is what the column reserves for it."""
4243        rows = self.shelf_rows()
4244        return rows * DEPOT_CARD_HEIGHT_PX + (rows - 1) * DEPOT_CARD_GAP_PX
4245
4246    def _draw_cards(self) -> None:
4247        """Place one plate and three lines per shelf item, once, on the canvas."""
4248        canvas = self._shelf
4249        if canvas is None:
4250            return
4251        _, _, view_w, view_h = (float(value) for value in canvas.get_global_rect())
4252        if view_w <= 1.0 or view_h <= 1.0:
4253            return
4254        # The panel fits width and height by separate factors, so the card
4255        # keeps its own proportions on each axis rather than one of them.
4256        wide = view_w / DEPOT_ROW_WIDTH_PX
4257        tall = view_h / max(1.0, self.shelf_height())
4258        gap_x, gap_y = DEPOT_CARD_GAP_PX * wide, DEPOT_CARD_GAP_PX * tall
4259        card_w = (view_w - (DEPOT_GRID_COLUMNS - 1) * gap_x) / DEPOT_GRID_COLUMNS
4260        card_h = DEPOT_CARD_HEIGHT_PX * tall
4261        line_h = DEPOT_CARD_LINE_PX * tall
4262        pad = DEPOT_CARD_PAD_PX * min(wide, tall)
4263        head_font = DEPOT_CARD_HEAD_FONT_PX * tall
4264        body_font = DEPOT_CARD_BODY_FONT_PX * tall
4265        self._card_chars = _row_chars(max(1.0, card_w - 2.0 * pad), body_font)
4266        for index in range(len(self.items)):
4267            column, row = index % DEPOT_GRID_COLUMNS, index // DEPOT_GRID_COLUMNS
4268            x, y = column * (card_w + gap_x), row * (card_h + gap_y)
4269            plate = self._place(canvas, Panel(), x, y, card_w, card_h)
4270            plate.bg_colour = DEPOT_CARD_FILL
4271            plate.border_colour = DEPOT_CARD_EDGE_COLOUR
4272            plate.border_width = DEPOT_CARD_BORDER_PX * min(wide, tall)
4273            width = max(1.0, card_w - 2.0 * pad)
4274            head = self._card_line(canvas, x + pad, y + pad, width, line_h, head_font)
4275            body = self._card_line(canvas, x + pad, y + pad + line_h, width, line_h, body_font)
4276            foot = self._card_line(canvas, x + pad, y + pad + 2.0 * line_h, width, line_h, body_font)
4277            self._item_rows.append(plate)
4278            self._item_lines.append((head, body, foot))
4279
4280    def _place(self, parent: Control, control: Control, x: float, y: float, width: float, height: float) -> Control:
4281        """Mount *control* on *parent* at a rect it keeps for its whole life."""
4282        control.margin_left, control.margin_top = float(x), float(y)
4283        control.size_x, control.size_y = float(width), float(height)
4284        return parent.add_child(control)
4285
4286    def _card_line(self, parent: Control, x: float, y: float, width: float, height: float, font: float) -> Label:
4287        """One of a card's three lines, never smaller than the engine will draw."""
4288        label = Label("", size_x=width, size_y=height)
4289        self._place(parent, label, x, y, width, height)
4290        label.font_size = _refit_font(font)
4291        label.text_colour = Colour.coerce(MENU_TEXT_COLOUR, name="card")
4292        return label
4293
4294    def shows_fit_panel(self) -> bool:
4295        """Whether this window is tall enough for the shelf and the fit block."""
4296        _, _, _, height = self.get_global_rect()
4297        return float(height) >= DEPOT_FIT_PANEL_MIN_WINDOW_PX
4298
4299    def subtitle_text(self) -> str:
4300        """The line under the title: the hold, and the two survival clocks.
4301
4302        A clock is only named while it is running out, and the sentence that
4303        names it is followed by the sentence that answers it: the shelf below
4304        carries both supplies, so the only bad news left to print is a ration
4305        this visit has already spent.
4306        """
4307        parts = [DEPOT_HOLD_SUBTITLE.format(scrap=self.scrap())]
4308        if self.low_air():
4309            parts.append(DEPOT_LOW_AIR_SUBTITLE.format(seconds=self.air_seconds()))
4310            if not self.stocks(DEPOT_O2_ITEM_ID):
4311                parts.append(DEPOT_NO_CANISTER)
4312        if self.low_fuel():
4313            parts.append(DEPOT_LOW_FUEL_SUBTITLE.format(jumps=self.jumps_left()))
4314            if not self.stocks(DEPOT_FUEL_ITEM_ID):
4315                parts.append(DEPOT_NO_FUEL_CELL)
4316        return ", ".join(parts)
4317
4318    def stocks(self, item_id: str) -> bool:
4319        """Whether the shelf still has an unsold *item_id* on it to buy."""
4320        return any(str(item.get("id", "")) == item_id and not item.get("sold") for item in self.items)
4321
4322    def supply_flag(self, item_id: str) -> str:
4323        """The mark a supply row carries while its own clock is running down."""
4324        if item_id == DEPOT_O2_ITEM_ID and self.low_air():
4325            return f"{DEPOT_MARK_AIR} "
4326        if item_id == DEPOT_FUEL_ITEM_ID and self.low_fuel():
4327            return f"{DEPOT_MARK_FUEL} "
4328        return ""
4329
4330    def kit_row_text(self, index: int) -> str:
4331        """One Doctrine Kit row: the name, that it is free, and what it seeds."""
4332        kit_id = self.kits[index]
4333        mark = DOCTRINE_MARK_CURSOR if self.cursor == index else " "
4334        head = f"{mark} {KIT_ROW.format(name=KIT_NAMES[kit_id], effects='')}"
4335        return head + ellipsise(KIT_EFFECTS[kit_id], DEPOT_ROW_CHARS - len(head))
4336
4337    def item_row_text(self, index: int) -> str:
4338        """One shelf item as a single line: name, category, price, effect, state.
4339
4340        What a card says over three lines, joined back into one. The card is
4341        what a pilot reads; this is what the line under the grid prints for
4342        whatever the cursor is on, and it is the form that fits everything in,
4343        so it is also the form anything scripted reads.
4344
4345        The effect is the only part that may be cut. The name, the price and
4346        the state mark are what the item is read for, and a line long enough to
4347        need cutting is long because its effect is, never because its price is.
4348        """
4349        item = self.items[index]
4350        item_id = str(item.get("id", ""))
4351        price = float(item.get("price", 0.0))
4352        mark = DOCTRINE_MARK_CURSOR if self.cursor == len(self.kits) + index else " "
4353        flag = self.supply_flag(item_id)
4354        head = f"{mark} {flag}{_item_name(item_id)} [{category_label(item)}] {price:.0f} scrap: "
4355        short = price - self.scrap()
4356        if item.get("sold"):
4357            tail = f", {DEPOT_MARK_SOLD}"
4358        elif short > 1e-9:
4359            tail = f", {DEPOT_MARK_SHORT.format(short=short)}"
4360        else:
4361            tail = ""
4362        effect = ellipsise(effect_line(item), DEPOT_ROW_CHARS - len(head) - len(tail))
4363        return f"{head}{effect}{tail}"
4364
4365    def item_card_lines(self, index: int) -> tuple[str, str, str]:
4366        """One card's three lines: what it is, what it does, and can I have it.
4367
4368        The third is never empty. A card that said nothing where the answer
4369        goes would be indistinguishable from one whose answer is yes, and the
4370        answer is the reason the card is being read.
4371        """
4372        item = self.items[index]
4373        item_id = str(item.get("id", ""))
4374        price = float(item.get("price", 0.0))
4375        mark = f"{DOCTRINE_MARK_CURSOR} " if self.cursor == len(self.kits) + index else "  "
4376        flag = self.supply_flag(item_id)
4377        head = DEPOT_CARD_HEAD.format(
4378            mark=mark, flag=flag, name=_item_name(item_id), price=price, category=category_label(item)
4379        )
4380        return head, self.item_card_effect(index), self.item_card_state(index)
4381
4382    def item_card_effect(self, index: int) -> str:
4383        """The card's middle line: what the thing does, whoever can use it.
4384
4385        The shelf's own one-liner collapses to the refusal for something the
4386        hull has no use for, which is right for a single line and wrong for a
4387        card: the card has a line for the refusal already, and printing it
4388        twice tells a pilot what they cannot do without ever saying what they
4389        were being offered.
4390        """
4391        item = self.items[index]
4392        item_id = str(item.get("id", ""))
4393        effect = effect_line(item)
4394        if effect and effect.upper() == self.item_card_state(index):
4395            return CONSUMABLE_EFFECTS.get(item_id, "") or item_summary(item_id) or effect
4396        return effect
4397
4398    def item_card_state(self, index: int) -> str:
4399        """The card's foot: the verb, the shortfall, or the hull's own refusal."""
4400        item = self.items[index]
4401        price = float(item.get("price", 0.0))
4402        if item.get("sold"):
4403            return DEPOT_CARD_SOLD
4404        short = price - self.scrap()
4405        if short > 1e-9:
4406            return DEPOT_CARD_SHORT.format(short=short)
4407        if item.get("fit_blocked") and item.get("fit"):
4408            return str(item["fit"]).upper()
4409        return DEPOT_CARD_BUY.format(price=price)
4410
4411    def action_row_text(self, action: str) -> str:
4412        """One action row, priced the same way the shelf is."""
4413        depot = self.depot
4414        if action == self.ACTION_REFIT:
4415            stowed = len(self.run.stowed_weapons) + len(self.run.stowed_modules)
4416            if self._refit_waiting and stowed:
4417                return DEPOT_REFIT_ROW_WAITING.format(stowed=stowed)
4418            return DEPOT_REFIT_ROW.format(stowed=stowed)
4419        if action == self.ACTION_SELL:
4420            scrap, cores, rate = depot.sell_quote() if depot is not None else (0.0, 0.0, 0.0)
4421            if scrap <= 0.0:
4422                return DEPOT_SELL_EMPTY_ROW
4423            return DEPOT_SELL_ROW.format(scrap=scrap, cores=cores, rate=rate)
4424        if action == self.ACTION_REROLL:
4425            return DEPOT_REROLL_ROW.format(price=depot.reroll_price() if depot is not None else 0)
4426        return DEPOT_UNDOCK_ROW
4427
4428    def _refresh(self) -> None:
4429        if self._subtitle is not None:
4430            _set_text(self._subtitle, self.subtitle_text())
4431        for index, row in enumerate(self._kit_rows):
4432            if index >= len(self.kits):
4433                continue
4434            _set_text(row, self.kit_row_text(index))
4435            row.text_colour = Colour.coerce(MENU_TEXT_COLOUR, name="kit")
4436        for index, plate in enumerate(self._item_rows):
4437            if index >= len(self.items) or index >= len(self._item_lines):
4438                continue
4439            self._paint_card(index, plate, self._item_lines[index])
4440        if self._detail_row is not None:
4441            index = self.cursor - len(self.kits)
4442            detail = self.item_row_text(index) if 0 <= index < len(self.items) else ""
4443            _set_text(self._detail_row, ellipsise(detail, DEPOT_ROW_CHARS))
4444        for index, action in enumerate(self.ACTIONS):
4445            if index >= len(self._action_rows):
4446                continue
4447            offset = len(self.kits) + len(self.items)
4448            on_cursor = self.cursor == offset + index
4449            mark = DOCTRINE_MARK_CURSOR if on_cursor else " "
4450            text = ellipsise(self.action_row_text(action), DEPOT_ROW_CHARS - 2)
4451            row = self._action_rows[index]
4452            _set_text(row, f"{mark} {text}")
4453            waiting = action == self.ACTION_REFIT and self._refit_waiting
4454            colour = MENU_SELECTED_COLOUR if on_cursor or waiting else MENU_TEXT_COLOUR
4455            row.text_colour = Colour.coerce(colour, name="action")
4456        if self._status_row is not None:
4457            _set_text(self._status_row, ellipsise(self.status, DEPOT_ROW_CHARS))
4458        for label, line in zip(self._fit_rows, self.fit_lines(), strict=False):
4459            _set_text(label, ellipsise(line, DEPOT_ROW_CHARS))
4460
4461    def _paint_card(self, index: int, plate: Panel, lines: tuple[Label, Label, Label]) -> None:
4462        """Write and colour one card. Nothing on it ever moves."""
4463        item = self.items[index]
4464        focused = self.cursor == len(self.kits) + index
4465        sold = bool(item.get("sold"))
4466        short = not sold and float(item.get("price", 0.0)) > self.scrap() + 1e-9
4467        refused = not sold and not short and bool(item.get("fit_blocked"))
4468        head_text, body_text, foot_text = self.item_card_lines(index)
4469        chars = self._card_chars
4470        _set_text(lines[0], ellipsise(head_text, chars))
4471        _set_text(lines[1], ellipsise(body_text, chars))
4472        _set_text(lines[2], ellipsise(foot_text, chars))
4473        if sold:
4474            plate.bg_colour = DEPOT_CARD_SOLD_FILL
4475            head_colour, foot_colour = DOCTRINE_BLOCKED_COLOUR, DEPOT_CARD_SOLD_COLOUR
4476        elif short:
4477            plate.bg_colour = DEPOT_CARD_SHORT_FILL
4478            head_colour, foot_colour = MENU_DIM_COLOUR, DEPOT_CARD_SHORT_COLOUR
4479        else:
4480            plate.bg_colour = DEPOT_CARD_FILL
4481            head_colour = MENU_SELECTED_COLOUR if focused else MENU_TEXT_COLOUR
4482            foot_colour = DEPOT_CARD_SHORT_COLOUR if refused else DEPOT_CARD_BUY_COLOUR
4483        plate.border_colour = DEPOT_CARD_FOCUS_COLOUR if focused else DEPOT_CARD_EDGE_COLOUR
4484        lines[0].text_colour = Colour.coerce(head_colour, name="card_head")
4485        lines[1].text_colour = Colour.coerce(DEPOT_CARD_BODY_COLOUR, name="card_body")
4486        lines[2].text_colour = Colour.coerce(foot_colour, name="card_foot")
4487
4488
4489@dataclass(frozen=True)
4490class RefitSlot:
4491    """One mount on the hull, as the refit panel needs to draw and name it.
4492
4493    A hardpoint and a socket are different things to every other module in the
4494    game and the same thing to a pilot changing a fit: a place on the hull with
4495    something bolted to it. This is that shared shape, built from the two racks
4496    and never stored anywhere: the racks stay authoritative.
4497
4498    :attr:`x` and :attr:`z` are hull units in the ship's own frame, nose along
4499    -Z and starboard along +X, which is what puts the drawn mount where the
4500    real one is.
4501    """
4502
4503    kind: str
4504    index: int
4505    label: str
4506    tag: str
4507    x: float
4508    z: float
4509    size: str = ""
4510
4511    #: The two kinds, spelled once.
4512    HARDPOINT = "hardpoint"
4513    SOCKET = "socket"
4514
4515
4516def _socket_tag(label: str) -> str:
4517    """A socket's label as a mount tag: "starboard quarter" to "S.QUAR"."""
4518    words = str(label).split()
4519    if not words:
4520        return "SOCKET"
4521    if len(words) == 1:
4522        return words[0][:5].upper()
4523    return f"{words[0][0].upper()}.{words[-1][:4].upper()}"
4524
4525
4526def refit_slots(rack, sockets) -> list[RefitSlot]:
4527    """Every mount on the hull, fore to aft, whatever kind it is.
4528
4529    Sorted by position rather than by rack, because the panel draws them on a
4530    plan and the cursor walks them down it: a list that ran the hardpoints and
4531    then the sockets would move the cursor about the hull at random.
4532    """
4533    slots: list[RefitSlot] = []
4534    for index in range(len(getattr(rack, "hardpoints", ()))):
4535        x, z = HARDPOINT_OFFSETS[index % len(HARDPOINT_OFFSETS)]
4536        slots.append(
4537            RefitSlot(RefitSlot.HARDPOINT, index, REFIT_HARDPOINT_LABEL.format(index=index + 1), f"H{index + 1}", x, z)
4538        )
4539    for socket in getattr(sockets, "sockets", ()):
4540        slots.append(
4541            RefitSlot(
4542                RefitSlot.SOCKET,
4543                socket.index,
4544                REFIT_SOCKET_LABEL.format(label=socket.label.upper()),
4545                _socket_tag(socket.label),
4546                float(socket.offset.x),
4547                float(socket.offset.y),
4548                str(socket.size),
4549            )
4550        )
4551    return sorted(slots, key=lambda slot: (slot.z, slot.x))
4552
4553
4554class RefitScene(Control):
4555    """The hull drawn as a hull, and the locker beside it.
4556
4557    The bay used to change a fit through a block of sentences under the shelf,
4558    one per stowed weapon per hardpoint. It worked and it read as nothing: the
4559    rows said "MOUNT AUTOCANNON ON HARDPOINT 2" without ever saying where
4560    hardpoint 2 was or what else the hull carried, four of them said the same
4561    four words, and a module could not be moved at all once it was bolted in.
4562
4563    So this screen is a plan of the ship. Every mount is a box where it really
4564    sits, carrying the short name of what is on it or EMPTY; the locker is the
4565    column beside it; and a refit is picking one of each, in either order. What
4566    the pick *would* do is printed before the press, in the same sentence that
4567    reports it afterwards, so a swap that costs a gun says which gun while it
4568    can still be reconsidered.
4569
4570    Every control is placed once, from the window rect, and never moves again:
4571    the locker is allocated for every item that could ever be in it (what is in
4572    it now, plus one per mount, which is what unmounting everything would come
4573    to), so mounting a gun rewrites text and never reflows a column. That is the
4574    engine's retained-draw contract taken seriously rather than worked around.
4575
4576    An overlay over the dock screen, which holds the tree still for it. The bay
4577    keeps the pause; this panel only takes the keys while it is up.
4578    """
4579
4580    # It runs while the bay holds the tree still, so it must not be paused with
4581    # everything else.
4582    update_mode = Property(
4583        UpdateMode.ALWAYS,
4584        hint="Processing behaviour while the tree is paused",
4585        on_change="_invalidate_update_mode_cache",
4586    )
4587
4588    #: The columns the focus moves between, and the rows under both.
4589    COLUMN_HULL = "hull"
4590    COLUMN_LOCKER = "locker"
4591    ACTION_UNMOUNT = "unmount"
4592    ACTION_DONE = "done"
4593    ACTIONS: tuple[str, ...] = (ACTION_UNMOUNT, ACTION_DONE)
4594
4595    def __init__(self, run: RunScene, **kwargs):
4596        kwargs.setdefault("name", "RefitPanel")
4597        super().__init__(**kwargs)
4598        self.run = run
4599        # It reads the pointer itself.
4600        self.mouse_filter = False
4601        self.depot: Depot | None = None
4602        #: Every mount on the hull, fore to aft. Rebuilt on open, never during.
4603        self.slots: list[RefitSlot] = []
4604        #: What the pilot is looking at: ``(column, index)`` into the mounts,
4605        #: the locker rows or the action rows.
4606        self.focus: tuple[str, int] = (self.COLUMN_HULL, 0)
4607        #: What is armed and waiting for its other half, or None.
4608        self.armed: tuple[str, int] | None = None
4609        self.status = ""
4610
4611        self._open = False
4612        self._backdrop: Panel | None = None
4613        self._slot_boxes: list[Panel] = []
4614        self._slot_labels: list[Label] = []
4615        self._locker_rows: list[Panel] = []
4616        self._locker_labels: list[Label] = []
4617        self._action_rows: list[Panel] = []
4618        self._action_labels: list[Label] = []
4619        self._preview: list[Label] = []
4620        self._status_row: Label | None = None
4621        self._locker_heading: Label | None = None
4622        #: How wide each block's text may be, in characters, and what the
4623        #: preview line is wrapped against.
4624        self._slot_chars: list[int] = []
4625        self._locker_chars = 40
4626        self._preview_chars = 60
4627        self._preview_width = REFIT_BASE_WIDTH_PX
4628        self._preview_font = REFIT_ROW_FONT_PX
4629        self._window: tuple[float, float, float, float] | None = None
4630        self._commit = PressLatch("interact")
4631        self._confirm = PressLatch(MENU_CONFIRM_ACTION)
4632        self._click = PressLatch("fire_primary")
4633        self._back = PressLatch(PAUSE_ACTION)
4634        self._pointer: tuple[float, float] | None = None
4635
4636    def on_ready(self):
4637        self.set_anchor_preset(AnchorPreset.FULL_RECT)
4638        self.visible = False
4639
4640    @property
4641    def is_open(self) -> bool:
4642        return self._open
4643
4644    # -- opening and closing ----------------------------------------------
4645
4646    def open(self, depot: Depot) -> None:
4647        """Raise the panel over *depot*'s shelf."""
4648        if self._open:
4649            return
4650        self.depot = depot
4651        self.slots = refit_slots(self.run.rack, self.run.sockets)
4652        self.focus = (self.COLUMN_HULL, 0) if self.slots else (self.COLUMN_LOCKER, 0)
4653        self.armed = None
4654        self.status = ""
4655        self._open = True
4656        self.visible = True
4657        # Primed on the press that opened the panel, so it cannot also mount.
4658        self._commit = PressLatch("interact")
4659        self._confirm = PressLatch(MENU_CONFIRM_ACTION)
4660        self._click = PressLatch("fire_primary")
4661        self._back = PressLatch(PAUSE_ACTION)
4662        self._pointer = None
4663        self._build()
4664
4665    def close(self) -> None:
4666        """Drop the panel and hand the bay back to the shelf."""
4667        if not self._open:
4668            return
4669        self._open = False
4670        self.visible = False
4671        self.depot = None
4672        if self._backdrop is not None:
4673            self._backdrop.destroy()
4674            self._backdrop = None
4675        self._slot_boxes.clear()
4676        self._slot_labels.clear()
4677        self._locker_rows.clear()
4678        self._locker_labels.clear()
4679        self._action_rows.clear()
4680        self._action_labels.clear()
4681        self._preview.clear()
4682        self._status_row = self._locker_heading = None
4683        self._window = None
4684        shop = self.run.depot_screen
4685        if shop is not None and shop.is_open:
4686            shop.refit_closed()
4687
4688    # -- what is on the hull and in the locker ------------------------------
4689
4690    def occupant(self, slot: RefitSlot) -> str:
4691        """The catalogue id bolted to *slot*, empty for a bare mount."""
4692        if slot.kind == RefitSlot.HARDPOINT:
4693            rack = self.run.rack
4694            fitted = rack.hardpoints[slot.index] if rack is not None else None
4695            return str(fitted.spec.id) if fitted is not None else ""
4696        sockets = self.run.sockets
4697        return str(sockets.module_id_at(slot.index) or "") if sockets is not None else ""
4698
4699    def locker(self) -> list[dict]:
4700        """Everything waiting for a mount: stowed guns, then loose modules."""
4701        run = self.run
4702        rows = [{"id": weapon_id, "kind": "weapon"} for weapon_id in run.stowed_weapons]
4703        rows += [{"id": module_id, "kind": "module"} for module_id in run.stowed_modules]
4704        return rows
4705
4706    def selected_slot(self) -> RefitSlot | None:
4707        """The mount the pilot has armed, None while nothing is armed."""
4708        if self.armed is None or self.armed[0] != self.COLUMN_HULL:
4709            return None
4710        index = self.armed[1]
4711        return self.slots[index] if 0 <= index < len(self.slots) else None
4712
4713    def selected_item(self) -> dict | None:
4714        """The locker row the pilot has armed, None while nothing is armed."""
4715        if self.armed is None or self.armed[0] != self.COLUMN_LOCKER:
4716            return None
4717        rows = self.locker()
4718        index = self.armed[1]
4719        return rows[index] if 0 <= index < len(rows) else None
4720
4721    # -- moving ------------------------------------------------------------
4722
4723    def entries(self) -> list[tuple[str, int]]:
4724        """Every focusable thing, in the order W and S walk them."""
4725        rows = [(self.COLUMN_HULL, index) for index in range(len(self.slots))]
4726        rows += [(self.COLUMN_LOCKER, index) for index in range(len(self.locker()))]
4727        rows += [(action, index) for index, action in enumerate(self.ACTIONS)]
4728        return rows
4729
4730    def move_focus(self, step: int) -> None:
4731        """Step the focus through the mounts, the locker and the action rows."""
4732        entries = self.entries()
4733        if not entries:
4734            return
4735        try:
4736            at = entries.index(self.focus)
4737        except ValueError:
4738            at = 0
4739            step = 0
4740        self.focus = entries[(at + int(step)) % len(entries)]
4741
4742    def move_column(self) -> None:
4743        """Jump between the hull and the locker, keeping the row where it fits.
4744
4745        One key for both directions: there are two columns, so "sideways" has
4746        only one meaning, and A and D both mean it.
4747        """
4748        column, index = self.focus
4749        rows = self.locker()
4750        if column == self.COLUMN_HULL:
4751            if rows:
4752                self.focus = (self.COLUMN_LOCKER, min(index, len(rows) - 1))
4753        elif self.slots:
4754            self.focus = (self.COLUMN_HULL, min(index, len(self.slots) - 1))
4755
4756    def place_focus(self, entry: tuple[str, int]) -> None:
4757        """Put the focus exactly here, which is what the pointer does."""
4758        if entry in self.entries():
4759            self.focus = entry
4760
4761    # -- what a mount would do ---------------------------------------------
4762
4763    def fit_refusal(self, item: dict, slot: RefitSlot) -> str:
4764        """Why *item* will not go on *slot*, empty when it will.
4765
4766        The rules are the racks' own; what is written here is only their
4767        wording, and it is written before the press rather than after it.
4768        """
4769        item_id = str(item.get("id", ""))
4770        name = _item_name(item_id)
4771        if str(item.get("kind", "")) == "weapon":
4772            if slot.kind == RefitSlot.SOCKET:
4773                return REFIT_REFUSE_GUN_IN_SOCKET.format(name=name, slot=slot.label)
4774            return ""
4775        if slot.kind == RefitSlot.HARDPOINT:
4776            return REFIT_REFUSE_MODULE_ON_HARDPOINT.format(name=name, slot=slot.label)
4777        sockets = self.run.sockets
4778        if sockets is None or item_id not in MODULE_CATALOGUE:
4779            return REFIT_REFUSED
4780        spec = module_spec(item_id)
4781        size = str(spec["socket_size"])
4782        if not sockets.sockets[slot.index].accepts(size):
4783            return REFIT_REFUSE_TOO_LARGE.format(
4784                name=name, size=size.upper(), slot=slot.label, socket_size=slot.size.upper()
4785            )
4786        if str(spec["family"]) == "turret":
4787            # The rack caps turrets, and a socket that already holds one is a
4788            # straight exchange rather than a fifth turret.
4789            standing = len(sockets.turrets())
4790            if self.occupant(slot) in MODULE_CATALOGUE and str(module_spec(self.occupant(slot))["family"]) == "turret":
4791                standing -= 1
4792            if standing >= balance.AUTO_TURRETS_MAX:
4793                return REFIT_REFUSE_TURRETS_FULL.format(name=name, count=standing + 1, maximum=balance.AUTO_TURRETS_MAX)
4794        return ""
4795
4796    def focused_pair(self) -> tuple[dict | None, RefitSlot | None]:
4797        """The refit the focus is currently describing: what, and where.
4798
4799        One of the two comes from the armed pick and the other from wherever
4800        the focus is standing, in whichever order they were chosen, which is
4801        what makes the panel work from either column.
4802        """
4803        column, index = self.focus
4804        item, slot = self.selected_item(), self.selected_slot()
4805        if column == self.COLUMN_HULL and 0 <= index < len(self.slots):
4806            slot = self.slots[index]
4807        rows = self.locker()
4808        if column == self.COLUMN_LOCKER and 0 <= index < len(rows):
4809            item = rows[index]
4810        return item, slot
4811
4812    def refusal(self) -> str:
4813        """Why the focused refit will not happen, empty when it will."""
4814        item, slot = self.focused_pair()
4815        if item is None or slot is None or self.focus[0] in self.ACTIONS:
4816            return ""
4817        return self.fit_refusal(item, slot)
4818
4819    def preview(self) -> str:
4820        """What confirming right now would do, said before it is done.
4821
4822        The whole panel turns on this line. Reading it with a mount focused and
4823        nothing armed describes the mount; with a thing armed it describes the
4824        refit that thing would make *here*, including the gun it would cost.
4825        """
4826        column, _index = self.focus
4827        if column == self.ACTION_UNMOUNT:
4828            slot = self.selected_slot()
4829            if slot is None:
4830                return REFIT_UNMOUNT_NOTHING
4831            occupant = self.occupant(slot)
4832            if not occupant:
4833                return REFIT_UNMOUNT_BARE.format(slot=slot.label)
4834            return REFIT_UNMOUNT_DONE.format(name=_item_name(occupant), slot=slot.label)
4835        if column == self.ACTION_DONE:
4836            return REFIT_ACTION_DONE
4837        item, slot = self.focused_pair()
4838        if item is not None and slot is not None:
4839            return self.fit_refusal(item, slot) or refit_preview(str(item["id"]), slot.label, self.occupant(slot))
4840        if item is not None:
4841            item_id = str(item["id"])
4842            return REFIT_PREVIEW_PICK_MOUNT.format(name=_item_name(item_id), summary=item_summary(item_id))
4843        if slot is not None:
4844            occupant = self.occupant(slot)
4845            if not occupant:
4846                return REFIT_PREVIEW_SLOT_BARE.format(slot=slot.label)
4847            return REFIT_PREVIEW_SLOT_FULL.format(
4848                slot=slot.label, occupant=_item_name(occupant), summary=item_summary(occupant)
4849            )
4850        return REFIT_LOCKER_EMPTY
4851
4852    # -- refitting ---------------------------------------------------------
4853
4854    def mount(self, item: dict, slot: RefitSlot) -> bool:
4855        """Put *item* on *slot*. False, and a reason on the line, when it will not."""
4856        refusal = self.fit_refusal(item, slot)
4857        if refusal:
4858            self.status = refusal
4859            return False
4860        item_id = str(item.get("id", ""))
4861        name = _item_name(item_id)
4862        if slot.kind == RefitSlot.HARDPOINT:
4863            displaced = self.run.mount_weapon(item_id, slot.index)
4864            if displaced is None:
4865                self.status = REFIT_REFUSED
4866                return False
4867            done = (
4868                DEPOT_SWAP_DONE_DISPLACED.format(name=name, hardpoint=slot.index + 1, displaced=_item_name(displaced))
4869                if displaced
4870                else DEPOT_SWAP_DONE.format(name=name, hardpoint=slot.index + 1)
4871            )
4872        else:
4873            displaced = self.run.install_module(item_id, slot.index)
4874            if displaced is None:
4875                self.status = REFIT_REFUSED
4876                return False
4877            done = (
4878                REFIT_INSTALL_DISPLACED.format(name=name, slot=slot.label, displaced=_item_name(displaced))
4879                if displaced
4880                else REFIT_INSTALL_DONE.format(name=name, slot=slot.label)
4881            )
4882        self._announce(done)
4883        self.armed = None
4884        self._settle_focus()
4885        return True
4886
4887    def unmount(self, slot: RefitSlot | None) -> bool:
4888        """Strip *slot* back to bare mounting and put what came off in the locker."""
4889        if slot is None:
4890            self.status = REFIT_UNMOUNT_NOTHING
4891            return False
4892        occupant = self.occupant(slot)
4893        if not occupant:
4894            self.status = REFIT_UNMOUNT_BARE.format(slot=slot.label)
4895            return False
4896        if slot.kind == RefitSlot.HARDPOINT:
4897            self.run.unmount_weapon(slot.index)
4898        else:
4899            self.run.uninstall_module(slot.index)
4900        self._announce(REFIT_UNMOUNT_DONE.format(name=_item_name(occupant), slot=slot.label))
4901        self._settle_focus()
4902        return True
4903
4904    def confirm(self) -> bool:
4905        """Act on whatever the focus is standing on."""
4906        column, index = self.focus
4907        if column == self.ACTION_DONE:
4908            self.close()
4909            return True
4910        if column == self.ACTION_UNMOUNT:
4911            return self.unmount(self.selected_slot())
4912        if column == self.COLUMN_HULL:
4913            if not 0 <= index < len(self.slots):
4914                return False
4915            item = self.selected_item()
4916            if item is not None:
4917                return self.mount(item, self.slots[index])
4918            self.armed = None if self.armed == (self.COLUMN_HULL, index) else (self.COLUMN_HULL, index)
4919            self.status = ""
4920            return True
4921        rows = self.locker()
4922        if not 0 <= index < len(rows):
4923            return False
4924        slot = self.selected_slot()
4925        if slot is not None:
4926            return self.mount(rows[index], slot)
4927        self.armed = None if self.armed == (self.COLUMN_LOCKER, index) else (self.COLUMN_LOCKER, index)
4928        self.status = ""
4929        return True
4930
4931    def _announce(self, text: str) -> None:
4932        """Say what just happened on the panel and on the run's own toast line."""
4933        self.status = text
4934        hud = self.run.hud
4935        if hud is not None:
4936            hud.show_toast(text)
4937
4938    def _settle_focus(self) -> None:
4939        """Keep the focus on something that still exists after a refit."""
4940        self.armed = None
4941        entries = self.entries()
4942        if self.focus not in entries:
4943            self.focus = entries[0] if entries else (self.COLUMN_HULL, 0)
4944
4945    # -- frame -------------------------------------------------------------
4946
4947    def on_update(self, dt: float):
4948        if not self._open:
4949            return
4950        shop = self.run.depot_screen
4951        if shop is None or not shop.is_open:
4952            self.close()
4953            return
4954        if self.get_global_rect() != self._window:
4955            # The window itself resized. Everything is placed from that rect,
4956            # so it is all rebuilt at the new one rather than left painted at
4957            # coordinates that have moved out from under it.
4958            self._build()
4959
4960        if Input.is_action_just_pressed("thrust_down") or Input.is_action_just_pressed(MENU_DOWN_ACTION):
4961            self.move_focus(1)
4962        elif Input.is_action_just_pressed("thrust_up") or Input.is_action_just_pressed(MENU_UP_ACTION):
4963            self.move_focus(-1)
4964        if Input.is_action_just_pressed("thrust_left") or Input.is_action_just_pressed("thrust_right"):
4965            self.move_column()
4966
4967        under, moved = self._pointed_at()
4968        if under is not None and moved:
4969            self.place_focus(under)
4970        committed, _ = self._commit.poll(dt)
4971        confirmed, _ = self._confirm.poll(dt)
4972        clicked, _ = self._click.poll(dt)
4973        backed, _ = self._back.poll(dt)
4974        if clicked and under is not None:
4975            self.place_focus(under)
4976        if backed:
4977            self.close()
4978            return
4979        if committed or confirmed or (clicked and under is not None):
4980            self.confirm()
4981        if not self._open:
4982            return
4983        self._refresh()
4984
4985    def _pointed_at(self) -> tuple[tuple[str, int] | None, bool]:
4986        """The entry under the pointer, and whether the pointer has moved.
4987
4988        A mount answers to its box and to its label both: the box is fifteen
4989        pixels across, which is a target for the eye rather than for a mouse.
4990        """
4991        position = Input.mouse_position
4992        point = (float(position.x), float(position.y))
4993        moved = self._pointer is not None and point != self._pointer
4994        self._pointer = point
4995        for index, box in enumerate(self._slot_boxes):
4996            label = self._slot_labels[index] if index < len(self._slot_labels) else None
4997            if _rect_holds(box.get_global_rect(), point) or (
4998                label is not None and label.text and _rect_holds(label.get_global_rect(), point)
4999            ):
5000                return (self.COLUMN_HULL, index), moved
5001        for index, row in enumerate(self._locker_rows):
5002            if index < len(self.locker()) and _rect_holds(row.get_global_rect(), point):
5003                return (self.COLUMN_LOCKER, index), moved
5004        for index, row in enumerate(self._action_rows):
5005            if _rect_holds(row.get_global_rect(), point):
5006                return (self.ACTIONS[index], index), moved
5007        return None, moved
5008
5009    # -- drawing -----------------------------------------------------------
5010
5011    def _build(self) -> None:
5012        """Place every control, once, from the window rect it will keep."""
5013        if self._backdrop is not None:
5014            self._backdrop.destroy()
5015        self._slot_boxes.clear()
5016        self._slot_labels.clear()
5017        self._locker_rows.clear()
5018        self._locker_labels.clear()
5019        self._action_rows.clear()
5020        self._action_labels.clear()
5021        self._preview.clear()
5022        self._slot_chars.clear()
5023
5024        window = self.get_global_rect()
5025        self._window = window
5026        _, _, width, height = (float(value) for value in window)
5027        room_w = max(1.0, width - 2.0 * MENU_WINDOW_MARGIN_PX)
5028        room_h = max(1.0, height - 2.0 * MENU_WINDOW_MARGIN_PX)
5029        scale = max(REFIT_MIN_SCALE, min(1.0, room_w / REFIT_BASE_WIDTH_PX, room_h / REFIT_BASE_HEIGHT_PX))
5030        panel_w = min(room_w, REFIT_BASE_WIDTH_PX * scale)
5031        panel_h = min(room_h, REFIT_BASE_HEIGHT_PX * scale)
5032        panel_x = (width - panel_w) / 2.0
5033        panel_y = (height - panel_h) / 2.0
5034
5035        self._backdrop = self.add_child(Panel(name="Backdrop"))
5036        self._backdrop.set_anchor_preset(AnchorPreset.FULL_RECT)
5037        self._backdrop.bg_colour = MENU_BACKDROP
5038        panel = self._place(Panel(name="Panel"), panel_x, panel_y, panel_w, panel_h)
5039        panel.bg_colour = MENU_PANEL
5040
5041        pad = REFIT_PAD_PX * scale
5042        line = REFIT_LINE_HEIGHT_PX * scale
5043        title_h = REFIT_TITLE_FONT_PX * scale * 1.4
5044        x0, y0 = panel_x + pad, panel_y + pad
5045        inner_w = panel_w - 2.0 * pad
5046        # Six footer lines: two of preview, the status, the two action rows and
5047        # the key legend. Measured off the bottom so the body takes what is left.
5048        body_y = y0 + title_h + line
5049        body_h = max(line * 2.0, panel_h - 2.0 * pad - title_h - line - 6.0 * line)
5050
5051        self._text(REFIT_TITLE, x0, y0, inner_w, title_h, REFIT_TITLE_FONT_PX * scale, MENU_TITLE_COLOUR)
5052        self._text(REFIT_SUBTITLE, x0, y0 + title_h, inner_w, line, REFIT_ROW_FONT_PX * scale, MENU_DIM_COLOUR)
5053
5054        diagram_w = inner_w * REFIT_DIAGRAM_FRACTION
5055        gap = REFIT_GAP_PX * scale
5056        self._build_diagram(x0, body_y, diagram_w, body_h, scale)
5057        self._build_locker(x0 + diagram_w + gap, body_y, inner_w - diagram_w - gap, body_h, scale)
5058
5059        footer_y = body_y + body_h
5060        row_font = REFIT_ROW_FONT_PX * scale
5061        self._preview = [
5062            self._text("", x0, footer_y + index * line, inner_w, line, row_font, MENU_TEXT_COLOUR) for index in range(2)
5063        ]
5064        self._preview_font = _refit_font(row_font)
5065        self._preview_width = inner_w
5066        self._preview_chars = _row_chars(inner_w, row_font)
5067        self._status_row = self._text("", x0, footer_y + 2.0 * line, inner_w, line, row_font, MENU_DIM_COLOUR)
5068        for index, _action in enumerate(self.ACTIONS):
5069            top = footer_y + (3.0 + index) * line
5070            row = self._plate(x0, top, inner_w, line * 0.92, scale)
5071            label = self._text("", x0 + pad * 0.5, top, inner_w - pad, line, row_font)
5072            self._action_rows.append(row)
5073            self._action_labels.append(label)
5074        self._text(
5075            REFIT_CONTROLS_LINE, x0, footer_y + 5.0 * line, inner_w, line, REFIT_SLOT_FONT_PX * scale, MENU_DIM_COLOUR
5076        )
5077        self._refresh()
5078
5079    def _build_diagram(self, x: float, y: float, width: float, height: float, scale: float) -> None:
5080        """Draw the hull plan and put a box on every mount it carries.
5081
5082        The plan is scaled to fit its box *including* the room the labels take,
5083        so a mount's name is cut to the space beside it rather than running out
5084        through the side of the panel. Port labels are right-aligned in a rect
5085        that ends at the mount, starboard ones left-aligned in a rect that
5086        starts there: the text hugs its mount at any length without the rect
5087        ever having to move when the name under it changes.
5088        """
5089        line = REFIT_LINE_HEIGHT_PX * scale
5090        self._text(REFIT_HULL_HEADING, x, y, width, line, REFIT_SLOT_FONT_PX * scale, MENU_DIM_COLOUR)
5091        top = y + line
5092        height = max(1.0, height - line)
5093
5094        hull_x = [abs(sx) + sw / 2.0 for sx, _sz, sw, _sd in REFIT_HULL_SHAPES]
5095        hull_z = [abs(sz) + sd / 2.0 for _sx, sz, _sw, sd in REFIT_HULL_SHAPES]
5096        half_x = max([*hull_x, *(abs(slot.x) for slot in self.slots), 1.0])
5097        half_z = max([*hull_z, *(abs(slot.z) for slot in self.slots), 1.0])
5098        unit = min(width / (2.0 * (half_x + REFIT_LABEL_ROOM_UNITS)), height / (2.0 * (half_z + 0.3)))
5099        centre_x, centre_y = x + width / 2.0, top + height / 2.0
5100
5101        for shape_x, shape_z, shape_w, shape_d in REFIT_HULL_SHAPES:
5102            plate = self._plate(
5103                centre_x + (shape_x - shape_w / 2.0) * unit,
5104                centre_y + (shape_z - shape_d / 2.0) * unit,
5105                shape_w * unit,
5106                shape_d * unit,
5107                scale,
5108                colour=REFIT_HULL_COLOUR,
5109                border=REFIT_HULL_EDGE_COLOUR,
5110            )
5111            del plate
5112
5113        marker = REFIT_MARKER_PX * scale
5114        font = REFIT_SLOT_FONT_PX * scale
5115        label_h = font * 1.4
5116        for slot in self.slots:
5117            mark_x, mark_y = centre_x + slot.x * unit, centre_y + slot.z * unit
5118            self._slot_boxes.append(self._plate(mark_x - marker / 2.0, mark_y - marker / 2.0, marker, marker, scale))
5119            if abs(slot.x) <= REFIT_CENTRELINE_UNITS:
5120                room = width / 2.0
5121                label_x = mark_x - room / 2.0
5122                label_y = mark_y - marker / 2.0 - label_h if slot.z < 0.0 else mark_y + marker / 2.0
5123                alignment = "center"
5124            elif slot.x < 0.0:
5125                room = max(font, mark_x - marker / 2.0 - x - font * 0.5)
5126                label_x, label_y, alignment = x, mark_y - label_h / 2.0, "right"
5127            else:
5128                room = max(font, x + width - mark_x - marker / 2.0 - font * 0.5)
5129                label_x, label_y, alignment = mark_x + marker / 2.0 + font * 0.5, mark_y - label_h / 2.0, "left"
5130            label = self._text("", label_x, label_y, room, label_h, font)
5131            label.alignment = alignment
5132            self._slot_labels.append(label)
5133            self._slot_chars.append(_row_chars(room, font))
5134
5135    def _build_locker(self, x: float, y: float, width: float, height: float, scale: float) -> None:
5136        """Allocate a row for everything that could ever be in the locker.
5137
5138        The bound is what is in it now plus one per mount: nothing else can put
5139        anything in it while the panel is up, and unmounting the whole hull is
5140        the worst it can come to. Allocating for that is what lets the column
5141        stay exactly where it was drawn for the life of the visit.
5142        """
5143        line = REFIT_LINE_HEIGHT_PX * scale
5144        font = REFIT_ROW_FONT_PX * scale
5145        self._locker_heading = self._text(
5146            REFIT_LOCKER_HEADING, x, y, width, line, REFIT_SLOT_FONT_PX * scale, MENU_DIM_COLOUR
5147        )
5148        allocated = max(1, len(self.locker()) + len(self.slots))
5149        row_h = min(line, max(1.0, (height - line) / allocated))
5150        font = min(font, row_h * 0.62)
5151        pad = REFIT_PAD_PX * scale * 0.5
5152        self._locker_chars = _row_chars(width - 2.0 * pad, font)
5153        for index in range(allocated):
5154            top = y + line + index * row_h
5155            self._locker_rows.append(self._plate(x, top, width, row_h * 0.92, scale))
5156            self._locker_labels.append(self._text("", x + pad, top, width - 2.0 * pad, row_h, font))
5157
5158    def _place(self, control: Control, x: float, y: float, width: float, height: float) -> Control:
5159        """Mount *control* on the backdrop at an absolute rect, once and for good."""
5160        control.margin_left, control.margin_top = float(x), float(y)
5161        control.size_x, control.size_y = float(width), float(height)
5162        return self._backdrop.add_child(control)
5163
5164    def _plate(self, x: float, y: float, width: float, height: float, scale: float, *, colour=None, border=None):
5165        """One placed panel with a border that a later colour change can show.
5166
5167        The order is load-bearing: the engine builds a panel's style box from
5168        whichever of the two colours is set first and gives it no border until
5169        one is asked for, so a plate whose width is set before its fill draws
5170        its ring at zero pixels for the life of the screen.
5171        """
5172        plate = self._place(Panel(), x, y, width, height)
5173        plate.bg_colour = colour if colour is not None else REFIT_ROW_COLOUR
5174        plate.border_colour = border if border is not None else REFIT_SLOT_EDGE_COLOUR
5175        plate.border_width = REFIT_BORDER_PX * scale
5176        return plate
5177
5178    def _text(self, text: str, x: float, y: float, width: float, height: float, font: float, colour=None) -> Label:
5179        """One placed line of type, never smaller than the engine will draw."""
5180        label = self._place(Label(text, size_x=width, size_y=height), x, y, width, height)
5181        label.font_size = _refit_font(font)
5182        label.text_colour = Colour.coerce(colour or MENU_TEXT_COLOUR, name="refit")
5183        return label
5184
5185    def _refresh(self) -> None:
5186        """Rewrite every line and re-colour every ring. Nothing moves."""
5187        for index, slot in enumerate(self.slots):
5188            if index >= len(self._slot_boxes):
5189                break
5190            occupant = self.occupant(slot)
5191            focused = self.focus == (self.COLUMN_HULL, index)
5192            armed = self.armed == (self.COLUMN_HULL, index)
5193            box = self._slot_boxes[index]
5194            box.bg_colour = REFIT_SLOT_FILLED_COLOUR if occupant else REFIT_SLOT_BARE_COLOUR
5195            box.border_colour = _ring_colour(focused, armed, REFIT_SLOT_EDGE_COLOUR)
5196            label = self._slot_labels[index]
5197            name = _item_name(occupant) if occupant else REFIT_SLOT_EMPTY
5198            mark = REFIT_MARK_ARMED if armed else ""
5199            chars = self._slot_chars[index] if index < len(self._slot_chars) else len(name) + len(slot.tag) + 2
5200            _set_text(label, ellipsise(f"{mark}{slot.tag}: {name}", chars))
5201            label.text_colour = Colour.coerce(_ring_colour(focused, armed, MENU_TEXT_COLOUR), name="slot")
5202
5203        rows = self.locker()
5204        for index, row in enumerate(self._locker_rows):
5205            label = self._locker_labels[index]
5206            if index >= len(rows):
5207                # An allocated row with nothing in it yet: kept, blank, at the
5208                # coordinates it was given, so filling it later moves nothing.
5209                _set_text(label, "")
5210                row.bg_colour = MENU_PANEL
5211                row.border_colour = MENU_PANEL
5212                continue
5213            item = rows[index]
5214            focused = self.focus == (self.COLUMN_LOCKER, index)
5215            armed = self.armed == (self.COLUMN_LOCKER, index)
5216            item_id = str(item["id"])
5217            text = REFIT_LOCKER_ROW.format(
5218                name=_item_name(item_id), kind=str(item["kind"]), summary=item_summary(item_id)
5219            )
5220            _set_text(label, ellipsise(f"{REFIT_MARK_ARMED if armed else ' '}{text}", self._locker_chars))
5221            label.text_colour = Colour.coerce(_ring_colour(focused, armed, MENU_TEXT_COLOUR), name="locker")
5222            row.bg_colour = REFIT_ROW_COLOUR
5223            row.border_colour = _ring_colour(focused, armed, REFIT_ROW_COLOUR)
5224
5225        if self._locker_heading is not None:
5226            _set_text(self._locker_heading, REFIT_LOCKER_HEADING if rows else REFIT_LOCKER_EMPTY)
5227
5228        for index, action in enumerate(self.ACTIONS):
5229            focused = self.focus == (action, index)
5230            text = REFIT_ACTION_UNMOUNT if action == self.ACTION_UNMOUNT else REFIT_ACTION_DONE
5231            label = self._action_labels[index]
5232            _set_text(label, f"{MENU_CURSOR_LEFT} {text} {MENU_CURSOR_RIGHT}" if focused else f"  {text}")
5233            label.text_colour = Colour.coerce(_ring_colour(focused, False, MENU_TEXT_COLOUR), name="action")
5234            self._action_rows[index].border_colour = _ring_colour(focused, False, REFIT_ROW_COLOUR)
5235
5236        refused = bool(self.refusal())
5237        lines = wrap_prose(self.preview(), self._preview_font, self._preview_width)
5238        for index, label in enumerate(self._preview):
5239            _set_text(label, lines[index] if index < len(lines) else "")
5240            label.text_colour = Colour.coerce(REFIT_REFUSED_COLOUR if refused else MENU_TEXT_COLOUR, name="preview")
5241        if self._status_row is not None:
5242            _set_text(self._status_row, ellipsise(self.status, self._preview_chars))
5243
5244
5245def _ring_colour(focused: bool, armed: bool, resting):
5246    """The focus ring's colour: focus wins, then the armed pick, then nothing."""
5247    if focused:
5248        return REFIT_FOCUS_COLOUR
5249    if armed:
5250        return REFIT_ARMED_COLOUR
5251    return resting
5252
5253
5254def _row_chars(width: float, font: float) -> int:
5255    """How many characters fit across *width* at *font*, at least one."""
5256    return max(1, int(width / max(1.0, _refit_font(font) * MENU_GLYPH_ADVANCE_FRACTION)))
5257
5258
5259def _refit_font(size: float) -> float:
5260    """The size the engine will really draw *size* at, floor included."""
5261    return max(REFIT_MIN_FONT_PX, float(size))
5262
5263
5264class WakeTerritoryDirector(HunterDirector):
5265    """The arrival ladder, with the chart's swept ground folded into it.
5266
5267    ``hunter.HunterDirector`` compresses its warning for repeat arrivals and
5268    for a muffling biome, and both of those are properties of the sector it has
5269    been told about. Whether that sector sits inside the Wake is a property of
5270    the chart, which the ladder has no route to and no business knowing, so the
5271    run scene owns the answer and hands it down exactly the way it hands down
5272    the biome. Compression takes a ``min`` on both counts: the Shrike never
5273    gives a longer warning because a rule stacked the other way.
5274    """
5275
5276    def __init__(self, *, wake_territory: bool = False, **kwargs):
5277        super().__init__(**kwargs)
5278        self.wake_territory = bool(wake_territory)
5279
5280    def set_wake_territory(self, active: bool) -> None:
5281        """Say whether the sector now being flown is ground the front has taken."""
5282        self.wake_territory = bool(active)
5283
5284    def telegraph_seconds(self, arrival_index: int) -> float:
5285        seconds = super().telegraph_seconds(arrival_index)
5286        if self.wake_territory:
5287            seconds = min(seconds, WAKE_TERRITORY_TELEGRAPH_S)
5288        return seconds
5289
5290
5291class RunScene(ShrikeScene):
5292    """One run: the assembled world, the two endings, and the collapse rule."""
5293
5294    run_started = Signal(dict)
5295    run_ended = Signal(str, dict)
5296    last_stand_triggered = Signal()
5297    extraction_completed = Signal(dict)
5298
5299    def __init__(self, config: dict | None = None, **kwargs):
5300        kwargs.setdefault("name", "Run")
5301        super().__init__(**kwargs)
5302        self.config_dict = dict(config) if config else RunConfig().as_dict()
5303        #: Set by the menu when a suspend slot is being resumed.
5304        self.resume_state: RunState | None = None
5305
5306        self.state: RunState | None = None
5307        self.onboarding_schedule = Onboarding(int(self.config_dict.get("run_number", 1)))
5308        self.running = False
5309        self.ended = False
5310
5311        self.power: PowerSystem | None = None
5312        self.signature: SignatureMeter | None = None
5313        self.economy: Economy | None = None
5314        self.notoriety: Node | None = None
5315        self.damage: DamageRouter | None = None
5316        self.juice: JuiceDirector | None = None
5317        self.audio: Node | None = None
5318        self.waves: WaveComposer | None = None
5319
5320        self.environment = None
5321        self.rig: CameraRig | None = None
5322        self.ship: PlayerShip | None = None
5323        self.rack: WeaponRack | None = None
5324        self.sockets: SocketRack | None = None
5325        self.hunter_director: WakeTerritoryDirector | None = None
5326        #: Whether this sector was already swept when the hull arrived in it.
5327        #: The arrival says so once; the front closing on a sector that was
5328        #: clean is the other announcement, and one jump never earns both.
5329        self._arrived_in_wake = False
5330        self.hud: Hud | None = None
5331        #: Wings state and generator state as they stood before this frame's
5332        #: input, so a key's answer never depends on node update order.
5333        self._power_state_seen: tuple[str, bool | None] | None = None
5334        self.chart_screen: StarChart | None = None
5335        self.pause_screen: PauseScene | None = None
5336        self.depot_screen: DepotScene | None = None
5337        self.refit_screen: RefitScene | None = None
5338        self.onboarding: OnboardingDirector | None = None
5339        self.sector: Sector | None = None
5340        self.depot: Depot | None = None
5341
5342        #: Weapons bought with every hardpoint full, waiting for a mount.
5343        self.stowed_weapons: list[str] = []
5344        #: Modules pulled out of a socket at the refit panel, waiting for one.
5345        #: A purchase never lands here: it is socketed or refused at the till.
5346        self.stowed_modules: list[str] = []
5347
5348        self.last_stand = False
5349        self.last_stand_offered = False
5350        self._stranded_for = 0.0
5351        self._scrap_earned_at_trigger = 0.0
5352        #: A spool has started and has not yet been matched to a destination.
5353        self._spool_unresolved = False
5354        #: The chart has bought a jump that is still in the channel.
5355        self._jump_committed = False
5356        #: Who the hull was lost to, held while the death settles. None until
5357        #: the hull reaches zero; see :meth:`_on_ship_destroyed`.
5358        self._death_killer: str | None = None
5359        self._death_wait = 0.0
5360        #: The bearing toward whatever last got through the hull, and when.
5361        self._last_hit_bearing: Vec3 | None = None
5362        self._last_hit_at = -KILLER_ATTRIBUTION_WINDOW_S
5363        self._scrap_toast_amount = 0.0
5364        self._scrap_toast_wait = 0.0
5365        self._ammo_rounds_bank = 0.0
5366        self._service_names: list[str] = []
5367        self._pool = ModulePool()
5368        self._wiring = SignalWiring(self)
5369
5370    # ------------------------------------------------------------------ boot
5371
5372    def on_ready(self):
5373        if not self.running:
5374            self.begin_run(self.config_dict)
5375
5376    def begin_run(self, config: dict) -> None:
5377        """Assemble the world for *config* and start the run."""
5378        if self.running:
5379            raise RuntimeError("this run has already begun")
5380        self.config_dict = dict(config)
5381        run_config = RunConfig.from_dict(self.config_dict)
5382        resumed = self.resume_state is not None
5383        self.state = self.resume_state if resumed else RunState(run_config)
5384        self.onboarding_schedule = Onboarding(self.state.run_number)
5385
5386        self._build_services()
5387        self._build_world()
5388        self._wire_ledger()
5389        self.settings().apply_to_run(self)
5390        self._apply_hull()
5391        self.enter_sector(self.state.current_node_id, first=True)
5392        if resumed and self.state.restore is not None:
5393            self._apply_restore(self.state.restore)
5394            self.state.restore = None
5395
5396        profile = self.profile()
5397        if not resumed:
5398            profile["runs_started"] = int(profile.get("runs_started", 0)) + 1
5399            # Written here rather than left for the ledger. The counter is the
5400            # only input the onboarding schedule has, so a run that reaches the
5401            # world and never reaches an ending must still count: the profile
5402            # used to touch the disk at end_run or at a suspend, which left a
5403            # pilot who closed the window mid-flight on run one for ever, with
5404            # no chart, no ballistics and no silent running, however many times
5405            # they flew.
5406            self.save_system().save_profile(profile)
5407        self.running = True
5408        self.run_started(dict(self.config_dict))
5409
5410    def _build_services(self) -> None:
5411        tree = self.tree
5412        settings = self.settings()
5413        state = self.state
5414
5415        self.power = PowerSystem(name="PowerSystem")
5416        # Section 10's schedule: silent running is a run-3 lesson. Before that
5417        # the key is answered, not obeyed, so the posture never acts invisibly
5418        # on a run whose HUD is not allowed to show it.
5419        self.power.silent_running_enabled = self.onboarding_schedule.silent_running_available()
5420        self.signature = SignatureMeter(name="SignatureMeter", act=state.act)
5421        self.economy = Economy(name="Economy")
5422        self.notoriety = Notoriety(name="Notoriety")
5423        self.damage = DamageRouter(name="DamageRouter", seed=state.seed)
5424        self.juice = JuiceDirector(name="JuiceDirector", shake_scale=settings.shake, hit_stop_scale=settings.hit_stop)
5425        self.waves = WaveComposer(name="WaveComposer")
5426        self.audio = _make_audio()
5427
5428        services = [
5429            (Services.POWER, self.power),
5430            (Services.SIGNATURE, self.signature),
5431            (Services.ECONOMY, self.economy),
5432            (Services.NOTORIETY, self.notoriety),
5433            (Services.DAMAGE, self.damage),
5434            (Services.JUICE, self.juice),
5435            (Services.WAVES, self.waves),
5436        ]
5437        if self.audio is not None:
5438            services.append((Services.AUDIO, self.audio))
5439        for name, node in services:
5440            tree.add_singleton(name, node)
5441            self._service_names.append(name)
5442
5443    def _build_world(self) -> None:
5444        state = self.state
5445        self.environment = self.add_child(artkit.build_environment(state.act, state.seed))
5446        sun = self.add_child(DirectionalLight3D(name="Sun"))
5447        sun.direction = SUN_DIRECTION
5448        sun.colour = SUN_COLOUR
5449        sun.intensity = SUN_INTENSITY
5450
5451        self.rig = self.add_child(CameraRig(name="CameraRig"))
5452        self.ship = self.add_child(
5453            PlayerShip(name="Ship", hull_id=state.config.hull_id, position=Vec3(0.0, PLANE_Y, 0.0))
5454        )
5455        self.sockets = self.ship.add_child(SocketRack(state.config.hull_id))
5456        self.rack = self.ship.add_child(WeaponRack(hardpoints=self.ship.hardpoint_count))
5457        for hardpoint, weapon_id in enumerate(STARTER_WEAPONS[: self.ship.hardpoint_count]):
5458            self.rack.equip(weapon_id, hardpoint)
5459        self._fit_starter_power()
5460        self.rig.set_target(self.ship)
5461
5462        self.hunter_director = self.add_child(WakeTerritoryDirector(name="HunterDirector", seed=state.seed))
5463        self.hud = self.add_child(Hud(name="Hud"))
5464        self.tree.add_singleton(Services.HUD, self.hud)
5465        self._service_names.append(Services.HUD)
5466        self.chart_screen = self.add_child(StarChart(name="StarChart"))
5467        self.chart_screen.run_state = state
5468        self.chart_screen.graph = state.chart
5469        self.chart_screen.current_node_id = state.current_node_id
5470        self.chart_screen.jumps_taken = state.jumps_taken
5471        self.chart_screen.jump_completed.connect(self._on_jump_completed)
5472        self.chart_screen.jump_started.connect(self._on_jump_started)
5473        self.chart_screen.wake_advanced.connect(self._on_wake_advanced)
5474        # Every spool is answered, whoever started it: the chart's confirm
5475        # button labels the ring, and a bare press of the drive key is either
5476        # matched to a destination or told what it is missing.
5477        self.ship.warp_spool_started.connect(self._on_warp_spool_started)
5478        self.ship.warp_spool_cancelled.connect(self._on_warp_spool_cancelled)
5479        self.ship.warp_completed.connect(self._on_warp_completed)
5480        if not self.onboarding_schedule.chart_open():
5481            # Run one flies a fixed gentle route: the screen still owns the
5482            # jump machinery, it simply does not take the player's input. The
5483            # route is taken by spooling the drive, which is the deliberate,
5484            # interruptible, diegetic act the chart's confirm button is.
5485            self.chart_screen.update_mode = UpdateMode.DISABLED
5486        # Nothing on the strip may name a key whose answer is "not yet": the
5487        # chart before run two, silent running before run three.
5488        self.hud.set_controls_bar(*controls_bar_rows(self.onboarding_schedule))
5489
5490        self.pause_screen = self.add_child(PauseScene(self))
5491        self.depot_screen = self.add_child(DepotScene(self))
5492        # After the dock screen, so the refit panel draws over the shelf it is
5493        # raised from rather than under it.
5494        self.refit_screen = self.add_child(RefitScene(self))
5495        self.onboarding = self.add_child(
5496            OnboardingDirector(name="Onboarding", onboarding=self.onboarding_schedule, profile=self.profile())
5497        )
5498
5499    def _fit_starter_power(self) -> None:
5500        """Socket the hull's starter power source, if any mount takes one.
5501
5502        Without a source the capacitor is a non-renewable hundred points and
5503        the solar and generator actions are dead keys, so every hull launches
5504        with the first entry in :data:`STARTER_POWER` it can physically carry.
5505        """
5506        for module_id in STARTER_POWER:
5507            size = str(module_spec(module_id)["socket_size"])
5508            free = self.sockets.free_sockets(size)
5509            if free:
5510                self.sockets.install(module_id, free[-1].index)
5511                return
5512
5513    def _apply_hull(self) -> None:
5514        """Fold the hull choice and the doctrine bag into the systems flow owns.
5515
5516        Only the effects whose consumer this module builds are applied here;
5517        everything else reads the same bag off ``RUN_STARTED``.
5518        """
5519        hull_id = self.state.config.hull_id
5520        modifiers = self.state.config.modifiers
5521        if hull_id == "dart" and self.signature is not None:
5522            self.signature.set_gain_multiplier("hull", balance.DART_SIGNATURE_FILL_MULT)
5523        capacitor_bonus = float(modifiers.get("capacitor_max_bonus", 0.0))
5524        if capacitor_bonus and self.power is not None:
5525            self.power.set_capacitor_max(self.power.capacitor_max + capacitor_bonus)
5526        fill = float(modifiers.get("signature_fill_mult", 1.0))
5527        if fill != 1.0 and self.signature is not None:
5528            self.signature.set_gain_multiplier("doctrine", fill)
5529
5530    def _wire_ledger(self) -> None:
5531        """Subscribe the ledger to everything a run is judged on.
5532
5533        The hunter's signals go through :class:`~shrike.power.SignalWiring`
5534        because the Shrike is spawned mid-run and replaced on every arrival:
5535        a one-shot connect in here would only ever see the first one.
5536        """
5537        ledger = self.state.ledger
5538        ledger.assists = self.settings().assists_in_use()
5539        self.ship.ship_destroyed.connect(self._on_ship_destroyed)
5540        self.damage.enemy_killed.connect(self._on_enemy_killed)
5541        # Through the wiring rather than a direct connect: the hunter emits its
5542        # burns itself, and it is spawned mid-run, after any one-shot connect.
5543        self._wiring.want(SignalNames.PLAYER_DAMAGED, self._on_player_damaged)
5544        self.economy.cores_banked.connect(self._on_cores_banked)
5545        self._wiring.want(SignalNames.HUNTER_DEPARTED, self._on_arrival_survived)
5546        self._wiring.want(SignalNames.QUILL_SHEARED, self._on_quill)
5547        self._wiring.sweep()
5548
5549    # -------------------------------------------------------------- sectors
5550
5551    def enter_sector(self, node_id: str, *, first: bool = False) -> None:
5552        """Generate and mount the sector at *node_id*, or end the run at the Gate.
5553
5554        Charted lanes run both ways, so this is also the retreat path, and two
5555        things here are what stop a retreat being a farm. The act, the wave
5556        budget and the drop tables follow the deepest sector the run has ever
5557        reached rather than the column it is standing in, so falling back
5558        cannot soften the game. And a node the run has already stood in is
5559        dealt again stripped, so the ore is not there twice.
5560        """
5561        state = self.state
5562        node = state.chart.nodes[node_id]
5563        if node.is_deep_gate and not first:
5564            self.extract()
5565            return
5566        if node.is_roost:
5567            self.enter_roost()
5568            return
5569        # Harmless to call every jump, and the Lure can be assembled mid-run.
5570        roost.reveal_roost_node(state.chart, self.profile())
5571
5572        arrived_from = state.current_node_id
5573        revisited = node_id in state.visited
5574        if arrived_from and arrived_from != node_id:
5575            state.visited.add(arrived_from)
5576        state.current_node_id = node_id
5577        ledger = state.ledger
5578        state.sector_index = max(ledger.deepest_sector, node.column + 1)
5579        state.biome_id = node.biome
5580        ledger.deepest_sector = max(ledger.deepest_sector, state.sector_index)
5581        ledger.sectors_visited = max(ledger.sectors_visited, state.jumps_taken + 1)
5582
5583        if self.sector is not None:
5584            self.sector.destroy()
5585        if self.depot is not None:
5586            if self.depot_screen is not None:
5587                self.depot_screen.close()
5588            self.depot.destroy()
5589            self.depot = None
5590
5591        self.signature.set_act(state.act)
5592        self.signature.reset_for_sector()
5593        # Beside the meter's own reset and for the same reason: what the last
5594        # sector was saying is not true here. Before the announcements below,
5595        # so the arrival card and the objective are what survives the sweep.
5596        if self.hud is not None:
5597            self.hud.reset_for_sector()
5598        self.hunter_director.set_biome(state.biome_id)
5599        self.hunter_director.set_wake_territory(node.consumed)
5600        self._arrived_in_wake = bool(node.consumed)
5601        if self.onboarding is not None:
5602            self.onboarding.set_sector(state.sector_index)
5603            # The sweep took the card with the rest of the glass. A lesson the
5604            # pilot is halfway through is not a thing the old sector owned, so
5605            # the schedule puts its live card straight back up.
5606            self.onboarding.restore_card()
5607
5608        self._place_at_warp_anchor()
5609
5610        self.sector = self.add_child(
5611            Sector(
5612                name="Sector",
5613                sector_index=state.sector_index,
5614                biome_id=state.biome_id,
5615                seed=state.sector_seed(),
5616                deck=state.deck,
5617                has_depot=self._edge_has_depot(arrived_from, node_id),
5618                chart_offline=not self.onboarding_schedule.chart_open(),
5619                revisited=revisited,
5620            )
5621        )
5622        # The sector is the one authority on whether a dock exists here: it
5623        # folds the edge roll together with the opening-sector guarantee, and
5624        # asking the edge again would lose the guarantee half of that answer.
5625        has_depot = self.sector.has_depot
5626        self.sector.vault_hacked.connect(self._on_vault_hacked)
5627        self.sector.resource_collected.connect(self._on_resource_collected)
5628        if has_depot:
5629            self._mount_depot()
5630        self._show_depot_route(node_id, has_depot)
5631        self._arm_default_warp()
5632        self._announce_arrival()
5633        # After the sector, never before it: the meter is reset by the
5634        # ``SECTOR_ENTERED`` the generator emits, so a signature written above
5635        # would be wiped by the arrival it was written for.
5636        if node.consumed:
5637            self._go_wake_territory(caught=False)
5638        self._announce_objective(has_depot)
5639        self.waves.start(state.sector_index, state.biome_id, state.sector_seed(), run_number=state.run_number)
5640
5641    def _arm_default_warp(self) -> None:
5642        """Point the drive at a lane the moment the hull arrives somewhere.
5643
5644        An arrival used to leave the drive pointing nowhere, so the first press
5645        of the drive key in every sector bought a refusal rather than a jump,
5646        and the chip could only say "pick destination (M)": the pilot had to
5647        open a screen and confirm a choice before the key they had already been
5648        taught would do anything at all. The arming is the chart's own default
5649        rule, so what an unread ``R`` buys and what an unread ``F`` on the chart
5650        buys are the same lane, and neither is swept ground.
5651
5652        Run one is left alone. Its drive flies a surveyed route it picks for
5653        itself at the press, the cheapest way onward rather than the deepest,
5654        and a lane armed off a chart that run has not been given would leave
5655        the chip quoting one destination while the key flew to another.
5656        """
5657        chart = self.chart_screen
5658        if chart is None or not self.onboarding_schedule.chart_open():
5659            return
5660        chart.arm_default()
5661
5662    def _place_at_warp_anchor(self) -> None:
5663        """Drop the hull on the new sector's warp-in point, dead in the water.
5664
5665        A jump used to leave the ship wherever the last sector left it, so a
5666        pilot who fought their way to the right-hand rim arrived on the rim of
5667        the next sector too, staring at the void: the generator clusters the
5668        first features within ``sector.SECTOR_NEAR_RADIUS`` of the anchor, and
5669        nobody was ever at the anchor to see them. Carried speed goes with the
5670        position, because arriving already at cruise into an unread screen is
5671        the same lost second. The rig cuts rather than eases, and the arrival
5672        flash covers the cut.
5673        """
5674        if self.ship is None:
5675            return
5676        self.ship.position = Vec3(Sector.warp_anchor)
5677        self.ship.velocity = Vec2(0.0, 0.0)
5678        self._cut_camera_to_ship()
5679
5680    def _cut_camera_to_ship(self) -> None:
5681        """Put the rig on the hull with no follow lag, aim lead included."""
5682        if self.rig is None or self.ship is None:
5683            return
5684        self.rig.set_aim(Vec3(self.ship.position))
5685        self.rig.snap()
5686
5687    def _show_depot_route(self, node_id: str, has_depot: bool) -> None:
5688        """Point the pilot at whatever this hold can actually be spent on.
5689
5690        A depot in the sector gets the HUD's beacon and a live range. With no
5691        depot here, the nearest one being a single jump out is what this
5692        sector's objective says instead, because "scrap with nothing to do with
5693        it" is a chart the player never opened rather than an economy that is
5694        missing.
5695        """
5696        del node_id
5697        hud = self.hud
5698        if hud is None:
5699            return
5700        if has_depot and self.depot is not None:
5701            hud.set_depot_beacon(Vec3(self.depot.position))
5702            return
5703        hud.set_depot_beacon(None)
5704
5705    def sector_title(self) -> str:
5706        """The place's name, in the chart's own spelling.
5707
5708        Not the biome: a chart deals a biome three or four times and gives each
5709        one a name no other node answers to, so an arrival card printing the
5710        bare biome would name a different place from the jump button that
5711        bought the trip.
5712        """
5713        node = self.state.chart.nodes.get(self.state.current_node_id)
5714        if node is not None:
5715            return node.label
5716        return str(self.state.biome_id).replace("_", " ").upper()
5717
5718    def _announce_arrival(self) -> None:
5719        """Name the sector on arrival, and leave the name standing in the corner.
5720
5721        A jump used to land the hull in an unnamed field of rocks that looked
5722        exactly like the field it had left, so a blind pilot flew three sectors
5723        without ever being sure one had happened. The card is the moment and
5724        the corner is the record.
5725        """
5726        hud = self.hud
5727        if hud is None:
5728            return
5729        state = self.state
5730        node = state.chart.nodes.get(state.current_node_id)
5731        # Swept ground is the one thing about a place that outranks the biome:
5732        # what this sector is like matters less than who has been through it.
5733        swept = node is not None and node.consumed
5734        flavour = WAKE_TERRITORY_FLAVOUR if swept else SECTOR_FLAVOUR.get(state.biome_id, "")
5735        hud.show_arrival_card(self.sector_title(), flavour)
5736
5737    def objective_line(self, has_depot: bool = False) -> str:
5738        """The one sentence this sector is for, in the pilot's own terms.
5739
5740        A blind playtest flew three sectors without ever being told the game
5741        had a goal: the HUD priced everything and asked for nothing. Run one is
5742        scripted, because the pilot has no chart to reason with; every run
5743        after it reads the state it is actually in, in priority order, and the
5744        Gate line quotes the fuel so the last jump is never a surprise.
5745        """
5746        state = self.state
5747        if not self.onboarding_schedule.chart_open():
5748            return OBJECTIVE_RUN_1
5749        graph = state.chart
5750        scrap = float(self.economy.scrap) if self.economy is not None else 0.0
5751        if has_depot and scrap >= OBJECTIVE_BANK_SCRAP:
5752            return OBJECTIVE_BANK
5753        gate = next((node for node in graph.nodes.values() if node.is_deep_gate), None)
5754        here = graph.nodes.get(state.current_node_id)
5755        columns = max(0, gate.column - here.column) if gate is not None and here is not None else 1
5756        if gate is not None and columns <= 1:
5757            # The last jump outranks every other errand: it is the one that
5758            # decides whether any of the hold is ever banked.
5759            quote = next((d.quote.total for d in self.chart_screen.destinations() if d.node_id == gate.id), 0.0)
5760            return OBJECTIVE_GATE.format(fuel=quote)
5761        if not has_depot and scrap >= OBJECTIVE_BANK_SCRAP:
5762            # Most nodes have a depot somewhere on their fan, so this is only
5763            # an objective once there is a hold worth taking to one.
5764            if any(edge.depot for edge in graph.edges_from(state.current_node_id)):
5765                return DEPOT_ONE_JUMP_TOAST
5766        return OBJECTIVE_ONWARD.format(columns=columns)
5767
5768    def _announce_objective(self, has_depot: bool) -> None:
5769        """Push this sector's objective onto the HUD's standing objective line.
5770
5771        The line stands until the next sector replaces it, so "what am I here
5772        for" has an answer minutes after arrival, not just for a toast's three
5773        seconds.
5774        """
5775        hud = self.hud
5776        line = self.objective_line(has_depot)
5777        if hud is None or not line:
5778            return
5779        hud.set_objective(line)
5780
5781    def _edge_has_depot(self, source_id: str, node_id: str) -> bool:
5782        """Whether the lane actually flown into *node_id* carried a drift depot.
5783
5784        The lane, not the fan: this used to answer yes if *any* node on the map
5785        had a depot edge into here, which was already generous and became
5786        nearly always true once every lane ran both ways. A depot is moored on
5787        one line, and the line the ship flew is the one that decides.
5788        """
5789        edge = self.state.chart.edge_between(source_id, node_id)
5790        return edge is not None and edge.depot
5791
5792    def _mount_depot(self) -> None:
5793        state = self.state
5794        anchor = self.sector.depot_anchor
5795        position = from_plane(anchor) if anchor is not None else Vec3(20.0, PLANE_Y, 0.0)
5796        broker = state.biome_id == "broker_claim"
5797        factory = Broker if broker else Depot
5798        self.depot = self.add_child(
5799            factory(
5800                name="Depot",
5801                seed=state.sector_seed(),
5802                act=state.act,
5803                pool=self._pool,
5804                position=position,
5805            )
5806        )
5807        # A revisited dock is the same dock. Its shelf already rebuilds
5808        # identically from the node's seed; handing it back the same purchase
5809        # counter is what stops a round trip re-stocking the once-per-visit
5810        # rows a pilot has already bought out.
5811        self.depot.purchases = state.depot_purchases.setdefault(state.current_node_id, {})
5812        self.depot.purchase_gate = self._purchase_gate
5813        self.depot.item_purchased.connect(self._on_item_purchased)
5814        self.depot.weapon_swapped.connect(self._on_weapon_swapped)
5815        self.depot.docked.connect(self._on_docked)
5816        self.depot.undocked.connect(self._on_undocked)
5817
5818    def build_profile(self) -> dict:
5819        """How a shelf reads this ship: the act, the fit, and the world pool.
5820
5821        The pity slot leans on what is already flown, and the tier band comes
5822        off the act, so a depot handed nothing would roll a shelf for a ship it
5823        has never seen.
5824        """
5825        state = self.state
5826        weapons = [weapon.spec.id for weapon in self.rack.weapons()] if self.rack is not None else []
5827        modules = list(self.sockets.installed_ids()) if self.sockets is not None else []
5828        return {
5829            "act": state.act,
5830            "weapons": weapons,
5831            "modules": modules,
5832            "pool_unlocks": list(self._pool.expansions),
5833            ModulePool.PROFILE_KEY: list(self.profile().get(ModulePool.PROFILE_KEY, ())),
5834        }
5835
5836    def _on_docked(self, depot_id: str) -> None:
5837        """Raise the dock screen. Docking is the only way scrap gets spent."""
5838        del depot_id
5839        if self.depot_screen is not None and self.depot is not None:
5840            self.depot_screen.open(self.depot)
5841
5842    def _on_undocked(self) -> None:
5843        if self.depot_screen is not None:
5844            self.depot_screen.close()
5845
5846    def _on_jump_completed(self, node_id: str) -> None:
5847        state = self.state
5848        state.jumps_taken = self.chart_screen.jumps_taken
5849        state.notoriety = int(getattr(self.notoriety, "value", 0))
5850        self.enter_sector(node_id)
5851
5852    # ------------------------------------------------------------ the Wake
5853
5854    def _on_wake_advanced(self, columns: int) -> None:
5855        """The front moved. If it moved onto this sector, it has caught up.
5856
5857        The chart feeds the front after the arrival and clamps it to the hull's
5858        own column, so the only sector it can ever take is the one the ship is
5859        standing in, and only for a pilot who has been spending jumps going
5860        backwards. That is the whole point of the red band: it is not scenery
5861        behind you, it is a thing that closes.
5862        """
5863        del columns
5864        state = self.state
5865        node = state.chart.nodes.get(state.current_node_id)
5866        if node is None or not node.consumed or self._arrived_in_wake:
5867            # Already swept when the hull got here, so the arrival has said it
5868            # once and this is the same sentence twice.
5869            return
5870        self._arrived_in_wake = True
5871        self.hunter_director.set_wake_territory(True)
5872        self._go_wake_territory(caught=True)
5873
5874    def _go_wake_territory(self, *, caught: bool) -> None:
5875        """Charge what the Wake's ground costs: a meter most of the way up.
5876
5877        Arriving in it and being overtaken by it are the same state and read
5878        the same way, so they land here together and differ only in the words.
5879        The meter is written raw, because this is the mechanic rather than a
5880        gain: a wake damper does not make swept ground quieter.
5881        """
5882        signature = self.signature
5883        if signature is not None:
5884            wanted = WAKE_TERRITORY_SIGNATURE_START - float(signature.value)
5885            if wanted > 0.0:
5886                signature.add(wanted, "wake_territory", raw=True)
5887        hud = self.hud
5888        if hud is None:
5889            return
5890        hud.flash_stamp(WAKE_CAUGHT_STAMP if caught else WAKE_TERRITORY_STAMP)
5891        hud.show_toast(WAKE_CAUGHT_TOAST if caught else WAKE_TERRITORY_TOAST)
5892
5893    # ------------------------------------------------------------- purchases
5894
5895    def _purchase_gate(self, item: dict) -> bool:
5896        """Whether the hull can take *item*, asked by the depot before payment.
5897
5898        A refusal names its reason on the toast line, so a full socket rack or
5899        a locker with nothing to feed costs a sentence rather than scrap.
5900        """
5901        refusal = self._purchase_refusal(item)
5902        if not refusal:
5903            return True
5904        if self.hud is not None:
5905            self.hud.show_toast(refusal)
5906        return False
5907
5908    def _purchase_refusal(self, item: dict) -> str:
5909        """Why *item* cannot be taken aboard right now; empty when it can be."""
5910        item_id = str(item.get("id", ""))
5911        if item_id in MODULE_CATALOGUE:
5912            size = str(module_spec(item_id)["socket_size"])
5913            if not self.sockets.free_sockets(size):
5914                return f"NO FREE {size.upper()} SOCKET FOR THE {_item_name(item_id)}"
5915        elif item_id == "ammo_box" and self._ammo_eater() is None:
5916            return "NO BALLISTIC WEAPON TO FEED"
5917        elif item_id == "breach_patch" and self.ship.open_breaches <= 0:
5918            return "NO OPEN BREACH TO FOAM"
5919        return ""
5920
5921    def _on_item_purchased(self, item_id: str, price: float) -> None:
5922        """Deliver a paid-for depot item to the run that bought it."""
5923        del price
5924        if item_id in MODULE_CATALOGUE:
5925            self._deliver_module(item_id)
5926        elif item_id in balance.WEAPONS:
5927            self._deliver_weapon(item_id)
5928        elif item_id in CONSUMABLES:
5929            self._deliver_consumable(item_id)
5930
5931    def _deliver_module(self, module_id: str) -> None:
5932        """Socket a bought module on the stern-most mount that takes it.
5933
5934        The gate has already vouched for a free socket, the same way the
5935        starter fit found one, so the install cannot fail after payment.
5936        """
5937        socket = self.sockets.socket_for(module_id)
5938        self.sockets.install(module_id, socket.index)
5939        self._purchase_toast(f"{_item_name(module_id)} INSTALLED, {socket.label} socket")
5940
5941    def _deliver_weapon(self, weapon_id: str) -> None:
5942        """Bolt a bought weapon to a free hardpoint, or stow it when none is."""
5943        hardpoint = self.rack.free_hardpoint()
5944        if hardpoint is not None:
5945            self.rack.equip(weapon_id, hardpoint)
5946            self._purchase_toast(f"{_item_name(weapon_id)} MOUNTED, hardpoint {hardpoint + 1}")
5947            return
5948        self.stowed_weapons.append(weapon_id)
5949        self._purchase_toast(f"{_item_name(weapon_id)} STOWED: hardpoints full (swap at dock)")
5950
5951    def _on_weapon_swapped(self, mounted: str, displaced: str, hardpoint: int) -> None:
5952        """Move a committed refit's ids between the locker and the hull.
5953
5954        The depot performs the swap on the rack and this scene owns the
5955        stowage, so the ids cross here: the mounted gun leaves the locker and
5956        whatever it displaced takes its place. One list stays authoritative.
5957        """
5958        del hardpoint
5959        if mounted in self.stowed_weapons:
5960            self.stowed_weapons.remove(mounted)
5961        if displaced:
5962            self.stowed_weapons.append(displaced)
5963
5964    # ------------------------------------------------------------- refitting
5965    #
5966    # The four moves a refit is made of. They live on the run rather than on
5967    # the panel that drives them because the run owns the locker: a gun coming
5968    # off a hardpoint and a module coming out of a socket land in the same
5969    # place, and one list per kind is what keeps that place authoritative.
5970
5971    def mount_weapon(self, weapon_id: str, hardpoint: int) -> str | None:
5972        """Bolt a stowed gun to a hardpoint. Returns the id it displaced.
5973
5974        The empty string for a bare mount, None when the bay refuses. The
5975        depot performs it, so a refit made here and one made by a script both
5976        announce themselves through ``weapon_swapped`` and both move the
5977        stowage through one handler.
5978        """
5979        depot = self.depot
5980        if depot is None or self.rack is None:
5981            return None
5982        return depot.swap_weapon(weapon_id, hardpoint)
5983
5984    def unmount_weapon(self, hardpoint: int) -> str:
5985        """Strip a hardpoint into the locker. Returns the id that came off."""
5986        rack = self.rack
5987        if rack is None or not 0 <= hardpoint < len(rack.hardpoints):
5988            return ""
5989        fitted = rack.hardpoints[hardpoint]
5990        if fitted is None:
5991            return ""
5992        weapon_id = str(fitted.spec.id)
5993        rack.unequip(hardpoint)
5994        self.stowed_weapons.append(weapon_id)
5995        return weapon_id
5996
5997    def install_module(self, module_id: str, socket_index: int) -> str | None:
5998        """Bolt a loose module into a socket. Returns the id it displaced.
5999
6000        The empty string for a bare socket, None when the rack refused. A
6001        refusal puts back whatever was already in the socket: a swap that
6002        cannot complete must not cost the module that was working.
6003        """
6004        sockets = self.sockets
6005        if sockets is None or module_id not in MODULE_CATALOGUE:
6006            return None
6007        displaced = str(sockets.module_id_at(socket_index) or "") if 0 <= socket_index < len(sockets.sockets) else ""
6008        if displaced:
6009            sockets.remove(socket_index)
6010        try:
6011            sockets.install(module_id, socket_index)
6012        except ValueError:
6013            if displaced:
6014                sockets.install(displaced, socket_index)
6015            return None
6016        if module_id in self.stowed_modules:
6017            self.stowed_modules.remove(module_id)
6018        if displaced:
6019            self.stowed_modules.append(displaced)
6020        return displaced
6021
6022    def uninstall_module(self, socket_index: int) -> str:
6023        """Strip a socket into the locker. Returns the id that came out."""
6024        sockets = self.sockets
6025        if sockets is None or not 0 <= socket_index < len(sockets.sockets):
6026            return ""
6027        module_id = str(sockets.module_id_at(socket_index) or "")
6028        if not module_id:
6029            return ""
6030        sockets.remove(socket_index)
6031        self.stowed_modules.append(module_id)
6032        return module_id
6033
6034    def _deliver_consumable(self, item_id: str) -> None:
6035        """Apply a bought consumable to the run state at once."""
6036        if item_id == "ammo_box":
6037            weapon_id = self._ammo_eater()
6038            self.rack.add_ammo_box(weapon_id)
6039            self._purchase_toast(f"AMMO BOX STOWED FOR THE {_item_name(weapon_id)}")
6040        elif item_id == "o2_canister":
6041            self.power.add_o2(balance.DEPOT_O2_CANISTER_AMOUNT)
6042            self._purchase_toast(f"O2 +{balance.DEPOT_O2_CANISTER_AMOUNT:.0f}")
6043        elif item_id == "fuel_cell":
6044            self.power.add_fuel(balance.DEPOT_FUEL_AMOUNT)
6045            self._purchase_toast(f"FUEL +{balance.DEPOT_FUEL_AMOUNT:.0f}")
6046        elif item_id == "breach_patch":
6047            self.ship.seal_breach()
6048            self._purchase_toast("BREACH FOAMED SHUT")
6049
6050    def _ammo_eater(self) -> str | None:
6051        """The mounted ballistic weapon a bought box feeds: the rack's own pick."""
6052        return self.rack.ammo_eater()
6053
6054    def _purchase_toast(self, text: str) -> None:
6055        if self.hud is not None:
6056            self.hud.show_toast(text)
6057
6058    # --------------------------------------------------------- doctrine kits
6059
6060    def kit_offers(self) -> list[str]:
6061        """The Doctrine Kits the dock screen should print, or nothing.
6062
6063        The pick is once per profile, ever: a profile that has taken one is
6064        never offered another, on this run or any later one. Until then every
6065        dock offers it, so a pilot who undocked without reading the rows has
6066        not silently spent their one free fork.
6067        """
6068        if str(self.profile().get("kit", "")):
6069            return []
6070        return list(KIT_CONTENTS)
6071
6072    def grant_kit(self, kit_id: str) -> bool:
6073        """Deliver *kit_id* free of charge and record the pick on the profile.
6074
6075        Delivery rides the paid paths: each part goes through the purchase
6076        gate and then :meth:`_on_item_purchased`, so a part the hull cannot
6077        take right now refuses with the same toast a purchase would, rather
6078        than installing around the fitting rules. A module part already
6079        socketed is skipped: every hull boots with the first power source it
6080        can carry, so the Solar Kit on a wings-carrying hull delivers the
6081        dampener and never a second wing array (the decision is recorded on
6082        ``balance.KIT_CONTENTS``). The Arsenal Kit's rounds are boxed straight
6083        into the locker under the cannon's own name, because the cannon may
6084        well be stowed when it arrives (the starter fit fills both hardpoints)
6085        and the rounds belong to it, not to the hungriest gun.
6086        """
6087        profile = self.profile()
6088        if kit_id not in KIT_CONTENTS or str(profile.get("kit", "")):
6089            return False
6090        profile["kit"] = kit_id
6091        if self.state is not None:
6092            self.state.kit = kit_id
6093        aboard = set(self.sockets.installed_ids()) if self.sockets is not None else set()
6094        for part in KIT_CONTENTS[kit_id]:
6095            if part in aboard:
6096                continue
6097            item = {"id": part, "price": 0.0}
6098            if self._purchase_gate(item):
6099                self._on_item_purchased(part, 0.0)
6100        if kit_id == KIT_ARSENAL and self.rack is not None:
6101            for _ in range(KIT_ARSENAL_AMMO_BOXES):
6102                self.rack.add_ammo_box("flak_cannon")
6103        self.meta_profile().persist()
6104        return True
6105
6106    # ----------------------------------------------------------------- frame
6107
6108    def on_update(self, dt: float):
6109        if not self.running or self.ended:
6110            return
6111        if self._death_killer is not None:
6112            # Dying. The world keeps drawing, and nothing else on the run reads
6113            # a key or settles a ledger until the blow has had its frame.
6114            self._death_wait -= dt
6115            if self._death_wait <= 0.0:
6116                self.end_run("death", killed_by=self._death_killer)
6117            return
6118        state = self.state
6119        state.elapsed_s += dt
6120        self._wiring.poll(dt)
6121        state.fuel = float(self.power.fuel)
6122        state.notoriety = int(getattr(self.notoriety, "value", 0))
6123        self._answer_keys()
6124        if self._spool_unresolved:
6125            self._spool_unresolved = False
6126            self._resolve_spool()
6127        self._tick_controls_bar()
6128        self._tick_affordance()
6129        self._tick_warp_status()
6130        self._tick_scrap_label(dt)
6131        self._tick_last_stand(dt)
6132        if self.power.o2 <= 0.0:
6133            self.end_run("death", killed_by="suffocation")
6134
6135    # -------------------------------------------------------- the key channel
6136
6137    def _answer_keys(self) -> None:
6138        """Give every bound gameplay key an answer this frame, or a reason.
6139
6140        The systems that own a key answer it in the world where they can: the
6141        wings unfold, the drive spools, the guns fire. This pass covers the
6142        rest, which is every press whose owner had nothing to say and every
6143        press made against a state that refuses it. It runs before the systems
6144        do nothing, not instead of them: an answer here is a line on the toast
6145        channel, never a substitute for the effect.
6146        """
6147        hud = self.hud
6148        if hud is None:
6149            return
6150        if self._power_state_seen is None:
6151            self._power_state_seen = self._power_state()
6152        shop = self.depot_screen
6153        chart = self.chart_screen
6154        if (shop is not None and shop.is_open) or (chart is not None and chart.is_open):
6155            # A screen that is up owns every key it names, and it answers them
6156            # itself. Nothing here may speak over a shelf or a route.
6157            self._power_state_seen = self._power_state()
6158            return
6159        if Input.is_action_just_pressed(CHART_ACTION) and not self.onboarding_schedule.chart_open():
6160            # Run one has no chart. Say so, rather than answering the key with
6161            # a sector jump nobody asked for.
6162            hud.show_toast(CHART_OFFLINE_TOAST)
6163        if Input.is_action_just_pressed("tractor_scoop"):
6164            hud.show_toast(SCOOP_PASSIVE_TOAST)
6165        if Input.is_action_just_pressed("solar_wings"):
6166            hud.show_toast(self._wings_answer())
6167        if Input.is_action_just_pressed("generator"):
6168            hud.show_toast(self._generator_answer())
6169        if Input.is_action_just_pressed("shield_left") or Input.is_action_just_pressed("shield_right"):
6170            hud.show_toast(SHIELD_MOVED_TOAST)
6171        if Input.is_action_just_pressed("afterburner"):
6172            flat = self.power is not None and self.power.capacitor <= 0.0
6173            hud.show_toast(AFTERBURNER_FLAT_TOAST if flat else AFTERBURNER_TOAST)
6174        if Input.is_action_just_pressed("interact") and not self.affordance_prompt() and not self.last_stand_offered:
6175            # The collapse offer takes the same key, and it answers with a
6176            # stamp of its own rather than with a refusal.
6177            hud.show_toast(INTERACT_NOTHING_TOAST)
6178        if Input.is_action_just_pressed("jettison_scrap"):
6179            hud.show_toast(self._jettison_answer())
6180        self._power_state_seen = self._power_state()
6181
6182    def _power_state(self) -> tuple[str, bool | None]:
6183        """The wings' state and the generator's, or ``None`` for absent parts."""
6184        ship = self.ship
6185        wings = ship.find(SolarWings) if ship is not None else None
6186        generator = ship.find(Generator) if ship is not None else None
6187        return (str(wings.state) if wings is not None else "", None if generator is None else bool(generator.running))
6188
6189    def _wings_answer(self) -> str:
6190        """What a press of the wings key does, in the pilot's own terms.
6191
6192        Read off the state as it stood *before* the press, latched last frame,
6193        so the answer does not depend on whether the wings' own node has
6194        already seen the same press this frame. Run one launches with the wings
6195        already unfolded: the blind pilot who pressed X on a deployed pair saw
6196        nothing happen and filed the key as dead, so a press that finds them
6197        out says exactly that and then says what it did about it.
6198        """
6199        state, _running = self._power_state_seen
6200        if not state:
6201            return WINGS_NONE_TOAST
6202        if state == "deployed":
6203            return WINGS_ALREADY_OUT_TOAST
6204        if state == "deploying":
6205            return WINGS_RETRACTING_TOAST
6206        return WINGS_DEPLOYING_TOAST
6207
6208    def _generator_answer(self) -> str:
6209        """What a press of the generator key does, or why it cannot.
6210
6211        The starter kit has no generator at all, which is the state the key was
6212        pressed in on both blind runs: the answer names the gap rather than
6213        letting Z press into nothing for a whole run.
6214        """
6215        _state, running = self._power_state_seen
6216        if running is None:
6217            return GENERATOR_NONE_TOAST
6218        if running:
6219            return GENERATOR_OFF_TOAST
6220        if self.power is not None and (self.power.silent_running or self.power.fuel <= 0.0):
6221            return GENERATOR_REFUSED_TOAST
6222        return GENERATOR_ON_TOAST
6223
6224    def _jettison_answer(self) -> str:
6225        """What a press of the jettison key does with this hold, or why nothing.
6226
6227        Jettisoned scrap is bait, and bait with nothing hunting is a hold
6228        thrown away for no reason, so the refusal teaches what the key is for
6229        rather than reporting a dump that never happened.
6230        """
6231        held = float(self.economy.scrap) if self.economy is not None else 0.0
6232        if held <= 0.0:
6233            return JETTISON_NOTHING_TOAST
6234        hunters = self.tree.group(Groups.HUNTER) if self.tree is not None else ()
6235        return JETTISON_DONE_TOAST if hunters else JETTISON_NO_HUNTER_TOAST
6236
6237    # ------------------------------------------------------- the verb channel
6238
6239    def _tick_controls_bar(self) -> None:
6240        """Hide and show the persistent key list, and remember the choice."""
6241        if self.hud is None or not Input.is_action_just_pressed(CONTROLS_BAR_ACTION):
6242            return
6243        showing = self.hud.toggle_controls_bar()
6244        settings = self.settings()
6245        settings.controls_bar = showing
6246        settings.store(self.profile())
6247        self.hud.show_toast(CONTROLS_BAR_ON_TOAST if showing else CONTROLS_BAR_OFF_TOAST)
6248
6249    def affordance_prompt(self) -> str:
6250        """The one verb line the HUD should be showing, empty when there is none.
6251
6252        Resolved in priority order rather than by distance, because the things
6253        competing for the key are not comparable: a bay you are sitting in beats
6254        anything, the sector's own reach comes next, and the breach patch is the
6255        fallback, since it is the one verb that is available everywhere and so
6256        would otherwise drown out every prompt that is actually about a place.
6257        """
6258        shop = self.depot_screen
6259        if shop is not None and shop.is_open:
6260            # The dock screen quotes the shelf, the sell offer and the way out
6261            # itself; a HUD verb line underneath it would name the same key
6262            # twice and read as a different offer.
6263            return ""
6264        ship = self.ship
6265        if ship is not None and ship.patching:
6266            return PATCH_PROMPT_RUNNING
6267        depot_line = self.depot.affordance() if self.depot is not None else ""
6268        if depot_line:
6269            return depot_line
6270        sector_offer = self.sector.affordance() if self.sector is not None else None
6271        if sector_offer is not None:
6272            return sector_offer.prompt
6273        if ship is not None and ship.open_breaches > 0:
6274            scrap = float(self.economy.scrap) if self.economy is not None else 0.0
6275            return PATCH_PROMPT if scrap >= balance.BREACH_PATCH_SCRAP else PATCH_PROMPT_BROKE
6276        return ""
6277
6278    def _tick_affordance(self) -> None:
6279        if self.hud is not None:
6280            self.hud.set_affordance(self.affordance_prompt())
6281
6282    def node_label(self, node_id: str) -> str:
6283        """The chart's own spelling of *node_id*, for anything that names it.
6284
6285        One spelling, everywhere: the warp chip, the spool ring and the chart's
6286        own boxes have to agree, or a pilot reading two of them at once is
6287        looking at two places.
6288        """
6289        node = self.state.chart.nodes.get(node_id)
6290        return node.label if node is not None else str(node_id)
6291
6292    def warp_status(self) -> tuple[str, bool]:
6293        """The warp chip's line and whether it is a refusal rather than an offer.
6294
6295        "No indication of when I can warp" was a true reading of the HUD: the
6296        spool ring only ever appeared once the drive was already running, so the
6297        one question a pilot asks every thirty seconds had no answer on screen
6298        until after they had committed to it.
6299        """
6300        chart = self.chart_screen
6301        if chart is None:
6302            return ("", False)
6303        # Swept ground is on the offer sheet like anything else; it is priced
6304        # in what waits there rather than in fuel, and the chip prices fuel.
6305        offered = chart.destinations()
6306        chosen = next((d for d in offered if d.node_id == chart.selected_id), None)
6307        if offered and not any(d.available for d in offered):
6308            # Nothing on the chart is affordable, which is the one state the
6309            # pilot must never have to open a screen to discover: quote the
6310            # cheapest way out and the gap, since that gap is the whole story
6311            # of the collapse that is about to be offered. The cheapest rather
6312            # than whatever the arrival armed, because a dry tank is a question
6313            # about the shortest gap to close, not about the lane the drive is
6314            # holding.
6315            chosen = min(offered, key=lambda d: d.quote.total)
6316        elif chosen is None and not self.onboarding_schedule.chart_open():
6317            # Run one flies a fixed route, so the drive already knows where it
6318            # is going and the chip quotes that rather than sending the pilot
6319            # to a chart they do not have. The route :meth:`gentle_jump` would
6320            # take, so the chip cannot name a lane the key will not fly.
6321            takeable = [d for d in offered if d.available]
6322            chosen = min(takeable, key=lambda d: d.quote.total) if takeable else None
6323        if chosen is None:
6324            if not self.onboarding_schedule.chart_open():
6325                # No chart this run, so nothing on the HUD may send the pilot
6326                # to one. The drive holds the route; the chip says only that.
6327                return (WARP_STATUS_SURVEYED, False)
6328            return (WARP_STATUS_PICK, False)
6329        # Both lines quote the price itemised: what a jump costs over the base
6330        # fare is the pilot's decision to make and was never named anywhere.
6331        price = warp_price(chosen.quote)
6332        if chosen.affordable:
6333            return (WARP_STATUS_READY.format(name=self.node_label(chosen.node_id), fuel=price), False)
6334        have = float(self.power.fuel) if self.power is not None else 0.0
6335        return (WARP_STATUS_NO_FUEL.format(need=price, have=have), True)
6336
6337    def _tick_warp_status(self) -> None:
6338        if self.hud is None:
6339            return
6340        line, blocked = self.warp_status()
6341        self.hud.set_warp_status(line, blocked=blocked)
6342
6343    # ------------------------------------------------------------ last stand
6344
6345    def stranded(self) -> bool:
6346        """True when no visible route out of this node is affordable."""
6347        return bool(self.chart_screen is not None and self.chart_screen.stranded)
6348
6349    def cheapest_exit_fuel(self) -> float:
6350        """Fuel the cheapest route out wants, zero when there is no route."""
6351        quotes = [d.quote.total for d in self.chart_screen.destinations() if not d.consumed]
6352        return min(quotes) if quotes else 0.0
6353
6354    def _tick_last_stand(self, dt: float) -> None:
6355        if self.last_stand or self.chart_screen is None:
6356            return
6357        if not self.stranded():
6358            self._stranded_for = 0.0
6359            self.last_stand_offered = False
6360            return
6361        self._stranded_for += dt
6362        if not self.last_stand_offered:
6363            if self._stranded_for < LAST_STAND_OFFER_GRACE_S:
6364                return
6365            # Raise the offer and stop there: a held interact must not accept
6366            # a collapse on the same frame the stamp appears.
6367            self.last_stand_offered = True
6368            if self.hud is not None:
6369                self.hud.flash_stamp("LAST STAND")
6370            return
6371        if Input.is_action_just_pressed("interact") or self._stranded_for >= LAST_STAND_AUTO_ACCEPT_S:
6372            self.trigger_last_stand()
6373
6374    def trigger_last_stand(self) -> None:
6375        """Take the collapse: free guns, a locked meter, and a priced ending.
6376
6377        The signature jump is left to the meter, which already consumes
6378        ``LAST_STAND_TRIGGERED``, so the Shrike is inbound the moment the
6379        signal lands.
6380        """
6381        if self.last_stand:
6382            return
6383        self.last_stand = True
6384        ledger = self.state.ledger
6385        ledger.last_stand = True
6386        ledger.cheapest_exit_fuel = self.cheapest_exit_fuel()
6387        self._scrap_earned_at_trigger = float(self.economy.scrap_earned_this_run)
6388        if self.rack is not None:
6389            self.rack.free_fire = True
6390        if self.hud is not None:
6391            self.hud.flash_stamp("LAST STAND")
6392        self.last_stand_triggered()
6393
6394    # ------------------------------------------------------------- endings
6395
6396    def extract(self) -> None:
6397        """Escape through the Deep Gate with the hoard.
6398
6399        The Gate's toll is already inside the warp quote the chart charged, so
6400        arriving here is the whole of the transaction.
6401        """
6402        self.end_run("extraction")
6403
6404    def enter_roost(self) -> None:
6405        """Take the predation fork: hand the run over to the Roost duel.
6406
6407        The Roost is only ever on the chart with the Lure aboard, and it owns
6408        its own persistence, so the run scene's job is the handover and the way
6409        back to the menu once the duel resolves either way.
6410        """
6411        if self.waves is not None:
6412            self.waves.stop()
6413        self.running = False
6414        self.ended = True
6415        scene = roost.RoostScene(self.profile(), name="Roost")
6416        home = _return_to_menu(self.tree, self.settings(), self.profile())
6417        scene.roost_victory.connect(home)
6418        scene.roost_failed.connect(home)
6419        self.go(scene)
6420
6421    def gentle_jump(self) -> bool:
6422        """Take run one's fixed route: the cheapest affordable way onward.
6423
6424        Reachable only through a chart action: the chart's own confirm button
6425        past run one, and the drive spool on run one, where the chart is shut.
6426        No key jumps a sector on its own.
6427        """
6428        chart = self.chart_screen
6429        offered = [d for d in chart.destinations() if d.available]
6430        if not offered:
6431            return False
6432        cheapest = min(offered, key=lambda destination: destination.quote.total)
6433        return bool(chart.select(cheapest.node_id) and chart.confirm())
6434
6435    # ------------------------------------------------------------------ warp
6436
6437    def _on_warp_spool_started(self, emergency: bool) -> None:
6438        """Note a spool and settle what it is for on the next frame.
6439
6440        Deliberately deferred: the chart starts its spool from inside
6441        :meth:`~shrike.chart.StarChart.confirm` and only announces the jump
6442        afterwards, so a decision taken here and now would call a bought route
6443        unauthorised and cancel it.
6444        """
6445        del emergency
6446        self._spool_unresolved = True
6447
6448    def _on_jump_started(self, node_id: str, fuel_cost: float) -> None:
6449        """The chart has bought a route; the ring in flight belongs to it."""
6450        del node_id, fuel_cost
6451        self._jump_committed = True
6452
6453    def _on_warp_completed(self, emergency: bool) -> None:
6454        del emergency
6455        self._jump_committed = False
6456
6457    def _resolve_spool(self) -> None:
6458        """Match a running spool to a destination, or refuse it and say why.
6459
6460        Pressing the drive key used to draw a ring that closed on nothing,
6461        which is a five-second animation for no jump. Now the key either buys
6462        the route the chart is pointing at, or it is told what it is missing:
6463        the ring never runs without a destination behind it.
6464        """
6465        ship, chart = self.ship, self.chart_screen
6466        if ship is None or chart is None or not ship.spooling:
6467            return
6468        if self._jump_committed:
6469            self._label_spool(chart.selected_id)
6470            return
6471        if not self.onboarding_schedule.chart_open():
6472            # Run one flies a fixed route, so the drive picks it: the cheapest
6473            # way onward, which is the only choice the run offers.
6474            if self.gentle_jump():
6475                self._label_spool(chart.selected_id)
6476            return
6477        target = chart.selected_id
6478        if not target:
6479            self._refuse_spool(WARP_NO_DESTINATION_TOAST, lesson=OnboardingLessons.WARP)
6480            return
6481        if chart.select(target) and chart.confirm():
6482            self._label_spool(target)
6483            return
6484        self._refuse_spool(WARP_UNAFFORDABLE_TOAST)
6485
6486    def _on_warp_spool_cancelled(self) -> None:
6487        """Release whatever an aborted spool was holding.
6488
6489        The HUD takes its own ring down off the same signal; what is settled
6490        here is the route: a jump the chart had already bought would otherwise
6491        stay pending and refuse every jump after it.
6492        """
6493        if self._jump_committed:
6494            self._jump_committed = False
6495            if self.chart_screen is not None:
6496                self.chart_screen.cancel_jump()
6497
6498    def _label_spool(self, node_id: str) -> None:
6499        """Write the destination and its fuel price around the spool ring.
6500
6501        The price is itemised the same way the warp chip itemises it
6502        (:func:`warp_price`): the ring and the chip are on screen together
6503        while a channel runs, and a ring quoting a bare 22 beside a chip
6504        quoting "22 (15 +7 HUNTED)" reads as two separate charges.
6505        """
6506        if self.hud is None or not node_id:
6507            return
6508        quote = next((d.quote for d in self.chart_screen.destinations() if d.node_id == node_id), None)
6509        self.hud.set_warp_target(
6510            self.node_label(node_id),
6511            float(quote.total) if quote is not None else 0.0,
6512            price_text=warp_price(quote) if quote is not None else "",
6513        )
6514
6515    def _refuse_spool(self, message: str, *, lesson: str = "") -> None:
6516        """Stand the drive down and put the reason on the toast line.
6517
6518        *lesson* is the card the refusal earns, and only the no-destination
6519        refusal earns one. A tank too shallow for the route is a pilot who has
6520        already picked a destination, and teaching them how to pick one raises
6521        "WARP NEEDS A DESTINATION" over a chart that has one: an answer to a
6522        question they did not ask, in place of the one they did. It also spends
6523        the lesson, which is taught once per profile, on the run where it was
6524        wrong. The refusal's own chip already names the fix.
6525        """
6526        if self.ship is not None:
6527            self.ship.cancel_warp_spool()
6528        if self.hud is not None:
6529            self.hud.end_warp_spool()
6530            self.hud.show_toast(message)
6531        if lesson and self.onboarding is not None:
6532            self.onboarding.teach(lesson)
6533
6534    def leave_for(self, scene: Node) -> None:
6535        """Stand the run down and swap to *scene* without settling a ledger.
6536
6537        The way out for a screen that is not an ending: the waves stop, the
6538        run stops counting, and nothing is banked, because the run itself is
6539        expected to come back from its suspend slot.
6540        """
6541        if self.waves is not None:
6542            self.waves.stop()
6543        self._close_overlays()
6544        self.running = False
6545        self.go(scene)
6546
6547    def _close_overlays(self) -> None:
6548        """Drop anything holding the tree still, so the next scene can breathe.
6549
6550        A screen that pauses and a ``change_scene`` are a deadlock waiting to
6551        happen: the pause outlives the screen that lifted it and the scene that
6552        replaces it never runs a frame.
6553        """
6554        if self.refit_screen is not None:
6555            self.refit_screen.close()
6556        if self.depot_screen is not None:
6557            self.depot_screen.close()
6558        if self.pause_screen is not None:
6559            self.pause_screen.close()
6560        if self.chart_screen is not None:
6561            self.chart_screen.close()
6562
6563    def end_run(self, outcome: str, *, killed_by: str = "") -> RunLedger:
6564        """Settle the run, bank it on the profile, and show the ledger."""
6565        if self.ended:
6566            return self.state.ledger
6567        self.ended = True
6568        self.running = False
6569        if self.waves is not None:
6570            self.waves.stop()
6571        self._close_overlays()
6572
6573        ledger = self._collect_ledger(outcome, killed_by)
6574        self._bank(ledger)
6575        self.run_ended(outcome, ledger.as_dict())
6576        if outcome == "extraction":
6577            self.extraction_completed(ledger.as_dict())
6578        self.go(LedgerScene(ledger))
6579        return ledger
6580
6581    def _collect_ledger(self, outcome: str, killed_by: str) -> RunLedger:
6582        state = self.state
6583        ledger = state.ledger
6584        ledger.outcome = outcome
6585        ledger.killed_by = killed_by
6586        ledger.elapsed_s = state.elapsed_s
6587        ledger.scrap_earned = float(self.economy.scrap_earned_this_run)
6588        ledger.scrap_carried = float(self.economy.scrap)
6589        ledger.cores_banked_in_run = float(self.economy.cores_banked_this_run)
6590        ledger.scrap_after_last_stand = (
6591            max(0.0, ledger.scrap_earned - self._scrap_earned_at_trigger) if ledger.last_stand else 0.0
6592        )
6593        ledger.notoriety = int(getattr(self.notoriety, "value", 0))
6594        ledger.hull_left = float(self.ship.hull)
6595        ledger.open_breaches = int(self.ship.open_breaches)
6596        ledger.fuel_left = float(self.power.fuel)
6597        ledger.o2_left = float(self.power.o2)
6598        ledger.signature = float(self.signature.value)
6599        ledger.loudest_gain = _loudest_gain(self.signature)
6600        if not ledger.last_stand:
6601            ledger.cheapest_exit_fuel = self.cheapest_exit_fuel()
6602
6603        rate = death_conversion_rate(self.profile(), self.state.config.modifiers)
6604        ledger.conversion = settle_conversion(ledger, death_rate=rate)
6605        ledger.cores_from_conversion = sum(entry.cores for entry in ledger.conversion)
6606        for entry in ledger.conversion:
6607            self.economy.convert(entry.scrap, entry.rate)
6608        return ledger
6609
6610    def _bank(self, ledger: RunLedger) -> None:
6611        """Pay the milestones, update the profile, and write it to disk."""
6612        profile = self.profile()
6613        ledger.milestones = earned_milestones(ledger, profile)
6614        flags = profile.setdefault("milestones", {})
6615        for name in ledger.milestones:
6616            flags[name] = True
6617        ledger.cores_from_milestones = float(sum(save.MILESTONE_CORES[name] for name in ledger.milestones))
6618
6619        ledger.score = run_score(ledger)
6620        ledger.best_score, ledger.new_best = record_best(profile, ledger.hull_id, ledger.score)
6621        ledger.recap = recap_sentence(ledger)
6622
6623        profile["cores"] = int(profile.get("cores", 0)) + int(round(ledger.cores_total))
6624        if ledger.outcome == "extraction":
6625            profile["runs_extracted"] = int(profile.get("runs_extracted", 0)) + 1
6626            profile["quills"] = int(profile.get("quills", 0)) + ledger.quills
6627            if ledger.scrap_carried >= balance.BARGE_UNLOCK_SCRAP_CARRIED:
6628                _unlock_hull(profile, "barge")
6629        else:
6630            kept = roost.quills_after_death(ledger.quills, roost_attempt=False)
6631            profile["quills"] = int(profile.get("quills", 0)) + kept
6632        if ledger.quills > 0:
6633            profile["runs_since_last_quill"] = 0
6634        else:
6635            profile["runs_since_last_quill"] = int(profile.get("runs_since_last_quill", 0)) + 1
6636        self.settings().store(profile)
6637        self.save_system().save_profile(profile)
6638
6639    # -- suspend -----------------------------------------------------------
6640
6641    def suspend(self) -> None:
6642        """Write the run to the one suspend slot, for a resume after a quit.
6643
6644        The profile goes to disk with it: the run already counted itself as
6645        started, and a quit that forgot that would let the onboarding schedule
6646        and the run counter drift from what the player actually played.
6647        """
6648        self.save_system().write_suspend(self._capture_suspend())
6649        self.save_system().save_profile(self.profile())
6650
6651    def _capture_suspend(self) -> dict:
6652        """The run as a suspend payload: chart position plus the live state.
6653
6654        ``RunState.as_suspend`` carries the identity and the route; everything
6655        a seed cannot reproduce (the four meters, the hold, the hull, the
6656        harvest progress) is read off the live services here, so a resume
6657        continues the run instead of refitting it.
6658        """
6659        if self.chart_screen is not None:
6660            self.state.armed_node_id = self.chart_screen.selected_id
6661        payload = self.state.as_suspend()
6662        if self.power is not None:
6663            payload["resources"].update(
6664                capacitor=float(self.power.capacitor),
6665                capacitor_max=float(self.power.capacitor_max),
6666                fuel=float(self.power.fuel),
6667                o2=float(self.power.o2),
6668            )
6669        if self.economy is not None:
6670            payload["resources"].update(
6671                scrap=float(self.economy.scrap),
6672                cores_banked_this_run=float(self.economy.cores_banked_this_run),
6673            )
6674            # The run ledger only settles at the end; mid-run the purse holds
6675            # the true income line.
6676            payload["ledger"]["scrap_earned"] = float(self.economy.scrap_earned_this_run)
6677        if self.ship is not None:
6678            payload["ship"].update(
6679                hull=float(self.ship.hull),
6680                hull_max=float(self.ship.hull_max),
6681                open_breaches=int(self.ship.open_breaches),
6682                position=[float(self.ship.position.x), float(self.ship.position.z)],
6683                heading=float(self.ship.heading),
6684            )
6685        if self.rack is not None:
6686            payload["ship"]["weapons"] = [
6687                {"hardpoint": hardpoint, "id": weapon.spec.id, "rounds": int(getattr(weapon, "rounds", -1))}
6688                for hardpoint, weapon in enumerate(self.rack.hardpoints)
6689                if weapon is not None
6690            ]
6691            payload["ship"]["ammo_boxes"] = {wid: int(held) for wid, held in self.rack.locker().items()}
6692            payload["ship"]["stowed_weapons"] = list(self.stowed_weapons)
6693        payload["ship"]["stowed_modules"] = list(self.stowed_modules)
6694        if self.sockets is not None:
6695            payload["ship"]["modules"] = [
6696                {"socket": index, "id": self.sockets.module_id_at(index)} for index, _node in self.sockets.installed()
6697            ]
6698        if self.signature is not None:
6699            payload["signature"].update(
6700                value=float(self.signature.value),
6701                act=int(self.signature.act),
6702                silent_running=bool(self.signature.silent_running),
6703            )
6704        if self.hunter_director is not None:
6705            payload["hunter"]["arrivals_this_run"] = int(self.hunter_director.arrivals_this_run)
6706        deck = self.state.deck
6707        payload["sector"]["event_draws"] = int(getattr(deck, "draws", 0))
6708        payload["sector"]["seen_events"] = sorted(deck.seen)
6709        if self.sector is not None:
6710            payload["sector"]["deposits"] = [
6711                {
6712                    "surface": float(deposit.remaining_surface),
6713                    "core": float(deposit.remaining_core),
6714                    "tapped": bool(deposit.tapped),
6715                    "split": bool(deposit.split),
6716                }
6717                for deposit in self.sector.deposits
6718            ]
6719        return payload
6720
6721    def _apply_restore(self, payload: dict) -> None:
6722        """Push a suspend payload's live state back into the rebuilt world.
6723
6724        A resume is the one arrival that is not a jump: the run was suspended
6725        somewhere in the sector and goes back exactly there, so the warp-in
6726        placement the boot's ``enter_sector`` did is overwritten here rather
6727        than skipped, and the rig is cut to wherever the hull really is.
6728        """
6729        resources = payload.get("resources") or {}
6730        ship_state = payload.get("ship") or {}
6731        signature = payload.get("signature") or {}
6732
6733        if self.ship is not None:
6734            self.ship.hull_max = float(ship_state.get("hull_max", self.ship.hull_max))
6735            self.ship.hull = float(ship_state.get("hull", self.ship.hull))
6736            self.ship.open_breaches = int(ship_state.get("open_breaches", 0))
6737            x, z = (list(ship_state.get("position") or [0.0, 0.0]) + [0.0, 0.0])[:2]
6738            self.ship.position = Vec3(float(x), PLANE_Y, float(z))
6739            self.ship.heading = float(ship_state.get("heading", self.ship.heading))
6740            self._cut_camera_to_ship()
6741        self._restore_fittings(ship_state)
6742        if self.power is not None:
6743            self.power.restore_levels(
6744                capacitor=float(resources.get("capacitor", self.power.capacitor)),
6745                fuel=float(resources.get("fuel", self.power.fuel)),
6746                o2=float(resources.get("o2", self.power.o2)),
6747            )
6748            self.power.open_breaches = int(ship_state.get("open_breaches", 0))
6749        if self.economy is not None:
6750            scrap = max(0.0, float(resources.get("scrap", 0.0)))
6751            if scrap > 0.0:
6752                self.economy.add_scrap(scrap, "resume")
6753            self.economy.scrap_earned_this_run = float(self.state.ledger.scrap_earned)
6754            self.economy.cores_banked_this_run = float(resources.get("cores_banked_this_run", 0.0))
6755            self.state.ledger.cores_banked_in_run = self.economy.cores_banked_this_run
6756        if self.signature is not None:
6757            saved = float(signature.get("value", self.signature.value))
6758            if saved > self.signature.value:
6759                self.signature.add(saved - self.signature.value, "baseline", raw=True)
6760        if self.notoriety is not None and self.state.notoriety > 0:
6761            self.notoriety.add(int(self.state.notoriety), "resume")
6762        if self.hunter_director is not None:
6763            self.hunter_director.arrivals_this_run = int((payload.get("hunter") or {}).get("arrivals_this_run", 0))
6764        self._restore_deposits((payload.get("sector") or {}).get("deposits") or [])
6765        # Last, and deliberately after the tank: the boot already armed this
6766        # sector's default, so a lane the pilot chose for themselves is put
6767        # back over the top of it and a run suspended with nothing armed comes
6768        # back armed like any other arrival. A saved lane the fan no longer
6769        # offers is refused by ``select`` and leaves the default standing.
6770        if self.chart_screen is not None and self.state.armed_node_id:
6771            self.chart_screen.select(self.state.armed_node_id)
6772
6773    def _restore_fittings(self, ship_state: dict) -> None:
6774        """Refit the racks to what the suspended run actually carried.
6775
6776        The rebuilt run launches with the starter fit, so without this a quit
6777        costs every weapon, box and module bought since minute one. Empty saved
6778        lists (including every payload written before fittings were captured)
6779        leave the starter fit alone. Runs before the power restore, so a
6780        refitted power module's sources are in place when the levels land.
6781        """
6782        saved_weapons = [r for r in (ship_state.get("weapons") or []) if isinstance(r, dict)]
6783        if self.rack is not None and saved_weapons:
6784            for hardpoint in range(len(self.rack.hardpoints)):
6785                self.rack.unequip(hardpoint)
6786            for record in saved_weapons:
6787                hardpoint = int(record.get("hardpoint", -1))
6788                weapon_id = str(record.get("id", ""))
6789                if weapon_id not in balance.WEAPONS or not 0 <= hardpoint < len(self.rack.hardpoints):
6790                    continue
6791                self.rack.equip(weapon_id, hardpoint)
6792                weapon = self.rack.hardpoints[hardpoint]
6793                rounds = int(record.get("rounds", -1))
6794                if rounds >= 0 and hasattr(weapon, "rounds"):
6795                    weapon.rounds = min(rounds, int(weapon.magazine_size))
6796        if self.rack is not None:
6797            for weapon_id, held in (ship_state.get("ammo_boxes") or {}).items():
6798                spec = balance.WEAPONS.get(str(weapon_id))
6799                if spec is None or spec.family != "ballistic":
6800                    continue
6801                for _ in range(max(0, int(held))):
6802                    self.rack.add_ammo_box(str(weapon_id))
6803        stowed = ship_state.get("stowed_weapons") or []
6804        if stowed:
6805            self.stowed_weapons = [str(w) for w in stowed if str(w) in balance.WEAPONS]
6806        loose = ship_state.get("stowed_modules") or []
6807        if loose:
6808            self.stowed_modules = [str(m) for m in loose if str(m) in MODULE_CATALOGUE]
6809        saved_modules = [r for r in (ship_state.get("modules") or []) if isinstance(r, dict)]
6810        if self.sockets is not None and saved_modules:
6811            for index, _node in self.sockets.installed():
6812                self.sockets.remove(index)
6813            for record in saved_modules:
6814                module_id = str(record.get("id", ""))
6815                socket_index = int(record.get("socket", -1))
6816                if module_id not in MODULE_CATALOGUE:
6817                    continue
6818                try:
6819                    self.sockets.install(module_id, socket_index)
6820                except ValueError:
6821                    # A payload from another hull layout; skipping one module
6822                    # beats refusing the whole resume.
6823                    continue
6824
6825    def _restore_deposits(self, saved: list) -> None:
6826        """Reapply per-deposit harvest progress onto the regenerated sector.
6827
6828        The sector regenerates deterministically from its seed, so the saved
6829        list lines up with the fresh one by order; a mismatched length means
6830        the payload is from another build of the generator and is skipped
6831        rather than misapplied.
6832        """
6833        if self.sector is None:
6834            return
6835        deposits = self.sector.deposits
6836        if len(saved) != len(deposits):
6837            return
6838        for record, deposit in zip(saved, deposits, strict=True):
6839            if not isinstance(record, dict):
6840                return
6841            deposit.remaining_surface = max(0.0, float(record.get("surface", deposit.remaining_surface)))
6842            deposit.remaining_core = max(0.0, float(record.get("core", deposit.remaining_core)))
6843            deposit.tapped = bool(record.get("tapped", deposit.tapped))
6844            if record.get("split", False):
6845                deposit.split_core()
6846            # The rock wears its own state, and a restored one has to wear the
6847            # state it was restored into rather than the one it was born with.
6848            deposit.refresh_visual()
6849
6850    def suspend_and_quit(self) -> None:
6851        self.suspend()
6852        self.quit()
6853
6854    # -- ledger sources ----------------------------------------------------
6855
6856    def _on_player_damaged(self, amount: float, direction: Vec3) -> None:
6857        """Remember where the last hit that reached the hull came from.
6858
6859        The bearing is the fallback the ledger names a killer from. The router
6860        itself records the attacker of every sourced hit before the hull
6861        resolves it (``DamageRouter.last_ship_attacker``), so the fatal shot
6862        names itself; what lands here is the history for hits nothing sourced,
6863        which is why :meth:`_attribute_killer` also has a proximity rule for a
6864        hull that died to the first thing that touched it.
6865        """
6866        del amount
6867        self._last_hit_bearing = Vec3(direction)
6868        self._last_hit_at = self.state.elapsed_s if self.state is not None else 0.0
6869
6870    def _attribute_killer(self) -> str:
6871        """Name what killed the ship, as specifically as the run can tell.
6872
6873        A fresh lantern burn first: the burn writes itself down before the
6874        hull resolves it (:func:`~shrike.hunter.killing_blow`), so a hull the
6875        beam finished is named for the beam and the dodge it teaches. The
6876        Shrike's mere presence claims nothing, because the beam is the only
6877        hull damage it deals: a hull a mite finished during an arrival died
6878        to the mite. So next the router's own record: enemies pass themselves
6879        as the source of every hit they deal, and the router writes the
6880        attacker down before the hull resolves the hit, so a death mid-hit
6881        reads the true killer. Only an unsourced death falls back to
6882        geometry: the enemy sitting on the bearing of the last hit that got
6883        through, nearest along that bearing, then whatever was inside
6884        :data:`KILLER_NEAR_UNITS`, which is close enough to have been on top
6885        of it. Failing all of it, a live Shrike in the sector takes the
6886        blame, and failing even that, the honest :data:`KILLER_UNKNOWN`.
6887        """
6888        if self.tree is None or self.ship is None:
6889            return KILLER_UNKNOWN
6890        hunter_present = any(
6891            isinstance(node, Hunter) and not node.destroying for node in self.tree.group(Groups.HUNTER)
6892        )
6893        if hunter_present and killing_blow(self.tree) == LANTERN_BLOW_ID:
6894            return LANTERN_BLOW_ID
6895        named = self.damage.last_ship_attacker() if self.damage is not None else ""
6896        if named:
6897            return named
6898        elapsed = self.state.elapsed_s if self.state is not None else 0.0
6899        bearing = self._last_hit_bearing if elapsed - self._last_hit_at <= KILLER_ATTRIBUTION_WINDOW_S else None
6900        unit = _plane_unit(bearing) if bearing is not None else None
6901
6902        best, best_distance = "", 0.0
6903        for enemy in self.tree.group(Groups.ENEMIES):
6904            offset = Vec3(enemy.position) - Vec3(self.ship.position)
6905            distance = math.hypot(float(offset.x), float(offset.z))
6906            if distance <= 1e-6:
6907                continue
6908            if unit is not None:
6909                alignment = (float(offset.x) * unit[0] + float(offset.z) * unit[1]) / distance
6910                if alignment < KILLER_BEARING_COS:
6911                    continue
6912            elif distance > KILLER_NEAR_UNITS:
6913                continue
6914            if not best or distance < best_distance:
6915                best, best_distance = _archetype_of(enemy), distance
6916        if best:
6917            return best
6918        return "hunter" if hunter_present else KILLER_UNKNOWN
6919
6920    def _on_ship_destroyed(self) -> None:
6921        """Note the death and let the world run on for :data:`DEATH_SETTLE_S`.
6922
6923        The killer is attributed here rather than when the timer expires, while
6924        the blow is still the freshest thing that happened; the wait is only so
6925        the blow gets a frame of its own before the ledger takes the screen.
6926        """
6927        if self._death_killer is not None or self.ended:
6928            return
6929        self._death_killer = self._attribute_killer()
6930        self._death_wait = DEATH_SETTLE_S
6931
6932    def _on_resource_collected(self, kind: str, amount: float) -> None:
6933        """Bank a scooped mote for the next "+N SCRAP" label, or stow ammo."""
6934        if str(kind) == "ammo":
6935            self._collect_ammo(float(amount))
6936            return
6937        if str(kind) != "scrap":
6938            return
6939        self._scrap_toast_amount += float(amount)
6940        self._scrap_toast_wait = SCRAP_TOAST_WINDOW_S
6941
6942    def _collect_ammo(self, rounds: float) -> None:
6943        """Turn scooped rounds into stowed boxes for whichever gun is hungriest.
6944
6945        A wreck's ammo bay releases one box's worth of rounds as a spray of
6946        motes, so the rounds bank up here and every full box is stowed through
6947        the same routing a depot purchase takes: the mounted ballistic weapon
6948        with the fewest spares. A hold with no ballistic weapon keeps the bank;
6949        the locker fills the moment one is fitted and the next round arrives.
6950        """
6951        self._ammo_rounds_bank += rounds
6952        if self.rack is None:
6953            return
6954        while self._ammo_rounds_bank >= balance.AMMO_BOX_ROUNDS - 1e-6:
6955            weapon_id = self._ammo_eater()
6956            if weapon_id is None:
6957                return
6958            self._ammo_rounds_bank -= balance.AMMO_BOX_ROUNDS
6959            self.rack.add_ammo_box(weapon_id)
6960            if self.hud is not None and self.ship is not None:
6961                self.hud.float_text(AMMO_BOX_FLOAT.format(name=_item_name(weapon_id)), node=self.ship)
6962
6963    def _tick_scrap_label(self, dt: float) -> None:
6964        """Float one label for everything scooped inside the merge window."""
6965        if self._scrap_toast_amount <= 0.0:
6966            return
6967        self._scrap_toast_wait -= dt
6968        if self._scrap_toast_wait > 0.0:
6969            return
6970        amount, self._scrap_toast_amount = self._scrap_toast_amount, 0.0
6971        if self.hud is not None and self.ship is not None:
6972            # The HUD banks the fractions and floats whole units only, so the
6973            # labels sum to exactly what the counter took: never "+0", and
6974            # never "+2" for a rock whose last mote paid 2.475.
6975            self.hud.credit_float("scrap", amount, "SCRAP", node=self.ship)
6976
6977    def _on_enemy_killed(self, archetype: str, position, elite: bool) -> None:
6978        del archetype, position
6979        ledger = self.state.ledger
6980        ledger.kills += 1
6981        if elite:
6982            ledger.elite_kills += 1
6983
6984    def _on_vault_hacked(self, scrap: float) -> None:
6985        del scrap
6986        self.state.ledger.vaults_cracked += 1
6987
6988    def _on_cores_banked(self, cores: float, rate: float) -> None:
6989        """Track in-run banking only.
6990
6991        The end-of-run settlement converts through the same economy, so once
6992        the run has ended this must stop mirroring or the ledger would count
6993        the settlement twice: once as banked, once as converted.
6994        """
6995        del cores, rate
6996        if self.ended or self.economy is None:
6997            return
6998        self.state.ledger.cores_banked_in_run = float(self.economy.cores_banked_this_run)
6999
7000    def _on_arrival_survived(self) -> None:
7001        """Count a survived arrival. The notoriety it earns is bounty.py's."""
7002        self.state.ledger.arrivals_survived += 1
7003
7004    def _on_quill(self, quills_this_run: int) -> None:
7005        self.state.ledger.quills = int(quills_this_run)
7006
7007    # -- teardown ----------------------------------------------------------
7008
7009    def on_exit_tree(self):
7010        """Retire the run-scoped services; only the meta ones outlive a run."""
7011        tree = self.tree
7012        if tree is None:
7013            return
7014        for name in self._service_names:
7015            tree.remove_singleton(name)
7016        self._service_names = []
7017
7018
7019def death_headline(ledger: RunLedger) -> str:
7020    """The one line the ledger opens with: what ended the run, in name.
7021
7022    Extraction has its own word for it. Everything else names the killer the
7023    run recorded, falling back to :data:`DEATH_HEADLINES` for the deaths that
7024    have no archetype behind them, so no run ever ends on "RUN ENDED".
7025    """
7026    if ledger.outcome == "extraction":
7027        return "EXTRACTED"
7028    killer = ledger.killed_by or KILLER_UNKNOWN
7029    named = DEATH_HEADLINES.get(killer)
7030    return named if named else f"DESTROYED BY {killer.replace('_', ' ').upper()}"
7031
7032
7033class LedgerScene(ShrikeScene):
7034    """The death (or extraction) ledger: what you banked, and what it cost.
7035
7036    Never a fail screen, and never a mystery either. It opens on the killer's
7037    name in the largest type on the screen with the run's one generated
7038    sentence directly under it, and only then itemises the conversion, the
7039    milestone bounties, the score against the hull's personal best and the
7040    assists that were on.
7041    """
7042
7043    def __init__(self, ledger: RunLedger, *, focus_entry: str = "", **kwargs):
7044        kwargs.setdefault("name", "Ledger")
7045        super().__init__(**kwargs)
7046        self.ledger = ledger
7047        self.focus_entry = str(focus_entry)
7048        self.menu: MenuList | None = None
7049
7050    def headline(self) -> str:
7051        """The screen's opening line: :func:`death_headline` for this ledger."""
7052        return death_headline(self.ledger)
7053
7054    def lines(self) -> list[str]:
7055        """The conversion table, in the order the screen prints it.
7056
7057        The headline and the recap are drawn above this in their own type, so
7058        neither is repeated here.
7059        """
7060        ledger = self.ledger
7061        out = [f"Scrap earned {ledger.scrap_earned:.0f}, carried {ledger.scrap_carried:.0f}"]
7062        out += [entry.line() for entry in ledger.conversion]
7063        if ledger.cores_banked_in_run > 0.0:
7064            out.append(f"Banked in run: {ledger.cores_banked_in_run:.1f} Cores")
7065        if ledger.milestones:
7066            out.append(f"Milestones: {', '.join(ledger.milestones)} (+{ledger.cores_from_milestones:.0f} Cores)")
7067        out.append(f"Cores this run: {ledger.cores_total:.1f}")
7068        out.append(f"Sector {ledger.deepest_sector}, {ledger.kills} kills, {ledger.quills} quills")
7069        best = "NEW BEST" if ledger.new_best else f"best {ledger.best_score}"
7070        out.append(f"Score {ledger.score} ({best})")
7071        if ledger.assists:
7072            out.append(f"Steady Wake: {', '.join(ledger.assists)}")
7073        return out
7074
7075    def on_ready(self):
7076        ledger = self.ledger
7077        subtitle = f"{ledger.elapsed_s / 60.0:.0f} minutes on the hull {ledger.hull_id}"
7078        box = self.build_panel(self.headline(), subtitle, title_size=LEDGER_HEADLINE_FONT_PX)
7079        # The recap is a generated sentence of unbounded length, and a panel
7080        # grows to fit its widest child: unwrapped, one long run stretched the
7081        # whole ledger out past the buttons it is a caption for.
7082        for line in wrap_prose(ledger.recap, LEDGER_RECAP_FONT_PX):
7083            recap = self.add_line(box, line)
7084            recap.font_size = LEDGER_RECAP_FONT_PX
7085        for line in self.lines():
7086            self.add_line(box, line)
7087        # What the number on the last row is, and where more of it comes from.
7088        # The ledger is where a player is paid, so it is where the currency has
7089        # to explain itself; the doctrine screen repeats it where it is spent.
7090        for line in wrap_prose(cores_source_line(self.profile()), MENU_LINE_FONT_PX):
7091            self.add_line(box, line, dim=True)
7092        self.menu = MenuList(box)
7093        self.menu.add("CONTINUE", lambda: self.go(MainMenuScene()))
7094        # The Cores this screen just paid out have somewhere to go, and the
7095        # ledger is where the player is looking when they are paid. Second
7096        # rather than first: the default row stays the way out.
7097        self.menu.add(LEDGER_DOCTRINE_ENTRY, self.open_doctrine)
7098        self.menu.add("RUN AGAIN", lambda: self.go(RunSetupScene()))
7099        self.add_line(box, MENU_CONTROLS_LINE, dim=True)
7100        self.menu.focus(self.focus_entry)
7101        self.settle_panel()
7102
7103    def open_doctrine(self) -> None:
7104        """Open the tree, with BACK returning to this ledger rather than past it."""
7105        ledger = self.ledger
7106        self.go(DoctrineScene(back_factory=lambda: LedgerScene(ledger, focus_entry=LEDGER_DOCTRINE_ENTRY)))
7107
7108    def on_update(self, dt: float):
7109        if self.menu is not None:
7110            self.menu.poll(dt)
7111
7112
7113class SettingsScene(ShrikeScene):
7114    """Accessibility, remapping and the Steady Wake assist panel.
7115
7116    Everything here is stored on the meta profile the moment it changes, and
7117    every assist is stamped on the ledger of any run that uses it. None of them
7118    locks content.
7119    """
7120
7121    def __init__(self, *, back_entry: str = MENU_ENTRY_SETTINGS, **kwargs):
7122        kwargs.setdefault("name", "Settings")
7123        super().__init__(**kwargs)
7124        self.menu: MenuList | None = None
7125        self.rebinding: str = ""
7126        #: Which front-door row the player came in through, so backing out puts
7127        #: the cursor back where they left it.
7128        self.back_entry = str(back_entry)
7129        self._sliders: dict[str, Slider] = {}
7130        self._slider_labels: dict[str, Label] = {}
7131        self._checks: dict[str, CheckBox] = {}
7132        self._damage_row = -1
7133        self._frame_cap_row = -1
7134        self._back: PressLatch | None = None
7135
7136    # -- edits -------------------------------------------------------------
7137
7138    def _live_audio(self):
7139        """The live ``AudioDirector``, when this screen is up over a run."""
7140        tree = self.tree
7141        if tree is None:
7142            return None
7143        return tree.singletons.get(Services.AUDIO)
7144
7145    def set_slider(self, name: str, value: float) -> None:
7146        """Move one of the feel sliders, the UI scale, or one of the mix rows."""
7147        settings = self.settings()
7148        if name == "ui_scale":
7149            settings.ui_scale = min(UI_SCALE_MAX, max(UI_SCALE_MIN, float(value)))
7150        else:
7151            setattr(settings, name, _clamp01(value))
7152        if name.startswith("volume_"):
7153            # A mix slider has to be audible while it is being dragged, or the
7154            # only way to set it is to leave the screen and come back.
7155            settings.apply_volumes(self._live_audio())
7156        self.persist()
7157
7158    def set_photosensitive(self, on: bool) -> None:
7159        self.settings().photosensitive = bool(on)
7160        self.persist()
7161
7162    def set_controls_bar(self, on: bool) -> None:
7163        """Show or hide the persistent controls bar along the bottom of the HUD."""
7164        self.settings().controls_bar = bool(on)
7165        self.persist()
7166
7167    def set_hold(self, action: str, hold: bool) -> None:
7168        """Hold-versus-toggle for fire, shield and silent running."""
7169        if action not in HOLD_OR_TOGGLE_ACTIONS:
7170            raise ValueError(f"{action!r} is not a hold-or-toggle action")
7171        self.settings().hold_actions[action] = bool(hold)
7172        self.persist()
7173
7174    def cycle_frame_cap(self) -> int:
7175        """Step the frame cap through :data:`FRAME_CAP_OPTIONS`, live, and store it.
7176
7177        Applied as well as stored: ``App.target_fps`` is writable mid-run, so the
7178        new cap takes effect on the next frame. It is still written to disk
7179        because ``main.resolve_frame_cap`` reads it at boot, which is what makes
7180        the choice survive a restart rather than what makes it take effect.
7181        """
7182        options = FRAME_CAP_OPTIONS
7183        settings = self.settings()
7184        current = _frame_cap_of(settings.frame_cap)
7185        index = options.index(current) if current in options else 0
7186        settings.frame_cap = options[(index + 1) % len(options)]
7187        self.persist()
7188        _apply_frame_cap(settings.frame_cap)
7189        return settings.frame_cap
7190
7191    def set_vsync(self, on: bool) -> None:
7192        """Flip vsync, live, the same way the frame cap now goes."""
7193        settings = self.settings()
7194        settings.vsync = bool(on)
7195        self.persist()
7196        _apply_vsync(settings.vsync)
7197
7198    def cycle_damage_taken(self) -> float:
7199        """Step the Steady Wake damage slider through its three settings."""
7200        steps = balance.ASSIST_DAMAGE_TAKEN_STEPS
7201        settings = self.settings()
7202        index = steps.index(_nearest_step(settings.damage_taken))
7203        settings.damage_taken = steps[(index + 1) % len(steps)]
7204        self.persist()
7205        return settings.damage_taken
7206
7207    def toggle_assist(self, name: str) -> bool:
7208        """Flip one of the Steady Wake toggles and hand back its new state."""
7209        if name not in ("slow_signature", "slow_lantern", "spool_immunity"):
7210            raise ValueError(f"{name!r} is not a Steady Wake toggle")
7211        settings = self.settings()
7212        value = not getattr(settings, name)
7213        setattr(settings, name, value)
7214        self.persist()
7215        return value
7216
7217    def rebind(self, action: str, bindings) -> None:
7218        """Rebind *action* to *bindings*, keeping its pad axes.
7219
7220        Raises for an unknown action or a binding that cannot be stored, so a
7221        control can never end up silently unbound.
7222        """
7223        if action not in REMAPPABLE_ACTIONS:
7224            raise ValueError(f"{action!r} is not remappable")
7225        tokens = []
7226        for binding in bindings:
7227            token = encode_binding(binding)
7228            if token is None:
7229                raise ValueError(f"{binding!r} cannot be bound from the options screen")
7230            tokens.append(token)
7231        self.settings().bindings[action] = tokens
7232        self.persist()
7233
7234    def reset_bindings(self) -> None:
7235        self.settings().bindings.clear()
7236        self.persist()
7237
7238    def persist(self) -> None:
7239        """Store the settings on the profile and write it out."""
7240        self.settings().store(self.profile())
7241        self.save_system().save_profile(self.profile())
7242
7243    # -- screen ------------------------------------------------------------
7244
7245    def on_ready(self):
7246        self._back = PressLatch(PAUSE_ACTION)
7247        settings = self.settings()
7248        box = self.build_panel("SETTINGS", "nothing here locks content")
7249
7250        for name, label in (("shake", "Screen shake"), ("flash", "Flash"), ("hit_stop", "Hit stop")):
7251            self._sliders[name] = self._slider(box, label, getattr(settings, name), 0.0, 1.0, name)
7252        self._sliders["ui_scale"] = self._slider(
7253            box, "UI scale", settings.ui_scale, UI_SCALE_MIN, UI_SCALE_MAX, "ui_scale"
7254        )
7255        for name, label in VOLUME_SETTINGS:
7256            self._sliders[name] = self._slider(box, label, getattr(settings, name), 0.0, 1.0, name)
7257        self._checks["photosensitive"] = box.add_child(
7258            CheckBox("Photosensitivity mode", checked=settings.photosensitive, on_toggle=self.set_photosensitive)
7259        )
7260        self._checks["controls_bar"] = box.add_child(
7261            CheckBox("Controls bar (H)", checked=settings.controls_bar, on_toggle=self.set_controls_bar)
7262        )
7263        for action in HOLD_OR_TOGGLE_ACTIONS:
7264            self._checks[action] = box.add_child(
7265                CheckBox(
7266                    f"Hold to {action.replace('_', ' ')}",
7267                    checked=settings.hold_to_use(action),
7268                    on_toggle=_bind(self.set_hold, action),
7269                )
7270            )
7271
7272        self.add_line(box, "STEADY WAKE", dim=True)
7273        for name, label in (
7274            ("slow_signature", "Signature fills slower"),
7275            ("slow_lantern", "Lantern sweeps slower"),
7276            ("spool_immunity", "Spool cannot be interrupted"),
7277        ):
7278            self._checks[name] = box.add_child(
7279                CheckBox(label, checked=getattr(settings, name), on_toggle=_bind(self._set_assist, name))
7280            )
7281
7282        self._checks["vsync"] = box.add_child(
7283            CheckBox("Vertical sync", checked=settings.vsync, on_toggle=self.set_vsync)
7284        )
7285
7286        self.menu = MenuList(box)
7287        self.menu.add(self.damage_row_label(), self._cycle_damage_row)
7288        self._damage_row = 0
7289        self.menu.add(self.frame_cap_row_label(), self._cycle_frame_cap_row)
7290        self._frame_cap_row = 1
7291        self.menu.add("RESET KEY BINDINGS", self.reset_bindings)
7292        self.menu.add("BACK", self.back)
7293        self.add_line(box, MENU_CONTROLS_LINE_BACK, dim=True)
7294        self.settle_panel()
7295
7296    def back(self) -> None:
7297        """Return to the front door with the cursor on the row we came in by."""
7298        self.go(MainMenuScene(focus_entry=self.back_entry))
7299
7300    def on_update(self, dt: float):
7301        if self._back is not None and self._back.poll(dt)[0]:
7302            self.back()
7303            return
7304        if self.menu is not None:
7305            self.menu.poll(dt)
7306
7307    # -- live labels -------------------------------------------------------
7308
7309    def damage_row_label(self) -> str:
7310        """The damage row, carrying the setting it is currently on.
7311
7312        "DAMAGE TAKEN: CYCLE" named the verb and hid the value, so the only way
7313        to read an assist that changes how much damage the hull takes was to
7314        press it three times and watch what happened.
7315        """
7316        return SETTINGS_DAMAGE_ROW.format(percent=self.settings().damage_taken * 100.0)
7317
7318    def _cycle_damage_row(self) -> float:
7319        """Step the damage assist and rewrite the row with its new value."""
7320        value = self.cycle_damage_taken()
7321        if self.menu is not None and self._damage_row >= 0:
7322            self.menu.relabel(self._damage_row, self.damage_row_label())
7323        return value
7324
7325    def frame_cap_row_label(self) -> str:
7326        """The frame cap row, carrying the cap it is currently set to."""
7327        cap = _frame_cap_of(self.settings().frame_cap)
7328        return SETTINGS_FRAME_CAP_ROW.format(value=SETTINGS_FRAME_CAP_FOLLOW if cap == 0 else f"{cap} FPS")
7329
7330    def _cycle_frame_cap_row(self) -> int:
7331        """Step the frame cap and rewrite the row with its new value."""
7332        value = self.cycle_frame_cap()
7333        if self.menu is not None and self._frame_cap_row >= 0:
7334            self.menu.relabel(self._frame_cap_row, self.frame_cap_row_label())
7335        return value
7336
7337    def slider_label(self, name: str, label: str, value: float) -> str:
7338        """One slider's caption, with its value written out beside its name."""
7339        if name == "ui_scale":
7340            return SETTINGS_SCALE_ROW.format(name=label, value=value)
7341        return SETTINGS_SLIDER_ROW.format(name=label, percent=value * 100.0)
7342
7343    def _slider(self, box: VBoxContainer, label: str, value: float, low: float, high: float, name: str) -> Slider:
7344        caption = self.add_line(box, self.slider_label(name, label, value), dim=True)
7345        self._slider_labels[name] = caption
7346        slider = box.add_child(Slider(low, high, value, size_y=MENU_ROW_HEIGHT_PX))
7347        slider.size_x = MENU_WIDTH_PX
7348        slider.step = 0.05
7349        slider.value_changed.connect(_bind(self._on_slider_moved, name, label, slider))
7350        return slider
7351
7352    def _on_slider_moved(self, name: str, label: str, slider: Slider, *_) -> None:
7353        """Store the new value and write it into the slider's own caption."""
7354        self.set_slider(name, slider.value)
7355        caption = self._slider_labels.get(name)
7356        if caption is not None:
7357            _set_text(caption, self.slider_label(name, label, slider.value))
7358
7359    def _set_assist(self, name: str, on: bool) -> None:
7360        setattr(self.settings(), name, bool(on))
7361        self.persist()
7362
7363
7364# ============================================================================
7365# Entry point
7366# ============================================================================
7367
7368
7369def create_entry_scene() -> Node:
7370    """The scene ``main.py`` boots.
7371
7372    The game opens on :class:`MainMenuScene`, its front door. Setting
7373    :data:`BOOT_STRAIGHT_INTO_RUN` boots a live run instead, which is a debug
7374    convenience rather than a shipping mode. Either way the menu, the setup
7375    screen, the ledger and the settings are one ``change_scene`` apart.
7376    """
7377    if BOOT_STRAIGHT_INTO_RUN:
7378        return RunScene(RunConfig(seed=random.randrange(1, 2**31)).as_dict())
7379    return MainMenuScene()
7380
7381
7382# ============================================================================
7383# Small helpers
7384# ============================================================================
7385
7386
7387def _return_to_menu(tree, settings: Settings, profile: dict):
7388    """A handler that puts the main menu back up, bound to the tree.
7389
7390    Bound to the tree rather than to a scene on purpose: the screen that asks
7391    for this is the one being swapped out, and it is gone by the time the
7392    handler runs.
7393    """
7394
7395    def handler(*_):
7396        scene = MainMenuScene(settings=settings, profile=profile)
7397        scene.input_actions = settings.input_actions()
7398        tree.change_scene(scene)
7399
7400    return handler
7401
7402
7403def _item_name(item_id: str) -> str:
7404    """A catalogue id as the toast line spells it: RAIL LANCE, not rail_lance."""
7405    return item_name(item_id)
7406
7407
7408def _bind(function, *args):
7409    """A zero-argument (or engine-argument) callable that calls *function*."""
7410
7411    def call(*extra):
7412        return function(*args, *extra)
7413
7414    return call
7415
7416
7417def _make_audio() -> Node | None:
7418    """The audio director, or None when the audio backend cannot bake a bank.
7419
7420    A headless box without an audio device must still be able to run the game,
7421    and silence is a better failure than a run that will not boot.
7422    """
7423    try:
7424        from .audio import AudioDirector
7425    except Exception:  # pragma: no cover - import-time backend failure
7426        return None
7427    try:
7428        return AudioDirector(name="AudioDirector")
7429    except Exception:  # pragma: no cover - device or bake failure
7430        return None
7431
7432
7433def _plane_unit(vector: Vec3) -> tuple[float, float] | None:
7434    """*vector* on the flight plane, normalised, or None when it has no length."""
7435    length = math.hypot(float(vector.x), float(vector.z))
7436    if length <= 1e-6:
7437        return None
7438    return (float(vector.x) / length, float(vector.z) / length)
7439
7440
7441def _archetype_of(enemy: Node) -> str:
7442    """The archetype id of *enemy*, spelled the way ``combat.py`` reports kills."""
7443    spec = getattr(enemy, "spec", None)
7444    archetype = getattr(spec, "id", None)
7445    return str(archetype) if archetype else type(enemy).__name__.lower()
7446
7447
7448def _loudest_gain(meter: SignatureMeter) -> str:
7449    """The signature reason that charged the most this sector, for the recap."""
7450    gains = {reason: value for reason, value in meter.gains.items() if reason != "baseline"}
7451    if not gains:
7452        return ""
7453    return max(gains.items(), key=lambda item: item[1])[0].replace("_", " ")
7454
7455
7456def _unlock_hull(profile: dict, hull_id: str) -> None:
7457    hulls = profile.setdefault("hulls", {"unlocked": [save.STARTER_HULL], "selected": save.STARTER_HULL})
7458    unlocked = hulls.setdefault("unlocked", [save.STARTER_HULL])
7459    if hull_id not in unlocked:
7460        unlocked.append(hull_id)
7461
7462
7463def hull_socket_count(hull_id: str) -> int:
7464    """How many sockets *hull_id* carries, from the module layout tables.
7465
7466    The setup screen quotes this rather than ``balance.HULL_SOCKETS`` so the
7467    number on the launch screen is the one the rack will really build.
7468    """
7469    return len(sockets_for_hull(hull_id))