From 24e8c5f4adece07b167e459ae035511d90e0710f Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 27 Oct 2025 19:50:28 +0800 Subject: [PATCH 01/33] Create __init__.py --- .../__init__.py" | 1933 +++++++++++++++++ 1 file changed, 1933 insertions(+) create mode 100644 "\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/__init__.py" diff --git "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/__init__.py" "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/__init__.py" new file mode 100644 index 00000000..d64c8d01 --- /dev/null +++ "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/__init__.py" @@ -0,0 +1,1933 @@ +import os, json, threading, time, re +from datetime import datetime, timedelta +from collections import defaultdict + +from tooldelta.plugin_load.classic_plugin import Plugin, plugin_entry +from tooldelta import fmts, game_utils +from tooldelta.constants import PacketIDS + +try: + import requests + HAVE_REQ = True +except Exception: + import urllib.request, urllib.error + HAVE_REQ = False + +_ig_sessions = defaultdict(dict) + +CONFIG_GROUP_VERSION = "0.4.0" +PAGE_LEN_API = 59 +PAGE_LEN_VIEW = 9 + +def _now_beijing(): + return datetime.utcfromtimestamp(time.time()) + timedelta(hours=8) + +def _fmt_bj(dt: datetime): + return dt.strftime("%Y-%m-%d %H:%M:%S") + +def _read_lines(path: str): + if not os.path.exists(path): return [] + with open(path, "r", encoding="utf-8") as f: + return [line.rstrip("\n") for line in f] + +def _write_lines(path: str, lines): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + for ln in lines: + f.write(ln + "\n") + +def _split_csv_names(val): + if isinstance(val, list): + return [str(x).strip() for x in val if str(x).strip()] + if isinstance(val, str): + return [x.strip() for x in val.split(",") if x.strip()] + return [] + +def _json_escape(s: str): + return s.replace("\\", "\\\\").replace("\"", "\\\"") + +class ServerBlacklistGateway(Plugin): + name = "服务器黑名单封禁系统" + author = "丸山彩" + version = (0, 5, 4) + description = "通过nv1拉黑式封禁玩家,同时是一个前置插件" + + def __init__(self, frame): + super().__init__(frame) + # 数据文件 + self.path_ban_time = os.path.join(self.data_path, "玩家拉黑时长数据.txt") + self.path_uid_map = os.path.join(self.data_path, "玩家名-uid-9位服务器赋予id记录.txt") + self.path_dev_ban = os.path.join(self.data_path, "设备封锁名单.txt") + + # 配置 + base = os.path.dirname(os.path.dirname(self.data_path)) + self.config_dir = os.path.join(base, "插件配置文件") + self.config_path = os.path.join(self.config_dir, f"{self.name}.json") + self.cn = self._load_config_items() + self.cfg = self._cn_items_to_internal(self.cn) + + self._stop_flag = False + self._auto_thread = None + + self._chat_sessions = {} + + try: + self.game_ctrl = self.frame.get_game_control() + except Exception: + self.game_ctrl = None + + self.ListenPreload(self._on_preload) + self.ListenFrameExit(self._on_exit) + self.ListenPacket(PacketIDS.IDPlayerList, self._on_playerlist_early) + self.ListenPacket(PacketIDS.IDText, self._on_text_packet) + self.ListenChat(self._on_player_chat) + + # 前置 API + + def ban(self, player_or_name, ban_time: int, reason: str = ""): + try: + if hasattr(player_or_name, "name"): + name = getattr(player_or_name, "name", None) or str(player_or_name) + else: + name = str(player_or_name) + name = name.strip() + if not name: + fmts.print_war("[API] ban:空名字,已忽略"); + return False + + skip, why = self._should_skip_target(name) + if skip: + fmts.print_inf(f"[API] ban:已跳过 {name}({why})") + return False + + if str(ban_time).strip() == "-1": + expire_dt = datetime(2099, 12, 31, 23, 59, 59) + is_perm = True + else: + secs = int(ban_time) + if secs <= 0: + fmts.print_war(f"[API] ban:无效封禁秒数 {ban_time}"); + return False + expire_dt = _now_beijing() + timedelta(seconds=secs) + is_perm = False + expire_str = _fmt_bj(expire_dt) + + ent = self._find_entity_by_name_quick(name) + if not ent: + fmts.print_war(f"[API] ban:服务器历史加入列表中未找到目标玩家:{name}") + return False + + ok, http_status, reason2 = self._set_state(ent["entity_id"], 1) + if not ok: + fmts.print_war(f"[API] ban:封禁失败:{name}(HTTP={http_status};原因={reason2})") + return False + + self._record_ban_time(name, ent.get("user_id",""), ent["entity_id"], expire_str) + self._update_uid_map(ent.get("user_id",""), ent["entity_id"], name) + fmts.print_suc(f"[API] 封禁成功:{name} 至 {expire_str}" if not is_perm else f"[API] 封禁成功(永久):{name}") + return True + except Exception as e: + fmts.print_war(f"[API] ban 异常:{e}") + return False + + + def unban(self, player_or_name): + try: + if hasattr(player_or_name, "name"): + name = getattr(player_or_name, "name", None) or str(player_or_name) + else: + name = str(player_or_name) + name = name.strip() + if not name: + fmts.print_war("[API] unban:空名字,已忽略"); + return False + + hits = self._search_list(player_list_type=2, name_frag=name, first_page_only=False) + if not hits: + fmts.print_inf(f"[API] unban:黑名单中未找到 {name}") + return False + + ent = None + for e in hits: + if str(e.get("name","")).strip().lower() == name.lower(): + ent = e; break + if not ent: + ent = hits[0] + + ok, http_status, reason2 = self._set_state(ent["entity_id"], 0) + if not ok: + fmts.print_war(f"[API] unban:解除失败:{name}(HTTP={http_status};原因={reason2})") + return False + + self._remove_from_ban_time_file(ent["entity_id"]) + fmts.print_suc(f"[API] 已解除拉黑:{name}") + return True + except Exception as e: + fmts.print_war(f"[API] unban 异常:{e}") + return False + + def _on_player_chat(self, chat): + try: + player_name = getattr(chat.player, "name", None) or "" + msg = (chat.msg or "").strip() + if not player_name or not msg: + return False + + fake_pkt = { + "TextType": 1, + "SourceName": player_name, + "Message": msg + } + try: + return bool(self._on_text_packet(fake_pkt)) or False + except Exception: + return False + except Exception: + return False + + def _default_items(self): + return { + "服务器ID": "CHANGE_ME", + "查询接口URL": "https://nv1.nethard.pro/api/open-api/rentalGame/getRentalGamePlayerList", + "设置状态接口URL": "https://nv1.nethard.pro/api/open-api/rentalGame/setRentalGamePlayerState", + "你的API-key": "CHANGE_ME", + "调用方": "gameaccount", + "Cookie": "locale=en-us", + "超时秒数": 10, + "历史加入翻页最大数": 10, + + "控制台": { + "封禁命令": "blban", + "解禁命令": "blunban" + }, + + "游戏内": { + "封禁提示词": ".blban", + "解禁提示词": ".blunban", + "OP可使用": True, + "允许普通玩家使用(逗号分隔)": "", + "排除的白名单玩家(逗号分隔)": "Steve,Alex" + }, + + "对OP生效": False, + "默认封禁时长": "30分", + + "联动猎户座": True, + "猎户座玩家记录路径": "插件数据文件/『Orion System』违规与作弊行为综合反制系统/玩家丨设备号丨xuid丨历史名称丨记录.json", + + "前置XUID记录路径": "插件数据文件/前置-玩家XUID获取/xuids.json", + "到期检查间隔秒": 1 + } + + def _merge_defaults(self, items: dict): + d = self._default_items() + for k, v in d.items(): + if k not in items: + items[k] = v + elif isinstance(v, dict): + for kk, vv in v.items(): + items[k].setdefault(kk, vv) + return items + + def _load_config_items(self): + os.makedirs(self.config_dir, exist_ok=True) + if not os.path.exists(self.config_path): + items = self._default_items() + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump({"配置版本": CONFIG_GROUP_VERSION, "配置项": items}, f, ensure_ascii=False, indent=2) + return items + try: + with open(self.config_path, "r", encoding="utf-8") as f: + wrapped = json.load(f) + except Exception: + items = self._default_items() + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump({"配置版本": CONFIG_GROUP_VERSION, "配置项": items}, f, ensure_ascii=False, indent=2) + return items + if not (isinstance(wrapped, dict) and isinstance(wrapped.get("配置项"), dict)): + items = self._default_items() + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump({"配置版本": CONFIG_GROUP_VERSION, "配置项": items}, f, ensure_ascii=False, indent=2) + return items + items = self._merge_defaults(wrapped["配置项"]) + try: + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump({"配置版本": wrapped.get("配置版本", CONFIG_GROUP_VERSION), "配置项": items}, f, ensure_ascii=False, indent=2) + except Exception: + pass + return items + + def _cn_items_to_internal(self, cn: dict): + c = cn.get("控制台", {}) or {} + gi = cn.get("游戏内", {}) or {} + hist_pages = int(cn.get("历史加入翻页最大数", 10) or 10) + if hist_pages < 1: hist_pages = 1 + + allowed_normal = _split_csv_names(gi.get("允许普通玩家使用(逗号分隔)", "")) + exclude_white = _split_csv_names(gi.get("排除的白名单玩家(逗号分隔)", "Steve,Alex")) + + return { + "server_id": str(cn.get("服务器ID", "")), + "query_url": str(cn.get("查询接口URL", "")), + "set_state_url": str(cn.get("设置状态接口URL", "")), + "api_key": str(cn.get("你的API-key", "")), + "x_caller": str(cn.get("调用方", "gameaccount")), + "cookie": str(cn.get("Cookie", "")), + "timeout_sec": float(cn.get("超时秒数", 10) or 10), + "history_max_pages": hist_pages, + + "console_ban": str(c.get("封禁命令", "blban")), + "console_unban": str(c.get("解禁命令", "blunban")), + + "chat_ban_kw": str(gi.get("封禁提示词", ".blban")), + "chat_unban_kw": str(gi.get("解禁提示词", ".blunban")), + "chat_op_can_use": bool(gi.get("OP可使用", True)), + "chat_allowed_names": [str(x) for x in allowed_normal], + "exclude_white_csv": [str(x) for x in exclude_white], + + "apply_to_op": bool(cn.get("对OP生效", False)), + + "default_ban_time": str(cn.get("默认封禁时长", "30分")), + "link_orion_record": bool(cn.get("联动猎户座", True)), + "orion_player_record_path": str(cn.get("猎户座玩家记录路径", "")), + "xuid_map_path": str(cn.get("前置XUID记录路径", "")), + "expire_check_interval": float(cn.get("到期检查间隔秒", 1) or 1), + } + + def _on_preload(self): + c = self.cfg + self.frame.add_console_cmd_trigger([c["console_ban"]], "", "手动拉黑(交互)", self._cmd_blban) + self.frame.add_console_cmd_trigger([c["console_unban"]], "", "手动解除拉黑(交互)", self._cmd_blunban) + + self._stop_flag = False + self._auto_thread = threading.Thread(target=self._expire_loop, daemon=True) + self._auto_thread.start() + pass + return False + + def _on_exit(self, ev=None): + self._stop_flag = True + self._chat_sessions.clear() + fmts.print_suc(f"{self.name} 已停止") + return False + + def _is_op_like_orion(self, player_name: str) -> bool: + try: + return bool(game_utils.is_op(player_name)) + except Exception: + pass + try: + gc = self.frame.get_game_control() + except Exception: + return False + for attr in ("isOP", "is_op", "IsOP", "isOp", "isOperator", "is_operator", + "isLevelAdmin", "is_admin", "checkOP", "check_op"): + fn = getattr(gc, attr, None) + if callable(fn): + try: + if bool(fn(player_name)): + return True + except Exception: + pass + return False + + def _can_player_use_chat_cmd(self, player_name: str) -> bool: + if self.cfg.get("chat_op_can_use", True) and self._is_op_like_orion(player_name): + return True + allow = [x.lower() for x in (self.cfg.get("chat_allowed_names") or [])] + return player_name.lower() in allow + + def _tell(self, player_name: str, text: str): + msg = _json_escape(text) + cmd = f'/tellraw "{_json_escape(player_name)}" {{"rawtext":[{{"text":"{msg}"}}]}}' + try: + gc = self.frame.get_game_control() + except Exception: + gc = None + if gc: + try: + if hasattr(gc, "sendwocmd"): + gc.sendwocmd(cmd); return + except Exception: + pass + try: + if hasattr(gc, "runcmd"): + gc.runcmd(cmd); return + except Exception: + pass + fmts.print_inf(f"[TELLRAW→{player_name}] {text}") + + def _get_online_names(self): + try: + gc = self.frame.get_game_control() + except Exception: + return [] + try: + players = gc.players.getAllPlayers() + names = [] + for p in players: + try: nm = p.name if hasattr(p, "name") else str(p) + except Exception: nm = str(p) + if nm: names.append(nm) + if names: return names + except Exception: + pass + try: + alt = getattr(gc, "allplayers", None) + if isinstance(alt, (list, tuple)): + return [str(x) for x in alt] + except Exception: + pass + return [] + + def _is_online_name(self, name: str) -> bool: + try: + gc = self.frame.get_game_control() + if hasattr(gc, "players") and hasattr(gc.players, "getPlayerByName"): + if gc.players.getPlayerByName(name) is not None: + return True + except Exception: + pass + try: + names = self._get_online_names() + return name.lower() in {x.lower() for x in names} + except Exception: + return False + + def _should_skip_target(self, name: str): + wl = [x.lower() for x in (self.cfg.get("exclude_white_csv") or [])] + if name.lower() in wl: + return True, "目标在反制白名单中" + + if not self.cfg.get("apply_to_op", False): + try: + if self._is_online_name(name) and self._is_op_like_orion(name): + return True, "目标为在线OP" + except Exception: + pass + + return False, "" + + def _cmd_blban(self, args): + try: + self._interactive_ban() + except KeyboardInterrupt: + fmts.print_inf("已退出服务器黑名单封禁") + + def _cmd_blunban(self, args): + try: + self._interactive_unban() + except KeyboardInterrupt: + fmts.print_inf("已退出服务器黑名单解封") + + def _paginate(self, items, page: int, per_page: int): + total = len(items) + if total <= 0: return (0, 0, 0) + total_pages = (total - 1) // per_page + 1 + page = max(1, min(page, total_pages)) + start = (page - 1) * per_page + end = min(total, start + per_page) + return (total_pages, start, end) + + def _parse_ban_input_to_datetime(self, s: str): + if s is None: return (None, False) + raw = str(s).strip() + if raw == "": return (None, False) + if raw == "-1": + return (datetime(2099, 12, 31, 23, 59, 59), True) + if re.fullmatch(r"\d+", raw): + sec = int(raw) + if sec <= 0: return (None, False) + return (_now_beijing() + timedelta(seconds=sec), False) + pairs = re.findall(r"(\d+)\s*(年|月|日|时|分|秒)", raw) + if not pairs: return (None, False) + now = _now_beijing() + y, M, d = now.year, now.month, now.day + H, m, s2 = now.hour, now.minute, now.second + for val, unit in pairs: + v = int(val) + if unit == "年": y = v if v > 0 else y + elif unit == "月": M = v if v > 0 else M + elif unit == "日": d = v if v > 0 else d + elif unit == "时": H = v + elif unit == "分": m = v + elif unit == "秒": s2 = v + try: + expire_dt = datetime(y, M, d, H, m, s2) + except Exception: + return (None, False) + return (expire_dt, False) + + def _safe_read_json(self, path: str): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + def _orion_build_indices(self, record: dict): + name_to_pairs = {} + xuid_to_devices = {} + xuid_to_names = {} + device_to_names = {} + try: + for device_id, inner in record.items(): + if not isinstance(inner, dict): continue + names_for_dev = set() + for xuid, names in inner.items(): + if not isinstance(names, list): continue + xuid_to_devices.setdefault(xuid, set()).add(device_id) + name_set = xuid_to_names.setdefault(xuid, set()) + for nm in names: + if not isinstance(nm, str): continue + names_for_dev.add(nm) + name_set.add(nm) + name_to_pairs.setdefault(nm.lower(), set()).add((xuid, device_id)) + if names_for_dev: + device_to_names.setdefault(device_id, set()).update(names_for_dev) + except Exception: + pass + return name_to_pairs, xuid_to_devices, xuid_to_names, device_to_names + + def _read_device_ban_file(self): + out = {} + for ln in _read_lines(self.path_dev_ban): + parts = ln.split("\t") + if len(parts) < 2: continue + dev = parts[0].strip() + exp = parts[1].strip() + names = set() + if len(parts) >= 3: + names = set([x for x in parts[2].split("|") if x]) + try: + dt = datetime.strptime(exp, "%Y-%m-%d %H:%M:%S") + except Exception: + continue + out[dev] = {"expire": dt, "names": names} + return out + + def _write_device_ban_file(self, m: dict): + rows = [] + for dev, info in m.items(): + exp = _fmt_bj(info["expire"]) + names = "|".join(sorted(info.get("names") or [])) + rows.append(f"{dev}\t{exp}\t{names}") + _write_lines(self.path_dev_ban, rows) + + def _add_device_bans_for_name_xuids(self, source_name: str, xuids: set, xuid_to_devices: dict, expire_dt: datetime): + m = self._read_device_ban_file() + for x in xuids: + devices = xuid_to_devices.get(x, set()) or set() + for dev in devices: + if dev in m: + if expire_dt > m[dev]["expire"]: + m[dev]["expire"] = expire_dt + m[dev].setdefault("names", set()).add(source_name) + else: + m[dev] = {"expire": expire_dt, "names": set([source_name])} + self._write_device_ban_file(m) + + def _remove_device_bans_for_name(self, name: str): + m = self._read_device_ban_file() + changed = False + to_del = [] + for dev, info in m.items(): + names = info.get("names") or set() + if name in names: + names.discard(name) + if names: + m[dev]["names"] = names + else: + to_del.append(dev) + changed = True + for dev in to_del: + del m[dev] + if changed: + self._write_device_ban_file(m) + + def _apply_device_bans_to_online(self): + try: + if not self.cfg.get("link_orion_record", False): + return + + orion_path = self.cfg.get("orion_player_record_path") or "" + if not orion_path or (not os.path.exists(orion_path)): + return + + record = self._safe_read_json(orion_path) + name_to_pairs, xuid_to_devices, xuid_to_names, device_to_names = self._orion_build_indices(record) + + dev_bans = self._read_device_ban_file() + if not dev_bans: + return + + now_bj = _now_beijing() + banned_devs = {dev for dev, info in dev_bans.items() if info["expire"] > now_bj} + if not banned_devs: + return + + online_names = self._get_online_names() or [] + for login_name in online_names: + if not login_name: + continue + skip, _ = self._should_skip_target(login_name) + if skip: + continue + + pairs = name_to_pairs.get(login_name.lower(), set()) + xuids = {x for (x, _dev) in pairs} + if not xuids: + continue + + devices = set() + for x in xuids: + devices |= (xuid_to_devices.get(x, set()) or set()) + + hit = [dev for dev in devices if dev in banned_devs] + if not hit: + continue + + expire_dt = max(dev_bans[dev]["expire"] for dev in hit) + if (expire_dt - now_bj).total_seconds() <= 1: + continue + + ent = self._find_entity_by_name_quick(login_name) + if not ent or not ent.get("entity_id"): + continue + + ok, http_status, reason = self._set_state(ent["entity_id"], 1) + if ok: + self._record_ban_time(login_name, ent.get("user_id", ""), ent["entity_id"], _fmt_bj(expire_dt)) + self._update_uid_map(ent.get("user_id", ""), ent["entity_id"], login_name) + fmts.print_suc(f"[设备封锁联动] 已拉黑:{login_name}(命中设备封锁;至 { _fmt_bj(expire_dt) })") + else: + fmts.print_war(f"[设备封锁联动] 拉黑失败:{login_name}(HTTP={http_status};原因={reason})") + except Exception as e: + fmts.print_war(f"[设备封锁联动] 扫描在线玩家异常:{e}") + + def _post_json(self, url, payload, headers, timeout): + raw = ""; status = -1 + try: + if HAVE_REQ: + r = requests.post(url, data=json.dumps(payload, ensure_ascii=False), headers=headers, timeout=timeout) + raw = r.text; status = r.status_code + else: + req = urllib.request.Request(url=url, method="POST") + for k, v in (headers or {}).items(): req.add_header(k, v) + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + with urllib.request.urlopen(req, data=data, timeout=timeout) as rr: + status = int(getattr(rr, "status", 200)) + raw = rr.read().decode("utf-8", "ignore") + except Exception as e: + return False, -1, {"error": str(e)} + try: + obj = json.loads(raw) + except Exception: + obj = {"raw": raw} + return (200 <= status < 300), status, obj + + def _headers(self): + h = { + "Content-Type": "application/json", + "authorization": str(self.cfg.get("api_key") or ""), + "X-Caller": str(self.cfg.get("x_caller") or "gameaccount"), + "x_caller": str(self.cfg.get("x_caller") or "gameaccount"), + } + ck = self.cfg.get("cookie") or "" + if ck: h["Cookie"] = ck + return h + + def _query_page(self, player_list_type: int, offset: int, length: int): + url = self.cfg.get("query_url") or "" + timeout = float(self.cfg.get("timeout_sec", 10) or 10) + payload = {"serverID": str(self.cfg.get("server_id") or ""), "length": int(length), "offset": int(offset), "playerListType": int(player_list_type)} + ok, http_status, obj = self._post_json(url, payload, self._headers(), timeout) + if not ok: return {} + if not (obj.get("success") in (True, "true", "True")): return {} + data = obj.get("data") or {} + try: + if int(data.get("code", -1)) != 0: return {} + except Exception: + return {} + return {"entities": data.get("entities") or [], "total": data.get("total", 0)} + + def _search_list(self, player_list_type: int, name_frag=None, first_page_only=False): + frag = (name_frag or "").strip().lower() + out = []; seen_first_ids = set() + page = 0 + cfg_max_pages = int(self.cfg.get("history_max_pages") or 10) + if cfg_max_pages < 1: cfg_max_pages = 1 + max_pages = cfg_max_pages if player_list_type == 1 else 10**9 + while True: + if page >= max_pages: break + offset = page * PAGE_LEN_API + batch = self._query_page(player_list_type, offset=offset, length=PAGE_LEN_API) + ents = (batch or {}).get("entities") or [] + if not ents: break + first_id = str(ents[0].get("entity_id") or ents[0].get("_id") or f"{offset}") + if first_id in seen_first_ids and page > 0: break + seen_first_ids.add(first_id) + for e in ents: + nm = str(e.get("name") or e.get("user_name") or "").strip() + if not nm: continue + if (not frag) or (frag in nm.lower()): + out.append({"name": nm, "user_id": str(e.get("user_id") or ""), "entity_id": str(e.get("entity_id") or e.get("_id") or "")}) + if first_page_only: break + if len(ents) < PAGE_LEN_API: break + page += 1 + return out + + def _find_entity_by_name_quick(self, name: str): + target = name.strip().lower() + page = 0; seen_first_ids=set() + max_pages = int(self.cfg.get("history_max_pages") or 10) + if max_pages < 1: max_pages = 1 + while page < max_pages: + offset = page * PAGE_LEN_API + batch = self._query_page(player_list_type=1, offset=offset, length=PAGE_LEN_API) + ents = (batch or {}).get("entities") or [] + if not ents: break + first_id = str(ents[0].get("entity_id") or ents[0].get("_id") or f"{offset}") + if first_id in seen_first_ids and page>0: break + seen_first_ids.add(first_id) + for e in ents: + nm = str(e.get("name") or e.get("user_name") or "").strip() + if nm and nm.lower() == target: + return {"name": nm, "user_id": str(e.get("user_id") or ""), "entity_id": str(e.get("entity_id") or e.get("_id") or "")} + if len(ents) < PAGE_LEN_API: break + page += 1 + return None + + def _set_state(self, entity_id: str, state: int): + url = self.cfg.get("set_state_url") or "" + timeout = float(self.cfg.get("timeout_sec", 10) or 10) + payload = {"entityID": int(entity_id), "PlayerState": int(state)} + ok, http_status, obj = self._post_json(url, payload, self._headers(), timeout) + if not ok: + return False, http_status, "HTTP失败" + if obj.get("success") in (True, "true", "True"): + data = obj.get("data", {}) + try: + if int(data.get("code", -1)) == 0: + return True, http_status, "" + except Exception: + pass + return False, http_status, f"业务失败: {obj}" + + def _query_local_ban_status(self, name:str) -> str: + try: + now = _now_beijing() + for ln in _read_lines(self.path_ban_time): + parts = ln.split("\t") + if len(parts) < 4: continue + nm, _, _, exp = parts[0], parts[1], parts[2], parts[3] + if nm != name: continue + try: + dt = datetime.strptime(exp, "%Y-%m-%d %H:%M:%S") + except: + continue + if dt > now: + return _fmt_bj(dt) + except Exception: + pass + return "" + + def _record_ban_time(self, name: str, uid: str, entity_id: str, expire_bj: str): + line = f"{name}\t{uid}\t{entity_id}\t{expire_bj}" + old = _read_lines(self.path_ban_time) + keep = [ln for ln in old if (len(ln.split('\t'))<3 or ln.split('\t')[2] != entity_id)] + keep.append(line) + _write_lines(self.path_ban_time, keep) + + def _remove_from_ban_time_file(self, entity_id: str): + lines = _read_lines(self.path_ban_time) + keep = [] + changed = False + for ln in lines: + parts = ln.split("\t") + if len(parts) >= 3 and parts[2] == str(entity_id): + changed = True + continue + keep.append(ln) + if changed: + _write_lines(self.path_ban_time, keep) + + def _update_uid_map(self, uid: str, entity_id: str, name: str): + uid = str(uid or ""); entity_id = str(entity_id or ""); name = str(name or "") + rows = _read_lines(self.path_uid_map) + out = []; found = False + for ln in rows: + parts = ln.split("\t") + if len(parts) < 3: continue + u, ent, names = parts[0], parts[1], parts[2] + if u == uid: + found = True + name_set = set([x for x in names.split("|") if x]) + name_set.add(name) + out.append(f"{uid}\t{entity_id or ent}\t{'|'.join(sorted(name_set))}") + else: + out.append(ln) + if not found: + out.append(f"{uid}\t{entity_id}\t{name}") + _write_lines(self.path_uid_map, out) + + def _expire_loop(self): + interval = float(self.cfg.get("expire_check_interval", 1) or 1) + slice_count = 10 + while not self._stop_flag: + try: + now_bj = _now_beijing() + lines = _read_lines(self.path_ban_time) + keep = [] + for ln in lines: + parts = ln.split("\t") + if len(parts) < 4: continue + name, uid, entity_id, expire_s = parts[0], parts[1], parts[2], parts[3] + try: + expire_dt = datetime.strptime(expire_s, "%Y-%m-%d %H:%M:%S") + except: + keep.append(ln); continue + if expire_dt <= now_bj: + ok, http_status, reason = self._set_state(entity_id, 0) + if ok: + fmts.print_suc(f"[拉黑已到期] {name}(uid={uid},entity_id={entity_id})") + else: + fmts.print_war(f"[解除拉黑失败] {name}(HTTP={http_status};原因={reason})") + keep.append(ln) + else: + keep.append(ln) + if keep != lines: + _write_lines(self.path_ban_time, keep) + + devmap = self._read_device_ban_file() + changed = False + for dev in list(devmap.keys()): + if devmap[dev]["expire"] <= now_bj: + del devmap[dev]; changed = True + if changed: + self._write_device_ban_file(devmap) + + except Exception as e: + fmts.print_war(f"[到期线程异常] {e}") + + per = max(0.05, interval / slice_count) + for _ in range(slice_count): + if self._stop_flag: break + time.sleep(per) + + def _interactive_ban(self): + PAGE = PAGE_LEN_VIEW + while True: + fmts.print_inf("—— 服务器黑名单封禁系统 ——") + fmts.print_inf("[1] 根据在线玩家封禁(可封设备号)") + fmts.print_inf("[2] 根据历史进服玩家模糊搜索封禁(读xuid文件,不封设备)") + fmts.print_inf("[3] 根据历史进服玩家模糊搜索封锁设备号(读猎户座数据文件)") + choice = input(fmts.fmt_info("输入 1/2/3 选择;输入 . 退出:")).strip() + if choice in (".", "。", ""): + fmts.print_inf("已退出封禁") + return + + if choice == "1": + names = self._get_online_names() + if not names: + fmts.print_war("当前无在线玩家"); return + + def render(page): + total_pages, start, end = self._paginate(names, page, PAGE) + if total_pages == 0: + fmts.print_inf("(空)"); return (page, total_pages, []) + fmts.print_inf(f"—— 在线名单 —— 第 {page}/{total_pages} 页") + cur = [] + for i, nm in enumerate(names[start:end], start=1): + st = self._query_local_ban_status(nm) + fmts.print_inf(f"[{i}] {nm} - {'已封禁,至 '+st if st else '未封禁'}") + cur.append(nm) + fmts.print_inf("提示:输入数字选择;- 上一页;+ 下一页;直接回车退出") + return (page, total_pages, cur) + + page = 1 + while True: + page, total_pages, cur = render(page) + if total_pages == 0: break + s = input(fmts.fmt_info("> ")).strip() + if s in ("", ".", "。"): return + if s == "-" and page > 1: page -= 1; continue + if s == "+" and page < total_pages: page += 1; continue + try: + idx = int(s) + if 1 <= idx <= len(cur): + name = cur[idx-1] + else: + fmts.print_war("序号超出范围"); continue + except: + fmts.print_war("请输入数字序号 / - / + / ."); continue + + skip, why = self._should_skip_target(name) + if skip: + fmts.print_war(f"已跳过:{name}({why})") + return + + t = input(fmts.fmt_info("请输入封禁时长(-1=永久;正整数=秒;或形如 2025年10月20日18时30分00秒;输入 . 取消):")).strip() + if t in (".", "。", ""): return + expire_dt, is_perm = self._parse_ban_input_to_datetime(t) + if not expire_dt: + fmts.print_war("封禁时长/时间格式无效"); return + expire = _fmt_bj(expire_dt) + + devices = set() + try: + if self.cfg.get("link_orion_record", False): + orion_path = self.cfg.get("orion_player_record_path") or "" + record = self._safe_read_json(orion_path) if orion_path and os.path.exists(orion_path) else {} + n2p, x2d, x2n, d2n = self._orion_build_indices(record) + pairs = n2p.get(name.lower(), set()) + xuids = {x for (x, _d) in pairs} + for x in xuids: + devices |= (x2d.get(x, set()) or set()) + if xuids: + self._add_device_bans_for_name_xuids(name, xuids, x2d, expire_dt) + else: + fmts.print_war("未开启『联动猎户座』,无法封锁设备与联动同设备玩家。") + except Exception as e: + fmts.print_war(f"[猎户座映射失败] {e}") + + ent = self._find_entity_by_name_quick(name) + if ent: + ok, http_status, reason = self._set_state(ent["entity_id"], 1) + if ok: + self._record_ban_time(name, ent.get("user_id",""), ent["entity_id"], expire) + self._update_uid_map(ent.get("user_id",""), ent["entity_id"], name) + fmts.print_suc(f"封禁成功:{name} 至 {expire}" if not is_perm else f"封禁成功(永久):{name}") + else: + fmts.print_war(f"封禁失败:{name}(HTTP={http_status};原因={reason})") + else: + fmts.print_war(f"服务器历史加入列表中未找到目标玩家:{name}") + + if devices: + try: + orion_path = self.cfg.get("orion_player_record_path") or "" + record = self._safe_read_json(orion_path) if orion_path and os.path.exists(orion_path) else {} + d2n = {} + for dev, inner in record.items(): + if not isinstance(inner, dict): continue + s_names = set() + for _xuid, names in inner.items(): + if isinstance(names, list): + for nm in names: + if isinstance(nm, str) and nm: + s_names.add(nm) + if s_names: + d2n[dev] = s_names + same_device_names = set() + for dev in devices: + same_device_names.update(d2n.get(dev, set())) + if name in same_device_names: + same_device_names.discard(name) + count_ok = 0; count_fail = 0 + for nm in sorted(same_device_names): + skip2, why2 = self._should_skip_target(nm) + if skip2: + continue + ent2 = self._find_entity_by_name_quick(nm) + if not ent2: + continue + ok2, http_status2, reason2 = self._set_state(ent2["entity_id"], 1) + if ok2: + self._record_ban_time(nm, ent2.get("user_id",""), ent2["entity_id"], expire) + self._update_uid_map(ent2.get("user_id",""), ent2["entity_id"], nm) + count_ok += 1 + else: + count_fail += 1 + if count_ok or count_fail: + fmts.print_inf(f"同设备封锁:成功 {count_ok} 人,失败 {count_fail} 人。") + except Exception as e: + fmts.print_war(f"[同设备封锁异常] {e}") + return + + elif choice == "2": + xuid_path = self.cfg.get("xuid_map_path") or "" + if not xuid_path or (not os.path.exists(xuid_path)): + fmts.print_war("未找到 XUID 名单文件,请检查“前置XUID记录路径”。") + return + xmap = self._safe_read_json(xuid_path) or {} + if not isinstance(xmap, dict): + fmts.print_war("XUID 名单文件格式错误,应为 {xuid: name} 映射。") + return + + frag = input(fmts.fmt_info("请输入名称片段(. 退出):")).strip() + if frag in (".", "。", ""): return + frag_l = frag.lower() + + items = [] + for xuid, nm in xmap.items(): + try: + nm_s = str(nm or "").strip() + if nm_s and (frag_l in nm_s.lower()): + items.append((nm_s, str(xuid))) + except Exception: + continue + seen = set(); dedup = [] + for nm, x in items: + if nm.lower() in seen: continue + seen.add(nm.lower()); dedup.append((nm, x)) + items = sorted(dedup, key=lambda t: (t[0].lower(), t[1])) + if not items: + fmts.print_war("未在 XUID 名单中找到匹配玩家"); return + + page = 1 + def render(page): + total_pages, start, end = self._paginate(items, page, PAGE_LEN_VIEW) + if total_pages == 0: + fmts.print_inf("(空)"); return (page, total_pages, []) + fmts.print_inf(f"—— XUID 名单匹配(名字):{frag} —— 第 {page}/{total_pages} 页") + cur = [] + for i, (nm, xuid) in enumerate(items[start:end], start=1): + st = self._query_local_ban_status(nm) + fmts.print_inf(f"[{i}] {nm} | xuid={xuid} - {'已封禁,至 '+st if st else '未封禁'}") + cur.append((nm, xuid)) + fmts.print_inf("提示:输入数字选择;- 上一页;+ 下一页;直接输入新片段重新搜索;. 退出") + return (page, total_pages, cur) + + while True: + page, total_pages, cur = render(page) + if total_pages == 0: break + s = input(fmts.fmt_info("> ")).strip() + if s in (".", "。"): return + if s == "-" and page > 1: page -= 1; continue + if s == "+" and page < total_pages: page += 1; continue + chosen = None + try: + idx = int(s) + if 1 <= idx <= len(cur): + chosen = cur[idx-1] + else: + fmts.print_war("序号超出范围"); continue + except: + frag = s; frag_l = frag.lower() + xmap = self._safe_read_json(xuid_path) or {} + items = [] + for xuid, nm in xmap.items(): + try: + nm_s = str(nm or "").strip() + if nm_s and (frag_l in nm_s.lower()): + items.append((nm_s, str(xuid))) + except Exception: + continue + seen = set(); dedup = [] + for nm, x in items: + if nm.lower() in seen: continue + seen.add(nm.lower()); dedup.append((nm, x)) + items = sorted(dedup, key=lambda t: (t[0].lower(), t[1])) + if not items: + fmts.print_war("未在 XUID 名单中找到匹配玩家"); return + page = 1; continue + + name, xuid = chosen + skip, why = self._should_skip_target(name) + if skip: + fmts.print_war(f"已跳过:{name}({why})") + return + t = input(fmts.fmt_info("请输入封禁时长(-1=永久;正整数=秒;或形如 2025年10月20日18时30分00秒;输入 . 取消):")).strip() + if t in (".", "。", ""): return + expire_dt, is_perm = self._parse_ban_input_to_datetime(t) + if not expire_dt: + fmts.print_war("封禁时长/时间格式无效"); return + expire = _fmt_bj(expire_dt) + ent = self._find_entity_by_name_quick(name) + if ent: + ok, http_status, reason = self._set_state(ent["entity_id"], 1) + if ok: + self._record_ban_time(name, ent.get("user_id",""), ent["entity_id"], expire) + self._update_uid_map(ent.get("user_id",""), ent["entity_id"], name) + fmts.print_suc(f"封禁成功:{name}(xuid={xuid}) 至 {expire}" if not is_perm else f"封禁成功(永久):{name}(xuid={xuid})") + else: + fmts.print_war(f"封禁失败:{name}(HTTP={http_status};原因={reason})") + else: + fmts.print_war(f"服务器历史加入列表中未找到目标玩家:{name}") + return + + elif choice == "3": + if not self.cfg.get("link_orion_record", False): + fmts.print_war("未开启『联动猎户座』,无法使用模式3。请在配置中启用。") + return + orion_path = self.cfg.get("orion_player_record_path") or "" + if not orion_path or (not os.path.exists(orion_path)): + fmts.print_war("未找到猎户座玩家记录文件,请检查配置路径。") + return + + record = self._safe_read_json(orion_path) + name_to_pairs, xuid_to_devices, xuid_to_names, device_to_names = self._orion_build_indices(record) + + frag = input(fmts.fmt_info("请输入名称片段(. 退出):")).strip() + if frag in (".", "。", ""): return + frag_l = frag.lower() + + candidate_names = sorted([nm for nm in set(name_to_pairs.keys()) if frag_l in nm]) + if candidate_names: + def devices_union_for_name(nm): + pairs = name_to_pairs.get(nm.lower(), set()) + xuids = {x for (x, _d) in pairs} + devs = set() + for x in xuids: devs |= (xuid_to_devices.get(x, set()) or set()) + return xuids, devs + + items = [] + for nm in candidate_names: + xuids, devs = devices_union_for_name(nm) + items.append((nm, xuids, devs)) + page = 1 + + def render(page): + total_pages, start, end = self._paginate(items, page, PAGE_LEN_VIEW) + if total_pages == 0: + fmts.print_inf("(空)"); return (page, total_pages, []) + fmts.print_inf(f"—— 猎户座匹配(名字):{frag} —— 第 {page}/{total_pages} 页") + cur = [] + for i, (nm, xuids, devs) in enumerate(items[start:end], start=1): + st = self._query_local_ban_status(nm) + fmts.print_inf(f"[{i}] {nm} | xuid数={len(xuids)} | 设备数={len(devs)} - {'已封禁,至 '+st if st else '未封禁'}") + cur.append((nm, xuids, devs)) + fmts.print_inf("提示:输入数字选择;- 上一页;+ 下一页;直接输入新片段重新搜索;. 退出") + return (page, total_pages, cur) + + while True: + page, total_pages, cur = render(page) + if total_pages == 0: break + s = input(fmts.fmt_info("> ")).strip() + if s in (".", "。"): return + if s == "-" and page > 1: page -= 1; continue + if s == "+" and page < total_pages: page += 1; continue + try: + idx = int(s) + if 1 <= idx <= len(cur): + nm, xuids, devs = cur[idx-1] + else: + fmts.print_war("序号超出范围"); continue + except: + frag = s; frag_l = frag.lower() + candidate_names = sorted([nn for nn in set(name_to_pairs.keys()) if frag_l in nn]) + if not candidate_names: + fmts.print_war("未找到匹配玩家"); return + items = [] + for nn in candidate_names: + xset, dset = devices_union_for_name(nn) + items.append((nn, xset, dset)) + page = 1; continue + + t = input(fmts.fmt_info("请输入封禁时长(-1=永久;正整数=秒;或形如 2025年10月20日18时30分00秒;输入 . 取消):")).strip() + if t in (".", "。", ""): return + expire_dt, is_perm = self._parse_ban_input_to_datetime(t) + if not expire_dt: + fmts.print_war("封禁时长/时间格式无效"); return + self._add_device_bans_for_name_xuids(nm, xuids, xuid_to_devices, expire_dt) + self._apply_device_bans_to_online() + fmts.print_suc(f"已封锁设备 {len(devs)} 个(按名字 {nm} 的 xuid 聚合,至 { _fmt_bj(expire_dt) }{'(永久)' if is_perm else ''})。") + fmts.print_inf("后续发现这些设备登录时将自动黑名单拉黑") + return + + xuid_hit = None + sample_name = "" + for xuid, names in sorted(xuid_to_names.items(), key=lambda kv: kv[0]): + for nm in names: + if frag_l in nm.lower(): + xuid_hit = xuid; sample_name = nm; break + if xuid_hit: break + + if not xuid_hit: + fmts.print_war("未在猎户座记录中找到匹配玩家") + return + + t = input(fmts.fmt_info(f"找到历史名(xuid={xuid_hit[:8]}…):例如 {sample_name};请输入封禁时长(-1/秒数/到期时间;. 取消):")).strip() + if t in (".", "。", ""): return + expire_dt, is_perm = self._parse_ban_input_to_datetime(t) + if not expire_dt: + fmts.print_war("封禁时长/时间格式无效"); return + self._add_device_bans_for_name_xuids(sample_name, {xuid_hit}, + self._orion_build_indices(self._safe_read_json(self.cfg.get("orion_player_record_path") or ""))[1], + expire_dt) + self._apply_device_bans_to_online() + fmts.print_suc(f"已封锁设备(历史名所在 xuid 聚合),至 { _fmt_bj(expire_dt) }{'(永久)' if is_perm else ''}。") + fmts.print_inf("后续发现这些设备登录时将自动黑名单拉黑") + return + + else: + fmts.print_war("请输入 1 / 2 / 3") + + def _interactive_unban(self): + PAGE = PAGE_LEN_VIEW + while True: + fmts.print_inf("—— 解除封禁 ——") + fmts.print_inf("[1] 列出黑名单第1页(黑名单列表的前59名玩家)") + fmts.print_inf("[2] 在黑名单中按名称片段搜索") + fmts.print_inf("[3] 解封玩家设备号") + choice = input(fmts.fmt_info("输入 1/2/3 选择;输入 . 退出:")).strip() + if choice in (".", "。", ""): + fmts.print_inf("已退出解封交互。") + return + + if choice == "1": + ents = self._search_list(player_list_type=2, name_frag=None, first_page_only=True) + if not ents: + fmts.print_war("未在黑名单列表找到任何玩家"); return + def render(page): + total_pages, start, end = self._paginate(ents, page, PAGE) + fmts.print_inf(f"—— 黑名单(第1页) ——") + cur=[] + for i, e in enumerate(ents[start:end], start=1): + nm=e.get("name","?"); uid=e.get("user_id","?"); eid=e.get("entity_id","?") + fmts.print_inf(f"[{i}] {nm} | uid={uid} | entity_id={eid}") + cur.append(e) + fmts.print_inf("提示:输入数字选择;直接回车退出") + return (page, 1, cur) + + page=1 + while True: + _,_,cur = render(page) + s = input(fmts.fmt_info("> ")).strip() + if s in ("", ".", "。"): return + try: + idx = int(s) + if 1 <= idx <= len(cur): + ent = cur[idx-1]; name=ent.get("name","") + ok, http_status, reason = self._set_state(ent["entity_id"], 0) + if ok: + fmts.print_suc(f"解除拉黑成功:{name}(entity_id={ent['entity_id']})") + self._remove_from_ban_time_file(ent["entity_id"]) + self._remove_device_bans_for_name(name) + else: + fmts.print_war(f"解除拉黑失败:{name}(HTTP={http_status};原因={reason})") + return + fmts.print_war("序号超出范围") + except: + fmts.print_war("请输入数字序号 / .") + return + + elif choice == "2": + frag = input(fmts.fmt_info("请输入名称片段(. 退出):")).strip().lower() + if frag in (".", "。", ""): + fmts.print_inf("已退出解封交互。"); return + ents = self._search_list(player_list_type=2, name_frag=frag, first_page_only=False) + if not ents: + fmts.print_war("未找到匹配玩家"); return + + def render(page): + total_pages, start, end = self._paginate(ents, page, PAGE) + if total_pages == 0: + fmts.print_inf("(空)"); return (page, total_pages, []) + fmts.print_inf(f"—— 黑名单(匹配:{frag})—— 第 {page}/{total_pages} 页") + cur=[] + for i, e in enumerate(ents[start:end], start=1): + nm=e.get("name","?"); uid=e.get("user_id","?"); eid=e.get("entity_id","?") + fmts.print_inf(f"[{i}] {nm} | uid={uid} | entity_id={eid}") + cur.append(e) + fmts.print_inf("提示:输入数字选择;- 上一页;+ 下一页;直接输入新片段重新搜索;. 退出") + return (page, total_pages, cur) + + page=1 + while True: + page, total_pages, cur = render(page) + if total_pages == 0: break + s = input(fmts.fmt_info("> ")).strip() + if s in (".", "。"): return + if s == "-" and page > 1: page -= 1; continue + if s == "+" and page < total_pages: page += 1; continue + try: + idx = int(s) + if 1 <= idx <= len(cur): + ent = cur[idx-1]; name=ent.get("name","") + ok, http_status, reason = self._set_state(ent["entity_id"], 0) + if ok: + fmts.print_suc(f"解除拉黑成功:{name}(entity_id={ent['entity_id']})") + self._remove_from_ban_time_file(ent["entity_id"]) + self._remove_device_bans_for_name(name) + else: + fmts.print_war(f"解除拉黑失败:{name}(HTTP={http_status};原因={reason})") + return + fmts.print_war("序号超出范围") + except: + frag = s.lower() + ents = self._search_list(player_list_type=2, name_frag=frag, first_page_only=False) + if not ents: + fmts.print_war("未找到匹配玩家"); return + page = 1; continue + + elif choice == "3": + devmap = self._read_device_ban_file() + if not devmap: + fmts.print_inf("(当前没有设备封锁记录)"); return + + items = [] + for dev, info in devmap.items(): + expire = info.get("expire") + names = sorted(list(info.get("names") or [])) + items.append((dev, expire, names)) + items.sort(key=lambda t: t[1]) + + def render(page): + total_pages, start, end = self._paginate(items, page, PAGE) + fmts.print_inf(f"—— 设备封锁记录 —— 第{page}/{total_pages}页") + cur = items[start:end] + for i, (dev, exp, names) in enumerate(cur, start=1): + nm = "|".join(names)[:40] + fmts.print_inf(f"[{i}] dev={dev} | expire={_fmt_bj(exp)} | 关联名称={nm}") + fmts.print_inf("提示:输入数字删除该条记录;输入 -/+ 翻页;输入 . 退出") + return total_pages, cur + + page = 1 + while True: + total_pages, cur = render(page) + s = input(fmts.fmt_info("> ")).strip() + if s in (".","。",""): return + if s == "-" and page > 1: page -= 1; continue + if s == "+" and page < total_pages: page += 1; continue + try: + idx = int(s) + if 1 <= idx <= len(cur): + dev = cur[idx-1][0] + if dev in devmap: + del devmap[dev] + self._write_device_ban_file(devmap) + fmts.print_suc(f"已删除设备封锁记录:{dev}") + else: + fmts.print_war("该设备记录已不存在。") + return + fmts.print_war("序号超出范围") + except: + fmts.print_war("请输入数字序号 / - / + / .") + else: + fmts.print_war("请输入 1 / 2 / 3") + + def _on_text_packet(self, pk): + try: + msg = pk.get("Message") or pk.get("message") or "" + src = pk.get("SourceName") or pk.get("source_name") or pk.get("Sender") or pk.get("sender") or "" + except Exception: + return False + if not msg or not src: + return False + + text = str(msg).strip() + player = str(src).strip() + + ban_kw = self.cfg.get("chat_ban_kw", ".blban") + unban_kw = self.cfg.get("chat_unban_kw", ".blunban") + + if text == ban_kw or text == unban_kw: + if not self._can_player_use_chat_cmd(player): + self._tell(player, "§c你没有权限使用该功能。") + return False + key = ("ban" if text == ban_kw else "unban") + self._chat_sessions[player] = {"type": key, "state": "menu"} + if key == "ban": + self._tell(player, "§b---- 服务器黑名单封禁系统 ----\n选择模式:\n§e1=在线玩家封禁\n§e2=历史进服玩家名称封禁\n§e3=历史进服玩家设备号封禁\n§7输入数字选择;输入 . 退出") + else: + self._tell(player, "---- 服务器黑名单解封系统 ----\n§b选择模式:\n§e1=列出黑名单列表的前59名玩家\n§e2=按名称片段搜索黑名单\n§e3=解封玩家设备号\n§7输入数字选择;输入 . 退出") + return False + + sess = self._chat_sessions.get(player) + if not sess: + return False + + if text in (".", "。"): + self._tell(player, "§a已退出。") + self._chat_sessions.pop(player, None) + return False + + if sess["type"] == "ban": + self._chat_ban_flow(player, text, sess) + else: + self._chat_unban_flow(player, text, sess) + return False + + def _chat_ban_flow(self, player, text, sess): + st = sess.get("state") + + if st == "menu": + if text not in ("1","2","3"): + self._tell(player, "§c请输入 1 / 2 / 3 进行选择,或输入 . 退出") + return + sess["mode"] = text + if text == "1": + names = self._get_online_names() + if not names: + self._tell(player, "§e当前无在线玩家。") + self._chat_sessions.pop(player, None); return + sess["names"] = names + sess["page"] = 1 + sess["state"] = "pick_online_name" + self._chat_render_online_list(player, sess); return + elif text == "2": + sess["state"] = "ask_xuid_frag" + self._tell(player, "§b请输入名称片段:§7输入 . 退出"); return + else: + sess["state"] = "ask_orion_frag" + self._tell(player, "§b请输入名称片段:§7输入 . 退出"); return + + if st == "pick_online_name": + if text == "-" or text == "上一页": + if sess["page"] > 1: sess["page"] -= 1 + self._chat_render_online_list(player, sess); return + if text == "+" or text == "下一页": + total_pages = self._chat_online_total_pages(sess) + if sess["page"] < total_pages: sess["page"] += 1 + self._chat_render_online_list(player, sess); return + try: + idx = int(text) + cur = self._chat_online_current_page_items(sess) + if 1 <= idx <= len(cur): + name = cur[idx-1] + else: + self._tell(player, "§c序号超出范围。"); return + except: + self._tell(player, "§c请输入数字序号;或 - / + 翻页;或 . 退出"); return + + skip, why = self._should_skip_target(name) + if skip: + self._tell(player, f"§e已跳过:{name} §7({why})") + self._chat_sessions.pop(player, None); return + + sess["target_name"] = name + sess["state"] = "ask_ban_time" + self._tell(player, "§b请输入封禁时长:§7-1=永久;正整数=秒;或形如 2025年10月20日18时30分00秒\n§7输入 . 取消") + return + + if st == "ask_xuid_frag": + frag = text.strip() + if not frag: + self._tell(player, "§c片段不能为空。"); return + xuid_path = self.cfg.get("xuid_map_path") or "" + if not xuid_path or (not os.path.exists(xuid_path)): + self._tell(player, "§c未找到 XUID 名单文件,请检查“前置XUID记录路径”。") + self._chat_sessions.pop(player, None); return + xmap = self._safe_read_json(xuid_path) or {} + if not isinstance(xmap, dict): + self._tell(player, "§cXUID 名单文件格式错误,应为 {xuid: name} 映射。") + self._chat_sessions.pop(player, None); return + frag_l = frag.lower() + items = [] + for xuid, nm in xmap.items(): + try: + nm_s = str(nm or "").strip() + if nm_s and (frag_l in nm_s.lower()): + items.append((nm_s, str(xuid))) + except Exception: + continue + seen = set(); dedup = [] + for nm, x in items: + if nm.lower() in seen: continue + seen.add(nm.lower()); dedup.append((nm, x)) + items = sorted(dedup, key=lambda t: (t[0].lower(), t[1])) + if not items: + self._tell(player, "§e未在 XUID 名单中找到匹配玩家。") + self._chat_sessions.pop(player, None); return + sess["xuid_items"] = items + sess["page"] = 1 + sess["state"] = "pick_xuid_name" + self._chat_render_xuid_items(player, sess, frag); return + + if st == "pick_xuid_name": + if text in ("-","+"): + self._chat_turn_page(player, sess, key="xuid_items", per=PAGE_LEN_VIEW, inc=(1 if text=="+" else -1)); return + try: + idx = int(text) + cur = self._chat_current_page_list(sess, key="xuid_items", per=PAGE_LEN_VIEW) + if 1 <= idx <= len(cur): + name, xuid = cur[idx-1] + else: + self._tell(player, "§c序号超出范围。"); return + except: + sess["state"] = "ask_xuid_frag" + self._tell(player, "§b请输入新的名称片段(XUID 文件):§7输入 . 退出"); return + + skip, why = self._should_skip_target(name) + if skip: + self._tell(player, f"§e已跳过:{name} §7({why})") + self._chat_sessions.pop(player, None); return + + sess["target_name"] = name + sess["target_xuid"] = xuid + sess["state"] = "ask_ban_time" + self._tell(player, "§b请输入封禁时长:§7-1=永久;正整数=秒;或形如 2025年10月20日18时30分00秒\n§7输入 . 取消") + return + + if st == "ask_orion_frag": + frag = text.strip() + if not frag: + self._tell(player, "§c片段不能为空。"); return + if not self.cfg.get("link_orion_record", False): + self._tell(player, "§c未开启『联动猎户座』,无法使用模式3。") + self._chat_sessions.pop(player, None); return + orion_path = self.cfg.get("orion_player_record_path") or "" + if not orion_path or (not os.path.exists(orion_path)): + self._tell(player, "§c未找到猎户座玩家记录文件,请检查配置路径。") + self._chat_sessions.pop(player, None); return + + record = self._safe_read_json(orion_path) + name_to_pairs, xuid_to_devices, xuid_to_names, device_to_names = self._orion_build_indices(record) + frag_l = frag.lower() + candidate_names = sorted([nm for nm in set(name_to_pairs.keys()) if frag_l in nm]) + if candidate_names: + items = [] + for nm in candidate_names: + pairs = name_to_pairs.get(nm.lower(), set()) + xuids = {x for (x, _d) in pairs} + devs = set() + for x in xuids: devs |= (xuid_to_devices.get(x, set()) or set()) + items.append((nm, xuids, devs)) + sess["orion_items"] = items + sess["page"] = 1 + sess["state"] = "pick_orion_name" + self._chat_render_orion_items(player, sess, frag); return + + xuid_hit = None + sample_name = "" + for xuid, names in sorted(xuid_to_names.items(), key=lambda kv: kv[0]): + for nm in names: + if frag_l in nm.lower(): + xuid_hit = xuid; sample_name = nm; break + if xuid_hit: break + if not xuid_hit: + self._tell(player, "§e未在猎户座记录中找到匹配玩家") + self._chat_sessions.pop(player, None); return + sess["history_xuid"] = xuid_hit + sess["history_name"] = sample_name + sess["state"] = "ask_ban_time_history" + self._tell(player, f"§b找到历史名:§e{sample_name} §7(xuid={xuid_hit[:8]}…);请输入封禁时长(-1/秒数/到期时间;. 取消)") + return + + if st == "pick_orion_name": + if text in ("-","+"): + self._chat_turn_page(player, sess, key="orion_items", per=PAGE_LEN_VIEW, inc=(1 if text=="+" else -1)); return + try: + idx = int(text) + cur = self._chat_current_page_list(sess, key="orion_items", per=PAGE_LEN_VIEW) + if 1 <= idx <= len(cur): + nm, xuids, devs = cur[idx-1] + else: + self._tell(player, "§c序号超出范围。"); return + except: + sess["state"] = "ask_orion_frag" + self._tell(player, "§b请输入新的名称片段:§7输入 . 退出"); return + + sess["orion_pick_nm"] = nm + sess["orion_pick_xuids"] = xuids + sess["state"] = "ask_ban_time" + self._tell(player, "§b请输入封禁时长:§7-1=永久;正整数=秒;或形如 2025年10月20日18时30分00秒\n§7输入 . 取消") + return + + if st in ("ask_ban_time", "ask_ban_time_history"): + expire_dt, is_perm = self._parse_ban_input_to_datetime(text) + if not expire_dt: + self._tell(player, "§c封禁时长/时间格式无效。"); return + expire = _fmt_bj(expire_dt) + + if sess.get("mode") == "1": + name = sess.get("target_name","") + + skip, why = self._should_skip_target(name) + if skip: + self._tell(player, f"§e已跳过:{name} §7({why})") + self._chat_sessions.pop(player, None); return + + devices = set() + try: + if self.cfg.get("link_orion_record", False): + orion_path = self.cfg.get("orion_player_record_path") or "" + record = self._safe_read_json(orion_path) if orion_path and os.path.exists(orion_path) else {} + n2p, x2d, x2n, d2n = self._orion_build_indices(record) + pairs = n2p.get(name.lower(), set()) + xuids = {x for (x, _d) in pairs} + for x in xuids: + devices |= (x2d.get(x, set()) or set()) + if xuids: + self._add_device_bans_for_name_xuids(name, xuids, x2d, expire_dt) + except Exception: + pass + + ent = self._find_entity_by_name_quick(name) + if ent: + ok, http_status, reason = self._set_state(ent["entity_id"], 1) + if ok: + self._record_ban_time(name, ent.get("user_id",""), ent["entity_id"], expire) + self._update_uid_map(ent.get("user_id",""), ent["entity_id"], name) + self._tell(player, f"§a封禁成功:§e{name} §7至 §b{expire}" + (" §7(永久)" if is_perm else "")) + else: + self._tell(player, f"§c封禁失败:§7{name} §8(HTTP={http_status}; 原因={reason})") + else: + self._tell(player, f"§e未能在历史加入列表找到该玩家的可操作条目:§7{name}") + + if devices: + try: + orion_path = self.cfg.get("orion_player_record_path") or "" + record = self._safe_read_json(orion_path) if orion_path and os.path.exists(orion_path) else {} + d2n = {} + for dev, inner in record.items(): + if not isinstance(inner, dict): continue + s_names = set() + for _xuid, names in inner.items(): + if isinstance(names, list): + for nm in names: + if isinstance(nm, str) and nm: + s_names.add(nm) + if s_names: + d2n[dev] = s_names + same_device_names = set() + for dev in devices: + same_device_names.update(d2n.get(dev, set())) + if name in same_device_names: + same_device_names.discard(name) + count_ok = 0; count_fail = 0 + for nm in sorted(same_device_names): + skip2, why2 = self._should_skip_target(nm) + if skip2: + continue + ent2 = self._find_entity_by_name_quick(nm) + if not ent2: + continue + ok2, http_status2, reason2 = self._set_state(ent2["entity_id"], 1) + if ok2: + self._record_ban_time(nm, ent2.get("user_id",""), ent2["entity_id"], expire) + self._update_uid_map(ent2.get("user_id",""), ent2["entity_id"], nm) + count_ok += 1 + else: + count_fail += 1 + if count_ok or count_fail: + self._tell(player, f"§7同设备封锁:成功 §a{count_ok} §7人,失败 §c{count_fail} §7人。") + except Exception: + pass + + self._chat_sessions.pop(player, None); return + + elif sess.get("mode") == "2": + name = sess.get("target_name","") + xuid = sess.get("target_xuid","") + + skip, why = self._should_skip_target(name) + if skip: + self._tell(player, f"§e已跳过:{name} §7({why})") + self._chat_sessions.pop(player, None); return + + ent = self._find_entity_by_name_quick(name) + if ent: + ok, http_status, reason = self._set_state(ent["entity_id"], 1) + if ok: + self._record_ban_time(name, ent.get("user_id",""), ent["entity_id"], expire) + self._update_uid_map(ent.get("user_id",""), ent["entity_id"], name) + self._tell(player, f"§a封禁成功:§e{name} §7(xuid={xuid}) 至 §b{expire}" + (" §7(永久)" if is_perm else "")) + else: + self._tell(player, f"§c封禁失败:§7{name} §8(HTTP={http_status}; 原因={reason})") + else: + self._tell(player, f"§e服务器历史加入列表中未找到目标玩家:§7{name}") + self._chat_sessions.pop(player, None); return + + else: + if st == "ask_ban_time_history": + xuid_hit = sess.get("history_xuid","") + sample_name = sess.get("history_name","") + record = self._safe_read_json(self.cfg.get("orion_player_record_path") or "") + x2d = self._orion_build_indices(record)[1] + devs = x2d.get(xuid_hit, set()) or set() + self._add_device_bans_for_name_xuids(sample_name, {xuid_hit}, x2d, expire_dt) + self._apply_device_bans_to_online() + self._tell(player, f"§a已封锁设备 §e{len(devs)} §7个(至 §b{expire}§7)。" + (" §7(永久)" if is_perm else "")) + self._tell(player, "§7后续发现这些设备登录时将自动黑名单拉黑") + self._chat_sessions.pop(player, None); return + else: + nm = sess.get("orion_pick_nm","") + xuids = sess.get("orion_pick_xuids", set()) + record = self._safe_read_json(self.cfg.get("orion_player_record_path") or "") + x2d = self._orion_build_indices(record)[1] + devs = set() + for x in xuids: + devs |= (x2d.get(x, set()) or set()) + self._add_device_bans_for_name_xuids(nm, xuids, x2d, expire_dt) + self._apply_device_bans_to_online() + self._tell(player, f"§a已封锁设备 §e{len(devs)} §7个(按名字 §e{nm} §7的 xuid 聚合,至 §b{expire}§7)。" + (" §7(永久)" if is_perm else "")) + self._tell(player, "§7后续发现这些设备登录时将自动黑名单拉黑") + self._chat_sessions.pop(player, None); return + + def _chat_unban_flow(self, player, text, sess): + st = sess.get("state") + if st == "menu": + if text not in ("1","2","3"): + self._tell(player, "§c请输入 1 / 2 / 3 进行选择,或输入 . 退出") + return + if text == "1": + ents = self._search_list(player_list_type=2, name_frag=None, first_page_only=True) + if not ents: + self._tell(player, "§e未在黑名单列表找到任何玩家。") + self._chat_sessions.pop(player, None); return + sess["ents"] = ents + sess["page"] = 1 + sess["state"] = "pick_unban_first" + self._chat_render_unban_page(player, sess); return + elif text == "2": + sess["state"] = "ask_unban_frag" + self._tell(player, "§b请输入名称片段(搜索黑名单):§7输入 . 退出"); return + else: + m = self._read_device_ban_file() + if not m: + self._tell(player, "§e当前没有设备封锁记录。"); self._chat_sessions.pop(player, None); return + items = [] + for dev, info in m.items(): + items.append((dev, info.get("expire"), sorted(list(info.get("names") or [])))) + items.sort(key=lambda t: t[1]) + sess["dev_items"] = items + sess["page"] = 1 + sess["state"] = "pick_dev_unban" + self._chat_render_dev_items(player, sess); return + + if st == "pick_unban_first": + if text in ("-","+"): + self._chat_turn_page(player, sess, key="ents", per=PAGE_LEN_VIEW, inc=(1 if text=="+" else -1)); return + if text in (".","。"): + self._chat_sessions.pop(player, None); return + try: + idx = int(text) + cur = self._chat_current_page_list(sess, key="ents", per=PAGE_LEN_VIEW) + if 1 <= idx <= len(cur): + ent = cur[idx-1]; name=ent.get("name","") + ok, http_status, reason = self._set_state(ent["entity_id"], 0) + if ok: + self._tell(player, f"§a解除拉黑成功:§e{name} §7(entity_id={ent['entity_id']})") + self._remove_from_ban_time_file(ent["entity_id"]) + self._remove_device_bans_for_name(name) + else: + self._tell(player, f"§c解除拉黑失败:§7{name} §8(HTTP={http_status}; 原因={reason})") + else: + self._tell(player, "§c序号超出范围。") + except: + self._tell(player, "§c请输入数字序号;或 - / + 翻页;或 . 退出") + self._chat_sessions.pop(player, None); return + + if st == "ask_unban_frag": + frag = text.strip().lower() + if frag in ("",".","。"): + self._chat_sessions.pop(player, None); return + ents = self._search_list(player_list_type=2, name_frag=frag, first_page_only=False) + if not ents: + self._tell(player, "§e未找到匹配玩家。") + self._chat_sessions.pop(player, None); return + sess["ents"] = ents + sess["page"] = 1 + sess["state"] = "pick_unban_search" + self._chat_render_unban_page(player, sess, frag=frag); return + + if st == "pick_unban_search": + if text in ("-","+"): + self._chat_turn_page(player, sess, key="ents", per=PAGE_LEN_VIEW, inc=(1 if text=="+" else -1)); return + if text in (".","。"): + self._chat_sessions.pop(player, None); return + try: + idx = int(text) + cur = self._chat_current_page_list(sess, key="ents", per=PAGE_LEN_VIEW) + if 1 <= idx <= len(cur): + ent = cur[idx-1]; name=ent.get("name","") + ok, http_status, reason = self._set_state(ent["entity_id"], 0) + if ok: + self._tell(player, f"§a解除拉黑成功:§e{name} §7(entity_id={ent['entity_id']})") + self._remove_from_ban_time_file(ent["entity_id"]) + self._remove_device_bans_for_name(name) + else: + self._tell(player, f"§c解除拉黑失败:§7{name} §8(HTTP={http_status}; 原因={reason})") + else: + self._tell(player, "§c序号超出范围。") + except: + self._tell(player, "§c请输入数字序号;或 - / + 翻页;或 . 退出") + self._chat_sessions.pop(player, None); return + + if st == "pick_dev_unban": + if text in ("-","+"): + self._chat_turn_page(player, sess, key="dev_items", per=PAGE_LEN_VIEW, inc=(1 if text=="+" else -1)); return + if text in (".","。"): + self._chat_sessions.pop(player, None); return + try: + idx = int(text) + cur = self._chat_current_page_list(sess, key="dev_items", per=PAGE_LEN_VIEW) + if 1 <= idx <= len(cur): + dev = cur[idx-1][0] + m = self._read_device_ban_file() + if dev in m: + del m[dev] + self._write_device_ban_file(m) + self._tell(player, f"§a已删除设备封锁记录:§e{dev}") + else: + self._tell(player, "§e该设备记录已不存在。") + else: + self._tell(player, "§c序号超出范围。") + except: + self._tell(player, "§c请输入数字序号;或 - / + 翻页;或 . 退出") + self._chat_sessions.pop(player, None); return + + def _chat_online_total_pages(self, sess): + names = sess.get("names") or [] + total_pages, _, _ = self._paginate(names, 1, PAGE_LEN_VIEW) + return total_pages + + def _chat_online_current_page_items(self, sess): + names = sess.get("names") or [] + page = sess.get("page", 1) + total_pages, start, end = self._paginate(names, page, PAGE_LEN_VIEW) + return names[start:end] + + def _chat_render_online_list(self, player, sess): + page = sess.get("page", 1) + names = sess.get("names") or [] + total_pages, start, end = self._paginate(names, page, PAGE_LEN_VIEW) + if total_pages == 0: + self._tell(player, "§7(空)") + return + lines = [f"§b—— 在线名单 —— §7第 §e{page}§7/§e{total_pages} §7页"] + cur = names[start:end] + for i, nm in enumerate(cur, start=1): + st = self._query_local_ban_status(nm) + lines.append(f"§7[{i}] §f{nm} §8- " + (f"§c已封禁,至 §7{st}" if st else "§a未封禁")) + lines.append("§7提示:输入数字选择;输入 §e- §7上一页;输入 §e+ §7下一页;输入 §e. §7退出") + self._tell(player, "\n".join(lines)) + + def _chat_current_page_list(self, sess, key, per): + items = sess.get(key) or [] + page = sess.get("page", 1) + total_pages, start, end = self._paginate(items, page, per) + return items[start:end] + + def _chat_turn_page(self, player, sess, key, per, inc): + items = sess.get(key) or [] + page = sess.get("page", 1) + inc + total_pages, _, _ = self._paginate(items, page, per) + if total_pages == 0: + self._tell(player, "§7(空)") + return + page = max(1, min(page, total_pages)) + sess["page"] = page + if key == "xuid_items": + self._chat_render_xuid_items(player, sess, "") + elif key == "orion_items": + self._chat_render_orion_items(player, sess, "") + elif key == "dev_items": + self._chat_render_dev_items(player, sess) + else: + self._chat_render_unban_page(player, sess) + + def _chat_render_xuid_items(self, player, sess, frag): + items = sess.get("xuid_items") or [] + page = sess.get("page", 1) + total_pages, start, end = self._paginate(items, page, PAGE_LEN_VIEW) + if total_pages == 0: + self._tell(player, "§7(空)"); return + lines = [f"§b—— XUID 名单匹配(名字) —— §7第 §e{page}§7/§e{total_pages} §7页"] + cur = items[start:end] + for i, (nm, xuid) in enumerate(cur, start=1): + st = self._query_local_ban_status(nm) + lines.append(f"§7[{i}] §f{nm} §8| xuid=§7{xuid} §8- " + (f"§c已封禁,至 §7{st}" if st else "§a未封禁")) + lines.append("§7提示:输入数字选择;输入 §e- §7上一页;输入 §e+ §7下一页;输入 §e. §7退出;或直接输入新片段重新搜索") + self._tell(player, "\n".join(lines)) + + def _chat_render_orion_items(self, player, sess, frag): + items = sess.get("orion_items") or [] + page = sess.get("page", 1) + total_pages, start, end = self._paginate(items, page, PAGE_LEN_VIEW) + if total_pages == 0: + self._tell(player, "§7(空)"); return + lines = [f"§b—— 猎户座匹配(名字) —— §7第 §e{page}§7/§e{total_pages} §7页"] + cur = items[start:end] + for i, (nm, xuids, devs) in enumerate(cur, start=1): + st = self._query_local_ban_status(nm) + lines.append(f"§7[{i}] §f{nm} §8| xuid数=§7{len(xuids)} §8| 设备数=§7{len(devs)} §8- " + (f"§c已封禁,至 §7{st}" if st else "§a未封禁")) + lines.append("§7提示:输入数字选择;输入 §e- §7上一页;输入 §e+ §7下一页;输入 §e. §7退出;或直接输入新片段重新搜索") + self._tell(player, "\n".join(lines)) + + def _chat_render_dev_items(self, player, sess): + page = sess.get("page", 1) + items = sess.get("dev_items") or [] + total_pages, start, end = self._paginate(items, page, PAGE_LEN_VIEW) + if total_pages == 0: + self._tell(player, "§7(空)") + return + lines = [f"§b—— 设备封锁记录 —— §7第 §e{page}§7/§e{total_pages} §7页"] + cur = items[start:end] + for i, (dev, exp, names) in enumerate(cur, start=1): + nm = "|".join(names)[:40] + lines.append(f"§7[{i}] §fdev={dev} §8| §7到期=§f{_fmt_bj(exp)} §8| §7关联名=§f{nm}") + lines.append("§7提示:输入数字删除该条设备记录;输入 §e- §7上一页;输入 §e+ §7下一页;输入 §e. §7退出") + self._tell(player, "\n".join(lines)) + + def _chat_render_unban_page(self, player, sess, frag=""): + ents = sess.get("ents") or [] + page = sess.get("page", 1) + total_pages, start, end = self._paginate(ents, page, PAGE_LEN_VIEW) + if total_pages == 0: + self._tell(player, "§7(空)"); return + title = "黑名单(第1页)" if not frag else f"黑名单(匹配:{frag})" + lines = [f"§b—— {title} —— §7第 §e{page}§7/§e{total_pages} §7页"] + cur = ents[start:end] + for i, e in enumerate(cur, start=1): + nm=e.get("name","?"); uid=e.get("user_id","?"); eid=e.get("entity_id","?") + lines.append(f"§7[{i}] §f{nm} §8| uid=§7{uid} §8| entity_id=§7{eid}") + if not frag: + lines.append("§7提示:输入数字选择;输入 §e- §7上一页;输入 §e+ §7下一页;输入 §e. §7退出") + else: + lines.append("§7提示:输入数字选择;输入 §e- §7上一页;输入 §e+ §7下一页;输入 §e. §7退出;或直接输入新片段重新搜索") + self._tell(player, "\n".join(lines)) + + def _on_playerlist_early(self, pk): + try: + if not self.cfg.get("link_orion_record", False): + return False + action = pk.get("ActionType", None) + is_join = (action == 0) or (action is False) or (str(action).lower() == "add") + if not is_join: + return False + entries = pk.get("Entries", []) or [] + if not entries: + return False + + orion_path = self.cfg.get("orion_player_record_path") or "" + if not orion_path or (not os.path.exists(orion_path)): + return False + + record = self._safe_read_json(orion_path) + name_to_pairs, xuid_to_devices, xuid_to_names, device_to_names = self._orion_build_indices(record) + + dev_bans = self._read_device_ban_file() + if not dev_bans: + return False + now_bj = _now_beijing() + banned_devs = {dev for dev, info in dev_bans.items() if info["expire"] > now_bj} + + for entry in entries: + login_name = entry.get("Username") or entry.get("Name") or entry.get("PlayerName") or "" + if not login_name: + continue + skip, _ = self._should_skip_target(login_name) + if skip: + continue + + pairs = name_to_pairs.get(login_name.lower(), set()) + xuids = {x for (x, _d) in pairs} + if not xuids: + continue + devices = set() + for x in xuids: + devices |= (xuid_to_devices.get(x, set()) or set()) + hit = [dev for dev in devices if dev in banned_devs] + if not hit: + continue + expire_dt = max(dev_bans[dev]["expire"] for dev in hit) + if (expire_dt - now_bj).total_seconds() <= 1: + continue + ent = self._find_entity_by_name_quick(login_name) + if not ent or not ent.get("entity_id"): + continue + ok, http_status, reason = self._set_state(ent["entity_id"], 1) + if ok: + self._record_ban_time(login_name, ent.get("user_id",""), ent["entity_id"], _fmt_bj(expire_dt)) + self._update_uid_map(ent.get("user_id",""), ent["entity_id"], login_name) + fmts.print_suc(f"[设备封锁] {login_name}(发现设备 {len(hit)} 个,至 { _fmt_bj(expire_dt) })") + else: + fmts.print_war(f"[设备封锁失败] {login_name}(HTTP={http_status};原因={reason})") + except Exception as e: + fmts.print_war(f"[设备封锁异常] {e}") + return False + +entry = plugin_entry(ServerBlacklistGateway, "服务器黑名单封禁系统") From 9c06bb3b795972ce49f9ddeb929f351d7fac9f36 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 27 Oct 2025 19:50:53 +0800 Subject: [PATCH 02/33] Add files via upload --- .../README.md" | 39 +++++++++++++++++++ .../datas.json" | 12 ++++++ 2 files changed, 51 insertions(+) create mode 100644 "\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" create mode 100644 "\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" diff --git "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" new file mode 100644 index 00000000..295340d2 --- /dev/null +++ "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" @@ -0,0 +1,39 @@ +# 通过nv1拉黑式封禁玩家,同时是一个前置插件 + +## 配置解释 +[ 1 ] 请输入19位服务器id +[ 2 ] 请输入API-key +[ 3 ] 使用nv1查询历史加入页面一页最大是59名玩家,“历史加入翻页最大数”默认是10即查找最新点击加入服务器的590名玩家 +[ 4 ] 控制台命令默认是“blban/blunban” +[ 5 ] 游戏内提示词默认是“.blban/.blunban” +[ 6 ] 默认管理员可以在游戏内使用 +[ 7 ] 可以添加能使用的玩家 +[ 8 ] 可以添加白名单玩家 +[ 9 ] 默认在线OP不进行反制 +[ 10 ] 如果其他插件没有传封禁秒数给黑名单封禁,默认封禁30分钟 +[ 11 ] 默认读取猎户座的数据文件,用于封锁设备号 + +## 使用方法 +[ 1 ] 手动封禁 +1.控制台输入“blban”,游戏内输入“.blban” +2.选项1:通过服务器在线玩家进行查询拉黑封禁 +3.选项2:读取前置-玩家xuid获取插件的数据文件对历史进服玩家模糊搜索进行查询拉黑封禁 +4.选项3:读取猎户座数据文件,模糊搜索有设备号的历史进服玩家,封锁设备号进行拉黑 +[ 2 ] 手动解封 +1.控制台输入“blunban”,游戏内输入“.blunban” +2.选项1:使用nv1查询黑名单列表第一页(前59名玩家)进行解除拉黑 +3.选项2:使用nv1在服务器黑名单列表模糊查询玩家进行解除拉黑 +4.选项3:删除设备封锁记录,解封设备号 +[ 3 ] 作为前置插件对接其他插件 +1.获取: +```python +def on_def(self): + self.ban = self.GetPluginAPI("服务器黑名单封禁系统") +``` +2.调用: +```python +# 封禁: +self.ban.ban(player_or_name, 秒数) +# 解封: +self.ban.unban(player_or_name) +``` \ No newline at end of file diff --git "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" new file mode 100644 index 00000000..12ffc0bd --- /dev/null +++ "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" @@ -0,0 +1,12 @@ +{ + "plugin-id": "服务器黑名单封禁系统", + "author": "丸山彩", + "version": "0.5.4", + "description": "通过nv1拉黑式封禁玩家,同时是一个前置插件", + "plugin-type": "classic", + "pre-plugins": { + "前置_聊天栏菜单": "0.4.1", + "前置_玩家XUID获取": "0.0.7", + "『Orion System』违规与作弊行为综合反制系统": "0.3.9" + } +} \ No newline at end of file From 43eaa3d4dd4e7fe09b9a14a76eba17b9b3b2fe23 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 27 Oct 2025 20:17:16 +0800 Subject: [PATCH 03/33] Update datas.json --- .../datas.json" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" index 12ffc0bd..4fec763e 100644 --- "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" +++ "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" @@ -9,4 +9,4 @@ "前置_玩家XUID获取": "0.0.7", "『Orion System』违规与作弊行为综合反制系统": "0.3.9" } -} \ No newline at end of file +} From 961dfab823931f06085eb15a74a2429917a4a0d8 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 27 Oct 2025 20:17:51 +0800 Subject: [PATCH 04/33] Update README.md --- .../README.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" index 295340d2..a3ec1b43 100644 --- "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" +++ "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/README.md" @@ -36,4 +36,4 @@ def on_def(self): self.ban.ban(player_or_name, 秒数) # 解封: self.ban.unban(player_or_name) -``` \ No newline at end of file +``` From fc308e64360c4568a8192722a50eb972767c5d6a Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Tue, 28 Oct 2025 18:10:21 +0800 Subject: [PATCH 05/33] Update datas.json --- .../datas.json" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" index 4fec763e..d74f435d 100644 --- "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" +++ "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" @@ -5,8 +5,8 @@ "description": "通过nv1拉黑式封禁玩家,同时是一个前置插件", "plugin-type": "classic", "pre-plugins": { - "前置_聊天栏菜单": "0.4.1", - "前置_玩家XUID获取": "0.0.7", + "聊天栏菜单": "0.4.1", + "玩家XUID获取": "0.0.7", "『Orion System』违规与作弊行为综合反制系统": "0.3.9" } } From 8c0286e0f67c9ce18445771d658ed80fde1fdedc Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Tue, 28 Oct 2025 18:42:05 +0800 Subject: [PATCH 06/33] Update datas.json --- .../datas.json" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" index d74f435d..b6e5c07a 100644 --- "a/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" +++ "b/\346\234\215\345\212\241\345\231\250\351\273\221\345\220\215\345\215\225\345\260\201\347\246\201\347\263\273\347\273\237/datas.json" @@ -6,7 +6,7 @@ "plugin-type": "classic", "pre-plugins": { "聊天栏菜单": "0.4.1", - "玩家XUID获取": "0.0.7", + "XUID获取": "0.0.7", "『Orion System』违规与作弊行为综合反制系统": "0.3.9" } } From e8b29b1656bd0e91d4cf2747b343ba06c9fa78f2 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Sat, 20 Dec 2025 05:55:46 +0800 Subject: [PATCH 07/33] Create __init__.py --- .../__init__.py" | 602 ++++++++++++++++++ 1 file changed, 602 insertions(+) create mode 100644 "\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" new file mode 100644 index 00000000..0483fd62 --- /dev/null +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" @@ -0,0 +1,602 @@ +import os +import json +import time +import threading +import base64 +from typing import Any, Dict, Tuple, Optional + +import requests + +from tooldelta.plugin_load.classic_plugin import Plugin, plugin_entry +from tooldelta.constants import PacketIDS +from tooldelta import fmts + + +CONFIG_GROUP_VERSION = "0.2.3" +PAGE_LEN_API = 59 + +def _get(d: Dict[str, Any], *keys, default=None): + for k in keys: + if k in d and d[k] is not None: + return d[k] + lower_map = {str(k).lower(): k for k in d.keys()} + for k in keys: + kk = lower_map.get(str(k).lower()) + if kk is not None and d.get(kk) is not None: + return d.get(kk) + return default + + +def _b64_to_bytes(v) -> bytes: + if v is None: + return b"" + if isinstance(v, bytes): + return v + if isinstance(v, str): + return base64.b64decode(v) + raise TypeError(f"unsupported type for b64 data: {type(v)}") + + +class SkinAbnormalStandalone(Plugin): + name = "皮肤异常拉黑丨锁服反制" + author = "丸山彩" + version = (0, 2, 3) + + _ALLOW_CAPE = False + _ANIM_COUNTS = {0, 1} + _SKIN_SIZES = {64, 128, 256, 512} + _ANIM_W = {0, 32} + _ANIM_H = {0, 64} + _REQUIRE_EMPTY_ANIMDATA = True + + _COOLDOWN_SEC = 60 + + _URL_SEARCH_RENTALGAME = "https://nv1.nethard.pro/api/open-api/rentalGame/searchRentalGame" + _URL_GET_PLAYER_LIST = "https://nv1.nethard.pro/api/open-api/rentalGame/getRentalGamePlayerList" + _URL_SET_PLAYER_STATE = "https://nv1.nethard.pro/api/open-api/rentalGame/setRentalGamePlayerState" + + _URL_GET_SAUTH = "https://nv1.nethard.pro/api/open-api/user/getLoginUserSAuth" + _URL_VITALITY_REFRESH = "https://nv1.nethard.pro/api/vitalityApi/refresh" + + _X_CALLER = "gameaccount" + _COOKIE = "locale=en-us" + _TIMEOUT_SEC = 10 + + def __init__(self, frame): + super().__init__(frame) + + base = os.path.dirname(os.path.dirname(self.data_path)) + self.root_dir = base + self.main_cfg_path = os.path.join(self.root_dir, "ToolDelta基本配置.json") + + self.config_dir = os.path.join(self.root_dir, "插件配置文件") + self.config_path = os.path.join(self.config_dir, f"{self.name}.json") + + self.cn = self._load_config_items() + self.cfg = self._cn_to_internal(self.cn) + + self._runtime_enabled = True + self._cooldown_ts: Dict[str, float] = {} + self._lock = threading.Lock() + + self._sdkuid = "" + self._vit_stop = threading.Event() + self._vit_thread: Optional[threading.Thread] = None + + self._autofill_lock = threading.Lock() + self._autofill_inflight = False + + self.ListenPreload(self._on_preload) + self.ListenFrameExit(self._on_frame_exit) + self.ListenPacket(PacketIDS.IDPlayerList, self._on_playerlist) + + # 配置 + + def _default_items(self): + return { + "服务器ID": "填好API-Key会自动填", + "NV1-OpenAPI-API-Key": "", + "活力刷新间隔秒": 2400, + "停止命令": "skinbanstop", + "开启命令": "skinbanstart", + } + + def _load_config_items(self): + os.makedirs(self.config_dir, exist_ok=True) + if not os.path.exists(self.config_path): + items = self._default_items() + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump({"配置版本": CONFIG_GROUP_VERSION, "配置项": items}, f, ensure_ascii=False, indent=2) + return items + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + wrapped = json.load(f) + except Exception: + wrapped = {} + + items = wrapped.get("配置项", {}) if isinstance(wrapped, dict) else {} + if not isinstance(items, dict): + items = {} + + defaults = self._default_items() + for k, v in defaults.items(): + items.setdefault(k, v) + + try: + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump( + {"配置版本": wrapped.get("配置版本", CONFIG_GROUP_VERSION), "配置项": items}, + f, + ensure_ascii=False, + indent=2, + ) + except Exception: + pass + + return items + + def _save_config_items(self, items: dict): + wrapped = {"配置版本": CONFIG_GROUP_VERSION, "配置项": items} + try: + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(wrapped, f, ensure_ascii=False, indent=2) + except Exception: + pass + + def _cn_to_internal(self, cn: dict): + return { + "server_entity_id": str(cn.get("服务器ID", "") or "").strip(), + "api_key": str(cn.get("NV1-OpenAPI-API-Key", "") or "").strip(), + "vitality_interval_sec": int(cn.get("活力刷新间隔秒", 2400) or 2400), + "cmd_stop": str(cn.get("停止命令", "skinbanstop") or "skinbanstop").strip(), + "cmd_start": str(cn.get("开启命令", "skinbanstart") or "skinbanstart").strip(), + } + + def _on_preload(self): + try: + self.frame.add_console_cmd_trigger([self.cfg["cmd_stop"]], None, "停止:禁用检测并停止活力刷新", self._cmd_stop) + self.frame.add_console_cmd_trigger([self.cfg["cmd_start"]], None, "开启:恢复检测并启动活力刷新", self._cmd_start) + except Exception: + pass + + if not self.cfg["api_key"]: + try: + fmts.print_war(f"[皮肤异常拉黑] 未填写 NV1-OpenAPI-API-Key,将不会拉黑/不会刷新活力") + fmts.print_inf(f"[皮肤异常拉黑] 请编辑:{self.config_path}") + except Exception: + pass + return False + + self._kick_autofill_server_entity_id() + + self._ensure_vitality_loop_started() + return False + + def _on_frame_exit(self, evt): + try: + self._runtime_enabled = False + self._stop_vitality_loop(join_sec=1.0) + except Exception: + pass + return False + + def _cmd_stop(self, args: list[str]): + self._runtime_enabled = False + self._stop_vitality_loop(join_sec=1.0) + try: + fmts.print_suc(f"[皮肤异常拉黑] 已停止(检测关闭 + 活力线程停止),服主可以上线") + except Exception: + pass + + def _cmd_start(self, args: list[str]): + self._runtime_enabled = True + if self.cfg["api_key"]: + self._ensure_vitality_loop_started() + self._kick_autofill_server_entity_id() + try: + fmts.print_suc(f"[皮肤异常拉黑] 已开启(检测恢复 + 活力线程运行),服主请下线") + except Exception: + pass + + def _ensure_vitality_loop_started(self): + if not self.cfg["api_key"]: + return + interval = int(self.cfg.get("vitality_interval_sec") or 0) + if interval <= 0: + return + if self._vit_thread and self._vit_thread.is_alive(): + return + + self._vit_stop.clear() + self._vit_thread = threading.Thread(target=self._vitality_loop, daemon=True) + self._vit_thread.start() + + def _stop_vitality_loop(self, join_sec: float = 0.0): + try: + self._vit_stop.set() + except Exception: + return + t = self._vit_thread + if t and join_sec and t.is_alive(): + try: + t.join(join_sec) + except Exception: + pass + + def _vitality_loop(self): + self._vitality_once() + while not self._vit_stop.is_set(): + interval = int(self.cfg.get("vitality_interval_sec") or 2400) + if self._vit_stop.wait(timeout=max(1, interval)): + return + self._vitality_once() + + def _vitality_once(self): + try: + sdkuid = self._sdkuid or self._get_sdkuid_from_sauth() + if not sdkuid: + return + self._call_vitality_refresh(sdkuid) + except Exception as e: + try: + fmts.print_war(f"[皮肤异常拉黑] 活力刷新失败:{e}") + except Exception: + pass + return + + def _get_sdkuid_from_sauth(self) -> str: + headers = { + "authorization": self.cfg["api_key"], + "X-Caller": self._X_CALLER, + "Cookie": self._COOKIE, + } + r = requests.get(self._URL_GET_SAUTH, headers=headers, timeout=self._TIMEOUT_SEC) + obj = r.json() if r is not None else {} + if not (isinstance(obj, dict) and obj.get("success") in (True, "true", "True")): + return "" + data = obj.get("data") or {} + sauth = (data.get("sauth") or {}) if isinstance(data, dict) else {} + sdkuid = str(sauth.get("sdkuid") or "").strip() + if not sdkuid: + return "" + self._sdkuid = sdkuid + try: + fmts.print_suc(f"[皮肤异常拉黑] 已获取sdkuid:{sdkuid}") + except Exception: + pass + return sdkuid + + def _call_vitality_refresh(self, sdkuid: str): + payload = {"sdkuid": sdkuid} + r = requests.post(self._URL_VITALITY_REFRESH, json=payload, timeout=self._TIMEOUT_SEC) + obj = r.json() if r is not None else {} + if not (isinstance(obj, dict) and obj.get("success") in (True, "true", "True")): + raise RuntimeError(f"vitality refresh failed: {obj!r}") + try: + fmts.print_suc(f"[皮肤异常拉黑] 已刷新活力") + except Exception: + pass + + def _load_server_id(self): + if not os.path.exists(self.main_cfg_path): + try: + fmts.print_war(f"未找到配置文件:{self.main_cfg_path}") + except Exception: + pass + return None + + try: + with open(self.main_cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception as e: + try: + fmts.print_war(f"读取 ToolDelta基本配置.json 失败:{e}") + except Exception: + pass + return None + + try: + block = cfg.get("NeOmega接入点启动模式", {}) + server_id = block.get("服务器号", None) + except Exception as e: + try: + fmts.print_war(f"解析服务器号时出错:{e}") + except Exception: + pass + return None + + if server_id is None: + try: + fmts.print_war(f"配置文件中未找到服务器号") + except Exception: + pass + return None + + s = str(server_id).strip() + if not (s.isdigit() and 4 <= len(s) <= 8): + try: + fmts.print_war(f"服务器号{server_id}不是4-8位数字") + except Exception: + pass + return None + return s + + def _kick_autofill_server_entity_id(self): + with self._autofill_lock: + if self._autofill_inflight: + return + self._autofill_inflight = True + threading.Thread(target=self._autofill_server_entity_id_worker, daemon=True).start() + + def _autofill_server_entity_id_worker(self): + try: + cur = (self.cfg.get("server_entity_id") or "").strip() + if cur and cur != "填好API-Key会自动填": + return + + server_code = self._load_server_id() + if not server_code: + return + + eid = self._query_server_entity_id(server_code) + if not eid: + return + + self.cn["服务器ID"] = str(eid) + self._save_config_items(self.cn) + self.cfg["server_entity_id"] = str(eid) + + try: + fmts.print_suc(f"[皮肤异常拉黑] 已填写服务器ID:{eid}") + except Exception: + pass + finally: + with self._autofill_lock: + self._autofill_inflight = False + + def _query_server_entity_id(self, server_code: str) -> str: + headers = { + "Content-Type": "application/json", + "X-Caller": self._X_CALLER, + "Cookie": self._COOKIE, + "authorization": self.cfg["api_key"], + } + payload = {"rentalGameCode": server_code, "offset": "0"} + try: + resp = requests.post(self._URL_SEARCH_RENTALGAME, json=payload, headers=headers, timeout=self._TIMEOUT_SEC) + except Exception: + return "" + if resp.status_code != 200: + return "" + try: + data = resp.json() + except Exception: + return "" + data_block = data.get("data") or {} + entities = data_block.get("entities") or [] + if not entities: + return "" + ent0 = entities[0] if isinstance(entities[0], dict) else {} + eid = ent0.get("entity_id", None) + return "" if eid is None else str(eid) + + def _headers(self): + return { + "Content-Type": "application/json", + "authorization": self.cfg["api_key"], + "X-Caller": self._X_CALLER, + "Cookie": self._COOKIE, + } + + def _post_json(self, url: str, payload: dict): + r = requests.post( + url, + data=json.dumps(payload, ensure_ascii=False), + headers=self._headers(), + timeout=self._TIMEOUT_SEC, + ) + if not (200 <= int(r.status_code) < 300): + return False, int(r.status_code), {} + try: + obj = r.json() + except Exception: + obj = {} + return True, int(r.status_code), obj + + def _query_history_page(self, offset: int, length: int): + payload = { + "serverID": str(self.cfg.get("server_entity_id") or ""), + "length": int(length), + "offset": int(offset), + "playerListType": 1, + } + ok, _, obj = self._post_json(self._URL_GET_PLAYER_LIST, payload) + if not ok: + return [] + if not (isinstance(obj, dict) and obj.get("success") in (True, "true", "True")): + return [] + data = obj.get("data") or {} + try: + if int(data.get("code", -1)) != 0: + return [] + except Exception: + return [] + ents = data.get("entities") or [] + return ents if isinstance(ents, list) else [] + + def _find_entity_id_in_history(self, name: str) -> str: + target = name.strip().lower() + if not target: + return "" + seen_first = set() + + for page in range(10): + ents = self._query_history_page(offset=page * PAGE_LEN_API, length=PAGE_LEN_API) + if not ents: + return "" + + first_id = str(_get(ents[0], "entity_id", "_id", default=page * PAGE_LEN_API) or "") + if first_id in seen_first and page > 0: + return "" + seen_first.add(first_id) + + for e in ents: + if not isinstance(e, dict): + continue + nm = str(_get(e, "name", "user_name", default="") or "").strip() + if nm and nm.lower() == target: + eid = _get(e, "entity_id", "_id", default=None) + return "" if eid is None else str(eid) + + if len(ents) < PAGE_LEN_API: + return "" + + return "" + + def _set_state(self, player_entity_id: str, state: int): + payload = {"entityID": int(player_entity_id), "PlayerState": int(state)} + ok, _, obj = self._post_json(self._URL_SET_PLAYER_STATE, payload) + if not ok: + return False + if isinstance(obj, dict) and obj.get("success") in (False, "false", "False"): + return False + return True + + def _cooldown_hit(self, name: str) -> bool: + now = time.time() + key = name.lower() + with self._lock: + last = self._cooldown_ts.get(key, 0.0) + if now - last < self._COOLDOWN_SEC: + return True + self._cooldown_ts[key] = now + return False + + def _on_playerlist(self, pk: Dict[str, Any]): + if not self._runtime_enabled: + return False + if not self.cfg["api_key"]: + return False + + sid = (self.cfg.get("server_entity_id") or "").strip() + if not sid or sid == "填好API-Key会自动填": + self._kick_autofill_server_entity_id() + return False + + try: + action = _get(pk, "ActionType", "actionType", default=None) + if action is None or int(action) != 0: + return False + except Exception: + return False + + entries = _get(pk, "Entries", "entries", default=[]) + if not isinstance(entries, list) or not entries: + return False + + for entry in entries: + if not isinstance(entry, dict): + continue + + name = str(_get(entry, "Username", "Name", "PlayerName", default="") or "").strip() + if not name: + continue + if self._cooldown_hit(name): + continue + + skin = _get(entry, "Skin", "skin", default=None) + skin_dict = skin if isinstance(skin, dict) else entry + + abnormal, why = self._is_abnormal_skin_like_orion1_strict(skin_dict) + if not abnormal: + continue + + reason = f"皮肤数据异常(反制1):{why}" + threading.Thread(target=self._ban_once, args=(name, reason), daemon=True).start() + + return False + + def _ban_once(self, name: str, reason: str): + try: + entity_id = "" + for wait_sec in (0.0, 0.8, 2.0, 4.0): + if wait_sec: + time.sleep(wait_sec) + entity_id = self._find_entity_id_in_history(name) + if entity_id: + break + + if not entity_id: + try: + fmts.print_war(f"[皮肤异常拉黑] 拉黑失败:历史进服玩家列表中找不到该玩家:{name}") + except Exception: + pass + return + + if self._set_state(entity_id, 1): + try: + fmts.print_suc(f"[皮肤异常拉黑] 已拉黑:{name}({reason})") + except Exception: + pass + except Exception: + pass + + # 皮肤检查 + + def _is_abnormal_skin_like_orion1_strict(self, skin: Dict[str, Any]) -> Tuple[bool, str]: + try: + SkinImageWidth = int(_get(skin, "SkinImageWidth", default=0) or 0) + SkinImageHeight = int(_get(skin, "SkinImageHeight", default=0) or 0) + SkinData = _get(skin, "SkinData", default=b"") + + CapeImageWidth = int(_get(skin, "CapeImageWidth", default=0) or 0) + CapeImageHeight = int(_get(skin, "CapeImageHeight", default=0) or 0) + CapeData = _get(skin, "CapeData", default=b"") + + Animations = _get(skin, "Animations", default=[]) + AnimationData = _get(skin, "AnimationData", default=b"") + + if not isinstance(Animations, list): + Animations = [] + + skin_bytes = _b64_to_bytes(SkinData) + skin_len = len(skin_bytes) + + a0 = Animations[0] if (len(Animations) == 1 and isinstance(Animations[0], dict)) else {} + a_w = int(_get(a0, "ImageWidth", default=0) or 0) + a_h = int(_get(a0, "ImageHeight", default=0) or 0) + a_bytes = _b64_to_bytes(_get(a0, "ImageData", default=b"")) + a_len = len(a_bytes) + + if SkinImageWidth * SkinImageHeight * 4 != skin_len: + return True, f"SkinData长度不匹配({SkinImageWidth}x{SkinImageHeight}x4 != {skin_len})" + + if SkinImageWidth not in self._SKIN_SIZES or SkinImageHeight not in self._SKIN_SIZES: + return True, f"皮肤尺寸不允许({SkinImageWidth}x{SkinImageHeight})" + + if not self._ALLOW_CAPE: + cape_bytes = _b64_to_bytes(CapeData) + if CapeImageWidth != 0 or CapeImageHeight != 0 or len(cape_bytes) != 0: + return True, "披风字段不允许" + + if len(Animations) not in self._ANIM_COUNTS: + return True, f"动画数量不允许(len={len(Animations)})" + + if a_w * a_h * 4 != a_len: + return True, f"动画ImageData长度不匹配({a_w}x{a_h}x4 != {a_len})" + + if a_w not in self._ANIM_W or a_h not in self._ANIM_H: + return True, f"动画尺寸不允许({a_w}x{a_h})" + + if self._REQUIRE_EMPTY_ANIMDATA: + ad = _b64_to_bytes(AnimationData) + if len(ad) != 0: + return True, "AnimationData非空" + + return False, "" + except Exception as e: + return True, f"解析异常({e})" + + +entry = plugin_entry(SkinAbnormalStandalone) From a959e308d2d00a636b9fa0692075801602cba54c Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Sat, 20 Dec 2025 05:56:23 +0800 Subject: [PATCH 08/33] Add files via upload --- .../README.md" | 17 +++++++++++++++++ .../datas.json" | 8 ++++++++ 2 files changed, 25 insertions(+) create mode 100644 "\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" create mode 100644 "\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" new file mode 100644 index 00000000..eec7872b --- /dev/null +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" @@ -0,0 +1,17 @@ +# 皮肤异常拉黑丨锁服反制 + +## 功能解释 +将皮肤数据异常玩家拉入租赁服黑名单,防御这类锁服,同时用活力API保护服主账号不被封禁。 + +## 配置解释 +- 活力刷新间隔建议40分钟 +- 停止命令:服主账号需要上线时在控制台输入这个命令 +- 开启命令:服主账号下线后在控制台输入这个命令 +- 服务器ID:自动填写 +- API-Key:需要填写 + +## 使用方法 +- 在 https://nv1.nethard.pro/app/openapi 重置OpenAPI Key并在OpenAPI账号处登录服主账号 +- 生成配置文件后把API-Key填入配置文件,然后重载插件 +- 如果服主账号需要登录上线,请在控制台发送停止命令,默认skinbanstop +- 服主账号下线后请在控制台发送开启命令,默认skinbanstart \ No newline at end of file diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" new file mode 100644 index 00000000..35a1d675 --- /dev/null +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" @@ -0,0 +1,8 @@ +{ + "plugin-id": "皮肤异常拉黑丨锁服反制", + "author": "丸山彩", + "version": "0.2.3", + "description": "将皮肤数据异常玩家拉入租赁服黑名单,防御这类锁服,同时用活力API保护服主账号不被封禁。", + "plugin-type": "classic", + "pre-plugins": {} +} From f014d8195a1d130313ef4fbdf68e52d8399c2904 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Sat, 20 Dec 2025 06:11:17 +0800 Subject: [PATCH 09/33] Update README.md --- .../README.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" index eec7872b..92abd5f6 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" @@ -14,4 +14,4 @@ - 在 https://nv1.nethard.pro/app/openapi 重置OpenAPI Key并在OpenAPI账号处登录服主账号 - 生成配置文件后把API-Key填入配置文件,然后重载插件 - 如果服主账号需要登录上线,请在控制台发送停止命令,默认skinbanstop -- 服主账号下线后请在控制台发送开启命令,默认skinbanstart \ No newline at end of file +- 服主账号下线后请在控制台发送开启命令,默认skinbanstart From fd9dcce7c0ed714234b585930e474e50d6231365 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 13:40:56 +0800 Subject: [PATCH 10/33] Update README.md --- .../README.md" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" index 92abd5f6..b604cc0a 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/README.md" @@ -13,5 +13,5 @@ ## 使用方法 - 在 https://nv1.nethard.pro/app/openapi 重置OpenAPI Key并在OpenAPI账号处登录服主账号 - 生成配置文件后把API-Key填入配置文件,然后重载插件 -- 如果服主账号需要登录上线,请在控制台发送停止命令,默认skinbanstop -- 服主账号下线后请在控制台发送开启命令,默认skinbanstart +- 如果服主账号需要登录上线,请在控制台发送停止命令(默认skinbanstop),否则每隔一段时间账号会被顶出 +- 服主账号下线后请在控制台发送开启命令(默认skinbanstart),以重启功能 From 893ab0cf1d69504594aa081c3df6e04196d9ce4c Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 13:42:49 +0800 Subject: [PATCH 11/33] Update __init__.py --- .../__init__.py" | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" index 0483fd62..6e07064c 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" @@ -40,7 +40,7 @@ def _b64_to_bytes(v) -> bytes: class SkinAbnormalStandalone(Plugin): name = "皮肤异常拉黑丨锁服反制" author = "丸山彩" - version = (0, 2, 3) + version = (0, 2, 4) _ALLOW_CAPE = False _ANIM_COUNTS = {0, 1} @@ -57,6 +57,7 @@ class SkinAbnormalStandalone(Plugin): _URL_GET_SAUTH = "https://nv1.nethard.pro/api/open-api/user/getLoginUserSAuth" _URL_VITALITY_REFRESH = "https://nv1.nethard.pro/api/vitalityApi/refresh" + _URL_POKE_LOGIN = "https://nv1.nethard.pro/api/open-api/user/getLoginUserInfo" _X_CALLER = "gameaccount" _COOKIE = "locale=en-us" @@ -237,7 +238,17 @@ def _vitality_once(self): sdkuid = self._sdkuid or self._get_sdkuid_from_sauth() if not sdkuid: return - self._call_vitality_refresh(sdkuid) + for attempt in range(3): + try: + self._call_vitality_refresh(sdkuid) + return + except Exception as e: + msg = str(e) + if ("vitality refresh failed:" in msg) and ("请先使用一次对应sdkuid的机器人账号" in msg) and attempt < 2: + self._poke_nv1_login_state() + continue + raise + except Exception as e: try: fmts.print_war(f"[皮肤异常拉黑] 活力刷新失败:{e}") @@ -278,6 +289,12 @@ def _call_vitality_refresh(self, sdkuid: str): except Exception: pass + def _poke_nv1_login_state(self): + try: + requests.get(self._URL_POKE_LOGIN, headers=self._headers(), timeout=self._TIMEOUT_SEC) + except Exception: + pass + def _load_server_id(self): if not os.path.exists(self.main_cfg_path): try: From 8fc22451b06ff86cd9600685646dfcd8ef21f6d3 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 13:43:48 +0800 Subject: [PATCH 12/33] Update datas.json --- .../datas.json" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" index 35a1d675..168dcb5e 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/datas.json" @@ -1,7 +1,7 @@ { "plugin-id": "皮肤异常拉黑丨锁服反制", "author": "丸山彩", - "version": "0.2.3", + "version": "0.2.4", "description": "将皮肤数据异常玩家拉入租赁服黑名单,防御这类锁服,同时用活力API保护服主账号不被封禁。", "plugin-type": "classic", "pre-plugins": {} From 3ffa148618f1855d81b3cefd8022b3e014113e12 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 17:06:18 +0800 Subject: [PATCH 13/33] Update __init__.py --- .../__init__.py" | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" index 6e07064c..01b55d3a 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" @@ -512,18 +512,18 @@ def _on_playerlist(self, pk: Dict[str, Any]): if not isinstance(entries, list) or not entries: return False - for entry in entries: - if not isinstance(entry, dict): + for ent in entries: + if not isinstance(ent, dict): continue - name = str(_get(entry, "Username", "Name", "PlayerName", default="") or "").strip() + name = str(_get(ent, "Username", "Name", "PlayerName", default="") or "").strip() if not name: continue if self._cooldown_hit(name): continue - skin = _get(entry, "Skin", "skin", default=None) - skin_dict = skin if isinstance(skin, dict) else entry + skin = _get(ent, "Skin", "skin", default=None) + skin_dict = skin if isinstance(skin, dict) else ent abnormal, why = self._is_abnormal_skin_like_orion1_strict(skin_dict) if not abnormal: From 38d9755662aef7f59308209eda42909ca69753f1 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 17:22:00 +0800 Subject: [PATCH 14/33] Update __init__.py --- .../__init__.py" | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" index 01b55d3a..a0aa904d 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" @@ -244,7 +244,11 @@ def _vitality_once(self): return except Exception as e: msg = str(e) - if ("vitality refresh failed:" in msg) and ("请先使用一次对应sdkuid的机器人账号" in msg) and attempt < 2: + if ( + "vitality refresh failed:" in msg + and "请先使用一次对应sdkuid的机器人账号" in msg + and attempt < 2 + ): self._poke_nv1_login_state() continue raise @@ -291,7 +295,11 @@ def _call_vitality_refresh(self, sdkuid: str): def _poke_nv1_login_state(self): try: - requests.get(self._URL_POKE_LOGIN, headers=self._headers(), timeout=self._TIMEOUT_SEC) + requests.get( + self._URL_POKE_LOGIN, + headers=self._headers(), + timeout=self._TIMEOUT_SEC, + ) except Exception: pass @@ -516,7 +524,16 @@ def _on_playerlist(self, pk: Dict[str, Any]): if not isinstance(ent, dict): continue - name = str(_get(ent, "Username", "Name", "PlayerName", default="") or "").strip() + name = str( + _get( + ent, + "Username", + "Name", + "PlayerName", + default="", + ) + or "" + ).strip() if not name: continue if self._cooldown_hit(name): From 785211a6629bf28cd815b26f51ad82d6ffbc1331 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 17:25:36 +0800 Subject: [PATCH 15/33] Update __init__.py --- .../__init__.py" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" index a0aa904d..0126bd55 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" @@ -252,7 +252,7 @@ def _vitality_once(self): self._poke_nv1_login_state() continue raise - + except Exception as e: try: fmts.print_war(f"[皮肤异常拉黑] 活力刷新失败:{e}") From 74cf1b1ebaa40795bd303217026fdae491e06dfe Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 17:32:05 +0800 Subject: [PATCH 16/33] Update __init__.py --- .../__init__.py" | 1 + 1 file changed, 1 insertion(+) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" index 0126bd55..a08ced9f 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" @@ -294,6 +294,7 @@ def _call_vitality_refresh(self, sdkuid: str): pass def _poke_nv1_login_state(self): + """刷新NV1登录状态。""" try: requests.get( self._URL_POKE_LOGIN, From fe4a7f983af848fa467d655359f0c87746e73be1 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Mon, 22 Dec 2025 17:35:45 +0800 Subject: [PATCH 17/33] Update __init__.py --- .../__init__.py" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" index a08ced9f..84e7bb45 100644 --- "a/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" +++ "b/\347\232\256\350\202\244\345\274\202\345\270\270\346\213\211\351\273\221\344\270\250\351\224\201\346\234\215\345\217\215\345\210\266/__init__.py" @@ -294,7 +294,7 @@ def _call_vitality_refresh(self, sdkuid: str): pass def _poke_nv1_login_state(self): - """刷新NV1登录状态。""" + """刷新NV1登录状态。""" try: requests.get( self._URL_POKE_LOGIN, From d9f0daa56b3aa4604985d3df5eee9a0026c3ee34 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 02:19:18 +0800 Subject: [PATCH 18/33] Create datas.json --- "CPS\346\230\276\347\244\272/datas.json" | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 "CPS\346\230\276\347\244\272/datas.json" diff --git "a/CPS\346\230\276\347\244\272/datas.json" "b/CPS\346\230\276\347\244\272/datas.json" new file mode 100644 index 00000000..dd02627f --- /dev/null +++ "b/CPS\346\230\276\347\244\272/datas.json" @@ -0,0 +1,10 @@ +{ + "plugin-id": "CPS显示", + "author": "丸山彩", + "version": "0.0.1", + "description": "在屏幕上显示CPS。", + "plugin-type": "classic", + "pre-plugins": { + "基本插件功能库": "0.0.12" + } +} From a2f321e40c78c385c2479d8ae06bc0bba09a021b Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 02:19:58 +0800 Subject: [PATCH 19/33] Add files via upload --- "CPS\346\230\276\347\244\272/README.md" | 38 +++ "CPS\346\230\276\347\244\272/__init__.py" | 346 ++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 "CPS\346\230\276\347\244\272/README.md" create mode 100644 "CPS\346\230\276\347\244\272/__init__.py" diff --git "a/CPS\346\230\276\347\244\272/README.md" "b/CPS\346\230\276\347\244\272/README.md" new file mode 100644 index 00000000..c271dec5 --- /dev/null +++ "b/CPS\346\230\276\347\244\272/README.md" @@ -0,0 +1,38 @@ +# CPS显示 + +## 概述 +通过挥手检测机器人周围玩家CPS并显示在玩家屏幕,也可以作为一个前置插件。检测范围限制在机器人附近。这是一个娱乐插件,不适合反制外挂。 + +## 配置 +- 检测周期秒:CPS = 周期内检测次数 / 检测周期 +- 是否显示:是否给玩家发 titleraw 显示 cps +- 显示间隔秒:玩家最短多久刷新一次标题 +- 前置空格:调节CPS显示位置 +- 颜色前缀:设置显示颜色 + +## 使用 +- 安装插件后大概需要重启机器人才能有效检测 +- 作为前置插件: +1.获取: +```python +def on_def(self): + self.cps = self.GetPluginAPI("CPS显示") +``` +2.调用: +```python +# 获取某玩家 cps: +cps_value = self.cps.get_cps("玩家名") + +# 获取全部玩家 cps 快照: +all_cps = self.cps.get_all_cps() + +# 订阅:当 cps 达到阈值触发回调 +def on_high_cps(player_name: str, cps: float): + # 此处执行 + pass + +sub_id = self.cps.subscribe(4.0, on_high_cps, cooldown=1.0) + +# 取消订阅: +self.cps.unsubscribe(sub_id) +``` diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" new file mode 100644 index 00000000..11a47a38 --- /dev/null +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -0,0 +1,346 @@ +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass +from typing import Callable, Deque, Dict, Optional + +from tooldelta import Plugin, plugin_entry, ToolDelta, Print, cfg + +try: + from tooldelta.constants import PacketIDS +except ImportError: + from tooldelta.constants import PacketIDs as PacketIDS + + +@dataclass +class _Subscription: + sub_id: int + threshold: float + cooldown: float + handler: Callable[[str, float], None] + last_fire_by_player: Dict[str, float] + + +class SwingCPSAPI(Plugin): + + name = "CPS显示" + author = "丸山彩" + version = (0, 0, 1) + + def __init__(self, frame: ToolDelta): + super().__init__(frame) + + default_cfg = { + "检测周期秒": 1.0, + "是否显示": True, + "显示间隔秒": 1.0, + "前置空格": " ", + "颜色前缀": "§e", + } + std_cfg = { + "检测周期秒": float, + "是否显示": bool, + "显示间隔秒": float, + "前置空格": str, + "颜色前缀": str, + } + self.config, _ = cfg.get_plugin_config_and_version( + self.name, std_cfg, default_cfg, self.version + ) + + self.funclib = None + + self.rt_to_name: Dict[int, str] = {} + + self._swing_times: Dict[str, Deque[float]] = {} + self._last_cps: Dict[str, float] = {} + self._last_title_ts: Dict[str, float] = {} + + self._last_players_scan_ts: float = 0.0 + + self._next_sub_id = 1 + self._subs: Dict[int, _Subscription] = {} + + self.ListenPreload(self.on_preload) + self.ListenActive(self.on_active) + self.ListenPlayerLeave(self.on_player_leave) + + def on_preload(self): + self.funclib = self.GetPluginAPI("基本插件功能库") + + for attr in ("AddPlayer", "PlayerList"): + pid = getattr(PacketIDS, attr, None) + if pid is not None: + self.ListenPacket(int(pid), self._make_mapping_cb(attr)) + + self.ListenPacket(int(PacketIDS.Animate), self.on_pkt_animate) + + self._clamp_config() + + def on_active(self): + if self.funclib is None: + self.funclib = self.GetPluginAPI("基本插件功能库") + + if self.funclib is None: + raise RuntimeError("缺少前置插件《基本插件功能库》") + + self._refresh_mapping_from_online_players(force=True) + + def on_player_leave(self, player): + try: + name = player.name + except Exception: + return + + self._swing_times.pop(name, None) + self._last_cps.pop(name, None) + self._last_title_ts.pop(name, None) + + for sub in self._subs.values(): + sub.last_fire_by_player.pop(name, None) + + def get_cps(self, player_name: str) -> float: + return float(self._last_cps.get(player_name, 0.0)) + + def get_all_cps(self) -> Dict[str, float]: + return dict(self._last_cps) + + def subscribe( + self, + threshold: float, + handler: Callable[[str, float], None], + cooldown: float = 1.0, + ) -> int: + if threshold <= 0: + threshold = 0.1 + if cooldown < 0: + cooldown = 0.0 + + sub_id = self._next_sub_id + self._next_sub_id += 1 + + self._subs[sub_id] = _Subscription( + sub_id=sub_id, + threshold=float(threshold), + cooldown=float(cooldown), + handler=handler, + last_fire_by_player={}, + ) + return sub_id + + def unsubscribe(self, sub_id: int) -> bool: + return self._subs.pop(int(sub_id), None) is not None + + def _make_mapping_cb(self, pkt_name: str): + def _cb(pkt): + try: + self._update_mapping(pkt_name, pkt) + except Exception: + pass + return False + return _cb + + def _get_int(self, d, keys): + if not isinstance(d, dict): + return None + for k in keys: + v = d.get(k) + if isinstance(v, int): + return v + return None + + def _get_str(self, d, keys): + if not isinstance(d, dict): + return None + for k in keys: + v = d.get(k) + if isinstance(v, str) and v: + return v + return None + + def _update_mapping(self, pkt_name: str, pkt): + if not isinstance(pkt, dict): + return + pn = pkt_name.lower() + + if pn == "addplayer": + rt = self._get_int(pkt, ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"]) + name = self._get_str(pkt, ["Username", "username", "PlayerName", "playername", "Name", "name"]) + if rt is not None and name: + self.rt_to_name[rt] = name + return + + if pn == "playerlist": + for key in ("Records", "records", "Entries", "entries", "players", "Players"): + arr = pkt.get(key) + if isinstance(arr, list): + for it in arr: + if not isinstance(it, dict): + continue + rt = self._get_int(it, ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"]) + name = self._get_str(it, ["Username", "username", "PlayerName", "playername", "Name", "name"]) + if rt is not None and name: + self.rt_to_name[rt] = name + return + + def on_pkt_animate(self, pkt: dict): + try: + if not isinstance(pkt, dict): + return False + + if pkt.get("ActionType", None) != 1: + return False + + rt = pkt.get("EntityRuntimeID", None) + if not isinstance(rt, int): + return False + + name = self.rt_to_name.get(rt) + if not name: + name = self._resolve_name_from_online_players(rt) + if not name: + return False + + now = time.time() + period = float(self.config["检测周期秒"]) + show_enabled = bool(self.config["是否显示"]) + title_interval = float(self.config["显示间隔秒"]) + + dq = self._swing_times.get(name) + if dq is None: + dq = deque() + self._swing_times[name] = dq + + dq.append(now) + + cutoff = now - period + while dq and dq[0] < cutoff: + dq.popleft() + + cps = len(dq) / period + self._last_cps[name] = cps + + if self._subs: + self._fire_subscriptions(name, cps, now) + + if show_enabled and self.funclib is not None: + last_ts = self._last_title_ts.get(name, 0.0) + if (now - last_ts) >= title_interval: + self._last_title_ts[name] = now + self._send_title(name, cps) + + except Exception as e: + Print.print_err(f"[{self.name}] 处理 Animate 包出错:{e}") + + return False + + def _fire_subscriptions(self, player_name: str, cps: float, now: float): + for sub in list(self._subs.values()): + if cps < sub.threshold: + continue + + last = sub.last_fire_by_player.get(player_name, 0.0) + if sub.cooldown > 0 and (now - last) < sub.cooldown: + continue + + sub.last_fire_by_player[player_name] = now + try: + sub.handler(player_name, cps) + except Exception as e: + Print.print_war(f"[{self.name}] 订阅回调异常(sub_id={sub.sub_id}):{e}") + + def _refresh_mapping_from_online_players(self, force: bool = False): + now = time.time() + if (not force) and (now - self._last_players_scan_ts) < 0.5: + return + self._last_players_scan_ts = now + + try: + players_mgr = self.frame.get_players() + players = players_mgr.getAllPlayers() + except Exception: + return + + for p in players: + try: + name = getattr(p, "name", None) + if not isinstance(name, str) or not name: + continue + + rt = self._get_runtime_id_from_player_obj(p) + if rt is None: + continue + + self.rt_to_name[int(rt)] = name + except Exception: + continue + + def _resolve_name_from_online_players(self, rt: int) -> Optional[str]: + self._refresh_mapping_from_online_players(force=False) + name = self.rt_to_name.get(rt) + if name: + return name + + self._refresh_mapping_from_online_players(force=True) + return self.rt_to_name.get(rt) + + def _get_runtime_id_from_player_obj(self, p) -> Optional[int]: + candidates = [ + "entity_runtime_id", + "runtime_id", + "runtimeId", + "entityRuntimeId", + "EntityRuntimeID", + "RuntimeID", + ] + for attr in candidates: + try: + v = getattr(p, attr, None) + if isinstance(v, int): + return v + except Exception: + pass + + try: + for attr in dir(p): + if "runtime" not in attr.lower(): + continue + try: + v = getattr(p, attr, None) + if isinstance(v, int): + return v + except Exception: + continue + except Exception: + pass + + return None + + def _escape_selector_name(self, name: str) -> str: + return name.replace("\\", "\\\\").replace('"', '\\"') + + def _send_title(self, player_name: str, cps: float): + cps_s = f"{cps:.1f}" + space = self.config["前置空格"] + color = self.config["颜色前缀"] + + selector_name = self._escape_selector_name(player_name) + cmd = ( + f'/titleraw @a[name="{selector_name}"] title ' + f'{{"rawtext":[{{"text":"{space}{color}cps:{cps_s}"}}]}}' + ) + self.funclib.sendaicmd(cmd) + + def _clamp_config(self): + period = float(self.config.get("检测周期秒", 1.0)) + if period <= 0: + period = 1.0 + self.config["检测周期秒"] = period + + interval = float(self.config.get("显示间隔秒", period)) + if interval <= 0: + interval = period + self.config["显示间隔秒"] = interval + +entry = plugin_entry(SwingCPSAPI, "CPS显示") From 494e501da9b4a4260eaa8d939003f941971ef01d Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 02:59:18 +0800 Subject: [PATCH 20/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 49 ++++++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index 11a47a38..a318aaec 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -15,6 +15,7 @@ @dataclass class _Subscription: + sub_id: int threshold: float cooldown: float @@ -114,8 +115,8 @@ def subscribe( ) -> int: if threshold <= 0: threshold = 0.1 - if cooldown < 0: - cooldown = 0.0 + + cooldown = max(cooldown, 0.0) sub_id = self._next_sub_id self._next_sub_id += 1 @@ -133,15 +134,18 @@ def unsubscribe(self, sub_id: int) -> bool: return self._subs.pop(int(sub_id), None) is not None def _make_mapping_cb(self, pkt_name: str): + def _cb(pkt): try: self._update_mapping(pkt_name, pkt) except Exception: pass return False + return _cb - def _get_int(self, d, keys): + @staticmethod + def _get_int(d, keys): if not isinstance(d, dict): return None for k in keys: @@ -150,7 +154,8 @@ def _get_int(self, d, keys): return v return None - def _get_str(self, d, keys): + @staticmethod + def _get_str(d, keys): if not isinstance(d, dict): return None for k in keys: @@ -165,21 +170,40 @@ def _update_mapping(self, pkt_name: str, pkt): pn = pkt_name.lower() if pn == "addplayer": - rt = self._get_int(pkt, ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"]) - name = self._get_str(pkt, ["Username", "username", "PlayerName", "playername", "Name", "name"]) + rt = self._get_int( + pkt, + ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"], + ) + name = self._get_str( + pkt, + ["Username", "username", "PlayerName", "playername", "Name", "name"], + ) if rt is not None and name: self.rt_to_name[rt] = name return if pn == "playerlist": - for key in ("Records", "records", "Entries", "entries", "players", "Players"): + for key in ( + "Records", + "records", + "Entries", + "entries", + "players", + "Players", + ): arr = pkt.get(key) if isinstance(arr, list): for it in arr: if not isinstance(it, dict): continue - rt = self._get_int(it, ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"]) - name = self._get_str(it, ["Username", "username", "PlayerName", "playername", "Name", "name"]) + rt = self._get_int( + it, + ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"], + ) + name = self._get_str( + it, + ["Username", "username", "PlayerName", "playername", "Name", "name"], + ) if rt is not None and name: self.rt_to_name[rt] = name return @@ -285,7 +309,8 @@ def _resolve_name_from_online_players(self, rt: int) -> Optional[str]: self._refresh_mapping_from_online_players(force=True) return self.rt_to_name.get(rt) - def _get_runtime_id_from_player_obj(self, p) -> Optional[int]: + @staticmethod + def _get_runtime_id_from_player_obj(p) -> Optional[int]: candidates = [ "entity_runtime_id", "runtime_id", @@ -317,7 +342,8 @@ def _get_runtime_id_from_player_obj(self, p) -> Optional[int]: return None - def _escape_selector_name(self, name: str) -> str: + @staticmethod + def _escape_selector_name(name: str) -> str: return name.replace("\\", "\\\\").replace('"', '\\"') def _send_title(self, player_name: str, cps: float): @@ -343,4 +369,5 @@ def _clamp_config(self): interval = period self.config["显示间隔秒"] = interval + entry = plugin_entry(SwingCPSAPI, "CPS显示") From 431f110036ccd4f41acb26157b4d10c37ac25690 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 11:18:50 +0800 Subject: [PATCH 21/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 75 ++++++++++++----------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index a318aaec..8decc6b2 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -15,6 +15,7 @@ @dataclass class _Subscription: + """CPS 阈值订阅项""" sub_id: int threshold: float @@ -24,12 +25,14 @@ class _Subscription: class SwingCPSAPI(Plugin): + """CPS 显示插件,对外提供接口""" name = "CPS显示" author = "丸山彩" version = (0, 0, 1) def __init__(self, frame: ToolDelta): + """初始化插件状态与监听""" super().__init__(frame) default_cfg = { @@ -68,6 +71,7 @@ def __init__(self, frame: ToolDelta): self.ListenPlayerLeave(self.on_player_leave) def on_preload(self): + """获取前置库 API,并注册包监听""" self.funclib = self.GetPluginAPI("基本插件功能库") for attr in ("AddPlayer", "PlayerList"): @@ -80,6 +84,7 @@ def on_preload(self): self._clamp_config() def on_active(self): + """检查前置插件与重载后在线玩家映射补齐""" if self.funclib is None: self.funclib = self.GetPluginAPI("基本插件功能库") @@ -89,6 +94,7 @@ def on_active(self): self._refresh_mapping_from_online_players(force=True) def on_player_leave(self, player): + """玩家离线时清理统计缓存""" try: name = player.name except Exception: @@ -102,9 +108,11 @@ def on_player_leave(self, player): sub.last_fire_by_player.pop(name, None) def get_cps(self, player_name: str) -> float: + """获取指定玩家最近一次计算到的""" return float(self._last_cps.get(player_name, 0.0)) def get_all_cps(self) -> Dict[str, float]: + """获取所有玩家 CPS 快照""" return dict(self._last_cps) def subscribe( @@ -113,6 +121,7 @@ def subscribe( handler: Callable[[str, float], None], cooldown: float = 1.0, ) -> int: + """订阅:当CPS达到阈值触发 handler(player_name, cps)""" if threshold <= 0: threshold = 0.1 @@ -131,11 +140,13 @@ def subscribe( return sub_id def unsubscribe(self, sub_id: int) -> bool: + """取消订阅""" return self._subs.pop(int(sub_id), None) is not None def _make_mapping_cb(self, pkt_name: str): def _cb(pkt): + """监听包时更新映射""" try: self._update_mapping(pkt_name, pkt) except Exception: @@ -146,6 +157,7 @@ def _cb(pkt): @staticmethod def _get_int(d, keys): + """从 dict 中按 keys 顺序取第一个 int 值""" if not isinstance(d, dict): return None for k in keys: @@ -156,6 +168,7 @@ def _get_int(d, keys): @staticmethod def _get_str(d, keys): + """从 dict 中按 keys 顺序取第一个非空 str 值""" if not isinstance(d, dict): return None for k in keys: @@ -165,6 +178,7 @@ def _get_str(d, keys): return None def _update_mapping(self, pkt_name: str, pkt): + """根据 AddPlayer/PlayerList 包更新 runtimeId->name 映射。""" if not isinstance(pkt, dict): return pn = pkt_name.lower() @@ -193,22 +207,36 @@ def _update_mapping(self, pkt_name: str, pkt): ): arr = pkt.get(key) if isinstance(arr, list): + for it in arr: if not isinstance(it, dict): continue rt = self._get_int( it, - ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"], + [ + "EntityRuntimeID", + "entityRuntimeId", + "RuntimeID", + "runtimeId", + ], ) name = self._get_str( it, - ["Username", "username", "PlayerName", "playername", "Name", "name"], + [ + "Username", + "username", + "PlayerName", + "playername", + "Name", + "name", + ], ) if rt is not None and name: self.rt_to_name[rt] = name return def on_pkt_animate(self, pkt: dict): + """统计挥手次数并计算 CPS""" try: if not isinstance(pkt, dict): return False @@ -260,6 +288,7 @@ def on_pkt_animate(self, pkt: dict): return False def _fire_subscriptions(self, player_name: str, cps: float, now: float): + """触发所有阈值订阅回调""" for sub in list(self._subs.values()): if cps < sub.threshold: continue @@ -275,6 +304,7 @@ def _fire_subscriptions(self, player_name: str, cps: float, now: float): Print.print_war(f"[{self.name}] 订阅回调异常(sub_id={sub.sub_id}):{e}") def _refresh_mapping_from_online_players(self, force: bool = False): + """从在线玩家列表刷新映射""" now = time.time() if (not force) and (now - self._last_players_scan_ts) < 0.5: return @@ -301,6 +331,7 @@ def _refresh_mapping_from_online_players(self, force: bool = False): continue def _resolve_name_from_online_players(self, rt: int) -> Optional[str]: + """从在线玩家列表补齐映射缺失""" self._refresh_mapping_from_online_players(force=False) name = self.rt_to_name.get(rt) if name: @@ -311,47 +342,20 @@ def _resolve_name_from_online_players(self, rt: int) -> Optional[str]: @staticmethod def _get_runtime_id_from_player_obj(p) -> Optional[int]: - candidates = [ - "entity_runtime_id", - "runtime_id", - "runtimeId", - "entityRuntimeId", - "EntityRuntimeID", - "RuntimeID", - ] - for attr in candidates: - try: - v = getattr(p, attr, None) - if isinstance(v, int): - return v - except Exception: - pass - + """从 Player 对象读取 runtime_id""" try: - for attr in dir(p): - if "runtime" not in attr.lower(): - continue - try: - v = getattr(p, attr, None) - if isinstance(v, int): - return v - except Exception: - continue + v = getattr(p, "runtime_id", None) + return v if isinstance(v, int) else None except Exception: - pass - - return None - - @staticmethod - def _escape_selector_name(name: str) -> str: - return name.replace("\\", "\\\\").replace('"', '\\"') + return None def _send_title(self, player_name: str, cps: float): + """向玩家发送 titleraw 显示 CPS""" cps_s = f"{cps:.1f}" space = self.config["前置空格"] color = self.config["颜色前缀"] - selector_name = self._escape_selector_name(player_name) + selector_name = player_name cmd = ( f'/titleraw @a[name="{selector_name}"] title ' f'{{"rawtext":[{{"text":"{space}{color}cps:{cps_s}"}}]}}' @@ -359,6 +363,7 @@ def _send_title(self, player_name: str, cps: float): self.funclib.sendaicmd(cmd) def _clamp_config(self): + """修正配置""" period = float(self.config.get("检测周期秒", 1.0)) if period <= 0: period = 1.0 From ddff3aebe16d11e0db07538288796953d21d094e Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 11:23:41 +0800 Subject: [PATCH 22/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 1 + 1 file changed, 1 insertion(+) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index 8decc6b2..fea7d56b 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -144,6 +144,7 @@ def unsubscribe(self, sub_id: int) -> bool: return self._subs.pop(int(sub_id), None) is not None def _make_mapping_cb(self, pkt_name: str): + """生成用于更新映射的包回调函数""" def _cb(pkt): """监听包时更新映射""" From dd7c9692724858353678242c10c5c9b6f25489ce Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 13:12:59 +0800 Subject: [PATCH 23/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 2 ++ 1 file changed, 2 insertions(+) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index fea7d56b..b4045258 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -352,6 +352,8 @@ def _get_runtime_id_from_player_obj(p) -> Optional[int]: def _send_title(self, player_name: str, cps: float): """向玩家发送 titleraw 显示 CPS""" + self.funclib.sendaicmd("/gamerule sendcommandfeedback false") + cps_s = f"{cps:.1f}" space = self.config["前置空格"] color = self.config["颜色前缀"] From 4241df09e4d400cc50ed24bb330748d10a3538e8 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 21:59:56 +0800 Subject: [PATCH 24/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 262 +++++++++++++++++++--- 1 file changed, 230 insertions(+), 32 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index b4045258..e74b23b4 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -3,9 +3,9 @@ import time from collections import deque from dataclasses import dataclass -from typing import Callable, Deque, Dict, Optional +from typing import Callable, Deque, Dict, Optional, List, Tuple -from tooldelta import Plugin, plugin_entry, ToolDelta, Print, cfg +from tooldelta import Plugin, plugin_entry, ToolDelta, Print, cfg, game_utils, utils try: from tooldelta.constants import PacketIDS @@ -29,13 +29,22 @@ class SwingCPSAPI(Plugin): name = "CPS显示" author = "丸山彩" - version = (0, 0, 1) + version = (0, 0, 2) + + _MODE1_DEDUP_EPS = 0.051 + + _MODE1_IGNORE_MIN = 0.0 + _MODE1_IGNORE_MAX = 0.051 + + _MODE2_FIRST_DIST_IF_MOB = 1.0 + _MODE2_FIRST_SECOND_PLAYER_DIST_MAX = 10.0 def __init__(self, frame: ToolDelta): """初始化插件状态与监听""" super().__init__(frame) default_cfg = { + "模式": 1, "检测周期秒": 1.0, "是否显示": True, "显示间隔秒": 1.0, @@ -43,6 +52,7 @@ def __init__(self, frame: ToolDelta): "颜色前缀": "§e", } std_cfg = { + "模式": int, "检测周期秒": float, "是否显示": bool, "显示间隔秒": float, @@ -66,6 +76,9 @@ def __init__(self, frame: ToolDelta): self._next_sub_id = 1 self._subs: Dict[int, _Subscription] = {} + self._pending_actions: Dict[str, Deque[float]] = {} + self._last_sound42_ts: Dict[str, float] = {} + self.ListenPreload(self.on_preload) self.ListenActive(self.on_active) self.ListenPlayerLeave(self.on_player_leave) @@ -79,7 +92,10 @@ def on_preload(self): if pid is not None: self.ListenPacket(int(pid), self._make_mapping_cb(attr)) - self.ListenPacket(int(PacketIDS.Animate), self.on_pkt_animate) + self.ListenPacket(int(PacketIDS.LevelSoundEvent), self.on_pkt_sound) + + animate_pid = getattr(PacketIDS, "Animate", 44) + self.ListenPacket(int(animate_pid), self.on_pkt_animate_action) self._clamp_config() @@ -104,6 +120,9 @@ def on_player_leave(self, player): self._last_cps.pop(name, None) self._last_title_ts.pop(name, None) + self._pending_actions.pop(name, None) + self._last_sound42_ts.pop(name, None) + for sub in self._subs.values(): sub.last_fire_by_player.pop(name, None) @@ -208,7 +227,6 @@ def _update_mapping(self, pkt_name: str, pkt): ): arr = pkt.get(key) if isinstance(arr, list): - for it in arr: if not isinstance(it, dict): continue @@ -236,58 +254,233 @@ def _update_mapping(self, pkt_name: str, pkt): self.rt_to_name[rt] = name return - def on_pkt_animate(self, pkt: dict): - """统计挥手次数并计算 CPS""" + def on_pkt_animate_action(self, pkt: dict): + """模式1:挥手动作""" try: + if int(self.config.get("模式", 1)) != 1: + return False + if not isinstance(pkt, dict): return False if pkt.get("ActionType", None) != 1: return False - rt = pkt.get("EntityRuntimeID", None) - if not isinstance(rt, int): + rt = self._get_int( + pkt, + ["EntityRuntimeID", "entityRuntimeId", "RuntimeID", "runtimeId"], + ) + if rt is None: return False name = self.rt_to_name.get(rt) if not name: - name = self._resolve_name_from_online_players(rt) - if not name: - return False + name = self._resolve_name_from_online_players(int(rt)) + if not name: + return False + + now_real = time.time() - now = time.time() - period = float(self.config["检测周期秒"]) - show_enabled = bool(self.config["是否显示"]) - title_interval = float(self.config["显示间隔秒"]) + self._flush_pending_actions(name, now_real) - dq = self._swing_times.get(name) + dq = self._pending_actions.get(name) if dq is None: dq = deque() - self._swing_times[name] = dq + self._pending_actions[name] = dq + dq.append(now_real) - dq.append(now) + except Exception as e: + Print.print_err(f"[{self.name}] 处理 Animate 动作包出错:{e}") - cutoff = now - period - while dq and dq[0] < cutoff: - dq.popleft() + return False - cps = len(dq) / period - self._last_cps[name] = cps + def on_pkt_sound(self, pkt: dict): + """两种模式:LevelSoundEvent""" + try: + if not isinstance(pkt, dict): + return False - if self._subs: - self._fire_subscriptions(name, cps, now) + st = pkt.get("SoundType", None) + if not isinstance(st, int): + return False + + pos = pkt.get("Position", None) + if not isinstance(pos, (list, tuple)) or len(pos) < 3: + return False + + sx, sy, sz = pos[0], pos[1], pos[2] + if not all(isinstance(t, (int, float)) for t in (sx, sy, sz)): + return False + + tx = float(sx) + ty = float(sy) - 0.9 + tz = float(sz) - 1.0 + + mode = int(self.config.get("模式", 1)) + now_real = time.time() + + if mode == 1: + if st != 42: + return False + + name = self._bind_nearest_player_by_sound(tx, ty, tz) + if not name: + return False + + self._last_sound42_ts[name] = now_real + + self._flush_pending_actions(name, now_real) - if show_enabled and self.funclib is not None: - last_ts = self._last_title_ts.get(name, 0.0) - if (now - last_ts) >= title_interval: - self._last_title_ts[name] = now - self._send_title(name, cps) + dq = self._pending_actions.get(name) + if dq: + dt = now_real - dq[-1] + if (dt > self._MODE1_IGNORE_MIN) and (dt <= self._MODE1_IGNORE_MAX): + dq.pop() + + self._record_event(name, now_real) + return False + + if st == 42: + name = self._bind_nearest_player_by_sound(tx, ty, tz) + if not name: + return False + self._record_event(name, now_real) + return False + + if st == 43: + attacker = self._bind_attacker_for_attack_sound(tx, ty, tz) + if not attacker: + return False + self._record_event(attacker, now_real) + return False + + return False except Exception as e: - Print.print_err(f"[{self.name}] 处理 Animate 包出错:{e}") + Print.print_err(f"[{self.name}] 处理 LevelSoundEvent 包出错:{e}") return False + def _flush_pending_actions(self, name: str, now_real: float): + """把超过 EPS 的 pending 动作刷入统计""" + dq = self._pending_actions.get(name) + if not dq: + return + + eps = self._MODE1_DEDUP_EPS + + while dq and (now_real - dq[0]) >= eps: + ts = dq.popleft() + self._record_event(name, ts) + + def _record_event(self, name: str, event_ts: float): + """把一次挥手/攻击计入统计窗口并显示/触发订阅""" + period = float(self.config["检测周期秒"]) + show_enabled = bool(self.config["是否显示"]) + title_interval = float(self.config["显示间隔秒"]) + now_real = time.time() + + dq = self._swing_times.get(name) + if dq is None: + dq = deque() + self._swing_times[name] = dq + + dq.append(event_ts) + + cutoff = event_ts - period + while dq and dq[0] < cutoff: + dq.popleft() + + cps = len(dq) / period + self._last_cps[name] = cps + + if self._subs: + self._fire_subscriptions(name, cps, now_real) + + if show_enabled and self.funclib is not None: + last_ts = self._last_title_ts.get(name, 0.0) + if (now_real - last_ts) >= title_interval: + self._last_title_ts[name] = now_real + self._send_title(name, cps) + + def _get_single_pos(self, player: str): + """获取单个玩家的坐标""" + return player, game_utils.getPosXYZ(player) + + def _gather_positions(self): + try: + players = self.game_ctrl.allplayers + except Exception: + return [] + try: + return utils.thread_gather([(self._get_single_pos, (p,)) for p in players]) + except Exception: + return [] + + def _bind_nearest_player_by_sound(self, x: float, y: float, z: float) -> Optional[str]: + """选处理后声音点最近的玩家""" + ress = self._gather_positions() + best_name = None + best_d2 = None + + for pname, (px, py, pz) in ress: + try: + if not isinstance(pname, str) or not pname: + continue + dx = float(px) - x + dy = float(py) - y + dz = float(pz) - z + d2 = dx * dx + dy * dy + dz * dz + if best_d2 is None or d2 < best_d2: + best_d2 = d2 + best_name = pname + except Exception: + continue + + return best_name + + def _bind_attacker_for_attack_sound(self, x: float, y: float, z: float) -> Optional[str]: + """选择攻击者""" + ress = self._gather_positions() + candidates: List[Tuple[float, str, float, float, float]] = [] + + for pname, (px, py, pz) in ress: + try: + if not isinstance(pname, str) or not pname: + continue + fpx, fpy, fpz = float(px), float(py), float(pz) + dx = fpx - x + dy = fpy - y + dz = fpz - z + d2 = dx * dx + dy * dy + dz * dz + candidates.append((d2, pname, fpx, fpy, fpz)) + except Exception: + continue + + if not candidates: + return None + + candidates.sort(key=lambda t: t[0]) + d2_1, name1, x1, y1, z1 = candidates[0] + dist1 = d2_1 ** 0.5 + + if dist1 > self._MODE2_FIRST_DIST_IF_MOB: + return name1 + + if len(candidates) < 2: + return None + + d2_2, name2, x2, y2, z2 = candidates[1] + dxp = x1 - x2 + dyp = y1 - y2 + dzp = z1 - z2 + dist12 = (dxp * dxp + dyp * dyp + dzp * dzp) ** 0.5 + + if dist12 > self._MODE2_FIRST_SECOND_PLAYER_DIST_MAX: + return None + + return name2 + def _fire_subscriptions(self, player_name: str, cps: float, now: float): """触发所有阈值订阅回调""" for sub in list(self._subs.values()): @@ -367,6 +560,11 @@ def _send_title(self, player_name: str, cps: float): def _clamp_config(self): """修正配置""" + mode = int(self.config.get("模式", 1)) + if mode not in (1, 2): + mode = 1 + self.config["模式"] = mode + period = float(self.config.get("检测周期秒", 1.0)) if period <= 0: period = 1.0 From b5408626d14595dc5a5cfc90841248b29a17d2e2 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 22:01:52 +0800 Subject: [PATCH 25/33] Update README.md --- "CPS\346\230\276\347\244\272/README.md" | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git "a/CPS\346\230\276\347\244\272/README.md" "b/CPS\346\230\276\347\244\272/README.md" index c271dec5..4462eba2 100644 --- "a/CPS\346\230\276\347\244\272/README.md" +++ "b/CPS\346\230\276\347\244\272/README.md" @@ -1,9 +1,10 @@ # CPS显示 ## 概述 -通过挥手检测机器人周围玩家CPS并显示在玩家屏幕,也可以作为一个前置插件。检测范围限制在机器人附近。这是一个娱乐插件,不适合反制外挂。 +通过检测挥手动作、空挥声音和受击声音检测机器人周围玩家CPS并显示在玩家屏幕,也可以作为一个前置插件。检测范围限制在机器人附近。这是一个娱乐插件,不适合反制外挂。 ## 配置 +- 模式:1为检测挥手动作+空挥声音,无误判但是攻击时很不精准 / 2为检测空挥声音+受击声音,受击声音无法精确绑定攻击者所以有误判但是1v1时可以精准测量CPS并且能防止绕过挥手 - 检测周期秒:CPS = 周期内检测次数 / 检测周期 - 是否显示:是否给玩家发 titleraw 显示 cps - 显示间隔秒:玩家最短多久刷新一次标题 From 546964e14ae2e9f168e08afdf8dc0173dd128d33 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 22:02:33 +0800 Subject: [PATCH 26/33] Update datas.json --- "CPS\346\230\276\347\244\272/datas.json" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/CPS\346\230\276\347\244\272/datas.json" "b/CPS\346\230\276\347\244\272/datas.json" index dd02627f..d4fe2828 100644 --- "a/CPS\346\230\276\347\244\272/datas.json" +++ "b/CPS\346\230\276\347\244\272/datas.json" @@ -1,7 +1,7 @@ { "plugin-id": "CPS显示", "author": "丸山彩", - "version": "0.0.1", + "version": "0.0.2", "description": "在屏幕上显示CPS。", "plugin-type": "classic", "pre-plugins": { From f4e806faefb5c5abbc0219b6c690354a4b51054e Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 22:34:01 +0800 Subject: [PATCH 27/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 130 ++++++++++++---------- 1 file changed, 73 insertions(+), 57 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index e74b23b4..2f6fa2da 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -297,70 +297,80 @@ def on_pkt_animate_action(self, pkt: dict): def on_pkt_sound(self, pkt: dict): """两种模式:LevelSoundEvent""" try: - if not isinstance(pkt, dict): - return False - - st = pkt.get("SoundType", None) - if not isinstance(st, int): - return False - - pos = pkt.get("Position", None) - if not isinstance(pos, (list, tuple)) or len(pos) < 3: + parsed = self._parse_level_sound_event(pkt) + if not parsed: return False - sx, sy, sz = pos[0], pos[1], pos[2] - if not all(isinstance(t, (int, float)) for t in (sx, sy, sz)): - return False - - tx = float(sx) - ty = float(sy) - 0.9 - tz = float(sz) - 1.0 - + st, tx, ty, tz, now_real = parsed mode = int(self.config.get("模式", 1)) - now_real = time.time() if mode == 1: - if st != 42: - return False - - name = self._bind_nearest_player_by_sound(tx, ty, tz) - if not name: - return False - - self._last_sound42_ts[name] = now_real - - self._flush_pending_actions(name, now_real) - - dq = self._pending_actions.get(name) - if dq: - dt = now_real - dq[-1] - if (dt > self._MODE1_IGNORE_MIN) and (dt <= self._MODE1_IGNORE_MAX): - dq.pop() - - self._record_event(name, now_real) - return False - - if st == 42: - name = self._bind_nearest_player_by_sound(tx, ty, tz) - if not name: - return False - self._record_event(name, now_real) - return False - - if st == 43: - attacker = self._bind_attacker_for_attack_sound(tx, ty, tz) - if not attacker: - return False - self._record_event(attacker, now_real) - return False - - return False + self._handle_sound_mode1(st, tx, ty, tz, now_real) + else: + self._handle_sound_mode2(st, tx, ty, tz, now_real) except Exception as e: Print.print_err(f"[{self.name}] 处理 LevelSoundEvent 包出错:{e}") return False + @staticmethod + def _parse_level_sound_event( + pkt: dict, + ) -> Optional[Tuple[int, float, float, float, float]]: + """解析 LevelSoundEvent""" + if not isinstance(pkt, dict): + return None + st = pkt.get("SoundType", None) + if not isinstance(st, int): + return None + pos = pkt.get("Position", None) + if not isinstance(pos, (list, tuple)) or len(pos) < 3: + return None + sx, sy, sz = pos[0], pos[1], pos[2] + if not all(isinstance(t, (int, float)) for t in (sx, sy, sz)): + return None + tx = float(sx) + ty = float(sy) - 0.9 + tz = float(sz) - 1.0 + now_real = time.time() + return st, tx, ty, tz, now_real + + def _handle_sound_mode1( + self, st: int, tx: float, ty: float, tz: float, now_real: float + ) -> None: + """模式1:空挥(42),按规则忽略动作""" + if st != 42: + return + name = self._bind_nearest_player_by_sound(tx, ty, tz) + if not name: + return + self._last_sound42_ts[name] = now_real + self._flush_pending_actions(name, now_real) + dq = self._pending_actions.get(name) + if dq: + dt = now_real - dq[-1] + if self._MODE1_IGNORE_MIN < dt <= self._MODE1_IGNORE_MAX: + dq.pop() + self._record_event(name, now_real) + + def _handle_sound_mode2( + self, st: int, tx: float, ty: float, tz: float, now_real: float + ) -> None: + """模式2:空挥(42)+攻击(43)""" + if st == 42: + name = self._bind_nearest_player_by_sound(tx, ty, tz) + if not name: + return + self._record_event(name, now_real) + return + if st == 43: + attacker = self._bind_attacker_for_attack_sound(tx, ty, tz) + if not attacker: + return + self._record_event(attacker, now_real) + return + def _flush_pending_actions(self, name: str, now_real: float): """把超过 EPS 的 pending 动作刷入统计""" dq = self._pending_actions.get(name) @@ -403,11 +413,13 @@ def _record_event(self, name: str, event_ts: float): self._last_title_ts[name] = now_real self._send_title(name, cps) - def _get_single_pos(self, player: str): + @staticmethod + def _get_single_pos(player: str): """获取单个玩家的坐标""" return player, game_utils.getPosXYZ(player) def _gather_positions(self): + """获取坐标""" try: players = self.game_ctrl.allplayers except Exception: @@ -417,7 +429,9 @@ def _gather_positions(self): except Exception: return [] - def _bind_nearest_player_by_sound(self, x: float, y: float, z: float) -> Optional[str]: + def _bind_nearest_player_by_sound( + self, x: float, y: float, z: float + ) -> Optional[str]: """选处理后声音点最近的玩家""" ress = self._gather_positions() best_name = None @@ -439,7 +453,9 @@ def _bind_nearest_player_by_sound(self, x: float, y: float, z: float) -> Optiona return best_name - def _bind_attacker_for_attack_sound(self, x: float, y: float, z: float) -> Optional[str]: + def _bind_attacker_for_attack_sound( + self, x: float, y: float, z: float + ) -> Optional[str]: """选择攻击者""" ress = self._gather_positions() candidates: List[Tuple[float, str, float, float, float]] = [] @@ -470,7 +486,7 @@ def _bind_attacker_for_attack_sound(self, x: float, y: float, z: float) -> Optio if len(candidates) < 2: return None - d2_2, name2, x2, y2, z2 = candidates[1] + _, name2, x2, y2, z2 = candidates[1] dxp = x1 - x2 dyp = y1 - y2 dzp = z1 - z2 From b3e1fcfa3e6ae427477b24e0b99e2a2d1452f0c0 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Wed, 24 Dec 2025 23:55:04 +0800 Subject: [PATCH 28/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index 2f6fa2da..a1bba4f6 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -31,10 +31,10 @@ class SwingCPSAPI(Plugin): author = "丸山彩" version = (0, 0, 2) - _MODE1_DEDUP_EPS = 0.051 + _MODE1_DEDUP_EPS = 0.052 - _MODE1_IGNORE_MIN = 0.0 - _MODE1_IGNORE_MAX = 0.051 + _MODE1_IGNORE_MIN = -0.003 + _MODE1_IGNORE_MAX = 0.052 _MODE2_FIRST_DIST_IF_MOB = 1.0 _MODE2_FIRST_SECOND_PLAYER_DIST_MAX = 10.0 From dcd5bc2320240cd23917cc68deb6464d9318dd5e Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Thu, 25 Dec 2025 13:47:54 +0800 Subject: [PATCH 29/33] Update README.md --- "CPS\346\230\276\347\244\272/README.md" | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/README.md" "b/CPS\346\230\276\347\244\272/README.md" index 4462eba2..19287cd3 100644 --- "a/CPS\346\230\276\347\244\272/README.md" +++ "b/CPS\346\230\276\347\244\272/README.md" @@ -4,7 +4,7 @@ 通过检测挥手动作、空挥声音和受击声音检测机器人周围玩家CPS并显示在玩家屏幕,也可以作为一个前置插件。检测范围限制在机器人附近。这是一个娱乐插件,不适合反制外挂。 ## 配置 -- 模式:1为检测挥手动作+空挥声音,无误判但是攻击时很不精准 / 2为检测空挥声音+受击声音,受击声音无法精确绑定攻击者所以有误判但是1v1时可以精准测量CPS并且能防止绕过挥手 +- 模式:模式1为检测挥手动作+空挥声音,无误判但是攻击时很不精准 / 模式2为检测空挥声音+受击声音,受击声音无法精确绑定攻击者所以有误判但是1v1时可以精准测量CPS并且能防止绕过挥手 - 检测周期秒:CPS = 周期内检测次数 / 检测周期 - 是否显示:是否给玩家发 titleraw 显示 cps - 显示间隔秒:玩家最短多久刷新一次标题 @@ -12,14 +12,22 @@ - 颜色前缀:设置显示颜色 ## 使用 -- 安装插件后大概需要重启机器人才能有效检测 +- 安装插件后大概需要重启机器人才能有效检测,两种模式首次使用可能都要重启 +- 纯PVP无PVE服可以把插件文件第 39 行的 1.0 改成 50.0 以进一步提高精准度,即 +```python +_MODE2_FIRST_DIST_IF_MOB = 1.0 +``` +改成 +```python +_MODE2_FIRST_DIST_IF_MOB = 50.0 +``` - 作为前置插件: -1.获取: +获取: ```python def on_def(self): self.cps = self.GetPluginAPI("CPS显示") ``` -2.调用: +调用: ```python # 获取某玩家 cps: cps_value = self.cps.get_cps("玩家名") From cc4e82490f54d66c25c38159a5c55e573133adab Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Thu, 25 Dec 2025 13:56:11 +0800 Subject: [PATCH 30/33] Update README.md --- "CPS\346\230\276\347\244\272/README.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/CPS\346\230\276\347\244\272/README.md" "b/CPS\346\230\276\347\244\272/README.md" index 19287cd3..58ce5d2e 100644 --- "a/CPS\346\230\276\347\244\272/README.md" +++ "b/CPS\346\230\276\347\244\272/README.md" @@ -13,7 +13,7 @@ ## 使用 - 安装插件后大概需要重启机器人才能有效检测,两种模式首次使用可能都要重启 -- 纯PVP无PVE服可以把插件文件第 39 行的 1.0 改成 50.0 以进一步提高精准度,即 +- 纯PVP无PVE服如果使用模式2可以把插件文件第 39 行的 1.0 改成 50.0 以进一步提高精准度,即 ```python _MODE2_FIRST_DIST_IF_MOB = 1.0 ``` From 66f748e10df8aa777351abbd4e99c3164f323783 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Fri, 26 Dec 2025 16:29:13 +0800 Subject: [PATCH 31/33] Update datas.json --- "CPS\346\230\276\347\244\272/datas.json" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/CPS\346\230\276\347\244\272/datas.json" "b/CPS\346\230\276\347\244\272/datas.json" index d4fe2828..dd233e25 100644 --- "a/CPS\346\230\276\347\244\272/datas.json" +++ "b/CPS\346\230\276\347\244\272/datas.json" @@ -1,7 +1,7 @@ { "plugin-id": "CPS显示", "author": "丸山彩", - "version": "0.0.2", + "version": "0.0.3", "description": "在屏幕上显示CPS。", "plugin-type": "classic", "pre-plugins": { From 1367aef6e822045384eb7290885516132d109526 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Fri, 26 Dec 2025 16:30:26 +0800 Subject: [PATCH 32/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 58 +++++++++++++++++------ 1 file changed, 43 insertions(+), 15 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index a1bba4f6..47beaa1c 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -1,11 +1,12 @@ from __future__ import annotations +import json import time from collections import deque from dataclasses import dataclass from typing import Callable, Deque, Dict, Optional, List, Tuple -from tooldelta import Plugin, plugin_entry, ToolDelta, Print, cfg, game_utils, utils +from tooldelta import Plugin, plugin_entry, ToolDelta, Print, cfg try: from tooldelta.constants import PacketIDS @@ -29,7 +30,7 @@ class SwingCPSAPI(Plugin): name = "CPS显示" author = "丸山彩" - version = (0, 0, 2) + version = (0, 0, 3) _MODE1_DEDUP_EPS = 0.052 @@ -331,8 +332,8 @@ def _parse_level_sound_event( if not all(isinstance(t, (int, float)) for t in (sx, sy, sz)): return None tx = float(sx) - ty = float(sy) - 0.9 - tz = float(sz) - 1.0 + ty = float(sy) + 0.72 + tz = float(sz) now_real = time.time() return st, tx, ty, tz, now_real @@ -413,19 +414,46 @@ def _record_event(self, name: str, event_ts: float): self._last_title_ts[name] = now_real self._send_title(name, cps) - @staticmethod - def _get_single_pos(player: str): - """获取单个玩家的坐标""" - return player, game_utils.getPosXYZ(player) - def _gather_positions(self): - """获取坐标""" - try: - players = self.game_ctrl.allplayers - except Exception: - return [] + """获取坐标: /querytarget @a""" try: - return utils.thread_gather([(self._get_single_pos, (p,)) for p in players]) + resp = self.game_ctrl.sendwscmd_with_resp("/querytarget @a", 1) + if (not resp.OutputMessages) or (not resp.OutputMessages[0].Success): + return [] + parameter = resp.OutputMessages[0].Parameters[0] + result_list = json.loads(parameter) if isinstance(parameter, str) else parameter + if not isinstance(result_list, list): + return [] + + players_uuid = getattr(self.game_ctrl, "players_uuid", None) + uuid_to_name = None + if isinstance(players_uuid, dict): + uuid_to_name = {v: k for k, v in players_uuid.items() if isinstance(v, str)} + + ress = [] + for it in result_list: + if not isinstance(it, dict): + continue + uid = it.get("uniqueId") + pos = it.get("position") + if not isinstance(uid, str): + continue + if not isinstance(pos, dict): + continue + + pname = uuid_to_name.get(uid) if uuid_to_name else None + if not isinstance(pname, str) or not pname: + continue + + x = pos.get("x") + y = pos.get("y") + z = pos.get("z") + if not all(isinstance(v, (int, float)) for v in (x, y, z)): + continue + + ress.append((pname, (float(x), float(y), float(z)))) + + return ress except Exception: return [] From 6c4a1824642a4c55f88c33a1377a9cb350309e02 Mon Sep 17 00:00:00 2001 From: Aya114514666 <1748554152@qq.com> Date: Fri, 26 Dec 2025 17:12:58 +0800 Subject: [PATCH 33/33] Update __init__.py --- "CPS\346\230\276\347\244\272/__init__.py" | 99 ++++++++++++++--------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git "a/CPS\346\230\276\347\244\272/__init__.py" "b/CPS\346\230\276\347\244\272/__init__.py" index 47beaa1c..7cf84c03 100644 --- "a/CPS\346\230\276\347\244\272/__init__.py" +++ "b/CPS\346\230\276\347\244\272/__init__.py" @@ -414,46 +414,73 @@ def _record_event(self, name: str, event_ts: float): self._last_title_ts[name] = now_real self._send_title(name, cps) + def _ws_querytarget_all(self): + """WS 执行 /querytarget @a,成功返回 Parameters[0],失败返回 None。""" + resp = self.game_ctrl.sendwscmd_with_resp("/querytarget @a", 1) + if (not resp.OutputMessages) or (not resp.OutputMessages[0].Success): + return None + return resp.OutputMessages[0].Parameters[0] + + @staticmethod + def _parse_querytarget_parameter(parameter): + """把 Parameters[0] 解析成 list。""" + if isinstance(parameter, str): + return json.loads(parameter) + return parameter + + def _build_uuid_to_name(self): + """players_uuid(name->uuid) 反转为 uuid->name。""" + players_uuid = getattr(self.game_ctrl, "players_uuid", None) + if not isinstance(players_uuid, dict): + return None + return { + v: k + for k, v in players_uuid.items() + if isinstance(v, str) + } + + @staticmethod + def _extract_positions(result_list, uuid_to_name): + """从 querytarget list 中抽取 (玩家名, (x,y,z)) 列表。""" + if not isinstance(result_list, list): + return [] + + ress = [] + for it in result_list: + if not isinstance(it, dict): + continue + + uid = it.get("uniqueId") + pos = it.get("position") + if not isinstance(uid, str): + continue + if not isinstance(pos, dict): + continue + + pname = uuid_to_name.get(uid) if uuid_to_name else None + if not isinstance(pname, str) or not pname: + continue + + x = pos.get("x") + y = pos.get("y") + z = pos.get("z") + if not all(isinstance(v, (int, float)) for v in (x, y, z)): + continue + + ress.append((pname, (float(x), float(y), float(z)))) + + return ress + def _gather_positions(self): """获取坐标: /querytarget @a""" try: - resp = self.game_ctrl.sendwscmd_with_resp("/querytarget @a", 1) - if (not resp.OutputMessages) or (not resp.OutputMessages[0].Success): - return [] - parameter = resp.OutputMessages[0].Parameters[0] - result_list = json.loads(parameter) if isinstance(parameter, str) else parameter - if not isinstance(result_list, list): + parameter = self._ws_querytarget_all() + if parameter is None: return [] - - players_uuid = getattr(self.game_ctrl, "players_uuid", None) - uuid_to_name = None - if isinstance(players_uuid, dict): - uuid_to_name = {v: k for k, v in players_uuid.items() if isinstance(v, str)} - - ress = [] - for it in result_list: - if not isinstance(it, dict): - continue - uid = it.get("uniqueId") - pos = it.get("position") - if not isinstance(uid, str): - continue - if not isinstance(pos, dict): - continue - - pname = uuid_to_name.get(uid) if uuid_to_name else None - if not isinstance(pname, str) or not pname: - continue - - x = pos.get("x") - y = pos.get("y") - z = pos.get("z") - if not all(isinstance(v, (int, float)) for v in (x, y, z)): - continue - - ress.append((pname, (float(x), float(y), float(z)))) - - return ress + + result_list = self._parse_querytarget_parameter(parameter) + uuid_to_name = self._build_uuid_to_name() + return self._extract_positions(result_list, uuid_to_name) except Exception: return []