From e169885d909c067f9e636e30b161788a256fef15 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 04:02:37 -0500 Subject: [PATCH 1/8] feat(m4c): ShellState.for_character derives vitals+location from the real character --- client/scripts/ui/shell/shell_state.gd | 16 ++++++++++ client/tests/unit/test_shell_state.gd | 41 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/client/scripts/ui/shell/shell_state.gd b/client/scripts/ui/shell/shell_state.gd index 89b1e7c..c504c2e 100644 --- a/client/scripts/ui/shell/shell_state.gd +++ b/client/scripts/ui/shell/shell_state.gd @@ -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 diff --git a/client/tests/unit/test_shell_state.gd b/client/tests/unit/test_shell_state.gd index 2319320..21a6257 100644 --- a/client/tests/unit/test_shell_state.gd +++ b/client/tests/unit/test_shell_state.gd @@ -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") From 8d963e803081ff9d421ffa18ccbf5f7ff9bb7ba4 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 04:06:44 -0500 Subject: [PATCH 2/8] feat(m4c): shell consumes an injected character (seed stays the F6 fallback) --- client/scripts/ui/shell/main_window_shell.gd | 8 +++-- client/tests/unit/test_main_window_shell.gd | 35 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd index a5d314b..fdf1e39 100644 --- a/client/scripts/ui/shell/main_window_shell.gd +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -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: diff --git a/client/tests/unit/test_main_window_shell.gd b/client/tests/unit/test_main_window_shell.gd index 13527d6..7615487 100644 --- a/client/tests/unit/test_main_window_shell.gd +++ b/client/tests/unit/test_main_window_shell.gd @@ -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") From 6b10b6ea6cb7767efbe7489f95a2b691c008e78a Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 04:10:10 -0500 Subject: [PATCH 3/8] feat(m4c): title screen is emit-only (routing moves to GameFlow) --- client/scripts/ui/title/title_screen.gd | 13 ------------- client/tests/unit/test_title_screen.gd | 3 +-- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/client/scripts/ui/title/title_screen.gd b/client/scripts/ui/title/title_screen.gd index df98673..f552c0d 100644 --- a/client/scripts/ui/title/title_screen.gd +++ b/client/scripts/ui/title/title_screen.gd @@ -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] diff --git a/client/tests/unit/test_title_screen.gd b/client/tests/unit/test_title_screen.gd index 2be0fa5..2896287 100644 --- a/client/tests/unit/test_title_screen.gd +++ b/client/tests/unit/test_title_screen.gd @@ -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"]) From 583d32456a771a9d774edc40dd3984de536333ec Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 04:15:28 -0500 Subject: [PATCH 4/8] feat(m4c): GameFlow coordinator wires title -> creation -> shell --- client/scenes/flow/GameFlow.tscn | 6 ++ client/scripts/ui/flow/game_flow.gd | 93 +++++++++++++++++++++++++ client/scripts/ui/flow/game_flow.gd.uid | 1 + client/tests/unit/test_game_flow.gd | 71 +++++++++++++++++++ client/tests/unit/test_game_flow.gd.uid | 1 + 5 files changed, 172 insertions(+) create mode 100644 client/scenes/flow/GameFlow.tscn create mode 100644 client/scripts/ui/flow/game_flow.gd create mode 100644 client/scripts/ui/flow/game_flow.gd.uid create mode 100644 client/tests/unit/test_game_flow.gd create mode 100644 client/tests/unit/test_game_flow.gd.uid diff --git a/client/scenes/flow/GameFlow.tscn b/client/scenes/flow/GameFlow.tscn new file mode 100644 index 0000000..e8ad548 --- /dev/null +++ b/client/scenes/flow/GameFlow.tscn @@ -0,0 +1,6 @@ +[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") diff --git a/client/scripts/ui/flow/game_flow.gd b/client/scripts/ui/flow/game_flow.gd new file mode 100644 index 0000000..9b162c9 --- /dev/null +++ b/client/scripts/ui/flow/game_flow.gd @@ -0,0 +1,93 @@ +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"]) diff --git a/client/scripts/ui/flow/game_flow.gd.uid b/client/scripts/ui/flow/game_flow.gd.uid new file mode 100644 index 0000000..9016e6f --- /dev/null +++ b/client/scripts/ui/flow/game_flow.gd.uid @@ -0,0 +1 @@ +uid://cy0r08ct0jugt diff --git a/client/tests/unit/test_game_flow.gd b/client/tests/unit/test_game_flow.gd new file mode 100644 index 0000000..9ef6db1 --- /dev/null +++ b/client/tests/unit/test_game_flow.gd @@ -0,0 +1,71 @@ +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") diff --git a/client/tests/unit/test_game_flow.gd.uid b/client/tests/unit/test_game_flow.gd.uid new file mode 100644 index 0000000..b42254a --- /dev/null +++ b/client/tests/unit/test_game_flow.gd.uid @@ -0,0 +1 @@ +uid://buyyn7tbjctb From 5a9f39ea48a965a6c916d87714e4fae572ae84d7 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 04:20:23 -0500 Subject: [PATCH 5/8] =?UTF-8?q?feat(m4c):=20GameFlow=20is=20the=20main=20s?= =?UTF-8?q?cene=20=E2=80=94=20title->creation->shell=20is=20the=20boot=20p?= =?UTF-8?q?ath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips run/main_scene from TitleScreen to the GameFlow coordinator, so the shipped boot path runs the real Title -> Creation -> Shell flow. Full suite 327/327 green headless; the human F5 smoke (ADR 0001 visual gate) is the remaining acceptance step. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/project.godot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/project.godot b/client/project.godot index 29567d0..e8846c6 100644 --- a/client/project.godot +++ b/client/project.godot @@ -13,7 +13,7 @@ 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/flow/GameFlow.tscn" config/features=PackedStringArray("4.7") [display] From d55321356323c78ef1dc7c72c0231714f9852240 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 05:16:32 -0500 Subject: [PATCH 6/8] refactor(m4c): rename GameFlow coordinator to Main, move to scenes/Main.tscn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry-point coordinator read as an internal name ("GameFlow" buried in scenes/flow/), so it wasn't obvious THIS is the scene that boots first — the title screen looked like the entry point. Renamed to the Godot-conventional Main (scenes/Main.tscn + scripts/main.gd) so the boot scene is self-evident. TitleScreen is unchanged and still shown BY Main as its first screen. Also drops the hand-written gd_scene uid (now matches sibling scenes) — closes the Minor review finding. Full suite 327/327 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/project.godot | 15 +-------------- client/scenes/Main.tscn | 6 ++++++ client/scenes/flow/GameFlow.tscn | 6 ------ client/scripts/{ui/flow/game_flow.gd => main.gd} | 4 ++-- client/scripts/main.gd.uid | 1 + client/scripts/ui/flow/game_flow.gd.uid | 1 - client/tests/unit/test_game_flow.gd.uid | 1 - .../unit/{test_game_flow.gd => test_main.gd} | 6 +++--- client/tests/unit/test_main.gd.uid | 1 + 9 files changed, 14 insertions(+), 27 deletions(-) create mode 100644 client/scenes/Main.tscn delete mode 100644 client/scenes/flow/GameFlow.tscn rename client/scripts/{ui/flow/game_flow.gd => main.gd} (96%) create mode 100644 client/scripts/main.gd.uid delete mode 100644 client/scripts/ui/flow/game_flow.gd.uid delete mode 100644 client/tests/unit/test_game_flow.gd.uid rename client/tests/unit/{test_game_flow.gd => test_main.gd} (92%) create mode 100644 client/tests/unit/test_main.gd.uid diff --git a/client/project.godot b/client/project.godot index e8846c6..5517100 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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/flow/GameFlow.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" diff --git a/client/scenes/Main.tscn b/client/scenes/Main.tscn new file mode 100644 index 0000000..bdb9e1d --- /dev/null +++ b/client/scenes/Main.tscn @@ -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") diff --git a/client/scenes/flow/GameFlow.tscn b/client/scenes/flow/GameFlow.tscn deleted file mode 100644 index e8ad548..0000000 --- a/client/scenes/flow/GameFlow.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[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") diff --git a/client/scripts/ui/flow/game_flow.gd b/client/scripts/main.gd similarity index 96% rename from client/scripts/ui/flow/game_flow.gd rename to client/scripts/main.gd index 9b162c9..27080c2 100644 --- a/client/scripts/ui/flow/game_flow.gd +++ b/client/scripts/main.gd @@ -1,4 +1,4 @@ -class_name GameFlow +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 @@ -88,6 +88,6 @@ func _start_campaign(creation: Dictionary) -> void: 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", []))) + push_error("Main: construct rejected a creation: %s" % str(result.get("errors", []))) return _show_shell(result["log"], result["state"]) diff --git a/client/scripts/main.gd.uid b/client/scripts/main.gd.uid new file mode 100644 index 0000000..f1c548d --- /dev/null +++ b/client/scripts/main.gd.uid @@ -0,0 +1 @@ +uid://du7rwup648n60 diff --git a/client/scripts/ui/flow/game_flow.gd.uid b/client/scripts/ui/flow/game_flow.gd.uid deleted file mode 100644 index 9016e6f..0000000 --- a/client/scripts/ui/flow/game_flow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cy0r08ct0jugt diff --git a/client/tests/unit/test_game_flow.gd.uid b/client/tests/unit/test_game_flow.gd.uid deleted file mode 100644 index b42254a..0000000 --- a/client/tests/unit/test_game_flow.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buyyn7tbjctb diff --git a/client/tests/unit/test_game_flow.gd b/client/tests/unit/test_main.gd similarity index 92% rename from client/tests/unit/test_game_flow.gd rename to client/tests/unit/test_main.gd index 9ef6db1..72846b6 100644 --- a/client/tests/unit/test_game_flow.gd +++ b/client/tests/unit/test_main.gd @@ -1,6 +1,6 @@ extends "res://addons/gut/test.gd" -const SCENE := "res://scenes/flow/GameFlow.tscn" +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") @@ -31,7 +31,7 @@ func _creation() -> Dictionary: func test_boots_into_the_title(): var flow := _flow() - assert_true(flow._current is TitleScreen, "GameFlow opens on the title") + assert_true(flow._current is TitleScreen, "Main opens on the title") func test_new_game_shows_creation(): @@ -53,7 +53,7 @@ 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. + # 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) diff --git a/client/tests/unit/test_main.gd.uid b/client/tests/unit/test_main.gd.uid new file mode 100644 index 0000000..a2dc6ce --- /dev/null +++ b/client/tests/unit/test_main.gd.uid @@ -0,0 +1 @@ +uid://17abdh3almry From 7f155b8de27baec03de6c1957aa6ea7476d9ecb1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 05:20:45 -0500 Subject: [PATCH 7/8] =?UTF-8?q?docs(roadmap):=20M4-c=20lands=20=E2=80=94?= =?UTF-8?q?=20Title=20->=20Creation=20->=20Shell=20flow;=20M4=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks M4-c done (the Main coordinator wires new game -> creation -> shell, the shell consumes the real created character, the saga fourth-path seam is kept addable) and flips M4 -> done / M5 -> next in the milestone table. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 626e5f5..a40ccf3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,8 +36,8 @@ 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. | -| **M5 — Tactical combat (gridless)** | Real stakes: the Combat HUD turn manager. | +| **M4 — Character creation** ✅ | The player enters the world; the proven stat/Luck model gets its UI. | +| **M5 — Tactical combat (gridless)** ◀ *here* | 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. | @@ -78,7 +78,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.*** From f5e6c59f2a1e2a5e843af50bd7f5a0097cb58602 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 05:27:04 -0500 Subject: [PATCH 8/8] docs(roadmap): add Ollama Cloud provider + backend switch as the next item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A laptop-dev enabler (planned, not specced): extend the proxy's M1 model-call pipeline with an Ollama Cloud backend reachable via API key, plus a switch to flip local-ollama <-> ollama-cloud, so AI works off the homelab. Reconciles against §4: the API key and provider choice stay server-side in the proxy (the client never holds a key, never picks the backend). The title Settings-button "config file" splits — the client may hold only the proxy base URL / profile (folds into M9); the provider/key switch is proxy config. Filed under a renamed "Landed / planned out of band" section and pointed to from the sequencing table as the next task before M5. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index a40ccf3..f596bd0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,17 +37,24 @@ M0–M2 built the **engine** (the contract, the client, the server loop, bounded | **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** ✅ | The player enters the world; the proven stat/Luck model gets its UI. | -| **M5 — Tactical combat (gridless)** ◀ *here* | Real stakes: the Combat HUD turn manager. | +| **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 `** 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.*