@beremaran/godot-agent-loop 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +925 -0
- package/addons/godot_agent_loop/LICENSE +23 -0
- package/addons/godot_agent_loop/README.md +31 -0
- package/addons/godot_agent_loop/plugin.cfg +8 -0
- package/addons/godot_agent_loop/plugin.gd +417 -0
- package/agent-plugin/.claude-plugin/plugin.json +19 -0
- package/agent-plugin/.codex-plugin/plugin.json +37 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/adapter-manifest.json +45 -0
- package/agent-plugin/pi/extension.ts +136 -0
- package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
- package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
- package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
- package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
- package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
- package/build/authoring-session-manager.js +237 -0
- package/build/domain-tool-registries.js +207 -0
- package/build/editor-bridge-protocol.js +1 -0
- package/build/editor-connection.js +112 -0
- package/build/editor-mutation-guard.js +30 -0
- package/build/editor-plugin-installer.js +220 -0
- package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
- package/build/game-command-service.js +49 -0
- package/build/game-connection.js +337 -0
- package/build/godot-executable.js +156 -0
- package/build/godot-process-manager.js +112 -0
- package/build/godot-subprocess.js +23 -0
- package/build/headless-operation-runner.js +68 -0
- package/build/headless-operation-service.js +65 -0
- package/build/index.js +478 -0
- package/build/interaction-server-installer.js +234 -0
- package/build/opencode-setup.js +199 -0
- package/build/project-support.js +219 -0
- package/build/runtime-protocol.js +202 -0
- package/build/scripts/godot_operations.gd +1534 -0
- package/build/scripts/mcp_editor_plugin.gd +417 -0
- package/build/scripts/mcp_interaction_server.gd +1255 -0
- package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
- package/build/scripts/mcp_runtime/command_params.gd +339 -0
- package/build/scripts/mcp_runtime/core_domain.gd +591 -0
- package/build/scripts/mcp_runtime/input_domain.gd +695 -0
- package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
- package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
- package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
- package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
- package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
- package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
- package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
- package/build/scripts/mcp_runtime/system_domain.gd +277 -0
- package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
- package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
- package/build/scripts/validate_script.gd +28 -0
- package/build/server-instructions.js +11 -0
- package/build/session-timing.js +20 -0
- package/build/tool-argument-validation.js +120 -0
- package/build/tool-definitions.js +2727 -0
- package/build/tool-handlers/game-tool-handlers.js +1345 -0
- package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
- package/build/tool-handlers/project-handler-services.js +1458 -0
- package/build/tool-handlers/project-tool-handlers.js +1506 -0
- package/build/tool-handlers/visual-regression-service.js +139 -0
- package/build/tool-manifest.js +1193 -0
- package/build/tool-mutation-policy.js +122 -0
- package/build/tool-registry.js +34 -0
- package/build/tool-surface.js +70 -0
- package/build/utils.js +273 -0
- package/package.json +110 -0
- package/product.json +52 -0
- package/scripts/prepare-package.js +42 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
import { createErrorResponse, normalizeParameters, PathSecurity } from '../utils.js';
|
|
6
|
+
/** Captures deterministic screenshots and persists bounded visual evidence. */
|
|
7
|
+
export class VisualRegressionService {
|
|
8
|
+
commands;
|
|
9
|
+
pathSecurity;
|
|
10
|
+
static maxPixels = 16_777_216;
|
|
11
|
+
constructor(commands, pathSecurity = new PathSecurity()) {
|
|
12
|
+
this.commands = commands;
|
|
13
|
+
this.pathSecurity = pathSecurity;
|
|
14
|
+
}
|
|
15
|
+
async execute(rawArgs) {
|
|
16
|
+
const args = normalizeParameters(rawArgs || {});
|
|
17
|
+
if (!args.action || typeof args.baselinePath !== 'string')
|
|
18
|
+
return createErrorResponse('action and baselinePath are required.');
|
|
19
|
+
const projectPath = this.commands.connectedProjectPath();
|
|
20
|
+
if (!projectPath)
|
|
21
|
+
return createErrorResponse('No connected Godot project. Use run_project first.');
|
|
22
|
+
const baselinePath = this.pathSecurity.resolveProjectPath(projectPath, args.baselinePath);
|
|
23
|
+
if (!baselinePath || !/\.png$/i.test(baselinePath))
|
|
24
|
+
return createErrorResponse('baselinePath must be a project-relative PNG path.');
|
|
25
|
+
const capture = await this.capture();
|
|
26
|
+
if ('error' in capture)
|
|
27
|
+
return createErrorResponse(capture.error);
|
|
28
|
+
if (capture.width * capture.height > VisualRegressionService.maxPixels) {
|
|
29
|
+
return createErrorResponse(`Screenshot exceeds ${VisualRegressionService.maxPixels} pixels.`);
|
|
30
|
+
}
|
|
31
|
+
if (args.action === 'capture_baseline') {
|
|
32
|
+
mkdirSync(dirname(baselinePath), { recursive: true });
|
|
33
|
+
writeFileSync(baselinePath, capture.png);
|
|
34
|
+
return this.response({ action: args.action, passed: true, baseline_path: args.baselinePath,
|
|
35
|
+
width: capture.width, height: capture.height, sha256: this.digest(capture.png), renderer: capture.renderer });
|
|
36
|
+
}
|
|
37
|
+
if (args.action !== 'compare')
|
|
38
|
+
return createErrorResponse('action must be capture_baseline or compare.');
|
|
39
|
+
if (!existsSync(baselinePath))
|
|
40
|
+
return createErrorResponse(`Baseline does not exist: ${args.baselinePath}`);
|
|
41
|
+
try {
|
|
42
|
+
const baselineBytes = readFileSync(baselinePath);
|
|
43
|
+
const baseline = PNG.sync.read(baselineBytes);
|
|
44
|
+
const actual = PNG.sync.read(capture.png);
|
|
45
|
+
if (baseline.width !== actual.width || baseline.height !== actual.height) {
|
|
46
|
+
return this.response({ action: args.action, passed: false, reason: 'dimension_mismatch',
|
|
47
|
+
expected: [baseline.width, baseline.height], actual: [actual.width, actual.height], renderer: capture.renderer }, true);
|
|
48
|
+
}
|
|
49
|
+
const mask = this.readMask(projectPath, args.maskPath, actual.width, actual.height);
|
|
50
|
+
if ('error' in mask)
|
|
51
|
+
return createErrorResponse(mask.error);
|
|
52
|
+
const tolerance = typeof args.channelTolerance === 'number' ? args.channelTolerance : 0;
|
|
53
|
+
const allowedRatio = typeof args.maxDifferentPixelRatio === 'number' ? args.maxDifferentPixelRatio : 0;
|
|
54
|
+
const diff = new PNG({ width: actual.width, height: actual.height });
|
|
55
|
+
let compared = 0;
|
|
56
|
+
let different = 0;
|
|
57
|
+
let maximumDelta = 0;
|
|
58
|
+
for (let pixel = 0; pixel < actual.width * actual.height; pixel++) {
|
|
59
|
+
const offset = pixel * 4;
|
|
60
|
+
if (mask.png && mask.png.data[offset + 3] === 0) {
|
|
61
|
+
actual.data.copy(diff.data, offset, offset, offset + 4);
|
|
62
|
+
diff.data[offset + 3] = 64;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
compared++;
|
|
66
|
+
let mismatch = false;
|
|
67
|
+
for (let channel = 0; channel < 4; channel++) {
|
|
68
|
+
const delta = Math.abs(actual.data[offset + channel] - baseline.data[offset + channel]);
|
|
69
|
+
maximumDelta = Math.max(maximumDelta, delta);
|
|
70
|
+
if (delta > tolerance)
|
|
71
|
+
mismatch = true;
|
|
72
|
+
}
|
|
73
|
+
if (mismatch) {
|
|
74
|
+
different++;
|
|
75
|
+
diff.data.set([255, 0, 0, 255], offset);
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
actual.data.copy(diff.data, offset, offset, offset + 4);
|
|
79
|
+
}
|
|
80
|
+
const ratio = compared === 0 ? 0 : different / compared;
|
|
81
|
+
const passed = ratio <= allowedRatio;
|
|
82
|
+
const artifactPath = typeof args.diffArtifactPath === 'string'
|
|
83
|
+
? this.pathSecurity.resolveProjectPath(projectPath, args.diffArtifactPath) : null;
|
|
84
|
+
if (args.diffArtifactPath && (!artifactPath || !/\.png$/i.test(artifactPath))) {
|
|
85
|
+
return createErrorResponse('diffArtifactPath must be a project-relative PNG path.');
|
|
86
|
+
}
|
|
87
|
+
if (artifactPath) {
|
|
88
|
+
mkdirSync(dirname(artifactPath), { recursive: true });
|
|
89
|
+
writeFileSync(artifactPath, PNG.sync.write(diff));
|
|
90
|
+
}
|
|
91
|
+
return this.response({ action: args.action, passed, baseline_path: args.baselinePath,
|
|
92
|
+
diff_artifact_path: args.diffArtifactPath ?? null, width: actual.width, height: actual.height,
|
|
93
|
+
compared_pixels: compared, different_pixels: different, different_pixel_ratio: ratio,
|
|
94
|
+
maximum_channel_delta: maximumDelta, channel_tolerance: tolerance,
|
|
95
|
+
max_different_pixel_ratio: allowedRatio, baseline_sha256: this.digest(baselineBytes),
|
|
96
|
+
actual_sha256: this.digest(capture.png), renderer: capture.renderer }, !passed);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return createErrorResponse(`Visual comparison failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async capture() {
|
|
103
|
+
if (!this.commands.hasActiveProcess())
|
|
104
|
+
return { error: 'No active Godot process. Use run_project first.' };
|
|
105
|
+
if (!this.commands.isConnected())
|
|
106
|
+
return { error: 'Not connected to game interaction server.' };
|
|
107
|
+
const screenshot = await this.commands.send('screenshot');
|
|
108
|
+
if ('error' in screenshot)
|
|
109
|
+
return { error: `Screenshot failed: ${screenshot.error.message}` };
|
|
110
|
+
const value = screenshot.result;
|
|
111
|
+
if (!value.data || !value.width || !value.height)
|
|
112
|
+
return { error: 'Screenshot returned incomplete PNG evidence.' };
|
|
113
|
+
const osInfo = await this.commands.send('os_info');
|
|
114
|
+
return { png: Buffer.from(value.data, 'base64'), width: value.width, height: value.height,
|
|
115
|
+
renderer: 'error' in osInfo ? { unavailable: osInfo.error.message } : osInfo.result };
|
|
116
|
+
}
|
|
117
|
+
readMask(projectPath, maskPath, width, height) {
|
|
118
|
+
if (!maskPath)
|
|
119
|
+
return { png: null };
|
|
120
|
+
if (typeof maskPath !== 'string')
|
|
121
|
+
return { error: 'maskPath must be a string.' };
|
|
122
|
+
const fullPath = this.pathSecurity.resolveProjectPath(projectPath, maskPath);
|
|
123
|
+
if (!fullPath || !existsSync(fullPath) || !/\.png$/i.test(fullPath))
|
|
124
|
+
return { error: `Mask does not exist or is not a PNG: ${maskPath}` };
|
|
125
|
+
try {
|
|
126
|
+
const png = PNG.sync.read(readFileSync(fullPath));
|
|
127
|
+
if (png.width !== width || png.height !== height)
|
|
128
|
+
return { error: 'Mask dimensions must match the screenshot.' };
|
|
129
|
+
return { png };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
return { error: `Could not decode mask: ${error instanceof Error ? error.message : String(error)}` };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
digest(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
136
|
+
response(value, isError = false) {
|
|
137
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], ...(isError ? { isError: true } : {}) };
|
|
138
|
+
}
|
|
139
|
+
}
|