- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches), 持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发 - 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道 - channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本 - 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab - native.py/server.py 新增对应转发 API - 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
2351 lines
88 KiB
Python
2351 lines
88 KiB
Python
# -*- 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.request import Request, urlopen
|
||
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 # 启动器本地端口
|
||
|
||
# 更新检查配置:Gitea 自托管仓库的 Releases 地址
|
||
UPDATE_REPO_API = "https://git.hanfuit.com/api/v1/repos/Lingbao/90CNServer/releases"
|
||
UPDATE_REPO_WEB = "https://git.hanfuit.com/Lingbao/90CNServer/releases"
|
||
CURRENT_VERSION = "v2026.11.04" # 本地当前版本号
|
||
|
||
|
||
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, "换装工具.exe")
|
||
if not os.path.exists(LOADOUT_EXE):
|
||
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe")
|
||
|
||
# 一键换装的持久化配置路径
|
||
LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.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))
|
||
|
||
|
||
# ------------------------------------------------------------------ 更新检查
|
||
_update_cache = {"ts": 0.0, "releases": None, "error": None}
|
||
_update_lock = threading.Lock()
|
||
|
||
|
||
def _fetch_releases(force=False):
|
||
"""拉取 Gitea Releases 列表(带 5 分钟缓存)。"""
|
||
now = time.time()
|
||
with _update_lock:
|
||
if not force and _update_cache["ts"] and now - _update_cache["ts"] < 300:
|
||
return _update_cache["releases"], _update_cache["error"]
|
||
try:
|
||
req = Request(UPDATE_REPO_API, headers={
|
||
"User-Agent": "DNF90Launcher/2.0",
|
||
"Accept": "application/json",
|
||
})
|
||
with urlopen(req, timeout=8) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
releases = data if isinstance(data, list) else []
|
||
with _update_lock:
|
||
_update_cache.update(ts=now, releases=releases, error=None)
|
||
return releases, None
|
||
except Exception as e:
|
||
with _update_lock:
|
||
_update_cache.update(ts=now, releases=None, error=str(e))
|
||
return None, str(e)
|
||
|
||
|
||
def _release_summary(rel):
|
||
"""把一个 release 对象规整成前端需要的字段。"""
|
||
return {
|
||
"tag": rel.get("tag_name") or rel.get("name") or "",
|
||
"name": rel.get("name") or rel.get("tag_name") or "",
|
||
"body": rel.get("body") or "",
|
||
"published_at": rel.get("published_at") or "",
|
||
"prerelease": bool(rel.get("prerelease")),
|
||
"html_url": rel.get("html_url") or UPDATE_REPO_WEB,
|
||
"assets": [
|
||
{"name": a.get("name"), "size": a.get("size"),
|
||
"download_url": a.get("browser_download_url")}
|
||
for a in (rel.get("assets") or [])
|
||
],
|
||
}
|
||
|
||
|
||
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/logs":
|
||
self._api_logs()
|
||
elif path == "/api/gm/health":
|
||
self._api_gm_health()
|
||
elif path == "/api/update/check":
|
||
self._api_update_check()
|
||
elif path == "/api/update/changelog":
|
||
self._api_update_changelog(qs)
|
||
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/game-settings/activity-switches":
|
||
self._api_activity_switches()
|
||
elif path == "/api/game-settings/channel-switches":
|
||
self._api_channel_switches()
|
||
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/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/game-settings/activity-switch":
|
||
self._api_activity_switch(data)
|
||
elif path == "/api/game-settings/channel-switch":
|
||
self._api_channel_switch(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_update_check(self):
|
||
"""检查最新发布版本(对比本地 CURRENT_VERSION)。"""
|
||
releases, err = _fetch_releases()
|
||
if err:
|
||
self._json({
|
||
"ok": True,
|
||
"current": CURRENT_VERSION,
|
||
"latest": None,
|
||
"has_update": False,
|
||
"error": err,
|
||
})
|
||
return
|
||
latest = releases[0] if releases else None
|
||
latest_tag = (latest.get("tag_name") or "").lstrip("v") if latest else ""
|
||
current = CURRENT_VERSION.lstrip("v")
|
||
self._json({
|
||
"ok": True,
|
||
"current": CURRENT_VERSION,
|
||
"latest": latest.get("tag_name") if latest else None,
|
||
"has_update": bool(latest_tag) and latest_tag != current,
|
||
"latest_url": latest.get("html_url") if latest else UPDATE_REPO_WEB,
|
||
"published_at": latest.get("published_at") if latest else None,
|
||
})
|
||
|
||
def _api_update_changelog(self, qs):
|
||
"""获取所有发布的更新日志。"""
|
||
force = qs.get("force", ["0"])[0] == "1"
|
||
releases, err = _fetch_releases(force=force)
|
||
if err:
|
||
self._json({"ok": False, "error": err, "releases": []})
|
||
return
|
||
self._json({
|
||
"ok": True,
|
||
"current": CURRENT_VERSION,
|
||
"releases": [_release_summary(r) for r in (releases or [])],
|
||
"web_url": UPDATE_REPO_WEB,
|
||
})
|
||
|
||
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_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_activity_switches(self):
|
||
if not self._require_native():
|
||
return
|
||
try:
|
||
res = NATIVE.get_activity_switches()
|
||
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_activity_switch(self, data):
|
||
if not self._require_native():
|
||
return
|
||
key = data.get("key", "")
|
||
enabled = bool(data.get("enabled", False))
|
||
if not key:
|
||
self._json({"error": "missing key"}, 400)
|
||
return
|
||
try:
|
||
res = NATIVE.set_activity_switch(key, 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_channel_switches(self):
|
||
if not self._require_native():
|
||
return
|
||
try:
|
||
res = NATIVE.get_channel_switches()
|
||
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_channel_switch(self, data):
|
||
if not self._require_native():
|
||
return
|
||
try:
|
||
channel_id = int(data.get("channel_id"))
|
||
except (TypeError, ValueError):
|
||
self._json({"error": "missing or invalid channel_id"}, 400)
|
||
return
|
||
enabled = bool(data.get("enabled", False))
|
||
try:
|
||
res = NATIVE.set_channel_switch(channel_id, 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_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()
|