shrike/meta.py¶
Part of SHRIKE.
1"""Fleet Doctrine, the milestone bounties, the hull trophies and the Hunt Ranks.
2
3Everything that outlives a run and changes how the next one behaves lives here.
4Four systems share one idea: a run is configured by a *bag of effects* resolved
5once at launch, and every source of permanent progression contributes to that
6same bag.
7
8Fleet Doctrine
9==============
10
11Sixty nodes in five branches of twelve, radiating from a start hub and bought
12with Cores: seven minors at ``balance.DOCTRINE_COST_MINOR``, four notables at
13``balance.DOCTRINE_COST_NOTABLE``, one keystone at
14``balance.DOCTRINE_COST_KEYSTONE``, which is ``balance.DOCTRINE_FULL_TREE_CORES``
15for the lot. Respec is free and total: :meth:`MetaProfile.respec` refunds every
16Core ever spent and empties the tree, because the tree is a loadout language
17rather than a commitment. At most
18``balance.DOCTRINE_MAX_ACTIVE_KEYSTONES`` keystones may be active in any one
19run, so owning all five is a choice widened, never a build stacked.
20
21**The design law is enforced in code**: at most one third of the nodes may be
22numeric. Exactly twenty are, four per branch, all of them cheap minors. The
23other forty change a verb: the foam patches while you move, the panels retract
24instantly, the spool cannot be interrupted below quarter hull, heralds drop
25fuel, the chart shows one more destination. :func:`_validate_tree` fails at
26import if a later edit tips that ratio, so the law cannot rot quietly.
27
28The effect engine
29=================
30
31A node's effect is either
32
33``numeric``
34 a contribution to a named key in :data:`NUMERIC_EFFECTS`, folded with that
35 key's operator (product, sum, maximum or minimum) over every source.
36``rule``
37 a named behaviour change carrying its own parameters. It is either in the
38 bag or it is not, and the module that owns the behaviour asks.
39
40:meth:`MetaProfile.modifiers` folds the owned doctrine, the active keystones,
41the current Hunt Rank and the selected hull into one :class:`Modifiers`, which
42is a plain ``dict`` with query helpers on top. Every numeric key is always
43present at its identity, so a consumer never has to guess a default; rule ids
44are present only when active. Both lookups validate against the registries, so
45a misspelled key is an immediate error rather than a silently ignored upgrade.
46
47Milestone bounties
48==================
49
50The first five runs each end on a visible bounty (``save.MILESTONE_CORES``),
51paid once, flagged in the profile forever. They exist to make runs one to five
52feel like they bank something even when they end badly.
53
54Hulls
55=====
56
57Four hulls, three of them feat-gated trophies rather than purchases: the Barge
58for extracting with a hold full of scrap, the Dart for leaving a Shrike arrival
59untouched, the Hive for extracting at Hunt Rank 2. Each rearranges the socket
60count and layout; the layouts themselves belong to ``modules.py`` and are
61reached through :func:`hull_socket_layout`, so there is one description of a
62hull's geometry rather than two that can disagree.
63
64Hunt Ranks
65==========
66
67One ascension tier per Roost kill. Ranks 1 to 4 ship, and each adds exactly one
68legible modifier plus ``balance.HUNT_RANK_CORE_INCOME_BONUS`` to Core income.
69Nothing gains flat hit points: the ranks remix the hunter, because the hunter is
70the difficulty organ.
71"""
72
73from __future__ import annotations
74
75import logging
76from collections.abc import Iterable, Sequence
77from dataclasses import dataclass, field
78
79from simvx.core import Node, Signal
80
81from . import balance, save
82from .runtime import Services
83
84log = logging.getLogger(__name__)
85
86# ============================================================================
87# Numbers this module owns
88#
89# balance.py prices the tree, the bounties, the hull feats and the Hunt Rank
90# modifiers. What it does not fix, and what therefore lives here, is the
91# strength of the individual doctrine nodes the design names without numbering,
92# the depth term of the run score, and the two hull handling characters.
93# ============================================================================
94
95#: Doctrine minor strengths, one per numeric node. Small on purpose: a numeric
96#: node is the cheap filler between the nodes that change a rule.
97FIRE_RATE_STEP = 1.05
98AMMO_BOX_ROUNDS_STEP = 1.20
99RELOAD_SPEED_STEP = 1.25
100BALLISTIC_DAMAGE_STEP = 1.06
101SOLAR_WING_HP_STEP = 1.25
102CAPACITOR_MAX_STEP = 20.0
103GENERATOR_FUEL_BURN_STEP = 0.85
104SHIELD_ABSORB_STEP = 1.15
105MUFFLED_HULL_SIGNATURE_STEP = 0.92
106SILENT_SPEED_STEP = 1.20
107SIGNATURE_DECAY_STEP = 0.5
108GENERATOR_SIGNATURE_STEP = 0.85
109SCOOP_RADIUS_STEP = 1.35
110DEPOT_PRICE_STEP = 0.90
111REFINERY_RATE_STEP = 0.10
112SHRIKE_DAMAGE_STEP = 1.10
113SHRIKE_FIN_DAMAGE_STEP = 1.25
114LOCKOUT_DURATION_STEP = 0.85
115FLETCHING_DROP_STEP = 1.25
116
117#: Rule parameters the design describes but does not number.
118SPOOL_BRACING_HULL_FRACTION = 0.25
119BROWNOUT_REFUNDS_PER_SECTOR = 1
120LANTERN_READ_LEAD_S = 1.0
121GHOST_WAKE_JUMPS_PER_SECTOR = 1
122EXTRA_BEARING_DESTINATIONS = 1
123COUNTER_LANTERN_CAPACITOR = 15.0
124HERALD_FUEL_DROP = 10.0
125FEEDING_TAX_MULTIPLIER = 2.0
126CASING_RECLAIM_SCRAP_PER_KILL = 2.0
127
128#: Run score: the design gives scrap, Cores and quills their weights in
129#: balance.py and calls the last term "a sector-depth bonus" without a number.
130SCORE_DEPTH_BONUS_PER_SECTOR = 25
131
132#: Hull handling characters. The Barge is sluggish, the Dart slippery; the
133#: Hive's drone slant is two extra auto-turrets over the shared cap.
134BARGE_HANDLING_MULT = 0.85
135DART_HANDLING_MULT = 1.15
136HIVE_EXTRA_TURRETS = 2
137
138#: Feat ids gating parts of the tree. Both are read off the profile.
139FEAT_FIRST_EXTRACTION = "first_extraction"
140FEAT_FIRST_QUILL = "first_quill"
141
142#: How a locked feat reads on the doctrine screen, in the second person.
143FEAT_PROSE: dict[str, str] = {
144 FEAT_FIRST_EXTRACTION: "extract from a run",
145 FEAT_FIRST_QUILL: "shear a quill from the Shrike",
146}
147
148#: The starter hull, unlocked from the first boot, shared with ``save.py``.
149STARTER_HULL = save.STARTER_HULL
150
151#: Cost per node kind, keyed by the kinds a :class:`DoctrineNode` may take.
152NODE_COSTS: dict[str, int] = {
153 "minor": balance.DOCTRINE_COST_MINOR,
154 "notable": balance.DOCTRINE_COST_NOTABLE,
155 "keystone": balance.DOCTRINE_COST_KEYSTONE,
156}
157
158
159# ============================================================================
160# Effects: the two things a node may do
161# ============================================================================
162
163
164@dataclass(frozen=True)
165class NumericEffect:
166 """One numeric key in the effect bag, and how its contributions combine.
167
168 ``identity`` is the value the key holds when nothing contributes, so a
169 consumer reading the bag always gets a usable number. ``op`` is one of
170 ``"mult"``, ``"add"``, ``"max"`` or ``"min"``.
171 """
172
173 key: str
174 op: str
175 identity: float
176 note: str = ""
177
178
179#: Every numeric key any source may write, with its fold operator. A key is
180#: shared deliberately where two sources describe the same quantity: the Dart
181#: hull, the Silent Running minor and Hunt Rank 1 all move
182#: ``signature_fill_mult``, and the run reads one number.
183NUMERIC_EFFECTS: dict[str, NumericEffect] = {
184 # Gunnery
185 "fire_rate_mult": NumericEffect("fire_rate_mult", "mult", 1.0, "shots per second, every weapon"),
186 "ammo_box_rounds_mult": NumericEffect("ammo_box_rounds_mult", "mult", 1.0, "rounds in a bought box"),
187 "reload_speed_mult": NumericEffect("reload_speed_mult", "mult", 1.0, "ballistic reload rate"),
188 "ballistic_damage_mult": NumericEffect("ballistic_damage_mult", "mult", 1.0, "ballistic damage"),
189 # Engineering
190 "solar_wing_hp_mult": NumericEffect("solar_wing_hp_mult", "mult", 1.0, "panel hit points"),
191 "capacitor_max_bonus": NumericEffect("capacitor_max_bonus", "add", 0.0, "added capacitor ceiling"),
192 "generator_fuel_burn_mult": NumericEffect("generator_fuel_burn_mult", "mult", 1.0, "fuel per second running"),
193 "shield_absorb_mult": NumericEffect("shield_absorb_mult", "mult", 1.0, "damage the arc eats"),
194 # Silent Running
195 "signature_fill_mult": NumericEffect("signature_fill_mult", "mult", 1.0, "signature gained per event"),
196 "silent_speed_mult": NumericEffect("silent_speed_mult", "mult", 1.0, "top speed while silent"),
197 "signature_decay_bonus": NumericEffect("signature_decay_bonus", "add", 0.0, "extra decay per second"),
198 "generator_signature_mult": NumericEffect("generator_signature_mult", "mult", 1.0, "generator noise"),
199 # Salvage
200 "scoop_radius_mult": NumericEffect("scoop_radius_mult", "mult", 1.0, "tractor scoop reach"),
201 "depot_price_mult": NumericEffect("depot_price_mult", "mult", 1.0, "what a depot charges"),
202 "refinery_rate_bonus": NumericEffect("refinery_rate_bonus", "add", 0.0, "added Cores per 10 scrap refined"),
203 "death_convert_rate": NumericEffect(
204 "death_convert_rate", "max", balance.CONVERT_RATE_DEATH, "Cores per 10 scrap on death"
205 ),
206 # Predation
207 "shrike_damage_mult": NumericEffect("shrike_damage_mult", "mult", 1.0, "your damage to the Shrike"),
208 "shrike_fin_damage_mult": NumericEffect("shrike_fin_damage_mult", "mult", 1.0, "your damage to fins"),
209 "lockout_duration_mult": NumericEffect("lockout_duration_mult", "mult", 1.0, "arrival lockout length"),
210 "fletching_drop_mult": NumericEffect("fletching_drop_mult", "mult", 1.0, "herald Fletching drop odds"),
211 # Hunt Ranks
212 "depot_stock_items": NumericEffect("depot_stock_items", "min", float(balance.DEPOT_STOCK_ITEMS), "depot slots"),
213 "breach_bleed_mult": NumericEffect("breach_bleed_mult", "mult", 1.0, "O2 lost per open breach"),
214 "core_income_bonus": NumericEffect("core_income_bonus", "add", 0.0, "fraction added to banked Cores"),
215 # Hulls
216 "handling_mult": NumericEffect("handling_mult", "mult", 1.0, "responsiveness of the airframe"),
217 "auto_turret_limit_bonus": NumericEffect("auto_turret_limit_bonus", "add", 0.0, "turrets over the shared cap"),
218}
219
220_FOLDS = {
221 "mult": lambda current, value: current * value,
222 "add": lambda current, value: current + value,
223 "max": max,
224 "min": min,
225}
226
227
228@dataclass(frozen=True)
229class Effect:
230 """One contribution to the effect bag.
231
232 ``kind`` is ``"numeric"`` (``key`` names a :data:`NUMERIC_EFFECTS` entry and
233 ``value`` is the contribution) or ``"rule"`` (``key`` is the rule id and
234 ``params`` carries whatever the owning module needs to apply it).
235 """
236
237 kind: str
238 key: str
239 value: float = 0.0
240 params: dict = field(default_factory=dict)
241
242
243def _numeric(key: str, value: float) -> Effect:
244 return Effect("numeric", key, value=value)
245
246
247def _rule(rule_id: str, **params) -> Effect:
248 return Effect("rule", rule_id, params=params)
249
250
251class Modifiers(dict):
252 """The resolved effect bag a run reads at launch.
253
254 A plain ``dict`` of effect id to value, so it serialises and inspects like
255 any other configuration, with typed access on top. Numeric keys are always
256 present at their identity; rule ids appear only when the rule is active, and
257 map to that rule's parameters. Both accessors reject an unknown id, so a
258 consumer that mistypes a key finds out immediately instead of silently
259 reading an identity forever.
260 """
261
262 def value(self, key: str) -> float:
263 """The folded value of numeric *key*."""
264 if key not in NUMERIC_EFFECTS:
265 raise KeyError(f"unknown numeric effect {key!r}; expected one of {sorted(NUMERIC_EFFECTS)}")
266 return float(self[key])
267
268 def scale(self, key: str, base: float) -> float:
269 """*base* scaled by numeric *key*, for the multiplicative keys."""
270 return base * self.value(key)
271
272 def bonus(self, key: str, base: float = 0.0) -> float:
273 """*base* plus numeric *key*, for the additive keys."""
274 return base + self.value(key)
275
276 def enabled(self, rule: str) -> bool:
277 """Whether rule *rule* is active this run."""
278 if rule not in RULE_EFFECTS:
279 raise KeyError(f"unknown rule {rule!r}; expected one of {sorted(RULE_EFFECTS)}")
280 return rule in self
281
282 def param(self, rule: str, name: str, default=None):
283 """One parameter of an active rule, or *default* while it is inactive."""
284 if not self.enabled(rule):
285 return default
286 return self[rule].get(name, default)
287
288 def active_rules(self) -> frozenset[str]:
289 """Every rule id currently in the bag."""
290 return frozenset(key for key in self if key in RULE_EFFECTS)
291
292
293def resolve(effects: Iterable[Effect]) -> Modifiers:
294 """Fold *effects* into a complete :class:`Modifiers` bag."""
295 bag = Modifiers({key: spec.identity for key, spec in NUMERIC_EFFECTS.items()})
296 for effect in effects:
297 if effect.kind == "numeric":
298 spec = NUMERIC_EFFECTS[effect.key]
299 bag[effect.key] = _FOLDS[spec.op](bag[effect.key], effect.value)
300 elif effect.kind == "rule":
301 merged = dict(bag.get(effect.key, {}))
302 merged.update(effect.params)
303 bag[effect.key] = merged
304 else:
305 raise ValueError(f"effect {effect.key!r} has unknown kind {effect.kind!r}")
306 return bag
307
308
309# ============================================================================
310# The tree's shape
311# ============================================================================
312
313#: Node kind per slot, identical in every branch: 7 minors, 4 notables, and the
314#: keystone last. Slot order is the order a branch's rows are written below.
315_SLOT_KINDS = (
316 "minor",
317 "minor",
318 "notable",
319 "minor",
320 "minor",
321 "notable",
322 "minor",
323 "notable",
324 "minor",
325 "minor",
326 "notable",
327 "keystone",
328)
329
330#: Prerequisite slots, as indices into the same branch. Slot 0 hangs off the
331#: start hub. The spine runs 0-1-2-3-5-8-10-11, which is the cheapest route to
332#: a keystone; slots 4, 6, 7 and 9 are the wider shoulder a full branch buys.
333_SLOT_REQUIRES: tuple[tuple[int, ...], ...] = (
334 (),
335 (0,),
336 (1,),
337 (2,),
338 (2,),
339 (3,),
340 (4,),
341 (6,),
342 (5,),
343 (7,),
344 (8,),
345 (10,),
346)
347
348
349@dataclass(frozen=True)
350class BranchSpec:
351 """One of the five branches radiating from the hub."""
352
353 id: str
354 title: str
355 theme: str
356 keystone: str
357 feat: str | None = None
358
359
360BRANCHES: dict[str, BranchSpec] = {
361 "gunnery": BranchSpec("gunnery", "Gunnery", "fire rate and the ammunition economy", "dead_reckoning"),
362 "engineering": BranchSpec(
363 "engineering", "Engineering", "capacitor, panels and damage control", "overload_discharge"
364 ),
365 "silent_running": BranchSpec("silent_running", "Silent Running", "signature control", "cold_start"),
366 "salvage": BranchSpec("salvage", "Salvage", "scoop, prices and conversion rates", "magpie_protocol"),
367 "predation": BranchSpec(
368 "predation", "Predation", "standing and fighting the hunter", "quillborn", feat=FEAT_FIRST_QUILL
369 ),
370}
371
372
373@dataclass(frozen=True)
374class DoctrineNode:
375 """One purchasable node.
376
377 ``requires`` names nodes in the same branch, all of which must be owned
378 first. ``feat`` is an extra gate the profile has to satisfy, used for the
379 Predation branch and for Deep Insertion.
380 """
381
382 id: str
383 branch: str
384 kind: str
385 title: str
386 description: str
387 effect: Effect
388 requires: tuple[str, ...] = ()
389 feat: str | None = None
390
391 @property
392 def cost(self) -> int:
393 """What the node costs in Cores, fixed by its kind."""
394 return NODE_COSTS[self.kind]
395
396 @property
397 def numeric(self) -> bool:
398 """Whether the node moves a number rather than changing a rule."""
399 return self.effect.kind == "numeric"
400
401
402# Branch rows, in slot order: (node id, title, effect, description). The kind,
403# cost and prerequisites come from the shared slot tables above, so a branch
404# reads as twelve statements of intent and cannot drift out of shape.
405
406_GUNNERY_ROWS = (
407 ("gunnery_steady_hands", "Steady Hands", _numeric("fire_rate_mult", FIRE_RATE_STEP), "Every weapon cycles faster."),
408 (
409 "gunnery_deep_magazines",
410 "Deep Magazines",
411 _numeric("ammo_box_rounds_mult", AMMO_BOX_ROUNDS_STEP),
412 "An ammunition box carries more rounds for the same price.",
413 ),
414 (
415 "gunnery_salvaged_barrels",
416 "Salvaged Barrels",
417 _rule("reload_while_moving"),
418 "Reloading no longer roots the ship; you may thrust through it.",
419 ),
420 (
421 "gunnery_hot_loading",
422 "Hot Loading",
423 _numeric("reload_speed_mult", RELOAD_SPEED_STEP),
424 "Ballistic reloads finish sooner.",
425 ),
426 (
427 "gunnery_tight_chokes",
428 "Tight Chokes",
429 _numeric("ballistic_damage_mult", BALLISTIC_DAMAGE_STEP),
430 "Ballistic rounds hit a little harder.",
431 ),
432 (
433 "gunnery_overpressure",
434 "Overpressure",
435 _rule("first_shot_after_reload_pierces"),
436 "The first shot out of a fresh magazine punches through its target.",
437 ),
438 (
439 "gunnery_casing_reclaim",
440 "Casing Reclaim",
441 _rule("ballistic_kills_shed_brass", scrap=CASING_RECLAIM_SCRAP_PER_KILL),
442 "A kill made with ballistics sheds its brass as scrap.",
443 ),
444 (
445 "gunnery_field_stripping",
446 "Field Stripping",
447 _rule("fabricator_prints_any_calibre"),
448 "The Fabricator prints a box for any ballistic weapon you carry, not just the one it was cut for.",
449 ),
450 (
451 "gunnery_tracer_discipline",
452 "Tracer Discipline",
453 _rule("hits_mark_targets"),
454 "A hit marks its target, which stays legible through nebula muffling.",
455 ),
456 (
457 "gunnery_shell_sorting",
458 "Shell Sorting",
459 _rule("shared_ammunition_pool"),
460 "One ammunition pool feeds every ballistic calibre aboard.",
461 ),
462 (
463 "gunnery_ammunition_sovereignty",
464 "Ammunition Sovereignty",
465 _rule("depots_always_stock_ammunition"),
466 "Every depot stocks ammunition, whatever else its shelves rolled.",
467 ),
468 (
469 "dead_reckoning",
470 "Dead Reckoning",
471 _rule("dead_reckoning", energy_weapons_disabled=True),
472 "Ballistic shots pierce everything they hit. Energy weapons will not fire at all.",
473 ),
474)
475
476_ENGINEERING_ROWS = (
477 (
478 "engineering_thicker_panels",
479 "Thicker Panels",
480 _numeric("solar_wing_hp_mult", SOLAR_WING_HP_STEP),
481 "Solar panels take more punishment before they shear.",
482 ),
483 (
484 "engineering_wider_bus",
485 "Wider Bus",
486 _numeric("capacitor_max_bonus", CAPACITOR_MAX_STEP),
487 "The capacitor holds a larger charge.",
488 ),
489 (
490 "engineering_hot_patch",
491 "Hot Patch",
492 _rule("patch_breach_while_moving"),
493 "Breach foam sets while you fly; patching no longer roots the ship.",
494 ),
495 (
496 "engineering_cold_windings",
497 "Cold Windings",
498 _numeric("generator_fuel_burn_mult", GENERATOR_FUEL_BURN_STEP),
499 "The generator burns less fuel for the same output.",
500 ),
501 (
502 "engineering_shield_conditioning",
503 "Shield Conditioning",
504 _numeric("shield_absorb_mult", SHIELD_ABSORB_STEP),
505 "The arc absorbs more before it breaks.",
506 ),
507 (
508 "engineering_spool_bracing",
509 "Spool Bracing",
510 _rule("spool_uninterruptible_at_low_hull", hull_fraction=SPOOL_BRACING_HULL_FRACTION),
511 "Below a quarter hull the warp spool cannot be interrupted at all.",
512 ),
513 (
514 "engineering_quick_retract",
515 "Quick Retract",
516 _rule("panels_retract_instantly"),
517 "Solar panels snap shut instantly instead of folding.",
518 ),
519 (
520 "engineering_arc_grounding",
521 "Arc Grounding",
522 _rule("shield_break_browns_capacitor"),
523 "A broken arc dumps its charge into the bus rather than locking the emitter out.",
524 ),
525 (
526 "engineering_brownout_reserve",
527 "Brownout Reserve",
528 _rule("refund_first_energy_denial", per_sector=BROWNOUT_REFUNDS_PER_SECTOR),
529 "A reserve cell covers the first energy request a sector denies you.",
530 ),
531 (
532 "engineering_redundant_feeds",
533 "Redundant Feeds",
534 _rule("destroyed_wings_keep_generating"),
535 "A sheared panel keeps feeding the bus until the sector ends.",
536 ),
537 (
538 "engineering_field_welding",
539 "Field Welding",
540 _rule("patch_restores_nearest_wing"),
541 "Patching a breach also welds the nearest destroyed panel back on.",
542 ),
543 (
544 "overload_discharge",
545 "Overload Discharge",
546 _rule("shield_break_emp_nova"),
547 "A broken shield releases an EMP nova. Your worst moment becomes a tool.",
548 ),
549)
550
551_SILENT_ROWS = (
552 (
553 "silent_muffled_hull",
554 "Muffled Hull",
555 _numeric("signature_fill_mult", MUFFLED_HULL_SIGNATURE_STEP),
556 "Everything you do reads a little quieter.",
557 ),
558 (
559 "silent_cold_thrusters",
560 "Cold Thrusters",
561 _numeric("silent_speed_mult", SILENT_SPEED_STEP),
562 "Silent running costs you less speed.",
563 ),
564 (
565 "silent_quiet_kills",
566 "Quiet Kills",
567 _rule("kills_are_silent_while_dark"),
568 "A kill made under silent running adds no signature at all.",
569 ),
570 (
571 "silent_deep_decay",
572 "Deep Decay",
573 _numeric("signature_decay_bonus", SIGNATURE_DECAY_STEP),
574 "The meter falls faster while you are dark.",
575 ),
576 (
577 "silent_baffled_generator",
578 "Baffled Generator",
579 _numeric("generator_signature_mult", GENERATOR_SIGNATURE_STEP),
580 "The generator's contribution to the meter is baffled down.",
581 ),
582 (
583 "silent_false_echo",
584 "False Echo",
585 _rule("jettisoned_scrap_is_a_decoy"),
586 "Jettisoned scrap plants a false echo the lantern sweeps instead of you.",
587 ),
588 (
589 "silent_shadow_scoop",
590 "Shadow Scoop",
591 _rule("scoop_is_silent_while_dark"),
592 "The tractor scoop makes no noise while silent running is engaged.",
593 ),
594 (
595 "silent_lantern_read",
596 "Lantern Read",
597 _rule("draw_lantern_path_early", lead_s=LANTERN_READ_LEAD_S),
598 "The lantern's sweep path is drawn a second before it burns.",
599 ),
600 (
601 "silent_dead_bus",
602 "Dead Bus",
603 _rule("silent_running_without_charge"),
604 "Silent running can be entered on an empty capacitor.",
605 ),
606 (
607 "silent_nebula_lungs",
608 "Nebula Lungs",
609 _rule("muffling_covers_generator"),
610 "Biome muffling covers the generator hum as well as the hull.",
611 ),
612 (
613 "silent_ghost_wake",
614 "Ghost Wake",
615 _rule("wakeless_jump", jumps=GHOST_WAKE_JUMPS_PER_SECTOR),
616 "One jump per sector leaves no column in the Wake.",
617 ),
618 (
619 "cold_start",
620 "Cold Start",
621 _rule("cold_start", seconds=balance.COLD_START_INVISIBLE_S),
622 "Every sector opens powered down and invisible. The ambush predator's opening.",
623 ),
624)
625
626_SALVAGE_ROWS = (
627 (
628 "salvage_underwriting",
629 "Underwriting",
630 _numeric("death_convert_rate", balance.CONVERT_RATE_DEATH_UPGRADED),
631 "Scrap in the hold when you die converts at a better rate.",
632 ),
633 (
634 "salvage_wide_scoop",
635 "Wide Scoop",
636 _numeric("scoop_radius_mult", SCOOP_RADIUS_STEP),
637 "The tractor scoop reaches further.",
638 ),
639 (
640 "salvage_extra_bearing",
641 "Extra Bearing",
642 _rule("chart_shows_extra_destination", destinations=EXTRA_BEARING_DESTINATIONS),
643 "The chart offers one more destination than the route would allow.",
644 ),
645 (
646 "salvage_haggling",
647 "Haggling",
648 _numeric("depot_price_mult", DEPOT_PRICE_STEP),
649 "Depots charge you less.",
650 ),
651 (
652 "salvage_refinery_tuning",
653 "Refinery Tuning",
654 _numeric("refinery_rate_bonus", REFINERY_RATE_STEP),
655 "A refinery batch pays more Cores per batch.",
656 ),
657 (
658 "salvage_pity_reroll",
659 "Pity Reroll",
660 _rule("free_pity_slot_reroll"),
661 "A depot's pity slot may be rerolled once for nothing.",
662 ),
663 (
664 "salvage_magnetic_sort",
665 "Magnetic Sort",
666 _rule("scoop_pulls_boxes_and_consumables"),
667 "The scoop pulls ammunition boxes and consumables, not just scrap.",
668 ),
669 (
670 "salvage_deep_insertion",
671 "Deep Insertion",
672 _rule(
673 "deep_insertion",
674 sector=balance.DEEP_INSERTION_START_SECTOR,
675 signature=balance.DEEP_INSERTION_START_SIGNATURE,
676 ),
677 "Start the run deep, hot, and with no Act 1 income. For pilots who have solved the Shallows.",
678 ),
679 (
680 "salvage_wreck_sense",
681 "Wreck Sense",
682 _rule("mark_unlooted_wrecks"),
683 "Unlooted wrecks are marked the moment you enter a sector.",
684 ),
685 (
686 "salvage_jettison_ledger",
687 "Jettison Ledger",
688 _rule("fed_scrap_still_scores"),
689 "Scrap fed to the Shrike still counts toward the run score.",
690 ),
691 (
692 "salvage_field_refinery",
693 "Field Refinery",
694 _rule("refinery_survives_docking"),
695 "A refinery batch keeps running while you dock, trade and undock.",
696 ),
697 (
698 "magpie_protocol",
699 "Magpie Protocol",
700 _rule("magpie_protocol", fraction=balance.MAGPIE_SALVAGE_FRACTION),
701 "Stolen and destroyed salvage is collected automatically at half value. No scoop pass needed.",
702 ),
703)
704
705_PREDATION_ROWS = (
706 (
707 "predation_quill_edge",
708 "Quill Edge",
709 _numeric("shrike_damage_mult", SHRIKE_DAMAGE_STEP),
710 "You cut the hide deeper.",
711 ),
712 (
713 "predation_fin_shear",
714 "Fin Shear",
715 _numeric("shrike_fin_damage_mult", SHRIKE_FIN_DAMAGE_STEP),
716 "Fin plating gives way sooner.",
717 ),
718 (
719 "predation_barbed_rounds",
720 "Barbed Rounds",
721 _rule("lockout_scrambles_warp_only"),
722 "The arrival lockout scrambles the warp drive and nothing else; your shots still land.",
723 ),
724 (
725 "predation_steady_nerve",
726 "Steady Nerve",
727 _numeric("lockout_duration_mult", LOCKOUT_DURATION_STEP),
728 "The lockout is over sooner.",
729 ),
730 (
731 "predation_fletching_luck",
732 "Fletching Luck",
733 _numeric("fletching_drop_mult", FLETCHING_DROP_STEP),
734 "Heralds part with Fletchings more readily.",
735 ),
736 (
737 "predation_counter_lantern",
738 "Counter Lantern",
739 _rule("dodged_sweep_refunds_capacitor", capacitor=COUNTER_LANTERN_CAPACITOR),
740 "A sweep you dodge clean feeds the capacitor instead of the hull.",
741 ),
742 (
743 "predation_carrion_sense",
744 "Carrion Sense",
745 _rule("heralds_drop_fuel", fuel=HERALD_FUEL_DROP),
746 "Heralds drop fuel, which turns an arrival into a resupply.",
747 ),
748 (
749 "predation_feeding_tax",
750 "Feeding Tax",
751 _rule("feeding_buys_double_time", multiplier=FEEDING_TAX_MULTIPLIER),
752 "Scrap fed to the Shrike buys twice the seconds.",
753 ),
754 (
755 "predation_scent_of_blood",
756 "Scent of Blood",
757 _rule("sheared_segments_stay_marked"),
758 "A segment you have sheared stays marked, so the next cut is aimed.",
759 ),
760 (
761 "predation_readable_ladder",
762 "Readable Ladder",
763 _rule("telegraph_never_compresses"),
764 "The telegraph ladder never compresses past its second rung, however many arrivals you have taken.",
765 ),
766 (
767 "predation_undertow",
768 "Undertow",
769 _rule("departure_sheds_plating"),
770 "The Shrike's departure leaves a field of its own shed plating behind.",
771 ),
772 (
773 "quillborn",
774 "Quillborn",
775 _rule("quillborn"),
776 "While the Shrike is present its damage ramp ramps yours too. The stand-and-fight covenant.",
777 ),
778)
779
780_BRANCH_ROWS = {
781 "gunnery": _GUNNERY_ROWS,
782 "engineering": _ENGINEERING_ROWS,
783 "silent_running": _SILENT_ROWS,
784 "salvage": _SALVAGE_ROWS,
785 "predation": _PREDATION_ROWS,
786}
787
788#: Nodes that carry an unlock gate their branch does not. Such a node must sit
789#: on a shoulder slot, never on the spine, or it would gate the branch's
790#: keystone behind a feat the keystone has nothing to do with;
791#: :func:`_validate_tree` enforces that.
792_NODE_FEATS = {"salvage_deep_insertion": FEAT_FIRST_EXTRACTION}
793
794
795def _build_tree() -> dict[str, DoctrineNode]:
796 """Expand the branch rows into the full sixty-node tree."""
797 tree: dict[str, DoctrineNode] = {}
798 for branch_id in balance.DOCTRINE_BRANCHES_IDS:
799 rows = _BRANCH_ROWS[branch_id]
800 ids = [row[0] for row in rows]
801 for slot, (node_id, title, effect, description) in enumerate(rows):
802 feat = _NODE_FEATS.get(node_id) or BRANCHES[branch_id].feat
803 tree[node_id] = DoctrineNode(
804 id=node_id,
805 branch=branch_id,
806 kind=_SLOT_KINDS[slot],
807 title=title,
808 description=description,
809 effect=effect,
810 requires=tuple(ids[index] for index in _SLOT_REQUIRES[slot]),
811 feat=feat,
812 )
813 return tree
814
815
816#: Every doctrine node, keyed by id, in branch then slot order.
817#:
818#: Values are frozen :class:`DoctrineNode` records rather than bare dictionaries:
819#: a mistyped field is then a construction error at import instead of a missing
820#: key at run start.
821DOCTRINE_TREE: dict[str, DoctrineNode] = _build_tree()
822
823#: Every rule id any source may raise, with the node, rank or hull that owns it.
824#: Built after the tree so :class:`Modifiers` can reject an unknown rule.
825RULE_EFFECTS: dict[str, str] = {}
826
827
828def branch_nodes(branch_id: str) -> tuple[DoctrineNode, ...]:
829 """Every node in *branch_id*, in slot order."""
830 if branch_id not in BRANCHES:
831 raise ValueError(f"unknown branch {branch_id!r}; expected one of {sorted(BRANCHES)}")
832 return tuple(node for node in DOCTRINE_TREE.values() if node.branch == branch_id)
833
834
835def keystone_ids() -> tuple[str, ...]:
836 """The five keystone node ids, in branch order."""
837 return tuple(balance.DOCTRINE_KEYSTONES[branch] for branch in balance.DOCTRINE_BRANCHES_IDS)
838
839
840def tree_cost() -> int:
841 """What buying every node costs in Cores."""
842 return sum(node.cost for node in DOCTRINE_TREE.values())
843
844
845def prerequisites_of(node_id: str) -> frozenset[str]:
846 """Every node that must be owned before *node_id* can be bought."""
847 node = DOCTRINE_TREE.get(node_id)
848 if node is None:
849 raise ValueError(f"unknown doctrine node {node_id!r}")
850 closure: set[str] = set()
851 frontier = list(node.requires)
852 while frontier:
853 current = frontier.pop()
854 if current in closure:
855 continue
856 closure.add(current)
857 frontier.extend(DOCTRINE_TREE[current].requires)
858 return frozenset(closure)
859
860
861def path_cost_to(node_id: str) -> int:
862 """What reaching *node_id* costs, the node and its prerequisites together."""
863 return DOCTRINE_TREE[node_id].cost + sum(DOCTRINE_TREE[step].cost for step in prerequisites_of(node_id))
864
865
866# ============================================================================
867# Hulls
868# ============================================================================
869
870
871@dataclass(frozen=True)
872class HullSpec:
873 """One hull: its unlock feat, its character, and the effects it carries.
874
875 The socket *count* is balance's; the socket *layout* belongs to
876 ``modules.py`` and is reached through :func:`hull_socket_layout`.
877 """
878
879 id: str
880 title: str
881 feat: str | None
882 description: str
883 effects: tuple[Effect, ...] = ()
884
885 @property
886 def sockets(self) -> int:
887 """How many sockets the hull carries."""
888 return balance.HULL_SOCKETS[self.id]
889
890
891HULLS: dict[str, HullSpec] = {
892 "vagrant": HullSpec(
893 "vagrant",
894 "Vagrant",
895 None,
896 "The starter: balanced sockets, balanced handling, no opinion of its own.",
897 ),
898 "barge": HullSpec(
899 "barge",
900 "Barge",
901 f"extract carrying at least {balance.BARGE_UNLOCK_SCRAP_CARRIED} scrap",
902 "The Fortress chassis: two more sockets, and it turns like a moon.",
903 effects=(_numeric("handling_mult", BARGE_HANDLING_MULT),),
904 ),
905 "dart": HullSpec(
906 "dart",
907 "Dart",
908 "escape a Shrike arrival untouched",
909 "The Salvager's: two fewer sockets, slippery, and it reads quieter to the hunter.",
910 effects=(
911 _numeric("handling_mult", DART_HANDLING_MULT),
912 _numeric("signature_fill_mult", balance.DART_SIGNATURE_FILL_MULT),
913 ),
914 ),
915 "hive": HullSpec(
916 "hive",
917 "Hive",
918 f"extract at Hunt Rank {balance.HIVE_UNLOCK_HUNT_RANK}",
919 "The Carrier fantasy: a drone-centric socket layout and bays for two turrets over the cap.",
920 effects=(
921 _rule("drone_centric_sockets"),
922 _numeric("auto_turret_limit_bonus", HIVE_EXTRA_TURRETS),
923 ),
924 ),
925}
926
927
928def hull_socket_layout(hull_id: str) -> tuple:
929 """The hull's socket layout, as ``modules.Socket`` records.
930
931 Imported on demand so that opening the doctrine screen does not drag the
932 whole run-time module stack in behind it.
933 """
934 from .modules import sockets_for_hull
935
936 return sockets_for_hull(hull_id)
937
938
939def hull_feat_met(hull_id: str, ledger: dict, profile: dict) -> bool:
940 """Whether the run described by *ledger* earns *hull_id*."""
941 if hull_id not in HULLS:
942 raise ValueError(f"unknown hull {hull_id!r}; expected one of {sorted(HULLS)}")
943 extracted = ledger.get("outcome") == "extracted"
944 if hull_id == "barge":
945 return extracted and float(ledger.get("scrap_carried", 0.0)) >= balance.BARGE_UNLOCK_SCRAP_CARRIED
946 if hull_id == "dart":
947 return bool(ledger.get("arrival_escaped_untouched"))
948 if hull_id == "hive":
949 return extracted and int(profile.get("hunt_rank", 0)) >= balance.HIVE_UNLOCK_HUNT_RANK
950 return False
951
952
953# ============================================================================
954# Hunt Ranks
955# ============================================================================
956
957
958@dataclass(frozen=True)
959class HuntRank:
960 """One ascension tier: a single legible modifier, plus its Core income."""
961
962 rank: int
963 title: str
964 description: str
965 effects: tuple[Effect, ...]
966
967
968HUNT_RANK_TIERS: dict[int, HuntRank] = {
969 1: HuntRank(
970 1,
971 "Marked",
972 "The signature meter fills faster.",
973 (_numeric("signature_fill_mult", balance.HUNT_RANK1_SIGNATURE_FILL_MULT),),
974 ),
975 2: HuntRank(
976 2,
977 "Watched",
978 "Depots stock one item fewer.",
979 (_numeric("depot_stock_items", float(balance.HUNT_RANK2_DEPOT_STOCK_ITEMS)),),
980 ),
981 3: HuntRank(
982 3,
983 "Bled",
984 "Breaches bleed oxygen at double the rate.",
985 (_numeric("breach_bleed_mult", balance.HUNT_RANK3_BREACH_BLEED_MULT),),
986 ),
987 4: HuntRank(
988 4,
989 "Swept",
990 "The lantern sweeps during phase-1 arrivals, in ordinary sectors.",
991 (_rule("lantern_sweeps_during_phase_one"),),
992 ),
993}
994
995
996def hunt_rank_effects(rank: int) -> list[Effect]:
997 """Every effect a profile at *rank* carries, income bonus included.
998
999 Ranks above ``balance.HUNT_RANKS_AT_LAUNCH`` contribute their Core income
1000 but no modifier, because ranks 5 to 8 are not implemented yet; asking for
1001 one is not an error, it simply gains nothing beyond the income.
1002 """
1003 if rank < 0 or rank > balance.HUNT_RANKS_TOTAL:
1004 raise ValueError(f"Hunt Rank {rank} is outside 0..{balance.HUNT_RANKS_TOTAL}")
1005 effects: list[Effect] = []
1006 for tier in range(1, rank + 1):
1007 effects.append(_numeric("core_income_bonus", balance.HUNT_RANK_CORE_INCOME_BONUS))
1008 entry = HUNT_RANK_TIERS.get(tier)
1009 if entry is not None:
1010 effects.extend(entry.effects)
1011 return effects
1012
1013
1014# ============================================================================
1015# Registry construction and the design law
1016# ============================================================================
1017
1018
1019def _all_effects() -> Iterable[tuple[Effect, str]]:
1020 """Every effect declared anywhere, paired with the source that owns it."""
1021 for node in DOCTRINE_TREE.values():
1022 yield node.effect, f"doctrine node {node.id!r}"
1023 for hull in HULLS.values():
1024 for effect in hull.effects:
1025 yield effect, f"hull {hull.id!r}"
1026 for tier in HUNT_RANK_TIERS.values():
1027 for effect in tier.effects:
1028 yield effect, f"Hunt Rank {tier.rank}"
1029
1030
1031def _build_rule_registry() -> None:
1032 """Collect every rule id, refusing a collision with a numeric key."""
1033 for effect, owner in _all_effects():
1034 if effect.kind == "numeric":
1035 if effect.key not in NUMERIC_EFFECTS:
1036 raise ValueError(f"{owner} writes unregistered numeric key {effect.key!r}")
1037 continue
1038 if effect.key in NUMERIC_EFFECTS:
1039 raise ValueError(f"{owner} declares rule {effect.key!r}, which is also a numeric key")
1040 RULE_EFFECTS.setdefault(effect.key, owner)
1041
1042
1043def _validate_tree() -> None:
1044 """Check the tree against balance.py and the design law, at import.
1045
1046 A tree that has quietly drifted out of shape is a balance bug nobody sees
1047 until a player counts the nodes, so it is a hard failure here instead.
1048 """
1049 if tuple(BRANCHES) != tuple(balance.DOCTRINE_BRANCHES_IDS):
1050 raise ValueError(f"branches {tuple(BRANCHES)} do not match balance.DOCTRINE_BRANCHES_IDS")
1051 if len(DOCTRINE_TREE) != balance.DOCTRINE_NODES:
1052 raise ValueError(f"tree has {len(DOCTRINE_TREE)} nodes, balance says {balance.DOCTRINE_NODES}")
1053
1054 for branch_id in BRANCHES:
1055 nodes = branch_nodes(branch_id)
1056 if len(nodes) != balance.DOCTRINE_NODES_PER_BRANCH:
1057 raise ValueError(f"branch {branch_id!r} has {len(nodes)} nodes")
1058 counts = {kind: sum(1 for node in nodes if node.kind == kind) for kind in NODE_COSTS}
1059 expected = {
1060 "minor": balance.DOCTRINE_MINORS_PER_BRANCH,
1061 "notable": balance.DOCTRINE_NOTABLES_PER_BRANCH,
1062 "keystone": balance.DOCTRINE_KEYSTONES_PER_BRANCH,
1063 }
1064 if counts != expected:
1065 raise ValueError(f"branch {branch_id!r} has node kinds {counts}, expected {expected}")
1066 keystone = balance.DOCTRINE_KEYSTONES[branch_id]
1067 if keystone not in DOCTRINE_TREE or DOCTRINE_TREE[keystone].kind != "keystone":
1068 raise ValueError(f"branch {branch_id!r} is missing its keystone {keystone!r}")
1069
1070 if tree_cost() != balance.DOCTRINE_FULL_TREE_CORES:
1071 raise ValueError(f"the full tree costs {tree_cost()}, balance says {balance.DOCTRINE_FULL_TREE_CORES}")
1072
1073 seen: set[str] = set()
1074 for node in DOCTRINE_TREE.values():
1075 for required in node.requires:
1076 if required not in DOCTRINE_TREE:
1077 raise ValueError(f"node {node.id!r} requires unknown node {required!r}")
1078 if DOCTRINE_TREE[required].branch != node.branch:
1079 raise ValueError(f"node {node.id!r} requires {required!r} from another branch")
1080 if required not in seen:
1081 raise ValueError(f"node {node.id!r} requires {required!r}, which is not declared before it")
1082 seen.add(node.id)
1083
1084 for branch_id, keystone in balance.DOCTRINE_KEYSTONES.items():
1085 branch_feat = BRANCHES[branch_id].feat
1086 for step in prerequisites_of(keystone):
1087 if DOCTRINE_TREE[step].feat != branch_feat:
1088 raise ValueError(
1089 f"keystone {keystone!r} is gated behind {step!r}, which carries the extra feat "
1090 f"{DOCTRINE_TREE[step].feat!r}; a feat-gated node belongs on a shoulder slot"
1091 )
1092
1093 numeric = sum(1 for node in DOCTRINE_TREE.values() if node.numeric)
1094 if numeric * 3 > balance.DOCTRINE_NODES:
1095 raise ValueError(
1096 f"{numeric} of {balance.DOCTRINE_NODES} nodes are numeric; the design law allows at most one third"
1097 )
1098
1099
1100_build_rule_registry()
1101_validate_tree()
1102
1103
1104# ============================================================================
1105# Milestones
1106# ============================================================================
1107
1108#: The five first-run bounties in the order the design lists them. The ids and
1109#: their prices are ``save.MILESTONE_CORES``; this fixes the presentation order.
1110MILESTONE_ORDER: tuple[str, ...] = (
1111 "first_extraction",
1112 "first_vault",
1113 "first_elite_kill",
1114 "first_act3_entry",
1115 "first_arrival_survived",
1116)
1117
1118MILESTONE_TITLES: dict[str, str] = {
1119 "first_extraction": "First Extraction",
1120 "first_vault": "First Vault Cracked",
1121 "first_elite_kill": "First Elite Killed",
1122 "first_act3_entry": "First Descent to Act 3",
1123 "first_arrival_survived": "First Arrival Survived",
1124}
1125
1126if set(MILESTONE_ORDER) != set(save.MILESTONE_CORES):
1127 raise ValueError("MILESTONE_ORDER has drifted from save.MILESTONE_CORES")
1128
1129
1130def milestone_bounty(milestone_id: str) -> int:
1131 """The Cores paid the first time *milestone_id* is achieved."""
1132 try:
1133 return save.MILESTONE_CORES[milestone_id]
1134 except KeyError:
1135 raise ValueError(
1136 f"unknown milestone {milestone_id!r}; expected one of {sorted(save.MILESTONE_CORES)}"
1137 ) from None
1138
1139
1140# ============================================================================
1141# The profile node
1142# ============================================================================
1143
1144
1145class MetaProfile(Node):
1146 """The tree singleton owning everything that persists between runs.
1147
1148 Added by ``flow.py`` as ``Services.META``. The profile dictionary is the one
1149 ``save.py`` reads and writes; this node is the only thing that interprets
1150 it. Nothing here reads live run state: a run is configured by the
1151 :class:`Modifiers` bag handed over at launch, so changing the tree
1152 mid-flight cannot alter the run in progress.
1153 """
1154
1155 doctrine_changed = Signal(str, bool) # SignalNames.DOCTRINE_CHANGED
1156
1157 def __init__(self, profile: dict | None = None, **kwargs):
1158 super().__init__(**kwargs)
1159 self._explicit_profile = profile is not None
1160 self.profile: dict = save.default_profile()
1161 if profile is not None:
1162 self.adopt(profile)
1163
1164 # -- lifecycle and persistence ----------------------------------------
1165
1166 def on_ready(self):
1167 if self._explicit_profile:
1168 return
1169 system = self.save_system()
1170 if system is not None:
1171 self.profile = system.load_profile()
1172
1173 def save_system(self):
1174 """The ``Services.SAVE`` singleton, or None while none is registered."""
1175 tree = self.tree
1176 if tree is None:
1177 return None
1178 return tree.singletons.get(Services.SAVE)
1179
1180 def adopt(self, profile: dict) -> None:
1181 """Take *profile* as the live profile, filling anything it omits.
1182
1183 A dictionary without a ``schema_version`` is assumed to be current,
1184 which is what makes a hand-built profile usable in a test or a debug
1185 console without reaching into the migration machinery.
1186 """
1187 payload = dict(profile)
1188 payload.setdefault("schema_version", save.PROFILE_SCHEMA_VERSION)
1189 self.profile = save.migrate_profile(payload)
1190 self._explicit_profile = True
1191
1192 def persist(self) -> bool:
1193 """Write the profile through the save system; False if there is none."""
1194 system = self.save_system()
1195 if system is None:
1196 log.debug("MetaProfile has no SaveSystem singleton; the profile stays in memory")
1197 return False
1198 system.save_profile(self.profile)
1199 return True
1200
1201 # -- Cores -------------------------------------------------------------
1202
1203 def cores(self) -> int:
1204 """Cores banked and unspent."""
1205 return int(self.profile["cores"])
1206
1207 def add_cores(self, amount: int, reason: str = "") -> int:
1208 """Bank *amount* Cores and return the new total."""
1209 if amount < 0:
1210 raise ValueError(f"add_cores takes a positive amount, got {amount}")
1211 self.profile["cores"] = self.cores() + int(amount)
1212 log.debug("banked %d Cores (%s), now %d", amount, reason or "unspecified", self.profile["cores"])
1213 return self.cores()
1214
1215 def core_income_multiplier(self) -> float:
1216 """What a run's banked Cores are multiplied by, from Hunt Ranks."""
1217 return 1.0 + resolve(hunt_rank_effects(self.hunt_rank())).value("core_income_bonus")
1218
1219 # -- the doctrine tree -------------------------------------------------
1220
1221 def owned_nodes(self) -> tuple[str, ...]:
1222 """Every doctrine node bought, in purchase order."""
1223 return tuple(self.profile["doctrine"]["owned"])
1224
1225 def is_owned(self, node_id: str) -> bool:
1226 """Whether *node_id* has been bought."""
1227 return node_id in self.profile["doctrine"]["owned"]
1228
1229 def spent_cores(self) -> int:
1230 """What the current tree cost to buy."""
1231 return sum(DOCTRINE_TREE[node_id].cost for node_id in self.owned_nodes())
1232
1233 def feat_met(self, feat: str | None) -> bool:
1234 """Whether the profile satisfies an unlock *feat*."""
1235 if feat is None:
1236 return True
1237 if feat == FEAT_FIRST_EXTRACTION:
1238 return int(self.profile["runs_extracted"]) > 0
1239 if feat == FEAT_FIRST_QUILL:
1240 return int(self.profile["quills"]) > 0
1241 raise ValueError(f"unknown feat {feat!r}")
1242
1243 def purchase_blocker(self, node_id: str) -> str | None:
1244 """Why *node_id* cannot be bought right now, or None if it can.
1245
1246 The reason is written for the doctrine screen to show verbatim.
1247 """
1248 node = DOCTRINE_TREE.get(node_id)
1249 if node is None:
1250 raise ValueError(f"unknown doctrine node {node_id!r}")
1251 if self.is_owned(node_id):
1252 return "already bought"
1253 if not self.feat_met(node.feat):
1254 return f"locked until you {FEAT_PROSE.get(node.feat, node.feat)}"
1255 missing = [required for required in node.requires if not self.is_owned(required)]
1256 if missing:
1257 names = ", ".join(DOCTRINE_TREE[required].title for required in missing)
1258 return f"requires {names}"
1259 if self.cores() < node.cost:
1260 return f"costs {node.cost} Cores, you have {self.cores()}"
1261 return None
1262
1263 def available_nodes(self) -> tuple[str, ...]:
1264 """Every node that could be bought right now."""
1265 return tuple(node_id for node_id in DOCTRINE_TREE if self.purchase_blocker(node_id) is None)
1266
1267 def buy_node(self, node_id: str) -> bool:
1268 """Buy *node_id*, spending its Cores. False if anything blocks it.
1269
1270 A keystone bought while fewer than
1271 ``balance.DOCTRINE_MAX_ACTIVE_KEYSTONES`` are active becomes active at
1272 once, so a player with room never has to visit a second screen to use
1273 what they just bought.
1274 """
1275 blocker = self.purchase_blocker(node_id)
1276 if blocker is not None:
1277 log.debug("cannot buy %s: %s", node_id, blocker)
1278 return False
1279 node = DOCTRINE_TREE[node_id]
1280 self.profile["cores"] = self.cores() - node.cost
1281 self.profile["doctrine"]["owned"].append(node_id)
1282 if node.kind == "keystone" and len(self.active_keystones()) < balance.DOCTRINE_MAX_ACTIVE_KEYSTONES:
1283 self.profile["doctrine"]["active_keystones"].append(node_id)
1284 self.doctrine_changed.emit(node_id, True)
1285 return True
1286
1287 def respec(self) -> int:
1288 """Refund every Core spent and empty the tree; returns the refund.
1289
1290 Free and total, by design: the tree is a loadout language. Called
1291 between runs, when a run's :class:`Modifiers` have already been taken.
1292 """
1293 refund = self.spent_cores()
1294 removed = self.owned_nodes()
1295 self.profile["doctrine"]["owned"] = []
1296 self.profile["doctrine"]["active_keystones"] = []
1297 self.profile["cores"] = self.cores() + refund
1298 for node_id in removed:
1299 self.doctrine_changed.emit(node_id, False)
1300 return refund
1301
1302 # -- keystones ---------------------------------------------------------
1303
1304 def owned_keystones(self) -> tuple[str, ...]:
1305 """Every keystone bought, whether or not it is active."""
1306 return tuple(node_id for node_id in self.owned_nodes() if DOCTRINE_TREE[node_id].kind == "keystone")
1307
1308 def active_keystones(self) -> list[str]:
1309 """The keystones taken into the next run, at most two of them."""
1310 return list(self.profile["doctrine"]["active_keystones"])
1311
1312 def set_active_keystones(self, node_ids: Sequence[str]) -> None:
1313 """Choose the keystones for the next run.
1314
1315 Raises rather than truncating: silently dropping the third choice would
1316 launch a run the player did not configure.
1317 """
1318 chosen = list(dict.fromkeys(node_ids))
1319 if len(chosen) > balance.DOCTRINE_MAX_ACTIVE_KEYSTONES:
1320 raise ValueError(
1321 f"at most {balance.DOCTRINE_MAX_ACTIVE_KEYSTONES} keystones may be active, got {len(chosen)}"
1322 )
1323 for node_id in chosen:
1324 node = DOCTRINE_TREE.get(node_id)
1325 if node is None or node.kind != "keystone":
1326 raise ValueError(f"{node_id!r} is not a keystone")
1327 if not self.is_owned(node_id):
1328 raise ValueError(f"keystone {node_id!r} has not been bought")
1329 self.profile["doctrine"]["active_keystones"] = chosen
1330
1331 # -- the resolved effect bag -------------------------------------------
1332
1333 def doctrine_effects(self) -> list[Effect]:
1334 """The effects the owned tree contributes, keystones filtered to active."""
1335 active = set(self.active_keystones())
1336 effects = []
1337 for node_id in self.owned_nodes():
1338 node = DOCTRINE_TREE[node_id]
1339 if node.kind == "keystone" and node_id not in active:
1340 continue
1341 effects.append(node.effect)
1342 return effects
1343
1344 def modifiers(self) -> Modifiers:
1345 """The complete effect bag for the next run.
1346
1347 Doctrine, active keystones, Hunt Rank and the selected hull, folded into
1348 one flat dictionary. ``flow.py`` takes this once at launch and passes it
1349 in the ``RUN_STARTED`` configuration; nothing reads it again mid-run.
1350 """
1351 effects = list(self.doctrine_effects())
1352 effects.extend(hunt_rank_effects(self.hunt_rank()))
1353 effects.extend(HULLS[self.selected_hull()].effects)
1354 return resolve(effects)
1355
1356 # -- hulls -------------------------------------------------------------
1357
1358 def unlocked_hulls(self) -> tuple[str, ...]:
1359 """Every hull the player may launch in."""
1360 return tuple(self.profile["hulls"]["unlocked"])
1361
1362 def selected_hull(self) -> str:
1363 """The hull the next run launches in."""
1364 return self.profile["hulls"]["selected"]
1365
1366 def select_hull(self, hull_id: str) -> None:
1367 """Choose the hull for the next run."""
1368 if hull_id not in self.unlocked_hulls():
1369 raise ValueError(f"hull {hull_id!r} is not unlocked")
1370 self.profile["hulls"]["selected"] = hull_id
1371
1372 def unlock_hull(self, hull_id: str) -> bool:
1373 """Unlock *hull_id*; False if it was already unlocked."""
1374 if hull_id not in HULLS:
1375 raise ValueError(f"unknown hull {hull_id!r}; expected one of {sorted(HULLS)}")
1376 if hull_id in self.unlocked_hulls():
1377 return False
1378 self.profile["hulls"]["unlocked"].append(hull_id)
1379 return True
1380
1381 # -- milestones --------------------------------------------------------
1382
1383 def milestone_claimed(self, milestone_id: str) -> bool:
1384 """Whether the bounty for *milestone_id* has already been paid."""
1385 milestone_bounty(milestone_id)
1386 return bool(self.profile["milestones"].get(milestone_id, False))
1387
1388 def unclaimed_milestones(self) -> tuple[str, ...]:
1389 """The bounties still outstanding, in presentation order."""
1390 return tuple(mid for mid in MILESTONE_ORDER if not self.milestone_claimed(mid))
1391
1392 def claim_milestone(self, milestone_id: str) -> int:
1393 """Pay the bounty for *milestone_id* once; 0 if already claimed."""
1394 bounty = milestone_bounty(milestone_id)
1395 if self.milestone_claimed(milestone_id):
1396 return 0
1397 self.profile["milestones"][milestone_id] = True
1398 self.add_cores(bounty, f"milestone {milestone_id}")
1399 return bounty
1400
1401 # -- Hunt Ranks --------------------------------------------------------
1402
1403 def hunt_rank(self) -> int:
1404 """The current ascension tier, one per Roost kill."""
1405 return int(self.profile["hunt_rank"])
1406
1407 def award_hunt_rank(self) -> int:
1408 """Advance one Hunt Rank and return the new one.
1409
1410 Clamped at ``balance.HUNT_RANKS_AT_LAUNCH``: ranks 5 to 8 are designed
1411 but not implemented, and handing one out would promise a remix of the
1412 hunter that does not exist yet.
1413 """
1414 rank = min(self.hunt_rank() + 1, balance.HUNT_RANKS_AT_LAUNCH)
1415 self.profile["hunt_rank"] = rank
1416 return rank
1417
1418 # -- ending a run ------------------------------------------------------
1419
1420 def note_run_started(self) -> int:
1421 """Count a launch and return the run number, counting from one."""
1422 self.profile["runs_started"] = int(self.profile["runs_started"]) + 1
1423 return int(self.profile["runs_started"])
1424
1425 def run_score(self, ledger: dict) -> int:
1426 """The run score the death and extraction ledgers rank on."""
1427 return int(
1428 float(ledger.get("scrap_earned", 0.0))
1429 + float(ledger.get("cores_banked", 0.0)) * balance.SCORE_CORES_WEIGHT
1430 + int(ledger.get("quills", 0)) * balance.SCORE_QUILLS_WEIGHT
1431 + int(ledger.get("deepest_sector", 0)) * SCORE_DEPTH_BONUS_PER_SECTOR
1432 )
1433
1434 def record_run(self, ledger: dict) -> dict:
1435 """Fold a finished run into the profile and report what it earned.
1436
1437 *ledger* is the run tally ``flow.py`` builds: ``outcome`` (one of
1438 ``"extracted"``, ``"died"`` or ``"abandoned"``), ``cores_banked``,
1439 ``scrap_earned``, ``scrap_carried``, ``quills``, ``deepest_sector``,
1440 ``hull_id``, ``milestones`` (ids achieved this run) and
1441 ``arrival_escaped_untouched``.
1442
1443 The returned summary is what the ledger screen prints: run Cores after
1444 the Hunt Rank income bonus, milestone bounties separately, any hull the
1445 run earned, the score, and whether it beat the personal best for its
1446 hull. The profile is written to disk before returning.
1447 """
1448 hull_id = ledger.get("hull_id") or self.selected_hull()
1449 if hull_id not in HULLS:
1450 raise ValueError(f"run ledger names unknown hull {hull_id!r}")
1451 extracted = ledger.get("outcome") == "extracted"
1452
1453 run_cores = int(round(float(ledger.get("cores_banked", 0.0)) * self.core_income_multiplier()))
1454 if run_cores:
1455 self.add_cores(run_cores, "run")
1456
1457 milestone_cores = 0
1458 claimed: list[str] = []
1459 for milestone_id in ledger.get("milestones", ()):
1460 paid = self.claim_milestone(milestone_id)
1461 if paid:
1462 milestone_cores += paid
1463 claimed.append(milestone_id)
1464
1465 quills = int(ledger.get("quills", 0))
1466 self.profile["quills"] = int(self.profile["quills"]) + quills
1467 if quills:
1468 self.profile["runs_since_last_quill"] = 0
1469 else:
1470 self.profile["runs_since_last_quill"] = int(self.profile["runs_since_last_quill"]) + 1
1471
1472 if extracted:
1473 self.profile["runs_extracted"] = int(self.profile["runs_extracted"]) + 1
1474
1475 unlocked = [
1476 candidate
1477 for candidate in HULLS
1478 if candidate not in self.unlocked_hulls() and hull_feat_met(candidate, ledger, self.profile)
1479 ]
1480 for candidate in unlocked:
1481 self.unlock_hull(candidate)
1482
1483 score = self.run_score(ledger)
1484 best = int(self.profile["best_scores"].get(hull_id, 0))
1485 personal_best = score > best
1486 if personal_best:
1487 self.profile["best_scores"][hull_id] = score
1488
1489 self.persist()
1490 return {
1491 "hull_id": hull_id,
1492 "run_cores": run_cores,
1493 "milestone_cores": milestone_cores,
1494 "cores_awarded": run_cores + milestone_cores,
1495 "cores_total": self.cores(),
1496 "milestones": claimed,
1497 "hulls_unlocked": unlocked,
1498 "score": score,
1499 "personal_best": personal_best,
1500 }