Files
90CNServer/launcher/server.py
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

2395 lines
90 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""DNF90 单机模拟器 —— 整合启动器 + GM 管理工具(单进程后端)。
纯 Python 标准库实现(无第三方依赖,方便 PyInstaller 打包成单 EXE)。
功能:
- 启动器:开始游戏 / 关闭服务 / 赞助二维码
- GM 工具:角色管理(含删除角色)、删档、无限疲劳、道具发放、
邮件、背包、任务、副本、钱包、玩法配置、频道管理
- 数据源:SQLite 直读(服务端停止时可删档/改属性) + Native API
(服务端运行时实时操作)
自提权:服务端启动需要管理员权限,启动时通过 UAC 自提权。
"""
import ctypes
import json
import os
import queue
import shutil
import socket
import subprocess
import sys
import threading
import time
import uuid
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from db import DNFDatabase, set_gm_config
from native import NativeClient, NativeError
# ------------------------------------------------------------------ 常量
GAME_USER = "dnfplayer"
GAME_PASS = "dnf123456"
CREATE_NO_WINDOW = 0x08000000
DETACHED_PROCESS = 0x00000008
DEFAULT_PORT = 19527 # 启动器本地端口
def base_dir():
"""程序所在目录(源码运行时 = 脚本目录,冻结运行时 = exe 目录)。"""
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def resource_dir():
"""资源目录(PyInstaller 打包时资源在 _MEIPASS)。"""
if getattr(sys, "frozen", False):
return getattr(sys, "_MEIPASS", base_dir())
return base_dir()
BASE_DIR = base_dir()
RES_DIR = resource_dir()
# 自定义账号配置文件(存 EXE 同目录,可跨机器拷贝)
ACCOUNTS_FILE = os.path.join(BASE_DIR, "accounts.json")
def _find_server_root():
"""定位服务端根目录(90server)。
启动器 EXE 始终放置在 90server 根目录内,因此按以下顺序识别,
完全基于「EXE 自身运行目录」,不依赖任何写死的盘符:
1. 环境变量 DNF90_SERVER_ROOT(显式覆盖)
2. EXE 所在目录本身(若其下存在 Server/ 目录)
3. 向上逐级查找含 Server/ 的目录(最多 4 层,兼容 EXE 放在子目录的情况)
"""
# 2. EXE 所在目录本身就是服务端根目录
d = os.path.abspath(BASE_DIR)
if os.path.isdir(os.path.join(d, "Server")):
return d
# 3. 向上逐级查找
for _ in range(4):
parent = os.path.dirname(d)
if parent == d:
break
d = parent
if os.path.isdir(os.path.join(d, "Server")):
return d
return ""
SERVER_ROOT = (os.environ.get("DNF90_SERVER_ROOT", "")
or _find_server_root()
or os.path.abspath(BASE_DIR))
CONTROL = os.path.join(SERVER_ROOT, "Server", "runtime", "bin", "DNF90Control.exe")
CLIENT_DIR = os.path.join(SERVER_ROOT, "Client")
CLIENT_LAUNCHER = os.path.join(SERVER_ROOT, "Server", "runtime", "bin", "DNF90Launcher.exe")
# GM 配置目录(物品名称表 + pvfexport 工具,均放这里,PVF 更新后自动重导出)
GM_CONFIG_DIR = os.path.join(SERVER_ROOT, "GM配置")
PVF_PATH = os.path.join(CLIENT_DIR, "Script.pvf")
# 一键换装工具(独立工具,放在服务端根目录的 LoadoutTool 文件夹下)
LOADOUT_DIR = os.path.join(SERVER_ROOT, "LoadoutTool")
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe")
# 一键换装 / 服务端工具的持久化配置路径
LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.json")
SERVER_CONFIG_DIR = os.path.join(SERVER_ROOT, "Server", "runtime", "config")
SERVER_FEATURES_PATH = os.path.join(SERVER_CONFIG_DIR, "server-features.json")
ACTIVITY_CONFIG_PATH = os.path.join(SERVER_CONFIG_DIR, "activity.json")
RAW_CONFIG_NAMES = ("instance.json", "dnfbridge.toml", "logic.toml", "plan.json")
# 赞助二维码图片路径(放启动器目录下 sponsor.png)
SPONSOR_IMG = os.path.join(BASE_DIR, "sponsor.png")
# 打包时优先从资源目录读取
if not os.path.exists(SPONSOR_IMG):
SPONSOR_IMG = os.path.join(RES_DIR, "sponsor.png")
# 数据库与实例配置候选路径(基于运行目录解析出的 SERVER_ROOT)
def _instance_candidates():
out = []
if SERVER_ROOT:
out.append(os.path.join(SERVER_ROOT, "Server", "runtime", "config", "instance.json"))
return out
def _db_candidates():
out = []
if SERVER_ROOT:
out.append(os.path.join(SERVER_ROOT, "Server", "runtime", "data", "dnf90.db"))
return out
# ------------------------------------------------------------------ 工具函数
def is_admin():
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0
except Exception:
return False
def port_open(port, host="127.0.0.1"):
s = socket.socket()
s.settimeout(0.3)
try:
s.connect((host, port))
return True
except OSError:
return False
finally:
s.close()
def _read_json_file(path, default):
try:
if path and os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
pass
return default
def _write_json_file(path, obj):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
def run_tool(args, stdin_bytes=None, timeout=180, env=None):
"""隐藏窗口运行一个控制台工具,返回 (returncode, output)。"""
try:
p = subprocess.run(
args, input=stdin_bytes, capture_output=True, env=env,
creationflags=CREATE_NO_WINDOW, timeout=timeout)
out = (p.stdout or b"").decode("utf-8", "replace").strip()
err = (p.stderr or b"").decode("utf-8", "replace").strip()
combined = out + ("\n" + err if err else "")
return p.returncode, combined.strip()
except subprocess.TimeoutExpired:
return -1, "操作超时"
except Exception as e:
return -1, repr(e)
def server_running():
"""服务端是否在运行(admin 端口 18111 是否监听)。"""
return port_open(18111)
# ------------------------------------------------------------------ 自定义账号配置
def load_accounts():
"""读取自定义账号列表(accounts.json),无则返回默认账号。
返回 list[dict],每项 {username, password, is_default}。
保证至少存在一个默认账号。
"""
default = [{"username": GAME_USER, "password": GAME_PASS, "is_default": True}]
if not os.path.exists(ACCOUNTS_FILE):
return default
try:
with open(ACCOUNTS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list) or not data:
return default
# 规范化字段
items = []
has_default = False
for it in data:
if not isinstance(it, dict):
continue
u = str(it.get("username", "")).strip()
p = str(it.get("password", ""))
if not u:
continue
is_def = bool(it.get("is_default", False))
has_default = has_default or is_def
items.append({"username": u, "password": p, "is_default": is_def})
if not items:
return default
if not has_default:
items[0]["is_default"] = True
return items
except (OSError, ValueError):
return default
def save_accounts(items):
"""把账号列表写回 accounts.json。"""
try:
with open(ACCOUNTS_FILE, "w", encoding="utf-8") as f:
json.dump(items, f, ensure_ascii=False, indent=2)
return True
except OSError:
return False
def get_default_account():
"""返回当前默认启动账号 (username, password)。"""
for it in load_accounts():
if it.get("is_default"):
return it["username"], it["password"]
return GAME_USER, GAME_PASS
def get_launch_account(username=None):
"""按启动器当前选择的账号返回 (username, password)。"""
accounts = load_accounts()
if username:
for it in accounts:
if it.get("username") == username:
return it["username"], it["password"]
return get_default_account()
def dnf_client_count():
"""统计当前已启动的 DNF 客户端数量,用于多开判断。"""
try:
out = subprocess.run(
["tasklist", "/fi", "imagename eq DNF.exe", "/nh"],
capture_output=True, text=True, encoding="mbcs", errors="ignore",
creationflags=CREATE_NO_WINDOW, timeout=5)
return len([l for l in (out.stdout or "").splitlines()
if l.strip().lower().startswith("dnf.exe")])
except Exception:
return 0
# ------------------------------------------------------------------ 流程
_logq = queue.Queue()
def log(msg):
_logq.put(str(msg))
def flow_start_game(account_username=None):
"""一键开始游戏;account_username 为空时使用默认账号。"""
log("== 开始游戏 ==")
user, passwd = get_launch_account(account_username)
log("登录账号:%s" % user)
server_dir = os.path.join(SERVER_ROOT, "Server")
client_dir = os.path.join(SERVER_ROOT, "Client")
if not os.path.isdir(server_dir) or not os.path.isdir(client_dir):
log("[错误] 客户端或服务端不完整,请重新下载游戏。")
log(" 期望目录:%s 和 %s" % (server_dir, client_dir))
return
if not os.path.exists(CONTROL):
log("[错误] 找不到服务端控制器:%s" % CONTROL)
return
if not os.path.isdir(CLIENT_DIR):
log("[错误] 找不到客户端目录:%s" % CLIENT_DIR)
return
log("[1/4] 登记客户端路径…")
rc, out = run_tool([CONTROL, "configure-client", "--directory", CLIENT_DIR])
log(out or ("rc=%d" % rc))
if rc:
log("[错误] 客户端路径登记失败,终止。")
return
if server_running():
log("[2/4] 服务端已在运行,跳过启动。")
else:
log("[2/4] 启动服务端(首次需 1-2 分钟)…")
rc, out = run_tool([CONTROL, "start"], timeout=300)
log(out or ("rc=%d" % rc))
if rc:
log("[错误] 服务端启动失败,请看上方输出。")
return
log("[3/4] 注册/校验账号 %s …" % user)
rc, out = run_tool([CONTROL, "account", "register", "--username", user,
"--password-stdin"], passwd.encode())
if rc == 0:
log("账号就绪。")
else:
log("注册跳过(可能已存在):%s" % (out or rc))
log("[4/4] 启动客户端并自动登录…")
client_env = os.environ.copy()
# 与 90server\登录游戏.bat 保持一致,否则客户端会卡在连接频道。
client_env["DNF_RESIDENT_CHANNEL_ID"] = "11"
client_env["DNF_AUTO_CONNECT_CHANNEL_ID"] = "11"
client_env["DNF_COMPANION_P0_STARTMAP_APC"] = "0"
launch_args = [CONTROL, "launch-client", "--client-directory", CLIENT_DIR,
"--username", user, "--password-stdin"]
if dnf_client_count() > 0:
launch_args.insert(2, "--multi-instance")
log("[多开] 检测到已有客户端,使用多实例启动。")
rc, out = run_tool(launch_args, passwd.encode(), timeout=120, env=client_env)
log(out or ("rc=%d" % rc))
if rc != 0 and os.path.exists(CLIENT_LAUNCHER):
log("[回退] 使用 DNF90Launcher.exe 启动客户端…")
try:
subprocess.Popen([CLIENT_LAUNCHER], cwd=SERVER_ROOT, env=client_env,
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
close_fds=True)
except Exception as e:
log("[错误] 客户端启动失败:%s" % e)
log("完成:若游戏窗口未出现,请查看上方报错。")
def flow_stop_server():
"""关闭服务。"""
log("== 关闭服务 ==")
if not os.path.exists(CONTROL):
log("[错误] 找不到服务端控制器:%s" % CONTROL)
return
log("正在安全停止服务端…")
rc, out = run_tool([CONTROL, "stop"])
log(out or ("rc=%d" % rc))
if rc == 0:
log("服务端已停止。")
else:
log("[警告] 停止返回码 %d,请查看上方输出。" % rc)
def flow_launch_loadout():
"""一键换装:启动 DNFLoadout.exe。"""
log("== 一键换装 ==")
if not os.path.exists(LOADOUT_EXE):
log("[错误] 找不到换装工具:%s" % LOADOUT_EXE)
return
if not server_running():
log("[提示] 服务端未运行,换装工具可能无法连接。建议先「开始游戏」。")
log("正在启动换装工具…")
try:
subprocess.Popen(
[LOADOUT_EXE], cwd=LOADOUT_DIR,
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
close_fds=True)
log("换装工具已启动。")
except Exception as e:
log("[错误] 启动失败:%s" % repr(e))
# ------------------------------------------------------------------ 数据库 / Native 客户端初始化
def load_instance(path):
if not path or not os.path.exists(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {}
def _resolve_instance():
inst_path = ""
for c in _instance_candidates():
if os.path.exists(c):
inst_path = c
break
return inst_path, load_instance(inst_path)
def _resolve_db():
db_path = ""
for c in _db_candidates():
if os.path.exists(c):
db_path = c
break
return db_path
# 全局单例(模块加载时初始化)
INSTANCE_PATH, INSTANCE = _resolve_instance()
DB_PATH = _resolve_db()
_server_cfg = INSTANCE.get("server", {}) or {}
ADMIN_TOKEN = _server_cfg.get("adminToken", "")
ADMIN_LISTEN = _server_cfg.get("adminListen", "127.0.0.1:18111")
DB = DNFDatabase(DB_PATH)
if DB_PATH and os.path.exists(DB_PATH):
DB.connect()
# 注入物品名称表目录与 PVF 路径(名称表放外部 GM配置 目录,PVF 更新后自动重导出)
set_gm_config(GM_CONFIG_DIR, PVF_PATH)
NATIVE = NativeClient("http://" + ADMIN_LISTEN, ADMIN_TOKEN) if ADMIN_TOKEN else None
# ------------------------------------------------------------------ HTTP 服务
def _json_bytes(obj, status=200):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
return status, body
class Handler(BaseHTTPRequestHandler):
server_version = "DNF90Launcher/2.0"
data_source = "sqlite" # "sqlite" | "native"
def log_message(self, fmt, *args):
pass
# ---- 基础 ----
def _send(self, status, body, ctype="application/json; charset=utf-8"):
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _json(self, obj, status=200):
self._send(*_json_bytes(obj, status))
def _send_file(self, path, ctype):
if not os.path.exists(path):
self._json({"error": "file missing"}, 404)
return
body = open(path, "rb").read()
self._send(200, body, ctype)
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return {}
# ---- 路由 ----
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
# 静态资源
if path in ("/", "/index.html"):
self._send_file(os.path.join(RES_DIR, "index.html"), "text/html; charset=utf-8")
elif path == "/style.css":
self._send_file(os.path.join(RES_DIR, "style.css"), "text/css; charset=utf-8")
elif path == "/shell.css":
self._send_file(os.path.join(RES_DIR, "shell.css"), "text/css; charset=utf-8")
elif path == "/app.js":
self._send_file(os.path.join(RES_DIR, "app.js"), "application/javascript; charset=utf-8")
elif path == "/shell.js":
self._send_file(os.path.join(RES_DIR, "shell.js"), "application/javascript; charset=utf-8")
elif path == "/sponsor.png":
if os.path.exists(SPONSOR_IMG):
self._send_file(SPONSOR_IMG, "image/png")
else:
self._json({"error": "赞助二维码尚未提供"}, 404)
# 启动器接口
elif path == "/api/status":
self._api_status()
elif path == "/api/loadout/config":
self._api_loadout_config()
elif path == "/api/loadout/characters":
self._api_loadout_characters()
elif path == "/api/loadout/equipment":
self._api_loadout_equipment(qs)
elif path == "/api/servertool/features":
self._api_servertool_features()
elif path == "/api/servertool/activity":
self._api_servertool_activity()
elif path == "/api/servertool/info":
self._api_servertool_info()
elif path == "/api/servertool/raw":
self._api_servertool_raw(qs)
elif path == "/api/servertool/gameplay":
self._api_servertool_gameplay()
elif path == "/api/servertool/batch-pickup":
self._api_servertool_batch_pickup()
elif path == "/api/companion":
self._api_companion(qs)
elif path == "/api/basic-attack":
self._json({"ok": True, "enabled": not os.path.exists(
os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))})
elif path == "/api/logs":
self._api_logs()
elif path == "/api/gm/health":
self._api_gm_health()
elif path == "/api/accounts-config":
self._api_accounts_config()
# GM 只读接口
elif path == "/api/accounts":
self._api_accounts()
elif path == "/api/characters":
self._api_characters(qs)
elif path == "/api/character":
self._api_character(qs)
elif path == "/api/character/inventory":
self._api_character_inventory(qs)
elif path == "/api/inventory/list":
self._api_inventory_list(qs)
elif path == "/api/character/equipment":
self._api_character_equipment(qs)
elif path == "/api/character/mailbox":
self._api_character_mailbox(qs)
elif path == "/api/character/fatigue":
self._api_character_fatigue(qs)
elif path == "/api/character/career":
self._api_character_career_get(qs)
elif path == "/api/character/dungeons":
self._api_character_dungeons(qs)
elif path == "/api/character/black-diamond":
self._api_character_black_diamond(qs)
elif path == "/api/gameplay":
self._api_gameplay(qs)
elif path == "/api/quests":
self._api_quests(qs)
elif path == "/api/quests/search":
self._api_quests_search(qs)
elif path == "/api/quests/detail":
self._api_quests_detail(qs)
elif path == "/api/wipe/preview":
self._api_wipe_preview(qs)
elif path == "/api/items/categories":
self._api_items_categories()
elif path == "/api/item/search":
self._api_item_search(qs)
elif path == "/api/auction/listings":
self._api_auction_listings()
elif path == "/api/auction/items":
self._api_auction_items(qs)
else:
self._json({"error": "not found"}, 404)
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
data = self._read_body()
if path == "/api/action":
self._api_action(data)
elif path == "/api/loadout/config":
self._api_save_loadout_config(data)
elif path == "/api/loadout/apply":
self._api_loadout_apply(data)
elif path == "/api/servertool/features":
self._api_save_servertool_features(data)
elif path == "/api/servertool/activity":
self._api_save_servertool_activity(data)
elif path == "/api/servertool/raw":
self._api_save_servertool_raw(data)
elif path == "/api/servertool/gameplay":
self._api_save_servertool_gameplay(data)
elif path == "/api/servertool/batch-pickup":
self._api_save_servertool_batch_pickup(data)
elif path == "/api/companion":
self._api_save_companion(data)
elif path == "/api/basic-attack":
enabled = bool(data.get("enabled", True))
flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled")
try:
if enabled:
if os.path.exists(flag):
os.remove(flag)
else:
os.makedirs(CLIENT_DIR, exist_ok=True)
with open(flag, "w", encoding="utf-8") as f:
f.write("disabled\n")
self._json({"ok": True, "enabled": enabled})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 500)
elif path == "/api/accounts-config":
self._api_accounts_config_post(data)
elif path == "/api/source":
self._api_source(data)
elif path == "/api/character/save":
self._api_character_save(data)
elif path == "/api/character/stat":
self._api_character_stat(data)
elif path == "/api/character/fatigue":
self._api_character_fatigue(data)
elif path == "/api/character/gold":
self._api_character_gold(data)
elif path == "/api/character/level":
self._api_character_level(data)
elif path == "/api/character/skill-points":
self._api_character_skill(data)
elif path == "/api/character/career":
self._api_character_career(data)
elif path == "/api/dungeon/unlock":
self._api_dungeon_unlock(data)
elif path == "/api/black-diamond/activate":
self._api_black_diamond_activate(data)
elif path == "/api/gameplay/hell-drop-rate":
self._api_gameplay_hell_drop_rate(data)
elif path == "/api/gameplay/epic-potion-multiplier":
self._api_gameplay_epic_potion(data)
elif path == "/api/gameplay/fatigue-mode":
self._api_gameplay_fatigue_mode(data)
elif path == "/api/gameplay/burning":
self._api_gameplay_burning(data)
elif path == "/api/character/fatigue/refill":
self._api_fatigue_refill(data)
elif path == "/api/quests/action":
self._api_quests_action(data)
elif path == "/api/character/grant":
self._api_grant_item(data)
elif path == "/api/character/delete":
self._api_character_delete(data)
elif path == "/api/account/delete":
self._api_account_delete(data)
elif path == "/api/item/search":
self._api_item_search_body(data)
elif path == "/api/item/grant":
self._api_item_grant_native(data)
elif path == "/api/item/grant-options":
self._api_item_grant_options(data)
elif path == "/api/item/mail":
self._api_item_mail(data)
elif path == "/api/mail/send":
self._api_mail_send(data)
elif path == "/api/mailbox/clear":
self._api_mailbox_clear(data)
elif path == "/api/inventory/clear":
self._api_inventory_clear(data)
elif path == "/api/inventory/delete":
self._api_inventory_delete(data)
elif path == "/api/wallet/query":
self._api_wallet_query(data)
elif path == "/api/cera":
self._api_cera(data)
elif path == "/api/auction/list":
self._api_auction_list(data)
elif path == "/api/auction/cancel":
self._api_auction_cancel(data)
else:
self._json({"error": "not found"}, 404)
# ==================== 启动器接口 ====================
def _api_status(self):
server_dir = os.path.join(SERVER_ROOT, "Server")
client_dir = os.path.join(SERVER_ROOT, "Client")
self._json({
"ok": True,
"server_running": server_running(),
"admin": is_admin(),
"server_root": SERVER_ROOT,
"client_dir": CLIENT_DIR,
"server_dir_exists": os.path.isdir(server_dir),
"client_dir_exists": os.path.isdir(client_dir),
"control_exists": os.path.exists(CONTROL),
"sponsor_exists": os.path.exists(SPONSOR_IMG),
"loadout_exists": os.path.exists(LOADOUT_EXE),
"db": DB_PATH,
"db_exists": bool(DB_PATH) and os.path.exists(DB_PATH),
"native_available": NATIVE is not None,
})
def _api_logs(self):
logs = []
while True:
try:
logs.append(_logq.get_nowait())
except queue.Empty:
break
self._json({"ok": True, "logs": logs})
def _api_action(self, data):
action = data.get("action", "")
if action == "start":
if not is_admin():
self._json({"ok": False, "error": "需要管理员权限,请以管理员身份运行启动器"})
return
account = str(data.get("account", "") or "").strip()
threading.Thread(target=_wrap, args=(flow_start_game, account), daemon=True).start()
self._json({"ok": True})
elif action == "stop":
threading.Thread(target=_wrap, args=(flow_stop_server,), daemon=True).start()
self._json({"ok": True})
elif action == "loadout":
threading.Thread(target=_wrap, args=(flow_launch_loadout,), daemon=True).start()
self._json({"ok": True})
else:
self._json({"ok": False, "error": "unknown action"})
# ==================== 一键换装 ====================
def _api_loadout_config(self):
cfg = _read_json_file(LOADOUT_CONFIG, {
"ServerUrl": "http://127.0.0.1:18111",
"AdminToken": ADMIN_TOKEN,
"Pages": [{"Name": "默认页", "Schemes": []}],
"ActivePage": 0,
})
self._json({"ok": True, "data": cfg})
def _api_save_loadout_config(self, data):
cfg = data.get("config") if isinstance(data, dict) else None
if not isinstance(cfg, dict):
self._json({"ok": False, "error": "config required"}, 400)
return
if not cfg.get("Pages"):
cfg["Pages"] = [{"Name": "默认页", "Schemes": []}]
try:
cfg["ActivePage"] = max(0, min(int(cfg.get("ActivePage", 0)),
len(cfg["Pages"]) - 1))
except (TypeError, ValueError):
cfg["ActivePage"] = 0
_write_json_file(LOADOUT_CONFIG, cfg)
self._json({"ok": True, "data": cfg})
def _api_loadout_characters(self):
if NATIVE is None:
self._json({"ok": False, "error": "Native API 不可用"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.loadout_characters()})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
def _api_loadout_equipment(self, qs):
character = (qs.get("character") or qs.get("id") or [""])[0].strip()
if not character:
self._json({"ok": False, "error": "character required"}, 400)
return
if NATIVE is None:
self._json({"ok": False, "error": "Native API 不可用"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.loadout_equipment(character)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
def _api_loadout_apply(self, data):
if NATIVE is None:
self._json({"ok": False, "error": "Native API 不可用"}, 400)
return
character = str(data.get("character", "")).strip()
slots = data.get("slots") or []
if not character or not isinstance(slots, list):
self._json({"ok": False, "error": "character/slots required"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.loadout_apply(character, slots)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
# ==================== 服务端工具 ====================
def _api_servertool_features(self):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.get_server_features()})
return
except Exception:
pass
cfg = _read_json_file(SERVER_FEATURES_PATH, {})
self._json({"ok": True, "source": "file", "data": cfg})
def _api_save_servertool_features(self, data):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.save_server_features(data)})
return
except Exception as e:
fallback_error = str(e)
else:
fallback_error = "Native API 不可用"
_write_json_file(SERVER_FEATURES_PATH, data)
self._json({"ok": True, "source": "file",
"warning": fallback_error, "data": data})
def _api_servertool_activity(self):
self._json({"ok": True,
"data": _read_json_file(ACTIVITY_CONFIG_PATH, {})})
def _api_save_servertool_activity(self, data):
_write_json_file(ACTIVITY_CONFIG_PATH, data)
self._json({"ok": True, "data": data})
def _api_servertool_info(self):
self._json({
"ok": True,
"features_path": SERVER_FEATURES_PATH,
"activity_path": ACTIVITY_CONFIG_PATH,
"config_dir": SERVER_CONFIG_DIR,
"raw_names": list(RAW_CONFIG_NAMES),
"server_running": server_running(),
})
def _api_servertool_raw(self, qs):
name = (qs.get("name") or [""])[0]
if name not in RAW_CONFIG_NAMES:
self._json({"error": "invalid config name"}, 400)
return
path = os.path.join(SERVER_CONFIG_DIR, name)
try:
text = ""
if os.path.exists(path):
with open(path, "r", encoding="utf-8-sig") as f:
text = f.read()
self._json({"ok": True, "name": name, "path": path, "text": text})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_save_servertool_raw(self, data):
name = str(data.get("name", ""))
text = str(data.get("text", ""))
if name not in RAW_CONFIG_NAMES:
self._json({"error": "invalid config name"}, 400)
return
path = os.path.join(SERVER_CONFIG_DIR, name)
try:
os.makedirs(SERVER_CONFIG_DIR, exist_ok=True)
if os.path.exists(path):
shutil.copy2(path, path + ".bak_tool")
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
self._json({"ok": True, "name": name, "path": path})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_servertool_gameplay(self):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.get_gameplay_settings()})
return
except Exception:
pass
self._json({"ok": True, "source": "file", "data": {}})
def _api_save_servertool_gameplay(self, data):
if NATIVE is None or not server_running():
self._json({"error": "服务端未运行,无法写入玩法参数"}, 400)
return
try:
if data.get("hellEpicDropRatePercent") is not None:
NATIVE.set_hell_drop_rate(int(data["hellEpicDropRatePercent"]))
if data.get("epicPotionMultiplierPercent") is not None:
NATIVE.set_epic_potion_multiplier(int(data["epicPotionMultiplierPercent"]))
self._json({"ok": True, "data": data})
except Exception as e:
self._json({"error": str(e)}, 502)
def _batch_pickup_path(self):
return INSTANCE_PATH if INSTANCE_PATH else os.path.join(
SERVER_CONFIG_DIR, "instance.json")
def _api_servertool_batch_pickup(self):
path = self._batch_pickup_path()
cfg = _read_json_file(path, {})
value = True
try:
value = bool(cfg.get("game", {}).get("dungeonBatchPickupEnabled", True))
except Exception:
value = True
self._json({"ok": True, "data": {"enabled": value}, "path": path})
def _api_save_servertool_batch_pickup(self, data):
path = self._batch_pickup_path()
cfg = _read_json_file(path, None)
if not isinstance(cfg, dict):
self._json({"error": "instance.json 不存在或格式错误"}, 400)
return
game = cfg.get("game")
if not isinstance(game, dict):
game = {}
cfg["game"] = game
game["dungeonBatchPickupEnabled"] = bool(data.get("enabled", True))
try:
if os.path.exists(path):
shutil.copy2(path, path + ".bak_tool")
_write_json_file(path, cfg)
self._json({"ok": True, "data": {"enabled": game["dungeonBatchPickupEnabled"]}})
except Exception as e:
self._json({"error": str(e)}, 500)
# ==================== 支援兵 ====================
def _api_companion(self, qs):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400)
return
character = (qs.get("character") or qs.get("id") or [""])[0].strip()
if not character:
self._json({"ok": False, "error": "缺少角色 ID"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_state(character)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
def _api_save_companion(self, data):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_save(data)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
# ==================== 自定义账号配置 ====================
def _api_accounts_config(self):
"""返回账号列表(含默认标记)。"""
try:
items = load_accounts()
self._json({"ok": True, "data": items,
"default_username": (get_default_account()[0])})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_accounts_config_post(self, data):
"""账号配置操作:add / delete / select。"""
op = data.get("op", "")
try:
items = load_accounts()
if op == "add":
username = str(data.get("username", "")).strip()
password = str(data.get("password", ""))
if not username:
self._json({"error": "用户名不能为空"}, 400)
return
if not password:
self._json({"error": "密码不能为空"}, 400)
return
if any(it["username"] == username for it in items):
self._json({"error": "该账号已存在"}, 400)
return
items.append({"username": username, "password": password,
"is_default": False})
save_accounts(items)
self._json({"ok": True, "data": items})
elif op == "delete":
username = str(data.get("username", "")).strip()
if not username:
self._json({"error": "缺少用户名"}, 400)
return
if username == GAME_USER:
self._json({"error": "默认账号不可删除"}, 400)
return
new_items = [it for it in items if it["username"] != username]
if len(new_items) == len(items):
self._json({"error": "账号不存在"}, 404)
return
# 若删的是默认账号,把第一个设为默认
was_default = any(it["username"] == username and it["is_default"] for it in items)
if was_default and new_items:
new_items[0]["is_default"] = True
save_accounts(new_items)
self._json({"ok": True, "data": new_items})
elif op == "select":
username = str(data.get("username", "")).strip()
if not username or not any(it["username"] == username for it in items):
self._json({"error": "账号不存在"}, 404)
return
for it in items:
it["is_default"] = (it["username"] == username)
save_accounts(items)
self._json({"ok": True, "data": items,
"default_username": username})
else:
self._json({"error": "未知操作"}, 400)
except Exception as e:
self._json({"error": str(e)}, 500)
# ==================== GM 数据源 ====================
def _api_gm_health(self):
self._json({
"ok": True,
"source": Handler.data_source,
"db": DB_PATH,
"db_exists": bool(DB_PATH) and os.path.exists(DB_PATH),
"native_available": NATIVE is not None,
"server_online": server_running(),
})
def _api_source(self, data):
src = data.get("source", "sqlite")
if src not in ("sqlite", "native"):
self._json({"error": "invalid source"}, 400)
return
if src == "native" and NATIVE is None:
self._json({"error": "Native API 不可用(未找到 adminToken)"}, 400)
return
Handler.data_source = src
self._json({"ok": True, "source": src})
def _use_sqlite(self):
return Handler.data_source == "sqlite" or not server_running()
# ---- 账号 ----
def _api_accounts(self):
if self._use_sqlite():
try:
self._json({"ok": True, "data": DB.list_accounts()})
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
self._json({"ok": True, "data": NATIVE.list_accounts()})
except NativeError as e:
self._json({"error": str(e)}, 500)
# ---- 角色 ----
def _api_characters(self, qs):
account = (qs.get("account") or [""])[0].strip()
if self._use_sqlite():
try:
chars = DB.list_characters()
if account:
chars = [c for c in chars
if str(c.get("account_id", "")) == account]
self._json({"ok": True, "data": chars})
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
self._json({"ok": True, "data": NATIVE.list_characters(account or None)})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_character(self, qs):
char_id = qs.get("id", [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
if self._use_sqlite():
ch = DB.get_character(char_id)
if ch is None:
self._json({"error": "角色不存在"}, 404)
return
self._json({"ok": True, "data": ch})
else:
try:
self._json({"ok": True, "data": NATIVE.get_character(char_id)})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_character_inventory(self, qs):
char_id = qs.get("id", [""])[0]
if self._use_sqlite():
self._json({"ok": True, "data": DB.get_record_json("inventory", str(char_id)) or {}})
else:
try:
self._json({"ok": True, "data": NATIVE.get_inventory(char_id)})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_character_equipment(self, qs):
char_id = qs.get("id", [""])[0]
if self._use_sqlite():
self._json({"ok": True, "data": DB.get_record_json("equipment", str(char_id)) or {}})
else:
try:
self._json({"ok": True, "data": NATIVE.get_equipment(char_id)})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_character_mailbox(self, qs):
char_id = qs.get("id", [""])[0]
if self._use_sqlite():
self._json({"ok": True, "data": DB.get_record_json("mailbox", str(char_id)) or {}})
else:
try:
self._json({"ok": True, "data": NATIVE.get_mailbox(char_id)})
except NativeError as e:
self._json({"error": str(e)}, 500)
# ---- 疲劳 ----
def _api_character_fatigue(self, data_or_qs):
# GET 传入 parse_qs 结果(值是 list),POST 传入 JSON dict(值是标量)
if isinstance(data_or_qs, dict):
raw_id = data_or_qs.get("id", "")
if isinstance(raw_id, list):
data = {k: (v[0] if isinstance(v, list) else v) for k, v in data_or_qs.items()}
else:
data = data_or_qs
else:
data = {}
char_id = data.get("id", "")
mode = data.get("mode", "query")
if not char_id:
self._json({"error": "missing id"}, 400)
return
if mode == "query":
if self._use_sqlite():
info = DB.get_fatigue(char_id)
self._json({"ok": True, "data": info})
else:
# Native 模式:从角色详情 stats 中读取疲劳字段
try:
detail = NATIVE.get_character_detail(char_id)
stats = (detail or {}).get("stats", {}) if isinstance(detail, dict) else {}
self._json({"ok": True, "data": {
"fatigue": stats.get("fatigue"),
"fatigue_limit": stats.get("fatigue_limit"),
"used": stats.get("fatigue_display_update"),
}})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
return
# 修改:仅 SQLite
if not self._use_sqlite():
self._json({"error": "修改疲劳值需服务端停止"}, 400)
return
try:
fatigue = int(data.get("fatigue", 999999))
limit = data.get("fatigue_limit")
limit = int(limit) if limit not in (None, "") else None
result = DB.set_fatigue(char_id, fatigue, limit)
self._json({"ok": True, "data": result})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
# ---- 角色保存 ----
def _api_character_save(self, data):
char_id = str(data.get("id", ""))
field = data.get("field")
value = data.get("value")
if not char_id or field is None:
self._json({"error": "missing id/field"}, 400)
return
if self._use_sqlite():
try:
DB.set_character_field(char_id, field, value)
self._json({"ok": True})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
else:
self._json({"error": "该操作需 SQLite 数据源"}, 400)
def _api_character_stat(self, data):
char_id = str(data.get("id", ""))
stat = data.get("stat")
value = data.get("value")
if not char_id or not stat:
self._json({"error": "missing id/stat"}, 400)
return
if self._use_sqlite():
try:
DB.set_character_stat(char_id, stat, value)
self._json({"ok": True})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
else:
if stat == "gold":
try:
NATIVE.set_gold(char_id, int(value))
self._json({"ok": True})
except NativeError as e:
self._json({"error": str(e)}, 500)
else:
self._json({"error": "Native 模式下仅支持修改金币,其余属性请切 SQLite"}, 400)
def _api_character_gold(self, data):
char_id = str(data.get("id", ""))
amount = data.get("amount")
if not char_id or amount is None:
self._json({"error": "missing id/amount"}, 400)
return
if self._use_sqlite():
try:
DB.set_character_stat(char_id, "gold", int(amount))
self._json({"ok": True})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
NATIVE.set_gold(char_id, int(amount))
self._json({"ok": True})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_character_level(self, data):
char_id = str(data.get("id", ""))
level = data.get("level")
if not char_id or level is None:
self._json({"error": "missing id/level"}, 400)
return
if self._use_sqlite():
try:
DB.set_character_field(char_id, "level", int(level))
self._json({"ok": True})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
NATIVE.set_level(char_id, int(level))
self._json({"ok": True})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_character_skill(self, data):
char_id = str(data.get("id", ""))
sp = data.get("bonus_sp", 0)
tp = data.get("bonus_tp", 0)
if self._use_sqlite():
try:
DB.set_character_stat(char_id, "bonus_sp", int(sp))
DB.set_character_stat(char_id, "bonus_tp", int(tp))
self._json({"ok": True})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
NATIVE.set_skill_bonus(char_id, int(sp), int(tp))
self._json({"ok": True})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_character_career_get(self, qs):
char_id = (qs.get("id") or [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
if self._use_sqlite():
# SQLite 直读模式下读取已存储的职业字段
try:
ch = DB.get_character(char_id)
if ch is None:
self._json({"error": "角色不存在"}, 404)
return
self._json({"ok": True, "data": {
"job": int(ch.get("job", "0") or 0),
"profession_name": ch.get("profession_name", "") or "",
"options": [],
}})
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
res = NATIVE.get_career(char_id)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
# ---- 副本 / 黑钻 / 爆率(Native,服务端在线) ----
def _api_character_dungeons(self, qs):
char_id = (qs.get("id") or [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
if not self._require_native():
return
try:
res = NATIVE.get_dungeons(char_id)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_character_black_diamond(self, qs):
char_id = (qs.get("id") or [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
if not self._require_native():
return
try:
res = NATIVE.get_black_diamond(char_id)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_gameplay(self, qs):
if not self._require_native():
return
try:
res = NATIVE.get_gameplay_settings()
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_dungeon_unlock(self, data):
char_id = str(data.get("id", ""))
kind = data.get("kind", "difficulties")
if not char_id:
self._json({"error": "missing id"}, 400)
return
if not self._require_native():
return
try:
if kind == "hell":
res = NATIVE.unlock_hell_challenges(char_id)
else:
res = NATIVE.unlock_dungeon_difficulties(char_id)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_black_diamond_activate(self, data):
char_id = str(data.get("id", ""))
days = data.get("days", 30)
if not char_id:
self._json({"error": "missing id"}, 400)
return
if not self._require_native():
return
try:
res = NATIVE.activate_black_diamond(char_id, int(days))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_gameplay_hell_drop_rate(self, data):
if not self._require_native():
return
try:
percent = int(data.get("percent"))
except (TypeError, ValueError):
self._json({"error": "missing or invalid percent"}, 400)
return
try:
res = NATIVE.set_hell_drop_rate(percent)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_gameplay_epic_potion(self, data):
if not self._require_native():
return
try:
percent = int(data.get("percent"))
except (TypeError, ValueError):
self._json({"error": "missing or invalid percent"}, 400)
return
try:
res = NATIVE.set_epic_potion_multiplier(percent)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_gameplay_fatigue_mode(self, data):
if not self._require_native():
return
mode = str(data.get("mode", "")).strip()
if mode not in ("none", "free", "lock", "refill"):
self._json({"error": "invalid mode"}, 400)
return
try:
res = NATIVE.set_fatigue_mode(mode)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_gameplay_burning(self, data):
if not self._require_native():
return
enabled = bool(data.get("enabled"))
try:
res = NATIVE.set_burning(enabled)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_fatigue_refill(self, data):
char_id = str(data.get("id", ""))
if not char_id:
self._json({"error": "missing id"}, 400)
return
if not self._require_native():
return
try:
res = NATIVE.refill_fatigue(char_id)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_character_career(self, data):
char_id = str(data.get("id", ""))
job = data.get("job", 0)
first = data.get("first", 0)
awakening = data.get("awakening", 0)
if self._use_sqlite():
try:
DB.set_character_field(char_id, "job", int(job))
DB.set_character_field(char_id, "first", int(first))
DB.set_character_field(char_id, "awakening", int(awakening))
self._json({"ok": True})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
NATIVE.set_career(char_id, int(job), int(first), int(awakening))
self._json({"ok": True})
except NativeError as e:
self._json({"error": str(e)}, 500)
# ---- 任务查询 / 操作(Native,服务端在线) ----
def _api_quests(self, qs):
char_id = (qs.get("id") or [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
if not self._require_native():
return
try:
res = NATIVE.get_quest_overview(char_id)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_quests_search(self, qs):
char_id = (qs.get("id") or [""])[0]
query = (qs.get("q") or [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
if not self._require_native():
return
try:
res = NATIVE.search_quests(char_id, query, 50)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_quests_detail(self, qs):
char_id = (qs.get("id") or [""])[0]
quest_id = (qs.get("quest_id") or [""])[0]
if not char_id or not quest_id:
self._json({"error": "missing id/quest_id"}, 400)
return
if not self._require_native():
return
try:
res = NATIVE.get_quest_detail(char_id, int(quest_id))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_quests_action(self, data):
char_id = str(data.get("id", ""))
quest_id = data.get("quest_id", 0)
action = str(data.get("action", ""))
if not char_id or not quest_id or not action:
self._json({"error": "missing id/quest_id/action"}, 400)
return
if not self._require_native():
return
actions = {
"accept": NATIVE.accept_quest,
"ready": NATIVE.mark_quest_ready,
"give-up": NATIVE.give_up_quest,
"complete": NATIVE.complete_quest,
"uncomplete": NATIVE.uncomplete_quest,
}
fn = actions.get(action)
if fn is None:
self._json({"error": "unknown action: " + action}, 400)
return
try:
res = fn(char_id, int(quest_id))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_grant_item(self, data):
char_id = str(data.get("id", ""))
item_id = data.get("item_id", 0)
count = data.get("count", 1)
if not char_id or not item_id:
self._json({"error": "missing id/item_id"}, 400)
return
if self._use_sqlite():
try:
result = DB.grant_item(char_id, int(item_id), int(count))
self._json({"ok": True, "data": result})
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
NATIVE.grant_item(char_id, int(item_id), int(count))
self._json({"ok": True})
except NativeError as e:
self._json({"error": str(e)}, 500)
# ---- 道具目录 / 搜索 / 分类(Native,服务端在线) ----
def _require_native(self):
if NATIVE is None:
self._json({"error": "Native API 不可用(未找到 adminToken)"}, 400)
return False
if not server_running():
self._json({"error": "物品目录由运行中的服务端解析 PVF 得到,请先启动服务端"}, 503)
return False
return True
def _api_items_categories(self):
if not self._require_native():
return
try:
self._json({"ok": True, "data": NATIVE.get_item_categories()})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_item_search(self, qs):
if not self._require_native():
return
try:
res = NATIVE.search_items(
query=qs.get("q", [""])[0],
kind=qs.get("kind", [""])[0],
tag=qs.get("tag", [""])[0],
segment=qs.get("segment", [""])[0],
group=qs.get("group", [""])[0],
job=qs.get("job", [""])[0],
min_level=int(qs.get("min_level", ["0"])[0] or 0),
max_level=int(qs.get("max_level", ["0"])[0] or 0),
rarity=int(qs.get("rarity", ["-1"])[0] or -1),
expiration=qs.get("expiration", [""])[0],
offset=int(qs.get("offset", ["0"])[0] or 0),
limit=int(qs.get("limit", ["50"])[0] or 50))
items = res.get("items", []) if isinstance(res, dict) else res
total = res.get("total", len(items)) if isinstance(res, dict) else len(items)
self._json({"ok": True, "items": items, "total": total})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_item_search_body(self, data):
"""POST /api/item/search —— 从 JSON body 读参数(装备/道具分区筛选)。"""
if not self._require_native():
return
try:
res = NATIVE.search_items(
query=str(data.get("query", "") or ""),
kind=str(data.get("kind", "") or ""),
tag=str(data.get("tag", "") or ""),
segment=str(data.get("segment", "") or ""),
group=str(data.get("group", "") or ""),
job=str(data.get("job", "") or ""),
min_level=int(data.get("min_level", 0) or 0),
max_level=int(data.get("max_level", 0) or 0),
rarity=int(data.get("rarity", -1) or -1),
expiration=str(data.get("expiration", "") or ""),
offset=int(data.get("offset", 0) or 0),
limit=int(data.get("limit", 50) or 50))
items = res.get("items", []) if isinstance(res, dict) else res
total = res.get("total", len(items)) if isinstance(res, dict) else len(items)
self._json({"ok": True, "items": items, "total": total})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_item_grant_native(self, data):
if not self._require_native():
return
char_id = str(data.get("id", ""))
item_id = data.get("item_id", 0)
count = data.get("count", 1)
if not char_id or not item_id:
self._json({"error": "missing id/item_id"}, 400)
return
try:
res = NATIVE.grant_item(char_id, int(item_id), int(count))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_item_grant_options(self, data):
if not self._require_native():
return
char_id = str(data.get("id", ""))
item_id = data.get("item_id", 0)
if not char_id or not item_id:
self._json({"error": "missing id/item_id"}, 400)
return
try:
res = NATIVE.grant_item_options(
char_id, int(item_id),
count=int(data.get("count", 1)),
reinforce=int(data.get("reinforce", 0) or 0),
amplify_type=int(data.get("amplify_type", 0) or 0),
forging=int(data.get("forging", 0) or 0),
equipment_quality=data.get("equipment_quality", "") or "",
durability=data.get("durability"),
expire_days=int(data.get("expire_days", 0) or 0),
emblem_item_ids=data.get("emblem_item_ids"),
enchant_bead_item_id=int(data.get("enchant_bead_item_id", 0) or 0))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_item_mail(self, data):
if not self._require_native():
return
char_id = str(data.get("id", ""))
item_id = data.get("item_id", 0)
if not char_id or not item_id:
self._json({"error": "missing id/item_id"}, 400)
return
try:
res = NATIVE.send_item_mail(
char_id, int(item_id),
count=int(data.get("count", 1)),
title=data.get("title", "") or "",
body=data.get("body", "") or "",
expire_days=int(data.get("expire_days", 0) or 0),
reinforce=int(data.get("reinforce", 0) or 0),
amplify_type=int(data.get("amplify_type", 0) or 0),
equipment_quality=data.get("equipment_quality", "") or "",
forging=int(data.get("forging", 0) or 0),
emblem_item_ids=data.get("emblem_item_ids"),
enchant_bead_item_id=int(data.get("enchant_bead_item_id", 0) or 0))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
# ---- 邮件 ----
def _api_mail_send(self, data):
if not self._require_native():
return
try:
res = NATIVE.send_system_mail(
character_id=data.get("character_id") or data.get("id") or "",
character_name=data.get("character_name", "") or "",
title=data.get("title", "") or "",
body=data.get("body", "") or "",
gold=int(data.get("gold", 0) or 0),
attachments=data.get("attachments"))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_mailbox_clear(self, data):
char_id = str(data.get("id", ""))
if not char_id:
self._json({"error": "missing id"}, 400)
return
try:
if self._use_sqlite():
DB.put_record_json("mailbox", char_id,
{"character_id": char_id, "mails": []})
self._json({"ok": True})
else:
NATIVE.clear_mailbox(character_id=char_id)
self._json({"ok": True})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
# ---- 背包 ----
def _api_inventory_clear(self, data):
char_id = str(data.get("id", ""))
if not char_id:
self._json({"error": "missing id"}, 400)
return
try:
if self._use_sqlite():
DB.put_record_json("inventory", char_id,
{"character_id": char_id, "slots": {}})
self._json({"ok": True})
else:
res = NATIVE.clear_inventory(
char_id,
collection=data.get("collection", "") or "",
keep_wallet=bool(data.get("keep_wallet", True)))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_inventory_list(self, qs):
"""GET /api/inventory/list?id=<char> —— 列角色背包全部行(背包管理页)。"""
char_id = (qs.get("id") or [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
if self._use_sqlite():
try:
rows = DB.inventory_list_slots(char_id)
self._json({"ok": True, "data": rows})
except Exception as e:
self._json({"error": str(e)}, 500)
else:
try:
raw = NATIVE.inventory_list(char_id) or {}
rows = raw.get("rows", []) if isinstance(raw, dict) else []
self._json({"ok": True, "data": rows})
except NativeError as e:
self._json({"error": str(e)}, 500)
def _api_inventory_delete(self, data):
char_id = str(data.get("id", ""))
if not char_id:
self._json({"error": "missing id"}, 400)
return
key = data.get("key") or data.get("entry_key") or ""
if not key:
self._json({"error": "缺少要删除的背包行 key"}, 400)
return
if self._use_sqlite():
try:
res = DB.inventory_delete_slot(char_id, key)
self._json({"ok": True, "data": res})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
return
try:
container = (data.get("container") or "inventory").strip().lower()
res = NATIVE.delete_inventory_item(
char_id, container, key, int(data.get("count", 1)))
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
# ---- 钱包 / 点券 ----
def _api_wallet_query(self, data):
if not self._require_native():
return
try:
res = NATIVE.query_wallet(
character_id=data.get("character_id", "") or "",
character_name=data.get("character_name", "") or "",
currency=data.get("currency", "") or "")
# 平铺返回字段,前端直接读 r.gold / r.cera 等
if isinstance(res, dict):
self._json({"ok": True, **res})
else:
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_cera(self, data):
if not self._require_native():
return
account_id = str(data.get("account_id", ""))
if not account_id:
self._json({"error": "missing account_id"}, 400)
return
try:
res = NATIVE.set_cera(
account_id,
amount=data.get("amount"),
value=data.get("value"),
currency=data.get("currency", "") or "")
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
# ---- 拍卖行(上下架) ----
def _api_auction_listings(self):
"""GET /api/auction/listings —— 列出所有挂单。"""
# 服务端在线时优先走 Native,读取服务端内存态(与上架/下架同一数据源)
if server_running() and NATIVE is not None:
try:
raw = NATIVE.auction_listings() or {}
listings = raw.get("listings", []) if isinstance(raw, dict) else []
escrow_gold = raw.get("escrow_gold", 0) if isinstance(raw, dict) else 0
self._json({
"ok": True,
"data": listings,
"escrow_gold": escrow_gold,
})
return
except NativeError as e:
self._json({"error": str(e)}, 500)
return
try:
listings = DB.auction_list_listings()
self._json({
"ok": True,
"data": listings,
"escrow_gold": DB.auction_escrow_gold(),
})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_auction_items(self, qs):
"""GET /api/auction/items?id=<char> —— 列出某角色背包物品(供上架选择)。"""
char_id = (qs.get("id") or [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
try:
inv = DB.get_record_json("inventory", str(char_id)) or {}
slots = inv.get("slots", {})
out = []
for key, v in slots.items():
if not isinstance(v, dict):
continue
out.append({
"key": key,
"item_id": v.get("item_id", 0),
"count": int(v.get("count", 0) or 0),
"bind": v.get("bind", False),
})
self._json({"ok": True, "data": out})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_auction_list(self, data):
"""POST /api/auction/list —— 上架一件物品。"""
seller_id = str(data.get("seller_id", ""))
item_id = data.get("item_id")
count = data.get("count", 1)
unit_price = data.get("unit_price")
if not seller_id or not item_id:
self._json({"error": "缺少卖家角色或物品"}, 400)
return
start_price = data.get("start_price")
buyout_price = data.get("buyout_price")
# 空字符串/None -> None(走默认)
if start_price == "" or start_price is None:
start_price = None
if buyout_price == "" or buyout_price is None:
buyout_price = None
duration_hours = int(data.get("duration_hours", 24) or 24)
grant_if_missing = bool(data.get("grant_if_missing"))
# 服务端在线时优先走 Native API:服务端 SQLite 后端是「内存态 + 每分钟落盘」,
# 游戏内拍卖行读的是内存态。若改走 SQLite 直读去写磁盘 dnf90.db,服务端
# 感知不到(游戏内搜不到),且下一次落盘会用内存态覆盖掉这次直读的写入,
# 导致「GM 上架不生效」。所以只要服务端在线、Native 可用,就必须走 Native。
if server_running() and NATIVE is not None:
try:
res = NATIVE.auction_list(
seller_id, int(item_id), int(count), int(unit_price),
start_price=start_price, buyout_price=buyout_price,
duration_hours=duration_hours,
grant_if_missing=grant_if_missing)
self._json({"ok": True, "data": res})
return
except NativeError as e:
self._json({"error": str(e)}, 500)
return
# 服务端未运行:离线 SQLite 直读(DB.auction_list 已支持发放式补足/直读)
try:
res = DB.auction_list(
seller_id, int(item_id), int(count), int(unit_price),
start_price=start_price, buyout_price=buyout_price,
duration_hours=duration_hours)
self._json({"ok": True, "data": res})
except (ValueError, KeyError) as e:
self._json({"error": str(e)}, 400)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_auction_cancel(self, data):
"""POST /api/auction/cancel —— 下架一件挂单。"""
listing_id = str(data.get("listing_id", ""))
confirm = str(data.get("confirm", ""))
if not listing_id:
self._json({"error": "缺少挂单 ID"}, 400)
return
if confirm != "CANCEL":
self._json({"error": "请在确认框中输入 CANCEL 以确认下架"}, 400)
return
# 服务端在线时优先走 Native API(原因同上架:内存态 vs 磁盘直读会不一致)
if server_running() and NATIVE is not None:
try:
res = NATIVE.auction_cancel(listing_id)
self._json({"ok": True, "data": res})
return
except NativeError as e:
self._json({"error": str(e)}, 500)
return
try:
res = DB.auction_cancel(listing_id)
self._json({"ok": True, "data": res})
except KeyError as e:
self._json({"error": str(e)}, 404)
except Exception as e:
self._json({"error": str(e)}, 500)
# ---- 删档 / 删除 ----
def _api_wipe_preview(self, qs):
char_id = qs.get("id", [""])[0]
if not char_id:
self._json({"error": "missing id"}, 400)
return
try:
rows = DB.list_character_record_kinds(char_id)
self._json({"ok": True, "data": rows})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_character_delete(self, data):
char_id = str(data.get("id", ""))
confirm = data.get("confirm", "")
if not char_id:
self._json({"error": "missing id"}, 400)
return
if confirm != "DELETE":
self._json({"error": "请在确认框中输入 DELETE 以确认删除"}, 400)
return
if server_running():
self._json({"error": "删除角色需先停止服务端(否则数据库被锁定)"}, 400)
return
try:
n = DB.delete_character(char_id)
self._json({"ok": True, "data": {"deleted": n}})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_account_delete(self, data):
account_id = str(data.get("id", ""))
confirm = data.get("confirm", "")
if not account_id:
self._json({"error": "missing id"}, 400)
return
if confirm != "DELETE":
self._json({"error": "请在确认框中输入 DELETE 以确认删除"}, 400)
return
if server_running():
self._json({"error": "删除账号需先停止服务端"}, 400)
return
try:
r = DB.delete_account(account_id)
self._json({"ok": True, "data": r})
except Exception as e:
self._json({"error": str(e)}, 500)
def _wrap(fn, *args, **kwargs):
try:
fn(*args, **kwargs)
except Exception as e:
log("[异常] %s" % repr(e))
finally:
log("-- 操作结束 --")
# ------------------------------------------------------------------ 一键换装全局热键
_HOTKEY_THREAD_STARTED = False
def _loadout_schemes():
cfg = _read_json_file(LOADOUT_CONFIG, {})
pages = cfg.get("Pages") or []
try:
page = pages[int(cfg.get("ActivePage", 0))]
except (IndexError, TypeError, ValueError):
page = pages[0] if pages else {"Schemes": []}
return cfg, page.get("Schemes") or []
def start_loadout_hotkeys():
"""与原 LoadoutTool 一致:轮询全局按键,命中方案就调用换装接口。"""
global _HOTKEY_THREAD_STARTED
if _HOTKEY_THREAD_STARTED:
return
_HOTKEY_THREAD_STARTED = True
def worker():
user32 = ctypes.windll.user32
down = set()
while True:
try:
cfg, schemes = _loadout_schemes()
if not server_running() or NATIVE is None:
down.clear()
time.sleep(0.15)
continue
ctrl = (user32.GetAsyncKeyState(0x11) & 0x8000) != 0
alt = (user32.GetAsyncKeyState(0x12) & 0x8000) != 0
shift = (user32.GetAsyncKeyState(0x10) & 0x8000) != 0
for scheme in schemes:
keycode = int(scheme.get("KeyCode") or 0)
if keycode <= 0:
continue
mods = int(scheme.get("Modifiers") or 0)
ok = ((mods & 2) == 0 or ctrl) and ((mods & 1) == 0 or alt) and ((mods & 4) == 0 or shift)
if (mods & 2) == 0 and ctrl:
ok = False
if (mods & 1) == 0 and alt:
ok = False
if (mods & 4) == 0 and shift:
ok = False
pressed = (user32.GetAsyncKeyState(keycode) & 0x8000) != 0
token = (keycode, mods)
if pressed and ok and token not in down:
down.add(token)
slots = scheme.get("Slots") or []
if slots:
threading.Thread(
target=_wrap,
args=(lambda s=scheme: _apply_loadout_scheme(s),),
daemon=True).start()
elif not pressed or not ok:
down.discard(token)
except Exception:
pass
time.sleep(0.05)
threading.Thread(target=worker, daemon=True).start()
def _apply_loadout_scheme(scheme):
character = str(scheme.get("CharacterName") or "").strip()
slots = scheme.get("Slots") or []
if not character or not slots:
log("[换装] 方案未绑定角色或没有装备快照")
return
log("[换装] 应用方案:%s -> %s" % (scheme.get("Name", ""), character))
try:
result = NATIVE.loadout_apply(character, slots)
applied = len((result or {}).get("applied") or [])
skipped = len((result or {}).get("skipped") or [])
log("[换装] 完成:成功 %d,跳过 %d" % (applied, skipped))
except Exception as e:
log("[换装] 失败:%s" % e)
# ------------------------------------------------------------------ 自提权
def self_elevate():
"""非管理员时通过 UAC 自提权重启自身。"""
if is_admin() or os.environ.get("DNF90_NO_ELEVATE") == "1":
return
if getattr(sys, "frozen", False):
exe = sys.executable
try:
rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, None, None, 1)
if rc > 32:
sys.exit(0)
except Exception:
pass
else:
exe = sys.executable
args = '"%s" "%s"' % (exe, os.path.abspath(__file__))
try:
rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, args, None, 1)
if rc > 32:
sys.exit(0)
except Exception:
pass
# ------------------------------------------------------------------ 主入口
def main():
self_elevate()
if not os.path.exists(CONTROL):
print("[警告] 未找到服务端控制器,请将启动器放在 90server 根目录旁。")
print(" 期望路径:%s" % CONTROL)
print("数据库:%s" % (DB_PATH or "(未找到,首次启动服务端后自动创建)"))
if NATIVE is not None:
print("Native API:http://%s(token 已加载)" % ADMIN_LISTEN)
else:
print("[提示] 未找到 adminToken,Native API 数据源不可用(SQLite 仍可用)。")
port = DEFAULT_PORT
while port_open(port) and port < DEFAULT_PORT + 20:
port += 1
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
url = "http://127.0.0.1:%d/" % port
# 在后台线程运行 HTTP 服务
def _serve():
server.serve_forever()
threading.Thread(target=_serve, daemon=True).start()
start_loadout_hotkeys()
# 用内嵌窗口(pywebview)显示界面,替代打开浏览器
show_window(url)
# 窗口关闭后清理
try:
server.shutdown()
server.server_close()
except Exception:
pass
try:
DB.close()
except Exception:
pass
# 强制退出:确保连 WebView2 子进程一起被系统回收,避免残留进程
try:
_kill_webview2_children()
except Exception:
pass
os._exit(0)
class WindowApi:
"""暴露给前端 JS 的窗口控制 API(pywebview js_api)。
前端通过 window.pywebview.api.minimize() 等调用,实现无边框窗口的
最小化 / 关闭 / 拖拽 / GM 全屏铺满(不遮任务栏)/ 恢复几何。
"""
def __init__(self):
self._saved_geometry = None # 进入 GM 全屏前保存的主窗口几何 (x, y, w, h)
def minimize(self):
try:
import webview
webview.windows[0].minimize()
except Exception as e:
print("[窗口] 最小化失败:%s" % e)
def close(self):
try:
import webview
# 销毁所有窗口,确保事件循环结束、进程退出
for w in list(webview.windows):
try:
w.destroy()
except Exception:
pass
except Exception as e:
print("[窗口] 关闭失败:%s" % e)
# 兜底:确保进程退出,不残留
try:
os._exit(0)
except Exception:
pass
@staticmethod
def _get_work_area():
"""获取 Windows 主显示器的工作区(不含任务栏),返回 (x, y, w, h)。"""
try:
import ctypes
from ctypes import wintypes
rect = wintypes.RECT()
if ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0):
return (rect.left, rect.top,
rect.right - rect.left, rect.bottom - rect.top)
except Exception as e:
print("[窗口] 获取工作区失败:%s" % e)
return None
def enter_gm(self):
"""进入 GM 全屏:保存主窗口几何,铺满工作区(不遮挡任务栏)。"""
try:
import webview
w = webview.windows[0]
self._saved_geometry = (w.x, w.y, w.width, w.height)
wa = self._get_work_area()
if wa:
wx, wy, ww, wh = wa
w.move(wx, wy)
w.resize(ww, wh)
else:
w.maximize()
except Exception as e:
print("[窗口] 进入 GM 全屏失败:%s" % e)
def restore_gm(self):
"""退出 GM 全屏:恢复主窗口到进入前的几何。"""
try:
import webview
w = webview.windows[0]
if self._saved_geometry:
x, y, ww, wh = self._saved_geometry
w.move(int(x), int(y))
w.resize(int(ww), int(wh))
self._saved_geometry = None
except Exception as e:
print("[窗口] 恢复窗口几何失败:%s" % e)
def toggle_maximize(self):
"""最大化/还原主窗口(优先铺满工作区,避免遮挡任务栏)。"""
try:
import webview
w = webview.windows[0]
if self._saved_geometry:
# 当前处于全屏态,还原
self.restore_gm()
else:
self._saved_geometry = (w.x, w.y, w.width, w.height)
wa = self._get_work_area()
if wa:
wx, wy, ww, wh = wa
w.move(wx, wy)
w.resize(ww, wh)
else:
w.maximize()
except Exception as e:
print("[窗口] 最大化/还原失败:%s" % e)
def resize_window(self, width, height):
"""调整主窗口尺寸(px)。"""
try:
import webview
webview.windows[0].resize(int(width), int(height))
except Exception as e:
print("[窗口] 调整尺寸失败:%s" % e)
def start_resize(self, edge):
"""开始系统级窗口缩放(edge: left/right/top/bottom/topleft/...)。"""
try:
import webview
# pywebview 无直接 start_resize;此处交由前端 JS 兜底调整,
# 仅保留接口以兼容前端调用,避免报错。
return True
except Exception as e:
print("[窗口] 缩放失败:%s" % e)
return False
def getPosition(self):
try:
import webview
return [webview.windows[0].x, webview.windows[0].y]
except Exception as e:
print("[窗口] 获取位置失败:%s" % e)
return [0, 0]
def move(self, x, y):
try:
import webview
webview.windows[0].move(int(x), int(y))
except Exception as e:
print("[窗口] 移动失败:%s" % e)
def show_window(url):
"""用 pywebview 内嵌窗口显示界面(独立原生窗口,而非浏览器)。
若 pywebview 不可用(例如未安装 WebView2 运行时),降级为打开浏览器。
"""
try:
import webview
except ImportError:
_fallback_browser(url)
return
try:
launcher_api = WindowApi()
# 屏幕尺寸,用于窗口居中
try:
_screens = webview.screens
if _screens and len(_screens) > 0:
_sw, _sh = _screens[0].width, _screens[0].height
else:
_sw, _sh = 1920, 1080
except Exception:
_sw, _sh = 1920, 1080
# 主窗口:启动器(无边框,屏幕居中)
window = webview.create_window(
"DNF90 单机模拟器",
url,
width=1204,
height=717,
x=max(0, (_sw - 1204) // 2),
y=max(0, (_sh - 717) // 2),
frameless=True, # 无系统标题栏
resizable=False, # 禁止鼠标拖动窗口边缘改变大小(GM 全屏程序化 resize 不受影响)
background_color="#0b0b14",
easy_drag=False, # 拖拽通过 js_api 的 move 实现(避免整窗可拖导致按钮误触)
js_api=launcher_api,
)
webview.start()
except Exception as e:
print("[警告] 内嵌窗口启动失败,降级为浏览器:%s" % e)
_fallback_browser(url)
# 内嵌窗口失败时保持服务运行,避免立即退出
try:
threading.Event().wait()
except KeyboardInterrupt:
pass
def _kill_webview2_children():
"""清理本进程派生的 WebView2 子进程(msedgewebview2)。
关闭窗口后 WebView2 宿主进程可能残留,通过 taskkill 按父进程关系清理。
"""
import subprocess as _sp
pid = os.getpid()
try:
# 列出本进程派生的 msedgewebview2 子进程
out = _sp.run(
["wmic", "process", "where",
"(Name='msedgewebview2.exe' and ParentProcessId=%d)" % pid,
"get", "ProcessId"],
capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=10)
for line in (out.stdout or b"").decode("utf-8", "replace").splitlines():
line = line.strip()
if line.isdigit():
try:
_sp.run(["taskkill", "/F", "/PID", line],
capture_output=True, creationflags=CREATE_NO_WINDOW,
timeout=10)
except Exception:
pass
except Exception:
pass
def _fallback_browser(url):
webbrowser.open(url)
print("已在浏览器打开:%s" % url)
print("按 Ctrl+C 退出。")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()