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/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index a71ba9406..c8a368250 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -231,7 +231,8 @@ func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void: var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "") if asset_key.is_empty(): return - play_at(asset_key, world_tile_pos * Constants.TILE_SIZE) + var C := load("res://scripts/constants.gd") + play_at(asset_key, world_tile_pos * C.TILE_SIZE) # --- Playback: spatial (D-018 close-range) --- 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..5163386fa 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) @@ -117,6 +117,8 @@ func _process(_delta: float) -> void: # Discrete actions: fire once on key press (not held). func _unhandled_input(event: InputEvent) -> void: + if GameState.dialogue_active or GameState.free_camera_mode: + return var action: Action = -1 if event.is_action_pressed("interact"): @@ -182,10 +184,11 @@ func _update_facing_from_mouse() -> void: if vp == null: return var canvas_xf := vp.get_canvas_transform() - var player_world_px := GameState.player_position * Constants.TILE_SIZE - var player_screen := canvas_xf * player_world_px - var mouse_screen := vp.get_mouse_position() - var delta := mouse_screen - player_screen + var C := load("res://scripts/constants.gd") + var player_world_px: Vector2 = GameState.player_position * C.TILE_SIZE + var player_screen: Vector2 = canvas_xf * player_world_px + var mouse_screen: Vector2 = vp.get_mouse_position() + var delta: Vector2 = mouse_screen - player_screen # Only update if mouse is meaningfully distant from player (avoid jitter at center) if delta.length_squared() > 4.0: facing_angle = delta.angle() 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..83e9007b0 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -27,8 +27,8 @@ var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport # Transport layer (non-test mode) -var _bridge: LocalBridge = null -var _server: ServerProcess = null +var _bridge = null # LocalBridge +var _server = null # ServerProcess var _connect_retries: int = 0 var _retry_timer: float = 0.0 var _handshake_start_usec: int = 0 @@ -126,14 +126,15 @@ func connect_to_sim() -> void: # Spawn server subprocess if not server_path.is_empty(): - _server = ServerProcess.new() + var SP := load("res://scripts/protocol/server_process.gd") + _server = SP.new() # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876"). # D-085 (#258): pass --game-id so server logs use the same session identifier. var args := ["127.0.0.1:" + str(server_port)] var game_id: String = GameState.current_game_id if not game_id.is_empty(): args.append_array(["--game-id", game_id]) - var pid := _server.start(server_path, args) + var pid: int = _server.start(server_path, args) if pid <= 0: push_error("SimBridge: failed to start server") _set_state(ConnectionState.ERROR) @@ -159,8 +160,9 @@ func disconnect_from_sim() -> void: # Attempt TCP connection. Called from _process() during CONNECTING state. func _try_connect() -> void: - _bridge = LocalBridge.new() - var err := _bridge.connect_to_server("127.0.0.1", server_port) + var LB := load("res://scripts/protocol/local_bridge.gd") + _bridge = LB.new() + var err: int = _bridge.connect_to_server("127.0.0.1", server_port) if err != OK: push_warning( ( @@ -220,7 +222,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns _bridge.poll() # Check connection dropped during handshake - var bridge_status := _bridge.get_status() + var bridge_status: int = _bridge.get_status() if ( bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE @@ -242,13 +244,14 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns return # Try to read first message - var msg := _bridge.poll_message() + var msg: PackedByteArray = _bridge.poll_message() if msg.is_empty(): return # Not ready yet, continue polling # Decode HandshakeMessage — D-192 (#875): protocol_version field dropped. # Server sends {} or a minimal dict; only structural validity is required. - var decoded: Variant = Messagepack.decode(msg) + var MP = load("res://addons/messagepack/messagepack.gd") + var decoded: Variant = MP.decode(msg) if decoded.status != null or not (decoded.value is Dictionary): var reason := "Handshake decode failed: malformed HandshakeMessage" push_error("SimBridge: %s" % reason) @@ -261,11 +264,10 @@ 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: - var send_err := _bridge.send_message(startup_bytes) + var send_err: int = _bridge.send_message(startup_bytes) if send_err != OK: var reason := "Failed to send startup message: %s" % error_string(send_err) push_error("SimBridge: %s" % reason) @@ -304,7 +306,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns match _bridge.get_status(): StreamPeerTCP.STATUS_CONNECTED: # Receive: drain all complete messages from the bridge - var msg := _bridge.poll_message() + var msg: PackedByteArray = _bridge.poll_message() while msg.size() > 0: receive_bytes(msg) msg = _bridge.poll_message() @@ -316,7 +318,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns if outbound.size() > 0: var encoded := Protocol.encode_player_inputs(outbound) if encoded.size() > 0: - var err := _bridge.send_message(encoded) + var err: int = _bridge.send_message(encoded) if err != OK: push_error("SimBridge: failed to send message: %s" % error_string(err)) else: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index c6aa484aa..46a20f5c6 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,33 @@ 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. Reset zoom to 1:1 on exit. + if Input.is_action_just_pressed("free_camera"): + GameState.free_camera_mode = not GameState.free_camera_mode + if not GameState.free_camera_mode: + camera.zoom = Vector2.ONE + 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 +267,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..744b7afd2 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -9,6 +9,9 @@ extends Node ## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc") ## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"}) +static func _mp(): + return load("res://addons/messagepack/messagepack.gd") + # -- Decode: bytes from server → GDScript types -------------------------------- @@ -17,7 +20,7 @@ extends Node ## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty ## when decoding v1 snapshots for backward compatibility. static func decode_snapshot(bytes: PackedByteArray) -> Variant: - var result = Messagepack.decode(bytes) + var result = _mp().decode(bytes) if result.status != null: push_error("Protocol: msgpack decode failed: %s" % result.status) return null @@ -613,38 +616,19 @@ 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() - var result = Messagepack.encode(msg) + var result = _mp().encode(msg) if result.status != null: push_error("Protocol: startup message encode failed: %s" % result.status) return PackedByteArray() @@ -665,7 +649,7 @@ static func encode_player_input( "action": action_value, } - var result = Messagepack.encode(input) + var result = _mp().encode(input) if result.status != null: push_error("Protocol: msgpack encode failed: %s" % result.status) return PackedByteArray() @@ -691,7 +675,7 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray: ) ) - var result = Messagepack.encode(wire_inputs) + var result = _mp().encode(wire_inputs) if result.status != null: push_error("Protocol: msgpack encode failed: %s" % result.status) return PackedByteArray() @@ -724,7 +708,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray: "action_data": {"ai_enhanced_dialogue": enabled}, } ] - var result = Messagepack.encode(entries) + var result = _mp().encode(entries) if result.status != null: push_error("Protocol: encode_change_settings failed: %s" % result.status) return PackedByteArray() @@ -735,7 +719,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray: ## Unit variant — no payload. Server responds with bookmark_catalog in the next snapshot. static func encode_request_bookmark_catalog() -> PackedByteArray: var entries: Array = [{"tick": 0, "action_name": "RequestBookmarkCatalog", "action_data": null}] - var result = Messagepack.encode(entries) + var result = _mp().encode(entries) if result.status != null: push_error("Protocol: encode_request_bookmark_catalog failed: %s" % result.status) return PackedByteArray() @@ -752,7 +736,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S "action_data": {"bookmark_id": bookmark_id, "starting_location_id": starting_location_id}, } ] - var result = Messagepack.encode(entries) + var result = _mp().encode(entries) if result.status != null: push_error("Protocol: encode_confirm_bookmark failed: %s" % result.status) return PackedByteArray() @@ -762,7 +746,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S ## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios). ## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null. static func decode_player_input(bytes: PackedByteArray) -> Variant: - var result = Messagepack.decode(bytes) + var result = _mp().decode(bytes) if result.status != null: push_error("Protocol: msgpack decode failed: %s" % result.status) return null 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_examine_display_sprint18.gd b/client/tests/test_examine_display_sprint18.gd index d5479e9d3..3edfc99c2 100644 --- a/client/tests/test_examine_display_sprint18.gd +++ b/client/tests/test_examine_display_sprint18.gd @@ -52,7 +52,7 @@ func after_test() -> void: # --------------------------------------------------------------------------- func test_gamestate_examine_result_field_exists() -> void: - assert_bool(GameState.has("current_examine_result")).override_failure_message( + assert_bool("current_examine_result" in GameState).override_failure_message( "GameState must have 'current_examine_result' field (#174)" ).is_true() diff --git a/client/tests/test_fog_sprint22.gd b/client/tests/test_fog_sprint22.gd new file mode 100644 index 000000000..4478a171d --- /dev/null +++ b/client/tests/test_fog_sprint22.gd @@ -0,0 +1,395 @@ +## 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() + # 32x32 is an arbitrary test fixture size — not a production assumption. + 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() + + # Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding, + # so (25,25) expands the bounds beyond the 32x32 fixture set in _reset_fog_state. + 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 enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding, + # so (30,30) expands the bounds beyond the 32x32 fixture set in _reset_fog_state. + 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_free_camera.gd b/client/tests/test_free_camera.gd new file mode 100644 index 000000000..2952c6aa9 --- /dev/null +++ b/client/tests/test_free_camera.gd @@ -0,0 +1,88 @@ +## Free camera mode tests (#898). +## Covers GameState flag default, InputMapper suppression, and zoom clamping. +class_name TestFreeCamera +extends GdUnitTestSuite + + +func before_test() -> void: + GameState.free_camera_mode = false + + +func after_test() -> void: + GameState.free_camera_mode = false + + +# -- GameState.free_camera_mode default ---------------------------------------- + +func test_free_camera_mode_starts_false() -> void: + ## #898: Free camera is off by default — normal gameplay on startup. + assert_bool(GameState.free_camera_mode).override_failure_message( + "GameState.free_camera_mode must default to false" + ).is_false() + + +# -- InputMapper suppression --------------------------------------------------- + +func test_input_mapper_suppresses_movement_in_free_camera_mode() -> void: + ## #898: While free camera is active, InputMapper._process() returns early so + ## no movement actions enter the queue. + GameState.free_camera_mode = true + var before := InputMapper.input_queue.size() + InputMapper._process(0.016) + var after := InputMapper.input_queue.size() + assert_int(after).override_failure_message( + "InputMapper must not enqueue movement while free_camera_mode is true" + ).is_equal(before) + InputMapper.input_queue.clear() + + +func test_input_mapper_suppresses_discrete_actions_in_free_camera_mode() -> void: + ## #898: _unhandled_input returns early in free camera — INTERACT and stance + ## actions must not be queued. + GameState.free_camera_mode = true + var before := InputMapper.input_queue.size() + var fake_event := InputEventAction.new() + fake_event.action = "interact" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must not enqueue discrete actions while free_camera_mode is true" + ).is_equal(before) + InputMapper.input_queue.clear() + + +func test_input_mapper_resumes_after_free_camera_off() -> void: + ## Turning free camera off lifts the suppression — _process runs normally again. + GameState.free_camera_mode = true + GameState.free_camera_mode = false + ## _process should no longer return early (queue may or may not grow depending + ## on held keys, but no crash and guard is lifted). + InputMapper._process(0.016) + assert_bool(true).is_true() # no crash = pass + InputMapper.input_queue.clear() + + +# -- Zoom clamp contract ------------------------------------------------------- + +func test_zoom_min_constant_is_0_5() -> void: + ## #898: Minimum zoom keeps the world recognisable. + var main_script = load("res://scripts/main.gd") + assert_float(main_script.FREE_CAMERA_ZOOM_MIN).override_failure_message( + "FREE_CAMERA_ZOOM_MIN must be 0.5" + ).is_equal_approx(0.5, 0.001) + + +func test_zoom_max_constant_is_8() -> void: + ## #898: Maximum zoom must not exceed 8× per spec. + var main_script = load("res://scripts/main.gd") + assert_float(main_script.FREE_CAMERA_ZOOM_MAX).override_failure_message( + "FREE_CAMERA_ZOOM_MAX must be 8.0" + ).is_equal_approx(8.0, 0.001) + + +func test_zoom_step_is_positive() -> void: + ## Zoom step must be > 0 so scroll wheel does something. + var main_script = load("res://scripts/main.gd") + assert_float(main_script.FREE_CAMERA_ZOOM_STEP).override_failure_message( + "FREE_CAMERA_ZOOM_STEP must be positive" + ).is_greater(0.0) diff --git a/client/tests/test_game_state_sprint20.gd b/client/tests/test_game_state_sprint20.gd index 8aa22c060..7b4aab108 100644 --- a/client/tests/test_game_state_sprint20.gd +++ b/client/tests/test_game_state_sprint20.gd @@ -12,7 +12,7 @@ class_name TestGameStateSprint20 extends GdUnitTestSuite -func before_each() -> void: +func before_test() -> void: GameState.stationary_ticks = 0 SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9) GameState.current_zone_id = "" 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..ff77cc54d 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 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/meta/screens/main_menu/main_menu.gd b/client/ui/meta/screens/main_menu/main_menu.gd index 55b1badd6..7b935c303 100644 --- a/client/ui/meta/screens/main_menu/main_menu.gd +++ b/client/ui/meta/screens/main_menu/main_menu.gd @@ -1,4 +1,4 @@ -extends MetaScreen +extends "res://ui/meta/meta_screen.gd" ## #258: Main menu — New Game / Continue / Load Game / Quit. ## New Game: opens character creation screen, then starts game. ## Continue: loads most recent save directory. 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)