Add hot-update boot pipeline, UI bundles/atlas assets, and design docs.
Ignore AIClient/Library and AIClient/HybridCLRData permanently; include HybridCLR/YooAsset project setup and UI tooling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CDN 发布与本地联调工具。
|
||||
|
||||
目录约定(与 ProjectDesign / Unity BuildAndStage 一致)::
|
||||
|
||||
Tools/cdn_root/wechat/version.json
|
||||
Tools/cdn_root/wechat/1.0.0/DefaultPackage/<yooasset files>
|
||||
|
||||
用法::
|
||||
|
||||
# 本地 HTTP(HostPlayMode 联调)
|
||||
python Tools/cdn_publish.py serve --port 8787
|
||||
|
||||
# 校验目录
|
||||
python Tools/cdn_publish.py status
|
||||
|
||||
# 上传到远端(需配置)
|
||||
python Tools/cdn_publish.py upload --mode copy --dest //server/cdn/www
|
||||
python Tools/cdn_publish.py upload --mode rsync --dest user@host:/var/cdn/www
|
||||
# 或自定义命令({src} {dest}):
|
||||
set CDN_UPLOAD_CMD=scp -r {src}/. {dest}/
|
||||
python Tools/cdn_publish.py upload --mode cmd --dest user@host:/var/www
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CDN_ROOT = REPO / "Tools" / "cdn_root"
|
||||
CHANNEL = "wechat"
|
||||
DEFAULT_PORT = 8787
|
||||
|
||||
|
||||
class QuietHandler(SimpleHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
sys.stderr.write("[%s] %s\n" % (self.log_date_time_string(), fmt % args))
|
||||
|
||||
def end_headers(self) -> None:
|
||||
# 方便微信/编辑器跨域拉 version.json;生产 CDN 由 Caddy 配
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
super().end_headers()
|
||||
|
||||
|
||||
def cmd_status(_: argparse.Namespace) -> int:
|
||||
version = CDN_ROOT / CHANNEL / "version.json"
|
||||
meta = CDN_ROOT / CHANNEL / "last_publish.json"
|
||||
print(f"cdn_root={CDN_ROOT}")
|
||||
print(f"version_json_exists={version.is_file()} path={version}")
|
||||
if version.is_file():
|
||||
print(version.read_text(encoding="utf-8"))
|
||||
print(f"meta_exists={meta.is_file()} path={meta}")
|
||||
if meta.is_file():
|
||||
print(meta.read_text(encoding="utf-8"))
|
||||
pkg_root = CDN_ROOT / CHANNEL
|
||||
if pkg_root.is_dir():
|
||||
for p in sorted(pkg_root.rglob("*")):
|
||||
if p.is_file() and p.name != "version.json":
|
||||
rel = p.relative_to(CDN_ROOT)
|
||||
print(f" file {rel} ({p.stat().st_size} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> int:
|
||||
if not CDN_ROOT.is_dir():
|
||||
print(f"ERROR: missing {CDN_ROOT}, run Unity menu AIGame/YooAsset/Build And Stage first", file=sys.stderr)
|
||||
return 1
|
||||
os.chdir(CDN_ROOT)
|
||||
handler = partial(QuietHandler, directory=str(CDN_ROOT))
|
||||
server = ThreadingHTTPServer(("0.0.0.0", args.port), handler)
|
||||
print(f"serving {CDN_ROOT} at http://127.0.0.1:{args.port}/")
|
||||
print(f"version url: http://127.0.0.1:{args.port}/{CHANNEL}/version.json")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_upload(args: argparse.Namespace) -> int:
|
||||
src = CDN_ROOT
|
||||
if not src.is_dir():
|
||||
print(f"ERROR: missing {src}", file=sys.stderr)
|
||||
return 1
|
||||
dest = args.dest
|
||||
if not dest:
|
||||
print("ERROR: --dest required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 上传前把 version.prod.json 覆盖为对外 version.json(若存在)
|
||||
prod = src / CHANNEL / "version.prod.json"
|
||||
live = src / CHANNEL / "version.json"
|
||||
if prod.is_file():
|
||||
shutil.copy2(prod, live)
|
||||
print(f"promoted {prod.name} -> version.json for upload")
|
||||
|
||||
mode = args.mode
|
||||
if mode == "copy":
|
||||
dest_path = Path(dest)
|
||||
dest_path.mkdir(parents=True, exist_ok=True)
|
||||
# 同步 wechat 通道目录
|
||||
target = dest_path / CHANNEL
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
shutil.copytree(src / CHANNEL, target, ignore=shutil.ignore_patterns("version.prod.json", "last_publish.json"))
|
||||
print(f"copied {src / CHANNEL} -> {target}")
|
||||
return 0
|
||||
|
||||
if mode == "rsync":
|
||||
cmd = [
|
||||
"rsync", "-avz", "--delete",
|
||||
"--exclude", "version.prod.json",
|
||||
"--exclude", "last_publish.json",
|
||||
f"{src / CHANNEL}/",
|
||||
f"{dest.rstrip('/')}/{CHANNEL}/",
|
||||
]
|
||||
print(" ".join(cmd))
|
||||
return subprocess.call(cmd)
|
||||
|
||||
if mode == "cmd":
|
||||
template = os.environ.get("CDN_UPLOAD_CMD", "").strip()
|
||||
if not template:
|
||||
print("ERROR: set CDN_UPLOAD_CMD with {src} and {dest}", file=sys.stderr)
|
||||
return 1
|
||||
cmd = template.format(src=str(src / CHANNEL), dest=dest)
|
||||
print(cmd)
|
||||
return subprocess.call(cmd, shell=True)
|
||||
|
||||
print(f"ERROR: unknown mode {mode}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_write_version(args: argparse.Namespace) -> int:
|
||||
"""手动改写 version.json(不重新打 AB)。"""
|
||||
path = CDN_ROOT / CHANNEL / "version.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"appVersionMin": args.app_version,
|
||||
"appVersionMax": args.app_version,
|
||||
"resVersion": args.res_version,
|
||||
"cdnRoot": args.cdn_root,
|
||||
"packages": ["DefaultPackage"],
|
||||
"forceUpdate": bool(args.force_update),
|
||||
"notice": args.notice or "",
|
||||
}
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"wrote {path}")
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIGame CDN publish helpers")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_status = sub.add_parser("status", help="Show staged CDN files")
|
||||
p_status.set_defaults(func=cmd_status)
|
||||
|
||||
p_serve = sub.add_parser("serve", help="Serve Tools/cdn_root over HTTP")
|
||||
p_serve.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
p_serve.set_defaults(func=cmd_serve)
|
||||
|
||||
p_upload = sub.add_parser("upload", help="Upload wechat channel to remote CDN root")
|
||||
p_upload.add_argument("--mode", choices=("copy", "rsync", "cmd"), default="copy")
|
||||
p_upload.add_argument("--dest", required=True, help="Remote or local destination root (www root)")
|
||||
p_upload.set_defaults(func=cmd_upload)
|
||||
|
||||
p_ver = sub.add_parser("write-version", help="Rewrite version.json")
|
||||
p_ver.add_argument("--app-version", default="1.0.0")
|
||||
p_ver.add_argument("--res-version", required=True)
|
||||
p_ver.add_argument("--cdn-root", default="https://www.xworld.ren/wechat/1.0.0/DefaultPackage")
|
||||
p_ver.add_argument("--force-update", action="store_true")
|
||||
p_ver.add_argument("--notice", default="")
|
||||
p_ver.set_defaults(func=cmd_write_version)
|
||||
|
||||
args = parser.parse_args()
|
||||
return int(args.func(args) or 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = "https://game.xworld.ren"
|
||||
|
||||
CASES = [
|
||||
("POST /login empty json", "POST", "/login", {"Content-Type": "application/json"}, b"{}"),
|
||||
("POST /login guest", "POST", "/login", {"Content-Type": "application/json"}, json.dumps({"mode": "guest", "pid": "m2"}).encode()),
|
||||
("POST /login device", "POST", "/login", {"Content-Type": "application/json"}, json.dumps({"deviceId": "m2_probe", "platform": "editor"}).encode()),
|
||||
("POST /login user", "POST", "/login", {"Content-Type": "application/json"}, json.dumps({"username": "test", "password": "test"}).encode()),
|
||||
("POST /api/v1/login", "POST", "/api/v1/login", {"Content-Type": "application/json"}, json.dumps({"deviceId": "m2"}).encode()),
|
||||
("POST /auth/login", "POST", "/auth/login", {"Content-Type": "application/json"}, json.dumps({"deviceId": "m2"}).encode()),
|
||||
("POST /ws/token", "POST", "/ws/token", {"Content-Type": "application/json"}, json.dumps({"pid": "m2"}).encode()),
|
||||
("GET /login", "GET", "/login", {}, None),
|
||||
("OPTIONS /login", "OPTIONS", "/login", {"Origin": "https://www.xworld.ren"}, None),
|
||||
]
|
||||
|
||||
|
||||
def hit(method, path, headers, body):
|
||||
req = urllib.request.Request(BASE + path, data=body, method=method)
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = resp.read()
|
||||
print(f"OK {method} {path} {resp.status} {data[:500]!r}")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
print(f"HTTP {e.code} {method} {path} headers={dict(e.headers)} body={body[:500]!r}")
|
||||
except Exception as e:
|
||||
print(f"ERR {method} {path}: {type(e).__name__} {e}")
|
||||
|
||||
|
||||
def main():
|
||||
for name, method, path, headers, body in CASES:
|
||||
print("---", name)
|
||||
hit(method, path, headers, body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = "https://game.xworld.ren"
|
||||
|
||||
BODIES = [
|
||||
{"user": "test", "pass": "test"},
|
||||
{"account": "test", "password": "test"},
|
||||
{"name": "test", "password": "test"},
|
||||
{"username": "admin", "password": "admin"},
|
||||
{"username": "guest", "password": "guest"},
|
||||
{"username": "demo", "password": "demo"},
|
||||
{"username": "udx", "password": "abcd99"},
|
||||
{"username": "test", "password": "test", "platform": "editor"},
|
||||
{"username": "test", "password": "test", "pid": "m2"},
|
||||
{"username": "a", "password": "b", "deviceId": "x"},
|
||||
]
|
||||
|
||||
PATHS = ["/login", "/register", "/signup", "/guest", "/auth", "/token"]
|
||||
|
||||
|
||||
def post(path, payload):
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = resp.read().decode("utf-8", "replace")
|
||||
print(f"OK {resp.status} {path} {payload} => {body[:500]}")
|
||||
return body
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "replace")
|
||||
print(f"HTTP {e.code} {path} {payload} => {body[:500]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"ERR {path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=== path discovery ===")
|
||||
for p in PATHS:
|
||||
post(p, {"username": "x", "password": "y"})
|
||||
|
||||
print("=== login field variants ===")
|
||||
for b in BODIES:
|
||||
post("/login", b)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,651 @@
|
||||
{
|
||||
"count": 38,
|
||||
"items": [
|
||||
{
|
||||
"name": "ui_panel_main",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_main.png",
|
||||
"planned": [
|
||||
8,
|
||||
8,
|
||||
480,
|
||||
256
|
||||
],
|
||||
"box": [
|
||||
22,
|
||||
62,
|
||||
543,
|
||||
315
|
||||
],
|
||||
"score": 9476.7
|
||||
},
|
||||
{
|
||||
"name": "ui_panel_popup",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_popup.png",
|
||||
"planned": [
|
||||
496,
|
||||
8,
|
||||
360,
|
||||
240
|
||||
],
|
||||
"box": [
|
||||
588,
|
||||
102,
|
||||
329,
|
||||
252
|
||||
],
|
||||
"score": 15755.6
|
||||
},
|
||||
{
|
||||
"name": "ui_panel_content",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_content.png",
|
||||
"planned": [
|
||||
864,
|
||||
8,
|
||||
320,
|
||||
200
|
||||
],
|
||||
"box": [
|
||||
944,
|
||||
119,
|
||||
327,
|
||||
218
|
||||
],
|
||||
"score": 21237.9
|
||||
},
|
||||
{
|
||||
"name": "ui_panel_header",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_header.png",
|
||||
"planned": [
|
||||
1192,
|
||||
8,
|
||||
400,
|
||||
80
|
||||
],
|
||||
"box": [
|
||||
1294,
|
||||
115,
|
||||
427,
|
||||
110
|
||||
],
|
||||
"score": 29148.7
|
||||
},
|
||||
{
|
||||
"name": "ui_overlay_dim",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_overlay_dim.png",
|
||||
"planned": [
|
||||
1600,
|
||||
8,
|
||||
192,
|
||||
192
|
||||
],
|
||||
"box": [
|
||||
1768,
|
||||
77,
|
||||
257,
|
||||
260
|
||||
],
|
||||
"score": 52426.6
|
||||
},
|
||||
{
|
||||
"name": "ui_divider_h",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_divider_h.png",
|
||||
"planned": [
|
||||
1192,
|
||||
96,
|
||||
400,
|
||||
16
|
||||
],
|
||||
"box": [
|
||||
1294,
|
||||
288,
|
||||
452,
|
||||
23
|
||||
],
|
||||
"score": 55947.7
|
||||
},
|
||||
{
|
||||
"name": "btn_primary_normal",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_primary_normal.png",
|
||||
"planned": [
|
||||
8,
|
||||
280,
|
||||
224,
|
||||
88
|
||||
],
|
||||
"box": [
|
||||
28,
|
||||
416,
|
||||
247,
|
||||
116
|
||||
],
|
||||
"score": 24205.5
|
||||
},
|
||||
{
|
||||
"name": "btn_primary_pressed",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_primary_pressed.png",
|
||||
"planned": [
|
||||
240,
|
||||
280,
|
||||
224,
|
||||
88
|
||||
],
|
||||
"box": [
|
||||
294,
|
||||
416,
|
||||
245,
|
||||
116
|
||||
],
|
||||
"score": 27326.5
|
||||
},
|
||||
{
|
||||
"name": "btn_primary_disabled",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_primary_disabled.png",
|
||||
"planned": [
|
||||
472,
|
||||
280,
|
||||
224,
|
||||
88
|
||||
],
|
||||
"box": [
|
||||
553,
|
||||
416,
|
||||
243,
|
||||
116
|
||||
],
|
||||
"score": 31317.7
|
||||
},
|
||||
{
|
||||
"name": "btn_secondary_normal",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_secondary_normal.png",
|
||||
"planned": [
|
||||
704,
|
||||
280,
|
||||
224,
|
||||
88
|
||||
],
|
||||
"box": [
|
||||
806,
|
||||
416,
|
||||
232,
|
||||
112
|
||||
],
|
||||
"score": 33491.7
|
||||
},
|
||||
{
|
||||
"name": "btn_secondary_pressed",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_secondary_pressed.png",
|
||||
"planned": [
|
||||
936,
|
||||
280,
|
||||
224,
|
||||
88
|
||||
],
|
||||
"box": [
|
||||
1052,
|
||||
416,
|
||||
217,
|
||||
112
|
||||
],
|
||||
"score": 34903.1
|
||||
},
|
||||
{
|
||||
"name": "btn_small_normal",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_small_normal.png",
|
||||
"planned": [
|
||||
1168,
|
||||
280,
|
||||
160,
|
||||
64
|
||||
],
|
||||
"box": [
|
||||
1287,
|
||||
438,
|
||||
156,
|
||||
79
|
||||
],
|
||||
"score": 41241.7
|
||||
},
|
||||
{
|
||||
"name": "btn_small_pressed",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_small_pressed.png",
|
||||
"planned": [
|
||||
1336,
|
||||
280,
|
||||
160,
|
||||
64
|
||||
],
|
||||
"box": [
|
||||
1465,
|
||||
438,
|
||||
158,
|
||||
78
|
||||
],
|
||||
"score": 43700.6
|
||||
},
|
||||
{
|
||||
"name": "btn_close",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_close.png",
|
||||
"planned": [
|
||||
1504,
|
||||
280,
|
||||
72,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
1640,
|
||||
413,
|
||||
116,
|
||||
122
|
||||
],
|
||||
"score": 56458.2
|
||||
},
|
||||
{
|
||||
"name": "btn_back",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_back.png",
|
||||
"planned": [
|
||||
1584,
|
||||
280,
|
||||
72,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
1772,
|
||||
413,
|
||||
113,
|
||||
121
|
||||
],
|
||||
"score": 74212.4
|
||||
},
|
||||
{
|
||||
"name": "scrollbar_track_v",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\scrollbar_track_v.png",
|
||||
"planned": [
|
||||
1664,
|
||||
280,
|
||||
28,
|
||||
160
|
||||
],
|
||||
"box": [
|
||||
1900,
|
||||
358,
|
||||
51,
|
||||
224
|
||||
],
|
||||
"score": 79677.2
|
||||
},
|
||||
{
|
||||
"name": "scrollbar_thumb_v",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\scrollbar_thumb_v.png",
|
||||
"planned": [
|
||||
1700,
|
||||
280,
|
||||
28,
|
||||
80
|
||||
],
|
||||
"box": [
|
||||
1969,
|
||||
398,
|
||||
53,
|
||||
151
|
||||
],
|
||||
"score": 115048.8
|
||||
},
|
||||
{
|
||||
"name": "input_bg_normal",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\input_bg_normal.png",
|
||||
"planned": [
|
||||
8,
|
||||
400,
|
||||
360,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
24,
|
||||
582,
|
||||
356,
|
||||
104
|
||||
],
|
||||
"score": 40769.7
|
||||
},
|
||||
{
|
||||
"name": "input_bg_focus",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\input_bg_focus.png",
|
||||
"planned": [
|
||||
376,
|
||||
400,
|
||||
360,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
387,
|
||||
576,
|
||||
355,
|
||||
113
|
||||
],
|
||||
"score": 41075.7
|
||||
},
|
||||
{
|
||||
"name": "ui_list_item_normal",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_list_item_normal.png",
|
||||
"planned": [
|
||||
744,
|
||||
400,
|
||||
320,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
752,
|
||||
580,
|
||||
351,
|
||||
108
|
||||
],
|
||||
"score": 41610.3
|
||||
},
|
||||
{
|
||||
"name": "ui_list_item_selected",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_list_item_selected.png",
|
||||
"planned": [
|
||||
1072,
|
||||
400,
|
||||
320,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
1111,
|
||||
579,
|
||||
352,
|
||||
109
|
||||
],
|
||||
"score": 43971.9
|
||||
},
|
||||
{
|
||||
"name": "tab_active",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\tab_active.png",
|
||||
"planned": [
|
||||
1400,
|
||||
400,
|
||||
200,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
1477,
|
||||
578,
|
||||
253,
|
||||
88
|
||||
],
|
||||
"score": 45976.1
|
||||
},
|
||||
{
|
||||
"name": "tab_inactive",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\tab_inactive.png",
|
||||
"planned": [
|
||||
1608,
|
||||
400,
|
||||
200,
|
||||
72
|
||||
],
|
||||
"box": [
|
||||
1742,
|
||||
590,
|
||||
273,
|
||||
78
|
||||
],
|
||||
"score": 67077.6
|
||||
},
|
||||
{
|
||||
"name": "progress_bg",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\progress_bg.png",
|
||||
"planned": [
|
||||
8,
|
||||
504,
|
||||
400,
|
||||
32
|
||||
],
|
||||
"box": [
|
||||
30,
|
||||
743,
|
||||
434,
|
||||
57
|
||||
],
|
||||
"score": 69423.9
|
||||
},
|
||||
{
|
||||
"name": "progress_fill",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\progress_fill.png",
|
||||
"planned": [
|
||||
416,
|
||||
504,
|
||||
400,
|
||||
32
|
||||
],
|
||||
"box": [
|
||||
488,
|
||||
743,
|
||||
405,
|
||||
57
|
||||
],
|
||||
"score": 73361.1
|
||||
},
|
||||
{
|
||||
"name": "slider_track",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\slider_track.png",
|
||||
"planned": [
|
||||
824,
|
||||
504,
|
||||
320,
|
||||
28
|
||||
],
|
||||
"box": [
|
||||
927,
|
||||
750,
|
||||
263,
|
||||
42
|
||||
],
|
||||
"score": 71486.1
|
||||
},
|
||||
{
|
||||
"name": "slider_fill",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\slider_fill.png",
|
||||
"planned": [
|
||||
1152,
|
||||
504,
|
||||
320,
|
||||
28
|
||||
],
|
||||
"box": [
|
||||
1220,
|
||||
749,
|
||||
233,
|
||||
45
|
||||
],
|
||||
"score": 68125.3
|
||||
},
|
||||
{
|
||||
"name": "slider_handle",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\slider_handle.png",
|
||||
"planned": [
|
||||
1480,
|
||||
496,
|
||||
56,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
1483,
|
||||
718,
|
||||
97,
|
||||
99
|
||||
],
|
||||
"score": 68583.1
|
||||
},
|
||||
{
|
||||
"name": "toast_bg",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toast_bg.png",
|
||||
"planned": [
|
||||
1544,
|
||||
496,
|
||||
280,
|
||||
80
|
||||
],
|
||||
"box": [
|
||||
1613,
|
||||
701,
|
||||
402,
|
||||
118
|
||||
],
|
||||
"score": 70046.3
|
||||
},
|
||||
{
|
||||
"name": "toggle_bg_off",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toggle_bg_off.png",
|
||||
"planned": [
|
||||
8,
|
||||
616,
|
||||
112,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
32,
|
||||
875,
|
||||
212,
|
||||
108
|
||||
],
|
||||
"score": 99618.0
|
||||
},
|
||||
{
|
||||
"name": "toggle_bg_on",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toggle_bg_on.png",
|
||||
"planned": [
|
||||
128,
|
||||
616,
|
||||
112,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
266,
|
||||
875,
|
||||
201,
|
||||
108
|
||||
],
|
||||
"score": 126013.9
|
||||
},
|
||||
{
|
||||
"name": "toggle_knob",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toggle_knob.png",
|
||||
"planned": [
|
||||
248,
|
||||
616,
|
||||
48,
|
||||
48
|
||||
],
|
||||
"box": [
|
||||
517,
|
||||
876,
|
||||
100,
|
||||
103
|
||||
],
|
||||
"score": 188991.6
|
||||
},
|
||||
{
|
||||
"name": "checkbox_off",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\checkbox_off.png",
|
||||
"planned": [
|
||||
304,
|
||||
616,
|
||||
56,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
671,
|
||||
870,
|
||||
110,
|
||||
112
|
||||
],
|
||||
"score": 249523.3
|
||||
},
|
||||
{
|
||||
"name": "checkbox_on",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\checkbox_on.png",
|
||||
"planned": [
|
||||
368,
|
||||
616,
|
||||
56,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
840,
|
||||
868,
|
||||
113,
|
||||
118
|
||||
],
|
||||
"score": 347900.6
|
||||
},
|
||||
{
|
||||
"name": "ico_search",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_search.png",
|
||||
"planned": [
|
||||
432,
|
||||
616,
|
||||
56,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
1023,
|
||||
862,
|
||||
129,
|
||||
134
|
||||
],
|
||||
"score": 503184.0
|
||||
},
|
||||
{
|
||||
"name": "ico_check",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_check.png",
|
||||
"planned": [
|
||||
496,
|
||||
616,
|
||||
56,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
1210,
|
||||
863,
|
||||
124,
|
||||
127
|
||||
],
|
||||
"score": 662935.9
|
||||
},
|
||||
{
|
||||
"name": "ico_settings",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_settings.png",
|
||||
"planned": [
|
||||
560,
|
||||
616,
|
||||
56,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
1416,
|
||||
855,
|
||||
143,
|
||||
147
|
||||
],
|
||||
"score": 929290.7
|
||||
},
|
||||
{
|
||||
"name": "ico_refresh",
|
||||
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_refresh.png",
|
||||
"planned": [
|
||||
624,
|
||||
616,
|
||||
56,
|
||||
56
|
||||
],
|
||||
"box": [
|
||||
1619,
|
||||
859,
|
||||
141,
|
||||
135
|
||||
],
|
||||
"score": 1189245.0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Slice UI Normal atlas into Common single sprites."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
ATLAS = Path(r"d:\UD\AI\AIGame\Tools\output\ui_normal_atlas_gpt.png")
|
||||
OUT_DIR = Path(r"d:\UD\AI\AIGame\AIClient\Assets\Bundles\UI\Texture\Common")
|
||||
REPORT = Path(r"d:\UD\AI\AIGame\Tools\output\ui_normal_slice_report.json")
|
||||
|
||||
# From Doc/UI_Normal_Set.md (left-top origin)
|
||||
SLICES = [
|
||||
("ui_panel_main", 8, 8, 480, 256),
|
||||
("ui_panel_popup", 496, 8, 360, 240),
|
||||
("ui_panel_content", 864, 8, 320, 200),
|
||||
("ui_panel_header", 1192, 8, 400, 80),
|
||||
("ui_overlay_dim", 1600, 8, 192, 192),
|
||||
("ui_divider_h", 1192, 96, 400, 16),
|
||||
("btn_primary_normal", 8, 280, 224, 88),
|
||||
("btn_primary_pressed", 240, 280, 224, 88),
|
||||
("btn_primary_disabled", 472, 280, 224, 88),
|
||||
("btn_secondary_normal", 704, 280, 224, 88),
|
||||
("btn_secondary_pressed", 936, 280, 224, 88),
|
||||
("btn_small_normal", 1168, 280, 160, 64),
|
||||
("btn_small_pressed", 1336, 280, 160, 64),
|
||||
("btn_close", 1504, 280, 72, 72),
|
||||
("btn_back", 1584, 280, 72, 72),
|
||||
("scrollbar_track_v", 1664, 280, 28, 160),
|
||||
("scrollbar_thumb_v", 1700, 280, 28, 80),
|
||||
("input_bg_normal", 8, 400, 360, 72),
|
||||
("input_bg_focus", 376, 400, 360, 72),
|
||||
("ui_list_item_normal", 744, 400, 320, 72),
|
||||
("ui_list_item_selected", 1072, 400, 320, 72),
|
||||
("tab_active", 1400, 400, 200, 72),
|
||||
("tab_inactive", 1608, 400, 200, 72),
|
||||
("progress_bg", 8, 504, 400, 32),
|
||||
("progress_fill", 416, 504, 400, 32),
|
||||
("slider_track", 824, 504, 320, 28),
|
||||
("slider_fill", 1152, 504, 320, 28),
|
||||
("slider_handle", 1480, 496, 56, 56),
|
||||
("toast_bg", 1544, 496, 280, 80),
|
||||
("toggle_bg_off", 8, 616, 112, 56),
|
||||
("toggle_bg_on", 128, 616, 112, 56),
|
||||
("toggle_knob", 248, 616, 48, 48),
|
||||
("checkbox_off", 304, 616, 56, 56),
|
||||
("checkbox_on", 368, 616, 56, 56),
|
||||
("ico_search", 432, 616, 56, 56),
|
||||
("ico_check", 496, 616, 56, 56),
|
||||
("ico_settings", 560, 616, 56, 56),
|
||||
("ico_refresh", 624, 616, 56, 56),
|
||||
]
|
||||
|
||||
|
||||
def find_boxes(arr: np.ndarray, alpha_thr: int = 24, min_px: int = 60):
|
||||
mask = arr[:, :, 3] > alpha_thr
|
||||
h, w = mask.shape
|
||||
visited = np.zeros_like(mask, dtype=bool)
|
||||
boxes = []
|
||||
for y in range(h):
|
||||
for x in np.where(mask[y] & ~visited[y])[0]:
|
||||
if visited[y, x] or not mask[y, x]:
|
||||
continue
|
||||
q = deque([(int(y), int(x))])
|
||||
visited[y, x] = True
|
||||
minx = maxx = int(x)
|
||||
miny = maxy = int(y)
|
||||
cnt = 0
|
||||
while q:
|
||||
cy, cx = q.popleft()
|
||||
cnt += 1
|
||||
for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)):
|
||||
if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not visited[ny, nx]:
|
||||
visited[ny, nx] = True
|
||||
q.append((ny, nx))
|
||||
minx = min(minx, nx)
|
||||
maxx = max(maxx, nx)
|
||||
miny = min(miny, ny)
|
||||
maxy = max(maxy, ny)
|
||||
bw, bh = maxx - minx + 1, maxy - miny + 1
|
||||
if cnt >= min_px and bw >= 10 and bh >= 8:
|
||||
boxes.append(
|
||||
{
|
||||
"miny": miny,
|
||||
"minx": minx,
|
||||
"maxy": maxy,
|
||||
"maxx": maxx,
|
||||
"w": bw,
|
||||
"h": bh,
|
||||
"cx": (minx + maxx) / 2,
|
||||
"cy": (miny + maxy) / 2,
|
||||
"px": cnt,
|
||||
}
|
||||
)
|
||||
return boxes
|
||||
|
||||
|
||||
def match_slices(boxes: list[dict]) -> list[dict]:
|
||||
used = set()
|
||||
matches = []
|
||||
for name, x, y, w, h in SLICES:
|
||||
pcx, pcy = x + w / 2, y + h / 2
|
||||
best_i, best_score = None, 1e18
|
||||
for i, b in enumerate(boxes):
|
||||
if i in used:
|
||||
continue
|
||||
# prefer position; lightly penalize size mismatch
|
||||
dist = (b["cx"] - pcx) ** 2 + (b["cy"] - pcy) ** 2
|
||||
size_pen = ((b["w"] - w) / max(w, 1)) ** 2 + ((b["h"] - h) / max(h, 1)) ** 2
|
||||
score = dist + size_pen * 8000
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_i = i
|
||||
if best_i is None:
|
||||
raise RuntimeError(f"no box for {name}")
|
||||
used.add(best_i)
|
||||
b = boxes[best_i]
|
||||
matches.append(
|
||||
{
|
||||
"name": name,
|
||||
"planned": [x, y, w, h],
|
||||
"box": [b["minx"], b["miny"], b["w"], b["h"]],
|
||||
"score": round(best_score, 1),
|
||||
}
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
img = Image.open(ATLAS).convert("RGBA")
|
||||
arr = np.array(img)
|
||||
boxes = find_boxes(arr)
|
||||
if len(boxes) != len(SLICES):
|
||||
raise SystemExit(f"expected {len(SLICES)} components, got {len(boxes)}")
|
||||
|
||||
matches = match_slices(boxes)
|
||||
written = []
|
||||
for m in matches:
|
||||
x, y, w, h = m["box"]
|
||||
crop = img.crop((x, y, x + w, y + h))
|
||||
# pad 2px transparent to reduce bleed when bilinear sampling
|
||||
pad = 2
|
||||
canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
|
||||
canvas.paste(crop, (pad, pad))
|
||||
out = OUT_DIR / f"{m['name']}.png"
|
||||
canvas.save(out)
|
||||
written.append({"name": m["name"], "path": str(out), **m})
|
||||
|
||||
REPORT.write_text(json.dumps({"count": len(written), "items": written}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"sliced {len(written)} sprites -> {OUT_DIR}")
|
||||
print(f"report -> {REPORT}")
|
||||
for m in matches[:5]:
|
||||
print(m["name"], "planned", m["planned"], "->", m["box"], "score", m["score"])
|
||||
print("...")
|
||||
for m in matches[-3:]:
|
||||
print(m["name"], "planned", m["planned"], "->", m["box"], "score", m["score"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
async def main() -> int:
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
print("NO_WEBSOCKETS")
|
||||
return 2
|
||||
|
||||
url = "wss://game.xworld.ren/ws?pid=m2_probe"
|
||||
print("connecting", url)
|
||||
try:
|
||||
async with websockets.connect(url, open_timeout=10, close_timeout=5) as ws:
|
||||
print("OPEN ok")
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=3)
|
||||
print("recv", type(msg).__name__, repr(msg)[:800])
|
||||
except asyncio.TimeoutError:
|
||||
print("recv_timeout (no hello)")
|
||||
|
||||
hello = {"op": "hello", "proto": 1, "app": "1.0.0", "pid": "m2_probe"}
|
||||
await ws.send(json.dumps(hello, ensure_ascii=False))
|
||||
print("sent", hello)
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=5)
|
||||
print("after_hello", type(msg).__name__, repr(msg)[:800])
|
||||
except asyncio.TimeoutError:
|
||||
print("after_hello_timeout")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print("ERR", type(e).__name__, e)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
URL = "wss://game.xworld.ren/ws"
|
||||
|
||||
CASES = [
|
||||
("pid_only", f"{URL}?pid=m2_probe", None),
|
||||
("token_q", f"{URL}?pid=m2_probe&token=test", None),
|
||||
("auth_q", f"{URL}?pid=m2_probe&auth=test", None),
|
||||
("key_q", f"{URL}?pid=m2_probe&key=test", None),
|
||||
("sig_q", f"{URL}?pid=m2_probe&sig=test", None),
|
||||
("no_query", URL, None),
|
||||
("bearer", f"{URL}?pid=m2_probe", [("Authorization", "Bearer test")]),
|
||||
("x_token", f"{URL}?pid=m2_probe", [("X-Token", "test")]),
|
||||
("x_api_key", f"{URL}?pid=m2_probe", [("X-Api-Key", "test")]),
|
||||
]
|
||||
|
||||
|
||||
async def try_one(websockets, name, url, headers):
|
||||
kwargs = {"open_timeout": 8}
|
||||
# websockets version differences
|
||||
sig = inspect.signature(websockets.connect)
|
||||
if headers:
|
||||
if "additional_headers" in sig.parameters:
|
||||
kwargs["additional_headers"] = headers
|
||||
elif "extra_headers" in sig.parameters:
|
||||
kwargs["extra_headers"] = headers
|
||||
try:
|
||||
async with websockets.connect(url, **kwargs) as ws:
|
||||
print(f"OK {name}")
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=2)
|
||||
print(f" recv {repr(msg)[:300]}")
|
||||
except asyncio.TimeoutError:
|
||||
print(" open but silent")
|
||||
except Exception as e:
|
||||
status = getattr(e, "status_code", None)
|
||||
if status is None and hasattr(e, "response"):
|
||||
status = getattr(e.response, "status_code", None)
|
||||
print(f"FAIL {name}: {type(e).__name__} status={status} {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
import websockets
|
||||
print("websockets", websockets.__version__)
|
||||
print("connect params", list(inspect.signature(websockets.connect).parameters))
|
||||
for name, url, headers in CASES:
|
||||
await try_one(websockets, name, url, headers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = "https://game.xworld.ren"
|
||||
USER = f"m2_{int(time.time())}"
|
||||
PASS = "m2_pass_123"
|
||||
|
||||
|
||||
def http_json(method, path, payload=None):
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data, method=method)
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
|
||||
|
||||
async def ws_try(url, headers=None):
|
||||
import websockets
|
||||
kwargs = {"open_timeout": 8}
|
||||
if headers:
|
||||
kwargs["extra_headers"] = headers
|
||||
print("WS", url, headers)
|
||||
async with websockets.connect(url, **kwargs) as ws:
|
||||
print("WS OPEN")
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=3)
|
||||
print("recv", repr(msg)[:500])
|
||||
except asyncio.TimeoutError:
|
||||
print("recv timeout")
|
||||
# try hello
|
||||
await ws.send(json.dumps({"op": "hello", "proto": 1}))
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=3)
|
||||
print("after hello", repr(msg)[:500])
|
||||
except asyncio.TimeoutError:
|
||||
print("after hello timeout")
|
||||
# ping style
|
||||
for payload in (
|
||||
{"op": "ping"},
|
||||
{"type": "ping"},
|
||||
"ping",
|
||||
):
|
||||
await ws.send(payload if isinstance(payload, str) else json.dumps(payload))
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=2)
|
||||
print("after", payload, "=>", repr(msg)[:300])
|
||||
except asyncio.TimeoutError:
|
||||
print("after", payload, "=> timeout")
|
||||
|
||||
|
||||
async def main():
|
||||
print("register", USER)
|
||||
st, reg = http_json("POST", "/register", {"username": USER, "password": PASS})
|
||||
print("register", st, reg)
|
||||
token = reg["token"]
|
||||
pid = reg["pid"]
|
||||
|
||||
st, login = http_json("POST", "/login", {"username": USER, "password": PASS})
|
||||
print("login", st, login)
|
||||
|
||||
cases = [
|
||||
(f"wss://game.xworld.ren/ws?pid={pid}&token={token}", None),
|
||||
(f"wss://game.xworld.ren/ws?pid={pid}", [("Authorization", f"Bearer {token}")]),
|
||||
(f"wss://game.xworld.ren/ws?token={token}", None),
|
||||
(f"wss://game.xworld.ren/ws?pid={pid}", [("X-Token", token)]),
|
||||
]
|
||||
for url, headers in cases:
|
||||
try:
|
||||
await ws_try(url, headers)
|
||||
break
|
||||
except Exception as e:
|
||||
print("FAIL", type(e).__name__, e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user