pkuxkx.noob机器人架构剖析
引我一直有一个说法,Mud的机器人是一个工程问题,不是一个技术问题。写机器人不需要什么技术储备。需要的是大量的踩坑经验。尤其是当机器人对应的Mud还在不停更新的时候,完善和维护机器人的过程中一定是一个不停堆积的屎山。
既然是一个工程问题,那首先就要明确这个工程的目标。做一个Mud的老泥虫,10多年前我早就完成了"维护别人的机器人","做一个自己的机器人","做一个自己的全自动机器人","做一个模块化的全功能全自动机器人"的成就。
写pkuixkx.noob这个机器,除了证明我的客户端有完整可用的功能之外,主要是为了实现"制作一个高可维护性"的机器人的成就。也就是,做这个机器的核心目的,是为了研究怎么提高机器人的可维护性和可维护周期。
只要在Mud死了或我死了之前,机器架构还没炸,就算成功。从这点来看,项目的可行性还是很高的。
以上,是这个机器人的制作背景介绍。
另,出于政策原因,帖子里所有的任务相关代码都做过截取和脱敏处理,保证没法直接或间接使用。
前排占位。 写在前面的话
作为一个工程问题,我十分推荐一本工程问题的入门书《人月神话》。恩,这不是神话的书。本质上,它的标题应该是《人月笑话》。
这本书其实并不是一个单纯的计算机相关的书,有兴趣的都可以找来读读,内容不多,很有现实意义。
其中有一章具有长期的警醒自己的价值:没有银弹。
是的,没有银弹,没有金手指,一起都只是权衡(tradeoff,看很多新技术介绍会容易看到这个概念)
用一个手段,必然是用一定的付出,获取一定的优势,所以要做的就是发挥足优势,避免付出积累到无法控制的状态。有经验的程序员,只是知道在哪里下刀的疼痛在自己能接受的范围内。
“那么,古尔丹,代价是什么呢?”
以及,既然没有银弹,那么就只有原则,没有铁则。如果代价足够大,那么再合适的地方不规范一下,也没什么不好。
知晓代价,理解代价,接受代价。
机器人架构
1.输入处理模块
这个是将输入(mud的文字输出)进行标准化的的模块。通过标准化程序将模块输出标准化,然后转化成 系统变量 和 事件,供订阅者消费
2.策略配置模块
策略配置模块通过 用户的任务变量,任务系统,任务模块,根据当前的系统变量在合适的时间点生成 任务流程
3.核心逻辑模块
核心逻辑模块的作用是根据策略配置模块生成的任务流程,根据系统变量,对输入处理模块生成的事件进行处理,推荐流程流转,直至已经分配的任务流程完全走完
4.房间系统
对于整个以心跳推进的mud系统来说,房间系统(当前房间环境/移动/路径)本质上来说是一个格格不入的独立系统。这也是很多新人会觉得移动/走路/遍历很难的地方。本质上,mud系统是一个 房间系统 和人物属性系统并行同时演化的系统。所以房间系统虽然内容不多,但在重要性上,是可以和核心策略系统一样重要,并立的重要模块。
5.其他独立系统
其他独立系统最典型的就是战斗系统,其他可能包括装备,技能策略等。有独立的配置属性和逻辑单元,不受核心逻辑模块控制。但由于体量和重要性不如房间系统,只能合并成一个模块,与核心逻辑模块,房间系统并立。
提出问题,找出方向
之前说过,我的目标是做一个高可维护性的机器人。
言下之意,我写过一个最后难以维护的机器人。就是helllua。这个机器人后期处于一个难以(相对)维护,我自己也不想去维护的阶段。
那么,问题出在哪呢?
出在触发失控。当触发堆到一定规模,甚至在不同任务中同一个触发要有不同的作用后,开始失控。触发出了问题,我甚至不能第一事件找到在代码的什么地方去Debug。
本质问题是,触发无法保证正确的关闭。触发后无法通过直觉的找到去哪里调试。也就是,当出现问题后,找到不责任人。
所以,整个机器的构建方向,就朝解决这个问题开始了。
触发真的需要关闭吗?
触发无法正确的关闭?
那简单阿,触发不要关闭就行了。
实际上,触发的关闭从逻辑上就是反直觉的。触发代表着一种未来的可能,关闭触发,除了性能考虑外,逻辑上就代表这个未来的可能不会出现了。可事件上关闭触发很多时候只是为了防止误触。
那么,我就不关闭触发,所以触发只更新数据和抛出事件。谁需要这个触发,谁就监听事件就行。负责触发的代码管杀不管埋,只管正确的响应。
正好引入控制反转的概念,业务的归业务,逻辑的归逻辑,不要混淆,彻底解耦。
当然,一切都有代价
引入事件系统很好的实现了代码解耦和责任划分,自然也有代价。代价是
1.失去了有哪些代码能响应的控制。
2.无法控制响应代码的顺序。
其中1还是一个责任人的问题,之后的内容里会统一处理
2是一个现实层面无解的问题。当然,当你可能有有几十个几百个代码响应同一个触发时,这本身对于普通人就是一个实况的状态。这时候只能通过细分事件,采用类似生命中期的方案,细分引入 beforeInit,init,postInit的阶段来进行缓解。
所幸在Mud的编程环节中,事件的响应顺序相对并没那么重要,压力没那么大。
参考
控制反转:https://www.zhihu.com/question/566195424/answer/3310640401
生命周期:https://www.zhihu.com/question/341446246/answer/798571703
范例
我框架启动时的生命周期代码
App.Start = function () {
App.Init()
App.LoadMods()
App.Load("ui/ui.js")
App.Load("param/param.js")
App.Load("core/core.js")
App.Load("info/info.js")
App.Load("alias/alias.js")
App.Load("quest/quest.js")
App.Load("help/help.js")
App.Raise("BeforeInit")
App.Raise("Init")
App.Raise("InitMod")
App.Raise("Ready")
App.Raise("AfterReady")
App.Raise("Intro")
}
那么,到底谁来负责呢?
在“提出问题,找出方向”和“触发真的需要关闭吗?”中,我都提出了一个责任人的概念。
什么叫责任人?
在“触发真的需要关闭吗?”中,我提到了控制反转的概念。各个部件和子组件只负责创建/销毁/提供基本功能的接口,完全不负责逻辑流程。
负责逻辑流程和调用的,就是明显的责任人。按传统的MVC架构的话,就是控制器Controller。
它应该代码很轻,体现了实际逻辑,将业务的细节都隐藏起来,方便维护和调试。
在我的这个机器人的架构中,责任人其实由三个部分
微观的 当前状态,流转层面的状态队列,和宏观成面的当前任务
在调试时,通过查看/打印 当前状态/状态队列/当前任务的信息,能很容易的定位到出问题的代码的位置,便于调试,同时当前状态/状态队列/当前任务都有自己独立的变量空间,是一个切割变量有效空间(作用域)的纬度。
参考:MVC模式 https://zhuanlan.zhihu.com/p/353173212
无所不在的状态。
状态机是一个很常见的计算机模型。
大概说明(不重要):https://zhuanlan.zhihu.com/p/543259936
我的理解就是,引入状态概念。根据所处状态不同,对输出作出不同的响应,并在状态中进行迁移,直到到达结束状态。
举个例子,大家写的lua里,等号= 有两个不同的作用,赋值和判断,根据上下文不同,作用不同,后续的状态也不同。
那么,如果我们把Mud机器人视为一个大型文本,成功的触发视为文本的输入,很明显,某种程度下机器人可以分解为不同的状态,同一个状态在面对同样的触发(事件后)执行的动作不同,会进入不同的新状态。会感觉很像状态机。
注意,仅仅是像。
所以,我在机器里引入了状态的概念。
一个状态,指机器当前功能的一个预期的切片(我预期会遇到npc,我预期会进入某个房间,我预期会出现某个文本)。他包括:
1.一个状态ID用于开发和调试
2.一个独立或共享的上下文
3.一个入口函数和出口函数,用于执行进入和离开函数的代码
4.一个事件响应函数,用于对各个关心的事件作出反应
入口用于执行初始指令,比如开始遍历前可以发送一个look或者set brief
上下文指一个独立的变量区域。最大的作用就是可以做快照和恢复时自动保存。比如走路时,遇到滑下山,那么就是把当前行走/遍历的上下文保存,从新地点定位,再移动到滑下山之前的位置,继续行走
出口指令用于销毁资源,比如删除临时计时器。一个常见的指令就是在一个房间等待N秒,超时进入下一个房间
事件响应函数则是代码主体,对于不同的情况作出判断,根据需要看是否进入其他状态
同时,有一个全局唯一的状态管理器,负责状态的切换
当引入状态/状态机后,我们获得的优势是
1.有了当前责任人,知道谁负责响应事件,清理触发和计时器
2.可以选择性的对事件作出响应
3.将代码的传染范围进行限制,只要一个状态不再使用,就不会传染到其他部分。作为一个工程项目,真的遇到问题或大调整,直接写一个新的状态,将老状态弃用即可。
当然,引入状态机,和真正的状态机模型一样,会引入很多缺点
1.无法应对意外,需要写独立的意外处理程序接入状态机流程。
2.引入复杂的状态迁移。传统的状态机的优势之一时可以用状态迁移表来进行方便的编写。以mud系统的复杂度,状态迁移表完全时人力无法维护的程度,必须引入其他机制处理。
凡事都有代价,不是么?
对了,在引入状态我还走过一定的弯路,我居然想着去层层继承。事实证明,继承这玩意,一层就够,剩下的还是接口+组建的方式更适合普通人类。
附录:
等待打码状态,包含弹窗/超时进入暂离模式/邻近超时提醒/获取焦点后重新弹窗等功能
(function (App) {
let basicstate = Include("core/state/basicstate.js")
let State=function(){
basicstate.call(this)
this.ID="core.state.captcha.captcha"
}
State.prototype = Object.create(basicstate.prototype)
State.prototype.Enter=function(context,oldstatue){
App.Raise("captcha")
switch (App.Data.CaptchaCurrentType){
case "工号":
this.Cmd="report "
break
case "zone":
case "zonestart":
case "zoneend":
case "tiangan":
case "flowers":
case "exits":
case "zoneroom":
case "2words":
case "show":
this.Cmd=null
break
default:
this.Cmd="fullme "
}
SetPriority(2)
App.Core.HUD.SetWarningMessage("需要打码")
App.Core.CaptchaShow()
world.DoAfterSpecial(App.Data.CaptchaTimeoutInSecounds, 'App.RaiseStateEvent("core.captcha.timeout")', 12);
if (App.Data.CaptchaTimeoutInSecounds>120){
world.DoAfterSpecial(App.Data.CaptchaTimeoutInSecounds-60, 'App.Raise("core.captcha.timeoutsoon")', 12);
}
if (App.Data.CaptchaTimeoutInSecounds>360){
world.DoAfterSpecial(300, 'App.Raise("core.captcha.timeoutsoon")', 12);
}
if (App.Data.CaptchaTimeoutInSecounds>660){
world.DoAfterSpecial(600, 'App.Raise("core.captcha.timeoutsoon")', 12);
}
}
State.prototype.Leave=function(context,newstatue){
Userinput.hideall()
SetPriority(0)
App.Core.HUD.SetWarningMessage("")
DeleteTemporaryTimers()
}
State.prototype.OnEvent=function(context,event,data){
switch(event){
case "captcha.submit":
App.SetAfk(false)
let code=App.Data.CaptchaCode
if (code){
if (this.Cmd){
App.Send(this.Cmd+code)
}else{
App.Next()
}
}else{
App.Data.CaptchaCountFail++
App.Next()
}
break
case "focus":
App.Core.CaptchaShow()
break
case "captcha.success":
App.Data.CaptchaCountSuccess++
App.Next()
break
case "captcha.fail":
App.Data.CaptchaCountFail++
App.Fail()
break
case "captcha.other":
App.Next()
break
case "core.captcha.timeout":
Note("等待打码超过"+App.Data.CaptchaTimeoutInSecounds+"秒,进入暂离模式")
App.SetAfk(true)
App.Next()
break
}
}
return State
})(App)
问题没有被解决,只是被延后。
状态机模式讲完了。还记得为什么引入状态机么?
为了解决责任人的问题。
那么下一个问题,切换状态的责任人是谁?
做一个巨大无比的状态迁移表么?可我们的代码实行了控制反转模式,一个状态按理不知道其他的状态,一个模块不应该知道是否又其他非依赖模块啊?
沉默,沉默是今晚的康桥。
为了一个问题,让我们继续裱糊,先解决状态迁移的问题。
既然一个状态不知道自己因该切换成什么状态,那么我们就引入一个状态队列。
状态队列需求如下
1.队列中压入多个在正常情况下正常流转的状态
2.状态流转中的特殊情况由当前状态自己进行转换
3.状态队列可以通过成功或者失败的方式中断
4.状态队列可以用简单的控制语句进行控制
所以,我参考Javascript的promise和Javascript的express框架的中间件结构,创建了一个状态自动机类
它是一个多层的数组,每层有一个或多个状态按顺序排列,可以动态维护。通过最外一层依次流转,每层执行完后执行内存未完成的状态。
每一层队列共享一套上下文
提供了如下方法
Finish 结束队列
Fail 失败并放弃队列
Push 新加一层队列和上下文
Append 在当前队列队列最后方追加状态
Insert 加载当前队列最前方插入状态
Pop 弹出一层队列
Label 创建一个循环点
Loop 循环到指定循环点
Break 中断指定循环点
Flush 清空队列
Snapshot 快照
Rollback 恢复快照
然后引入了App.Next和App.Fail方法,用于在状态中交出控制权,由状态队列进行流转控制。
引入这么个结构后,我们能明确状态状态应该怎么流转,以及接下去要执行哪些状态。能很好的对代码进行开发和维护。
但问题依然还在
1.写法太不人性化,这么多状态难以正确记忆,调试时也很蛋疼
2.依然需要解决谁为队列负责的问题
3.难以应对突发情况
参考
Express框架:https://expressjs.com/en/4x/api.html#app
Promise:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises
代码最好的注释,是不需要注释。
既然我们这个项目解决的时可维护性的问题,那么就让我们重视这个问题吧
首先,算然我们的机器的地层时状态,但实际写机器的时候,我们不太可能在一个一个状态里搏斗。将常用状态分装为指令是一个很常见的操作。
一个指令有一个文本id和参数
App.NewCommand("to", App.Options.NewWalk("yz-jindian")),
然后可以将指令串成队列
App.Commands([
App.NewCommand("to", App.Options.NewWalk("yz-jindian")),
App.NewCommand("do", "sell " + App.Core.Asset.Asset.UNID),
App.NewCommand("do", "i2"),
App.NewCommand("nobusy"),
App.NewCommand("function", function () {
App.Core.Asset.Execute()
}),
]).Push()
App.Next()
再配合上必要的逻辑判断
App.Quest.Zhuliu.Diebao.FinishNext=function(){
if (App.Data.Ask.Replies=="余玠(Yu jie)告诉你:这篇情报我颇有不解,你帮忙看看?"){
App.Commands([
App.NewCommand("delay",2),
App.NewCommand("function", function () { App.API.Captcha({type:"2words",timeout:15*60*1000}) }),
App.NewCommand("function", App.Quest.Zhuliu.Diebao.WaitCaptcha),
]).Push()
App.Next()
return
}
App.Next()
}
这就是很直接的将代码流程图化了。
这样做的优点就是,有了流程图,直接就可以啪啦啪啦把代码堆出来。对于类似的任务,可以直接ctrl+C 然后ctrl +V,改一下流程关键部分和触发,就能直接开始跑任务测试了。
范例:破阵任务部分脱敏代码
App.Quest.Zhuliu.Pozhen.Start = function () {
App.Quest.Zhuliu.Pozhen.Data = {}
App.Commands([
App.NewCommand('prepare', App.PrapareFull),
App.NewCommand("to", App.Options.NewWalk("gyz")),
App.NewCommand("planevent",App.Options.NewPlanEvent("#zhuliu",["pozhen"])),
App.NewCommand("ask", App.Quest.Zhuliu.Pozhen.QuestionJob),
App.NewCommand("nobusy"),
App.NewCommand("function", App.Quest.Zhuliu.Pozhen.Next),
]).Push()
App.Next()
}
App.Quest.Zhuliu.Pozhen.Go = function (zone, room) {
App.Quest.Zhuliu.Pozhen.Data.CurrentExit = 0
App.Quest.Zhuliu.Pozhen.Data.Exits = []
App.Quest.Zhuliu.Pozhen.Data.Zone = zone
App.Quest.Zhuliu.Pozhen.Data.Room = room
App.Quest.Zhuliu.Pozhen.Data.Visited = {}
let path = App.API.GetZoneRoomPatrol(zone, room)
if (path == null) {
Note("无地区[" + zone + "]的信息")
return
}
if (path == "") {
Note("区域[" + zone + "]不可用")
App.Quest.Zhuliu.Pozhen.Fail()
return
}
App.Core.Traversal.New()
App.Data.Traversal.Target = "*"
App.Data.Traversal.Type = "custom"
App.Data.Traversal.Answer = path
App.Data.Traversal.State = "mod.zhuliu.pozhen.arrive"
App.Commands([
App.NewCommand("combatinit"),
App.NewCommand("do", "set wimpy 0"),
App.NewCommand("powerup"),
App.NewCommand("function", App.Core.Traversal.Start),
App.NewCommand("function", App.Quest.Zhuliu.Pozhen.Finish),
]).Push()
App.Next()
}
App.Quest.Zhuliu.Pozhen.Finish = function () {
let cmds = []
if (App.Data.Room.Name == "阵眼"||App.Data.Room.Name == "阵眼[破阵任务副本]") {
cmds.push(App.NewCommand("move", App.Options.NewPath("w")))
}
cmds = cmds.concat([
App.NewCommand("nobusy"),
App.NewCommand("do",GetVariable("connect_cmd")),
App.NewCommand('prepare', App.PrapareFull),
App.NewCommand("to", App.Options.NewWalk("gyz")),
App.NewCommand("ask", App.Quest.Zhuliu.Pozhen.QuestionFinish),
App.NewCommand("nobusy"),
App.NewCommand("ask", App.Quest.Zhuliu.Pozhen.QuestionFail),
App.NewCommand("nobusy"),
App.NewCommand("do", "jq"),
App.NewCommand("nobusy"),
App.NewCommand('prepare', App.PrapareFull),
])
App.Commands(cmds).Push()
App.Next()
}
页:
[1]
2