Compare commits
10 Commits
10f89baef9
...
e5fb173008
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fb173008 | |||
| f5e6c59f2a | |||
| 7f155b8de2 | |||
| d553213563 | |||
| 5a9f39ea48 | |||
| 583d32456a | |||
| 6b10b6ea6c | |||
| 8d963e8030 | |||
| e169885d90 | |||
| f763aa0c31 |
@@ -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
6
client/scenes/Main.tscn
Normal 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
93
client/scripts/main.gd
Normal 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"])
|
||||
1
client/scripts/main.gd.uid
Normal file
1
client/scripts/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://du7rwup648n60
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
71
client/tests/unit/test_main.gd
Normal file
71
client/tests/unit/test_main.gd
Normal 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")
|
||||
1
client/tests/unit/test_main.gd.uid
Normal file
1
client/tests/unit/test_main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://17abdh3almry
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -36,18 +36,25 @@ M0–M2 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.***
|
||||
|
||||
@@ -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