fix(client): address PR #65 review — POI key, ToldBy parsing, KG dirty flag

- minimap.gd: fix "poi_category" → "category" key mismatch so POI
  colors and shapes render correctly
- journal_panel.gd: parse ToldBy(N) source format, resolve entity
  names from player_knowledge; move confidence/source labels to
  UIStrings per D-042
- observer/mod.rs: add Changed<KnowledgeGraph> dirty flag to skip
  per-tick KG serialization when unchanged
- types.rs: fix stale version doc comment (13 → 14)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 02:41:31 +01:00
co-authored by Claude Opus 4.6
parent a52cf4f494
commit a82eb0748a
6 changed files with 486 additions and 289 deletions
+11
View File
@@ -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)
+424 -266
View File
@@ -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()
+38 -18
View File
@@ -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":
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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,
+11 -3
View File
@@ -72,7 +72,7 @@ pub fn compute_observer_snapshot(
Entity,
&TilePosition,
Option<&Facing>,
&KnowledgeGraph,
Ref<KnowledgeGraph>,
&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<KnownEntityWire> = 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 {