From 6c3a30a4bc38b2bccad3ce9ced2a50ae8262f486 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 11:16:33 +0100 Subject: [PATCH] feat(ui): add relationship color accent to E-Talk overlay (#537) Phase 1: interaction_list.gd shows a 3px left-edge accent bar in D-033 relationship color (teal/green/amber/red) at 85% alpha. Cross-references entity_id against visible_entities via _cache_entity_relationship(). NPC name and tier hint deferred to Phase 2 (requires server protocol extension). Co-Authored-By: Claude Opus 4.6 --- client/tests/test_etalk_overlay_sprint17.gd | 471 ++++++++++++++++++++ client/ui/interaction_list.gd | 17 + 2 files changed, 488 insertions(+) create mode 100644 client/tests/test_etalk_overlay_sprint17.gd diff --git a/client/tests/test_etalk_overlay_sprint17.gd b/client/tests/test_etalk_overlay_sprint17.gd new file mode 100644 index 000000000..d67fd3b5d --- /dev/null +++ b/client/tests/test_etalk_overlay_sprint17.gd @@ -0,0 +1,471 @@ +## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1 +## Tests for relationship color indicator and TIER_LABELS context hint. +## +## Spec refs: +## D-033 (entity color = relationship to player) +## D-028 (dialogue tier access model — labels implemented in TIER_LABELS const) +## D-051 (diegetic insert display) +## +## Phase 1 scope (2026-02-24): +## - Relationship color: prompt_label font_color set to D-033 palette via +## _get_entity_relationship(entity_id) → Constants.color_for_relationship() +## - Context tier hint: context_label.text from TIER_LABELS dict +## - NPC name display: Phase 2 (needs server protocol change — no known_attributes in v13) +## +## Implementation: client/ui/interaction_prompt.gd +## - `_get_entity_relationship(entity_id: int) -> String` +## - `const TIER_LABELS := {"Unknown": "—", "Friendly": "trusted contact", +## "PersonOfInterest": "person of interest", "Hostile": "threat"}` +## - context_label at $MarginContainer/VBoxContainer/ContextLabel +class_name TestETalkOverlaySprint17 +extends GdUnitTestSuite + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.nearby_interactions = [] + GameState.visible_entities = [] + + +func after_test() -> void: + GameState.nearby_interactions = [] + GameState.visible_entities = [] + + +# ------------------------------------------------------------------------- +# D-033: Constants.color_for_relationship() — palette baseline +# ------------------------------------------------------------------------- + +func test_relationship_unknown_maps_to_teal() -> void: + assert_that(Constants.color_for_relationship("Unknown")).is_equal(Color("#4a9ebb")) + +func test_relationship_known_maps_to_green() -> void: + assert_that(Constants.color_for_relationship("Known")).is_equal(Color("#6bc9a6")) + +func test_relationship_friendly_maps_to_green() -> void: + # Friendly uses the same color as Known per D-033 spec + assert_that(Constants.color_for_relationship("Friendly")).is_equal(Color("#6bc9a6")) + +func test_relationship_poi_maps_to_amber() -> void: + assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Color("#e8c547")) + +func test_relationship_hostile_maps_to_red() -> void: + assert_that(Constants.color_for_relationship("Hostile")).is_equal(Color("#d45d5d")) + +func test_relationship_unknown_string_defaults_to_teal() -> void: + assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + + +# ------------------------------------------------------------------------- +# UIStrings: relationship state labels exist (Phase 2 pre-fixture) +# ------------------------------------------------------------------------- + +func test_ui_strings_has_relationship_unknown_label() -> void: + assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true() + +func test_ui_strings_has_relationship_known_label() -> void: + assert_that(UIStrings.has_key("relationship_states.known.label")).is_true() + +func test_ui_strings_has_relationship_friendly_label() -> void: + assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true() + +func test_ui_strings_has_relationship_poi_label() -> void: + assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true() + +func test_ui_strings_has_relationship_hostile_label() -> void: + assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true() + + +# ------------------------------------------------------------------------- +# _get_entity_relationship(): cross-references visible_entities by entity_id +# ------------------------------------------------------------------------- + +func test_get_entity_relationship_returns_relationship_string() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Friendly", "observation": "Visible", + }] + var prompt = _make_prompt() + assert_that(prompt._get_entity_relationship(2)).is_equal("Friendly") + prompt.queue_free() + +func test_get_entity_relationship_all_five_states() -> void: + var prompt = _make_prompt() + for rel in ["Unknown", "Known", "Friendly", "PersonOfInterest", "Hostile"]: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": rel, "observation": "Visible", + }] + assert_that(prompt._get_entity_relationship(2)).is_equal(rel) + prompt.queue_free() + +func test_get_entity_relationship_returns_unknown_for_missing_entity() -> void: + GameState.visible_entities = [] + var prompt = _make_prompt() + assert_that(prompt._get_entity_relationship(99)).is_equal("Unknown") + prompt.queue_free() + +func test_get_entity_relationship_returns_unknown_when_field_absent() -> void: + # Entity in visible list but no "relationship" key → default "Unknown" + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", + }] + var prompt = _make_prompt() + assert_that(prompt._get_entity_relationship(2)).is_equal("Unknown") + prompt.queue_free() + + +# ------------------------------------------------------------------------- +# TIER_LABELS dict — D-028 dialogue tier hints (#537) +# Note: "Known" is absent from the dict; fallback is "—" +# ------------------------------------------------------------------------- + +func test_tier_labels_unknown_is_dash() -> void: + var prompt = _make_prompt() + assert_that(prompt.TIER_LABELS.get("Unknown", "—")).is_equal("—") + prompt.queue_free() + +func test_tier_labels_known_falls_back_to_dash() -> void: + # "Known" not in TIER_LABELS — GDScript .get() default applies + var prompt = _make_prompt() + assert_that(prompt.TIER_LABELS.get("Known", "—")).is_equal("—") + prompt.queue_free() + +func test_tier_labels_friendly_is_trusted_contact() -> void: + var prompt = _make_prompt() + assert_that(prompt.TIER_LABELS.get("Friendly", "—")).is_equal("trusted contact") + prompt.queue_free() + +func test_tier_labels_poi_is_person_of_interest() -> void: + var prompt = _make_prompt() + assert_that(prompt.TIER_LABELS.get("PersonOfInterest", "—")).is_equal("person of interest") + prompt.queue_free() + +func test_tier_labels_hostile_is_threat() -> void: + var prompt = _make_prompt() + assert_that(prompt.TIER_LABELS.get("Hostile", "—")).is_equal("threat") + prompt.queue_free() + +func test_tier_labels_unknown_string_falls_back_to_dash() -> void: + var prompt = _make_prompt() + assert_that(prompt.TIER_LABELS.get("SomeNewState", "—")).is_equal("—") + prompt.queue_free() + + +# ------------------------------------------------------------------------- +# context_label: text and visibility after _show_prompt (NPC entity) +# ------------------------------------------------------------------------- + +func test_context_label_shows_tier_for_npc() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Friendly", "observation": "Visible", + }] + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt._process(0.0) + var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel") + assert_that(ctx.text).is_equal("trusted contact") + assert_that(ctx.visible).is_true() + prompt.queue_free() + +func test_context_label_shows_dash_for_unknown_npc() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible", + }] + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt._process(0.0) + var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel") + assert_that(ctx.text).is_equal("—") + assert_that(ctx.visible).is_true() + prompt.queue_free() + +func test_context_label_shows_threat_for_hostile_npc() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Hostile", "observation": "Visible", + }] + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt._process(0.0) + var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel") + assert_that(ctx.text).is_equal("threat") + prompt.queue_free() + +func test_context_label_hidden_for_non_npc_entity() -> void: + # Object interaction: no relationship color, no tier hint + GameState.nearby_interactions = [{ + "entity_id": 10, "entity_type": "Object", "distance": 1, + "verbs": [{"kind": "Read", "label": "Read", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt._process(0.0) + var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel") + assert_that(ctx.visible).is_false() + prompt.queue_free() + +func test_context_label_hidden_after_prompt_hides() -> void: + # When NPC walks away → prompt hides → context_label also hides + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Friendly", "observation": "Visible", + }] + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt._process(0.0) + assert_that(prompt._is_showing).is_true() + + GameState.nearby_interactions = [] + prompt._process(0.0) + # _hide_prompt clears context_label.visible + var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel") + assert_that(ctx.visible).is_false() + prompt.queue_free() + + +# ------------------------------------------------------------------------- +# Relationship color via two-step lookup (#537 Phase 1 core path) +# ------------------------------------------------------------------------- + +func test_entity_relationship_color_unknown_is_teal() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible", + }] + var prompt = _make_prompt() + var rel := prompt._get_entity_relationship(2) + assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + prompt.queue_free() + +func test_entity_relationship_color_friendly_is_green() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Friendly", "observation": "Visible", + }] + var prompt = _make_prompt() + var rel := prompt._get_entity_relationship(2) + assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + prompt.queue_free() + +func test_entity_relationship_color_poi_is_amber() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "PersonOfInterest", "observation": "Visible", + }] + var prompt = _make_prompt() + var rel := prompt._get_entity_relationship(2) + assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_POI) + prompt.queue_free() + +func test_entity_relationship_color_hostile_is_red() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Hostile", "observation": "Visible", + }] + var prompt = _make_prompt() + var rel := prompt._get_entity_relationship(2) + assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_HOSTILE) + prompt.queue_free() + +func test_entity_relationship_color_missing_entity_defaults_to_unknown() -> void: + GameState.visible_entities = [] + var prompt = _make_prompt() + var rel := prompt._get_entity_relationship(99) + assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + prompt.queue_free() + +func test_entity_relationship_color_updates_live() -> void: + # Relationship shift mid-session → lookup returns new value immediately + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible", + }] + var prompt = _make_prompt() + assert_that(Constants.color_for_relationship(prompt._get_entity_relationship(2))).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + + GameState.visible_entities[0]["relationship"] = "Friendly" + assert_that(Constants.color_for_relationship(prompt._get_entity_relationship(2))).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + prompt.queue_free() + + +# ------------------------------------------------------------------------- +# Regression: existing prompt behavior unchanged after #537 changes +# ------------------------------------------------------------------------- + +func test_prompt_hides_when_no_interactions() -> void: + GameState.nearby_interactions = [] + var prompt = _make_prompt() + prompt._process(0.0) + assert_that(prompt._is_showing).is_false() + prompt.queue_free() + +func test_prompt_shows_on_npc_interaction() -> void: + GameState.visible_entities = [{ + "entity_id": 2, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible", + }] + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt._process(0.0) + assert_that(prompt._is_showing).is_true() + prompt.queue_free() + +func test_prompt_get_selected_verb_still_works() -> void: + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [ + {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, + {"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true}, + ], + }] + var prompt = _make_prompt() + assert_that(prompt.get_selected_verb()).is_equal("Talk") + prompt.queue_free() + +func test_prompt_get_target_still_works() -> void: + GameState.nearby_interactions = [{ + "entity_id": 5, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt._process(0.0) + assert_that(prompt.get_interaction_target()).is_equal(5) + prompt.queue_free() + +func test_prompt_suppressed_when_insert_inactive() -> void: + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + prompt.set_insert_active(false) + prompt._process(0.0) + assert_that(prompt._is_showing).is_false() + prompt.queue_free() + + +# ------------------------------------------------------------------------- +# Edge cases +# ------------------------------------------------------------------------- + +func test_overlay_hides_on_empty_verb_list() -> void: + GameState.nearby_interactions = [{ + "entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [], + }] + var prompt = _make_prompt() + prompt._process(0.0) + assert_that(prompt._is_showing).is_false() + prompt.queue_free() + +func test_overlay_entity_not_in_visible_list_defaults_color() -> void: + # NearbyInteraction for entity not in visible_entities → color defaults to Unknown teal + GameState.visible_entities = [] + GameState.nearby_interactions = [{ + "entity_id": 999, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }] + var prompt = _make_prompt() + var rel := prompt._get_entity_relationship(999) + assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + prompt.queue_free() + +func test_overlay_multiple_interactions_nearest_is_target() -> void: + # Server sends nearest first; overlay targets index 0 + GameState.visible_entities = [ + { + "entity_id": 2, "x": 11.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Friendly", "observation": "Visible", + }, + { + "entity_id": 3, "x": 12.0, "y": 9.0, "z": 0, + "kind": {"variant": "Npc", "data": null}, + "visibility": "Forward", "relationship": "Hostile", "observation": "Visible", + }, + ] + GameState.nearby_interactions = [ + { + "entity_id": 2, "entity_type": "Npc", "distance": 1, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }, + { + "entity_id": 3, "entity_type": "Npc", "distance": 2, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}], + }, + ] + var prompt = _make_prompt() + prompt._process(0.0) + # Target is entity 2 (nearest, Friendly) + assert_that(prompt.get_interaction_target()).is_equal(2) + var rel := prompt._get_entity_relationship(prompt.get_interaction_target()) + assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_FRIENDLY) + prompt.queue_free() + + +# ------------------------------------------------------------------------- +# Scene-level: prompt in correct CanvasLayer +# ------------------------------------------------------------------------- + +func test_interaction_prompt_in_insert_overlay() -> void: + # D-049 / D-057: InteractionPrompt must be in InsertOverlay (CanvasLayer 10) + var scene := load("res://scenes/main.tscn") + var instance = scene.instantiate() + auto_free(instance) + add_child(instance) + + assert_that(instance.get_node_or_null("InsertOverlay/InteractionPrompt")).is_not_null() + + +# ------------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------------- + +func _make_prompt() -> PanelContainer: + var PromptScript = load("res://ui/interaction_prompt.gd") + var panel = PanelContainer.new() + panel.set_script(PromptScript) + var margin = MarginContainer.new() + margin.name = "MarginContainer" + panel.add_child(margin) + var vbox = VBoxContainer.new() + vbox.name = "VBoxContainer" + margin.add_child(vbox) + var prompt_label = Label.new() + prompt_label.name = "PromptLabel" + vbox.add_child(prompt_label) + var context_label = Label.new() + context_label.name = "ContextLabel" + vbox.add_child(context_label) + add_child(panel) # _ready() fires here: @onready vars resolve from the tree above + return panel diff --git a/client/ui/interaction_list.gd b/client/ui/interaction_list.gd index 2a96d6850..298c4c63f 100644 --- a/client/ui/interaction_list.gd +++ b/client/ui/interaction_list.gd @@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}] var _selected_index: int = 0 var _active_tween: Tween = null var _verb_labels: Array[Label] = [] +# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar +var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM @onready var _vbox: VBoxContainer = $VBox @@ -52,6 +54,9 @@ func _draw() -> void: var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2) draw_rect(bg_rect, INSERT_BG) draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0) + # #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship + var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y) + draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85)) func update_from_state() -> void: @@ -88,6 +93,7 @@ func update_from_state() -> void: _verb_items = sorted _selected_index = 0 _cache_entity_position() + _cache_entity_relationship() _rebuild_labels() _show() @@ -127,6 +133,17 @@ func _cache_entity_position() -> void: return +## #537: Cache relationship color for the target entity (D-033 palette). +## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities. +func _cache_entity_relationship() -> void: + for entity in GameState.visible_entities: + if entity.get("entity_id") == _current_target_id: + var rel: String = str(entity.get("relationship", "Unknown")) + _relationship_color = Constants.color_for_relationship(rel) + return + _relationship_color = Constants.IMPLANT_TEXT_DIM + + ## Convert entity world position to screen coords and reposition this Control. ## Runs every frame while showing so the list tracks the entity as the camera moves. func _update_screen_position() -> void: