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