feat(m4c): GameFlow coordinator wires title -> creation -> shell
This commit is contained in:
6
client/scenes/flow/GameFlow.tscn
Normal file
6
client/scenes/flow/GameFlow.tscn
Normal file
@@ -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")
|
||||
93
client/scripts/ui/flow/game_flow.gd
Normal file
93
client/scripts/ui/flow/game_flow.gd
Normal file
@@ -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"])
|
||||
1
client/scripts/ui/flow/game_flow.gd.uid
Normal file
1
client/scripts/ui/flow/game_flow.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cy0r08ct0jugt
|
||||
71
client/tests/unit/test_game_flow.gd
Normal file
71
client/tests/unit/test_game_flow.gd
Normal file
@@ -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")
|
||||
1
client/tests/unit/test_game_flow.gd.uid
Normal file
1
client/tests/unit/test_game_flow.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://buyyn7tbjctb
|
||||
Reference in New Issue
Block a user