diff --git a/docs/superpowers/plans/2026-07-15-m4c-title-creation-shell-flow.md b/docs/superpowers/plans/2026-07-15-m4c-title-creation-shell-flow.md new file mode 100644 index 0000000..a708606 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-m4c-title-creation-shell-flow.md @@ -0,0 +1,615 @@ +# 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. ✓