Five TDD tasks: ShellState.for_character, the shell's injected-character seams, the emit-only title, the GameFlow coordinator (+ its flow and construct-failure tests), and the main_scene flip with a human smoke gate. Each task carries full test code, exact commands, and a revert-and-watch-red step per docs/traps.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
26 KiB
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
Global Constraints
- §2 (code owns state): the flow carries only plain data;
NewGame.constructre-rolls the character from the creation seed itself. No task hands a computed stat between screens. - ⛨ saga: do NOT weld
new game → creation → shellinto one unit._show_creation()and_start_campaign(creation)stay separate methods so a fourth entry path is an addedmenu_activatedarm, not surgery on existing edges. - §13: a failure never reaches the player as an error. A
constructthat 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()andadd_child()). - ADR 0001 (editor-first UI): applies to Control layout scenes.
GameFlowis 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(fromclient/, ~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: ownsworld/service/origin, holds the Title→Creation→Shell sequence, one_showswap primitive.scenes/flow/GameFlow.tscn— oneNodewithgame_flow.gdattached. The newmain_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— addfor_character(state, log)factory.scripts/ui/shell/main_window_shell.gd— addinjected_log/injected_stateseams; seed calls become fallbacks.scripts/ui/title/title_screen.gd— emit-only (dropconst SHELL,_on_menu_activated, the self-connect).project.godot—run/main_scene→GameFlow.tscn.tests/unit/test_shell_state.gd—for_charactertest.tests/unit/test_main_window_shell.gd— injected-path test.tests/unit/test_title_screen.gd— drop the_on_menu_activateddisconnect (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:
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()):
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
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 beforeadd_child(mirrors the existingserviceseam). -
Step 1: Write the failing test
Add to tests/unit/test_main_window_shell.gd:
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):
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):
_state = ShellState.seed()
_log = _build_seed_log()
with:
# 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
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(removeconst SHELL,_on_menu_activated, the self-connect) - Test:
tests/unit/test_title_screen.gd:39-47
Interfaces:
-
Consumes: nothing new.
-
Produces:
TitleScreenemitsmenu_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:
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:
- Delete
const SHELL := "res://scenes/shell/MainWindowShell.tscn"(line 12). - In
_ready(), delete the linemenu_activated.connect(_on_menu_activated)(line 47). - Delete the entire
_on_menu_activatedfunction (lines 131-138):
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
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),CharacterCreationseams (world,origin) + signalcreation_confirmed(creation),NewGame.construct(origin, world, creation) -> {ok, errors, log, state},MainWindowShellseams (service,injected_log,injected_state). -
Produces:
GameFlownode with public seamsworld: 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:
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:
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)
- In
_start_campaign, temporarily delete theif not result.get("ok"...)early return. Runtest_game_flow.gd→test_construct_failure_empty_name_stays_on_creationandtest_construct_failure_bad_calling_stays_on_creationmust FAIL (a rejected construct reaches the shell). Revert. - 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
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:
- The title appears (embers, gold rule).
- Select New Game → the character creation screen appears (four race cards, seven calling cards, five ability cards, no "luck").
- Build a legal character, give a name, press Enter the World.
- 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
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. ✓