fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user