北大侠客行MUD论坛

 找回密码
 注册
搜索
热搜: 新手 wiki 升级
查看: 468|回复: 17

pkuxkx.noob机器人架构剖析

[复制链接]
发表于 2024-3-11 16:40:33 | 显示全部楼层 |阅读模式


我一直有一个说法,Mud的机器人是一个工程问题,不是一个技术问题。写机器人不需要什么技术储备。需要的是大量的踩坑经验。尤其是当机器人对应的Mud还在不停更新的时候,完善和维护机器人的过程中一定是一个不停堆积的屎山。

既然是一个工程问题,那首先就要明确这个工程的目标。做一个Mud的老泥虫,10多年前我早就完成了"维护别人的机器人","做一个自己的机器人","做一个自己的全自动机器人","做一个模块化的全功能全自动机器人"的成就。

写pkuixkx.noob这个机器,除了证明我的客户端有完整可用的功能之外,主要是为了实现"制作一个高可维护性"的机器人的成就。也就是,做这个机器的核心目的,是为了研究怎么提高机器人的可维护性和可维护周期。

只要在Mud死了或我死了之前,机器架构还没炸,就算成功。从这点来看,项目的可行性还是很高的。

以上,是这个机器人的制作背景介绍。

另,出于政策原因,帖子里所有的任务相关代码都做过截取和脱敏处理,保证没法直接或间接使用。

北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
发表于 2024-3-11 16:42:09 | 显示全部楼层
前排占位。
北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:42:14 | 显示全部楼层
写在前面的话

作为一个工程问题,我十分推荐一本工程问题的入门书《人月神话》。恩,这不是神话的书。本质上,它的标题应该是《人月笑话》。

这本书其实并不是一个单纯的计算机相关的书,有兴趣的都可以找来读读,内容不多,很有现实意义。

其中有一章具有长期的警醒自己的价值:没有银弹。

是的,没有银弹,没有金手指,一起都只是权衡(tradeoff,看很多新技术介绍会容易看到这个概念)

用一个手段,必然是用一定的付出,获取一定的优势,所以要做的就是发挥足优势,避免付出积累到无法控制的状态。有经验的程序员,只是知道在哪里下刀的疼痛在自己能接受的范围内。

“那么,古尔丹,代价是什么呢?”

以及,既然没有银弹,那么就只有原则,没有铁则。如果代价足够大,那么再合适的地方不规范一下,也没什么不好。

知晓代价,理解代价,接受代价。

北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:42:57 | 显示全部楼层
机器人架构



1.输入处理模块

这个是将输入(mud的文字输出)进行标准化的的模块。通过标准化程序将模块输出标准化,然后转化成 系统变量 和 事件,供订阅者消费

2.策略配置模块

策略配置模块通过 用户的任务变量,任务系统,任务模块,根据当前的系统变量在合适的时间点生成 任务流程

3.核心逻辑模块

核心逻辑模块的作用是根据策略配置模块生成的任务流程,根据系统变量,对输入处理模块生成的事件进行处理,推荐流程流转,直至已经分配的任务流程完全走完

4.房间系统

对于整个以心跳推进的mud系统来说,房间系统(当前房间环境/移动/路径)本质上来说是一个格格不入的独立系统。这也是很多新人会觉得移动/走路/遍历很难的地方。本质上,mud系统是一个 房间系统 和人物属性系统并行同时演化的系统。所以房间系统虽然内容不多,但在重要性上,是可以和核心策略系统一样重要,并立的重要模块。

5.其他独立系统
其他独立系统最典型的就是战斗系统,其他可能包括装备,技能策略等。有独立的配置属性和逻辑单元,不受核心逻辑模块控制。但由于体量和重要性不如房间系统,只能合并成一个模块,与核心逻辑模块,房间系统并立。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?注册

x
北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:43:38 | 显示全部楼层
提出问题,找出方向

之前说过,我的目标是做一个高可维护性的机器人。

言下之意,我写过一个最后难以维护的机器人。就是helllua。这个机器人后期处于一个难以(相对)维护,我自己也不想去维护的阶段。

那么,问题出在哪呢?

出在触发失控。当触发堆到一定规模,甚至在不同任务中同一个触发要有不同的作用后,开始失控。触发出了问题,我甚至不能第一事件找到在代码的什么地方去Debug。

本质问题是,触发无法保证正确的关闭。触发后无法通过直觉的找到去哪里调试。也就是,当出现问题后,找到不责任人。

所以,整个机器的构建方向,就朝解决这个问题开始了。

北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:44:18 | 显示全部楼层
触发真的需要关闭吗?

触发无法正确的关闭?

那简单阿,触发不要关闭就行了。

实际上,触发的关闭从逻辑上就是反直觉的。触发代表着一种未来的可能,关闭触发,除了性能考虑外,逻辑上就代表这个未来的可能不会出现了。可事件上关闭触发很多时候只是为了防止误触。

那么,我就不关闭触发,所以触发只更新数据和抛出事件。谁需要这个触发,谁就监听事件就行。负责触发的代码管杀不管埋,只管正确的响应。

正好引入控制反转的概念,业务的归业务,逻辑的归逻辑,不要混淆,彻底解耦。

当然,一切都有代价

引入事件系统很好的实现了代码解耦和责任划分,自然也有代价。代价是

1.失去了有哪些代码能响应的控制。
2.无法控制响应代码的顺序。

其中1还是一个责任人的问题,之后的内容里会统一处理

2是一个现实层面无解的问题。当然,当你可能有有几十个几百个代码响应同一个触发时,这本身对于普通人就是一个实况的状态。这时候只能通过细分事件,采用类似生命中期的方案,细分引入 beforeInit,init,postInit的阶段来进行缓解。

所幸在Mud的编程环节中,事件的响应顺序相对并没那么重要,压力没那么大。

参考

控制反转:https://www.zhihu.com/question/566195424/answer/3310640401
生命周期:https://www.zhihu.com/question/341446246/answer/798571703

范例

我框架启动时的生命周期代码

  1. App.Start = function () {

  2.     App.Init()

  3.     App.LoadMods()

  4.     App.Load("ui/ui.js")

  5.     App.Load("param/param.js")

  6.     App.Load("core/core.js")

  7.     App.Load("info/info.js")

  8.     App.Load("alias/alias.js")

  9.     App.Load("quest/quest.js")

  10.     App.Load("help/help.js")

  11.     App.Raise("BeforeInit")

  12.     App.Raise("Init")

  13.     App.Raise("InitMod")

  14.     App.Raise("Ready")

  15.     App.Raise("AfterReady")

  16.     App.Raise("Intro")

  17. }
复制代码


北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:45:08 | 显示全部楼层
那么,到底谁来负责呢?

在“提出问题,找出方向”和“触发真的需要关闭吗?”中,我都提出了一个责任人的概念。

什么叫责任人?

在“触发真的需要关闭吗?”中,我提到了控制反转的概念。各个部件和子组件只负责创建/销毁/提供基本功能的接口,完全不负责逻辑流程。

负责逻辑流程和调用的,就是明显的责任人。按传统的MVC架构的话,就是控制器Controller。

它应该代码很轻,体现了实际逻辑,将业务的细节都隐藏起来,方便维护和调试。

在我的这个机器人的架构中,责任人其实由三个部分

微观的 当前状态,流转层面的状态队列,和宏观成面的当前任务

在调试时,通过查看/打印 当前状态/状态队列/当前任务的信息,能很容易的定位到出问题的代码的位置,便于调试,同时当前状态/状态队列/当前任务都有自己独立的变量空间,是一个切割变量有效空间(作用域)的纬度。

参考:MVC模式 https://zhuanlan.zhihu.com/p/353173212

北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:45:47 | 显示全部楼层
无所不在的状态。

状态机是一个很常见的计算机模型。

大概说明(不重要):https://zhuanlan.zhihu.com/p/543259936

我的理解就是,引入状态概念。根据所处状态不同,对输出作出不同的响应,并在状态中进行迁移,直到到达结束状态。

举个例子,大家写的lua里,等号= 有两个不同的作用,赋值和判断,根据上下文不同,作用不同,后续的状态也不同。

那么,如果我们把Mud机器人视为一个大型文本,成功的触发视为文本的输入,很明显,某种程度下机器人可以分解为不同的状态,同一个状态在面对同样的触发(事件后)执行的动作不同,会进入不同的新状态。会感觉很像状态机。

注意,仅仅是像。

所以,我在机器里引入了状态的概念。

一个状态,指机器当前功能的一个预期的切片(我预期会遇到npc,我预期会进入某个房间,我预期会出现某个文本)。他包括:

1.一个状态ID用于开发和调试
2.一个独立或共享的上下文
3.一个入口函数和出口函数,用于执行进入和离开函数的代码
4.一个事件响应函数,用于对各个关心的事件作出反应

入口用于执行初始指令,比如开始遍历前可以发送一个look或者set brief
上下文指一个独立的变量区域。最大的作用就是可以做快照和恢复时自动保存。比如走路时,遇到滑下山,那么就是把当前行走/遍历的上下文保存,从新地点定位,再移动到滑下山之前的位置,继续行走
出口指令用于销毁资源,比如删除临时计时器。一个常见的指令就是在一个房间等待N秒,超时进入下一个房间
事件响应函数则是代码主体,对于不同的情况作出判断,根据需要看是否进入其他状态

同时,有一个全局唯一的状态管理器,负责状态的切换

当引入状态/状态机后,我们获得的优势是

1.有了当前责任人,知道谁负责响应事件,清理触发和计时器
2.可以选择性的对事件作出响应
3.将代码的传染范围进行限制,只要一个状态不再使用,就不会传染到其他部分。作为一个工程项目,真的遇到问题或大调整,直接写一个新的状态,将老状态弃用即可。

当然,引入状态机,和真正的状态机模型一样,会引入很多缺点

1.无法应对意外,需要写独立的意外处理程序接入状态机流程。
2.引入复杂的状态迁移。传统的状态机的优势之一时可以用状态迁移表来进行方便的编写。以mud系统的复杂度,状态迁移表完全时人力无法维护的程度,必须引入其他机制处理。

凡事都有代价,不是么?

对了,在引入状态我还走过一定的弯路,我居然想着去层层继承。事实证明,继承这玩意,一层就够,剩下的还是接口+组建的方式更适合普通人类。

附录:

等待打码状态,包含弹窗/超时进入暂离模式/邻近超时提醒/获取焦点后重新弹窗等功能

  1. (function (App) {

  2.     let basicstate = Include("core/state/basicstate.js")

  3.     let State=function(){

  4.         basicstate.call(this)

  5.         this.ID="core.state.captcha.captcha"



  6.     }

  7.     State.prototype = Object.create(basicstate.prototype)

  8.     State.prototype.Enter=function(context,oldstatue){

  9.         App.Raise("captcha")

  10.         switch (App.Data.CaptchaCurrentType){

  11.             case "工号":

  12.                 this.Cmd="report "

  13.                 break

  14.             case "zone":

  15.             case "zonestart":

  16.             case "zoneend":

  17.             case "tiangan":

  18.             case "flowers":

  19.             case "exits":

  20.             case "zoneroom":

  21.             case "2words":

  22.             case "show":

  23.                 this.Cmd=null

  24.                 break

  25.             default:

  26.                 this.Cmd="fullme "

  27.         }

  28.         SetPriority(2)

  29.         App.Core.HUD.SetWarningMessage("需要打码")

  30.         App.Core.CaptchaShow()

  31.         world.DoAfterSpecial(App.Data.CaptchaTimeoutInSecounds, 'App.RaiseStateEvent("core.captcha.timeout")', 12);

  32.         if (App.Data.CaptchaTimeoutInSecounds>120){

  33.             world.DoAfterSpecial(App.Data.CaptchaTimeoutInSecounds-60, 'App.Raise("core.captcha.timeoutsoon")', 12);

  34.         }

  35.         if (App.Data.CaptchaTimeoutInSecounds>360){

  36.             world.DoAfterSpecial(300, 'App.Raise("core.captcha.timeoutsoon")', 12);

  37.         }

  38.         if (App.Data.CaptchaTimeoutInSecounds>660){

  39.             world.DoAfterSpecial(600, 'App.Raise("core.captcha.timeoutsoon")', 12);

  40.         }

  41.     }

  42.     State.prototype.Leave=function(context,newstatue){

  43.         Userinput.hideall()

  44.         SetPriority(0)

  45.         App.Core.HUD.SetWarningMessage("")

  46.         DeleteTemporaryTimers()

  47.     }

  48.     State.prototype.OnEvent=function(context,event,data){

  49.         switch(event){

  50.             case "captcha.submit":

  51.                 App.SetAfk(false)

  52.                 let code=App.Data.CaptchaCode

  53.                 if (code){

  54.                     if (this.Cmd){

  55.                         App.Send(this.Cmd+code)

  56.                     }else{

  57.                         App.Next()

  58.                     }

  59.                 }else{

  60.                     App.Data.CaptchaCountFail++

  61.                     App.Next()

  62.                 }

  63.                 break

  64.             case "focus":

  65.                 App.Core.CaptchaShow()

  66.                 break

  67.             case "captcha.success":

  68.                 App.Data.CaptchaCountSuccess++

  69.                 App.Next()

  70.                 break

  71.             case "captcha.fail":

  72.                 App.Data.CaptchaCountFail++

  73.                 App.Fail()

  74.                 break

  75.             case "captcha.other":

  76.                 App.Next()

  77.                 break

  78.             case "core.captcha.timeout":

  79.                 Note("等待打码超过"+App.Data.CaptchaTimeoutInSecounds+"秒,进入暂离模式")

  80.                 App.SetAfk(true)

  81.                 App.Next()

  82.                 break

  83.         }

  84.     }

  85.     return State

  86. })(App)

复制代码


北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:46:42 | 显示全部楼层
问题没有被解决,只是被延后。

状态机模式讲完了。还记得为什么引入状态机么?

为了解决责任人的问题。

那么下一个问题,切换状态的责任人是谁?

做一个巨大无比的状态迁移表么?可我们的代码实行了控制反转模式,一个状态按理不知道其他的状态,一个模块不应该知道是否又其他非依赖模块啊?

沉默,沉默是今晚的康桥。

为了一个问题,让我们继续裱糊,先解决状态迁移的问题。

既然一个状态不知道自己因该切换成什么状态,那么我们就引入一个状态队列。

状态队列需求如下

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 ... uide/Using_promises

北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
 楼主| 发表于 2024-3-11 16:47:59 | 显示全部楼层
代码最好的注释,是不需要注释。

既然我们这个项目解决的时可维护性的问题,那么就让我们重视这个问题吧

首先,算然我们的机器的地层时状态,但实际写机器的时候,我们不太可能在一个一个状态里搏斗。将常用状态分装为指令是一个很常见的操作。

一个指令有一个文本id和参数

  1.                     App.NewCommand("to", App.Options.NewWalk("yz-jindian")),

复制代码

然后可以将指令串成队列

  1.                 App.Commands([

  2.                     App.NewCommand("to", App.Options.NewWalk("yz-jindian")),

  3.                     App.NewCommand("do", "sell " + App.Core.Asset.Asset.UNID),

  4.                     App.NewCommand("do", "i2"),

  5.                     App.NewCommand("nobusy"),

  6.                     App.NewCommand("function", function () {

  7.                         App.Core.Asset.Execute()

  8.                     }),

  9.                 ]).Push()

  10.                 App.Next()

复制代码


再配合上必要的逻辑判断

  1.     App.Quest.Zhuliu.Diebao.FinishNext=function(){

  2.         if (App.Data.Ask.Replies[0]=="余玠(Yu jie)告诉你:这篇情报我颇有不解,你帮忙看看?"){

  3.             App.Commands([

  4.                 App.NewCommand("delay",2),

  5.                 App.NewCommand("function", function () { App.API.Captcha({type:"2words",timeout:15*60*1000}) }),

  6.                 App.NewCommand("function", App.Quest.Zhuliu.Diebao.WaitCaptcha),

  7.             ]).Push()

  8.             App.Next()

  9.             return

  10.         }

  11.         App.Next()

  12.     }



复制代码


这就是很直接的将代码流程图化了。

这样做的优点就是,有了流程图,直接就可以啪啦啪啦把代码堆出来。对于类似的任务,可以直接ctrl+C 然后ctrl +V,改一下流程关键部分和触发,就能直接开始跑任务测试了。

范例:破阵任务部分脱敏代码

  1. App.Quest.Zhuliu.Pozhen.Start = function () {

  2.     App.Quest.Zhuliu.Pozhen.Data = {}

  3.     App.Commands([

  4.         App.NewCommand('prepare', App.PrapareFull),

  5.         App.NewCommand("to", App.Options.NewWalk("gyz")),

  6.         App.NewCommand("planevent",App.Options.NewPlanEvent("#zhuliu",["pozhen"])),

  7.         App.NewCommand("ask", App.Quest.Zhuliu.Pozhen.QuestionJob),

  8.         App.NewCommand("nobusy"),

  9.         App.NewCommand("function", App.Quest.Zhuliu.Pozhen.Next),

  10.     ]).Push()

  11.     App.Next()

  12. }



  13.     App.Quest.Zhuliu.Pozhen.Go = function (zone, room) {

  14.         App.Quest.Zhuliu.Pozhen.Data.CurrentExit = 0

  15.         App.Quest.Zhuliu.Pozhen.Data.Exits = []

  16.         App.Quest.Zhuliu.Pozhen.Data.Zone = zone

  17.         App.Quest.Zhuliu.Pozhen.Data.Room = room

  18.         App.Quest.Zhuliu.Pozhen.Data.Visited = {}

  19.         let path = App.API.GetZoneRoomPatrol(zone, room)

  20.         if (path == null) {

  21.             Note("无地区[" + zone + "]的信息")

  22.             return

  23.         }

  24.         if (path == "") {

  25.             Note("区域[" + zone + "]不可用")

  26.             App.Quest.Zhuliu.Pozhen.Fail()

  27.             return

  28.         }

  29.         App.Core.Traversal.New()

  30.         App.Data.Traversal.Target = "*"

  31.         App.Data.Traversal.Type = "custom"

  32.         App.Data.Traversal.Answer = path

  33.         App.Data.Traversal.State = "mod.zhuliu.pozhen.arrive"

  34.         App.Commands([

  35.             App.NewCommand("combatinit"),

  36.             App.NewCommand("do", "set wimpy 0"),

  37.             App.NewCommand("powerup"),

  38.             App.NewCommand("function", App.Core.Traversal.Start),

  39.             App.NewCommand("function", App.Quest.Zhuliu.Pozhen.Finish),

  40.         ]).Push()

  41.         App.Next()

  42.     }



  43.         App.Quest.Zhuliu.Pozhen.Finish = function () {

  44.         let cmds = []

  45.         if (App.Data.Room.Name == "阵眼"||App.Data.Room.Name == "阵眼[破阵任务副本]") {

  46.             cmds.push(App.NewCommand("move", App.Options.NewPath("w")))

  47.         }

  48.         cmds = cmds.concat([

  49.             App.NewCommand("nobusy"),

  50.             App.NewCommand("do",GetVariable("connect_cmd")),

  51.             App.NewCommand('prepare', App.PrapareFull),

  52.             App.NewCommand("to", App.Options.NewWalk("gyz")),

  53.             App.NewCommand("ask", App.Quest.Zhuliu.Pozhen.QuestionFinish),

  54.             App.NewCommand("nobusy"),

  55.             App.NewCommand("ask", App.Quest.Zhuliu.Pozhen.QuestionFail),

  56.             App.NewCommand("nobusy"),

  57.             App.NewCommand("do", "jq"),

  58.             App.NewCommand("nobusy"),

  59.             App.NewCommand('prepare', App.PrapareFull),

  60.         ])

  61.         App.Commands(cmds).Push()

  62.         App.Next()

  63.     }

复制代码


北大侠客行Mud(pkuxkx.net),最好的中文Mud游戏!
您需要登录后才可以回帖 登录 | 注册

本版积分规则

Archiver|手机版|小黑屋|北大侠客行MUD ( 京ICP备16065414号-1 )

GMT+8, 2024-4-27 11:52 PM , Processed in 0.010381 second(s), 15 queries .

Powered by Discuz! X3.4

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表