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