From aaff2c8554a07e36ec3b883f690d77ed6ad4a685 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 03:43:57 -0500 Subject: [PATCH] =?UTF-8?q?docs(m4c):=20spec=20=E2=80=94=20title=20?= =?UTF-8?q?=E2=86=92=20creation=20=E2=86=92=20shell=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GameFlow coordinator node (new main_scene) owns the Title → Creation → Shell sequence in one file: instantiate → inject → add_child, reusing the existing injection seam that change_scene_to_file cannot. Title becomes emit-only; the shell gains injected_log/injected_state seams and stops self-seeding on the real path (seed stays as the F6 fallback), so the created character's vitals, location, and opening narration are real. The ⛨ saga constraint is honored structurally: _show_creation and _start_campaign are separate, so the fourth entry path is an added arm, not surgery on existing edges. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...15-m4c-title-creation-shell-flow-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md diff --git a/docs/superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md b/docs/superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md new file mode 100644 index 0000000..a6f550e --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md @@ -0,0 +1,281 @@ +# M4-c — Title → creation → shell flow + +**Milestone:** M4-c (roadmap §M4). **Date:** 2026-07-15. +**§2 side:** n/a (flow orchestration — code owns it, no AI). +**Depends on:** the Title screen (M3), the creation screen (M4-b), the creation +model `NewGame.construct` (M4-a), the shell (M3-a). +**Goal it advances:** a playable start-to-shell loop — the player builds a +character and lands in the exploration shell as *that* character. + +--- + +## 1. Problem + +The flow from the title screen into the game is fictional in two places: + +1. **Title skips creation.** `title_screen.gd`'s `new_game` item does + `get_tree().change_scene_to_file(SHELL)` — it jumps straight to the shell and + never touches the creation screen. +2. **The shell fakes a character.** `main_window_shell.gd._build_seed_log()` + hardcodes "Vexcca, a human sellsword" and `ShellState.seed()` hardcodes her + vitals. The shell has never consumed a real, created character. + +Meanwhile the creation screen already emits `creation_confirmed(creation)` and +`NewGame.construct(origin, world, creation)` already returns +`{ok, log: CanonLog, state: GameState}` — the two objects the shell needs. Nothing +connects the emit to the construct to the shell. + +M4-c wires those three edges: **Title → Creation → Shell**, with the created +character flowing all the way through. + +### Constraints + +- **⛨ saga (roadmap §M3/§M4):** do NOT hardcode a linear + `new game → creation → world` flow as one welded unit. The saga's fourth entry + path — "begin the next campaign of an existing saga" — must be addable later + without rewriting existing edges. +- **§2:** the screen never hands game state upward. `construct` re-rolls the + character from the creation seed itself; the flow only carries plain data. +- **§13:** a failure never surfaces to the player as an error. +- **No new global state.** The codebase uses an injection-seam pattern + (`instantiate()` → set property → `add_child()`), not autoloads. There are zero + autoloads today; this milestone adds none. + +--- + +## 2. Approach (chosen) + +**A flow coordinator node.** A new script-first root `Node`, `GameFlow`, becomes +the `main_scene`. It owns one `ContentDB` and the current screen (its only child), +and it holds the entire Title → Creation → Shell sequence in one file. + +Rejected alternatives: + +- **Autoload handoff singleton** — keep `change_scene_to_file` everywhere, carry + the character in a global. Smallest screen diff, but smears the flow across three + handlers + global mutable state, fighting the no-hardcoded-flow constraint and + cutting against the injection-seam pattern. +- **Screens instantiate their own successor** — hardcodes the flow *into* the + screens and couples them; worst for the saga constraint. + +The coordinator is the only option where the sequence is a single editable object +(saga-friendly) and it reuses the existing injection seam (no global state). + +--- + +## 3. Components + +### 3.1 `GameFlow` (new) + +- **Files:** `client/scripts/ui/flow/game_flow.gd`, + `client/scenes/flow/GameFlow.tscn` (a trivial one-node scene). +- **Type:** script-first `Node`. It has no Control layout, so ADR 0001's + editor-first rule does not apply — that rule governs *Control layout* scenes; + this is a pure coordinator. (Noted explicitly so a reviewer does not read the + code-built structure as an ADR 0001 violation.) +- **Owns:** + - `world: ContentDB` — loaded once in `_ready` (`ContentDB.default_content_root()`). + - `service: DmService` — built once and injected into the shell, so the shell's + own `service == null` fallback is not needed on the real path (it stays for + F6/standalone). + - `origin: Dictionary` — the deserter origin (the single origin that exists). + - the current child screen. +- **`main_scene`:** `project.godot` `run/main_scene` changes from + `res://scenes/title/TitleScreen.tscn` to `res://scenes/flow/GameFlow.tscn`. + +**The one swap primitive:** + +```gdscript +func _show(next: PackedScene, inject: Callable) -> void: + # free current child, instantiate next, run the injection seam, then add. + for c in get_children(): + c.queue_free() + var screen := next.instantiate() + inject.call(screen) # set injected properties BEFORE add_child (_ready) + add_child(screen) +``` + +`inject` is the seam: it runs after `instantiate()` (which does NOT run `_ready`) +and before `add_child()` (which does) — the same window the tests use. This is +precisely what `change_scene_to_file` cannot provide. + +**The sequence (the whole table lives here):** + +| Step | Trigger | GameFlow method | +|---|---|---| +| boot | `_ready()` | `_show_title()` | +| title | `menu_activated(&"new_game")` | `_show_creation()` | +| title | `menu_activated(&"quit")` | `get_tree().quit()` | +| creation | `creation_confirmed(creation)` | `_start_campaign(creation)` | + +- `_show_title()` → `_show(TITLE, func(s): s.menu_activated.connect(_on_menu))`. +- `_show_creation()` → `_show(CREATION, func(s): s.world = world; s.origin = origin; + s.creation_confirmed.connect(_start_campaign))`. +- `_start_campaign(creation: Dictionary)` → call `NewGame.construct`; on success + `_show_shell(result.log, result.state)`; on failure see §5. +- `_show_shell(log, state)` → `_show(SHELL, func(s): s.service = service; + s.injected_log = log; s.injected_state = state)`. + +**Saga seam:** `_show_creation()` and `_start_campaign(creation)` are deliberately +separate. `_start_campaign` takes a plain Dictionary and does not assume the +creation came from the screen. The saga's fourth path becomes a new +`menu_activated` arm that synthesizes a `creation` Dict and calls +`_start_campaign` directly, skipping the creation screen — no existing edge +changes. + +### 3.2 `title_screen.gd` (modified) + +Becomes **emit-only**. It already emits `menu_activated(id)`. Remove: + +- `const SHELL`, +- the `new_game` and `quit` bodies of `_on_menu_activated` (and the now-dead + `_on_menu_activated` connection if it holds nothing else). + +The screen no longer knows what comes after it. Its atmosphere/selection/input +behavior is untouched. + +### 3.3 `character_creation.gd` (unchanged) + +No change. It already accepts injected `world`/`origin` and already emits +`creation_confirmed(draft.to_creation())`. M4-b left the signal dangling for +exactly this milestone to connect. + +### 3.4 `main_window_shell.gd` + `ShellState` (modified) + +Two new injection seams on the shell, mirroring `service`: + +```gdscript +var injected_log: CanonLog = null +var injected_state: GameState = null +``` + +In `_ready()`, the seed calls become fallbacks (same shape as `service == null`): + +```gdscript +_state = ShellState.for_character(injected_state, injected_log) if injected_state \ + else ShellState.seed() +_log = injected_log if injected_log else _build_seed_log() +``` + +New factory **`ShellState.for_character(state: GameState, log: CanonLog)`**: + +- `vitals.hp / hp_max` ← `state.sheet.hp / state.sheet.max_hp()` +- `vitals.mp / mp_max` ← `state.sheet.mp / state.sheet.max_mp()` +- `vitals.purse_copper` ← `state.purse_copper` +- `location_label` ← `log.location.name` +- `turn_order`, `consumables`, `round_label` ← **the same placeholders `seed()` + uses** (combat = M5, inventory = M7 own these; not M4-c's to invent). + +`_build_seed_log()` and `ShellState.seed()` **stay** — they are the F6/standalone +fallback, unchanged. On the real path the injected objects win and the created +character's vitals, location, and opening narration (via the injected log) are all +real. + +--- + +## 4. Data flow + +``` +project.godot main_scene = GameFlow.tscn + │ +GameFlow._ready → loads ContentDB (world), origin (deserter), builds DmService + │ _show_title +TitleScreen ──menu_activated(&"new_game")──▶ GameFlow._show_creation + │ inject world + origin +CharacterCreation ──creation_confirmed(creation)──▶ GameFlow._start_campaign + │ NewGame.construct(origin, world, creation) → {ok, log, state} + │ inject log + state + service +MainWindowShell (vitals/location/narration all from the real character) +``` + +`creation` is a plain Dictionary carrying `{name, race_id, calling_id, seed, +spend, skills, bonus_skill}`. `construct` re-rolls the attributes and Luck from +`seed` itself (§2/§10) — the flow never carries a computed stat. + +--- + +## 5. Error handling (§13) + +`NewGame.construct` returns `{ok: false, errors}` on invalid input. Through the UI +this is **unreachable** — `character_creation._bind_cta` disables the CTA on any +`NewGame.validate` error, so `creation_confirmed` only fires on a legal draft. But +§2 requires `construct` to distrust its caller, and so does `GameFlow`: + +- On `result.ok == false`, `_start_campaign` **logs the errors and returns** — + it does not swap to the shell. The player remains on the valid creation screen. +- No crash, no half-built shell reaches the player. + +This path exists to be handled, not hit; a test drives it deliberately (§6). + +--- + +## 6. Testing + +All new guards get the revert-and-watch-it-go-red treatment per +[`docs/traps.md`](../../traps.md). Drive screens by emitting their **real +signals**, never by calling handlers (traps.md: tests that called handlers +instead of pressing nodes). + +1. **GameFlow flow — new game reaches the shell as the created character.** + Instantiate GameFlow with an injected fake `world`/`origin`/`service`; let it + show the title; `emit_signal("menu_activated", &"new_game")` → assert the child + is a `CharacterCreation`. Build a valid `creation` Dict for a known character + (e.g. name "Dagnet"), `emit_signal("creation_confirmed", creation)` → assert + the child is a `MainWindowShell` **and its canon log player name == "Dagnet"**. + The name assertion proves the character flowed through, not merely that a scene + swapped. *Re-break:* feed a Dict whose name differs from the asserted one and + watch it go red. + +2. **Shell injected path — vitals derive from the real sheet.** Inject a + `log`+`state` for a character with a known HP (distinct from the seed's 42/60); + `add_child`; assert the `HP` label reads the injected HP. *Re-break:* force the + `seed()` branch and watch it fail. + +3. **`ShellState.for_character` unit.** Vitals + location derive from the passed + `state`/`log`; `turn_order`/`consumables`/`round_label` equal `seed()`'s + placeholders. *Re-break:* change one derived field's source and watch the + assertion fail. + +4. **Construct-failure stays on creation (§5).** Drive `_start_campaign` with a + `creation` Dict that fails `validate`; assert no `MainWindowShell` is added and + the flow is unchanged. *Re-break:* make `_start_campaign` transition + unconditionally and watch this go red. + +5. **Title is emit-only.** Assert `title_screen.gd` no longer references a shell + scene path and emits `menu_activated` (the existing title test may already + cover the emit; extend it to assert the routing bodies are gone). + +--- + +## 7. Scope boundary + +**In:** the three flow edges; the character flowing through them; the shell's +vitals + location + narration deriving from the real character; the emit-only +title; error handling for the unreachable construct failure. + +**Out (owned elsewhere):** +- Origin selection UI — deserter is the single origin; the seam accepts one origin + now and a choice later. +- Save/load, continue, load — M9. +- Quit-confirmation dialog. +- Any HUD field beyond vitals + location (turn order = M5, consumables = M7). +- The saga's fourth entry path itself — this milestone only keeps it *addable*. + +--- + +## 8. Files + +**New:** +- `client/scripts/ui/flow/game_flow.gd` +- `client/scenes/flow/GameFlow.tscn` +- `client/tests/unit/test_game_flow.gd` + +**Modified:** +- `client/project.godot` — `run/main_scene` → `GameFlow.tscn` +- `client/scripts/ui/title/title_screen.gd` — emit-only +- `client/scripts/ui/shell/main_window_shell.gd` — `injected_log`/`injected_state` + seams + fallback wiring +- `client/scripts/ui/shell/shell_state.gd` — `for_character(state, log)` factory +- `client/tests/unit/test_main_window_shell.gd` — injected-path test +- `client/tests/unit/test_shell_state.gd` — `for_character` test +- `client/tests/unit/test_title_screen.gd` — emit-only assertions