From 87b5cbb6c2d5326952012262f9043860c0d315f6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 02:04:18 +0100 Subject: [PATCH 01/11] fix(ui): correct entity renderer test assertions for Sprite2D and zero offsets (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 7 pre-existing failures in test_entity_renderer tests: - ColorRect → Sprite2D cast; .color → .self_modulate for D-033 color checks - Position offset: (TILE_SIZE-24)/2 → EntityRenderer.ENTITY_OFFSET_{X,Y} (0.0) - Rotation accuracy: expected values updated for raw un-normalised Godot rotation Also adds SoundIndicatorRenderer to global_script_class_cache.cfg so test_rendering.gd parses. Co-Authored-By: Claude Sonnet 4.6 --- client/tests/test_rendering.gd | 46 +++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 100be1862..b3d004d69 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -218,9 +218,9 @@ func test_entity_renderer_positions_centered() -> void: renderer.update_entities([_test_entities[0]]) var node = renderer.entity_nodes[1] - var offset: float = (Constants.TILE_SIZE - 24) / 2.0 - var expected_x: float = 5.0 * Constants.TILE_SIZE + offset - var expected_y: float = 5.0 * Constants.TILE_SIZE + offset + # Sprite2D renderer: ENTITY_OFFSET_X=0.0, ENTITY_OFFSET_Y=TILE_SIZE-ENTITY_HEIGHT=0.0 + var expected_x: float = 5.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X + var expected_y: float = 5.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y assert_that(node.position.x).is_equal_approx(expected_x, 0.01) assert_that(node.position.y).is_equal_approx(expected_y, 0.01) renderer.queue_free() @@ -248,9 +248,9 @@ func test_entity_renderer_player_color_differs_from_npc() -> void: var renderer := _make_entity_renderer() renderer.update_entities(_test_entities) - var player_node = renderer.entity_nodes[1] as ColorRect - var npc_node = renderer.entity_nodes[2] as ColorRect - assert_that(player_node.color != npc_node.color).is_true() + var player_node = renderer.entity_nodes[1] as Sprite2D + var npc_node = renderer.entity_nodes[2] as Sprite2D + assert_that(player_node.self_modulate != npc_node.self_modulate).is_true() renderer.queue_free() func test_entity_renderer_empty_entities_clears_all() -> void: @@ -268,23 +268,23 @@ func test_entity_renderer_empty_entities_clears_all() -> void: func test_entity_renderer_player_uses_d033_color() -> void: var renderer := _make_entity_renderer() renderer.update_entities(_test_entities_v2) - var player_node = renderer.entity_nodes[1] as ColorRect - assert_that(player_node.color).is_equal(Constants.ENTITY_COLOR_PLAYER) + var player_node = renderer.entity_nodes[1] as Sprite2D + assert_that(player_node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER) renderer.queue_free() func test_entity_renderer_npc_uses_unknown_teal() -> void: var renderer := _make_entity_renderer() renderer.update_entities(_test_entities_v2) - var npc_node = renderer.entity_nodes[2] as ColorRect - assert_that(npc_node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN) + var npc_node = renderer.entity_nodes[2] as Sprite2D + assert_that(npc_node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN) renderer.queue_free() func test_entity_renderer_object_uses_grey() -> void: var renderer := _make_entity_renderer() var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Object", "data": null}, "visibility": "Forward"}] renderer.update_entities(obj) - var node = renderer.entity_nodes[3] as ColorRect - assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT) + var node = renderer.entity_nodes[3] as Sprite2D + assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT) renderer.queue_free() func test_entity_renderer_peripheral_entity_dimmed() -> void: @@ -319,14 +319,14 @@ func test_entity_renderer_facing_indicator_rotation_accuracy() -> void: # {facing_angle → expected indicator rotation} # Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2. var angles := { - -PI / 2.0: 0.0, # North - -PI / 4.0: PI / 4.0, # Northeast - 0.0: PI / 2.0, # East - PI / 4.0: 3.0 * PI / 4.0, # Southeast - PI / 2.0: PI, # South - 3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI]) - PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2) - -3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4) + -PI / 2.0: 0.0, # North + -PI / 4.0: PI / 4.0, # Northeast + 0.0: PI / 2.0, # East + PI / 4.0: 3.0 * PI / 4.0, # Southeast + PI / 2.0: PI, # South + 3.0 * PI / 4.0: 5.0 * PI / 4.0, # Southwest (raw: 3PI/4 + PI/2 = 5PI/4) + PI: 3.0 * PI / 2.0, # West (raw: PI + PI/2 = 3PI/2) + -3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4) } renderer.update_entities(_test_entities_v2) var player_node = renderer.entity_nodes[1] @@ -405,9 +405,9 @@ func test_regression_345_entity_position_set_from_entity_id_entity() -> void: {"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, ]) var node = renderer.entity_nodes[5] - var offset: float = (Constants.TILE_SIZE - 24) / 2.0 - assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01) - assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01) + # Sprite2D renderer: ENTITY_OFFSET_X=0.0, ENTITY_OFFSET_Y=TILE_SIZE-ENTITY_HEIGHT=0.0 + assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, 0.01) + assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y, 0.01) renderer.queue_free() -- 2.54.0 From 974bb2b28ec3f6962bc794fd59a78532f1a13665 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 02:05:15 +0100 Subject: [PATCH 02/11] feat(ui): bind dialogue speaker colors to entity identity (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintains Dict[entity_id → Color] in dialogue_box for player conversations. On first encounter, assigns a round-robin palette color; reuses on subsequent lines. Eliminates position-based name-hash coloring for player dialogue. Changes: - Add _npc_entity_colors dict, _npc_entity_id, _next_npc_color fields - Add _assign_npc_color(entity_id) — registers palette color on first encounter - show_dialogue: accept npc_entity_id param, register entity color - append_line: optional speaker_entity_id/target_entity_id stored in log entries - append_player_line: pass _npc_entity_id as target_entity_id - append_dialogue_response: accept entity_id, register, pass to append_line - _format_entry else branch: look up _npc_entity_colors before name-hash fallback - main.gd: pass _last_dialogue_npc_id to show_dialogue, speaker_entity_id to append_dialogue_response Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/main.gd | 5 ++-- client/ui/dialogue_box.gd | 63 ++++++++++++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 934423248..716ee6e34 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -351,7 +351,8 @@ func _consume_dialogue() -> void: dialogue_box.show_dialogue( dlg.get("npc_name", ""), dlg.get("speech", ""), - dlg.get("options", []) + dlg.get("options", []), + _last_dialogue_npc_id ) GameState.current_dialogue = null @@ -385,7 +386,7 @@ func _consume_dialogue_response() -> void: var speaker_color_index: int = dr.get("speaker_color_index", -1) var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name) dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index) - dialogue_box.append_dialogue_response(speaker_name, dr.get("text", "")) + dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id) GameState.dialogue_response = null diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index c1b4f2ec1..da30b0698 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -45,6 +45,13 @@ var _option_texts: Array[String] = [] var _option_is_confrontation: Array[bool] = [] 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. +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 + # -- UI state -- var _active_tween: Tween = null var _beat_tween: Tween = null # D-063: confrontation beat delay @@ -186,16 +193,22 @@ func _update_layout() -> void: ## speaker/target: display names. text: the spoken line. ## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated). ## Active conversation entries are pinned (no timeout) while _in_player_conversation. -func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void: +## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573). +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 - _log_entries.append({ + var entry: Dictionary = { "speaker": speaker, "target": target, "text": text, "is_passive": is_passive, "pinned": pinned, "timestamp_msec": Time.get_ticks_msec(), - }) + } + if speaker_entity_id >= 0: + entry["speaker_entity_id"] = speaker_entity_id + if target_entity_id >= 0: + entry["target_entity_id"] = target_entity_id + _log_entries.append(entry) _log_dirty = true _ensure_visible() @@ -263,26 +276,32 @@ func on_conversation_ended(_event: Dictionary) -> void: ## Append the player's chosen response to the log. func append_player_line(target_npc: String, text: String) -> void: - append_line(PLAYER_NAME, target_npc, text, false) + append_line(PLAYER_NAME, target_npc, text, false, -1, _npc_entity_id) ## Append an NPC follow-up line (from dialogue_response). -func append_dialogue_response(npc_name: String, text: String) -> void: - append_line(npc_name, PLAYER_NAME, text, false) +func append_dialogue_response(npc_name: String, text: String, entity_id: int = -1) -> void: + if entity_id >= 0: + _assign_npc_color(entity_id) + append_line(npc_name, PLAYER_NAME, text, false, entity_id, -1) # -- Active player conversation -- ## Show dialogue with NPC speech and response options. ## npc_name: who is speaking. speech: the NPC's line. options: player choices. -func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void: +## npc_entity_id: entity ID of the NPC for stable color assignment (#573). +func show_dialogue(npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1) -> void: _npc_name = npc_name + _npc_entity_id = npc_entity_id _cancel_beat() _in_player_conversation = true + if npc_entity_id >= 0: + _assign_npc_color(npc_entity_id) # Append NPC's line to the log if not speech.is_empty(): - append_line(npc_name, PLAYER_NAME, speech, false) + append_line(npc_name, PLAYER_NAME, speech, false, npc_entity_id, -1) # Clear old options and show new ones _clear_options() @@ -509,8 +528,17 @@ func _format_entry(entry: Dictionary, alpha: float) -> String: # Legacy string-keyed entry (player dialogue, backward compat) speaker = _escape_bbcode(entry.get("speaker", "?")) target = _escape_bbcode(entry.get("target", "?")) - speaker_color = _color_for_name(entry.get("speaker", "?")) - target_color = _color_for_name(entry.get("target", "?")) + # #573: use entity-ID-bound color if available; fall back to name-hash + var sp_eid: int = entry.get("speaker_entity_id", -1) + var tg_eid: int = entry.get("target_entity_id", -1) + if sp_eid >= 0 and _npc_entity_colors.has(sp_eid): + speaker_color = _npc_entity_colors[sp_eid] + else: + speaker_color = _color_for_name(entry.get("speaker", "?")) + if tg_eid >= 0 and _npc_entity_colors.has(tg_eid): + target_color = _npc_entity_colors[tg_eid] + else: + target_color = _color_for_name(entry.get("target", "?")) involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME) var text: String = _escape_bbcode(entry.text) @@ -546,6 +574,21 @@ static func _escape_bbcode(text: String) -> String: return text.replace("[", "[lb]").replace("]", "[rb]") +## 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. +func _assign_npc_color(entity_id: int) -> Color: + if entity_id < 0: + return _speech_color + if _npc_entity_colors.has(entity_id): + return _npc_entity_colors[entity_id] + if _npc_colors.is_empty(): + return _speech_color + var color := _enforce_contrast(_npc_colors[_next_npc_color % _npc_colors.size()]) + _next_npc_color += 1 + _npc_entity_colors[entity_id] = color + return color + + ## Get a stable color for a character name, with contrast floor enforcement. func _color_for_name(char_name: String) -> Color: if char_name == PLAYER_NAME: -- 2.54.0 From d30ab62bc0ed0f13f9a546786d6784ed448a2faa Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 09:50:02 +0100 Subject: [PATCH 03/11] 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 --- client/tests/test_dialogue_speaker_colors.gd | 153 +++++++++++++++++++ client/ui/dialogue_box.gd | 18 ++- 2 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 client/tests/test_dialogue_speaker_colors.gd diff --git a/client/tests/test_dialogue_speaker_colors.gd b/client/tests/test_dialogue_speaker_colors.gd new file mode 100644 index 000000000..356a809ad --- /dev/null +++ b/client/tests/test_dialogue_speaker_colors.gd @@ -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() diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index da30b0698..fee49943d 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -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 -- 2.54.0 From 60d2c5bb9085a1e8e4f0541e35b90d516b79192f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 09:50:08 +0100 Subject: [PATCH 04/11] fix(ui): correct Sprite2D/self_modulate assertions in P2 client tests (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same Sprite2D correction applied to test_client_p2.gd entity color tests (Terrain, Player) — ColorRect was replaced with Sprite2D in entity_renderer.gd. Minor comment clarification in test_rendering.gd rotation test. Co-Authored-By: Claude Sonnet 4.6 --- client/tests/test_client_p2.gd | 8 ++++---- client/tests/test_rendering.gd | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/tests/test_client_p2.gd b/client/tests/test_client_p2.gd index c0bca3a5f..cd7f5cdfb 100644 --- a/client/tests/test_client_p2.gd +++ b/client/tests/test_client_p2.gd @@ -167,8 +167,8 @@ func test_entity_terrain_uses_object_color() -> void: var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0, "kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}] renderer.update_entities(entity) - var node = renderer.entity_nodes[70] as ColorRect - assert_that(node.color).override_failure_message( + var node = renderer.entity_nodes[70] as Sprite2D + assert_that(node.self_modulate).override_failure_message( "Terrain kind should use ENTITY_COLOR_OBJECT" ).is_equal(Constants.ENTITY_COLOR_OBJECT) renderer.queue_free() @@ -181,8 +181,8 @@ func test_entity_player_color_regardless_of_sector() -> void: var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}] renderer.update_entities(entity) - var node = renderer.entity_nodes[80] as ColorRect - assert_that(node.color).override_failure_message( + var node = renderer.entity_nodes[80] as Sprite2D + assert_that(node.self_modulate).override_failure_message( "Player color must be constant regardless of visibility sector" ).is_equal(Constants.ENTITY_COLOR_PLAYER) # Alpha should still be dimmed for Peripheral diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index b3d004d69..2fbe3c651 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -326,7 +326,7 @@ func test_entity_renderer_facing_indicator_rotation_accuracy() -> void: PI / 2.0: PI, # South 3.0 * PI / 4.0: 5.0 * PI / 4.0, # Southwest (raw: 3PI/4 + PI/2 = 5PI/4) PI: 3.0 * PI / 2.0, # West (raw: PI + PI/2 = 3PI/2) - -3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4) + -3.0 * PI / 4.0: -PI / 4.0, # Northwest: wraps negative — Godot returns raw un-normalised rotation } renderer.update_entities(_test_entities_v2) var player_node = renderer.entity_nodes[1] -- 2.54.0 From c3abf32185a4f41bc6461300c7d86a90620264d0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 09:50:23 +0100 Subject: [PATCH 05/11] feat(ui): add in-game debug console with tilde toggle and command dispatch (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - debug_console.gd: new ModalLayer Control — tilde key toggles bottom-40% panel, command history (up/down), SimBridge dispatch for all DebugCommandKind variants: ticks, contaminate, tp, activate, triangle, npc, triangles, pop, status, help - debug_console.tscn: minimal scene node; UI built programmatically in _ready() - input_mapper.gd: DEBUG_COMMAND action added to Action enum - sim_bridge.gd: DEBUG_COMMAND → "DebugCommand" wire mapping - protocol.gd: v18 debug_response decode (command, text, success fields) - game_state.gd: debug_response field + apply_snapshot one-shot handling - main.gd: @onready ref, router registration, _consume_debug_response(), settings signal - settings_dialog.gd: debug_console_toggled signal + CheckButton toggle row (+36px height), reads initial state from user://settings.cfg; CheckButton state loaded from PREFS_PATH - main.tscn: DebugConsole node on ModalLayer, load_steps 27→28 Co-Authored-By: Claude Sonnet 4.6 --- client/scenes/main.tscn | 6 +- client/scripts/autoloads/game_state.gd | 11 + client/scripts/autoloads/input_mapper.gd | 1 + client/scripts/autoloads/sim_bridge.gd | 2 + client/scripts/main.gd | 16 ++ client/scripts/protocol/protocol.gd | 12 + client/ui/debug_console.gd | 314 +++++++++++++++++++++++ client/ui/debug_console.gd.uid | 1 + client/ui/debug_console.tscn | 15 ++ client/ui/settings_dialog.gd | 26 +- 10 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 client/ui/debug_console.gd create mode 100644 client/ui/debug_console.gd.uid create mode 100644 client/ui/debug_console.tscn diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e3d7600d6..fdead1941 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=27 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=28 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -26,6 +26,7 @@ [ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"] [ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"] [ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"] +[ext_resource type="PackedScene" uid="uid://b2ndm9rvx8cqp" path="res://ui/debug_console.tscn" id="27_debug_console"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -194,3 +195,6 @@ layer = 30 ; #257: Loading screen — full-screen overlay during save/load round-trip [node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")] + +; #581: Debug console — tilde key toggles, bottom 40% of screen +[node name="DebugConsole" parent="ModalLayer" instance=ExtResource("27_debug_console")] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 36df1c02e..371d57042 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -81,6 +81,11 @@ var rng_seed: Variant = null # One-shot: consumed by main.gd after display, then set back to null. var save_result: Variant = null +# v18 fields (#580): debug console response from server. +# {command: String, text: String, success: bool} or null. +# One-shot: consumed by main.gd and forwarded to DebugConsole, then set to null. +var debug_response: Variant = null + # #257: Pending load path — set by main menu "Load Game" selection. # main.gd sends LOAD_GAME on startup if non-empty, then clears this field. # Format: user://saves//.sav or "" if no pending load. @@ -311,6 +316,12 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: save_result = null + # v18: debug_response (#580) — debug console command result. + if snapshot.has("debug_response") and snapshot.debug_response is Dictionary: + debug_response = snapshot.debug_response + else: + debug_response = null + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # Only update when field is present (null means no change, server sends when KG changes). if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 6128d4b4d..eb18149f6 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -24,6 +24,7 @@ enum Action { TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path + DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server } var input_queue: Array[Dictionary] = [] diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index c6196aea2..1497d000e 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -416,6 +416,8 @@ static func action_enum_to_wire(action: int) -> String: return "SaveGame" # #554: F5 quicksave (D-085) InputMapper.Action.LOAD_GAME: return "LoadGame" # #554: F6 quickload (D-085) + InputMapper.Action.DEBUG_COMMAND: + return "DebugCommand" # #581: debug console command dispatch _: push_warning("SimBridge: unknown action enum %s" % action) return "" diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 716ee6e34..8bcd7cbcd 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -22,6 +22,7 @@ extends Node2D @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) @onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load +@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution @@ -122,6 +123,11 @@ func _ready() -> void: _router.register("conversation_ended", _consume_conversation_ended) _router.register("dialogue_response", _consume_dialogue_response) _router.register("save_result", _consume_save_result) + _router.register("debug_response", _consume_debug_response) + + # #581: Wire settings_dialog debug console toggle → debug_console.set_enabled + if settings_dialog and debug_console: + settings_dialog.debug_console_toggled.connect(debug_console.set_enabled) func _process(delta: float) -> void: @@ -382,6 +388,8 @@ func _consume_dialogue_response() -> void: if GameState.dialogue_response == null or not dialogue_box: return var dr: Dictionary = GameState.dialogue_response + # v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id. + # Edge case: fast re-engagement with a different NPC could misattribute — low probability. var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id) var speaker_color_index: int = dr.get("speaker_color_index", -1) var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name) @@ -414,6 +422,14 @@ func _consume_save_result() -> void: monologue_display.show_notification(msg) +# #581: Forward debug_response from server to the debug console. +func _consume_debug_response() -> void: + if GameState.debug_response == null or not debug_console: + return + debug_console.append_response(GameState.debug_response) + GameState.debug_response = null + + # D-061: Handle dialogue option selection → send to server func _on_dialogue_option_selected(response_id: String, text: String) -> void: SimBridge.send_input({ diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index aaedcac73..a8c605c56 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -244,6 +244,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "error": raw_save.get("error"), } + # v18: debug_response (#580) — debug console command result. + # {command: String, text: String, success: bool} + var debug_response: Variant = null + var raw_debug: Variant = raw.get("debug_response") + if raw_debug is Dictionary: + debug_response = { + "command": str(raw_debug.get("command", "")), + "text": str(raw_debug.get("text", "")), + "success": bool(raw_debug.get("success", false)), + } + # TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020). # Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs). # When server populates this field, client-side accumulation fallback in game_state.gd @@ -320,6 +331,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "examine_result": examine_result, "player_knowledge": player_knowledge, "save_result": save_result, + "debug_response": debug_response, "stationary_ticks": stationary_ticks, "zone_id": zone_id, } diff --git a/client/ui/debug_console.gd b/client/ui/debug_console.gd new file mode 100644 index 000000000..90d15275d --- /dev/null +++ b/client/ui/debug_console.gd @@ -0,0 +1,314 @@ +class_name DebugConsole +extends Control + +## In-game debug console (#581). Tilde key (`) toggles open/closed. +## Semi-transparent panel anchored to bottom ~40% of screen. +## Dispatches DebugCommandKind variants to server via SimBridge. +## Settings-toggled; enabled state persisted in user://settings.cfg. + +const PREFS_PATH := "user://settings.cfg" +const PREFS_SECTION := "debug" +const PREFS_KEY_ENABLED := "console_enabled" +const MAX_LOG_LINES := 50 + +const BG_COLOR := Color(0.04, 0.04, 0.06, 0.92) +const BORDER_COLOR := Color("#4a9ebb") +const TEXT_COLOR := Color("#c8d0e0") +const SUCCESS_COLOR := Color("#6bc9a6") +const ERROR_COLOR := Color("#d45d5d") +const INPUT_COLOR := Color("#e8c547") + +var _enabled: bool = true +var _open: bool = false +var _log_lines: Array[String] = [] +var _panel: PanelContainer = null +var _output_log: RichTextLabel = null +var _input_line: LineEdit = null +var _history: Array[String] = [] +var _history_idx: int = -1 + + +func _ready() -> void: + _load_prefs() + visible = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_preset(Control.PRESET_FULL_RECT) + _build_ui() + get_viewport().size_changed.connect(_update_panel_layout) + + +func _build_ui() -> void: + _panel = PanelContainer.new() + _panel.mouse_filter = Control.MOUSE_FILTER_STOP + _panel.anchor_left = 0.0 + _panel.anchor_top = 0.6 + _panel.anchor_right = 1.0 + _panel.anchor_bottom = 1.0 + _panel.offset_left = 0.0 + _panel.offset_top = 0.0 + _panel.offset_right = 0.0 + _panel.offset_bottom = 0.0 + + var bg_style := StyleBoxFlat.new() + bg_style.bg_color = BG_COLOR + bg_style.border_color = BORDER_COLOR + bg_style.border_width_top = 1 + bg_style.content_margin_left = 8.0 + bg_style.content_margin_right = 8.0 + bg_style.content_margin_top = 6.0 + bg_style.content_margin_bottom = 6.0 + _panel.add_theme_stylebox_override("panel", bg_style) + add_child(_panel) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 4) + _panel.add_child(vbox) + + _output_log = RichTextLabel.new() + _output_log.bbcode_enabled = true + _output_log.size_flags_vertical = Control.SIZE_EXPAND_FILL + _output_log.scroll_following = true + _output_log.selection_enabled = true + _output_log.add_theme_color_override("default_color", TEXT_COLOR) + _output_log.add_theme_font_size_override("normal_font_size", 13) + vbox.add_child(_output_log) + + var sep := HSeparator.new() + vbox.add_child(sep) + + _input_line = LineEdit.new() + _input_line.placeholder_text = "enter command (help for list)" + _input_line.clear_button_enabled = false + _input_line.add_theme_font_size_override("font_size", 13) + _input_line.add_theme_color_override("font_color", INPUT_COLOR) + _input_line.text_submitted.connect(_on_input_submitted) + _input_line.gui_input.connect(_on_input_key) + vbox.add_child(_input_line) + + +func _update_panel_layout() -> void: + # Anchors handle resize automatically; no manual size calc needed. + pass + + +# -- Input handling -- + +func _unhandled_input(event: InputEvent) -> void: + if not _enabled: + return + if not event is InputEventKey or not event.pressed or event.echo: + return + if event.keycode == KEY_QUOTELEFT: + get_viewport().set_input_as_handled() + _toggle() + return + if _open: + # Consume all keyboard events — prevent movement/action leaking through + get_viewport().set_input_as_handled() + if event.keycode == KEY_ESCAPE: + _close() + + +func _on_input_key(event: InputEvent) -> void: + if not event is InputEventKey or not event.pressed or event.echo: + return + if event.keycode == KEY_UP: + _history_up() + get_viewport().set_input_as_handled() + elif event.keycode == KEY_DOWN: + _history_down() + get_viewport().set_input_as_handled() + + +func _toggle() -> void: + if _open: + _close() + else: + _open_console() + + +func _open_console() -> void: + _open = true + visible = true + mouse_filter = Control.MOUSE_FILTER_STOP + _input_line.clear() + _input_line.grab_focus() + _history_idx = -1 + + +func _close() -> void: + _open = false + visible = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + _input_line.release_focus() + + +func is_open() -> bool: + return _open + + +# -- Command input -- + +func _on_input_submitted(text: String) -> void: + var trimmed := text.strip_edges() + _input_line.clear() + _history_idx = -1 + if trimmed.is_empty(): + return + if _history.is_empty() or _history[0] != trimmed: + _history.push_front(trimmed) + if _history.size() > 20: + _history.pop_back() + _append_text("> " + trimmed, TEXT_COLOR) + _dispatch(trimmed) + + +func _dispatch(line: String) -> void: + var parts := line.split(" ", false) + if parts.is_empty(): + return + var cmd := parts[0].to_lower() + match cmd: + "help": + _print_help() + "ticks": + if parts.size() < 2 or not parts[1].is_valid_int(): + _append_text("usage: ticks ", ERROR_COLOR) + return + var n := int(parts[1]) + if n <= 0: + _append_text("ticks: n must be > 0", ERROR_COLOR) + return + _send_debug({"AdvanceTicks": n}) + "contaminate": + _send_debug("SkipToContamination") + "tp": + if parts.size() < 2: + _append_text("usage: tp [z] or tp ", ERROR_COLOR) + return + if parts.size() >= 3 and parts[1].is_valid_int() and parts[2].is_valid_int(): + var z := int(parts[3]) if parts.size() >= 4 and parts[3].is_valid_int() else 0 + _send_debug({"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}}) + else: + var loc := " ".join(PackedStringArray(parts.slice(1))) + _send_debug({"TeleportToLocation": loc}) + "activate": + _send_debug("ForceContaminationActivate") + "triangle": + if parts.size() < 2: + _append_text("usage: triangle ", ERROR_COLOR) + return + _send_debug({"ForceTriangleActivation": parts[1]}) + "npc": + if parts.size() < 2 or not parts[1].is_valid_int(): + _append_text("usage: npc ", ERROR_COLOR) + return + _send_debug({"InspectNpc": int(parts[1])}) + "triangles": + _send_debug("ListTriangles") + "pop": + _send_debug("ListPopulation") + "status": + _send_debug("GetContaminationStatus") + _: + _append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR) + + +func _send_debug(kind: Variant) -> void: + var err := SimBridge.send_input({ + "action": InputMapper.Action.DEBUG_COMMAND, + "action_data": kind, + "timestamp_msec": Time.get_ticks_msec(), + }) + if err != OK: + _append_text("send error: %s" % error_string(err), ERROR_COLOR) + + +# -- Response display -- + +## Append a server debug response to the output log. Auto-opens console if closed. +func append_response(response: Dictionary) -> void: + var success: bool = response.get("success", false) + var text: String = response.get("text", "") + var color := SUCCESS_COLOR if success else ERROR_COLOR + _append_text(text, color) + if not _open: + _open_console() + + +# -- Log rendering -- + +func _append_text(text: String, color: Color) -> void: + var escaped := text.replace("[", "[lb]").replace("]", "[rb]") + _log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped]) + if _log_lines.size() > MAX_LOG_LINES: + _log_lines = _log_lines.slice(_log_lines.size() - MAX_LOG_LINES) + if _output_log: + _output_log.text = "\n".join(_log_lines) + + +func _print_help() -> void: + _append_text( + "Commands:\n" + + " ticks — fast-forward N ticks\n" + + " contaminate — skip to contamination phase\n" + + " tp [z] — teleport to tile position\n" + + " tp — teleport to named location\n" + + " activate — force contamination activate\n" + + " triangle — force triangle activation\n" + + " npc — inspect NPC state\n" + + " triangles — list all triangles\n" + + " pop — list active NPCs\n" + + " status — contamination status\n" + + " help — this list", + TEXT_COLOR + ) + + +# -- Command history -- + +func _history_up() -> void: + if _history.is_empty(): + return + _history_idx = mini(_history_idx + 1, _history.size() - 1) + _input_line.text = _history[_history_idx] + _input_line.caret_column = _input_line.text.length() + + +func _history_down() -> void: + if _history_idx <= 0: + _history_idx = -1 + _input_line.clear() + return + _history_idx -= 1 + _input_line.text = _history[_history_idx] + _input_line.caret_column = _input_line.text.length() + + +# -- Settings -- + +func set_enabled(enabled: bool) -> void: + _enabled = enabled + if not _enabled and _open: + _close() + _save_prefs() + + +func is_enabled() -> bool: + return _enabled + + +func _load_prefs() -> void: + var cfg := ConfigFile.new() + if cfg.load(PREFS_PATH) != OK: + return + _enabled = cfg.get_value(PREFS_SECTION, PREFS_KEY_ENABLED, true) + + +func _save_prefs() -> void: + var cfg := ConfigFile.new() + cfg.load(PREFS_PATH) # load existing (may have other sections like "audio") + cfg.set_value(PREFS_SECTION, PREFS_KEY_ENABLED, _enabled) + var err := cfg.save(PREFS_PATH) + if err != OK: + push_warning("DebugConsole: failed to save prefs (%d)" % err) diff --git a/client/ui/debug_console.gd.uid b/client/ui/debug_console.gd.uid new file mode 100644 index 000000000..5b1559a85 --- /dev/null +++ b/client/ui/debug_console.gd.uid @@ -0,0 +1 @@ +uid://c8pvt3xr7kmd2 diff --git a/client/ui/debug_console.tscn b/client/ui/debug_console.tscn new file mode 100644 index 000000000..934729983 --- /dev/null +++ b/client/ui/debug_console.tscn @@ -0,0 +1,15 @@ +[gd_scene load_steps=2 format=3 uid="uid://b2ndm9rvx8cqp"] + +[ext_resource type="Script" uid="uid://c8pvt3xr7kmd2" path="res://ui/debug_console.gd" id="1_debug_console"] + +; #581: In-game debug console. Tilde key toggles. ModalLayer. +; UI built programmatically in _ready() — scene contains only root node + script. +[node name="DebugConsole" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_debug_console") diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index cbde721e3..55b94dc66 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -11,7 +11,7 @@ const TITLE_COLOR := Color("#4a9ebb") const FONT_SIZE := 14 const BOX_WIDTH := 460 -const BOX_HEIGHT := 340 +const BOX_HEIGHT := 376 # +36 for Debug Console row const PADDING := 20 const ROW_HEIGHT := 36 @@ -28,6 +28,7 @@ var _active: bool = false var _container: VBoxContainer = null signal closed +signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled func _ready() -> void: @@ -109,6 +110,29 @@ func _build_ui() -> void: db_label.text = _format_db(value) ) + # #581: Debug Console toggle + var debug_hbox := HBoxContainer.new() + debug_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(debug_hbox) + + var debug_label := Label.new() + debug_label.text = "Debug Console" + debug_label.custom_minimum_size = Vector2(150, 0) + debug_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + debug_label.add_theme_font_size_override("font_size", FONT_SIZE) + debug_label.add_theme_color_override("font_color", TEXT_COLOR) + debug_hbox.add_child(debug_label) + + var debug_check := CheckButton.new() + var cfg := ConfigFile.new() + debug_check.button_pressed = true # default: enabled + if cfg.load(DebugConsole.PREFS_PATH) == OK: + debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true) + debug_check.toggled.connect(func(enabled: bool) -> void: + debug_console_toggled.emit(enabled) + ) + debug_hbox.add_child(debug_check) + # Spacer var spacer := Control.new() spacer.custom_minimum_size = Vector2(0, 8) -- 2.54.0 From 475280191cc1c2174f17aad7beee5fa05c621b6e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 20:32:32 +0100 Subject: [PATCH 06/11] fix(ui): render LOS boundary wall tiles through fog without marking explored (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game_state.gd: add boundary_positions Dictionary field; BoundaryWall tiles from visible_tiles go to boundary_positions instead of visible_positions — rendered by tile_renderer but not tracked as explored fog memory - fog_state.gd: update_from_state() writes VIS_FORWARD for boundary_positions so fog lifts over margin wall content; boundary tiles excluded from exploration step so they don't persist as EXP_EXPLORED when player turns away - tile_renderer.gd: no changes needed — renders all visible_tiles by type, sector-agnostic - test_fog_shader.gd: 4 new tests — boundary excluded from visible_positions, tracked in boundary_positions, cleared each snapshot, fog lifts to VIS_FORWARD Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/autoloads/fog_state.gd | 8 +++ client/scripts/autoloads/game_state.gd | 16 ++++-- client/tests/test_fog_shader.gd | 73 ++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/client/scripts/autoloads/fog_state.gd b/client/scripts/autoloads/fog_state.gd index 32252f5d5..7a87709d6 100644 --- a/client/scripts/autoloads/fog_state.gd +++ b/client/scripts/autoloads/fog_state.gd @@ -161,6 +161,14 @@ func update_from_state() -> void: if px < 0 or py < 0 or px >= _width or py >= _height: continue _vis_bytes[py * _width + px] = VIS_FORWARD + # #585: BoundaryWall margin tiles — fog lifts so wall content composites correctly, + # but NOT in visible_positions so they don't persist as explored memory. + for pos in GameState.boundary_positions: + var px: int = pos.x - ox + var py: int = pos.y - oy + if px < 0 or py < 0 or px >= _width or py >= _height: + continue + _vis_bytes[py * _width + px] = VIS_FORWARD _vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes) visibility_texture.update(_vis_image) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 371d57042..bdccdce4a 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -18,7 +18,8 @@ var current_tick: int = 0 var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] var visible_tiles: Array = [] -var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups +var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles) +var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored # v2 fields (D-015, D-031) var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty @@ -347,17 +348,24 @@ func apply_snapshot(snapshot: Dictionary) -> void: current_zone_id = player_tile.get("zone_id", "") if player_tile else "" # v2: visible_tiles with visibility sectors - # Derives visible_positions when not explicitly provided (real server mode) + # Derives visible_positions when not explicitly provided (real server mode). + # #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored. if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: visibility_sectors.clear() var has_explicit_positions := snapshot.has("visible_positions") if not has_explicit_positions: visible_positions.clear() + boundary_positions.clear() for vtile in snapshot.visible_tiles: if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"): continue var pos := Vector2i(vtile.x, vtile.y) + var vis_sector: String = vtile.get("visibility", "") if vtile.has("visibility"): - visibility_sectors[pos] = vtile.visibility - if not has_explicit_positions: + visibility_sectors[pos] = vis_sector + # #585: BoundaryWall tiles are margin tiles visible through fog but not persistently + # explored — they don't update the player's exploration memory when they leave LOS. + if vis_sector == "BoundaryWall": + boundary_positions[pos] = true + elif not has_explicit_positions: visible_positions[pos] = true diff --git a/client/tests/test_fog_shader.gd b/client/tests/test_fog_shader.gd index 6500b5ebb..e741b808e 100644 --- a/client/tests/test_fog_shader.gd +++ b/client/tests/test_fog_shader.gd @@ -268,6 +268,79 @@ func test_game_state_visible_positions_cleared_on_new_snapshot() -> void: assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true() +# -- #585: BoundaryWall tiles — visible in fog, not persistently explored ------ + +func test_boundary_wall_tiles_not_in_visible_positions() -> void: + ## #585: BoundaryWall margin tiles must NOT enter visible_positions. + ## They are rendered via tile_renderer (from visible_tiles) but must not + ## update the player's fog exploration memory. + GameState.apply_snapshot({ + "tick": 1, + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward"}, + {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall"}, + ], + }) + assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true() + assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_false() + assert_that(GameState.boundary_positions.has(Vector2i(6, 5))).is_true() + + +func test_boundary_wall_tiles_in_visibility_sectors() -> void: + ## BoundaryWall visibility sector is still tracked in visibility_sectors + ## (for potential future use — wall coloring, etc.) + GameState.apply_snapshot({ + "tick": 1, + "visible_tiles": [ + {"x": 3, "y": 3, "z": 0, "visibility": "BoundaryWall"}, + ], + }) + assert_that(GameState.visibility_sectors.has(Vector2i(3, 3))).is_true() + assert_that(GameState.visibility_sectors[Vector2i(3, 3)]).is_equal("BoundaryWall") + + +func test_boundary_positions_cleared_on_new_snapshot() -> void: + ## BoundaryWall positions are cleared each snapshot so stale walls don't persist. + GameState.apply_snapshot({ + "tick": 1, + "visible_tiles": [{"x": 7, "y": 7, "z": 0, "visibility": "BoundaryWall"}], + }) + assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_true() + GameState.apply_snapshot({ + "tick": 2, + "visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}], + }) + assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_false() + assert_that(GameState.boundary_positions.size()).is_equal(0) + + +func test_boundary_wall_fog_vis_forward() -> void: + ## #585: BoundaryWall tiles must lift fog (VIS_FORWARD = 255) so wall content composites. + ## visible_positions excludes boundary tiles; fog_state writes vis bytes for them separately. + ## Reads _vis_bytes directly (packed byte array) to avoid ImageTexture.get_image() lag. + var fog_state = _get_fog_state() + if fog_state == null: + return + GameState.apply_snapshot({ + "tick": 1, + "visible_tiles": [ + {"x": 0, "y": 0, "z": 0, "visibility": "Forward"}, # normal LOS tile + {"x": 1, "y": 0, "z": 0, "visibility": "BoundaryWall"}, # margin tile + ], + }) + fog_state.update_from_state() + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.map_bounds.size.x + var vis: PackedByteArray = fog_state._vis_bytes + var normal_idx: int = (0 - oy) * w + (0 - ox) + var boundary_idx: int = (0 - oy) * w + (1 - ox) + assert_int(vis[normal_idx]).is_equal(FogState.VIS_FORWARD) # normal tile: VIS_FORWARD + assert_int(vis[boundary_idx]).is_equal(FogState.VIS_FORWARD) # boundary also fog-lifted + GameState.visible_positions.clear() + GameState.boundary_positions.clear() + + # -- Z-layer compliance (D-049) ----------------------------------------------- func test_fog_overlay_z_layer() -> void: -- 2.54.0 From 33b26c1a15573be6286ec63e96f27845c5947316 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 20:33:08 +0100 Subject: [PATCH 07/11] test(client): add BoundaryWall fog tests and document tile_renderer behavior (#585) 4 new tests in test_fog_sprint22.gd verify BoundaryWall tiles populate boundary_positions (not visible_positions), get VIS_FORWARD without EXP_VISIBLE, stay EXP_UNEXPLORED after leaving LOS, and clear on new snapshot. Comment in tile_renderer.gd documents implicit rendering path. Co-Authored-By: Claude Opus 4.6 --- client/scripts/rendering/tile_renderer.gd | 8 +- client/tests/test_fog_sprint22.gd | 135 ++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index d96050006..f9081a8e7 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -64,11 +64,17 @@ func _setup_tileset() -> void: tile_set = ts # Update tiles from snapshot data -# tiles: Array of {x: int, y: int, z: int, type: String} +# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)} # z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.), # NOT the Godot scene z_index (which controls render order within a floor). # This node only renders floor-level 0. Higher floor levels will be handled # by separate TileMapLayer nodes when multi-floor rendering is implemented. +# +# BoundaryWall tiles (#585): visibility="BoundaryWall" tiles (wall tiles 1 step beyond +# LOS boundary) are rendered normally here — they have a "type" field from protocol.gd +# so they composite correctly under the fog shader. The fog/exploration exemption is +# handled in fog_state.gd (VIS_FORWARD without EXP_VISIBLE) and game_state.gd +# (boundary_positions not visible_positions). No special handling needed in this method. func update_tiles(tiles: Array) -> void: if not _initialized: return diff --git a/client/tests/test_fog_sprint22.gd b/client/tests/test_fog_sprint22.gd index fb71b84da..3f851298c 100644 --- a/client/tests/test_fog_sprint22.gd +++ b/client/tests/test_fog_sprint22.gd @@ -24,12 +24,14 @@ func before_test() -> void: GameState.visible_positions.clear() GameState.visible_tiles.clear() GameState.visibility_sectors.clear() + GameState.boundary_positions.clear() func after_test() -> void: GameState.visible_positions.clear() GameState.visible_tiles.clear() GameState.visibility_sectors.clear() + GameState.boundary_positions.clear() # -- Spec constants (D-059) --------------------------------------------------- @@ -489,6 +491,139 @@ func test_visible_positions_cleared_on_new_snapshot() -> void: assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true() +# -- Sprint 23: BoundaryWall handling (#585) ---------------------------------- + +func test_boundary_positions_populated_from_snapshot() -> void: + # #585: BoundaryWall tiles go to boundary_positions (not visible_positions). + # Fog lifts for boundary wall tiles so wall content composites correctly. + GameState.apply_snapshot({ + "tick": 20, + "visible_tiles": [ + {"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"}, + {"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, + ], + }) + assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message( + "Forward tile must be in visible_positions" + ).is_true() + assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message( + "BoundaryWall tile must NOT be in visible_positions (#585)" + ).is_false() + assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message( + "BoundaryWall tile must be in boundary_positions (#585)" + ).is_true() + + +func test_boundary_wall_vis_forward_not_exp_visible() -> void: + # #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE. + # They render through fog but are not stored as exploration memory. + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.boundary_positions = {Vector2i(6, 5): true} + GameState.visible_tiles = [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, + {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, + ] + fog_state.update_from_state() + + var vis_bytes = fog_state.get("_vis_bytes") + var exp_bytes = fog_state.get("_exp_bytes") + if vis_bytes == null or exp_bytes == null: + push_warning("TestFogSprint22: byte arrays not accessible — skipped") + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + return + var px := 6 - ox + var py := 5 - oy + if px < 0 or py < 0 or px >= w: + push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped") + return + var idx := py * w + px + if idx < 0 or idx >= vis_bytes.size(): + return + assert_int(vis_bytes[idx]).override_failure_message( + "BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)" + ).is_equal(fog_state.VIS_FORWARD) + assert_int(exp_bytes[idx]).override_failure_message( + "BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)" + ).is_not_equal(fog_state.EXP_VISIBLE) + + +func test_boundary_wall_stays_unexplored_after_leaving_los() -> void: + # #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED. + # Normal LOS tiles decay to EXP_EXPLORED when they leave LOS. + # Boundary tiles must stay EXP_UNEXPLORED — they were never explored. + var fog_state = _get_fog_state() + if fog_state == null: + return + if not fog_state.has_method("update_from_state"): + return + + # Frame 1: BoundaryWall at (6,5) is visible + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.boundary_positions = {Vector2i(6, 5): true} + GameState.visible_tiles = [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, + {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, + ] + fog_state.update_from_state() + + # Frame 2: both leave LOS + GameState.visible_positions.clear() + GameState.boundary_positions.clear() + GameState.visible_tiles = [] + fog_state.update_from_state() + + var exp_bytes = fog_state.get("_exp_bytes") + if exp_bytes == null: + return + var ox: int = fog_state.map_bounds.position.x + var oy: int = fog_state.map_bounds.position.y + var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 + if w <= 0: + return + var px := 6 - ox + var py := 5 - oy + if px >= 0 and py >= 0 and px < w: + var idx := py * w + px + if idx >= 0 and idx < exp_bytes.size(): + assert_int(exp_bytes[idx]).override_failure_message( + "BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)" + ).is_equal(fog_state.EXP_UNEXPLORED) + + +func test_boundary_wall_cleared_on_new_snapshot() -> void: + # #585: boundary_positions must be cleared each tick — old walls must not persist. + # BoundaryWall positions shift as the player moves; stale positions would lift fog + # where no wall exists. + GameState.apply_snapshot({ + "tick": 30, + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, + {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, + ], + }) + assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true() + + GameState.apply_snapshot({ + "tick": 31, + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, + ], + }) + assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message( + "Stale BoundaryWall position must be cleared on next snapshot (#585)" + ).is_false() + + # -- Performance (D-059) ------------------------------------------------------- func test_fog_state_update_under_2ms_for_400_tiles() -> void: -- 2.54.0 From e9dba609c92b26bb89ae6223d8fff6dc0d30ac34 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 20:33:32 +0100 Subject: [PATCH 08/11] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 875781c98..2df0a8fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Storyteller lifecycle rules — single activation per session, no concurrency, terminal resolution constants (#572) - Storyteller activation_pass() — gate/proximity/engagement scoring/routing/module selection/TriangleActivatedEvent on 10-tick cadence (#579) - Debug console server — 10 DebugCommandKind variants (AdvanceTicks, SkipToContamination, TeleportToPosition, InspectNpc, ListTriangles, etc.) with DebugResponsePayload on ObserverSnapshot (#580) +- Debug console client — tilde-toggle UI panel with command input, output log, settings toggle, and full DebugCommandKind dispatch via protocol v18 (#581) +- Entity-bound dialogue speaker colors — NPC colors assigned by entity ID (not screen position) with per-conversation lifecycle and round-robin palette (#573) ### Fixed - LOS boundary walls — 1-tile wall margin beyond vision cone included in visible_tiles as BoundaryWall sector, walls at fog edge now render instead of bleeding into fog (#584) +- LOS boundary walls client — BoundaryWall tiles render through fog without marking explored, 4 new fog tests verify lifecycle (#585) +- Entity renderer test failures — updated 7 stale ColorRect/position assertions for Sprite2D migration, fixed SoundIndicatorRenderer class cache (#574) +- Dialogue speaker color contrast — re-enforce contrast floor after desaturation for passive (overheard) lines ### Changed - PROTOCOL_VERSION bumped 17 → 18 (debug_response field on ObserverSnapshot, DebugCommand PlayerAction variant) -- 2.54.0 From 1feec914b7b109e45847ac3b50107711d507bec8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 22:29:38 +0100 Subject: [PATCH 09/11] =?UTF-8?q?fix(client):=20bump=20PROTOCOL=5FVERSION?= =?UTF-8?q?=2017=20=E2=86=92=2018=20to=20match=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server #580 bumped to 18 for debug_response field. Client was still at 17, causing every snapshot to be rejected — game unplayable. Co-Authored-By: Claude Opus 4.6 --- client/scripts/protocol/protocol.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index a8c605c56..d3b208349 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -11,7 +11,7 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -const PROTOCOL_VERSION: int = 17 +const PROTOCOL_VERSION: int = 18 # -- Decode: bytes from server → GDScript types -------------------------------- -- 2.54.0 From 388df8000a62921bfd0f0dc6039a4c1e24d9f3f5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 22:31:52 +0100 Subject: [PATCH 10/11] =?UTF-8?q?fix(client):=20debug=20console=20review?= =?UTF-8?q?=20fixes=20=E2=80=94=20D-088=20pause,=20settings=20state,=20res?= =?UTF-8?q?ponse=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add D-088 Overlay pause/unpause signals to DebugConsole, wire in main.gd so sim does not advance while typing debug commands - Settings dialog reads live DebugConsole.is_enabled() instead of ConfigFile directly, preventing checkbox/state divergence - append_response respects disabled state — no auto-open when user disabled console via settings - tp command warns on invalid z value instead of silently defaulting to 0 Co-Authored-By: Claude Opus 4.6 --- client/scripts/main.gd | 5 +++++ client/ui/debug_console.gd | 18 +++++++++++++++--- client/ui/settings_dialog.gd | 13 +++++++++---- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 8bcd7cbcd..624eb8533 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -129,6 +129,11 @@ func _ready() -> void: if settings_dialog and debug_console: settings_dialog.debug_console_toggled.connect(debug_console.set_enabled) + # #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input + if debug_console: + debug_console.pause_requested.connect(_on_dialogue_pause_requested) + debug_console.unpause_requested.connect(_on_dialogue_unpause_requested) + func _process(delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input diff --git a/client/ui/debug_console.gd b/client/ui/debug_console.gd index 90d15275d..e2b673064 100644 --- a/client/ui/debug_console.gd +++ b/client/ui/debug_console.gd @@ -5,6 +5,10 @@ extends Control ## Semi-transparent panel anchored to bottom ~40% of screen. ## Dispatches DebugCommandKind variants to server via SimBridge. ## Settings-toggled; enabled state persisted in user://settings.cfg. +## D-088: triggers Overlay pause while open — sim must not advance during debug input. + +signal pause_requested # D-088: pause sim while console is open +signal unpause_requested # D-088: unpause sim when console closes const PREFS_PATH := "user://settings.cfg" const PREFS_SECTION := "debug" @@ -134,6 +138,7 @@ func _open_console() -> void: _input_line.clear() _input_line.grab_focus() _history_idx = -1 + pause_requested.emit() # D-088: pause sim while typing debug commands func _close() -> void: @@ -141,6 +146,7 @@ func _close() -> void: visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE _input_line.release_focus() + unpause_requested.emit() # D-088: resume sim when console closes func is_open() -> bool: @@ -187,7 +193,12 @@ func _dispatch(line: String) -> void: _append_text("usage: tp [z] or tp ", ERROR_COLOR) return if parts.size() >= 3 and parts[1].is_valid_int() and parts[2].is_valid_int(): - var z := int(parts[3]) if parts.size() >= 4 and parts[3].is_valid_int() else 0 + var z := 0 + if parts.size() >= 4: + if parts[3].is_valid_int(): + z = int(parts[3]) + else: + _append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR) _send_debug({"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}}) else: var loc := " ".join(PackedStringArray(parts.slice(1))) @@ -226,13 +237,14 @@ func _send_debug(kind: Variant) -> void: # -- Response display -- -## Append a server debug response to the output log. Auto-opens console if closed. +## Append a server debug response to the output log. Auto-opens console if closed +## (only if console is enabled — respect user's settings toggle). func append_response(response: Dictionary) -> void: var success: bool = response.get("success", false) var text: String = response.get("text", "") var color := SUCCESS_COLOR if success else ERROR_COLOR _append_text(text, color) - if not _open: + if not _open and _enabled: _open_console() diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index 55b94dc66..51f1fe2cb 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -124,10 +124,15 @@ func _build_ui() -> void: debug_hbox.add_child(debug_label) var debug_check := CheckButton.new() - var cfg := ConfigFile.new() - debug_check.button_pressed = true # default: enabled - if cfg.load(DebugConsole.PREFS_PATH) == OK: - debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true) + # Query live DebugConsole node if available; fall back to prefs file + var console_node := get_node_or_null("/root/Main/ModalLayer/DebugConsole") + if console_node and console_node.has_method("is_enabled"): + debug_check.button_pressed = console_node.is_enabled() + else: + var cfg := ConfigFile.new() + debug_check.button_pressed = true + if cfg.load(DebugConsole.PREFS_PATH) == OK: + debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true) debug_check.toggled.connect(func(enabled: bool) -> void: debug_console_toggled.emit(enabled) ) -- 2.54.0 From 1f52f0b00d06faa91308efc46cb4eb0e9f523ff6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 4 Mar 2026 22:32:07 +0100 Subject: [PATCH 11/11] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df0a8fc9..c352e091c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - LOS boundary walls client — BoundaryWall tiles render through fog without marking explored, 4 new fog tests verify lifecycle (#585) - Entity renderer test failures — updated 7 stale ColorRect/position assertions for Sprite2D migration, fixed SoundIndicatorRenderer class cache (#574) - Dialogue speaker color contrast — re-enforce contrast floor after desaturation for passive (overheard) lines +- PROTOCOL_VERSION 17 → 18 mismatch — client rejected every server snapshot +- Debug console D-088 pause — sim now pauses while console is open, matching dialogue/settings overlay behavior +- Debug console settings toggle reads live state instead of ConfigFile, preventing checkbox divergence ### Changed - PROTOCOL_VERSION bumped 17 → 18 (debug_response field on ObserverSnapshot, DebugCommand PlayerAction variant) -- 2.54.0