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