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
@@ -0,0 +1,153 @@
## Sprint 23 #573: dialogue speaker color binding tests.
##
## Verifies entity-ID-bound speaker color assignment in dialogue_box.gd:
## round-robin palette allocation, same-entity reuse, conversation-end reset,
## and fallback behavior when no entity ID is provided.
##
## D-030: fixture-based, server-free, no subprocess required.
class_name TestDialogueSpeakerColors
extends GdUnitTestSuite
func _make_dialogue_box() -> Control:
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
push_warning("TestDialogueSpeakerColors: dialogue_box.tscn not found — scene tests skipped")
return null
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
add_child(node)
return node
func before_test() -> void:
GameState.dialogue_active = false
func after_test() -> void:
GameState.dialogue_active = false
# -- _assign_npc_color: round-robin assignment ---------------------------------
func test_assign_npc_color_returns_palette_color() -> void:
## First call for an entity ID should return a color from the NPC palette.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color: Color = box._assign_npc_color(100)
assert_that(color).override_failure_message(
"_assign_npc_color must return a non-default color for a valid entity ID (#573)"
).is_not_equal(box._speech_color)
func test_assign_npc_color_same_entity_returns_same_color() -> void:
## Repeated calls for the same entity ID must return the same color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color1: Color = box._assign_npc_color(200)
var color2: Color = box._assign_npc_color(200)
assert_that(color1).override_failure_message(
"_assign_npc_color must return the same color for the same entity ID (#573)"
).is_equal(color2)
func test_assign_npc_color_different_entities_get_different_colors() -> void:
## Different entity IDs should get different colors (within palette size).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color1: Color = box._assign_npc_color(300)
var color2: Color = box._assign_npc_color(301)
assert_that(color1).override_failure_message(
"Different entity IDs must get different palette colors (#573)"
).is_not_equal(color2)
func test_assign_npc_color_negative_id_returns_speech_color() -> void:
## Negative entity ID (no entity) should fall back to _speech_color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
var color: Color = box._assign_npc_color(-1)
assert_that(color).override_failure_message(
"_assign_npc_color(-1) must return _speech_color fallback (#573)"
).is_equal(box._speech_color)
# -- Color registry cleared on conversation end --------------------------------
func test_color_registry_cleared_on_conversation_end() -> void:
## After hide_dialogue(), the color registry must be empty so next
## conversation starts fresh (avoids palette exhaustion).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [], 400)
assert_bool(box._npc_entity_colors.has(400)).override_failure_message(
"Entity color should be registered during conversation (#573)"
).is_true()
box.hide_dialogue()
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
"_npc_entity_colors must be cleared after conversation ends (#573)"
).is_true()
assert_int(box._next_npc_color).override_failure_message(
"_next_npc_color must reset to 0 after conversation ends (#573)"
).is_equal(0)
func test_color_registry_reset_gives_fresh_assignment() -> void:
## After conversation end + new conversation, same entity ID gets a color
## (may differ from previous conversation — that's fine, per-conversation).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [], 500)
var color1: Color = box._npc_entity_colors.get(500, Color.BLACK)
box.hide_dialogue()
box.show_dialogue("NPC", "Hi again.", [], 500)
var color2: Color = box._npc_entity_colors.get(500, Color.BLACK)
# Both should be valid palette colors (not BLACK fallback)
assert_that(color1).override_failure_message(
"First conversation color must be a palette color (#573)"
).is_not_equal(Color.BLACK)
assert_that(color2).override_failure_message(
"Second conversation color must be a palette color (#573)"
).is_not_equal(Color.BLACK)
# -- show_dialogue entity ID threading ----------------------------------------
func test_show_dialogue_registers_npc_color() -> void:
## show_dialogue with a valid entity ID must register the color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("Kael", "Welcome.", [], 600)
assert_bool(box._npc_entity_colors.has(600)).override_failure_message(
"show_dialogue must register entity color when npc_entity_id provided (#573)"
).is_true()
func test_show_dialogue_without_entity_id_no_registration() -> void:
## show_dialogue without entity ID should not register any color.
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.show_dialogue("NPC", "Hello.", [])
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
"show_dialogue without entity ID must not register colors (#573)"
).is_true()
# -- append_dialogue_response defensive guard ----------------------------------
func test_append_dialogue_response_registers_color_if_missing() -> void:
## append_dialogue_response with a valid entity_id must register the color
## even if show_dialogue was not called first (defensive guard).
var box := _make_dialogue_box()
if box == null: return
auto_free(box)
box.append_dialogue_response("Voss", "I see.", 700)
assert_bool(box._npc_entity_colors.has(700)).override_failure_message(
"append_dialogue_response must register color for unknown entity ID (#573)"
).is_true()