先把代码总体架构给组织理顺了。
之前的代码走的按功能按组件划分。现在将每个模块分了3个层,按层整理代码。
这样的优点是有测试的可能。hellclient本身因为都是散布的组建,其实很难做分块的测试。
同时找代码也更方便一点。
毕竟hellclient是从0开始长起来的,互相之间的依赖肯定更重。
hellclient.net是重构项目,能花时间考虑怎么组织和布局。
先把代码总体架构给组织理顺了。
之前的代码走的按功能按组件划分。现在将每个模块分了3个层,按层整理代码。
这样的优点是有测试的可能。hellclient本身因为都是散布的组建,其实很难做分块的测试。
同时找代码也更方便一点。
毕竟hellclient是从0开始长起来的,互相之间的依赖肯定更重。
hellclient.net是重构项目,能花时间考虑怎么组织和布局。
此外.net版本还会尝试 精细的经常控制。
go 版本过于然用go协程,然后锁控制,后期有点失控。
重写的原因之前已经说过了,主要是golang的v8库的问题
当然,既然重写,也肯定要把代码重新组织一遍。
目前看go版本的代码有点过于混杂,不够清晰,不方便测试和扩展。
第一步是拆分项目,目前项目拆分为4个部分
此项目是备用计划,最终可能会废弃。
主要是hellclient在实际使用一段时间,发生了一些不可调和的问题。
hellclient采用go语言开发,一开始的目标是纯go,无额外依赖,外部扩展的方式。
但因为js实际的性能问题,引入了v8引擎。
在windows下go嵌入v8引擎十分麻烦,使用的v8go包最后一次更新在2023年,而且很早的版本就放弃windows支持了。可见的短时间内不可能有windows版本的更新。这导致hellclient为了跨端兼容智能使用很老版本的v8go,而且与linux-arm版本冲突。
所以开一个备用计划,看看是否能依托微软的clearscript库,实现一个完全兼容hellclient当前版本的实现。
以最靠近Mud服务器到最靠近用户排序
做机器人的第一步就是在机器和MUD实际的交互中加一层适配器层,机器人只与适配器层交互,避免与客户端/Mud的过度耦合。适配器层包括两个组件
配置使用基本统一的格式,由默认值/变量/数据文件一次覆盖获得最终结果,使得配置模块与业务逻辑独立,业务逻辑之需要注册新的参数,由具体的UI/变量负责加载和解析
我整个机器是使用的 类似 Promise的Command队列进行管理,并提供必须的队列控制指令,比如Flush,Insert,Append,Snapshot,Rollback,提供统一的驱动引擎
提供了#start+quest变量,以及#do的用户指令队列两种形式,使得用户能在不修改代码的情况下,尽可能的进行自定义流程管控。配合信息配置层的设置,尽量在变量和输入层面就能取代大部分代码工作
通过助理按钮和别名系统,提供尽可能方便的设置功能,便于用户使用机器,以及在手机上进行操作。
有一小部分Mud会有试图用字符图形化传达信息的任务。
这种任务,如果少量,算挺有情趣。如果出现大量,那就基本是一个纯机器向的MUD给机器制作者分级的机制了。
图形任务,有各种各样的形式,难以做一个统一的介绍,但可以整理下思路。
一般来说,图形任务的生成是走以下流程
所以,对于图形化任务的处理,基本上也是按这个顺序倒过来依次处理。
对于图形任务,已经脱离了对手动/体验型玩家的基本尊重了,很容易成为服务器LPC对抗VPS的脚本对拼,个人建议,不要投入太大精力。毕竟做机器只是为了游戏本身的乐趣或者体验变成的快乐,而不是对抗某个后者某群WIZ的自我满足感。所以,建议点到为止即可。
升级Avalonia版本,提升界面性能
延时响应是Mud解密中很常见的一个状态。
指当你发出指令后,游戏中的NPC或者场景会在一定时间后,或者断断续续的做出响应。
一般来说,延迟响应为了考虑到玩家的体验,服务器的响应需要进行特别复杂的处理,使用简单的匹配和Mode标记就可以处理。
之所以单独作为一个课题讨论,是因为延时响应的核心处理点是 失效管理。
普通解密,触发器一般是即用即关的,不需要考虑太多的意外状态,通过触发组就能很好的解决了。
而延时解密,因为要考虑到可能会有终止/意外/并行的情况,需要手动的对出发考虑失效和重复生效、计时器延时等状况,触发器组就很捉襟见肘了,一般需要更多的代码工具去进行管理。
我这里是通过自己的Task/Plan模块,将所有的触发/计时器/事件处理封装在一个Task对象内,并强制使用一个作用范围强制失效来解决这个问题的。
参考代码:
let baohuwait = 28 * 1000
let matcherKill = /^你对(.+)的(黑衣人|邪派高手|绝世高手)喝道:大胆狂徒,竟敢在这撒野!!/
//等待NPC出现的计划
let PlanProtect = new App.Plan(
App.Positions["Quest"],
(task) => {
task.AddTrigger(matcherKill, (tri, result) => {
if (result[1] != App.Data.Player.Score.名字) {
return true;
}
App.Send("halt")
let id
switch (result[2]) {
case "黑衣人":
id = "heiyi ren"
break
case "邪派高手":
id = "xiepai gaoshou"
break
case "绝世高手":
id = "jueshi gaoshou"
break
}
Baohu.Data.ID = id
Baohu.Data.Type = result[2]
}).WithName("ok")
let wait = Baohu.Data.Start + baohuwait - $.Now()
if (wait > 0) {
task.AddTimer(wait, (timer) => {
Note("准备迎敌")
App.Send("halt")
App.Core.Heal.TryTouch()
$.RaiseStage("prepare")
$.RaiseStage("baohu-ready")
return true
}).WithNoRepeat(true)
}
task.AddTimer(1100, () => {
if (App.Core.Weapon.Touch) {
if (($.Now() - Baohu.Data.Start) < baohuwait) {
App.Send("halt")
App.Core.Heal.TryTouch()
$.RaiseStage("pause")
$.RaiseStage("wait")
}
}
return true
})
task.AddTimer(3000, () => {
let d = (($.Now() - Baohu.Data.Start) / 1000).toFixed(0)
Note(`保护开始${d}秒`)
return true
})
task.AddTimer(60000, () => {
App.Log("保护等待超时")
return false
}).WithName("timeout")
$.RaiseStage("wait")
},
(result) => {
if (result.Name == "ok") {
$.PushCommands(
$.CounterAttack(`${GetVariable("id")}'s ${Baohu.Data.ID}`, App.NewCombat("baohu").WithTags(`baohu-${Baohu.Data.Type}`).WithPlan(PlanCombat)),
$.Function(Baohu.Finish),
)
$.Next()
return
}
App.Send("halt")
App.Log(`保护NPC${Baohu.Data.NPC.ID}失败`)
Baohu.Fail()
}
)
可以看到,我使用了一个叫做PlanProtect的计划(task工厂),创建了一个基于Quest的封装,所有的触发和计时器都绑定在这个Task封装上,并在Quest发生变化时强制失效。
由于我一直认为Mud机器人是一个工程问题,难点在于可维护性和代码失控。所以,我认为,选用或者开发一个触发失效工具,是十分重要的,也应该是整个机器人的核心驱动逻辑之一。
随机迷宫以及地图是很多Mud引入的新任务类型。
比如下面就是一个典型的迷宫地图
┌─┬─┬─┬─┬─┬─┬─┬─┐
│★│ │ │
├ ┼─┼ ┼─┼ ┼─┼─┼ ┤
│ │ │ │ │ │
├ ┼ ┼─┼ ┼ ┼─┼─┼ ┤
│ │ │ │ │
├ ┼─┼ ┼─┼ ┼─┼ ┼ ┤
│ │ │ │ │
├─┼ ┼ ┼─┼─┼─┼ ┼ ┤
│ │ │ │ │ │ │ │
├ ┼ ┼ ┼ ┼ ┼ ┼ ┼─┤
│ │ │ │ │ │ │
├ ┼ ┼─┼─┼─┼ ┼ ┼ ┤
│ │ │ │
├─┼─┼ ┼─┼ ┼─┼ ┼ ┤
│ │ │ │
└─┴─┴─┴─┴─┴─┴─┴─┘
这种地图本质就是在服务器创建了一系列的虚拟房间,并建立了房间中之间的联系,最后将这些方向和关系再打印成文字图案。
那么,处理方式也比较直接。
先将文字图标准化,化成比较简单的单字节图,然后再遍历建立临时房间和出口信息就可以。
参考HellMapManager.Dll的范例代码
local hmmlib=require('hmm')
local hmm=hmmlib.new()
hmm.DllEncoding=0 --0 for utf-8, 1 for gbk
local json=require('json')
local file=assert(io.open("hongchen.hmm","r"))
local data=file:read("*a")
file:close();
hmm:call("import",data)
local extrooms={}
local extpaths={}
local function buildMyRoom(entry,roomid,roomname)
local myroom1=hmmlib.Room.new()
myroom1.Key="myroom-entry"
myroom1.Name=roomname.."大厅"
local myroomexit1=hmmlib.Exit.new()
myroomexit1.Command="open gate;n"
myroomexit1.To="myroom-home"
local myroomexit2=hmmlib.Exit.new()
myroomexit2.Command="out"
myroomexit2.To=entry
myroom1.Exits={myroomexit1,myroomexit2}
local myroom2=hmmlib.Room.new()
myroom2.Key="myroom-home"
myroom2.Name=roomname.."卧室"
local myroomexit3=hmmlib.Exit.new()
myroomexit3.Command="open gate;s"
myroomexit3.To="myroom-entry"
myroom2.Exits={myroomexit3}
table.insert(extrooms, myroom1)
table.insert(extrooms, myroom2)
local entrypath=hmmlib.Path.new()
entrypath.From=entry
entrypath.Command="go "..roomid
entrypath.To="myroom-entry"
table.insert(extpaths, entrypath)
end
buildMyRoom("2440","myhouse","大别野")
local mazemaptxt=[[
┌─┬─┬─┬─┬─┬─┬─┬─┐
│ │ │
├─┼ ┼─┼─┼─┼─┼ ┼ ┤
│ │ │ │ │ │
├ ┼ ┼─┼ ┼ ┼─┼ ┼ ┤
│ ♚ │ │ │ │ │
├─┼ ┼ ┼ ┼ ┼─┼ ┼ ┤
│ │ │ │ │
├─┼ ┼─┼ ┼─┼─┼ ┼─┤
│ │ │ │ │ │
├ ┼ ┼─┼─┼ ┼ ┼─┼ ┤
│ │ │ │ │ │
├ ┼ ┼─┼─┼ ┼ ┼ ┼ ┤
│ │ │ │ │ │
├ ┼ ┼─┼ ┼ ┼ ┼ ┼─┤
│ │ │ │ │ ★│
└─┴─┴─┴─┴─┴─┴─┴─┘
]]
--♚为当前位置,★为出口
local Maze={}
Maze.__index=Maze
Maze.new=function()
local self=setmetatable({},Maze)
self.RoomPrefix="maze-"
self.RoomKeys={}
self.Rooms={}
self.Paths={}
self.EntryRoom=""
self.ExitRooms={}
return self
end
function Maze:buildRoomKey(x,y)
return self.RoomPrefix..tostring(x).."-"..tostring(y)
end
function Maze:buildExit(room,tokey,command)
local exit1=hmmlib.Exit.new()
exit1.Command=command
exit1.To=tokey
table.insert(room.Exits, exit1)
end
function Maze:readmap(maptxt)
local maplines={}
for line in string.gmatch(maptxt, "[^\r\n]+") do
if #line>0 then
--因为utf8不定长,进行处理
line = string.gsub(line," "," ")
line = string.gsub(line,"│","+")
line = string.gsub(line,"─","+")
line = string.gsub(line,"├","+")
line = string.gsub(line,"┤","+")
line = string.gsub(line,"┴","+")
line = string.gsub(line,"┬","+")
line = string.gsub(line,"└","+")
line = string.gsub(line,"┘","+")
line = string.gsub(line,"┌","+")
line = string.gsub(line,"┐","+")
line = string.gsub(line,"┼","+")
line = string.gsub(line,"♚","I")
line = string.gsub(line,"★","O")
table.insert(maplines, line)
end
end
local roomwidth=(#maplines[1]-1)/2
local roomheight=(#maplines-1)/2
local currentY=1
--按行处理
while currentY<=roomheight do
local currentX=1
--处理每个房间
while currentX<=roomwidth do
local roomkey=self:buildRoomKey(currentX-1, currentY-1)
table.insert(self.RoomKeys, roomkey)
local room=hmmlib.Room.new()
room.Key=roomkey
if maplines[currentY*2]:sub(currentX*2, currentX*2)=="I" then
self.EntryRoom=roomkey
end
if maplines[currentY*2]:sub(currentX*2, currentX*2)=="O" then
table.insert(self.ExitRooms, roomkey)
end
if maplines[currentY*2]:sub(currentX*2+1, currentX*2+1)==" " then
self:buildExit(room,self:buildRoomKey(currentX,currentY-1),"e")
end
if maplines[currentY*2]:sub(currentX*2-1, currentX*2-1)==" " then
self:buildExit(room,self:buildRoomKey(currentX-2,currentY-1),"w")
end
if maplines[currentY*2+1]:sub(currentX*2, currentX*2)==" " then
self:buildExit(room,self:buildRoomKey(currentX-1,currentY),"s")
end
if maplines[currentY*2-1]:sub(currentX*2, currentX*2)==" " then
self:buildExit(room,self:buildRoomKey(currentX-1,currentY-2),"n")
end
table.insert(self.Rooms, room)
currentX=currentX+1
end
currentY=currentY+1
end
end
function Maze:applyTo(environment)
for _, room in ipairs(self.Rooms) do
table.insert(environment.Rooms, room)
end
for _, path in ipairs(self.Paths) do
table.insert(environment.Paths, path)
end
end
local mymaze=Maze.new()
mymaze:readmap(mazemaptxt)
local query=hmmlib.QueryPathAny.new()
query.From={"2522"}
query.Target={"myroom-home"}
query.Environment=hmmlib.Environment.new()
query.Environment.Rooms=extrooms
query.Environment.Paths=extpaths
local result=json.decode(hmm:call("querypathany", json.encode(query)))
if (result==nil) then
print("No home path found")
else
print("Home path found:")
print(json.encode(result))
end
-- 遍历迷宫后离开
local queryall=hmmlib.QueryPath.new()
queryall.Start=mymaze.EntryRoom
queryall.Target=mymaze.RoomKeys
queryall.Environment=hmmlib.Environment.new()
mymaze:applyTo(queryall.Environment)
result=json.decode(hmm:call("querypathall", json.encode(queryall)))
if (result==nil) then
print("No full maze path found")
else
print("Full maze path found:")
print(json.encode(result))
end
--遍历完成,离开路径
query=hmmlib.QueryPathAny.new()
query.From={result["To"]}
query.Target=mymaze.ExitRooms
query.Environment=hmmlib.Environment.new()
mymaze:applyTo(query.Environment)
result=json.decode(hmm:call("querypathany", json.encode(query)))
if (result==nil) then
print("No maze path found")
else
print("Maze path found:")
print(json.encode(result))
end
在处理动态地图时,核心还是不要想的太复杂,直接找出Room的对应元素,以及相应的Exit,然后根据需要遍历或者导航即可。
问与答是一个最为典型简单的Mud解密,是大部分Mud任务的基础与核心,可以作为我们研究Mud解密的入口。
对于一个标准的问答对话来说,一般是
这时,很多老玩家可能会向你介绍多行匹配这一神器。
但很可惜,这只是试图逃避正确解决问答问题的逃课行为。
多行匹配完全不适合问答解密上。因为多行匹配无法进行逻辑处理,无法引入状态。也无法标准化处理。
因此,多行匹配在问答问题上,只能算是一个补丁。如果你的机器建立在大量补丁上,很快就会带来难以解决的架构问题。
那么,怎么来进行一个标准化的问答呢?
很简单,对NPC的回答进行录制。
在ask npc时,同时发送你的ask指令和同步指令。对应的,你会预期两个服务器的回复
这样,就把问答问题,转换为对一个字符串数组的按行分析。
再配合简单的状态机形式,一般就是一个局部的Mode变量,就行解决大部分的问题了。
以hongchenjs的长安任务为例,需要向梁兴禄发起ask 任务,处理的代码如下
Changan.Check = function () {
if (App.Data.Ask.Answers.length) {
if (App.Data.Ask.Answers[0].Line == "梁兴禄对你说道:本府不是已经给你派发了差事,完不成的话就先跟我取消。") {
Changan.Fail();
return;
}
if (App.Data.Ask.Answers[0].Line == "梁兴禄对你说道:你的江湖经验已经很高了,再在衙门里也混不出个名堂了。") {
Quest.Cooldown(10 * 60 * 60 * 1000)
App.Next();
return
}
if (App.Data.Ask.Answers[0].Line == "梁兴禄盯着你看了看,说道:“你刚取消过一次任务,过一分钟再来吧。”") {
Quest.Cooldown(60 * 1000)
// App.Core.Timeslice.Change("")
App.Next();
return
}
if (App.Data.Ask.Answers[0].Line.match(matcherTooMany)) {
Quest.Cooldown(4 * 3600 * 1000)
// App.Core.Timeslice.Change("")
App.Next();
return
}
if (App.Data.Ask.Answers[0].Line == "梁兴禄对你说道:嗯,既然你有心为衙门出力,我便给你个差事。") {
Changan.All++;
App.Send("changanjob")
if (App.Data.Ask.Answers[1].Line == "梁兴禄说着便拿出一份公函交给你。") {
Changan.Gonghan()
return
}
Changan.Check2()
return
}
}
App.Fail()
}
Changan.Check2 = function () {
let result = App.Data.Ask.Answers[1].Line.match(matcherCode)
if (result) {
Changan.Data.Name = result[2];
for (var i = 3; i < App.Data.Ask.Answers.length; i++) {
let token = App.Data.Ask.Answers[i].Line.match(matcherCodeToken)
if (token) {
Changan.Data.Code += token[1].trim()
} else {
break
}
}
Changan.Data.Type = "code"
Changan.DoCode()
return
} else {
App.Log("未知的长安任务对话")
Changan.Fail()
return
}
}
能很好的看出怎么用标准录制的问答信息,来解决复杂谜题。
解密系统在mud机器人中,属于比较高层的应用层。
整个解密系统的本质就是:发出指令,期待响应,从响应中解析出系统状态,进入合适的模块。
一般而言,mud的解析会分为以下几种:
其中单行格式化信息和多行复杂信息,是比较正常的信息交互模式。
而延迟信息,图形化信息,多重交互信息,就大概率是巫师为机器人设置的障碍了。
在开始深入解析解密系统之前,我们先要确认一点,解密系统的基础。
所有的解密,本质来说都是从两步开始的。
确认信息同步,是为了跟信息一个明确的结束状态。一般会准备一个平时不怎么使用的指令,用来做信息结束的确认。只有看到这个指令回复出现,才进入下一步的动作。之前的所有的出发,不直接执行指令,只改变状态/属性/变量,最后才进行决策。同时,这也与mud机器的同步问题有关。
而记录和调阅多行信息,是模拟人对mud信息的处理。机器对mud是按行进行匹配的,但人对mud是根据历史多行进行阅读的。所以,在准备解密时,必须把所有可能有用的信息记录在行数组里。再根据确定的触发,匹配合适的上下文,分析数据。
对于机器人,我的观点是
Mud应该是为人设计的。如果完全不适合人玩,那这机器也没有做的意义。
这也是我的解密模块的理论基础。
HMM嵌入脚本Dll是将HMM程序本地编译为AOT版本,在dll里引用。
本质是将http api接口替换为c api,通过文本进行调用的接口。
优点:
限制:
作为作者,个人对用用只有以下三个希望
API:
API文档
预览版本下载链接:
https://github.com/hellclient-scripts/hellmapmanager/releases/tag/embedding.2026.04.22
简单的benchmark:
gowrk+httpapi: 0.5ms/req
nodejs:1ms/req
dll+lua:3ms/req
lua5.1/luajit:10ms/req
这次优化了下寻路算法,就正常寻路优化到了10ms左右
但是由于这是同一优化,api/node/dll模式也得到了极大的提升,所以差距还是等比例的。
因为,虽然寻路优化到了可用的范围(10ms)
但在追求性能的场合,依然无法提供性能保证。
更新内容为加入脚本shared数据功能和相关api
shared为使用同一个脚本的游戏可以共享读取的数据。
相对于现有的数据:
通过测试,将HMM编译为动态链接库,然后封装成lua库供lua调用,大概能在lua(非git)跑出45 ms的速度,也算可用了。
经过实际业务的测试,很遗憾的放弃hmm.ts的lua版性能支持。
由于HellMapManager的功能日渐复杂,性能压力也越来越大。目前的HMM的性能测试数据如下
由于绝大部分客户端仅支持原生lua, 并不支持luajit,而且ts转译lua还是有不小的性能损失的。所以hmm.ts基本不可能在可见的技术情况下达到可用的程度。
因此,我们不得不遗憾的承认,没有能力让hmm.ts达到在各大客户端可用的程度。
hmm.ts的lua版本仅提供兼容性支持,提供可编程的数据转换。
如果lua机器需要使用hmm数据库,需要走api通道。
当然,如果有可能的话,我也希望能让支持lua的客户端能高效的使用全功能的hmm.ts功能。