From 583d32456a771a9d774edc40dd3984de536333ec Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 15 Jul 2026 04:15:28 -0500 Subject: [PATCH] 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