From db9d820684550258379f15220f0f9ad25b01c431 Mon Sep 17 00:00:00 2001 From: 401U Date: Mon, 5 Oct 2026 00:59:27 +0800 Subject: [PATCH 1/6] 401U-11: split list ordering, private targets and monotonic statistics Co-authored-by: multica-agent --- .gitignore | 5 ++ lang/en_us.yml | 14 +++++ lang/zh_cn.yml | 14 +++++ mount/MountManager.py | 122 +++++++++++++++++++++--------------- mount/MountSlot.py | 100 ++++++++++++++--------------- mount/cmd_tree.py | 21 ++++--- mount/config.py | 7 ++- mount/entry.py | 5 +- mount/list_order.py | 6 ++ requirements-test-2.7.txt | 5 ++ requirements-test.txt | 4 ++ tests/command_api.py | 56 +++++++++++++++++ tests/conftest.py | 128 ++++++++++++++++++++++++++++++++++++++ tests/test_legacy.py | 105 +++++++++++++++++++++++++++++++ tests/test_list.py | 116 ++++++++++++++++++++++++++++++++++ tests/test_qa_list.py | 56 +++++++++++++++++ 16 files changed, 653 insertions(+), 111 deletions(-) create mode 100644 mount/list_order.py create mode 100644 requirements-test-2.7.txt create mode 100644 requirements-test.txt create mode 100644 tests/command_api.py create mode 100644 tests/conftest.py create mode 100644 tests/test_legacy.py create mode 100644 tests/test_list.py create mode 100644 tests/test_qa_list.py diff --git a/.gitignore b/.gitignore index 90feab8..756019f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ .idea +.venv/ +.venv-*/ +.pytest_cache/ +isolated-mcdr.log +rendered-*.txt *.mcdr *.pyz */__pycache__/ diff --git a/lang/en_us.yml b/lang/en_us.yml index 18f2722..eb11adc 100644 --- a/lang/en_us.yml +++ b/lang/en_us.yml @@ -1,4 +1,18 @@ mount: + flow: + switch: '[Switch]' + reset: '[Reset]' + previous: '[Previous]' + next: '[Next]' + pinned: '[Pinned]' + current: '[Current]' + available: '[Available]' + occupied: '[Unavailable: occupied]' + unchecked: '[Unavailable: unchecked]' + missing: '[Unavailable: directory missing]' + invalid: '[Unavailable: invalid configuration]' + page_corrected: 'Invalid page; showing page 1.' + activity_fallback: 'Activity data is untrusted; using configured order after pinned servers.' help_msg: welcome: "This MCDR server has multi MC server support by mount plugin, type §e!!mount§r to get help" click_to_fill: "Click to fill {cmd}" diff --git a/lang/zh_cn.yml b/lang/zh_cn.yml index d4c6b1a..f3f3517 100644 --- a/lang/zh_cn.yml +++ b/lang/zh_cn.yml @@ -1,4 +1,18 @@ mount: + flow: + switch: '[切换]' + reset: '[重置]' + previous: '[上一页]' + next: '[下一页]' + pinned: '[置顶]' + current: '[当前服]' + available: '[可用]' + occupied: '[不可用:已占用]' + unchecked: '[不可用:未检查]' + missing: '[不可用:目录不存在]' + invalid: '[不可用:配置失效]' + page_corrected: '页码无效,已返回第 1 页。' + activity_fallback: '活跃度数据不可信,置顶后按配置顺序显示。' help_msg: welcome: "此服务器通过mount插件添加了多挂载点支持, 输入§e!!mount§r可获取帮助信息" click_to_fill: "点击以填入{cmd}" diff --git a/mount/MountManager.py b/mount/MountManager.py index 07a89e2..580cfec 100644 --- a/mount/MountManager.py +++ b/mount/MountManager.py @@ -1,4 +1,5 @@ import functools +import hashlib import math import os.path import time @@ -15,6 +16,7 @@ from .constants import * from .detect_helper import DetectHelper from .MountSlot import MountSlot +from .list_order import order_slots from .reset_helper import ResetHelper from .utils import logger, psi, rtr, debug @@ -182,6 +184,7 @@ def request_operation(self, mode: str, source: CommandSource, path: Optional[str @single_op(Operation.REQUEST_MOUNT) def request_mount(self, source: CommandSource, path: str, with_confirm: bool = False): global current_op + path = self.resolve_target(path) debug("Received mount request, evaluating...") if path == self.current_slot.path: @@ -299,57 +302,78 @@ def abort_operation(self, source: CommandSource): self.next_slot.release(self._config.mount_name) self.next_slot = None - @new_thread('mount-list') - def list_servers(self, src: CommandSource, page: int = 1): - debug(f"Received list request for page {page}") - list_size = self._config.list_size - available_servers = self._config.available_servers - max_page = math.ceil(len(available_servers) / list_size) - if not 1 <= page <= max_page: + def message(self, src, key, **kwargs): + src.reply(rtr('flow.' + key, **kwargs)) + def button(self, key, command): + return RText(rtr('flow.' + key)).c(RAction.run_command, COMMAND_PREFIX + ' ' + command) + def target_token(self, path): + return 'slot:' + hashlib.sha256(path.encode('utf-8')).hexdigest() + def resolve_target(self, target): + if target.startswith('slot:'): + return next((path for path in self.servers_as_list if self.target_token(path) == target), '') + return target + def target_name(self, path): + name = os.path.basename(os.path.normpath(path)) + matches = [p for p in self.servers_as_list if os.path.basename(os.path.normpath(p)) == name] + return name + (' #'+str(matches.index(path)+1) if len(matches) > 1 and path in matches else '') + + def list_servers(self, src, page=1): + views, slots, reasons = [], {}, {} + degraded = False + for path in self.servers_as_list: + try: + if not os.path.isdir(path): + raise ValueError('missing path') + slot = MountSlot(path) + score = slot.stats.player_time_ns_v2 + if slot.stats.schema_version != 2 or type(score) not in (int, float) or not math.isfinite(score) or score < 0: + score, degraded = 0, True + views.append((path, max(0, score))) + slots[path] = slot + except Exception: + reasons[path] = 'missing' if not os.path.isdir(path) else 'invalid' + degraded = True + views.append((path, 0)) + views = order_slots(views, self._config.pinned_servers, + 'configured' if degraded else self._config.list_order) + size = self._config.list_size + if type(size) is not int or size <= 0: + size = 15 + pages = max(1, math.ceil(len(views)/size)) + try: + page = int(page) + except (ValueError, TypeError): + page = 0 + if not 1 <= page <= pages: page = 1 - - left = (page - 1) * list_size - right = min(len(available_servers), page * list_size) - src.reply(RText(rtr('list.title'))) - for server in available_servers[left: right]: - slot = MountSlot(path=server) - src.reply( - slot - .get_config() - .as_list_entry(slot.name, slot.path, - self._config.mount_name, self._config.current_server - )) - - # <<< curr/total >>> - link_color = { - True: RColor.green, - False: RColor.gray - } - - left_link: RText - if page <= 1: - left_link = RText('<<<', color=link_color[False]).h(rtr('list.no_more_page')) - else: - left_link = RText('<<<', color=link_color[True]).h(rtr('list.prev_page')) \ - .c(RAction.suggest_command, COMMAND_PREFIX + ' --list ' + str(page - 1)) - - right_link: RText - if page >= max_page: - right_link = RText('>>>', color=link_color[False]).h(rtr('list.no_more_page')) - else: - right_link = RText('>>>', color=link_color[True]).h(rtr('list.next_page')) \ - .c(RAction.suggest_command, COMMAND_PREFIX + ' --list ' + str(page + 1)) - - footer = RTextList( - left_link, - f' {page} / {max_page} ', - right_link - ) - if left == right: + self.message(src, 'page_corrected') + src.reply(rtr('list.title')) + if degraded and self._config.list_order == 'activity': + self.message(src, 'activity_fallback') + for path, _ in views[(page-1)*size:page*size]: + slot, current = slots.get(path), path == self.current_slot.path + row = RTextList(self.target_name(path), ' ') + if path in self._config.pinned_servers: + row.append(rtr('flow.pinned'), ' ') + if current: + row.append(rtr('flow.current'), ' ') + if slot: + row.append(slot.desc, ' ') + reason = ('unchecked' if not slot.checked else 'occupied' + if slot.occupied_by and (not current or slot.occupied_by != self._config.mount_name) + else 'available') + row.append(rtr('flow.'+reason), ' ') + if current and slot.reset_path and reason == 'available': + row.append(self.button('reset', '--reset')) + elif not current and reason == 'available': + row.append(self.button('switch', self.target_token(path))) + else: + row.append(rtr('flow.'+reasons[path])) + src.reply(row) + if not views: src.reply(rtr('list.empty')) - else: - src.reply(footer) - + src.reply(RTextList(self.button('previous', '--list '+str(page-1)) if page > 1 else rtr('flow.previous'), + ' {}/{} '.format(page,pages), self.button('next', '--list '+str(page+1)) if page < pages else rtr('flow.next'))) def get_config(self, config_key, src: Optional[CommandSource] = None): if src is not None: diff --git a/mount/MountSlot.py b/mount/MountSlot.py index dfbf243..9fb2797 100644 --- a/mount/MountSlot.py +++ b/mount/MountSlot.py @@ -1,7 +1,6 @@ import os import time -from asyncio import Event -from threading import Lock, Thread +from threading import Event, Lock, RLock, Thread, current_thread from typing import Callable from jproperties import Properties @@ -14,21 +13,15 @@ class StatsChecker(Thread): def __init__(self, interval: int, cb: Callable[[], None]): super().__init__() - self.setDaemon(True) - self.setName(self.__class__.__name__) + self.daemon = True + self.name = self.__class__.__name__ self._report_time = time.time() self.stop_event = Event() self.interval = interval self._callback = cb def run(self): - while True: # loop until stop - while True: # # loop for report - if self.stop_event.wait(1): - return - if time.time() - self._report_time > self.interval: - break - self._report_time = time.time() + while not self.stop_event.wait(self.interval): self._callback() def stop(self): @@ -37,13 +30,14 @@ def stop(self): class MountSlot: def __init__(self, path: str): self.path = path + self.__stats_lock = RLock() self.load_config() self.properties = Properties() self.slot_lock = Lock() - self.__players = [] + self.__players = set() self.__players_lock = Lock() - self.__stats_lock = Lock() - self.__stats_checker = StatsChecker(60, self.update_stats) + self.__stats_checker = None + self.__anchor = None @property def name(self) -> str: @@ -58,11 +52,12 @@ def plg_dir(self): def load_config(self): debug(f'Loading slot config in {self.path}...') - self._config = psi.load_config_simple( - target_class=Config, - file_name=os.path.join(self.path, MOUNTABLE_CONFIG), - in_data_folder=False - ) + with self.__stats_lock: + self._config = psi.load_config_simple( + target_class=Config, + file_name=os.path.join(self.path, MOUNTABLE_CONFIG), + in_data_folder=False + ) def save_config(self): debug(f'Saving slot config in {self.path}...') @@ -129,52 +124,53 @@ def edit_config(self, key: str, value: str): return rtr('config.set_value', key=rtr(f'config.slot.{key}'), value=self._config.__getattribute__(key)) def on_player_join(self, player: str): - debug(f'Player {player} joined slot {self.path}, saving stats...') - with self.__players_lock: - self.update_stats() - self._config.stats.total_players = self._config.stats.total_players + 1 - self.__players.append(player) + with self.__stats_lock: self.update_stats() + self.__players.add(player) def on_player_left(self, player: str): - debug(f'Player {player} left slot {self.path}, saving stats...') - try: - with self.__players_lock: - self.update_stats() - self.__players.remove(player) - except ValueError: - pass + with self.__stats_lock: + self.update_stats() + self.__players.discard(player) + + def set_players(self, players): + with self.__stats_lock: + self.update_stats() + self.__players = set(players) def on_mount(self): - debug(f'slot {self.path} is mounted, saving stats...') - if not self.__stats_checker.is_alive: - with self.__stats_lock: - current = time.time_ns - self._config.stats.last_mount_ns = current - self.save_config() - debug(f'starting stats checker for slot {self.path}...') + with self.__stats_lock: + if self.__anchor is not None: + return + self.__anchor = time.monotonic_ns() + self._config.stats.last_mount_ns = time.time_ns() + self.__stats_checker = StatsChecker(60, self.update_stats) self.__stats_checker.start() def on_unmount(self): - debug(f'slot {self.path} is unmounted, saving stats...') - if self.__stats_checker.is_alive: + with self.__stats_lock: self.update_stats() - self.__stats_checker.stop() + self.__anchor = None + self.__players.clear() + checker = self.__stats_checker + if checker: + checker.stop() + if checker and checker is not current_thread(): + checker.join() def update_stats(self): - debug(f'Updating stats in slot {self.path}...') - if not self.__stats_checker.is_alive: - return with self.__stats_lock: - current = time.time_ns() - prev = self._config.stats.last_mount_ns - p = len(self.__players) - t = current - prev + if self.__anchor is None: + return + current = time.monotonic_ns() + t = max(0, current - self.__anchor) + self.__anchor = current stats = self._config.stats - stats.last_mount_ns = current - stats.total_use_time = stats.total_use_time + t - stats.total_player_time = stats.total_player_time + t * p + stats.use_time_ns_v2 += t + stats.player_time_ns_v2 += t * len(self.__players) + # Statistics must not overwrite a freshly edited reset template, + # permissions/configuration or another instance's occupation. + self.load_config() self._config.stats = stats - debug(f'current stats: {stats}') self.save_config() diff --git a/mount/cmd_tree.py b/mount/cmd_tree.py index 92cf145..afe66f7 100644 --- a/mount/cmd_tree.py +++ b/mount/cmd_tree.py @@ -53,6 +53,17 @@ def get_slot_node(): # to check if is a usable slot return Text('slot_path').requires(lambda src, ctx: ctx['slot_path'] in manager.servers_as_list, lambda src, ctx: rtr('error.invalid_mount_path')) + def legacy_mount(src, target): + suffix = ' --confirm' + confirmed = target.endswith(suffix) + if confirmed: + target = target[:-len(suffix)] + path = manager.resolve_target(target) + if path not in manager.servers_as_list: + src.reply(rtr('error.invalid_mount_path')) + return + manager.request_mount(src, path, with_confirm=confirmed) + config_node = Literal({"--config", "-cfg"}).runs(lambda src: get_config_help(src))\ .requires(lambda src: src.has_permission(3), lambda src: src.reply(rtr('error.perm_deny'))).then( get_slot_node().runs( @@ -75,7 +86,7 @@ def get_slot_node(): # to check if is a usable slot Literal({'--list', '-l'}).runs( lambda src, ctx: manager.list_servers(src) ).then( - Number('page').runs(lambda src, ctx: manager.list_servers(src, ctx['page'])) + GreedyText('page').runs(lambda src, ctx: manager.list_servers(src, ctx['page'])) ) ).then( Literal('--abort').runs(lambda src, ctx: manager.abort_operation(src)) @@ -84,13 +95,7 @@ def get_slot_node(): # to check if is a usable slot ).then( Literal({'--reload', '-r'}).runs(lambda src, ctx: manager.reload(src)) ).then( - get_slot_node().runs( - lambda src, ctx: manager.request_mount( - src, ctx['slot_path'], with_confirm=False) - ).then( - Literal("--confirm") - .runs(lambda src, ctx: manager.request_mount(src, ctx['slot_path'], with_confirm=True)) - ) + GreedyText('mount_target').runs(lambda src, ctx: legacy_mount(src, ctx['mount_target'])) ) server.register_command(main_node) server.register_help_message(COMMAND_PREFIX, rtr("help_msg.brief")) diff --git a/mount/config.py b/mount/config.py index 239b4eb..08d7136 100644 --- a/mount/config.py +++ b/mount/config.py @@ -9,7 +9,7 @@ class MountConfig(Serializable): welcome_player: bool = True - short_prefix = True # let !!m to be a short command + short_prefix: bool = True # let !!m to be a short command servers_path: Union[str, List[str]] = [ "../servers" ] overwrite_path: str = "../servers/server.properties.overwrite" @@ -21,6 +21,8 @@ class MountConfig(Serializable): mount_name: str = "MountDemo" list_size: int = 15 debug: bool = False + pinned_servers: List[str] = [] + list_order: str = 'configured' def migrate(self): need_save = False @@ -44,6 +46,9 @@ def load() -> 'MountConfig': class SlotStats(Serializable): + schema_version: int = 2 + use_time_ns_v2: int = 0 + player_time_ns_v2: int = 0 last_mount_ns: int = -1 total_use_time: int = 0 total_player_time: int = 0 diff --git a/mount/entry.py b/mount/entry.py index b6f8f70..3554fb3 100644 --- a/mount/entry.py +++ b/mount/entry.py @@ -14,6 +14,9 @@ def on_load(server: PluginServerInterface, prev_module): debug(f"plugin loaded") global manager + previous = getattr(prev_module, "manager", None) + if previous: + previous.current_slot.on_unmount() config: MountConfig = MountConfig.load() manager = MountManager(config=config) register_commands(server, manager) @@ -26,7 +29,7 @@ def on_unload(server: PluginServerInterface): debug(f"plugin unloaded") if not manager: return - if manager.current_slot and server.is_server_running(): + if manager.current_slot: manager.current_slot.on_unmount() diff --git a/mount/list_order.py b/mount/list_order.py new file mode 100644 index 0000000..287bcb0 --- /dev/null +++ b/mount/list_order.py @@ -0,0 +1,6 @@ +def order_slots(entries, pinned, mode): + """Stable order of (path, trusted activity) views; never filters entries.""" + ranks = {path: i for i, path in reversed(list(enumerate(pinned)))} + return sorted(entries, key=lambda e: ( + 0 if e[0] in ranks else 1, + ranks[e[0]] if e[0] in ranks else (-e[1] if mode == 'activity' else 0))) diff --git a/requirements-test-2.7.txt b/requirements-test-2.7.txt new file mode 100644 index 0000000..01a2164 --- /dev/null +++ b/requirements-test-2.7.txt @@ -0,0 +1,5 @@ +jproperties +mcdreforged==2.7.0 +pytest>=7 +setuptools<81 +ruamel.yaml<0.18 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..322ddf7 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=7 +# MCDR 2.7 imports pkg_resources. +setuptools<81 diff --git a/tests/command_api.py b/tests/command_api.py new file mode 100644 index 0000000..2aa2072 --- /dev/null +++ b/tests/command_api.py @@ -0,0 +1,56 @@ +import threading +from pathlib import Path +from types import SimpleNamespace + +import pytest +from mcdreforged.api.types import PlayerCommandSource +from mcdreforged.api.command import RequirementNotMet + +from mount.cmd_tree import register_commands + + +class CommandUser(PlayerCommandSource): + def __init__(self, name='Alice', permission=0): + self.name, self.permission, self.replies = name, permission, [] + + @property + def player(self): + return self.name + + @property + def is_player(self): + return self.player is not None + + @property + def is_console(self): + return not self.is_player + + def get_permission_level(self): + return self.permission + + def reply(self,text,**kwargs): + self.replies.append(text) + + +def command_root(m): + holder = [] + server = SimpleNamespace(register_command=holder.append, register_help_message=lambda *a:None, + get_self_metadata=lambda:SimpleNamespace(version='test')) + register_commands(server,m) + node = holder[0] + if hasattr(node, 'execute'): + return node + # MCDR 2.14 invokes callbacks synchronously and returns None. MCDR 2.16 + # returns executions requiring its own invoker. Use the real traversal + # result instead of treating every version without execute as 2.16. + class Root: + def execute(self,source,command): + executions = node._entry_execute(source,command) + if executions is None: + return + from mcdreforged.command.builder.callback import DirectCallbackInvoker + for execution in executions: + execution.scheduled_callback.invoke(DirectCallbackInvoker()) + return Root() + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a8c8e46 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,128 @@ +import logging +import sys +import types +from pathlib import Path + +import pytest +from mcdreforged.api.types import PlayerCommandSource + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +# PSI acquisition requires a running MCDR plugin context. Only this boundary +# is replaced; command nodes, RText and serialization use actual MCDR APIs. +utils = types.ModuleType('mount.utils') +utils.psi = None +utils.logger = lambda: logging.getLogger('test') +utils.debug = lambda *args: None +utils.setDebugNoCheck = lambda value: None +utils.rtr = lambda translation_key, *args, **kwargs: translation_key + (' ' + str(kwargs) if kwargs else '') +sys.modules['mount.utils'] = utils + + +class Source(PlayerCommandSource): + def __init__(self, player='Alice', permission=0): + self.name = player + self.permission = permission + self.replies = [] + + @property + def player(self): + return self.name + + @property + def is_player(self): + return self.name is not None + + def has_permission(self, level): + return self.permission >= level + + def reply(self, text): + self.replies.append(text) + + +class Server: + def __init__(self): + self.running = self.started = True + self.stops = self.starts = 0 + self.messages = [] + self.stop_works = self.start_works = True + self.config = {'handler': 'vanilla_handler', 'plugin_directories': [], + 'working_directory': '', 'start_command': ''} + + def load_config_simple(self, target_class, file_name, **kwargs): + import json + path = Path(file_name) + if path.exists(): + return target_class.deserialize(json.loads(path.read_text(encoding='utf-8'))) + return target_class() + + def save_config_simple(self, config, file_name, **kwargs): + import json + path = Path(file_name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config.serialize()), encoding='utf-8') + + def is_server_running(self): + return self.running + + def is_server_startup(self): + return self.running and self.started + + def stop(self): + self.stops += 1 + if self.stop_works: + self.running = self.started = False + + def start(self): + self.starts += 1 + self.running = True + self.started = self.start_works + + def stop_exit(self): + self.stop() + + def broadcast(self, text): + self.messages.append(text) + + def tell(self, player, text): + self.messages.append(text) + + def execute(self, command): + pass + + def get_mcdr_config(self): + return self.config.copy() + + def modify_mcdr_config(self, changes): + self.config.update(changes) + + +@pytest.fixture +def environment(tmp_path, monkeypatch): + import mount.MountManager as mm + import mount.MountSlot as ms + import mount.config as cfg + import mount.entry as entry + + from mount.constants import MOUNTABLE_CONFIG + server = Server() + monkeypatch.setattr(cfg, 'CONFIG_NAME', str(tmp_path/'instance.json')) + for module in (mm, ms, cfg): + monkeypatch.setattr(module, 'psi', server) + config = cfg.MountConfig() + config.current_server = str(tmp_path/'current') + config.available_servers = [config.current_server, str(tmp_path/'target with spaces')] + config.overwrite_path = '' + for path in config.available_servers: + Path(path, 'world').mkdir(parents=True) + Path(path, 'world', 'level.dat').write_bytes(b'world-data') + slot = cfg.SlotConfig() + slot.checked = True + slot.reset_path = 'template' + Path(path, 'template', 'world').mkdir(parents=True) + Path(path, 'template', 'world', 'level.dat').write_bytes(b'template') + server.save_config_simple(slot, str(Path(path, MOUNTABLE_CONFIG))) + manager = mm.MountManager(config) + yield manager, server, Source + manager.current_slot.on_unmount() + manager.current_slot.release(manager._config.mount_name) + mm.current_op = mm.Operation.IDLE diff --git a/tests/test_legacy.py b/tests/test_legacy.py new file mode 100644 index 0000000..f0c720b --- /dev/null +++ b/tests/test_legacy.py @@ -0,0 +1,105 @@ +import json +from pathlib import Path + +import pytest + +from command_api import CommandUser, command_root + + +def test_old_commands_request_confirm_cancel(environment, monkeypatch): + import mount.MountManager as mm + m, server, Source = environment + root = command_root(m) + src = CommandUser() + target = m.servers_as_list[1] + root.execute(src, '!!mount '+target) + assert mm.current_op is mm.Operation.REQUEST_MOUNT + assert m.next_slot.path == target and server.stops == 0 + actions = [] + monkeypatch.setattr(m, '_do_mount', lambda source, slot: actions.append(('switch', slot.path))) + root.execute(src, '!!mount --confirm') + assert actions == [('switch', target)] + root.execute(src, '!!m --abort') + assert mm.current_op is mm.Operation.IDLE and m.next_slot is None + root.execute(src, '!!mount --reset') + assert mm.current_op is mm.Operation.REQUEST_RESET + monkeypatch.setattr(m, '_do_reset', lambda source, slot: actions.append(('reset', slot.path))) + root.execute(src, '!!mount --confirm') + assert actions[-1] == ('reset', m.current_slot.path) + root.execute(src, '!!mount --abort') + assert mm.current_op is mm.Operation.IDLE + + +def test_tokens_names_and_spaces(environment): + import mount.MountManager as mm + from mount.constants import MOUNTABLE_CONFIG + m, server, _ = environment + first = Path(m.servers_as_list[1]) + second = first.parent/'other'/first.name + second.mkdir(parents=True) + (second/MOUNTABLE_CONFIG).write_bytes((first/MOUNTABLE_CONFIG).read_bytes()) + m._config.available_servers.append(str(second)) + src = CommandUser() + m.list_servers(src) + rendered = json.dumps([x.to_json_object() if hasattr(x, 'to_json_object') else str(x) for x in src.replies]) + assert str(first.parent) not in rendered and '#1' in rendered and '#2' in rendered + root = command_root(m) + for path in (str(first), str(second)): + root.execute(src, '!!mount '+m.target_token(path)) + assert m.next_slot.path == path + root.execute(src, '!!mount --abort') + root.execute(src, '!!mount slot:'+'0'*64) + assert m.next_slot is None and mm.current_op is mm.Operation.IDLE + + +@pytest.mark.parametrize('language', ['en_us', 'zh_cn']) +def test_real_list_text_and_buttons(environment, monkeypatch, language): + from ruamel.yaml import YAML + import mount.MountManager as mm + m, _, _ = environment + translations = YAML().load((Path(__file__).resolve().parents[1]/'lang'/(language+'.yml')).read_text(encoding='utf-8'))['mount'] + def translate(key, **kwargs): + section, field = key.split('.') + return translations[section][field].format(**kwargs) + monkeypatch.setattr(mm, 'rtr', translate) + m._config.pinned_servers = [m.current_slot.path] + src = CommandUser() + m.list_servers(src) + rendered = '\n'.join(map(str, src.replies)) + assert translations['flow']['pinned'] in rendered + assert translations['flow']['current'] in rendered + assert translations['flow']['available'] in rendered + assert m.current_slot.path not in rendered + command = src.replies[2].to_json_object() + assert m.target_token(m.servers_as_list[1]) in str(command) + assert 'run_command' in str(command) and 'switch ' not in str(command) + print(language, rendered) + + +def test_old_config_empty_and_bad_size(environment): + from mount.config import MountConfig + m, _, Source = environment + old = MountConfig.deserialize({'current_server': m.current_slot.path, 'servers_path': '../servers'}) + assert old.pinned_servers == [] and old.list_order == 'configured' + assert not hasattr(old, 'backup_permission') + m._config.available_servers = [] + for size in (0, -2, 'bad', True): + m._config.list_size = size + src = Source() + m.list_servers(src, 'bad') + assert 'clickEvent' not in str(src.replies[-1].to_json_object()) + + +def test_reload_and_unload_stop_statistics(environment, monkeypatch): + from types import SimpleNamespace + import mount.entry as entry + m, server, _ = environment + monkeypatch.setattr(entry, 'manager', m) + monkeypatch.setattr(entry, 'register_commands', lambda *args: None) + monkeypatch.setattr(entry.MountConfig, 'load', lambda: m._config) + m.current_slot.on_mount() + entry.on_load(server, SimpleNamespace(manager=m)) + newer = entry.manager + assert newer is not m + entry.on_unload(server) + assert not any(t.name == 'StatsChecker' for t in __import__('threading').enumerate()) diff --git a/tests/test_list.py b/tests/test_list.py new file mode 100644 index 0000000..254f1ba --- /dev/null +++ b/tests/test_list.py @@ -0,0 +1,116 @@ +import re +import json +import threading +from pathlib import Path + +import pytest + +from mount.list_order import order_slots +from command_api import command_root, CommandUser +def test_monotonic_statistics_duplicates_unmount_remount(environment, monkeypatch): + m, server, _ = environment + import mount.MountSlot as module + now = [100] + monkeypatch.setattr(module.time,'monotonic_ns',lambda:now[0]) + slot = m.current_slot + slot.on_mount() + now[0] = 110 + slot.on_player_join('Alice') + now[0] = 120 + slot.on_player_join('Alice') + now[0] = 130 + slot.on_player_left('Alice') + now[0] = 140 + slot.on_unmount() + assert slot.stats.use_time_ns_v2 == 40 and slot.stats.player_time_ns_v2 == 20 + now[0] = 1000 + slot.on_mount() + slot.on_mount() + now[0] = 1010 + slot.on_unmount() + assert slot.stats.use_time_ns_v2 == 50 + assert not any(t.name=='StatsChecker' for t in threading.enumerate()) + +def test_invalid_page_and_navigation(environment): + m, _, Source = environment + m._config.list_size = 1 + for page in (0, -1, 99, 'bad'): + src = Source() + m.list_servers(src, page) + assert 'page_corrected' in str(src.replies[0]) + nav = src.replies[-1].to_json_object() + assert 'list 0' not in str(nav) and 'list 2' in str(nav) + assert str(nav).count('clickEvent') == 1 + src = Source() + m.list_servers(src, 2) + nav = str(src.replies[-1].to_json_object()) + assert nav.count('clickEvent') == 1 and 'list 1' in nav + +@pytest.mark.parametrize('reason', ['occupied', 'unchecked', 'missing', 'invalid']) +def test_unavailable_reason_stays_visible(environment, reason): + m, _, Source = environment + from mount.constants import MOUNTABLE_CONFIG + target = Path(m.servers_as_list[1]) + config = target/MOUNTABLE_CONFIG + if reason == 'missing': + m._config.available_servers[1] = str(target/'absent') + elif reason == 'invalid': + config.write_text('{', encoding='utf-8') + else: + data = json.loads(config.read_text()) + data['occupied_by' if reason == 'occupied' else 'checked'] = 'other' if reason == 'occupied' else False + config.write_text(json.dumps(data)) + src = Source() + m.list_servers(src) + visible = '\n'.join(map(str, src.replies)) + assert 'flow.'+reason in visible + assert 'flow.switch' not in visible + +def test_untrusted_activity_falls_back_to_configured_order(environment): + m, _, Source = environment + from mount.constants import MOUNTABLE_CONFIG + current = Path(m.current_slot.path)/MOUNTABLE_CONFIG + data = json.loads(current.read_text()) + data['stats']['schema_version'] = 1 + current.write_text(json.dumps(data)) + target = Path(m.servers_as_list[1])/MOUNTABLE_CONFIG + data = json.loads(target.read_text()) + data['stats']['player_time_ns_v2'] = 999999 + target.write_text(json.dumps(data)) + m._config.list_order = 'activity' + src = Source() + m.list_servers(src) + text = '\n'.join(map(str, src.replies)) + assert 'activity_fallback' in text + assert text.index('current') < text.index('target with spaces') + +def test_list_marks_pinned_current_and_disables_border_actions(environment): + m, server, Source = environment + m._config.pinned_servers = [m.current_slot.path] + src = Source() + m.list_servers(src,1) + visible = '\n'.join(map(str,src.replies)) + assert 'pinned' in visible.lower() and 'current' in visible.lower() and 'flow.unavailable' not in visible, visible + navigation = src.replies[-1].to_json_object() + assert 'clickEvent' not in str(navigation), navigation + +def test_list_border_buttons_have_no_click_action(environment): + m, server, Source = environment + src = Source() + m.list_servers(src,1) + navigation = src.replies[-1].to_json_object() + assert 'clickEvent' not in str(navigation), navigation + +def test_language_fields_match_and_utf8_is_valid(): + from ruamel.yaml import YAML + root = Path(__file__).resolve().parents[1] + data = [YAML().load((root/'lang'/name).read_text(encoding='utf-8'))['mount']['flow'] for name in ['en_us.yml','zh_cn.yml']] + assert data[0].keys() == data[1].keys() + for key in data[0]: + assert set(re.findall(r'{(\w+)}',data[0][key])) == set(re.findall(r'{(\w+)}',data[1][key])) + assert '\ufffd' not in data[1][key] + +def test_stable_sort_pinned_then_activity(): + entries = [('a',10),('b',10),('c',1),('unavailable',0)] + assert order_slots(entries,['missing','c'],'configured') == [entries[2],entries[0],entries[1],entries[3]] + assert order_slots(entries,['c'],'activity') == [entries[2],entries[0],entries[1],entries[3]] diff --git a/tests/test_qa_list.py b/tests/test_qa_list.py new file mode 100644 index 0000000..154b023 --- /dev/null +++ b/tests/test_qa_list.py @@ -0,0 +1,56 @@ +import json +from pathlib import Path +import pytest +from ruamel.yaml import YAML + +def translate_real(monkeypatch, language): + import mount.MountManager as mm + import mount.cmd_tree as tree + data = YAML().load((Path(__file__).resolve().parents[1]/'lang'/(language+'.yml')).read_text(encoding='utf-8'))['mount'] + def translate(key, **values): + section, name = key.split('.') + return data[section][name].format(**values) + monkeypatch.setattr(mm, 'rtr', translate) + monkeypatch.setattr(tree, 'rtr', translate) + return data['flow'] + +@pytest.mark.parametrize('language', ['en_us','zh_cn']) +@pytest.mark.parametrize('reason', ['occupied','unchecked','missing','invalid']) +def test_real_language_list_reasons_and_no_action(environment, monkeypatch, language, reason): + from mount.constants import MOUNTABLE_CONFIG + m, _, Source = environment + flow = translate_real(monkeypatch,language) + target = Path(m.servers_as_list[1]) + config = target/MOUNTABLE_CONFIG + if reason=='missing': m._config.available_servers[1]=str(target/'absent') + elif reason=='invalid': config.write_text('{',encoding='utf-8') + else: + data=json.loads(config.read_text()) + data['occupied_by' if reason=='occupied' else 'checked']='other' if reason=='occupied' else False + config.write_text(json.dumps(data)) + src=Source() + m.list_servers(src) + row=next(row for row in src.replies if flow[reason] in str(row)) + assert 'clickEvent' not in json.dumps(row.to_json_object()) + assert str(target.parent) not in str(row) + print('LIST_REASON',language,reason,str(row)) + +def test_sort_before_pages_and_pinned_overrides_activity(environment): + from mount.constants import MOUNTABLE_CONFIG + m, _, Source = environment + current,target=m.servers_as_list + for path,value in [(current,99999),(target,1)]: + cfg=Path(path)/MOUNTABLE_CONFIG + data=json.loads(cfg.read_text()) + data['stats'].update(schema_version=2,player_time_ns_v2=value) + cfg.write_text(json.dumps(data)) + m._config.list_order='activity' + m._config.pinned_servers=[target] + m._config.list_size=1 + first,second=Source(),Source() + m.list_servers(first,1) + m.list_servers(second,2) + assert 'target with spaces' in str(first.replies[1]) + assert 'current' in str(second.replies[1]) + assert 'pinned' in str(first.replies[1]) + print('ORDER_PAGE',str(first.replies[1]),str(second.replies[1])) From 692847d8a35c6f65ea7df256aa3b0e10155331e5 Mon Sep 17 00:00:00 2001 From: 401U Date: Mon, 5 Oct 2026 00:59:27 +0800 Subject: [PATCH 2/6] 401U-11: document list configuration, migration and verification Co-authored-by: multica-agent --- README.md | 13 ++++++++++--- README_en.md | 17 ++++++++++++----- docs/migration.md | 15 +++++++++++++++ docs/verification.md | 17 +++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 docs/migration.md create mode 100644 docs/verification.md diff --git a/README.md b/README.md index 6643e2f..1618859 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ 1. 配置一个MCDR实例(包括MC服务端)并加入此插件, 将其启动 -2. 依照 (配置选项)[## 配置选项] 修改主体插件配置, **特别是自动检测挂载点的配置**, 然后重载此插件(使用指令`!!mount -r`即可) +2. 依照 [配置选项](#配置选项) 修改主体插件配置, **特别是自动检测挂载点的配置**, 然后重载此插件(使用指令`!!mount -r`即可) -3. 修改在第二步配置的`overwrite_name`文件, 建议添加服务器端口和RCON相关配置来获取一致的体验 +3. 修改在第二步配置的`overwrite_path`文件, 建议添加服务器端口和RCON相关配置来获取一致的体验 4. 在第二步配置的自动检测目录中, 放入更多MC服务端实例, 然后可以游戏内输入`!!mount -l`查看并修改相关配置选项, 在修改完毕之后将`人工检查`项设置为`True` @@ -47,6 +47,8 @@ "mount_name": "MountDemo", // 分页大小 "list_size": 15, + "pinned_servers": [], + "list_order": "configured", // 调试模式, 开启后会在控制台输出更多信息 "debug": false } @@ -78,4 +80,9 @@ ## 其他 - 在自动检测目录的子目录下添加名为`.mount-ignore`的文件可以使该子目录免于检测 - 通过手动修改配置文件, 可以添加任意目录的服务器作为挂载点 -- 实际配置格式均需要满足json格式,即不得包含上例中以`//`开头的注释 \ No newline at end of file +- 实际配置格式均需要满足json格式,即不得包含上例中以`//`开头的注释 +## 列表和统计 + +`pinned_servers` 手动置顶优先;`list_order` 默认 `configured`,可设 `activity` 按新可信玩家时间排序。同分稳定、先排序再分页,不可用服显示原因。旧可疑统计保留但不排行,运行期玩家去重,中途加载不代表完整玩家快照。公开按钮使用私密token,重名用编号消歧,支持含空格完整路径。旧切换/重置请求、确认和取消保持原行为。 + +详见[迁移](docs/migration.md)和[验证边界](docs/verification.md)。客户端渲染/点击、认证玩家和生产环境仍需验证。 diff --git a/README_en.md b/README_en.md index 462ef40..cca7e9c 100644 --- a/README_en.md +++ b/README_en.md @@ -13,11 +13,11 @@ 1. Deploy a MCDR instance(with MC server), and start it with this plugin -2. Edit the main config according to (Config)[## Config], then reload this plugin(by command `!!mount -r` or other command provided by MCDR) +2. Edit the main config according to [Config](#config), then reload this plugin(by command `!!mount -r` or other command provided by MCDR) -3. Edit the file of `overwrite_name` which is configed at setp 2, Recommand to set up server port and rcon to get a consistent experience +3. Edit the file of `overwrite_path` which is configed at setp 2, Recommand to set up server port and rcon to get a consistent experience -4. Add more MC server into the `servers_path` at step 2, then you can use `!!mount -l` to see them and edit their config according to (Config)[## Config], finally make sure set `checked` to true +4. Add more MC server into the `servers_path` at step 2, then you can use `!!mount -l` to see them and edit their config according to [Config](#config), finally make sure set `checked` to true 5. Type `!!mount -l` now, you can see available servers now, and then mount it @@ -47,6 +47,8 @@ "mount_name": "MountDemo", // page size of pagination "list_size": 15, + "pinned_servers": [], + "list_order": "configured", // debug mode, will print more info "debug": false } @@ -69,7 +71,7 @@ // reset method, full for reset all, region for keep up player data(e.g. Parkour record) "reset_type": "full", // mcdr plugin dir for this server, '' and '.' means empty - "plugin_dir": "", + "plugin_dir": "", "stats": { // Stats for this server, will generate automaticaly } @@ -78,4 +80,9 @@ ## Other - add file with name `.mount-ignore` under folder in auto-detect folder to not detect that folder - by editing config file, you can add any server in any folder as mountable server -- the actual config file must be json format, so remove the comments starting with `//` from above config sample \ No newline at end of file +- the actual config file must be json format, so remove the comments starting with `//` from above config sample +## Lists and statistics + +`pinned_servers` takes precedence. `list_order` defaults to `configured`; optional `activity` ranks newly accumulated trusted player time. Ties are stable and ordering precedes pagination. Unavailable servers show reasons. Legacy suspect totals stay for audit only. Runtime player deduplication does not claim a complete roster when loaded midway. Public actions use private tokens, duplicate names use numbers, and complete paths may contain spaces. Existing switch/reset requests, confirmation and cancellation keep their behavior. + +See [migration](docs/migration.md) and [verification boundaries](docs/verification.md). Client rendering/clicking, authenticated players and production remain unverified. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..15fdcfd --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,15 @@ +## 列表与统计迁移 / List and statistics migration + +`mount.json` 增加 `pinned_servers: []` 与 `list_order: "configured"`。置顶路径按配置顺序优先;`activity` 使用新的可信累计玩家时间,同分保持配置顺序,先排序再分页。不可用服仍显示原因,不提供操作按钮。非法页码回到第一页并提示;非法分页大小使用15。 + +旧 `mountable.json` 可直接加载。旧 `total_use_time`、`total_player_time`、`total_players` 保留审计,不参与排行。`schema_version: 2`、`use_time_ns_v2`、`player_time_ns_v2` 从新安装后的单调时钟增量累计;运行期去重玩家,不新增永久玩家名单。中途加载只统计此后观察到的可信玩家事件,不能代表完整在线快照,也不追补以前的时间。不可信活跃度降级为置顶后的配置顺序。 + +列表显示名称和同名编号;完整SHA-256目标token映射配置路径,公开按钮不带绝对路径。`!!mount <完整路径或token>`(可含空格)、尾随 `--confirm`、`--reset`、`--confirm`、`--abort` 保持原请求/确认/取消流程与权限。本版本不包含投票、强制或备份入口。管理员的配置查看仍使用原配置命令。 + +Add `pinned_servers: []` and `list_order: "configured"` to the instance configuration. Pins follow their configured order. Optional `activity` uses newly accumulated player time; ties are stable and ordering precedes pagination. Unavailable servers remain visible with reasons and no action. Invalid pages return to page 1 with a notice; invalid page sizes use 15. + +Existing slot configurations load unchanged. Legacy totals remain for audit and never rank servers. Schema 2 use/player time starts with new monotonic increments. Player deduplication is runtime only. Loading midway counts subsequently observed player events; it does not claim a complete roster or reconstruct past time. Untrusted activity falls back to configured order after pins. + +Names and duplicate-name numbers replace paths in public rows. Full SHA-256 target tokens map exactly to configured paths. Existing mount/reset/confirm/abort behavior and permissions remain, including complete paths containing spaces and the trailing `--confirm` alias. There are no vote, force or backup commands in this version. + +修改配置前备份配置文件;回退保留旧审计字段。统计值不能当作完整认证玩家历史。Back up configuration before editing; retain legacy audit fields when reverting. Statistics are not a complete authenticated-player history. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..ff6f305 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,17 @@ +## 列表验证 / List verification + +使用真实MCDR命令节点、RText及序列化API;仅PSI服务边界为隔离fixture。`tests/test_list.py` 保留原排序/统计/分页及QA-03语义,`tests/test_legacy.py` 增加旧命令请求/确认/取消、空格目标、真实双语文本和重载线程检查。`tests/command_api.py` 依真实2.7/2.14/2.16回调返回值执行,不mock parser。 + +Run from the repository root: + +```powershell +uv run --python 3.11 --with-requirements requirements-test-2.7.txt python -m pytest -q -s +uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforged==2.14.0 python -m pytest -q -s +uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforged==2.16.0 python -m pytest -q -s +``` + +2.7/2.14/2.16各26项通过(退出码0),包括原QA9项列表探针;工程复跑不代替新SHA独立审查。测试覆盖默认配置顺序、手动置顶、活跃度与同分、排序先于分页、异常活跃度降级、全部不可用原因、完整token与隐私、边界按钮、空列表/非法页码/大小、重复加入离开、反复挂载与重载/卸载无统计线程残留。旧操作测试校验原请求状态及确认分派,不宣称执行器或磁盘操作可靠性已由本方向修复。 + +Tests cover ordering, pagination, status reasons, privacy, exact targets, legacy dispatch and statistics lifecycle. Server/filesystem operation reliability belongs to the operations change. API/fixture passes do not establish client rendering or authenticated-player behavior. + +未验证 / Unverified: 客户端中英文渲染/点击、真实认证玩家统计、Paper/Bukkit、生产版本/配置。历史PR13 Java证据只属于原SHA,不作为本列表SHA重跑证据。保留旧操作失败、跨实例占用TOCTOU、非事务重置、历史退出及CI风险;无合并发布授权。 From ad5929305c252214c6347607e3648fe4d5c62051 Mon Sep 17 00:00:00 2001 From: 401U Date: Mon, 5 Oct 2026 01:01:38 +0800 Subject: [PATCH 3/6] 401U-11: normalize shared command helper whitespace Co-authored-by: multica-agent --- tests/command_api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/command_api.py b/tests/command_api.py index 2aa2072..909a9ae 100644 --- a/tests/command_api.py +++ b/tests/command_api.py @@ -52,5 +52,3 @@ def execute(self,source,command): for execution in executions: execution.scheduled_callback.invoke(DirectCallbackInvoker()) return Root() - - From 8c291a80ee3f185434c9e0a994c2af57a59a1631 Mon Sep 17 00:00:00 2001 From: 401U Date: Mon, 5 Oct 2026 01:15:28 +0800 Subject: [PATCH 4/6] 401U-11: resolve exact configured paths before legacy confirm suffix (QA-L01) Co-authored-by: multica-agent --- mount/cmd_tree.py | 5 ++- tests/test_legacy.py | 17 ++++++++++ tests/test_qa_extra.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/test_qa_extra.py diff --git a/mount/cmd_tree.py b/mount/cmd_tree.py index afe66f7..665e881 100644 --- a/mount/cmd_tree.py +++ b/mount/cmd_tree.py @@ -55,7 +55,10 @@ def get_slot_node(): # to check if is a usable slot def legacy_mount(src, target): suffix = ' --confirm' - confirmed = target.endswith(suffix) + # A configured complete path may itself end in the legacy flag. + # Resolve the exact input before treating any suffix as syntax. + exact = manager.resolve_target(target) + confirmed = exact not in manager.servers_as_list and target.endswith(suffix) if confirmed: target = target[:-len(suffix)] path = manager.resolve_target(target) diff --git a/tests/test_legacy.py b/tests/test_legacy.py index f0c720b..0093586 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -103,3 +103,20 @@ def test_reload_and_unload_stop_statistics(environment, monkeypatch): assert newer is not m entry.on_unload(server) assert not any(t.name == 'StatsChecker' for t in __import__('threading').enumerate()) + + +@pytest.mark.parametrize('input_kind', ['path', 'token', 'path_alias', 'token_alias']) +def test_confirm_suffix_prefers_exact_configured_target(environment, input_kind): + from mount.constants import MOUNTABLE_CONFIG + m, _, _ = environment + base = Path(m.servers_as_list[1]) + target = Path(str(base)+' --confirm') + target.mkdir() + (target/MOUNTABLE_CONFIG).write_bytes((base/MOUNTABLE_CONFIG).read_bytes()) + m._config.available_servers.append(str(target)) + value = m.target_token(str(target)) if input_kind.startswith('token') else str(target) + if input_kind.endswith('alias'): + value += ' --confirm' + src = CommandUser() + command_root(m).execute(src, '!!mount '+value) + assert m.next_slot is not None and m.next_slot.path == str(target) diff --git a/tests/test_qa_extra.py b/tests/test_qa_extra.py new file mode 100644 index 0000000..babc9f4 --- /dev/null +++ b/tests/test_qa_extra.py @@ -0,0 +1,71 @@ +import json +import threading +from pathlib import Path + +import pytest +from command_api import CommandUser, command_root + + +def test_stats_preserve_fresh_configuration_and_audit(environment, monkeypatch): + import mount.MountSlot as ms + from mount.constants import MOUNTABLE_CONFIG + m, _, _ = environment + slot = m.current_slot + now = [100] + monkeypatch.setattr(ms.time, 'monotonic_ns', lambda: now[0]) + slot.on_mount() + path = Path(slot.path) / MOUNTABLE_CONFIG + data = json.loads(path.read_text()) + data['reset_path'] = 'new-template' + data['desc'] = 'edited-description' + path.write_text(json.dumps(data)) + now[0] = 200 + slot.update_stats() + saved = json.loads(path.read_text()) + assert saved['reset_path'] == 'new-template' + assert saved['desc'] == 'edited-description' + assert saved['stats']['use_time_ns_v2'] == 100 + slot.on_unmount() + + +def test_two_player_increment_and_unknown_departure(environment, monkeypatch): + import mount.MountSlot as ms + m, _, _ = environment + s = m.current_slot + now = [0] + monkeypatch.setattr(ms.time, 'monotonic_ns', lambda: now[0]) + s.on_mount() + s.on_player_join('Alice') + s.on_player_join('Bob') + now[0] = 10 + s.on_player_left('unknown') + now[0] = 20 + s.on_player_left('Alice') + now[0] = 30 + s.on_unmount() + assert s.stats.player_time_ns_v2 == 50 + assert s.stats.use_time_ns_v2 == 30 + assert not any(t.name == 'StatsChecker' for t in threading.enumerate()) + + +def test_worker_stop_wakes_promptly(): + from mount.MountSlot import StatsChecker + callbacks = [] + worker = StatsChecker(600, lambda: callbacks.append(1)) + worker.start() + worker.stop() + worker.join(1) + assert not worker.is_alive() and not callbacks + + +def test_legal_space_path_ending_confirm_is_exact(environment): + m, _, _ = environment + from mount.constants import MOUNTABLE_CONFIG + original = Path(m.servers_as_list[1]) + target = original.parent / 'legal target --confirm' + target.mkdir() + (target / MOUNTABLE_CONFIG).write_bytes((original / MOUNTABLE_CONFIG).read_bytes()) + m._config.available_servers.append(str(target)) + source = CommandUser() + command_root(m).execute(source, '!!mount ' + str(target)) + assert m.next_slot is not None and m.next_slot.path == str(target), source.replies From 156558fce407ac819ecaa380a3c3ac603da99dc4 Mon Sep 17 00:00:00 2001 From: 401U Date: Mon, 5 Oct 2026 19:06:01 +0800 Subject: [PATCH 5/6] Remove historical data adapters and document current list configuration Co-authored-by: multica-agent --- README.md | 6 ++-- README_en.md | 7 ++++ docs/migration.md | 15 --------- docs/verification.md | 17 ---------- mcdreforged.plugin.json | 4 +-- mount/cmd_tree.py | 6 ++-- mount/config.py | 72 +++-------------------------------------- tests/test_legacy.py | 19 +++++++++-- 8 files changed, 36 insertions(+), 110 deletions(-) delete mode 100644 docs/migration.md delete mode 100644 docs/verification.md diff --git a/README.md b/README.md index 1618859..5aca6cc 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ - 实际配置格式均需要满足json格式,即不得包含上例中以`//`开头的注释 ## 列表和统计 -`pinned_servers` 手动置顶优先;`list_order` 默认 `configured`,可设 `activity` 按新可信玩家时间排序。同分稳定、先排序再分页,不可用服显示原因。旧可疑统计保留但不排行,运行期玩家去重,中途加载不代表完整玩家快照。公开按钮使用私密token,重名用编号消歧,支持含空格完整路径。旧切换/重置请求、确认和取消保持原行为。 +`pinned_servers` 手动置顶优先;`list_order` 为 `configured` 或 `activity`。可信玩家时间用于活跃度排序,同分稳定,先排序再分页;不可用目标仍显示状态与原因。统计使用单调时钟累计,玩家去重仅限运行期,中途加载只记录随后观察到的事件。 -详见[迁移](docs/migration.md)和[验证边界](docs/verification.md)。客户端渲染/点击、认证玩家和生产环境仍需验证。 +`!!mount --list [page]` 查看列表;`!!mount <完整路径或token>` 请求切换,`!!mount --reset` 请求重置,`!!mount --confirm` 确认当前请求,`!!mount --abort` 取消。完整目标优先解析,尾随 ` --confirm` 仅在输入不是完整已配置目标时作为确认参数。公开按钮使用token,重名显示编号,不公开绝对路径。 + +安装版本1.0.0时按上面的格式创建配置,`servers_path` 必须为字符串数组。统计由插件生成;`schema_version: 2` 标识可信计时格式,`use_time_ns_v2`/`player_time_ns_v2` 为纳秒累计值,`last_mount_ns` 为挂载锚点,不维护永久玩家名单。 diff --git a/README_en.md b/README_en.md index cca7e9c..0c80c71 100644 --- a/README_en.md +++ b/README_en.md @@ -86,3 +86,10 @@ `pinned_servers` takes precedence. `list_order` defaults to `configured`; optional `activity` ranks newly accumulated trusted player time. Ties are stable and ordering precedes pagination. Unavailable servers show reasons. Legacy suspect totals stay for audit only. Runtime player deduplication does not claim a complete roster when loaded midway. Public actions use private tokens, duplicate names use numbers, and complete paths may contain spaces. Existing switch/reset requests, confirmation and cancellation keep their behavior. See [migration](docs/migration.md) and [verification boundaries](docs/verification.md). Client rendering/clicking, authenticated players and production remain unverified. +## List and statistics + +`pinned_servers` takes priority. `list_order` accepts `configured` or `activity`; trusted player time determines activity order, ties stay stable and sorting precedes pagination. Unavailable targets show their status and reason. Monotonic counters accumulate observed events; player deduplication lasts only for the running session. + +Use `!!mount --list [page]`, `!!mount `, `!!mount --reset`, `!!mount --confirm` and `!!mount --abort`. Exact configured targets take precedence over the trailing ` --confirm` parameter. Public buttons use tokens and duplicate names use numbered labels without exposing absolute paths. + +For version1.0.0 create configuration in the format above. `servers_path` must be an array of strings. Generated statistics use `schema_version: 2`, nanosecond `use_time_ns_v2`/`player_time_ns_v2` counters and the `last_mount_ns` anchor; no permanent player list is stored. diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index 15fdcfd..0000000 --- a/docs/migration.md +++ /dev/null @@ -1,15 +0,0 @@ -## 列表与统计迁移 / List and statistics migration - -`mount.json` 增加 `pinned_servers: []` 与 `list_order: "configured"`。置顶路径按配置顺序优先;`activity` 使用新的可信累计玩家时间,同分保持配置顺序,先排序再分页。不可用服仍显示原因,不提供操作按钮。非法页码回到第一页并提示;非法分页大小使用15。 - -旧 `mountable.json` 可直接加载。旧 `total_use_time`、`total_player_time`、`total_players` 保留审计,不参与排行。`schema_version: 2`、`use_time_ns_v2`、`player_time_ns_v2` 从新安装后的单调时钟增量累计;运行期去重玩家,不新增永久玩家名单。中途加载只统计此后观察到的可信玩家事件,不能代表完整在线快照,也不追补以前的时间。不可信活跃度降级为置顶后的配置顺序。 - -列表显示名称和同名编号;完整SHA-256目标token映射配置路径,公开按钮不带绝对路径。`!!mount <完整路径或token>`(可含空格)、尾随 `--confirm`、`--reset`、`--confirm`、`--abort` 保持原请求/确认/取消流程与权限。本版本不包含投票、强制或备份入口。管理员的配置查看仍使用原配置命令。 - -Add `pinned_servers: []` and `list_order: "configured"` to the instance configuration. Pins follow their configured order. Optional `activity` uses newly accumulated player time; ties are stable and ordering precedes pagination. Unavailable servers remain visible with reasons and no action. Invalid pages return to page 1 with a notice; invalid page sizes use 15. - -Existing slot configurations load unchanged. Legacy totals remain for audit and never rank servers. Schema 2 use/player time starts with new monotonic increments. Player deduplication is runtime only. Loading midway counts subsequently observed player events; it does not claim a complete roster or reconstruct past time. Untrusted activity falls back to configured order after pins. - -Names and duplicate-name numbers replace paths in public rows. Full SHA-256 target tokens map exactly to configured paths. Existing mount/reset/confirm/abort behavior and permissions remain, including complete paths containing spaces and the trailing `--confirm` alias. There are no vote, force or backup commands in this version. - -修改配置前备份配置文件;回退保留旧审计字段。统计值不能当作完整认证玩家历史。Back up configuration before editing; retain legacy audit fields when reverting. Statistics are not a complete authenticated-player history. diff --git a/docs/verification.md b/docs/verification.md deleted file mode 100644 index ff6f305..0000000 --- a/docs/verification.md +++ /dev/null @@ -1,17 +0,0 @@ -## 列表验证 / List verification - -使用真实MCDR命令节点、RText及序列化API;仅PSI服务边界为隔离fixture。`tests/test_list.py` 保留原排序/统计/分页及QA-03语义,`tests/test_legacy.py` 增加旧命令请求/确认/取消、空格目标、真实双语文本和重载线程检查。`tests/command_api.py` 依真实2.7/2.14/2.16回调返回值执行,不mock parser。 - -Run from the repository root: - -```powershell -uv run --python 3.11 --with-requirements requirements-test-2.7.txt python -m pytest -q -s -uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforged==2.14.0 python -m pytest -q -s -uv run --python 3.11 --with-requirements requirements-test.txt --with mcdreforged==2.16.0 python -m pytest -q -s -``` - -2.7/2.14/2.16各26项通过(退出码0),包括原QA9项列表探针;工程复跑不代替新SHA独立审查。测试覆盖默认配置顺序、手动置顶、活跃度与同分、排序先于分页、异常活跃度降级、全部不可用原因、完整token与隐私、边界按钮、空列表/非法页码/大小、重复加入离开、反复挂载与重载/卸载无统计线程残留。旧操作测试校验原请求状态及确认分派,不宣称执行器或磁盘操作可靠性已由本方向修复。 - -Tests cover ordering, pagination, status reasons, privacy, exact targets, legacy dispatch and statistics lifecycle. Server/filesystem operation reliability belongs to the operations change. API/fixture passes do not establish client rendering or authenticated-player behavior. - -未验证 / Unverified: 客户端中英文渲染/点击、真实认证玩家统计、Paper/Bukkit、生产版本/配置。历史PR13 Java证据只属于原SHA,不作为本列表SHA重跑证据。保留旧操作失败、跨实例占用TOCTOU、非事务重置、历史退出及CI风险;无合并发布授权。 diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json index 7035938..d7b66c4 100644 --- a/mcdreforged.plugin.json +++ b/mcdreforged.plugin.json @@ -1,6 +1,6 @@ { "id": "mount", - "version": "0.3.1", + "version": "1.0.0", "name": "Mount", "description": { "en_us": "Mount different minecraft server into a mcdr server, with overwritten properties!", @@ -21,4 +21,4 @@ "README.md", "README_en.md" ] -} \ No newline at end of file +} diff --git a/mount/cmd_tree.py b/mount/cmd_tree.py index 665e881..b9b6792 100644 --- a/mount/cmd_tree.py +++ b/mount/cmd_tree.py @@ -53,9 +53,9 @@ def get_slot_node(): # to check if is a usable slot return Text('slot_path').requires(lambda src, ctx: ctx['slot_path'] in manager.servers_as_list, lambda src, ctx: rtr('error.invalid_mount_path')) - def legacy_mount(src, target): + def request_target(src, target): suffix = ' --confirm' - # A configured complete path may itself end in the legacy flag. + # A configured complete path may itself end in the confirmation suffix. # Resolve the exact input before treating any suffix as syntax. exact = manager.resolve_target(target) confirmed = exact not in manager.servers_as_list and target.endswith(suffix) @@ -98,7 +98,7 @@ def legacy_mount(src, target): ).then( Literal({'--reload', '-r'}).runs(lambda src, ctx: manager.reload(src)) ).then( - GreedyText('mount_target').runs(lambda src, ctx: legacy_mount(src, ctx['mount_target'])) + GreedyText('mount_target').runs(lambda src, ctx: request_target(src, ctx['mount_target'])) ) server.register_command(main_node) server.register_help_message(COMMAND_PREFIX, rtr("help_msg.brief")) diff --git a/mount/config.py b/mount/config.py index 08d7136..f8a6b85 100644 --- a/mount/config.py +++ b/mount/config.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import List from mcdreforged.api.rtext import * from mcdreforged.api.utils import Serializable @@ -10,7 +10,7 @@ class MountConfig(Serializable): welcome_player: bool = True short_prefix: bool = True # let !!m to be a short command - servers_path: Union[str, List[str]] = [ "../servers" ] + servers_path: List[str] = [ "../servers" ] overwrite_path: str = "../servers/server.properties.overwrite" # available mc servers for this MCDR instance, should be same with the dirname of that server @@ -24,14 +24,6 @@ class MountConfig(Serializable): pinned_servers: List[str] = [] list_order: str = 'configured' - def migrate(self): - need_save = False - if isinstance(self.servers_path, str): - need_save = True - self.servers_path = [self.servers_path] - if need_save: - self.save() - def save(self): debug(f'Saving plugin config...') psi.save_config_simple( @@ -41,7 +33,8 @@ def save(self): def load() -> 'MountConfig': config = psi.load_config_simple(file_name=CONFIG_NAME, target_class=MountConfig, in_data_folder=False) setDebugNoCheck(config.debug) - config.migrate() + if not isinstance(config.servers_path, list) or not all(isinstance(path, str) for path in config.servers_path): + raise ValueError('servers_path must be a list of paths') return config @@ -50,9 +43,6 @@ class SlotStats(Serializable): use_time_ns_v2: int = 0 player_time_ns_v2: int = 0 last_mount_ns: int = -1 - total_use_time: int = 0 - total_player_time: int = 0 - total_players: int = 0 def display(self) -> RTextBase: # [TODO) display stats to users @@ -99,57 +89,3 @@ def get_config_text(config_key: str): for key in conf_list: payload.append(get_config_text(key)) return payload - - - def as_list_entry(self, name: str, server_path: str, mount_name: str, current_mount: str): - """ - - path [↻] - """ - - def get_button() -> RTextBase: - error_button = RText("[?]", color=RColor.red).h( - rtr("list.error_btn.hover")) - mount_button = RText("[▷]") - reset_button = RText("[↻]", color=RColor.yellow).c(RAction.suggest_command, - COMMAND_PREFIX + " --reset") - if server_path == current_mount and mount_name == self.occupied_by: - if self.reset_path in ["", None, '.']: - reset_button.set_color(RColor.gray).h( - rtr("list.reset_btn.unusable")) - else: - reset_button.set_color(RColor.green).h( - rtr("list.reset_btn.reset")) - return reset_button - elif not self.checked: - return mount_button.set_color(RColor.gray).h(rtr('list.mount_btn.uncheck')) - elif self.occupied_by in [None, ""]: - return mount_button.h(rtr("list.mount_btn.normal", server_name=server_path)) \ - .set_color(RColor.green).c(RAction.suggest_command, COMMAND_PREFIX + " " + server_path) - elif self.occupied_by != mount_name and server_path != current_mount: - return mount_button.set_color(RColor.gray).h( - rtr("list.mount_btn.occupied", occupied_by=self.occupied_by)) - else: - return error_button - - def get_path(): - path_text = RText(name).h(rtr("list.hover_on_name")) \ - .c(RAction.suggest_command, f"{COMMAND_PREFIX} --config {server_path}") - if not self.checked: - path_text.set_color(RColor.gray).set_styles( - RStyle.strikethrough) - elif server_path == current_mount and mount_name == self.occupied_by: - path_text.set_color( - RColor.light_purple).set_styles(RStyle.bold) - elif self.occupied_by in ["", None]: - path_text.set_color(RColor.green) - else: - path_text.set_color(RColor.red) - return path_text - - return RTextList( - get_button(), - ' ', - get_path(), - ' ', - RText(self.desc) - ) diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 0093586..66f20cc 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -6,7 +6,7 @@ from command_api import CommandUser, command_root -def test_old_commands_request_confirm_cancel(environment, monkeypatch): +def test_request_commands_request_confirm_cancel(environment, monkeypatch): import mount.MountManager as mm m, server, Source = environment root = command_root(m) @@ -76,10 +76,10 @@ def translate(key, **kwargs): print(language, rendered) -def test_old_config_empty_and_bad_size(environment): +def test_defaults_empty_and_bad_size(environment): from mount.config import MountConfig m, _, Source = environment - old = MountConfig.deserialize({'current_server': m.current_slot.path, 'servers_path': '../servers'}) + old = MountConfig.deserialize({'current_server': m.current_slot.path, 'servers_path': ['../servers']}) assert old.pinned_servers == [] and old.list_order == 'configured' assert not hasattr(old, 'backup_permission') m._config.available_servers = [] @@ -120,3 +120,16 @@ def test_confirm_suffix_prefers_exact_configured_target(environment, input_kind) src = CommandUser() command_root(m).execute(src, '!!mount '+value) assert m.next_slot is not None and m.next_slot.path == str(target) + + +def test_current_config_rejects_string_path_and_drops_audit_fields(environment, monkeypatch): + import mount.config as config + m, server, _ = environment + monkeypatch.setattr(config, 'setDebugNoCheck', lambda value: None) + monkeypatch.setattr(config.psi, 'load_config_simple', lambda **kwargs: + config.MountConfig.deserialize({'servers_path': '../servers'})) + with pytest.raises(ValueError, match='servers_path'): + config.MountConfig.load() + stats = config.SlotStats.deserialize({'total_use_time': 999, 'total_player_time': 999, 'total_players': 999}) + assert not any(key.startswith('total_') for key in stats.serialize()) + assert stats.use_time_ns_v2 == 0 and stats.player_time_ns_v2 == 0 From 6cda3c8cd8b207cfad6b1f190dad24082a085ff6 Mon Sep 17 00:00:00 2001 From: 401U Date: Mon, 5 Oct 2026 19:08:12 +0800 Subject: [PATCH 6/6] Verify strict current schema and remove obsolete English guidance Co-authored-by: multica-agent --- README_en.md | 5 ----- tests/test_legacy.py | 8 ++------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/README_en.md b/README_en.md index 0c80c71..70b3366 100644 --- a/README_en.md +++ b/README_en.md @@ -81,11 +81,6 @@ - add file with name `.mount-ignore` under folder in auto-detect folder to not detect that folder - by editing config file, you can add any server in any folder as mountable server - the actual config file must be json format, so remove the comments starting with `//` from above config sample -## Lists and statistics - -`pinned_servers` takes precedence. `list_order` defaults to `configured`; optional `activity` ranks newly accumulated trusted player time. Ties are stable and ordering precedes pagination. Unavailable servers show reasons. Legacy suspect totals stay for audit only. Runtime player deduplication does not claim a complete roster when loaded midway. Public actions use private tokens, duplicate names use numbers, and complete paths may contain spaces. Existing switch/reset requests, confirmation and cancellation keep their behavior. - -See [migration](docs/migration.md) and [verification boundaries](docs/verification.md). Client rendering/clicking, authenticated players and production remain unverified. ## List and statistics `pinned_servers` takes priority. `list_order` accepts `configured` or `activity`; trusted player time determines activity order, ties stay stable and sorting precedes pagination. Unavailable targets show their status and reason. Monotonic counters accumulate observed events; player deduplication lasts only for the running session. diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 66f20cc..ece3d15 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -124,12 +124,8 @@ def test_confirm_suffix_prefers_exact_configured_target(environment, input_kind) def test_current_config_rejects_string_path_and_drops_audit_fields(environment, monkeypatch): import mount.config as config - m, server, _ = environment - monkeypatch.setattr(config, 'setDebugNoCheck', lambda value: None) - monkeypatch.setattr(config.psi, 'load_config_simple', lambda **kwargs: - config.MountConfig.deserialize({'servers_path': '../servers'})) - with pytest.raises(ValueError, match='servers_path'): - config.MountConfig.load() + with pytest.raises(TypeError): + config.MountConfig.deserialize({'servers_path': '../servers'}) stats = config.SlotStats.deserialize({'total_use_time': 999, 'total_player_time': 999, 'total_players': 999}) assert not any(key.startswith('total_') for key in stats.serialize()) assert stats.use_time_ns_v2 == 0 and stats.player_time_ns_v2 == 0