3 Commits

Author SHA1 Message Date
adtpdn 4721795a69 chore: bump version to 2.4.7 2026-07-10 17:50:34 +08:00
adtpdn b30709de3d fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation 2026-07-10 17:36:26 +08:00
adtpdn 2b4c9d9dcb feat(candy-survival): toggle-and-walk knock mode, unified animations, score rewards, RPC fix
- Changed knock from instant-ambush to toggle-and-walk: press Q to toggle Knock Mode ON, walk into target to execute, consumed on use
- Unified knock animation with other game modes (attack_mode SFX, bump, 3-tile knockback, stagger)
- Added +100 score rewards for completing goals, KNOCK, and GHOST
- Fixed RPC unknown peer ID crash for AI bots in ghost/candy stack sync
- Bots auto-toggle is_charged_strike when they have knock charges
- Fixed profile panel username update on connected peers
- Added SafeNakamaMultiplayerBridge for match state crash prevention
2026-07-09 18:03:26 +08:00
91 changed files with 5423 additions and 1052 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
{
"godotTools.editorPath.godot4": "/home/beng/Godot/Editors/4.6.3-stable/Godot_v4.6.3-stable_linux.x86_64",
"godotTools.editorPath.godot4": "/home/beng/Godot/Editors/.editor_config/Tekton Dash/Godot_v4.6.3-stable_linux.x86_64",
"editor.tabSize": 4,
"editor.insertSpaces": false,
"files.eol": "\n",
+10
View File
@@ -1,4 +1,14 @@
## [NEXT]
- Fixed `api_rpc_async` returning null or retrying indefinitely when a Nakama session expires, cleanly catching null sessions without crashing.
- Fixed `AuthManager.logout()` failing to invalidate Nakama sessions on the backend.
- Fixed numerous offline networking crashes across `main.gd`, `player.gd`, `player_action_manager.gd`, and `tekton.gd` where `multiplayer.get_unique_id()` or `multiplayer.is_server()` was called during teardown or disconnected states.
- Fixed `on_thrown_landing` throwing C++ errors when knocked offline by guarding `get_tree()` calls.
- Changed Candy Survival knock from instant-ambush to **toggle-and-walk mode** like other game modes: press Q to toggle Knock Mode ON (red glow), then walk into a target to execute the knock. Press Q again to cancel. Consumed on use. Bots auto-toggle when they have charges.
- Fixed `Attempt to call RPC with unknown peer ID: 5` crash in Candy Survival when updating ghost inventory or candy stacks for AI bots or unverified peers.
- Added +100 score rewards in Candy Survival when completing goals by collecting tiles, successfully landing a KNOCK, or tactically activating GHOST.
- Updated Candy Survival knocking animation to match other game modes (`attack_mode` SFX, bump animation, 3-tile knockback along path, and stun stagger) while keeping the Candy Survival ruleset: successful knock steals all target's candies and knocks target back; knocking a target with 0 candies backfires, knocking the attacker back 3 tiles and transferring attacker's candies to target.
- Fixed profile panel (`SaveNameBtn`) failing when updating username on connected peers by passing `null` for login username so Nakama only updates `display_name` without unique username collisions.
- Preserved detailed profile update failure messages in ProfilePanel UI status label and persisted display name changes to storage.
## [2.4.6] — 2026-07-09
- Fixed NakamaMultiplayerBridge dictionary lookup crash when receiving match state before user presence is populated by creating a `SafeNakamaMultiplayerBridge` subclass.
+130
View File
@@ -0,0 +1,130 @@
# Change Log
All notable changes to this project are documented below.
The format is based on [keep a changelog](http://keepachangelog.com/) and this project uses [semantic versioning](http://semver.org/).
## [3.3.1] - 2023-04-17
### Fixed
- Fix arguments for HTTPRequest.request() for beta17
- Fix typehints for enums in Godot 4.0-rc1
- Fix type check and typehint for Godot 4.0-rc3
- Fix null byte array error in GodotHttpAdapter for C#
## [3.3.0] - 2023-01-30
### Added
- Add support for subscription validation APIs that were added in Nakama v3.13.0
- Add support for sending events
- Allow disabling threads for making HTTP requests
- Add support for delete_account_sync() and other API changes that were added in Nakama v3.15.0
## [3.2.0] - 2022-08-30
### Fixed
- Fix NakamaSocket.add_matchmaker_party_async() and the tests for it
- Fix MatchData.op_code type in schema to TYPE_INT
- Fix circular reference in Nakama singleton to itself
### Added
- Add support for receiving binary data in "NakamaRTAPI.MatchState"
- Add support for sending and receiving binary data in "NakamaRTAPI.PartyData"
- Add NakamaMultiplayerBridge to integrate with Godot's High-Level Multiplayer API
## [3.1.0] - 2022-04-28
### Added
- Expose the "seen_before" property on "NakamaAPI.ApiValidatedPurchase"
- Add support for creating match by name
- Add support for "count_multple" on "NakamaSocket.add_matchmaker_async()" and "NakamaSocket.add_matchmaker_party_async()"
- Add C# support classes to better integrate the .NET client with the Mono version of Godot, allowing HTML5 exports to work
### Fixed
- Fix receiving "NakamaRTAPI.PartyClose" message
- Fix sending and receiving of PartyData
## [3.0.0] - 2022-03-28
### Added
- Add realtime party support.
- Add purchase validation functions.
- Add Apple authentication functions.
- Add "demote_group_users_async" function.
- A session can be refreshed on demand with "session_refresh_async" method.
- Session and/or refresh tokens can now be invalidated with a client logout.
- The client now supports session auto-refresh using refresh tokens. This is enabled by default.
- The client now supports auto-retrying failed request due to network error. This is enabled by defulut.
- The client now support cancelling requests in-flight via "client.cancel_request".
### Fixed
- Fix Dictionary serialization (e.g. "NakamaSocket.add_matchmaker_async" "p_string_props" and "p_numeric_props").
- Pass join metadata onwards into match join message.
- Don't stop processing messages when the game is paused.
- Fix "rpc_async", "rpc_async_with_key". Now uses GET request only if no payload is passed.
- Fix client errors parsing in Nakama 3.x
- Make it possible to omit the label and query on NakamaClient.list_matches_async().
### Backwards incompatible changes
- The "received_error" signal on "NakamaSocket" is now emited with an "NakamaRTAPI.Error" object received from the server.
Previously, it was emitted with an integer error code when the socket failed to connect.
If you have old code using the "received_error" signal, you can switch to the new "connection_error" signal, which was added to replace it.
## [2.1.0] - 2020-08-01
### Added
- Add an optional log level parameter to "Nakama.create_client".
### Changed
- Update variable definitions to new gdscript variable controls.
### Fixed
- Fix "add_friends_async" should have its "id" field input as optional.
- Fix "add_matchmaker_async" and "MatchmakerAdd" parameter assignment.
- Fix missing "presence" property in NakamaRTAPI.MatchData.
- Fix NakamaSocket not emitting "received_error" correctly.
- Fix "DEFAULT_LOG_LEVEL" in Nakama.gd not doing anything.
## [2.0.0] - 2020-04-02
### Added
- Decode base64 data in "MatchData". (breaks compat)
- Add "FacebookInstantGame" endpoints (link/unlink/authenticate).
- GDScript-style comments (removing all XML tags).
- Add "list_storage_objects_async" "p_user_id" parameter to allow listing user(s) objects.
### Fixed
- Fix encoding of "op_code" in "MatchDataSend" and marshalling of "NakamaSocket.send_match_state_[raw_]async".
- Fix parsing of "MatchmakerMatched" messages when no token is specified.
- Disable "HTTPRequest.use_threads" in HTML5 exports.
- "NakamaSession.is_expired" returned reversed result.
- Fix "NakamaClient.update_account_async" to allow updating account without username change.
- Fix "NakamaClient.update_group_async" to allow updating group without name change.
- Fix "HTTPAdapter._send_async" error catching for some edge cases.
- Fix "NakamaClient.send_rpc_async" with empty payload (will send empty string now).
- Fix "NakamaRTAPI.Status" parsing.
- Fix "NakamaClient" "list_leaderboard_records_around_owner_async" and "list_leaderboard_records_async" parameter order. (breaks compat)
- Rename "NakamaClient.JoinTournamentAsync" to "join_tournament_async" for consistent naming.
- Update all "p_limit" parameters default in "NakamaClient" to "10".
- Fix "NakamaRTAPI.Stream" parsing.
## [1.0.0] - 2020-01-28
### Added
- Initial public release.
- Client API implementation.
- Realtime Socket implementation.
- Helper singleton.
- Setup instructions.
+203
View File
@@ -0,0 +1,203 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -1
View File
@@ -14,7 +14,7 @@ The plugin auto-starts the MCP server and connects over WebSocket. No manual con
## Requirements
- Godot 4.3+ (4.4+ recommended)
- Godot 4.5+ (4.7+ recommended)
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
<details>
<summary>Install uv</summary>
+53 -19
View File
@@ -24,6 +24,7 @@ const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
const SERVER_NAME := "godot-ai"
@@ -37,8 +38,14 @@ const DEFAULT_WS_PORT := 9500
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
const MIN_PORT := 1024
const MAX_PORT := 65535
## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
## pathological run of occupied ports can't stall the (cold-path) caller.
## 64 localhost binds are sub-millisecond; finding a free port realistically
## takes one or two probes, so this only bounds the worst case.
const SUGGEST_PORT_MAX_PROBES := 64
const SETTING_WS_PORT := "godot_ai/ws_port"
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
const _DISCOVERY_TIMEOUT_MS := 3000
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
@@ -131,15 +138,39 @@ static func excluded_domains() -> String:
return ",".join(parts)
## Clamp `start` into the legal port range, then walk
## `candidate`..`candidate+span-1` and return the first port that is NOT
## currently excluded by Windows' winnat reservation table. Falls back to the
## clamped candidate if nothing clears (caller can apply anyway — user may
## just retry). On non-Windows this is a no-op: all ports pass, returns the
## clamped candidate.
## Suggest a port the caller can actually switch to. Walks
## `candidate`..`candidate+span-1` and returns the first port that is both
## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
## grab these; bind fails with WinError 10013 and netstat shows nothing) and
## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
## "free" honest on macOS/Linux, where the reservation table is empty but the
## next port up may still be occupied — the same suggestion feeds the dock
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
## log line. Falls back to the clamped candidate if nothing in the window
## clears both checks (caller surfaces it as a best-effort hint; the user can
## retry or pick another). Best-effort by nature: a TOCTOU window remains
## between the probe and the caller actually binding the port. The bind probe
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
## stall on a pathological run of occupied ports.
static func suggest_free_port(start: int, span: int = 2048) -> int:
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT)
var limit := mini(candidate + span - 1, MAX_PORT)
var p := candidate
var probes := 0
while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
## Jump past a whole Windows-reserved range in one step (no-op on
## POSIX: returns `p` unchanged), so we don't probe port-by-port
## through the large adjacent ranges those services reserve. The
## jump itself runs no bind probes, so it doesn't count against the cap.
var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
if not_reserved < p or not_reserved > limit:
break
p = not_reserved
probes += 1
if PortResolver.can_bind_local_port(p):
return p
p += 1
return candidate
# --- Client operations (string id) ---------------------------------------
@@ -509,8 +540,8 @@ static func invalidate_uvx_cli_cache() -> void:
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
## with a mutex so it can race safely against worker threads calling
## `find()` from `_run_client_action_worker`. The mutex is held only
## across the dictionary clear, never across `OS.execute`, so this call
## can never block the main thread on a subprocess.
## across the dictionary clear, never across the bounded subprocess lookup,
## so this call can never block the main thread on a subprocess.
static func invalidate_cli_cache() -> void:
CliFinder.invalidate()
@@ -521,10 +552,9 @@ static var _uv_version_searched: bool = false
## Cached for the editor session. The dock's `_refresh_setup_status`
## (called via `call_deferred` from `_build_ui`) calls this on the
## main thread in user mode, so a single cold `OS.execute(uvx,
## ["--version"])` adds ~80 ms to the dock's first paint on Linux and
## more on Windows. Subsequent calls (focus-in refresh, manual Refresh
## clicks) reuse the cached string.
## main thread in user mode, so the cold `uvx --version` probe is
## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
## manual Refresh clicks) reuse the cached string.
##
## Invalidate via `invalidate_uv_version_cache()` when the user
## installs / reinstalls uv via the dock so the next refresh reflects
@@ -539,9 +569,10 @@ static func check_uv_version() -> String:
_uv_version_searched = true
_uv_version_cache = ""
return ""
var output: Array = []
if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0:
_uv_version_cache = output[0].strip_edges()
var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
_uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
else:
_uv_version_cache = ""
_uv_version_searched = true
@@ -612,9 +643,12 @@ static func find_worktree_src_dir(start_dir: String) -> String:
static func _find_system_install() -> String:
var cmd := "which" if OS.get_name() != "Windows" else "where"
var output: Array = []
if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0:
var found: String = output[0].strip_edges()
var result := McpCliExec.run(cmd, ["godot-ai"], _DISCOVERY_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
if lines.is_empty():
return ""
var found := CliFinder._pick_best_path(lines) if OS.get_name() == "Windows" else lines[0].strip_edges()
if not found.is_empty():
return found
return ""
+33 -7
View File
@@ -34,6 +34,7 @@ extends RefCounted
const DEFAULT_TIMEOUT_MS := 8000
const _POLL_INTERVAL_MS := 50
const _KILL_GRACE_MS := 500
static func run(
@@ -44,6 +45,15 @@ static func run(
) -> Dictionary:
if exe.is_empty():
return _spawn_failed_result()
return _run_piped(exe, args, timeout_ms, capture_stderr)
static func _run_piped(
exe: String,
args: Array,
timeout_ms: int,
capture_stderr: bool,
) -> Dictionary:
var spawn_exe := exe
var spawn_args := args
@@ -76,12 +86,18 @@ static func run(
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
while OS.is_process_running(pid):
if Time.get_ticks_msec() >= deadline:
## Read whatever made it to the pipes before we kill the
## process — partial output beats blank "timed out" when the
## CLI was emitting useful diagnostics on its way to hanging.
var partial_stdout := _drain_pipe(stdio)
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
## Kill before draining: a pipe read can block while the child is
## still alive. Once it exits, drain any buffered partial output.
OS.kill(pid)
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
OS.delay_msec(_POLL_INTERVAL_MS)
var partial_stdout := ""
var partial_stderr := ""
if not OS.is_process_running(pid):
partial_stdout = _drain_pipe(stdio)
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
_close_pipes(stdio, stderr_pipe)
return {
"exit_code": -1,
@@ -119,9 +135,19 @@ static func _spawn_failed_result() -> Dictionary:
static func _drain_pipe(pipe: Variant) -> String:
if pipe is FileAccess:
return (pipe as FileAccess).get_as_text()
if not (pipe is FileAccess):
return ""
var f := pipe as FileAccess
var bytes := PackedByteArray()
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
while bytes.size() < max_bytes:
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
if chunk.is_empty():
break
bytes.append_array(chunk)
if f.eof_reached():
break
return bytes.get_string_from_utf8()
static func _join_streams(stdout: String, stderr_text: String) -> String:
+12 -11
View File
@@ -14,7 +14,7 @@ extends RefCounted
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
## concurrent mutation, so `_cache` / `_searched` access is guarded by
## `_mutex`. The mutex is held only across dictionary read/write — the slow
## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
## main-thread `invalidate()` can never block on a worker's subprocess.
## Two workers racing the same exe both call `_resolve()` and both write
## back the same answer; that's wasted work, not corruption.
@@ -24,6 +24,8 @@ static var _mutex: Mutex = Mutex.new()
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
static var _searched: Dictionary = {}
const _LOOKUP_TIMEOUT_MS := 3000
## Find any of the supplied exe names; returns the first hit.
## On Windows pass the .exe variant in `exe_names` if relevant.
@@ -54,8 +56,8 @@ static func _find_one(exe_name: String) -> String:
_mutex.unlock()
if already_searched:
return cached
# `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` /
# `which`), which can take 100ms-1s. Holding the mutex across that
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
# would let a concurrent `invalidate()` on the main thread freeze the
# editor for the duration of the subprocess — which defeats the whole
# point of running CLI lookup off the main thread.
@@ -81,20 +83,19 @@ static func _resolve(exe_name: String) -> String:
var shell := OS.get_environment("SHELL")
if shell.is_empty():
shell = "/bin/bash"
var login_output: Array = []
var stripped := exe_name.trim_suffix(".exe")
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
if login_exit == 0 and login_output.size() > 0:
var login_found: String = login_output[0].strip_edges()
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
if int(login_result.get("exit_code", -1)) == 0:
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
if not login_found.is_empty() and FileAccess.file_exists(login_found):
return login_found
# 3. which / where with inherited PATH
var lookup := "where" if is_windows else "which"
var output: Array = []
var exit_code := OS.execute(lookup, [exe_name], output, true)
if exit_code == 0 and output.size() > 0:
var lines := PackedStringArray(output[0].split("\n"))
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
if int(result.get("exit_code", -1)) == 0:
var output := str(result.get("stdout", ""))
var lines := PackedStringArray(output.split("\n"))
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
if not found.is_empty():
return found
+10 -3
View File
@@ -7,13 +7,20 @@ func _init() -> void:
display_name = "Antigravity"
config_type = "json"
doc_url = "https://www.antigravity.dev/"
## Antigravity moved its shared MCP config from `~/.gemini/antigravity/`
## to `~/.gemini/config/` (IDE + CLI now read the same file there); the
## old path is left in `detect_paths` below so an existing install is
## still recognized, but new/updated entries write to the current path.
path_template = {
"unix": "~/.gemini/antigravity/mcp_config.json",
"windows": "$USERPROFILE/.gemini/antigravity/mcp_config.json",
"unix": "~/.gemini/config/mcp_config.json",
"windows": "$USERPROFILE/.gemini/config/mcp_config.json",
}
server_key_path = PackedStringArray(["mcpServers"])
entry_url_field = "serverUrl"
## `disabled` is user-state (they may have flipped the entry off in the
## UI); seeded on first Configure but preserved across reconfigure.
entry_initial_fields = {"disabled": false}
detect_paths = PackedStringArray(path_template.values())
detect_paths = PackedStringArray(path_template.values() + [
"~/.gemini/antigravity/mcp_config.json",
"$USERPROFILE/.gemini/antigravity/mcp_config.json",
])
+38 -1
View File
@@ -48,6 +48,12 @@ var server_version := ""
var dispatcher
var log_buffer
var surfaced_error_tracker
## Set by plugin.gd. Lets the per-frame play-state poll end game-run
## bookkeeping when the game exits on its own (self-quit, crash) — the
## debugger session's stopped signal is not reliably connected, and no MCP
## stop op runs in that path (#642).
var debugger_plugin
## Set by plugin.gd when the HTTP port is occupied by an incompatible or
## unverified server. Keeping the Connection node alive lets handlers and the
## dock share one object, but no WebSocket is opened to the wrong server.
@@ -89,6 +95,10 @@ func _process(delta: float) -> void:
if pause_processing:
return
_peer.poll()
## Run-stop bookkeeping must not wait behind the socket-state machine:
## if the game stops while disconnected, the first command drained on
## reconnect would still observe stale "live" state (PR #642 review).
_check_game_run_play_state(EditorInterface.is_playing_scene())
match _peer.get_ready_state():
WebSocketPeer.STATE_OPEN:
@@ -309,6 +319,8 @@ func send_deferred_response(request_id: String, payload: Dictionary) -> void:
## `readiness_after` payload field were ever dropped.
if not response.has("readiness"):
response["readiness"] = get_readiness()
if not response.has("error_watermark"):
_stamp_error_watermark(response)
if _send_json(response) and dispatcher != null:
dispatcher.complete_deferred_response(request_id)
@@ -319,10 +331,15 @@ func _hook_editor_signals() -> void:
EditorInterface.get_editor_settings() # ensure interface is ready
_last_scene_path = _get_current_scene_path()
_last_play_state = EditorInterface.is_playing_scene()
_last_play_state_for_run = _last_play_state
var _last_scene_path := ""
var _last_play_state := false
## Separate edge tracker for game-run bookkeeping: _last_play_state only
## advances when the play_state_changed event sends successfully, but ending
## run tracking must not depend on the websocket being up.
var _last_play_state_for_run := false
var _last_readiness := ""
@@ -359,7 +376,22 @@ func _check_state_changes() -> void:
if send_event("readiness_changed", {"readiness": readiness}):
_last_readiness = readiness
if log_buffer:
log_buffer.log("[event] readiness -> %s" % readiness)
## echo=false: readiness flips on every filesystem scan
## (each import cycles importing -> ready), so echoing to
## console spams every install during normal editing (#626).
## The line stays in the ring for the dock's log panel.
log_buffer.log("[event] readiness -> %s" % readiness, false)
## Playing→stopped edge for game-run bookkeeping. Runs every process tick
## (any socket state) so a self-quit game's run ends even while the
## transport is down or reconnecting.
func _check_game_run_play_state(playing: bool) -> void:
if playing == _last_play_state_for_run:
return
if not playing and debugger_plugin != null:
debugger_plugin.note_editor_play_stopped()
_last_play_state_for_run = playing
func _get_current_scene_path() -> String:
@@ -403,6 +435,7 @@ func _handle_outbound_backpressure(
return false
var err_response := _make_backpressure_error(request_id, buffered_bytes, message_bytes)
_stamp_error_watermark(err_response)
var err_text := JSON.stringify(err_response)
var err_bytes := err_text.to_utf8_buffer().size()
if _would_exceed_outbound_backpressure(buffered_bytes, err_bytes):
@@ -457,6 +490,10 @@ static func _make_backpressure_error(
}
func _stamp_error_watermark(response: Dictionary) -> void:
McpSurfacedErrorTracker.stamp_watermark(response, surfaced_error_tracker)
## Build a human-readable session ID of form "<slug>@<4hex>" from the project path.
## The slug is derived from the project directory name so agents can recognize
## which editor they're targeting; the hex suffix disambiguates same-project twins.
+561 -23
View File
@@ -64,6 +64,8 @@ const EVAL_PROBE_INTERVAL_SEC := 0.35
var _log_buffer: McpLogBuffer
var _game_log_buffer: McpGameLogBuffer
var _editor_log_buffer: McpEditorLogBuffer
var _surfaced_error_tracker
## Pending request_id -> {connection, timer, timeout_callable}.
## We retain the bound timeout lambda so `_clear_pending` can disconnect
@@ -80,12 +82,41 @@ var _game_run_token := 0
var _ready_run_token := -1
var _game_session_id := -1
var _game_run_active := false
var _manual_run_armed := false
var _game_run_started_msec := 0
var _game_run_started_editor_cursor := 0
var _game_run_started_debugger_cursor := 0
var _game_helper_expected := true
## #645: a GDScript parse error hit while an editor-launched game boots calls
## GDScriptLanguage::debug_break_parse — the game parks in a remote-debugger
## break BEFORE the helper's mcp:hello and before any record reaches the
## Errors tab, the editor Logger, or the game log. The only editor-side traces
## are the debugger break signals; the stack frames land in the Stack Trace
## panel a few frames later. Track the break here so game_status can report
## status="break" and a synthesized error record can name the failure.
var _break_active := false
var _break_can_debug := false
var _break_reason := ""
var _break_pre_live := false
var _break_run_token := -1
var _break_record_synthesized := false
## #645: how long after the break signal to scrape the Stack Trace panel for
## frames. The editor requests the stack dump from the game separately, so the
## panel is empty at signal time; ~0.5s has it populated. The late tick
## synthesizes with whatever is available so a scrape failure still yields a
## record carrying the break reason.
const BREAK_FRAME_SCRAPE_DELAYS_SEC: Array[float] = [0.5, 2.0]
signal game_ready
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void:
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_game_log_buffer = game_log_buffer
_editor_log_buffer = editor_log_buffer
_surfaced_error_tracker = surfaced_error_tracker
func _has_capture(prefix: String) -> bool:
@@ -102,32 +133,512 @@ func _has_capture(prefix: String) -> bool:
## plugin.gd's _enter_tree logs "plugin loaded", and ci-reload-test
## asserts "plugin loaded" is the first line after a plugin reload.
func _setup_session(session_id: int) -> void:
_connect_session_stopped(session_id)
_connect_session_break_signals(session_id)
if EditorInterface.is_playing_scene() and not _game_run_active:
_begin_game_run_tracking(_editor_log_cursor(), true, true, true, true, true)
else:
_game_ready = false
_ready_run_token = -1
_game_session_id = session_id
func begin_game_run() -> void:
func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void:
_begin_game_run_tracking(editor_log_cursor, helper_expected, true, true)
func _begin_game_run_tracking(
editor_log_cursor: int = 0,
helper_expected: bool = true,
rotate_game_log: bool = true,
sticky_debugger_scan: bool = true,
quiet: bool = false,
manual_armed: bool = false,
) -> void:
_game_run_token += 1
_game_run_active = true
_manual_run_armed = manual_armed
_game_ready = false
_ready_run_token = -1
_game_session_id = -1
if _log_buffer:
_log_buffer.log("[debug] game capture pending run token %d" % _game_run_token)
clear_debug_break()
_game_run_started_msec = Time.get_ticks_msec()
_game_run_started_editor_cursor = maxi(0, editor_log_cursor)
if _surfaced_error_tracker != null:
_surfaced_error_tracker.note_game_run_started(sticky_debugger_scan)
_game_run_started_debugger_cursor = _surfaced_error_tracker.debugger_promoted_total()
else:
_game_run_started_debugger_cursor = 0
_game_helper_expected = helper_expected
var run_id := ""
if _game_log_buffer and rotate_game_log:
run_id = _game_log_buffer.clear_for_new_run()
if _log_buffer and not quiet:
var log_text := "[debug] game capture pending run token %d" % _game_run_token
if not run_id.is_empty():
log_text += " (run %s)" % run_id
_log_buffer.log(log_text)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func end_game_run() -> void:
_game_run_active = false
_manual_run_armed = false
_game_ready = false
_ready_run_token = -1
_game_session_id = -1
clear_debug_break()
if _surfaced_error_tracker != null:
_surfaced_error_tracker.note_game_run_stopped()
## Authoritative fallback for runs whose debugger `stopped` signal never
## fired or was never connected: the editor's play state falling to stopped
## means the game process is gone. A game that exits on its own
## (get_tree().quit(), crash) has no MCP stop op to run the bookkeeping, and
## without this game_status stayed "live" until the next run (#642 smoke).
## Called on the playing→stopped edge only, so the pre-play launch window
## (run tracking begun, is_playing_scene() not yet true) is never clipped.
func note_editor_play_stopped() -> void:
if not _game_run_active:
return
end_game_run()
func _connect_session_stopped(session_id: int) -> void:
var session = get_session(session_id)
if session == null:
return
var stopped := Callable(self, "_on_debugger_session_stopped").bind(session_id)
if not session.stopped.is_connected(stopped):
session.stopped.connect(stopped)
func _on_debugger_session_stopped(session_id: int) -> void:
if _game_session_id != -1 and session_id != _game_session_id:
return
## MCP-started runs normally end via project_manage(op="stop"), but a game
## that exits on its own (get_tree().quit(), crash) emits only this signal.
## Without ending the run here, game_status stays "live" until the next
## run's bookkeeping rewrites it (#642 live smoke). Before the game session
## attaches (_game_session_id == -1) only manual runs may end on this
## signal — a foreign session's stop must not cancel a launching MCP run.
if not _manual_run_armed and _game_session_id == -1:
return
end_game_run()
## --- #645: boot-time debugger breaks ---------------------------------------
func _connect_session_break_signals(session_id: int) -> void:
var session = get_session(session_id)
if session != null:
var breaked_cb := Callable(self, "_on_debugger_session_breaked").bind(session_id)
if not session.breaked.is_connected(breaked_cb):
session.breaked.connect(breaked_cb)
var continued_cb := Callable(self, "_on_debugger_session_continued").bind(session_id)
if not session.continued.is_connected(continued_cb):
session.continued.connect(continued_cb)
_connect_script_debugger_breaked()
## The session-level `breaked` signal carries only can_debug; the underlying
## ScriptEditorDebugger's own `breaked` also carries the human-readable break
## reason ("Parser Error: ..."), which is otherwise visible only in the
## Debugger UI. EditorDebuggerSession does not expose its debugger node, so
## locate ScriptEditorDebugger instances by walking the editor UI — the same
## approach as the tracker's Errors-tab scrape.
func _connect_script_debugger_breaked() -> void:
var base := EditorInterface.get_base_control()
if base == null:
return
var debuggers: Array[Node] = []
_collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
for dbg in debuggers:
if not dbg.has_signal("breaked"):
continue
var cb := Callable(self, "_on_script_debugger_breaked")
if not dbg.is_connected("breaked", cb):
dbg.connect("breaked", cb)
static func _collect_nodes_of_class(node: Node, klass: String, out: Array[Node]) -> void:
if node.get_class() == klass:
out.append(node)
for child in node.get_children():
_collect_nodes_of_class(child, klass, out)
func _on_debugger_session_breaked(can_debug: bool, session_id: int) -> void:
if _game_session_id != -1 and session_id != _game_session_id:
return
note_debug_break(can_debug, "")
func _on_debugger_session_continued(session_id: int) -> void:
if _game_session_id != -1 and session_id != _game_session_id:
return
clear_debug_break()
## reallydid=false is Godot's "left the break" notification (debug_exit).
func _on_script_debugger_breaked(reallydid: bool, can_debug: bool, reason: String, _has_stackdump: bool) -> void:
if not reallydid:
clear_debug_break()
return
note_debug_break(can_debug, reason)
## Record that the game process is parked in a remote-debugger break. Fires
## once per break from the session signal (no reason text) and again moments
## later from the ScriptEditorDebugger signal (with reason) — the notices
## merge into one break. Public so tests can drive break state directly.
func note_debug_break(can_debug: bool, reason: String) -> void:
var first_notice := not _break_active
_break_active = true
_break_can_debug = can_debug
if not reason.is_empty():
_break_reason = reason
if not first_notice:
return
_break_run_token = _game_run_token
_break_pre_live = _game_run_active and not is_game_capture_ready()
_break_record_synthesized = false
if _log_buffer:
_log_buffer.log("[debug] debugger break (pre_live=%s can_debug=%s)" % [str(_break_pre_live), str(can_debug)])
if _break_pre_live:
_schedule_break_record_synthesis()
func clear_debug_break() -> void:
_break_active = false
_break_can_debug = false
_break_reason = ""
_break_pre_live = false
_break_run_token = -1
_break_record_synthesized = false
## Stack frames land in the Stack Trace panel a few frames after the break
## signal (the editor requests the stack dump separately), so the record is
## synthesized on short timers rather than at signal time.
func _schedule_break_record_synthesis() -> void:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return
var token := _break_run_token
for i in BREAK_FRAME_SCRAPE_DELAYS_SEC.size():
var final := i == BREAK_FRAME_SCRAPE_DELAYS_SEC.size() - 1
var timer := tree.create_timer(BREAK_FRAME_SCRAPE_DELAYS_SEC[i])
timer.timeout.connect(func() -> void: _on_break_scrape_tick(token, final))
func _on_break_scrape_tick(run_token: int, final: bool) -> void:
if not _break_active or _break_record_synthesized or run_token != _break_run_token:
return
var frames := _scrape_break_stack_frames()
if frames.is_empty() and not final:
return
synthesize_break_error_record(frames)
## Read the debugger's Stack Trace panel rows. Row metadata is a Dictionary
## {frame, file, function, line} regardless of editor locale, so stack trees
## are identified by metadata shape rather than the translated column title.
func _scrape_break_stack_frames() -> Array[Dictionary]:
var base := EditorInterface.get_base_control()
if base == null:
return []
var debuggers: Array[Node] = []
_collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers)
for dbg in debuggers:
var trees: Array[Node] = []
_collect_nodes_of_class(dbg, "Tree", trees)
for t in trees:
var frames := _frames_from_stack_tree(t as Tree)
if not frames.is_empty():
return frames
return []
static func _frames_from_stack_tree(tree: Tree) -> Array[Dictionary]:
var frames: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return frames
var item := root.get_first_child()
while item != null:
var meta = item.get_metadata(0)
if not (meta is Dictionary and meta.has("file") and meta.has("frame")):
return []
frames.append({
"path": str(meta.get("file", "")),
"line": int(meta.get("line", 0)),
"function": str(meta.get("function", "")),
})
item = item.get_next()
return frames
## Build the Errors-tab-shaped record for a boot-time break and promote it via
## the tracker so recent_editor_errors_since / logs_read / the watermark all
## surface it — the break itself produces no record anywhere else (#645).
## Public so tests can synthesize without waiting on scrape timers.
func synthesize_break_error_record(frames: Array[Dictionary]) -> void:
if _break_record_synthesized:
return
_break_record_synthesized = true
if _surfaced_error_tracker == null:
return
var reason := _break_reason
if reason.is_empty():
reason = "Game process broke into the debugger during startup (script parse/load error; reason not captured)"
var top: Dictionary = frames[0] if not frames.is_empty() else {}
var location := {
"path": str(top.get("path", "")),
"line": int(top.get("line", 0)),
"function": str(top.get("function", "")),
}
var entry := {
"source": "editor",
"level": "error",
"text": reason,
"path": location["path"],
"line": location["line"],
"function": location["function"],
"details": {
"debugger_tab": "Stack Trace",
"message": reason,
"error_type_name": "debugger_break",
"source": location.duplicate(true),
"resolved": location.duplicate(true),
"frames": frames.duplicate(true),
},
}
_surfaced_error_tracker.record_synthetic_error(entry)
if _log_buffer:
_log_buffer.log("[debug] synthesized boot-break error record: %s" % McpSurfacedErrorTracker.format_editor_error_summary(entry))
## --- end #645 ---------------------------------------------------------------
func is_game_capture_ready() -> bool:
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
static func with_liveness_flags(status: Dictionary) -> Dictionary:
var enriched := status.duplicate(true)
var state := str(enriched.get("status", "stopped"))
enriched["helper_live"] = state == "live"
enriched["session_active"] = not state in ["not_live", "stopped"]
return enriched
func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary:
var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec
var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0))
var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0
## "stopped" also covers idle/never-ran; no game run is currently active.
var status := "stopped"
if _game_run_active:
## #645: a parked process takes precedence over "live" — a game frozen
## in a remote-debugger break cannot service game-side tools even when
## its helper registered before the break.
if _break_active:
status = "break"
elif is_game_capture_ready():
status = "live"
elif not _game_helper_expected:
status = "no_helper"
elif elapsed_msec >= ready_wait_msec:
status = "not_live"
else:
status = "launching"
var out := {
"status": status,
"run_token": _game_run_token,
"active": _game_run_active,
"ready": is_game_capture_ready(),
"helper_expected": _game_helper_expected,
"run_started_msec": _game_run_started_msec,
"elapsed_msec": elapsed_msec,
"ready_wait_msec": ready_wait_msec,
"editor_log_cursor": _game_run_started_editor_cursor,
}
if status == "break":
out["break"] = {
"reason": _break_reason,
"can_debug": _break_can_debug,
"pre_live": _break_pre_live,
}
return with_liveness_flags(out)
func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary:
var state := str(status.get("status", "stopped"))
var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var recent_errors: Array = errors_info.get("errors", [])
var recent_errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
var data := {
"game_status": status.duplicate(true),
"recent_errors": recent_errors,
"recent_errors_scope": recent_errors_scope,
"recent_errors_may_predate_run": recent_errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
}
data.merge(split_errors_by_scope(recent_errors, recent_errors_scope), true)
var message := ""
match state:
"not_live":
if not recent_errors.is_empty() and recent_errors_scope == "run":
message = "The game failed to load or crashed before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
message += " Editor logs since this run may be truncated; showing retained errors."
elif not recent_errors.is_empty():
message = "The game is not responding and reported no load errors during this run. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
message = "The game is not responding and reported no load errors before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and retry."
"break":
var break_info: Dictionary = status.get("break", {})
var break_reason := str(break_info.get("reason", ""))
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
if bool(break_info.get("pre_live", true)):
message = "The game hit a script error during startup and is frozen at a debugger break%s. It cannot become live; call project_manage(op='stop') to end the run, fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % reason_suffix
else:
message = "The game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
"no_helper":
message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game."
"launching":
message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0)
"stopped":
message = "The game is not running. Start the project and retry the game-side tool."
_:
message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state
var err := ErrorCodes.make(code, message)
var inner: Dictionary = err.get("error", {})
inner["data"] = data
err["error"] = inner
return err
static func split_errors_by_scope(recent_errors: Array, scope: String) -> Dictionary:
var current_run_errors: Array = []
var retained_errors: Array = []
if scope == "run":
current_run_errors = recent_errors
elif scope == "retained_recent":
retained_errors = recent_errors
return {
"current_run_errors": current_run_errors,
"retained_errors": retained_errors,
}
## `force_debugger_scan` bypasses the tracker's scan gate for one read. Keep it
## false on per-frame polling paths (the run-liveness loop) — a forced scan
## walks the Debugger dock UI — and pass true only for one-shot reads that must
## see rows which landed after the last gated scan (#641).
func recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
return _recent_editor_errors_since(cursor, force_debugger_scan)
func _recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary:
var out: Array[Dictionary] = []
var truncated := false
if _surfaced_error_tracker != null:
var captured_by_tracker: Dictionary = _surfaced_error_tracker.editor_entries_since(
maxi(0, cursor),
_game_run_started_debugger_cursor,
force_debugger_scan,
)
truncated = bool(captured_by_tracker.get("truncated", false))
for raw_entry in captured_by_tracker.get("entries", []):
var compact := _compact_editor_error(raw_entry)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": truncated, "scope": "run"}
for raw_entry in _surfaced_error_tracker.retained_recent_editor_entries():
var compact := _compact_editor_error(raw_entry, true)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": false, "scope": "retained_recent"}
return {"errors": out, "truncated": false, "scope": "none"}
if _editor_log_buffer == null:
return {"errors": out, "truncated": false, "scope": "none"}
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1)
truncated = bool(captured.get("truncated", false))
for raw_entry in captured.get("entries", []):
var compact := _compact_editor_error(raw_entry)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": truncated, "scope": "run"}
for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)):
var compact := _compact_editor_error(raw_entry, true)
if compact.is_empty():
continue
out.append(compact)
if out.size() >= 5:
break
if not out.is_empty():
return {"errors": out, "truncated": false, "scope": "retained_recent"}
return {"errors": out, "truncated": false, "scope": "none"}
func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary:
if not raw_entry is Dictionary:
return {}
var entry := raw_entry as Dictionary
if str(entry.get("level", "info")) != "error":
return {}
var path := str(entry.get("path", ""))
if fallback_recent and _is_diagnostic_noise_path(path):
return {}
var compact := {
"source": "editor",
"level": "error",
"text": str(entry.get("text", "")),
"path": path,
"line": int(entry.get("line", 0)),
"function": str(entry.get("function", "")),
}
if entry.has("details"):
compact["details"] = entry["details"].duplicate(true)
return compact
func _is_diagnostic_noise_path(path: String) -> bool:
return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/")
func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for i in range(entries.size() - 1, -1, -1):
out.append(entries[i])
return out
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func _capture(message: String, data: Array, session_id: int) -> bool:
## Godot passes the full "prefix:tail" string as `message`.
match message:
@@ -152,17 +663,21 @@ func _capture(message: String, data: Array, session_id: int) -> bool:
## Boot beacon from the game-side autoload. Tells us the
## game has registered its "mcp" capture and is safe to send
## take_screenshot to — before this, Godot's debugger would
## drop our message silently. Also marks a fresh play
## cycle: rotate the game-log buffer so each run starts
## clean and gets a new run_id.
## drop our message silently.
_game_ready = true
_ready_run_token = _game_run_token
game_ready.emit()
if _game_log_buffer:
var run_id := _game_log_buffer.clear_for_new_run()
## #641: boot-time parse errors race the hello beacon — both ride
## the same debugger channel, and the editor inserts Errors-tab
## rows with a per-frame throttle, so rows can land moments after
## the run is declared live. Arm forced scans so those rows get
## promoted into the watermark even if no tool call follows.
if _surfaced_error_tracker != null:
_surfaced_error_tracker.schedule_deferred_scans()
if _log_buffer:
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id)
elif _log_buffer:
if _game_log_buffer:
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id())
else:
_log_buffer.log("[debug] <- mcp:hello from game_helper")
return true
"mcp:eval_response":
@@ -259,11 +774,17 @@ func _wait_then_send(
timeout_sec: float,
) -> void:
var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
## #645: always yield at least one frame — the dispatcher registers the
## deferred request only after the handler returns DEFERRED_RESPONSE, so a
## same-frame error reply would be dropped as an expired request. The break
## check then bails out with the actionable break error instead of waiting
## out the full window (a game parked in a debugger break never beacons).
await tree.process_frame
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC))
_send_error_response(connection, request_id,
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
return
_send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
@@ -357,16 +878,23 @@ func _on_timeout(request_id: String) -> void:
var connection: McpConnection = pending.connection
if connection == null or not is_instance_valid(connection):
return
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.")
var status := get_game_status(-1, GAME_READY_WAIT_SEC)
var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Game screenshot timed out after reaching the game helper. The game may be busy or unable to render a frame. Check logs_read(source='game') and retry.")
if status.get("status", "") != "live":
err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR)
_send_error_response(connection, request_id, err)
if _log_buffer:
_log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id)
func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void:
_send_error_response(connection, request_id, ErrorCodes.make(code, message))
func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void:
if connection == null or not is_instance_valid(connection):
return
var err := ErrorCodes.make(code, message)
connection.send_deferred_response(request_id, err)
@@ -440,15 +968,20 @@ func _wait_then_eval(
## the not-ready path returns its actionable error before the 15s server-side
## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC.
var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
## #645: the leading yield guarantees the dispatcher has registered the
## deferred request before any reply (a same-frame reply is dropped as
## expired); the break check bails out early because a parked game never
## registers its capture.
await tree.process_frame
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up
## but the game-side capture didn't register within the short wait. Fast
## and caller-actionable; classifying it apart from the opaque 10s hang
## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
"Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC))
_send_error_response(connection, request_id,
_explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY))
return
_send_eval(tree, code, request_id, connection, timeout_sec)
@@ -701,11 +1234,16 @@ func _wait_then_game_command(
timeout_sec: float,
) -> void:
var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0)
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
## #645: the leading yield guarantees the dispatcher has registered the
## deferred request before any reply (a same-frame reply is dropped as
## expired); the break check bails out early because a parked game never
## registers its capture.
await tree.process_frame
while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline:
await tree.process_frame
if not is_game_capture_ready():
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC))
_send_error_response(connection, request_id,
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
return
_send_game_command(tree, op, params, request_id, connection, timeout_sec)
+17 -7
View File
@@ -9,6 +9,7 @@ var _command_queue: Array[Dictionary] = []
var _handlers: Dictionary = {} # command_name -> Callable
var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms}
var _log_buffer
var _surfaced_error_tracker
var mcp_logging := true
var deferred_timeout_overrides_ms: Dictionary = {}
@@ -16,16 +17,19 @@ const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
"create_script": 4500,
"stop_project": 4500,
"run_project": 6000,
"take_screenshot": 30000,
"game_eval": 15000,
"game_command": 15000,
"scan_filesystem": 30000,
}
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd")
func _init(log_buffer: McpLogBuffer) -> void:
func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_surfaced_error_tracker = surfaced_error_tracker
## Register a command handler. The callable receives (params: Dictionary) -> Dictionary.
@@ -41,6 +45,11 @@ func clear() -> void:
_command_queue.clear()
_pending_deferred.clear()
_log_buffer = null
_surfaced_error_tracker = null
func set_surfaced_error_tracker(surfaced_error_tracker) -> void:
_surfaced_error_tracker = surfaced_error_tracker
## Invoke a registered handler directly by name. Returns the handler's raw
@@ -160,6 +169,7 @@ func _dispatch(cmd: Dictionary) -> Dictionary:
## See connection.gd::send_deferred_response for the deferred-response
## counterpart, which stamps the same field.
result["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(result)
if mcp_logging:
var status: String = result.get("status", "ok")
@@ -251,19 +261,19 @@ func _collect_deferred_timeouts() -> Array[Dictionary]:
## dispatcher emits so the server cache can't drift just because
## the editor happened to time out a deferred command.
response["readiness"] = McpConnection.get_readiness()
_stamp_error_watermark(response)
responses.append(response)
if mcp_logging and _log_buffer != null:
_log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id])
return responses
func _stamp_error_watermark(response: Dictionary) -> void:
McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker)
static func _capture_compact_backtrace(max_frames: int = 8) -> String:
# Use Engine.call() instead of a direct Engine.capture_script_backtraces()
# reference: the method is Godot 4.4+, and 4.3's GDScript parser type-checks
# the static call against GDScriptNativeClass at parse time and rejects the
# whole script even when guarded by has_method() at runtime.
if Engine.has_method("capture_script_backtraces"):
var traces: Array = Engine.call("capture_script_backtraces", false)
var traces: Array = Engine.capture_script_backtraces(false)
for bt in traces:
if bt != null and not bt.is_empty():
return _trim_backtrace_string(bt.format(0, 2), max_frames)
+8 -1
View File
@@ -12,6 +12,9 @@ extends VBoxContainer
signal logging_enabled_changed(enabled: bool)
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
## Preload (not the McpSettings class_name) for consistency with the parse
## hazard note on `_log_buffer` below.
const Settings := preload("res://addons/godot_ai/utils/settings.gd")
## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at
## script-load and trips the self-update parse hazard (#398). The type fence
@@ -50,7 +53,9 @@ func _build_ui() -> void:
_log_toggle = CheckButton.new()
_log_toggle.text = "Log"
_log_toggle.button_pressed = true
## Restore the persisted choice — a hardcoded `true` here meant the
## toggle reset to noisy on every editor restart (#626).
_log_toggle.button_pressed = Settings.mcp_logging_enabled()
_log_toggle.toggled.connect(_on_log_toggled)
log_header_row.add_child(_log_toggle)
@@ -62,6 +67,7 @@ func _build_ui() -> void:
_log_display.scroll_following = true
_log_display.bbcode_enabled = false
_log_display.selection_enabled = true
_log_display.visible = _log_toggle.button_pressed
add_child(_log_display)
@@ -91,5 +97,6 @@ func tick() -> void:
func _on_log_toggled(enabled: bool) -> void:
Settings.set_mcp_logging_enabled(enabled)
_log_display.visible = enabled
logging_enabled_changed.emit(enabled)
+133 -273
View File
@@ -14,20 +14,24 @@ var _debugger_plugin: McpDebuggerPlugin
var _game_log_buffer: McpGameLogBuffer
var _editor_log_buffer: McpEditorLogBuffer
var _debugger_errors_root: Node
var _debugger_search_root_cache: Node
var _surfaced_error_tracker
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null) -> void:
func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null, surfaced_error_tracker = null) -> void:
_log_buffer = log_buffer
_connection = connection
_debugger_plugin = debugger_plugin
_game_log_buffer = game_log_buffer
_editor_log_buffer = editor_log_buffer
_debugger_errors_root = debugger_errors_root
_surfaced_error_tracker = surfaced_error_tracker
if _surfaced_error_tracker == null:
_surfaced_error_tracker = McpSurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer, _debugger_errors_root)
func get_editor_state(_params: Dictionary) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
var game_status := _current_game_status()
var data := {
"godot_version": Engine.get_version_info().get("string", "unknown"),
"project_name": ProjectSettings.get_setting("application/config/name", ""),
@@ -38,6 +42,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
## false between Play→Stop cycles. Lets capture-source=game callers
## poll for a real ready signal instead of guessing with sleep().
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
"game_status": game_status,
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
}
## Half-installed addon tree from a failed self-update rollback. When
## non-empty, the agent / dock paint the operator-facing recovery copy
@@ -72,6 +79,7 @@ func get_logs(params: Dictionary) -> Dictionary:
var include_details: bool = bool(params.get("include_details", false))
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
if not source in VALID_LOG_SOURCES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
@@ -82,7 +90,7 @@ func get_logs(params: Dictionary) -> Dictionary:
"plugin":
return _get_plugin_logs(count, offset)
"game":
return _get_game_logs(count, offset, include_details)
return _get_game_logs(count, offset, include_details, since_run_id)
"editor":
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
"all":
@@ -90,6 +98,17 @@ func get_logs(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
func _current_game_status() -> Dictionary:
if _debugger_plugin == null:
return McpDebuggerPlugin.with_liveness_flags({
"status": "stopped",
"active": false,
"ready": false,
"helper_expected": true,
})
return _debugger_plugin.get_game_status()
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
var page: Array[Dictionary] = []
@@ -107,7 +126,10 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
}
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
var game_status := _current_game_status()
var helper_live := bool(game_status.get("helper_live", false))
var session_active := bool(game_status.get("session_active", false))
if _game_log_buffer == null:
return {
"data": {
@@ -117,23 +139,75 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
"returned_count": 0,
"offset": offset,
"run_id": "",
"is_running": false,
"current_run_id": "",
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": 0,
"stale_run_id": false,
}
}
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
return {
"data": {
var current_run_id := _game_log_buffer.run_id()
var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
var page := _entries_for_response(run_page.get("entries", []), include_details)
var data := {
"source": "game",
"lines": page,
"total_count": _game_log_buffer.total_count(),
"total_count": int(run_page.get("total_count", 0)),
"returned_count": page.size(),
"offset": offset,
"run_id": _game_log_buffer.run_id(),
"is_running": EditorInterface.is_playing_scene(),
"run_id": target_run_id,
"current_run_id": current_run_id,
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": _game_log_buffer.dropped_count(),
"stale_run_id": stale_run_id,
}
}
_merge_editor_errors_hint(data, game_status)
return {"data": data}
## #641: boot-time parse errors happen while autoload scripts compile — before
## the game helper's logger attaches via OS.add_logger — so they can NEVER
## appear in the game buffer. They surface only through the editor scope
## (Errors-tab rows + editor logger). Cross-reference them here so an
## empty/clean game log is not mistaken for a clean launch.
func _merge_editor_errors_hint(data: Dictionary, game_status: Dictionary) -> void:
if _debugger_plugin == null:
return
## A since_run_id read of a prior run must not carry the CURRENT run's
## editor errors — the hint interprets the run being read.
if bool(data.get("stale_run_id", false)):
return
## run_token == 0 means no tracked run ever started this session; the
## run-start cursor would be 0 and every retained editor error would be
## misattributed to "this run".
if int(game_status.get("run_token", 0)) <= 0:
return
## One-shot read — force the scan so rows that landed after the last
## gated scan (and before the deferred timers fire) make the FIRST
## logs_read(source='game') response, not just a later one.
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(
int(game_status.get("editor_log_cursor", 0)), true)
if str(errors_info.get("scope", "none")) != "run":
return
var errors: Array = errors_info.get("errors", [])
if errors.is_empty():
return
data["editor_errors_count"] = errors.size()
data["editor_errors_hint"] = (
"%d editor-side error%s from this run (first: %s) missing from the game log — boot-time parse/load errors occur before the game helper's logger attaches. Read logs_read(source='editor', include_details=true)."
% [errors.size(), "s" if errors.size() != 1 else "", _format_editor_error_summary(errors[0])]
)
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary:
@@ -211,21 +285,24 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
combined.append({"source": "plugin", "level": "info", "text": line})
for entry in _collect_editor_log_entries():
combined.append(entry)
var run_id := ""
var current_run_id := ""
var dropped := 0
if _game_log_buffer != null:
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
run_id = _game_log_buffer.run_id()
current_run_id = run_id
dropped = _game_log_buffer.dropped_count()
var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
for entry in run_page.get("entries", []):
combined.append(entry)
var stop := mini(combined.size(), offset + count)
var page: Array[Dictionary] = []
for i in range(mini(offset, combined.size()), stop):
page.append(combined[i])
page = _entries_for_response(page, include_details)
var run_id := ""
var dropped := 0
if _game_log_buffer != null:
run_id = _game_log_buffer.run_id()
dropped = _game_log_buffer.dropped_count()
if _editor_log_buffer != null:
dropped += _editor_log_buffer.dropped_count()
var game_status := _current_game_status()
return {
"data": {
"source": "all",
@@ -234,7 +311,11 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
"returned_count": page.size(),
"offset": offset,
"run_id": run_id,
"is_running": EditorInterface.is_playing_scene(),
"current_run_id": current_run_id,
"is_running": bool(game_status.get("session_active", false)),
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
"game_status": game_status,
"dropped_count": dropped,
}
}
@@ -256,208 +337,7 @@ func _entries_for_response(entries: Array[Dictionary], include_details: bool) ->
func _collect_editor_log_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
entries.append(entry)
for entry in _read_debugger_error_entries():
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in _locate_debugger_error_trees():
for entry in _entries_from_debugger_error_tree(tree):
if not _has_equivalent_log_entry(entries, entry):
entries.append(entry)
return entries
func _locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
if _debugger_plugin == null and _debugger_errors_root == null:
return trees
var root: Node = _debugger_errors_root
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func _debugger_search_root() -> Node:
## logs_read is a polling tool, so per-call discovery must not recurse the
## entire editor UI. EditorDebuggerNode is the bottom-panel container that
## owns every ScriptEditorDebugger session tab and lives for the editor's
## lifetime — find it once from the base control, then scan only its
## subtree on later calls. The error Trees themselves can't be cached:
## they are identified by their content, and an emptied tree is
## indistinguishable from any other Tree.
if is_instance_valid(_debugger_search_root_cache):
return _debugger_search_root_cache
_debugger_search_root_cache = null
var base := EditorInterface.get_base_control()
if base == null:
return null
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
if _debugger_search_root_cache == null:
return base
return _debugger_search_root_cache
static func _find_first_of_class(node: Node, klass: String) -> Node:
if node.get_class() == klass:
return node
for child in node.get_children():
var found := _find_first_of_class(child, klass)
if found != null:
return found
return null
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
if node is Tree and _tree_has_debugger_errors(node as Tree):
out.append(node as Tree)
for child in node.get_children():
if child is Node:
_collect_debugger_error_trees(child as Node, out)
static func _tree_has_debugger_errors(tree: Tree) -> bool:
var root := tree.get_root()
if root == null:
return false
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
return true
item = item.get_next()
return false
static func _entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return entries
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
entries.append(_entry_from_debugger_error_item(item))
item = item.get_next()
return entries
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
var title := item.get_text(1)
var loc := _location_from_metadata(item.get_metadata(0))
var function := _function_from_title(title)
return {
"source": "editor",
"level": "warn" if item.has_meta("_is_warning") else "error",
"text": title,
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
"details": _details_from_debugger_error_item(item, loc, function),
}
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
var children: Array[Dictionary] = []
var child := item.get_first_child()
while child != null:
var child_loc := _location_from_metadata(child.get_metadata(0))
children.append({
"label": child.get_text(0),
"text": child.get_text(1),
"path": str(child_loc.get("path", "")),
"line": int(child_loc.get("line", 0)),
})
child = child.get_next()
return {
"debugger_tab": "Errors",
"time": item.get_text(0),
"message": item.get_text(1),
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
"source": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"resolved": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"children": children,
"frames": _frames_from_error_children(children),
}
static func _is_debugger_error_item(item: TreeItem) -> bool:
return item.has_meta("_is_warning") or item.has_meta("_is_error")
## ScriptEditorDebugger lays out an error item's children flat, in order: an
## optional "<X Error>" row, one "<X Source>" row, then one row per stack
## frame. Only frame 0 carries the "<Stack Trace>" label (TTR-translated);
## later frames have an empty label. Every frame row carries [path, line]
## metadata, but so can the Error/Source rows, so metadata alone can't
## identify frames — the frame run has to be found first.
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
var start := -1
for i in children.size():
if str(children[i].label).contains("Stack Trace"):
start = i
break
if start < 0:
## Non-English editor locale: the "<Stack Trace>" label is translated.
## Frames past the first are the only rows with an empty label and a
## real location; back up one row to recover the labeled first frame
## (rows before the frame run always have a non-empty label).
for i in children.size():
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
start = maxi(i - 1, 0)
break
if start < 0:
return []
var frames: Array[Dictionary] = []
for i in range(start, children.size()):
if str(children[i].path).is_empty():
continue
frames.append({
"path": children[i].path,
"line": children[i].line,
"function": _function_from_frame_text(children[i].text),
})
return frames
static func _location_from_metadata(meta: Variant) -> Dictionary:
if meta is Array and meta.size() >= 2:
return {"path": str(meta[0]), "line": int(meta[1])}
return {"path": "", "line": 0}
static func _function_from_title(title: String) -> String:
var colon := title.find(": ")
if colon <= 0:
return ""
return title.substr(0, colon)
static func _function_from_frame_text(text: String) -> String:
var marker := text.find(" @ ")
if marker < 0:
return ""
var fn := text.substr(marker + 3).strip_edges()
if fn.ends_with("()"):
fn = fn.substr(0, fn.length() - 2)
return fn
return _surfaced_error_tracker.collect_editor_log_entries()
static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]:
@@ -468,23 +348,6 @@ static func _slice_entries(entries: Array[Dictionary], offset: int, count: int)
return page
static func _has_equivalent_log_entry(entries: Array[Dictionary], candidate: Dictionary) -> bool:
var key := _log_entry_key(candidate)
for entry in entries:
if _log_entry_key(entry) == key:
return true
return false
static func _log_entry_key(entry: Dictionary) -> String:
return "%s|%s|%s|%s" % [
str(entry.get("level", "")),
str(entry.get("text", "")),
str(entry.get("path", "")),
str(entry.get("line", 0)),
]
## Map of human-readable monitor names to Performance.Monitor enum values.
const MONITORS := {
"time/fps": Performance.TIME_FPS,
@@ -591,8 +454,30 @@ func take_screenshot(params: Dictionary) -> Dictionary:
return McpDispatcher.DEFERRED_RESPONSE
"cinematic":
return _take_cinematic_screenshot(max_resolution)
"viewport_2d":
viewport = EditorInterface.get_editor_viewport_2d()
if viewport == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No 2D viewport available")
var scene_root_2d := EditorInterface.get_edited_scene_root()
if scene_root_2d == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY,
"No scene open — open a scene first")
if not view_target.is_empty() or coverage or custom_elevation != null or custom_azimuth != null or custom_fov != null:
return ErrorCodes.make(
ErrorCodes.INVALID_PARAMS,
"view_target, coverage, elevation, azimuth, and fov are not supported with source='viewport_2d'"
)
## Capture the 2D editor viewport directly; no view_target/coverage for 2D.
RenderingServer.force_draw(false)
var image_2d: Image = viewport.get_texture().get_image()
if image_2d == null or image_2d.is_empty():
return _empty_image_error(
"viewport_2d",
"Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet."
)
return _finalize_image(image_2d, "viewport_2d", max_resolution)
_:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'cinematic', or 'game'" % source)
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source)
## Handle view_target: temporarily reposition the editor's own camera to
## frame one or more target nodes, force a render, capture, then restore.
@@ -845,12 +730,14 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
return {}
var root_type := scene_root.get_class()
var hint: String
if scene_root is Node2D or scene_root is Control:
var is_2d_scene := scene_root is CanvasItem
if is_2d_scene:
hint = (
"The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. "
+ "Options: (a) open a 3D scene, "
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
+ "(c) use source=\"viewport_2d\" to capture the 2D editor viewport directly, "
+ "(d) call scene_get_hierarchy first to inspect what's available."
) % root_type
else:
hint = (
@@ -859,7 +746,10 @@ static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary:
+ "(b) use source=\"cinematic\" if a Camera3D exists in the scene, "
+ "(c) call scene_get_hierarchy first to inspect what's available."
) % root_type
return _make_viewport_not_3d_error(root_type, hint)
var err := _make_viewport_not_3d_error(root_type, hint)
if is_2d_scene:
err["error"]["data"]["suggestion"] = "use source='viewport_2d' for 2D scenes"
return err
## True if scene_root is itself a Node3D or owns any Node3D descendant.
@@ -1029,37 +919,7 @@ func clear_logs(params: Dictionary) -> Dictionary:
func _clear_debugger_error_trees() -> int:
var cleared := 0
for tree in _locate_debugger_error_trees():
cleared += _entries_from_debugger_error_tree(tree).size()
if not _press_debugger_clear_button(tree):
## No Clear button near this tree (synthetic roots in tests).
## A raw clear is acceptable there; the real panel always routes
## through the button below.
tree.clear()
return cleared
## Clear via ScriptEditorDebugger's own Clear button so the engine runs
## _clear_errors_list() — clearing the Tree directly leaves error_count/
## warning_count, the "Errors (N)" tab badge, the errors_cleared signal, and
## the toolbar button states out of sync with the emptied tree. The button is
## identified by its pressed-connection target, not its (translated) label.
static func _press_debugger_clear_button(tree: Tree) -> bool:
var parent := tree.get_parent()
if parent == null:
return false
var stack: Array[Node] = [parent]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is BaseButton:
for conn in node.get_signal_connection_list("pressed"):
if str(conn.get("callable", "")).contains("_clear_errors_list"):
node.emit_signal("pressed")
return true
for child in node.get_children():
stack.push_back(child)
return false
return _surfaced_error_tracker.clear_debugger_error_trees()
func reload_plugin(_params: Dictionary) -> Dictionary:
@@ -5,6 +5,32 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles file read/write operations and reimport within the Godot project.
## Bounds for the deferred scan wait. `write_file`/`reimport` register single
## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem`
## is the heavier, explicit "rebuild the class registry" path agents call after
## adding `class_name` scripts headlessly (no window focus to trigger it).
## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we
## always send a real reply before a DEFERRED_TIMEOUT is synthesised.
const _SCAN_START_GRACE_MSEC := 750
const _SCAN_SETTLE_MAX_MSEC := 28000
## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't
## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()`
## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing
## in that window would observe `false` and stack another scan() — the exact
## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is
## set before the first scan() and cleared when its settle coroutine finishes;
## concurrent requests coalesce onto the running scan instead of starting one.
## `static` so it's shared across handler instances; it resets on plugin reload
## (script re-parse), which self-heals any latch orphaned by a mid-await teardown.
static var _scan_in_flight := false
var _connection: McpConnection
func _init(connection: McpConnection = null) -> void:
_connection = connection
func read_file(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
@@ -110,3 +136,108 @@ func reimport(params: Dictionary) -> Dictionary:
"reason": "Reimport is a file system operation",
}
}
## Force a full EditorFileSystem scan and wait for it to settle. This is the
## headless equivalent of the editor regaining window focus: `update_file()`
## (used by write_file/reimport/script_create) registers a single file with the
## resource pipeline but does NOT rebuild the global `class_name` table, so a
## freshly-created `class_name MyThing extends Resource` stays invisible to
## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents
## driving the editor without focus call this once after a batch of script
## creates to make new types instantiable/referenceable. See issue #83.
func scan_filesystem(params: Dictionary) -> Dictionary:
var efs := EditorInterface.get_resource_filesystem()
if efs == null:
return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "EditorFileSystem not available")
var request_id: String = params.get("_request_id", "")
# Async path: a scan can't be awaited on the calling frame without freezing
# the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and
# push the real reply from a static coroutine once the scan settles — by
# which point new class_names are registered.
if _connection != null and not request_id.is_empty():
_finish_scan_deferred(_connection, request_id, efs)
return McpDispatcher.DEFERRED_RESPONSE
# Synchronous fallback: batch_execute (no request_id) and unit-test contexts
# (no connection) can't await, so kick a single-flight scan and return
# immediately without the settle confirmation. Respect the latch so we don't
# stack onto a deferred scan; don't set it (there's no coroutine here to
# clear it — the brief is_scanning() window covers the rest).
var already := _scan_in_flight or efs.is_scanning()
if not already:
efs.scan()
return {
"data": {
"scan_completed": false,
"scan_settle": "not_waited",
"was_already_scanning": already,
"global_class_count": ProjectSettings.get_global_class_list().size(),
# Present in both paths for a consistent response shape; the sync
# path doesn't await, so it can't measure a delta.
"global_classes_registered_delta": 0,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
}
## `static` is load-bearing for the same reason as ScriptHandler's deferred
## finish: the coroutine must outlive the handler RefCounted, which can be freed
## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise
## everything; reference no instance state.
static func _finish_scan_deferred(
connection: McpConnection,
request_id: String,
efs: EditorFileSystem,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
var classes_before := ProjectSettings.get_global_class_list().size()
# Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(),
# which lags scan() by a frame or two — see the latch declaration). Only the
# request that sets the latch calls scan(); concurrent requests coalesce and
# just await the running scan. This is what actually prevents the stacked
# scan() SIGABRT (dsarno/godot#6), even within the start-grace window.
var was_already_scanning := _scan_in_flight or efs.is_scanning()
var we_started := not was_already_scanning
if we_started:
_scan_in_flight = true
efs.scan()
# Hand back a frame so _dispatch() registers this request as deferred before
# the coroutine can push a reply (mirrors _finish_create_script_deferred).
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC
var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC
var saw_scanning := efs.is_scanning()
while Time.get_ticks_msec() < deadline_ms:
if efs.is_scanning():
saw_scanning = true
elif saw_scanning or Time.get_ticks_msec() > start_grace_ms:
# Either the scan ran and finished, or it never flipped is_scanning()
# within the grace window (a no-op scan because nothing changed).
break
await tree.process_frame
# Clear the latch in all paths (no try/finally in GDScript): do it before the
# is_instance_valid early-return so a freed connection can't orphan it.
if we_started:
_scan_in_flight = false
if not is_instance_valid(connection):
return
var completed := not efs.is_scanning()
var classes_after := ProjectSettings.get_global_class_list().size()
connection.send_deferred_response(request_id, {
"data": {
"scan_completed": completed,
"scan_settle": "settled" if completed else "timeout",
"was_already_scanning": was_already_scanning,
"global_class_count": classes_after,
"global_classes_registered_delta": classes_after - classes_before,
"undoable": false,
"reason": "Filesystem scan is an editor operation",
}
})
+189 -7
View File
@@ -18,11 +18,13 @@ func list_actions(params: Dictionary) -> Dictionary:
## See #213.
var user_authored := _read_user_authored_actions()
var actions: Array[Dictionary] = []
var seen := {}
for action_name in InputMap.get_actions():
var name_str := str(action_name)
var is_user_action := user_authored.has(name_str)
if not include_builtin and not is_user_action:
continue
seen[name_str] = true
var events: Array[Dictionary] = []
for event in InputMap.action_get_events(action_name):
events.append(_serialize_event(event))
@@ -31,6 +33,25 @@ func list_actions(params: Dictionary) -> Dictionary:
"events": events,
"event_count": events.size(),
"is_builtin": not is_user_action,
"loaded_in_input_map": true,
})
for action_name in user_authored.keys():
var name_str := str(action_name)
if seen.has(name_str):
continue
var setting: Dictionary = user_authored.get(name_str, {})
var events: Array[Dictionary] = []
for event in setting.get("events", []):
if event is InputEvent:
events.append(_serialize_event(event))
else:
events.append({"type": type_string(typeof(event)), "string": str(event)})
actions.append({
"name": name_str,
"events": events,
"event_count": events.size(),
"is_builtin": false,
"loaded_in_input_map": false,
})
return {"data": {"actions": actions, "count": actions.size()}}
@@ -43,7 +64,8 @@ func _read_user_authored_actions() -> Dictionary:
return {}
var result: Dictionary = {}
for key in cfg.get_section_keys("input"):
result[key] = true
var value = cfg.get_value("input", key, {})
result[key] = value if value is Dictionary else {}
return result
@@ -54,9 +76,9 @@ func add_action(params: Dictionary) -> Dictionary:
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
if InputMap.has_action(action):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action)
@@ -85,22 +107,47 @@ func add_action(params: Dictionary) -> Dictionary:
}
func ensure_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var result := _ensure_action_state(action, deadzone)
if result.has("error"):
return result
return {"data": result}
func remove_action(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if not InputMap.has_action(action):
var key := "input/%s" % action
var was_loaded := InputMap.has_action(action)
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
## An action can live in the editor process's InputMap, in project.godot,
## or both. Actions persisted by a previous editor session exist only on
## disk (`loaded_in_input_map: false` in list_actions) — those must still
## be removable. #632
if not was_loaded and old_setting == null:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action)
var key := "input/%s" % action
var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null
if was_loaded:
InputMap.erase_action(action)
if old_setting != null:
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
if was_loaded:
var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5
InputMap.add_action(action, dz)
if old_setting is Dictionary:
@@ -115,6 +162,7 @@ func remove_action(params: Dictionary) -> Dictionary:
"data": {
"action": action,
"removed": true,
"was_loaded": was_loaded,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
@@ -157,6 +205,112 @@ func bind_event(params: Dictionary) -> Dictionary:
}
func ensure_binding(params: Dictionary) -> Dictionary:
var action: String = params.get("action", "")
var event_type: String = params.get("event_type", "")
var deadzone: float = params.get("deadzone", 0.5)
if action.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action")
if event_type.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type")
var deadzone_error := _validate_deadzone(deadzone)
if deadzone_error.has("error"):
return deadzone_error
var event_or_error = _create_event(event_type, params)
if event_or_error is Dictionary:
return event_or_error
var event: InputEvent = event_or_error
var ensured := _ensure_action_state(action, deadzone)
if ensured.has("error"):
return ensured
for existing in InputMap.action_get_events(action):
if _events_match(existing, event):
return {
"data": {
"action": action,
"event": _serialize_event(existing),
"already_bound": true,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input binding already exists",
}
}
InputMap.action_add_event(action, event)
var err := _save_action_events(action)
if err != OK:
InputMap.action_erase_event(action, event)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err])
return {
"data": {
"action": action,
"event": _serialize_event(event),
"already_bound": false,
"action_created": ensured.get("created", false),
"undoable": false,
"reason": "Input bindings are saved to project.godot",
}
}
func _validate_deadzone(deadzone: float) -> Dictionary:
if deadzone < 0.0 or deadzone > 1.0:
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE,
"deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone)
return {}
func _ensure_action_state(action: String, deadzone: float) -> Dictionary:
var key := "input/%s" % action
var user_authored := _read_user_authored_actions()
var existed_in_input_map := InputMap.has_action(action)
var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key)
var old_setting = user_authored.get(action, null) if user_authored.has(action) else null
if old_setting == null and ProjectSettings.has_setting(key):
old_setting = ProjectSettings.get_setting(key)
if not existed_in_input_map:
var dz := deadzone
if old_setting is Dictionary:
dz = float(old_setting.get("deadzone", deadzone))
InputMap.add_action(action, dz)
if old_setting is Dictionary:
for ev in old_setting.get("events", []):
if ev is InputEvent:
InputMap.action_add_event(action, ev)
if not existed_in_project:
var err := _save_action_events(action)
if err != OK:
if not existed_in_input_map:
InputMap.erase_action(action)
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
"Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err])
var stored_deadzone := deadzone
if ProjectSettings.has_setting(key):
var stored = ProjectSettings.get_setting(key)
if stored is Dictionary:
stored_deadzone = float(stored.get("deadzone", deadzone))
return {
"action": action,
"deadzone": stored_deadzone,
"created": not existed_in_input_map and not existed_in_project,
"already_exists": existed_in_input_map or existed_in_project,
"loaded_in_input_map": true,
"persisted": true,
"undoable": false,
"reason": "Input actions are saved to project.godot",
}
## Returns an InputEvent on success, or a Dictionary error on failure.
## Caller must check ``result is Dictionary`` before treating it as an event.
func _create_event(event_type: String, params: Dictionary):
@@ -257,6 +411,32 @@ func _serialize_event(event: InputEvent) -> Dictionary:
return {"type": event.get_class(), "string": str(event)}
func _events_match(a: InputEvent, b: InputEvent) -> bool:
if a is InputEventKey and b is InputEventKey:
return _key_events_match(a as InputEventKey, b as InputEventKey)
return _serialize_event(a) == _serialize_event(b)
func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool:
if a.ctrl_pressed != b.ctrl_pressed:
return false
if a.alt_pressed != b.alt_pressed:
return false
if a.shift_pressed != b.shift_pressed:
return false
if a.meta_pressed != b.meta_pressed:
return false
var a_codes := [a.keycode, a.physical_keycode]
var b_codes := [b.keycode, b.physical_keycode]
for a_code in a_codes:
if int(a_code) == KEY_NONE:
continue
for b_code in b_codes:
if int(b_code) != KEY_NONE and int(a_code) == int(b_code):
return true
return false
func _save_action_events(action: String) -> int:
var events: Array = []
for event in InputMap.action_get_events(action):
@@ -267,6 +447,8 @@ func _save_action_events(action: String) -> int:
var deadzone: float = 0.5
if old_setting is Dictionary:
deadzone = old_setting.get("deadzone", 0.5)
elif InputMap.has_action(action):
deadzone = InputMap.action_get_deadzone(action)
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": events,
+136 -11
View File
@@ -239,16 +239,10 @@ func set_property(params: Dictionary) -> Dictionary:
# properties. Mirrors resource_create's inline-assign path but
# avoids a separate tool call for the common case.
var type_str: String = value.get("__class__", "")
var class_err := ResourceHandler._validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null or not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource" % type_str
)
var res: Resource = instance
var made := ResourceHandler._instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
var remaining: Dictionary = (value as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -528,6 +522,7 @@ func _set_owner_recursive(node: Node, owner: Node) -> void:
## is optional — the coercer defaults it to 1.0 when absent.
const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
@@ -556,6 +551,8 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedVector2Array
TYPE_PACKED_VECTOR3_ARRAY:
ok = value is PackedVector3Array
TYPE_PACKED_VECTOR4_ARRAY:
ok = value is PackedVector4Array
TYPE_PACKED_COLOR_ARRAY:
ok = value is PackedColorArray
TYPE_PACKED_INT32_ARRAY:
@@ -568,8 +565,31 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedFloat64Array
TYPE_PACKED_STRING_ARRAY:
ok = value is PackedStringArray
TYPE_VECTOR2I: ok = value is Vector2i
TYPE_VECTOR3I: ok = value is Vector3i
TYPE_VECTOR4: ok = value is Vector4
TYPE_VECTOR4I: ok = value is Vector4i
TYPE_QUATERNION: ok = value is Quaternion
TYPE_RECT2: ok = value is Rect2
TYPE_RECT2I: ok = value is Rect2i
TYPE_AABB: ok = value is AABB
TYPE_PLANE: ok = value is Plane
TYPE_BASIS: ok = value is Basis
TYPE_TRANSFORM2D: ok = value is Transform2D
TYPE_TRANSFORM3D: ok = value is Transform3D
TYPE_PROJECTION: ok = value is Projection
_:
# null / untyped-TYPE_NIL / already-correct-type are handled by
# Godot's setter; anything else would silently no-op, so error.
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
return null
var unsupported := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
type_string(typeof(value)), type_string(target_type),
],
)
return ErrorCodes.prefix_message(unsupported, prefix)
if ok:
return null
var dict_err := _check_dict_coerce_failed(value, target_type)
@@ -597,6 +617,8 @@ static func _shape_hint(target_type: int) -> String:
return "[{\"x\":0,\"y\":0}, ...]"
TYPE_PACKED_VECTOR3_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
TYPE_PACKED_VECTOR4_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
TYPE_PACKED_COLOR_ARRAY:
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
@@ -605,6 +627,24 @@ static func _shape_hint(target_type: int) -> String:
return "[float, ...]"
TYPE_PACKED_STRING_ARRAY:
return "[\"...\", ...]"
TYPE_VECTOR2I:
return "{\"x\":0,\"y\":0}"
TYPE_VECTOR3I:
return "{\"x\":0,\"y\":0,\"z\":0}"
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
return "{\"position\":{...},\"size\":{...}}"
TYPE_PLANE:
return "{\"normal\":{...},\"d\":0}"
TYPE_BASIS:
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
TYPE_TRANSFORM2D:
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
TYPE_TRANSFORM3D:
return "{\"basis\":{...},\"origin\":{...}}"
TYPE_PROJECTION:
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
var keys: Array[String] = []
match target_type:
TYPE_VECTOR2: keys = VECTOR2_KEYS
@@ -666,7 +706,15 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
if value is Dictionary and value.has_all(COLOR_KEYS):
return Color(value["r"], value["g"], value["b"], value.get("a", 1.0))
if value is String:
return Color(value)
# Color(String) silently returns black for an unparseable string
# (e.g. "Color(1,1,1,1)" or a typo). Validate with the two-sentinel
# from_string idiom (see theme_handler/material_values); on failure
# fall through so the value stays a String and _check_coerced flags
# it as an error instead of writing black.
var col_a := Color.from_string(value, Color(0, 0, 0, 0))
var col_b := Color.from_string(value, Color(1, 1, 1, 1))
if col_a == col_b:
return col_a
TYPE_BOOL:
if value is float or value is int:
return bool(value)
@@ -717,6 +765,17 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_PACKED_VECTOR4_ARRAY:
if value is Array:
var out := PackedVector4Array()
for item in value:
if item is Vector4:
out.append(item)
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
else:
return value
return out
TYPE_PACKED_COLOR_ARRAY:
if value is Array:
var out := PackedColorArray()
@@ -757,6 +816,72 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_VECTOR2I:
if value is Dictionary and value.has_all(VECTOR2_KEYS):
return Vector2i(int(value["x"]), int(value["y"]))
TYPE_VECTOR3I:
if value is Dictionary and value.has_all(VECTOR3_KEYS):
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
TYPE_VECTOR4:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4(value["x"], value["y"], value["z"], value["w"])
TYPE_VECTOR4I:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
TYPE_QUATERNION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Quaternion(value["x"], value["y"], value["z"], value["w"])
TYPE_RECT2:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
if p is Vector2 and s is Vector2:
return Rect2(p, s)
TYPE_RECT2I:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
if p is Vector2i and s is Vector2i:
return Rect2i(p, s)
TYPE_AABB:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
if p is Vector3 and s is Vector3:
return AABB(p, s)
TYPE_PLANE:
if value is Dictionary and value.has("normal") and value.has("d"):
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
if n is Vector3:
return Plane(n, float(value["d"]))
TYPE_BASIS:
if value is Dictionary and value.has_all(["x", "y", "z"]):
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
if bx is Vector3 and by is Vector3 and bz is Vector3:
return Basis(bx, by, bz)
TYPE_TRANSFORM2D:
if value is Dictionary and value.has_all(["x", "y", "origin"]):
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
return Transform2D(tx, ty, to_)
TYPE_TRANSFORM3D:
if value is Dictionary and value.has("basis") and value.has("origin"):
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
if b is Basis and o is Vector3:
return Transform3D(b, o)
TYPE_PROJECTION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
return Projection(px, py, pz, pw)
# PackedByteArray intentionally unhandled — needs design decision
# (base64 string vs. raw int list); JSON has no native byte type.
return value
+225 -16
View File
@@ -6,14 +6,17 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles project settings and filesystem search commands.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const RUN_READY_WAIT_SEC := 3.0
var _connection: McpConnection
var _debugger_plugin
var _editor_log_buffer
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
_editor_log_buffer = editor_log_buffer
func get_project_setting(params: Dictionary) -> Dictionary:
@@ -81,16 +84,15 @@ func run_project(params: Dictionary) -> Dictionary:
# stop-not-running case in telemetry). Surface state via was_already_running
# so a caller wanting a *different* scene can detect and stop+restart.
if EditorInterface.is_playing_scene():
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": true,
"undoable": false,
"reason": "Project was already running; no action taken",
}
}
return _run_project_current_liveness_response(
_run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
true,
"Project was already running; no action taken"
)
)
var validation_error: Variant = null
if mode == "custom":
@@ -125,7 +127,7 @@ func run_project(params: Dictionary) -> Dictionary:
restore_setting = true
if _debugger_plugin != null:
_debugger_plugin.begin_game_run()
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
match mode:
"main":
@@ -142,16 +144,223 @@ func run_project(params: Dictionary) -> Dictionary:
if _connection:
_connection.pause_processing = false
var base_data := _run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
false,
"Play/stop is a runtime action"
)
var request_id: String = params.get("_request_id", "")
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
_finish_run_project_deferred(request_id, base_data)
return McpDispatcher.DEFERRED_RESPONSE
return _run_project_current_liveness_response(base_data)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func _game_helper_autoload_expected() -> bool:
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
func _run_project_base_data(
mode: String,
scene: String,
autosave: bool,
was_already_running: bool,
reason: String
) -> Dictionary:
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"scene": scene,
"autosave": autosave,
"was_already_running": false,
"was_already_running": was_already_running,
"undoable": false,
"reason": "Play/stop is a runtime action",
"reason": reason,
}
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
if _debugger_plugin == null:
return {"data": base_data}
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
## One-shot read — force a Debugger-tab scan so boot errors that landed
## after the last gated scan are in this response (#641).
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
var tree := _connection.get_tree()
while true:
await tree.process_frame
if not is_instance_valid(_connection):
return
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
if (
not EditorInterface.is_playing_scene()
and int(pre_status.get("elapsed_msec", 0)) > 100
and str(pre_status.get("status", "stopped")) == "launching"
):
_debugger_plugin.end_game_run()
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var decision := _run_project_liveness_decision(status, errors_info)
if not bool(decision.get("resolve", false)):
continue
## #641: the loop above polls with gated (cheap) scans; boot parse
## errors can land in the Errors tab in the same frames the run goes
## live. Re-gather once with a forced scan before replying so the
## response reports them instead of leaving them to a later
## logs_read. Rebuilding the decision with strictly-more errors can
## only keep it resolved (errors never un-resolve a decision).
errors_info = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true)
decision = _run_project_liveness_decision(status, errors_info)
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
return
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
var data := base_data.duplicate(true)
var game_status: Dictionary = decision.get("game_status", {})
data["game_status"] = game_status
data["helper_live"] = bool(game_status.get("helper_live", false))
data["session_active"] = bool(game_status.get("session_active", false))
if bool(data.get("was_already_running", false)):
data["reason"] = _run_project_already_running_message(decision)
else:
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
data["recent_errors"] = decision.get("recent_errors", [])
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true)
return {"data": data}
func _run_project_already_running_message(decision: Dictionary) -> String:
var state := str(decision.get("liveness_status", "unknown"))
match state:
"live":
var live_errors: Array = decision.get("recent_errors", [])
if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return (
"Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)."
% [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])]
)
return "Project was already running; the Godot AI game helper is live."
"not_live":
var errors: Array = decision.get("recent_errors", [])
var scope := str(decision.get("recent_errors_scope", "none"))
if not errors.is_empty() and scope == "run":
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
if not errors.is_empty():
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
"break":
var break_errors: Array = decision.get("recent_errors", [])
if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run":
return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0])
if not break_errors.is_empty():
return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0])
return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel."
"no_helper":
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
"launching":
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
"stopped":
return "Project was already marked playing by the editor, but no active game liveness run exists."
_:
return "Project was already running; current liveness status is %s." % state
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
var state := str(status.get("status", "stopped"))
var recent_errors: Array = errors_info.get("errors", [])
var errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
var elapsed_msec := int(status.get("elapsed_msec", 0))
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
var decision := {
"resolve": false,
"game_status": enriched_status,
"liveness_status": state,
"recent_errors": recent_errors,
"recent_errors_scope": errors_scope,
"recent_errors_may_predate_run": errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
"message": "",
}
if state == "live":
decision["resolve"] = true
if correlated_error:
## #641: "live" only means the helper autoload registered — scripts
## can still have failed to parse or load during boot (a broken
## node script does not stop the game from running). Surface those
## errors in the success message so agents don't read a clean
## launch into a run that silently lost scripts.
decision["message"] = (
"Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)."
% [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])]
)
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
else:
decision["message"] = "Game launched and the Godot AI game helper is live."
elif state == "break":
## #645: the game process is parked in a remote-debugger break. A
## boot-time parse error (GDScriptLanguage::debug_break_parse) produces
## no Errors-tab row, no Logger entry, and no game-log line — the
## synthesized break record is the only evidence, and it lands a
## moment after the break signal (stack frames arrive async). Wait for
## it (correlated_error) before resolving; the ready window is the
## fallback if synthesis never lands.
var break_info: Dictionary = status.get("break", {})
var break_reason := str(break_info.get("reason", ""))
if bool(break_info.get("pre_live", true)):
decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec
var summary := break_reason
if correlated_error:
summary = _format_editor_error_summary(recent_errors[0])
if summary.is_empty():
summary = "script parse/load error (reason not captured)"
decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary
else:
var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else ""
decision["resolve"] = true
decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix
elif correlated_error:
decision["resolve"] = true
decision["liveness_status"] = "not_live"
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
elif state == "not_live":
decision["resolve"] = true
if not recent_errors.is_empty():
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
elif state == "no_helper":
decision["resolve"] = true
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
elif state == "stopped":
decision["resolve"] = true
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
elif state == "launching" and elapsed_msec >= ready_wait_msec:
decision["resolve"] = true
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
return decision
func _format_editor_error_summary(entry: Dictionary) -> String:
return McpSurfacedErrorTracker.format_editor_error_summary(entry)
func stop_project(params: Dictionary) -> Dictionary:
+177 -25
View File
@@ -178,19 +178,10 @@ func create_resource(params: Dictionary) -> Dictionary:
return home_err
var has_file_target := not resource_path.is_empty()
var class_err := _validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
if not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
)
var res: Resource = instance
var made := _instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
if not properties.is_empty():
var apply_err := _apply_resource_properties(res, properties)
@@ -226,6 +217,75 @@ static func _validate_resource_class(type_str: String) -> Variant:
return null
## Build the "Unknown resource type" error with a steer toward op="scan". A type
## that reaches here is neither an engine built-in (ClassDB) nor a registered
## project class (the global script-class registry). In an agent-driven workflow
## the most common cause is a `class_name` script just made via script_create
## that isn't registered yet — the global class table only rebuilds on a
## filesystem scan (normally an editor-focus event). Point the caller at the one
## cheap call that fixes that, so it doesn't fall back to a full plugin reload.
## See #614 for the headless scan op.
static func _unknown_resource_type_error(type_str: String) -> Dictionary:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
(
"Unknown resource type: %s — not an engine built-in or a registered project class. "
+ "If you just created it with script_create, the global class table is stale until a "
+ "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling."
) % type_str
)
## Resolve a resource type name to a fresh instance. Handles engine built-ins
## (ClassDB) and project `class_name` Resources (the global script-class
## registry). Returns a Resource on success, or an error dict on failure.
static func _instantiate_resource(type_str: String) -> Variant:
if ClassDB.class_exists(type_str):
var class_err: Variant = _validate_resource_class(type_str)
if class_err != null:
return class_err
var built_in := ClassDB.instantiate(type_str)
if built_in == null or not (built_in is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return built_in
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == type_str:
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
# Reject non-Resource script classes BEFORE constructing them:
# scr.new() runs _init(), and an @tool class_name extending a
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
# an orphan instance this path never frees. get_instance_base_type()
# resolves to the native base, so multi-level custom Resource
# hierarchies (B extends A extends Resource) still pass.
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
if not scr.can_instantiate():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
# Reject scripts whose _init() requires arguments BEFORE scr.new():
# scr.new() passes no args, so a required-arg _init raises and aborts
# this handler mid-call, null-cascading into a generic "malformed
# result" error instead of a clean rejection. get_script_method_list()
# reports the effective (incl. inherited) _init; required args =
# args - default_args. Statically detectable only — a _init that runs
# but throws still falls through to scr.new() and the dispatcher catch.
for method in scr.get_script_method_list():
if method.get("name", "") == "_init":
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
if required_args > 0:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
break
var made: Variant = scr.new()
if made == null or not (made is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return made
return _unknown_resource_type_error(type_str)
## Apply a dict of property values to a freshly-instantiated Resource,
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
## Returns null on success or an error dict on failure.
@@ -240,9 +300,17 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
valid.append(prop.name)
valid.sort()
# Name the script's class_name (e.g. MyTestResource) rather than the
# native base (Resource) so the hint names the type the agent created,
# and point at the real MCP verb — resource_manage(op="get_info") now
# answers for project class_name Resources too.
var type_label := res.get_class()
var res_script: Variant = res.get_script()
if res_script is Script and not String(res_script.get_global_name()).is_empty():
type_label = String(res_script.get_global_name())
var err := ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
)
err["error"]["data"] = {"valid_properties": valid}
return err
@@ -270,16 +338,15 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
# resource_create/environment_create callers can populate
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
var sub_type: String = v.get("__class__", "")
var class_err := _validate_resource_class(sub_type)
if class_err != null:
return class_err
var sub_instance := ClassDB.instantiate(sub_type)
if sub_instance == null or not (sub_instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
)
var sub_res: Resource = sub_instance
# Resolve via the shared helper so the nested shortcut accepts both
# engine built-ins (ClassDB) and project `class_name` Resources,
# exactly like the top-level resource_create path.
var sub_made := _instantiate_resource(sub_type)
if sub_made is Dictionary:
# Preserve the property-slot context the inline path used to add.
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
return sub_made
var sub_res: Resource = sub_made
var remaining: Dictionary = (v as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -361,7 +428,13 @@ func get_resource_info(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
if not ClassDB.class_exists(type_str):
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
# Project class_name Resources aren't in ClassDB; resolve them through the
# global script-class registry so get_info answers for the same custom
# types resource_create can make. Read-only — never instantiates.
var custom_info: Variant = _custom_resource_info(type_str)
if custom_info != null:
return custom_info
return _unknown_resource_type_error(type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
@@ -396,3 +469,82 @@ func get_resource_info(params: Dictionary) -> Dictionary:
data["concrete_subclasses"] = class_info.concrete_inheritors
return {"data": data}
## Resolve a loaded global-class script to its native base type, or an error if
## the script failed to load (not a Script) or to compile (empty base type).
## Shared by the create and get_info custom-Resource paths so both report a
## compile failure rather than a misleading "is not a Resource type (extends )".
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
if not (scr is Script):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
var base_type: StringName = scr.get_instance_base_type()
if String(base_type).is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
return base_type
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
## dict, an error dict (for a class_name whose native base is not a Resource), or
## null if `type_str` is not a registered global class. Read-only: resolves
## properties from the script + its native base WITHOUT instantiating (no _init()).
static func _custom_resource_info(type_str: String) -> Variant:
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") != type_str:
continue
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
var can_instantiate: bool = scr.can_instantiate()
# Inherited (native) properties come from the engine base via ClassDB...
var class_info := ClassIntrospection.build(String(base_type), {
"sections": ["properties"],
"include_inherited": true,
"limit": 0,
})
var props: Array = []
for native_prop in class_info.properties:
props.append(native_prop)
# ...and the script's own (and inherited script) exported properties come
# from the Script itself, so we never construct the resource. A real
# default isn't available without instantiating, so script props carry an
# explicit null — keeping one uniform key set across the array (native
# props carry their real default).
for raw_prop in scr.get_script_property_list():
var prop: Dictionary = raw_prop
var usage := int(prop.get("usage", 0))
if not (usage & PROPERTY_USAGE_EDITOR):
continue
props.append({
"name": str(prop.get("name", "")),
"type": type_string(int(prop.get("type", TYPE_NIL))),
"class_name": str(prop.get("class_name", "")),
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
"hint_string": str(prop.get("hint_string", "")),
"usage": usage,
"default": null,
})
props.sort_custom(func(a, b): return a.name < b.name)
# parent_class is the immediate script parent when there is one (so a
# multi-level chain B -> A -> Resource reports A), else the native base.
var parent_name := String(base_type)
var base_script: Variant = scr.get_base_script()
if base_script is Script and not String(base_script.get_global_name()).is_empty():
parent_name = String(base_script.get_global_name())
return {"data": {
"type": type_str,
"parent_class": parent_name,
"can_instantiate": can_instantiate,
# is_abstract reflects real abstractness (the @abstract annotation),
# NOT editor-instantiability — a non-@tool concrete Resource has
# can_instantiate()==false in-editor but is not abstract.
"is_abstract": scr.is_abstract(),
"properties": props,
"property_count": props.size(),
}}
return null
+88 -5
View File
@@ -143,9 +143,17 @@ func create_scene(params: Dictionary) -> Dictionary:
}
## How long open_scene waits for the editor to actually switch to the
## requested scene before replying switched=false. Tab switches normally land
## within a few frames; keep this under the dispatcher's 4500 ms deferred
## default so the coroutine always answers before DEFERRED_TIMEOUT fires.
const _OPEN_SETTLE_MAX_MSEC := 3000
## Open an existing scene by file path.
func open_scene(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var force_reload: bool = params.get("force_reload", false)
if path.is_empty():
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path")
@@ -156,15 +164,90 @@ func open_scene(params: Dictionary) -> Dictionary:
if not ResourceLoader.exists(path):
return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path)
EditorInterface.open_scene_from_path(path)
return {
"data": {
var scene_root := EditorInterface.get_edited_scene_root()
var current_path := scene_root.scene_file_path if scene_root else ""
## Instance id of the root at call time. A completed open OR reload always
## replaces the edited-scene root with a NEW instance, so this is the
## reliable completion signal — unlike scene_file_path, which is unchanged
## across a force_reload of the already-open scene (#633 review).
var prev_root_id := scene_root.get_instance_id() if scene_root else 0
var payload := {
"path": path,
"force_reload": force_reload,
"reloaded_from_disk": false,
"previous_scene_path": current_path,
"undoable": false,
"reason": "Scene navigation cannot be undone via editor undo",
}
}
if current_path == path and not force_reload:
## Already the edited scene — nothing switches, reply immediately.
payload["switched"] = true
payload["settle"] = "already_current"
return {"data": payload}
if force_reload and current_path == path:
EditorInterface.reload_scene_from_path(path)
payload["reloaded_from_disk"] = true
else:
EditorInterface.open_scene_from_path(path)
## The tab switch completes asynchronously; replying now lets an immediate
## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued
## right after open_scene saved the old scene). Defer the reply until the
## edited scene actually is `path` AND its root is a fresh instance, so
## success means "the editor is now editing the (re)loaded scene".
var request_id: String = params.get("_request_id", "")
if _connection != null and not request_id.is_empty():
_finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload)
return McpDispatcher.DEFERRED_RESPONSE
## Synchronous fallback (batch_execute and unit-test contexts can't await):
## preserve the old reply-immediately behavior, flagged as not waited on.
payload["switched"] = false
payload["settle"] = "not_waited"
return {"data": payload}
## `static` is load-bearing (same reason as FilesystemHandler's deferred scan
## finish): the coroutine must outlive this RefCounted handler, which can be
## freed mid-await by an editor_reload_plugin. Parameterise everything;
## reference no instance state.
static func _finish_open_scene_deferred(
connection: McpConnection,
request_id: String,
path: String,
prev_root_id: int,
payload: Dictionary,
) -> void:
if not is_instance_valid(connection):
return
var tree := connection.get_tree()
if tree == null:
return
# Hand back a frame so _dispatch() registers this request as deferred
# before the coroutine can push a reply.
await tree.process_frame
var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC
while Time.get_ticks_msec() < deadline_ms:
var root := EditorInterface.get_edited_scene_root()
# Require BOTH the target path AND a fresh root instance: a
# force_reload keeps scene_file_path == path across the reload, so the
# instance swap is what proves the (re)load actually completed rather
# than the coroutine settling on the stale pre-reload root.
if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id:
if not is_instance_valid(connection):
return
payload["switched"] = true
payload["settle"] = "settled"
connection.send_deferred_response(request_id, {"data": payload})
return
await tree.process_frame
if not is_instance_valid(connection):
return
payload["switched"] = false
payload["settle"] = "timeout"
connection.send_deferred_response(request_id, {"data": payload})
## Save the currently edited scene.
+74 -10
View File
@@ -3,7 +3,7 @@ extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd")
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd")
## Handles script creation, reading, attaching, detaching, and symbol inspection.
@@ -63,6 +63,33 @@ func create_script(params: Dictionary) -> Dictionary:
}
_attach_gdscript_diagnostics(data, path, content)
# A freshly-declared `class_name` is NOT in the global class table until a
# filesystem scan runs — update_file() below registers the file with the
# resource pipeline but not the class registry (see the scan() comment).
# Surface that precisely (only when the class isn't already registered) so a
# headless caller knows to follow up with filesystem_manage(op="scan")
# instead of hitting a confusing "Unknown type" / "Unknown resource type" on
# the very next call. We don't scan here — a scan() per create is the exact
# SIGABRT race documented below; the explicit op is single-flight.
# Skip the hint when the script failed to parse: a scan won't register a
# class from a broken script, so pointing at op="scan" would steer the caller
# away from the real fix (the parse error already attached above).
var declared_class := _extract_class_name(content)
if (
not declared_class.is_empty()
and not _script_has_error_diagnostics(data)
and not _class_name_registered(declared_class)
):
data["class_name"] = declared_class
data["class_registration"] = "scan_required"
data["class_registration_hint"] = (
"New class_name '%s' isn't in the global class table yet. " % declared_class
+ "Call filesystem_manage(op=\"scan\") if it won't resolve on the next "
+ "call (e.g. resource_manage op=\"create\", or used as a type in another "
+ "script). The editor also registers it on its next filesystem scan or "
+ "when its window regains focus."
)
# Register just this file with the editor instead of a full recursive
# scan(). A scan() per write stacks `update_scripts_classes` /
# `update_script_paths_documentation` WorkerThreadPool tasks under concurrent
@@ -142,6 +169,48 @@ static func _finish_create_script_deferred(
connection.send_deferred_response(request_id, {"data": payload})
## Extract the `class_name` a script declares, or "" if none. A cheap line scan
## (no full parse) for create_script's "scan_required" hint. Stops at the first
## space/tab or comma so all three valid forms yield just the name:
## `class_name Foo`, `class_name Foo extends Bar`, and the icon form
## `class_name Foo, "res://icon.svg"`.
static func _extract_class_name(content: String) -> String:
for raw_line in content.split("\n"):
var line := raw_line.strip_edges()
if line.begins_with("class_name "):
var rest := line.substr(11).strip_edges()
var cut := rest.length()
for i in rest.length():
var ch := rest[i]
if ch == " " or ch == "\t" or ch == ",":
cut = i
break
return rest.substr(0, cut)
return ""
## True if create_script's diagnostics captured a parse error for this script.
## Used to suppress the "scan_required" hint when the class can't register
## anyway — see create_script.
static func _script_has_error_diagnostics(data: Dictionary) -> bool:
for diag in data.get("diagnostics", []):
if diag is Dictionary and diag.get("level", "") == "error":
return true
return false
## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an
## already-registered project global class. A brand-new class_name returns false
## until a filesystem scan registers it.
static func _class_name_registered(cn: String) -> bool:
if ClassDB.class_exists(cn):
return true
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == cn:
return true
return false
func read_script(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
@@ -204,21 +273,16 @@ static func _validate_gdscript_source(content: String) -> Dictionary:
}
static func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger") and OS.has_method("remove_logger")):
return _empty_diagnostics_capture()
var logger_script := LoggerLoader.build(LoggerLoader.VALIDATION_LOGGER_PATH)
if logger_script == null:
return _empty_diagnostics_capture()
func _capture_gdscript_load_diagnostics(path: String) -> Dictionary:
var buffer := McpEditorLogBuffer.new()
var logger = logger_script.new(buffer)
var logger := ValidationLogger.new(buffer)
var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary:
OS.call("add_logger", logger)
OS.add_logger(logger)
# ResourceLoader.load() reports parse failure instead of throwing, and
# a failed GDScript parse does not execute user code; remove immediately
# after the synchronous load to keep the private capture window tiny.
ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
OS.call("remove_logger", logger)
OS.remove_logger(logger)
return {}
)
return capture
+12 -2
View File
@@ -152,7 +152,7 @@ func connect_signal(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
_undo_redo.add_do_method(source, "connect", signal_name, callable)
_undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
_undo_redo.commit_action()
@@ -174,9 +174,19 @@ func disconnect_signal(params: Dictionary) -> Dictionary:
if not source.is_connected(signal_name, callable):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
# Capture the connection's current flags so undo restores it exactly as it
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
# silently promote a runtime-only connection into one that serializes on the
# next save. (The connection still exists at this point — checked above.)
var reconnect_flags := 0
for conn in source.get_signal_connection_list(signal_name):
if conn.get("callable", Callable()) == callable:
reconnect_flags = int(conn.get("flags", 0))
break
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
_undo_redo.commit_action()
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
+28 -1
View File
@@ -30,7 +30,11 @@ func run_tests(params: Dictionary) -> Dictionary:
discovery.errors.size(),
", ".join(discovery.errors),
]
return {"data": {"error": msg, "total": 0, "load_errors": discovery.errors}}
var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors}
## Keep the edited_scene annotation on the no-suites error payload too,
## so the response contract is consistent across every return path.
_annotate_edited_scene(no_suites)
return {"data": no_suites}
var ctx := {
"undo_redo": _undo_redo,
@@ -40,9 +44,32 @@ func run_tests(params: Dictionary) -> Dictionary:
var results := _runner.run_suites(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter)
if not discovery.errors.is_empty():
results["load_errors"] = discovery.errors
_annotate_edited_scene(results)
return {"data": results}
## Many suites assume the project's main scene is the edited scene (they read
## /Main/... nodes directly). Running with another scene open produces a flood
## of phantom failures that look like real regressions. Surface the edited
## scene and a warning when it differs from run/main_scene so the failures are
## attributable at a glance instead of costing a debugging round (#635).
func _annotate_edited_scene(results: Dictionary) -> void:
var scene_root := EditorInterface.get_edited_scene_root()
var edited := scene_root.scene_file_path if scene_root else ""
results["edited_scene"] = edited
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
if main_scene.is_empty() or edited == main_scene:
return
if int(results.get("failed", 0)) <= 0:
return
results["scene_warning"] = (
"Edited scene is '%s' but the project main scene is '%s'. Many suites "
% [edited if not edited.is_empty() else "<none>", main_scene]
+ "assume the main scene is open and will report phantom failures "
+ "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene
)
func get_test_results(params: Dictionary) -> Dictionary:
var verbose: bool = params.get("verbose", false)
return {"data": _runner.get_results(verbose)}
+163
View File
@@ -0,0 +1,163 @@
@tool
extends RefCounted
## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells
## directly in the editor scene with full undo/redo support.
##
## All ops target TileMapLayer nodes in the currently edited scene by
## scene-relative path (e.g. "/LavaLake20x20/Ground").
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
const MAX_RECT_FILL_CELLS := 4096
var _undo_redo: EditorUndoRedoManager
func _init(undo_redo: EditorUndoRedoManager) -> void:
_undo_redo = undo_redo
## Set a single tile cell.
## params: {path, source_id, atlas_col, atlas_row, map_x, map_y}
## Returns: {map_x, map_y, source_id, atlas_col, atlas_row}
func set_cell(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0))
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var prev := _capture_cell_state(node, pos)
_undo_redo.create_action("MCP: TileMap set_cell")
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev)
_undo_redo.commit_action()
return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src,
"atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}}
## Fill a rectangular region with one tile type in a single undo action.
## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h}
## Returns: {cells_filled, rect: {x, y, w, h}}
func set_cells_rect(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var src := int(params.get("source_id", 0))
var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0))
var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0))
var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1))
if rw <= 0 or rh <= 0:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh]
)
var cell_count := rw * rh
if cell_count > MAX_RECT_FILL_CELLS:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS]
)
var cells: Array[Vector2i] = []
var snapshot: Array[Dictionary] = []
for x in range(rx, rx + rw):
for y in range(ry, ry + rh):
var pos := Vector2i(x, y)
cells.append(pos)
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
_undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh])
for pos in cells:
_undo_redo.add_do_method(node, "set_cell", pos, src, atlas)
_undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cells_filled": cells.size(),
"rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}}
## Remove all tiles from a TileMapLayer.
## params: {path}
## Returns: {cleared: true}
func clear_layer(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var snapshot := _capture_used_cells_snapshot(node)
_undo_redo.create_action("MCP: TileMap clear")
_undo_redo.add_do_method(node, "clear")
_undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot)
_undo_redo.commit_action()
return {"data": {"cleared": true, "undoable": true}}
## Return all used cell coordinates.
## params: {path}
## Returns: {cells: [{x, y}, ...], count: int}
func get_used_cells(params: Dictionary) -> Dictionary:
var layer := _resolve_layer(params)
if layer.has("error"): return layer
var node: TileMapLayer = layer.node
var cells := node.get_used_cells()
var result: Array = []
for c in cells:
result.append({"x": c.x, "y": c.y})
return {"data": {"cells": result, "count": result.size()}}
## Resolve a TileMapLayer node from params["path"] in the currently edited
## scene. Returns {"node": TileMapLayer} on success, or an error dict.
func _resolve_layer(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var scene_file: String = params.get("scene_file", "")
var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file)
if resolved.has("error"):
return resolved
var node: Node = resolved.node
if not node is TileMapLayer:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE,
"Node is not a TileMapLayer: %s" % path)
return {"node": node}
func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary:
var source_id := node.get_cell_source_id(pos)
if source_id == -1:
return {"has_tile": false}
var atlas: Vector2i = node.get_cell_atlas_coords(pos)
var alternative := node.get_cell_alternative_tile(pos)
return {
"has_tile": true,
"source_id": source_id,
"atlas_col": atlas.x,
"atlas_row": atlas.y,
"alternative": alternative,
}
func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]:
var snapshot: Array[Dictionary] = []
for pos in node.get_used_cells():
snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)})
return snapshot
func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
node.clear()
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void:
for entry in snapshot:
_restore_cell_state(node, entry.pos, entry.state)
func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void:
if not state.get("has_tile", false):
node.erase_cell(pos)
return
node.set_cell(
pos,
int(state.get("source_id", -1)),
Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))),
int(state.get("alternative", 0))
)
@@ -0,0 +1 @@
uid://cm8s7a0ey2q6k
+174
View File
@@ -0,0 +1,174 @@
@tool
extends RefCounted
## TileSet management — atlas inspection helpers.
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
func _init() -> void:
pass
## Query all occupied atlas tile positions for a single source.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
##
## Returns:
## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}}
## on success (including empty sources, where tiles=[] and count=0)
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies any resource.
func get_atlas_tiles(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var src: TileSetAtlasSource = resolved.src
var tiles: Array = []
for i in range(src.get_tiles_count()):
var v: Vector2i = src.get_tile_id(i)
tiles.append({"col": v.x, "row": v.y})
return {"data": {"tiles": tiles, "count": tiles.size()}}
## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG.
##
## params:
## tileset_path — res:// path to the TileSet resource (required, non-empty)
## source_id — raw TileSet source id (required)
## max_size — optional int; if > 0, the image is scaled so its longest
## edge is at most max_size pixels (default 0 = full res)
##
## Returns:
## {"data": {"image_base64": String, "width": int, "height": int,
## "original_width": int, "original_height": int, "format": "png"}}
## on success
## ErrorCodes.make(code, message) on any validation or load failure
##
## Error codes:
## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent
## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false
## WRONG_TYPE — loaded resource is not a TileSet, or source is
## not a TileSetAtlasSource, or texture is null
## VALUE_OUT_OF_RANGE — source_id not present in TileSet
##
## This method is read-only: it never calls ResourceSaver or modifies anything.
func get_atlas_image(params: Dictionary) -> Dictionary:
var resolved := _resolve_atlas_source(params)
if resolved.has("error"):
return resolved
var source_id: int = resolved.source_id
var src: TileSetAtlasSource = resolved.src
var tex: Texture2D = src.texture
if tex == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d has no texture assigned" % source_id
)
var img: Image = tex.get_image()
if img == null:
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Could not retrieve image data from texture of source %d" % source_id
)
if img.is_compressed():
var decompress_err := img.decompress()
if decompress_err != OK:
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)]
)
var original_width: int = img.get_width()
var original_height: int = img.get_height()
var max_size: int = params.get("max_size", 0)
if max_size > 0:
var longest_edge: int = max(original_width, original_height)
if longest_edge > max_size:
var scale: float = float(max_size) / float(longest_edge)
var new_w: int = max(1, int(original_width * scale))
var new_h: int = max(1, int(original_height * scale))
img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
var png_bytes: PackedByteArray = img.save_png_to_buffer()
if png_bytes.is_empty():
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"PNG encoding produced empty output for source %d" % source_id
)
var b64: String = Marshalls.raw_to_base64(png_bytes)
return {
"data": {
"image_base64": b64,
"width": img.get_width(),
"height": img.get_height(),
"original_width": original_width,
"original_height": original_height,
"format": "png",
}
}
func _resolve_atlas_source(params: Dictionary) -> Dictionary:
var tileset_path: String = params.get("tileset_path", "")
if tileset_path.is_empty():
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'tileset_path' parameter is required and must not be empty"
)
if not params.has("source_id"):
return ErrorCodes.make(
ErrorCodes.MISSING_REQUIRED_PARAM,
"'source_id' parameter is required"
)
if not ResourceLoader.exists(tileset_path):
return ErrorCodes.make(
ErrorCodes.RESOURCE_NOT_FOUND,
"TileSet resource not found: %s" % tileset_path
)
var ts = load(tileset_path)
if not ts is TileSet:
var loaded_type := "null" if ts == null else ts.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type]
)
var source_id: int = int(params.get("source_id", -999))
if source_id < 0 or not ts.has_source(source_id):
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
"source_id %d does not exist in TileSet" % source_id
)
var src = ts.get_source(source_id)
if not src is TileSetAtlasSource:
var source_type: String = "null" if src == null else src.get_class()
return ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type]
)
return {
"source_id": source_id,
"src": src,
}
@@ -0,0 +1 @@
uid://de3v4m1pnk7tr
+300 -53
View File
@@ -40,8 +40,17 @@ const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.g
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
const DEV_MODE_SETTING := "godot_ai/dev_mode"
## "Change the port + reconfigure your clients" guide. Surfaced from the crash
## panel when a foreign process holds the HTTP port — the one piece of recovery
## (per-client config rewrite) that doesn't fit in the inline crash body.
## Resolved against the installed plugin version at click time (see
## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
## not tip-of-main, which may have drifted from that build's UI.
const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000
const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
@@ -60,15 +69,16 @@ var _status_icon: ColorRect
var _status_label: Label
var _client_grid: VBoxContainer
var _client_configure_all_btn: Button
var _client_empty_cta_btn: Button
var _clients_summary_label: Label
var _clients_window: Window
var _dev_mode_toggle: CheckButton
var _install_label: Label
# Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients
# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients
# that cap total tool count (Antigravity: 100). Pending set is mutated by
# checkbox clicks; saved set reflects what the spawned server actually
# sees. `Apply & Restart Server` writes pending → setting and triggers a
# sees. `Apply and Restart Server` writes pending → setting and triggers a
# plugin reload so the new server comes up with the trimmed list.
var _tools_pending_excluded: PackedStringArray = PackedStringArray()
var _tools_saved_excluded: PackedStringArray = PackedStringArray()
@@ -147,23 +157,24 @@ static var _orphaned_client_status_refresh_threads: Array[Thread] = []
## Per-row worker state for Configure / Remove. Issue #239: shelling out
## to a hung CLI on main hangs the editor. We dispatch each click to its
## own thread (one slot per client) and apply the result via call_deferred
## once the subprocess returns or the wall-clock budget in McpCliExec
## kicks in. The buttons stay disabled while the slot is busy so the user
## can't queue a re-click on the same row.
## own thread (one slot per client), then `_process` reaps completed workers
## and applies returned payloads on main. The buttons stay disabled while
## the slot is busy so the user can't queue a re-click on the same row.
##
## Per-client (not single-slot) so Configure-all can fan out — the
## workers are independent, only the row UI is shared, and McpCliExec
## bounds the wall-clock for each.
##
## No orphan-thread list (unlike the refresh worker): action threads
## never get abandoned mid-flight. McpCliExec's wall-clock budget caps
## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager`
## install-time drain blocks briefly and finishes — there's no path that
## "gives up" on an action thread the way `_abandon_client_status_refresh_thread`
## does for the refresh worker.
## A watchdog can abandon a slot when a worker fails to report completion.
## The thread object is retained in `_orphaned_client_action_threads` until
## it finishes so GDScript does not destroy a live Thread object.
var _client_action_threads: Dictionary = {}
var _client_action_generations: Dictionary = {}
var _client_action_started_msec: Dictionary = {}
var _client_action_names: Dictionary = {}
## Timed-out Configure/Remove workers are abandoned but retained here until
## they finish, so GDScript does not destroy a live Thread object.
static var _orphaned_client_action_threads: Array[Thread] = []
# Dev-mode only
var _dev_section: VBoxContainer
@@ -193,6 +204,11 @@ var _crash_panel: VBoxContainer
var _crash_output: RichTextLabel
var _crash_restart_btn: Button
var _crash_reload_btn: Button
## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
## case (no `can_recover_incompatible` proof). The inline body names a free
## port; this button carries the per-client reconfigure steps that don't fit
## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
var _crash_docs_btn: Button
## Port-picker escape hatch — visible inside the crash panel when the root
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
## the EditorSetting and reloads the plugin in response to the panel's
@@ -238,10 +254,14 @@ func _ready() -> void:
func _process(_delta: float) -> void:
_prune_orphaned_client_status_refresh_threads()
_prune_orphaned_client_action_threads()
_poll_completed_client_status_refresh_thread()
_poll_completed_client_action_threads()
_check_client_status_refresh_timeout()
_check_client_action_timeouts()
if _connection == null:
return
_prune_orphaned_client_status_refresh_threads()
_check_client_status_refresh_timeout()
_retry_deferred_client_status_refresh()
_update_status()
if _log_viewer != null and _log_viewer.visible:
@@ -275,6 +295,8 @@ func _exit_tree() -> void:
## drains directly because it has additional state-machine work
## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit.
func prepare_for_self_update_drain() -> void:
_poll_completed_client_status_refresh_thread()
_poll_completed_client_action_threads()
_drain_client_status_refresh_workers()
_drain_client_action_workers()
@@ -309,13 +331,13 @@ func _drain_client_action_workers() -> void:
## plugin disable / install-update path reloads our script class, so any
## live Thread must finish before its slot is GC'd or we hit
## `~Thread … destroyed without its completion having been realized` →
## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the
## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV.
## VM corruption. Normal UI recovery is handled by the per-row watchdog;
## teardown still blocks because GDScript's Thread API has no kill/timeout
## primitive and destroying a live Thread corrupts the VM.
##
## Generation-bumped per-row so any pending `call_deferred(
## "_apply_client_action_result")` from a worker that finished after we
## started draining detects the generation mismatch and short-circuits
## without touching freed UI state.
## Generation-bumped per-row so any result from a worker that finished
## after we started draining detects the generation mismatch and
## short-circuits without touching freed UI state.
##
## After draining, restore the row UI for any in-flight rows: bare
## `_client_action_threads.clear()` would leave the dock stuck showing
@@ -328,6 +350,8 @@ func _drain_client_action_workers() -> void:
if t != null:
t.wait_to_finish()
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
_finalize_action_buttons(String(client_id))
var row: Dictionary = _client_rows.get(String(client_id), {})
if not row.is_empty():
@@ -337,6 +361,71 @@ func _drain_client_action_workers() -> void:
""
)
_client_action_threads.clear()
for thread in _orphaned_client_action_threads:
if thread != null:
thread.wait_to_finish()
_orphaned_client_action_threads.clear()
_client_action_started_msec.clear()
_client_action_names.clear()
func _check_client_action_timeouts() -> void:
var now := Time.get_ticks_msec()
for client_id in _client_action_threads.keys():
if not _client_action_started_msec.has(client_id):
continue
var started := int(_client_action_started_msec.get(client_id, 0))
if now - started >= CLIENT_ACTION_TIMEOUT_MSEC:
_abandon_client_action_thread(String(client_id))
func _abandon_client_action_thread(client_id: String) -> void:
if not _client_action_threads.has(client_id):
return
var thread: Thread = _client_action_threads[client_id]
var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec()))
var worker_alive := thread != null and thread.is_alive()
if thread != null:
_orphaned_client_action_threads.append(thread)
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
var action := str(_client_action_names.get(client_id, "configure"))
_client_action_names.erase(client_id)
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
_finalize_action_buttons(client_id)
print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [
client_id,
action,
elapsed,
str(worker_alive),
])
var label := "Remove" if action == "remove" else "Configure"
_apply_row_status(
client_id,
Client.Status.ERROR,
"%s did not report completion in time; refreshing current status." % label
)
_refresh_clients_summary()
if is_inside_tree():
_request_client_status_refresh(true)
func _prune_orphaned_client_action_threads() -> void:
var completed_orphan := false
for i in range(_orphaned_client_action_threads.size() - 1, -1, -1):
var thread := _orphaned_client_action_threads[i]
if thread == null:
_orphaned_client_action_threads.remove_at(i)
elif not thread.is_alive():
thread.wait_to_finish()
_orphaned_client_action_threads.remove_at(i)
completed_orphan = true
if completed_orphan and is_inside_tree():
_request_client_action_completion_refresh()
func _request_client_action_completion_refresh() -> void:
_request_client_status_refresh(true)
func _notification(what: int) -> void:
@@ -473,6 +562,13 @@ func _build_ui() -> void:
_crash_reload_btn.pressed.connect(_on_reload_plugin)
_crash_panel.add_child(_crash_reload_btn)
_crash_docs_btn = Button.new()
_crash_docs_btn.text = "How to change the port"
_crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients"
_crash_docs_btn.visible = false
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
_crash_panel.add_child(_crash_docs_btn)
_crash_panel.add_child(HSeparator.new())
add_child(_crash_panel)
@@ -487,7 +583,7 @@ func _build_ui() -> void:
_update_label = Label.new()
_update_label.add_theme_font_size_override("font_size", 15)
_update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3))
## Wrap long banner text (e.g. the < 4.4 manual-update guidance) instead
## Wrap long banner text (e.g. the < 4.5 support-floor guidance) instead
## of letting a single line stretch the whole dock wide. The dock is a
## fixed-width side panel, so constrain horizontally and wrap.
_update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -558,30 +654,45 @@ func _build_ui() -> void:
add_child(HSeparator.new())
# --- Clients ---
var clients_row := HBoxContainer.new()
clients_row.add_theme_constant_override("separation", 8)
var clients_header_row := HBoxContainer.new()
clients_header_row.add_theme_constant_override("separation", 8)
var clients_header := _make_header("Clients")
clients_row.add_child(clients_header)
clients_header_row.add_child(clients_header)
_clients_summary_label = Label.new()
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
_clients_summary_label.clip_text = true
_clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
clients_row.add_child(_clients_summary_label)
clients_header_row.add_child(_clients_summary_label)
var clients_actions := HFlowContainer.new()
clients_actions.add_theme_constant_override("h_separation", 8)
clients_actions.add_theme_constant_override("v_separation", 4)
var clients_refresh_btn := Button.new()
clients_refresh_btn.text = "Refresh"
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
clients_row.add_child(clients_refresh_btn)
clients_actions.add_child(clients_refresh_btn)
var clients_open_btn := Button.new()
clients_open_btn.text = "Clients & Settings"
clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
clients_open_btn.text = "Clients & Tools"
clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
clients_open_btn.pressed.connect(_on_open_clients_window)
clients_row.add_child(clients_open_btn)
clients_actions.add_child(clients_open_btn)
add_child(clients_row)
add_child(clients_header_row)
add_child(clients_actions)
_client_empty_cta_btn = Button.new()
_client_empty_cta_btn.text = "Configure an AI client ->"
_client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
_client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_client_empty_cta_btn.visible = false
_client_empty_cta_btn.pressed.connect(_on_open_clients_window)
add_child(_client_empty_cta_btn)
# Drift banner — hidden until a sweep finds at least one mismatched client.
_drift_banner = VBoxContainer.new()
@@ -600,7 +711,7 @@ func _build_ui() -> void:
add_child(_drift_banner)
_clients_window = Window.new()
_clients_window.title = "MCP Clients & Settings"
_clients_window.title = "Godot AI"
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
_clients_window.visible = false
@@ -758,7 +869,7 @@ func _build_client_row(client_id: String) -> void:
# --- Status updates ---
func _update_status() -> void:
var connected: bool = _connection.is_connected
var connected: bool = _connection != null and _connection.is_connected
## During plugin self-update there's a brief window where this dock
## script is already the new version (Godot hot-reloads scripts on
## file change) but `_plugin` is still the old `EditorPlugin` instance
@@ -785,7 +896,7 @@ func _update_status() -> void:
status_text = "Restarting server..."
status_color = COLOR_AMBER
elif connected:
status_text = "Connected"
status_text = _connected_status_text()
status_color = Color.GREEN
elif state == ServerStateScript.CRASHED:
var exit_ms: int = server_status.get("exit_ms", 0)
@@ -858,6 +969,15 @@ func _update_crash_panel(server_status: Dictionary) -> void:
not show_recovery_restart
and state != ServerStateScript.INCOMPATIBLE
)
## Docs link only for the genuinely-foreign occupant: a recoverable
## (older godot-ai) server gets Restart Server instead, and the inline
## body already names a free port — the link carries the per-client
## reconfigure steps that don't fit inline.
if _crash_docs_btn != null:
_crash_docs_btn.visible = (
state == ServerStateScript.INCOMPATIBLE
and not bool(server_status.get("can_recover_incompatible", false))
)
var port_picker_visible := (
state == ServerStateScript.PORT_EXCLUDED
@@ -887,9 +1007,16 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
if not message.is_empty():
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
## Genuinely foreign occupant (no recovery proof). Name a concrete
## free port so the user doesn't have to hunt for one, and let the
## crash panel's "How to change the port" link carry the per-client
## reconfigure steps. `suggest_free_port` already routes through the
## Windows reservation table, so the named port won't itself fail
## with WinError 10013.
var hint := _free_port_hint(port)
if not message.is_empty():
return message
return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port
return "%s %s" % [message, hint]
return "Port %d is occupied by an incompatible server. %s" % [port, hint]
ServerStateScript.FOREIGN_PORT:
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
ServerStateScript.CRASHED:
@@ -907,6 +1034,31 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
return ""
## One sentence naming concrete free ports for the user to switch to. Names
## BOTH http and ws: this branch also fires for an incompatible godot-ai
## server we can't prove we own, which commonly holds both ports — moving only
## http would then leave the new server unable to bind ws. Both suggestions are
## routed through `suggest_free_port` so they clear Windows' winnat reservation
## table (no point suggesting a port that 10013s on bind). Only the http port
## reaches client configs; the ws port is server↔plugin, hence the wording.
## The per-client reconfigure steps live behind the crash panel's docs link.
static func _free_port_hint(port: int) -> String:
var free_http := ClientConfigurator.suggest_free_port(port + 1)
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1)
return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws]
## URL for the port-conflict guide, pinned to the release tag that matches the
## installed plugin version (releases are tagged `v<version>`). The crash-panel
## button only exists in builds that ship `docs/port-conflicts.md`, so the
## versioned ref always resolves — and a shipped build never points users at a
## tip-of-main guide that has drifted from its own UI.
static func _port_conflict_docs_url() -> String:
var version := ClientConfigurator.get_plugin_version()
var git_ref := ("v%s" % version) if not version.is_empty() else "main"
return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner`
## confirms `*.update_backup` files exist in the addons tree. Mirrors the
## issue #354 fix shape: structured, agent-readable diagnostic that survives
@@ -981,10 +1133,17 @@ func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void:
## Signal handler for the extracted LogViewer — the panel owns its own
## display visibility, the dock owns dispatcher logging routing.
## display visibility, the dock owns logging routing. Routes to BOTH the
## dispatcher (gates [recv]/[send] recording) and the log buffer's console
## echo — the connection logs [event]/[defer] lines directly to the buffer,
## bypassing the dispatcher, so gating only `mcp_logging` left the console
## spamming with the toggle off (#626). Ring recording is unaffected, so
## the dock's log panel keeps working while the console stays quiet.
func _on_log_logging_enabled_changed(enabled: bool) -> void:
if _connection and _connection.dispatcher:
_connection.dispatcher.mcp_logging = enabled
if _log_buffer != null:
_log_buffer.enabled = enabled
## Signal handler for the extracted PortPickerPanel — the panel range-validates
@@ -1297,7 +1456,9 @@ func _refresh_setup_status() -> void:
# User mode — check for uv
var uv_version := ClientConfigurator.check_uv_version()
if not uv_version.is_empty():
_setup_container.add_child(_make_status_row("uv", uv_version, Color.GREEN))
var compact_uv_version := _compact_uv_version_text(uv_version)
var uv_tooltip := uv_version if compact_uv_version != uv_version else ""
_setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip))
## Build the Server row with a placeholder label we can update every
## frame. `_refresh_server_version_label` replaces the text + color
## once `McpConnection.server_version` lands via `handshake_ack`, and
@@ -1358,19 +1519,39 @@ func _resolve_plugin_symlink_target() -> String:
return target
func _make_status_row(label_text: String, value_text: String, value_color: Color) -> HBoxContainer:
static func _compact_uv_version_text(uv_version: String) -> String:
var text := uv_version.strip_edges()
if text.ends_with(")"):
var metadata_start := text.rfind(" (")
if metadata_start >= 0:
return text.substr(0, metadata_start).strip_edges()
return text
func _make_status_row(
label_text: String,
value_text: String,
value_color: Color,
tooltip_text: String = ""
) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
if not tooltip_text.is_empty():
row.tooltip_text = tooltip_text
var label := Label.new()
label.text = label_text
label.add_theme_color_override("font_color", COLOR_MUTED)
label.custom_minimum_size.x = 60
if not tooltip_text.is_empty():
label.tooltip_text = tooltip_text
row.add_child(label)
var value := Label.new()
value.text = value_text
value.add_theme_color_override("font_color", value_color)
if not tooltip_text.is_empty():
value.tooltip_text = tooltip_text
row.add_child(value)
return row
@@ -1480,6 +1661,14 @@ func _update_dev_section_buttons() -> void:
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
func _client_status_refresh_has_completed() -> bool:
return _last_client_status_refresh_completed_msec > 0
func _connected_status_text() -> String:
return "Server connected"
func _on_install_uv() -> void:
match OS.get_name():
"Windows":
@@ -1548,28 +1737,65 @@ func _dispatch_client_action(client_id: String, action: String) -> void:
_client_action_generations[client_id] = generation
var thread := Thread.new()
_client_action_threads[client_id] = thread
_client_action_started_msec[client_id] = Time.get_ticks_msec()
_client_action_names[client_id] = action
var err := thread.start(
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
)
if err != OK:
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
_finalize_action_buttons(client_id)
_apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread")
_refresh_clients_summary()
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void:
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> Dictionary:
var result: Dictionary
if action == "remove":
result = ClientConfigurator.remove(client_id, server_url)
else:
result = ClientConfigurator.configure(client_id, server_url)
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
call_deferred("_apply_client_action_result", client_id, action, result, generation)
return {
"client_id": client_id,
"action": action,
"result": result,
"generation": generation,
}
func _poll_completed_client_action_threads() -> void:
for client_id in _client_action_threads.keys():
var thread: Thread = _client_action_threads[client_id]
if thread == null or thread.is_alive():
continue
var payload: Variant = thread.wait_to_finish()
_client_action_threads[client_id] = null
if payload is Dictionary:
var data := payload as Dictionary
var result: Dictionary = data.get("result", {})
_apply_client_action_result(
String(data.get("client_id", client_id)),
String(data.get("action", _client_action_names.get(client_id, "configure"))),
result,
int(data.get("generation", _client_action_generations.get(client_id, 0)))
)
else:
_apply_client_action_result(
String(client_id),
String(_client_action_names.get(client_id, "configure")),
{"status": "error", "message": "worker returned no result"},
int(_client_action_generations.get(client_id, 0))
)
func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void:
if int(_client_action_generations.get(client_id, 0)) != generation:
if _client_action_threads.get(client_id, null) == null:
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
return
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
return
@@ -1578,6 +1804,8 @@ func _apply_client_action_result(client_id: String, action: String, result: Dict
if t != null:
t.wait_to_finish()
_client_action_threads.erase(client_id)
_client_action_started_msec.erase(client_id)
_client_action_names.erase(client_id)
_finalize_action_buttons(client_id)
if _server_blocks_client_health():
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
@@ -1645,7 +1873,7 @@ func _on_configure_all_clients() -> void:
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
_refresh_clients_summary()
return
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
if ClientRefreshStateScript.should_disable_client_actions(_refresh_state):
return
for client_id in _client_rows:
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
@@ -1700,7 +1928,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
var tools_tab := VBoxContainer.new()
tools_tab.add_theme_constant_override("separation", 8)
var tools_margin := _build_margin_container()
tools_margin.name = "Settings"
tools_margin.name = "Tools"
tools_margin.add_child(tools_tab)
tabs.add_child(tools_margin)
@@ -1793,7 +2021,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
footer.add_theme_constant_override("separation", 8)
_tools_apply_btn = Button.new()
_tools_apply_btn.text = "Apply && Restart Server"
_tools_apply_btn.text = "Apply and Restart Server"
_tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains."
_tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_tools_apply_btn.pressed.connect(_on_tools_apply)
@@ -1976,8 +2204,11 @@ func _refresh_clients_summary() -> void:
)
_clients_summary_label.text = text
if _client_configure_all_btn != null:
_client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state)
_client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state)
if _client_empty_cta_btn != null:
_client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
_refresh_drift_banner(mismatched_ids)
_update_status()
func _show_manual_command_for(client_id: String) -> void:
@@ -2167,8 +2398,8 @@ func _warm_strategy_bytecode() -> void:
func _begin_client_status_refresh_run() -> int:
## Marks a refresh as starting and returns the new generation token.
## Generation is bumped here (not at completion) so that a worker callback
## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree`
## Generation is bumped here (not at completion) so that a worker result
## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree`
## fires can be detected as stale via generation mismatch.
_refresh_state = ClientRefreshStateScript.RUNNING
_client_status_refresh_pending = false
@@ -2181,7 +2412,7 @@ func _begin_client_status_refresh_run() -> int:
func _finalize_completed_refresh() -> void:
## Stamps cooldown and clears in-flight state. Called at the end of every
## refresh that successfully applied results — the worker callback path
## refresh that successfully applied results — the worker reaping path
## and the no-CLI fast path in `_perform_initial_client_status_refresh`.
_last_client_status_refresh_completed_msec = Time.get_ticks_msec()
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
@@ -2308,7 +2539,7 @@ func _retry_deferred_client_status_refresh() -> void:
_request_client_status_refresh(force)
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void:
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> Dictionary:
var results: Dictionary = {}
for probe in client_probes:
var client_id := String(probe.get("id", ""))
@@ -2325,8 +2556,25 @@ func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_
"installed": installed,
"error_msg": details.get("error_msg", ""),
}
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
call_deferred("_apply_client_status_refresh_results", results, generation)
return {"results": results, "generation": generation}
func _poll_completed_client_status_refresh_thread() -> void:
if _client_status_refresh_thread == null:
return
if _client_status_refresh_thread.is_alive():
return
var payload: Variant = _client_status_refresh_thread.wait_to_finish()
_client_status_refresh_thread = null
if payload is Dictionary:
var data := payload as Dictionary
var results: Dictionary = data.get("results", {})
_apply_client_status_refresh_results(
results,
int(data.get("generation", _client_status_refresh_generation))
)
else:
_apply_client_status_refresh_results({}, _client_status_refresh_generation)
func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void:
@@ -2491,6 +2739,5 @@ func _on_install_state_changed(state: Dictionary) -> void:
if state.has("banner_visible") and _update_banner != null:
_update_banner.visible = bool(state["banner_visible"])
if String(state.get("outcome", "")) == "success" and _update_label != null:
## Visual confirmation for the pre-4.4 "Updated! Restart the editor."
## terminal state — the only outcome the manager paints green for.
## Visual confirmation for successful terminal update states.
_update_label.add_theme_color_override("font_color", Color.GREEN)
+1 -1
View File
@@ -3,5 +3,5 @@
name="Godot AI"
description="MCP server and AI tools for Godot"
author="Godot AI"
version="2.7.6"
version="2.9.1"
script="plugin.gd"
+75 -49
View File
@@ -6,13 +6,8 @@ const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd
## Editor-process Logger subclass — captures parse errors, @tool runtime
## errors, and push_error/push_warning so the LLM can read them via
## `logs_read(source="editor")`. Loaded dynamically because
## `extends Logger` requires Godot 4.5+. The logger script lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor scan never
## parses it (no "Could not find base class Logger" error on < 4.5), and
## LoggerLoader compiles it from source at runtime only after the
## ClassDB.class_exists("Logger") gate below. See issue #231 / #475.
const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd")
## `logs_read(source="editor")`.
const EditorLogger := preload("res://addons/godot_ai/runtime/editor_logger.gd")
## EditorSettings keys used to remember which server process the plugin
## spawned — survives editor restarts, lets a later editor session adopt
@@ -48,6 +43,7 @@ const Telemetry := preload("res://addons/godot_ai/telemetry.gd")
const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd")
const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd")
const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd")
const SurfacedErrorTracker := preload("res://addons/godot_ai/utils/surfaced_error_tracker.gd")
const Dock := preload("res://addons/godot_ai/mcp_dock.gd")
const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd")
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
@@ -83,6 +79,8 @@ const EnvironmentHandler := preload("res://addons/godot_ai/handlers/environment_
const TextureHandler := preload("res://addons/godot_ai/handlers/texture_handler.gd")
const CurveHandler := preload("res://addons/godot_ai/handlers/curve_handler.gd")
const ControlDrawRecipeHandler := preload("res://addons/godot_ai/handlers/control_draw_recipe_handler.gd")
const TilemapHandler := preload("res://addons/godot_ai/handlers/tilemap_handler.gd")
const TilesetHandler := preload("res://addons/godot_ai/handlers/tileset_handler.gd")
## The Python server writes its own PID here on startup (passed as
## `--pid-file`) and unlinks on clean exit. Deterministic replacement
@@ -144,19 +142,14 @@ const STARTUP_TRACE_COUNTER_NAMES := [
##
## `tests/unit/test_plugin_self_update_safety.py` locks this wording in.
##
## `_editor_logger` is untyped because its script extends Godot 4.5+'s Logger
## class: `logger_loader.gd` compiles it at runtime from on-disk source
## (FileAccess + `GDScript.new()`) past the `ClassDB.class_exists("Logger")`
## gate in `_attach_editor_logger`, so the plugin still parses on 4.4. Null on
## Godot < 4.5 or before `_attach_editor_logger` runs; "attached" state IS
## exactly "non-null".
var _connection
var _dispatcher
var _telemetry
var _log_buffer
var _game_log_buffer
var _editor_log_buffer
var _editor_logger
var _surfaced_error_tracker
var _editor_logger: Logger
var _dock
var _handlers: Array = [] # prevent GC of RefCounted handlers
var _debugger_plugin
@@ -197,11 +190,9 @@ func _enter_tree() -> void:
print("MCP | plugin disabled in headless mode")
return
## Self-update from a pre-loggers/ version leaves the old logger scripts
## orphaned at runtime/*.gd (the runner only writes files in the new ZIP,
## it doesn't prune). Those still `extends Logger` and re-emit the parse
## errors on Godot < 4.5. Delete them once so upgraders match a fresh
## install. No-op on fresh installs and dev checkouts (files absent).
## Self-update extracts over the live addon and doesn't prune files that
## disappeared from the new ZIP. Remove obsolete Logger-loader quarantine
## files/folders once so upgraders match a fresh install.
_cleanup_legacy_logger_scripts()
## Register port overrides before spawn so `http_port()` / `ws_port()`
@@ -211,17 +202,24 @@ func _enter_tree() -> void:
_startup_trace_phase("settings_registered")
_log_buffer = LogBuffer.new()
## Apply the persisted dock "Log" toggle before anything logs through the
## buffer. Without this the choice only took effect after a manual toggle
## and reset to noisy on every editor restart (#626).
_log_buffer.enabled = McpSettings.mcp_logging_enabled()
_start_server()
_startup_trace_phase("server_start")
_game_log_buffer = GameLogBuffer.new()
_editor_log_buffer = EditorLogBuffer.new()
_surfaced_error_tracker = SurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer)
_attach_editor_logger()
_dispatcher = Dispatcher.new(_log_buffer)
_dispatcher = Dispatcher.new(_log_buffer, _surfaced_error_tracker)
_dispatcher.mcp_logging = _log_buffer.enabled
_startup_trace_phase("core_objects")
_connection = Connection.new()
_connection.log_buffer = _log_buffer
_connection.surfaced_error_tracker = _surfaced_error_tracker
_connection.ws_port = _resolved_ws_port
_connection.connect_blocked = _lifecycle.is_connection_blocked()
_connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "")
@@ -233,19 +231,20 @@ func _enter_tree() -> void:
_telemetry = Telemetry.new(_connection)
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer)
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer, _surfaced_error_tracker)
add_debugger_plugin(_debugger_plugin)
_connection.debugger_plugin = _debugger_plugin
_ensure_game_helper_autoload()
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer)
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer, null, _surfaced_error_tracker)
var scene_handler := SceneHandler.new(_connection)
var node_handler := NodeHandler.new(get_undo_redo())
var project_handler := ProjectHandler.new(_connection, _debugger_plugin)
var project_handler := ProjectHandler.new(_connection, _debugger_plugin, _editor_log_buffer)
var client_handler := ClientHandler.new()
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
var api_handler := ApiHandler.new()
var filesystem_handler := FilesystemHandler.new()
var filesystem_handler := FilesystemHandler.new(_connection)
var signal_handler := SignalHandler.new(get_undo_redo())
var autoload_handler := AutoloadHandler.new()
var input_handler := InputHandler.new()
@@ -263,7 +262,9 @@ func _enter_tree() -> void:
var texture_handler := TextureHandler.new(get_undo_redo(), _connection)
var curve_handler := CurveHandler.new(get_undo_redo(), _connection)
var control_draw_recipe_handler := ControlDrawRecipeHandler.new(get_undo_redo())
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler]
var tilemap_handler := TilemapHandler.new(get_undo_redo())
var tileset_handler := TilesetHandler.new()
_handlers = [editor_handler, scene_handler, node_handler, project_handler, client_handler, script_handler, resource_handler, api_handler, filesystem_handler, signal_handler, autoload_handler, input_handler, test_handler, batch_handler, ui_handler, theme_handler, animation_handler, material_handler, particle_handler, camera_handler, audio_handler, physics_shape_handler, environment_handler, texture_handler, curve_handler, control_draw_recipe_handler, tilemap_handler, tileset_handler]
_dispatcher.register("get_editor_state", editor_handler.get_editor_state)
_dispatcher.register("get_scene_tree", scene_handler.get_scene_tree)
@@ -318,6 +319,7 @@ func _enter_tree() -> void:
_dispatcher.register("read_file", filesystem_handler.read_file)
_dispatcher.register("write_file", filesystem_handler.write_file)
_dispatcher.register("reimport", filesystem_handler.reimport)
_dispatcher.register("scan_filesystem", filesystem_handler.scan_filesystem)
_dispatcher.register("list_signals", signal_handler.list_signals)
_dispatcher.register("connect_signal", signal_handler.connect_signal)
_dispatcher.register("disconnect_signal", signal_handler.disconnect_signal)
@@ -326,8 +328,10 @@ func _enter_tree() -> void:
_dispatcher.register("remove_autoload", autoload_handler.remove_autoload)
_dispatcher.register("list_actions", input_handler.list_actions)
_dispatcher.register("add_action", input_handler.add_action)
_dispatcher.register("ensure_action", input_handler.ensure_action)
_dispatcher.register("remove_action", input_handler.remove_action)
_dispatcher.register("bind_event", input_handler.bind_event)
_dispatcher.register("ensure_binding", input_handler.ensure_binding)
_dispatcher.register("run_tests", test_handler.run_tests)
_dispatcher.register("get_test_results", test_handler.get_test_results)
_dispatcher.register("batch_execute", batch_handler.batch_execute)
@@ -393,6 +397,12 @@ func _enter_tree() -> void:
_dispatcher.register(
"control_draw_recipe", control_draw_recipe_handler.control_draw_recipe
)
_dispatcher.register("tilemap_set_cell", tilemap_handler.set_cell)
_dispatcher.register("tilemap_set_cells_rect", tilemap_handler.set_cells_rect)
_dispatcher.register("tilemap_clear", tilemap_handler.clear_layer)
_dispatcher.register("tilemap_get_cells", tilemap_handler.get_used_cells)
_dispatcher.register("tileset_get_atlas_tiles", tileset_handler.get_atlas_tiles)
_dispatcher.register("tileset_get_atlas_image", tileset_handler.get_atlas_image)
_connection.dispatcher = _dispatcher
add_child(_connection)
@@ -489,6 +499,7 @@ func _exit_tree() -> void:
_log_buffer = null
_game_log_buffer = null
_editor_log_buffer = null
_surfaced_error_tracker = null
_stop_server()
## Symmetric with prepare_for_update_reload: the static guard persists
@@ -504,9 +515,7 @@ func _exit_tree() -> void:
## Attach editor_logger.gd as a Godot logger so editor-process script
## errors (parse errors, @tool runtime errors, EditorPlugin errors,
## push_error/push_warning) flow into _editor_log_buffer for
## logs_read(source="editor"). Logger subclassing is 4.5+ only; the
## ClassDB gate keeps the plugin loadable on 4.4 with no-op editor logs
## (the buffer stays empty, logs_read returns no entries).
## logs_read(source="editor").
##
## Limitation called out in the issue: parse errors fired *before* the
## plugin's _enter_tree (e.g. during the editor's initial filesystem
@@ -516,36 +525,53 @@ func _exit_tree() -> void:
## file would re-emit them but at the cost of disrupting the user's
## editing state, so we accept the gap.
func _attach_editor_logger() -> void:
if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger")):
return
var logger_script := LoggerLoader.build(LoggerLoader.EDITOR_LOGGER_PATH)
if logger_script == null:
return
_editor_logger = logger_script.new(_editor_log_buffer)
OS.call("add_logger", _editor_logger)
_editor_logger = EditorLogger.new(_editor_log_buffer)
OS.add_logger(_editor_logger)
## Remove the pre-2.5.8 logger scripts left at runtime/*.gd by a self-update
## (the runner doesn't prune files dropped between versions). They `extends
## Logger` and would re-emit "Could not find base class Logger" parse errors
## on Godot < 4.5 even though the live copies now live in the .gdignore'd
## runtime/loggers/ folder. Idempotent: existence-guarded, so it's a no-op on
## fresh installs and symlinked dev checkouts.
## Remove old Logger-quarantine artifacts left by extract-over-live
## self-update. Idempotent: existence-guarded, so it's a no-op on fresh
## installs and symlinked dev checkouts.
func _cleanup_legacy_logger_scripts() -> void:
var legacy := [
"res://addons/godot_ai/runtime/editor_logger.gd",
"res://addons/godot_ai/runtime/editor_logger.gd.uid",
"res://addons/godot_ai/runtime/game_logger.gd",
"res://addons/godot_ai/runtime/game_logger.gd.uid",
var legacy_files := [
"res://addons/godot_ai/runtime/logger_loader.gd",
"res://addons/godot_ai/runtime/logger_loader.gd.uid",
"res://addons/godot_ai/testing/script_error_capture_loader.gd",
"res://addons/godot_ai/testing/script_error_capture_loader.gd.uid",
]
for res_path in legacy:
for res_path in legacy_files:
if FileAccess.file_exists(res_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path))
var legacy_dirs := [
"res://addons/godot_ai/runtime/loggers",
"res://addons/godot_ai/testing/loggers",
]
for res_path in legacy_dirs:
var absolute := ProjectSettings.globalize_path(res_path)
if DirAccess.dir_exists_absolute(absolute):
_remove_dir_recursive_absolute(absolute)
static func _remove_dir_recursive_absolute(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var name := dir.get_next()
while not name.is_empty():
var child := path.path_join(name)
if dir.current_is_dir():
_remove_dir_recursive_absolute(child)
else:
DirAccess.remove_absolute(child)
name = dir.get_next()
dir.list_dir_end()
DirAccess.remove_absolute(path)
func _detach_editor_logger() -> void:
if _editor_logger != null and OS.has_method("remove_logger"):
OS.call("remove_logger", _editor_logger)
if _editor_logger != null:
OS.remove_logger(_editor_logger)
_editor_logger = null
@@ -3,20 +3,12 @@ extends Logger
## Editor-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). plugin.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"), so the `extends Logger` parse only ever
## happens on 4.5+ where it resolves. Registered from plugin.gd::_enter_tree
## NOTE: deliberately no `class_name`. Registered from plugin.gd::_enter_tree
## so we can intercept editor-process script errors — parse errors, @tool
## runtime errors, EditorPlugin errors, push_error/push_warning — and
## surface them via `logs_read(source="editor")`. Without this, the LLM
## sees nothing in `logs_read` while the same errors show in red lines in
## Godot's Output panel.
## runtime errors, EditorPlugin errors, push_error/push_warning — and surface
## them via `logs_read(source="editor")`. Without this, the LLM sees nothing
## in `logs_read` while the same errors show in red lines in Godot's Output
## panel.
##
## Why only `_log_error` and not `_log_message`:
## `_log_message(msg, error)` covers print() and printerr(), which is the
@@ -34,17 +26,13 @@ extends Logger
const ADDON_PATH_MARKER := "/addons/godot_ai/"
## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name.
## This script is compiled from source at runtime by logger_loader.gd; a bare
## class_name reference depends on the global class-name table being populated
## A bare class_name reference depends on the global class-name table being populated
## at compile time, which isn't guaranteed on a cold editor enable mid-scan.
## `const preload` resolves at compile time independent of the registry —
## matches game_logger.gd's deliberate choice for the same reason.
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
## McpEditorLogBuffer — untyped because this script is loaded dynamically and
## McpEditorLogBuffer's class_name isn't yet registered on the parser at the
## time `extends Logger` resolves. Constructor-injected so the hot path
## doesn't need a per-call null check.
## Constructor-injected so the hot path doesn't need a per-call null check.
var _buffer
@@ -0,0 +1 @@
uid://cxfregddqj5b8
+98 -26
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,16 +76,9 @@ 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)
## _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.
@@ -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:
@@ -3,17 +3,10 @@ extends Logger
## Game-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirelyon Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). game_helper.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"). Registered from inside the running game
## so we can intercept print(), printerr(), push_error(), and
## push_warning() and ferry them back to the editor over the
## EngineDebugger channel — the same bridge PR #76 uses for screenshots.
## NOTE: deliberately no `class_name`. Registered from inside the running
## game so we can intercept print(), printerr(), push_error(), and
## push_warning() and ferry them back to the editor over the EngineDebugger
## channel — the same bridge PR #76 uses for screenshots.
##
## Logger virtuals can be called from any thread (e.g. async loaders push
## errors off the main thread). We accumulate into _pending under a Mutex
@@ -81,9 +74,9 @@ func _log_error(
## Collect every function name in the first non-empty backtrace so
## game_helper can match its eval's uniquely named wrapper function.
var funcs := PackedStringArray()
for bt in script_backtraces:
for bt: RefCounted in script_backtraces:
if bt != null and bt.get_frame_count() > 0:
for i in bt.get_frame_count():
for i: int in bt.get_frame_count():
funcs.append(bt.get_frame_function(i))
break
_mutex.lock()
@@ -0,0 +1 @@
uid://dwcs6d1y7vhqi
-57
View File
@@ -1,57 +0,0 @@
@tool
extends RefCounted
## Runtime builder for the `extends Logger` scripts in `runtime/loggers/`.
##
## `Logger` is a Godot 4.5+ class. A `.gd` file that statically declares
## `extends Logger` is rejected by the parser on Godot < 4.5 — and Godot's
## editor filesystem scan parses *every* `.gd` under the project, so just
## shipping `editor_logger.gd` / `game_logger.gd` printed two
## `Parse Error: Could not find base class "Logger"` lines on every 4.3/4.4
## editor startup (#475 follow-up). They were functionally harmless (the
## scripts are only ever instanced behind a `ClassDB.class_exists("Logger")`
## gate) but they were real red error text we shouldn't ship.
##
## Fix: the two logger scripts live in `runtime/loggers/`, which carries a
## `.gdignore` so the editor scan skips the folder entirely — no parse, no
## error, on any engine. This loader reads the source off disk with
## `FileAccess` (unaffected by `.gdignore`, which only governs the resource
## importer) and compiles it at runtime via `GDScript.new()`. Callers gate
## on `ClassDB.class_exists("Logger")` first, so `build()` only ever runs on
## 4.5+, where `extends Logger` resolves cleanly.
##
## This script itself does NOT extend Logger, so it parses on every engine
## and is safe to `preload` from `plugin.gd` and `game_helper.gd`.
const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd"
const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd"
const VALIDATION_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/validation_logger.gd"
## Compile a `.gdignore`'d logger script from its on-disk source. Returns the
## ready-to-instance GDScript, or null if the file is missing (e.g. excluded
## from an exported game) or fails to compile. Callers must already have
## confirmed `ClassDB.class_exists("Logger")` — building an `extends Logger`
## script on an engine without the class will fail the reload() and return
## null, which the gated callers treat as "logging unavailable".
static func build(path: String) -> GDScript:
if not FileAccess.file_exists(path):
return null
var source := FileAccess.get_file_as_string(path)
if source.is_empty():
return null
var script := GDScript.new()
script.source_code = source
## Deliberately do NOT set `script.resource_path`: this builds a fresh
## anonymous GDScript every call, and a reload cycle (editor_reload_plugin,
## self-update disable→enable) calls build() again for the same path. Two
## live Resources sharing one non-empty resource_path trips Godot's
## "Another resource is loaded from path ..." error and leaves the new
## script with an empty path anyway — re-introducing red console text on
## every reload, the exact thing this folder's .gdignore set out to remove.
## game_helper.gd::_handle_eval compiles from source the same way and also
## omits resource_path. The script still resolves its absolute preloads /
## class_names fine without a path.
if script.reload() != OK:
return null
return script
@@ -1 +0,0 @@
uid://d3plpedkpvec6
@@ -0,0 +1 @@
uid://b2ff3aot6t2l4
@@ -3,9 +3,7 @@ extends Logger
## Captures GDScript runtime errors emitted while a test is running.
##
## Deliberately no class_name: this script is compiled dynamically by
## script_error_capture_loader.gd only when Logger exists. It lives in a
## `.gdignore`d folder so older Godot editor scans never parse `extends Logger`.
## Deliberately no class_name: this is an internal test helper.
##
## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and
## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and
@@ -0,0 +1 @@
uid://crk8w2nei087v
@@ -1,26 +0,0 @@
@tool
extends RefCounted
## Builds the Logger-based test script-error capture lazily.
##
## Logger exists only on newer Godot versions. The capture implementation lives
## under a `.gdignore`d folder and is compiled from source only after the runner
## verifies the Logger API is present, so older editor scans do not parse an
## `extends Logger` file and emit red startup errors.
const SCRIPT_ERROR_CAPTURE_PATH := "res://addons/godot_ai/testing/loggers/script_error_capture.gd"
static func build() -> Object:
if not ClassDB.class_exists("Logger") or not OS.has_method("add_logger"):
return null
if not FileAccess.file_exists(SCRIPT_ERROR_CAPTURE_PATH):
return null
var source := FileAccess.get_file_as_string(SCRIPT_ERROR_CAPTURE_PATH)
if source.is_empty():
return null
var script := GDScript.new()
script.source_code = source
if script.reload() != OK:
return null
return script.new()
@@ -1 +0,0 @@
uid://pmtc07go8ty4
+8 -8
View File
@@ -5,17 +5,17 @@ extends RefCounted
## Lightweight test runner for MCP plugin tests. Discovers test_* methods
## on McpTestSuite instances, runs them, and collects structured results.
const ScriptErrorCaptureLoader := preload("res://addons/godot_ai/testing/script_error_capture_loader.gd")
const ScriptErrorCapture := preload("res://addons/godot_ai/testing/script_error_capture.gd")
var _results: Array[Dictionary] = []
var _last_run_ms: int = 0
var _script_error_capture: Object = null
var _script_error_capture: ScriptErrorCapture = null
var _capture_registered := false
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null:
OS.call("remove_logger", _script_error_capture)
OS.remove_logger(_script_error_capture)
_capture_registered = false
@@ -173,10 +173,10 @@ func _register_capture() -> void:
if _capture_registered:
return
if _script_error_capture == null:
_script_error_capture = ScriptErrorCaptureLoader.build()
_script_error_capture = ScriptErrorCapture.new()
if _script_error_capture == null:
return
OS.call("add_logger", _script_error_capture)
OS.add_logger(_script_error_capture)
_capture_registered = true
@@ -186,19 +186,19 @@ func _unregister_capture() -> void:
if _script_error_capture == null:
_capture_registered = false
return
OS.call("remove_logger", _script_error_capture)
OS.remove_logger(_script_error_capture)
_capture_registered = false
func _begin_script_error_capture() -> void:
if _script_error_capture != null and _capture_registered:
_script_error_capture.call("begin_capture")
_script_error_capture.begin_capture()
func _end_script_error_capture() -> PackedStringArray:
if _script_error_capture == null or not _capture_registered:
return PackedStringArray()
return _script_error_capture.call("end_capture") as PackedStringArray
return _script_error_capture.end_capture()
static func _edited_scene_root() -> Node:
+3 -3
View File
@@ -124,14 +124,14 @@ func skip_suite(reason: String) -> void:
## Mark the current test as skipped when the running Godot is older than
## `min_version` (a "major.minor" string like "4.4"). Use for tests that
## `min_version` (a "major.minor" string like "4.6"). Use for tests that
## exercise an engine API or behavior that only exists on newer Godot.
## Returns true when the test was skipped, so callers can `return` from
## the test body.
##
## Example:
## func test_uses_44_only_api() -> void:
## if skip_on_godot_lt("4.4", "Engine.capture_script_backtraces is 4.4+"):
## func test_uses_46_only_api() -> void:
## if skip_on_godot_lt("4.6", "example API requires Godot 4.6+"):
## return
## ...
func skip_on_godot_lt(min_version: String, reason: String = "") -> bool:
+2
View File
@@ -50,6 +50,8 @@ const DOMAINS := [
{"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]},
{"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]},
{"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]},
{"id": "tilemap", "label": "tilemap", "count": 1, "tools": ["tilemap_manage"]},
{"id": "tileset", "label": "tileset", "count": 1, "tools": ["tileset_manage"]},
{"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]},
]
+13 -1
View File
@@ -26,6 +26,7 @@ extends McpStructuredLogRing
const MAX_LINES := 500
var _mutex := Mutex.new()
var _error_appended_total := 0
func _init() -> void:
@@ -33,9 +34,10 @@ func _init() -> void:
func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void:
var coerced_level := _coerce_level(level)
var entry := {
"source": "editor",
"level": _coerce_level(level),
"level": coerced_level,
"text": text,
"path": path,
"line": line,
@@ -45,6 +47,8 @@ func append(level: String, text: String, path: String = "", line: int = 0, funct
entry["details"] = details.duplicate(true)
_mutex.lock()
_append_entry(entry)
if coerced_level == "error":
_error_appended_total += 1
_mutex.unlock()
@@ -96,9 +100,17 @@ func appended_total() -> int:
return n
func error_appended_total() -> int:
_mutex.lock()
var n := _error_appended_total
_mutex.unlock()
return n
func clear() -> int:
_mutex.lock()
var n := _total_count_unlocked()
_clear_storage()
_error_appended_total = 0
_mutex.unlock()
return n
+64 -12
View File
@@ -6,9 +6,8 @@ extends McpStructuredLogRing
## ferried back from the playing game over the EngineDebugger channel.
##
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
## rotates each time clear_for_new_run() fires (called on the game's
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
## this play started".
## rotates at play-start, giving agents a stable cursor for "lines from
## this run" even when the game never reaches the mcp:hello boot beacon.
##
## Single-threaded — game_helper.gd drains its logger from `_process` and
## calls `append` from the main thread, so this subclass can use the base
@@ -17,6 +16,9 @@ extends McpStructuredLogRing
const MAX_LINES := 2000
var _run_id := ""
var _run_seq := 0
var _error_warn_total := 0
var _error_total := 0
func _init() -> void:
@@ -24,18 +26,30 @@ func _init() -> void:
func append(level: String, text: String, details: Dictionary = {}) -> void:
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
var coerced_level := _coerce_level(level)
var entry := {
"source": "game",
"level": coerced_level,
"text": text,
"run_id": _run_id,
}
if not details.is_empty():
entry["details"] = details.duplicate(true)
_append_entry(entry)
if coerced_level in ["warn", "error"]:
_error_warn_total += 1
if coerced_level == "error":
_error_total += 1
## Rotate the run identifier and drop all buffered entries. Called when the
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
## Returns the new run_id.
## Rotate the run identifier without dropping buffered entries. Called at
## play-start so even no-hello parse failures get a fresh current-run identity.
## Historical lines stay tagged with their original run_id and can still be
## queried explicitly.
func clear_for_new_run() -> String:
_clear_storage()
_run_id = _generate_run_id()
_error_warn_total = 0
_error_total = 0
return _run_id
@@ -43,8 +57,46 @@ func run_id() -> String:
return _run_id
static func _generate_run_id() -> String:
func error_warn_total() -> int:
return _error_warn_total
func error_total() -> int:
return _error_total
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
return get_run_page(run_id, offset, count).entries
func run_total_count(run_id: String) -> int:
return int(get_run_page(run_id, 0, 0).total_count)
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
var entries := _entries_for_run(run_id)
var start := mini(maxi(0, offset), entries.size())
var stop := mini(entries.size(), start + maxi(0, count))
var out: Array[Dictionary] = []
for i in range(start, stop):
out.append(entries[i])
return {
"entries": out,
"total_count": entries.size(),
}
func _entries_for_run(run_id: String) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for entry in get_range(0, total_count()):
if str(entry.get("run_id", "")) == run_id:
out.append(entry)
return out
func _generate_run_id() -> String:
## Opaque to agents — they only check equality. Time-based is plenty
## unique within a single editor session and avoids the RNG-seed
## reproducibility footgun.
return "r%d" % Time.get_ticks_msec()
## unique within a single editor session; the local sequence protects
## fast back-to-back test runs within the same millisecond.
_run_seq += 1
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
+6 -2
View File
@@ -26,9 +26,13 @@ var _total_logged: int = 0
var enabled := true
func log(msg: String) -> void:
## `echo=false` records the line into the ring (so the dock's log panel shows
## it) without printing to the Godot console. Used for high-frequency
## machine-driven lines like readiness flips, which spammed the console of
## every install (#626) — filesystem scans toggle readiness on each import.
func log(msg: String, echo: bool = true) -> void:
var line := "MCP | %s" % msg
if enabled and console_echo:
if enabled and console_echo and echo:
print(line)
_lines.append(line)
if _lines.size() > MAX_LINES:
@@ -57,6 +57,14 @@ static func has_worker_alive(state: int) -> bool:
return state == RUNNING or state == RUNNING_TIMED_OUT
## True while the status worker is still within its healthy budget. Once a
## refresh has timed out, the dock keeps the warning badge but must let users
## retry Configure / Configure all instead of stranding the controls behind an
## orphaned, uninterruptible worker.
static func should_disable_client_actions(state: int) -> bool:
return state == RUNNING
## True when the dock should reject new refresh spawns. Used by the
## focus-in / manual button / cooldown-timer entrypoints.
static func is_blocked_for_spawn(state: int) -> bool:
@@ -300,6 +300,15 @@ func _set_incompatible_server(live: Dictionary, expected_version: String, port:
var proof_name := str(proof.get("proof", ""))
_can_recover_incompatible = not proof_name.is_empty()
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
if not _can_recover_incompatible:
## Non-recoverable: a foreign / unprovable occupant holds the port and
## we have no ownership proof, so we must NOT kill it — surface a
## concrete free port the user can switch to instead (the same hint
## the dock crash body renders). Logging it to the editor output also
## lets `ci-stale-server-smoke --mode foreign` assert this upstream
## classification from CI. Reservation-aware on Windows.
var suggested := ClientConfigurator.suggest_free_port(port + 1)
print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, suggested])
_host._refresh_dock_client_statuses()
+21
View File
@@ -13,6 +13,9 @@ const SETTING_HTTP_PORT := "godot_ai/http_port"
## Comma-separated list of tool domains excluded from the server at spawn time.
const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains"
const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled"
## Whether MCP log lines echo to the Godot console (dock "Log" toggle).
## The dock's ring-buffer log panel keeps recording regardless.
const SETTING_MCP_LOGGING := "godot_ai/mcp_logging"
## Returns true if the string value is truthy
@@ -37,3 +40,21 @@ static func telemetry_enabled() -> bool:
if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED):
return bool(es.get_setting(SETTING_TELEMETRY_ENABLED))
return true
## Returns whether MCP log lines should echo to the Godot console. Read at
## plugin startup (to apply the persisted choice to the log buffer and
## dispatcher) and by the dock's LogViewer toggle for its initial state.
## Defaults to true when the user has never touched the toggle.
static func mcp_logging_enabled() -> bool:
var es := EditorInterface.get_editor_settings()
if es != null and es.has_setting(SETTING_MCP_LOGGING):
return bool(es.get_setting(SETTING_MCP_LOGGING))
return true
## Persist the dock "Log" toggle so the choice survives editor restarts (#626).
static func set_mcp_logging_enabled(enabled: bool) -> void:
var es := EditorInterface.get_editor_settings()
if es != null:
es.set_setting(SETTING_MCP_LOGGING, enabled)
@@ -0,0 +1,540 @@
@tool
class_name McpSurfacedErrorTracker
extends RefCounted
## Central source for "errors the agent should know exist".
##
## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the
## game subprocess can land solely in the Debugger Errors tab, so this tracker
## promotes visible Debugger-tab rows into a monotonic sequence before the
## dispatcher stamps a watermark on each response envelope.
const MAX_PROMOTED_DEBUGGER_ENTRIES := 500
const MAX_PROMOTED_DEBUGGER_KEYS := 5000
const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250
const DEBUGGER_SCAN_AFTER_STOP_MS := 5000
## #641: delays for the self-scheduled forced scans armed on run stop (and on
## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows
## the remote debugger delivers right around the event, and a late one past
## Godot's per-frame Errors-tab insertion throttle for error floods.
const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0]
var _editor_log_buffer
var _game_log_buffer
var _debugger_errors_root: Node
var _debugger_search_root_cache: Node
var _promoted_debugger_keys: Dictionary = {}
var _promoted_debugger_key_order: Array[String] = []
var _promoted_debugger_entries: Array[Dictionary] = []
var _debugger_promoted_total := 0
var _run_seq := 0
var _oldest_retained_debugger_sequence := 1
var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS
var _debugger_scan_active := false
var _debugger_scan_until_msec := 0
var _deferred_scans_scheduled_total := 0
func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void:
_editor_log_buffer = editor_log_buffer
_game_log_buffer = game_log_buffer
_debugger_errors_root = debugger_errors_root
func note_game_run_started(sticky_scan: bool = true) -> void:
_run_seq += 1
_debugger_scan_active = sticky_scan
_debugger_scan_until_msec = 0
if not sticky_scan:
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
refresh_debugger_errors(true)
func note_game_run_stopped() -> void:
_debugger_scan_active = false
_debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS
schedule_deferred_scans()
## #641: promotion into the watermark used to depend on a tool call arriving
## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS
## of stop). Boot parse errors that landed in the Errors tab with no tool call
## in that window were never promoted, so the agent never got the
## new_errors_since_last_call hint. These editor-side timers force a scan
## regardless of tool-call cadence; the next stamped response then carries the
## already-promoted count even after the gate closes. Scans are content-keyed
## and idempotent, so a timer firing after an unrelated new run is harmless.
func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return
for delay in delays:
var timer := tree.create_timer(maxf(0.05, float(delay)))
timer.timeout.connect(_on_deferred_scan_timeout)
_deferred_scans_scheduled_total += 1
func deferred_scans_scheduled_total() -> int:
return _deferred_scans_scheduled_total
func _on_deferred_scan_timeout() -> void:
refresh_debugger_errors(true)
func refresh_debugger_errors(force: bool = true) -> void:
var now := Time.get_ticks_msec()
if not force and not _should_scan_debugger_for_cached_watermark(now):
return
_last_debugger_refresh_msec = now
var current_by_key: Dictionary = {}
for entry in _raw_debugger_error_entries():
if str(entry.get("level", "")) != "error":
continue
var key := _log_entry_key(entry)
var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry})
info["count"] = int(info.get("count", 0)) + 1
current_by_key[key] = info
for key in _promoted_debugger_keys.keys():
if not current_by_key.has(key):
_promoted_debugger_keys[key] = 0
for key in current_by_key.keys():
var info: Dictionary = current_by_key[key]
var current := int(info.get("count", 0))
var stored := int(_promoted_debugger_keys.get(key, 0))
if current < stored:
_promoted_debugger_keys[key] = current
continue
if current == stored:
continue
if not _promoted_debugger_keys.has(key):
_promoted_debugger_key_order.append(key)
_promoted_debugger_keys[key] = current
_debugger_promoted_total += current - stored
var source_entry: Dictionary = info.get("entry", {})
var promoted := source_entry.duplicate(true)
promoted["_debugger_key"] = key
promoted["_debugger_occurrences"] = current
promoted["_debugger_sequence"] = _debugger_promoted_total
_remove_promoted_debugger_entry(key)
_promoted_debugger_entries.append(promoted)
_trim_promoted_debugger_entries()
_trim_promoted_debugger_key_counts()
## #645: promote an error record that has no Errors-tab row to scrape — e.g. a
## boot-time parse error that parked the game in a remote-debugger break before
## any surface got a record. The entry joins the same promoted sequence as
## scraped Debugger rows, so run-scoping (editor_entries_since), the retained
## fallback, and the response watermark all see it with no extra plumbing.
## Re-recording the same key later (the same script still broken on the next
## run) re-promotes it with a fresh sequence, mirroring how re-appearing
## Errors-tab rows behave; scan reconciliation zeroes the key's count once the
## break ends since the row never exists in the live tab.
func record_synthetic_error(entry: Dictionary) -> void:
var key := _log_entry_key(entry)
var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1
if not _promoted_debugger_keys.has(key):
_promoted_debugger_key_order.append(key)
_promoted_debugger_keys[key] = occurrences
_debugger_promoted_total += 1
var promoted := entry.duplicate(true)
promoted["_debugger_key"] = key
promoted["_debugger_occurrences"] = occurrences
promoted["_debugger_sequence"] = _debugger_promoted_total
promoted["_debugger_synthetic"] = true
_remove_promoted_debugger_entry(key)
_promoted_debugger_entries.append(promoted)
_trim_promoted_debugger_entries()
_trim_promoted_debugger_key_counts()
func watermark(force_debugger_scan: bool = false) -> Dictionary:
refresh_debugger_errors(force_debugger_scan)
return {
"run_seq": _run_seq,
"editor_ring": _error_appended_total(),
"debugger_promoted": _debugger_promoted_total,
"game_error_warn": _game_error_total(),
}
static func stamp_watermark(response: Dictionary, tracker) -> void:
if tracker == null:
return
if not tracker.has_method("watermark"):
return
response["error_watermark"] = tracker.watermark()
func debugger_promoted_total(force_debugger_scan: bool = true) -> int:
refresh_debugger_errors(force_debugger_scan)
return _debugger_promoted_total
func collect_editor_log_entries() -> Array[Dictionary]:
refresh_debugger_errors(true)
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
if _editor_log_buffer != null:
for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()):
seen_keys[_log_entry_key(entry)] = true
entries.append(entry)
for entry in read_debugger_error_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
## #645: synthesized break records have no live Errors-tab row to scrape —
## merge them from the promoted list so logs_read(source="editor") shows
## the record that run/game responses point at.
for entry in _promoted_debugger_entries:
if not bool(entry.get("_debugger_synthetic", false)):
continue
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(_strip_promotion_bookkeeping(entry))
return entries
static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary:
var clean := entry.duplicate(true)
for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]:
clean.erase(key)
return clean
func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary:
refresh_debugger_errors(force_debugger_scan)
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
var truncated := false
if _editor_log_buffer != null:
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1)
truncated = bool(captured.get("truncated", false))
for entry in captured.get("entries", []):
seen_keys[_log_entry_key(entry)] = true
entries.append(entry)
if debugger_cursor < _oldest_retained_debugger_sequence - 1:
truncated = true
for entry in _promoted_debugger_entries:
if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor:
continue
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return {
"entries": entries,
"truncated": truncated,
}
func retained_recent_editor_entries() -> Array[Dictionary]:
## There is no shared timestamp across the editor logger ring and Godot's
## Debugger Errors tree. Preserve the pre-PR fallback contract: newest
## buffered editor entries first, then debugger-only rows that were not in
## the ring, so stale Debugger rows cannot outrank newer ring entries.
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
if _editor_log_buffer != null:
entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count())
entries.reverse()
for entry in entries:
seen_keys[_log_entry_key(entry)] = true
for entry in collect_editor_log_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return entries
func read_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var seen_keys: Dictionary = {}
for entry in _raw_debugger_error_entries():
var key := _log_entry_key(entry)
if seen_keys.has(key):
continue
seen_keys[key] = true
entries.append(entry)
return entries
func locate_debugger_error_trees() -> Array[Tree]:
var trees: Array[Tree] = []
var root: Node = _debugger_errors_root
## #641: a deferred-scan timer can outlive an injected root (tests,
## teardown). A freed root must not fall through to the live editor UI —
## that would promote unrelated real errors into a tracker scoped to the
## dead root — so treat it as "nothing to scan".
if root != null and not is_instance_valid(root):
return trees
if root == null:
root = _debugger_search_root()
if root == null:
return trees
_collect_debugger_error_trees(root, trees)
return trees
func clear_debugger_error_trees() -> int:
var cleared := 0
for tree in locate_debugger_error_trees():
cleared += entries_from_debugger_error_tree(tree).size()
if not _press_debugger_clear_button(tree):
## Synthetic roots in tests do not have Godot's Clear button.
tree.clear()
return cleared
func _debugger_search_root() -> Node:
if is_instance_valid(_debugger_search_root_cache):
return _debugger_search_root_cache
_debugger_search_root_cache = null
var base := EditorInterface.get_base_control()
if base == null:
return null
_debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode")
if _debugger_search_root_cache == null:
return base
return _debugger_search_root_cache
static func _find_first_of_class(node: Node, klass: String) -> Node:
if node.get_class() == klass:
return node
for child in node.get_children():
var found := _find_first_of_class(child, klass)
if found != null:
return found
return null
static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void:
if node is Tree and _tree_has_debugger_errors(node as Tree):
out.append(node as Tree)
for child in node.get_children():
if child is Node:
_collect_debugger_error_trees(child as Node, out)
static func _tree_has_debugger_errors(tree: Tree) -> bool:
var root := tree.get_root()
if root == null:
return false
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
return true
item = item.get_next()
return false
static func _press_debugger_clear_button(tree: Tree) -> bool:
var parent := tree.get_parent()
if parent == null:
return false
var stack: Array[Node] = [parent]
while not stack.is_empty():
var node: Node = stack.pop_back()
if node is BaseButton:
for conn in node.get_signal_connection_list("pressed"):
if str(conn.get("callable", "")).contains("_clear_errors_list"):
node.emit_signal("pressed")
return true
for child in node.get_children():
stack.push_back(child)
return false
static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]:
var entries: Array[Dictionary] = []
var root := tree.get_root()
if root == null:
return entries
var item := root.get_first_child()
while item != null:
if _is_debugger_error_item(item):
entries.append(_entry_from_debugger_error_item(item))
item = item.get_next()
return entries
static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary:
var title := item.get_text(1)
var loc := _location_from_metadata(item.get_metadata(0))
var function := _function_from_title(title)
return {
"source": "editor",
"level": "warn" if item.has_meta("_is_warning") else "error",
"text": title,
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
"details": _details_from_debugger_error_item(item, loc, function),
}
static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary:
var children: Array[Dictionary] = []
var child := item.get_first_child()
while child != null:
var child_loc := _location_from_metadata(child.get_metadata(0))
children.append({
"label": child.get_text(0),
"text": child.get_text(1),
"path": str(child_loc.get("path", "")),
"line": int(child_loc.get("line", 0)),
})
child = child.get_next()
return {
"debugger_tab": "Errors",
"time": item.get_text(0),
"message": item.get_text(1),
"error_type_name": "warning" if item.has_meta("_is_warning") else "error",
"source": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"resolved": {
"path": str(loc.get("path", "")),
"line": int(loc.get("line", 0)),
"function": function,
},
"children": children,
"frames": _frames_from_error_children(children),
}
static func _is_debugger_error_item(item: TreeItem) -> bool:
return item.has_meta("_is_warning") or item.has_meta("_is_error")
static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]:
var start := -1
for i in children.size():
if str(children[i].label).contains("Stack Trace"):
start = i
break
if start < 0:
for i in children.size():
if str(children[i].label).is_empty() and not str(children[i].path).is_empty():
start = maxi(i - 1, 0)
break
if start < 0:
return []
var frames: Array[Dictionary] = []
for i in range(start, children.size()):
if str(children[i].path).is_empty():
continue
frames.append({
"path": children[i].path,
"line": children[i].line,
"function": _function_from_frame_text(children[i].text),
})
return frames
static func _location_from_metadata(meta: Variant) -> Dictionary:
if meta is Array and meta.size() >= 2:
return {"path": str(meta[0]), "line": int(meta[1])}
return {"path": "", "line": 0}
static func _function_from_title(title: String) -> String:
var colon := title.find(": ")
if colon <= 0:
return ""
return title.substr(0, colon)
static func _function_from_frame_text(text: String) -> String:
var marker := text.find(" @ ")
if marker < 0:
return ""
var fn := text.substr(marker + 3).strip_edges()
if fn.ends_with("()"):
fn = fn.substr(0, fn.length() - 2)
return fn
## Shared one-line rendering of a compact editor-error entry for messages and
## hints ("text (path:line)"). Single home so the debugger plugin, project
## handler, and editor handler can't drift apart.
static func format_editor_error_summary(entry: Dictionary) -> String:
var text := str(entry.get("text", "editor error"))
var path := str(entry.get("path", ""))
var line := int(entry.get("line", 0))
if not path.is_empty() and line > 0:
return "%s (%s:%d)" % [text, path, line]
if not path.is_empty():
return "%s (%s)" % [text, path]
return text
static func _log_entry_key(entry: Dictionary) -> String:
return "%s|%s|%s|%s" % [
str(entry.get("level", "")),
str(entry.get("text", "")),
str(entry.get("path", "")),
str(entry.get("line", 0)),
]
func _error_appended_total() -> int:
if _editor_log_buffer == null:
return 0
if _editor_log_buffer.has_method("error_appended_total"):
return int(_editor_log_buffer.call("error_appended_total"))
return 0
func _game_error_total() -> int:
if _game_log_buffer == null:
return 0
if _game_log_buffer.has_method("error_total"):
return int(_game_log_buffer.call("error_total"))
return 0
func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool:
if not _debugger_scan_active and now_msec > _debugger_scan_until_msec:
return false
return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS
func _trim_promoted_debugger_entries() -> void:
while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES:
_promoted_debugger_entries.pop_front()
if _promoted_debugger_entries.is_empty():
_oldest_retained_debugger_sequence = _debugger_promoted_total + 1
else:
_oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1))
func _trim_promoted_debugger_key_counts() -> void:
while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS:
var key := _promoted_debugger_key_order.pop_front()
_promoted_debugger_keys.erase(key)
func _remove_promoted_debugger_entry(key: String) -> void:
for i in range(_promoted_debugger_entries.size() - 1, -1, -1):
if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key:
_promoted_debugger_entries.remove_at(i)
return
func _raw_debugger_error_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for tree in locate_debugger_error_trees():
entries.append_array(entries_from_debugger_error_tree(tree))
return entries
@@ -0,0 +1 @@
uid://o0ulahkt83re
+39 -187
View File
@@ -124,13 +124,9 @@ func clear_pending_download() -> void:
_latest_checksum_url = ""
## True when the running Godot can self-update in place. Godot < 4.4 takes
## the `_install_zip_inline` extract-then-restart path, and that engine's
## stricter `GDScript::reload()` (`!p_keep_state && has_instances` ->
## `ERR_ALREADY_IN_USE`) turns the extract-over-live-scripts into a reload
## error flood plus a SIGSEGV in `EditorDockManager::remove_dock` /
## `SceneTree::finalize` on the restart/quit (#475). So on < 4.4 we don't
## run the in-editor pipeline at all — the user updates manually.
## True when the running Godot is within the supported self-update floor.
## Godot < 4.5 must not be offered a one-click update to a release whose
## always-loaded scripts depend on 4.5 APIs/classes.
## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified.
func _can_self_update() -> bool:
var v := Engine.get_version_info()
@@ -138,48 +134,35 @@ func _can_self_update() -> bool:
## Pure version predicate, split out so it's testable without faking the
## running engine. In-editor self-update needs Godot >= 4.4.
## running engine. In-editor self-update needs Godot >= 4.5.
static func _version_can_self_update(major: int, minor: int) -> bool:
return major > 4 or (major == 4 and minor >= 4)
return major > 4 or (major == 4 and minor >= 5)
## Banner guidance for the gated (< 4.4) path. Shown up-front at check time
## (with the available version) and again on click, so the user understands
## the manual-update flow before they press anything. Single source of truth
## so check-time and click-time text never drift.
## Banner guidance for engines below the support floor. Shown up-front at
## check time so those users do not install an incompatible latest release.
static func _manual_update_label(version: String) -> String:
var prefix := "Update available"
var release_noun := "release"
var suffix := ""
if not version.is_empty():
prefix = "Update v%s available" % version
release_noun = "version"
suffix = " (latest: v%s)" % version
return (
prefix
+ " — in-editor update needs Godot 4.4+. Open the download page, then "
+ "replace addons/godot_ai/ manually and relaunch."
"This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix]
+ "Upgrade to Godot 4.5+ to keep receiving updates."
)
## Driven by the dock's Update button. On Godot < 4.4 (see `_can_self_update`)
## the in-editor install is disabled — we open the release page for a manual
## download instead, never entering the extract pipeline that crashes those
## engines. With no resolved download URL — either the check never completed,
## or the release didn't ship a matching asset — also falls back to opening
## the release page. Otherwise kicks off the download → extract → reload
## pipeline.
## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update)
## the in-editor install is disabled so users cannot install an incompatible
## latest release. With no resolved download URL, falls back to opening the
## release page. Otherwise kicks off the download -> extract -> reload pipeline.
func start_install() -> void:
if not _can_self_update():
## Only claim success + lock the button if the browser actually opened.
## On failure (no handler, headless) keep the button enabled so the
## user can retry. Either way, leave the version-bearing guidance label
## from check time in place — don't re-emit label_text.
if OS.shell_open(RELEASES_PAGE) == OK:
install_state_changed.emit({
"button_text": "Opened download page",
"button_text": "Upgrade Godot",
"button_disabled": true,
})
else:
install_state_changed.emit({
"button_text": "Couldn't open browser — retry",
"button_disabled": false,
"label_text": _manual_update_label(""),
"banner_visible": true,
})
return
@@ -232,7 +215,6 @@ func start_install() -> void:
"button_disabled": false,
})
## Consulted by the dock's spawn paths (focus-in refresh, manual button,
## deferred initial refresh) — true while plugin scripts are being
## overwritten. A worker mid-`GDScriptFunction::call` into a half-
@@ -314,6 +296,8 @@ static func _is_trusted_download_url(url: String) -> bool:
const SCHEME := "https://"
if not url.begins_with(SCHEME):
return false
if url.find("\\") >= 0:
return false
var rest := url.substr(SCHEME.length())
var authority := rest
var slash := rest.find("/")
@@ -353,17 +337,17 @@ func _on_update_check_completed(
var parsed := parse_releases_response(result, response_code, body)
if not bool(parsed.get("has_update", false)):
return
if not _can_self_update():
install_state_changed.emit({
"button_text": "Upgrade Godot",
"button_disabled": true,
"label_text": _manual_update_label(String(parsed.get("version", ""))),
"banner_visible": true,
})
return
_latest_download_url = String(parsed.get("download_url", ""))
_latest_checksum_url = String(parsed.get("checksum_url", ""))
update_check_completed.emit(parsed)
## On engines that can't self-update (Godot < 4.4, #475), surface the
## full manual-update guidance AND relabel the button up-front — before
## any click — so the user knows what the button does and why.
if not _can_self_update():
install_state_changed.emit({
"button_text": "Open download page",
"label_text": _manual_update_label(String(parsed.get("version", ""))),
})
func _on_download_completed(
@@ -457,7 +441,7 @@ func _on_checksum_completed(
print("MCP | self-update checksum verified (sha256 %s)" % actual)
install_state_changed.emit({"button_text": "Installing..."})
_install_zip()
_install_zip.call_deferred()
## Surface an integrity-check failure and drop the staged zip so the bad
@@ -483,8 +467,9 @@ static func _parse_sha256_digest(text: String) -> String:
if trimmed.is_empty():
return ""
## First whitespace-delimited token; `sha256sum` separates digest and
## filename with two spaces, so allow_empty=false collapses the run.
var tokens := trimmed.split(" ", false)
## filename with two spaces, but some tools use tabs.
var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ")
var tokens := normalized.split(" ", false)
if tokens.is_empty():
return ""
var digest := String(tokens[0]).strip_edges().to_lower()
@@ -512,167 +497,34 @@ func _install_zip() -> void:
return
## Drain in-flight workers + block new ones BEFORE any disk write.
## Without this, focus-in landing in the extractreload window spawns
## Without this, focus-in landing in the extract -> reload window spawns
## a worker that walks into a partially-overwritten script and
## SIGABRTs in `GDScriptFunction::call`.
_install_in_flight = true
_drain_dock_workers()
var version := Engine.get_version_info()
var has_runner: bool = (
_plugin != null
and _plugin.has_method("install_downloaded_update")
)
## Same major-aware predicate as the _can_self_update() gate, so a future
## Godot 5.x (minor 0) takes the runner path the gate promised — not the
## pre-4.4 inline extract. A bare `minor >= 4` here would route 5.0 to the
## crash-prone inline path even though the gate let it in.
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))) and has_runner:
if has_runner:
install_state_changed.emit({"button_text": "Reloading..."})
## Runner takes over: plugin tears down, runner extracts + scans +
## re-enables. `install_downloaded_update` calls
## `prepare_for_update_reload()` internally (kills the server,
## resets the spawn guard) see plugin.gd::install_downloaded_update.
## resets the spawn guard) - see plugin.gd::install_downloaded_update.
_plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock)
return
_install_zip_inline(version)
func _install_zip_inline(version: Dictionary) -> void:
## Pre-4.4 fallback. EditorInterface.set_plugin_enabled off/on is
## re-entry-unsafe on older Godot; we extract in-process and ask the
## user to restart.
var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)
var install_base := ProjectSettings.globalize_path("res://")
var reader := ZIPReader.new()
if reader.open(zip_path) != OK:
_install_in_flight = false
install_state_changed.emit({
"button_text": "Extract failed",
"button_disabled": false,
})
return
var files := reader.get_files()
for file_path in files:
if not file_path.begins_with("addons/godot_ai/"):
continue
## Skip zip dir entries; parent dirs are created from each validated
## file's base dir below — the same shape the runner uses. Creating a
## dir from an unvalidated entry would itself be a traversal hole.
if file_path.ends_with("/"):
continue
## Reject path-traversal / absolute / backslash entries BEFORE any
## path_join + write. The modern runner enforces this via
## `update_reload_runner.gd::_is_safe_zip_addon_file`; the pre-4.4
## inline path used to gate only on the `addons/godot_ai/` prefix, so
## `addons/godot_ai/../../evil.gd` escaped the addon dir. This guard
## closes that gap so the weaker path runs the same checks. See #522.
if not _is_safe_zip_addon_file(file_path):
_abort_inline_install(reader, "unsafe zip path: %s" % file_path)
return
var dir := file_path.get_base_dir()
DirAccess.make_dir_recursive_absolute(install_base.path_join(dir))
var content := reader.read_file(file_path)
var target := install_base.path_join(file_path)
var f := FileAccess.open(target, FileAccess.WRITE)
## Unlike the runner (tmp+rename+per-file backup+rollback), this pre-4.4
## path writes directly over live files and can't roll back. It used to
## skip a null open and ignore store_buffer errors silently, leaving a
## partially-overwritten addons tree while still telling the user to
## restart onto it. Check both error surfaces and abort loudly instead.
## See #524.
if f == null:
_abort_inline_install(
reader,
"could not open %s for write (error %d)" % [target, FileAccess.get_open_error()],
)
return
f.store_buffer(content)
var write_error := f.get_error()
f.close()
if write_error != OK:
_abort_inline_install(reader, "write error %d for %s" % [write_error, target])
return
reader.close()
DirAccess.remove_absolute(zip_path)
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP))
DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR))
## Kill the old server before the reload so the re-enabled plugin spawns
## a fresh one against the new plugin version (#132).
if _plugin != null and _plugin.has_method("prepare_for_update_reload"):
_plugin.prepare_for_update_reload()
if _version_can_self_update(int(version.get("major", 0)), int(version.get("minor", 0))):
install_state_changed.emit({"button_text": "Scanning..."})
## Filesystem scan must complete before plugin reload — otherwise
## plugin.gd re-parses against a ClassDB that hasn't seen the new
## files yet, parse errors, dock tears down silently. See #127.
var fs := EditorInterface.get_resource_filesystem()
if fs != null:
fs.filesystem_changed.connect(
_on_filesystem_scanned_for_update, CONNECT_ONE_SHOT
)
fs.scan()
else:
_reload_after_update.call_deferred()
else:
## Pre-4.4: no plugin reload; refreshes resume on the old dock
## instance until the user restarts.
_install_in_flight = false
install_state_changed.emit({
"button_text": "Restart editor to apply",
"button_disabled": true,
"label_text": "Updated! Restart the editor.",
"outcome": "success",
})
## Abort the inline (pre-4.4) extract on a path-safety or write failure.
## Closes the ZIP reader, drops the in-flight gate so dock spawn paths
## un-block, and surfaces the failure loudly: this path has no rollback, so
## the addons tree may be partially overwritten and the user must reinstall
## from the download page rather than relaunch onto a half-written plugin.
## See #522 / #524.
func _abort_inline_install(reader: ZIPReader, reason: String) -> void:
reader.close()
_install_in_flight = false
push_error(
"MCP | self-update extract failed: %s. addons/godot_ai/ may be"
% reason
+ " partially updated — reinstall the plugin from the download page"
+ " before relaunching."
)
print("MCP | self-update extract aborted: %s" % reason)
install_state_changed.emit({
"button_text": "Extract failed — reinstall",
"button_text": "Reload runner missing",
"button_disabled": false,
})
## Mirror of `update_reload_runner.gd::_is_safe_zip_addon_file`. Rejects any
## entry that could escape `addons/godot_ai/` — absolute paths, backslashes,
## and `.`/`..`/empty path segments — before it reaches a `path_join` + write
## on the inline (pre-4.4) extract path, which has no rollback. Static so the
## guard is unit-testable without instancing the manager. See #522.
static func _is_safe_zip_addon_file(file_path: String) -> bool:
if file_path.is_absolute_path() or file_path.contains("\\"):
return false
if not file_path.begins_with("addons/godot_ai/"):
return false
var rel_path := file_path.trim_prefix("addons/godot_ai/")
if rel_path.is_empty() or rel_path.ends_with("/"):
return false
for segment in rel_path.split("/", true):
if segment.is_empty() or segment == "." or segment == "..":
return false
return true
func _on_filesystem_scanned_for_update() -> void:
install_state_changed.emit({"button_text": "Reloading..."})
_reload_after_update.call_deferred()
+15 -1
View File
@@ -1,7 +1,21 @@
{
"latest_version": "2.4.6",
"latest_version": "2.4.7",
"minimum_app_version": "2.1.0",
"releases": [
{
"version": "2.4.7",
"date": "2026-07-10",
"pck_url": "https://git.klud.top/danchie/tekton/raw/branch/patches/patch.pck",
"pck_size": 0,
"changelog": [
"Fixed `api_rpc_async` returning null or retrying indefinitely when a Nakama session expires, cleanly catching null sessions without crashing.",
"Fixed `AuthManager.logout()` failing to invalidate Nakama sessions on the backend.",
"Fixed numerous offline networking crashes across `main.gd`, `player.gd`, `player_action_manager.gd`, and `tekton.gd` where `multiplayer.get_unique_id()` or `multiplayer.is_server()` was called during teardown or disconnected states.",
"Fixed `on_thrown_landing` throwing C++ errors when knocked offline by guarding `get_tree()` calls.",
"Changed Candy Survival knock from instant-ambush to **toggle-and-walk mode** like other game modes.",
"Fixed `Attempt to call RPC with unknown peer ID: 5` crash in Candy Survival."
]
},
{
"version": "2.4.6",
"date": "2026-07-09",
+21
View File
@@ -0,0 +1,21 @@
codegen
=======
> A util tool to generate a client from the Swagger spec of Nakama's server API.
## Usage
```shell
go run main.go "$GOPATH/src/github.com/heroiclabs/nakama/apigrpc/apigrpc.swagger.json" > ../addons/com.heroiclabs.nakama/api/NakamaAPI.gd
```
### Rationale
We want to maintain a simple lean low level client within our GDScript client which has minimal dependencies so we built our own. This gives us complete control over the dependencies required and structure of the code generated.
The generated code is designed to be supported Godot Engine `3.1+`.
### Limitations
The code generator has __only__ been checked against the Swagger specification generated for Nakama server. YMMV.
+639
View File
@@ -0,0 +1,639 @@
// Copyright 2018 The Nakama Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"os"
"strings"
"text/template"
)
var utilities = map[string]string {
"ApiAccount":
`
var _wallet_dict = null
var wallet_dict : Dictionary:
get:
if _wallet_dict == null:
if _wallet == null:
return {}
var json = JSON.new()
if json.parse(_wallet) != OK:
return {}
_wallet_dict = json.get_data()
return _wallet_dict as Dictionary
`,
}
const codeTemplate string = `### Code generated by codegen/main.go. DO NOT EDIT. ###
extends RefCounted
class_name NakamaAPI
{{- range $defname, $definition := .Definitions }}
{{- $classname := $defname | title }}
{{- if isRefToEnum $classname }}
# {{ enumSummary $definition | stripNewlines }}
{{- range $idx, $val := ($definition | enumDescriptions) }}
# {{ $val }}
{{- end -}}
# {{ $definition | enumDescriptions }}
enum {{ $classname | title }} { {{- range $idx, $enum := $definition.Enum }}{{ $enum }} = {{ $idx }},{{- end -}} }
{{- else }}
# {{ $definition.Description | stripNewlines }}
class {{ $classname }} extends NakamaAsyncResult:
const _SCHEMA = {
{{- range $propname, $property := $definition.Properties }}
{{- $fieldname := $propname | pascalToSnake }}
{{- $_field := printf "_%s" $fieldname }}
{{- $gdType := godotType $property.Type $property.Ref $property.Items.Type $property.Items.Ref (isRefToEnum (cleanRef $property.Ref)) }}
"{{ $fieldname }}": {"name": "{{ $_field }}", "type": {{ $gdType | godotSchemaType }}, "required": false
{{- if eq $property.Type "array" -}}
, "content": {{ (godotType $property.Items.Type $property.Items.Ref "" "" false) | godotSchemaType }}
{{- else if eq $property.Type "object" -}}
, "content": {{ (godotType $property.AdditionalProperties.Type "" "" "" false) | godotSchemaType }}
{{- end -}}
},
{{- end }}
}
{{- range $propname, $property := $definition.Properties }}
{{- $fieldname := $propname | pascalToSnake }}
{{- $_field := printf "_%s" $fieldname }}
{{- $gdType := godotType $property.Type $property.Ref $property.Items.Type $property.Items.Ref (isRefToEnum (cleanRef $property.Ref)) }}
{{- $gdDef := $gdType | godotDef }}
# {{ $property.Description }}
var {{ $_field }}
var {{ $fieldname }} : {{ $gdType }}:
get:
{{- if $property.Ref }}
{{- if isRefToEnum (cleanRef $property.Ref) }}{{/* Enums */}}
return {{ cleanRef $property.Ref }}.values()[0] if not {{ cleanRef $property.Ref }}.values().has({{ $_field }}) else {{ $_field }}
{{- else }}{{/* Object reference */}}
return _{{ $fieldname }} as {{ $gdType }}
{{- end }}
{{- else if eq $property.Type "object"}}{{/* Dictionaries */}}
return Dictionary() if not {{ $_field }} is Dictionary else {{ $_field }}.duplicate()
{{- else }}{{/* Simple type */}}
return {{ $gdDef }} if not {{ $_field }} is {{ $gdType }} else {{ $gdType }}({{ $_field }})
{{- end }}
{{- end }}
{{- godotClassUtils $classname }}
func _init(p_exception = null):
super(p_exception)
static func create(p_ns : GDScript, p_dict : Dictionary) -> {{ $classname }}:
return _safe_ret(NakamaSerializer.deserialize(p_ns, "{{ $classname }}", p_dict), {{ $classname }}) as {{ $classname }}
func serialize() -> Dictionary:
return NakamaSerializer.serialize(self)
func _to_string() -> String:
if is_exception():
return get_exception()._to_string()
var output : String = ""
{{- range $propname, $property := $definition.Properties }}
{{- $fieldname := $propname | pascalToSnake }}
{{- $_field := printf "_%s" $fieldname }}
{{- if eq $property.Type "array" }}
output += "{{ $fieldname }}: %s, " % [{{ $_field }}]
{{- else if eq $property.Type "object" }}
var map_string : String = ""
if typeof({{ $_field }}) == TYPE_DICTIONARY:
for k in {{ $_field }}:
map_string += "{%s=%s}, " % [k, {{ $_field }}[k]]
output += "{{ $fieldname }}: [%s], " % map_string
{{- else }}
output += "{{ $fieldname }}: %s, " % {{ $_field }}
{{- end }}
{{- end }}
return output
{{- end }}
{{- end }}
# The low level client for the Nakama API.
class ApiClient extends RefCounted:
var _base_uri : String
var _http_adapter
var _namespace : GDScript
var _server_key : String
var auto_refresh := true
var auto_refresh_time := 300
var auto_retry : bool:
set(p_value):
_http_adapter.auto_retry = p_value
get:
return _http_adapter.auto_retry
var auto_retry_count : int:
set(p_value):
_http_adapter.auto_retry_count = p_value
get:
return _http_adapter.auto_retry_count
var auto_retry_backoff_base : int:
set(p_value):
_http_adapter.auto_retry_backoff_base = p_value
get:
return _http_adapter.auto_retry_backoff_base
var last_cancel_token:
get:
return _http_adapter.get_last_token()
func _init(p_base_uri : String, p_http_adapter, p_namespace : GDScript, p_server_key : String, p_timeout : int = 10):
_base_uri = p_base_uri
_http_adapter = p_http_adapter
_http_adapter.timeout = p_timeout
_namespace = p_namespace
_server_key = p_server_key
func _refresh_session(p_session : NakamaSession):
if auto_refresh and p_session.is_valid() and p_session.refresh_token and not p_session.is_refresh_expired() and p_session.would_expire_in(auto_refresh_time):
var request = ApiSessionRefreshRequest.new()
request._token = p_session.refresh_token
return await session_refresh_async(_server_key, "", request)
return null
func cancel_request(p_token):
if p_token:
_http_adapter.cancel_request(p_token)
{{- range $url, $path := .Paths }}
{{- range $method, $operation := $path}}
# {{ $operation.Summary | stripNewlines }}
{{- if $operation.Responses.Ok.Schema.Ref }}
func {{ $operation.OperationId | apiFuncName }}_async(
{{- else }}
func {{ $operation.OperationId | apiFuncName }}_async(
{{- end}}
{{- if $operation.Security }}
{{- with (index $operation.Security 0) }}
{{- range $key, $value := . }}
{{- if eq $key "BasicAuth" }}
p_basic_auth_username : String
, p_basic_auth_password : String
{{- else if eq $key "HttpKeyAuth" }}
p_bearer_token : String
{{- end }}
{{- end }}
{{- end }}
{{- else }}
p_session : NakamaSession
{{- end }}
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- if not $parameter.Required }}{{/* Godot does not support typed optional parameters yet. */}}
, {{ $argument }} = null # : {{ $parameter.Type }}
{{- else if eq $parameter.In "body" }}
{{- if eq $parameter.Schema.Type "string" }}
, {{ $argument }} : String
{{- else }}
, {{ $argument }} : {{ $parameter.Schema.Ref | cleanRef }}
{{- end }}
{{- else }}
, {{ $argument }} : {{ godotType $parameter.Type $parameter.Schema.Ref $parameter.Items.Type "" (isRefToEnum (cleanRef $parameter.Schema.Ref)) }}
{{- end }}
{{- end }}
)
{{- if $operation.Responses.Ok.Schema.Ref }} -> {{ $operation.Responses.Ok.Schema.Ref | cleanRef }}
{{- else }} -> NakamaAsyncResult
{{- end }}:
{{- $classname := "NakamaAsyncResult" }}
{{- if $operation.Responses.Ok.Schema.Ref }}
{{- $classname = $operation.Responses.Ok.Schema.Ref | cleanRef }}
{{- end }}
{{- if not $operation.Security }}
var try_refresh = await _refresh_session(p_session)
if try_refresh != null:
if try_refresh.is_exception():
return {{ $classname }}.new(try_refresh.get_exception())
await p_session.refresh(try_refresh)
{{- end }}
var urlpath : String = "{{- $url }}"
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- if eq $parameter.In "path" }}
urlpath = urlpath.replace("{{- print "{" $parameter.Name "}"}}", NakamaSerializer.escape_http({{ $argument }}))
{{- end }}
{{- end }}
var query_params = ""
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- $snakecase := $parameter.Name | pascalToSnake }}
{{- if eq $parameter.In "query"}}
{{- if $parameter.Required }}
if true: # Hack for static checks
{{- else }}
if {{ $argument }} != null:
{{- end }}
{{- if eq $parameter.Type "integer" }}
query_params += "{{- $snakecase }}=%d&" % {{ $argument }}
{{- else if eq $parameter.Type "string" }}
query_params += "{{- $snakecase }}=%s&" % NakamaSerializer.escape_http({{ $argument }})
{{- else if eq $parameter.Type "boolean" }}
query_params += "{{- $snakecase }}=%s&" % str(bool({{ $argument }})).to_lower()
{{- else if eq $parameter.Type "array" }}
for elem in {{ $argument }}:
query_params += "{{- $snakecase }}=%s&" % elem
{{- else }}
{{ $parameter }} // ERROR
{{- end }}
{{- end }}
{{- end }}
var uri = "%s%s%s" % [_base_uri, urlpath, "?" + query_params if query_params else ""]
var method = "{{- $method | uppercase }}"
var headers = {}
{{- if $operation.Security }}
{{- with (index $operation.Security 0) }}
{{- range $key, $value := . }}
{{- if eq $key "BasicAuth" }}
var credentials = Marshalls.utf8_to_base64(p_basic_auth_username + ":" + p_basic_auth_password)
var header = "Basic %s" % credentials
headers["Authorization"] = header
{{- else if eq $key "HttpKeyAuth" }}
if (p_bearer_token):
var header = "Bearer %s" % p_bearer_token
headers["Authorization"] = header
{{- end }}
{{- end }}
{{- end }}
{{- else }}
var header = "Bearer %s" % p_session.token
headers["Authorization"] = header
{{- end }}
var content : PackedByteArray
{{- range $parameter := $operation.Parameters }}
{{- $argument := $parameter.Name | prependParameter }}
{{- if eq $parameter.In "body" }}
{{- if eq $parameter.Schema.Type "string" }}
content = JSON.stringify({{ $argument }}).to_utf8_buffer()
{{- else }}
content = JSON.stringify({{ $argument }}.serialize()).to_utf8_buffer()
{{- end }}
{{- end }}
{{- end }}
var result = await _http_adapter.send_async(method, uri, headers, content)
if result is NakamaException:
return {{ $classname }}.new(result)
{{- if $operation.Responses.Ok.Schema.Ref }}
var out : {{ $classname }} = NakamaSerializer.deserialize(_namespace, "{{ $classname }}", result)
return out
{{- else }}
return NakamaAsyncResult.new()
{{- end}}
{{- end }}
{{- end }}
`
func convertRefToClassName(input string) (className string) {
cleanRef := strings.TrimPrefix(input, "#/definitions/")
className = strings.Title(cleanRef)
return
}
func stripNewlines(input string) (output string) {
output = strings.Replace(input, "\n", " ", -1)
return
}
func prependParameter(input string) (output string) {
output = "p_" + pascalToSnake(input)
return
}
func pascalToSnake(input string) (output string) {
output = ""
prev_low := false
for _, v := range input {
is_cap := v >= 'A' && v <= 'Z'
is_low := v >= 'a' && v <= 'z'
if is_cap && prev_low {
output = output + "_"
}
output += strings.ToLower(string(v))
prev_low = is_low
}
return
}
func apiFuncName(input string) (output string) {
output = pascalToSnake(input[7:])
return
}
func godotType(p_type string, p_ref string, p_item_type string, p_extra string, p_is_enum bool) (out string) {
is_array := false
is_dict := false
switch p_type {
case "integer":
out = "int"
case "string":
out = "String"
case "boolean":
out = "bool"
case "array":
is_array = true
case "object":
is_dict = true
default:
if p_is_enum {
out = "int"
} else {
out = convertRefToClassName(p_ref)
}
}
if is_array {
switch p_item_type {
case "integer":
out = "PackedIntArray"
return
case "string":
out = "PackedStringArray"
return
case "boolean":
out = "PackedIntArray"
return
default:
out = "Array"
}
}
if is_dict {
out = "Dictionary"
}
return
}
func godotDef(p_type string) (out string) {
switch(p_type) {
case "bool": out = "false"
case "int": out = "0"
case "String": out = "\"\""
case "PackedIntArray": out = "PackedIntArray()"
case "PackedStringArray": out = "PackedStringArray()"
case "Array": out = "Array()"
case "Dictionary": out = "Dictionary()"
}
return
}
func godotLooseType(p_type string) (out string) {
switch(p_type) {
case "PackedStringArray", "PackedIntArray":
out = "Array"
default:
out = p_type
}
return
}
func godotSchemaType(p_type string) (out string) {
out = "TYPE_"
switch(p_type) {
case "bool": out += "BOOL"
case "int": out += "INT"
case "String": out += "STRING"
case "PackedIntArray": out += "ARRAY"
case "PackedStringArray": out += "ARRAY"
case "Array": out += "ARRAY"
case "Dictionary": out += "DICTIONARY"
default: out = "\"" + p_type + "\""
}
return
}
func pascalToCamel(input string) (camelCase string) {
if input == "" {
return ""
}
camelCase = strings.ToLower(string(input[0]))
camelCase += string(input[1:])
return camelCase
}
func camelToPascal(camelCase string) (pascalCase string) {
if len(camelCase) <= 0 {
return ""
}
pascalCase = strings.ToUpper(string(camelCase[0])) + camelCase[1:]
return
}
func enumSummary(def Definition) string {
// quirk of swagger generation: if enum doesn't have a title
// then the title can be found as the first entry in the split description.
if def.Title != "" {
return def.Title
}
split := strings.Split(def.Description, "\n")
if len(split) <= 0 {
panic("No newlines in enum description found.")
}
return split[0]
}
func enumDescriptions(def Definition) (output []string) {
split := strings.Split(def.Description, "\n")
if len(split) <= 0 {
panic("No newlines in enum description found.")
}
if def.Title != "" {
return split
}
// quirk of swagger generation: if enum doesn't have a title
// then the title can be found as the first entry in the split description.
// so ignore for individual enum descriptions.
return split[2:]
}
type Definition struct {
Properties map[string]struct {
Type string
Ref string `json:"$ref"` // used with object
Items struct { // used with type "array"
Type string
Ref string `json:"$ref"`
}
AdditionalProperties struct {
Type string // used with type "map"
}
Format string // used with type "boolean"
Description string
}
Enum []string
Description string
// used only by enums
Title string
}
func godotClassUtils(p_name string) string {
if val, ok := utilities[p_name]; ok {
return val
}
return ""
}
func main() {
// Argument flags
var output = flag.String("output", "", "The output for generated code.")
flag.Parse()
inputs := flag.Args()
if len(inputs) < 1 {
fmt.Printf("No input file found: %s\n\n", inputs)
fmt.Println("openapi-gen [flags] inputs...")
flag.PrintDefaults()
return
}
input := inputs[0]
content, err := ioutil.ReadFile(input)
if err != nil {
fmt.Printf("Unable to read file: %s\n", err)
return
}
var schema struct {
Paths map[string]map[string]struct {
Summary string
OperationId string
Responses struct {
Ok struct {
Schema struct {
Ref string `json:"$ref"`
}
} `json:"200"`
}
Parameters []struct {
Name string
In string
Required bool
Type string // used with primitives
Items struct { // used with type "array"
Type string
}
Schema struct { // used with http body
Type string
Ref string `json:"$ref"`
}
Format string // used with type "boolean"
}
Security []map[string][]struct {
}
}
Definitions map[string]Definition
}
if err := json.Unmarshal(content, &schema); err != nil {
fmt.Printf("Unable to decode input %s : %s\n", input, err)
return
}
fmap := template.FuncMap{
"cleanRef": convertRefToClassName,
"stripNewlines": stripNewlines,
"title": strings.Title,
"uppercase": strings.ToUpper,
"prependParameter": prependParameter,
"pascalToSnake": pascalToSnake,
"apiFuncName": apiFuncName,
"godotType": godotType,
"godotLooseType": godotLooseType,
"godotSchemaType": godotSchemaType,
"godotDef": godotDef,
"isRefToEnum": func(ref string) bool {
if len(ref) == 0 {
return false
}
// swagger schema definition keys have inconsistent casing
var camelOk bool
var pascalOk bool
var enums []string
asCamel := pascalToCamel(ref)
if _, camelOk = schema.Definitions[asCamel]; camelOk {
enums = schema.Definitions[asCamel].Enum
}
asPascal := camelToPascal(ref)
if _, pascalOk = schema.Definitions[asPascal]; pascalOk {
enums = schema.Definitions[asPascal].Enum
}
if !pascalOk && !camelOk {
fmt.Printf("no definition found: %v", ref)
return false
}
return len(enums) > 0
},
"enumDescriptions": enumDescriptions,
"enumSummary": enumSummary,
"godotClassUtils": godotClassUtils,
}
tmpl, err := template.New(input).Funcs(fmap).Parse(codeTemplate)
if err != nil {
fmt.Printf("Template parse error: %s\n", err)
return
}
if len(*output) < 1 {
tmpl.Execute(os.Stdout, schema)
return
}
f, err := os.Create(*output)
if err != nil {
fmt.Printf("Unable to create file: %s\n", err)
return
}
defer f.Close()
writer := bufio.NewWriter(f)
tmpl.Execute(writer, schema)
writer.Flush()
}
+4 -4
View File
@@ -14,7 +14,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/windows/tekton_armageddon_v2.4.6.exe"
export_path="build/windows/tekton_armageddon_v2.4.7.exe"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -85,7 +85,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/tekton-dash-armageddon-v.2.4.6.apk"
export_path="build/tekton-dash-armageddon-v.2.4.7.apk"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -314,7 +314,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/tekton_armageddon_v2.4.6.zip"
export_path="build/tekton_armageddon_v2.4.7.zip"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -589,7 +589,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/linux/tekton_armageddon_v2.4.6.x86_64"
export_path="build/linux/tekton_armageddon_v2.4.7.x86_64"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
+1 -1
View File
@@ -14,7 +14,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Tekton Dash Armageddon"
config/name="Tekton Dash"
config/version="2.4.6"
run/main_scene="res://scenes/ui/boot_screen.tscn"
config/features=PackedStringArray("4.6", "Forward Plus")
+22 -16
View File
@@ -63,7 +63,7 @@ func _ready():
# Auto-start game if coming from lobby (already connected to match)
# Works for both Nakama mode and LAN direct mode (ENet).
var is_lan_connected = LobbyManager.is_lan_mode and multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
if (NakamaManager.is_connected_to_nakama() or is_lan_connected) and multiplayer.get_unique_id() != 0:
if multiplayer.has_multiplayer_peer() and (NakamaManager.is_connected_to_nakama() or is_lan_connected) and multiplayer.get_unique_id() != 0:
print("Coming from lobby - auto-starting game...")
await get_tree().process_frame
_auto_start_from_lobby()
@@ -221,7 +221,7 @@ func _setup_effect_elevation():
func _on_goal_count_updated(peer_id: int, count: int):
# Only update for local player
if peer_id == multiplayer.get_unique_id():
if multiplayer.has_multiplayer_peer() and peer_id == multiplayer.get_unique_id():
ui_manager.update_goal_count_label(count)
func _init_managers():
@@ -564,15 +564,16 @@ func _process(delta):
func _on_match_joined(match_id: String):
var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel")
if network_panel:
network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id())
if multiplayer.is_server():
network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id() if multiplayer and multiplayer.has_multiplayer_peer() else 0)
if multiplayer and multiplayer.has_multiplayer_peer() and multiplayer.is_server():
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Server (Match: %s)" % match_id
_setup_host_game()
else:
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client"
_setup_client_game()
else:
if multiplayer.is_server():
# If the UI isn't ready yet, still need to setup logic based on mode
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
_setup_host_game()
else:
_setup_client_game()
@@ -683,7 +684,9 @@ func _spawn_lobby_client_sync(peer_id: int):
func _setup_client_game():
"""Setup client when transitioning from lobby."""
var my_id = multiplayer.get_unique_id()
var my_id = 0
if multiplayer and multiplayer.has_multiplayer_peer():
my_id = multiplayer.get_unique_id()
print("Client setup - my peer ID: ", my_id)
# INITIALIZE ARENA SIZE for Stop n Go locally to prevent out-of-bounds before sync arrives
@@ -766,8 +769,8 @@ func _auto_start_from_lobby():
# Update NetworkPanel in PauseMenu (if exists)
var network_panel = get_node_or_null("PauseMenu/Panel/NetworkPanel")
if network_panel:
network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id())
if multiplayer.is_server():
network_panel.get_node("NetworkInfo/UniquePeerID").text = str(multiplayer.get_unique_id() if multiplayer and multiplayer.has_multiplayer_peer() else 0)
if multiplayer and multiplayer.has_multiplayer_peer() and multiplayer.is_server():
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Host (Match: %s)" % short_id
else:
network_panel.get_node("NetworkInfo/NetworkSideDisplay").text = "Client (Match: %s)" % short_id
@@ -1385,7 +1388,7 @@ func add_player_character(peer_id: int, is_bot: bool = false):
print("[Main] Set player %d name to %s from Lobby data" % [peer_id, p_name])
break
if peer_id == multiplayer.get_unique_id():
if multiplayer.has_multiplayer_peer() and peer_id == multiplayer.get_unique_id():
GameStateManager.local_player_character = player_character
ui_manager.set_local_player(player_character)
if touch_controls:
@@ -1608,7 +1611,7 @@ func sync_playerboard(player_id: int, new_playerboard: Array):
player.playerboard = new_playerboard.duplicate()
# Update UI for local player
if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character:
if multiplayer.has_multiplayer_peer() and player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character:
ui_manager.update_playerboard_ui()
@rpc("any_peer", "call_local", "reliable")
@@ -1628,7 +1631,7 @@ func sync_playerboard_slot(player_id: int, slot_index: int, item_id: int):
return
# Update UI for local player only
if player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character:
if multiplayer.has_multiplayer_peer() and player_id == multiplayer.get_unique_id() and GameStateManager.local_player_character:
ui_manager.update_playerboard_ui()
@@ -1649,7 +1652,7 @@ func verify_all_connections():
@rpc
func connection_verify(expected_players: Array):
for peer_id in expected_players:
if peer_id != multiplayer.get_unique_id() and not has_node(str(peer_id)):
if multiplayer.has_multiplayer_peer() and peer_id != multiplayer.get_unique_id() and not has_node(str(peer_id)):
rpc_id(1, "request_specific_player_data", peer_id)
@rpc("any_peer")
@@ -1721,9 +1724,9 @@ func create_specific_player(data: Dictionary):
# Force collision update
if player_character.has_node("CollisionShape3D"):
player_character.get_node("CollisionShape3D").disabled = false
# Wait for player managers to initialize (player.gd has 0.1s await in _ready)
# Check if this is the local player (client's own player)
var is_local_player = (peer_id == multiplayer.get_unique_id())
var is_local_player = multiplayer.has_multiplayer_peer() and (peer_id == multiplayer.get_unique_id())
if is_local_player and GameStateManager.local_player_character == null:
GameStateManager.local_player_character = player_character
ui_manager.set_local_player(player_character)
@@ -2326,7 +2329,7 @@ func _show_game_over_panel():
canvas_layer.add_child(panel)
# Populate data
var local_peer_id = multiplayer.get_unique_id()
var local_peer_id = multiplayer.get_unique_id() if multiplayer.has_multiplayer_peer() else 0
panel.setup(all_player_scores, local_peer_id)
# --- REPORT NAKAMA MATCH STATS ---
@@ -2351,6 +2354,7 @@ func _show_game_over_panel():
# Connect signals
panel.back_pressed.connect(_on_back_to_menu_pressed)
panel.rematch_pressed.connect(func():
if multiplayer.has_multiplayer_peer():
LobbyManager.request_rematch.rpc(multiplayer.get_unique_id())
)
@@ -2479,7 +2483,9 @@ func _render_leaderboard_entries(sorted_player_data: Array):
if not vbox: vbox = leaderboard_panel.get_node_or_null("VBox")
if not vbox: return
var my_id = multiplayer.get_unique_id()
var my_id = 0
if multiplayer and multiplayer.has_multiplayer_peer():
my_id = multiplayer.get_unique_id()
var my_index = -1
for i in range(sorted_player_data.size()):
if sorted_player_data[i].peer_id == my_id:
+20 -16
View File
@@ -33,7 +33,7 @@ var display_name: String:
# Helper to check network status
func can_rpc() -> bool:
if not multiplayer.has_multiplayer_peer(): return false
if not is_inside_tree() or not multiplayer or not multiplayer.has_multiplayer_peer(): return false
if multiplayer.multiplayer_peer.get_class() == "OfflineMultiplayerPeer": return false
return multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
@@ -81,7 +81,7 @@ var is_charged_strike: bool = false:
_refresh_player_visuals()
# Sync to others if we are the authority
if is_multiplayer_authority() and can_rpc():
if is_inside_tree() and is_multiplayer_authority() and can_rpc() and multiplayer.has_multiplayer_peer():
rpc("sync_charged_strike", is_charged_strike)
@rpc("any_peer", "call_local", "reliable")
@@ -219,8 +219,8 @@ func _ready():
# This prevents "grounding" friction and gravity from fighting our Tweens
motion_mode = CharacterBody3D.MOTION_MODE_FLOATING
print("[Player] initialized: ", name, " Authority: ", get_multiplayer_authority())
# name = str(get_multiplayer_authority()) # CRITICAL FIX: Do NOT overwrite name. Bots have authority 1 but unique names (IDs).
var authority_id = get_multiplayer_authority() if multiplayer and multiplayer.has_multiplayer_peer() else 0
print("[Player] initialized: ", name, " Authority: ", authority_id)
# Look up player's display name from LobbyManager
var my_id = get_multiplayer_authority()
@@ -255,7 +255,7 @@ func _ready():
var mat = ShaderMaterial.new()
mat.shader = pointer_shader
if name == str(multiplayer.get_unique_id()):
if multiplayer and multiplayer.has_multiplayer_peer() and name == str(multiplayer.get_unique_id()):
mat.set_shader_parameter("pointer_color", Color(0.0, 1.0, 0.0, 1.0)) # Green
if has_node("Name"):
$Name.modulate = Color(0.2, 0.8, 0.2, 1.0) # Pleasant Green for Name
@@ -308,7 +308,7 @@ func _ready():
current_position = _find_random_spawn_position()
update_player_position(current_position)
spawn_point_selected = true
if can_rpc():
if can_rpc() and multiplayer.has_multiplayer_peer():
rpc("set_spawn_position", current_position)
rpc("notify_spawn_selected", current_position)
@@ -318,11 +318,11 @@ func _ready():
var char_index = (bot_id_val - 1) % bot_characters.size()
var bot_char_name = bot_characters[char_index]
set_character(bot_char_name)
if is_multiplayer_authority() and can_rpc():
if can_rpc() and multiplayer.has_multiplayer_peer():
rpc("sync_character", bot_char_name)
# Sync bot status to network
if is_multiplayer_authority() and can_rpc():
if can_rpc() and multiplayer.has_multiplayer_peer():
rpc("sync_bot_status", true)
# Continue to manager initialization...
@@ -347,7 +347,7 @@ func _ready():
current_position.y * 1 + 1 * 0.5
)
target_visual_position = global_position
if is_multiplayer_authority() and can_rpc():
if can_rpc() and multiplayer.has_multiplayer_peer():
rpc("sync_position", current_position)
else:
target_visual_position = global_position
@@ -417,7 +417,9 @@ func play_scatter_knock():
func play_playerboard_scatter():
"""Show the one-shot scatter VFX over the playerboard UI, but only on the
local human player's own client (the board UI belongs to them)."""
var is_local = name == str(multiplayer.get_unique_id())
var is_local = false
if multiplayer and multiplayer.has_multiplayer_peer():
is_local = name == str(multiplayer.get_unique_id())
var is_bot_check = is_bot or is_in_group("Bots")
if not is_local or is_bot_check:
return
@@ -672,7 +674,7 @@ func _setup_character() -> void:
return
var character_name = "Masbro" # Default
var player_authority_id = get_multiplayer_authority()
var player_authority_id = get_multiplayer_authority() if multiplayer and multiplayer.has_multiplayer_peer() else name.to_int()
# Look up character from LobbyManager for this player (works for all players)
if LobbyManager:
@@ -896,7 +898,9 @@ func sync_playerboard(new_playerboard: Array):
# Only update UI if this is the LOCAL HUMAN PLAYER
# Bots managed by server (ID 1) might sync here, but we must NOT update UI for them
var is_local = name == str(multiplayer.get_unique_id())
var is_local = false
if multiplayer and multiplayer.has_multiplayer_peer():
is_local = name == str(multiplayer.get_unique_id())
var is_bot_check = is_bot or is_in_group("Bots")
# DEBUG: Trace why UI updates might be happening
@@ -1376,7 +1380,7 @@ func _process(delta):
if LobbyManager.get_randomize_spawn() and spawn_point_selected and not visible:
visible = true
if not multiplayer.has_multiplayer_peer():
if not multiplayer or not multiplayer.has_multiplayer_peer():
return
if is_multiplayer_authority():
@@ -1478,7 +1482,7 @@ var last_sent_position: Vector3
func _physics_process(delta):
# Sync position periodically (Heartbeat / Smoothing)
if not multiplayer.has_multiplayer_peer():
if not multiplayer or not multiplayer.has_multiplayer_peer():
return
if is_multiplayer_authority():
@@ -2018,7 +2022,7 @@ func bot_try_grab_item() -> bool:
#if empty_slot == -1:
#return false
#
#if is_multiplayer_authority():
#if is_multiplayer_authority() and can_rpc() and multiplayer.has_multiplayer_peer():
#rpc("bot_grab_item", grid_position, empty_slot, cell.x, cell.y, cell.z)
#return true
#
@@ -2065,7 +2069,7 @@ func bot_try_grab_item() -> bool:
#return false # no space
#
## Perform the grab and auto-place
#if is_multiplayer_authority():
#if is_multiplayer_authority() and can_rpc() and multiplayer.has_multiplayer_peer():
## Update gridmap: remove item
#rpc("sync_grid_item", cell.x, cell.y, cell.z, -1)
## Update playerboard
+6 -6
View File
@@ -4,12 +4,12 @@
[ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="1_pti1t"]
[ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="3_3vyn0"]
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="3_font"]
[ext_resource type="PackedScene" uid="uid://ejeamn0pyey4" path="res://assets/characters/Bob.glb" id="4_bob"]
[ext_resource type="PackedScene" uid="uid://d4cul3w3wem5w" path="res://assets/characters/Gatot.glb" id="4_gatot"]
[ext_resource type="PackedScene" uid="uid://1vk0mjnwkngi" path="res://assets/characters/Masbro.glb" id="4_masbro"]
[ext_resource type="PackedScene" uid="uid://bmln7v6v5kvxg" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
[ext_resource type="PackedScene" uid="uid://5qdk1umx2rjf" path="res://assets/characters/Bob.glb" id="4_bob"]
[ext_resource type="PackedScene" uid="uid://bfujakntxa0v6" path="res://assets/characters/Gatot.glb" id="4_gatot"]
[ext_resource type="PackedScene" uid="uid://cfjx66gthp1c5" path="res://assets/characters/Masbro.glb" id="4_masbro"]
[ext_resource type="PackedScene" uid="uid://cxvbrdybeglt5" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
[ext_resource type="Texture2D" uid="uid://brhn1dhp1gm13" path="res://assets/graphics/character_selection/sc_characters/sc_copper.png" id="4_pti1t"]
[ext_resource type="AnimationLibrary" uid="uid://c3pyopnwibckj" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
[ext_resource type="AnimationLibrary" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="tex_gold"]
[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="tex_star"]
@@ -388,7 +388,7 @@ unique_name_in_owner = true
own_world_3d = true
transparent_bg = true
handle_input_locally = false
size = Vector2i(651, 554)
size = Vector2i(651, 558)
render_target_update_mode = 4
[node name="WorldEnvironment" type="WorldEnvironment" parent="MainMargin/MainHBox/CenterCol/ViewportWrapper/ViewportContainer/PreviewViewport" unique_id=1660814495]
+11
View File
@@ -210,6 +210,17 @@ func _run_ai_tick():
print("[BotController] Action Taken: Attack Pursuit")
return
# Priority 0.3: Candy Survival — auto-toggle knock mode when bot has charges
if strategic_planner and strategic_planner.is_candy_survival_mode():
var gm = strategic_planner._get_candy_survival_manager()
if gm and gm.active:
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
var charges = gm.player_knocks.get(pid, 0)
if charges > 0:
actor.is_charged_strike = true
else:
actor.is_charged_strike = false
# Priority 0.5: Candy Survival (#075) — use Ghost powerup if boxed in
if await _try_activate_ghost():
print("[BotController] Action Taken: Ghost (trapped)")
+3
View File
@@ -427,6 +427,9 @@ func link_google(id_token: String) -> bool:
func logout() -> void:
print("[AuthManager] Logging out...")
if NakamaManager.client and NakamaManager.session:
await NakamaManager.client.session_logout_async(NakamaManager.session)
# FULL CLEANUP: close socket, leave bridge, clear multiplayer peer
NakamaManager.cleanup()
+69 -19
View File
@@ -283,6 +283,7 @@ func on_blueprint_completed(pid: int, primary_tile_id: int, off_color: bool) ->
break
_give_candy(pid, candy_color)
_add_score(pid, 100) # Award +100 points when completing goals by collecting tiles
# Clear the board now that it's matched and converted to a candy
if main_scene and player_node:
@@ -404,22 +405,11 @@ func try_knock(attacker: int, target: int) -> bool:
var attacker_node = _find_player_node(attacker)
var target_node = _find_player_node(target)
if attacker_node and target_node:
if attacker_node.has_method("can_rpc") and attacker_node.can_rpc():
attacker_node.rpc("sync_bump", target_node.current_position)
if target_candies == 0:
# Backfire: attacker loses a charge & gets staggered
player_knocks[attacker] = player_knocks.get(attacker, START_KNOCK) - 1
rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
# Attacker is knocked down briefly (backfire)
if attacker_node:
if attacker_node.has_method("can_rpc") and attacker_node.can_rpc():
attacker_node.rpc("apply_stagger", 2.0)
elif attacker_node.has_method("apply_stagger"):
attacker_node.apply_stagger(2.0)
# Backfire penalty: Attacker's candy stack transfers to Target!
var attacker_colors = player_candy_colors.get(attacker, [])
var attacker_candies = attacker_colors.size()
@@ -452,14 +442,7 @@ func try_knock(attacker: int, target: int) -> bool:
rpc("sync_knock_charge_count", attacker, player_knocks[attacker])
player_last_knocked_by[target] = attacker
# Target is knocked down / staggered on successful knock
if target_node:
if target_node.has_method("can_rpc") and target_node.can_rpc():
target_node.rpc("apply_stagger", 2.0)
elif target_node.has_method("apply_stagger"):
target_node.apply_stagger(2.0)
_add_score(attacker, target_candies * 100)
_add_score(attacker, target_candies * 100 + 100) # Award +100 points for using KNOCK + 100 per candy stolen
_update_candy_badge(attacker)
_update_candy_badge(target)
@@ -493,6 +476,7 @@ func try_activate_ghost(pid: int) -> bool:
player.sync_modulate(Color(1.0, 1.0, 1.0, 0.4))
break
_add_score(pid, 100) # Award +100 points for tactically using GHOST
return true
func is_ghost_active(pid: int) -> bool:
@@ -716,6 +700,63 @@ func sync_knock_result(attacker: int, target: int, candies: int) -> void:
if hud_knock_label and player_knocks.has(local):
hud_knock_label.text = "🥊 Knocks: %d" % player_knocks[local]
var attacker_node = _find_player_node(attacker)
var target_node = _find_player_node(target)
if not attacker_node or not target_node:
return
# SFX: Play attack_mode sound like other game modes
var sfx = Engine.get_main_loop().root.get_node_or_null("SfxManager")
if sfx and sfx.has_method("play"):
sfx.play("attack_mode")
# Attacker bump animation toward target
if attacker_node.has_method("sync_bump"):
attacker_node.sync_bump(target_node.current_position, false)
# Calculate push direction from attacker to target
var diff: Vector2i = target_node.current_position - attacker_node.current_position
var push_dir = Vector2i(clamp(diff.x, -1, 1), clamp(diff.y, -1, 1))
if push_dir == Vector2i.ZERO:
push_dir = Vector2i(1, 0)
if candies > 0:
# SUCCESSFUL STEAL: target has candies
# Target is knocked back up to 3 tiles & stunned
_apply_knock_animation_to_victim(target_node, push_dir, 2.0)
else:
# BACKFIRE: target had 0 candies
# Target is NOT stunned ("whoever doesn't hold candy, will not stunned")
# Attacker backfires: knocked back up to 3 tiles away from target & stunned
_apply_knock_animation_to_victim(attacker_node, -push_dir, 2.0)
func _apply_knock_animation_to_victim(victim_node: Node, push_dir: Vector2i, stagger_duration: float) -> void:
if not is_instance_valid(victim_node):
return
var pushed_to_pos: Vector2i = victim_node.current_position
var push_path: Array = []
for i in range(3):
var next_back: Vector2i = pushed_to_pos + push_dir
var can_push: bool = false
if victim_node.get("movement_manager") and victim_node.movement_manager.has_method("_can_push_to"):
can_push = victim_node.movement_manager._can_push_to(next_back)
if can_push:
pushed_to_pos = next_back
push_path.append(Vector2(pushed_to_pos.x, pushed_to_pos.y))
if is_sticky_cell(pushed_to_pos):
break
else:
break
if push_path.size() > 0:
if victim_node.has_method("start_movement_along_path"):
victim_node.start_movement_along_path(push_path, false, true)
victim_node.target_position = pushed_to_pos
if victim_node.has_method("apply_stagger"):
victim_node.apply_stagger(stagger_duration)
@rpc("authority", "call_remote", "unreliable")
func sync_ghost_active(pid: int, active_on: bool) -> void:
if not active:
@@ -759,7 +800,10 @@ func sync_state_to_player(peer_id: int) -> void:
var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
if curr_pid == pid:
if player.has_method("sync_candy_stack"):
if peer_id in multiplayer.get_peers():
player.rpc_id(peer_id, "sync_candy_stack", colors)
else:
player.sync_candy_stack(colors)
break
func _update_special_inventory_for_ghosts(pid: int) -> void:
@@ -771,10 +815,16 @@ func _update_special_inventory_for_ghosts(pid: int) -> void:
var st_manager = player_node.get_node_or_null("SpecialTilesManager")
if st_manager:
var has_charges = (player_ghosts.get(pid, 0) > 0)
if pid in multiplayer.get_peers():
if has_charges:
st_manager.rpc_id(pid, "sync_inventory_add", st_manager.SpecialEffect.INVISIBLE_MODE, 8)
else:
st_manager.rpc_id(pid, "sync_inventory_remove", st_manager.SpecialEffect.INVISIBLE_MODE)
else:
if has_charges:
st_manager.sync_inventory_add(st_manager.SpecialEffect.INVISIBLE_MODE, 8)
else:
st_manager.sync_inventory_remove(st_manager.SpecialEffect.INVISIBLE_MODE)
@rpc("any_peer", "call_local", "reliable")
func add_ghost_charge(pid: int) -> void:
+3 -3
View File
@@ -22,7 +22,7 @@ func after_action_completed():
return
player._is_processing_action = true
if player.is_multiplayer_authority():
if player.can_rpc() and multiplayer.has_multiplayer_peer():
player.update_finish_availability()
# Clear the highlights after placing the tiles. (Quickfix for Clientside)
@@ -30,12 +30,12 @@ func after_action_completed():
# Only update UI if this is the LOCAL HUMAN PLAYER
# Guard against stale callbacks after peer teardown (host quitting solo match)
if not multiplayer.has_multiplayer_peer():
if not multiplayer or not multiplayer.has_multiplayer_peer():
player._is_processing_action = false
return
if multiplayer.get_unique_id() == player.get_multiplayer_authority():
# Sync playerboard (Bots DO need to sync their board logic, just not update local UI)
if player.is_multiplayer_authority() and player.has_method("can_rpc") and player.can_rpc():
if player.can_rpc() and multiplayer.has_multiplayer_peer():
var main = player.get_tree().get_root().get_node_or_null("Main")
if main:
main.rpc("sync_playerboard", player.name.to_int(), player.playerboard)
+14 -24
View File
@@ -99,6 +99,7 @@ func handle_unhandled_input(event):
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
if gm and gm.active:
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
if player.can_rpc() and multiplayer.has_multiplayer_peer():
if multiplayer.is_server():
gm.try_activate_ghost(pid)
else:
@@ -120,35 +121,24 @@ func handle_unhandled_input(event):
var gm = player.get_node_or_null("/root/Main/CandySurvivalManager")
if gm and gm.active:
var att_id = player.get("peer_id") if "peer_id" in player else player.name.to_int()
# Toggle mode: if already in knock mode, turn it off
if player.get("is_charged_strike"):
player.is_charged_strike = false
NotificationManager.send_message(player, "Knock Mode OFF", NotificationManager.MessageType.WARNING)
get_viewport().set_input_as_handled()
return
# Check charges before toggling on
if gm.player_knocks.get(att_id, 5) <= 0:
NotificationManager.send_message(player, "No Knock charges left!", NotificationManager.MessageType.WARNING)
get_viewport().set_input_as_handled()
return
# Find a target to knock (adjacent player: cardinal or diagonal distance <= 1)
var p_pos = player.current_position
var all_players = get_tree().get_nodes_in_group("Players")
var target_found = null
var min_dist = 999
for other in all_players:
if other == player or not is_instance_valid(other): continue
var other_pos = other.current_position
var dist = max(abs(p_pos.x - other_pos.x), abs(p_pos.y - other_pos.y))
if dist <= 1 and dist < min_dist:
target_found = other
min_dist = dist
if dist == 1:
break
if target_found:
var tgt_id = target_found.get("peer_id") if "peer_id" in target_found else target_found.name.to_int()
if multiplayer.is_server():
gm.try_knock(att_id, tgt_id)
else:
gm.rpc_id(1, "try_knock", att_id, tgt_id)
else:
# No target, but play the bump animation anyway in place
player.rpc("sync_bump", p_pos)
# Toggle knock mode ON (reuses charged strike visuals)
player.is_charged_strike = true
NotificationManager.send_message(player, "🥊 Knock Mode ON — Walk into a target!", NotificationManager.MessageType.POWERUP)
player.update_active_player_indicator()
get_viewport().set_input_as_handled()
return
+11 -18
View File
@@ -168,6 +168,7 @@ func simple_move_to(grid_position: Vector2i) -> bool:
if gm and gm.active and gm.has_method("try_deliver"):
var dist = abs(grid_position.x - 8) + abs(grid_position.y - 8)
if dist <= 1:
if player.can_rpc() and multiplayer.has_multiplayer_peer():
if multiplayer.is_server():
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
gm.try_deliver(pid)
@@ -215,16 +216,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
NotificationManager.send_message(player, "Target is Immune!", NotificationManager.MessageType.WARNING)
return false
# Candy Survival: check if attacker has knock charges
var has_knock = false
var main_for_knock = player.get_tree().root.get_node_or_null("Main")
var gm_for_knock = main_for_knock.get("candy_survival_manager") if main_for_knock else null
if gm_for_knock and gm_for_knock.active:
var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
var charges = gm_for_knock.player_knocks.get(att_pid, 0) if gm_for_knock.player_knocks else 0
has_knock = charges > 0
if (not player.get("is_charged_strike") and not has_knock) or player.get("is_invisible"):
if not player.get("is_charged_strike") or player.get("is_invisible"):
# Standard bumping effect (Visual only)
print("[Move] Push blocked: Not charged or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
if _can_rpc():
@@ -258,12 +250,13 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
if gm and gm.active and gm.has_method("try_knock"):
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
var other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int()
if gm.try_knock(pid, other_pid):
print("[CandySurvival] Player %d knocked %d, stole candies!" % [pid, other_pid])
if player.can_rpc() and multiplayer.has_multiplayer_peer():
if multiplayer.is_server():
gm.try_knock(pid, other_pid)
else:
print("[CandySurvival] Player %d tried to knock %d (backfire or no charges)" % [pid, other_pid])
if _can_rpc():
player.rpc("sync_bump", target_pos, false)
gm.rpc_id(1, "try_knock", pid, other_pid)
# Consume knock mode after use (toggle off)
player.is_charged_strike = false
return false
# === SUPER PUSH (Attack Mode) ===
@@ -324,7 +317,7 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
print("[Move] Ghost mode bypassed push-into-sticky at %s" % pushed_to_pos)
else:
print("[Move] Player pushed into sticky cell at %s — slowed" % pushed_to_pos)
if multiplayer.is_server() or other_player.is_multiplayer_authority():
if other_player.can_rpc() and multiplayer.has_multiplayer_peer() and (multiplayer.is_server() or other_player.is_multiplayer_authority()):
gm_sticky.apply_sticky_slow(other_player)
# 2. Apply freeze/stun effect
@@ -351,10 +344,10 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
if main_score:
var gcm = main_score.get_node_or_null("GoalsCycleManager")
if gcm:
if multiplayer.is_server():
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
# Server/Bot: Directly add score to specific player ID
gcm.add_score(player.name.to_int(), 200)
else:
elif player.can_rpc() and multiplayer.has_multiplayer_peer():
# Client: Request score add (sender ID used)
gcm.rpc("request_add_score", 200)
NotificationManager.send_message(player, "Successful Attack! +200 Pts", NotificationManager.MessageType.GOAL)
+2 -2
View File
@@ -216,10 +216,9 @@ func update_display_name(new_name: String) -> bool:
emit_signal("profile_updated")
return true
var formatted_username = new_name.replace(" ", "_").to_lower()
var result: NakamaAsyncResult = await NakamaManager.client.update_account_async(
NakamaManager.session,
formatted_username, # username (sync to display name)
null, # username (keep existing unique username unchanged)
new_name # display_name
)
@@ -228,6 +227,7 @@ func update_display_name(new_name: String) -> bool:
return false
profile["display_name"] = new_name
await _save_profile_data()
emit_signal("profile_updated")
return true
+4 -2
View File
@@ -196,6 +196,7 @@ func host_game(room_meta: Dictionary = {}):
if bridge.match_state == NakamaMultiplayerBridge.MatchState.CONNECTED:
print("[NakamaManager] Already hosting a match; ignoring duplicate host request.")
return
if bridge.match_state != NakamaMultiplayerBridge.MatchState.DISCONNECTED:
# Stranded mid-join (e.g. previous attempt never resolved). Reset so the
# bridge is idle before we try again.
@@ -203,8 +204,9 @@ func host_game(room_meta: Dictionary = {}):
await bridge.leave()
print("Hosting match via Nakama Bridge...")
await bridge.create_match()
# Store room metadata in Nakama storage so other players can see it in listings
var result = await bridge.create_match()
if result and result.is_exception():
emit_signal("match_join_error", result.get_exception().message)
if session and current_match_id and room_meta.size() > 0:
var meta_json = JSON.stringify(room_meta)
var write_obj = NakamaWriteStorageObject.new(
+6
View File
@@ -93,8 +93,14 @@ func api_rpc_async(rpc_id: String, payload: String = "{}") -> Dictionary:
var retries := 0
while retries <= MAX_RETRIES:
if not nakama_backend or not nakama_backend.client or not nakama_backend.session:
return { "success": false, "error": ErrorCode.UNAUTHORIZED, "message": "Not authenticated" }
var result = await nakama_backend.client.rpc_async(nakama_backend.session, rpc_id, payload)
if result == null:
return { "success": false, "error": ErrorCode.INTERNAL_ERROR, "message": "RPC call returned null" }
# NakamaAPI.ApiRpc has is_exception()
if result.is_exception():
var ex = result.get_exception()
@@ -0,0 +1 @@
uid://dfyyc34ekv3yw
+9 -4
View File
@@ -311,7 +311,7 @@ func on_thrown_landing(attacker: Node = null, intensity: float = 1.0):
# Disable movement/interaction logic temporarily
var controller = get_node_or_null("TektonController")
if controller and controller.get("timer"):
if controller and is_instance_valid(controller) and controller.get("timer") and is_instance_valid(controller.timer):
controller.timer.stop()
# Visual Shrink
@@ -329,7 +329,7 @@ func on_thrown_landing(attacker: Node = null, intensity: float = 1.0):
t.tween_property(mesh, "scale", base_scale * 0.5, 0.2).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
# Spawn tiles (as requested "tekton will spawn a tiles around that floor also")
if is_multiplayer_authority():
if is_inside_tree() and is_multiplayer_authority():
spawn_tiles_around(int(8 * intensity))
# Floor Freeze (Visual/Instant - Run on all clients locally)
@@ -341,7 +341,12 @@ func on_thrown_landing(attacker: Node = null, intensity: float = 1.0):
temporarily_change_floor(current_position, 1, 6, 3.0)
# Wait 3.0 seconds
await get_tree().create_timer(3.0).timeout
var tree = get_tree() if is_inside_tree() else null
if tree:
await tree.create_timer(3.0).timeout
else:
is_recovering = false
return
# Grow back
for i in range(mesh_cache.size()):
@@ -355,7 +360,7 @@ func on_thrown_landing(attacker: Node = null, intensity: float = 1.0):
# Resume AI
if not is_multiplayer_authority() or not is_carried:
if controller and controller.has_method("_start_timer"):
if controller and is_instance_valid(controller) and controller.has_method("_start_timer"):
controller._start_timer()
+1
View File
@@ -31,6 +31,7 @@ func _ready():
_start_timer()
func _start_timer():
if not timer or not is_instance_valid(timer): return
var time = rng.randf_range(move_interval_min, move_interval_max)
timer.start(time)
+1 -1
View File
@@ -607,7 +607,7 @@ func _on_save_name_pressed() -> void:
emit_signal("profile_updated")
await get_tree().create_timer(3.0).timeout
status_label.text = ""
else:
elif status_label.text == "Saving...":
_set_status("Failed to update name.", Color.RED)
# ─────────────────────────────────────────────────────────────
+133
View File
@@ -0,0 +1,133 @@
extends Node
class Log:
enum {ERROR, INFO, DEBUG}
static func _s(lvl):
if lvl == ERROR:
return "ERROR"
elif lvl == INFO:
return "INFO"
elif lvl == DEBUG:
return "DEBUG"
return "WTF"
static func __log(lvl, msg, data):
print("======= %s: %s" % [_s(lvl), msg])
if not data.is_empty():
var json = JSON.new()
print(json.stringify(data, " ", true))
static func error(msg, data={}):
__log(ERROR, msg, data)
static func info(msg, data={}):
__log(INFO, msg, data)
static func debug(msg, data={}):
__log(DEBUG, msg, data)
const REAL_ASSERT = false
var _success = false
var _start_time = 0
var _quit = false
var _disabled = false
var __me = ""
# Override this to specify test initialization (called on _ready if not disabled)
# You can run your whole test here, and call done() when finished
func setup():
pass
# Override this to do cleanup, make assertions at the end of the test (called on _exit_tree)
# NOTE: You can still fail here if you like with fail() or by failing an assertion
func teardown():
pass
func _init():
__me = get_script()
while __me.get_base_script() != null:
__me = __me.get_base_script()
func _ready():
if _disabled:
set_process(false)
set_physics_process(false)
set_process_input(false)
set_process_unhandled_input(false)
set_process_unhandled_key_input(false)
done()
Log.info("SKIP: %s" % __get_source(self))
return
_start_time = Time.get_ticks_usec()
Log.info("RUNNING: %s" % __get_source(self))
setup()
func _exit_tree():
if _disabled:
return
teardown()
Log.info("%s: %s" % ["SUCCESS" if _success else "!!!!!!!!!!!!!!!!!!! FAILURE", __get_source(self)])
func __get_source(who):
if who == null or who.get_script() == null:
return "Unknown source: %s" % str(who)
return who.get_script().resource_path
func __get_caller():
for s in get_stack():
if __me.resource_path != s.source:
return s
return null
func __get_assertion():
var stack = get_stack()
stack.reverse()
for s in stack:
if __me.resource_path == s.source:
return s
return null
func __assert(v1, v2):
if not (v1 == v2):
Log.error("Assert Failed", {"caller": __get_caller(), "assertion": __get_assertion(), "full_stack": get_stack()})
print_tree_pretty()
_quit = true
if REAL_ASSERT:
assert(v1 == v2)
if v1 == v2:
return false
return true
func assert_time(max_time):
__assert(max_time > float(Time.get_ticks_usec() - _start_time) / 1000.0 / 1000.0, true)
### Returns true if the assertion failed, so you can do:
### if assert_cond(cond):
### return # Assertion failed!
func assert_cond(cond):
return __assert(cond, true)
func assert_false(cond):
return __assert(cond, false)
func assert_equal(v1, v2):
return __assert(v1, v2)
func done():
_success = true
_quit = true
func fail():
_success = false
_quit = true
__assert(false, true)
func disable():
_disabled = true
func is_disabled():
return _disabled
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+119
View File
@@ -0,0 +1,119 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
_global_script_classes=[{
"base": "RefCounted",
"class": &"NakamaAPI",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaAPI.gd"
}, {
"base": "RefCounted",
"class": &"NakamaAsyncResult",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaAsyncResult.gd"
}, {
"base": "RefCounted",
"class": &"NakamaClient",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/client/NakamaClient.gd"
}, {
"base": "RefCounted",
"class": &"NakamaException",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaException.gd"
}, {
"base": "Node",
"class": &"NakamaHTTPAdapter",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/client/NakamaHTTPAdapter.gd"
}, {
"base": "RefCounted",
"class": &"NakamaLogger",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaLogger.gd"
}, {
"base": "RefCounted",
"class": &"NakamaMultiplayerBridge",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaMultiplayerBridge.gd"
}, {
"base": "MultiplayerPeerExtension",
"class": &"NakamaMultiplayerPeer",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaMultiplayerPeer.gd"
}, {
"base": "NakamaAsyncResult",
"class": &"NakamaRTAPI",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaRTAPI.gd"
}, {
"base": "RefCounted",
"class": &"NakamaRTMessage",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaRTMessage.gd"
}, {
"base": "RefCounted",
"class": &"NakamaSerializer",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaSerializer.gd"
}, {
"base": "NakamaAsyncResult",
"class": &"NakamaSession",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaSession.gd"
}, {
"base": "RefCounted",
"class": &"NakamaSocket",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/socket/NakamaSocket.gd"
}, {
"base": "Node",
"class": &"NakamaSocketAdapter",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/socket/NakamaSocketAdapter.gd"
}, {
"base": "RefCounted",
"class": &"NakamaStorageObjectId",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaStorageObjectId.gd"
}, {
"base": "RefCounted",
"class": &"NakamaWriteStorageObject",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaWriteStorageObject.gd"
}]
_global_script_class_icons={
"NakamaAPI": "",
"NakamaAsyncResult": "",
"NakamaClient": "",
"NakamaException": "",
"NakamaHTTPAdapter": "",
"NakamaLogger": "",
"NakamaMultiplayerBridge": "",
"NakamaMultiplayerPeer": "",
"NakamaRTAPI": "",
"NakamaRTMessage": "",
"NakamaSerializer": "",
"NakamaSession": "",
"NakamaSocket": "",
"NakamaSocketAdapter": "",
"NakamaStorageObjectId": "",
"NakamaWriteStorageObject": ""
}
[application]
run/main_scene="res://tester.tscn"
config/features=PackedStringArray("4.0")
[autoload]
Nakama="*res://addons/com.heroiclabs.nakama/Nakama.gd"
Config="*res://utils/config.gd"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
END_STRING="======= TESTS END"
PROJECT_PATH="test_suite/"
GODOT_BIN="test_suite/bin/godot.elf"
OUT=`$GODOT_BIN --headless --debug --path test_suite/ -s res://runner.gd`
RUN=`echo $OUT | grep "$END_STRING"`
RES=`echo $OUT | grep FAILURE`
echo "$OUT"
if [ -z "$RUN" ]; then
echo "Run failed!"
exit 1
fi
if [ -z "$RES" ]; then
echo "Tests passed!"
exit 0
fi
echo "Tests failed!"
exit 1
+15
View File
@@ -0,0 +1,15 @@
extends SceneTree
func _init():
var me = ProjectSettings.localize_path(ProjectSettings.globalize_path(get_script().resource_path))
var dir = ProjectSettings.localize_path(me.get_base_dir())
if dir != "res://":
print("Must be run with the script dir as the res:// path")
print("Current path: ", ProjectSettings.globalize_path(dir))
print("RES:// path: ", ProjectSettings.globalize_path("res://"))
quit()
return
var n = load("res://tester.gd").new()
root.add_child(n)
current_scene = n
+7
View File
@@ -0,0 +1,7 @@
{
"HOST": "127.0.0.1",
"PORT": 7350,
"SCHEME": "http",
"SERVER_KEY": "defaultkey"
}
+56
View File
@@ -0,0 +1,56 @@
extends Node
var current = null
var tests = []
var frame_time = 0.0
var fixed_time = 0.0
var dirs = ["tests"]
func _ready():
while not dirs.is_empty():
var d = dirs.pop_front()
_expand(d, dirs)
print("Running %d tests:\n%s" % [tests.size(), tests])
func _process(delta):
frame_time += delta
_check_end()
func _physics_process(delta):
fixed_time += delta
_check_end()
func _check_end():
if current == null:
if tests.size() == 0:
_end_tests()
return
current = load(tests.pop_front()).new()
add_child(current)
elif current._quit:
if not current._success:
_end_tests()
current.queue_free()
current = null
func _end_tests():
print("======= TESTS END")
set_process(false)
set_physics_process(false)
await get_tree().create_timer(1.0).timeout
get_tree().call_deferred("quit")
func _expand(p_name, r_dirs):
var dir = DirAccess.open("res://")
if dir.change_dir(p_name) != OK:
print("Unable to chdir into: %s" % p_name)
return
dir.list_dir_begin()
var f = dir.get_next()
while f != "":
if dir.current_is_dir():
r_dirs.append("%s/%s" % [p_name, f])
if f.ends_with("_test.gd"):
tests.append("%s/%s" % [p_name, f])
f = dir.get_next()
dir.list_dir_end()
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://bglgahpqv1ojs"]
[ext_resource type="Script" path="res://tester.gd" id="1"]
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
+32
View File
@@ -0,0 +1,32 @@
extends "res://base_test.gd"
func setup():
var username = str(randi_range(1000, 100000))
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
# POST
var session = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session.is_valid()) or assert_cond(!session.is_expired()):
return
# PUT
var update = await client.update_account_async(session, username)
if assert_false(update.is_exception()):
return
# GET
var account : NakamaAPI.ApiAccount = await client.get_account_async(session)
if assert_false(account.is_exception()):
return
if assert_cond(account.user.username == username):
return
# POST - DELETE
var group : NakamaAPI.ApiGroup = await client.create_group_async(session, "MyGroupName3")
if assert_false(group.is_exception()):
return
var delete = await client.delete_group_async(session, group.id)
if assert_false(delete.is_exception()):
return
# All good
done()
return
func _process(_delta):
assert_time(3)
+79
View File
@@ -0,0 +1,79 @@
extends "res://base_test.gd"
var content = {"My": "message"}
var match_string_props = {"region": "europe"}
var match_numeric_props = {"rank": 8}
var got_msg = false
var got_match = false
var socket1 = null
var socket2 = null
func setup():
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session1 = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session1.is_valid()):
return
var session2 = await client.authenticate_custom_async("MyIdentifier2")
if assert_cond(session1.is_valid()):
return
socket1 = Nakama.create_socket_from(client)
socket1.received_channel_message.connect(self._on_socket1_message)
socket1.received_matchmaker_matched.connect(self._on_socket1_matchmaker_matched)
var done = await socket1.connect_async(session1)
# Check that connection succeded
if assert_false(done.is_exception()):
return
socket2 = Nakama.create_socket_from(client)
done = await socket2.connect_async(session2)
# Check that connection succeded
if assert_false(done.is_exception()):
return
# Join room
var room1 = await socket1.join_chat_async("MyRoom", NakamaSocket.ChannelType.Room)
if assert_false(room1.is_exception()):
return
var room2 = await socket2.join_chat_async("MyRoom", NakamaSocket.ChannelType.Room)
if assert_false(room2.is_exception()):
return
# Socket 2 send message to socket 1
var msg_ack = await socket2.write_chat_message_async(room2.id, content)
if assert_false(msg_ack.is_exception()):
return
var ticket1 = await socket1.add_matchmaker_async("+properties.region:europe +properties.rank:>=7 +properties.rank:<=9", 2, 8, match_string_props, match_numeric_props)
if assert_false(ticket1.is_exception()):
return
var ticket2 = await socket2.add_matchmaker_async("+properties.region:europe +properties.rank:>=7 +properties.rank:<=9", 2, 8, match_string_props, match_numeric_props)
if assert_false(ticket2.is_exception()):
return
func _on_socket1_message(msg):
if assert_equal(msg.content, JSON.stringify(content)):
return
got_msg = true
check_end()
func _on_socket1_matchmaker_matched(p_matchmaker_matched):
if assert_equal(JSON.stringify(p_matchmaker_matched.users[0].string_properties), JSON.stringify(match_string_props)):
return
if assert_equal(JSON.stringify(p_matchmaker_matched.users[0].numeric_properties), JSON.stringify(match_numeric_props)):
return
if assert_equal(JSON.stringify(p_matchmaker_matched.users[1].string_properties), JSON.stringify(match_string_props)):
return
if assert_equal(JSON.stringify(p_matchmaker_matched.users[1].numeric_properties), JSON.stringify(match_numeric_props)):
return
got_match = true
check_end()
func _process(_delta):
assert_time(60)
func check_end():
if got_match and got_msg:
done()
+98
View File
@@ -0,0 +1,98 @@
extends "res://base_test.gd"
var content = {"My": "message"}
var match_props = {"region": "europe"}
var got_msg = false
var got_match = false
var socket1 : NakamaSocket = null
var socket2 : NakamaSocket = null
func setup():
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session1 = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session1.is_valid()):
return
var session2 = await client.authenticate_custom_async("MyIdentifier2")
if assert_cond(session2.is_valid()):
return
socket1 = Nakama.create_socket_from(client)
socket1.received_party_close.connect(self._on_party_close)
socket1.received_party_data.connect(self._on_party_data)
socket1.received_party_join_request.connect(self._on_party_join_request)
var conn = await socket1.connect_async(session1)
# Check that connection succeded
if assert_false(conn.is_exception()):
return
var party = await socket1.create_party_async(false, 2)
if assert_false(party.is_exception()):
return
#done()
socket2 = Nakama.create_socket_from(client)
socket2.received_party.connect(self._on_party)
socket2.received_party_close.connect(self._on_party_close)
socket2.received_party_join_request.connect(self._on_party_join_request)
socket2.received_party_leader.connect(self._on_party_leader)
socket2.received_party_presence.connect(self._on_party_presence)
var conn2 = await socket2.connect_async(session2)
# Check that connection succeded
if assert_false(conn2.is_exception()):
return
var join = await socket2.join_party_async(party.party_id)
if assert_false(join.is_exception()):
return
func _on_party_join_request(party_join_request : NakamaRTAPI.PartyJoinRequest):
prints("_on_party_join_request", party_join_request)
var requests : NakamaRTAPI.PartyJoinRequest = await socket1.list_party_join_requests_async(party_join_request.party_id)
if assert_false(requests.is_exception()):
return
if assert_cond(requests.presences.size() == 1):
return
await socket1.accept_party_member_async(party_join_request.party_id, party_join_request.presences[0])
await socket1.promote_party_member(party_join_request.party_id, party_join_request.presences[0])
func _on_party(party):
prints("_on_party", party)
func _on_party_close(party_close):
prints("_on_party_close", party_close)
func _on_party_data(data : NakamaRTAPI.PartyData):
prints("_on_party_data", data)
var left = await socket1.leave_party_async(data.party_id)
if assert_false(left.is_exception()):
return
func _on_party_leader(party_leader : NakamaRTAPI.PartyLeader):
prints("_on_party_leader", party_leader)
var ticket = await socket2.add_matchmaker_party_async(party_leader.party_id)
if assert_false(ticket.is_exception()):
return
_on_party_ticket(ticket)
func _on_party_ticket(ticket : NakamaRTAPI.PartyMatchmakerTicket):
prints("_on_party_ticket", ticket)
var removed = await socket2.remove_matchmaker_party_async(ticket.party_id, ticket.ticket)
if assert_false(removed.is_exception()):
return
var sent = await socket2.send_party_data_async(ticket.party_id, 1, "asd")
if assert_false(sent.is_exception()):
return
func _on_party_presence(party_presence : NakamaRTAPI.PartyPresenceEvent):
prints("_on_party_presence", party_presence)
var left = party_presence.leaves.size() == 1
if left:
var closed = await socket2.close_party_async(party_presence.party_id)
if assert_false(closed.is_exception()):
return
done()
+27
View File
@@ -0,0 +1,27 @@
extends "res://base_test.gd"
var _connected = false
func setup():
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session.is_valid()):
return
var socket = Nakama.create_socket_from(client)
socket.connected.connect(self._on_socket_connected)
var conn = await socket.connect_async(session)
# Check that connection succeded
if assert_false(conn.is_exception()):
return
# Check that signal has been called
if assert_cond(_connected):
return
done()
func _on_socket_connected():
_connected = true
func _process(_delta):
assert_time(3)
+72
View File
@@ -0,0 +1,72 @@
extends "res://base_test.gd"
const COLLECTION = "ACollection"
const K1 = "Question"
const K2 = "Answer"
const V1 = {"question": "Ultimate question"}
const V2 = {"answer": "42"}
func setup():
var username = str(randi_range(1000, 100000))
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session.is_valid()):
return
var objs = [
NakamaWriteStorageObject.new(COLLECTION, K1, 1, 1, to_json(V1), ""),
NakamaWriteStorageObject.new(COLLECTION, K2, 1, 1, to_json(V2), "")
]
var write : NakamaAPI.ApiStorageObjectAcks = await client.write_storage_objects_async(session, objs)
if assert_false(write.is_exception()):
return
var objs_ids = []
for a in write.acks:
#var obj : NakamaAPI.ApiStorageObjectAck = a
objs_ids.append(NakamaStorageObjectId.new(a.collection, a.key, a.user_id, a.version))
var read : NakamaAPI.ApiStorageObjects = await client.read_storage_objects_async(session, objs_ids)
if assert_false(read.is_exception()):
return
if assert_equal(read.objects.size(), 2):
return
if assert_equal(read.objects[0].collection, COLLECTION):
return
if assert_cond(read.objects[0].key in [K1, K2]):
return
if assert_cond(to_json(parse_json(read.objects[0].value)) in [to_json(V1), to_json(V2)]):
return
# Delete one
var del = await client.delete_storage_objects_async(session, [objs_ids[0]])
if assert_false(del.is_exception()):
return
# Confirm that one was deleted
var read2 : NakamaAPI.ApiStorageObjects = await client.read_storage_objects_async(session, objs_ids)
if assert_false(read2.is_exception()):
return
if assert_equal(read2.objects.size(), 1):
return
if assert_equal(read2.objects[0].collection, COLLECTION):
return
if assert_equal(read2.objects[0].key, objs_ids[1].key):
return
if assert_cond(to_json(parse_json(read2.objects[0].value)) in [to_json(V1), to_json(V2)]):
return
done()
return
func _process(_delta):
assert_time(3)
func to_json(value) -> String:
return JSON.stringify(value)
func parse_json(value):
var json = JSON.new()
if json.parse(value) != OK:
return null
return json.get_data()
+22
View File
@@ -0,0 +1,22 @@
extends Node
var HOST = "127.0.0.1"
var PORT = 7350
var SCHEME = "http"
var SERVER_KEY = "defaultkey"
func _ready():
var f = FileAccess.open("res://settings.json", FileAccess.READ)
if not f:
return
var json = JSON.new()
var error = json.parse(f.get_as_text())
var parsed = json.get_data()
if error != OK or typeof(parsed) != TYPE_DICTIONARY:
return
for k in parsed:
match k:
"HOST": HOST = parsed[k]
"PORT": PORT = parsed[k]
"SCHEME": SCHEME = parsed[k]
"SERVER_KEY": SERVER_KEY = parsed[k]