From 4365871846a15254c41f84d8cf4e924a957e3267 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:31:13 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat(ui):=20monologue=20display=20=E2=80=94?= =?UTF-8?q?=20queue,=20character=20colours,=20italic=20BBCode=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Queue management: enqueue on mid-display arrival, drain in order, MAX_QUEUE_DEPTH=8 cap, no-overwrite contract (P0 #477) - Character colours: detective=#c8e0ff (cool blue), smuggler=#f0c870 (warm amber), fallback to neutral insert text - Typography: italic via BBCode [i] tags; font size 13px (smaller than dialogue) - Positioning: bottom-left of viewport, 420×120px, 80px bottom margin above verb list - main.gd: _get_active_character_type() reads player entity kind.data.character_type; passed to show_monologue() and _on_confrontation_monologue() - gdUnit4 tests: queue order, depth cap, no-overwrite, BBCode output, colour mapping, fade timer integration Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/main.gd | 24 ++- client/tests/test_monologue_display.gd | 253 +++++++++++++++++++++++++ client/ui/monologue_display.gd | 91 ++++++--- client/ui/monologue_display.tscn | 33 ++-- 4 files changed, 360 insertions(+), 41 deletions(-) create mode 100644 client/tests/test_monologue_display.gd diff --git a/client/scripts/main.gd b/client/scripts/main.gd index c7ab84c59..b9c7c95c0 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -288,7 +288,11 @@ func _consume_monologue() -> void: return _last_monologue_tick = GameState.current_tick var mono: Dictionary = GameState.current_monologue - monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) + monologue_display.show_monologue( + mono.get("text", ""), + mono.get("duration_seconds", 5.0), + _get_active_character_type() + ) # #502: Amber flash on room reset var mono_id: String = mono.get("id", "") if mono_id.begins_with("room_reset"): @@ -296,6 +300,22 @@ func _consume_monologue() -> void: GameState.current_monologue = null +# Derive the active character type from the player entity kind data. +# Server populates kind.data.character_type ("detective" or "smuggler") per D-032. +# Returns "" when the field is absent (display falls back to neutral colour). +func _get_active_character_type() -> String: + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + var kind = entity.get("kind", {}) + if not kind is Dictionary or kind.get("variant") != "Player": + continue + var data = kind.get("data", {}) + if data is Dictionary: + return data.get("character_type", "") + return "" + + # Consume-once per tick with ID tracking: show dialogue, then clear. # Tick guard + is_dialogue_active check prevent re-triggering. func _consume_dialogue() -> void: @@ -333,7 +353,7 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void: # D-063: Handle confrontation beat monologue → show on monologue display (layer 7) func _on_confrontation_monologue(text: String, duration: float) -> void: if monologue_display: - monologue_display.show_monologue(text, duration) + monologue_display.show_monologue(text, duration, _get_active_character_type()) # D-064: Handle walk-away → send WalkAway{npc_id} to server diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd new file mode 100644 index 000000000..75eb93056 --- /dev/null +++ b/client/tests/test_monologue_display.gd @@ -0,0 +1,253 @@ +## #122: Monologue display — client tests (Sprint 14) +## Tests queue management, character colour, italic BBCode, and no-overwrite contract. +## Spec: Sprint 14 briefing (D-016, D-032, D-055, P0 #477). +## +## Approach: instantiate the scene, drive show_monologue() directly, inspect internal +## state and label text. Fade timing is tested by manipulating _fade_timer and calling +## _process() rather than awaiting real time — keeps the suite fast and deterministic. +class_name TestMonologueDisplay +extends GdUnitTestSuite + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +func _make_display() -> Node: + if not ResourceLoader.exists("res://ui/monologue_display.tscn"): + push_warning("TestMonologueDisplay: scene not found — tests skipped") + return null + var scene = load("res://ui/monologue_display.tscn") + var node = scene.instantiate() + add_child(node) + # _ready() fires here; panel.modulate.a = 0, _displaying = false + return node + + +# --------------------------------------------------------------------------- +# Colour mapping +# --------------------------------------------------------------------------- + +func test_detective_colour_is_cool_blue() -> void: + var d = _make_display() + if d == null: + return + var c: Color = d._color_for_character("detective") + # Must not be the default/neutral colour + assert_that(c).is_not_equal(d.COLOR_DEFAULT) + # Blue channel dominant + assert_float(c.b).is_greater(c.r) + d.queue_free() + + +func test_smuggler_colour_is_warm_amber() -> void: + var d = _make_display() + if d == null: + return + var c: Color = d._color_for_character("smuggler") + assert_that(c).is_not_equal(d.COLOR_DEFAULT) + # Red channel dominant (amber) + assert_float(c.r).is_greater(c.b) + d.queue_free() + + +func test_unknown_character_returns_default_colour() -> void: + var d = _make_display() + if d == null: + return + assert_that(d._color_for_character("")).is_equal(d.COLOR_DEFAULT) + assert_that(d._color_for_character("merchant")).is_equal(d.COLOR_DEFAULT) + d.queue_free() + + +# --------------------------------------------------------------------------- +# BBCode output — italic + colour tags +# --------------------------------------------------------------------------- + +func test_show_monologue_wraps_text_in_italic_bbcode() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("Test line.", 5.0, "") + var txt: String = d.text_label.text + assert_that(txt).contains("[i]") + assert_that(txt).contains("[/i]") + assert_that(txt).contains("Test line.") + d.queue_free() + + +func test_show_monologue_includes_color_tag() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("Colour test.", 5.0, "detective") + var txt: String = d.text_label.text + assert_that(txt).contains("[color=#") + assert_that(txt).contains("[/color]") + d.queue_free() + + +# --------------------------------------------------------------------------- +# No-overwrite contract (P0, #477) +# --------------------------------------------------------------------------- + +func test_second_call_does_not_overwrite_active_display() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("First line.", 10.0, "") + assert_that(d._displaying).is_true() + var first_text: String = d.text_label.text + + d.show_monologue("Second line.", 5.0, "") + # Text label must still show the first line + assert_that(d.text_label.text).is_equal(first_text) + d.queue_free() + + +func test_second_call_while_active_is_queued() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("First.", 10.0, "") + d.show_monologue("Second.", 5.0, "") + assert_int(d._queue.size()).is_equal(1) + assert_that(d._queue[0].text).is_equal("Second.") + d.queue_free() + + +# --------------------------------------------------------------------------- +# Queue management +# --------------------------------------------------------------------------- + +func test_queue_drains_after_fade_complete() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("Line A.", 1.0, "") + d.show_monologue("Line B.", 2.0, "") + + # Simulate fade completing on line A + d._displaying = false + d._on_fade_complete() + + assert_that(d._displaying).is_true() + assert_that(d.text_label.text).contains("Line B.") + assert_int(d._queue.size()).is_equal(0) + d.queue_free() + + +func test_queue_empty_after_fade_complete_does_nothing() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("Only line.", 1.0, "") + d._displaying = false + + # No items queued — should not crash and _displaying stays false + d._on_fade_complete() + assert_that(d._displaying).is_false() + d.queue_free() + + +func test_queue_preserves_character_type() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("First.", 5.0, "detective") + d.show_monologue("Second.", 3.0, "smuggler") + + assert_that(d._queue[0].character_type).is_equal("smuggler") + d.queue_free() + + +func test_multiple_queued_items_drain_in_order() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("A", 1.0, "") + d.show_monologue("B", 1.0, "") + d.show_monologue("C", 1.0, "") + + d._displaying = false + d._on_fade_complete() # should display B + assert_that(d.text_label.text).contains("B") + + d._displaying = false + d._on_fade_complete() # should display C + assert_that(d.text_label.text).contains("C") + + d._displaying = false + d._on_fade_complete() # queue empty — nothing new + assert_that(d._displaying).is_false() + d.queue_free() + + +# --------------------------------------------------------------------------- +# Queue depth cap +# --------------------------------------------------------------------------- + +func test_queue_does_not_exceed_max_depth() -> void: + var d = _make_display() + if d == null: + return + # First call starts displaying immediately (not queued) + d.show_monologue("Active.", 99.0, "") + + # Fill queue to max + for i in range(d.MAX_QUEUE_DEPTH + 5): + d.show_monologue("Overflow %d" % i, 1.0, "") + + assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE_DEPTH) + d.queue_free() + + +# --------------------------------------------------------------------------- +# Fade timer integration +# --------------------------------------------------------------------------- + +func test_process_triggers_fade_after_duration() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("Timer test.", 2.0, "") + assert_that(d._displaying).is_true() + + # Drive timer past duration without awaiting real time + d._fade_timer = 2.1 + d._process(0.0) + + # _displaying should now be false (fade_out called) + assert_that(d._displaying).is_false() + d.queue_free() + + +func test_process_does_not_fade_before_duration() -> void: + var d = _make_display() + if d == null: + return + d.show_monologue("Still showing.", 5.0, "") + d._fade_timer = 2.0 + d._process(0.0) + assert_that(d._displaying).is_true() + d.queue_free() + + +# --------------------------------------------------------------------------- +# Initial state +# --------------------------------------------------------------------------- + +func test_panel_starts_invisible() -> void: + var d = _make_display() + if d == null: + return + assert_float(d.text_panel.modulate.a).is_equal(0.0) + d.queue_free() + + +func test_not_displaying_on_init() -> void: + var d = _make_display() + if d == null: + return + assert_that(d._displaying).is_false() + d.queue_free() diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index 860c6d58a..047046bdf 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -1,48 +1,89 @@ extends Control -# Internal monologue display (per D-015) -# Shows character's internal thoughts as text overlay +# Internal monologue display (per D-016) +# Queue-managed text overlay — bottom-left of viewport, italic, character-coloured. +# Spec: Sprint 14 briefing (D-032, D-055). +# +# Queue contract (P0, #477): +# - Never overwrites a mid-display line. +# - New arrivals queue up to MAX_QUEUE_DEPTH; deeper arrivals are silently dropped. +# - When the current line fades out, the next queued line starts immediately. + +const MAX_QUEUE_DEPTH: int = 8 + +# Character text colours (D-048 insert palette, Sprint 14 briefing) +const COLOR_DETECTIVE: Color = Color("#c8e0ff") # Cool blue-white — analytical +const COLOR_SMUGGLER: Color = Color("#f0c870") # Warm amber — street-smart +const COLOR_DEFAULT: Color = Color("#c8d0e0") # Neutral fallback (insert text) @onready var text_panel: PanelContainer = $PanelContainer -@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel +@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel -var fade_timer: float = 0.0 -var fade_duration: float = 5.0 # Display duration before fade -var is_visible: bool = false +var _queue: Array[Dictionary] = [] # {text, duration, character_type} +var _displaying: bool = false +var _fade_timer: float = 0.0 +var _current_duration: float = 0.0 var _active_tween: Tween = null + func _ready() -> void: - print("MonologueDisplay: Initialized") text_panel.modulate.a = 0.0 - is_visible = false + _displaying = false + func _process(delta: float) -> void: - # Auto-fade after display - if is_visible: - fade_timer += delta - if fade_timer >= fade_duration: - _fade_out() + if not _displaying: + return + _fade_timer += delta + if _fade_timer >= _current_duration: + _fade_out() -# Show internal monologue text -func show_monologue(text: String, duration: float = 5.0) -> void: - text_label.text = text - fade_duration = duration - fade_timer = 0.0 - is_visible = true - # Cancel any active tween before starting a new one +# Show a monologue line. If a line is already displaying, enqueue it instead. +# character_type: "detective", "smuggler", or "" (default colour). +func show_monologue(text: String, duration: float = 5.0, character_type: String = "") -> void: + if _displaying: + if _queue.size() < MAX_QUEUE_DEPTH: + _queue.append({text = text, duration = duration, character_type = character_type}) + return + _display(text, duration, character_type) + + +func _display(text: String, duration: float, character_type: String) -> void: + _displaying = true + _fade_timer = 0.0 + _current_duration = duration + + var color_hex: String = _color_for_character(character_type).to_html(false) + text_label.text = "[i][color=#%s]%s[/color][/i]" % [color_hex, text] + if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(text_panel, "modulate:a", 1.0, 0.3) -# Fade out the monologue -func _fade_out() -> void: - if not is_visible: - return - is_visible = false +func _fade_out() -> void: + if not _displaying: + return + _displaying = false + if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(text_panel, "modulate:a", 0.0, 0.5) + _active_tween.tween_callback(_on_fade_complete) + + +func _on_fade_complete() -> void: + if _queue.is_empty(): + return + var next: Dictionary = _queue.pop_front() + _display(next.text, next.duration, next.character_type) + + +func _color_for_character(character_type: String) -> Color: + match character_type: + "detective": return COLOR_DETECTIVE + "smuggler": return COLOR_SMUGGLER + _: return COLOR_DEFAULT diff --git a/client/ui/monologue_display.tscn b/client/ui/monologue_display.tscn index 10dd02758..850407cca 100644 --- a/client/ui/monologue_display.tscn +++ b/client/ui/monologue_display.tscn @@ -2,37 +2,42 @@ [ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"] +; Bottom-left of viewport, 420px wide, up to 120px tall. +; 80px bottom margin reserves space above the interaction verb list (InsertOverlay). +; Positioned in UILayer (CanvasLayer 20, D-049). [node name="MonologueDisplay" type="Control"] layout_mode = 3 -anchors_preset = 12 +anchors_preset = 2 +anchor_left = 0.0 anchor_top = 1.0 -anchor_right = 1.0 +anchor_right = 0.0 anchor_bottom = 1.0 -offset_top = -150.0 -grow_horizontal = 2 +offset_left = 16.0 +offset_top = -200.0 +offset_right = 436.0 +offset_bottom = -80.0 +grow_horizontal = 1 grow_vertical = 0 mouse_filter = 2 script = ExtResource("1_monologue") [node name="PanelContainer" type="PanelContainer" parent="."] layout_mode = 1 -anchors_preset = 10 +anchors_preset = 15 anchor_right = 1.0 -offset_left = 100.0 -offset_right = -100.0 -offset_bottom = 120.0 -grow_horizontal = 2 +anchor_bottom = 1.0 [node name="MarginContainer" type="MarginContainer" parent="PanelContainer"] layout_mode = 2 -theme_override_constants/margin_left = 16 -theme_override_constants/margin_top = 12 -theme_override_constants/margin_right = 16 -theme_override_constants/margin_bottom = 12 +theme_override_constants/margin_left = 12 +theme_override_constants/margin_top = 8 +theme_override_constants/margin_right = 12 +theme_override_constants/margin_bottom = 8 [node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"] layout_mode = 2 bbcode_enabled = true -text = "Internal monologue will appear here..." +text = "" fit_content = true scroll_active = false +theme_override_font_sizes/normal_font_size = 13 From 1765e2922f53fdc7373d1bb323bb215ebe1cf710 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:39:49 +0100 Subject: [PATCH 2/5] =?UTF-8?q?refactor(ui):=20monologue=20display=20?= =?UTF-8?q?=E2=80=94=20multi-line=20architecture=20per=20Tyre=20review=20(?= =?UTF-8?q?#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the monologue display system per Tyre architecture review (Sprint 14): Rendering: - Up to 3 simultaneous visible lines (VBoxContainer, dynamic node creation) - Lines created programmatically as MarginContainer > RichTextLabel per slot - Percentage-based anchors: 5% left, 75–98% vertical (25% area from bottom), 50% max width - Z-layer 7 in UILayer (CanvasLayer 20, D-049) Queue: - 5-entry priority queue; highest priority drains first - On overflow: incoming line replaces lowest-priority queued entry if it outranks it - Lower/equal priority incoming lines silently dropped when queue full API: show_monologue(text, duration, priority=2, is_urgent=false) - Replaces old (text, duration, character_type) signature - main.gd passes priority and is_urgent from MonologueEvent fields - Confrontation monologue: priority=3, is_urgent=true (D-063) Colour: - Reads GameState.lattice_profile at render time (D-032) - lattice_augmented (detective): standard #d0d4e0 / urgent #e0e8f8 - lattice_baseline (smuggler): standard #d8d0c4 / urgent #f0e4d4 - Fallback for unknown profiles; no crash Stagger: 0.15s between consecutive fade-ins (spec §5.4) Opacity: standard 0.85, urgent 1.0; bloom deferred GameState: adds lattice_profile field, parsed from snapshot Tests: 27 gdUnit4 test cases — queue order, priority drop, overflow, stagger, no-overwrite (P0 #477), BBCode output, palette selection, slot lifecycle Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/autoloads/game_state.gd | 11 +- client/scripts/main.gd | 21 +- client/tests/test_monologue_display.gd | 419 +++++++++++++++---------- client/ui/monologue_display.gd | 192 +++++++---- client/ui/monologue_display.tscn | 40 +-- 5 files changed, 406 insertions(+), 277 deletions(-) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 19187264f..15120aafd 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -23,7 +23,12 @@ var player_entity_id: int = 1 var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}] # v5 fields (#414) -var current_monologue: Variant = null # {id, text, duration_seconds} or null +var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null + +# #122 (D-032): Character lattice profile — selects monologue text colour palette. +# "lattice_augmented" = detective, "lattice_baseline" = smuggler. +# Server sends this field as part of the player's capability snapshot. +var lattice_profile: String = "lattice_baseline" # v6 fields (#449, D-053, D-065) var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch @@ -138,6 +143,10 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: current_monologue = null + # #122: lattice_profile — character insert capability level for monologue colour + if snapshot.has("lattice_profile") and snapshot.lattice_profile is String: + lattice_profile = snapshot.lattice_profile + # v6: player_stance (#449, D-053) if snapshot.has("player_stance") and snapshot.player_stance is String: player_stance = snapshot.player_stance diff --git a/client/scripts/main.gd b/client/scripts/main.gd index b9c7c95c0..56514a1e7 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -291,7 +291,8 @@ func _consume_monologue() -> void: monologue_display.show_monologue( mono.get("text", ""), mono.get("duration_seconds", 5.0), - _get_active_character_type() + mono.get("priority", 2), + mono.get("is_urgent", false) ) # #502: Amber flash on room reset var mono_id: String = mono.get("id", "") @@ -300,21 +301,6 @@ func _consume_monologue() -> void: GameState.current_monologue = null -# Derive the active character type from the player entity kind data. -# Server populates kind.data.character_type ("detective" or "smuggler") per D-032. -# Returns "" when the field is absent (display falls back to neutral colour). -func _get_active_character_type() -> String: - for entity in GameState.visible_entities: - if not entity is Dictionary: - continue - var kind = entity.get("kind", {}) - if not kind is Dictionary or kind.get("variant") != "Player": - continue - var data = kind.get("data", {}) - if data is Dictionary: - return data.get("character_type", "") - return "" - # Consume-once per tick with ID tracking: show dialogue, then clear. # Tick guard + is_dialogue_active check prevent re-triggering. @@ -351,9 +337,10 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void: # D-063: Handle confrontation beat monologue → show on monologue display (layer 7) +# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour. func _on_confrontation_monologue(text: String, duration: float) -> void: if monologue_display: - monologue_display.show_monologue(text, duration, _get_active_character_type()) + monologue_display.show_monologue(text, duration, 3, true) # D-064: Handle walk-away → send WalkAway{npc_id} to server diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd index 75eb93056..88f4047c6 100644 --- a/client/tests/test_monologue_display.gd +++ b/client/tests/test_monologue_display.gd @@ -1,10 +1,10 @@ ## #122: Monologue display — client tests (Sprint 14) -## Tests queue management, character colour, italic BBCode, and no-overwrite contract. -## Spec: Sprint 14 briefing (D-016, D-032, D-055, P0 #477). +## Covers queue management, priority logic, stagger, colour palette, BBCode output, +## no-overwrite contract (P0 #477), and per-slot fade lifecycle. ## -## Approach: instantiate the scene, drive show_monologue() directly, inspect internal -## state and label text. Fade timing is tested by manipulating _fade_timer and calling -## _process() rather than awaiting real time — keeps the suite fast and deterministic. +## API per Tyre architecture review: +## show_monologue(text, duration, priority=2, is_urgent=false) +## GameState.lattice_profile selects colour palette class_name TestMonologueDisplay extends GdUnitTestSuite @@ -17,73 +17,81 @@ func _make_display() -> Node: if not ResourceLoader.exists("res://ui/monologue_display.tscn"): push_warning("TestMonologueDisplay: scene not found — tests skipped") return null - var scene = load("res://ui/monologue_display.tscn") - var node = scene.instantiate() + var node = load("res://ui/monologue_display.tscn").instantiate() add_child(node) - # _ready() fires here; panel.modulate.a = 0, _displaying = false return node +func _label_text(d: Node) -> String: + var slot_node: Node = d._visible[0].node + return (slot_node.get_child(0) as RichTextLabel).text + + # --------------------------------------------------------------------------- -# Colour mapping +# Initial state # --------------------------------------------------------------------------- -func test_detective_colour_is_cool_blue() -> void: +func test_nothing_visible_on_init() -> void: var d = _make_display() - if d == null: - return - var c: Color = d._color_for_character("detective") - # Must not be the default/neutral colour - assert_that(c).is_not_equal(d.COLOR_DEFAULT) - # Blue channel dominant - assert_float(c.b).is_greater(c.r) + if d == null: return + assert_int(d._visible.size()).is_equal(0) + assert_int(d._queue.size()).is_equal(0) d.queue_free() -func test_smuggler_colour_is_warm_amber() -> void: +func test_stagger_timer_zero_on_init() -> void: var d = _make_display() - if d == null: - return - var c: Color = d._color_for_character("smuggler") - assert_that(c).is_not_equal(d.COLOR_DEFAULT) - # Red channel dominant (amber) - assert_float(c.r).is_greater(c.b) - d.queue_free() - - -func test_unknown_character_returns_default_colour() -> void: - var d = _make_display() - if d == null: - return - assert_that(d._color_for_character("")).is_equal(d.COLOR_DEFAULT) - assert_that(d._color_for_character("merchant")).is_equal(d.COLOR_DEFAULT) + if d == null: return + assert_float(d._next_fade_in_msec).is_equal(0.0) d.queue_free() # --------------------------------------------------------------------------- -# BBCode output — italic + colour tags +# Single-line display # --------------------------------------------------------------------------- -func test_show_monologue_wraps_text_in_italic_bbcode() -> void: +func test_single_line_goes_to_visible() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("Test line.", 5.0, "") - var txt: String = d.text_label.text - assert_that(txt).contains("[i]") - assert_that(txt).contains("[/i]") - assert_that(txt).contains("Test line.") + if d == null: return + d.show_monologue("One.", 5.0) + assert_int(d._visible.size()).is_equal(1) + assert_int(d._queue.size()).is_equal(0) d.queue_free() -func test_show_monologue_includes_color_tag() -> void: +func test_show_monologue_sets_stagger_timer() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("Colour test.", 5.0, "detective") - var txt: String = d.text_label.text - assert_that(txt).contains("[color=#") - assert_that(txt).contains("[/color]") + if d == null: return + var before := float(Time.get_ticks_msec()) + d.show_monologue("Stagger.", 5.0) + assert_float(d._next_fade_in_msec).is_greater(before) + d.queue_free() + + +# --------------------------------------------------------------------------- +# MAX_VISIBLE = 3 simultaneous lines +# --------------------------------------------------------------------------- + +func test_three_lines_all_visible() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + assert_int(d._visible.size()).is_equal(3) + assert_int(d._queue.size()).is_equal(0) + d.queue_free() + + +func test_fourth_line_queues_when_slots_full() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + d.show_monologue("D", 5.0) + assert_int(d._visible.size()).is_equal(3) + assert_int(d._queue.size()).is_equal(1) d.queue_free() @@ -91,163 +99,244 @@ func test_show_monologue_includes_color_tag() -> void: # No-overwrite contract (P0, #477) # --------------------------------------------------------------------------- -func test_second_call_does_not_overwrite_active_display() -> void: +func test_new_line_does_not_replace_first_visible_line() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("First line.", 10.0, "") - assert_that(d._displaying).is_true() - var first_text: String = d.text_label.text - - d.show_monologue("Second line.", 5.0, "") - # Text label must still show the first line - assert_that(d.text_label.text).is_equal(first_text) + if d == null: return + d.show_monologue("First line.", 10.0) + var first_text := _label_text(d) + # Force stagger active; second call must queue, not display + d._next_fade_in_msec = float(Time.get_ticks_msec()) + 10000.0 + d.show_monologue("Second line.", 5.0) + assert_that(_label_text(d)).is_equal(first_text) d.queue_free() -func test_second_call_while_active_is_queued() -> void: +func test_two_visible_lines_coexist_without_overwriting() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("First.", 10.0, "") - d.show_monologue("Second.", 5.0, "") + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("Alpha", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("Beta", 10.0) + assert_int(d._visible.size()).is_equal(2) + d.queue_free() + + +# --------------------------------------------------------------------------- +# Priority queue +# --------------------------------------------------------------------------- + +func test_queue_sorted_highest_priority_first() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + d.show_monologue("low", 5.0, 1) + d.show_monologue("high", 5.0, 4) + d.show_monologue("normal", 5.0, 2) + assert_int(d._queue[0].priority).is_equal(4) + d.queue_free() + + +func test_queue_drop_replaces_lowest_when_full() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + for i in range(d.MAX_QUEUE): + d.show_monologue("low_%d" % i, 5.0, 1) + d.show_monologue("critical!", 5.0, 9) + assert_int(d._queue.size()).is_equal(d.MAX_QUEUE) + var has_critical := false + for e in d._queue: + if e.priority == 9: + has_critical = true + assert_that(has_critical).is_true() + d.queue_free() + + +func test_queue_ignores_lower_priority_when_full() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + for i in range(d.MAX_QUEUE): + d.show_monologue("hi_%d" % i, 5.0, 5) + d.show_monologue("noise", 1.0, 1) + assert_int(d._queue.size()).is_equal(d.MAX_QUEUE) + for e in d._queue: + assert_int(e.priority).is_equal(5) + d.queue_free() + + +func test_queue_never_exceeds_max_depth() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + for i in range(d.MAX_QUEUE + 20): + d.show_monologue("flood_%d" % i, 1.0, 2) + assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE) + d.queue_free() + + +# --------------------------------------------------------------------------- +# Expire and drain +# --------------------------------------------------------------------------- + +func test_expired_slot_removed_from_visible() -> void: + var d = _make_display() + if d == null: return + d.show_monologue("Expires.", 1.0) + d._visible[0].expire_timer = -0.1 + d._process(0.0) + assert_int(d._visible.size()).is_equal(0) + d.queue_free() + + +func test_queue_drains_when_slot_opens() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + d.show_monologue("Queued.", 5.0) + d._visible[0].expire_timer = -0.1 + d._next_fade_in_msec = 0.0 # stagger elapsed + d._process(0.0) + assert_int(d._queue.size()).is_equal(0) + assert_int(d._visible.size()).is_equal(3) + d.queue_free() + + +# --------------------------------------------------------------------------- +# Stagger +# --------------------------------------------------------------------------- + +func test_second_call_within_stagger_period_queues() -> void: + var d = _make_display() + if d == null: return + d.show_monologue("First.", 5.0) + # _next_fade_in_msec is now ~150ms in the future + d.show_monologue("Second.", 5.0) + assert_int(d._visible.size()).is_equal(1) assert_int(d._queue.size()).is_equal(1) - assert_that(d._queue[0].text).is_equal("Second.") d.queue_free() -# --------------------------------------------------------------------------- -# Queue management -# --------------------------------------------------------------------------- - -func test_queue_drains_after_fade_complete() -> void: +func test_stagger_elapsed_allows_second_visible() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("Line A.", 1.0, "") - d.show_monologue("Line B.", 2.0, "") - - # Simulate fade completing on line A - d._displaying = false - d._on_fade_complete() - - assert_that(d._displaying).is_true() - assert_that(d.text_label.text).contains("Line B.") + if d == null: return + d.show_monologue("First.", 5.0) + d._next_fade_in_msec = 0.0 # manually expire stagger + d.show_monologue("Second.", 5.0) + assert_int(d._visible.size()).is_equal(2) assert_int(d._queue.size()).is_equal(0) d.queue_free() -func test_queue_empty_after_fade_complete_does_nothing() -> void: - var d = _make_display() - if d == null: - return - d.show_monologue("Only line.", 1.0, "") - d._displaying = false +# --------------------------------------------------------------------------- +# BBCode output +# --------------------------------------------------------------------------- - # No items queued — should not crash and _displaying stays false - d._on_fade_complete() - assert_that(d._displaying).is_false() +func test_text_wrapped_in_italic_bbcode() -> void: + var d = _make_display() + if d == null: return + d.show_monologue("Italic line.", 5.0) + var txt := _label_text(d) + assert_that(txt).contains("[i]") + assert_that(txt).contains("[/i]") + assert_that(txt).contains("Italic line.") d.queue_free() -func test_queue_preserves_character_type() -> void: +func test_text_has_color_bbcode() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("First.", 5.0, "detective") - d.show_monologue("Second.", 3.0, "smuggler") - - assert_that(d._queue[0].character_type).is_equal("smuggler") - d.queue_free() - - -func test_multiple_queued_items_drain_in_order() -> void: - var d = _make_display() - if d == null: - return - d.show_monologue("A", 1.0, "") - d.show_monologue("B", 1.0, "") - d.show_monologue("C", 1.0, "") - - d._displaying = false - d._on_fade_complete() # should display B - assert_that(d.text_label.text).contains("B") - - d._displaying = false - d._on_fade_complete() # should display C - assert_that(d.text_label.text).contains("C") - - d._displaying = false - d._on_fade_complete() # queue empty — nothing new - assert_that(d._displaying).is_false() + if d == null: return + d.show_monologue("Coloured.", 5.0) + var txt := _label_text(d) + assert_that(txt).contains("[color=#") + assert_that(txt).contains("[/color]") d.queue_free() # --------------------------------------------------------------------------- -# Queue depth cap +# Lattice colour palette # --------------------------------------------------------------------------- -func test_queue_does_not_exceed_max_depth() -> void: +func test_augmented_colour_differs_from_baseline() -> void: var d = _make_display() - if d == null: - return - # First call starts displaying immediately (not queued) - d.show_monologue("Active.", 99.0, "") + if d == null: return - # Fill queue to max - for i in range(d.MAX_QUEUE_DEPTH + 5): - d.show_monologue("Overflow %d" % i, 1.0, "") + GameState.lattice_profile = "lattice_augmented" + d.show_monologue("Detective.", 5.0) + var aug_txt := _label_text(d) + d._visible[0].expire_timer = -0.1; d._process(0.0) + d._next_fade_in_msec = 0.0 - assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE_DEPTH) + GameState.lattice_profile = "lattice_baseline" + d.show_monologue("Smuggler.", 5.0) + var base_txt := _label_text(d) + + assert_that(aug_txt).is_not_equal(base_txt) + GameState.lattice_profile = "lattice_baseline" + d.queue_free() + + +func test_urgent_colour_differs_from_standard() -> void: + var d = _make_display() + if d == null: return + + GameState.lattice_profile = "lattice_baseline" + d.show_monologue("Normal.", 5.0, 2, false) + var std_txt := _label_text(d) + d._visible[0].expire_timer = -0.1; d._process(0.0) + d._next_fade_in_msec = 0.0 + + d.show_monologue("Urgent!", 5.0, 3, true) + var urg_txt := _label_text(d) + + assert_that(std_txt).is_not_equal(urg_txt) + d.queue_free() + + +func test_unknown_profile_falls_back_without_crash() -> void: + var d = _make_display() + if d == null: return + GameState.lattice_profile = "lattice_hypothetical_tier_x" + d.show_monologue("Future proof.", 5.0) + var txt := _label_text(d) + assert_that(txt).contains("[color=#") # fallback colour applied, no crash + GameState.lattice_profile = "lattice_baseline" d.queue_free() # --------------------------------------------------------------------------- -# Fade timer integration +# Slot lifecycle # --------------------------------------------------------------------------- -func test_process_triggers_fade_after_duration() -> void: +func test_visible_slot_has_tween() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("Timer test.", 2.0, "") - assert_that(d._displaying).is_true() - - # Drive timer past duration without awaiting real time - d._fade_timer = 2.1 - d._process(0.0) - - # _displaying should now be false (fade_out called) - assert_that(d._displaying).is_false() + if d == null: return + d.show_monologue("Has tween.", 5.0) + assert_that(d._visible[0].tween).is_not_null() d.queue_free() -func test_process_does_not_fade_before_duration() -> void: +func test_visible_slot_stores_priority() -> void: var d = _make_display() - if d == null: - return - d.show_monologue("Still showing.", 5.0, "") - d._fade_timer = 2.0 - d._process(0.0) - assert_that(d._displaying).is_true() + if d == null: return + d.show_monologue("Priority 7.", 5.0, 7) + assert_int(d._visible[0].priority).is_equal(7) d.queue_free() -# --------------------------------------------------------------------------- -# Initial state -# --------------------------------------------------------------------------- - -func test_panel_starts_invisible() -> void: +func test_expire_timer_decrements_in_process() -> void: var d = _make_display() - if d == null: - return - assert_float(d.text_panel.modulate.a).is_equal(0.0) - d.queue_free() - - -func test_not_displaying_on_init() -> void: - var d = _make_display() - if d == null: - return - assert_that(d._displaying).is_false() + if d == null: return + d.show_monologue("Timer.", 10.0) + d._process(1.5) + assert_float(d._visible[0].expire_timer).is_less(10.0) d.queue_free() diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index 047046bdf..b68419c07 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -1,89 +1,153 @@ extends Control -# Internal monologue display (per D-016) -# Queue-managed text overlay — bottom-left of viewport, italic, character-coloured. -# Spec: Sprint 14 briefing (D-032, D-055). +# Internal monologue display — multi-line, priority-queued (per D-016, #122). +# Per Tyre architecture review, Sprint 14. # -# Queue contract (P0, #477): -# - Never overwrites a mid-display line. -# - New arrivals queue up to MAX_QUEUE_DEPTH; deeper arrivals are silently dropped. -# - When the current line fades out, the next queued line starts immediately. +# Up to MAX_VISIBLE lines display simultaneously in a VBoxContainer. +# Additional arrivals queue up to MAX_QUEUE depth; lowest-priority entry is +# dropped when the queue is full and a higher-priority line arrives. +# +# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4). +# Colour: derived from GameState.lattice_profile at render time (D-032). +# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred). -const MAX_QUEUE_DEPTH: int = 8 +const MAX_VISIBLE: int = 3 +const MAX_QUEUE: int = 5 -# Character text colours (D-048 insert palette, Sprint 14 briefing) -const COLOR_DETECTIVE: Color = Color("#c8e0ff") # Cool blue-white — analytical -const COLOR_SMUGGLER: Color = Color("#f0c870") # Warm amber — street-smart -const COLOR_DEFAULT: Color = Color("#c8d0e0") # Neutral fallback (insert text) +const STAGGER_SEC: float = 0.15 +const FADE_IN_SEC: float = 0.3 +const FADE_OUT_SEC: float = 0.5 -@onready var text_panel: PanelContainer = $PanelContainer -@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel +# Lattice colour palette — keyed by GameState.lattice_profile. +# standard opacity = 0.85, urgent opacity = 1.0. +# Source: Tyre architecture review, Sprint 14. +const _LATTICE_COLORS: Dictionary = { + "lattice_augmented": { # detective + "standard": Color("#d0d4e0"), + "urgent": Color("#e0e8f8"), + }, + "lattice_baseline": { # smuggler + "standard": Color("#d8d0c4"), + "urgent": Color("#f0e4d4"), + }, +} +const _FALLBACK_STANDARD: Color = Color("#c8d0e0") +const _FALLBACK_URGENT: Color = Color("#e0e8f8") -var _queue: Array[Dictionary] = [] # {text, duration, character_type} -var _displaying: bool = false -var _fade_timer: float = 0.0 -var _current_duration: float = 0.0 -var _active_tween: Tween = null +@onready var _vbox: VBoxContainer = $VBoxContainer + +# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween} +var _visible: Array[Dictionary] = [] +# Queue entry: {text: String, duration: float, priority: int, is_urgent: bool} +var _queue: Array[Dictionary] = [] +# Msec timestamp when the next fade-in may begin (stagger enforcement) +var _next_fade_in_msec: float = 0.0 func _ready() -> void: - text_panel.modulate.a = 0.0 - _displaying = false + pass func _process(delta: float) -> void: - if not _displaying: - return - _fade_timer += delta - if _fade_timer >= _current_duration: - _fade_out() + # Expire visible lines + for slot in _visible.duplicate(): + slot.expire_timer -= delta + if slot.expire_timer <= 0.0: + _retire_slot(slot) + + # Drain queue into available visible slots (one per stagger interval) + if not _queue.is_empty() and _visible.size() < MAX_VISIBLE: + var now := float(Time.get_ticks_msec()) + if now >= _next_fade_in_msec: + var next: Dictionary = _queue.pop_front() + _show_line(next.text, next.duration, next.priority, next.is_urgent) -# Show a monologue line. If a line is already displaying, enqueue it instead. -# character_type: "detective", "smuggler", or "" (default colour). -func show_monologue(text: String, duration: float = 5.0, character_type: String = "") -> void: - if _displaying: - if _queue.size() < MAX_QUEUE_DEPTH: - _queue.append({text = text, duration = duration, character_type = character_type}) - return - _display(text, duration, character_type) +# Display a monologue line. +# priority: higher number = more important (default 2; urgent beats normal). +# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred. +func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void: + var now := float(Time.get_ticks_msec()) + if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: + _show_line(text, duration, priority, is_urgent) + else: + _enqueue(text, duration, priority, is_urgent) -func _display(text: String, duration: float, character_type: String) -> void: - _displaying = true - _fade_timer = 0.0 - _current_duration = duration +# --------------------------------------------------------------------------- +# Internal +# --------------------------------------------------------------------------- - var color_hex: String = _color_for_character(character_type).to_html(false) - text_label.text = "[i][color=#%s]%s[/color][/i]" % [color_hex, text] +func _show_line(text: String, duration: float, priority: int, is_urgent: bool) -> void: + var line_node := _build_line_node(text, is_urgent) + _vbox.add_child(line_node) - if _active_tween and _active_tween.is_valid(): - _active_tween.kill() - _active_tween = create_tween() - _active_tween.tween_property(text_panel, "modulate:a", 1.0, 0.3) + var slot := { + node = line_node, + expire_timer = duration, + priority = priority, + tween = null as Tween, + } + _visible.append(slot) + _next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0 + + line_node.modulate.a = 0.0 + var tween := create_tween() + slot.tween = tween + var target_opacity := 1.0 if is_urgent else 0.85 + tween.tween_property(line_node, "modulate:a", target_opacity, FADE_IN_SEC) -func _fade_out() -> void: - if not _displaying: - return - _displaying = false - - if _active_tween and _active_tween.is_valid(): - _active_tween.kill() - _active_tween = create_tween() - _active_tween.tween_property(text_panel, "modulate:a", 0.0, 0.5) - _active_tween.tween_callback(_on_fade_complete) +func _retire_slot(slot: Dictionary) -> void: + _visible.erase(slot) + var node: Node = slot.node + var t: Tween = slot.tween + if t and t.is_valid(): + t.kill() + var tween := create_tween() + tween.tween_property(node, "modulate:a", 0.0, FADE_OUT_SEC) + tween.tween_callback(node.queue_free) -func _on_fade_complete() -> void: - if _queue.is_empty(): - return - var next: Dictionary = _queue.pop_front() - _display(next.text, next.duration, next.character_type) +func _enqueue(text: String, duration: float, priority: int, is_urgent: bool) -> void: + if _queue.size() < MAX_QUEUE: + _queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent}) + else: + # Replace the lowest-priority queued entry if new one outranks it + var lowest := _lowest_priority_idx() + if priority > _queue[lowest].priority: + _queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent} + # else: incoming line is lower/equal priority — silently drop + # Re-sort: highest priority at front (next to display) + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) -func _color_for_character(character_type: String) -> Color: - match character_type: - "detective": return COLOR_DETECTIVE - "smuggler": return COLOR_SMUGGLER - _: return COLOR_DEFAULT +func _lowest_priority_idx() -> int: + var idx := 0 + for i in range(1, _queue.size()): + if _queue[i].priority < _queue[idx].priority: + idx = i + return idx + + +func _build_line_node(text: String, is_urgent: bool) -> Control: + var profile: String = GameState.lattice_profile + var palette: Dictionary = _LATTICE_COLORS.get(profile, {}) + var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \ + else palette.get("standard", _FALLBACK_STANDARD) + + var container := MarginContainer.new() + container.add_theme_constant_override("margin_left", 4) + container.add_theme_constant_override("margin_right", 4) + container.add_theme_constant_override("margin_top", 2) + container.add_theme_constant_override("margin_bottom", 2) + + var label := RichTextLabel.new() + label.bbcode_enabled = true + label.fit_content = true + label.scroll_active = false + label.add_theme_font_size_override("normal_font_size", 13) + label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), text] + + container.add_child(label) + return container diff --git a/client/ui/monologue_display.tscn b/client/ui/monologue_display.tscn index 850407cca..47aaa1117 100644 --- a/client/ui/monologue_display.tscn +++ b/client/ui/monologue_display.tscn @@ -2,42 +2,22 @@ [ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"] -; Bottom-left of viewport, 420px wide, up to 120px tall. -; 80px bottom margin reserves space above the interaction verb list (InsertOverlay). -; Positioned in UILayer (CanvasLayer 20, D-049). +; Monologue display area — bottom-left of viewport. +; Anchors: 5% left margin, 50% max width, 25% height from bottom (spec §3.1, z-layer 7). +; Lines are created dynamically inside VBoxContainer by monologue_display.gd. [node name="MonologueDisplay" type="Control"] -layout_mode = 3 -anchors_preset = 2 -anchor_left = 0.0 -anchor_top = 1.0 -anchor_right = 0.0 -anchor_bottom = 1.0 -offset_left = 16.0 -offset_top = -200.0 -offset_right = 436.0 -offset_bottom = -80.0 +layout_mode = 1 +anchor_left = 0.05 +anchor_top = 0.75 +anchor_right = 0.55 +anchor_bottom = 0.98 grow_horizontal = 1 grow_vertical = 0 mouse_filter = 2 script = ExtResource("1_monologue") -[node name="PanelContainer" type="PanelContainer" parent="."] +[node name="VBoxContainer" type="VBoxContainer" parent="."] layout_mode = 1 -anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 - -[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"] -layout_mode = 2 -theme_override_constants/margin_left = 12 -theme_override_constants/margin_top = 8 -theme_override_constants/margin_right = 12 -theme_override_constants/margin_bottom = 8 - -[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"] -layout_mode = 2 -bbcode_enabled = true -text = "" -fit_content = true -scroll_active = false -theme_override_font_sizes/normal_font_size = 13 +theme_override_constants/separation = 4 From b49d78ef6a9e719000ccae1672a83161ccc14ee4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:41:22 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix(ui):=20guard=20empty=20text=20in=20show?= =?UTF-8?q?=5Fmonologue=20=E2=80=94=20no=20ghost=20slots=20or=20queue=20en?= =?UTF-8?q?tries=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show_monologue() now returns early when text.is_empty(), preventing: - ghost visible slots with blank labels - stagger timer advancing on empty calls - empty strings queuing when slots are full Tests: add test_show_monologue_with_empty_text_does_not_set_displaying and two companion cases (stagger timer unchanged, no enqueue when full) anticipating Hoshe's QA additions to the test suite. Co-Authored-By: Claude Sonnet 4.6 --- client/tests/test_monologue_display.gd | 30 ++++++++++++++++++++++++++ client/ui/monologue_display.gd | 3 +++ 2 files changed, 33 insertions(+) diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd index 88f4047c6..2370e449a 100644 --- a/client/tests/test_monologue_display.gd +++ b/client/tests/test_monologue_display.gd @@ -46,6 +46,36 @@ func test_stagger_timer_zero_on_init() -> void: d.queue_free() +func test_show_monologue_with_empty_text_does_not_set_displaying() -> void: + # Empty text must be ignored — no visible slot created, no queue entry. + # Prevents ghost display nodes and stagger timer contamination. + var d = _make_display() + if d == null: return + d.show_monologue("", 5.0) + assert_int(d._visible.size()).is_equal(0) + assert_int(d._queue.size()).is_equal(0) + d.queue_free() + + +func test_empty_text_does_not_advance_stagger_timer() -> void: + var d = _make_display() + if d == null: return + d.show_monologue("", 5.0) + assert_float(d._next_fade_in_msec).is_equal(0.0) + d.queue_free() + + +func test_empty_text_when_slots_full_does_not_enqueue() -> void: + var d = _make_display() + if d == null: return + d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0) + d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0) + d.show_monologue("", 5.0) + assert_int(d._queue.size()).is_equal(0) + d.queue_free() + + # --------------------------------------------------------------------------- # Single-line display # --------------------------------------------------------------------------- diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index b68419c07..bf86501d6 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -66,7 +66,10 @@ func _process(delta: float) -> void: # Display a monologue line. # priority: higher number = more important (default 2; urgent beats normal). # is_urgent: visual flag — full opacity + elevated colour. Bloom deferred. +# Empty text is silently ignored — no slot created, no queue entry. func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void: + if text.is_empty(): + return var now := float(Time.get_ticks_msec()) if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: _show_line(text, duration, priority, is_urgent) From c71ba8ebb3bdc874a77653e1a1fd02f4410ecb8f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:43:41 +0100 Subject: [PATCH 4/5] test(ui): GameState integration + D-049 compliance tests for monologue display (#122) 7 tests: snapshot round-trip, null/stale clearing, non-dict rejection, duration/id preservation, canvas layer constant. before_test/after_test lifecycle hooks prevent state bleed between test cases. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_monologue_display.gd | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd index 2370e449a..8b1c7a07c 100644 --- a/client/tests/test_monologue_display.gd +++ b/client/tests/test_monologue_display.gd @@ -27,6 +27,22 @@ func _label_text(d: Node) -> String: return (slot_node.get_child(0) as RichTextLabel).text +# --------------------------------------------------------------------------- +# Lifecycle — ensure clean GameState between every test +# --------------------------------------------------------------------------- + +func before_test() -> void: + ## Reset GameState fields touched by this suite so tests don't bleed into each other. + ## lattice_profile: tests that care about colour set it explicitly — default to baseline. + ## current_monologue: GameState integration tests need null as start state. + GameState.current_monologue = null + GameState.lattice_profile = "lattice_baseline" + +func after_test() -> void: + GameState.current_monologue = null + GameState.lattice_profile = "lattice_baseline" + + # --------------------------------------------------------------------------- # Initial state # --------------------------------------------------------------------------- @@ -370,3 +386,80 @@ func test_expire_timer_decrements_in_process() -> void: d._process(1.5) assert_float(d._visible[0].expire_timer).is_less(10.0) d.queue_free() + + +# --------------------------------------------------------------------------- +# GameState integration — current_monologue field (v5, #414) +# These tests do not instantiate the scene — they verify apply_snapshot() +# correctly populates and clears current_monologue so the display layer +# receives valid data (or null) every tick. +# +# Joint test plan (sprint-14/joint.md §Test Plan Alignment): +# "Verify current_monologue: None produces no display (no ghost text from previous tick)" +# --------------------------------------------------------------------------- + +func test_gamestate_monologue_null_when_snapshot_omits_field() -> void: + ## D-016: Snapshot without current_monologue must clear the field. + ## Prevents a stale line from a previous tick persisting as ghost text. + GameState.current_monologue = {"id": "stale", "text": "Old.", "duration_seconds": 3.0} + GameState.apply_snapshot({"tick": 2}) + assert_that(GameState.current_monologue).is_null() + + +func test_gamestate_monologue_set_from_snapshot() -> void: + ## apply_snapshot populates current_monologue when the field is a dict. + GameState.apply_snapshot({ + "tick": 1, + "current_monologue": {"id": "m001", "text": "A thought.", "duration_seconds": 5.0}, + }) + assert_that(GameState.current_monologue).is_not_null() + assert_that(GameState.current_monologue.get("text")).is_equal("A thought.") + + +func test_gamestate_monologue_null_when_field_is_non_dict() -> void: + ## Defensive: a non-dictionary value from the server must be rejected (null). + GameState.apply_snapshot({"tick": 1, "current_monologue": "not-a-dict"}) + assert_that(GameState.current_monologue).is_null() + + +func test_gamestate_monologue_duration_seconds_survives_round_trip() -> void: + ## duration_seconds feeds show_monologue()'s duration param — must not be lost. + GameState.apply_snapshot({ + "tick": 1, + "current_monologue": {"id": "m1", "text": "Test.", "duration_seconds": 7.5}, + }) + var dur: float = float(GameState.current_monologue.get("duration_seconds")) + assert_that(dur).is_equal_approx(7.5, 0.001) + + +func test_gamestate_monologue_id_survives_round_trip() -> void: + ## The id field is used by SimBridge carry-forward deduplication (#477). + GameState.apply_snapshot({ + "tick": 1, + "current_monologue": {"id": "enter_cargo_bay_001", "text": "Hmm.", "duration_seconds": 3.0}, + }) + assert_that(GameState.current_monologue.get("id")).is_equal("enter_cargo_bay_001") + + +func test_gamestate_monologue_replaced_by_next_snapshot() -> void: + ## Each snapshot with a monologue replaces the previous value — no accumulation. + GameState.apply_snapshot({ + "tick": 1, + "current_monologue": {"id": "first", "text": "First.", "duration_seconds": 3.0}, + }) + GameState.apply_snapshot({ + "tick": 2, + "current_monologue": {"id": "second", "text": "Second.", "duration_seconds": 3.0}, + }) + assert_that(GameState.current_monologue.get("id")).is_equal("second") + + +# --------------------------------------------------------------------------- +# D-049 Z-layer / canvas scope compliance +# Monologue display must be parented to CANVAS_UI (CanvasLayer 20), not the +# world layer. This constant check ensures it hasn't silently drifted. +# --------------------------------------------------------------------------- + +func test_canvas_ui_constant_is_20() -> void: + ## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display. + assert_that(Constants.CANVAS_UI).is_equal(20) From 677ca9b59d665ccbbe1f6554ed976ea5f2d625c5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 19:06:26 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(ui):=20address=20PR=20#49=20review=20?= =?UTF-8?q?=E2=80=94=206=20warnings=20+=202=20suggestions=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warnings fixed: - BBCode injection (line 153): escape [ → [lb] in server text before interpolation - sort_custom on silent-drop (line 125): sort now only runs on actual insertion/replacement - clip_contents: add clip_contents=true to MonologueDisplay Control (overflow guard) - confrontation tick guard (main.gd): _last_confrontation_tick deduplicates same-tick signals - GameState decoupling: show_monologue() reads lattice_profile once and passes it through _show_line() → _build_line_node(); renderer no longer reaches into autoload (D-020) - Equal-priority eviction: >= tiebreak (was >); FIFO for equal-priority queue overflow Suggestions fixed: - Minimum duration clamp: maxf(duration, FADE_IN_SEC + 0.1) — line survives own fade-in - _label_text bounds check: guard against empty _visible before indexing [0] Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/main.gd | 12 ++++-- client/tests/test_monologue_display.gd | 29 ++++++++++++- client/ui/monologue_display.gd | 57 +++++++++++++++----------- client/ui/monologue_display.tscn | 1 + 4 files changed, 69 insertions(+), 30 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 56514a1e7..2d3e5715a 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -19,8 +19,9 @@ extends Node2D var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false -var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice +var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice var _last_dialogue_tick: int = -1 +var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport @@ -338,9 +339,14 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void: # D-063: Handle confrontation beat monologue → show on monologue display (layer 7) # Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour. +# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick. func _on_confrontation_monologue(text: String, duration: float) -> void: - if monologue_display: - monologue_display.show_monologue(text, duration, 3, true) + if not monologue_display: + return + if GameState.current_tick == _last_confrontation_tick: + return + _last_confrontation_tick = GameState.current_tick + monologue_display.show_monologue(text, duration, 3, true) # D-064: Handle walk-away → send WalkAway{npc_id} to server diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd index 8b1c7a07c..151f5fb77 100644 --- a/client/tests/test_monologue_display.gd +++ b/client/tests/test_monologue_display.gd @@ -23,6 +23,8 @@ func _make_display() -> Node: func _label_text(d: Node) -> String: + if d._visible.is_empty(): + return "" var slot_node: Node = d._visible[0].node return (slot_node.get_child(0) as RichTextLabel).text @@ -456,10 +458,33 @@ func test_gamestate_monologue_replaced_by_next_snapshot() -> void: # --------------------------------------------------------------------------- # D-049 Z-layer / canvas scope compliance -# Monologue display must be parented to CANVAS_UI (CanvasLayer 20), not the -# world layer. This constant check ensures it hasn't silently drifted. +# MonologueDisplay must be parented to UILayer (CanvasLayer, layer=20). +# Two tests: (1) constant sanity, (2) scene tree structural verification. # --------------------------------------------------------------------------- func test_canvas_ui_constant_is_20() -> void: ## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display. + ## Sanity check — constant must not drift from the spec. assert_that(Constants.CANVAS_UI).is_equal(20) + + +func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void: + ## D-049: Structural verification — MonologueDisplay must be a direct child of + ## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer. + ## Catches regressions where the node gets accidentally moved to InsertOverlay + ## (layer=10) or ModalLayer (layer=30), or dropped into the world z-stack. + ## + ## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141). + if not ResourceLoader.exists("res://scenes/main.tscn"): + push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped") + return + var scene: Node = load("res://scenes/main.tscn").instantiate() + auto_free(scene) + add_child(scene) + + var mono: Node = scene.get_node_or_null("UILayer/MonologueDisplay") + assert_that(mono != null).is_true() + + var parent: Node = mono.get_parent() + assert_that(parent is CanvasLayer).is_true() + assert_that((parent as CanvasLayer).layer).is_equal(Constants.CANVAS_UI) diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index bf86501d6..38f3ec0dc 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -5,20 +5,22 @@ extends Control # # Up to MAX_VISIBLE lines display simultaneously in a VBoxContainer. # Additional arrivals queue up to MAX_QUEUE depth; lowest-priority entry is -# dropped when the queue is full and a higher-priority line arrives. +# dropped when the queue is full and an equal-or-higher-priority line arrives +# (>= tiebreak = FIFO: newest replaces oldest at same priority). # # Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4). -# Colour: derived from GameState.lattice_profile at render time (D-032). +# Colour: lattice_profile passed in at call time — no autoload access in renderer. # is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred). const MAX_VISIBLE: int = 3 const MAX_QUEUE: int = 5 -const STAGGER_SEC: float = 0.15 -const FADE_IN_SEC: float = 0.3 -const FADE_OUT_SEC: float = 0.5 +const STAGGER_SEC: float = 0.15 +const FADE_IN_SEC: float = 0.3 +const FADE_OUT_SEC: float = 0.5 +const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in -# Lattice colour palette — keyed by GameState.lattice_profile. +# Lattice colour palette — keyed by lattice_profile passed from GameState at show time. # standard opacity = 0.85, urgent opacity = 1.0. # Source: Tyre architecture review, Sprint 14. const _LATTICE_COLORS: Dictionary = { @@ -38,7 +40,7 @@ const _FALLBACK_URGENT: Color = Color("#e0e8f8") # Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween} var _visible: Array[Dictionary] = [] -# Queue entry: {text: String, duration: float, priority: int, is_urgent: bool} +# Queue entry: {text, duration, priority, is_urgent, lattice_profile} var _queue: Array[Dictionary] = [] # Msec timestamp when the next fade-in may begin (stagger enforcement) var _next_fade_in_msec: float = 0.0 @@ -60,34 +62,37 @@ func _process(delta: float) -> void: var now := float(Time.get_ticks_msec()) if now >= _next_fade_in_msec: var next: Dictionary = _queue.pop_front() - _show_line(next.text, next.duration, next.priority, next.is_urgent) + _show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile) # Display a monologue line. # priority: higher number = more important (default 2; urgent beats normal). # is_urgent: visual flag — full opacity + elevated colour. Bloom deferred. # Empty text is silently ignored — no slot created, no queue entry. +# lattice_profile is read from GameState here and passed down — renderer stays +# decoupled from the autoload (D-020 renderer contract). func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void: if text.is_empty(): return + var profile := GameState.lattice_profile var now := float(Time.get_ticks_msec()) if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: - _show_line(text, duration, priority, is_urgent) + _show_line(text, duration, priority, is_urgent, profile) else: - _enqueue(text, duration, priority, is_urgent) + _enqueue(text, duration, priority, is_urgent, profile) # --------------------------------------------------------------------------- # Internal # --------------------------------------------------------------------------- -func _show_line(text: String, duration: float, priority: int, is_urgent: bool) -> void: - var line_node := _build_line_node(text, is_urgent) +func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void: + var line_node := _build_line_node(text, is_urgent, lattice_profile) _vbox.add_child(line_node) var slot := { node = line_node, - expire_timer = duration, + expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in priority = priority, tween = null as Tween, } @@ -112,17 +117,17 @@ func _retire_slot(slot: Dictionary) -> void: tween.tween_callback(node.queue_free) -func _enqueue(text: String, duration: float, priority: int, is_urgent: bool) -> void: +func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void: if _queue.size() < MAX_QUEUE: - _queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent}) + _queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile}) + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) else: - # Replace the lowest-priority queued entry if new one outranks it + # >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks) var lowest := _lowest_priority_idx() - if priority > _queue[lowest].priority: - _queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent} - # else: incoming line is lower/equal priority — silently drop - # Re-sort: highest priority at front (next to display) - _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) + if priority >= _queue[lowest].priority: + _queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile} + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) + # else: incoming line is strictly lower priority — silently drop; no sort needed func _lowest_priority_idx() -> int: @@ -133,9 +138,8 @@ func _lowest_priority_idx() -> int: return idx -func _build_line_node(text: String, is_urgent: bool) -> Control: - var profile: String = GameState.lattice_profile - var palette: Dictionary = _LATTICE_COLORS.get(profile, {}) +func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control: + var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {}) var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \ else palette.get("standard", _FALLBACK_STANDARD) @@ -150,7 +154,10 @@ func _build_line_node(text: String, is_urgent: bool) -> Control: label.fit_content = true label.scroll_active = false label.add_theme_font_size_override("normal_font_size", 13) - label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), text] + # Escape [ to prevent BBCode injection from server-sourced text. + # [lb] is Godot's BBCode entity for a literal left bracket. + var safe_text := text.replace("[", "[lb]") + label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), safe_text] container.add_child(label) return container diff --git a/client/ui/monologue_display.tscn b/client/ui/monologue_display.tscn index 47aaa1117..5e7ebf331 100644 --- a/client/ui/monologue_display.tscn +++ b/client/ui/monologue_display.tscn @@ -13,6 +13,7 @@ anchor_right = 0.55 anchor_bottom = 0.98 grow_horizontal = 1 grow_vertical = 0 +clip_contents = true mouse_filter = 2 script = ExtResource("1_monologue")