jarlyyn 发表于 2024-5-28 14:54:18

杰哥乱弹琴之发送队列管理

队列/发送队列是MUD客户端/机器人的重要功能之一。

一般客户端都会提供类似于speedwalk的定节奏发送,实现基本的队列和发送限流。

因为speedwalk在追求高性能/立即反应的情况下性能较差,所以我做了另做了一套基于漏桶算法的限流器。

由于是跳过客户端完全由脚本控制的发送管理,所以除了正常的限流外,能完全定制大部分细节,所以可以可以实现新人比较喜欢的#wait,#t+,#t-等类zmud指令,以及可以当作普通队列进行单布发送或重发等功能。

jarlyyn 发表于 2024-5-28 14:54:53


使用库之前先要进行安装。

目前这个库支持GBK编码的mushclient和utf8编码的mudlet

具体安装说明见

https://www.pkuxkx.com/forum/thread-49188-1-1.html

此帖的2楼

jarlyyn 发表于 2024-5-28 14:58:00

简单说一下漏桶算法和令牌算法。

其实这两个算法说是算法,实际上是一个 “高可行性的实施方案”罢了。

可以参考

https://www.cnblogs.com/kiko2014551511/p/16869723.html

总的来说,令牌算法是每隔一定时间有一定的新额度,更适用于防止被请求方打爆

漏桶算法就是有个桶,保证指令以恒定的速度流出去,没留出去的都存在桶里,更适用于防止打爆被请求方。

jarlyyn 发表于 2024-5-28 15:05:11

然后是先上代码和明确概念。

我把我的限流器起名叫metronome,家里有学琴的娃应该都见过,就是能调整间隔,然后以固定节奏打拍子,让娃跟着节奏弹奏的东西。

所以,总体来说,这个节拍器主要是两个参数。

第一个beats,节拍,就是过去一定时间里最多能发送多少指令。

另一个是tick,节奏,就是节拍失效的时间。

然后看创建代码

    function M.Metronome:new()
      local m = {
            _beats = 0,
            _tick = 0,
            _timer = M.DefaultTimer,
            _queue = list:new(),
            _sent = list:new(),
            _paused = false,
            _resumeNext = false,
            _sender = self.DefaultSender,
            _decoder = M.DefaultDecoder,
            _converter = M.DefaultConverter,
            _pipe = nil,
            _last = {},
            params = {}
      }
      setmetatable(m, self)
      return m
    end
核心就是那个_sent,代表的是发送历史,也是控制的核心。

_sent会在每次插入或者固定时间进行检查,如果超过节奏,就把对应的发送记录清理掉。然后再和beats对比,如果比beats小,那就说明还能发送指令。

一个很简单容易理解的机制。

不听更新和维护_sent的发送记录,_sent比beats大,就继续等着,比beats小,就能发送,直到_sent的长度大于等于beats为止。


jarlyyn 发表于 2024-5-28 15:15:17

作为一个自定义的限流器,节拍器一开始的目的是为了解决怎么尽快的发出指令,以及怎么确保同时发出指令。

由于mud是基于心跳的。

所以大部分工作的目的是为了

怎么顶着心跳发送的上限发送指令,不受惩罚

这个很简单,用心跳一半的周期发送限制一半的指令,这样可以避免连续两次发送超过心跳上限(爆发式发送也不会超过一个心跳的限制数量)

为了避免网络延迟,可以把周期调的比心跳的一半略大一丢丢,指令数比限制的一半略小一丢丢。

毕竟我一直玩的是会雷劈的MUD,需要在被雷P的边缘作死。

同时,还要确保有必要时,指令必须同时发送

很简单,kill和kill后的指令必须同时发出,不然不是被npc先手白打一个回合了么?

因此。节拍器最核心的指令就是

metronome:send({cmd1,cmd2,cmd3,cmd3},grouped)里面的grouped是是否按组发送。

按组发送的话,节拍器会判断当前tick剩下的空间(space),也就是_sent-beats是否足够。不够就下拍子发送。

当然,为了防止死循环,如果当前拍子如果没发送过任何其他指令,哪怕指令组超长也会强制发出。

以上都是背景和原始设计意图,在北侠用不用关心这个核心功能,北侠的性能完全撑不起我的节拍器全力输出,要不是我还做了个散热器限制输出,top cmd肯定分分钟被打爆。

jarlyyn 发表于 2024-5-28 15:22:36

下面是正题。

当我们有了_sent这个大杀器之后,我们能对队列进行非常细致的调整。

比如,我们如果用当前时间,把_sent填满,那就能强制节拍器休息,直到当前节拍过去

Metronome:full()
Metronome:fullTick()这就是这两个函数的用法了。

那我们拓展一下,如果把未来的时间戳把_sent填满呢?

那不就是让整个节拍器停止N秒,然后继续发送?

不就是Zmud的#wait功能吗?

Metronome:wait(offset)这不就一下子好用了么?

要实现这个功能,我们可以让push方法不仅接收字符串指令,还能接受函数,在函数里给节拍器:wait就行了,这个非常简单。

下一步,我们怎么实现#wait指令和:wait函数的转换呢?

jarlyyn 发表于 2024-5-28 15:27:07

这里我们肯定需要一个将"#wait"的指令,转换成function() Metronome:wait(xxx) end的函数,我这里管他叫解码器(decoder)。

除了#wait,用这个方法很明显我们还能加入#t+ #t- 开关触发组的功能,#pause #resume 的暂停/继续控制,甚至#print的打印方法。

这是我的默认解码器

    M._commands = {}
    M.register = function(command, handler)
      M._commands = handler
    end
    M.decoder = function(metronome, data)
      runtime.HC.eventBus:raiseEvent('core.metronome.sent',metronome)
      if (#data > 0 and string.sub(data, 1, 1) == '#') then
            local cmd, sep, param = string.match(data, "^#([^ ]+)(%s*)(.-)$")
            if cmd ~= nil then
                if M._commands ~= nil then
                  return M._commands(metronome, param)
                end
            end
      end
      return data
    end
    M.register('wait', function(metronome, param)
      return function(metronome)
            metronome:wait(param / 1000)
      end
    end)
    M.register('pause', function(metronome, param)
      return function(metronome)
            metronome:pause()
      end
    end)
    M.register('resume', function(metronome, param)
      return function(metronome)
            metronome:resume()
      end
    end)
    M.register('print', function(metronome, param)
      return function(metronome)
            print(param)
      end
    end)
    M.register('t+', function(metronome, param)
      return function(metronome)
            runtime.world:enableTriggers(param)
      end
    end)
    M.register('t-', function(metronome, param)
      return function(metronome)
            runtime.world:disableTriggers(param)
      end
    end)

很简单易懂

还提供了一个register方法,能很方便的添加自己的新指令。

使用了这个,我们就能直接发送"n;w;w;#wait 3000;n;e;"的指令了。

jarlyyn 发表于 2024-5-28 15:34:20

实现了#wait,我们就要进入下一个课题了。

节拍器的转发/重定向。

我一直说zmud的#wait不够好用,因为zmud只有一个主队列,你一个队列#wait了其他的发送都卡住了。

而我的这个节拍器明显可以创建多个。

那么我们完全可以用一个主节拍器(默认会安装到Hclua.HC.sender)管理发送和限流。

给不同的功能,比如遍历,任务,触发 给到不同的子节拍器。

甚至我们可以给不同的节拍器设置不同的发送限制,来简单实现 普通指令发送 和 房间移动指令的分别限流

也就是可以做到普通指令每秒发送20个的情况下,移动指令只能1秒4个,甚至4秒1个(比如华山巡山等必须在指定房间待满一定时间)的功能

主要这样

mysend1.withPipe(Hclua.HC.sender).withBeats(2).withTick(1)
mysend2.withPipe(Hclua.HC.sender).withBeats(1).withTick(4)我们就能同时拥有三个不同控制流程的队列了,甚至我们还能创建一个队列暂停着手动发送,这不久实现简单遍历了么?

jarlyyn 发表于 2024-5-28 15:43:48

好,让我们发挥北侠的光荣传统

脑袋一拍,说干就干。

除了常规很容易实现的pause和resume之外,我还实现了一个resumeNext方法

Metronome:resumeNext()简单粗暴的设置了一个单步发送的标志位_resumeNext

在展厅状态下,发送一个指令,再把_resumeNext设为false,实现了一步一发送的功能。

当然,既然想拿来做队列设置简单遍历,还需要做重发功能。

每次发送成功的正常指令,我都放再了一个_last变量里,还提供了一个resend指令,插入重新发送。

这样做简单遍历就很简单了。

判断到了下一个房间,比如看到出口或者你自定义的response了
Metronome:resumeNext()
如果busy了,或者门没开需要重试,那么
Metronome:resend()
简单粗暴又直接。

当然,我个人极不推荐任何用任何形式的队列实现的遍历,值是给个临时解决方案。

jarlyyn 发表于 2024-5-28 15:48:59

好了,下一个问题来了。

如果我的遍历队列,一个移动指令需要对应多个指令怎么办?

比如我移动的路径可能是

open gate;n又比如,我希望每个指令之后自动发送一个response R:walknext,做触发判断是否有我找的npc,怎么办?

很简单,我加入了一个转换器(converter),功能是在计算完指令限制后,将一组原生的指令,转化成一组新的指令。

这个我可以简单的 将 open gate&&n 拆分,并加入resposne,
open gate
n
response R:walkok

还不影响主队列

到此为止,很明显,我们加上合适的触发(response 和 需要重发),就能做一个简单遍历找npc的功能了。
页: [1] 2
查看完整版本: 杰哥乱弹琴之发送队列管理