# -*- 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