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