Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-15-m4c-title-creation-shell-flow.md
Phillip Tarrant f763aa0c31 docs(m4c): implementation plan — title → creation → shell flow
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>
2026-07-15 03:54:18 -05:00

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.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.godotrun/main_sceneGameFlow.tscn.
  • tests/unit/test_shell_state.gdfor_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:

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 before add_child (mirrors the existing service seam).

  • 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 (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:

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):
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), 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:

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)
  1. In _start_campaign, temporarily delete the if not result.get("ok"...) early return. Run test_game_flow.gdtest_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
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
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. ✓