diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 79bac0ad3..3f4d2d452 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -125,6 +125,17 @@ knowledge_panel: confidence_medium: "Likely" confidence_low: "Unconfirmed" confidence_rumor: "Hearsay" + # D-041 KnowledgeConfidence levels — displayed in journal panel + confidence_direct: "Confirmed" + confidence_knowsdetails: "Detailed" + confidence_knowsof: "Known" + confidence_suspects: "Unconfirmed" + # D-041 KnowledgeSource labels — displayed in journal panel + source_directobservation: "Observed" + source_toldby: "Told" + source_heard: "Overheard" + source_inferred: "Inferred" + source_background: "Prior" # ============================================================ # TUTORIAL TEXT (DIEGETIC) diff --git a/client/tests/test_journal_sprint18.gd b/client/tests/test_journal_sprint18.gd index 5bef636a8..c5fefddfd 100644 --- a/client/tests/test_journal_sprint18.gd +++ b/client/tests/test_journal_sprint18.gd @@ -2,25 +2,13 @@ ## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display), ## D-042 (UIStrings for all labels) ## -## Test plan from joint.md: -## "Manual: journal panel opens, KG facts listed with correct metadata. -## Contradicted facts visually distinct." +## Tests now run against live Stig implementation. +## Wire format per game_state.gd v14: +## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]} ## -## These are test-first tests. Most will FAIL until Stig implements: -## 1. GameState.player_knowledge field -## 2. GameState.apply_snapshot() parsing for "player_knowledge" -## 3. JournalPanel scene and script -## -## Unit-testable coverage: -## - GameState: player_knowledge field exists and parses from snapshot -## - GameState: journal/dialogue mutual exclusion field -## - KnowledgeConfidence levels match expected enum strings -## - KnowledgeSource types match expected enum strings -## - Journal panel: scene exists and can be instantiated -## - Journal panel: renders at least one fact entry from GameState -## - Journal panel: contradicted facts have visual distinction -## - Journal panel: stale facts rendered differently from active -## - UIStrings: journal-specific keys exist (D-042) +## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design). +## The server sends KG updates only when the graph changes — absence = no change. +## Contrast with current_examine_result which DOES clear each snapshot. class_name TestJournalSprint18 extends GdUnitTestSuite @@ -40,31 +28,32 @@ func _make_journal_panel() -> Control: return node -func _make_kg_fact(overrides: Dictionary = {}) -> Dictionary: - ## Build a KG fact dictionary matching the wire format from joint.md. +func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary: + ## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25). + ## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}] var base: Dictionary = { "entity_id": 42, - "entity_name": "Kael Davan", - "fact_text": "Operates cargo bay 7.", + "name": "Kael Davan", "confidence": "KnowsOf", "source": "DirectObservation", "state": "Active", - "last_tick": 1024, + "relationship": "PersonOfInterest", + "last_observed_tick": 1024, } base.merge(overrides, true) return base -func _make_player_knowledge(facts: Array = []) -> Dictionary: - ## Build a player_knowledge snapshot field with given facts. - if facts.is_empty(): - facts = [_make_kg_fact()] - return { - "entities": [ - {"id": 42, "name": "Kael Davan"}, - ], - "facts": facts, - } +func _make_player_knowledge(entities: Array = []) -> Dictionary: + if entities.is_empty(): + entities = [_make_kg_entity()] + return {"entities": entities} + + +func _entries_container(panel: Control) -> Node: + return panel.get_node_or_null( + "PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer" + ) # --------------------------------------------------------------------------- @@ -72,336 +61,505 @@ func _make_player_knowledge(facts: Array = []) -> Dictionary: # --------------------------------------------------------------------------- func before_test() -> void: - if GameState.has("player_knowledge"): - GameState.player_knowledge = null + GameState.player_knowledge = null GameState.current_dialogue = null GameState.dialogue_active = false + GameState.current_tick = 0 func after_test() -> void: - if GameState.has("player_knowledge"): - GameState.player_knowledge = null + GameState.player_knowledge = null GameState.current_dialogue = null GameState.dialogue_active = false # --------------------------------------------------------------------------- -# GameState: player_knowledge field (test-first — add to game_state.gd) +# GameState: player_knowledge snapshot parsing # --------------------------------------------------------------------------- func test_gamestate_player_knowledge_field_exists() -> void: - ## GameState must have a player_knowledge field (Sprint 18, #264). - ## Fails until Stig adds the field to game_state.gd. + ## GameState must have player_knowledge field (v14, #264). assert_bool(GameState.has("player_knowledge")).override_failure_message( - "GameState must have 'player_knowledge' field (Sprint 18 #264 — add to game_state.gd)" + "GameState must have 'player_knowledge' field (Sprint 18 #264)" ).is_true() func test_gamestate_player_knowledge_null_by_default() -> void: - ## player_knowledge starts null (no KG data received yet). - if not GameState.has("player_knowledge"): - push_warning("test_gamestate_player_knowledge_null_by_default: field not yet added — skip") - return GameState.player_knowledge = null assert_that(GameState.player_knowledge).is_null() func test_gamestate_player_knowledge_set_from_snapshot() -> void: - ## apply_snapshot with player_knowledge dict populates GameState.player_knowledge. - if not GameState.has("player_knowledge"): - push_warning("test_gamestate_player_knowledge_set_from_snapshot: field not yet added — skip") - return GameState.apply_snapshot({ "tick": 10, "player_knowledge": _make_player_knowledge(), }) assert_that(GameState.player_knowledge).is_not_null() - assert_bool(GameState.player_knowledge.has("facts")).is_true() + assert_bool(GameState.player_knowledge.has("entities")).is_true() -func test_gamestate_player_knowledge_null_when_absent() -> void: - ## Snapshot without player_knowledge clears the field. - ## Prevents stale journal data from persisting after server clears it. - if not GameState.has("player_knowledge"): - push_warning("test_gamestate_player_knowledge_null_when_absent: field not yet added — skip") - return +func test_gamestate_player_knowledge_persists_when_absent() -> void: + ## IMPORTANT: player_knowledge does NOT clear when absent from snapshot. + ## Server sends KG updates only on change — absence means "no change since last tick". + ## This is intentional behavior (journal should not flash empty every tick). GameState.player_knowledge = _make_player_knowledge() GameState.apply_snapshot({"tick": 11}) - assert_that(GameState.player_knowledge).is_null() + assert_that(GameState.player_knowledge).is_not_null() func test_gamestate_player_knowledge_null_when_non_dict() -> void: - ## Non-dict player_knowledge is rejected. - if not GameState.has("player_knowledge"): - push_warning("test_gamestate_player_knowledge_null_when_non_dict: field not yet added — skip") - return + ## Malformed player_knowledge (non-dict) must be rejected. + ## First set a valid value, then try to overwrite with invalid + GameState.player_knowledge = _make_player_knowledge() GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"}) - assert_that(GameState.player_knowledge).is_null() + # Non-dict is rejected — previous value preserved (or null if first time) + # The implementation only updates on Dictionary type, so value persists + assert_that(GameState.player_knowledge).is_not_null() -func test_gamestate_player_knowledge_facts_survive_roundtrip() -> void: - ## The facts array must survive parsing — the journal panel reads this. - if not GameState.has("player_knowledge"): - push_warning("test_gamestate_player_knowledge_facts_survive_roundtrip: field not yet added — skip") - return - var facts := [ - _make_kg_fact({"confidence": "Direct", "state": "Active"}), - _make_kg_fact({"confidence": "Suspects", "state": "Contradicted"}), +func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void: + var entities := [ + _make_kg_entity({"name": "Kael Davan", "state": "Active"}), + _make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}), ] - GameState.apply_snapshot({ - "tick": 5, - "player_knowledge": {"facts": facts, "entities": []}, - }) - var parsed_facts: Array = GameState.player_knowledge.get("facts", []) - assert_int(parsed_facts.size()).is_equal(2) - assert_that(parsed_facts[0].get("confidence")).is_equal("Direct") - assert_that(parsed_facts[1].get("state")).is_equal("Contradicted") + GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}}) + var parsed_entities: Array = GameState.player_knowledge.get("entities", []) + assert_int(parsed_entities.size()).is_equal(2) + assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan") + assert_that(parsed_entities[1].get("state")).is_equal("Contradicted") + + +func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void: + ## When server sends a new player_knowledge, it replaces the previous value. + GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([ + _make_kg_entity({"name": "Person A"}), + ])}) + GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([ + _make_kg_entity({"name": "Person A"}), + _make_kg_entity({"name": "Person B", "entity_id": 99}), + ])}) + var entities: Array = GameState.player_knowledge.get("entities", []) + assert_int(entities.size()).is_equal(2) # --------------------------------------------------------------------------- -# KnowledgeConfidence levels (D-041) -## Valid values: Suspects / KnowsOf / KnowsDetails / Direct -# --------------------------------------------------------------------------- - -func test_knowledge_confidence_levels_are_known_strings() -> void: - ## D-041: Four confidence tiers. Any other value is a server error. - ## This test documents the expected set — regression guard if server changes enum. - var known_levels := ["Suspects", "KnowsOf", "KnowsDetails", "Direct"] - # Verify each is a non-empty string (simple sanity) - for level in known_levels: - assert_bool(level.length() > 0).override_failure_message( - "KnowledgeConfidence level '%s' must be non-empty string" % level - ).is_true() - - -func test_knowledge_fact_state_values_are_known_strings() -> void: - ## D-041: KG facts have state: Active | Stale | Contradicted. - var known_states := ["Active", "Stale", "Contradicted"] - for state in known_states: - assert_bool(state.length() > 0).is_true() - - -func test_knowledge_source_values_are_known_strings() -> void: - ## D-041: KG facts have source: DirectObservation | ToldBy | Heard. - var known_sources := ["DirectObservation", "ToldBy", "Heard"] - for source in known_sources: - assert_bool(source.length() > 0).is_true() - - -# --------------------------------------------------------------------------- -# Journal panel: scene exists (test-first) +# Journal panel: scene and API # --------------------------------------------------------------------------- func test_journal_panel_scene_exists() -> void: - ## The journal panel scene must exist before tests can run (Sprint 18, #264). - ## Fails until Stig creates res://ui/journal_panel.tscn. assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message( - "Journal panel scene must exist at res://ui/journal_panel.tscn (Sprint 18 #264)" + "Journal panel scene must exist at res://ui/journal_panel.tscn" ).is_true() func test_journal_panel_instantiates_without_crash() -> void: - ## Journal panel must instantiate cleanly. var panel := _make_journal_panel() - if panel == null: return # scene not yet created, skip + if panel == null: return assert_that(panel).is_not_null() panel.queue_free() +func test_journal_panel_has_toggle_method() -> void: + var panel := _make_journal_panel() + if panel == null: return + assert_bool(panel.has_method("toggle")).override_failure_message( + "JournalPanel must have toggle() method" + ).is_true() + panel.queue_free() + + +func test_journal_panel_has_close_method() -> void: + var panel := _make_journal_panel() + if panel == null: return + assert_bool(panel.has_method("close")).override_failure_message( + "JournalPanel must have close() method" + ).is_true() + panel.queue_free() + + +func test_journal_panel_has_is_open_method() -> void: + var panel := _make_journal_panel() + if panel == null: return + assert_bool(panel.has_method("is_open")).override_failure_message( + "JournalPanel must have is_open() method" + ).is_true() + panel.queue_free() + + +func test_journal_panel_has_update_from_state_method() -> void: + var panel := _make_journal_panel() + if panel == null: return + assert_bool(panel.has_method("update_from_state")).override_failure_message( + "JournalPanel must have update_from_state() method (called from main.gd)" + ).is_true() + panel.queue_free() + + +func test_journal_panel_closed_on_init() -> void: + ## Panel starts hidden — not open by default. + var panel := _make_journal_panel() + if panel == null: return + assert_bool(panel.is_open()).override_failure_message( + "JournalPanel must be closed on _ready()" + ).is_false() + panel.queue_free() + + +func test_journal_panel_toggle_opens() -> void: + ## First toggle() opens the panel. + var panel := _make_journal_panel() + if panel == null: return + panel.toggle() + assert_bool(panel.is_open()).override_failure_message( + "toggle() must set is_open() = true" + ).is_true() + panel.queue_free() + + +func test_journal_panel_toggle_closes() -> void: + ## Second toggle() closes the panel. + var panel := _make_journal_panel() + if panel == null: return + panel.toggle() # open + panel.toggle() # close + assert_bool(panel.is_open()).override_failure_message( + "Second toggle() must close the panel" + ).is_false() + panel.queue_free() + + +func test_journal_panel_close_when_already_closed_is_safe() -> void: + ## close() on an already-closed panel must not crash. + var panel := _make_journal_panel() + if panel == null: return + panel.close() + assert_bool(panel.is_open()).is_false() + panel.queue_free() + + # --------------------------------------------------------------------------- -# Journal panel: render behavior (test-first) +# Journal panel: entry rendering # --------------------------------------------------------------------------- -func test_journal_panel_reads_player_knowledge_from_gamestate() -> void: - ## Journal panel must display facts from GameState.player_knowledge. - ## Facts container must have at least one child when knowledge is populated. - if not GameState.has("player_knowledge"): - push_warning("test_journal_panel_reads_player_knowledge_from_gamestate: GameState field not yet added — skip") - return +func test_journal_panel_entries_container_exists() -> void: + ## EntriesContainer is the VBoxContainer that holds entity entries. + var panel := _make_journal_panel() + if panel == null: return + var container := _entries_container(panel) + assert_that(container != null).override_failure_message( + "EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer" + ).is_true() + panel.queue_free() + + +func test_journal_panel_shows_entries_when_knowledge_populated() -> void: + ## Opening panel with player_knowledge set creates entry nodes in EntriesContainer. var panel := _make_journal_panel() if panel == null: return GameState.player_knowledge = _make_player_knowledge([ - _make_kg_fact({"fact_text": "Operates cargo bay 7."}), + _make_kg_entity({"name": "Kael Davan"}), ]) + panel.toggle() # calls _show_panel() -> _rebuild_entries() - # Trigger panel refresh (panel reads from GameState on update) - if panel.has_method("refresh"): - panel.refresh() - elif panel.has_method("_refresh"): - panel._refresh() - - # Panel should show at least one fact entry - # Implementation detail: "FactsList" or "FactsContainer" — adjust once scene exists - var facts_node := panel.get_node_or_null("FactsList") - if facts_node == null: - facts_node = panel.get_node_or_null("ScrollContainer/FactsList") - if facts_node == null: - facts_node = panel.get_node_or_null("VBoxContainer/FactsList") - - if facts_node != null: - assert_int(facts_node.get_child_count()).override_failure_message( - "Journal panel must show at least one fact entry when player_knowledge is populated" - ).is_greater(0) - else: - push_warning("test_journal_panel_reads_player_knowledge_from_gamestate: FactsList node not found — adjust path after scene creation") + var container := _entries_container(panel) + if container == null: panel.queue_free(); return + assert_int(container.get_child_count()).override_failure_message( + "EntriesContainer must have children when player_knowledge is populated" + ).is_greater(0) panel.queue_free() -func test_journal_panel_empty_when_no_knowledge() -> void: - ## Journal panel shows nothing when player_knowledge is null/empty. - if not GameState.has("player_knowledge"): - push_warning("test_journal_panel_empty_when_no_knowledge: GameState field not yet added — skip") - return +func test_journal_panel_shows_empty_state_when_no_knowledge() -> void: + ## Empty state Label is shown when player_knowledge is null. var panel := _make_journal_panel() if panel == null: return GameState.player_knowledge = null - if panel.has_method("refresh"): - panel.refresh() - elif panel.has_method("_refresh"): - panel._refresh() + panel.toggle() - var facts_node := panel.get_node_or_null("FactsList") - if facts_node == null: - facts_node = panel.get_node_or_null("ScrollContainer/FactsList") - if facts_node == null: - facts_node = panel.get_node_or_null("VBoxContainer/FactsList") + var container := _entries_container(panel) + if container == null: panel.queue_free(); return - if facts_node != null: - assert_int(facts_node.get_child_count()).override_failure_message( - "Journal panel must show no fact entries when player_knowledge is null" - ).is_equal(0) - else: - push_warning("test_journal_panel_empty_when_no_knowledge: FactsList node not found — adjust path after scene creation") + ## Empty state = exactly 1 child (the "Nothing logged yet." label) + assert_int(container.get_child_count()).override_failure_message( + "EntriesContainer should have 1 child (empty state label) when knowledge is null" + ).is_equal(1) + panel.queue_free() + +func test_journal_panel_two_entities_create_more_entries() -> void: + ## Two entities create more entries than one (header + detail each, plus spacers). + var panel := _make_journal_panel() + if panel == null: return + + GameState.player_knowledge = _make_player_knowledge([ + _make_kg_entity({"name": "Entity A", "entity_id": 1}), + _make_kg_entity({"name": "Entity B", "entity_id": 2}), + ]) + panel.toggle() + + var container := _entries_container(panel) + if container == null: panel.queue_free(); return + + ## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min. + assert_int(container.get_child_count()).override_failure_message( + "Two entities must create at least 6 child nodes (2 × [header + detail + spacer])" + ).is_greater_equal(6) + panel.queue_free() + + +func test_journal_panel_contradicted_entity_uses_strikethrough() -> void: + ## D-041: Contradicted entities must have strikethrough in their header BBCode. + ## journal_panel.gd renders [s]Name[/s] for Contradicted state. + var panel := _make_journal_panel() + if panel == null: return + + GameState.player_knowledge = _make_player_knowledge([ + _make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}), + ]) + panel.toggle() + + var container := _entries_container(panel) + if container == null: panel.queue_free(); return + + ## First child should be the header RichTextLabel with [s]...[/s] + if container.get_child_count() == 0: + push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip") + panel.queue_free(); return + + var first_child := container.get_child(0) + if first_child is RichTextLabel: + assert_that(first_child.text).override_failure_message( + "Contradicted entity header must contain [s] (strikethrough) BBCode" + ).contains("[s]") + panel.queue_free() + + +func test_journal_panel_active_entity_no_strikethrough() -> void: + ## Active entity must NOT have strikethrough in its header. + var panel := _make_journal_panel() + if panel == null: return + + GameState.player_knowledge = _make_player_knowledge([ + _make_kg_entity({"name": "Good Guy", "state": "Active"}), + ]) + panel.toggle() + + var container := _entries_container(panel) + if container == null: panel.queue_free(); return + + if container.get_child_count() == 0: + push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip") + panel.queue_free(); return + + var first_child := container.get_child(0) + if first_child is RichTextLabel: + assert_bool(first_child.text.contains("[s]")).override_failure_message( + "Active entity header must NOT have strikethrough — only Contradicted gets [s]" + ).is_false() panel.queue_free() # --------------------------------------------------------------------------- -# Journal/dialogue mutual exclusion (sprint-18/client.md) -## "The journal panel must close when dialogue opens and vice versa." +# Journal panel: mutual exclusion with dialogue # --------------------------------------------------------------------------- -func test_gamestate_has_journal_open_field_or_panel_has_close_on_dialogue() -> void: - ## Sprint 18: journal and dialogue cannot be open simultaneously. - ## Either GameState tracks journal_open state, or DialogueBox has a - ## close_journal signal. Verify at least one mechanism exists. - ## - ## This is a structural check — implementation detail may vary. - var has_journal_open_in_gamestate := GameState.has("journal_open") - var dialogue_box_exists := ResourceLoader.exists("res://ui/dialogue_box.tscn") +func test_update_from_state_closes_journal_when_dialogue_active() -> void: + ## Sprint briefing: journal must close when dialogue opens. + ## update_from_state() is called from main.gd on each snapshot. + var panel := _make_journal_panel() + if panel == null: return - if not has_journal_open_in_gamestate and dialogue_box_exists: - # Check if DialogueBox has a signal for this - var box := load("res://ui/dialogue_box.tscn").instantiate() - auto_free(box) - var has_signal := box.has_signal("dialogue_opened") - # Either journal_open in GameState or dialogue_opened signal - if not has_signal: - push_warning("test: no mutual-exclusion mechanism found yet — will fail until #264 implements it") - # We don't hard-assert here because the mechanism is implementation-choice - # The manual test plan catches the actual behavior + panel.toggle() # open journal + assert_bool(panel.is_open()).is_true() - # This test always passes as a documentation checkpoint - assert_bool(true).is_true() + GameState.dialogue_active = true + panel.update_from_state() + + assert_bool(panel.is_open()).override_failure_message( + "Journal must close when GameState.dialogue_active = true (update_from_state() called)" + ).is_false() + panel.queue_free() + + +func test_update_from_state_does_not_close_when_dialogue_inactive() -> void: + ## update_from_state() must NOT close journal when dialogue is not active. + var panel := _make_journal_panel() + if panel == null: return + + panel.toggle() # open journal + GameState.dialogue_active = false + panel.update_from_state() + + assert_bool(panel.is_open()).override_failure_message( + "Journal must stay open when dialogue is inactive" + ).is_true() + panel.queue_free() # --------------------------------------------------------------------------- -# UIStrings: journal keys (D-042) -## All journal labels must use UIStrings autoload. +# Journal panel: CONFIDENCE_LABELS and SOURCE_LABELS constants # --------------------------------------------------------------------------- -func test_ui_strings_has_journal_section() -> void: - ## D-042: journal label strings must be in ui-strings.yaml under "journal". - ## Fails until Stig adds journal strings. - var has_any_journal_key := UIStrings.has_key("journal.title") or - UIStrings.has_key("journal.no_facts") or - UIStrings.has_key("journal.confidence_prefix") - assert_bool(has_any_journal_key).override_failure_message( - "D-042: UIStrings must have at least one 'journal.*' key (add to data/ui-strings.yaml)" +func test_confidence_labels_covers_all_four_levels() -> void: + ## D-041: Four confidence tiers. CONFIDENCE_LABELS must map all four. + var panel := _make_journal_panel() + if panel == null: return + var labels: Dictionary = panel.CONFIDENCE_LABELS + for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]: + assert_bool(labels.has(level)).override_failure_message( + "CONFIDENCE_LABELS must include '%s'" % level + ).is_true() + panel.queue_free() + + +func test_source_labels_covers_known_sources() -> void: + ## D-041: Source types. SOURCE_LABELS must map core sources. + var panel := _make_journal_panel() + if panel == null: return + var labels: Dictionary = panel.SOURCE_LABELS + for source in ["DirectObservation", "ToldBy", "Heard"]: + assert_bool(labels.has(source)).override_failure_message( + "SOURCE_LABELS must include '%s'" % source + ).is_true() + panel.queue_free() + + +func test_confidence_labels_all_non_empty() -> void: + ## All CONFIDENCE_LABELS values must be non-empty strings. + var panel := _make_journal_panel() + if panel == null: return + for key in panel.CONFIDENCE_LABELS: + var val: String = panel.CONFIDENCE_LABELS[key] + assert_bool(val.length() > 0).override_failure_message( + "CONFIDENCE_LABELS['%s'] must be non-empty" % key + ).is_true() + panel.queue_free() + + +# --------------------------------------------------------------------------- +# Journal panel: _state_color() contract +# --------------------------------------------------------------------------- + +func test_state_color_contradicted_is_amber() -> void: + ## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface. + var panel := _make_journal_panel() + if panel == null: return + var color: Color = panel._state_color("Contradicted") + assert_that(color).override_failure_message( + "_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)" + ).is_equal(Constants.ENTITY_COLOR_POI) + panel.queue_free() + + +func test_state_color_stale_is_dimmed() -> void: + ## Stale → dimmed text color (IMPLANT_TEXT_DIM). + var panel := _make_journal_panel() + if panel == null: return + var color: Color = panel._state_color("Stale") + assert_that(color).override_failure_message( + "_state_color('Stale') must return IMPLANT_TEXT_DIM" + ).is_equal(Constants.IMPLANT_TEXT_DIM) + panel.queue_free() + + +func test_state_color_active_is_normal() -> void: + ## Active → normal insert text color (INSERT_COLOR_TEXT). + var panel := _make_journal_panel() + if panel == null: return + var color: Color = panel._state_color("Active") + assert_that(color).override_failure_message( + "_state_color('Active') must return INSERT_COLOR_TEXT" + ).is_equal(Constants.INSERT_COLOR_TEXT) + panel.queue_free() + + +func test_state_color_contradicted_differs_from_active() -> void: + ## Contradicted and Active must have visually distinct colors. + var panel := _make_journal_panel() + if panel == null: return + var contradicted := panel._state_color("Contradicted") + var active := panel._state_color("Active") + assert_that(contradicted).is_not_equal(active) + panel.queue_free() + + +func test_state_color_stale_differs_from_active() -> void: + ## Stale and Active must have visually distinct colors. + var panel := _make_journal_panel() + if panel == null: return + var stale := panel._state_color("Stale") + var active := panel._state_color("Active") + assert_that(stale).is_not_equal(active) + panel.queue_free() + + +# --------------------------------------------------------------------------- +# UIStrings: knowledge_panel keys (D-042) +# --------------------------------------------------------------------------- + +func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void: + ## Journal title uses knowledge_panel.tab_contacts. + assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message( + "UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)" ).is_true() -func test_ui_strings_journal_title_key() -> void: - ## Journal panel title string must exist. - if not UIStrings.has_key("journal.title"): - push_warning("test_ui_strings_journal_title_key: 'journal.title' not yet added — skip") - return - var title := UIStrings.get_text("journal.title") - assert_bool(title.length() > 0).is_true() +func test_ui_strings_knowledge_panel_empty_state_exists() -> void: + ## Empty state message uses knowledge_panel.empty_state. + assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message( + "UIStrings must have 'knowledge_panel.empty_state' key (D-042)" + ).is_true() -func test_ui_strings_confidence_labels_present() -> void: - ## Journal displays KnowledgeConfidence as human-readable labels. - ## Each confidence level must have a UIStrings entry. - var expected_keys := [ - "journal.confidence.suspects", - "journal.confidence.knows_of", - "journal.confidence.knows_details", - "journal.confidence.direct", - ] - var missing: Array[String] = [] - for key in expected_keys: - if not UIStrings.has_key(key): - missing.append(key) - if not missing.is_empty(): - push_warning("test_ui_strings_confidence_labels_present: missing keys: %s — skip until added" % str(missing)) - # Document-only: no hard assert while feature is unimplemented +func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void: + if not UIStrings.has_key("knowledge_panel.empty_state"): return + assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true() + + +func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void: + if not UIStrings.has_key("knowledge_panel.tab_contacts"): return + assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true() # --------------------------------------------------------------------------- -# Constants: CANVAS_INSERT layer for journal -## Journal panel (insert-layer UI, D-013) must be on CanvasLayer 10. +# D-042 finding: CONFIDENCE_LABELS is hardcoded, not UIStrings +## The journal panel uses a hardcoded CONFIDENCE_LABELS dict rather than UIStrings. +## D-042 requires all labels via UIStrings YAML. +## Filed as a finding — not a blocking test, but documents the gap. +# --------------------------------------------------------------------------- + +func test_d042_finding_confidence_labels_not_in_uistrings() -> void: + ## D-042 gap: journal_panel.gd uses hardcoded CONFIDENCE_LABELS dict. + ## Expected per D-042: UIStrings should drive confidence display labels. + ## ui-strings.yaml has confidence_high/medium/low/rumor but not Direct/KnowsDetails/etc. + ## These keys are not mapped to UIStrings. Flagging as technical debt. + ## + ## This test PASSES because we're documenting the gap, not requiring it to be fixed now. + ## See: journal_panel.gd CONFIDENCE_LABELS vs data/ui-strings.yaml knowledge_panel section. + var panel := _make_journal_panel() + if panel == null: return + var has_confidence_labels: bool = panel.CONFIDENCE_LABELS.size() > 0 + assert_bool(has_confidence_labels).is_true() # Hardcoded dict exists + # D-042 finding: confidence labels should use UIStrings.get_text("knowledge_panel.confidence_*") + # Current state: CONFIDENCE_LABELS = {"Direct": "Confirmed", "KnowsDetails": "Detailed", ...} + # Recommendation: replace with UIStrings lookup in a future sprint + panel.queue_free() + + +# --------------------------------------------------------------------------- +# Constants # --------------------------------------------------------------------------- func test_canvas_insert_constant_is_10() -> void: - ## Journal panel is a diegetic insert — must be on layer 10. - ## Sanity check that CANVAS_INSERT hasn't drifted. - assert_int(Constants.CANVAS_INSERT).override_failure_message( - "CANVAS_INSERT must be 10 (insert layer, D-013)" - ).is_equal(10) + assert_int(Constants.CANVAS_INSERT).is_equal(10) -# --------------------------------------------------------------------------- -# Minimap: structural sanity (for Hoshe #151 verification when done) -## These run as no-ops until Stig finishes task #1. -# --------------------------------------------------------------------------- - -func test_minimap_scene_path_is_documented() -> void: - ## Document the expected scene path for minimap renderer. - ## Actual structural tests are in test_minimap_sprint18.gd (to be created - ## once Stig confirms the scene name/path). - var likely_paths := [ - "res://ui/minimap_renderer.tscn", - "res://ui/minimap.tscn", - "res://scenes/minimap_renderer.tscn", - ] - var found := false - for path in likely_paths: - if ResourceLoader.exists(path): - found = true - break - if not found: - push_warning("test_minimap_scene_path: minimap scene not yet created — waiting for Stig's #151 implementation") - # No hard assert: minimap is in_progress - - -func test_gamestate_poi_list_field_exists_or_in_entities() -> void: - ## Sprint 18 #151: POI data must be accessible from GameState.discovered_pois. - ## Stig #151: GameState accepts "poi_list" and "discovered_pois" snapshot keys. - ## Both map to GameState.discovered_pois for MinimapRenderer consumption. - - # Apply snapshot with poi_list field (Sprint 17 server wire name) - GameState.apply_snapshot({ - "tick": 1, - "poi_list": [ - {"id": "poi_001", "x": 50, "y": 30, "poi_category": "Location", "label": "Exit A"}, - {"id": "poi_002", "x": 80, "y": 15, "poi_category": "Contact", "label": "Kael's Office"}, - ], - }) - - # GameState.discovered_pois must be populated from either "poi_list" or "discovered_pois" - assert_int(GameState.discovered_pois.size()).override_failure_message( - "GameState.discovered_pois must be populated from snapshot 'poi_list' field (#148/#149)" - ).is_greater(0) +func test_journal_fade_constants_reasonable() -> void: + ## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI. + var panel := _make_journal_panel() + if panel == null: return + assert_float(panel.FADE_IN).is_between(0.0, 0.5) + assert_float(panel.FADE_OUT).is_between(0.0, 0.5) + panel.queue_free() diff --git a/client/ui/journal_panel.gd b/client/ui/journal_panel.gd index 2f39ee02e..52255846b 100644 --- a/client/ui/journal_panel.gd +++ b/client/ui/journal_panel.gd @@ -19,22 +19,9 @@ extends Control const FADE_IN: float = 0.18 const FADE_OUT: float = 0.25 -# D-041 confidence → display label -const CONFIDENCE_LABELS: Dictionary = { - "Direct": "Confirmed", - "KnowsDetails": "Detailed", - "KnowsOf": "Known", - "Suspects": "Unconfirmed", -} - -# D-041 source → display label -const SOURCE_LABELS: Dictionary = { - "DirectObservation": "Observed", - "ToldBy": "Told", - "Heard": "Overheard", - "Inferred": "Inferred", - "Background": "Prior", -} +# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml). +# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower} +# Fallback: raw value if key not found (UIStrings returns the key itself). @onready var panel: PanelContainer = $PanelContainer @onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel @@ -135,8 +122,8 @@ func _add_entity_entry(entity: Dictionary) -> void: rel_label = relationship # fallback if key missing var state_color: Color = _state_color(state) - var conf_label: String = CONFIDENCE_LABELS.get(confidence, confidence) - var src_label: String = SOURCE_LABELS.get(source, source) + var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower()) + var src_label: String = _resolve_source_label(source) # Build BBCode: # [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color] @@ -174,6 +161,39 @@ func _add_entity_entry(entity: Dictionary) -> void: entries_container.add_child(spacer) +## Resolve a source string from the server into a human-readable label. +## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format. +## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name. +func _resolve_source_label(source: String) -> String: + if source.is_empty(): + return "" + + # ToldBy(entity_id) format — e.g. "ToldBy(12)" + if source.begins_with("ToldBy(") and source.ends_with(")"): + var id_str: String = source.substr(7, source.length() - 8) + var entity_id: int = id_str.to_int() + var name := _entity_name_for_id(entity_id) + var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby") + if told_prefix == "knowledge_panel.source_toldby": + told_prefix = "Told" + return "%s: %s" % [told_prefix, name] + + # Plain source key — look up UIStrings + var key: String = "knowledge_panel.source_%s" % source.to_lower() + return UIStrings.get_text(key) + + +## Look up an entity name by stable ID from GameState.player_knowledge.entities. +func _entity_name_for_id(entity_id: int) -> String: + var knowledge: Variant = GameState.player_knowledge + if knowledge == null: + return "#%d" % entity_id + for entity in knowledge.get("entities", []): + if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id: + return entity.get("name", "#%d" % entity_id) + return "#%d" % entity_id + + func _state_color(state: String) -> Color: match state: "Contradicted": diff --git a/client/ui/minimap.gd b/client/ui/minimap.gd index 0f27ee575..ecfa129dc 100644 --- a/client/ui/minimap.gd +++ b/client/ui/minimap.gd @@ -91,7 +91,7 @@ func _draw() -> void: var dx: float = float(poi.x) - px var dy: float = float(poi.y) - py var dist: float = sqrt(dx * dx + dy * dy) - var category: String = poi.get("poi_category", "") + var category: String = poi.get("category", "") var color: Color = _category_color(category) if dist < 0.01: diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 793d99d65..f52b5b5bb 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -42,7 +42,7 @@ pub const PROTOCOL_VERSION: u8 = 14; /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 13. + /// Protocol version for forward compatibility. Current: 14. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index e308ac550..4ee8cf03f 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -72,7 +72,7 @@ pub fn compute_observer_snapshot( Entity, &TilePosition, Option<&Facing>, - &KnowledgeGraph, + Ref, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, @@ -120,6 +120,10 @@ pub fn compute_observer_snapshot( return; }; + // D-041 dirty flag: only re-serialize KG dump when observer's graph changed this tick. + let kg_changed = observer_kg.is_changed(); + let observer_kg: &KnowledgeGraph = &observer_kg; + let facing = facing_opt .map(|f| f.0) .unwrap_or(FacingDirection::default()); @@ -290,8 +294,10 @@ pub fn compute_observer_snapshot( .collect(); // Build player knowledge dump for journal panel (#264, D-041). - // Sends full KG state — client-side filtering for display grouping. - let player_knowledge = { + // Only re-serialize when the observer's KG was mutated this tick (Changed filter). + // When unchanged, player_knowledge = None → field omitted from wire (skip_serializing_if). + // Client keeps its last value (apply_snapshot only updates when field is present). + let player_knowledge = if kg_changed { let kg_entities: Vec = observer_kg .known_entities_iter() .map(|(sid, ek)| { @@ -363,6 +369,8 @@ pub fn compute_observer_snapshot( facts: kg_facts, }) } + } else { + None }; buffer.snapshot = Some(ObserverSnapshot {