diff --git a/client/scenes/shell/NarrationBook.tscn b/client/scenes/shell/NarrationBook.tscn new file mode 100644 index 0000000..465a65b --- /dev/null +++ b/client/scenes/shell/NarrationBook.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"] + +[node name="NarrationBook" type="Control"] +script = ExtResource("1") diff --git a/client/scripts/ui/shell/narration_book.gd b/client/scripts/ui/shell/narration_book.gd new file mode 100644 index 0000000..d1a4626 --- /dev/null +++ b/client/scripts/ui/shell/narration_book.gd @@ -0,0 +1,106 @@ +class_name NarrationBook +extends Control +## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes +## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are +## written back to the canon log (§11). Prose-only this milestone: the numbered +## response choices and the "describe your own action" line are faithful but inert +## (no action system yet); one in-world refire affordance re-runs narration. + +var last_degraded: bool = false + +var _service: DmService +var _log: CanonLog +var _state: ShellState +var _header: Label +var _prose: RichTextLabel +var _status: Label +var _refire: Button +var _degraded_tag: Label +var _indicator: ConsideringIndicator + + +func _ready() -> void: + var col := VBoxContainer.new() + col.set_anchors_preset(Control.PRESET_FULL_RECT) + col.add_theme_constant_override("separation", 18) + add_child(col) + + _header = Label.new() + _header.theme_type_variation = ThemeKeys.ACCENT + col.add_child(_header) + + _prose = RichTextLabel.new() + _prose.bbcode_enabled = true + _prose.fit_content = true + _prose.size_flags_vertical = Control.SIZE_EXPAND_FILL + _prose.custom_minimum_size = Vector2(660, 320) + col.add_child(_prose) + + _degraded_tag = Label.new() + _degraded_tag.theme_type_variation = ThemeKeys.MONO + _degraded_tag.text = "THE MASTER'S VOICE COMES THIN" # shown only when degraded + _degraded_tag.visible = false + col.add_child(_degraded_tag) + + # Faithful-but-inert placeholders (charter: prose-only this milestone). + var prompt := Label.new() + prompt.theme_type_variation = ThemeKeys.ACCENT + prompt.text = "▸ Your turn. What do you do?" + col.add_child(prompt) + for line in [ + "1 · Pay the fence double and keep your hand where he can see it", + "2 · Draw your blade — he's closer than his guards", + "3 · Lie — tell him the rest is coming with your dwarf"]: + var choice := Button.new() + choice.theme_type_variation = ThemeKeys.PARCHMENT_BUTTON + choice.text = line + choice.disabled = true # inert until combat/dialogue give choices meaning + col.add_child(choice) + var free_text := Label.new() + free_text.theme_type_variation = ThemeKeys.MONO + free_text.text = "⌨ …or describe your own action" + col.add_child(free_text) + + # The one live control: refire the narration. + _refire = Button.new() + _refire.theme_type_variation = ThemeKeys.PARCHMENT_BUTTON + _refire.text = "▸ The Master continues…" + _refire.pressed.connect(_on_refire_pressed) + col.add_child(_refire) + + _status = Label.new() + _status.theme_type_variation = ThemeKeys.MONO + col.add_child(_status) + + _indicator = ConsideringIndicator.new() + add_child(_indicator) + + +func setup(service: DmService, log: CanonLog, state: ShellState) -> void: + _service = service + _log = log + _state = state + _header.text = "%s · %s" % [state.round_label, state.location_label] + + +func narrate() -> void: + if _service == null or _log == null: + return + _refire.disabled = true + _indicator.start(_status) + var result: NarrateResult = await _service.narrate(_log) + _indicator.stop() + show_prose(result) + for f in result.facts: + _log.add_fact(f) # §11: code applies the state write, explicitly + _refire.disabled = false + + +func show_prose(result: NarrateResult) -> void: + _prose.text = result.display_text + last_degraded = result.degraded + _degraded_tag.visible = result.degraded + + +func _on_refire_pressed() -> void: + narrate() diff --git a/client/scripts/ui/shell/narration_book.gd.uid b/client/scripts/ui/shell/narration_book.gd.uid new file mode 100644 index 0000000..5d65a8f --- /dev/null +++ b/client/scripts/ui/shell/narration_book.gd.uid @@ -0,0 +1 @@ +uid://blmbc06xpea3x diff --git a/client/tests/unit/test_narration_book.gd b/client/tests/unit/test_narration_book.gd new file mode 100644 index 0000000..5914a1f --- /dev/null +++ b/client/tests/unit/test_narration_book.gd @@ -0,0 +1,37 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/shell/NarrationBook.tscn" +const FakeTransport = preload("res://tests/doubles/fake_transport.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") + + +func _book_with(response) -> NarrationBook: + var transport = FakeTransport.new() + transport.set_response(response) + var service := DmService.new(transport, FallbackLibrary.new()) + var book = load(SCENE).instantiate() + add_child_autofree(book) # _ready() builds nodes + the considering indicator + book.setup(service, CanonLog.new(), ShellState.seed()) + return book + + +func test_narrate_shows_prose_and_harvests_facts(): + var book := _book_with(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + var log := CanonLog.new() + var transport = FakeTransport.new() + transport.set_response(DmResponse.ok(200, + {"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"})) + book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed()) + await book.narrate() + assert_string_contains(book._prose.text, "The rain runs black into the gutter.") + assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose") + assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log") + assert_false(book.last_degraded) + + +func test_degraded_response_shows_fallback_and_sets_flag(): + var book := _book_with(DmResponse.failed("proxy down")) + await book.narrate() + assert_true(book.last_degraded, "a failed call degrades") + assert_ne(book._prose.text, "", "the fallback line is still shown (§13)") diff --git a/client/tests/unit/test_narration_book.gd.uid b/client/tests/unit/test_narration_book.gd.uid new file mode 100644 index 0000000..bf30a1e --- /dev/null +++ b/client/tests/unit/test_narration_book.gd.uid @@ -0,0 +1 @@ +uid://dj6ly2ljf3ai4