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/README.md b/README.md index 6643e2f..5aca6cc 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,11 @@ ## 其他 - 在自动检测目录的子目录下添加名为`.mount-ignore`的文件可以使该子目录免于检测 - 通过手动修改配置文件, 可以添加任意目录的服务器作为挂载点 -- 实际配置格式均需要满足json格式,即不得包含上例中以`//`开头的注释 \ No newline at end of file +- 实际配置格式均需要满足json格式,即不得包含上例中以`//`开头的注释 +## 列表和统计 + +`pinned_servers` 手动置顶优先;`list_order` 为 `configured` 或 `activity`。可信玩家时间用于活跃度排序,同分稳定,先排序再分页;不可用目标仍显示状态与原因。统计使用单调时钟累计,玩家去重仅限运行期,中途加载只记录随后观察到的事件。 + +`!!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 462ef40..70b3366 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,11 @@ ## 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 +## 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/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/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/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..b9b6792 100644 --- a/mount/cmd_tree.py +++ b/mount/cmd_tree.py @@ -53,6 +53,20 @@ 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 request_target(src, target): + suffix = ' --confirm' + # 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) + 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 +89,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 +98,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: 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 239b4eb..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 @@ -9,8 +9,8 @@ class MountConfig(Serializable): welcome_player: bool = True - short_prefix = True # let !!m to be a short command - servers_path: Union[str, List[str]] = [ "../servers" ] + short_prefix: bool = True # let !!m to be a short command + 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 @@ -21,14 +21,8 @@ class MountConfig(Serializable): mount_name: str = "MountDemo" list_size: int = 15 debug: bool = False - - 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() + pinned_servers: List[str] = [] + list_order: str = 'configured' def save(self): debug(f'Saving plugin config...') @@ -39,15 +33,16 @@ 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 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 - total_players: int = 0 def display(self) -> RTextBase: # [TODO) display stats to users @@ -94,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/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..909a9ae --- /dev/null +++ b/tests/command_api.py @@ -0,0 +1,54 @@ +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..ece3d15 --- /dev/null +++ b/tests/test_legacy.py @@ -0,0 +1,131 @@ +import json +from pathlib import Path + +import pytest + +from command_api import CommandUser, command_root + + +def test_request_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_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']}) + 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()) + + +@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) + + +def test_current_config_rejects_string_path_and_drops_audit_fields(environment, monkeypatch): + import mount.config as config + 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 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_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 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]))