Compare commits
2 Commits
a106be673f
...
10f89baef9
| Author | SHA1 | Date | |
|---|---|---|---|
| 10f89baef9 | |||
| aaff2c8554 |
3
clean_superpowers.sh
Executable file
3
clean_superpowers.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/env bash
|
||||
rm .superpowers/sdd/*.diff
|
||||
rm .superpowers/sdd/*.md
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user