From e1ea07e746087b0736bd5eec77ef065320355aa4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 13:15:05 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(client):=20save/load=20client=20UI=20?= =?UTF-8?q?=E2=80=94=20F5/F6=20quicksave/quickload=20(#554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire SaveGame/LoadGame player actions through the full client stack: protocol v15 decode, InputMapper F5/F6 bindings, SimBridge wire mapping with one-shot carry-forward, GameState save_result field, and HUD notification via monologue display. Quit-to-menu triggers quicksave before scene change. Co-Authored-By: Claude Opus 4.6 --- client/data/ui-strings.yaml | 3 ++ client/project.godot | 10 +++++ client/scripts/autoloads/game_state.gd | 11 ++++++ client/scripts/autoloads/input_mapper.gd | 17 +++++++- client/scripts/autoloads/session_manager.gd | 10 ++++- client/scripts/autoloads/sim_bridge.gd | 7 ++++ client/scripts/main.gd | 24 ++++++++++++ client/scripts/protocol/protocol.gd | 14 ++++++- client/scripts/protocol/test_harness.gd | 1 + client/ui/monologue_display.gd | 43 +++++++++++++++++++++ 10 files changed, 135 insertions(+), 5 deletions(-) diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 4cca59cca..61906917c 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -104,6 +104,9 @@ notifications: # System save_complete: "Progress saved." + load_complete: "Session restored." + save_failed: "Save failed." + load_failed: "Load failed." connection_lost: "Signal interrupted." connection_restored: "Signal restored." diff --git a/client/project.godot b/client/project.godot index eee056cf3..486c553c6 100644 --- a/client/project.godot +++ b/client/project.godot @@ -136,6 +136,16 @@ teleport_hub={ "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":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +quicksave={ +"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":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +quickload={ +"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":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index d96294fd2..a59174bfd 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -70,6 +70,11 @@ var insert_active: bool = true # Null in v0.1 (server does not yet send this field; protocol change required). var rng_seed: Variant = null +# v15 fields (#554, D-085): save/load result from server. +# {success: bool, kind: "save"|"load", error: Variant} or null. +# One-shot: consumed by main.gd after display, then set back to null. +var save_result: Variant = null + # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] @@ -279,6 +284,12 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: current_examine_result = null + # v15: save_result (#554, D-085) — one-shot save/load confirmation from server. + if snapshot.has("save_result") and snapshot.save_result is Dictionary: + save_result = snapshot.save_result + else: + save_result = null + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # Only update when field is present (null means no change, server sends when KG changes). if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 37957efd0..f947d61b8 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -22,6 +22,8 @@ enum Action { OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only SET_FACING, # D-054: facing octant update (no movement) TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) + SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path + LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path } var input_queue: Array[Dictionary] = [] @@ -112,12 +114,23 @@ func _unhandled_input(event: InputEvent) -> void: elif event.is_action_pressed("teleport_hub"): if GameState.gauntlet_mode: action = Action.TELEPORT_HUB + elif event.is_action_pressed("quicksave"): + action = Action.SAVE_GAME + elif event.is_action_pressed("quickload"): + action = Action.LOAD_GAME if action != -1: - input_queue.append({ + var entry := { "action": action, "timestamp_msec": Time.get_ticks_msec(), - }) + } + # #554: Attach save path for SaveGame/LoadGame actions + if action == Action.SAVE_GAME or action == Action.LOAD_GAME: + var game_id := GameState.current_game_id + if game_id.is_empty(): + return # No active session — ignore save/load + entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"} + input_queue.append(entry) get_viewport().set_input_as_handled() diff --git a/client/scripts/autoloads/session_manager.gd b/client/scripts/autoloads/session_manager.gd index da1ddade7..5b719b8ef 100644 --- a/client/scripts/autoloads/session_manager.gd +++ b/client/scripts/autoloads/session_manager.gd @@ -84,8 +84,14 @@ func quit_to_menu() -> void: func _do_quit_to_menu() -> void: _cleanup_quit_dialog() - # #554: F5 quicksave will be triggered here before scene change when server - # supports SaveCommand. For now: navigate to menu without saving. + # #554: Trigger quicksave before navigating to menu. + if not GameState.current_game_id.is_empty(): + var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav" + SimBridge.send_input({ + "action": InputMapper.Action.SAVE_GAME, + "timestamp_msec": Time.get_ticks_msec(), + "action_data": {"path": path}, + }) GameState.current_game_id = "" get_tree().change_scene_to_file(MENU_SCENE) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 5e7b52283..6031d497a 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -289,6 +289,9 @@ func receive_bytes(bytes: PackedByteArray) -> void: if old_conv_ended.size() > 0: var new_conv_ended: Array = snapshot.get("conversation_ended", []) snapshot["conversation_ended"] = old_conv_ended + new_conv_ended + # #554: Carry forward save/load result (one-shot, consumed by main.gd) + if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null: + snapshot["save_result"] = _last_snapshot["save_result"] _last_snapshot = snapshot # Drain the outbound buffer. Returns raw input entries for batch encoding. @@ -326,6 +329,10 @@ static func action_enum_to_wire(action: int) -> String: return "SetFacing" # D-054: facing octant update (no movement) InputMapper.Action.TELEPORT_HUB: return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel) + InputMapper.Action.SAVE_GAME: + return "SaveGame" # #554: F5 quicksave (D-085) + InputMapper.Action.LOAD_GAME: + return "LoadGame" # #554: F6 quickload (D-085) _: push_warning("SimBridge: unknown action enum %s" % action) return "" diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 387ab937e..a30189d70 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -173,6 +173,9 @@ func _process(delta: float) -> void: _consume_conversation_ended() _consume_dialogue_response() + # #554: Show save/load result notification + _consume_save_result() + # Track camera to player (D-015: locked, fixed-north). # #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED. # Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame. @@ -381,6 +384,27 @@ func _consume_dialogue_response() -> void: GameState.dialogue_response = null +# #554: Show save/load result notification from server response. +func _consume_save_result() -> void: + if GameState.save_result == null: + return + var result: Dictionary = GameState.save_result + GameState.save_result = null # consume once + var msg: String + if result.get("success", false): + if result.get("kind", "") == "save": + msg = UIStrings.get_text("notifications.save_complete") + else: + msg = UIStrings.get_text("notifications.load_complete") + else: + if result.get("kind", "") == "save": + msg = UIStrings.get_text("notifications.save_failed") + else: + msg = UIStrings.get_text("notifications.load_failed") + if monologue_display: + monologue_display.show_notification(msg) + + # D-061: Handle dialogue option selection → send to server func _on_dialogue_option_selected(response_id: String, text: String) -> void: SimBridge.send_input({ diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 49175d52f..0f00caf74 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -11,7 +11,7 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -const PROTOCOL_VERSION: int = 14 +const PROTOCOL_VERSION: int = 15 # -- Decode: bytes from server → GDScript types -------------------------------- @@ -233,6 +233,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "confidence": str(raw_examine.get("confidence", "KnowsOf")), } + # v15: save_result (#554, D-085) — one-shot save/load operation result. + # {success: bool, kind: "save"|"load", error: String|null} + var save_result: Variant = null + var raw_save: Variant = raw.get("save_result") + if raw_save is Dictionary: + save_result = { + "success": bool(raw_save.get("success", false)), + "kind": str(raw_save.get("kind", "")), + "error": raw_save.get("error"), + } + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. # {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}], # facts: [{fact_id, confidence, source, state, acquired_tick}]} @@ -290,6 +301,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "poi_list": poi_list, "examine_result": examine_result, "player_knowledge": player_knowledge, + "save_result": save_result, } diff --git a/client/scripts/protocol/test_harness.gd b/client/scripts/protocol/test_harness.gd index b376322c3..d75b6f8cf 100644 --- a/client/scripts/protocol/test_harness.gd +++ b/client/scripts/protocol/test_harness.gd @@ -189,6 +189,7 @@ func snapshot() -> Dictionary: "gauntlet_mode": gauntlet_mode, "conversation_events": conv_events, "conversation_ended": conv_ended, + "save_result": null, } diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index f95b913a3..0b9eacba8 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -35,6 +35,8 @@ const _LATTICE_COLORS: Dictionary = { } const _FALLBACK_STANDARD: Color = Color("#c8d0e0") const _FALLBACK_URGENT: Color = Color("#e0e8f8") +const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification +const _NOTIFICATION_DURATION: float = 2.5 @onready var _vbox: VBoxContainer = $VBoxContainer @@ -71,6 +73,18 @@ func _process(delta: float) -> void: # 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. +func show_notification(text: String) -> void: + if text.is_empty(): + return + var now := float(Time.get_ticks_msec()) + if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: + _show_notification_line(text) + else: + _enqueue(text, _NOTIFICATION_DURATION, 1, false, "") + + func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void: if text.is_empty(): return @@ -86,6 +100,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: # Internal # --------------------------------------------------------------------------- +func _show_notification_line(text: String) -> void: + var container := MarginContainer.new() + container.add_theme_constant_override("margin_left", 4) + container.add_theme_constant_override("margin_right", 4) + container.add_theme_constant_override("margin_top", 2) + container.add_theme_constant_override("margin_bottom", 2) + var label := RichTextLabel.new() + label.bbcode_enabled = true + label.fit_content = true + label.scroll_active = false + label.add_theme_font_size_override("normal_font_size", 13) + var safe_text := text.replace("[", "[lb]") + label.text = "[color=#%s]%s[/color]" % [_NOTIFICATION_COLOR.to_html(false), safe_text] + container.add_child(label) + _vbox.add_child(container) + var slot := { + node = container, + expire_timer = _NOTIFICATION_DURATION, + priority = 1, + tween = null, + } + _visible.append(slot) + _next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0 + container.modulate.a = 0.0 + var tween := create_tween() + slot.tween = tween + tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC) + + func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void: var line_node := _build_line_node(text, is_urgent, lattice_profile) _vbox.add_child(line_node) From 3725a3df5e28ff868f9791fec75a4270860774e2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 15:43:53 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(client):=20address=20PR=20#70=20review?= =?UTF-8?q?=20=E2=80=94=20event=20leak,=20quit=20flush,=20notification=20c?= =?UTF-8?q?olor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - input_mapper.gd: call set_input_as_handled() before early return on empty game_id so F5/F6 events don't propagate to other handlers - session_manager.gd: defer scene change by one frame after buffering quit-save so SimBridge._process() flushes the outbound buffer - monologue_display.gd: tag queued notifications with is_notification flag so drain path routes to _show_notification_line (correct color) instead of _show_line (lattice-profile fallback color) Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/input_mapper.gd | 1 + client/scripts/autoloads/session_manager.gd | 11 ++++++++++- client/ui/monologue_display.gd | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index f947d61b8..6128d4b4d 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -128,6 +128,7 @@ func _unhandled_input(event: InputEvent) -> void: if action == Action.SAVE_GAME or action == Action.LOAD_GAME: var game_id := GameState.current_game_id if game_id.is_empty(): + get_viewport().set_input_as_handled() return # No active session — ignore save/load entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"} input_queue.append(entry) diff --git a/client/scripts/autoloads/session_manager.gd b/client/scripts/autoloads/session_manager.gd index 5b719b8ef..87bd3c90d 100644 --- a/client/scripts/autoloads/session_manager.gd +++ b/client/scripts/autoloads/session_manager.gd @@ -85,6 +85,8 @@ func quit_to_menu() -> void: func _do_quit_to_menu() -> void: _cleanup_quit_dialog() # #554: Trigger quicksave before navigating to menu. + # send_input() buffers the command — defer scene change by one frame so + # SimBridge._process() flushes the outbound buffer before teardown. if not GameState.current_game_id.is_empty(): var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav" SimBridge.send_input({ @@ -92,7 +94,14 @@ func _do_quit_to_menu() -> void: "timestamp_msec": Time.get_ticks_msec(), "action_data": {"path": path}, }) - GameState.current_game_id = "" + GameState.current_game_id = "" + _navigate_to_menu.call_deferred() + else: + GameState.current_game_id = "" + get_tree().change_scene_to_file(MENU_SCENE) + + +func _navigate_to_menu() -> void: get_tree().change_scene_to_file(MENU_SCENE) diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index 0b9eacba8..37a52844d 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -64,7 +64,10 @@ func _process(delta: float) -> void: var now := float(Time.get_ticks_msec()) if now >= _next_fade_in_msec: var next: Dictionary = _queue.pop_front() - _show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile) + 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) # Display a monologue line. @@ -82,7 +85,15 @@ func show_notification(text: String) -> void: if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: _show_notification_line(text) else: - _enqueue(text, _NOTIFICATION_DURATION, 1, false, "") + var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true} + if _queue.size() < MAX_QUEUE: + _queue.append(entry) + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) + else: + var lowest := _lowest_priority_idx() + if 1 >= _queue[lowest].priority: + _queue[lowest] = entry + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void: