Compare commits

...

2 Commits

Author SHA1 Message Date
10f89baef9 Adding helper script to clean up stale superpowers sdd files 2026-07-15 03:47:30 -05:00
aaff2c8554 docs(m4c): spec — title → creation → shell flow
A GameFlow coordinator node (new main_scene) owns the Title → Creation →
Shell sequence in one file: instantiate → inject → add_child, reusing the
existing injection seam that change_scene_to_file cannot. Title becomes
emit-only; the shell gains injected_log/injected_state seams and stops
self-seeding on the real path (seed stays as the F6 fallback), so the
created character's vitals, location, and opening narration are real.

The ⛨ saga constraint is honored structurally: _show_creation and
_start_campaign are separate, so the fourth entry path is an added arm,
not surgery on existing edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 03:43:57 -05:00
2 changed files with 284 additions and 0 deletions

3
clean_superpowers.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/env bash
rm .superpowers/sdd/*.diff
rm .superpowers/sdd/*.md

View File

@@ -0,0 +1,281 @@
# M4-c — Title → creation → shell flow
**Milestone:** M4-c (roadmap §M4). **Date:** 2026-07-15.
**§2 side:** n/a (flow orchestration — code owns it, no AI).
**Depends on:** the Title screen (M3), the creation screen (M4-b), the creation
model `NewGame.construct` (M4-a), the shell (M3-a).
**Goal it advances:** a playable start-to-shell loop — the player builds a
character and lands in the exploration shell as *that* character.
---
## 1. Problem
The flow from the title screen into the game is fictional in two places:
1. **Title skips creation.** `title_screen.gd`'s `new_game` item does
`get_tree().change_scene_to_file(SHELL)` — it jumps straight to the shell and
never touches the creation screen.
2. **The shell fakes a character.** `main_window_shell.gd._build_seed_log()`
hardcodes "Vexcca, a human sellsword" and `ShellState.seed()` hardcodes her
vitals. The shell has never consumed a real, created character.
Meanwhile the creation screen already emits `creation_confirmed(creation)` and
`NewGame.construct(origin, world, creation)` already returns
`{ok, log: CanonLog, state: GameState}` — the two objects the shell needs. Nothing
connects the emit to the construct to the shell.
M4-c wires those three edges: **Title → Creation → Shell**, with the created
character flowing all the way through.
### Constraints
- **⛨ saga (roadmap §M3/§M4):** do NOT hardcode a linear
`new game → creation → world` flow as one welded unit. The saga's fourth entry
path — "begin the next campaign of an existing saga" — must be addable later
without rewriting existing edges.
- **§2:** the screen never hands game state upward. `construct` re-rolls the
character from the creation seed itself; the flow only carries plain data.
- **§13:** a failure never surfaces to the player as an error.
- **No new global state.** The codebase uses an injection-seam pattern
(`instantiate()` → set property → `add_child()`), not autoloads. There are zero
autoloads today; this milestone adds none.
---
## 2. Approach (chosen)
**A flow coordinator node.** A new script-first root `Node`, `GameFlow`, becomes
the `main_scene`. It owns one `ContentDB` and the current screen (its only child),
and it holds the entire Title → Creation → Shell sequence in one file.
Rejected alternatives:
- **Autoload handoff singleton** — keep `change_scene_to_file` everywhere, carry
the character in a global. Smallest screen diff, but smears the flow across three
handlers + global mutable state, fighting the no-hardcoded-flow constraint and
cutting against the injection-seam pattern.
- **Screens instantiate their own successor** — hardcodes the flow *into* the
screens and couples them; worst for the saga constraint.
The coordinator is the only option where the sequence is a single editable object
(saga-friendly) and it reuses the existing injection seam (no global state).
---
## 3. Components
### 3.1 `GameFlow` (new)
- **Files:** `client/scripts/ui/flow/game_flow.gd`,
`client/scenes/flow/GameFlow.tscn` (a trivial one-node scene).
- **Type:** script-first `Node`. It has no Control layout, so ADR 0001's
editor-first rule does not apply — that rule governs *Control layout* scenes;
this is a pure coordinator. (Noted explicitly so a reviewer does not read the
code-built structure as an ADR 0001 violation.)
- **Owns:**
- `world: ContentDB` — loaded once in `_ready` (`ContentDB.default_content_root()`).
- `service: DmService` — built once and injected into the shell, so the shell's
own `service == null` fallback is not needed on the real path (it stays for
F6/standalone).
- `origin: Dictionary` — the deserter origin (the single origin that exists).
- the current child screen.
- **`main_scene`:** `project.godot` `run/main_scene` changes from
`res://scenes/title/TitleScreen.tscn` to `res://scenes/flow/GameFlow.tscn`.
**The one swap primitive:**
```gdscript
func _show(next: PackedScene, inject: Callable) -> void:
# free current child, instantiate next, run the injection seam, then add.
for c in get_children():
c.queue_free()
var screen := next.instantiate()
inject.call(screen) # set injected properties BEFORE add_child (_ready)
add_child(screen)
```
`inject` is the seam: it runs after `instantiate()` (which does NOT run `_ready`)
and before `add_child()` (which does) — the same window the tests use. This is
precisely what `change_scene_to_file` cannot provide.
**The sequence (the whole table lives here):**
| Step | Trigger | GameFlow method |
|---|---|---|
| boot | `_ready()` | `_show_title()` |
| title | `menu_activated(&"new_game")` | `_show_creation()` |
| title | `menu_activated(&"quit")` | `get_tree().quit()` |
| creation | `creation_confirmed(creation)` | `_start_campaign(creation)` |
- `_show_title()``_show(TITLE, func(s): s.menu_activated.connect(_on_menu))`.
- `_show_creation()` → `_show(CREATION, func(s): s.world = world; s.origin = origin;
s.creation_confirmed.connect(_start_campaign))`.
- `_start_campaign(creation: Dictionary)` → call `NewGame.construct`; on success
`_show_shell(result.log, result.state)`; on failure see §5.
- `_show_shell(log, state)` → `_show(SHELL, func(s): s.service = service;
s.injected_log = log; s.injected_state = state)`.
**Saga seam:** `_show_creation()` and `_start_campaign(creation)` are deliberately
separate. `_start_campaign` takes a plain Dictionary and does not assume the
creation came from the screen. The saga's fourth path becomes a new
`menu_activated` arm that synthesizes a `creation` Dict and calls
`_start_campaign` directly, skipping the creation screen — no existing edge
changes.
### 3.2 `title_screen.gd` (modified)
Becomes **emit-only**. It already emits `menu_activated(id)`. Remove:
- `const SHELL`,
- the `new_game` and `quit` bodies of `_on_menu_activated` (and the now-dead
`_on_menu_activated` connection if it holds nothing else).
The screen no longer knows what comes after it. Its atmosphere/selection/input
behavior is untouched.
### 3.3 `character_creation.gd` (unchanged)
No change. It already accepts injected `world`/`origin` and already emits
`creation_confirmed(draft.to_creation())`. M4-b left the signal dangling for
exactly this milestone to connect.
### 3.4 `main_window_shell.gd` + `ShellState` (modified)
Two new injection seams on the shell, mirroring `service`:
```gdscript
var injected_log: CanonLog = null
var injected_state: GameState = null
```
In `_ready()`, the seed calls become fallbacks (same shape as `service == null`):
```gdscript
_state = ShellState.for_character(injected_state, injected_log) if injected_state \
else ShellState.seed()
_log = injected_log if injected_log else _build_seed_log()
```
New factory **`ShellState.for_character(state: GameState, log: CanonLog)`**:
- `vitals.hp / hp_max` ← `state.sheet.hp / state.sheet.max_hp()`
- `vitals.mp / mp_max` ← `state.sheet.mp / state.sheet.max_mp()`
- `vitals.purse_copper` ← `state.purse_copper`
- `location_label` ← `log.location.name`
- `turn_order`, `consumables`, `round_label` ← **the same placeholders `seed()`
uses** (combat = M5, inventory = M7 own these; not M4-c's to invent).
`_build_seed_log()` and `ShellState.seed()` **stay** — they are the F6/standalone
fallback, unchanged. On the real path the injected objects win and the created
character's vitals, location, and opening narration (via the injected log) are all
real.
---
## 4. Data flow
```
project.godot main_scene = GameFlow.tscn
GameFlow._ready → loads ContentDB (world), origin (deserter), builds DmService
│ _show_title
TitleScreen ──menu_activated(&"new_game")──▶ GameFlow._show_creation
│ inject world + origin
CharacterCreation ──creation_confirmed(creation)──▶ GameFlow._start_campaign
│ NewGame.construct(origin, world, creation) → {ok, log, state}
│ inject log + state + service
MainWindowShell (vitals/location/narration all from the real character)
```
`creation` is a plain Dictionary carrying `{name, race_id, calling_id, seed,
spend, skills, bonus_skill}`. `construct` re-rolls the attributes and Luck from
`seed` itself (§2/§10) — the flow never carries a computed stat.
---
## 5. Error handling (§13)
`NewGame.construct` returns `{ok: false, errors}` on invalid input. Through the UI
this is **unreachable** — `character_creation._bind_cta` disables the CTA on any
`NewGame.validate` error, so `creation_confirmed` only fires on a legal draft. But
§2 requires `construct` to distrust its caller, and so does `GameFlow`:
- On `result.ok == false`, `_start_campaign` **logs the errors and returns** —
it does not swap to the shell. The player remains on the valid creation screen.
- No crash, no half-built shell reaches the player.
This path exists to be handled, not hit; a test drives it deliberately (§6).
---
## 6. Testing
All new guards get the revert-and-watch-it-go-red treatment per
[`docs/traps.md`](../../traps.md). Drive screens by emitting their **real
signals**, never by calling handlers (traps.md: tests that called handlers
instead of pressing nodes).
1. **GameFlow flow — new game reaches the shell as the created character.**
Instantiate GameFlow with an injected fake `world`/`origin`/`service`; let it
show the title; `emit_signal("menu_activated", &"new_game")` → assert the child
is a `CharacterCreation`. Build a valid `creation` Dict for a known character
(e.g. name "Dagnet"), `emit_signal("creation_confirmed", creation)` → assert
the child is a `MainWindowShell` **and its canon log player name == "Dagnet"**.
The name assertion proves the character flowed through, not merely that a scene
swapped. *Re-break:* feed a Dict whose name differs from the asserted one and
watch it go red.
2. **Shell injected path — vitals derive from the real sheet.** Inject a
`log`+`state` for a character with a known HP (distinct from the seed's 42/60);
`add_child`; assert the `HP` label reads the injected HP. *Re-break:* force the
`seed()` branch and watch it fail.
3. **`ShellState.for_character` unit.** Vitals + location derive from the passed
`state`/`log`; `turn_order`/`consumables`/`round_label` equal `seed()`'s
placeholders. *Re-break:* change one derived field's source and watch the
assertion fail.
4. **Construct-failure stays on creation (§5).** Drive `_start_campaign` with a
`creation` Dict that fails `validate`; assert no `MainWindowShell` is added and
the flow is unchanged. *Re-break:* make `_start_campaign` transition
unconditionally and watch this go red.
5. **Title is emit-only.** Assert `title_screen.gd` no longer references a shell
scene path and emits `menu_activated` (the existing title test may already
cover the emit; extend it to assert the routing bodies are gone).
---
## 7. Scope boundary
**In:** the three flow edges; the character flowing through them; the shell's
vitals + location + narration deriving from the real character; the emit-only
title; error handling for the unreachable construct failure.
**Out (owned elsewhere):**
- Origin selection UI — deserter is the single origin; the seam accepts one origin
now and a choice later.
- Save/load, continue, load — M9.
- Quit-confirmation dialog.
- Any HUD field beyond vitals + location (turn order = M5, consumables = M7).
- The saga's fourth entry path itself — this milestone only keeps it *addable*.
---
## 8. Files
**New:**
- `client/scripts/ui/flow/game_flow.gd`
- `client/scenes/flow/GameFlow.tscn`
- `client/tests/unit/test_game_flow.gd`
**Modified:**
- `client/project.godot` — `run/main_scene` → `GameFlow.tscn`
- `client/scripts/ui/title/title_screen.gd` — emit-only
- `client/scripts/ui/shell/main_window_shell.gd` — `injected_log`/`injected_state`
seams + fallback wiring
- `client/scripts/ui/shell/shell_state.gd` — `for_character(state, log)` factory
- `client/tests/unit/test_main_window_shell.gd` — injected-path test
- `client/tests/unit/test_shell_state.gd` — `for_character` test
- `client/tests/unit/test_title_screen.gd` — emit-only assertions