Files
90CNServer/gmtool/gm_client.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

414 lines
17 KiB
Python
Raw Permalink 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 工具 —— 服务端 GM API 客户端封装。
负责:
1. 自动发现本地服务端(DNF90Server.exe)的 admin 端口与 token;
2. 封装全部 /dnf/gm/* 与 /local/* 接口为统一方法;
3. 处理 X-Admin-Token / X-Admin-Confirm / X-Admin-Idempotency-Key 等管理头。
"""
import json
import os
import re
import socket
import threading
import time
import uuid
import requests
DEFAULT_ADMIN_PORT = 18111
DEFAULT_TOKEN = "local-admin-token"
# 常见部署根目录,用于自动定位实例配置与 token
SEARCH_ROOTS = [
r"D:\90server",
r"D:\90CN",
r"Z:\90server",
r"C:\90server",
r"D:\90轮回源码",
]
def _is_port_open(host, port, timeout=0.35):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
class GMClient:
"""与服务端本地 GM API 通信的客户端。"""
def __init__(self, host="127.0.0.1", port=DEFAULT_ADMIN_PORT, token=None):
self.host = host
self.port = port
self.token = token or DEFAULT_TOKEN
self.base_url = "http://%s:%d" % (host, port)
self._lock = threading.Lock()
# ------------------------------------------------------------------ 发现
@staticmethod
def discover():
"""尝试自动发现服务端地址与 token。返回 (base_url, token) 或 (None, None)。"""
# 1) 直接探测默认端口
if _is_port_open("127.0.0.1", DEFAULT_ADMIN_PORT):
token = GMClient._find_token()
if GMClient._probe("127.0.0.1", DEFAULT_ADMIN_PORT, token):
return "http://127.0.0.1:%d" % DEFAULT_ADMIN_PORT, token
# 2) 扫描配置里的 adminListen
for root in SEARCH_ROOTS:
if not os.path.isdir(root):
continue
for token in GMClient._tokens_from_root(root):
# 尝试从 token 配置旁推断端口
for port in GMClient._ports_from_root(root):
if _is_port_open("127.0.0.1", port) and GMClient._probe("127.0.0.1", port, token):
return "http://127.0.0.1:%d" % port, token
return None, None
@staticmethod
def _probe(host, port, token):
try:
r = requests.get("http://%s:%d/dnf/gm/status" % (host, port),
headers={"X-Admin-Token": token}, timeout=1.0)
return r.status_code in (200, 401, 403)
except Exception:
return False
@staticmethod
def _tokens_from_root(root):
"""从部署目录里搜集候选 token。"""
tokens = [DEFAULT_TOKEN]
for dirpath, _dirs, files in os.walk(root):
if len(tokens) > 40:
break
for fn in files:
lower = fn.lower()
if lower in ("instance.json", "server.json", "config.json", "dnfbridge.toml"):
p = os.path.join(dirpath, fn)
try:
txt = open(p, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
# token = "..."
for m in re.finditer(r'(?:adminToken|token|admin_token|AdminToken)["\']?\s*[:=]\s*["\']([^"\']+)["\']', txt):
v = m.group(1).strip()
if v and v not in tokens:
tokens.append(v)
# [admin]\ntoken = "..."
for m in re.finditer(r'token\s*=\s*"([^"]+)"', txt):
v = m.group(1).strip()
if v and v not in tokens:
tokens.append(v)
return tokens
@staticmethod
def _ports_from_root(root):
ports = [DEFAULT_ADMIN_PORT]
for dirpath, _dirs, files in os.walk(root):
if len(ports) > 20:
break
for fn in files:
if fn.lower() in ("instance.json", "server.json", "dnfbridge.toml"):
p = os.path.join(dirpath, fn)
try:
txt = open(p, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
for m in re.finditer(r'(?:adminListen|listen)["\']?\s*[:=]\s*["\']?127\.0\.0\.1:(\d+)', txt):
try:
port = int(m.group(1))
if port not in ports:
ports.append(port)
except ValueError:
pass
return ports
# ------------------------------------------------------------------ HTTP
def _headers(self, mutate=False, confirm=None):
h = {"X-Admin-Token": self.token, "Content-Type": "application/json"}
if mutate:
op_id = confirm or "op"
h["X-Admin-Confirm"] = op_id
h["X-Admin-Idempotency-Key"] = str(uuid.uuid4())
return h
def _request(self, method, path, **kwargs):
url = self.base_url + path
return requests.request(method, url, timeout=15, **kwargs)
def _get(self, path, params=None):
r = self._request("GET", path, params=params, headers=self._headers())
return self._parse(r)
def _post(self, path, payload=None, mutate=True):
r = self._request("POST", path, json=payload or {},
headers=self._headers(mutate=mutate))
return self._parse(r)
@staticmethod
def _parse(r):
try:
data = r.json()
except ValueError:
data = {"ok": False, "error": "非 JSON 响应 (%d)" % r.status_code, "raw": r.text[:200]}
# 统一归一化:服务端两套路由返回格式略有差异
if isinstance(data, dict):
if "success" in data and "data" in data:
return {"ok": data.get("success", False), "data": data.get("data"),
"error": data.get("error")}
if "ok" in data:
return data
return {"ok": r.status_code < 400, "data": data,
"error": None if r.status_code < 400 else "HTTP %d" % r.status_code}
# ------------------------------------------------------------------ 状态
def status(self):
return self._get("/dnf/gm/status")
def channels(self):
return self._get("/dnf/gm/channels")
def gameplay(self):
return self._get("/dnf/gm/gameplay")
# ------------------------------------------------------------------ 账号
def list_accounts(self, q="", limit=50):
return self._get("/dnf/gm/accounts", params={"q": q, "limit": limit})
def account(self, account_id):
return self._get("/dnf/gm/accounts/%s" % account_id)
def account_characters(self, account_id):
return self._get("/dnf/gm/accounts/%s/characters" % account_id)
def set_account_value(self, account_id, field, value):
return self._post("/dnf/gm/accounts/%s/value" % account_id,
{"field": field, "value": value})
# ------------------------------------------------------------------ 角色
def characters(self, account_id="", character_name="", username=""):
"""兼容旧路由 /local/dnf-gm/characters 的角色查询。"""
params = {}
if username:
params["username"] = username
elif account_id:
params["account_id"] = account_id
elif character_name:
params["character_name"] = character_name
return self._get("/local/dnf-gm/characters", params=params)
def character(self, character_id):
return self._get("/dnf/gm/characters/%s" % character_id)
def character_detail(self, character_id):
return self._get("/dnf/gm/characters/%s/detail" % character_id)
def set_gold(self, character_id, value):
return self._post("/dnf/gm/characters/%s/gold" % character_id, {"value": value})
def set_level(self, character_id, level):
return self._post("/dnf/gm/characters/%s/level" % character_id, {"level": level})
def set_skill_points(self, character_id, bonus_sp, bonus_tp):
return self._post("/dnf/gm/characters/%s/skill-points" % character_id,
{"bonus_sp": bonus_sp, "bonus_tp": bonus_tp})
def set_career(self, character_id, job, first, awakening):
return self._post("/dnf/gm/characters/%s/career" % character_id,
{"job": job, "first": first, "awakening": awakening})
# ------------------------------------------------------------------ 邮件
def mailbox(self, character_id):
return self._get("/dnf/gm/characters/%s/mailbox" % character_id)
def delete_mail(self, character_id, mail_ids, force=False):
return self._post("/dnf/gm/characters/%s/mailbox/delete" % character_id,
{"mail_ids": mail_ids, "force": force})
def send_mail(self, character_id, item_id, count, title, body,
expire_days=7, reinforce=0, amplify_type=0,
equipment_quality="", forging=0, emblem_item_ids=None,
enchant_bead_item_id=0):
payload = {
"item_id": item_id, "count": count, "title": title, "body": body,
"expire_days": expire_days, "reinforce": reinforce,
"amplify_type": amplify_type, "equipment_quality": equipment_quality,
"forging": forging, "emblem_item_ids": emblem_item_ids or [],
"enchant_bead_item_id": enchant_bead_item_id,
}
return self._post("/dnf/gm/characters/%s/mail" % character_id, payload)
def system_mail(self, character_name, title, body, gold=0, attachments=None):
payload = {"character_name": character_name, "title": title, "body": body,
"gold": gold, "attachments": attachments or []}
return self._post("/local/dnf-gm/mail", payload)
# ------------------------------------------------------------------ 道具
def search_items(self, q="", limit=30):
return self._get("/dnf/gm/items", params={"q": q, "limit": limit})
def item_page(self, **kwargs):
return self._get("/dnf/gm/items/page", params=kwargs)
def item_detail(self, item_id):
return self._get("/dnf/gm/items/%d/detail" % item_id)
def item_categories(self):
return self._get("/dnf/gm/items/categories")
def item_mail_options(self, item_id, mode="", q="", rarity=-1, limit=40, offset=0):
params = {"mode": mode, "q": q, "rarity": rarity, "limit": limit, "offset": offset}
return self._get("/dnf/gm/items/%d/mail-options" % item_id, params=params)
def grant_item(self, character_id, item_id, count=1, **opts):
return self._post("/local/dnf-gm/item/grant", {
"character_id": str(character_id), "item_id": item_id, "count": count, **opts})
def delete_item(self, character_id, item_key, count=1):
return self._post("/local/dnf-gm/item/delete", {
"character_id": str(character_id), "key": item_key, "count": count})
# ------------------------------------------------------------------ 背包
def inventory(self, character_id):
return self._get("/dnf/gm/characters/%s/inventory" % character_id)
def delete_inventory_item(self, character_id, container, key, count):
return self._post("/dnf/gm/characters/%s/inventory/delete" % character_id,
{"container": container, "key": key, "count": count})
def clear_inventory(self, character_id, container):
return self._post("/dnf/gm/characters/%s/inventory/clear" % character_id,
{"container": container})
# ------------------------------------------------------------------ 装备
def unequip(self, character_id, key):
return self._post("/dnf/gm/characters/%s/equipment/unequip" % character_id, {"key": key})
def delete_equipment(self, character_id, key):
return self._post("/dnf/gm/characters/%s/equipment/delete" % character_id, {"key": key})
def extra_equipment_slots(self, character_id):
return self._get("/dnf/gm/characters/%s/equipment-slots" % character_id)
def unlock_equipment_slots(self, character_id, slots):
return self._post("/dnf/gm/characters/%s/equipment-slots/unlock" % character_id,
{"slots": slots})
def equipment_move(self, character_id, source_key, target_slot):
return self._post("/local/equipment-move", {
"character_id": str(character_id), "key": source_key, "target": target_slot})
# ------------------------------------------------------------------ 任务
def quests(self, character_id):
return self._get("/dnf/gm/characters/%s/quests" % character_id)
def search_quests(self, character_id, q="", limit=40):
return self._get("/dnf/gm/characters/%s/quests/search" % character_id,
params={"q": q, "limit": limit})
def quest_detail(self, character_id, quest_id):
return self._get("/dnf/gm/characters/%s/quests/%d/detail" % (character_id, quest_id))
def quest_action(self, character_id, quest_id, action):
return self._post("/dnf/gm/characters/%s/quests/%d/%s" % (character_id, quest_id, action))
def quest_complete_batch(self, character_id, quest_ids):
return self._post("/dnf/gm/characters/%s/quests/complete-batch" % character_id,
{"quest_ids": quest_ids})
def quest_resync(self, character_id):
return self._post("/local/quest-resync", {"character_id": int(character_id)})
# ------------------------------------------------------------------ 副本
def dungeons(self, character_id):
return self._get("/dnf/gm/characters/%s/dungeons" % character_id)
def unlock_dungeon_difficulties(self, character_id):
return self._post("/dnf/gm/characters/%s/dungeons/unlock-difficulties" % character_id)
def unlock_hell(self, character_id):
return self._post("/dnf/gm/characters/%s/dungeons/unlock-hell" % character_id)
def dungeon_control(self, character_id):
return self._get("/local/dungeon-control", params={"character_id": int(character_id)})
def dungeon_return_town(self, character_id):
return self._post("/local/dungeon-control", {
"character_id": int(character_id), "action": "return_to_town"})
# ------------------------------------------------------------------ 钱包
def wallet_query(self, character_id):
return self._post("/local/dnf-gm/wallet/query", {"character_id": str(character_id)})
def wallet_update(self, character_id, field, value):
return self._post("/local/dnf-gm/wallet/update",
{"character_id": str(character_id), field: value})
def revive_coin(self, character_id, delta):
return self._post("/local/dnf-gm/character/revive-coin",
{"character_id": str(character_id), "delta": delta})
def account_cera(self, account_id, delta):
return self._post("/local/dnf-gm/account/cera",
{"account_id": str(account_id), "delta": delta})
def black_diamond_status(self, character_id="", name=""):
params = {}
if character_id:
params["id"] = character_id
if name:
params["name"] = name
return self._get("/local/dnf-gm/character/black-diamond", params=params)
def activate_black_diamond(self, character_id, days):
return self._post("/local/dnf-gm/character/black-diamond",
{"character_id": str(character_id), "days": days})
# ------------------------------------------------------------------ 玩法
def set_hell_drop_rate(self, percent):
return self._post("/dnf/gm/gameplay/hell-drop-rate", {"percent": percent})
def set_epic_potion_multiplier(self, percent):
return self._post("/dnf/gm/gameplay/epic-potion-test-multiplier", {"percent": percent})
def reload_channels(self):
return self._post("/dnf/gm/channels/reload")
# ------------------------------------------------------------------ 其他
def kick_sessions(self, ban_type, ban_value):
return self._post("/local/dnf-gm/kick-sessions",
{"ban_type": ban_type, "ban_value": ban_value})
def clone_character(self, source_character_id, name):
return self._post("/local/character-clone",
{"source_character_id": int(source_character_id), "name": name})
def record_meta(self):
return self._get("/dnf/gm/records/meta")
def query_records(self, **kwargs):
return self._post("/dnf/gm/records/query", kwargs)
# 全局单例(懒加载,首次调用时自动发现)
_CLIENT = None
_CLIENT_LOCK = threading.Lock()
def get_client():
global _CLIENT
with _CLIENT_LOCK:
if _CLIENT is None:
_CLIENT = GMClient()
return _CLIENT
def reconnect(host="127.0.0.1", port=DEFAULT_ADMIN_PORT, token=None):
global _CLIENT
with _CLIENT_LOCK:
_CLIENT = GMClient(host, port, token)
return _CLIENT