fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation

This commit is contained in:
2026-07-10 17:36:26 +08:00
parent 2b4c9d9dcb
commit b30709de3d
84 changed files with 5291 additions and 982 deletions
+99 -27
View File
@@ -23,13 +23,25 @@ const CAPTURE_PREFIX := "mcp"
## Cap per-frame flush so a runaway print loop can't blow the debugger's
## packet budget in a single send. Surplus stays queued for the next frame.
const FLUSH_BATCH_LIMIT := 200
## How long take_screenshot waits for the game's first real presentation
## before reading the viewport texture back. The "mcp" capture registers in
## this autoload's _ready(), which runs BEFORE the main scene enters the tree
## and before the renderer has presented anything — so a request arriving
## right after mcp:hello would otherwise read back the clear-color
## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's
## GPU-less paravirtualized macOS runners, where the first present lags
## seconds behind boot). MUST stay below the editor-side reply timer
## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a
## game that genuinely can't render falls through to the existing
## texture/image error replies before the editor gives up with its
## generic timeout.
const FIRST_FRAME_WAIT_SEC := 6.0
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd")
var _registered := false
## Untyped because the McpGameLogger script is loaded dynamically (it
## extends Logger, which only exists in Godot 4.5+).
var _logger
## Captures game-process print, warning, and error output for the editor.
var _logger: Logger
var _logger_attached := false
## Entries drained from the logger but not yet sent over the debugger
## channel. Holds the tail of one drain() so we can bleed it out across
@@ -64,17 +76,10 @@ func _ready() -> void:
_registered = true
## Capture print() / printerr() / push_error() / push_warning() and
## ferry them to the editor in mcp:log_batch messages flushed from
## _process. Logger subclassing was added in Godot 4.5 — gate on
## ClassDB so the rest of the helper still loads on older engines.
## game_logger.gd lives in the `.gdignore`'d runtime/loggers/ folder so
## it never parse-errors during a < 4.5 editor scan; LoggerLoader
## compiles it from source at runtime, only past this gate.
if ClassDB.class_exists("Logger") and OS.has_method("add_logger"):
var logger_script := LoggerLoader.build(LoggerLoader.GAME_LOGGER_PATH)
if logger_script != null:
_logger = logger_script.new()
OS.call("add_logger", _logger)
_logger_attached = true
## _process.
_logger = GameLogger.new()
OS.add_logger(_logger)
_logger_attached = true
## Routed to the editor's Output panel via Godot's remote-stdout
## forwarder — handy when diagnosing why capture timed out.
print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)"
@@ -109,16 +114,16 @@ func _exit_tree() -> void:
if _registered:
EngineDebugger.unregister_message_capture(CAPTURE_PREFIX)
_registered = false
if _logger_attached and _logger != null and OS.has_method("remove_logger"):
OS.call("remove_logger", _logger)
if _logger_attached and _logger != null:
OS.remove_logger(_logger)
_logger_attached = false
_logger = null
## Dispatched for messages prefixed "mcp:" on the debugger channel.
## Different Godot versions pass either the tail ("take_screenshot") or the
## full message ("mcp:take_screenshot") to the capture callable — accept
## both forms so this works across 4.2/4.3/4.4/4.5.
## Godot passes the full message ("mcp:take_screenshot") to the capture
## callable; trim defensively so tests can still call the helper with either
## form.
func _on_debug_message(message: String, data: Array) -> bool:
var action := message.trim_prefix("mcp:")
match action:
@@ -141,11 +146,25 @@ func _handle_take_screenshot(data: Array) -> void:
var request_id: String = data[0] if data.size() > 0 else ""
var max_resolution: int = int(data[1]) if data.size() > 1 else 0
var viewport := get_tree().root
var tree := get_tree()
var viewport := tree.root if tree != null else null
if viewport == null:
_reply_error(request_id, "No game root viewport available")
return
## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in
## the tree and at least one frame has been drawn after this request, so
## the readback never precedes the first real present. Past the deadline,
## fall through anyway: current_scene stays null under a custom main
## loop, and frames_drawn never advances in a render-less game — both
## are handled by the texture/image error replies below.
var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0)
while tree.current_scene == null and Time.get_ticks_msec() < deadline:
await tree.process_frame
var frames_at_request := Engine.get_frames_drawn()
while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline:
await tree.process_frame
var texture := viewport.get_texture()
if texture == null:
_reply_error(request_id, "Root viewport has no texture (headless?)")
@@ -217,6 +236,8 @@ func _handle_game_command(data: Array) -> void:
result = _game_input_mouse(json.data)
"input_gamepad":
result = _game_input_gamepad(json.data)
"input_action":
result = _game_input_action(json.data)
"input_state":
result = _game_input_state(json.data)
_:
@@ -458,7 +479,10 @@ func _game_input_key(params: Dictionary) -> Dictionary:
func _game_input_mouse(params: Dictionary) -> Dictionary:
var event := str(params.get("event", "button"))
var pos := _dict_to_vector2(params.get("position", {}))
var pos_result := _resolve_mouse_position(params.get("position"))
if pos_result.has("error"):
return {"sent": false, "event": event, "error": pos_result.error}
var pos: Vector2 = pos_result.position
match event:
"motion":
var motion := InputEventMouseMotion.new()
@@ -504,6 +528,27 @@ func _game_input_gamepad(params: Dictionary) -> Dictionary:
return {"sent": false, "error": "Invalid gamepad control: %s" % control}
func _game_input_action(params: Dictionary) -> Dictionary:
var action := str(params.get("action", ""))
if action.is_empty():
return {"sent": false, "error": "Missing action"}
if not InputMap.has_action(action):
return {"sent": false, "action": action, "error": "Unknown action: %s" % action}
var pressed := bool(params.get("pressed", true))
var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0)
if pressed:
Input.action_press(action, strength)
else:
Input.action_release(action)
return {
"sent": true,
"action": action,
"pressed": pressed,
"strength": strength,
"delivery": "action_state",
}
func _game_input_state(params: Dictionary) -> Dictionary:
var actions: Array = params.get("actions", [])
if actions.is_empty():
@@ -515,14 +560,41 @@ func _game_input_state(params: Dictionary) -> Dictionary:
return {"actions": states}
func _dict_to_vector2(value: Variant) -> Vector2:
## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to
## the live cursor position — a deliberate default. A present but wrong-shaped
## value is rejected instead of silently substituting the cursor, which
## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array;
## returns {position: Vector2} or {error: String}.
func _resolve_mouse_position(value: Variant) -> Dictionary:
var viewport := get_viewport()
var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO
if value == null:
return {"position": fallback}
if value is Dictionary:
if value.is_empty() or (not value.has("x") and not value.has("y")):
return fallback
return Vector2(float(value.get("x", fallback.x)), float(value.get("y", fallback.y)))
return fallback
var dict: Dictionary = value
if dict.is_empty():
return {"position": fallback}
# A non-empty dict that carries neither coordinate is a caller mistake,
# not "use the default" — reject rather than silently substitute.
if not dict.has("x") and not dict.has("y"):
return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())}
var x_val: Variant = dict.get("x", fallback.x)
var y_val: Variant = dict.get("y", fallback.y)
if not _is_number(x_val) or not _is_number(y_val):
return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]}
return {"position": Vector2(float(x_val), float(y_val))}
if value is Array:
var arr: Array = value
if arr.size() != 2:
return {"error": "position array must be [x, y] (got %d elements)" % arr.size()}
if not _is_number(arr[0]) or not _is_number(arr[1]):
return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]}
return {"position": Vector2(float(arr[0]), float(arr[1]))}
return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))}
func _is_number(v: Variant) -> bool:
return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT
func _mouse_button_index(name: String) -> int: