Shop / List-Screen Contract
The key words MUST, MUST NOT, and MAY are used as in RFC 2119.
This is the one hierarchy that is deliberately not a class hierarchy: a cross-script base class on a lazily-loaded screen fails to resolve at runtime in the Android export (Could not resolve class "ShopBase" — every shop became unopenable; see docs/shop-dedup.md). The grouping goal is identical — shared behavior in one place, screens only owning what's theirs — but the mechanism is preloaded static helpers. Screens preload() and delegate.
What the helpers own
| Helper | Owns |
|---|---|
ShopNav.handle(screen, event, opts) | The input skeleton: modal guard → cancel → tab keys → up/down selection wrap → accept → screen-specific tail. Uniform SFX and input-as-handled. |
ShopNav.switch_shop_tab | The buy/sell tab switch (wrap, selection reset, sell-list regen). |
ShopNav.selected_item / confirm / info | The selected-row bounds guard and the ConfirmDialog lifecycle — building the modal, owning _active_modal set/release, callback ordering. |
ShopUI.setup_portrait | The shared two-column panel layout. |
What a screen MUST provide
var _selected_index: int(when using selection) andvar _active_modal: Control(when using modals) — the helpers read/write these by name.- Hooks via the
optsdictionary:list_size,on_move,on_accept,on_tab,on_cancel,on_other— omitted hooks make those keys fall through. Per-screen content rendering (_refresh_display) stays per-screen by design. - Historically-silent screens pass
sfx: false; behavior changes (gaining sfx) are deliberate, not side effects.
Invariants
- Screens MUST NOT hand-roll the input skeleton or the modal lifecycle — a new list screen that doesn't delegate to
ShopNavis reintroducing the nine-copy drift #274 removed (the dup ratchet will flag it). - While
_active_modalis valid, the modal owns input — both the helper and any screen-local_processpolling MUST respect it. - Buy affordance is disabled rows with reasons, never rejection modals on confirm (see
/mechanics/shops). - Screens MUST NOT gain a
class_namebase — if a future Godot release fixes the Android resolution bug, migrate deliberately, not opportunistically.
Autopilot coupling (a real constraint, not a footnote)
The autopilot drives these screens by reading their privates via node.get("_selected_index")-style access. Renaming or relocating a screen's state variables MUST be paired with a grep of autopilot.gd — get() on a missing property returns null silently and the sanity gate times out (the #335 incident).
Testing
The helper contract is pinned by test_shop_nav / test_shop_confirm (synthesized input events against a stub screen). New screens get a logic-level test plus coverage in the shop-smoke / shops matrix phase.