roblox-studio-physical-operation-mcp 0.1.0__tar.gz → 0.1.1__tar.gz
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.
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/.gitignore +3 -1
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/PKG-INFO +1 -1
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/pyproject.toml +1 -1
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/log_utils.py +467 -467
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/server.py +1 -1
- roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/pause.png +0 -0
- roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/play.png +0 -0
- roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/stop.png +0 -0
- roblox_studio_physical_operation_mcp-0.1.1/tests/default.project.json +27 -0
- roblox_studio_physical_operation_mcp-0.1.1/tests/game.rbxl +0 -0
- roblox_studio_physical_operation_mcp-0.1.1/tests/src/TestScript.server.lua +32 -0
- roblox_studio_physical_operation_mcp-0.1.1/tests/test_fix_verify.py +77 -0
- roblox_studio_physical_operation_mcp-0.1.1/tests/test_warn_log.py +100 -0
- roblox_studio_physical_operation_mcp-0.1.0/tests/game.rbxl +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/.github/workflows/publish.yml +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/LICENSE +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/README.md +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/requirements.txt +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/__init__.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/__main__.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/log_filter.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/studio_manager.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/toolbar_detector.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/roblox_studio_physical_operation_mcp/windows_utils.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/run_server.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/README.md +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_full.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_postmessage.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_sendinput.py +0 -0
- {roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/tests/test_window_capture.py +0 -0
{roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roblox-studio-physical-operation-mcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: MCP server for controlling Roblox Studio - toolbar detection, game control, log analysis
|
|
5
5
|
Project-URL: Homepage, https://github.com/white-dragon-tools/roblox-studio-physical-operation-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/white-dragon-tools/roblox-studio-physical-operation-mcp
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "roblox-studio-physical-operation-mcp"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "MCP server for controlling Roblox Studio - toolbar detection, game control, log analysis"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,467 +1,467 @@
|
|
|
1
|
-
"""
|
|
2
|
-
日志工具模块: 日志读取、搜索等
|
|
3
|
-
|
|
4
|
-
优化:
|
|
5
|
-
- 从文件末尾倒序读取,适合大文件
|
|
6
|
-
- 支持过滤特定类别 (如 FLog::Output)
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import os
|
|
10
|
-
import re
|
|
11
|
-
from typing import Optional, Generator
|
|
12
|
-
from dataclasses import dataclass
|
|
13
|
-
|
|
14
|
-
LOG_DIR = os.path.expandvars(r"%LOCALAPPDATA%\Roblox\logs")
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
DEFAULT_CATEGORIES = ["FLog::Output"]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@dataclass
|
|
21
|
-
class LogEntry:
|
|
22
|
-
timestamp: str
|
|
23
|
-
level: str
|
|
24
|
-
category: str
|
|
25
|
-
message: str
|
|
26
|
-
raw: str
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def parse_log_line(line: str) -> Optional[LogEntry]:
|
|
30
|
-
"""解析单行日志"""
|
|
31
|
-
# 格式: 2026-02-03T08:52:02.095Z,128.095795,1996c,12 [DFLog::HttpTraceError] message
|
|
32
|
-
# 或: 2026-02-03T08:52:04.244Z,130.244095,12f4,6,Info [FLog::...] message
|
|
33
|
-
match = re.match(
|
|
34
|
-
r'^(\d{4}-\d{2}-\d{2}T[\d:.]+Z),[\d.]+,[a-f0-9]+,\d+(?:,(\w+))?\s*\[([^\]]+)\]\s*(.*)$',
|
|
35
|
-
line
|
|
36
|
-
)
|
|
37
|
-
if match:
|
|
38
|
-
return LogEntry(
|
|
39
|
-
timestamp=match.group(1),
|
|
40
|
-
level=match.group(2) or "Info",
|
|
41
|
-
category=match.group(3),
|
|
42
|
-
message=match.group(4),
|
|
43
|
-
raw=line
|
|
44
|
-
)
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def read_file_reverse(file_path: str, chunk_size: int = 8192) -> Generator[str, None, None]:
|
|
49
|
-
"""
|
|
50
|
-
从文件末尾倒序读取行
|
|
51
|
-
|
|
52
|
-
对于大文件,这比读取整个文件再 reverse 更高效
|
|
53
|
-
"""
|
|
54
|
-
with open(file_path, 'rb') as f:
|
|
55
|
-
# 移动到文件末尾
|
|
56
|
-
f.seek(0, 2)
|
|
57
|
-
file_size = f.tell()
|
|
58
|
-
|
|
59
|
-
buffer = b''
|
|
60
|
-
position = file_size
|
|
61
|
-
|
|
62
|
-
while position > 0:
|
|
63
|
-
# 计算读取位置
|
|
64
|
-
read_size = min(chunk_size, position)
|
|
65
|
-
position -= read_size
|
|
66
|
-
f.seek(position)
|
|
67
|
-
|
|
68
|
-
# 读取并拼接
|
|
69
|
-
chunk = f.read(read_size)
|
|
70
|
-
buffer = chunk + buffer
|
|
71
|
-
|
|
72
|
-
# 按行分割
|
|
73
|
-
lines = buffer.split(b'\n')
|
|
74
|
-
|
|
75
|
-
# 最后一个可能不完整,保留到下次
|
|
76
|
-
buffer = lines[0]
|
|
77
|
-
|
|
78
|
-
# 倒序返回完整的行
|
|
79
|
-
for line in reversed(lines[1:]):
|
|
80
|
-
line_str = line.decode('utf-8', errors='ignore').strip()
|
|
81
|
-
if line_str:
|
|
82
|
-
yield line_str
|
|
83
|
-
|
|
84
|
-
# 处理剩余的 buffer
|
|
85
|
-
if buffer:
|
|
86
|
-
line_str = buffer.decode('utf-8', errors='ignore').strip()
|
|
87
|
-
if line_str:
|
|
88
|
-
yield line_str
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
from .log_filter import should_exclude
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def get_logs_from_line(
|
|
95
|
-
log_path: str,
|
|
96
|
-
after_line: int = None,
|
|
97
|
-
before_line: int = None,
|
|
98
|
-
timestamps: bool = False,
|
|
99
|
-
categories: list[str] = None,
|
|
100
|
-
apply_filter: bool = True
|
|
101
|
-
) -> dict:
|
|
102
|
-
"""
|
|
103
|
-
从指定行范围读取日志
|
|
104
|
-
|
|
105
|
-
Args:
|
|
106
|
-
log_path: 日志文件路径
|
|
107
|
-
after_line: 从哪一行之后开始读取,None 表示从头开始
|
|
108
|
-
before_line: 到哪一行之前结束,None 表示到末尾
|
|
109
|
-
timestamps: 是否附加时间戳
|
|
110
|
-
categories: 只返回这些类别的日志,默认 ["FLog::Output"]
|
|
111
|
-
apply_filter: 是否应用过滤规则排除 Studio 内部日志
|
|
112
|
-
|
|
113
|
-
Returns:
|
|
114
|
-
{
|
|
115
|
-
"logs": "日志文本",
|
|
116
|
-
"start_line": 起始行号,
|
|
117
|
-
"last_line": 最后行号,
|
|
118
|
-
"remaining": 剩余有效日志行数,
|
|
119
|
-
"has_more": 是否还有更多
|
|
120
|
-
}
|
|
121
|
-
"""
|
|
122
|
-
MAX_BYTES = 32000
|
|
123
|
-
|
|
124
|
-
if not os.path.exists(log_path):
|
|
125
|
-
return {"logs": "", "start_line": 0, "last_line": 0, "remaining": 0, "has_more": False}
|
|
126
|
-
|
|
127
|
-
if categories is None:
|
|
128
|
-
categories = DEFAULT_CATEGORIES
|
|
129
|
-
|
|
130
|
-
start_line = None
|
|
131
|
-
last_line = 0
|
|
132
|
-
current_bytes = 0
|
|
133
|
-
log_lines = []
|
|
134
|
-
remaining = 0
|
|
135
|
-
bytes_exceeded = False
|
|
136
|
-
|
|
137
|
-
try:
|
|
138
|
-
with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
139
|
-
for line_num, line in enumerate(f, 1):
|
|
140
|
-
# 跳过 after_line 之前的行
|
|
141
|
-
if after_line is not None and line_num <= after_line:
|
|
142
|
-
continue
|
|
143
|
-
|
|
144
|
-
# 停止在 before_line
|
|
145
|
-
if before_line is not None and line_num >= before_line:
|
|
146
|
-
break
|
|
147
|
-
|
|
148
|
-
line = line.strip()
|
|
149
|
-
if not line:
|
|
150
|
-
continue
|
|
151
|
-
|
|
152
|
-
entry = parse_log_line(line)
|
|
153
|
-
if not entry:
|
|
154
|
-
continue
|
|
155
|
-
|
|
156
|
-
# 类别过滤
|
|
157
|
-
if categories and entry.category not in categories:
|
|
158
|
-
continue
|
|
159
|
-
|
|
160
|
-
# 应用排除规则
|
|
161
|
-
if apply_filter and should_exclude(entry.message):
|
|
162
|
-
continue
|
|
163
|
-
|
|
164
|
-
# 这是一条有效日志
|
|
165
|
-
remaining += 1
|
|
166
|
-
|
|
167
|
-
# 如果已超过字节限制,只统计不添加
|
|
168
|
-
if bytes_exceeded:
|
|
169
|
-
continue
|
|
170
|
-
|
|
171
|
-
# 格式化输出
|
|
172
|
-
if timestamps:
|
|
173
|
-
time_part = entry.timestamp[11:19]
|
|
174
|
-
output_line = f"[{time_part}] {entry.message}"
|
|
175
|
-
else:
|
|
176
|
-
output_line = entry.message
|
|
177
|
-
|
|
178
|
-
line_bytes = len(output_line.encode('utf-8')) + 1
|
|
179
|
-
|
|
180
|
-
# 检查是否超过字节限制
|
|
181
|
-
if current_bytes + line_bytes > MAX_BYTES and log_lines:
|
|
182
|
-
bytes_exceeded = True
|
|
183
|
-
continue
|
|
184
|
-
|
|
185
|
-
if start_line is None:
|
|
186
|
-
start_line = line_num
|
|
187
|
-
|
|
188
|
-
log_lines.append(output_line)
|
|
189
|
-
last_line = line_num
|
|
190
|
-
current_bytes += line_bytes
|
|
191
|
-
|
|
192
|
-
except Exception:
|
|
193
|
-
pass
|
|
194
|
-
|
|
195
|
-
returned_count = len(log_lines)
|
|
196
|
-
return {
|
|
197
|
-
"logs": "\n".join(log_lines),
|
|
198
|
-
"start_line": start_line or 0,
|
|
199
|
-
"last_line": last_line,
|
|
200
|
-
"remaining": remaining - returned_count,
|
|
201
|
-
"has_more": remaining > returned_count
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def get_recent_logs(
|
|
206
|
-
log_path: str,
|
|
207
|
-
limit: int = 100,
|
|
208
|
-
categories: list[str] = None,
|
|
209
|
-
min_level: Optional[str] = None,
|
|
210
|
-
apply_filter: bool = True
|
|
211
|
-
) -> list[LogEntry]:
|
|
212
|
-
"""
|
|
213
|
-
从日志文件末尾读取最近的日志
|
|
214
|
-
|
|
215
|
-
Args:
|
|
216
|
-
log_path: 日志文件路径
|
|
217
|
-
limit: 返回的最大条数
|
|
218
|
-
categories: 只返回这些类别的日志,默认 ["FLog::Output"]
|
|
219
|
-
min_level: 最低日志级别过滤
|
|
220
|
-
apply_filter: 是否应用过滤规则排除 Studio 内部日志,默认 True
|
|
221
|
-
|
|
222
|
-
Returns:
|
|
223
|
-
日志条目列表 (按时间正序)
|
|
224
|
-
"""
|
|
225
|
-
if not os.path.exists(log_path):
|
|
226
|
-
return []
|
|
227
|
-
|
|
228
|
-
if categories is None:
|
|
229
|
-
categories = DEFAULT_CATEGORIES
|
|
230
|
-
|
|
231
|
-
entries = []
|
|
232
|
-
try:
|
|
233
|
-
for line in read_file_reverse(log_path):
|
|
234
|
-
entry = parse_log_line(line)
|
|
235
|
-
if not entry:
|
|
236
|
-
continue
|
|
237
|
-
|
|
238
|
-
# 类别过滤
|
|
239
|
-
if categories and entry.category not in categories:
|
|
240
|
-
continue
|
|
241
|
-
|
|
242
|
-
# 级别过滤
|
|
243
|
-
if min_level and entry.level.lower() != min_level.lower():
|
|
244
|
-
continue
|
|
245
|
-
|
|
246
|
-
# 应用排除规则
|
|
247
|
-
if apply_filter and should_exclude(entry.message):
|
|
248
|
-
continue
|
|
249
|
-
|
|
250
|
-
entries.append(entry)
|
|
251
|
-
if len(entries) >= limit:
|
|
252
|
-
break
|
|
253
|
-
except Exception:
|
|
254
|
-
pass
|
|
255
|
-
|
|
256
|
-
# 返回正序 (最旧的在前)
|
|
257
|
-
return list(reversed(entries))
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def get_all_logs(
|
|
261
|
-
log_path: str,
|
|
262
|
-
limit: int = 100,
|
|
263
|
-
min_level: Optional[str] = None
|
|
264
|
-
) -> list[LogEntry]:
|
|
265
|
-
"""
|
|
266
|
-
获取所有类别的日志 (不过滤类别)
|
|
267
|
-
"""
|
|
268
|
-
return get_recent_logs(log_path, limit, categories=[], min_level=min_level)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def search_logs(
|
|
272
|
-
log_path: str,
|
|
273
|
-
pattern: str,
|
|
274
|
-
limit: int = 50,
|
|
275
|
-
categories: list[str] = None
|
|
276
|
-
) -> list[LogEntry]:
|
|
277
|
-
"""
|
|
278
|
-
在日志中搜索匹配的条目
|
|
279
|
-
|
|
280
|
-
Args:
|
|
281
|
-
log_path: 日志文件路径
|
|
282
|
-
pattern: 正则表达式模式
|
|
283
|
-
limit: 返回的最大条数
|
|
284
|
-
categories: 只搜索这些类别,None 表示搜索所有
|
|
285
|
-
"""
|
|
286
|
-
if not os.path.exists(log_path):
|
|
287
|
-
return []
|
|
288
|
-
|
|
289
|
-
entries = []
|
|
290
|
-
regex = re.compile(pattern, re.IGNORECASE)
|
|
291
|
-
|
|
292
|
-
try:
|
|
293
|
-
for line in read_file_reverse(log_path):
|
|
294
|
-
if not regex.search(line):
|
|
295
|
-
continue
|
|
296
|
-
|
|
297
|
-
entry = parse_log_line(line)
|
|
298
|
-
if not entry:
|
|
299
|
-
continue
|
|
300
|
-
|
|
301
|
-
if categories and entry.category not in categories:
|
|
302
|
-
continue
|
|
303
|
-
|
|
304
|
-
entries.append(entry)
|
|
305
|
-
if len(entries) >= limit:
|
|
306
|
-
break
|
|
307
|
-
except Exception:
|
|
308
|
-
pass
|
|
309
|
-
|
|
310
|
-
return list(reversed(entries))
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def search_logs_from_line(
|
|
314
|
-
log_path: str,
|
|
315
|
-
pattern: str,
|
|
316
|
-
after_line: int = None,
|
|
317
|
-
before_line: int = None,
|
|
318
|
-
timestamps: bool = False,
|
|
319
|
-
categories: list[str] = None,
|
|
320
|
-
apply_filter: bool = True
|
|
321
|
-
) -> dict:
|
|
322
|
-
"""
|
|
323
|
-
在指定行范围内搜索日志
|
|
324
|
-
|
|
325
|
-
Args:
|
|
326
|
-
log_path: 日志文件路径
|
|
327
|
-
pattern: 正则表达式模式
|
|
328
|
-
after_line: 从哪一行之后开始搜索
|
|
329
|
-
before_line: 到哪一行之前结束
|
|
330
|
-
timestamps: 是否附加时间戳
|
|
331
|
-
categories: 只搜索这些类别,默认 ["FLog::Output"]
|
|
332
|
-
apply_filter: 是否应用过滤规则
|
|
333
|
-
|
|
334
|
-
Returns:
|
|
335
|
-
{
|
|
336
|
-
"logs": "匹配的日志文本",
|
|
337
|
-
"start_line": 起始行号,
|
|
338
|
-
"last_line": 最后行号,
|
|
339
|
-
"match_count": 匹配条数,
|
|
340
|
-
"remaining": 剩余匹配数,
|
|
341
|
-
"has_more": 是否还有更多
|
|
342
|
-
}
|
|
343
|
-
"""
|
|
344
|
-
MAX_BYTES = 32000
|
|
345
|
-
|
|
346
|
-
if not os.path.exists(log_path):
|
|
347
|
-
return {"logs": "", "start_line": 0, "last_line": 0, "match_count": 0, "remaining": 0, "has_more": False}
|
|
348
|
-
|
|
349
|
-
if categories is None:
|
|
350
|
-
categories = DEFAULT_CATEGORIES
|
|
351
|
-
|
|
352
|
-
try:
|
|
353
|
-
regex = re.compile(pattern, re.IGNORECASE)
|
|
354
|
-
except re.error:
|
|
355
|
-
return {"error": f"Invalid regex pattern: {pattern}"}
|
|
356
|
-
|
|
357
|
-
start_line = None
|
|
358
|
-
last_line = 0
|
|
359
|
-
current_bytes = 0
|
|
360
|
-
log_lines = []
|
|
361
|
-
match_count = 0
|
|
362
|
-
bytes_exceeded = False
|
|
363
|
-
|
|
364
|
-
try:
|
|
365
|
-
with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
366
|
-
for line_num, line in enumerate(f, 1):
|
|
367
|
-
if after_line is not None and line_num <= after_line:
|
|
368
|
-
continue
|
|
369
|
-
|
|
370
|
-
if before_line is not None and line_num >= before_line:
|
|
371
|
-
break
|
|
372
|
-
|
|
373
|
-
line = line.strip()
|
|
374
|
-
if not line:
|
|
375
|
-
continue
|
|
376
|
-
|
|
377
|
-
entry = parse_log_line(line)
|
|
378
|
-
if not entry:
|
|
379
|
-
continue
|
|
380
|
-
|
|
381
|
-
if categories and entry.category not in categories:
|
|
382
|
-
continue
|
|
383
|
-
|
|
384
|
-
if apply_filter and should_exclude(entry.message):
|
|
385
|
-
continue
|
|
386
|
-
|
|
387
|
-
# 正则匹配
|
|
388
|
-
if not regex.search(entry.message):
|
|
389
|
-
continue
|
|
390
|
-
|
|
391
|
-
match_count += 1
|
|
392
|
-
|
|
393
|
-
if bytes_exceeded:
|
|
394
|
-
continue
|
|
395
|
-
|
|
396
|
-
if timestamps:
|
|
397
|
-
time_part = entry.timestamp[11:19]
|
|
398
|
-
output_line = f"{line_num}|[{time_part}] {entry.message}"
|
|
399
|
-
else:
|
|
400
|
-
output_line = f"{line_num}|{entry.message}"
|
|
401
|
-
|
|
402
|
-
line_bytes = len(output_line.encode('utf-8')) + 1
|
|
403
|
-
|
|
404
|
-
if current_bytes + line_bytes > MAX_BYTES and log_lines:
|
|
405
|
-
bytes_exceeded = True
|
|
406
|
-
continue
|
|
407
|
-
|
|
408
|
-
if start_line is None:
|
|
409
|
-
start_line = line_num
|
|
410
|
-
|
|
411
|
-
log_lines.append(output_line)
|
|
412
|
-
last_line = line_num
|
|
413
|
-
current_bytes += line_bytes
|
|
414
|
-
|
|
415
|
-
except Exception:
|
|
416
|
-
pass
|
|
417
|
-
|
|
418
|
-
returned_count = len(log_lines)
|
|
419
|
-
return {
|
|
420
|
-
"logs": "\n".join(log_lines),
|
|
421
|
-
"start_line": start_line or 0,
|
|
422
|
-
"last_line": last_line,
|
|
423
|
-
"match_count": returned_count,
|
|
424
|
-
"remaining": match_count - returned_count,
|
|
425
|
-
"has_more": match_count > returned_count
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def find_latest_studio_log() -> Optional[str]:
|
|
430
|
-
"""查找最新的 Studio 日志文件"""
|
|
431
|
-
if not os.path.exists(LOG_DIR):
|
|
432
|
-
return None
|
|
433
|
-
|
|
434
|
-
studio_logs = []
|
|
435
|
-
for f in os.listdir(LOG_DIR):
|
|
436
|
-
if "Studio" in f and f.endswith(".log"):
|
|
437
|
-
path = os.path.join(LOG_DIR, f)
|
|
438
|
-
studio_logs.append((path, os.path.getmtime(path)))
|
|
439
|
-
|
|
440
|
-
if not studio_logs:
|
|
441
|
-
return None
|
|
442
|
-
|
|
443
|
-
studio_logs.sort(key=lambda x: x[1], reverse=True)
|
|
444
|
-
return studio_logs[0][0]
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def clean_old_logs(days: int = 7) -> int:
|
|
448
|
-
"""清理超过指定天数的旧日志"""
|
|
449
|
-
if not os.path.exists(LOG_DIR):
|
|
450
|
-
return 0
|
|
451
|
-
|
|
452
|
-
from datetime import datetime
|
|
453
|
-
count = 0
|
|
454
|
-
now = datetime.now().timestamp()
|
|
455
|
-
threshold = days * 24 * 60 * 60
|
|
456
|
-
|
|
457
|
-
for f in os.listdir(LOG_DIR):
|
|
458
|
-
if f.endswith(".log"):
|
|
459
|
-
path = os.path.join(LOG_DIR, f)
|
|
460
|
-
try:
|
|
461
|
-
if now - os.path.getmtime(path) > threshold:
|
|
462
|
-
os.remove(path)
|
|
463
|
-
count += 1
|
|
464
|
-
except Exception:
|
|
465
|
-
pass
|
|
466
|
-
|
|
467
|
-
return count
|
|
1
|
+
"""
|
|
2
|
+
日志工具模块: 日志读取、搜索等
|
|
3
|
+
|
|
4
|
+
优化:
|
|
5
|
+
- 从文件末尾倒序读取,适合大文件
|
|
6
|
+
- 支持过滤特定类别 (如 FLog::Output)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from typing import Optional, Generator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
LOG_DIR = os.path.expandvars(r"%LOCALAPPDATA%\Roblox\logs")
|
|
15
|
+
|
|
16
|
+
# 默认读取这些类别的日志(包含用户脚本的 print 和 warn 输出)
|
|
17
|
+
DEFAULT_CATEGORIES = ["FLog::Output", "FLog::Warning"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class LogEntry:
|
|
22
|
+
timestamp: str
|
|
23
|
+
level: str
|
|
24
|
+
category: str
|
|
25
|
+
message: str
|
|
26
|
+
raw: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_log_line(line: str) -> Optional[LogEntry]:
|
|
30
|
+
"""解析单行日志"""
|
|
31
|
+
# 格式: 2026-02-03T08:52:02.095Z,128.095795,1996c,12 [DFLog::HttpTraceError] message
|
|
32
|
+
# 或: 2026-02-03T08:52:04.244Z,130.244095,12f4,6,Info [FLog::...] message
|
|
33
|
+
match = re.match(
|
|
34
|
+
r'^(\d{4}-\d{2}-\d{2}T[\d:.]+Z),[\d.]+,[a-f0-9]+,\d+(?:,(\w+))?\s*\[([^\]]+)\]\s*(.*)$',
|
|
35
|
+
line
|
|
36
|
+
)
|
|
37
|
+
if match:
|
|
38
|
+
return LogEntry(
|
|
39
|
+
timestamp=match.group(1),
|
|
40
|
+
level=match.group(2) or "Info",
|
|
41
|
+
category=match.group(3),
|
|
42
|
+
message=match.group(4),
|
|
43
|
+
raw=line
|
|
44
|
+
)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_file_reverse(file_path: str, chunk_size: int = 8192) -> Generator[str, None, None]:
|
|
49
|
+
"""
|
|
50
|
+
从文件末尾倒序读取行
|
|
51
|
+
|
|
52
|
+
对于大文件,这比读取整个文件再 reverse 更高效
|
|
53
|
+
"""
|
|
54
|
+
with open(file_path, 'rb') as f:
|
|
55
|
+
# 移动到文件末尾
|
|
56
|
+
f.seek(0, 2)
|
|
57
|
+
file_size = f.tell()
|
|
58
|
+
|
|
59
|
+
buffer = b''
|
|
60
|
+
position = file_size
|
|
61
|
+
|
|
62
|
+
while position > 0:
|
|
63
|
+
# 计算读取位置
|
|
64
|
+
read_size = min(chunk_size, position)
|
|
65
|
+
position -= read_size
|
|
66
|
+
f.seek(position)
|
|
67
|
+
|
|
68
|
+
# 读取并拼接
|
|
69
|
+
chunk = f.read(read_size)
|
|
70
|
+
buffer = chunk + buffer
|
|
71
|
+
|
|
72
|
+
# 按行分割
|
|
73
|
+
lines = buffer.split(b'\n')
|
|
74
|
+
|
|
75
|
+
# 最后一个可能不完整,保留到下次
|
|
76
|
+
buffer = lines[0]
|
|
77
|
+
|
|
78
|
+
# 倒序返回完整的行
|
|
79
|
+
for line in reversed(lines[1:]):
|
|
80
|
+
line_str = line.decode('utf-8', errors='ignore').strip()
|
|
81
|
+
if line_str:
|
|
82
|
+
yield line_str
|
|
83
|
+
|
|
84
|
+
# 处理剩余的 buffer
|
|
85
|
+
if buffer:
|
|
86
|
+
line_str = buffer.decode('utf-8', errors='ignore').strip()
|
|
87
|
+
if line_str:
|
|
88
|
+
yield line_str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
from .log_filter import should_exclude
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_logs_from_line(
|
|
95
|
+
log_path: str,
|
|
96
|
+
after_line: int = None,
|
|
97
|
+
before_line: int = None,
|
|
98
|
+
timestamps: bool = False,
|
|
99
|
+
categories: list[str] = None,
|
|
100
|
+
apply_filter: bool = True
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""
|
|
103
|
+
从指定行范围读取日志
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
log_path: 日志文件路径
|
|
107
|
+
after_line: 从哪一行之后开始读取,None 表示从头开始
|
|
108
|
+
before_line: 到哪一行之前结束,None 表示到末尾
|
|
109
|
+
timestamps: 是否附加时间戳
|
|
110
|
+
categories: 只返回这些类别的日志,默认 ["FLog::Output"]
|
|
111
|
+
apply_filter: 是否应用过滤规则排除 Studio 内部日志
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
{
|
|
115
|
+
"logs": "日志文本",
|
|
116
|
+
"start_line": 起始行号,
|
|
117
|
+
"last_line": 最后行号,
|
|
118
|
+
"remaining": 剩余有效日志行数,
|
|
119
|
+
"has_more": 是否还有更多
|
|
120
|
+
}
|
|
121
|
+
"""
|
|
122
|
+
MAX_BYTES = 32000
|
|
123
|
+
|
|
124
|
+
if not os.path.exists(log_path):
|
|
125
|
+
return {"logs": "", "start_line": 0, "last_line": 0, "remaining": 0, "has_more": False}
|
|
126
|
+
|
|
127
|
+
if categories is None:
|
|
128
|
+
categories = DEFAULT_CATEGORIES
|
|
129
|
+
|
|
130
|
+
start_line = None
|
|
131
|
+
last_line = 0
|
|
132
|
+
current_bytes = 0
|
|
133
|
+
log_lines = []
|
|
134
|
+
remaining = 0
|
|
135
|
+
bytes_exceeded = False
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
139
|
+
for line_num, line in enumerate(f, 1):
|
|
140
|
+
# 跳过 after_line 之前的行
|
|
141
|
+
if after_line is not None and line_num <= after_line:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# 停止在 before_line
|
|
145
|
+
if before_line is not None and line_num >= before_line:
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
line = line.strip()
|
|
149
|
+
if not line:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
entry = parse_log_line(line)
|
|
153
|
+
if not entry:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# 类别过滤
|
|
157
|
+
if categories and entry.category not in categories:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# 应用排除规则
|
|
161
|
+
if apply_filter and should_exclude(entry.message):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# 这是一条有效日志
|
|
165
|
+
remaining += 1
|
|
166
|
+
|
|
167
|
+
# 如果已超过字节限制,只统计不添加
|
|
168
|
+
if bytes_exceeded:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# 格式化输出
|
|
172
|
+
if timestamps:
|
|
173
|
+
time_part = entry.timestamp[11:19]
|
|
174
|
+
output_line = f"[{time_part}] {entry.message}"
|
|
175
|
+
else:
|
|
176
|
+
output_line = entry.message
|
|
177
|
+
|
|
178
|
+
line_bytes = len(output_line.encode('utf-8')) + 1
|
|
179
|
+
|
|
180
|
+
# 检查是否超过字节限制
|
|
181
|
+
if current_bytes + line_bytes > MAX_BYTES and log_lines:
|
|
182
|
+
bytes_exceeded = True
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if start_line is None:
|
|
186
|
+
start_line = line_num
|
|
187
|
+
|
|
188
|
+
log_lines.append(output_line)
|
|
189
|
+
last_line = line_num
|
|
190
|
+
current_bytes += line_bytes
|
|
191
|
+
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
returned_count = len(log_lines)
|
|
196
|
+
return {
|
|
197
|
+
"logs": "\n".join(log_lines),
|
|
198
|
+
"start_line": start_line or 0,
|
|
199
|
+
"last_line": last_line,
|
|
200
|
+
"remaining": remaining - returned_count,
|
|
201
|
+
"has_more": remaining > returned_count
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_recent_logs(
|
|
206
|
+
log_path: str,
|
|
207
|
+
limit: int = 100,
|
|
208
|
+
categories: list[str] = None,
|
|
209
|
+
min_level: Optional[str] = None,
|
|
210
|
+
apply_filter: bool = True
|
|
211
|
+
) -> list[LogEntry]:
|
|
212
|
+
"""
|
|
213
|
+
从日志文件末尾读取最近的日志
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
log_path: 日志文件路径
|
|
217
|
+
limit: 返回的最大条数
|
|
218
|
+
categories: 只返回这些类别的日志,默认 ["FLog::Output"]
|
|
219
|
+
min_level: 最低日志级别过滤
|
|
220
|
+
apply_filter: 是否应用过滤规则排除 Studio 内部日志,默认 True
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
日志条目列表 (按时间正序)
|
|
224
|
+
"""
|
|
225
|
+
if not os.path.exists(log_path):
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
if categories is None:
|
|
229
|
+
categories = DEFAULT_CATEGORIES
|
|
230
|
+
|
|
231
|
+
entries = []
|
|
232
|
+
try:
|
|
233
|
+
for line in read_file_reverse(log_path):
|
|
234
|
+
entry = parse_log_line(line)
|
|
235
|
+
if not entry:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# 类别过滤
|
|
239
|
+
if categories and entry.category not in categories:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# 级别过滤
|
|
243
|
+
if min_level and entry.level.lower() != min_level.lower():
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
# 应用排除规则
|
|
247
|
+
if apply_filter and should_exclude(entry.message):
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
entries.append(entry)
|
|
251
|
+
if len(entries) >= limit:
|
|
252
|
+
break
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# 返回正序 (最旧的在前)
|
|
257
|
+
return list(reversed(entries))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_all_logs(
|
|
261
|
+
log_path: str,
|
|
262
|
+
limit: int = 100,
|
|
263
|
+
min_level: Optional[str] = None
|
|
264
|
+
) -> list[LogEntry]:
|
|
265
|
+
"""
|
|
266
|
+
获取所有类别的日志 (不过滤类别)
|
|
267
|
+
"""
|
|
268
|
+
return get_recent_logs(log_path, limit, categories=[], min_level=min_level)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def search_logs(
|
|
272
|
+
log_path: str,
|
|
273
|
+
pattern: str,
|
|
274
|
+
limit: int = 50,
|
|
275
|
+
categories: list[str] = None
|
|
276
|
+
) -> list[LogEntry]:
|
|
277
|
+
"""
|
|
278
|
+
在日志中搜索匹配的条目
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
log_path: 日志文件路径
|
|
282
|
+
pattern: 正则表达式模式
|
|
283
|
+
limit: 返回的最大条数
|
|
284
|
+
categories: 只搜索这些类别,None 表示搜索所有
|
|
285
|
+
"""
|
|
286
|
+
if not os.path.exists(log_path):
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
entries = []
|
|
290
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
for line in read_file_reverse(log_path):
|
|
294
|
+
if not regex.search(line):
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
entry = parse_log_line(line)
|
|
298
|
+
if not entry:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
if categories and entry.category not in categories:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
entries.append(entry)
|
|
305
|
+
if len(entries) >= limit:
|
|
306
|
+
break
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
return list(reversed(entries))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def search_logs_from_line(
|
|
314
|
+
log_path: str,
|
|
315
|
+
pattern: str,
|
|
316
|
+
after_line: int = None,
|
|
317
|
+
before_line: int = None,
|
|
318
|
+
timestamps: bool = False,
|
|
319
|
+
categories: list[str] = None,
|
|
320
|
+
apply_filter: bool = True
|
|
321
|
+
) -> dict:
|
|
322
|
+
"""
|
|
323
|
+
在指定行范围内搜索日志
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
log_path: 日志文件路径
|
|
327
|
+
pattern: 正则表达式模式
|
|
328
|
+
after_line: 从哪一行之后开始搜索
|
|
329
|
+
before_line: 到哪一行之前结束
|
|
330
|
+
timestamps: 是否附加时间戳
|
|
331
|
+
categories: 只搜索这些类别,默认 ["FLog::Output"]
|
|
332
|
+
apply_filter: 是否应用过滤规则
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
{
|
|
336
|
+
"logs": "匹配的日志文本",
|
|
337
|
+
"start_line": 起始行号,
|
|
338
|
+
"last_line": 最后行号,
|
|
339
|
+
"match_count": 匹配条数,
|
|
340
|
+
"remaining": 剩余匹配数,
|
|
341
|
+
"has_more": 是否还有更多
|
|
342
|
+
}
|
|
343
|
+
"""
|
|
344
|
+
MAX_BYTES = 32000
|
|
345
|
+
|
|
346
|
+
if not os.path.exists(log_path):
|
|
347
|
+
return {"logs": "", "start_line": 0, "last_line": 0, "match_count": 0, "remaining": 0, "has_more": False}
|
|
348
|
+
|
|
349
|
+
if categories is None:
|
|
350
|
+
categories = DEFAULT_CATEGORIES
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
354
|
+
except re.error:
|
|
355
|
+
return {"error": f"Invalid regex pattern: {pattern}"}
|
|
356
|
+
|
|
357
|
+
start_line = None
|
|
358
|
+
last_line = 0
|
|
359
|
+
current_bytes = 0
|
|
360
|
+
log_lines = []
|
|
361
|
+
match_count = 0
|
|
362
|
+
bytes_exceeded = False
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
366
|
+
for line_num, line in enumerate(f, 1):
|
|
367
|
+
if after_line is not None and line_num <= after_line:
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
if before_line is not None and line_num >= before_line:
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
line = line.strip()
|
|
374
|
+
if not line:
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
entry = parse_log_line(line)
|
|
378
|
+
if not entry:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
if categories and entry.category not in categories:
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
if apply_filter and should_exclude(entry.message):
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
# 正则匹配
|
|
388
|
+
if not regex.search(entry.message):
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
match_count += 1
|
|
392
|
+
|
|
393
|
+
if bytes_exceeded:
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
if timestamps:
|
|
397
|
+
time_part = entry.timestamp[11:19]
|
|
398
|
+
output_line = f"{line_num}|[{time_part}] {entry.message}"
|
|
399
|
+
else:
|
|
400
|
+
output_line = f"{line_num}|{entry.message}"
|
|
401
|
+
|
|
402
|
+
line_bytes = len(output_line.encode('utf-8')) + 1
|
|
403
|
+
|
|
404
|
+
if current_bytes + line_bytes > MAX_BYTES and log_lines:
|
|
405
|
+
bytes_exceeded = True
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
if start_line is None:
|
|
409
|
+
start_line = line_num
|
|
410
|
+
|
|
411
|
+
log_lines.append(output_line)
|
|
412
|
+
last_line = line_num
|
|
413
|
+
current_bytes += line_bytes
|
|
414
|
+
|
|
415
|
+
except Exception:
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
returned_count = len(log_lines)
|
|
419
|
+
return {
|
|
420
|
+
"logs": "\n".join(log_lines),
|
|
421
|
+
"start_line": start_line or 0,
|
|
422
|
+
"last_line": last_line,
|
|
423
|
+
"match_count": returned_count,
|
|
424
|
+
"remaining": match_count - returned_count,
|
|
425
|
+
"has_more": match_count > returned_count
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def find_latest_studio_log() -> Optional[str]:
|
|
430
|
+
"""查找最新的 Studio 日志文件"""
|
|
431
|
+
if not os.path.exists(LOG_DIR):
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
studio_logs = []
|
|
435
|
+
for f in os.listdir(LOG_DIR):
|
|
436
|
+
if "Studio" in f and f.endswith(".log"):
|
|
437
|
+
path = os.path.join(LOG_DIR, f)
|
|
438
|
+
studio_logs.append((path, os.path.getmtime(path)))
|
|
439
|
+
|
|
440
|
+
if not studio_logs:
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
studio_logs.sort(key=lambda x: x[1], reverse=True)
|
|
444
|
+
return studio_logs[0][0]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def clean_old_logs(days: int = 7) -> int:
|
|
448
|
+
"""清理超过指定天数的旧日志"""
|
|
449
|
+
if not os.path.exists(LOG_DIR):
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
from datetime import datetime
|
|
453
|
+
count = 0
|
|
454
|
+
now = datetime.now().timestamp()
|
|
455
|
+
threshold = days * 24 * 60 * 60
|
|
456
|
+
|
|
457
|
+
for f in os.listdir(LOG_DIR):
|
|
458
|
+
if f.endswith(".log"):
|
|
459
|
+
path = os.path.join(LOG_DIR, f)
|
|
460
|
+
try:
|
|
461
|
+
if now - os.path.getmtime(path) > threshold:
|
|
462
|
+
os.remove(path)
|
|
463
|
+
count += 1
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
return count
|
|
@@ -18,7 +18,7 @@ from .windows_utils import (
|
|
|
18
18
|
from .log_utils import get_recent_logs, search_logs, clean_old_logs
|
|
19
19
|
from .toolbar_detector import detect_toolbar_state, detect_toolbar_state_with_debug
|
|
20
20
|
|
|
21
|
-
mcp = FastMCP("roblox-studio-mcp")
|
|
21
|
+
mcp = FastMCP("roblox-studio-physical-operation-mcp")
|
|
22
22
|
|
|
23
23
|
# 截图输出目录 (系统临时文件夹)
|
|
24
24
|
import tempfile
|
roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/pause.png
ADDED
|
Binary file
|
roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/play.png
ADDED
|
Binary file
|
roblox_studio_physical_operation_mcp-0.1.1/roblox_studio_physical_operation_mcp/templates/stop.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "LogTest",
|
|
3
|
+
"tree": {
|
|
4
|
+
"$className": "DataModel",
|
|
5
|
+
"ServerScriptService": {
|
|
6
|
+
"$className": "ServerScriptService",
|
|
7
|
+
"TestScript": {
|
|
8
|
+
"$path": "src/TestScript.server.lua"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"StarterPlayer": {
|
|
12
|
+
"$className": "StarterPlayer",
|
|
13
|
+
"StarterPlayerScripts": {
|
|
14
|
+
"$className": "StarterPlayerScripts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"Workspace": {
|
|
18
|
+
"$className": "Workspace",
|
|
19
|
+
"$properties": {
|
|
20
|
+
"FilteringEnabled": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"ReplicatedStorage": {
|
|
24
|
+
"$className": "ReplicatedStorage"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- 测试脚本:验证 print 和 warn 日志是否都能被 MCP 捕获
|
|
2
|
+
-- Issue #1: 看不到 roblox 运行后的警告日志
|
|
3
|
+
|
|
4
|
+
print("========== 日志测试开始 ==========")
|
|
5
|
+
|
|
6
|
+
-- 测试 print (普通输出)
|
|
7
|
+
print("[TEST] 这是一条 print 消息 - 应该能看到")
|
|
8
|
+
|
|
9
|
+
-- 测试 warn (警告输出)
|
|
10
|
+
warn("[TEST] 这是一条 warn 警告消息 - 需要验证是否能看到")
|
|
11
|
+
|
|
12
|
+
-- 测试多条消息
|
|
13
|
+
for i = 1, 3 do
|
|
14
|
+
print("[TEST] print 循环消息 #" .. i)
|
|
15
|
+
warn("[TEST] warn 循环警告 #" .. i)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
-- 测试 error (不会中断脚本的方式)
|
|
19
|
+
print("[TEST] 准备测试 error 输出...")
|
|
20
|
+
|
|
21
|
+
-- 使用 pcall 包装 error,避免脚本中断
|
|
22
|
+
local success, err = pcall(function()
|
|
23
|
+
error("[TEST] 这是一条 error 错误消息 - 需要验证是否能看到")
|
|
24
|
+
end)
|
|
25
|
+
|
|
26
|
+
if not success then
|
|
27
|
+
print("[TEST] pcall 捕获到错误: " .. tostring(err))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
print("========== 日志测试结束 ==========")
|
|
31
|
+
print("[TEST] 如果你只能看到 print 消息,说明 warn 被过滤了")
|
|
32
|
+
print("[TEST] 如果你能看到 warn 消息,说明问题已修复")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
测试脚本:验证 warn 日志修复
|
|
3
|
+
Issue #1: 看不到 roblox 运行后的警告日志
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
11
|
+
|
|
12
|
+
from roblox_studio_physical_operation_mcp.server import (
|
|
13
|
+
studio_list, game_start, game_stop, logs_get
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
PLACE_PATH = os.path.join(os.path.dirname(__file__), "game.rbxl")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
print("=" * 60)
|
|
21
|
+
print("Issue #1 Fix Verification")
|
|
22
|
+
print("=" * 60)
|
|
23
|
+
|
|
24
|
+
# 查找我们的测试实例
|
|
25
|
+
instances = studio_list()
|
|
26
|
+
our_instance = None
|
|
27
|
+
for inst in instances:
|
|
28
|
+
if inst.get("place_path") and "game.rbxl" in inst.get("place_path", ""):
|
|
29
|
+
our_instance = inst
|
|
30
|
+
break
|
|
31
|
+
|
|
32
|
+
if not our_instance:
|
|
33
|
+
print("Error: Test game.rbxl not running!")
|
|
34
|
+
print(f"Running instances: {instances}")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
print(f"Found test instance: PID={our_instance['pid']}")
|
|
38
|
+
|
|
39
|
+
# 重新开始游戏
|
|
40
|
+
print("\n[1] Restart game (F5)...")
|
|
41
|
+
game_stop(place_path=PLACE_PATH)
|
|
42
|
+
time.sleep(2)
|
|
43
|
+
game_start(place_path=PLACE_PATH)
|
|
44
|
+
time.sleep(5)
|
|
45
|
+
|
|
46
|
+
# 获取日志
|
|
47
|
+
print("\n[2] Get logs (after fix)...")
|
|
48
|
+
logs = logs_get(place_path=PLACE_PATH)
|
|
49
|
+
log_text = logs.get("logs", "")
|
|
50
|
+
|
|
51
|
+
print("-" * 40)
|
|
52
|
+
# 只打印测试相关的行
|
|
53
|
+
for line in log_text.split("\n"):
|
|
54
|
+
if "[TEST]" in line or "=====" in line:
|
|
55
|
+
print(line)
|
|
56
|
+
print("-" * 40)
|
|
57
|
+
|
|
58
|
+
# 验证
|
|
59
|
+
print("\n[3] Verification:")
|
|
60
|
+
has_print = "print" in log_text and "[TEST]" in log_text
|
|
61
|
+
has_warn = "warn" in log_text and "[TEST]" in log_text
|
|
62
|
+
|
|
63
|
+
print(f" print messages: {'VISIBLE' if has_print else 'NOT VISIBLE'}")
|
|
64
|
+
print(f" warn messages: {'VISIBLE' if has_warn else 'NOT VISIBLE'}")
|
|
65
|
+
|
|
66
|
+
if has_print and has_warn:
|
|
67
|
+
print("\n SUCCESS: Issue #1 is FIXED!")
|
|
68
|
+
elif has_print and not has_warn:
|
|
69
|
+
print("\n FAILED: warn still filtered!")
|
|
70
|
+
else:
|
|
71
|
+
print("\n UNKNOWN: Check output above")
|
|
72
|
+
|
|
73
|
+
print("\n" + "=" * 60)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
测试脚本:验证 warn 日志是否能被捕获
|
|
3
|
+
Issue #1: 看不到 roblox 运行后的警告日志
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
# 添加项目路径
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
12
|
+
|
|
13
|
+
from roblox_studio_physical_operation_mcp.server import (
|
|
14
|
+
studio_list, studio_open, studio_query,
|
|
15
|
+
game_start, game_stop, logs_get, modal_close
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
PLACE_PATH = os.path.join(os.path.dirname(__file__), "game.rbxl")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
print("=" * 60)
|
|
23
|
+
print("Issue #1 测试: 验证 warn 日志是否能被捕获")
|
|
24
|
+
print("=" * 60)
|
|
25
|
+
|
|
26
|
+
# 1. 检查 Studio 是否已运行
|
|
27
|
+
print("\n[1] 检查 Studio 实例...")
|
|
28
|
+
instances = studio_list()
|
|
29
|
+
print(f" 当前实例: {instances}")
|
|
30
|
+
|
|
31
|
+
# 2. 打开 Studio
|
|
32
|
+
print(f"\n[2] 打开 Studio: {PLACE_PATH}")
|
|
33
|
+
result = studio_open(PLACE_PATH)
|
|
34
|
+
print(f" 结果: {result}")
|
|
35
|
+
|
|
36
|
+
# 3. 等待加载
|
|
37
|
+
print("\n[3] 等待 Studio 加载 (8秒)...")
|
|
38
|
+
time.sleep(8)
|
|
39
|
+
|
|
40
|
+
# 4. 关闭弹窗
|
|
41
|
+
print("\n[4] 关闭弹窗...")
|
|
42
|
+
result = modal_close(place_path=PLACE_PATH)
|
|
43
|
+
print(f" 结果: {result}")
|
|
44
|
+
|
|
45
|
+
# 5. 查询状态
|
|
46
|
+
print("\n[5] 查询 Studio 状态...")
|
|
47
|
+
status = studio_query(place_path=PLACE_PATH)
|
|
48
|
+
print(f" 状态: {status}")
|
|
49
|
+
|
|
50
|
+
if not status.get("active"):
|
|
51
|
+
print(" 错误: Studio 未运行!")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# 6. 开始游戏
|
|
55
|
+
print("\n[6] 开始游戏 (F5)...")
|
|
56
|
+
result = game_start(place_path=PLACE_PATH)
|
|
57
|
+
print(f" 结果: {result}")
|
|
58
|
+
|
|
59
|
+
# 7. 等待脚本执行
|
|
60
|
+
print("\n[7] 等待脚本执行 (5秒)...")
|
|
61
|
+
time.sleep(5)
|
|
62
|
+
|
|
63
|
+
# 8. 获取日志
|
|
64
|
+
print("\n[8] 获取日志 (默认过滤)...")
|
|
65
|
+
logs = logs_get(place_path=PLACE_PATH)
|
|
66
|
+
print(f" 日志结果:")
|
|
67
|
+
print("-" * 40)
|
|
68
|
+
print(logs.get("logs", "无日志"))
|
|
69
|
+
print("-" * 40)
|
|
70
|
+
print(f" last_line: {logs.get('last_line')}")
|
|
71
|
+
print(f" remaining: {logs.get('remaining')}")
|
|
72
|
+
|
|
73
|
+
# 9. 分析结果
|
|
74
|
+
print("\n[9] 分析结果...")
|
|
75
|
+
log_text = logs.get("logs", "")
|
|
76
|
+
has_print = "[TEST] 这是一条 print 消息" in log_text
|
|
77
|
+
has_warn = "[TEST] 这是一条 warn 警告消息" in log_text
|
|
78
|
+
|
|
79
|
+
print(f" print 消息: {'✓ 可见' if has_print else '✗ 不可见'}")
|
|
80
|
+
print(f" warn 消息: {'✓ 可见' if has_warn else '✗ 不可见'}")
|
|
81
|
+
|
|
82
|
+
if has_print and not has_warn:
|
|
83
|
+
print("\n ⚠️ 问题确认: warn 日志被过滤了!")
|
|
84
|
+
elif has_print and has_warn:
|
|
85
|
+
print("\n ✓ 正常: print 和 warn 都可见")
|
|
86
|
+
else:
|
|
87
|
+
print("\n ? 异常: 需要检查日志输出")
|
|
88
|
+
|
|
89
|
+
# 10. 停止游戏
|
|
90
|
+
print("\n[10] 停止游戏 (Shift+F5)...")
|
|
91
|
+
result = game_stop(place_path=PLACE_PATH)
|
|
92
|
+
print(f" 结果: {result}")
|
|
93
|
+
|
|
94
|
+
print("\n" + "=" * 60)
|
|
95
|
+
print("测试完成")
|
|
96
|
+
print("=" * 60)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
|
Binary file
|
|
File without changes
|
{roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/LICENSE
RENAMED
|
File without changes
|
{roblox_studio_physical_operation_mcp-0.1.0 → roblox_studio_physical_operation_mcp-0.1.1}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|