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>
This commit is contained in:
@@ -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. ✓
|
||||
Reference in New Issue
Block a user