Documents the ImplantApp lifecycle ordering and the nav-stack state guarantee at each hook: - Class-level docstring on implant_app.gd describes on_install, on_open, on_close, and on_insert_deactivated: when each fires, what nav state subclasses can rely on, and what is safe to do (construct + register_screen in on_install; data refresh + read nav.current() in on_open; pause timers in on_close; no close_app manual call in on_insert_deactivated — call super or replicate the guard). - Arch doc gains a "Lifecycle hooks" subsection under ImplantApp base class with a four-row contract table plus explanatory notes on two load-bearing invariants: why on_install sees an empty stack (bottom-up _ready order, no open signal yet); why on_close must not push/pop (would destroy preserved position on reopen). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
381 lines
24 KiB
Markdown
381 lines
24 KiB
Markdown
# 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 schema_version: int = 1 # bump on breaking manifest changes
|
|
@export var app_path: String = "" # unique HudGroups id, e.g. "implant/map"
|
|
@export var scene_path: String = "" # res:// path to the app's root scene (empty = metadata-only)
|
|
@export var default_mode: String = "fullscreen" # "gameplay" | "insert" | "fullscreen"
|
|
@export var default_key: int = -1 # e.g. KEY_M = 77; -1 = no default binding
|
|
@export var preserves_state: bool = true # keep nav stack across close/open
|
|
```
|
|
|
|
**`schema_version`** lets the registry differentiate mods built against older or newer manifest shapes. `ImplantRegistry.CURRENT_SCHEMA_VERSION` names the version the current build understands. On scan:
|
|
- `schema_version == CURRENT`: silent, proceed normally.
|
|
- `schema_version < CURRENT`: `print_verbose("backfilled from v{n}")`, proceed with field defaults.
|
|
- `schema_version > CURRENT`: `push_warning("proceeding best-effort")`, proceed.
|
|
|
|
Reject-on-unknown is deliberately avoided — forward tolerance matters more than strict validation in a modded context. An app that relies on a field the build does not understand may malfunction at runtime; that failure is traceable via the warning.
|
|
|
|
**`default_mode`** is a string (`"gameplay"` / `"insert"` / `"fullscreen"`) rather than the raw `HudGroups.Mode` int, because editing enum ints in `.tres` is fragile. The registry resolves the string to `HudGroups.Mode` via `ImplantRegistry.get_resolved_mode(app_path)`; invalid strings skip the manifest with a warning.
|
|
|
|
`display_name` and `icon_path` are intentionally omitted at this stage — they add surface without callers. Add them only when a launcher UI needs them.
|
|
|
|
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
|
|
|
|
var _screens: Dictionary = {} # screen_id → Control
|
|
var _current_screen_id: String = ""
|
|
|
|
signal app_opened(mode: int)
|
|
signal app_closed
|
|
|
|
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()
|
|
|
|
# --- Screen management (base-owned) ---
|
|
|
|
## Register a screen under an id. Base adds it as a child and starts it hidden.
|
|
## Call from on_install(). Wire signals BEFORE calling register_screen.
|
|
func register_screen(id: String, screen: Control) -> void:
|
|
# Warns + returns on duplicate id; base-parents if unparented; forces visible = false.
|
|
...
|
|
|
|
func current_screen_id() -> String:
|
|
return _current_screen_id
|
|
|
|
## Default screen-swap. Base handles leave/hide/enter/show with has_method tolerance
|
|
## and skips leave/hide when replacing the same screen id (payload-only update).
|
|
## Subclasses rarely override.
|
|
func _on_screen_changed(new_id: String) -> void:
|
|
# See implant_app.gd for the full body.
|
|
...
|
|
|
|
# --- 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: # SnapshotConsumers calls this
|
|
# Default: closes the app if it is active in INSERT mode. FULLSCREEN apps inherit no-op.
|
|
pass
|
|
func handle_intent(_action: String, _params: Dictionary) -> void: pass # future — Intents
|
|
```
|
|
|
|
**What the subclass is responsible for:**
|
|
- Loading its own manifest in `_ready()` before calling `super._ready()`.
|
|
- In `on_install`: constructing screens, calling `setup`/signal wiring on them, then `register_screen(id, screen)`. Setting `nav.set_default(id)` once.
|
|
- Reading state via `current_screen_id()`, emitting app-specific signals, handling app-specific input.
|
|
|
|
**What the subclass is *not* responsible for:**
|
|
- `HudGroups` wiring. Ever.
|
|
- `visible` management (app-level or screen-level). Ever.
|
|
- Anchor / mouse-filter boilerplate. Ever.
|
|
- `add_child` on registered screens. `register_screen` handles parenting.
|
|
- `_current_screen_id` bookkeeping. The base owns it.
|
|
- Enter/leave dispatch. The default `_on_screen_changed` drives it; screens that need enter/leave implement those methods, the base invokes them via `has_method` tolerance.
|
|
|
|
### Lifecycle hooks
|
|
|
|
The four hooks fire in a defined order with a defined nav-stack guarantee at each point. Subclasses can rely on this contract without inspecting `HudGroups` state directly.
|
|
|
|
| Hook | When it fires | Nav stack state when it fires | Intended use |
|
|
|------|--------------|-------------------------------|--------------|
|
|
| `on_install()` | Once, from `_ready()`, after nav is created | **Empty.** No screens have been pushed yet. | Construct screens; call `register_screen(id, screen)`; call `nav.set_default("...")`. Wiring only — do not read `nav.current()`. |
|
|
| `on_open(mode)` | Every activation (FULLSCREEN or INSERT) | **Non-empty.** Base ensures: `preserves_state=false` → `reset_to_default()` called; `preserves_state=true` and stack was empty → `push_default()` called; `preserves_state=true` and stack non-empty → untouched. | Data refresh; announce current screen; start animations. `nav.current()` is safe here. |
|
|
| `on_close()` | Every deactivation (GAMEPLAY or different app takes focus) | Stack preserved — base does not mutate it. | Pause timers; stop high-frequency feeds; save scroll position. Do **not** push or pop — the stack survives for the next `on_open`. |
|
|
| `on_insert_deactivated()` | SnapshotConsumers calls this when server drops insert state | Whatever `on_close()` left it (if the app was already closed) or the live state (if the app is still open) | Base default: close if active in INSERT mode, no-op otherwise. FULLSCREEN apps override. Do not call `close_app()` manually — call `super()` or replicate the guard. |
|
|
|
|
**Why `on_install` sees an empty stack:** `_ready()` fires bottom-up — children before parents. The base's `_ready()` creates `nav` and then calls `on_install()` synchronously. No `HudGroups.app_changed` signal has fired yet (that comes from `open_app()`, which requires the game to be running). Subclasses that call `nav.current()` in `on_install` always see `""` — which is always wrong. The right pattern is to call `nav.set_default("my_first_screen")` in `on_install` and let the base push it on the first `on_open`.
|
|
|
|
**Why `on_close` must not push/pop:** The nav stack is the in-flight navigation position that survives across close/reopen cycles (when `preserves_state=true`). Mutating it in `on_close` destroys the user's position. If an app needs to reset navigation on close, set `preserves_state=false` in the manifest instead — the base handles the reset at the top of `on_open`, which is the right moment.
|
|
|
|
## `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, registered via `ImplantApp.register_screen(id, screen)`.** Subclass `on_install` constructs each screen, wires its signals, and calls `register_screen` — base handles `add_child` and `visible = false`. Screens never touch `HudGroups`, `APP_PATH`, `app_changed`, or their own visibility. They only emit nav signals back to the shell (e.g. `select_system(system_id)`), and the shell translates to `nav.push("system", {...})`.
|
|
|
|
Screens optionally expose:
|
|
|
|
```gdscript
|
|
func enter(payload: Dictionary) -> void # called when pushed or re-surfaced
|
|
func leave() -> void # called when popped or superseded
|
|
```
|
|
|
|
The base's default `_on_screen_changed` invokes these via `has_method` — screens that need neither don't ship stubs. The base also detects same-screen-replace (`nav.replace("system", new_payload)` while already on `"system"`) and re-enters with the fresh payload without calling `leave` first. Atlas exercises this on the reach → system-picker → system-orbital transition.
|
|
|
|
### 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
|
|
|
|
### Landed — Sprint 36 #844 and review rounds
|
|
|
|
- `implant_app.gd`, `implant_nav_stack.gd`, `implant_app_manifest.gd` — new classes
|
|
- `implant_registry.gd` — autoload; lazy-scans `apps/*/app.tres` on first `get_manifests()` call, exposes `get_manifests()`, `get_resolved_mode()`, `instantiate_all(parent)`, `get_app_instance(app_path)`
|
|
- Manifest `schema_version` field with `ImplantRegistry.CURRENT_SCHEMA_VERSION = 1`; tiered warn/backfill/proceed behavior
|
|
- `scene_path` consumed: `ImplantRegistry.instantiate_all($AppsContainer)` called from `hud.gd._ready()`. `hud.tscn` no longer direct-instances apps — holds only the `AppsContainer` placement node. `main.gd` looks up app instances via `ImplantRegistry.get_app_instance(app_path)`.
|
|
- `main.gd` key routing replaced with registry-driven loop; `KEY_M` / `KEY_N` literals removed
|
|
- `ImplantApp` base owns `_screens` / `_current_screen_id`; `register_screen(id, screen)` + `current_screen_id()` + default `_on_screen_changed` with `has_method` tolerance and same-screen-replace detection
|
|
- Atlas: split into `apps/atlas/` with `atlas_app.gd` + per-screen `Control`s. `Level` enum replaced by nav-stack screen ids. `on_install` is construct → setup → wire → `register_screen` per screen.
|
|
- Economics: relocated to `apps/economics/economics_app.gd`, refactored to `extends ImplantApp`; `on_install` reduced to three lines (construct overview_screen, register_screen, nav.set_default)
|
|
- Both apps ship `schema_version = 1` manifests
|
|
- `SnapshotConsumers` calls `on_insert_deactivated()` uniformly; the base's default closes the app when active in INSERT mode
|
|
- Legacy `implant/map/starchart` HudGroups path deleted
|
|
- `hud.gd`: single `_ready()` call to `ImplantRegistry.instantiate_all($AppsContainer)` seeds all registered apps; Godot's bottom-up `_ready` ordering guarantees `main.gd` finds them when it looks up by `app_path`
|
|
|
|
### 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.
|
|
- **`handle_global_key` lifecycle hook** — new `ImplantApp` override; shells handle intra-app keys (e.g. economics `[`/`]` system navigation). Main.gd routes unhandled keydown to the active app instead of hardcoding per-app bindings.
|
|
|
|
### 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 future PRs adding a new implant app
|
|
|
|
- [ ] App directory lives under `client/ui/implant/apps/{name}/` with `app.tres`, `{name}_app.gd`, `{name}_app.tscn`
|
|
- [ ] Manifest includes `schema_version: int = 1` (or higher if manifest schema has bumped)
|
|
- [ ] Manifest `default_mode` is one of `"gameplay"`, `"insert"`, `"fullscreen"`
|
|
- [ ] Manifest `app_path` is unique; `default_key` either -1 or non-colliding
|
|
- [ ] App script `extends ImplantApp`; its `_ready()` assigns `manifest = load("res://ui/implant/apps/{name}/app.tres")` then calls `super._ready()`
|
|
- [ ] `on_install` constructs and `register_screen`s each screen; sets `nav.set_default("...")` once. No direct `add_child` on screens; no `.visible =` on screens; no `_current_screen_id` field.
|
|
- [ ] Screens are plain `Control`s. They optionally define `enter(payload)` / `leave()`. They do not touch `HudGroups`, `APP_PATH`, or `app_changed`.
|
|
- [ ] Nothing in `hud.tscn` direct-instances the new app. Discovery happens via the registry.
|
|
- [ ] No `KEY_*` literals in `main.gd` tied to the new app — rely on `default_key` in the manifest.
|
|
- [ ] Cross-app wiring (if any) stays on a named signal today; migrate to `handle_intent` when Intents dispatcher 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.
|