shrike/chart.py¶

Part of SHRIKE.

   1"""The star chart: a branching route eaten from behind, and the warp that pays for it.
   2
   3Two things live here. :class:`ChartGraph` is the run's map as pure data: 14 to
   418 nodes laid out in columns across three acts, every edge carrying its honest
   5fuel price and whether a drift depot sits on it, and a red Wake front that eats
   6one column roughly every two jumps. :class:`StarChart` is the full-screen pause
   7screen that draws it and drives the warp.
   8
   9**Every line runs both ways.** The generator shapes a forward fan and then
  10mirrors it, so a charted route can be flown back down at the ordinary price.
  11That is what makes the Wake front matter: a map you can only walk forward
  12through is already safe from farming by its own geometry, and a red band behind
  13such a pilot is decoration. Backtracking is a loan rather than a savings
  14account, and the front is what charges the interest. A column the front has
  15taken is still a destination and still priced; what it costs is charged on
  16arrival (the run scene starts the meter high and the Shrike's warning short),
  17and the screen says so before the fuel is spent. The front is clamped to the
  18pilot's own column, so it can close on the ground under their feet but can
  19never eat the Deep Gate out in front of them.
  20
  21Honesty is the whole design of this screen. Every destination shows what it is
  22before you pay for it: its biome, what it is rich and poor in, whether a depot
  23sits on the way, what the jump costs, and how many jumps remain before the Wake
  24takes each of the next :data:`~shrike.balance.WAKE_PREVIEW_ADVANCES` columns.
  25Nothing is revealed on arrival that could have been shown here.
  26
  27The warp prices, all from ``balance``:
  28
  29* ``WARP_FUEL_BASE`` for a jump to the next column, plus
  30  ``WARP_FUEL_PER_COLUMN_SKIPPED`` for each column the edge leaps over.
  31* ``EMERGENCY_WARP_FUEL_MULT`` on the lot while the hunter is in the sector:
  32  the panic button is priced, not free.
  33* ``DEEP_GATE_TOLL`` on top for the jump through the Deep Gate, which is the
  34  extraction itself and so quotes the toll in the same price the pilot reads.
  35
  36Honesty runs to what the map must contain as well as to what it shows: a run
  37cannot be dealt a chart with no air on it, so :data:`O2_GUARANTEE_COLUMNS`
  38columns in there is always a reachable Ice Field or Vent Field.
  39
  40It runs to the names too. A chart deals a biome three or four times, so the
  41biome cannot be what a sector is *called*: every node gets a name no other node
  42on the chart answers to (:meth:`ChartGraph._name_nodes`), and that name is what
  43the boxes, the jump button, the warnings and the arrival card all print.
  44Otherwise "JUMP: SOLAR SHALLOWS, 15 FUEL" names three destinations at once, one
  45of them swept and one of them the sector the pilot just left.
  46
  47The channel is the ship's: :class:`StarChart` calls ``begin_warp_spool`` and
  48banks the fuel only when ``warp_completed`` fires, so a spool broken by fire (
  49``balance.WARP_SPOOL_PENALTY_PER_HIT_S`` per hit) costs nothing but the time.
  50A committed route lives exactly as long as its channel: any ending releases it,
  51and a channel that outlives the fuel to pay for it refuses the jump out loud
  52rather than arriving somewhere for free.
  53
  54Run state is read, not owned. :meth:`StarChart.open` takes whatever object the
  55run scene uses for it and reads ``chart``, ``current_node_id`` and
  56``jumps_taken`` off it, writing the last two back when a jump completes.
  57"""
  58
  59from __future__ import annotations
  60
  61import math
  62import random
  63from collections import Counter
  64from dataclasses import dataclass, field
  65
  66from simvx.core import Anchor, Control, Input, PointerFilter, Property, Signal, UpdateMode
  67
  68from . import balance
  69from .hud import GLYPH_ADVANCE_FRACTION, TEXT_EM_PX
  70from .runtime import Groups, Services
  71from .ship import SPOOL_FAILURE_NO_FUEL
  72
  73# ============================================================================
  74# Local constants
  75#
  76# balance.py owns what changes how the game plays. The generator shape below
  77# and the pixel geometry further down are this module's own business.
  78# ============================================================================
  79
  80#: Chance that a node also gets an edge leaping the next column entirely. Skip
  81#: edges are what make ``WARP_FUEL_PER_COLUMN_SKIPPED`` a live decision rather
  82#: than a rule with no instances.
  83SKIP_EDGE_CHANCE = 0.35
  84
  85#: The first column is the arrival node and the last is the Deep Gate; every
  86#: column between them is this wide before the generator widens some of them.
  87COLUMN_WIDTH_MIN = 2
  88COLUMN_WIDTH_MAX = 3
  89
  90#: Biomes the deck may deal per act. Act 1 is the deep breath, so it stays on
  91#: the gentle three; the Broker Claim is dealt at most once and never in Act 1.
  92ACT_BIOME_POOL: dict[int, tuple[str, ...]] = {
  93    1: ("debris_field", "solar_shallows", "ice_field"),
  94    2: ("debris_field", "solar_shallows", "ice_field", "nebula", "wreck_graveyard", "vent_field"),
  95    3: ("nebula", "wreck_graveyard", "vent_field", "debris_field", "ice_field"),
  96}
  97
  98#: Biomes whose sectors grow breathable ice or vent it. Reading a chart for
  99#: air means reading for one of these, and the generator guarantees one.
 100O2_BIOMES: tuple[str, ...] = ("ice_field", "vent_field")
 101#: How deep the air guarantee reaches: every seed puts a reachable O2 biome no
 102#: further out than this column. A tank is 400 seconds and a sector is not far
 103#: short of that, so a map that deals three dry columns is a map that kills a
 104#: pilot who did nothing wrong.
 105O2_GUARANTEE_COLUMNS = balance.CHART_O2_GUARANTEE_COLUMNS
 106
 107#: Short, honest labels for what a node holds. Drawn on the node itself.
 108ICON_SCRAP_RICH = "SCRAP++"
 109ICON_SCRAP_POOR = "SCRAP-"
 110ICON_SOLAR = "SOLAR"
 111ICON_NO_SOLAR = "DARK"
 112ICON_O2 = "O2"
 113ICON_FUEL = "FUEL"
 114ICON_VAULT = "VAULT"
 115ICON_MUFFLED = "MUFFLED"
 116ICON_BROKER = "BROKER"
 117ICON_GATE = "GATE"
 118ICON_ROOST = "ROOST"
 119
 120ACT_LABELS = {1: "THE SHALLOWS", 2: "THE CLAIMS", 3: "THE DEEP"}
 121
 122ROOST_NODE_ID = "roost"
 123
 124#: Roman numerals, largest first, for the suffix that tells two sectors of the
 125#: same biome apart. A chart deals at most ``balance.CHART_NODES_MAX`` nodes,
 126#: so the ladder never has to climb far, but it is written as a real converter
 127#: rather than a table because a table is a thing that runs out in silence.
 128ROMAN_STEPS: tuple[tuple[int, str], ...] = ((10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"))
 129
 130
 131# ============================================================================
 132# Screen geometry and palette (presentation, in pixels at UI scale 1.0)
 133# ============================================================================
 134
 135PANEL_MARGIN_PX = 46.0
 136FOOTER_HEIGHT_PX = 52.0
 137NODE_WIDTH_PX = 132.0
 138NODE_HEIGHT_PX = 74.0
 139NODE_LINE_HEIGHT_PX = 15.0
 140NODE_FONT_SCALE = 0.8
 141#: How far the node's rows are inset from its box, each side. A nine-column
 142#: chart leaves no gap at all between boxes, so a name that overruns its own
 143#: box is a name printed into the next sector's; the name row is set smaller
 144#: to fit rather than allowed to spill.
 145NODE_TEXT_INSET_PX = 6.0
 146HEADER_FONT_SCALE = 1.25
 147LABEL_FONT_SCALE = 0.85
 148EDGE_THICKNESS_PX = 2.0
 149SELECTED_THICKNESS_PX = 3.0
 150DEPOT_MARKER_RADIUS_PX = 6.0
 151#: The wake preview label's plate, sized for "TAKEN IN N" at the label scale.
 152WAKE_LABEL_WIDTH_PX = 104.0
 153WAKE_LABEL_HEIGHT_PX = 20.0
 154#: The renderer stacks lines of type on this pitch over :data:`hud.TEXT_EM_PX`,
 155#: so a drawn row occupies :func:`text_line_px` and the row under it starts
 156#: there. The header band's three rows are derived from that rather than
 157#: measured by eye: set by hand they overlapped by two pixels at each seam, so
 158#: the one sentence on this screen that says what the red band is arrived with
 159#: its ascenders in the title and its descenders under the preview plates,
 160#: illegible on the only screen that ever explains the mark.
 161TEXT_LINE_PITCH = 1.2
 162
 163
 164def text_line_px(font_scale: float) -> float:
 165    """The height one drawn row of type occupies at *font_scale*, at UI scale 1."""
 166    return TEXT_EM_PX * TEXT_LINE_PITCH * float(font_scale)
 167
 168
 169def text_width_px(text: str, font_scale: float) -> float:
 170    """How wide *text* draws at *font_scale*, in the renderer's own advance."""
 171    return len(str(text)) * TEXT_EM_PX * GLYPH_ADVANCE_FRACTION * float(font_scale)
 172
 173
 174def fitted_font_scale(text: str, font_scale: float, width: float) -> float:
 175    """*font_scale*, shrunk just enough that *text* draws no wider than *width*."""
 176    drawn = text_width_px(text, font_scale)
 177    if drawn <= 0.0 or width <= 0.0:
 178        return float(font_scale)
 179    return float(font_scale) * min(1.0, width / drawn)
 180
 181
 182#: The header band, top down: the title, the legend, and the preview plates at
 183#: its foot. Each row starts where the one above it ends.
 184HEADER_TITLE_OFFSET_PX = 8.0
 185LEGEND_OFFSET_PX = HEADER_TITLE_OFFSET_PX + text_line_px(HEADER_FONT_SCALE)
 186HEADER_HEIGHT_PX = LEGEND_OFFSET_PX + text_line_px(LABEL_FONT_SCALE) + WAKE_LABEL_HEIGHT_PX
 187#: The footer's jump button: the mouse's way to commit, and the label that
 188#: names what committing costs.
 189CONFIRM_WIDTH_PX = 300.0
 190CONFIRM_HEIGHT_PX = 30.0
 191#: Seconds a refusal stays on the chart's own status line. The screen answers
 192#: its own clicks: a HUD toast under this backdrop is a sentence in the dark.
 193STATUS_HOLD_S = 3.5
 194
 195#: The cursor's keys. WASD is the flight grip the hands are already in; the
 196#: menu actions are the arrows every other screen in the game advertises as
 197#: "W/S OR ARROWS", and a chart that took only WASD was the one screen where
 198#: that sentence was false. Both halves of the arrow block are read, because
 199#: the footer offers the block whole: a map is walked sideways as readily as
 200#: it is walked down, and Left and Right sitting dead beside a working Up and
 201#: Down answers half of what the footer advertises. Tab is the tractor scoop
 202#: and means nothing here, so it stays unbound and the footer does not offer
 203#: it.
 204CYCLE_FORWARD_ACTIONS = ("thrust_down", "thrust_right", "menu_down", "menu_right")
 205CYCLE_BACK_ACTIONS = ("thrust_up", "thrust_left", "menu_up", "menu_left")
 206CONFIRM_ACTIONS = ("interact", "menu_confirm")
 207
 208#: The footer, in keys rather than in verbs. A player who has only ever seen
 209#: this screen cannot map "JUMP: INTERACT" onto a keyboard, and closes the
 210#: chart rather than guess. It names every key that works and no key that does
 211#: not, on its own row; the row under it says what the screen does rather than
 212#: what it takes. One row carrying both would run wider than the slot it is
 213#: centred in, so whatever was added to it would be paid for by the clauses at
 214#: either end.
 215FOOTER_HINT = "MOVE: WASD OR ARROWS   JUMP: F OR ENTER   CLOSE: M   CLICK TO SELECT, CLICK AGAIN TO JUMP"
 216#: The order the keys walk the offer sheet in, said rather than learned by
 217#: pressing. The cursor runs the ways deeper first and the ways back last (see
 218#: :meth:`StarChart._cursor_order`), which is why one press backwards from the
 219#: opening selection lands on the lane home; a pilot who has not been told that
 220#: reads their first press as landing somewhere arbitrary.
 221FOOTER_HINT_SECOND = "CURSOR RUNS DEEPER FIRST, THEN BACK   EVERY LINE RUNS BOTH WAYS"
 222FOOTER_STRANDED = "NO AFFORDABLE JUMP   LAST STAND"
 223#: The one line naming what the red band is, under the header, on the only
 224#: screen that ever shows it. Without it a player reads a red rectangle
 225#: creeping up behind them, two orange countdowns to something unnamed, and no
 226#: reason to believe any of it is theirs to act on.
 227WAKE_LEGEND = "RED IS THE SHRIKE'S SWEPT GROUND: STILL YOURS TO JUMP INTO, BUT THE HUNT STARTS HOT THERE"
 228#: The jump button's four states.
 229CONFIRM_LABEL = "JUMP: {label}, {fuel:.0f} FUEL"
 230CONFIRM_LABEL_WAKE = "JUMP: {label}, {fuel:.0f} FUEL, SHRIKE TERRITORY"
 231CONFIRM_LABEL_EMPTY = "PICK A SECTOR"
 232CONFIRM_LABEL_SHORT = "NEEDS {fuel:.0f} FUEL"
 233#: What a click on a sector the drive cannot take is answered with.
 234STATUS_NO_ROUTE = "NO ROUTE THERE FROM HERE: PICK A SECTOR THE LINES REACH"
 235STATUS_UNAFFORDABLE = "NOT ENOUGH FUEL: NEEDS {need:.0f}, YOU HAVE {have:.0f}"
 236#: The warning a red destination earns, before any fuel is spent on it. The
 237#: Wake warns rather than refuses: swept ground stays a destination.
 238#: It leads with the name, because the chart deals four Solar Shallows and the
 239#: warning is about exactly one of them.
 240STATUS_WAKE_TERRITORY = "{label}: SHRIKE TERRITORY, IT IS WAITING THERE. F OR ANOTHER CLICK JUMPS ANYWAY"
 241STATUS_SELECTED = "{label} SELECTED: F OR ANOTHER CLICK JUMPS"
 242#: What the node's price row says instead of a fuel figure once the front has
 243#: been through, and what the preview lines count down to.
 244NODE_PRICE_WAKE = "FUEL {fuel:.0f}, SHRIKE"
 245WAKE_PREVIEW_LABEL = "TAKEN IN {jumps}"
 246
 247COLOUR_BACKDROP = (0.02, 0.03, 0.05, 0.92)
 248COLOUR_PANEL = (0.07, 0.09, 0.13, 0.95)
 249COLOUR_NODE = (0.12, 0.15, 0.21, 0.95)
 250COLOUR_NODE_CURRENT = (0.20, 0.32, 0.40, 0.95)
 251COLOUR_NODE_CONSUMED = (0.26, 0.05, 0.05, 0.95)
 252COLOUR_EDGE = (0.38, 0.44, 0.55, 0.75)
 253COLOUR_EDGE_REACHABLE = (0.62, 0.86, 1.00, 0.95)
 254COLOUR_EDGE_UNAFFORDABLE = (0.45, 0.30, 0.30, 0.80)
 255COLOUR_SELECTED = (1.00, 0.86, 0.42, 1.00)
 256COLOUR_TEXT = (0.84, 0.88, 0.95, 0.95)
 257COLOUR_TEXT_DIM = (0.55, 0.60, 0.70, 0.90)
 258COLOUR_WAKE = (0.72, 0.09, 0.08, 0.55)
 259COLOUR_WAKE_PREVIEW = (0.86, 0.32, 0.16, 0.85)
 260COLOUR_DEPOT = (0.55, 1.00, 0.78, 0.95)
 261COLOUR_BROKER = (0.90, 0.55, 1.00, 0.95)
 262
 263
 264# ============================================================================
 265# Chart data
 266# ============================================================================
 267
 268
 269def _rect_holds(rect: tuple[float, float, float, float], x: float, y: float) -> bool:
 270    """Whether a drawn rect contains a screen point."""
 271    rx, ry, rw, rh = rect
 272    return rw > 0.0 and rh > 0.0 and rx <= x <= rx + rw and ry <= y <= ry + rh
 273
 274
 275def _any_just_pressed(actions: tuple[str, ...]) -> bool:
 276    """Whether any of *actions* went down this frame."""
 277    return any(Input.just_pressed(action) for action in actions)
 278
 279
 280@dataclass(frozen=True)
 281class ChartEdge:
 282    """One route between two chart nodes.
 283
 284    ``columns_skipped`` is how many whole columns the edge leaps over, which is
 285    what ``balance.WARP_FUEL_PER_COLUMN_SKIPPED`` charges for. ``depot`` marks
 286    the drift depot sitting on the way; ``broker`` marks that depot as the
 287    Broker barge, which only a notorious ship ever sees.
 288    """
 289
 290    source: str
 291    target: str
 292    columns_skipped: int
 293    depot: bool = False
 294    broker: bool = False
 295
 296
 297@dataclass
 298class ChartNode:
 299    """One sector the run may visit.
 300
 301    ``icons`` is the honest read: every entry is derived from the same balance
 302    tables the sector generator uses, so what the chart promises is what the
 303    sector holds.
 304
 305    ``name`` is what the graph settled on once it had seen the whole chart, and
 306    is the only spelling any screen should print. It is left empty until the
 307    generator names the map, so a node built by hand still reads as its biome.
 308    """
 309
 310    id: str
 311    column: int
 312    row: int
 313    act: int
 314    biome: str
 315    icons: tuple[str, ...] = ()
 316    scrap_estimate: int = 0
 317    edges: list[ChartEdge] = field(default_factory=list)
 318    consumed: bool = False
 319    is_deep_gate: bool = False
 320    is_roost: bool = False
 321    requires_notoriety: int = 0
 322    name: str = ""
 323
 324    @property
 325    def base_label(self) -> str:
 326        """What kind of place this is, before the chart makes it tellable apart."""
 327        if self.is_deep_gate:
 328            return "DEEP GATE"
 329        if self.is_roost:
 330            return "THE ROOST"
 331        return self.biome.replace("_", " ").upper()
 332
 333    @property
 334    def label(self) -> str:
 335        """The name drawn on the node, unique across the chart it belongs to.
 336
 337        A chart deals four Solar Shallows and three Ice Fields as a matter of
 338        course, so "JUMP: SOLAR SHALLOWS, 15 FUEL" named three different
 339        destinations on one screen and the pilot could not tell which of them
 340        the cursor was standing on, nor that the one behind them was the one
 341        they had come from. Duplicates carry a roman numeral; a biome dealt
 342        once keeps its bare name.
 343        """
 344        return self.name or self.base_label
 345
 346
 347@dataclass(frozen=True)
 348class WarpQuote:
 349    """The whole price of one jump, itemised the way the screen shows it."""
 350
 351    source: str
 352    target: str
 353    columns_skipped: int
 354    base: float
 355    skip_surcharge: float
 356    emergency: bool
 357    toll: float
 358
 359    @property
 360    def total(self) -> float:
 361        """Fuel the jump actually costs, tolls and emergency premium included."""
 362        drive = (self.base + self.skip_surcharge) * (balance.EMERGENCY_WARP_FUEL_MULT if self.emergency else 1.0)
 363        return drive + self.toll
 364
 365
 366@dataclass(frozen=True)
 367class WakeAdvance:
 368    """One of the Wake front's coming meals, as the chart previews it."""
 369
 370    column: int
 371    jumps_until: int
 372
 373
 374@dataclass(frozen=True)
 375class Destination:
 376    """A destination as the screen offers it: priced, gated and legible."""
 377
 378    node_id: str
 379    quote: WarpQuote
 380    depot: bool
 381    broker_depot: bool
 382    affordable: bool
 383    consumed: bool
 384
 385    @property
 386    def available(self) -> bool:
 387        """True when this jump can actually be taken right now.
 388
 389        Fuel is the only thing that can refuse a route. ``consumed`` is a
 390        warning rather than a wall: the Wake's swept ground is jumpable, and
 391        the bill for it falls on arrival rather than at the till.
 392        """
 393        return self.affordable
 394
 395
 396def warp_quote(graph: ChartGraph, source_id: str, target_id: str, *, emergency: bool = False) -> WarpQuote:
 397    """Price the jump from *source_id* to *target_id*.
 398
 399    Raises ``KeyError`` if no edge joins them: the chart never quotes a route
 400    the drive cannot fly.
 401    """
 402    edge = graph.edge_between(source_id, target_id)
 403    if edge is None:
 404        raise KeyError(f"no chart edge from {source_id!r} to {target_id!r}")
 405    target = graph.nodes[target_id]
 406    return WarpQuote(
 407        source=source_id,
 408        target=target_id,
 409        columns_skipped=edge.columns_skipped,
 410        base=balance.WARP_FUEL_BASE,
 411        skip_surcharge=balance.WARP_FUEL_PER_COLUMN_SKIPPED * edge.columns_skipped,
 412        emergency=bool(emergency),
 413        toll=balance.DEEP_GATE_TOLL if target.is_deep_gate else 0.0,
 414    )
 415
 416
 417class ChartGraph:
 418    """The run's branching map, and the Wake front eating it from behind.
 419
 420    Columns are sector visits: column 0 is where the run starts, the last
 421    column is the Deep Gate, and a node's act follows from its column through
 422    ``balance.act_for_sector``. Every node fans out to
 423    ``balance.CHART_DESTINATIONS_MIN`` to ``MAX`` destinations, some of which
 424    leap a whole column for the surcharge.
 425
 426    The Wake is tracked as a float column count so a rate change mid-run (the
 427    acceleration above ``balance.WAKE_ACCEL_NOTORIETY``) carries the fraction
 428    already earned. :attr:`wake_column` is the index of the last column eaten,
 429    and is ``-1`` before the front has taken anything.
 430    """
 431
 432    def __init__(self, seed: int, acts: int = balance.CHART_ACTS):
 433        self.seed = int(seed)
 434        self.acts = max(1, int(acts))
 435        self.nodes: dict[str, ChartNode] = {}
 436        self.columns: list[list[str]] = []
 437        self.start_id = ""
 438        self.deep_gate_id = ""
 439        self.roost_id = ""
 440
 441        self.wake_column = -1
 442        self._wake_progress = 0.0
 443        self._jumps_seen = 0
 444
 445        self._generate(random.Random(self.seed))
 446
 447    # -- generation --------------------------------------------------------
 448
 449    def _generate(self, rng: random.Random) -> None:
 450        widths = self._column_widths(rng)
 451        broker_dealt = False
 452        for column, width in enumerate(widths):
 453            ids: list[str] = []
 454            for row in range(width):
 455                node_id = f"c{column}r{row}"
 456                act = min(self.acts, balance.act_for_sector(column + 1))
 457                is_gate = column == len(widths) - 1
 458                biome, broker_dealt = self._deal_biome(rng, act, is_gate, broker_dealt)
 459                node = ChartNode(
 460                    id=node_id,
 461                    column=column,
 462                    row=row,
 463                    act=act,
 464                    biome=biome,
 465                    is_deep_gate=is_gate,
 466                    requires_notoriety=balance.BROKER_NOTORIETY_THRESHOLD if biome == "broker_claim" else 0,
 467                )
 468                node.scrap_estimate = _scrap_estimate(act, biome)
 469                node.icons = _icons_for(node)
 470                self.nodes[node_id] = node
 471                ids.append(node_id)
 472            self.columns.append(ids)
 473
 474        self.start_id = self.columns[0][0]
 475        self.deep_gate_id = self.columns[-1][0]
 476        self._link(rng)
 477        self._guarantee_oxygen(rng)
 478        self._name_nodes()
 479
 480    def _column_widths(self, rng: random.Random) -> list[int]:
 481        """One entry per column: 1, then 2 or 3 per middle column, then 1.
 482
 483        Sized so the node count always lands inside
 484        ``balance.CHART_NODES_MIN..MAX`` for every legal column count.
 485        """
 486        count = rng.randint(balance.RUN_SECTOR_VISITS_MIN, balance.RUN_SECTOR_VISITS_MAX)
 487        middles = count - 2
 488        widths = [1] + [COLUMN_WIDTH_MIN] * middles + [1]
 489        base = sum(widths)
 490        low = max(0, balance.CHART_NODES_MIN - base)
 491        high = min(middles, balance.CHART_NODES_MAX - base)
 492        for index in rng.sample(range(1, middles + 1), rng.randint(low, max(low, high))):
 493            widths[index] = COLUMN_WIDTH_MAX
 494        return widths
 495
 496    def _deal_biome(self, rng: random.Random, act: int, is_gate: bool, broker_dealt: bool) -> tuple[str, bool]:
 497        if is_gate:
 498            return "debris_field", broker_dealt
 499        if act > 1 and not broker_dealt and rng.random() < balance.DEPOT_EDGE_FRACTION * 0.5:
 500            return "broker_claim", True
 501        return rng.choice(ACT_BIOME_POOL[act]), broker_dealt
 502
 503    def _link(self, rng: random.Random) -> None:
 504        """Fan every column out to the next, then guarantee reachability.
 505
 506        A generated map with an orphan column entry would silently shrink the
 507        run, so every node in a column takes at least one inbound edge before
 508        the pass finishes. The fan is the shape the chart is generated with and
 509        the band ``balance.CHART_DESTINATIONS_MIN..MAX`` governs; the mirror
 510        pass afterwards is not new map, it is the same lines read the other
 511        way, so :meth:`forward_edges` is what a shape assertion should ask.
 512        """
 513        for column, ids in enumerate(self.columns[:-1]):
 514            nxt = self.columns[column + 1]
 515            skippable = self.columns[column + 2] if column + 2 < len(self.columns) else []
 516            inbound: set[str] = set()
 517            for node_id in ids:
 518                node = self.nodes[node_id]
 519                fan = min(len(nxt), rng.randint(balance.CHART_DESTINATIONS_MIN, balance.CHART_DESTINATIONS_MAX))
 520                targets = _nearest_rows(self.nodes, nxt, node.row, fan)
 521                for target in targets:
 522                    node.edges.append(ChartEdge(node_id, target, 0))
 523                    inbound.add(target)
 524                if skippable and rng.random() < SKIP_EDGE_CHANCE:
 525                    leap = _nearest_rows(self.nodes, skippable, node.row, 1)[0]
 526                    node.edges.append(ChartEdge(node_id, leap, 1))
 527            for orphan in nxt:
 528                if orphan in inbound:
 529                    continue
 530                closest = min(ids, key=lambda source: abs(self.nodes[source].row - self.nodes[orphan].row))
 531                self.nodes[closest].edges.append(ChartEdge(closest, orphan, 0))
 532
 533        # Depots sit on roughly half the between-node edges; a Broker barge is
 534        # the depot on the way into a Broker Claim, so notoriety gates the shop
 535        # and the route to it in one place.
 536        for node in self.nodes.values():
 537            resolved: list[ChartEdge] = []
 538            for edge in node.edges:
 539                target = self.nodes[edge.target]
 540                broker = target.biome == "broker_claim"
 541                depot = broker or rng.random() < balance.DEPOT_EDGE_FRACTION
 542                resolved.append(ChartEdge(edge.source, edge.target, edge.columns_skipped, depot=depot, broker=broker))
 543            node.edges = resolved
 544
 545        self._mirror()
 546
 547    def _mirror(self) -> None:
 548        """Run every charted line back the other way, at the same price.
 549
 550        A jump is a jump: the drive does not care which end of a surveyed lane
 551        it enters, so a backward hop costs ``WARP_FUEL_BASE`` and the same skip
 552        surcharge its forward twin does, and the drift depot moored on the lane
 553        is passed either way. The mirror consumes no random numbers, so a seed
 554        still deals exactly the chart it always dealt.
 555
 556        Every mirror is built from a snapshot of the forward pass, or a node
 557        reached later in the sweep would mirror its own inbound mirrors back
 558        again and double the map. The Deep Gate takes none: arriving there is
 559        the extraction, so no hull ever stands on it, and a lane leading out of
 560        it would be a lane nobody can fly.
 561        """
 562        mirrors: list[ChartEdge] = []
 563        for node in self.nodes.values():
 564            for edge in node.edges:
 565                if self.nodes[edge.target].is_deep_gate:
 566                    continue
 567                # The broker flag follows the *arriving* node, which is this
 568                # edge's source once the line is reversed.
 569                broker = self.nodes[edge.source].biome == "broker_claim"
 570                mirrors.append(
 571                    ChartEdge(
 572                        edge.target,
 573                        edge.source,
 574                        edge.columns_skipped,
 575                        depot=edge.depot or broker,
 576                        broker=broker,
 577                    )
 578                )
 579        for mirror in mirrors:
 580            self.nodes[mirror.source].edges.append(mirror)
 581
 582    def forward_edges(self, node_id: str) -> list[ChartEdge]:
 583        """The edges of *node_id* that lead deeper, ignoring the mirrored ones.
 584
 585        The generator's shape guarantees (fan width, depot frequency) are
 586        properties of this list rather than of :meth:`edges_from`.
 587        """
 588        node = self.nodes.get(node_id)
 589        if node is None:
 590            return []
 591        return [edge for edge in node.edges if self.nodes[edge.target].column > node.column]
 592
 593    def _guarantee_oxygen(self, rng: random.Random) -> None:
 594        """Make sure air is on the map before the tank could plausibly run out.
 595
 596        Dealing biomes independently per node leaves a real minority of seeds
 597        with no O2 biome in reach at all, and a pilot cannot mine their way out
 598        of that: the chart is the only place the answer could have been. So the
 599        nearest reachable ordinary node is re-dealt as an Ice Field, which is
 600        in every act's pool, and its icons are re-derived so the chart still
 601        promises exactly what the sector will hold. Never the start node, which
 602        the mirrored lines put back inside its own reachable set: air the pilot
 603        is already standing in is not air this guarantee is about.
 604        """
 605        reachable = self.reachable_within(O2_GUARANTEE_COLUMNS)
 606        if any(self.nodes[node_id].biome in O2_BIOMES for node_id in reachable):
 607            return
 608        candidates = [
 609            node_id
 610            for node_id in sorted(reachable)
 611            if node_id != self.start_id
 612            and not self.nodes[node_id].is_deep_gate
 613            and self.nodes[node_id].requires_notoriety == 0
 614        ]
 615        if not candidates:
 616            return
 617        nearest = min(self.nodes[node_id].column for node_id in candidates)
 618        chosen = self.nodes[rng.choice([n for n in candidates if self.nodes[n].column == nearest])]
 619        chosen.biome = O2_BIOMES[0]
 620        chosen.scrap_estimate = _scrap_estimate(chosen.act, chosen.biome)
 621        chosen.icons = _icons_for(chosen)
 622
 623    def _name_nodes(self) -> None:
 624        """Give every node a name no other node on this chart answers to.
 625
 626        The biome is what a place *is*, and a chart deals the same biome three
 627        or four times, so the biome alone cannot be what a place is *called*: a
 628        jump button reading "JUMP: SOLAR SHALLOWS, 15 FUEL" named any of three
 629        sectors, one of them the swept one and one of them the sector the pilot
 630        had just left. Duplicates take a roman numeral in column-then-row
 631        order, which is the order the screen draws them in, so the numbering
 632        reads left to right down the map. A biome dealt once keeps its bare
 633        name rather than being called "I" for no one's benefit.
 634
 635        The order is the graph's own, not a random one, so a seed names its
 636        chart the same way every time it is dealt and a suspend that stores
 637        node ids resumes under the names it was saved with.
 638        """
 639        order = [node_id for ids in self.columns for node_id in ids]
 640        dealt = Counter(self.nodes[node_id].base_label for node_id in order)
 641        numbered: Counter[str] = Counter()
 642        for node_id in order:
 643            node = self.nodes[node_id]
 644            base = node.base_label
 645            if dealt[base] < 2:
 646                node.name = base
 647                continue
 648            numbered[base] += 1
 649            node.name = f"{base} {_roman(numbered[base])}"
 650
 651    # -- topology ----------------------------------------------------------
 652
 653    def reachable_within(self, columns: int) -> set[str]:
 654        """Every node the start can route into without passing *columns* out.
 655
 656        Presence on the map is not the same as being flyable to: an edge fan
 657        that never reaches a column entry makes whatever sits there fiction.
 658        """
 659        seen: set[str] = set()
 660        frontier = [self.start_id]
 661        while frontier:
 662            for edge in self.edges_from(frontier.pop()):
 663                target = self.nodes[edge.target]
 664                if edge.target in seen or target.column > int(columns):
 665                    continue
 666                seen.add(edge.target)
 667                frontier.append(edge.target)
 668        return seen
 669
 670    def edges_from(self, node_id: str) -> list[ChartEdge]:
 671        """Every edge leaving *node_id*, consumed targets included."""
 672        node = self.nodes.get(node_id)
 673        return list(node.edges) if node is not None else []
 674
 675    def edge_between(self, source_id: str, target_id: str) -> ChartEdge | None:
 676        """The edge joining two nodes, or None when no route exists."""
 677        for edge in self.edges_from(source_id):
 678            if edge.target == target_id:
 679                return edge
 680        return None
 681
 682    def visible(self, node_id: str, notoriety: int = 0) -> bool:
 683        """Whether *node_id* is on the chart at this notoriety.
 684
 685        The Broker Claim is drawn only for a ship notorious enough to dock
 686        there: a quiet player never sees the black market, and knows it.
 687        """
 688        node = self.nodes.get(node_id)
 689        if node is None:
 690            return False
 691        return int(notoriety) >= node.requires_notoriety
 692
 693    def reachable(self, from_id: str, fuel: float, *, emergency: bool = False, notoriety: int = 0) -> list[str]:
 694        """Destinations from *from_id* the ship can both see and afford.
 695
 696        Swept columns are in the list. The front does not close a route, it
 697        changes what is standing at the other end of it.
 698        """
 699        out: list[str] = []
 700        for edge in self.edges_from(from_id):
 701            if not self.visible(edge.target, notoriety):
 702                continue
 703            if warp_quote(self, from_id, edge.target, emergency=emergency).total <= float(fuel) + 1e-9:
 704                out.append(edge.target)
 705        return out
 706
 707    def fuel_cost(self, from_id: str, target_id: str, *, emergency: bool = False) -> float:
 708        """Total fuel for the jump, the number the screen prints."""
 709        return warp_quote(self, from_id, target_id, emergency=emergency).total
 710
 711    # -- the Wake ----------------------------------------------------------
 712
 713    @staticmethod
 714    def wake_rate(notoriety: int) -> float:
 715        """Columns eaten per jump at this notoriety."""
 716        if int(notoriety) >= balance.WAKE_ACCEL_NOTORIETY:
 717            return balance.WAKE_ACCEL_COLUMNS_PER_JUMP
 718        return balance.WAKE_COLUMNS_PER_JUMP
 719
 720    def advance_wake(self, jumps_taken: int, notoriety: int, *, pilot_column: int | None = None) -> int:
 721        """Feed the front the jumps taken so far. Returns columns eaten now.
 722
 723        *jumps_taken* is the run's cumulative count, not a delta, so calling
 724        this twice for one jump eats nothing the second time.
 725
 726        *pilot_column* is the column the ship is standing in. The front is
 727        clamped to it, so the furthest it can reach is the ground under the
 728        pilot's own feet and never the Deep Gate out in front of them: a hull
 729        that spends its jumps bouncing backwards is caught, not cut off from
 730        the exit. Progress keeps accruing while the clamp holds, so the front
 731        takes back what it was owed the moment the pilot moves on. Passing
 732        ``None`` leaves it unclamped, which is what a chart with nobody on it
 733        (a suspend being replayed, a headless probe) wants.
 734        """
 735        jumps_taken = max(0, int(jumps_taken))
 736        delta = jumps_taken - self._jumps_seen
 737        if delta <= 0:
 738            return 0
 739        self._jumps_seen = jumps_taken
 740        self._wake_progress += delta * self.wake_rate(notoriety)
 741        wanted = min(int(self._wake_progress), len(self.columns))
 742        if pilot_column is not None:
 743            wanted = min(wanted, int(pilot_column) + 1)
 744        eaten = wanted - (self.wake_column + 1)
 745        if eaten <= 0:
 746            return 0
 747        for column in range(self.wake_column + 1, wanted):
 748            for node_id in self.columns[column]:
 749                self.nodes[node_id].consumed = True
 750        self.wake_column = wanted - 1
 751        return eaten
 752
 753    def wake_preview(
 754        self, jumps_taken: int, notoriety: int, count: int = balance.WAKE_PREVIEW_ADVANCES
 755    ) -> list[WakeAdvance]:
 756        """The front's next *count* advances, as the chart always shows them."""
 757        rate = self.wake_rate(notoriety)
 758        progress = self._wake_progress + max(0, int(jumps_taken) - self._jumps_seen) * rate
 759        out: list[WakeAdvance] = []
 760        for step in range(1, int(count) + 1):
 761            column = self.wake_column + step
 762            if column >= len(self.columns):
 763                break
 764            owed = (column + 1) - progress
 765            out.append(WakeAdvance(column=column, jumps_until=max(0, math.ceil(owed / rate)) if rate > 0 else 0))
 766        return out
 767
 768    # -- the Roost ---------------------------------------------------------
 769
 770    def reveal_roost(self) -> str:
 771        """Place the Roost beside the Deep Gate. Returns its node id.
 772
 773        The assembled Lure is what puts it on the chart, so extraction and
 774        predation become one fork taken with the same fuel in the same tank.
 775        Calling this twice is harmless.
 776
 777        Its approaches are the one place on the map that is not mirrored: the
 778        duel has no warp-out, so a line back out of the nest would be a lie.
 779        """
 780        if self.roost_id:
 781            return self.roost_id
 782        gate = self.nodes[self.deep_gate_id]
 783        node = ChartNode(
 784            id=ROOST_NODE_ID,
 785            column=gate.column,
 786            row=gate.row + 1,
 787            act=gate.act,
 788            biome="roost",
 789            is_roost=True,
 790        )
 791        node.icons = _icons_for(node)
 792        self.nodes[node.id] = node
 793        self.columns[gate.column].append(node.id)
 794        for source in self.columns[gate.column - 1]:
 795            if self.edge_between(source, gate.id) is not None:
 796                self.nodes[source].edges.append(ChartEdge(source, node.id, 0))
 797        self.roost_id = node.id
 798        # The nest is the only node added after the chart was named. Renaming
 799        # is stable rather than incremental, so nothing already on the map
 800        # changes its spelling under a pilot who has been reading it all run.
 801        self._name_nodes()
 802        return node.id
 803
 804
 805def _roman(number: int) -> str:
 806    """*number* as a roman numeral: the suffix two like sectors are told apart by."""
 807    value = max(1, int(number))
 808    out: list[str] = []
 809    for size, glyph in ROMAN_STEPS:
 810        while value >= size:
 811            out.append(glyph)
 812            value -= size
 813    return "".join(out)
 814
 815
 816def _nearest_rows(nodes: dict[str, ChartNode], candidates: list[str], row: int, count: int) -> list[str]:
 817    """The *count* candidates whose rows sit closest to *row*, nearest first."""
 818    ordered = sorted(candidates, key=lambda node_id: (abs(nodes[node_id].row - row), nodes[node_id].row))
 819    return ordered[: max(1, count)]
 820
 821
 822def _scrap_estimate(act: int, biome: str) -> int:
 823    """Mid-band scrap for a sector of this act and biome, from the drop table."""
 824    low, high = balance.ACT_DROPS[act].scrap_per_sector
 825    return int(round((low + high) * 0.5 * balance.BIOMES[biome].scrap_mult))
 826
 827
 828def _icons_for(node: ChartNode) -> tuple[str, ...]:
 829    """The honest read of a node, derived from the tables the sector obeys."""
 830    if node.is_deep_gate:
 831        return (ICON_GATE,)
 832    if node.is_roost:
 833        return (ICON_ROOST,)
 834    spec = balance.BIOMES[node.biome]
 835    icons: list[str] = []
 836    if spec.scrap_mult > 1.0:
 837        icons.append(ICON_SCRAP_RICH)
 838    elif spec.scrap_mult < 1.0:
 839        icons.append(ICON_SCRAP_POOR)
 840    if spec.solar_mult > 1.0:
 841        icons.append(ICON_SOLAR)
 842    elif spec.solar_mult <= 0.0:
 843        icons.append(ICON_NO_SOLAR)
 844    if node.biome in ("ice_field", "vent_field"):
 845        icons.append(ICON_O2)
 846    if node.biome == "vent_field":
 847        icons.append(ICON_FUEL)
 848    if spec.telegraph_override_s is not None:
 849        icons.append(ICON_MUFFLED)
 850    if node.biome == "broker_claim":
 851        icons.append(ICON_BROKER)
 852    if balance.ACT_DROPS[node.act].vaults_per_sector[1] >= 1:
 853        icons.append(ICON_VAULT)
 854    return tuple(icons)
 855
 856
 857# ============================================================================
 858# The screen
 859# ============================================================================
 860
 861
 862class StarChart(Control):
 863    """The full-screen pause chart, and the warp it commits the run to.
 864
 865    Opening pauses the tree; the chart itself runs on ``UpdateMode.ALWAYS`` so
 866    it can read its own input while everything else is frozen. Selection is
 867    keyboard, pad and mouse at once and adds no action to the map: the thrust
 868    actions and all four ``menu_`` arrows cycle destinations, ``interact`` or
 869    ``menu_confirm`` commits, and ``star_chart`` toggles the screen. The
 870    cursor walks the offer sheet in the order the map is drawn,
 871    column then row, so the lane home sits at the left-hand end of the cycle
 872    rather than wherever the generator appended it, and it is never where the
 873    cursor starts. ``fire_primary`` is the pointer's half: a click picks
 874    the sector it lands on, a second click or the jump button in the footer
 875    spends the fuel, and a click on a sector the drive cannot reach is answered
 876    on the status line. The footer names keys rather than actions, because the
 877    only place a player can read this screen is the screen.
 878
 879    Swept ground is offered like any other destination and refused by nothing
 880    but the tank. What it costs is said three times before the fuel goes: the
 881    node's own price row, the status line the moment the cursor lands on it,
 882    and the jump button's label. What it actually costs is charged by the run
 883    scene on arrival, which reads ``ChartNode.consumed`` for itself.
 884
 885    Committing does not teleport anything. It closes the chart, emits
 886    ``JUMP_STARTED`` with the honest price and starts the ship's spool; the
 887    fuel leaves the tank and ``JUMP_COMPLETED`` fires only when the channel
 888    survives to the end, so a spool broken by fire costs nothing but the
 889    seconds. The Wake is fed immediately after, and ``WAKE_ADVANCED`` carries
 890    however many columns it took.
 891    """
 892
 893    update_mode = Property(
 894        UpdateMode.ALWAYS,
 895        hint="Processing behaviour while the tree is paused",
 896        on_change="_invalidate_update_mode_cache",
 897    )
 898
 899    dynamic = True
 900
 901    chart_opened = Signal()
 902    jump_started = Signal(str, float)
 903    jump_completed = Signal(str)
 904    wake_advanced = Signal(int)
 905
 906    def __init__(self, **kwargs):
 907        super().__init__(**kwargs)
 908        # The chart is a pause screen, not a click target for the guns: it
 909        # reads the pointer itself and never eats a shot while it is closed.
 910        self.pointer_filter = PointerFilter.IGNORE
 911        # It also has to keep running while it holds the tree still, or the
 912        # screen that owns the pause could never lift it.
 913        self.ui_scale: float = 1.0
 914
 915        self.graph: ChartGraph | None = None
 916        self.run_state = None
 917        self.current_node_id = ""
 918        self.jumps_taken = 0
 919
 920        self._open = False
 921        self._selected = ""
 922        self._pending: WarpQuote | None = None
 923        self._spooling_ship = None
 924        self._node_rects: dict[str, tuple[float, float, float, float]] = {}
 925        #: The jump button, as the last draw placed it.
 926        self._confirm_rect: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
 927        self._hovered = ""
 928        self._status = ""
 929        self._status_remaining = 0.0
 930        self._clicking = False
 931        self._click_armed = False
 932
 933    def on_ready(self):
 934        self.anchor = Anchor.FULL_RECT
 935
 936    # -- opening and closing ----------------------------------------------
 937
 938    def open(self, run_state) -> None:
 939        """Show the chart for *run_state* and pause the run.
 940
 941        *run_state* supplies ``chart`` (a :class:`ChartGraph`),
 942        ``current_node_id`` and ``jumps_taken``; the last two are written back
 943        when a jump completes.
 944        """
 945        graph = getattr(run_state, "chart", None)
 946        if not isinstance(graph, ChartGraph):
 947            raise TypeError("run_state.chart must be a ChartGraph")
 948        self.run_state = run_state
 949        self.graph = graph
 950        self._open = True
 951        self._click_armed = False
 952        self._clicking = Input.pressed("fire_primary")
 953        self.arm_default()
 954        if self.tree is not None:
 955            self.tree.paused = True
 956        self.chart_opened.emit()
 957
 958    def close(self) -> None:
 959        """Hide the chart and let the run breathe again."""
 960        if not self._open:
 961            return
 962        self._open = False
 963        if self.tree is not None:
 964            self.tree.paused = False
 965
 966    @property
 967    def is_open(self) -> bool:
 968        return self._open
 969
 970    @property
 971    def selected_id(self) -> str:
 972        """The destination the pilot is about to buy, empty when none is."""
 973        return self._selected
 974
 975    # -- destinations ------------------------------------------------------
 976
 977    @property
 978    def notoriety(self) -> int:
 979        """Run notoriety, from the tally node when the run has one."""
 980        tally = self._service(Services.NOTORIETY)
 981        if tally is not None:
 982            return int(getattr(tally, "value", 0))
 983        return int(getattr(self.run_state, "notoriety", 0) or 0)
 984
 985    @property
 986    def fuel(self) -> float:
 987        """Fuel in the tank, from the power system when the run has one."""
 988        power = self._service(Services.POWER)
 989        if power is not None:
 990            return float(getattr(power, "fuel", 0.0))
 991        return float(getattr(self.run_state, "fuel", balance.FUEL_MAX))
 992
 993    @property
 994    def emergency(self) -> bool:
 995        """True while the hunter is in the sector, which is what prices 1.5x."""
 996        tree = self.tree
 997        return tree is not None and tree.first(Groups.HUNTER) is not None
 998
 999    def destinations(self) -> list[Destination]:
1000        """Every visible route out of the current node, priced and gated.
1001
1002        In cycle order: the ways deeper first, then the ways back, each block
1003        in the order the screen draws them (column, then row). The order used
1004        to be whatever order the generator appended edges in, which put the
1005        mirrored lanes home at the end of a list nothing else was sorted by,
1006        so pressing a key moved the cursor somewhere the map gave no reason to
1007        expect and a blind pilot read the lane home as missing entirely.
1008        """
1009        graph = self.graph
1010        if graph is None or self.current_node_id not in graph.nodes:
1011            return []
1012        emergency = self.emergency
1013        fuel = self.fuel
1014        out: list[Destination] = []
1015        for edge in sorted(graph.edges_from(self.current_node_id), key=self._cursor_order):
1016            if not graph.visible(edge.target, self.notoriety):
1017                continue
1018            quote = warp_quote(graph, self.current_node_id, edge.target, emergency=emergency)
1019            out.append(
1020                Destination(
1021                    node_id=edge.target,
1022                    quote=quote,
1023                    depot=edge.depot,
1024                    broker_depot=edge.broker,
1025                    affordable=quote.total <= fuel + 1e-9,
1026                    consumed=graph.nodes[edge.target].consumed,
1027                )
1028            )
1029        return out
1030
1031    def _cursor_order(self, edge: ChartEdge) -> tuple[int, int, int, str]:
1032        """Where an edge sits in the cycle: the ways on, then the ways back.
1033
1034        Inside each block it is the order the map is drawn in, column then row.
1035        The ways back sort last for two reasons: the cursor opens on the first
1036        entry and retreating must never be what an unread ``F`` buys, and a
1037        single backwards press from there wraps straight onto the lane home,
1038        which is the shortest walk the keys can offer to the one destination a
1039        pilot goes looking for.
1040        """
1041        node = self.graph.nodes[edge.target] if self.graph is not None else None
1042        if node is None:
1043            return (1, 0, 0, edge.target)
1044        return (0 if self._leads_deeper(edge.target) else 1, node.column, node.row, edge.target)
1045
1046    def _leads_deeper(self, node_id: str) -> bool:
1047        """Whether *node_id* sits further out than the sector the hull is in."""
1048        graph = self.graph
1049        if graph is None or node_id not in graph.nodes or self.current_node_id not in graph.nodes:
1050            return False
1051        return graph.nodes[node_id].column > graph.nodes[self.current_node_id].column
1052
1053    @property
1054    def stranded(self) -> bool:
1055        """True when no destination is affordable: the Last Stand condition."""
1056        return not any(destination.available for destination in self.destinations())
1057
1058    def select(self, node_id: str) -> bool:
1059        """Point the cursor at *node_id*. False when it is not on offer."""
1060        if any(destination.node_id == node_id for destination in self.destinations()):
1061            self._selected = node_id
1062            self._announce_selection()
1063            return True
1064        return False
1065
1066    def cycle_selection(self, step: int) -> None:
1067        """Move the cursor *step* places through the offered destinations."""
1068        offered = [destination.node_id for destination in self.destinations()]
1069        if not offered:
1070            self._selected = ""
1071            return
1072        if self._selected not in offered:
1073            self._selected = offered[0]
1074        else:
1075            self._selected = offered[(offered.index(self._selected) + int(step)) % len(offered)]
1076        self._announce_selection()
1077
1078    def _announce_selection(self) -> None:
1079        """Warn on the status line when the cursor lands on swept ground.
1080
1081        The pointer gets its warning from :meth:`click_at`; this is the same
1082        warning for the keys, which are the only way a pad ever reads this
1083        screen. Nothing is said about an ordinary destination, because the
1084        footer, the node and the jump button already price it.
1085        """
1086        chosen = next((d for d in self.destinations() if d.node_id == self._selected), None)
1087        if chosen is not None and chosen.consumed:
1088            self.set_status(STATUS_WAKE_TERRITORY.format(label=self._label_of(chosen.node_id)))
1089
1090    def _take_position(self) -> None:
1091        """Read where the hull is off the run before offering it anything.
1092
1093        The run scene owns the hull's place on the map and this screen is told
1094        rather than asked, so every arming point reads it again: an arrival
1095        arms the fan of the sector the ship is actually in, whether the arming
1096        came from opening the screen or from the run scene's own arrival path.
1097        """
1098        run_state = self.run_state
1099        if run_state is None:
1100            return
1101        if self.graph is None and isinstance(getattr(run_state, "chart", None), ChartGraph):
1102            self.graph = run_state.chart
1103        node_id = str(getattr(run_state, "current_node_id", "") or "")
1104        fallback = self.current_node_id or (self.graph.start_id if self.graph is not None else "")
1105        self.current_node_id = node_id or fallback
1106        self.jumps_taken = int(getattr(run_state, "jumps_taken", self.jumps_taken))
1107
1108    def arm_default(self) -> str:
1109        """Point the drive at a sane destination, and return the node armed.
1110
1111        The cursor's opening position and the lane a bare press of the drive
1112        key buys are the same thing, so one rule settles both: the ways deeper
1113        first, and swept ground never chosen on the pilot's behalf. The cursor
1114        is where an unread ``F`` lands and the armed lane is where an unread
1115        ``R`` goes, and neither may put the run in Shrike territory by
1116        accident. Nor is the way home a default: retreating is a decision, so
1117        it is a place the cursor is walked onto rather than one it starts on.
1118
1119        Called on open and after every jump, and by the run scene on every
1120        arrival, so the drive key is answerable from the first frame in a
1121        sector rather than only once the chart has been opened. Returns ""
1122        when the fan offers nothing at all, which is the Deep Gate and the
1123        Roost, both of which end the run on arrival anyway.
1124        """
1125        self._take_position()
1126        offered = self.destinations()
1127        deeper = [d for d in offered if self._leads_deeper(d.node_id)]
1128        for pool in (
1129            [d for d in deeper if d.available and not d.consumed],
1130            [d for d in offered if d.available and not d.consumed],
1131            [d for d in deeper if d.available],
1132            [d for d in offered if d.available],
1133            offered,
1134        ):
1135            if pool:
1136                self._selected = pool[0].node_id
1137                return self._selected
1138        self._selected = ""
1139        return self._selected
1140
1141    # -- the jump ----------------------------------------------------------
1142
1143    def confirm(self) -> bool:
1144        """Commit to the selected destination and start the spool.
1145
1146        Returns False when nothing is selected, when the route is unaffordable
1147        or already eaten, and when the drive refuses to spool at all, which is
1148        what the hunter's arrival lockout does. Nothing is committed and no
1149        signal is emitted on a refusal: a scrambled drive has not jumped.
1150        """
1151        graph = self.graph
1152        if graph is None or not self._selected or self._pending is not None:
1153            return False
1154        chosen = next((d for d in self.destinations() if d.node_id == self._selected), None)
1155        if chosen is None or not chosen.available:
1156            return False
1157
1158        ship = self.tree.first(Groups.SHIP) if self.tree is not None else None
1159        if ship is not None and hasattr(ship, "begin_warp_spool"):
1160            ship.begin_warp_spool(emergency=chosen.quote.emergency)
1161            if not getattr(ship, "spooling", False):
1162                return False
1163            self._spooling_ship = ship
1164            ship.warp_completed.connect(self._on_warp_completed)
1165            # Both endings, not just the happy one. A route held past the
1166            # channel that bought it is cashed in by the next spool the pilot
1167            # starts for any reason at all, which is a jump nobody asked for.
1168            ship.warp_spool_cancelled.connect(self._on_spool_cancelled)
1169
1170        self.close()
1171        self._pending = chosen.quote
1172        self.jump_started.emit(chosen.node_id, chosen.quote.total)
1173        if self._spooling_ship is None:
1174            # No hull in the tree (a chart-only harness, or the menu): there is
1175            # no channel to run, so the jump resolves at once.
1176            self._complete_jump()
1177        return True
1178
1179    def cancel_jump(self) -> None:
1180        """Abandon a committed jump that has not finished spooling."""
1181        ship = self._release_ship()
1182        self._pending = None
1183        if ship is not None:
1184            ship.cancel_warp_spool()
1185
1186    @property
1187    def pending_target(self) -> str:
1188        """The node a committed jump is spooling toward, empty when none is."""
1189        return self._pending.target if self._pending is not None else ""
1190
1191    def _release_ship(self):
1192        """Stop listening to the hull this jump was riding, and return it."""
1193        ship, self._spooling_ship = self._spooling_ship, None
1194        if ship is not None:
1195            ship.warp_completed.disconnect(self._on_warp_completed)
1196            ship.warp_spool_cancelled.disconnect(self._on_spool_cancelled)
1197        return ship
1198
1199    def _on_warp_completed(self, emergency: bool) -> None:
1200        del emergency
1201        self._release_ship()
1202        self._complete_jump()
1203
1204    def _on_spool_cancelled(self) -> None:
1205        """The channel ended without a jump, so the route goes back on the map."""
1206        self._release_ship()
1207        self._pending = None
1208
1209    def _complete_jump(self) -> None:
1210        quote, self._pending = self._pending, None
1211        if quote is None or self.graph is None:
1212            return
1213        power = self._service(Services.POWER)
1214        if power is not None and not power.spend_fuel(quote.total):
1215            # The channel survived and the tank did not. The route is not
1216            # taken, nothing is consumed, and the pilot is told the number
1217            # they were short by rather than arriving somewhere for free.
1218            self._refuse_jump(quote)
1219            return
1220
1221        self.current_node_id = quote.target
1222        self.jumps_taken += 1
1223        if self.run_state is not None:
1224            self.run_state.current_node_id = self.current_node_id
1225            self.run_state.jumps_taken = self.jumps_taken
1226        self.jump_completed.emit(quote.target)
1227
1228        # The front is fed after the arrival, and clamped to where the arrival
1229        # left the hull: a pilot who has just spent a jump retreating can be
1230        # caught by it in the sector they are standing in, which is what
1231        # ``WAKE_ADVANCED`` is listened for.
1232        here = self.graph.nodes.get(self.current_node_id)
1233        eaten = self.graph.advance_wake(
1234            self.jumps_taken, self.notoriety, pilot_column=here.column if here is not None else None
1235        )
1236        if eaten > 0:
1237            self.wake_advanced.emit(eaten)
1238        self.arm_default()
1239
1240    def _refuse_jump(self, quote: WarpQuote) -> None:
1241        """Say why a finished channel bought nothing, and re-offer the map."""
1242        hud = self._service(Services.HUD)
1243        have = self.fuel
1244        if hud is not None and hasattr(hud, "show_toast"):
1245            hud.show_toast(f"{SPOOL_FAILURE_NO_FUEL}: need {quote.total:.0f}, have {have:.0f}")
1246        self.arm_default()
1247
1248    def _service(self, name: str):
1249        tree = self.tree
1250        return tree.singletons.get(name) if tree is not None else None
1251
1252    # -- input -------------------------------------------------------------
1253
1254    def on_update(self, dt: float):
1255        if self._status_remaining > 0.0:
1256            self._status_remaining = max(0.0, self._status_remaining - dt)
1257        if Input.just_pressed("star_chart"):
1258            if self._open:
1259                self.close()
1260            elif self.run_state is not None:
1261                self.open(self.run_state)
1262            return
1263        if not self._open:
1264            self._clicking = Input.pressed("fire_primary")
1265            return
1266
1267        if _any_just_pressed(CYCLE_FORWARD_ACTIONS):
1268            self.cycle_selection(1)
1269        elif _any_just_pressed(CYCLE_BACK_ACTIONS):
1270            self.cycle_selection(-1)
1271
1272        pointer = Input.mouse_position
1273        point = (float(pointer.x), float(pointer.y))
1274        self._hovered = self._node_at(*self.to_local(point))
1275        if _any_just_pressed(CONFIRM_ACTIONS):
1276            self.confirm()
1277            return
1278        held = Input.pressed("fire_primary")
1279        clicked, self._clicking = held and not self._clicking, held
1280        if clicked:
1281            self.click_at(*point)
1282
1283    def click_at(self, x: float, y: float) -> bool:
1284        """Answer a click at a screen point. True when it committed a jump.
1285
1286        A click picks the sector it is on, and it takes a second click, the
1287        jump button or the key to spend the fuel. Hover selects nothing, so the
1288        map can be read with the pointer anywhere on it. A click on somewhere the drive cannot
1289        reach is answered on the status line rather than ignored.
1290        """
1291        x, y = self.to_local((x, y))
1292        if _rect_holds(self._confirm_rect, x, y):
1293            return self.confirm()
1294        node_id = self._node_at(x, y)
1295        if not node_id:
1296            return False
1297        if node_id == self._selected and self._click_armed:
1298            return self.confirm()
1299
1300        destination = next((d for d in self.destinations() if d.node_id == node_id), None)
1301        label = self._label_of(node_id)
1302        if destination is None:
1303            self.set_status(STATUS_NO_ROUTE)
1304            return False
1305        self._selected = node_id
1306        # The chart opens with a sector already under the cursor for the
1307        # keyboard's sake. Arming the second click only once a click has chosen
1308        # something is what stops the first click a player ever makes on this
1309        # screen from spending their fuel.
1310        self._click_armed = True
1311        if destination.consumed:
1312            self.set_status(STATUS_WAKE_TERRITORY.format(label=label))
1313        elif not destination.affordable:
1314            self.set_status(STATUS_UNAFFORDABLE.format(need=destination.quote.total, have=self.fuel))
1315        else:
1316            self.set_status(STATUS_SELECTED.format(label=label))
1317        return False
1318
1319    def _label_of(self, node_id: str) -> str:
1320        """What this screen calls *node_id*: the one spelling every line uses."""
1321        graph = self.graph
1322        return graph.nodes[node_id].label if graph is not None and node_id in graph.nodes else node_id
1323
1324    def set_status(self, text: str) -> None:
1325        """Put one line on the chart's own status slot for :data:`STATUS_HOLD_S`."""
1326        self._status = str(text)
1327        self._status_remaining = STATUS_HOLD_S
1328
1329    @property
1330    def status(self) -> str:
1331        """The chart's status line, empty once it has timed out."""
1332        return self._status if self._status_remaining > 0.0 else ""
1333
1334    @property
1335    def hovered_id(self) -> str:
1336        """The sector the pointer is over, empty when it is over none."""
1337        return self._hovered
1338
1339    def node_rects(self) -> dict[str, tuple[float, float, float, float]]:
1340        """Where each node was last drawn, in the chart's own space."""
1341        return dict(self._node_rects)
1342
1343    def confirm_rect(self) -> tuple[float, float, float, float]:
1344        """Where the jump button was last drawn, in the chart's own space."""
1345        return self._confirm_rect
1346
1347    def confirm_label(self) -> str:
1348        """What the jump button says about the selection standing right now."""
1349        chosen = next((d for d in self.destinations() if d.node_id == self._selected), None)
1350        if chosen is None or self.graph is None:
1351            return CONFIRM_LABEL_EMPTY
1352        if not chosen.affordable:
1353            return CONFIRM_LABEL_SHORT.format(fuel=chosen.quote.total)
1354        label = self._label_of(chosen.node_id)
1355        template = CONFIRM_LABEL_WAKE if chosen.consumed else CONFIRM_LABEL
1356        return template.format(label=label, fuel=chosen.quote.total)
1357
1358    def _node_at(self, x: float, y: float) -> str:
1359        for node_id, rect in self._node_rects.items():
1360            if _rect_holds(rect, x, y):
1361                return node_id
1362        return ""
1363
1364    # -- drawing -----------------------------------------------------------
1365
1366    def on_draw(self, renderer):
1367        if not self._open or self.graph is None:
1368            self._node_rects.clear()
1369            self._confirm_rect = (0.0, 0.0, 0.0, 0.0)
1370            return
1371        _, _, w, h = self.get_rect()
1372        rect = (0.0, 0.0, w, h)
1373        scale = self.ui_scale
1374        self._layout(rect, scale)
1375
1376        x, y, w, h = rect
1377        renderer.rect((x, y), (w, h), fill=COLOUR_BACKDROP)
1378        margin = PANEL_MARGIN_PX * scale
1379        renderer.rect((x + margin, y + margin), (max(0.0, w - margin * 2), max(0.0, h - margin * 2)), fill=COLOUR_PANEL)
1380        self._draw_wake(renderer, rect, scale)
1381        self._draw_edges(renderer, scale)
1382        self._draw_nodes(renderer, scale)
1383        self._draw_header(renderer, rect, scale)
1384        self._draw_footer(renderer, rect, scale)
1385        self._draw_wake_labels(renderer, rect, scale)
1386
1387    def _layout(self, rect: tuple[float, float, float, float], scale: float) -> None:
1388        """Place every node box. Columns run left to right, rows top to bottom."""
1389        self._node_rects.clear()
1390        graph = self.graph
1391        if graph is None:
1392            return
1393        x, y, w, h = rect
1394        margin = PANEL_MARGIN_PX * scale
1395        top = y + margin + HEADER_HEIGHT_PX * scale
1396        bottom = y + h - margin - FOOTER_HEIGHT_PX * scale
1397        node_w = NODE_WIDTH_PX * scale
1398        node_h = NODE_HEIGHT_PX * scale
1399        columns = len(graph.columns)
1400        span = max(1.0, w - margin * 2 - node_w)
1401        for column, ids in enumerate(graph.columns):
1402            cx = x + margin + (span * column / max(1, columns - 1))
1403            usable = max(node_h, bottom - top)
1404            for index, node_id in enumerate(ids):
1405                cy = top + (usable - node_h) * (index / max(1, len(ids) - 1) if len(ids) > 1 else 0.5)
1406                self._node_rects[node_id] = (cx, cy, node_w, node_h)
1407
1408    def _centre(self, node_id: str) -> tuple[float, float]:
1409        rx, ry, rw, rh = self._node_rects[node_id]
1410        return rx + rw * 0.5, ry + rh * 0.5
1411
1412    def _draw_wake(self, renderer, rect, scale: float) -> None:
1413        graph = self.graph
1414        if graph is None:
1415            return
1416        x, y, w, h = rect
1417        margin = PANEL_MARGIN_PX * scale
1418        top = y + margin
1419        height = max(0.0, h - margin * 2)
1420        for column in range(0, graph.wake_column + 1):
1421            if not graph.columns[column]:
1422                continue
1423            rx, _, rw, _ = self._node_rects[graph.columns[column][0]]
1424            renderer.rect((rx - rw * 0.15, top), (rw * 1.3, height), fill=COLOUR_WAKE)
1425
1426        for advance in graph.wake_preview(self.jumps_taken, self.notoriety):
1427            ids = graph.columns[advance.column] if advance.column < len(graph.columns) else []
1428            if not ids:
1429                continue
1430            rx, _, rw, _ = self._node_rects[ids[0]]
1431            line_x = rx - rw * 0.18
1432            renderer.line(
1433                (line_x, top), (line_x, top + height), stroke=COLOUR_WAKE_PREVIEW, width=EDGE_THICKNESS_PX * scale
1434            )
1435
1436    def _draw_wake_labels(self, renderer, rect, scale: float) -> None:
1437        """Name each preview line, on a plate nothing else prints through.
1438
1439        At the very top of their lines the header's title would print
1440        straight through the labels and the panel edge would clip the first
1441        column's. So they draw last, on their own plates, at
1442        the foot of the header band: above every node box and below both
1443        header texts, and clamped inside the panel.
1444        """
1445        graph = self.graph
1446        if graph is None:
1447            return
1448        x, y, w, _ = rect
1449        margin = PANEL_MARGIN_PX * scale
1450        label_w = WAKE_LABEL_WIDTH_PX * scale
1451        label_h = WAKE_LABEL_HEIGHT_PX * scale
1452        top = y + margin + HEADER_HEIGHT_PX * scale - label_h
1453        for advance in graph.wake_preview(self.jumps_taken, self.notoriety):
1454            ids = graph.columns[advance.column] if advance.column < len(graph.columns) else []
1455            if not ids:
1456                continue
1457            rx, _, rw, _ = self._node_rects[ids[0]]
1458            line_x = rx - rw * 0.18
1459            left = min(max(x + margin, line_x + 4.0 * scale), x + w - margin - label_w)
1460            renderer.rect((left, top), (label_w, label_h), fill=COLOUR_BACKDROP)
1461            renderer.text(
1462                WAKE_PREVIEW_LABEL.format(jumps=advance.jumps_until),
1463                fill=COLOUR_WAKE_PREVIEW,
1464                scale=LABEL_FONT_SCALE * scale,
1465                align="centre",
1466                valign="centre",
1467                rect=(left, top, label_w, label_h),
1468            )
1469
1470    def _draw_edges(self, renderer, scale: float) -> None:
1471        """One line per charted lane, drawn from the pilot's end first.
1472
1473        Every lane is two edges, one each way, and both land on the same
1474        pixels. Drawing the pair twice would let the mirrored edge's plain grey
1475        paint over the live route's highlight, so a lane is drawn once and the
1476        sweep starts at the node the ship is standing in, which is the only
1477        node whose edges are coloured by what they cost.
1478        """
1479        graph = self.graph
1480        if graph is None:
1481            return
1482        offered = {destination.node_id: destination for destination in self.destinations()}
1483        order = [self.current_node_id] + [node_id for node_id in graph.nodes if node_id != self.current_node_id]
1484        drawn: set[tuple[str, str]] = set()
1485        for node_id in order:
1486            node = graph.nodes.get(node_id)
1487            if node is None or node.id not in self._node_rects or not graph.visible(node.id, self.notoriety):
1488                continue
1489            sx, sy = self._centre(node.id)
1490            for edge in node.edges:
1491                if edge.target not in self._node_rects or not graph.visible(edge.target, self.notoriety):
1492                    continue
1493                lane = (min(node.id, edge.target), max(node.id, edge.target))
1494                if lane in drawn:
1495                    continue
1496                drawn.add(lane)
1497                tx, ty = self._centre(edge.target)
1498                colour = COLOUR_EDGE
1499                thickness = EDGE_THICKNESS_PX * scale
1500                if node.id == self.current_node_id:
1501                    destination = offered.get(edge.target)
1502                    if destination is not None and destination.available:
1503                        colour = COLOUR_EDGE_REACHABLE
1504                    else:
1505                        colour = COLOUR_EDGE_UNAFFORDABLE
1506                    if edge.target == self._selected:
1507                        colour = COLOUR_SELECTED
1508                        thickness = SELECTED_THICKNESS_PX * scale
1509                renderer.line((sx, sy), (tx, ty), stroke=colour, width=thickness)
1510                if edge.depot:
1511                    renderer.circle(
1512                        ((sx + tx) * 0.5, (sy + ty) * 0.5),
1513                        DEPOT_MARKER_RADIUS_PX * scale,
1514                        fill=COLOUR_BROKER if edge.broker else COLOUR_DEPOT,
1515                    )
1516
1517    def _draw_nodes(self, renderer, scale: float) -> None:
1518        graph = self.graph
1519        if graph is None:
1520            return
1521        offered = {destination.node_id: destination for destination in self.destinations()}
1522        line = NODE_LINE_HEIGHT_PX * scale
1523        font = NODE_FONT_SCALE * scale
1524        for node_id, (rx, ry, rw, rh) in self._node_rects.items():
1525            node = graph.nodes[node_id]
1526            if not graph.visible(node_id, self.notoriety):
1527                continue
1528            if node.consumed:
1529                fill = COLOUR_NODE_CONSUMED
1530            elif node_id == self.current_node_id:
1531                fill = COLOUR_NODE_CURRENT
1532            else:
1533                fill = COLOUR_NODE
1534            renderer.rect((rx, ry), (rw, rh), fill=fill)
1535            if node_id == self._selected:
1536                renderer.rect((rx, ry), (rw, rh), stroke=COLOUR_SELECTED)
1537            elif node_id == self._hovered:
1538                # The pointer does not choose anything by resting; it says what
1539                # it is over, so a click is never a surprise.
1540                renderer.rect((rx, ry), (rw, rh), stroke=COLOUR_EDGE_REACHABLE)
1541
1542            inset = NODE_TEXT_INSET_PX * scale
1543            renderer.text(
1544                node.label,
1545                (rx + inset, ry + 4.0 * scale),
1546                fill=COLOUR_TEXT,
1547                scale=fitted_font_scale(node.label, font, rw - inset * 2.0),
1548            )
1549            icons = " ".join(node.icons)
1550            renderer.text(icons, (rx + 6.0 * scale, ry + 4.0 * scale + line), fill=COLOUR_TEXT_DIM, scale=font)
1551            renderer.text(
1552                f"~{node.scrap_estimate} SCRAP" if not node.is_deep_gate else f"TOLL {int(balance.DEEP_GATE_TOLL)}",
1553                (rx + 6.0 * scale, ry + 4.0 * scale + line * 2),
1554                fill=COLOUR_TEXT_DIM,
1555                scale=font,
1556            )
1557            destination = offered.get(node_id)
1558            if destination is not None:
1559                price = f"FUEL {destination.quote.total:.0f}"
1560                if destination.quote.emergency:
1561                    price += " EMG"
1562                if destination.consumed:
1563                    # Priced, not barred: the row says what the fuel buys and
1564                    # what is standing in it.
1565                    price = NODE_PRICE_WAKE.format(fuel=destination.quote.total)
1566                renderer.text(
1567                    price,
1568                    (rx + 6.0 * scale, ry + 4.0 * scale + line * 3),
1569                    fill=COLOUR_TEXT if destination.available else COLOUR_EDGE_UNAFFORDABLE,
1570                    scale=font,
1571                )
1572
1573    def _draw_header(self, renderer, rect, scale: float) -> None:
1574        graph = self.graph
1575        if graph is None:
1576            return
1577        x, y, w, _ = rect
1578        margin = PANEL_MARGIN_PX * scale
1579        here = graph.nodes.get(self.current_node_id)
1580        act = here.act if here is not None else 1
1581        renderer.text(
1582            f"STAR CHART   {ACT_LABELS.get(act, '')}   JUMP {self.jumps_taken}",
1583            (x + margin + 8.0 * scale, y + margin + HEADER_TITLE_OFFSET_PX * scale),
1584            fill=COLOUR_TEXT,
1585            scale=HEADER_FONT_SCALE * scale,
1586        )
1587        renderer.text(
1588            f"FUEL {self.fuel:.0f} / {balance.FUEL_MAX:.0f}   NOTORIETY {self.notoriety}",
1589            (x + w - margin - 300.0 * scale, y + margin + 12.0 * scale),
1590            fill=COLOUR_TEXT_DIM,
1591            scale=LABEL_FONT_SCALE * scale,
1592        )
1593        # The legend. Red and the two orange countdowns are the only marks on
1594        # this screen with no other place to be explained, and a mark nobody
1595        # can read is a mark that reads as decoration.
1596        renderer.text(
1597            WAKE_LEGEND,
1598            (x + margin + 8.0 * scale, y + margin + LEGEND_OFFSET_PX * scale),
1599            fill=COLOUR_WAKE_PREVIEW,
1600            scale=LABEL_FONT_SCALE * scale,
1601        )
1602
1603    def _draw_footer(self, renderer, rect, scale: float) -> None:
1604        """The key names, the status line and the jump button.
1605
1606        The button is laid out here rather than in :meth:`_layout` because it
1607        is the only thing on the screen whose size is its own rather than the
1608        graph's, and it is hit-tested from the rect this leaves behind.
1609        """
1610        x, y, w, h = rect
1611        margin = PANEL_MARGIN_PX * scale
1612        footer = FOOTER_HEIGHT_PX * scale
1613        top = y + h - margin - footer
1614        confirm_w = CONFIRM_WIDTH_PX * scale
1615        confirm_h = CONFIRM_HEIGHT_PX * scale
1616        self._confirm_rect = (
1617            x + w - margin - confirm_w - 8.0 * scale,
1618            top + (footer - confirm_h) * 0.5,
1619            confirm_w,
1620            confirm_h,
1621        )
1622
1623        slot_w = w - margin * 2 - confirm_w
1624        # A refusal and the Last Stand each own the whole slot: one sentence,
1625        # centred, with nothing beside it to read first. The standing hint is
1626        # two rows, because the keys and what the cursor does with them do not
1627        # fit on one at a width the slot has.
1628        if self.status:
1629            rows = ((self.status, COLOUR_SELECTED),)
1630        elif self.stranded:
1631            rows = ((FOOTER_STRANDED, COLOUR_TEXT_DIM),)
1632        else:
1633            rows = ((FOOTER_HINT, COLOUR_TEXT_DIM), (FOOTER_HINT_SECOND, COLOUR_TEXT_DIM))
1634        row_h = footer / len(rows)
1635        for index, (text, colour) in enumerate(rows):
1636            renderer.text(
1637                text,
1638                fill=colour,
1639                scale=LABEL_FONT_SCALE * scale,
1640                align="centre",
1641                valign="centre",
1642                rect=(x + margin, top + row_h * index, slot_w, row_h),
1643            )
1644
1645        cx, cy, cw, ch = self._confirm_rect
1646        chosen = next((d for d in self.destinations() if d.node_id == self._selected), None)
1647        live = chosen is not None and chosen.available
1648        renderer.rect((cx, cy), (cw, ch), fill=COLOUR_NODE_CURRENT if live else COLOUR_NODE)
1649        renderer.rect((cx, cy), (cw, ch), stroke=COLOUR_SELECTED if live else COLOUR_EDGE)
1650        renderer.text(
1651            self.confirm_label(),
1652            fill=COLOUR_TEXT if live else COLOUR_TEXT_DIM,
1653            scale=LABEL_FONT_SCALE * scale,
1654            align="centre",
1655            valign="centre",
1656            rect=(cx, cy, cw, ch),
1657        )