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