用户工具

站点工具


tools:pymud

PyMUD - Python原生MUD客户端

写在最前面的话

最早想要自己写MUD客户端的念头,还是在几年前。但前几年事情太多,人太忙,我记得自20年疫情之后,到今年年初就没有再登陆过北侠了。23年春节之后空闲一些,于2023年2月19日重启MUD客户端的计划,5月29日形成第一个发布版(0.05b),12月5日发布首个支持pip安装的package版本(0.15),目前发布pip安装的最新版为0.17.3。

在自己写客户端之前,我主要用过zmud和mushclient两个客户端,北大侠客行一直是用mushclient(玩的那会儿还没有mudlet)。我认为mushclient是一个功能非常强大的客户端,唯一缺点是不支持跨平台。由于工作原因,上班的地方不能上网,手机玩的话,确实没有特别适合的跨平台客户端(tintint++倒是支持,但一直不想重学然后重写我在mushclient里的所有python脚本),加上我是一个程序爱好者,所以决定自己干起,正好在游戏之中学习了。

因为我要综合平衡工作、生活、写代码、当然还有自己玩,所以整个更新节奏不会很快,但我认为我会一直更新下去的。感谢北大侠客行巫师团队的努力,北侠吸引我玩的动力,也是我不断更新完善客户端的动力!

0. 最近更新

0.17.3 (2024-01-02)

  • 问题修复:修复了原有的#repeat功能。命令行#repeat/#rep可以重复输入上一次命令(这个基本没用,主要是我在远程连接时,手机上没有方向键...)
  • 问题修复:修改定时器的实现方式,真正修复了定时器每reload后会新增一个的bug。
  • 功能增加:命令行使用#tri, #ali, #cmd, #ti时,除了接受on/off参数外,增加了del参数,可以删除对应的触发器、别名、命令、定时器。例如:#ti tm_test del 可以删除id为“tm_test”的定时器。
  • 功能调整:调整了#help {cmd}的显示格式,最后一行也增加了换行符,确保后续数据在下一行出现。
  • 功能调整:调整了Timer和SimpleTimer在#timer时的显示格式。
  • 实现调整:调整了Session.clean实现中各对象清理的顺序,将任务清除移到了最后。

0.17.2 (2023-12-28)

  • 功能修改:会话菜单 "显示/隐藏命令" 和 "打开/关闭自动重连" 操作后,增加在当前会话中提示状态信息。
  • 功能修改:Timer实现进行修改,以确保一个定时器仅创建一个任务。
  • 功能调整:Timer对象在复位Session对象时,也同时复位。目的是确保reload时不重新创建定时器任务。
  • 功能调整:在会话连接时,不再复位Session有关对象信息。该复位活动仅在连接断开时和脚本重新加载时进行。
  • 功能调整:启动PYMUD时,会将控制台标题设置为PYMUD+版本号。

0.17.1post1 (2023-12-27)

  • 问题修复:修复enableGroup中定时器处的bug
  • 功能修改:会话连接和重新连接时,取消原定时器停止的设定,目前保留为只清除所有task、复位Command
  • 功能修改:auto_reconnect设定目前对正常/异常断开均有效。若设置为True,当连接断开后15s后自动重连
  • 功能修改:会话菜单下增加“打开/关闭自动重连”子菜单,可以动态切换自动重连是否打开。

0.17.1 (2023-12-27)

本版对模块功能进行了整体调整,支持加载/卸载/重载/预加载多个模块,具体内容如下:

  • 当模块中存在名为Configuration类时,以主模块形式加载,即:自动创建该Configuration类的实例(与原脚本相同)
  • 当模块中不存在名为Configuration类时,以子模块形式加载,即:仅加载该模块,但不会创建Configuration的实例
  • :#load命令支持同时加载多个模块,模块名以半角逗号(,)隔开即可。此时按给定的名称顺序逐一加载。如:#load mod1,mod2
  • 增加#unload命令,卸载卸载名称模块,同时卸载多个模块时,模块名以半角逗号(,)隔开即可。卸载时,如果该模块有Configuration类,会自动调用其del方法
  • 修改reload命令功能,当不带参数时,重新加载所有已加载模块,带参数时,首先尝试重新加载指定名称模块,若模块中不存在该名称模块,则重新加载指定名称的插件。若存在同名模块和插件,则仅重新加载插件(建议不要让插件和模块同名)
  • 增加#modules(简写为#mods)命令,可以列出所有已经加载的模块清单
  • Session类新增load_module方法,可以在脚本中调用以加载给定名称的模块。该方法接受1个参数,可以使用元组/列表形式指定多个模块,也可以使用字符串指定单个模块
  • Session类新增unload_module方法,可以在脚本中调用以卸载给定名称的模块。参数与load_module类似。
  • Session类新增reload_module方法,可以在脚本中调用以重新加载给定名称的模块。当不指定参数时,重新加载所有模块。当指定1个参数时,与load_module和unload_module方法类似
  • 修改Settings.py和本地pymud.cfg文件中sessions块脚本的定义的可接受值。默认加载脚本default_script现可接受字符串和列表以支持多个模块加载。多个模块加载有两种形式,既可以用列表形式指定多个,如["script1","script2"],也可以用字符串以逗号隔开指定多个,如"script1,script2"
  • 修改Settings.py和本地pymud.cfg文件中sessions块脚本中chars指定的会话菜单参数。当前,菜单后面的列表参数可以支持额外增加第3个对象,其中第3个为该会话特定需要加载的模块。该参数也可以使用逗号分隔或者列表形式。
  • 当创建会话时,自动加载的模块会首先加载default_script中指定的模块名称,然后再加载chars中指定的模块名称。
  • 上述所有修改均向下兼容,不影响原脚本使用。

0.17.0 (2023-12-24)

  • 功能修改:调整修改GMCP数据的wildcards处理方式,恢复为eval,其余不变。(回滚0.16.2版更改)
  • 功能修改:将本地pymud.cfg文件的读取默认编码调整为utf8,以避免加载出现问题
  • 问题修复:sessions.py中,修复系统command与会话command重名的问题(这次才发现)
  • 功能修改:将自动脚本加载调整到session创建初始,而不论是否连接服务器
  • 功能修改:脚本load和reload时,不再清空任何对象,保留内容包括:中止并清空所有task,关闭所有定时器,将所有异步对象复位
  • 功能修改:去掉了左右边框
  • 问题修复:修复了当使用session.addCommand/addTrigger/addAlias等添加对象,而对象是Command/Trigger/Alias等的子类时,由于类型检查失败导致无法成功的问题
  • 功能修改:增加自动重连配置,Settings.client["auto_reconnect"]配置,当为True时,若连接过程中出现异常断开,则10秒后自动重连。该配置默认为False。
  • 功能修改:当连接过程中出现异常时,异常提示中增加异常时刻。
  • 功能修改:#reload指令增加可以重新加载插件功能。例如,#reload chathook会重新加载名为chathook的插件。
  • 功能增加:增加#py指令,可以直接在命令行中写代码并执行。执行的上下文环境为当前环境,即self代表当前session。例如,#py self.writeline("xixi")相当于直接在脚本会话中调用发送xixi指令
  • 功能新增:新增插件(Plugins)功能。将自动读取pymud模块目录的plugins子目录以及当前脚本目录的plugins子目录下的.py文件,若发现遵照插件规范脚本,将自动加载该模块到pymud。可以使用#plugins查看所有被加载的插件,可以直接带参数插件名(如#plugins myplugin)查看插件的详细信息(自动打印插件的doc属性,即写在文件最前面的字符串常量)插件文件中必须有以下定义:
名称类型状态含义
PLUGIN_NAMEstr必须有插件唯一名称
PLUGIN_DESCdict必须有插件描述信息的详情,必要关键字包含VERSION(版本)、AUTHOR(作者)、RELEASE_DATE(发布日期)、DESCRIPTION(简要描述)
PLUGIN_PYMUD_STARTfunc(app)函数定义必须有,函数体可以为空PYMUD自动读取并加载插件时自动调用的函数, app为PyMudApp(pymud管理类)。该函数仅会在程序运行时,自动加载一次
PLUGIN_SESSION_CREATEfunc(session)函数定义必须有,函数体可以为空在会话中加载插件时自动调用的函数, session为加载插件的会话。该函数在每一个会话创建时均被自动加载一次
PLUGIN_SESSION_DESTROYfunc(session)函数定义必须有,函数体可以为空在会话中卸载插件时自动调用的函数, session为卸载插件的会话。卸载在每一个会话关闭时均被自动运行一次。
  • 功能修改:对session自动加载mud文件中变量失败时的异常进行管理,此时将不加载变量,自动继续进行
  • 功能修改:所有匹配类对象的匹配模式patterns支持动态修改,涉及Alias,Trigger,Command。修改方式为直接对其patterns属性赋值。如tri.patterns = aNewPattern
  • 功能修改:连接/断开连接时刻都会在提示中增加时刻信息,而不论是否异常。

特别备注:上述版本变更内容尚未在后面的功能介绍中修改,请首先查看更新说明!!

0.16.2 (2023-12-19)

  • 功能修改:归一化#命令和非#命令处理,使session.exec_command、exec_command_async、exec_command_after均可以处理#命令,例如session.exec_command("#save")。同时,也可以在命令行使用#all发送#命令,如"#all #save"此类
  • 功能修改:调整脚本加载与变量自动加载的顺序。当前为连接自动加载时,首先加载变量,然后再加载脚本。目的是使脚本的变化可以覆盖加载的变量内容,而不是反向覆盖。
  • 功能修改:会话变量保存和加载可以配置是否打开,默认为打开。见Settings.client["var_autosave] 和 Settings.client["var_autoload"]。同理,该配置可以被本地pymud.cfg所覆盖
  • 功能修改:将MatchObject的同步onSuccess和异步await的执行顺序进行调整,以确保一定是同步onSuccess先执行。涉及Trigger、Command等。
  • 功能修改:修改了GMCPTrigger的onSuccess处置和await triggered处置参数,以保持与Trigger同步。当前,onSuccess函数传递3个参数,name,line(GMCP收到的原始str数据),wildcards(经json.load处理的GMCP数据,若load失败,返回与line相同值)。await triggered返回与Triggerd的await triggered相同,均为BaseObject.State,包含4个参数的元组,result(永为True),name(GMCP的id),line(GMCP原始数据),wildcards(GMCP处理后数据)。其中,后3个参数与onSuccess函数调用时传递参数相同。
  • 功能修改:增加GMCP默认处理。当未使用GMCPTrigger对对应的GMCP消息进行处理时,默认使用[GMCP] name: value的形式输出GMCP收到的消息,以便于个人脚本调试。

0.16.1.post2 (2023-12-12)

  • 问题修复:修改github项目地址为原pymud地址

0.16.1.post1 (2023-12-12)

  • 问题修复:修复版本显示,更正问为0.16.1(原0.16.0)
  • 问题修复:发布日期标志修改为当前时间
  • 功能修改:CodeLine的执行运行处理修改为不删除中间的多余空白

0.16.1 (2023-12-11)

  • 问题修复:修复init.py中的all变量为字符串
  • 功能增加:可以加载自定义Settings。在执行python -m pymud时,会自动从当前目录读取pymud.cfg文件。使用json格式将配置信息写在该文件中即可。支持模块中settings.py里的sessions, client, server, styles, text字段内容。
  • 功能增加:增加全局变量集,可以使用session.setGlobal和session.getGlobal进行访问,以便于跨session通信。也可以使用#global在命令行访问
  • 功能增加:增加变量的持久化,持久化文件保存于当前目录,文件名为session名称.mud,该文件在会话初始化时自动读取,会话断开时自动保存,其他时候使用#save保存。
  • 功能增加:在extras.py中增加DotDict,用于支持字典的.访问方式
  • 功能增加:使用DotDict增加了session有关对象的点访问方式(.)的快捷访问,包括变量vars,全局变量globals,触发器tris,别名alis,命令cmds,定时器timers,gmcp。例如:session.vars.charname,相当于session.getVariable('charname')
  • 功能增加:增加#all命令,可以向当前所有活动会话发送同一消息,例如#all xixi,可以使所有连接的会话都发送emote
  • 功能增加:增加%copy系统变量,当复制后,会将复制内容赋值给%copy变量
  • 功能增加:增加Trigger测试功能,使用#test {msg}在命令行输入后,会如同接收到服务端数据一样引发触发反应,并且会使用[PYMUD TRIGGER TEST]进行信息显示。
  • 功能增加:匹配#test命令和%copy变量使用如下:窗体中复制有关行,然后命令行中输入#test %copy可使用复制的行来测试触发器
  • 功能修改:将原CodeBlock修改为CodeBlock和CodeLine组成,以适应新的#test命令
  • 功能修改:session对命令的输入异步处理函数handle_input_async进行微小调整,以适应#test命令使用
  • 功能修改:退出时未断开session时的提示窗口文字改为红色(原黄色对比度问题,看不清楚)
  • 功能修改:恢复了#help功能,可以在任意会话中使用#help列出所有帮助主题,#help topic可以查看主题详情
  • 功能修改:在#reload重新加载脚本时,保留变量数据

1. 需求、安装与运行

1.1 概述

PyMUD具有以下特点:

  • 原生Python开发,除prompt-toolkit及其依赖库(wcwidth, pygments, pyperclip)外,不需要其他第三方库支持
  • 使用原生asyncio库进行网络和事务处理,支持async/await语法的原生异步操作(PyMUD最大特色)。原生异步意味着可以支持很多其他异步库,例如可以使用aiohttp来进行网络页面访问而不产生阻塞等等:)
  • 基于控制台的全屏UI界面设计,支持鼠标操作(可触摸设备上支持触摸屏操作)
  • 支持分屏显示,在数据快速滚动的时候,上半屏保持不动,以确保不错过信息
  • 解决了99%情况下,北大侠客行中文对不齐,也就是看不清字符画的问题(因为我没有走遍所有地方,不敢保证100%)
  • 真正的支持多session会话,支持命令和鼠标切换会话
  • 原生支持多种服务器端编码方式,不论是GBK、BIG5、还是UTF-8
  • 支持NWAS、MTTS协商,支持GMCP、MSDP、MSSP协议, MXP待开发。
  • 一次脚本开发,多平台运行。只要能在该平台上运行Python,就可以运行PyMUD客户端
  • 脚本所有语法均采用Python原生语法,因此你只要会用Python,就可以自己写脚本,免去了再去学习lua、熟悉各类APP的使用的难处
  • Python拥有极为强大的文字处理能力,用于处理文本的MUD最为合适
  • Python拥有极为丰富的第三方库,能支持的第三方库,就能在PyMUD中支持
  • 我自己还在玩,所以本客户端会持续进行更新:)

1.2 环境需求

PyMUD是一个原生基于Python语言的MUD客户端,因此最基本的环境是Python环境而非操作系统环境。理论上,只要你的操作系统下可以运行Python,就可以运行PyMUD。另外,本客户端的UI设计是基于控制台的,因此也不需要有图形环境的支持。

  • 操作系统需求:不限,能运行Python是必要条件。
  • 版本需求:要求 >=3.7(已测试3.7.9,更旧的版本不确定能否使用,请自行尝试),32位/64位随意,但建议用64位版,可以支持4G以上的内存访问
  • 支持库需求:prompt-toolkit 3.0(代码在 https://github.com/prompt-toolkit/python-prompt-toolkit ), 以及由prompt-toolkit所依赖的wcwidth、pygment、pyperclip

1.3 安装

  • 安装Python,这个就不讲了。如果不会python语言可以学,如果不会任何编程语言,用这个客户端可能还是有点难度。所以假设用户是会python的
  • 安装PyMUD程序本体:可以直接使用pip命令安装或更新。所需的支持库会自动安装
    pip install pymud
    pip install --upgrade pymud

1.4 运行

使用pip安装的pymud,可以通过标准模块调用语法:python -m pymud调用模块执行。

python -m pymud

2. 主要文件

  • settings.py 程序应用的配置文件。为支持包运行,可以通过本地目录的pymud.cfg文件覆盖settings.py的配置(后面详述)
  • pymud.py 主入口对象,PyMudApp的实现,主要处理界面、菜单、快捷键、鼠标、会话管理等功能;
  • extras.py 对prompt-toolkit库里某些类的继承修改,以适应东方字符的宽度、北大侠客行字符对齐、鼠标操作、显示的分屏处理等;额外点访问支持的字典类型定义等;
  • dialogs.py 调用的各种对话框的实现(ui界面内容)
  • protocol.py 处理Telnet协议,以及MUD的各种扩展协议(如MTTS、GMCP等)
  • session.py 会话管理对象,每一个会话,就是一个角色的世界,所有会话处理,包括与protocol之间的交互、触发器、别名、变量等的处理均在此实现
  • objects.py MUD基本对象,触发器、别名、定时器、命令等的实现类
  • pkuxkx.py 这个文件不是程序的组成部分,只是使用pymud写的角色脚本的一个示例(如何写脚本将在后面详述)

3. 主要界面和操作

3.1 主要界面

下图是主要界面,用1-7标注出7个主要内容,含义如下:

  1. 菜单栏,可以鼠标操作(手机上是触控),目前只有3个1级菜单
  2. 多会话的显示和切换标签。多个会话时,每一个会话的名称会在此处,灰底亮字的是当前会话。颜色为绿色的表示当前会话处于连接状态,未处于连接状态的为白色(灰色)。可以直接单击会话名称切换会话。键盘的话,Ctrl+左右箭头可以切换
  3. 中间分隔线。当向上滚动或翻页的时候,会产生中间分隔线,此时上半部分不再滚动,下半部分持续滚动显示最新的消息。取消分隔的话,可以向下滚动到底,或者Ctrl-Z快捷键取消(仿zmud操作)。上下翻页只能使用鼠标滚轮,或者PageUp\PageDown按键。鼠标滚轮是一次一行,键盘是一次一页。
  4. 状态窗口。可以使用函数自定义状态窗口显示内容,图例是我自己定义的状态窗口显示内容。
  5. 命令输入行。命令输入行支持历史记录、以及基于历史记录的自动建议和自动完成。键盘上下方向键可以在历史记录中选择,键盘右键实现历史记录建议的自动完成。比如前面输入过ask pu about job,后面输入ask的时候,后面的 pu about job会以灰色显示(表示自动建议),只需键盘右箭头,即可完成输入。
  6. 状态栏,显示状态信息,目前只有会话切换、复制内容的信息会显示,可以通过app.set_status来设置显示的文字
  7. 状态栏(右),显示连接状态和连接时间。因为基于控制台的ui不是时刻刷新,因此链接的时间有时会滞后显示,看上去就是秒在跳动,不影响时间记录。

3.2 会话与连接管理

连接到服务器有以下四种方式:

  • 使用“世界”->“创建新会话菜单”。如果是连接北侠,修改会话名称为你想要的即可(不改也可以连接)。
  • 使用命令行命令:在命令行内输入:

    #session {session_name} {host} {port} {encoding}

    大括号内容为,分别代表会话名称、服务器地址、端口、编码方式(编码方式可不指定,此时默认为utf-8编码)。例如,使用下列命令可以创建一个名为newstart的会话并连接到北侠。

    #session newstart mud.pkuxkx.com 8081
  • 在settings.py中加入常用的角色会话。此时会自动生成菜单,鼠标选择对应的菜单可自动连接到会话。

      sessions = {
          "pkuxkx" : {                                                  # 一个服务器作为一个一级菜单项,所有角色放在下面
              "host" : "mud.pkuxkx.com",                                # 服务器地址
              "port" : "8081",                                          # 服务器端口
              "encoding" : "utf8",                                      # 服务器编码
              "autologin" : "{0};{1}",                                  # 自动登录命令,此处{0}、{1}是两个参数,实际运用时会自动指代下面的角色id和密码进行替换。分号指示两次命令输入。当不使用自动登录时,该参数可以设置为None,或空字符串""
              "default_script": "pkuxkx",                               # 角色登录后默认加载的脚本(省略.py扩展名)。此处指默认加载当前目录下的pkuxkx.py文件
              "chars" : {
                  "newstart" : ["newstart", "mypassword"],              # 创建的角色菜单,会话名、角色ID、角色密码
                  "your_session"  :["char_id", "char_pass"],            # 可保存多个角色,使用,隔开,即在字典中使用多个key。
              }
          },
          "evennia" : {                                                 # 也可以创建多个服务器的菜单,按规则依次填写即可。
              "host" : "192.168.220.128",
              "port" : "4000",
              "encoding" : "utf8",
              "autologin" : "connect {0} {1}",
              "default_script": None,
              "chars" : {
                  "evennia"   : ("admin", "admin"),
              }
          }
      }
  • 在当前目录下新建pymud.cfg文件,并使用json格式按settings.py中样式覆盖sessions等部分,也可以覆盖settings.py中的其他设置。如(注:此处json文件不支持注释):

     {
      "client" : {          
          "status_height"     : 4,        
          "interval"          : 10,                   
          "auto_connect"      : 1,      
          "echo_input"        : 0,     
          "status_display"    : 1
      },
    
      "sessions": {
          "pkuxkx" : {
              "host" : "mud.pkuxkx.com",
              "port" : "8081",
              "encoding" : "utf8",
              "autologin" : "{0};{1}",
              "default_script": "myscript",
              "chars" : {
                  "newstart" : ["newstart", "password"],
                  "your_session" : ["char_id", "password"]
              }
          }
      }
     }
  • 可以支持同时打开多个会话,此时在顶部中央会显示不同的会话名称和当前会话。可以使用鼠标切换会话,或者使用快捷键Ctrl+左右箭头实现会话向左和向右切换。

  • 当使用quit命令退出北侠账号、或者由于网络异常,或者操作菜单“会话”->“断开连接”后,服务器会断开连接,但此时会话窗口并不会关闭。在这种情况下,可以使用命令#connect(或缩写#con)重新连接到服务器。也可以使用菜单“会话”->“连接/重新连接到服务器”执行同样功能。
  • 关闭会话窗口可以使用#close命令。也可以使用菜单“会话”->“关闭当前会话”进行关闭。

4. 脚本

4.1 知识基础

PyMUD是一个基于控制台UI的MUD客户端,因此不能像zmud/mushclient/mudlet等客户端那样,在窗口中添加触发、别名等内容,所有上述实现都依赖脚本。又因为是Python语言实现,因此也支持Python语言实现。

要能在PyMUD中实现初级脚本功能,首先要掌握以下知识和概念:
  • Python基本语法与内置类型;
  • 面向对象开发(OOP)的核心概念:封装、继承与多态;
  • 函数式编程的基本概念;
  • 位置参数与命名参数的基本概念;
  • 基于Python的正则表达式;
如要使用PyMUD的高级功能和实现脚本的高级功能,则还需要掌握以下内容:
  • Python asyncio包的熟练使用,包括async/await语法、coroutine、Task概念与运用、Event/Future的使用、事件循环等
如对PyMUD有兴趣,请自行学习上述内容

4.2 创建脚本

PyMUD使用#load命令加载脚本文件,调用了importlib库的import_module方法。除此之外,为了满足会话的使用要求,加载的脚本文件中,必须有一个命名为:Configuration的类,构造函数必须接受一个Session类型的参数(传递的是当前会话)。

一个可加载的基本的脚本文件内容参考如下:

class Configuration:
    def __init__(self, session):
        self.session = session

4.3 内置基本操作

4.3.1 内置命令(#开头的)

PyMUD接受在命令行使用命令进行交互。PyMUD内置命令均使用#开头。目前支持以下命令:

命令简介示例
help获取帮助. #help, #help session
exit退出app
close关闭当前会话
session创建新会话. #session mysession mud.pkuxkx.com 8080 gbk
{session_name}可以直接使用会话名加#,将该会话切换为当前会话. #mysession
{num}重复num次向mud服务器发送命令. #3 get b1a from jin nang
all向所有活动的会话发送同样的命令. #all quit
connect, con连接或重新连接一个断开状态的会话. #con
info在当前会话中输出一行蓝色的信息. #info this is an information
warning在当前会话中输出一行黄色的信息
error在当前会话中输出一行红色的信息
clear, cls清除当前会话中的所有文本缓存
load加载制定脚本文件,省略.py扩展名. #load myscript
reload重新加载脚本文件,为了修改脚本后快速加载
alias, ali别名命令,详见#help alias
trigger, tri触发器命令,详见#help trigger
gmcpgmcp命令,详见#help gmcp
command, cmd命令命令,详见#help command
timer, ti定时器命令,详见#help timer
variable, var变量命令,详见#help variable
global全局变量命令
test测试触发器命令,后面的内容会发送到触发器测试环境,会执行触发. #test %copy
wait, wa延时命令,用于单行多命令输入,或者SimpleTrigger等其中,延时指定ms数xixi;#wa 1000;haha
message, mess消息命令,产生一个弹出窗口,显示指定消息. #mess %line
gag隐藏当前行,用于触发器中. #gag
replace替换当前行显示,用于触发器中. #replace %line-[这是增加的内容]
repeat, #rep重复输出上一次的命令
save将当前会话变量保存到文件,即时生效

4.3.2 内置变量(%开头的)

内置变量以%开头,可以在触发器命令中直接使用变量名进行访问,例如“#mess %line”。PyMUD的会话支持以下内置变量:

变量含义
%line当前行内容(纯文本)
%raw当前行信息(带有ANSI字符的原始格式)
%copy当复制内容后,复制内容信息
%1~%9别名、触发器的正则捕获内容

4.3.3 内置对象

脚本中所需使用的有关基本对象已经内置在objects.py中,可以直接import使用。基本对象含义如下:

类型简介用途
Alias别名的基本类型可以用来实现基本/复杂别名,必须全代码实现
Trigger触发器的基本类型可以用来实现基本/复杂触发器,必须全代码实现
Timer定时器的基本类型可以用来实现基本/复杂定时器,必须全代码实现
SimpleAlias简单别名可以使用mud命令和脚本而非代码的方法来实现的别名,类似zmud别名
SimpleTrigger简单触发器可以使用mud命令和脚本而非代码的方法来实现的触发器,类似zmud触发器
SimpleTimer简单定时器可以使用mud命令和脚本而非代码的方法来实现的定时器,类似zmud定时器
Command命令的基本类型命令是PyMUD的一大原创特色,在高级脚本中会详细讲到
SimpleCommand简单命令命令是PyMUD的一大原创特色,在高级脚本中会详细讲到
GMCPTriggerGMCP触发器专门用于处理MUD服务器GMCP数据,操作方法类似于触发器
CodeBlock可以运行的代码块用于SimpleTrigger、SimpleAlias、SimpleTimer以及命令行输入处理的模块

在脚本中要使用上述类型的import:

from pymud import Alias, Trigger, Timer, SimpleTrigger, SimpleAlias, SimpleTimer, Command, SimpleCommand, GMCPTrigger

4.4 Session类型

与MUD服务器的所有交互操作,均由Session类所实现,因此,其与服务器的交互方法是编写脚本所需要的必要内容。Session类的常用方法如下。

方法名称参数:类型返回值简介
writelineline:strNone向服务器中写入一行。如果参数使用;分隔(使用Settings.client.seperator指定),将分行后逐行写入
exec_commandline:strNone执行一段命令,并将数据发送到服务器。本函数和writeline的区别在于,本函数会先进行Command和Alias解析,若不是再使用writeline发送。当line不包含Command和Alias时,等同于writeline
exec_command_afterwait:float, line:strNone等待wait秒后,再执行line所代表的命令
exec_command_asyncline:strNoneexec_command的异步(async)版本。在每个命令发送前会等待上一个执行完成。
getUniqueNumberint生成一个本会话的唯一序号
getUniqueIDprefixstr生成一个类似{prefix}_{uniqueID}的唯一序号
enableGroupgroup:str, enabled=TrueNone使能/禁用指定组名为group的所有对象,包括Alias、Trigger、Timer、Command、GMCPTrigger
addAliasesalis:dictNone向会话中添加一组Alias对象,对象由字典alis包含
addAliasali:AliasNone向会话中添加一个Alias对象
delAliasali:strNone删除会话中id为ali的Alias对象
addTriggerstris:dictNone添加一组Trigger对象
addTriggertri:TriggerNone添加一个Trigger
delTriggertri:strNone删除id为tri的Trigger
addCommandscmds:dictNone添加一组Command对象
addCommandcmd:CommandNone添加一个Command
delCommandcmd:strNone删除id为cmd的Command
addTimerstis:dictNone添加一组Timer对象
addTimerti:TimerNone添加一个Timer
delTimerti:strNone删除id为ti的Timer
addGMCPsgmcp:dictNone添加一组GMCPTrigger对象
addGMCPgmcp:GMCPTriggerNone添加一个GMCPTrigger
delGMCPgmcp:strNone删除id为gmcp的GMCPTrigger
setVariablename:str,valueNone设置(或新增)一个变量,变量名为name,值为value
setVariablesnames,valueNone设置(或新增)一组变量,变量名为names(元组或列表),值为values(元组或列表)
updateVariableskvdict:dictNone更新一组变量,与setVariables的区别为,接受的参数为dict键值对
getVariablename:str,default=None任意类型获取名为name的变量值,当name不存在时,返回default
getVariablesnamestuple获取names列表中所有的变量值。对每一个,当name不存在时,获取内容为None值
setGlobalname:str,valueNone设置(或新增)一个全局变量,变量名为name,值为value(全局变量是可以跨session进行访问的变量)
getGlobalname:str,default=None任意类型获取名为name的全局变量值,当name不存在时,返回default
replacenewstr:strNone在触发器中使用,将会话中当前行的显示内容修改为newstr。使用replace可以在触发器时将额外信息增加到当前行中
infomsg,title,styleNone向Session窗体中输出一串信息,会以[title] msg的形式输出,title默认PYMUD INFO,颜色(style指定)默认绿色
warningmsg,title,styleNone向Session窗体中输出一串信息,会以[title] msg的形式输出,title默认PYMUD WARNING,颜色(style指定)默认黄色
errormsg,title,styleNone向Session窗体中输出一串信息,会以[title] msg的形式输出,title默认PYMUD ERROR,颜色(style指定)默认红色

Session类型有部分直接访问的属性,如下表

属性名称简介
connectedbool类型,指示会话连接状态
durationfloat类型,返回服务器端连接的时间,以秒为单位
status_maker引用类型,指向状态栏显示的函数或字符串
varsvariable变量的点访问器。即可以使用session.vars.neili直接访问名为neili的变量。下同
globals全局变量的点访问器
tris触发器的点访问器
alis别名的点访问器
cmds命令的点访问器
timers定时器的点访问器
gmcpgmcp的点访问器

4.5 触发器(基础版)

4.5.1 触发器入门

进入MUD游戏,第一个要使用的必然是触发器(Trigger)。PyMUD支持多种特性的触发器,并内置实现了Trigger和SimpleTrigger两个基础类。 要在会话中使用触发器,要做的两件事是:

  • 构建一个Trigger类(或其子类)的实例。SimpleTrigger是Trigger的子类,你也可以构建自己定义的子类。
  • 将该实例通过session.addTrigger方法增加到会话的触发器清单中。

4.5.2 类型定义与构造函数

Trigger是触发器的基础类,继承自MatchObject类(可以不用理会)。SimpleTrigger继承自Trigger,可以直接用命令而非函数来实现触发器触发时的操作。 Trigger与SimpleTrigger的构造函数分别如下:

class Trigger:
    def __init__(self, session, patterns, *args, **kwargs):
        pass
class SimpleTrigger:
    def __init__(self, session, patterns, code, *args, **kwargs):
        pass

为了使用统一的函数语法,除重要的参数session(指定会话)、patterns(指定匹配模式)、code(SimpleTrigger)的执行代码之外,其余所有触发器的参数都通过命名参数在kwargs中指定。触发器支持和使用的命名参数、默认值及其含义如下:

  • id: 唯一标识符。不指定时,默认生成session中此类的唯一标识。
  • group: 触发器所属的组名,默认未空。支持使用session.enableGroup来进行整组对象的使能/禁用
  • priority: 优先级,默认100。在触发时会按优先级排序执行,越小优先级越高。
  • enabled: 使能状态,默认为True。标识是否使能该触发器。
  • oneShot: 单次触发,默认为False。当该值为True时,触发器被触发一次之后,会自动从会话中移除。
  • onSuccess: 函数的引用,默认为空。当触发器被触发时自动调用的函数,函数类型应为func(id, line, wildcards)形式。
  • ignoreCase: 忽略大小写,默认为False。触发器匹配时是否忽略大小写。
  • isRegExp:是否正则表达式,默认为True。即指定的触发匹配模式patterns是否为正则表达式。
  • keepEval: 匹配成功后持续进行后续匹配,默认为False。当有两个满足相同匹配模式的触发器时,要设置该属性为True,否则第一次匹配成功后,该行不会进行后续触发器匹配(意味着只有最高优先级的触发器会被匹配)
  • raw: 原始代码匹配,默认为False。当为True时,对MUD服务器的数据原始代码(含VT100控制指令)进行匹配。在进行颜色匹配的时候使用。

构造函数中的其他参数含义如下:

  • session: 指定的会话对象,必须有
  • patterns: 匹配模式。当为单行匹配时,传递字符串(正则表达式或原始数据)。当需要进行多行匹配时,使用元组或者列表传递多个匹配行即可。
  • code: SimpleTrigger独有,即匹配成功后,执行的代码串。该代码串类似于zmud的应用,可以用mud命令、别名以分号(;)隔开,也可以在命令之中插入PyMUD支持的#指令,如#wait(缩写为#wa)

4.5.3 使用示例

简单触发器

例如,在新手任务(平一指配药)任务中,要在要到任务后,自动n一步,并在延时500ms后进行配药;配药完成后自动s,并提交配好的药,并再次接下一个任务,则可以如此建立触发器:

tri1 = SimpleTrigger(self.session, "^[> ]*你向平一指打听有关『工作』的消息。", "n;#wa 500;peiyao")
self.session.addTrigger(tri1)
tri2 = SimpleTrigger(self.session, "^[> ]*不知过了多久,你终于把药配完。", "s;#wa 500;give ping yao;#wa 500;ask ping about 工作")
self.session.addTrigger(tri2)

标准触发器

例如,当收到有关fullme或者其他图片任务的链接信息时,自动调用浏览器打开该网址,则可以建立一个标准触发器(示例中同时指定了触发器id):

def initTriggers(self):
    tri = Trigger(self.session, id = 'tri_webpage', patterns = r'^http://fullme.pkuxkx.com/robot.php.+$', onSuccess = self.ontri_webpage)
    self.session.addTrigger(tri)
def ontri_webpage(self, name, line, wildcards):
    webbrowser.open(line)        # 在开头之前已经 import webbrowser

多行触发器

例如,在set hpbrief long的情况下,hpbrief显示三行内容。此时进行多行触发(3行),并将触发中获取的参数值保存到variable变量中:

HP_KEYS = (
        "exp", "pot", "maxneili", "neili", "maxjingli", "jingli",
        "maxqi", "effqi", "qi", "maxjing", "effjing", "jing",
        "zhenqi", "zhenyuan", "food", "water", "fighting", "busy"
        )
def initTriggers(self):
    tri = Trigger(self.session, id = "tri_hpbrief", patterns = (r'^[> ]*#(\d+.?\d*[KM]?),(\d+),(\d+),(\d+),(\d+),(\d+)$', r'^[> ]*#(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)$', r'^[> ]*#(\d+),(\d+),(-?\d+),(-?\d+),(\d+),(\d+)$',), onSuccess = self.ontri_hpbrief)
    self.session.addTrigger(tri)
def ontri_hpbrief(self, name, line, wildcards):
    self.session.setVariables(self.HP_KEYS, wildcards)

ANSI触发器

如果要捕获文字中的颜色、闪烁等特性,则可以使用触发器的raw属性。例如,在长安爵位任务中,要同时判断路人身上的衣服和鞋子的颜色和类型时,可以使用如下触发:

def initTriggers(self):
    tri = Trigger(self.session, patterns = r"^.+□(?:\x1b\[[\d;]+m)?(身|脚)\S+一[双|个|件|把](?:\x1b\[([\d;]+)m)?([^\x1b\(\)]+)(?:\x1b\[[\d;]+m)?\(.+\)", onSuccess = self.judgewear, raw = True)
    self.session.addTrigger(tri)
def judgewear(self, name, line, wildcards):
    buwei = wildcards[0]        # 身体部位,身/脚
    color = wildcards[1]        # 颜色,30,31,34,35为深色,32,33,36,37为浅色
    wear  = wildcards[2]        # 着装是布衣/丝绸衣服、凉鞋/靴子等等
    # 对捕获结果的进一步判断,此处省略

4.6 别名

4.6.1 别名入门

当要简化一些输入的MUD命令,或者代入一些参数时,会使用到别名(Alias)。PyMUD支持多种特性的别名,并内置实现了Alias和SimpleAlias两个基础类。 要在会话中使用别名,要做的两件事是:

  • 构建一个Alias类(或其子类)的实例。SimpleAlias是Alias的子类,你也可以构建自己定义的子类。
  • 将该实例通过session.addAlias方法增加到会话的别名清单中。

4.6.2 类型定义与构造函数

Alias是别名的基础类,与触发器一样,也是继承自MatchObject类。事实上,在PyMUD设计时,我认为别名和触发器是完全相同的东西,只不过一个对输入的命令进行匹配处理(别名),而另一个对MUD服务器端的消息进行匹配处理(触发器),因而在代码上,这两个类除了名称不同之外,差异也是极小的。主要差异是在于触发器增加了异步触发功能(高级内容中会讲到)。SimpleAlias继承自Alias,可以直接用命令而非函数来实现别名触发时的操作。 Alias与SimpleAlias的构造函数与Trigger和SimpleTrigger完全相同,此处不再列举: 别名支持的命名参数、默认值及其含义与上一节触发器的完全相同,但以下命名参数在别名中虽然支持,但不使用:

  • oneShot: 别名不存在只用一次,因此设置这个无实际意义;
  • raw: 别名不存在ANSI版本和纯文本版本,因此设置这个也无实际意义。

4.6.3 使用示例

简单别名

例如,要将从扬州中央广场到信阳小广场的路径设置为别名,可以如此建立别名:

ali = SimpleAlias(self.session, "yz_xy", "#4 w;nw;#5 w")
self.session.addAlias(ali)

带有参数的别名

例如,每次慕容信件任务完成后都要从尸体中取出信件,另外还有可能有黄金、白银,每次都输入get letter fromc corpse等等命令太长,想进行缩写,则可以如此建立别名:

ali = SimpleAlias(self.session, "^gp\s(.+)$", "get %1 from corpse")
self.session.addAlias(ali)

建立别名之后,可以使用gp silver, gp gold, gp letter代替 get silver/gold/letter from corpse

标准别名

例如,要将gan che to 方向建立成别名,并且在方向既可以使用缩写(e代表east之类),也可以使用全称,则可以建立一个标准别名:

DIRS_ABBR = {
        "e": "east",
        "w": "west",
        "s": "south",
        "n": "north",
        "u": "up",
        "d": "down",
        "se": "southeast",
        "sw": "southwest",
        "ne": "northeast",
        "nw": "northwest",
        "eu": "eastup",
        "wu": "westup",
        "su": "southup",
        "nu": "northup",
        "ed": "eastdown",
        "wd": "westdown",
        "sd": "southdown",
        "nd": "northdown",
    }
 
def initAliases(self):
    ali = Alias(self.session, "^gc\s(.+)$", onSuccess = self.ganche)
    self.addAlias(ali)
 
def ganche(self, name, line, wildcards):
    dir = wildcards[0]
    if dir in self.DIRS_ABBR.keys():
        cmd = "gan che to {}".format(self.DIRS_ABBR[dir]
    else:
        cmd = f"gan che to {dir}"
    self.session.writeline(cmd)

4.7 定时器

4.7.1 定时器入门

要周期性的执行某段代码,会使用到定时器(Timer)。PyMUD支持多种特性的定时器,并内置实现了Timer和SimpleTimer两个基础类。 要在会话中使用定时器,要做的两件事是:

  • 构建一个Timer类(或其子类)的实例。SimpleTimer是Timer的子类,你也可以构建自己定义的子类。
  • 将该实例通过session.addTimer方法增加到会话的定时器清单中。

4.7.2 类型定义与构造函数

Timer与SimpleTimer的构造函数分别如下:

class Timer:
    def __init__(self, session, *args, **kwargs):
        pass
class SimpleTimer:
    def __init__(self, session, code, *args, **kwargs):
        pass

定时器支持以下几个命名参数,其默认值及其含义为:

  • id: 唯一标识符。不指定时,默认生成session中此类的唯一标识。
  • group: 触发器所属的组名,默认未空。支持使用session.enableGroup来进行整组对象的使能/禁用
  • enabled: 使能状态,默认为True。标识是否使能该定时器。
  • timeout: 超时时间,即定时器延时多久后执行操作,默认为10s
  • oneShot: 单次执行,默认为False。当为True时,定时器仅响应一次,之后自动停止。否则,每隔timeout时间均会执行。
  • onSuccess: 函数的引用,默认为空。当定时器超时时自动调用的函数,函数类型应为func(id)形式。
  • code: SimpleTimer中的code,与之前的SimpleAlias、SimpleTrigger相同用法。

4.7.3 定时器示例

简单定时器

例如,在莫高窟冥想时,每隔5s发送一次mingxiang命令,则可以这样实现定时器

tm = SimpleTimer(self.session, timeout = 5, id = "tm_mingxiang", code = "mingxiang")
self.session.addTimers(tm)

标准定时器

上述定时器的标准实现版本如下

def initTimers(self):
    tm = Timer(self.session, timeout = 5, id = "tm_mingxiang", onSuccess = self.timer_mingxiang)
    self.session.addTimers(tm)
def timer_mingxiang(self, id, *args, **kwargs):
    self.session.writeline("mingxiang")

4.8 变量

4.8.1 变量简介

由于PyMUD需要完整的Python脚本进行实现,在许多情况下,如zmud/mushclient里面的变量是可以完全使用Python自己的变量来进行替代的。但PyMUD保留了变量这一功能,我设计脚本的思路为,只有单个模块使用的变量,多使用Python变量实现,而需要跨模块调用的变量,才使用PyMUD变量进行存储。 事实上,PyMUD变量本质上仍是Python的变量,只不过赋值给了对应的session会话所有。也基于此特点,PyMUD的变量支持任意值类型,不是一定为简单类型,也可以为列表、字典,也可以为复杂的对象类型。 在触发器、别名的使用过程中,存在以下系统变量: %line:即触发的行本身。对于多行触发,%line会返回多行 %1 ~ %9: 触发器、别名使用时的正则匹配的匹配组

4.8.2 变量使用

变量可以直接在SimpleTrigger、SimpleAlias、SimpleTimer中调用。系统变量直接调用(如%line),自定义变量前面加@调用。 例如,在收到“xxx告诉你:”之类的消息时,使用#message弹窗显示可以:

tri = SimpleTrigger(self.session, r".+告诉你:.+", "#message %line")
self.session.addTrigger(tri)

例如,通过触发器捕获到身上的钱的数量,可以将数量与类型联合为字典,再添加到赋值给1个变量

money = {'cash': 0, 'gold': 1, 'silver': 50, 'coin': 77}
self.session.setVariable("money", money)
# 在使用时,则这样获取
money = self.session.getVariable("money")

也可以将上述变量分别存在不同的名称中,并一同读写:

money_key   = ('cash', 'gold', 'silver', 'coin')
money_count = (0, 1, 50, 77)
self.session.setVariables(money_key, money_count)
# 在使用时,则这样获取
silver = self.session.getVariable("silver")

5. 高级脚本

5.1 异步入门

PyMUD的高级脚本使用,必须要掌握基于asyncio库的async/await使用方法。PyMUD使用异步架构构建了整个程序的核心框架。当然,异步在脚本中不是必须的,它只是一种可选的实现方式而已,其他任何客户端都不像PyMUD一样原生完全支持异步操作,这也是PyMUD客户端与其他所有客户端的最核心差异所在。

可以先在以下帖子看看我和HellClient客户端大佬jarlyyn的讨论帖: https://www.pkuxkx.com/forum/thread-47989-2-1.html

另以下有几篇参考文档可供学习。

python的asyncio库介绍:https://docs.python.org/zh-cn/3.10/library/asyncio.html

Python异步协程(asyncio详解):https://www.cnblogs.com/Red-Sun/p/16934843.html

由于MUD是基于网咯IO的程序,在程序运行的过程中,大部分时间是空闲在等待服务器数据或客户端输入的。也正是如此,同步模式下的等待都会造成阻塞。

基于async/await的异步可以带来不少的好处,包括:

  • 可以使用同步编程的思维来构建异步程序,这也是其中最大的一个好处。
  • 复用异步Trigger,可以极大的增加代码的可读性。
  • 可以更便捷的实现程序代码的高内聚,低耦合。特别是降低耦合性。

试想以下场景:当我们在北侠中完成一次dazuo之后,正常的服务器回应消息是:你运功完毕,深深吸了口气,站了起来。 那么,我们有可能是想持续打坐修炼内力,也可能是只要执行一次dazuo max指令;也可能时tuna过程中yun regenerate之后的打坐;在持续打坐过程中,也考虑要每隔多少次补满食物饮水; 要在一个服务器回应消息下进行不同种类的处理,在以往同步模式下,可以有很多种实现,例如可以:

  • 设置不同变量标识状态,在该触发器下根据变量确定状态再判断后续执行;
  • 设置多个完全相同的Trigger,并分属不同的group,根据需要只使能所需的组

上述第1种方式下,多个模块功能的代码都集中在同一个触发器下,一个出错会影响其他,要新增功能则又要对其进行修改; 第2中方式下,同样模式匹配的触发过多,代码会显得臃肿。

如果使用异步,则可以仅使用一个触发器,我们在等待该触发器触发事件后再执行对应代码。此时,触发器的触发结果与执行的内容结果是解耦的,即触发器本身不包含触发器被触发后应该执行的代码,这部分代码由功能实现函数进行完成。这也是异步触发器的由来。

5.2 异步触发器

PyMUD的Trigger类同时支持同步和异步模式。当使用异步触发器时,有以下两个建议:

  1. 不要使用SimpleTrigger。因为其code代码的执行是包含在触发器类的定义中。
  2. 不要指定Trigger的onSuccess调用,因为该函数调用是同步的。

Trigger类的triggered方法是一个async定义的协程函数。在不指定code、不指定onSuccess时,其默认的触发函数仅设置Trigger的Event标识,该标识是一个asyncio.Event对象。可以使用下面代码来异步等待触发器的执行。

await tri.triggered()

以之前打坐的触发示例:

class Configuration:
    def __init__(self, session):
        self.session = session
        self.initTriggers()
    def initTriggers(self):
        self._triggers = {}
        self._triggers['tri_dazuo'] = Trigger(self.session, r"^[> ]*你运功完毕,深深吸了口气,站了起来。", id = "tri_dazuo")
        self.session.addTriggers(self._triggers)
 
    async def dazuo_always(self):
        # 此处仅为了说明异步触发器的使用,假设气是无限的,可以无限打坐
        # 每打坐100次,吃干粮,喝酒袋
        time = 0
        while True:                                       # 永久循环
            self.session.writeline("dazuo 10")            # 发送打坐命令
            await self._triggers["tri_dazuo"].triggered() # 等待dazuo触发
            times += 1
            if times > 100:
                self.session.writeline("eat liang")
                self.session.writeline("drink jiudai")
                times = 0

从上面的异步示例中可以看出,dazuo/eat/drink代码不是放在Trigger的触发中的,而且该代码逻辑一目了然(因为是以同步思维实现的异步)。当然,上面的代码仅是一个异步触发的使用示例,实际dazuo远比此复杂。

5.3 命令(Command)

5.3.1 命令入门

有了异步Trigger之后,命令就有了实现的基础。命令是什么?可以这么理解,PyMUD的命令,就是将MUD的命令输入、返回响应等封装在一起的一种对象,基于Command可以实现从最基本的MUD命令响应,到最复杂的完整的任务辅助脚本。

要在会话中使用触发器,要做的事是:

  • 构建一个Command的子类(或直接构建一个SimpleCommand类,后面会讲到),实现并覆盖基类的execute方法
  • 创建该Command子类的实例。一个子类应该只构建一个实例。
  • 将该实例通过session.addCommand方法增加到会话的命令清单中

此时,调用该Command,只需在命令行与输入该Command匹配模式匹配的命令即可

5.3.2 类型定义与构造函数

Command与Trigger、Alias一样,也是继承自MathObject,也是通过模式匹配进行调用。因此,Command的构造函数与Trigger、Alias相同:

class Command:
    def __init__(self, session, patterns, *args, **kwargs):
        pass

与Alias、Trigger的差异是,Command包含几个新的会经常被使用的方法调用,见下表。

方法参数返回值含义
create_taskcoro, args, nameasyncio.Task实际是asyncio.create_task的包装,在创建任务的同时,将其加入了session的task清单和本Command的Task清单,可以保证执行,也可以供后续操作使用
reset复位该任务。复位除了清除标识位之外,还会清除所有未完成的task。在Command的多次调用时,要手动调用reset方法,以防止同一个命令被多次触发。
executecmd, *args, **kwargsasync定义的异步方法,在Command被执行时会自动调用该方法

5.3.3 使用示例

Command示例之一:walk命令

先用一个简单的示例来说明Command的应用。在张金敖任务(机关人起源)中,当到达对应节点时,需要使用walk命令行走指定步数然后等待线索。现在想在行走指定步数之后自动使用walk -p停止下来,可以使用以下一个Command类来实现。此处所有逻辑在CmdWalk类override的execute方法中实现,对已经行走步数的技术是通过while循环实现。这种实现方式代码可读性好,其逻辑思维符合正常同步思维模式。

    class CmdWalk(Command):
        "北侠节点处的Walk指令,控制指定步数"
        _help = """
            定制walk命令使用参考,可接受正常walk命令,也可以使用诸如walk yangzhou 8来控制从节点向yangzhou节点行走,8步之后停下来
            正常指令      含义
            walk xxx:    标准walk指令
            walk -c xxx: 显示路径
            walk -p:     暂停行走
            walk:        继续行走
            特殊指令:
            walk xxx 3:  第二个参数的数字,控制行走的步数,用于张金敖任务
        """
        def __init__(self, session, *args, **kwargs):
            self.tri_room = Trigger(self.session, id = 'tri_room', patterns = r'^[>]*(?:\s)?(\S.+)\s-\s*[★|☆|∞]?$', isRegExp = True)
            self.session.addTrigger(self.tri_room)
            super().__init__(session, "^walk(?:\s(\S+))?(?:\s(\S+))?(?:\s(\S+))?", *args, **kwargs)
 
        async def execute(self, cmd, *args, **kwargs):
            try:
                m = re.match(self.patterns, cmd)
                if m:
                    para = list()
                    for i in range(1, 4):
                        if m[i] != None:
                            para.append(m[i])
 
                    # 如果是步数设置,需要人工控制
                    # 例如walk yangzhou 8
                    if (len(para) > 0) and para[-1].isnumeric():
                        cnt = int(para[-1])
                        step = "walk " + " ".join(para[:-1])
                        self.info(f"即将通过walk行走,目的地{para[-2]},步数{para[-1]}步...", "walk")
                        self.session.writeline("set walk_speed 2")      # 调节速度
                        await asyncio.sleep(1)
                        self.session.writeline(step)
 
                        # 使用循环控制步数
                        while cnt > 0:                                                                     
                            cnt = cnt - 1
                            await self.tri_room.triggered()                            # 等待房间名被触发cnt次
 
                        self.session.writeline("walk -p")
                        self.session.writeline("unset walk_speed")      # 恢复速度
                        self.info(f"通过walk行走,目的地{para[-2]},步数{para[-1]}步完毕", "walk")
                    # 否则直接命令发送
                    else:
                        self.session.writeline(cmd)
 
            except Exception as e:
                self.error(f"异步执行中遇到异常, {e}, 类型为 {type(e)}")
                self.error(f"异常追踪为: {traceback.format_exc()}")

Command示例之二:dzt命令

Command类也可以支持复杂逻辑带不同参数的指令,直至可以实现一个完整的任务辅助机器人。此处再举一个dazuo的例子。在北侠中,打坐是最长使用的命令之一。我们可能需要打坐到双倍内力即停止以进行后续任务,或者专门打坐修炼内力,或者使用dz命令进行打坐以打通任督二脉。在专门修炼内力时,还需要定期补充食物饮水。打坐的时候,也要统筹考虑气不足、精不足等各种可能遇到的情况。以下是一个完整实现的打坐命令,可以使用dzt xxx来执行上述所有操作。详细代码如下: 注:此Command调用了支持jifa/enable命令的CmdEnable,支持hpbrief的命令CmdHpbrief,以及支持食物饮水等生活的命令CmdLifMisc,具体代码未列出,但在阅读dazuoto命令代码时不影响可读性。

class CmdDazuoto(Command):
    """
    各种打坐的统一命令, 使用方法:
    dzt 0 或 dzt always: 一直打坐
    dzt 1 或 dzt once: 执行一次dazuo max
    dzt 或 dzt max: 持续执行dazuo max,直到内力到达接近2*maxneili后停止
    dzt dz: 使用dz命令一直dz
    dzt stop: 安全终止一直打坐命令
    """
    def __init__(self, session, cmdEnable, cmdHpbrief, cmdLifeMisc, *args, **kwargs):
        super().__init__(session, "^(dzt)(?:\s+(\S+))?$", *args, **kwargs)
        self._cmdEnable = cmdEnable
        self._cmdHpbrief = cmdHpbrief
        self._cmdLifeMisc = cmdLifeMisc
        self._triggers = {}
 
        self._initTriggers()
 
        self._force_level = 0
        self._dazuo_point = 10
 
        self._halted = False
 
    def _initTriggers(self):
        self._triggers["tri_dz_done"]   = self.tri_dz_done      = Trigger(self.session, r'^[> ]*你运功完毕,深深吸了口气,站了起来。$', id = "tri_dz_done", keepEval = True, group = "dazuoto")
        self._triggers["tri_dz_noqi"]   = self.tri_dz_noqi      = Trigger(self.session, r'^[> ]*你现在的气太少了,无法产生内息运行全身经脉。|^[> ]*你现在气血严重不足,无法满足打坐最小要求。|^[> ]*你现在的气太少了,无法产生内息运行小周天。$', id = "tri_dz_noqi", group = "dazuoto")
        self._triggers["tri_dz_nojing"] = self.tri_dz_nojing    = Trigger(self.session, r'^[> ]*你现在精不够,无法控制内息的流动!$', id = "tri_dz_nojing", group = "dazuoto")
        self._triggers["tri_dz_wait"]   = self.tri_dz_wait      = Trigger(self.session, r'^[> ]*你正在运行内功加速全身气血恢复,无法静下心来搬运真气。$', id = "tri_dz_wait", group = "dazuoto")
        self._triggers["tri_dz_halt"]   = self.tri_dz_halt      = Trigger(self.session, r'^[> ]*你把正在运行的真气强行压回丹田,站了起来。', id = "tri_dz_halt", group = "dazuoto")
        self._triggers["tri_dz_finish"] = self.tri_dz_finish    = Trigger(self.session, r'^[> ]*你现在内力接近圆满状态。', id = "tri_dz_finish", group = "dazuoto")
        self._triggers["tri_dz_dz"]     = self.tri_dz_dz        = Trigger(self.session, r'^[> ]*你将运转于全身经脉间的内息收回丹田,深深吸了口气,站了起来。|^[> ]*你的内力增加了!!', id = "tri_dz_dz", group = "dazuoto")
 
        self.session.addTriggers(self._triggers)  
 
    def stop(self):
        self.tri_dz_done.enabled = False
        self._halted = True
        self._always = False
 
    async def dazuo_to(self, to):
        # 开始打坐
        dazuo_times = 0
        self.tri_dz_done.enabled = True
        if not self._force_level:
            await self._cmdEnable.execute("enable")
            force_info = self.session.getVariable("eff-force", ("none", 0))
            self._force_level = force_info[1]
 
        self._dazuo_point = (self._force_level - 5) // 10
        if self._dazuo_point < 10:  self._dazuo_point = 10
 
        await self._cmdHpbrief.execute("hpbrief")
 
        neili = int(self.session.getVariable("neili", 0))
        maxneili = int(self.session.getVariable("maxneili", 0))
        force_info = self.session.getVariable("eff-force", ("none", 0))
 
        if to == "dz":
            cmd_dazuo = "dz"
            self.tri_dz_dz.enabled = True
            self.info('即将开始进行dz,以实现小周天循环', '打坐')
 
        elif to == "max":
            cmd_dazuo = "dazuo max"
            need = math.floor(1.90 * maxneili)
            self.info('当前内力:{},需打坐到:{},还需{}, 打坐命令{}'.format(neili, need, need - neili, cmd_dazuo), '打坐')
        elif to == "once":
            cmd_dazuo = "dazuo max"
            self.info('将打坐1次 {dazuo max}.', '打坐')
        else:
            cmd_dazuo = f"dazuo {self._dazuo_point}"
            self.info('开始持续打坐, 打坐命令 {}'.format(cmd_dazuo), '打坐')
 
        while (to == "dz") or (to == "always") or (neili / maxneili < 1.90):
            if self._halted:
                self.info("打坐任务已被手动中止。", '打坐')
                break
 
            waited_tris = []
            waited_tris.append(self.create_task(self.tri_dz_done.triggered()))
            waited_tris.append(self.create_task(self.tri_dz_noqi.triggered()))
            waited_tris.append(self.create_task(self.tri_dz_nojing.triggered()))
            waited_tris.append(self.create_task(self.tri_dz_wait.triggered()))
            waited_tris.append(self.create_task(self.tri_dz_halt.triggered()))
            if to != "dz":
                waited_tris.append(self.create_task(self.tri_dz_finish.triggered()))
            else:
                waited_tris.append(self.create_task(self.tri_dz_dz.triggered()))
 
            self.session.writeline(cmd_dazuo)
 
            done, pending = await asyncio.wait(waited_tris, timeout =100, return_when = "FIRST_COMPLETED")
            tasks_done = list(done)
            tasks_pending = list(pending)
            for t in tasks_pending:
                t.cancel()
 
            if len(tasks_done) == 1:
                task = tasks_done[0]
                _, name, _, _ = task.result()
 
                if name in (self.tri_dz_done.id, self.tri_dz_dz.id):
                    if (to == "always"):
                        dazuo_times += 1
                        if dazuo_times > 100:
                            # 此处,每打坐200次,补满水食物
                            self.info('该吃东西了', '打坐')
                            await self._cmdLifeMisc.execute("feed")
                            dazuo_times = 0
 
                    elif (to == "dz"):
                        dazuo_times += 1
                        if dazuo_times > 50:
                            # 此处,每打坐50次,补满水食物
                            self.info('该吃东西了', '打坐')
                            await self._cmdLifeMisc.execute("feed")
                            dazuo_times = 0
 
                    elif (to == "max"):
                        await self._cmdHpbrief.execute("hpbrief")
                        neili = int(self.session.getVariable("neili", 0))
 
                        if self._force_level >= 161:
                            self.session.writeline("exert recover")
                            await asyncio.sleep(0.2)
 
                    elif (to == "once"):
                        self.info('打坐1次任务已成功完成.', '打坐')
                        break
 
                elif name == self.tri_dz_noqi.id:
                    if self._force_level >= 161:
                        await asyncio.sleep(0.1)
                        self.session.writeline("exert recover")
                        await asyncio.sleep(0.1)
                    else:
                        await asyncio.sleep(15)
 
                elif name == self.tri_dz_nojing.id:
                    await asyncio.sleep(1)
                    self.session.writeline("exert regenerate")
                    await asyncio.sleep(1)
 
                elif name == self.tri_dz_wait.id:
                    await asyncio.sleep(5)
 
                elif name == self.tri_dz_halt.id:
                    self.info("打坐已被手动halt中止。", '打坐')
                    break
 
                elif name == self.tri_dz_finish.id:
                    self.info("内力已最大,将停止打坐。", '打坐')
                    break
 
            else:
                self.info("命令执行中发生错误,请人工检查", '打坐')
                return self.FAILURE
 
        self.info('已成功完成', '打坐')
        self.tri_dz_done.enabled = False
        self.tri_dz_dz.enabled = False
        self._onSuccess()
        return self.SUCCESS
 
    async def execute(self, cmd, *args, **kwargs):
        try:
            self.reset()
            if cmd:
                m = re.match(self.patterns, cmd)
                if m:
                    cmd_type = m[1]
                    param = m[2]
                    self._halted = False
 
                    if param == "stop":
                        self._halted = True
                        self.info('已被人工终止,即将在本次打坐完成后结束。', '打坐')
                        return self.SUCCESS
 
                    elif param in ("dz",):
                        return await self.dazuo_to("dz")
 
                    elif param in ("0", "always"):
                        return await self.dazuo_to("always")
 
                    elif param in ("1", "once"):
                        return await self.dazuo_to("once")
 
                    elif not param or param == "max":
                        return await self.dazuo_to("max")
 
 
        except Exception as e:
            self.error(f"异步执行中遇到异常, {e}, 类型为 {type(e)}")
            self.error(f"异常追踪为: {traceback.format_exc()}")

5.4 GMCP触发器(GMCPTrigger)

5.4.1 GMCP入门

GMCP(Generic Mud Communication Protocol)是一种用于传递非显示字符的MUD通信协议,在标准telnet协议的基础上定义了特定的选项协商和子协商(命令为0xC9)。有关GMCP协议的详细信息参见 https://tintin.mudhalla.net/protocols/gmcp/

北侠是支持GMCP进行数据通信的,详细可以在游戏中使用tune gmcp进行查看可以支持的具体GMCP种类。

PyMUD使用GMCPTrigger类来进行GMCP消息的处理。其使用方法与标准Trigger基本相同,也同样支持同步与异步两种方式。与Trigger的最大差异在于,GMCPTrigger使用name参数作为触发条件,该name必须与MUD服务器发送的GMCP消息的名称完全一致(区分大小写,因此大小写也必须一致)。

要在会话中使用GMCP触发器,要做的两件事是:

  • 构建一个GMCPTrigger类(或其子类)的实例。
  • 将该实例通过session.addGMCPTrigger方法增加到会话的清单中。

5.4.2 类型定义与构造函数

GMCPTrigger的构造函数如下:

class GMCPTrigger(BaseObject):
    """
    支持GMCP收到数据的处理,可以类似于Trigger的使用用法
    GMCP必定以指定name为触发,触发时,其值直接传递给对象本身
    """
    def __init__(self, session, name, *args, **kwargs):
                pass

GMCPTrigger也使用了统一的函数语法,必须指定的位置参数包括session(指定会话)、name(为GMCPTrigger服务器发送的消息名称),其余的参数都通过命名参数在kwargs中指定,与Trigger基本相同。具体如下:

  • id: 唯一标识符。不指定时,默认生成session中此类的唯一标识。
  • group: GMCP触发器所属的组名,默认未空。支持使用session.enableGroup来进行整组对象的使能/禁用
  • enabled: 使能状态,默认为True。标识是否使能该触发器。
  • onSuccess: 函数的引用,默认为空。当触发器被触发时自动调用的函数,函数类型应为func(name, line, wildcards)形式。其中Name为GMCP名称,line为收到的值原始文本,wildcards为尝试用json.load解析后的值内容。

5.4.3 使用示例

(由于GMCPTrigger在0.16.2版本发生变更,此处待更新)(by newstart, 2023-12-19)

5.5 状态窗口定制

可以通过脚本定制状态窗口内容。要定制状态窗口的显示内容,将session.status_maker属性赋值为一个返回支持显示结果的函数即可。可以支持标准字符串或者prompt_toolkit所支持的格式化显示内容。 有关prompt_toolkit的格式化字符串显示,可以参见该库的官方帮助页面: https://python-prompt-toolkit.readthedocs.io/en/master/pages/printing_text.html

以下是一个实现状态窗口的示例:

from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from settings import Settings
 
class Configuration:
    def __init__(self, session) -> None:
        session.status_maker = self.status_window
 
    def status_window(self):
 
        formatted_list = list()
 
        (jing, effjing, maxjing, jingli, maxjingli, qi, effqi, maxqi, neili, maxneili) = self.session.getVariables(("jing", "effjing", "maxjing", "jingli", "maxjingli", "qi", "effqi", "maxqi", "neili", "maxneili"))
        ins_loc = self.session.getVariable("ins_loc", None)
        tm_locs = self.session.getVariable("tm_locs", None)
        ins = False
        if isinstance(ins_loc, dict) and (len(ins_loc) >= 1):
            ins = True
            loc = ins_loc
 
        elif isinstance(tm_locs, list) and (len(tm_locs) == 1):
            ins = True
            loc = tm_locs[0]
 
        # line 1. char, menpai, deposit, food, water, exp, pot
        formatted_list.append((Settings.styles["title"], "【角色】"))
        formatted_list.append((Settings.styles["value"], "{0}({1})".format(self.session.getVariable('charname'), self.session.getVariable('char'))))
        formatted_list.append(("", " "))
        formatted_list.append((Settings.styles["title"], "【门派】"))
        formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('menpai'))))
        formatted_list.append(("", " "))
        formatted_list.append((Settings.styles["title"], "【存款】"))
        formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('deposit'))))
        formatted_list.append(("", " "))
        formatted_list.append((Settings.styles["title"], "【食物】"))
 
        food = int(self.session.getVariable('food', '0'))
        if food < 100:
            style = Settings.styles["red"]
        elif food < 200:
            style = Settings.styles["yellow"]
        elif food < 350:
            style = Settings.styles["green"]
        else:
            style = Settings.styles["skyblue"]
 
        formatted_list.append((style, "{}".format(food)))
        formatted_list.append(("", " "))
 
        formatted_list.append((Settings.styles["title"], "【饮水】"))
        water = int(self.session.getVariable('water', '0'))
        if water < 100:
            style = Settings.styles["red"]
        elif water < 200:
            style = Settings.styles["yellow"]
        elif water < 350:
            style = Settings.styles["green"]
        else:
            style = Settings.styles["skyblue"]
        formatted_list.append((style, "{}".format(water)))
        formatted_list.append(("", " "))
        formatted_list.append((Settings.styles["title"], "【经验】"))
        formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('exp'))))
        formatted_list.append(("", " "))
        formatted_list.append((Settings.styles["title"], "【潜能】"))
        formatted_list.append((Settings.styles["value"], "{}".format(self.session.getVariable('pot'))))
        formatted_list.append(("", " "))
        formatted_list.append((Settings.styles["title"], "【惯导】"))
        if ins:
            formatted_list.append((Settings.styles["skyblue"], "正常"))
            formatted_list.append(("", " "))
            formatted_list.append((Settings.styles["title"], "【位置】"))
            formatted_list.append((Settings.styles["green"], f"{loc['city']} {loc['name']}({loc['id']})"))
        else:
            formatted_list.append((Settings.styles["red"], "丢失"))
            formatted_list.append(("", " "))
            formatted_list.append((Settings.styles["title"], "【位置】"))
            formatted_list.append((Settings.styles["value"], f"{self.session.getVariable('%room')}"))
 
        # a new-line
        formatted_list.append(("", "\n"))
 
        # line 2. hp
        if jing != None:
            formatted_list.append((Settings.styles["title"], "【精神】"))
            if int(effjing) < int(maxjing):
                style = Settings.styles["red"]
            elif int(jing) < 0.8 * int(effjing):
                style = Settings.styles["yellow"]
            else:
                style = Settings.styles["green"]
            formatted_list.append((style, "{0}[{1:3.0f}%] / {2}[{3:3.0f}%]".format(jing, 100.0*float(jing)/float(maxjing), effjing, 100.0*float(effjing)/float(maxjing),)))
            formatted_list.append(("", " "))
 
            formatted_list.append((Settings.styles["title"], "【气血】"))
            if int(effqi) < int(maxqi):
                style = Settings.styles["red"]
            elif int(qi) < 0.8 * int(effqi):
                style = Settings.styles["yellow"]
            else:
                style = Settings.styles["green"]
            formatted_list.append((style, "{0}[{1:3.0f}%] / {2}[{3:3.0f}%]".format(qi, 100.0*float(qi)/float(maxqi), effqi, 100.0*float(effqi)/float(maxqi),)))
            formatted_list.append(("", " "))
 
            formatted_list.append((Settings.styles["title"], "【精力】"))
            if int(jingli) < 0.6 * int(maxjingli):
                style = Settings.styles["red"]
            elif int(jingli) < 0.8 * int(maxjingli):
                style = Settings.styles["yellow"]
            elif int(jingli) < 1.2 * int(maxjingli):
                style = Settings.styles["green"] 
            else:
                style = Settings.styles["skyblue"]
            formatted_list.append((style, "{0} / {1}[{2:3.0f}%]".format(jingli, maxjingli, 100.0*float(jingli)/float(maxjingli))))
            formatted_list.append(("", " "))
 
            formatted_list.append((Settings.styles["title"], "【内力】"))
            if int(neili) < 0.6 * int(maxneili):
                style = Settings.styles["red"]
            elif int(neili) < 0.8 * int(maxneili):
                style = Settings.styles["yellow"]
            elif int(neili) < 1.2 * int(maxneili):
                style = Settings.styles["green"] 
            else:
                style = Settings.styles["skyblue"]
            formatted_list.append((style, "{0} / {1}[{2:3.0f}%]".format(neili, maxneili, 100.0*float(neili)/float(maxneili))))
            formatted_list.append(("", " "))
 
            # a new-line
            formatted_list.append(("", "\n"))
 
        # line 3. GPS info
        def go_direction(dir, mouse_event: MouseEvent):
            if mouse_event.event_type == MouseEventType.MOUSE_UP:
                self.session.exec_command(dir)
        if ins:
            formatted_list.append((Settings.styles["title"], "【路径】"))
            # formatted_list.append(("", "  "))
            links = self.mapper.FindRoomLinks(loc['id'])
            for link in links:
                dir = link.path
                dir_cmd = dir
                if dir in Configuration.DIRS_ABBR.keys():
                    dir = Configuration.DIRS_ABBR[dir]
                else:
                    m = re.match(r'(\S+)\((.+)\)', dir)
                    if m:
                        dir_cmd = m[2]
 
                formatted_list.append((Settings.styles["link"], f"{dir}: {link.city} {link.name}({link.linkto})", functools.partial(go_direction, dir_cmd)))
                formatted_list.append(("", " "))
 
        return formatted_list
tools/pymud.txt · 最后更改: 2024/02/04 17:06 由 newstart