diff --git a/docs/architecture/implant-app-pattern.md b/docs/architecture/implant-app-pattern.md new file mode 100644 index 000000000..4ef001460 --- /dev/null +++ b/docs/architecture/implant-app-pattern.md @@ -0,0 +1,325 @@ +# Implant App Pattern + +**Status:** Proposed — introduced in Sprint 36 atlas refactor (#844). `implant_app.gd`, `implant_nav_stack.gd`, `implant_app_manifest.gd`, and `implant_registry.gd` land alongside the atlas split. Economics panel is migrated in the same PR as the second reference implementation. + +## Context + +The implant is the diegetic frame for every HUD tool the player carries — map atlas, economics monitor, Drifter's Guide reader (GTTR), knowledge journal, and anything we add later. These are not ad-hoc panels. They share z-index rules (D-170), a component library (D-169), an input model (one app active at a time, others occluded or hidden), and a visual identity. + +The useful mental model: treat the implant as a **faux mobile OS**. + +| Mobile OS concept | Implant equivalent | State | +|--------------------|--------------------------------------------------|------------------------------| +| Window manager | `HudGroups` autoload (D-170) | Exists | +| UIKit / widget set | D-169 component library (`ImplantPanel`, etc.) | Exists | +| Activity / App | `ImplantApp` base class | **Introduced here** | +| Manifest / Info.plist | `ImplantAppManifest` resource + `app.tres` | **Introduced here** | +| Nav controller | `ImplantNavStack` | **Introduced here** | +| Intent dispatcher | (planned — cross-app routing) | Seam sketched, not built yet | +| PackageManager | `ImplantRegistry` autoload | **Introduced here** (in-tree scan only) | +| Mod SDK / .ipa | pck-mounted app bundles, signing | Deferred (Phase 6+) | + +This document specifies the four pieces landing now and the seams left open for the deferred pieces. + +## Design drivers + +1. **Cargo-cult elimination.** Every implant app currently re-implements ~30 LOC of boilerplate: anchoring, `visible = false`, `MOUSE_FILTER_STOP`, `HudGroups.register`, `app_changed` filtering, `set_insert_active`, `toggle_visible`. Drift between apps is already visible — the atlas and economics panel handle `INSERT` mode differently. Absorb all of it into a base class. +2. **Moddability as first-class driver.** A modder or content author writes a self-contained app directory, drops it under `client/ui/implant/apps/`, and the implant discovers and registers it at startup. **No core-code edits required to ship a new app.** This constraint shapes every downstream choice — manifest format, key-binding routing, data-channel seam, inter-app signalling. +3. **Intra-app navigation without ad-hoc enums.** The atlas ships a `Level` enum and manual screen management. Each new app repeating that invention is wrong. One nav primitive, reused. + +## The Manifest — file-based discovery + +Each app directory contains an `app.tres` (Godot `Resource`) declaring the app's identity and capabilities. At startup, `ImplantRegistry` scans `res://ui/implant/apps/*/app.tres` and builds the installed-app registry. **This scan is the seam that later extends to mod discovery** — once we mount modded pck files under a known path, the same scan finds their manifests. + +```gdscript +# client/ui/implant/implant_app_manifest.gd +extends Resource +class_name ImplantAppManifest + +@export var app_path: String = "" # unique HudGroups id, e.g. "implant/map" +@export var display_name: String = "" # human-facing, e.g. "Atlas" +@export var icon_path: String = "" # res:// path to icon texture +@export var scene_path: String = "" # res:// path to the app's root scene +@export var default_mode: int = 2 # HudGroups.Mode.FULLSCREEN +@export var default_key: int = -1 # e.g. KEY_M; -1 = no default binding +@export var preserves_state: bool = true # keep nav stack across close/open +``` + +Keeping the manifest declarative — a `Resource`, not a script — means the installed-app list is data. Mod authors don't need to touch GDScript to register. The scan output can be cached. Validation happens in one place. + +## Generic key routing in `main.gd` + +Today (`main.gd`:180ish): + +```gdscript +if event.keycode == KEY_M: + atlas_panel.toggle_visible() +elif event.keycode == KEY_N: + economics_panel.toggle_visible() +``` + +Every new app requires a `main.gd` edit. Fatal for moddability. Replace with a registry-driven router: + +```gdscript +for manifest in ImplantRegistry.get_manifests(): + if manifest.default_key == event.keycode: + HudGroups.toggle_app(manifest.app_path, manifest.default_mode) + return +``` + +Adding a new app = drop directory, restart, done. + +**Key-binding collision policy for this sprint:** first-wins, warn on startup. Future sprint: settings UI to remap. + +## `ImplantApp` base class + +Absorbs every piece of cargo-culted boilerplate. Each app's shell extends `ImplantApp` and overrides lifecycle hooks instead of reinventing infrastructure. + +```gdscript +# client/ui/implant/implant_app.gd +class_name ImplantApp +extends Control + +var manifest: ImplantAppManifest = null +var nav: ImplantNavStack = null + +signal app_opened(mode: int) +signal app_closed +signal insert_deactivated + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + visible = false + + if manifest: + HudGroups.register(self, manifest.app_path) + HudGroups.app_changed.connect(_internal_app_changed) + + nav = ImplantNavStack.new() + nav.screen_changed.connect(_on_screen_changed) + add_child(nav) + + on_install() + +# --- Internal wiring — do not override --- + +func _internal_app_changed(app_path: String, mode: int) -> void: + if manifest == null: + return + if app_path != manifest.app_path: + if visible: + visible = false + on_close() + app_closed.emit() + return + + match mode: + HudGroups.Mode.FULLSCREEN, HudGroups.Mode.INSERT: + if not visible: + visible = true + if not manifest.preserves_state: + nav.reset_to_default() + elif nav.is_empty(): + nav.push_default() + on_open(mode) + app_opened.emit(mode) + HudGroups.Mode.GAMEPLAY: + if visible: + visible = false + on_close() + app_closed.emit() + +# --- Lifecycle hooks — subclasses override --- + +func on_install() -> void: pass # once, after _ready +func on_open(_mode: int) -> void: pass # every transition to visible +func on_close() -> void: pass # every transition to hidden +func on_insert_deactivated() -> void: pass # SnapshotConsumers calls this +func _on_screen_changed(_screen_id: String) -> void: pass # override to swap visible screen +func handle_intent(_action: String, _params: Dictionary) -> void: pass # future — Intents +``` + +**What the subclass is responsible for:** +- Building its root scene (via `scene_path` in the manifest). +- Registering screens with the nav stack. +- Overriding `_on_screen_changed` to swap the visible screen. +- Emitting app-specific signals (e.g. `economics_link_requested` today, `intent_requested` later). + +**What the subclass is *not* responsible for:** +- `HudGroups` wiring. Ever. +- `visible` management. Ever. +- Anchor / mouse-filter boilerplate. Ever. + +## `ImplantNavStack` — intra-app navigation + +Apps push and pop screens. The stack is owned by `ImplantApp` (one per app instance — no global nav state). + +```gdscript +# client/ui/implant/implant_nav_stack.gd +class_name ImplantNavStack +extends Node + +signal screen_changed(current_screen_id: String) + +var _stack: Array[String] = [] +var _payloads: Array[Dictionary] = [] +var _default_screen_id: String = "" + +func set_default(screen_id: String) -> void +func push(screen_id: String, payload: Dictionary = {}) -> void +func pop() -> void +func replace(screen_id: String, payload: Dictionary = {}) -> void +func reset_to_default() -> void +func is_empty() -> bool +func push_default() -> void +func current() -> String +func current_payload() -> Dictionary +``` + +**Mutation is synchronous.** `push()` updates `_stack`, emits `screen_changed` before returning. The shell's handler synchronously calls `enter(payload)` on the new screen and `leave()` on the previous one. No `call_deferred` races; a frame always ends with a coherent nav state. + +**Screens are plain `Control` nodes.** They expose: + +```gdscript +func enter(payload: Dictionary) -> void # called when pushed / re-surfaced +func leave() -> void # called when a new screen pushes on top / popped +``` + +Screens never touch `HudGroups`, `APP_PATH`, or `app_changed`. They only emit nav signals back to the shell — e.g. `select_system(system_id)` — and the shell translates to `nav.push("system", {...})`. + +### Nav-stack edge cases + +Behavior defined by the base class — subclasses do not re-implement these. + +- **`open_app()` with `preserves_state = false`:** stack cleared, default screen pushed. State-free apps never surprise the user with stale context. +- **`open_app()` with `preserves_state = true`, empty stack:** default screen pushed. Typical first-ever-open case. +- **`open_app()` with `preserves_state = true`, non-empty stack:** no mutation — app re-opens on the screen the user was last viewing. This is the atlas case (reopen on the body you were looking at). +- **`push()` / `pop()` / `replace()`:** synchronous. Stack mutates inside the call; `screen_changed` fires before the call returns; the shell's `_on_screen_changed` handler swaps the visible screen via `enter` / `leave` synchronously. No frame-boundary coherence issues. +- **Stack ownership:** each `ImplantApp` instance owns exactly one `ImplantNavStack`. No shared nav state across apps. Cross-app navigation goes through Intents (below), not through a shared stack. +- **Popping an empty stack:** no-op; log a warning. Preserves the invariant that a visible app always has at least one screen. + +## Inter-app navigation — Intents (seam only) + +Economics → Atlas linking is the first real case. Today it's handled by a direct signal `economics_link_requested(planet_id)` wired in `main.gd`. Hardcoded routing between named apps is fatal for modded apps — a modded atlas replacement or a modded economics replacement can't receive the signal. + +The target pattern: + +```gdscript +# emitted by any app's shell: +signal intent_requested(target_app: String, action: String, params: Dictionary) + +# economics app emits: +intent_requested.emit("implant/map", "focus_planet", {"planet_id": id}) +``` + +An intents dispatcher (sibling of `ImplantRegistry`) routes the intent to the target app's shell, calling `handle_intent(action, params)` which the registered target app overrides. + +**This sprint: keep the existing `economics_link_requested` wiring.** The base class exposes `handle_intent` as a no-op hook so subclasses don't need rewiring once the dispatcher lands. Refactor to Intents is mechanical and can happen in a small follow-up PR. + +## Data channels — seam flagged + +Modded apps will want to subscribe to simulation data: observer snapshots, entity events, region-filtered feeds, periodic economics rollups. Today, apps reach into `GameState` and `SnapshotConsumers` directly. + +**This sprint: no change.** Apps continue to use the direct path. + +**Future seam:** a `DataChannels` autoload apps subscribe to by channel name, with manifest-declared requirements: + +```gdscript +# in app.tres +@export var subscribed_channels: Array[String] = ["snapshot/observer", "economics/rollup"] +``` + +The registry wires subscriptions on `on_install`. Modded apps get their data through a documented API rather than poking core globals. **Not implemented this sprint — flagged so the base-class API doesn't accidentally close the door.** Specifically: `on_install` is the right hook for channel subscription; `on_close` is not where channels get unsubscribed (an app stays subscribed while hidden). Binding subscription lifetime to app instance lifetime, not visibility, is the right shape. + +## D-169 component library — keep, hands off + +`ImplantPanel`, `ImplantHeader`, `ImplantSeparator`, `ImplantDataRow`, `ImplantTextBlock` are already modder-friendly — pure `Control` nodes styled via `default_implant.tres`. Modded apps compose with them exactly like in-tree apps. + +**Flagged risk:** if any primitive grows a dependency on a specific in-tree data model (e.g. `ImplantDataRow` taking a `CurrencyAmount`-typed parameter instead of a generic string), moddability regresses. **Rule: keep primitives data-shape agnostic.** Adapters live in each app, not in the shared library. + +## Directory layout + +``` +client/ui/implant/ + default_implant.tres # D-169 theme + implant_panel.gd # D-169 primitives — unchanged + implant_header.gd + implant_separator.gd + implant_data_row.gd + implant_text_block.gd + implant_theme.gd + implant_app.gd # NEW — base class + implant_nav_stack.gd # NEW — nav helper + implant_app_manifest.gd # NEW — manifest resource class + implant_registry.gd # NEW — autoload; scans apps/ at startup + widgets/ # shared cross-app widgets + # (future: reach_map_widget.gd once extracted from atlas) + apps/ + atlas/ + app.tres # manifest: app_path=implant/map, default_key=KEY_M + atlas_app.gd # extends ImplantApp — owns nav + shared header + atlas_app.tscn # root scene + atlas_marker_overlay.gd # moved from implant/ + atlas_overlay_bar.gd + atlas_viewer.gd + screens/ + reach_screen.gd # plain Control; enter/leave; emits nav signals + system_screen.gd + planet_screen.gd + regional_screen.gd + economics/ + app.tres # manifest: app_path=implant/economics, default_key=KEY_N + economics_app.gd # extends ImplantApp + economics_app.tscn + screens/ + overview_screen.gd # currently the only screen; keep the directory shape +``` + +Autoload order: `implant_registry` must scan before `main.gd` queries manifests for key routing. Manifest scan is lazy-on-first-read to sidestep the `class_name` autoload parse-order trap documented in `CLAUDE.md`. + +## Phasing + +### Lands this PR (#844) + +- `implant_app.gd`, `implant_nav_stack.gd`, `implant_app_manifest.gd` — new classes +- `implant_registry.gd` — new autoload; scans `apps/*/app.tres`, exposes `get_manifests()` +- `main.gd` key routing replaced with registry-driven loop; `KEY_M` / `KEY_N` literals removed +- Atlas: split into `apps/atlas/` with `atlas_app.gd` + per-screen `Control`s. `Level` enum removed in favor of nav-stack screen ids +- Economics: relocated to `apps/economics/economics_app.gd`, refactored to `extends ImplantApp` +- Both apps ship their `app.tres` manifests +- `SnapshotConsumers` updated to call `on_insert_deactivated()` uniformly instead of per-panel hacks +- Legacy `implant/map/starchart` HudGroups path deleted + +### Follow-up (not this sprint, not blockers) + +- **Intents dispatcher** — replace `economics_link_requested` with generic `intent_requested` routing. Mechanical refactor. +- **`DataChannels` autoload** — manifest-declared snapshot subscriptions. Seam already reserved via `on_install` lifecycle position. +- **Launcher UI** — once a fourth implant app exists, a chooser becomes necessary. Until then, key bindings suffice. +- **Settings-UI key remapping** — resolves key-binding collisions beyond first-wins. + +### Deferred (Phase 6+, not sprint-scoped) + +- Shipped-build mod discovery (pck mounting, manifest signing, trust model) +- Scripting API surface — what mods can safely call into core +- Mod registry UI — enable/disable, conflict resolution, version compatibility + +## Review checklist for #844 + +Use while implementing; check during review. + +- [ ] Every cargo-culted snippet absorbed into `ImplantApp`: anchoring, `visible = false`, `MOUSE_FILTER_STOP`, `HudGroups.register`, `app_changed` filtering, `set_insert_active`, `toggle_visible`. No app shell re-implements any of these. +- [ ] Atlas shell uses `ImplantNavStack` — no resurrected `Level` enum by another name. +- [ ] Screens are plain `Control`s. They do not touch `HudGroups`, `APP_PATH`, or `app_changed`. They only emit nav signals to the shell. +- [ ] `SnapshotConsumers` calls `on_insert_deactivated()` uniformly — no per-panel type checks. +- [ ] Economics refactor is behaviorally identical — same `economics_link_requested` signal path, intra-app selection state preserved across close/reopen. +- [ ] `main.gd` contains no `KEY_M` / `KEY_N` literals tied to specific apps — all routing via `ImplantRegistry.get_manifests()`. +- [ ] Both apps ship a valid `app.tres` with all required fields. +- [ ] No app reaches into another app's internals — cross-app communication stays on the `economics_link_requested` seam (until Intents lands). + +## Risks / open items + +- **Manifest scan load cost.** Cheap in-tree but becomes load-time-variable once modded apps enter the mix. Flag for profiling once registered apps > ~10. +- **Autoload parse-order.** `implant_registry` must defer `class_name ImplantAppManifest` resolution to first `get_manifests()` call rather than `_ready()`. See `CLAUDE.md` "Autoload parse-order rule." +- **Manifest validation.** An invalid `app.tres` (missing fields, bad paths) in a modded app must not crash startup. Warn, skip, continue. +- **Scene-vs-script coupling.** The manifest points at `scene_path`; the shell script is loaded from the scene's root node. If a modder ships a scene with the wrong root script, failures should be diagnosable. Flagged for the eventual mod SDK docs.