fix(ui): clear color registry on conversation end, add speaker color tests (#573)

- Reset _npc_entity_colors/_npc_entity_id/_next_npc_color in _end_player_conversation()
  to prevent palette exhaustion across long sessions with many unique NPCs
- Re-enforce contrast floor after passive desaturation (_enforce_contrast after _desaturate)
- Add TestDialogueSpeakerColors suite: palette allocation, entity reuse, reset, fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 09:50:02 +01:00
co-authored by Claude Sonnet 4.6
parent 974bb2b28e
commit d30ab62bc0
2 changed files with 168 additions and 3 deletions
+15 -3
View File
@@ -48,6 +48,8 @@ var _npc_name: String = ""
# -- Entity color registry (#573) --
# Maps entity_id → Color for dialogue participants.
# Assigned from _npc_colors palette on first encounter; player uses _player_color.
# v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid
# palette exhaustion (8 entries) across long sessions with 9+ NPCs.
var _npc_entity_colors: Dictionary = {} # entity_id -> Color
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
@@ -194,6 +196,7 @@ func _update_layout() -> void:
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
func append_line(speaker: String, target: String, text: String, is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
var pinned := not is_passive and _in_player_conversation
var entry: Dictionary = {
@@ -280,6 +283,8 @@ func append_player_line(target_npc: String, text: String) -> void:
## Append an NPC follow-up line (from dialogue_response).
## Note: expects show_dialogue() to have been called first to set _npc_entity_id.
## Defensive: if entity_id is valid but not yet registered, _assign_npc_color handles it.
func append_dialogue_response(npc_name: String, text: String, entity_id: int = -1) -> void:
if entity_id >= 0:
_assign_npc_color(entity_id)
@@ -335,6 +340,11 @@ func _end_player_conversation() -> void:
entry.timestamp_msec = now
_log_dirty = true
# #573: Clear per-conversation color registry to avoid palette exhaustion
_npc_entity_colors.clear()
_npc_entity_id = -1
_next_npc_color = 0
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
audio_dip_cleared.emit()
@@ -544,10 +554,10 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
var text: String = _escape_bbcode(entry.text)
var is_passive: bool = entry.is_passive
# Desaturate passive name colours (Araminta review)
# Desaturate passive name colours (Araminta review), re-enforce contrast floor after
if is_passive:
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
target_color = _desaturate(target_color, PASSIVE_DESATURATION)
speaker_color = _enforce_contrast(_desaturate(speaker_color, PASSIVE_DESATURATION))
target_color = _enforce_contrast(_desaturate(target_color, PASSIVE_DESATURATION))
var sc := _color_with_alpha(speaker_color, alpha)
var ac := _color_with_alpha(_arrow_color, alpha)
@@ -576,6 +586,8 @@ static func _escape_bbcode(text: String) -> String:
## Assign a palette color to an NPC entity ID on first encounter (#573).
## Returns the same color on subsequent calls for the same entity ID.
## TODO D-033 Phase 2: derive from relationship color — current independent palette
## will need alignment when relationship-based entity colors arrive.
func _assign_npc_color(entity_id: int) -> Color:
if entity_id < 0:
return _speech_color