Compare commits

...

10 Commits

Author SHA1 Message Date
e5fb173008 Merge M4-c: Title → Creation → Shell flow (the Main coordinator)
New Game now runs character creation and lands in the exploration shell as the
CREATED character — HP/MP/purse/location/opening-narration all real. A script-first
`Main` coordinator (scenes/Main.tscn, the main_scene) owns Title → Creation → Shell
via instantiate → inject → add_child; the title is emit-only; the shell consumes an
injected character (seed stays the F6 fallback). ⛨ saga fourth-path seam kept addable.

Completes M4. Full suite 327/327; whole-branch review clean; human F5 smoke passed.
Also adds the roadmap's next item (Ollama Cloud proxy provider + backend switch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:35:05 -05:00
f5e6c59f2a docs(roadmap): add Ollama Cloud provider + backend switch as the next item
A laptop-dev enabler (planned, not specced): extend the proxy's M1 model-call
pipeline with an Ollama Cloud backend reachable via API key, plus a switch to
flip local-ollama <-> ollama-cloud, so AI works off the homelab.

Reconciles against §4: the API key and provider choice stay server-side in the
proxy (the client never holds a key, never picks the backend). The title
Settings-button "config file" splits — the client may hold only the proxy
base URL / profile (folds into M9); the provider/key switch is proxy config.

Filed under a renamed "Landed / planned out of band" section and pointed to
from the sequencing table as the next task before M5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:27:04 -05:00
7f155b8de2 docs(roadmap): M4-c lands — Title -> Creation -> Shell flow; M4 complete
Marks M4-c done (the Main coordinator wires new game -> creation -> shell,
the shell consumes the real created character, the saga fourth-path seam is
kept addable) and flips M4 -> done / M5 -> next in the milestone table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:20:45 -05:00
d553213563 refactor(m4c): rename GameFlow coordinator to Main, move to scenes/Main.tscn
The entry-point coordinator read as an internal name ("GameFlow" buried in
scenes/flow/), so it wasn't obvious THIS is the scene that boots first — the
title screen looked like the entry point. Renamed to the Godot-conventional
Main (scenes/Main.tscn + scripts/main.gd) so the boot scene is self-evident.
TitleScreen is unchanged and still shown BY Main as its first screen.

Also drops the hand-written gd_scene uid (now matches sibling scenes) —
closes the Minor review finding. Full suite 327/327 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 05:16:55 -05:00
5a9f39ea48 feat(m4c): GameFlow is the main scene — title->creation->shell is the boot path
Flips run/main_scene from TitleScreen to the GameFlow coordinator, so the
shipped boot path runs the real Title -> Creation -> Shell flow. Full suite
327/327 green headless; the human F5 smoke (ADR 0001 visual gate) is the
remaining acceptance step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 04:20:23 -05:00
583d32456a feat(m4c): GameFlow coordinator wires title -> creation -> shell 2026-07-15 04:15:28 -05:00
6b10b6ea6c feat(m4c): title screen is emit-only (routing moves to GameFlow) 2026-07-15 04:10:10 -05:00
8d963e8030 feat(m4c): shell consumes an injected character (seed stays the F6 fallback) 2026-07-15 04:06:44 -05:00
e169885d90 feat(m4c): ShellState.for_character derives vitals+location from the real character 2026-07-15 04:02:37 -05:00
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
14 changed files with 899 additions and 36 deletions

View File

@@ -13,26 +13,13 @@ config_version=5
config/name="coc-rpg"
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
config/version="0.01-alpha"
run/main_scene="res://scenes/title/TitleScreen.tscn"
run/main_scene="res://scenes/Main.tscn"
config/features=PackedStringArray("4.7")
[display]
; The DESIGN CANVAS stays 1920x1080 — the mockups (§16) are authored there, and every
; .tscn's offsets are in those coordinates. It is a logical canvas, not a demand on the
; monitor: canvas_items + keep scales it uniformly into whatever window it is given, so
; the UI never reflows, it only gets smaller. Nothing below changes a layout number.
window/size/viewport_width=1920
window/size/viewport_height=1080
; The WINDOW is what the laptop has to fit, and it is a different question. 1600x900 was
; larger than a 1600x900 laptop's *usable* area once the WM took its panel and titlebar,
; so the window manager clamped it to 1589x752 and the run came up pillarboxed. 1280x720
; is 16:9 and fits any laptop this ships to; the window is resizable (Godot's default),
; and canvas_items rescales the canvas live on every resize.
window/size/window_width_override=1280
window/size/window_height_override=720
window/size/resizable=true
window/stretch/mode="canvas_items"
; keep = letterbox rather than distort or reflow. This IS Godot 4's default, so the editor
; prunes the line whenever it rewrites this file — that is cosmetic, not a behaviour change.
window/stretch/aspect="keep"

6
client/scenes/Main.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
[node name="Main" type="Node"]
script = ExtResource("1")

93
client/scripts/main.gd Normal file
View File

@@ -0,0 +1,93 @@
class_name Main
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("Main: construct rejected a creation: %s" % str(result.get("errors", [])))
return
_show_shell(result["log"], result["state"])

View File

@@ -0,0 +1 @@
uid://du7rwup648n60

View File

@@ -19,6 +19,8 @@ extends Control
const HttpTransport = preload("res://scripts/net/http_transport.gd")
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
var _state: ShellState
var _log: CanonLog
@@ -40,8 +42,10 @@ func _ready() -> void:
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
_state = ShellState.seed()
_log = _build_seed_log()
# 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()
_bind_state()
if service == null:

View File

@@ -34,3 +34,19 @@ static func seed() -> ShellState:
s.location_label = "THE LOWER WARD"
s.dock_open = true
return s
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 := ShellState.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

View File

@@ -9,8 +9,6 @@ extends Control
signal menu_activated(id: StringName)
const SHELL := "res://scenes/shell/MainWindowShell.tscn"
var _rows: Array = []
var _sel: int = 0
@@ -44,7 +42,6 @@ func _ready() -> void:
_wire_row_mouse()
_footer_left.text = Version.new().footer()
menu_activated.connect(_on_menu_activated)
_update_selection()
@@ -128,16 +125,6 @@ func _activate(i: int) -> void:
menu_activated.emit(_rows[i].get_meta("action"))
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
func _update_selection() -> void:
for i in range(_rows.size()):
var row: Control = _rows[i]

View File

@@ -0,0 +1,71 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/Main.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, "Main 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 Main 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")

View File

@@ -0,0 +1 @@
uid://17abdh3almry

View File

@@ -3,6 +3,8 @@ extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/MainWindowShell.tscn"
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
const NewGame = preload("res://scripts/newgame/new_game.gd")
const ContentDB = preload("res://scripts/content/content_db.gd")
func _shell() -> MainWindowShell:
@@ -47,3 +49,36 @@ func test_shell_seed_player_has_a_real_race_and_calling():
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
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")

View File

@@ -1,5 +1,46 @@
extends "res://addons/gut/test.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")
func test_turn_entry_holds_fields():
var e := TurnEntry.new("DW", 12, &"ally")

View File

@@ -38,8 +38,7 @@ func test_up_from_first_wraps_to_last():
func test_new_game_and_quit_emit_their_action():
var t := _title()
# Isolate the signal seam so the test does not actually change scene / quit.
t.menu_activated.disconnect(t._on_menu_activated)
# 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"])

View File

@@ -36,18 +36,25 @@ M0M2 built the **engine** (the contract, the client, the server loop, bounded
| **M1 — Prove the server loop** ✅ | A real model sits behind `/dm/narrate` and the pipeline is reusable. |
| **M2 — Prove aliveness** ✅ | **Does bounded AI dialogue feel alive?** — answered; bounded NPC dialogue + DM loop live-proven. |
| **M3 — Visual foundation & shell** ✅ | The `Theme` + the exploration HUD; every screen inherits the look and lives in the shell. |
| **M4 — Character creation** *here* | The player enters the world; the proven stat/Luck model gets its UI. |
| **M4 — Character creation** | The player enters the world; the proven stat/Luck model gets its UI. |
| **M5 — Tactical combat (gridless)** | Real stakes: the Combat HUD turn manager. |
| **M6 — Dialogue screen** | The proven NPC free-text loop inside the mock's conversation UI. |
| **M7 — Inventory & character sheet** | State the player can see and manage. |
| **M8 — World systems** | World-map travel, a shop economy, the quest log. |
| **M9 — Framing, AI-flavor & persistence** | Title/pause, save/load, and the Improviser/Banter/Luck-drift roles + fallback sweep. |
### Landed out of band
**▶ Next (before M5):** *Provider flexibility — Ollama Cloud + a backend switch* (in **Landed / planned out of band**, below). A laptop-dev enabler: it lets the proxy serve the same Ollama-hosted models over an API key from any machine, so development continues away from the homelab. Not a game-screen milestone — a cross-cutting infra item taken now because the alternative is a laptop with no AI.
Not every item is a milestone. These shipped on their own because a later milestone
would have been more expensive to build on top of the old shape.
### Landed / planned out of band
Not every item is a milestone. Some shipped on their own because a later milestone
would have been more expensive to build on top of the old shape; some are planned
cross-cutting work that does not belong to a single screen.
-**Provider flexibility — Ollama Cloud + a backend switch** *(next; not yet specced — a future session builds it).* Development spans two machines: a desktop with the homelab Ollama, and a laptop with no homelab reach. Today the proxy's model-call pipeline (M1) only knows a **local** Ollama, so the laptop has no AI at all. This adds **Ollama Cloud** as a backend so the same Ollama-hosted models are reachable over an **API key** from anywhere, and a **switch** to flip between `local-ollama` (desktop, free/offline) and `ollama-cloud` (laptop) — and the door stays open for prod's Replicate (§4) as a third backend.
- **§4 is load-bearing — the key and the provider choice live server-side, in the proxy.** The client never holds an API key and never learns which backend serves a role (§4). Concretely: extend the proxy's Ollama httpx client (M1) to target an **Ollama-compatible cloud endpoint** with an **`Authorization: Bearer <key>`** header, the key read from the proxy's environment / `.env`, never a client binary. The "flip a switch" is a **proxy config/env** value selecting the backend (endpoint + key + per-role model ids) — this is the M1 role→model routing config growing a *backend* axis, a deploy/config change, not a client patch, exactly as §4 intends.
- **The client's Settings-button angle (reconcile, don't silently adopt):** the "config file surfaced by the title **Settings** button" is a real feature, but under §4 it splits in two. **Legit client setting:** the **proxy base URL / profile** (§4 lets the client know *one base URL*) — a persisted client config choosing "laptop proxy" vs "desktop proxy," editable via the M9 Settings screen. **NOT a client setting:** the AI **provider and API key** — those stay in the proxy; the Settings screen must never hold the key or pick the backend, or the §4 boundary leaks into a shippable binary. So the honest "flip a switch" is *two* switches — a proxy-side provider/key config (this item, the immediate laptop-dev enabler) and a client-side base-URL/profile that **folds into M9's settings + persistence**. For laptop work right now, only the proxy switch is needed.
- *§2: text (the provider serves text; the client stays provider-agnostic) · depends on the M1 pipeline · goal: AI on any machine, switchable — and a proxy that is already multi-backend before Replicate (§4) arrives.*
-**Currency — copper with real denominations** ([spec](superpowers/specs/2026-07-12-currency-copper-design.md)). `1g = 100s = 10,000c`, **stored entirely in copper** as one `GameState.purse_copper` int, formatted only at the display edge (`◈ 3s 47c`). `coin.json``copper`/`silver`/`gold`. Money is **not** an inventory item: `give_item`/`accept_item` carry no quantity and `gifts_given` is a global one-shot, so a lone `copper` id would have been givable once, for one copper, per campaign — the denominations are **move tokens** instead (`give_item(gold)` = +10,000c), routed to the purse and never into `inventory`, so money cannot surface in an inventory grid by construction. Taken before M7 while `content/world/items/` held two files. **Prices remain M8's content BOM — nothing is priced yet.** *§2: state · goal: money that means something.*
-**A move applies at most once per reply** — found by the review of the above. `TagExtractor` returns *every* tag occurrence and `MoveValidator` is a membership test, so a model repeating a tag N times picked an amount **in unary** (§2 breach: the AI choosing a number). Now deduped by (name, id) across a reply, and `adjust_disposition`'s `MAX_DELTA` caps the reply's **net** swing rather than each tag — it was per-tag, so three `+15`s moved standing by 45, the exact "wholesale swing" the constant forbids. *§2: state · goal: the bounded vocabulary stays bounded.*
@@ -78,7 +85,7 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (
-**Creation model (M4-a)** — the rules a character is made of, no UI ([spec](superpowers/specs/2026-07-12-creation-model-design.md)). Race/calling/skill **mechanics** are static tables in code (`client/scripts/rules/`); their **blurbs** are hand-written content (`content/world/{callings,races}/`, loaded by `ContentDB`, parity-tested), so the content BOM does not block the engine. `CharacterSheet` **stores only what was rolled or chosen** (attributes, race, calling, skills, current hp/mp) and **derives** the rest (`max_hp`, `ac`, `save`, `skill_bonus`, `spell_dc`) — so no derived number can drift from its inputs, and M5's level curve is a function change rather than a stored-field migration. **`NewGame.construct` carries a seed, not a stat block:** it rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2), and the same seed yields an identical character, hidden Luck included (§10) — pinned by a golden-vector test. The roll is **3d6 with a hard floor of 8** (straight 3d6 puts ~9% on a primary the +3 pool cannot rescue; a character bad at the one thing he is *for* is the "fine" §7 forbids). **LCK is unspendable, not merely hidden.** Open seams marked honestly: `ac()` is `10 + DEX` until armor exists (M7), `max_mp()` is a `TUNABLE` placeholder (M5 owns the curve), L1 talents are id stubs (M5). **Live-proven end-to-end** (2026-07-12): a beastfolk cutpurse built by the pipeline → canon log → schema validation → `Player: Vexcca, a beastfolk cutpurse` in the digest (no numeric Luck, no stats, no snake_case id) → real narration from qwen3.5. 250 client tests, 76 api (live layer included). *§2: state · goal: the character exists in code.*
-**Contract migration** — the two contract migrations this milestone owed have **landed**: the canon log's `player` block is now `{name, race_id, calling_id, luck_descriptor}` (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now `allowed_callings` (`content/origins/deserter.json` lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree.
-**Character creation UI (M4-b)** — the mock's screen over M4-a's model: four race cards and seven nameplate calling cards (blurb + mechanics shown only for the *chosen* calling), the **+3 additive spend pool** with `` flooring at the rolled base, proficiency chips gated by the chosen calling's pool (plus the Human bonus-skill row), five ability cards — **never six, and the word "luck" appears nowhere on the screen** (§7) — and the DM origin panel composing race + calling fragments. Five mock/model reconciles settled in the spec's §4 (skill picking added, seven callings not five, blurb-on-chosen-only, `+`/`` both present, the portrait ` ` arrows cut for lacking art). **Architecture:** a pure, node-free `CreationDraft` (`RefCounted`) holds every rule — changing calling clears stale skill picks, changing race drops a now-granted pick, re-roll clears the spend and only the spend, `errors()` delegates to `NewGame.validate` rather than keeping a second copy of the rules; `CreationCopy` is a pure static formatter that derives every display string (hit die, armour word, saves, skill count) from the `Races`/`Callings`/`Skills` tables, so retuning a calling retunes its card without touching a string literal; the `.tscn` owns the layout per ADR 0001 and `character_creation.gd` only binds. **The §2 property holds without the screen having to be trustworthy:** the screen shows its five numbers by calling the same newly-public `NewGame.roll_attributes(seed)` that `construct` calls internally — the screen never hands a number to anything, only a seed and four choices; `construct` rolls the identical attributes again, itself, off the same seed. Re-roll is unlimited (the floor of 8 already prevents a dead primary), and every re-roll silently re-rolls the hidden LCK draw riding the same RNG stream — the screen says nothing about this, ever. Emits `creation_confirmed(creation: Dictionary)`; does not call `construct` itself, so a saga can synthesize the same Dictionary later and skip the scene entirely. **Test count: 250 → 302.** **The honest seams:** three rounds of adversarial review found ten guard-shaped assertions across this milestone that could not fail against the bug they named (substring collisions, a BBCode wrapper masking an empty ContentDB, a test asserting the script's own loop bound, a trivial origin fixture, assertions already true before the action under test ran, a theme-drift guard checking three hardcoded variations instead of all of them, a `Label` sweep that missed `RichTextLabel`, and tests that called handlers directly instead of pressing nodes) — all rewritten against the actual artifact and reverted-and-reconfirmed-red per `docs/traps.md`'s standard; the new species are folded into that file. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
- **Title → creation → shell flow (M4-c)**wire the title screen's *new game* path into character creation and on into the M3 shell, without hardcoding a `new game → creation → world` linear flow (the saga's fourth entry path, §M3, must still be addable later). *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.*
- **Title → creation → shell flow (M4-c)**the *new game* path is wired end-to-end ([spec](superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md)). A script-first **`Main`** coordinator node (`scenes/Main.tscn`, now the `main_scene`) owns the Title → Creation → Shell sequence in one file, swapping screens via **instantiate → inject → add_child** the injection seam `change_scene_to_file` cannot provide, and the reason it, not a linear chain, holds the flow. The **Title became emit-only** (it announces `menu_activated`; the coordinator routes it — it no longer knows what comes next), and the **shell stopped faking a character**: new `injected_log`/`injected_state` seams + a `ShellState.for_character(state, log)` factory mean the created character's **HP/MP/purse, location, and opening narration are real** (`seed()`/`_build_seed_log()` stay as the F6/standalone fallback, exactly like `service == null`). The flow carries only a plain `creation` Dictionary; `NewGame.construct` re-rolls the character from its seed itself (§2). **⛨ saga honored structurally:** `_show_creation()` and `_start_campaign(creation)` are separate methods, so the fourth entry path ("begin the next campaign of an existing saga") is an *added `menu_activated` arm* that synthesizes a Dictionary and calls `_start_campaign` directly — no existing edge changes. **§13:** a `construct` that returns `{ok:false}` (unreachable via the CTA gate, but the coordinator distrusts its caller anyway) logs and stays on creation — never a crash, never a half-built shell. Built subagent-per-task with a per-task + final whole-branch review; every new guard reverted-and-reconfirmed-red per `docs/traps.md`. **Test count: 302 → 327.** The coordinator was named `Main` (not `GameFlow`) so the boot scene is self-evident; `TitleScreen` is unchanged and shown *by* `Main`. *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.*
### M5 — Tactical combat (gridless)
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, **tier 1** (variable per tier). **~15 pieces.***

View File

@@ -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. ✓