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        }