# M4-c — Title → Creation → Shell Flow Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Wire the title screen's *new game* path through character creation and into the exploration shell, with the created character flowing all the way through — HP/MP/purse/location/opening-narration all real. **Architecture:** A new script-first `GameFlow` root node becomes the `main_scene` and owns the Title → Creation → Shell sequence in one file, swapping screens via `instantiate()` → inject → `add_child()` (the injection seam that `change_scene_to_file` cannot provide). The title becomes emit-only; the shell gains `injected_log`/`injected_state` seams and stops self-seeding on the real path (its `seed()`/`_build_seed_log()` stay as the F6/standalone fallback). **Tech Stack:** Godot 4.7, GDScript, GUT 9.7 for tests. **Spec:** [`docs/superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md`](../specs/2026-07-15-m4c-title-creation-shell-flow-design.md) ## Global Constraints - **§2 (code owns state):** the flow carries only plain data; `NewGame.construct` re-rolls the character from the creation seed itself. No task hands a computed stat between screens. - **⛨ saga:** do NOT weld `new game → creation → shell` into one unit. `_show_creation()` and `_start_campaign(creation)` stay separate methods so a fourth entry path is an added `menu_activated` arm, not surgery on existing edges. - **§13:** a failure never reaches the player as an error. A `construct` that returns `{ok:false}` logs and aborts the transition; it never crashes or shows a half-built shell. - **No autoloads / no new global state.** Use the existing injection-seam pattern (set a property between `instantiate()` and `add_child()`). - **ADR 0001 (editor-first UI):** applies to *Control layout* scenes. `GameFlow` is a pure coordinator with no layout, so it is script-first by design — this is not a violation. - **traps.md discipline:** every new guard must be reverted-and-watched-red before acceptance. Drive screens by emitting their real signals, never by calling handlers directly. - **Test runner:** whole suite `./run_tests.sh` (from `client/`, ~4s). Single file: `./run_tests.sh -gselect=test_NAME.gd`. Single method: add `-gunit_test_name=test_method`. - All paths below are relative to `client/` unless noted. --- ## File Structure **New:** - `scripts/ui/flow/game_flow.gd` — the coordinator: owns `world`/`service`/`origin`, holds the Title→Creation→Shell sequence, one `_show` swap primitive. - `scenes/flow/GameFlow.tscn` — one `Node` with `game_flow.gd` attached. The new `main_scene`. - `tests/unit/test_game_flow.gd` — flow tests (new game reaches shell as the created character; construct-failure stays put). **Modified:** - `scripts/ui/shell/shell_state.gd` — add `for_character(state, log)` factory. - `scripts/ui/shell/main_window_shell.gd` — add `injected_log`/`injected_state` seams; seed calls become fallbacks. - `scripts/ui/title/title_screen.gd` — emit-only (drop `const SHELL`, `_on_menu_activated`, the self-connect). - `project.godot` — `run/main_scene` → `GameFlow.tscn`. - `tests/unit/test_shell_state.gd` — `for_character` test. - `tests/unit/test_main_window_shell.gd` — injected-path test. - `tests/unit/test_title_screen.gd` — drop the `_on_menu_activated` disconnect (it no longer exists). --- ## Task 1: `ShellState.for_character` factory Derive real HUD vitals + location from a constructed character, keeping the turn-order/consumables/round placeholders honest (combat=M5, inventory=M7 own those). **Files:** - Modify: `scripts/ui/shell/shell_state.gd` - Test: `tests/unit/test_shell_state.gd` **Interfaces:** - Consumes: `GameState` (`.sheet.hp`, `.sheet.max_hp()`, `.sheet.mp`, `.sheet.max_mp()`, `.purse_copper`), `CanonLog` (`.location.name`), `NewGame.construct` (test only). - Produces: `static func ShellState.for_character(state: GameState, log: CanonLog) -> ShellState`. - [ ] **Step 1: Write the failing test** Add to `tests/unit/test_shell_state.gd`: ```gdscript const NewGame = preload("res://scripts/newgame/new_game.gd") const ContentDB = preload("res://scripts/content/content_db.gd") func _built(): var world = ContentDB.new() world.load_from(ContentDB.default_content_root()) var origin = ContentDB.load_json(ContentDB.origin_path("deserter")) var creation := { "name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309, "spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception", } return NewGame.construct(origin, world, creation) func test_for_character_derives_vitals_and_location_from_the_real_character(): var res = _built() assert_true(res["ok"], str(res["errors"])) var state = res["state"] var log = res["log"] var s := ShellState.for_character(state, log) assert_eq(s.vitals["hp"], state.sheet.hp, "hp from the sheet") assert_eq(s.vitals["hp_max"], state.sheet.max_hp(), "hp_max from the sheet") assert_eq(s.vitals["mp"], state.sheet.mp, "mp from the sheet") assert_eq(s.vitals["mp_max"], state.sheet.max_mp(), "mp_max from the sheet") assert_eq(s.vitals["purse_copper"], state.purse_copper, "purse from origin grants (47c)") assert_eq(s.location_label, log.location.name, "location from the canon log") assert_ne(s.vitals["hp_max"], 60, "NOT the seed's hardcoded 60 — this is a real L1 sheet") func test_for_character_keeps_the_seed_placeholders_for_combat_and_inventory(): # turn_order/consumables/round_label have no real source yet (M5/M7 own them); # for_character must reuse seed()'s placeholders, not invent its own. var res = _built() var s := ShellState.for_character(res["state"], res["log"]) var seed := ShellState.seed() assert_eq(s.turn_order.size(), seed.turn_order.size(), "turn rail is still the placeholder") assert_eq(s.consumables, seed.consumables, "consumables are still the placeholder") assert_eq(s.round_label, seed.round_label, "round label is still the placeholder") ``` - [ ] **Step 2: Run test to verify it fails** Run: `./run_tests.sh -gselect=test_shell_state.gd` Expected: FAIL — `Invalid call. Nonexistent function 'for_character' in base 'RefCounted (ShellState)'`. - [ ] **Step 3: Write minimal implementation** Add to `scripts/ui/shell/shell_state.gd` (after `seed()`): ```gdscript static func for_character(state: GameState, log: CanonLog) -> ShellState: ## The real HUD state for a constructed character. Vitals + location come from ## the character (§2 — code owns them); turn_order/consumables/round_label are ## still seed() placeholders because combat (M5) and inventory (M7) own those. var s := seed() # start from the placeholders, then overwrite what is real s.vitals = { "hp": state.sheet.hp, "hp_max": state.sheet.max_hp(), "mp": state.sheet.mp, "mp_max": state.sheet.max_mp(), "purse_copper": state.purse_copper, } s.location_label = log.location.name return s ``` - [ ] **Step 4: Run test to verify it passes** Run: `./run_tests.sh -gselect=test_shell_state.gd` Expected: PASS (existing seed tests + 2 new tests). - [ ] **Step 5: Re-break to prove the guard (traps.md)** Temporarily change `"hp_max": state.sheet.max_hp()` to `"hp_max": 60`. Run the test. Expected: FAIL on `test_for_character_derives_vitals_and_location_from_the_real_character` (both the `==` and the `assert_ne 60`). Revert the change; re-run; PASS. - [ ] **Step 6: Commit** ```bash git add client/scripts/ui/shell/shell_state.gd client/tests/unit/test_shell_state.gd git commit -m "feat(m4c): ShellState.for_character derives vitals+location from the real character" ``` --- ## Task 2: Shell consumes an injected character Add the `injected_log`/`injected_state` seams; make the seed calls fallbacks. The seed path stays fully working for F6/standalone. **Files:** - Modify: `scripts/ui/shell/main_window_shell.gd:21-23` (seams) and `:43-44` (fallback wiring) - Test: `tests/unit/test_main_window_shell.gd` **Interfaces:** - Consumes: `ShellState.for_character(state, log)` (Task 1). - Produces: shell public seams `injected_log: CanonLog`, `injected_state: GameState`, set before `add_child` (mirrors the existing `service` seam). - [ ] **Step 1: Write the failing test** Add to `tests/unit/test_main_window_shell.gd`: ```gdscript const NewGame = preload("res://scripts/newgame/new_game.gd") const ContentDB = preload("res://scripts/content/content_db.gd") func _built_character(): var world = ContentDB.new() world.load_from(ContentDB.default_content_root()) var origin = ContentDB.load_json(ContentDB.origin_path("deserter")) var creation := { "name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309, "spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception", } return NewGame.construct(origin, world, creation) func _shell_with_character(res) -> MainWindowShell: var node = load(SCENE).instantiate() var transport = FakeTransport.new() transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."})) node.service = DmService.new(transport, FallbackLibrary.new()) node.injected_log = res["log"] node.injected_state = res["state"] add_child_autofree(node) return node func test_shell_shows_the_injected_character_not_the_seed(): var res = _built_character() assert_true(res["ok"], str(res["errors"])) var node := _shell_with_character(res) assert_same(node._log, res["log"], "the injected canon log drives narration") var expected := "HP %d / %d" % [res["state"].sheet.hp, res["state"].sheet.max_hp()] assert_eq(node._hp.text, expected, "HP label derives from the real sheet") assert_ne(node._hp.text, "HP 42 / 60", "NOT the seed's Vexcca vitals") ``` - [ ] **Step 2: Run test to verify it fails** Run: `./run_tests.sh -gselect=test_main_window_shell.gd` Expected: FAIL — `Invalid set index 'injected_log'` (the property does not exist yet). - [ ] **Step 3: Write minimal implementation** In `scripts/ui/shell/main_window_shell.gd`, add the seams next to `service` (currently line 21): ```gdscript var service: DmService = null # injection seam; real HttpTransport built if null var injected_log: CanonLog = null # injection seam; _build_seed_log() if null (F6/standalone) var injected_state: GameState = null # injection seam; ShellState.seed() if null ``` Then in `_ready()` replace the two seed lines (currently lines 43-44): ```gdscript _state = ShellState.seed() _log = _build_seed_log() ``` with: ```gdscript # The injected character wins; the seed calls are the F6/standalone fallback — # same shape as `service == null` building a real transport below. _state = ShellState.for_character(injected_state, injected_log) if injected_state != null else ShellState.seed() _log = injected_log if injected_log != null else _build_seed_log() ``` - [ ] **Step 4: Run test to verify it passes** Run: `./run_tests.sh -gselect=test_main_window_shell.gd` Expected: PASS (existing seed/fallback tests still green — they inject only `service`, so both seams stay null and the seed path runs — plus the new injected test). - [ ] **Step 5: Re-break to prove the guard (traps.md)** Temporarily force the seed path: change the `_state`/`_log` lines to always use `ShellState.seed()` / `_build_seed_log()`. Run the test. Expected: FAIL on `test_shell_shows_the_injected_character_not_the_seed` (`_log` mismatch + `HP 42 / 60`). Revert; re-run; PASS. - [ ] **Step 6: Commit** ```bash git add client/scripts/ui/shell/main_window_shell.gd client/tests/unit/test_main_window_shell.gd git commit -m "feat(m4c): shell consumes an injected character (seed stays the F6 fallback)" ``` --- ## Task 3: Title becomes emit-only Strip the title's knowledge of what comes next. It already emits `menu_activated(id)`; the coordinator will route it. **Files:** - Modify: `scripts/ui/title/title_screen.gd` (remove `const SHELL`, `_on_menu_activated`, the self-connect) - Test: `tests/unit/test_title_screen.gd:39-47` **Interfaces:** - Consumes: nothing new. - Produces: `TitleScreen` emits `menu_activated(id: StringName)` and does not self-route (no scene changes, no quit from within the screen). - [ ] **Step 1: Update the test first (it currently disconnects a method we are deleting)** In `tests/unit/test_title_screen.gd`, replace `test_new_game_and_quit_emit_their_action` (lines 39-47) with: ```gdscript func test_new_game_and_quit_emit_their_action(): var t := _title() # Title is emit-only now: no internal handler to isolate. Just watch the emit. watch_signals(t) t._activate(_index_of(t, &"new_game")) assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"]) t._activate(_index_of(t, &"quit")) assert_signal_emitted_with_parameters(t, "menu_activated", [&"quit"]) ``` - [ ] **Step 2: Run test to verify it fails** Run: `./run_tests.sh -gselect=test_title_screen.gd` Expected: PASS still (the removed `disconnect` line was the only thing that would break — but `_on_menu_activated` still exists right now, so this test passes both before and after the edit). To get a real red first, temporarily rename `_activate`'s emit target — SKIP; instead this task's red/green is driven by Step 4 after the source change. Proceed to Step 3. - [ ] **Step 3: Make the title emit-only** In `scripts/ui/title/title_screen.gd`: 1. Delete `const SHELL := "res://scenes/shell/MainWindowShell.tscn"` (line 12). 2. In `_ready()`, delete the line `menu_activated.connect(_on_menu_activated)` (line 47). 3. Delete the entire `_on_menu_activated` function (lines 131-138): ```gdscript func _on_menu_activated(id: StringName) -> void: match id: &"new_game": get_tree().change_scene_to_file(SHELL) &"quit": get_tree().quit() _: pass # inert placeholder — the screen lands in a later milestone ``` - [ ] **Step 4: Run the full suite** Run: `./run_tests.sh -gselect=test_title_screen.gd` Expected: PASS. `test_new_game_and_quit_emit_their_action` still asserts the emit; nothing self-routes. - [ ] **Step 5: Verify the title no longer references downstream scenes** Run: `grep -n "SHELL\|change_scene\|_on_menu_activated" client/scripts/ui/title/title_screen.gd` Expected: no matches. (This is the proof the screen is emit-only; it cannot be a runtime assert.) - [ ] **Step 6: Commit** ```bash git add client/scripts/ui/title/title_screen.gd client/tests/unit/test_title_screen.gd git commit -m "feat(m4c): title screen is emit-only (routing moves to GameFlow)" ``` --- ## Task 4: `GameFlow` coordinator The single home for the Title → Creation → Shell sequence. Injection seams on `world`/`service`/`origin` keep it headless-testable. **Files:** - Create: `scripts/ui/flow/game_flow.gd` - Create: `scenes/flow/GameFlow.tscn` - Test: `tests/unit/test_game_flow.gd` **Interfaces:** - Consumes: `TitleScreen.menu_activated(id)`, `CharacterCreation` seams (`world`, `origin`) + signal `creation_confirmed(creation)`, `NewGame.construct(origin, world, creation) -> {ok, errors, log, state}`, `MainWindowShell` seams (`service`, `injected_log`, `injected_state`). - Produces: `GameFlow` node with public seams `world: ContentDB`, `service: DmService`, `origin: Dictionary`, and `_current: Node` (the active screen, read by tests). - [ ] **Step 1: Write the failing test** Create `tests/unit/test_game_flow.gd`: ```gdscript extends "res://addons/gut/test.gd" const SCENE := "res://scenes/flow/GameFlow.tscn" const ContentDB = preload("res://scripts/content/content_db.gd") const FakeTransport = preload("res://tests/doubles/fake_transport.gd") const DmResponse = preload("res://scripts/net/dm_response.gd") func _flow() -> Node: # Inject fakes BEFORE _ready (add_child): a real world + a network-free service, # so the shell's initial narrate never touches HTTP. var node = load(SCENE).instantiate() var world = ContentDB.new() world.load_from(ContentDB.default_content_root()) node.world = world node.origin = ContentDB.load_json(ContentDB.origin_path("deserter")) var transport = FakeTransport.new() transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."})) node.service = DmService.new(transport, FallbackLibrary.new()) add_child_autofree(node) return node func _creation() -> Dictionary: return { "name": "Dagnet", "race_id": "human", "calling_id": "sellsword", "seed": 8675309, "spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception", } func test_boots_into_the_title(): var flow := _flow() assert_true(flow._current is TitleScreen, "GameFlow opens on the title") func test_new_game_shows_creation(): var flow := _flow() flow._current.emit_signal("menu_activated", &"new_game") assert_true(flow._current is CharacterCreation, "new game routes to creation") func test_confirming_creation_reaches_the_shell_as_the_created_character(): var flow := _flow() flow._current.emit_signal("menu_activated", &"new_game") flow._current.emit_signal("creation_confirmed", _creation()) assert_true(flow._current is MainWindowShell, "creation routes to the shell") assert_eq(flow._current._log.player.name, "Dagnet", "the CREATED character flowed through — not a seed, not a swap alone") func _assert_stays_on_creation(bad: Dictionary, why: String) -> void: var flow := _flow() flow._current.emit_signal("menu_activated", &"new_game") # Drive the REAL signal with an invalid dict. The CTA gates this out via the UI, # but §2 forbids GameFlow trusting the caller and §13 forbids surfacing an error. flow._current.emit_signal("creation_confirmed", bad) assert_false(flow._current is MainWindowShell, "a failed construct never reaches the shell (§13): %s" % why) assert_true(flow._current is CharacterCreation, "the player stays on the valid creation screen: %s" % why) func test_construct_failure_empty_name_stays_on_creation(): var bad := _creation() bad["name"] = "" # empty name fails NewGame.validate _assert_stays_on_creation(bad, "empty name") func test_construct_failure_bad_calling_stays_on_creation(): var bad := _creation() bad["calling_id"] = "bard" # NPC-only / not a real calling — fails NewGame.validate _assert_stays_on_creation(bad, "unknown calling") ``` - [ ] **Step 2: Run test to verify it fails** Run: `./run_tests.sh -gselect=test_game_flow.gd` Expected: FAIL — the scene `res://scenes/flow/GameFlow.tscn` does not exist yet (load returns null). - [ ] **Step 3: Write the coordinator script** Create `scripts/ui/flow/game_flow.gd`: ```gdscript class_name GameFlow extends Node ## The single home for the game's screen sequence: Title → Creation → Shell. ## Script-first by design — a pure coordinator with no Control layout, so ADR 0001's ## editor-first rule does not apply (that rule governs layout scenes). It owns one ## ContentDB + one DmService and swaps screens via instantiate → inject → add_child, ## the injection seam change_scene_to_file cannot provide. ## ## §2: the flow carries only plain data; NewGame.construct re-rolls the character ## from the creation seed itself. ⛨ saga: _show_creation and _start_campaign are ## separate, so a fourth entry path is an added arm, not surgery on existing edges. const HttpTransport = preload("res://scripts/net/http_transport.gd") const TITLE := preload("res://scenes/title/TitleScreen.tscn") const CREATION := preload("res://scenes/creation/CharacterCreation.tscn") const SHELL := preload("res://scenes/shell/MainWindowShell.tscn") # Injection seams — set BETWEEN instantiate() and add_child(), as the shell/creation # do for their own deps. _ready builds the real ones when nothing was supplied. var world: ContentDB = null var service: DmService = null var origin: Dictionary = {} var _current: Node = null var _http: HTTPRequest func _ready() -> void: if world == null: world = ContentDB.new() world.load_from(ContentDB.default_content_root()) if origin.is_empty(): origin = ContentDB.load_json(ContentDB.origin_path("deserter")) if service == null: _http = HTTPRequest.new() add_child(_http) service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new()) _show_title() func _show(next: PackedScene, inject: Callable) -> void: ## The one swap primitive. inject runs after instantiate() (which does NOT run ## _ready) and before add_child (which does) — the injection window. The outgoing ## screen is queue_free'd (deferred): a signal handler must never free the node ## still executing up its own call stack. var old := _current var screen := next.instantiate() inject.call(screen) _current = screen add_child(screen) if old != null: old.queue_free() func _show_title() -> void: _show(TITLE, func(s): s.menu_activated.connect(_on_menu)) func _show_creation() -> void: _show(CREATION, func(s): s.world = world s.origin = origin s.creation_confirmed.connect(_start_campaign)) func _show_shell(log: CanonLog, state: GameState) -> void: _show(SHELL, func(s): s.service = service s.injected_log = log s.injected_state = state) func _on_menu(id: StringName) -> void: match id: &"new_game": _show_creation() &"quit": get_tree().quit() _: pass # continue / load land in later milestones (M9); inert for now func _start_campaign(creation: Dictionary) -> void: ## Takes a plain Dictionary — does NOT assume it came from the creation screen, so ## a saga can synthesize one and call this directly (skipping creation). var result := NewGame.construct(origin, world, creation) if not result.get("ok", false): # Unreachable via the UI (the CTA gates on validate), but §2 forbids trusting # the caller and §13 forbids surfacing an error. Log and stay put. push_error("GameFlow: construct rejected a creation: %s" % str(result.get("errors", []))) return _show_shell(result["log"], result["state"]) ``` - [ ] **Step 4: Create the scene** Create `scenes/flow/GameFlow.tscn` — a single `Node` with the script attached. Author it in the editor (New Scene → Node → attach `res://scripts/ui/flow/game_flow.gd` → save as `res://scenes/flow/GameFlow.tscn`), or write the file directly: ``` [gd_scene load_steps=2 format=3 uid="uid://coc_gameflow"] [ext_resource type="Script" path="res://scripts/ui/flow/game_flow.gd" id="1"] [node name="GameFlow" type="Node"] script = ExtResource("1") ``` - [ ] **Step 5: Run test to verify it passes** Run: `./run_tests.sh -gselect=test_game_flow.gd` Expected: PASS — boots into title; new_game → creation; creation_confirmed → shell with `player.name == "Dagnet"`; both construct-failure parameterizations stay on creation. - [ ] **Step 6: Re-break to prove the guards (traps.md)** 1. In `_start_campaign`, temporarily delete the `if not result.get("ok"...)` early return. Run `test_game_flow.gd` → `test_construct_failure_empty_name_stays_on_creation` and `test_construct_failure_bad_calling_stays_on_creation` must FAIL (a rejected construct reaches the shell). Revert. 2. In `test_confirming_creation...`, temporarily change the created name to `"Dagnet"` → assert against `"Aldric"`. Run → FAIL (proves the name assertion reads the real flowed character, not a constant). Revert. - [ ] **Step 7: Commit** ```bash git add client/scripts/ui/flow/game_flow.gd client/scenes/flow/GameFlow.tscn client/tests/unit/test_game_flow.gd git commit -m "feat(m4c): GameFlow coordinator wires title -> creation -> shell" ``` --- ## Task 5: Make `GameFlow` the entry point + smoke the whole loop Flip `main_scene` and confirm the real, non-injected boot path start-to-shell (ADR 0001: the final visual gate is a person). **Files:** - Modify: `project.godot` (`run/main_scene`) **Interfaces:** - Consumes: everything above. - Produces: the shipped boot path — launching the client opens the title; *new game* leads through creation into a shell showing the created character. - [ ] **Step 1: Flip the main scene** In `project.godot`, change: ``` run/main_scene="res://scenes/title/TitleScreen.tscn" ``` to: ``` run/main_scene="res://scenes/flow/GameFlow.tscn" ``` - [ ] **Step 2: Run the full suite** Run: `./run_tests.sh` Expected: PASS. All prior tests + the new Task 1/2/4 tests. Note the new total (was 319; +~7 new tests). - [ ] **Step 3: Manual smoke (human/visual gate — ADR 0001)** Launch the client (F5 in the editor, or `godot --path client`). Verify the *real* path with no injected fakes: 1. The **title** appears (embers, gold rule). 2. Select **New Game** → the **character creation** screen appears (four race cards, seven calling cards, five ability cards, no "luck"). 3. Build a legal character, give a name, press **Enter the World**. 4. The **shell** appears; the DM narration book opens on *this* character's scene (grounded in the deserter origin — Greywater, the desertion), and the command bar HP/MP/purse reflect the built character (not Vexcca's 42/60, ◈3s 47c). Record the result. If narration shows the degraded fallback (API down), that is acceptable — the character/vitals must still be the created one. - [ ] **Step 4: Commit** ```bash git add client/project.godot git commit -m "feat(m4c): GameFlow is the main scene — title->creation->shell is the boot path" ``` --- ## Self-Review **Spec coverage:** - §1 problem (title skips creation; shell fakes) → Tasks 3, 2, 5. ✓ - §3.1 GameFlow (owns world/service/origin, `_show`, sequence table, saga seam) → Task 4. ✓ - §3.2 title emit-only → Task 3. ✓ - §3.3 creation unchanged → no task (correct — it already emits). ✓ - §3.4 shell seams + `ShellState.for_character` → Tasks 1, 2. ✓ - §4 data flow → exercised by Task 4's shell-reached-as-created-character test. ✓ - §5 error handling → Task 4's `test_construct_failure_{empty_name,bad_calling}_stays_on_creation` + re-break. ✓ - §6 testing (real signals, name-proves-flow, injected vitals, for_character unit, construct-failure) → Tasks 1,2,4. ✓ - §3.1 main_scene flip → Task 5. ✓ - ⛨ saga seam (separate `_show_creation`/`_start_campaign`) → Task 4 script. ✓ **Placeholder scan:** none — every step has concrete code/commands. **Type consistency:** `for_character(state, log)` defined Task 1, consumed Task 2 (via shell `_ready`) and Task 4 (via shell). `injected_log`/`injected_state` defined Task 2, set in Task 4's `_show_shell`. `_current`, `_start_campaign(creation)`, `_show_creation()` consistent across Task 4 script + test. `NewGame.construct` return keys (`ok`/`errors`/`log`/`state`) match the real signature. ✓