目录

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)

0.17.2 (2023-12-28)

0.17.1post1 (2023-12-27)

0.17.1 (2023-12-27)

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

0.17.0 (2023-12-24)

名称类型状态含义
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为卸载插件的会话。卸载在每一个会话关闭时均被自动运行一次。

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

0.16.2 (2023-12-19)

0.16.1.post2 (2023-12-12)

0.16.1.post1 (2023-12-12)

0.16.1 (2023-12-11)

1. 需求、安装与运行

1.1 概述

PyMUD具有以下特点:

1.2 环境需求

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

1.3 安装

1.4 运行

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

python -m pymud

2. 主要文件

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 会话与连接管理

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

4. 脚本

4.1 知识基础

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

要能在PyMUD中实现初级脚本功能,首先要掌握以下知识和概念:
如要使用PyMUD的高级功能和实现脚本的高级功能,则还需要掌握以下内容:
如对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两个基础类。 要在会话中使用触发器,要做的两件事是:

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中指定。触发器支持和使用的命名参数、默认值及其含义如下:

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

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两个基础类。 要在会话中使用别名,要做的两件事是:

4.6.2 类型定义与构造函数

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

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两个基础类。 要在会话中使用定时器,要做的两件事是:

4.7.2 类型定义与构造函数

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

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

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

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的异步可以带来不少的好处,包括:

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

上述第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,只需在命令行与输入该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触发器,要做的两件事是:

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基本相同。具体如下:

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