From 3f5b4258ba3f929c3af905b6aade7405bdd3e0bf Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 2 May 2026 10:19:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20sprint=2038=20=E2=80=94=20free?= =?UTF-8?q?=20camera=20viewer,=20archetype=20strip,=20test=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add free camera mode (F4 toggle): WASD pan, scroll zoom, decoupled from player position (#898) - Strip archetype-driven code: remove character_archetype, lattice_profile, and lattice color palettes from client (#882) - Fix confrontation_monologue signal not firing in headless test mode (#867) - Revive fog state behavioral tests: EXP_EXPLORED persistence, grow-only bounds, texture-resize copy, BoundaryWall handling (#879) - Triage pre-existing test failures: fix examine_display dismiss timing, fog test position fragility, rendering snapshot assertions, time_display format (#871) Co-Authored-By: Claude Opus 4.6 (1M context) --- client/project.godot | 5 + client/scripts/autoloads/game_state.gd | 11 +- client/scripts/autoloads/input_mapper.gd | 2 +- client/scripts/autoloads/session_manager.gd | 26 +- client/scripts/autoloads/sim_bridge.gd | 1 - client/scripts/main.gd | 42 ++- client/scripts/protocol/protocol.gd | 25 +- client/scripts/snapshot_handler.gd | 4 - client/tests/test_client_p1.gd | 27 +- client/tests/test_dialogue_sprint18.gd | 7 +- client/tests/test_fog_sprint22.gd | 392 ++++++++++++++++++++ client/tests/test_monologue_display.gd | 39 -- client/tests/test_rendering.gd | 22 +- client/tests/test_signal_sprint24.gd | 68 +--- client/tests/test_time_display_sprint17.gd | 6 +- client/ui/dialogue_box.gd | 3 +- client/ui/examine_display.gd | 6 +- client/ui/monologue_display.gd | 53 +-- 18 files changed, 504 insertions(+), 235 deletions(-) create mode 100644 client/tests/test_fog_sprint22.gd diff --git a/client/project.godot b/client/project.godot index 8997d2f61..90529a0b8 100644 --- a/client/project.godot +++ b/client/project.godot @@ -124,6 +124,11 @@ stance_down={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null) ] } +free_camera={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} bug_report={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 2be6bba7c..9a4efa52c 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -41,11 +41,6 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs # v5 fields (#414) var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null -# #122 (D-032): Character lattice profile — selects monologue text colour palette. -# "lattice_augmented" = detective, "lattice_baseline" = smuggler. -# Server sends this field as part of the player's capability snapshot. -var lattice_profile: String = "lattice_baseline" - # v6 fields (#449, D-053, D-065) var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch var player_inventory: Array = [] # [{item_id, name, slot}] @@ -94,10 +89,8 @@ var debug_response: Variant = null # Format: user://saves//.sav or "" if no pending load. var pending_load_path: String = "" -# #588: Character archetype chosen at character select screen. -# "detective" or "smuggler". Set before game scene loads; sent in StartupMessage. -# Default: "detective" — fallback for legacy saves without character.txt. -var character_archetype: String = "detective" +# #898: Free camera mode — camera decoupled from player, WASD pans camera directly. +var free_camera_mode: bool = false # #705: Character visual descriptor — set by character_creation.gd on confirmation. # Passed to EntityRenderer for the player entity's CharacterVisual on game start. diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index ce019f291..655a632c9 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -68,7 +68,7 @@ func _process(_delta: float) -> void: # D-054: Update facing angle from mouse position every frame _update_facing_from_mouse() - if GameState.dialogue_active: + if GameState.dialogue_active or GameState.free_camera_mode: return # D-054: Send facing octant to server when it changes (even without movement) diff --git a/client/scripts/autoloads/session_manager.gd b/client/scripts/autoloads/session_manager.gd index 996ca12c0..f5b95844c 100644 --- a/client/scripts/autoloads/session_manager.gd +++ b/client/scripts/autoloads/session_manager.gd @@ -55,12 +55,11 @@ func new_game() -> String: ## Resume an existing game session by setting the active game-id. -## Restores world_seed and character_archetype from the save directory. +## Restores world_seed from the save directory. func resume_game(game_id: String) -> void: GameState.current_game_id = game_id var save_path := SAVES_DIR + game_id + "/" GameState.world_seed = _read_seed_file(save_path) - GameState.character_archetype = _read_archetype_file(save_path) ## List all game directories under user://saves/ sorted by last-modified (most recent first). @@ -171,29 +170,6 @@ func _read_seed_file(save_path: String) -> int: return file.get_64() & 0x7FFFFFFFFFFFFFFF -## Write character_archetype to save directory. Called after new_game() creates the dir. -func save_character_archetype(game_id: String, archetype: String) -> void: - var save_path := SAVES_DIR + game_id + "/" - var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE) - if file == null: - push_error( - ( - "SessionManager: failed to write character.txt: %s" - % error_string(FileAccess.get_open_error()) - ) - ) - return - file.store_string(archetype) - - -## Read character_archetype from save directory. Returns "detective" if missing (legacy saves). -func _read_archetype_file(save_path: String) -> String: - var file := FileAccess.open(save_path + "character.txt", FileAccess.READ) - if file == null: - return "detective" - return file.get_as_text().strip_edges() - - func _find_newest_save(dir_path: String) -> String: var dir := DirAccess.open(dir_path) if dir == null: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index e82566693..36e454c92 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -261,7 +261,6 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns # Server blocks waiting for this before entering the tick loop. var startup_bytes := Protocol.encode_startup_message( GameState.world_seed, - GameState.character_archetype, GameState.character_visual_descriptor ) if startup_bytes.size() > 0: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index c6aa484aa..202253104 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1,6 +1,11 @@ extends Node2D const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 +# #898: Free camera pan speed in pixels/second (unzoomed) and zoom step per scroll tick. +const FREE_CAMERA_PAN_SPEED: float = 400.0 +const FREE_CAMERA_ZOOM_STEP: float = 0.1 +const FREE_CAMERA_ZOOM_MIN: float = 0.5 +const FREE_CAMERA_ZOOM_MAX: float = 8.0 var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry @@ -184,10 +189,31 @@ func _ready() -> void: atlas_app.economics_link_requested.connect(_on_atlas_economics_link) +func _unhandled_input(event: InputEvent) -> void: + # #898: Scroll wheel zoom in free camera mode. + if GameState.free_camera_mode and event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.pressed: + var zoom := camera.zoom + if mb.button_index == MOUSE_BUTTON_WHEEL_UP: + zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP) + elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN: + zoom -= Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP) + camera.zoom = zoom.clamp( + Vector2(FREE_CAMERA_ZOOM_MIN, FREE_CAMERA_ZOOM_MIN), + Vector2(FREE_CAMERA_ZOOM_MAX, FREE_CAMERA_ZOOM_MAX) + ) + get_viewport().set_input_as_handled() + + func _unhandled_key_input(event: InputEvent) -> void: if not (event is InputEventKey) or not event.is_pressed() or event.is_echo(): return var key_event := event as InputEventKey + # #898: F4 toggles free camera mode. + if key_event.physical_keycode == KEY_F4: + GameState.free_camera_mode = not GameState.free_camera_mode + return # Registry-driven toggle: each manifest declares its own default_key. for manifest: ImplantAppManifest in ImplantRegistry.get_manifests(): if manifest.app_path.is_empty(): @@ -239,9 +265,23 @@ func _process(delta: float) -> void: # #559: Dispatch snapshot to registered handlers (router pattern). _router.dispatch(snapshot) + # #898: Free camera WASD pan — runs in place of player tracking. + if GameState.free_camera_mode: + var pan := Vector2.ZERO + if Input.is_action_pressed("move_north"): + pan.y -= 1.0 + if Input.is_action_pressed("move_south"): + pan.y += 1.0 + if Input.is_action_pressed("move_east"): + pan.x += 1.0 + if Input.is_action_pressed("move_west"): + pan.x -= 1.0 + if pan != Vector2.ZERO: + var speed := FREE_CAMERA_PAN_SPEED / camera.zoom.x + camera.global_position += pan.normalized() * speed * delta # Track camera to player (D-015: locked, fixed-north). # #117: Manual exponential smoothing. - if _camera_anchored: + elif _camera_anchored: var target := GameState.player_position * Constants.TILE_SIZE if _teleport_in_progress: camera.global_position = target diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 8180bf935..f1693ceb5 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -613,34 +613,15 @@ static func _decode_enum_variant(raw) -> Dictionary: # -- Encode: GDScript types → bytes to server ---------------------------------- -## Encode a StartupMessage to MessagePack bytes (#175, #588, #718). +## Encode a StartupMessage to MessagePack bytes (#175, #718). ## Sent by the client immediately after handshake validation. -## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032). -## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant). +## Server reads this to initialize SimRng (D-010, D-029). ## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict. static func encode_startup_message( - world_seed: int, character_archetype: String = "detective", character_visual: Variant = null + world_seed: int, character_visual: Variant = null ) -> PackedByteArray: - # Map client lowercase archetype string to server PascalCase enum variant. - # Explicit match prevents unknown strings silently reaching the server as - # garbage enum values — fail loudly and fall back to "Detective". - var archetype_variant: String - match character_archetype: - "detective": - archetype_variant = "Detective" - "smuggler": - archetype_variant = "Smuggler" - _: - push_error( - ( - "Protocol: unknown character_archetype '%s' — defaulting to 'Detective'" - % character_archetype - ) - ) - archetype_variant = "Detective" var msg := { "world_seed": world_seed, - "character_archetype": archetype_variant, } if character_visual != null and character_visual.has_method("to_dict"): msg["character_visual_descriptor"] = character_visual.to_dict() diff --git a/client/scripts/snapshot_handler.gd b/client/scripts/snapshot_handler.gd index 75d1f0e7c..daaf9861c 100644 --- a/client/scripts/snapshot_handler.gd +++ b/client/scripts/snapshot_handler.gd @@ -88,10 +88,6 @@ static func apply(snapshot: Dictionary) -> void: else: GameState.current_monologue = null - # #122: lattice_profile - if snapshot.has("lattice_profile") and snapshot.lattice_profile is String: - GameState.lattice_profile = snapshot.lattice_profile - # v6: player_stance (#449, D-053) if snapshot.has("player_stance") and snapshot.player_stance is String: GameState.player_stance = snapshot.player_stance diff --git a/client/tests/test_client_p1.gd b/client/tests/test_client_p1.gd index eed13bc82..a60b3daab 100644 --- a/client/tests/test_client_p1.gd +++ b/client/tests/test_client_p1.gd @@ -40,15 +40,17 @@ func test_fog_visibility_forward_tile() -> void: var fog = _get_fog_state() if fog == null: return - # Reset to deterministic state — 64x64 map at origin, all bytes zeroed + # Reset to deterministic state — 64x64 map at origin, all bytes zeroed. + # Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within + # the 64x64 box and does not trigger an unexpected _resize() in update_from_state(). GameState.visible_tiles = [] fog._resize(Rect2i(0, 0, 64, 64)) - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.visibility_sectors = {Vector2i(5, 5): "Forward"} + GameState.visible_positions = {Vector2i(10, 10): true} + GameState.visibility_sectors = {Vector2i(10, 10): "Forward"} fog.update_from_state() - # Index: row 5 * width 64 + col 5 - assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message( - "Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD + # Index: row 10 * width 64 + col 10 + assert_that(fog._vis_bytes[10 * 64 + 10]).override_failure_message( + "Forward tile at (10,10) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD ).is_equal(FogState.VIS_FORWARD) GameState.visible_positions.clear() GameState.visibility_sectors.clear() @@ -81,10 +83,12 @@ func test_fog_exploration_persistence() -> void: var fog = _get_fog_state() if fog == null: return + # Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within + # the 64x64 box and does not trigger an unexpected _resize() in update_from_state(). GameState.visible_tiles = [] fog._resize(Rect2i(0, 0, 64, 64)) - var pos := Vector2i(5, 5) - var idx: int = 5 * 64 + 5 + var pos := Vector2i(10, 10) + var idx: int = 10 * 64 + 10 # Frame 1: tile visible GameState.visible_positions = {pos: true} GameState.visibility_sectors = {pos: "Forward"} @@ -118,9 +122,10 @@ func test_fog_hidden_tile_value() -> void: assert_that(fog._vis_bytes[idx]).override_failure_message( "Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN ).is_equal(FogState.VIS_HIDDEN) - # Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.visibility_sectors = {Vector2i(5, 5): "Forward"} + # Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible. + # Use position (10,10): 8-tile padding stays within the 64x64 box, no resize triggered. + GameState.visible_positions = {Vector2i(10, 10): true} + GameState.visibility_sectors = {Vector2i(10, 10): "Forward"} fog.update_from_state() assert_that(fog._vis_bytes[idx]).override_failure_message( "Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN diff --git a/client/tests/test_dialogue_sprint18.gd b/client/tests/test_dialogue_sprint18.gd index df8336657..ce8fb55c9 100644 --- a/client/tests/test_dialogue_sprint18.gd +++ b/client/tests/test_dialogue_sprint18.gd @@ -182,11 +182,10 @@ func test_d063_dim_alpha_is_set() -> void: box.queue_free() -func skip_test_d063_confrontation_signal_fires_on_confrontation_option() -> void: +func test_d063_confrontation_signal_fires_on_confrontation_option() -> void: ## D-063: Selecting a confrontation option fires confrontation_monologue signal. - ## BROKEN (#867): signal_fired stays false in headless; create_tween() before emit - ## may abort _start_confrontation_beat if panel node is null. Bug filed. - ## This delivers the 1-2 second internal monologue beat to MonologueDisplay. + ## Fixed (#867): guard tween_property behind is_instance_valid(panel) so emit fires + ## even in headless mode where the panel node may not be in the scene tree. var box := _make_dialogue_box() if box == null: return diff --git a/client/tests/test_fog_sprint22.gd b/client/tests/test_fog_sprint22.gd new file mode 100644 index 000000000..3f9849186 --- /dev/null +++ b/client/tests/test_fog_sprint22.gd @@ -0,0 +1,392 @@ +## Sprint 22 fog state behavioral tests — revived in Sprint 38 (#879). +## Original: deleted in Sprint 37 (#870 parse-error cleanup). +## Spec refs: D-059, D-066, #569, #585 +## +## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant, +## texture-resize copy, BoundaryWall handling. +## +## Uses FogState autoload directly via /root/FogState — byte-level assertions +## on _vis_bytes and _exp_bytes, consistent with test_fog_shader.gd approach. +class_name TestFogSprint22 +extends GdUnitTestSuite + + +# -- Helpers ------------------------------------------------------------------- + +func _get_fog_state() -> Node: + var node = get_node_or_null("/root/FogState") + if node == null: + push_warning("TestFogSprint22: FogState autoload not found — test skipped") + return node + + +func _reset_fog_state(fog_state: Node) -> void: + GameState.visible_positions.clear() + GameState.boundary_positions.clear() + GameState.visible_tiles.clear() + GameState.visibility_sectors.clear() + fog_state._resize(Rect2i(0, 0, 32, 32)) + + +# -- EXP_EXPLORED persistence -------------------------------------------------- +## D-059: Previously-seen tiles render as "deep fog" (EXP_EXPLORED = 128). +## Once a tile enters LOS, leaving LOS must NOT reset it to EXP_UNEXPLORED. +## This is the core "fog of war memory" invariant. + +func test_exp_explored_persists_after_leaving_los() -> void: + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE + GameState.visible_positions = {Vector2i(2, 2): true} + 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 exp: PackedByteArray = fog_state._exp_bytes + var idx_2_2: int = (2 - oy) * w + (2 - ox) + assert_int(exp[idx_2_2]).override_failure_message( + "D-059: visible tile must have EXP_VISIBLE (255) on first sight" + ).is_equal(FogState.EXP_VISIBLE) + + # Tick 2: tile (2,2) leaves LOS — only (3,3) is visible now + GameState.visible_positions = {Vector2i(3, 3): true} + fog_state.update_from_state() + + # After leaving LOS, (2,2) must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0) + ox = fog_state.map_bounds.position.x + oy = fog_state.map_bounds.position.y + w = fog_state.map_bounds.size.x + exp = fog_state._exp_bytes + idx_2_2 = (2 - oy) * w + (2 - ox) + assert_int(exp[idx_2_2]).override_failure_message( + "D-059: tile leaving LOS must decay to EXP_EXPLORED (128), not EXP_UNEXPLORED (0)" + ).is_equal(FogState.EXP_EXPLORED) + + _reset_fog_state(fog_state) + + +func test_never_seen_tile_stays_unexplored() -> void: + ## Corollary: a tile that was never in LOS stays EXP_UNEXPLORED. + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tile (5,5) never enters LOS + GameState.visible_positions = {Vector2i(2, 2): true} + 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 exp: PackedByteArray = fog_state._exp_bytes + var idx_5_5: int = (5 - oy) * w + (5 - ox) + assert_int(exp[idx_5_5]).override_failure_message( + "D-059: tile never in LOS must remain EXP_UNEXPLORED (0)" + ).is_equal(FogState.EXP_UNEXPLORED) + + _reset_fog_state(fog_state) + + +func test_exp_explored_not_overwritten_by_subsequent_invisible_ticks() -> void: + ## EXP_EXPLORED must not decay further after the player moves away. + ## If the player is never in the area again, the tile stays at EXP_EXPLORED. + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: see tile (4,4) + GameState.visible_positions = {Vector2i(4, 4): true} + fog_state.update_from_state() + + # Tick 2: player moves far away, (4,4) out of LOS + GameState.visible_positions = {Vector2i(20, 20): true} + fog_state.update_from_state() + + # Tick 3: player stays far away + GameState.visible_positions = {Vector2i(20, 20): true} + 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 exp: PackedByteArray = fog_state._exp_bytes + var idx_4_4: int = (4 - oy) * w + (4 - ox) + assert_int(exp[idx_4_4]).override_failure_message( + "D-059: EXP_EXPLORED must not decay further once set — tile stays at 128" + ).is_equal(FogState.EXP_EXPLORED) + + _reset_fog_state(fog_state) + + +# -- Grow-only bounds invariant ------------------------------------------------ +## D-059: map_bounds only ever grows. Previously-explored tiles that leave the +## visible area must not be evicted from the texture. The bounds never shrink. + +func test_bounds_grow_when_player_moves_to_new_area() -> void: + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: small area visible + GameState.visible_positions = {Vector2i(2, 2): true, Vector2i(3, 3): true} + fog_state.update_from_state() + var bounds_after_t1: Rect2i = fog_state.map_bounds + + # Tick 2: player moves to a larger area + GameState.visible_positions = {Vector2i(20, 20): true, Vector2i(25, 25): true} + fog_state.update_from_state() + var bounds_after_t2: Rect2i = fog_state.map_bounds + + # Bounds must have grown or stayed the same — never shrunk + assert_bool(bounds_after_t2.size.x >= bounds_after_t1.size.x).override_failure_message( + "D-059: map_bounds width must never shrink (grow-only invariant)" + ).is_true() + assert_bool(bounds_after_t2.size.y >= bounds_after_t1.size.y).override_failure_message( + "D-059: map_bounds height must never shrink (grow-only invariant)" + ).is_true() + + _reset_fog_state(fog_state) + + +func test_bounds_contain_new_visible_positions() -> void: + ## After update_from_state, all visible positions must lie within map_bounds. + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + GameState.visible_positions = {Vector2i(10, 5): true, Vector2i(15, 12): true} + fog_state.update_from_state() + var bounds: Rect2i = fog_state.map_bounds + + for pos in GameState.visible_positions: + assert_bool(bounds.has_point(pos)).override_failure_message( + "D-059: visible position %s must be within map_bounds %s" % [pos, bounds] + ).is_true() + + _reset_fog_state(fog_state) + + +func test_bounds_encompass_previous_area_after_player_moves() -> void: + ## Old area coordinates must still be within map_bounds after player moves away. + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: see area around (2,2) + GameState.visible_positions = {Vector2i(2, 2): true} + fog_state.update_from_state() + + # Tick 2: player moves far away + GameState.visible_positions = {Vector2i(20, 20): true} + fog_state.update_from_state() + + # The original tile (2,2) must still be within map_bounds + var bounds: Rect2i = fog_state.map_bounds + assert_bool(bounds.has_point(Vector2i(2, 2))).override_failure_message( + "D-059: grow-only — previously-visited area (2,2) must remain within map_bounds" + ).is_true() + + _reset_fog_state(fog_state) + + +# -- Texture-resize copy ------------------------------------------------------- +## D-059: When bounds grow (resize), exploration data from the old bounds +## must be preserved in the new texture at the correct offsets. +## This is the "texture-resize copy" invariant. + +func test_exploration_data_preserved_across_resize() -> void: + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: mark (3,3) as explored + GameState.visible_positions = {Vector2i(3, 3): true} + fog_state.update_from_state() + + # Force a resize by moving far away + GameState.visible_positions = {Vector2i(25, 25): true} + fog_state.update_from_state() + + # After resize, (3,3) must still be EXP_EXPLORED (not reset to EXP_UNEXPLORED) + 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 exp: PackedByteArray = fog_state._exp_bytes + var idx_3_3: int = (3 - oy) * w + (3 - ox) + assert_int(exp[idx_3_3]).override_failure_message( + "D-059: exploration state (EXP_EXPLORED=128) must survive texture resize" + ).is_equal(FogState.EXP_EXPLORED) + + _reset_fog_state(fog_state) + + +func test_newly_added_area_starts_unexplored_after_resize() -> void: + ## When bounds grow to include a new area, those new tiles start as EXP_UNEXPLORED. + ## The copy preserves old data; new tiles get the default (0). + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Establish a small explored area + GameState.visible_positions = {Vector2i(2, 2): true} + fog_state.update_from_state() + + # Move far away to trigger bounds growth + GameState.visible_positions = {Vector2i(30, 30): true} + fog_state.update_from_state() + + # A completely new tile (30,30) on this tick should be EXP_VISIBLE (just entered LOS) + 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 exp: PackedByteArray = fog_state._exp_bytes + var idx_30_30: int = (30 - oy) * w + (30 - ox) + assert_int(exp[idx_30_30]).override_failure_message( + "D-059: tile first entering LOS after resize must be EXP_VISIBLE (255)" + ).is_equal(FogState.EXP_VISIBLE) + + _reset_fog_state(fog_state) + + +# -- BoundaryWall handling (#585) ---------------------------------------------- +## BoundaryWall margin tiles: fog lifts (VIS_FORWARD) so wall content composites, +## but they do NOT persist as explored (not in visible_positions or _exp_bytes). + +func test_boundary_wall_vis_bytes_are_forward() -> void: + ## #585: BoundaryWall tiles must receive VIS_FORWARD in the vis texture + ## so the wall sprite composites correctly (not occluded by fog). + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.boundary_positions = {Vector2i(6, 5): true} + 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 boundary_idx: int = (5 - oy) * w + (6 - ox) + assert_int(vis[boundary_idx]).override_failure_message( + "#585: BoundaryWall tile must have VIS_FORWARD (255) in vis texture" + ).is_equal(FogState.VIS_FORWARD) + + _reset_fog_state(fog_state) + + +func test_boundary_wall_does_not_persist_as_explored() -> void: + ## #585: BoundaryWall tiles must NOT become EXP_EXPLORED after leaving the area. + ## They are rendering artifacts, not player memory. + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: have a boundary wall tile at (6,5) + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.boundary_positions = {Vector2i(6, 5): true} + fog_state.update_from_state() + + # Tick 2: player moves away; (6,5) is no longer a boundary wall + GameState.visible_positions = {Vector2i(20, 20): true} + GameState.boundary_positions.clear() + fog_state.update_from_state() + + # (6,5) must not be EXP_EXPLORED — it was never a true explored tile + 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 exp: PackedByteArray = fog_state._exp_bytes + var boundary_idx: int = (5 - oy) * w + (6 - ox) + assert_int(exp[boundary_idx]).override_failure_message( + "#585: BoundaryWall tile must NOT persist as EXP_EXPLORED — only true LOS tiles are explored" + ).is_equal(FogState.EXP_UNEXPLORED) + + _reset_fog_state(fog_state) + + +func test_normal_tile_adjacent_to_boundary_still_explored() -> void: + ## The normal LOS tile adjacent to a BoundaryWall must still be marked explored. + ## BoundaryWall exclusion must not affect neighboring tiles. + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: normal tile (5,5) in LOS, boundary wall at (6,5) + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.boundary_positions = {Vector2i(6, 5): true} + fog_state.update_from_state() + + # Tick 2: player moves away + GameState.visible_positions = {Vector2i(20, 20): true} + GameState.boundary_positions.clear() + fog_state.update_from_state() + + # Normal tile (5,5) must be EXP_EXPLORED + 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 exp: PackedByteArray = fog_state._exp_bytes + var normal_idx: int = (5 - oy) * w + (5 - ox) + assert_int(exp[normal_idx]).override_failure_message( + "#585: normal LOS tile adjacent to BoundaryWall must still be EXP_EXPLORED (128)" + ).is_equal(FogState.EXP_EXPLORED) + + _reset_fog_state(fog_state) + + +func test_boundary_wall_visibility_only_when_present() -> void: + ## #585: A tile that is a BoundaryWall in tick 1 but absent in tick 2 + ## must have VIS_HIDDEN in tick 2 (fog reapplied). + var fog_state = _get_fog_state() + if fog_state == null: + return + + _reset_fog_state(fog_state) + + # Tick 1: boundary wall at (6,5) + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.boundary_positions = {Vector2i(6, 5): true} + fog_state.update_from_state() + + # Tick 2: player moves far away; (6,5) no longer visible or boundary + GameState.visible_positions = {Vector2i(20, 20): true} + GameState.boundary_positions.clear() + fog_state.update_from_state() + + # (6,5) must be VIS_HIDDEN — fog returned + 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 boundary_idx: int = (5 - oy) * w + (6 - ox) + assert_int(vis[boundary_idx]).override_failure_message( + "#585: BoundaryWall tile must return to VIS_HIDDEN when not in current boundary set" + ).is_equal(FogState.VIS_HIDDEN) + + _reset_fog_state(fog_state) diff --git a/client/tests/test_monologue_display.gd b/client/tests/test_monologue_display.gd index 4eee39917..21787f56d 100644 --- a/client/tests/test_monologue_display.gd +++ b/client/tests/test_monologue_display.gd @@ -4,7 +4,6 @@ ## ## API per Tyre architecture review: ## show_monologue(text, duration, priority=2, is_urgent=false) -## GameState.lattice_profile selects colour palette class_name TestMonologueDisplay extends GdUnitTestSuite @@ -35,14 +34,11 @@ func _label_text(d: Node) -> String: func before_test() -> void: ## Reset GameState fields touched by this suite so tests don't bleed into each other. - ## lattice_profile: tests that care about colour set it explicitly — default to baseline. ## current_monologue: GameState integration tests need null as start state. GameState.current_monologue = null - GameState.lattice_profile = "lattice_baseline" func after_test() -> void: GameState.current_monologue = null - GameState.lattice_profile = "lattice_baseline" # --------------------------------------------------------------------------- @@ -310,34 +306,10 @@ func test_text_has_color_bbcode() -> void: d.queue_free() -# --------------------------------------------------------------------------- -# Lattice colour palette -# --------------------------------------------------------------------------- - -func test_augmented_colour_differs_from_baseline() -> void: - var d = _make_display() - if d == null: return - - GameState.lattice_profile = "lattice_augmented" - d.show_monologue("Detective.", 5.0) - var aug_txt := _label_text(d) - d._visible[0].expire_timer = -0.1; d._process(0.0) - d._next_fade_in_msec = 0.0 - - GameState.lattice_profile = "lattice_baseline" - d.show_monologue("Smuggler.", 5.0) - var base_txt := _label_text(d) - - assert_that(aug_txt).is_not_equal(base_txt) - GameState.lattice_profile = "lattice_baseline" - d.queue_free() - - func test_urgent_colour_differs_from_standard() -> void: var d = _make_display() if d == null: return - GameState.lattice_profile = "lattice_baseline" d.show_monologue("Normal.", 5.0, 2, false) var std_txt := _label_text(d) d._visible[0].expire_timer = -0.1; d._process(0.0) @@ -350,17 +322,6 @@ func test_urgent_colour_differs_from_standard() -> void: d.queue_free() -func test_unknown_profile_falls_back_without_crash() -> void: - var d = _make_display() - if d == null: return - GameState.lattice_profile = "lattice_hypothetical_tier_x" - d.show_monologue("Future proof.", 5.0) - var txt := _label_text(d) - assert_that(txt).contains("[color=#") # fallback colour applied, no crash - GameState.lattice_profile = "lattice_baseline" - d.queue_free() - - # --------------------------------------------------------------------------- # Slot lifecycle # --------------------------------------------------------------------------- diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 3a7f85ff3..b9e86393e 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -123,23 +123,26 @@ func test_game_state_warns_on_missing_player() -> void: # -- SimBridge: test data completeness -- func test_sim_bridge_test_snapshot_has_tiles() -> void: + ## Protocol uses "visible_tiles" (not "tiles") for test snapshot — updated from stale assertion. SimBridge.reset_test_state() var snap = SimBridge._test_snapshot() - assert_that(snap.has("tiles")).is_true() - assert_that(snap.tiles.size()).is_greater(0) - var tile = snap.tiles[0] + assert_that(snap.has("visible_tiles")).is_true() + assert_that(snap.visible_tiles.size()).is_greater(0) + var tile = snap.visible_tiles[0] assert_that(tile.has("x")).is_true() assert_that(tile.has("y")).is_true() assert_that(tile.has("type")).is_true() func test_sim_bridge_test_snapshot_has_visible_positions() -> void: + ## Protocol uses "visible_tiles" for position data — visible_positions is derived client-side. + ## Updated from stale assertion: TestHarness snapshot never had a top-level "visible_positions". SimBridge.reset_test_state() var snap = SimBridge._test_snapshot() - assert_that(snap.has("visible_positions")).is_true() - assert_that(snap.visible_positions.size()).is_greater(0) - var pos = snap.visible_positions[0] - assert_that(pos.has("x")).is_true() - assert_that(pos.has("y")).is_true() + assert_that(snap.has("visible_tiles")).is_true() + assert_that(snap.visible_tiles.size()).is_greater(0) + var vtile = snap.visible_tiles[0] + assert_that(vtile.has("x")).is_true() + assert_that(vtile.has("y")).is_true() func test_sim_bridge_test_snapshot_has_player_entity() -> void: SimBridge.reset_test_state() @@ -162,10 +165,11 @@ func test_sim_bridge_test_snapshot_has_npc() -> void: assert_that(has_npc).is_true() func test_sim_bridge_test_tiles_contain_all_types() -> void: + ## Protocol uses "visible_tiles" — updated from stale "tiles" assertion. SimBridge.reset_test_state() var snap = SimBridge._test_snapshot() var types: Dictionary = {} - for tile in snap.tiles: + for tile in snap.visible_tiles: types[tile.type] = true assert_that(types.has("floor")).is_true() assert_that(types.has("wall")).is_true() diff --git a/client/tests/test_signal_sprint24.gd b/client/tests/test_signal_sprint24.gd index 3bdeed7aa..c71450b29 100644 --- a/client/tests/test_signal_sprint24.gd +++ b/client/tests/test_signal_sprint24.gd @@ -1,79 +1,17 @@ -## Sprint 24 — Signal acceptance tests (#588, #590, #592) +## Sprint 24 — Signal acceptance tests (#590, #592) ## ## Client-side acceptance criteria: -## - #588: character_archetype field in GameState, StartupMessage, SessionManager persistence ## - #590: triangle_crisis_events decoded by Protocol, chimed once per triangle_id ## - #592: news_ticker decode + update_from_state hide/show behavior ## -## Spec: D-032 (monologue pools per character), D-016 (client displays server data only), -## D-042 (UI strings in yaml), D-067 (chime on recognition onset) +## Spec: D-016 (client displays server data only), D-042 (UI strings in yaml), +## D-067 (chime on recognition onset) class_name TestSignalSprint24 extends GdUnitTestSuite const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn") -# -- #588: Character archetype field ------------------------------------------ - -func test_game_state_has_character_archetype_field() -> void: - assert_bool("character_archetype" in GameState).override_failure_message( - "GameState must have a character_archetype field (#588)" - ).is_true() - - -func test_game_state_character_archetype_default_is_detective() -> void: - # Fresh GameState defaults to "detective" (safest fallback for legacy saves). - var archetype = GameState.get("character_archetype") - assert_str(archetype).override_failure_message( - "GameState.character_archetype default must be 'detective'" - ).is_equal("detective") - - -func test_protocol_startup_message_unknown_archetype_defaults_to_detective() -> void: - # Unknown archetype strings must not silently pass garbage to the server. - # The match guard falls back to "Detective" and calls push_error. - var bytes: PackedByteArray = Protocol.encode_startup_message(0, "hacker") - var decoded = Messagepack.decode(bytes) - assert_that(decoded.status).is_null() - assert_str(decoded.value["character_archetype"]).override_failure_message( - "Unknown archetype must fall back to 'Detective'" - ).is_equal("Detective") - - -func test_protocol_startup_message_includes_character_archetype() -> void: - # StartupMessage wire payload must carry "character_archetype" key (#588). - var bytes: PackedByteArray = Protocol.encode_startup_message(12345, "detective") - assert_bool(bytes.size() > 0).is_true() - var decoded = Messagepack.decode(bytes) - assert_that(decoded.status).is_null() - var msg: Dictionary = decoded.value - assert_bool(msg.has("character_archetype")).override_failure_message( - "StartupMessage must contain 'character_archetype' key, got: %s" % str(msg.keys()) - ).is_true() - - -func test_protocol_startup_message_detective_maps_to_pascal_case() -> void: - # "detective" client string must map to "Detective" PascalCase server enum variant. - var bytes: PackedByteArray = Protocol.encode_startup_message(0, "detective") - var decoded = Messagepack.decode(bytes) - assert_str(decoded.value["character_archetype"]).is_equal("Detective") - - -func test_protocol_startup_message_smuggler_maps_to_pascal_case() -> void: - # "smuggler" client string must map to "Smuggler" PascalCase server enum variant. - var bytes: PackedByteArray = Protocol.encode_startup_message(0, "smuggler") - var decoded = Messagepack.decode(bytes) - assert_str(decoded.value["character_archetype"]).is_equal("Smuggler") - - -func test_protocol_startup_message_preserves_world_seed() -> void: - # Adding character_archetype must not break world_seed encoding. - var seed: int = 0xDEADBEEF - var bytes: PackedByteArray = Protocol.encode_startup_message(seed, "detective") - var decoded = Messagepack.decode(bytes) - assert_int(decoded.value["world_seed"]).is_equal(seed) - - # -- #590: triangle_crisis_events decode -------------------------------------- func test_protocol_decode_includes_triangle_crisis_events_field() -> void: diff --git a/client/tests/test_time_display_sprint17.gd b/client/tests/test_time_display_sprint17.gd index 57f49ea3b..b01ae5bd2 100644 --- a/client/tests/test_time_display_sprint17.gd +++ b/client/tests/test_time_display_sprint17.gd @@ -294,10 +294,12 @@ func test_hud_time_row_updates_after_process() -> void: "tick": 1, "version": 23, "entities": [], "game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"}, }) - instance._process(0.016) - + # In test mode SimBridge is disconnected — poll_snapshot() returns null so the + # SnapshotEventRouter inside main._process() never fires. Call the HUD directly + # instead, which is what the router would do in a live session. var hud = instance.get_node_or_null("InsertOverlay/HUD") assert_that(hud).is_not_null() + hud.update_from_state() assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1") diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 5be34fc10..292a00758 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -498,7 +498,8 @@ func _start_confrontation_beat(response_id: String, text: String) -> void: if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() - _active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2) + if _active_tween and _active_tween.is_valid() and is_instance_valid(panel): + _active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2) confrontation_monologue.emit( UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION diff --git a/client/ui/examine_display.gd b/client/ui/examine_display.gd index 14e3899d8..130aa2dd5 100644 --- a/client/ui/examine_display.gd +++ b/client/ui/examine_display.gd @@ -82,12 +82,16 @@ func _start_fade_out() -> void: ## Dismiss immediately (e.g. when dialogue opens). +## Sets _active = false immediately so is_active() returns false before the fade completes. func dismiss() -> void: if not _active: return + _active = false if _dismiss_tween and _dismiss_tween.is_valid(): _dismiss_tween.kill() - _start_fade_out() + var t := create_tween() + t.tween_property(self, "modulate:a", 0.0, FADE_OUT) + t.tween_callback(func(): visible = false) func is_active() -> bool: diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index 71f16342f..58aaf3ecd 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -9,7 +9,6 @@ extends Control # (>= tiebreak = FIFO: newest replaces oldest at same priority). # # Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4). -# Colour: lattice_profile passed in at call time — no autoload access in renderer. # is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred). const MAX_VISIBLE: int = 3 @@ -20,29 +19,16 @@ const FADE_IN_SEC: float = 0.3 const FADE_OUT_SEC: float = 0.5 const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in -# Lattice colour palette — keyed by lattice_profile passed from GameState at show time. -# standard opacity = 0.85, urgent opacity = 1.0. +# Monologue colour palette — standard opacity = 0.85, urgent opacity = 1.0. # Source: Tyre architecture review, Sprint 14. -const _LATTICE_COLORS: Dictionary = { - "lattice_augmented": - { # detective - "standard": Color("#d0d4e0"), - "urgent": Color("#e0e8f8"), - }, - "lattice_baseline": - { # smuggler - "standard": Color("#d8d0c4"), - "urgent": Color("#f0e4d4"), - }, -} -const _FALLBACK_STANDARD: Color = Color("#c8d0e0") -const _FALLBACK_URGENT: Color = Color("#e0e8f8") +const _STANDARD_COLOR: Color = Color("#c8d0e0") +const _URGENT_COLOR: Color = Color("#e0e8f8") const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification const _NOTIFICATION_DURATION: float = 2.5 # Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween} var _visible: Array[Dictionary] = [] -# Queue entry: {text, duration, priority, is_urgent, lattice_profile} +# Queue entry: {text, duration, priority, is_urgent} var _queue: Array[Dictionary] = [] # Msec timestamp when the next fade-in may begin (stagger enforcement) var _next_fade_in_msec: float = 0.0 @@ -69,19 +55,15 @@ func _process(delta: float) -> void: if next.get("is_notification", false): _show_notification_line(next.text) else: - _show_line( - next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile - ) + _show_line(next.text, next.duration, next.priority, next.is_urgent) # Display a monologue line. # priority: higher number = more important (default 2; urgent beats normal). # is_urgent: visual flag — full opacity + elevated colour. Bloom deferred. # Empty text is silently ignored — no slot created, no queue entry. -# lattice_profile is read from GameState here and passed down — renderer stays -# decoupled from the autoload (D-020 renderer contract). # #554: Show a brief system notification (save/load result, connection status). -# Uses neutral color, short duration, bypasses lattice_profile styling. +# Uses neutral color, short duration. func show_notification(text: String) -> void: if text.is_empty(): return @@ -94,7 +76,6 @@ func show_notification(text: String) -> void: duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, - lattice_profile = "", is_notification = true } if _queue.size() < MAX_QUEUE: @@ -116,12 +97,11 @@ func show_monologue( ) -> void: if text.is_empty(): return - var profile := GameState.lattice_profile var now := float(Time.get_ticks_msec()) if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: - _show_line(text, duration, priority, is_urgent, profile) + _show_line(text, duration, priority, is_urgent) else: - _enqueue(text, duration, priority, is_urgent, profile) + _enqueue(text, duration, priority, is_urgent) # --------------------------------------------------------------------------- @@ -159,9 +139,9 @@ func _show_notification_line(text: String) -> void: func _show_line( - text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String + text: String, duration: float, priority: int, is_urgent: bool ) -> void: - var line_node := _build_line_node(text, is_urgent, lattice_profile) + var line_node := _build_line_node(text, is_urgent) _vbox.add_child(line_node) var slot := { @@ -192,7 +172,7 @@ func _retire_slot(slot: Dictionary) -> void: func _enqueue( - text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String + text: String, duration: float, priority: int, is_urgent: bool ) -> void: if _queue.size() < MAX_QUEUE: _queue.append( @@ -201,7 +181,6 @@ func _enqueue( duration = duration, priority = priority, is_urgent = is_urgent, - lattice_profile = lattice_profile } ) _queue.sort_custom( @@ -216,7 +195,6 @@ func _enqueue( duration = duration, priority = priority, is_urgent = is_urgent, - lattice_profile = lattice_profile } _queue.sort_custom( func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority @@ -232,13 +210,8 @@ func _lowest_priority_idx() -> int: return idx -func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control: - var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {}) - var color: Color = ( - palette.get("urgent", _FALLBACK_URGENT) - if is_urgent - else palette.get("standard", _FALLBACK_STANDARD) - ) +func _build_line_node(text: String, is_urgent: bool) -> Control: + var color: Color = _URGENT_COLOR if is_urgent else _STANDARD_COLOR var container := MarginContainer.new() container.add_theme_constant_override("margin_left", 4)