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 )