feat(shell): NarrationBook wired to /dm/narrate with refire + fact-harvest
This commit is contained in:
6
client/scenes/shell/NarrationBook.tscn
Normal file
6
client/scenes/shell/NarrationBook.tscn
Normal file
@@ -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")
|
||||
106
client/scripts/ui/shell/narration_book.gd
Normal file
106
client/scripts/ui/shell/narration_book.gd
Normal file
@@ -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()
|
||||
1
client/scripts/ui/shell/narration_book.gd.uid
Normal file
1
client/scripts/ui/shell/narration_book.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://blmbc06xpea3x
|
||||
37
client/tests/unit/test_narration_book.gd
Normal file
37
client/tests/unit/test_narration_book.gd
Normal file
@@ -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)")
|
||||
1
client/tests/unit/test_narration_book.gd.uid
Normal file
1
client/tests/unit/test_narration_book.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dj6ly2ljf3ai4
|
||||
Reference in New Issue
Block a user