shrike/trading.py¶
Part of SHRIKE.
1"""The scrap ledger, the drift depots, and the Broker barge.
2
3Three things live here.
4
5:class:`Economy` is the run's purse. It is the only place scrap is added,
6spent or turned into Cores, so every conversion in the game passes one function
7and shares one unit, Cores per 10 scrap. It listens for the sector's
8``RESOURCE_COLLECTED`` and credits the scrap motes that reach the ship, which
9is what makes flying through the confetti the income.
10
11:class:`Depot` is the trading post: a hermit in a gutted freighter with a
12docking bay, six items of stock plus a pity slot biased toward the build you
13are actually flying, an oxygen canister and a fuel cell that are on the shelf
14whatever the roll says, a reroll that doubles every time you use it, and a
15standing offer to buy your scrap outright at
16``balance.CONVERT_RATE_TRADING_POST``. That
17offer is what anchors meta banking to places on the chart: away from a post the
18only conversion is the Refinery, and the Refinery is loud.
19
20The bay owns the docking, the shelf and the prices; the screen that shows them
21is ``shrike.flow.DepotScene``, which drives :meth:`Depot.tick_interact` by hand
22while it holds the run still so the bay keeps answering its own key. A shelf
23stays put for the whole visit: :meth:`Depot.stock_for` marks a bought entry
24``sold`` rather than dropping it, because a shelf that reshuffles between the
25press and the release is a shelf nobody can read. Consumables restock inside
26:data:`CONSUMABLE_PURCHASE_LIMITS`; a module or a weapon is a thing, and the bay
27has one of it.
28
29:class:`Broker` is the same barge for notorious ships only. Below
30``balance.BROKER_NOTORIETY_THRESHOLD`` it will not open its bay, and above it
31the stock is the eight tier-3 exclusives a quiet player never sees.
32
33Modules themselves are ``shrike.modules``'s business. The shelf reads
34``MODULE_CATALOGUE`` for a module's family, tier and price, and ``ModulePool``
35for whether the world offers it at all, so the shop and the socket rack can
36never disagree about what a thing is or what it costs. Weapons have no such
37catalogue, so their price band lives here beside the consumables.
38
39What a row promises
40===================
41
42Every priced row also carries a ``fit``: where this purchase would land on the
43hull that is docked right now, read off the live racks rather than guessed.
44That is what turns "AUTOCANNON, 43 scrap" into "AUTOCANNON, 43 scrap, mounts
45hardpoint 2" or, on a full rack, into an honest warning that it will stow. The
46effect half of the line comes from the two modules that own the numbers,
47``modules.effect_summary`` and ``weapons.weapon_summary``, so a rebalance moves
48the shelf text with it and no sentence here can drift out of date.
49
50The bay is also where a fit is changed, because it is the only place the run
51stands still. :meth:`Depot.swap_weapon` performs one mount and announces what
52came off; the run scene owns the stowage and moves the two ids itself, which is
53what keeps a single list authoritative. The screen that drives it is
54``shrike.flow.RefitScene``, a schematic of the hull with its mounts drawn where
55they really sit; :meth:`Depot.swap_options` is the same offer as a flat list,
56which is what a headless caller wants and what the dock screen used to print.
57The sentences both of them speak live here, beside the shelf's, so the wording
58of a fit is written once: :func:`item_summary` for what a thing does and
59:func:`refit_preview` for what mounting it here would cost.
60"""
61
62from __future__ import annotations
63
64import math
65import random
66from collections.abc import Callable
67from dataclasses import dataclass
68
69from simvx.core import Area3D, Input, Node, Node3D, Signal, SphereShape3D, Vec2
70
71from . import balance
72from .modules import (
73 MODULE_CATALOGUE,
74 SOCKET_RACK_GROUP,
75 ModulePool,
76 effect_summary,
77 module_spec,
78 price_band,
79 socket_label,
80)
81from .power import SignalWiring
82from .runtime import Groups, Layers, Services, SignalNames, to_plane
83from .weapons import WEAPON_RACK_GROUP, weapon_summary
84
85try:
86 from . import artkit
87except ImportError: # pragma: no cover - artkit is optional for logic tests
88 artkit = None
89
90# ============================================================================
91# Local constants
92#
93# balance.py owns prices, rates and thresholds. What follows is the shape of
94# the shop and the reach of the docking bay, which are this module's own.
95# ============================================================================
96
97#: How close the ship must come before the bay will take it, in world units.
98#:
99#: The barge's own drum is five units across the beam, and the ship cruises at
100#: about sixteen units a second, so the original eight left a three-unit shell
101#: of hull clearance that a pilot at speed crossed in a fifth of a second. That
102#: is not a docking bay, it is a coin toss, and it is why a marked depot read as
103#: something the game would not let you touch. Fourteen gives the approach about
104#: a second and a half of standing still in, which is what a bay is for.
105DOCK_RADIUS = 14.0
106
107#: Hold the interact button this long while docked to sell the hold. The
108#: design's interact ladder is tap to dock, 1 s to patch, 3 s to sell or refine.
109INTERACT_SELL_HOLD_S = 3.0
110
111#: The bay's affordance lines. Every one names its key and its verb, and the
112#: sell line names the price, because a hold is only worth selling if the pilot
113#: can see what it is worth.
114PROMPT_DOCK = "F: DOCK"
115PROMPT_SELL = "HOLD F: SELL {scrap:.0f} SCRAP FOR {cores:.1f} CORES"
116PROMPT_SELL_EMPTY = "DOCKED: nothing in the hold to sell"
117PROMPT_DOCK_REFUSED = "BROKER BARGE: notoriety {threshold} to dock"
118
119#: How many of one shelf entry a single visit will sell.
120#:
121#: A module or a weapon is a thing, and the bay has exactly one of it. A
122#: consumable is a commodity and the bay will keep selling it while the hold
123#: can pay, except where the design caps it: air is rationed at
124#: ``balance.DEPOT_O2_CANISTERS_PER_VISIT`` so a full purse cannot simply buy
125#: its way out of the life-support clock.
126UNIQUE_PURCHASE_LIMIT = 1
127#: What an uncapped consumable is capped at anyway, so no shelf is infinite.
128CONSUMABLE_RESTOCK_LIMIT = balance.DEPOT_CONSUMABLE_RESTOCK_LIMIT
129CONSUMABLE_PURCHASE_LIMITS: dict[str, int] = {
130 "o2_canister": balance.DEPOT_O2_CANISTERS_PER_VISIT,
131 "fuel_cell": balance.DEPOT_FUEL_CELLS_PER_VISIT,
132}
133
134#: The two supplies every bay carries whatever the dice say.
135#:
136#: Air and fuel are not stock, they are the two clocks a run is flown against,
137#: and a shelf that rolled neither is a shop with no answer to either. The
138#: blind gate found exactly that: a bay selling an autocannon, a dampener, an
139#: RTG, a sentry, an ammo box and an emitter vane to a hull down to 61 oxygen
140#: with one jump of fuel in the tank, and no screen anywhere in the game that
141#: named where more of either comes from.
142#:
143#: So they are stocked *beside* the rolled shelf rather than inside it: a
144#: reroll re-rolls the six and never touches these two, and the rolled
145#: consumable slot draws from what is left, so the shelf can never spend a
146#: slot offering air it was already offering. What a visit has already sold
147#: still stands: the canister ration is spent once it is spent.
148GUARANTEED_CONSUMABLES: tuple[str, ...] = ("o2_canister", "fuel_cell")
149#: The slot mark a standing supply carries, beside ``stock`` and ``pity``.
150SUPPLY_SLOT = "supply"
151
152#: How often the pity slot really honours the build it is reading. Below 1.0
153#: on purpose: an archetype has to stay findable without being guaranteed.
154PITY_BIAS_STRENGTH = 0.75
155
156#: Extra weight the Refinery carries in the pity slot. The conversion bet only
157#: stays live if most runs are offered the module that makes it.
158REFINERY_PITY_WEIGHT = 3.0
159
160#: Tiers a depot may stock per act, read off the wreck drop table so the shop
161#: and the world agree about when tier 3 exists at all.
162ACT_TIER_BANDS = {act: drops.module_tiers_in_wrecks for act, drops in balance.ACT_DROPS.items()}
163
164#: Spacing between one depot's seeds and the next reroll's, so two depots one
165#: apart never share a shelf.
166_REROLL_STRIDE = 1_000_003
167
168
169# ============================================================================
170# Stock catalogue
171# ============================================================================
172
173
174@dataclass(frozen=True)
175class StockItem:
176 """One thing on a depot's shelf."""
177
178 id: str
179 kind: str # "weapon" | "module" | "consumable"
180 family: str
181 tier: int
182 price: int
183 slot: str = "stock" # "stock" | "pity"
184 broker_exclusive: bool = False
185 pool_sibling: str = ""
186
187 def as_dict(self) -> dict:
188 """The plain-dict form the depot hands out."""
189 return {
190 "id": self.id,
191 "kind": self.kind,
192 "family": self.family,
193 "tier": self.tier,
194 "price": self.price,
195 "slot": self.slot,
196 "broker_exclusive": self.broker_exclusive,
197 "pool_sibling": self.pool_sibling,
198 }
199
200
201#: Consumables, priced by the design rather than by a tier band.
202CONSUMABLES: dict[str, tuple[str, int]] = {
203 "ammo_box": ("ammo", balance.AMMO_BOX_PRICE_SCRAP),
204 "o2_canister": ("life_support", balance.DEPOT_O2_CANISTER_PRICE_SCRAP),
205 "fuel_cell": ("fuel", balance.DEPOT_FUEL_PRICE_SCRAP),
206 "breach_patch": ("repair", balance.BREACH_PATCH_SCRAP),
207}
208
209#: Which price band each weapon is sold in. ``balance.WEAPONS`` fixes what a
210#: weapon does and which economy it feeds; what a trader charges for one is the
211#: shop's business, and follows the module price curve by tier.
212WEAPON_TIERS: dict[str, int] = {
213 "pulse_blaster": 1,
214 "mining_laser": 1,
215 "autocannon": 1,
216 "scatter_coil": 2,
217 "arc_beam": 2,
218 "flak_cannon": 2,
219 "rail_lance": 2,
220 "grav_hook": 2,
221 "missile_rack": 3,
222 "nova_mortar": 3,
223}
224
225
226#: What one of each consumable actually does, as the delta it applies. A
227#: consumable is bought for a number, so the shelf quotes the number: "+40 O2
228#: now" is an offer where "air in the tank" is a mood.
229CONSUMABLE_EFFECTS: dict[str, str] = {
230 "ammo_box": "one full box of rounds now",
231 "o2_canister": (
232 f"+{balance.DEPOT_O2_CANISTER_AMOUNT:.0f} O2 now, "
233 f"{balance.DEPOT_O2_CANISTER_AMOUNT / balance.O2_DRAIN_PER_S:.0f} s of air on a sound hull"
234 ),
235 "fuel_cell": f"+{balance.DEPOT_FUEL_AMOUNT:.0f} fuel now",
236 "breach_patch": "seals one open breach now",
237}
238
239#: What each shelf category is called on the dock screen.
240CATEGORY_LABELS: dict[str, str] = {"weapon": "weapon", "module": "module", "consumable": "supply"}
241#: What a standing supply's category says instead. The phrase is the promise:
242#: a pilot who rerolls a shelf hunting for air has to be able to read, before
243#: they spend the scrap, that these two rows are not what a reroll changes.
244CATEGORY_ALWAYS_STOCKED = "supply, always stocked"
245
246#: Where a purchase would land, said before the scrap is spent. The shelf is
247#: the last place a fit can still be planned, so a weapon that will not mount
248#: says so on the row rather than in the toast that follows the payment.
249FIT_MOUNTS = "mounts hardpoint {hardpoint}"
250FIT_STOWS = "STOWS: hardpoints full (swap at dock)"
251FIT_INSTALLS = "{socket} socket"
252FIT_NO_SOCKET = "NO FREE {size} SOCKET"
253FIT_FEEDS = "feeds the {name}, {rounds} rounds"
254FIT_NO_FEED = "NO BALLISTIC WEAPON TO FEED"
255FIT_SEALS = "{breaches} open now"
256FIT_NO_BREACH = "NO OPEN BREACH TO SEAL"
257
258
259#: Headings of the fit panel's three blocks.
260FIT_HEADING_WEAPONS = "WEAPONS"
261FIT_HEADING_MODULES = "MODULES"
262FIT_HEADING_STOWED = "STOWED"
263
264#: A refit offer as one line: what goes on, where, and what comes off.
265SWAP_ROW = "MOUNT {name} ON HARDPOINT {hardpoint}, {displaced} COMES OFF"
266SWAP_ROW_FREE = "MOUNT {name} ON HARDPOINT {hardpoint}, which is empty"
267
268#: What the refit panel says. Every line is the same sentence at a different
269#: stage of one decision: what a mount is carrying, what putting this on it
270#: would do, and why it will not go on at all. A refusal is written the same
271#: way as an offer on purpose: the panel prints the answer *before* the commit,
272#: so "will not fit" has to read as a description of the hull rather than as an
273#: error the player has already earned.
274REFIT_SLOT_EMPTY = "EMPTY"
275REFIT_PREVIEW_MOUNT = "{name} ON {slot}: {summary}, REPLACES {displaced}"
276REFIT_PREVIEW_MOUNT_BARE = "{name} ON {slot}: {summary}, THE MOUNT IS BARE"
277REFIT_PREVIEW_PICK_MOUNT = "{name}: {summary}. PICK A MOUNT FOR IT"
278REFIT_PREVIEW_SLOT_FULL = "{slot} CARRIES {occupant}: {summary}"
279REFIT_PREVIEW_SLOT_BARE = "{slot} IS BARE. PICK SOMETHING OUT OF THE LOCKER"
280REFIT_REFUSE_GUN_IN_SOCKET = "{name} IS A GUN: IT NEEDS A HARDPOINT, NOT THE {slot}"
281REFIT_REFUSE_MODULE_ON_HARDPOINT = "{name} IS A MODULE: IT NEEDS A SOCKET, NOT {slot}"
282REFIT_REFUSE_TOO_LARGE = "{name} NEEDS A {size} SOCKET; {slot} IS {socket_size}"
283REFIT_REFUSE_TURRETS_FULL = "{name} WOULD BE TURRET {count}: A HULL CARRIES {maximum}"
284
285
286def item_name(item_id: str) -> str:
287 """A shelf entry's id as a display name: ``pulse_blaster`` to ``PULSE BLASTER``."""
288 return str(item_id).replace("_", " ").upper()
289
290
291def category_label(item: dict) -> str:
292 """The one-word category the dock screen files *item* under."""
293 kind = str(item.get("kind", ""))
294 label = CATEGORY_LABELS.get(kind, kind or "item")
295 if item.get("slot") == "pity":
296 return f"{label}, for your build"
297 if item.get("slot") == SUPPLY_SLOT:
298 return CATEGORY_ALWAYS_STOCKED
299 return label
300
301
302def effect_line(item: dict) -> str:
303 """One line saying what *item* does, for the shelf that is selling it.
304
305 Every line is mechanical. A weapon leads with where it would land, because
306 a gun that is going to stow is a different purchase from one that is going
307 to shoot, and then quotes its DPS, its feed and its trigger. A module leads
308 with the numbers it actually applies and ends with the mount it takes. A
309 consumable leads with its delta.
310
311 The mount half comes off ``item["fit"]``, which :meth:`Depot.priced` folds
312 in from the live hull. Without a hull the line still describes the thing,
313 it simply cannot promise it a socket. A consumable the hull has no use for
314 prints the refusal alone: "+40 O2 now" beside "no tank to fill" is two
315 halves of a sentence arguing with each other.
316 """
317 item_id = str(item.get("id", ""))
318 kind = str(item.get("kind", ""))
319 fit = str(item.get("fit", ""))
320 blocked = bool(item.get("fit_blocked", False))
321 if kind == "weapon":
322 if item_id not in balance.WEAPONS:
323 return ""
324 return ", ".join(part for part in (fit, weapon_summary(item_id)) if part)
325 if kind == "module":
326 if item_id not in MODULE_CATALOGUE:
327 return ""
328 return ", ".join(part for part in (effect_summary(item_id), fit or socket_label(item_id)) if part)
329 if blocked:
330 return fit
331 return ", ".join(part for part in (CONSUMABLE_EFFECTS.get(item_id, ""), fit) if part)
332
333
334def item_summary(item_id: str) -> str:
335 """What a thing in the locker does, gun or module, in one line.
336
337 The two catalogues answer the question in their own words and neither
338 knows about the other, so the refit panel would otherwise have to branch on
339 the kind at every place it prints a name. Empty for an id in neither, which
340 is what a stale save or a hand-built test row hands in.
341 """
342 item_id = str(item_id)
343 if item_id in balance.WEAPONS:
344 return weapon_summary(item_id)
345 if item_id in MODULE_CATALOGUE:
346 return effect_summary(item_id)
347 return ""
348
349
350def refit_preview(item_id: str, slot_label: str, occupant: str = "") -> str:
351 """What mounting *item_id* on *slot_label* would do, before it is committed.
352
353 The panel prints this while the mount is only being *looked* at, which is
354 the whole point of it: a refit that names the gun coming off before the
355 press is a decision, and the same sentence after the press is a receipt.
356 *occupant* is the id already on the mount, empty for a bare one.
357 """
358 name = item_name(item_id)
359 summary = item_summary(item_id)
360 if occupant:
361 return REFIT_PREVIEW_MOUNT.format(name=name, slot=slot_label, summary=summary, displaced=item_name(occupant))
362 return REFIT_PREVIEW_MOUNT_BARE.format(name=name, slot=slot_label, summary=summary)
363
364
365def fit_panel_lines(
366 rack=None,
367 sockets=None,
368 stowed: tuple[str, ...] | list[str] = (),
369 stowed_modules: tuple[str, ...] | list[str] = (),
370) -> list[str]:
371 """The current fit as a screen prints it: hardpoints, sockets, stowage.
372
373 The dock screen's FIT panel. Composed here rather than in either rack
374 because a fit is the two of them together, and a pilot deciding what to buy
375 reads them as one list. Either rack may be absent, which is what a test
376 harness or a half-built run hands in; the block it owns is simply left out.
377
378 The stowage is one block, guns and modules together, because it is one
379 locker: a module pulled out of a socket at the refit panel is waiting for a
380 mount in exactly the way a gun with no free hardpoint is.
381 """
382 lines: list[str] = []
383 if rack is not None:
384 lines.append(FIT_HEADING_WEAPONS)
385 lines.extend(rack.fit_lines())
386 if sockets is not None:
387 lines.append(FIT_HEADING_MODULES)
388 lines.extend(sockets.fit_lines())
389 locker = [*stowed, *stowed_modules]
390 if locker:
391 lines.append(FIT_HEADING_STOWED)
392 lines.extend(f"{item_name(item_id)}, {item_summary(item_id)}" for item_id in locker)
393 return lines
394
395
396def _weapon_price(weapon_id: str, rng: random.Random) -> int:
397 low, high = price_band(WEAPON_TIERS.get(weapon_id, 1))
398 return int(rng.randint(low, high))
399
400
401def _weapon_family(weapon_id: str) -> str:
402 spec = balance.WEAPONS.get(weapon_id)
403 return spec.family if spec is not None else "energy"
404
405
406def _module_item(module_id: str, *, slot: str = "stock") -> StockItem:
407 """One catalogue module as a shelf entry, priced by ``shrike.modules``."""
408 spec = module_spec(module_id)
409 return StockItem(
410 id=module_id,
411 kind="module",
412 family=str(spec["family"]),
413 tier=int(spec["tier"]),
414 price=int(spec["price"]),
415 slot=slot,
416 broker_exclusive=bool(spec["broker_exclusive"]),
417 pool_sibling=str(spec["pool_sibling"] or ""),
418 )
419
420
421# ============================================================================
422# Economy
423# ============================================================================
424
425
426class Economy(Node):
427 """The run's scrap and the Cores it becomes. Singleton ``Services.ECONOMY``.
428
429 Scrap arrives from the sector's ``RESOURCE_COLLECTED``, from bounties and
430 from anything else that calls :meth:`add_scrap`. It leaves through
431 :meth:`spend_scrap` (purchases, patches, the Lure fee) or through
432 :meth:`convert`, which is the only door onto the meta currency and takes
433 its rate from the caller: ``balance.CONVERT_RATE_TRADING_POST`` at a depot,
434 ``CONVERT_RATE_REFINERY`` aboard, ``CONVERT_RATE_EXTRACTION`` at the Gate,
435 ``CONVERT_RATE_DEATH`` when the run ends badly.
436 """
437
438 scrap_changed = Signal(float)
439 cores_banked = Signal(float, float)
440
441 def __init__(self, *, scrap: float = 0.0, **kwargs):
442 super().__init__(**kwargs)
443 self.scrap = max(0.0, float(scrap))
444 self.cores_banked_this_run = 0.0
445 #: Every scrap ever credited this run, for the death ledger's income line.
446 self.scrap_earned_this_run = 0.0
447 self._wiring = SignalWiring(self)
448 self._wiring.want(SignalNames.RESOURCE_COLLECTED, self._on_resource_collected)
449
450 def on_ready(self):
451 self._wiring.sweep()
452
453 def on_update(self, dt: float):
454 self._wiring.poll(dt)
455
456 def add_scrap(self, amount: float, source: str = "") -> None:
457 """Credit *amount* of scrap. *source* is for the ledger, not the maths."""
458 del source
459 amount = float(amount)
460 if amount <= 0.0:
461 return
462 self.scrap += amount
463 self.scrap_earned_this_run += amount
464 self.scrap_changed(self.scrap)
465
466 def spend_scrap(self, amount: float) -> bool:
467 """Atomic spend. False and no deduction when the hold is short."""
468 amount = float(amount)
469 if amount < 0.0 or amount > self.scrap + 1e-9:
470 return False
471 self.scrap = max(0.0, self.scrap - amount)
472 self.scrap_changed(self.scrap)
473 return True
474
475 def convert(self, scrap: float, rate: float) -> float:
476 """Turn carried scrap into Cores at *rate* Cores per 10 scrap.
477
478 Converts at most what is carried, so a caller may pass more than the
479 hold holds and still get an honest answer. Returns the Cores banked.
480 """
481 scrap = min(max(0.0, float(scrap)), self.scrap)
482 if scrap <= 0.0:
483 return 0.0
484 cores = balance.cores_from_scrap(scrap, float(rate))
485 self.scrap -= scrap
486 self.cores_banked_this_run += cores
487 self.scrap_changed(self.scrap)
488 self.cores_banked(cores, float(rate))
489 return cores
490
491 def _on_resource_collected(self, kind: str, amount: float) -> None:
492 if kind == "scrap":
493 self.add_scrap(amount, "sector")
494
495
496# ============================================================================
497# Depots
498# ============================================================================
499
500
501class Depot(Node3D):
502 """A drift depot: a docking bay, a shelf of stock and a standing scrap offer.
503
504 Docking is proximity plus intent. The bay is an ``Area3D`` sensor on
505 ``Layers.INTERACT`` watching for the ship, and a tap of ``interact`` inside
506 it docks; holding the same button for :data:`INTERACT_SELL_HOLD_S` while
507 docked sells the whole hold at the post's rate. Flying out undocks.
508
509 Stock is deterministic from ``seed`` and the reroll count, so a route
510 replayed on the same seed offers the same shelf; :meth:`stock_for` layers
511 the pity slot on top of it from the build profile the run hands in, and
512 puts :data:`GUARANTEED_CONSUMABLES` in front of the whole roll, so air and
513 fuel are always for sale somewhere the pilot can find them.
514 """
515
516 docked = Signal(str)
517 undocked = Signal()
518 #: ``(item_id, price)`` once payment has cleared. Module-local by design:
519 #: the run scene connects at mount and delivers the goods, so the depot
520 #: never has to know what a socket or a hardpoint is.
521 item_purchased = Signal(str, float)
522 #: ``(mounted_id, displaced_id, hardpoint)`` after a swap at the bay. The
523 #: run scene owns the stowage, so it listens for this and moves the two ids
524 #: between its locker and the hull.
525 weapon_swapped = Signal(str, str, int)
526
527 def __init__(
528 self,
529 *,
530 depot_id: str = "depot",
531 seed: int = 0,
532 act: int = 1,
533 dock_radius: float = DOCK_RADIUS,
534 pool: ModulePool | None = None,
535 **kwargs,
536 ):
537 super().__init__(**kwargs)
538 self.depot_id = str(depot_id)
539 self.seed = int(seed)
540 self.act = max(1, min(balance.CHART_ACTS, int(act)))
541 self.dock_radius = float(dock_radius)
542
543 self.is_docked = False
544 self.in_range = False
545 self.rerolls = 0
546 #: Ids this visit will not sell again, either because the bay had one
547 #: of them or because the ration is spent.
548 self.sold_out: set[str] = set()
549 #: How many of each id this visit has sold, which is what the shelf
550 #: shows and what :data:`CONSUMABLE_PURCHASE_LIMITS` is measured against.
551 self.purchases: dict[str, int] = {}
552 #: The world pool this shop stocks from; a purchase here widens it for
553 #: good, and the meta profile is what carries that between runs.
554 self.pool = pool if pool is not None else ModulePool()
555 #: Sibling modules a purchase here has just unlocked, for the run to bank.
556 self.pool_unlocks: list[str] = []
557 #: Asked before payment, with the item dict. Returning False refuses
558 #: the sale and nothing is spent; the run scene installs one that
559 #: checks the hull can actually take the item, so a full socket rack
560 #: costs a toast rather than scrap.
561 self.purchase_gate: Callable[[dict], bool] | None = None
562
563 self._interact_hold = 0.0
564 self._sold_this_press = False
565 #: True while a press that has already been answered elsewhere is still
566 #: down. Undocking releases the bay on the same button the dock screen
567 #: commits with, and without this the release that closed the bay would
568 #: open it again on the very next frame.
569 self._ignore_release = False
570 self._stock_cache: tuple[object, list[dict]] | None = None
571
572 # -- scene ------------------------------------------------------------
573
574 def on_enter_tree(self):
575 super().on_enter_tree()
576 self.add_to_group(Groups.DEPOTS)
577
578 def on_ready(self):
579 if artkit is not None:
580 self.add_child(artkit.build_depot(self.seed))
581 self.add_child(
582 Area3D(
583 name="DockSensor",
584 shape=SphereShape3D(radius=self.dock_radius),
585 collision_layer=Layers.INTERACT,
586 collision_mask=Layers.MASK_INTERACT_SENSOR,
587 )
588 )
589
590 @property
591 def plane_position(self) -> Vec2:
592 return to_plane(self.world_position)
593
594 def _ship(self):
595 tree = self.tree
596 return tree.get_first_in_group(Groups.SHIP) if tree is not None else None
597
598 def on_update(self, dt: float):
599 ship = self._ship()
600 if ship is None:
601 self.in_range = False
602 if self.is_docked:
603 self.undock()
604 return
605
606 here = self.plane_position
607 there = to_plane(ship.world_position)
608 distance = math.hypot(float(here.x) - float(there.x), float(here.y) - float(there.y))
609 self.in_range = distance <= self.dock_radius
610
611 if self.is_docked and not self.in_range:
612 self.undock()
613
614 self.tick_interact(dt)
615
616 def tick_interact(self, dt: float, *, taps_toggle_bay: bool = True) -> None:
617 """Read one frame of the bay's interact ladder.
618
619 Resolved on release so one button can both tap and hold: a tap toggles
620 the bay, :data:`INTERACT_SELL_HOLD_S` of hold sells the hold at the
621 post's rate.
622
623 Called from :meth:`on_update` in the ordinary case, and directly by the
624 dock screen while that screen holds the run still: a paused depot would
625 stop answering the button the world spent the whole run teaching. The
626 screen passes ``taps_toggle_bay=False`` because while it is up a tap is
627 its own confirm and the screen owns the way out, but the sell rung stays
628 live, so holding the key still empties the hold exactly as it does in
629 open space.
630 """
631 if Input.is_action_just_pressed("interact"):
632 self._interact_hold = 0.0
633 self._sold_this_press = False
634 self._ignore_release = False
635 if Input.is_action_pressed("interact"):
636 self._interact_hold += dt
637 if self.is_docked and not self._sold_this_press and self._interact_hold >= INTERACT_SELL_HOLD_S:
638 self._sold_this_press = True
639 self.sell_hold()
640 elif Input.is_action_just_released("interact"):
641 tapped = not self._sold_this_press and self._interact_hold < INTERACT_SELL_HOLD_S
642 if tapped and taps_toggle_bay and not self._ignore_release:
643 if self.is_docked:
644 self.undock()
645 elif self.in_range:
646 self.dock()
647 self._interact_hold = 0.0
648 self._sold_this_press = False
649 self._ignore_release = False
650
651 def interact_hold_fraction(self) -> float:
652 """Progress of the sell hold, 0 to 1, for the HUD's radial fill."""
653 if not self.is_docked or self._sold_this_press:
654 return 0.0
655 return min(1.0, self._interact_hold / INTERACT_SELL_HOLD_S)
656
657 def affordance(self) -> str:
658 """The verb line for the bay's current state, empty when out of range.
659
660 Docked, the line quotes the hold and what the post would pay for it.
661 In range but not docked, it names the key. A barge that will not open
662 its bay says why, rather than answering a press with silence.
663 """
664 if self.is_docked:
665 economy = self._economy()
666 scrap = float(getattr(economy, "scrap", 0.0)) if economy is not None else 0.0
667 if scrap <= 0.0:
668 return PROMPT_SELL_EMPTY
669 cores = balance.cores_from_scrap(scrap, balance.CONVERT_RATE_TRADING_POST)
670 return PROMPT_SELL.format(scrap=scrap, cores=cores)
671 if not self.in_range:
672 return ""
673 if not self.can_dock():
674 return PROMPT_DOCK_REFUSED.format(threshold=balance.BROKER_NOTORIETY_THRESHOLD)
675 return PROMPT_DOCK
676
677 # -- docking -----------------------------------------------------------
678
679 def can_dock(self) -> bool:
680 """Whether this bay will take the ship at all. Always true for a post."""
681 return True
682
683 def dock(self) -> bool:
684 """Open the bay. False when the ship is out of range or refused."""
685 if self.is_docked or not self.in_range or not self.can_dock():
686 return False
687 self.is_docked = True
688 self.docked(self.depot_id)
689 return True
690
691 def undock(self) -> None:
692 """Close the bay and let the run continue."""
693 if not self.is_docked:
694 return
695 self.is_docked = False
696 self._ignore_release = True
697 self.undocked()
698
699 # -- stock -------------------------------------------------------------
700
701 def stock_for(self, build_profile: dict) -> list[dict]:
702 """This visit's shelf: the standing supplies, six items, and the pity slot.
703
704 The supplies are :data:`GUARANTEED_CONSUMABLES`, air and fuel, which
705 every bay carries and no reroll can take away. Six is
706 ``balance.DEPOT_STOCK_WEAPONS`` weapons, ``DEPOT_STOCK_MODULES``
707 modules and ``DEPOT_STOCK_CONSUMABLES`` consumable drawn from what the
708 supplies leave; the pity slot is drawn from whatever families the
709 profile is already flying, so an archetype stays findable without ever
710 being promised. The result is stable for a given profile and reroll
711 count.
712
713 A bought entry stays on the shelf carrying ``sold``, rather than
714 vanishing and letting the rest of the shelf reshuffle under a pilot who
715 is still reading it. Only a reroll changes what is on offer.
716 """
717 profile = dict(build_profile or {})
718 signature = (self.rerolls, _profile_signature(profile))
719 if self._stock_cache is not None and self._stock_cache[0] == signature:
720 return [self.priced(item) for item in self._stock_cache[1]]
721
722 # Seeded from integers only: a shelf must roll the same way in every
723 # process, and Python's string hashing is salted per run.
724 rng = random.Random(self.seed * _REROLL_STRIDE + self.rerolls)
725 items = self._roll_stock(rng, profile)
726 self._stock_cache = (signature, [dict(item) for item in items])
727 return [self.priced(item) for item in items]
728
729 def priced(self, item: dict) -> dict:
730 """One shelf entry with this visit's live state folded in.
731
732 Beside what the visit has sold, the entry carries ``fit``: where this
733 purchase would land on the hull that is docked right now. It is read
734 off the live racks rather than guessed, so the row cannot promise a
735 hardpoint the delivery will not give it.
736 """
737 entry = dict(item)
738 item_id = str(entry.get("id", ""))
739 entry["sold"] = item_id in self.sold_out
740 entry["bought"] = int(self.purchases.get(item_id, 0))
741 entry["fit"], entry["fit_blocked"] = self.fit_for(entry)
742 return entry
743
744 # -- what a purchase would do to the hull ------------------------------
745
746 def weapon_rack(self):
747 """The docked ship's weapon rack, or None when no hull is in the tree."""
748 tree = self.tree
749 racks = tree.group(WEAPON_RACK_GROUP) if tree is not None else []
750 return racks[0] if racks else None
751
752 def socket_rack(self):
753 """The docked ship's socket rack, or None when no hull is in the tree."""
754 tree = self.tree
755 racks = tree.group(SOCKET_RACK_GROUP) if tree is not None else []
756 return racks[0] if racks else None
757
758 def fit_for(self, item: dict) -> tuple[str, bool]:
759 """Where buying *item* would land it, and whether it would land at all.
760
761 The text is what the shelf prints; the flag is True when the hull has
762 nowhere to put the thing, which is the same answer the run scene's
763 purchase gate gives after the press. Saying it on the row is what turns
764 a refused purchase from a surprise into a decision.
765
766 Empty and False when there is no hull to ask, which is the honest
767 answer: a shelf rolled outside a run knows what a thing is and not
768 where it would go.
769 """
770 item_id = str(item.get("id", ""))
771 kind = str(item.get("kind", ""))
772 if kind == "weapon":
773 return self._weapon_fit()
774 if kind == "module":
775 return self._module_fit(item_id)
776 return self._consumable_fit(item_id)
777
778 def _weapon_fit(self) -> tuple[str, bool]:
779 rack = self.weapon_rack()
780 if rack is None:
781 return "", False
782 hardpoint = rack.free_hardpoint()
783 # A full rack is not a refusal: the weapon is bought and stowed, and
784 # the bay is where it is swapped onto the hull.
785 if hardpoint is None:
786 return FIT_STOWS, False
787 return FIT_MOUNTS.format(hardpoint=hardpoint + 1), False
788
789 def _module_fit(self, module_id: str) -> tuple[str, bool]:
790 sockets = self.socket_rack()
791 if sockets is None or module_id not in MODULE_CATALOGUE:
792 return "", False
793 socket = sockets.socket_for(module_id)
794 if socket is None:
795 return FIT_NO_SOCKET.format(size=str(module_spec(module_id)["socket_size"]).upper()), True
796 return FIT_INSTALLS.format(socket=socket.label), False
797
798 def _consumable_fit(self, item_id: str) -> tuple[str, bool]:
799 if item_id == "ammo_box":
800 rack = self.weapon_rack()
801 if rack is None:
802 return "", False
803 eater = rack.ammo_eater()
804 if eater is None:
805 return FIT_NO_FEED, True
806 rounds = int(balance.WEAPONS[eater].rounds_per_box or 0)
807 return FIT_FEEDS.format(name=item_name(eater), rounds=rounds), False
808 if item_id == "breach_patch":
809 ship = self._ship()
810 if ship is None:
811 return "", False
812 breaches = int(getattr(ship, "open_breaches", 0) or 0)
813 if breaches <= 0:
814 return FIT_NO_BREACH, True
815 return FIT_SEALS.format(breaches=breaches), False
816 return "", False
817
818 def _roll_stock(self, rng: random.Random, profile: dict) -> list[dict]:
819 weapons = self._weapon_pool(profile)
820 modules = self._module_pool(profile)
821 # The two clocks first, and outside the roll: see GUARANTEED_CONSUMABLES.
822 # First in the list as well as always in it, because the shelf is read
823 # from the top and a bay is docked at when something is running out.
824 items: list[StockItem] = [self._supply(consumable_id) for consumable_id in GUARANTEED_CONSUMABLES]
825
826 for weapon_id in _sample(rng, weapons, balance.DEPOT_STOCK_WEAPONS):
827 items.append(
828 StockItem(
829 weapon_id,
830 "weapon",
831 _weapon_family(weapon_id),
832 WEAPON_TIERS.get(weapon_id, 1),
833 _weapon_price(weapon_id, rng),
834 )
835 )
836 for module_id in _sample(rng, modules, balance.DEPOT_STOCK_MODULES):
837 items.append(_module_item(module_id))
838 rolled = [consumable_id for consumable_id in CONSUMABLES if consumable_id not in GUARANTEED_CONSUMABLES]
839 for consumable_id in _sample(rng, rolled, balance.DEPOT_STOCK_CONSUMABLES):
840 family, price = CONSUMABLES[consumable_id]
841 items.append(StockItem(consumable_id, "consumable", family, 1, price))
842
843 for _ in range(balance.DEPOT_PITY_SLOTS):
844 pity = self._roll_pity(rng, profile, modules, {item.id for item in items})
845 if pity is not None:
846 items.append(pity)
847 return [item.as_dict() for item in items]
848
849 def _supply(self, consumable_id: str) -> StockItem:
850 """One standing supply row, priced by the design rather than by a roll."""
851 family, price = CONSUMABLES[consumable_id]
852 return StockItem(consumable_id, "consumable", family, 1, price, slot=SUPPLY_SLOT)
853
854 def _roll_pity(
855 self,
856 rng: random.Random,
857 profile: dict,
858 modules: list[str],
859 taken: set[str],
860 ) -> StockItem | None:
861 """One slot that reads the build. Biased, never guaranteed."""
862 weights = _profile_weights(profile)
863 candidates = [module_id for module_id in modules if module_id not in taken] or list(modules)
864 if not candidates:
865 return None
866
867 def weight_of(module_id: str) -> float:
868 family = module_spec(module_id)["family"]
869 weight = weights.get(family, 0.0)
870 if family == "refinery":
871 weight += REFINERY_PITY_WEIGHT
872 return weight
873
874 matching = [module_id for module_id in candidates if weight_of(module_id) > 0.0]
875 if matching and rng.random() < PITY_BIAS_STRENGTH:
876 chosen = _weighted_choice(rng, matching, [weight_of(module_id) for module_id in matching])
877 else:
878 chosen = rng.choice(candidates)
879 return _module_item(chosen, slot="pity")
880
881 def _tier_band(self, profile: dict) -> tuple[int, int]:
882 act = int(profile.get("act", self.act))
883 return ACT_TIER_BANDS[max(1, min(balance.CHART_ACTS, act))]
884
885 def _weapon_pool(self, profile: dict) -> list[str]:
886 low, high = self._tier_band(profile)
887 owned = set(profile.get("weapons", ()))
888 pool = [wid for wid, tier in WEAPON_TIERS.items() if low <= tier <= high and wid not in owned]
889 return pool or [wid for wid in WEAPON_TIERS if wid not in owned] or list(WEAPON_TIERS)
890
891 def pool_for(self, profile: dict) -> ModulePool:
892 """The world pool this visit stocks from, widened by what is unlocked."""
893 expansions = set(self.pool.expansions)
894 expansions |= {str(module_id) for module_id in profile.get("pool_unlocks", ())}
895 expansions |= {str(module_id) for module_id in profile.get(ModulePool.PROFILE_KEY, ())}
896 return ModulePool(expansions)
897
898 def _module_pool(self, profile: dict) -> list[str]:
899 low, high = self._tier_band(profile)
900 owned = set(profile.get("modules", ()))
901 available = self.pool_for(profile).available()
902 pool = [
903 module_id
904 for module_id in available
905 if low <= module_spec(module_id)["tier"] <= high
906 and module_id not in owned
907 and module_id not in self.sold_out
908 ]
909 return pool or available
910
911 def reroll_price(self) -> int:
912 """What the next reroll costs: 10 scrap, doubling every time."""
913 return int(balance.DEPOT_REROLL_BASE_SCRAP * balance.DEPOT_REROLL_PRICE_MULT**self.rerolls)
914
915 def reroll(self) -> bool:
916 """Buy a fresh shelf. False when the hold cannot cover the price."""
917 economy = self._economy()
918 price = self.reroll_price()
919 if economy is None or not economy.spend_scrap(price):
920 return False
921 self.rerolls += 1
922 self._stock_cache = None
923 return True
924
925 # -- trading -----------------------------------------------------------
926
927 def purchase_limit(self, item: dict) -> int:
928 """How many of *item* this visit will sell in total."""
929 if str(item.get("kind", "")) != "consumable":
930 return UNIQUE_PURCHASE_LIMIT
931 return CONSUMABLE_PURCHASE_LIMITS.get(str(item.get("id", "")), CONSUMABLE_RESTOCK_LIMIT)
932
933 def buy(self, item: dict) -> bool:
934 """Buy one stocked *item*.
935
936 False, and no payment, when the bay is closed, the item is gone, the
937 :attr:`purchase_gate` refuses it, or the hold cannot cover the price.
938 """
939 item_id = str(item.get("id", ""))
940 economy = self._economy()
941 if not self.is_docked or economy is None or item_id in self.sold_out:
942 return False
943 if self.purchase_gate is not None and not self.purchase_gate(dict(item)):
944 return False
945 price = float(item.get("price", 0.0))
946 if not economy.spend_scrap(price):
947 return False
948 sold = self.purchases.get(item_id, 0) + 1
949 self.purchases[item_id] = sold
950 if sold >= self.purchase_limit(item):
951 self.sold_out.add(item_id)
952 if item.get("kind") == "module":
953 sibling = self.pool.record_purchase(item_id)
954 if sibling is not None:
955 self.pool_unlocks.append(sibling)
956 self.item_purchased(item_id, price)
957 return True
958
959 # -- refitting ---------------------------------------------------------
960
961 def swap_options(self, stowed: tuple[str, ...] | list[str]) -> list[dict]:
962 """Every stowed weapon against every hardpoint it could take.
963
964 The flat form of the offer the refit panel draws on the hull: each
965 entry carries the id going on, the hardpoint it goes on, the id coming
966 off and a line naming all three, so a caller with no screen (a test, a
967 headless probe) can enumerate the refits a visit is offering.
968
969 Empty when nothing is stowed, which is the ordinary case.
970 """
971 rack = self.weapon_rack()
972 if rack is None:
973 return []
974 options: list[dict] = []
975 for weapon_id in stowed:
976 if weapon_id not in balance.WEAPONS:
977 continue
978 for hardpoint, fitted in enumerate(rack.hardpoints):
979 displaced = fitted.weapon_id if fitted is not None else ""
980 template = SWAP_ROW if displaced else SWAP_ROW_FREE
981 options.append(
982 {
983 "weapon_id": weapon_id,
984 "hardpoint": hardpoint,
985 "displaced": displaced,
986 "label": template.format(
987 name=item_name(weapon_id),
988 hardpoint=hardpoint + 1,
989 displaced=item_name(displaced),
990 ),
991 }
992 )
993 return options
994
995 def swap_weapon(self, weapon_id: str, hardpoint: int) -> str | None:
996 """Mount a stowed weapon and hand back the id it displaced.
997
998 The empty string when the hardpoint was bare, and None when the swap
999 could not happen at all: no bay open, no hull, or a hardpoint that does
1000 not exist. The caller owns the stowage and moves both ids itself, which
1001 is what keeps one list authoritative.
1002 """
1003 rack = self.weapon_rack()
1004 if not self.is_docked or rack is None or weapon_id not in balance.WEAPONS:
1005 return None
1006 if not 0 <= hardpoint < len(rack.hardpoints):
1007 return None
1008 displaced = rack.swap_in(weapon_id, hardpoint)
1009 # The shelf needs no invalidation: the rolled stock is cached, but
1010 # `priced` re-reads the hull on every shelf read, so the next refresh
1011 # already names the hardpoint this swap has just freed or filled.
1012 self.weapon_swapped(weapon_id, displaced, hardpoint)
1013 return displaced
1014
1015 def sell_quote(self) -> tuple[float, float, float]:
1016 """What the hold is, what the post would pay for it, and at what rate."""
1017 economy = self._economy()
1018 scrap = float(getattr(economy, "scrap", 0.0)) if economy is not None else 0.0
1019 rate = balance.CONVERT_RATE_TRADING_POST
1020 return scrap, balance.cores_from_scrap(scrap, rate), rate
1021
1022 def sell_hold(self) -> float:
1023 """Sell the whole hold at the post's rate. Returns Cores banked."""
1024 economy = self._economy()
1025 if economy is None or not self.is_docked:
1026 return 0.0
1027 return economy.convert(economy.scrap, balance.CONVERT_RATE_TRADING_POST)
1028
1029 def _economy(self) -> Economy | None:
1030 tree = self.tree
1031 return tree.singletons.get(Services.ECONOMY) if tree is not None else None
1032
1033 def _notoriety(self) -> int:
1034 tree = self.tree
1035 tally = tree.singletons.get(Services.NOTORIETY) if tree is not None else None
1036 return int(getattr(tally, "value", 0)) if tally is not None else 0
1037
1038
1039class Broker(Depot):
1040 """The black-market barge: notoriety only, and the eight best modules.
1041
1042 It will not open its bay below ``balance.BROKER_NOTORIETY_THRESHOLD``, and
1043 what it sells is the tier-3 stock no quiet run ever sees. That gate is the
1044 payoff gradient that keeps loud play worth its price.
1045 """
1046
1047 def __init__(self, *, depot_id: str = "broker", **kwargs):
1048 super().__init__(depot_id=depot_id, **kwargs)
1049
1050 def can_dock(self) -> bool:
1051 tree = self.tree
1052 tally = tree.singletons.get(Services.NOTORIETY) if tree is not None else None
1053 if tally is not None and hasattr(tally, "broker_unlocked"):
1054 return bool(tally.broker_unlocked())
1055 return self._notoriety() >= balance.BROKER_NOTORIETY_THRESHOLD
1056
1057 def exclusives(self) -> list[str]:
1058 """The tier-3 modules only this barge stocks."""
1059 return [module_id for module_id, spec in MODULE_CATALOGUE.items() if spec["broker_exclusive"]]
1060
1061 def stock_for(self, build_profile: dict) -> list[dict]:
1062 """All eight exclusives, priced. No pity slot: it is all top shelf."""
1063 del build_profile
1064 return [self.priced(_module_item(module_id).as_dict()) for module_id in self.exclusives()]
1065
1066
1067# ============================================================================
1068# Helpers
1069# ============================================================================
1070
1071
1072def _profile_signature(profile: dict) -> tuple:
1073 """A hashable read of the parts of a build profile the shelf depends on."""
1074 return (
1075 int(profile.get("act", 0)),
1076 tuple(sorted(str(x) for x in profile.get("weapons", ()))),
1077 tuple(sorted(str(x) for x in profile.get("modules", ()))),
1078 tuple(sorted(str(x) for x in profile.get("pool_unlocks", ()))),
1079 tuple(sorted(str(x) for x in profile.get(ModulePool.PROFILE_KEY, ()))),
1080 tuple(sorted((str(k), float(v)) for k, v in dict(profile.get("families", {})).items())),
1081 )
1082
1083
1084def _profile_weights(profile: dict) -> dict[str, float]:
1085 """Family weights for the pity slot, from whatever the profile carries.
1086
1087 ``families`` wins where it is given; otherwise the weights are read off
1088 what the ship is already flying, which is the same question asked the other
1089 way round.
1090 """
1091 weights = {str(k): float(v) for k, v in dict(profile.get("families", {})).items()}
1092 for module_id in profile.get("modules", ()):
1093 spec = MODULE_CATALOGUE.get(str(module_id))
1094 if spec is not None:
1095 family = str(spec["family"])
1096 weights[family] = weights.get(family, 0.0) + 1.0
1097 for weapon_id in profile.get("weapons", ()):
1098 family = _weapon_family(str(weapon_id))
1099 weights[family] = weights.get(family, 0.0) + 1.0
1100 if family == "ballistic":
1101 weights["turret"] = weights.get("turret", 0.0) + 0.5
1102 return weights
1103
1104
1105def _sample(rng: random.Random, pool: list, count: int) -> list:
1106 """*count* distinct picks from *pool*, or the whole pool when it is short."""
1107 if count >= len(pool):
1108 return list(pool)
1109 return rng.sample(pool, count)
1110
1111
1112def _weighted_choice(rng: random.Random, items: list, weights: list[float]):
1113 total = sum(weights)
1114 if total <= 0.0:
1115 return rng.choice(items)
1116 roll = rng.random() * total
1117 for item, weight in zip(items, weights, strict=True):
1118 roll -= weight
1119 if roll <= 0.0:
1120 return item
1121 return items[-1]