« February 2015 | Main | April 2015 »

March 15, 2015

抵抗组织:阿瓦隆及兰斯洛特扩充

昨天下午约了一帮同事去密室,由于在最后 40 秒成功破关,大家都很兴奋,决定再换个地方欢乐一下,结果就在地图上找了家附近的桌游店。

一开始有 8 个人, 我知道这么多人想开桌游实在没有什么好的选择。玩了两局赛马后又来了三个人。一开始,我实在不想再玩 bluffing game 了。无论是狼人或三国杀,还是抵抗组织,都在三年前玩伤了(开桌游店的日子里,长达半年的时间,每周两个晚上抵抗组织)。

这时发现桌游店里有一盒阿瓦隆。一直听说抵抗组织出了这么一个扩充并且口碑非常的好(bgg 上 party game 分类第一),所以饶有兴趣的读了一下规则书。的确有那么一点意思,就组织大家开了几局。正好 11 个人,我可以做主持人。(当然抵抗组织和杀人狼人不同,玩熟了后,完全不需要额外的主持人的)


对没玩过抵抗组织的同学来说,我有必要简单介绍一下抵抗组织的规则。熟悉规则的同学可以跳过这一段。

简单说就是个不死人的杀人游戏。和所有 bluffing game 一样,由游戏规则决定,将一些线索隐藏在游戏者之间,鼓励玩家制造(和推理识破)谎言来达成胜利目标。

和杀人游戏不一样,它解决的重大问题是:不会在整局游戏结束前,有人提前退场。就这一点来说,我偏爱抵抗组织的规则越胜过杀人(狼人)。而且游戏过程中没有黑夜杀人环节,也就不需要额外的主持人了。(当然杀人游戏有个生推变种也不需要)

抵抗组织游戏人数 5 到 10 人,按总人数将玩家分为两派。好人略占多数。

游戏开始时有一个黑夜流程,用于坏人相互确认。在整局游戏中,所有玩家都以好人身份相互扯淡。没有任何理由说自己是坏身份(因为坏人相互认识)。

无论参与人数多少,游戏都分最多 5 个任务进行。每个任务都可能成功或失败。若成功三次,好人胜利;失败三次则坏人胜利。

每个任务至多分 5 个小环节。每个小环节有一个队长做主持人,队长轮流担当。

在每个小环节的讨论之后(通常是讨论谁是好人谁是坏人,并确定最有好人可能的人选),队长选出 n 个玩家完成当前任务。这个 n 由规则决定,和玩家人数以及当前是第几个任务有关。通常是五个任务从易到难,也就是人数越来越多(但第四个任务会比第三个任务简单一点)。参与人数越多,就越容易混入坏人导致任务失败。

队长选定任务人选后,所有人同时(用投票板)投票表示赞成或反对,不可弃权。如果赞成的人大于反对的人数,则开始任务,否则队长换给下一个人继续下一小轮。如果 5 个小轮后依旧无法通过提案,坏人方直接胜利。

开始任务环节后,参与任务的玩家用投票板选择任务成功,还是干扰任务导致失败。和提案投票不同,任务票会洗乱,玩家不可以知道谁投的什么票。只要有一个人(第四轮是个例外)投出任务失败,这个任务就失败了。

每个任务参与任务的人数如下:

  • 5 人时,任务人数分别是 2/3/2/3/3 。
  • 6 人时,任务人数分别是 2/3/4/3/4 。
  • 7 人时,任务人数分别是 2/3/3/4/4 。
  • 8-10 人时,任务人数分别是 3/4/4/5/5 。

第四个任务为特殊轮,当 7 人以上游戏时,必须有 2 张失败票任务才算为失败。


通过规则我们可以看到,这就是一个好人占多数,但相互猜忌的游戏。好人的目的就是想办法证明自己是好人,并正确的判断同伙是谁。通常在前 4 个任务 2:2 之后,最后一轮好人一伙必须相互之间完全信任,才有可能让最后一个任务成功。(因为除了 10 人游戏,最后一轮任务要求全部好人上场,而通过这唯一正确的人选方案又必须得到多数人,通常是全部好人的一致同意)

而坏人只需要制造谎言,引导好人作出错误的推理即可。

和杀人游戏不同,这个游戏的推理依据非常强,而且随游戏进程发展,信息越来越多,且不对称。所以完全没有必要借助场外因素(感觉你就是坏人、我觉得晚上你动了一下等等)。没有死人的设定,也使得所有玩家都可以从始至终的投入游戏。


新版阿瓦隆的修订规则给了我很大的惊喜。尤其是可选身份让这个游戏更具推理性和娱乐性了。

游戏的背景被设定为大家都熟悉的亚瑟王的传说。有了让人熟悉的人物名字后,更容易交流。

基本规则就是在好人方加入梅林,坏人方加入刺客,这两个角色。

梅林可以在一开始的黑夜中确认所有的坏人是谁。如果没有额外主持人,可以让所有人都闭眼并将手握拳放在桌上,然后用口令让坏人竖起大拇指;然后梅林睁眼数一下坏人即可。

再加入了梅林后,好人任务胜利的可能性大大增强(讨论和投票纠结的更少,游戏进程会快很多);但是,一旦好人取得三个任务胜利后,坏人还有最后一次翻盘机会:最后坏人可以做一次讨论,然后由刺客猜出梅林是谁,只要能猜对、坏人胜利。


阿瓦隆的有趣之处在于还多了许多身份牌,各有不同的技能。这会使得推理的线索更多,不同身份的玩家的策略也变得不一样。虽然规则书上说,第一次玩不要加太多特殊身份,但我们一群人也算是老玩家了,干脆 10 人局全部特殊身份都加上了。

好人方加入了 Percival ,我拿到的牌上翻译成派西维尔。不过按一般的圆桌骑士的中文翻译应该译为珀西瓦里。就是那个守护圣杯的骑士,通俗说,就是个忠臣。他的能力是可以在黑夜里看见梅林和莫甘娜(假梅林)。一旦他区分出真假梅林后,对好人方的推理和决策有非常大的作用。

坏人方加入了 Morgana ,牌上翻译为莫甘娜。在亚瑟王传说的中文翻译中,就是那个摩根女巫。她知道自己可能被派西维尔认为是梅林,所以在游戏过程中一般都是装梅林以误导派西维尔。

坏人方还多了一个头目莫德雷德 (Mordred) ,也就是亚瑟王的私生子及最大的反派。他的能力是不会被梅林看见,更容易隐藏自己的身份。

另一个特殊身份的坏人是奥伯伦 (Oberon) ,传说中的精灵之王、梅林他爹。他虽然是属于坏人一边的,但这个角色的加入是为了修正平衡性让好人更容易赢。这种诡异的设定注定了这个角色玩起来非常困难、你也可以通俗的认为这个角色相当于白痴,玩起来难度颇高。他的能力是:在黑夜阶段和其它坏人互不相认(坏人们不知道他是谁,他也不知道同伴在哪),但在梅林权利阶段他需要竖起拇指被梅林看见。

列出这些身份间的关系可能更清楚一些:

梅林可以看见:莫甘娜(假梅林)、奥伯伦(白痴)、刺客(但不知具体身份);看不见莫德雷德(boss)。 派西维尔(忠臣)可以看见:梅林和莫甘娜(假梅林)。 其他无身份好人什么也看不见。

莫甘娜、刺客、莫德雷德相互认识(但不知具体身份)。 奥伯伦(白痴)和其它坏人互不可见,但它会被梅林看见。


我在做了一轮主持后,观察了大家大家的策略,发现整个游戏比原版抵抗组织丰富了许多。一下子就来了兴趣,然后参与进去玩了 3 盘(10 人局)。非常幸运的各抽了一次派西维尔、梅林、莫甘娜。第一盘输了,后两盘胜利。感觉上游戏双方的平衡性非常好。有身份时需要考虑特别多东西。

第一盘做派西维尔很顺利的第一轮就当了领袖,所以一开始就直接提议自己和梅林与莫甘娜三人做任务。这样从这个议案开始,推理就非常有意义。很快就能推理出谁是真梅林。

可惜杀到第 5 轮后还是输了。感觉即要引导同伙做出正确的判断,又帮助梅林隐藏身份,实在是太难了。因为梅林知道的信息最多,却不能暴露;所以派西维尔就变得非常重要,他可以体会梅林的意图,并引导大家来正确的投票。

第二盘抽到梅林、有第一盘做派西维尔的经验,领悟了梅林的玩法;故意用正确的推理导出错误的结论误导了坏人(使他们相信我不是梅林),然后根据自己知道的信息,轻松拿下了三个任务;刺客最后也没怀疑过我的梅林身份。

第三盘居然抽到莫甘娜。这下体验就完整了。做过真梅林,再扮演假梅林真是太轻松了。派西维尔同学到后面一直把我当成梅林,成了我们的同伙。游戏很快就结束了。


桌游店里的这盒阿瓦隆不知道是不是因为是盗版,虽然有兰斯洛特扩充卡却没有看到说明书。

所以我们看到有一对兰斯洛特卡却不知道怎么用。回家后我上网查了一下:原来阿瓦隆出过一个扩展,增加了两个新的可选规则。石中剑以及兰斯洛特卡。

这两个可选规则我在网上找到了英文版本规则。(见这个页面最下方的小字链接

我简单把大意翻译一下(下次有机会试一下):


石中剑(Excalibur):

每次指派任务的时候,队长除了选出参于任务的玩家外,还需要把石中剑卡交给其中一个玩家(但不能给自己)。

做任务时,参与玩家把任务卡放在桌面后,先不忙洗乱。而是等有石中剑的玩家决定是否行使他的权利。这个玩家可以选择让另一个参与任务的人交换一下他的任务卡,并看一下那张卡是什么。也就是说,如果指定的那个人出的是任务成功,就会变成失败;而失败则会变成成功;并且持有石中剑的玩家知道指定的那个人的决定。

所以说,石中剑是把双刃剑。有可能对好人有利、也有可能有害。


兰斯洛特(Lancelot)

兰斯洛特有两张名字一样都阵营相反的卡片,需要同时用于游戏。这样游戏就多了一个特殊角色。好人方就有了最多三个特殊身份;但因为坏人最多是四个,所以加入兰斯洛特后,必须去掉另一个坏人身份,我想一般是去掉奥伯伦吧。

加入兰斯洛特后,有三种可选玩法,任选一种。

最简单的规则是选择三:好坏兰斯洛特相互可见。在黑夜中,加一个相互可见的环节即可。

另外另外选择中,黑夜坏人确认阶段坏兰斯洛特并不睁眼看同伴,但需要竖起大拇指让同伴可见。并且它会被梅林看见。

选择一是使用 5 张忠诚卡,其中三张是白板,另两张表示阵营转换。洗乱后堆叠起来在第三个任务中使用。在第三任务的每个小轮开始时,先翻一张忠诚卡,如果是白板,什么也不会发生;如果是阵营转换,那么好的兰斯洛特变成坏的,坏的变成好的。这两个玩家自己心里清楚。

由于小轮次有可能发生 1 到 5 轮,所以好坏兰斯洛特的切换可能不会发生、也可能发生一次或两次(还原)。

选择二是使用 7 张忠诚卡,其中五张白板,两张阵营转换。洗乱后抽五张出来,依次亮开对应到五个任务中。(这样,抽到的阵营转换卡的数量还是有可能为 0 张、1 张或 2 张)

每个任务中,如果有坏兰斯洛特参与的坏,他必须投任务失败。他不能假装好人而投任务成功,这是因为他可能知道自己会变成好人的。

游戏结束的时候,兰斯洛特们按结束时刻的阵营算输赢。

以后再有机会开游戏的话,我会把三种选择都试一次,看看哪种更有趣味。


过去,我们用硬币做道具就可以在聚会的时候玩抵抗组织了。但阿瓦隆似乎离开了卡牌,开展游戏就非常困难。而且即使有牌做道具的话,在餐桌上也很难开展。

我突然想到,如果能做一个基于 web 的手机版本就好了。

选一个人开游戏,在服务器上(或做成 app 在同一个局域网内手机自建服务器)创建一个游戏,生成一个 QR 码。其他人扫一下码就可以加入游戏,填上自己的名字,服务起就可以帮你分配好身份对应到这个名字上。同时可以把游戏内的身份介绍也发到各自的手机。

通过手机,还可以节省黑夜环节。所有该看到的人都可以在自己的手机上看到。

通过手机还可以完成投票环节。如果再加上聊天室的话,就是一个在线版的阿瓦隆。如果是当面聚会的话,我们不需要在手机上扯淡、手机可以帮助我们完成那些需要桌子和道具的活。特别适合在餐桌或野外开展。

March 13, 2015

给 sproto 增加 unordered map 的支持

花了两天给 sproto 增加了 unordered map 的支持。

问题是这样的:

sproto 支持数组,但很多情况下,业务处理中,我们并不用数组来保存大量的相同类型的结构数据。因为那样不方便检索。

比如你要配置若干地图表、NPC 表等等的信息,固然可以用 sproto 的 array 来保存。但是在运行时,你更希望用定义好的 id 来检索它们。如果 sproto 不支持 unordered map 的话,你就需要在 decode 之后,对 array table 做一次遍历,用一张新表来建立索引。

google protocal buffers 2 也有这个问题,据说第 3 版要增加 map 用来兼容 json ,这个话题最后再说。

为了解决这个问题,最简单的方法是在 sproto 的定义中增加一个特性,允许给 array 定义一个主索引。建议编码器在编解码这个数组的时候,按类型的主索引去处理这张表。

比如

.Person {
    name 0 : string
    id 1 : integer
    email 2 : string

    .PhoneNumber {
        number 0 : string
        type 1 : integer
    }

    phone 3 : *PhoneNumber
}

.AddressBook {
    person 0 : *Person(id)
}

这里的 AddressBook 类型中 person 这个字段,原本是一个 Person 类型的数组列表。如果没有在后面加上 (id) 的话,就建议编码器用 Person 类型中的 id 字段做为主索引。

主索引字段可以是所有的内建类型(integer boolean 或 string ),但不可以是自定义类型或数组。

这个只是一个可选项,并不需要修改 sproto 的 wire type ,所以增加这个特性后,和之前的版本是完全兼容的。

在编解码过程中,利用这个额外信息,可以把数据处理为 unordered map 而不是 list 。目前的 lua binding 实现会利用这个信息。

一旦你设定了主索引,编码的时候就不再用数字迭代,而改用 lua_next 。我在这里的实现上偷了个懒、因为严格意义上,我们还需要检查输入的 unordered map 的 key 是否和对应的 value 中主索引项的值完全相同。不过一旦实现这个检查,不仅代码会复杂的多,还会对之前的数据造成一些不兼容(目前你完全可以依旧把 list 输入编码为 unordered map )。

解码的时候,如果标注了主索引,就会在解码每个数据项时,取出对应的值作为这一项的 key 。


对于 test.lua 中的范例:

local sp = parser.parse [[
.Person {
    name 0 : string
    id 1 : integer
    email 2 : string

    .PhoneNumber {
        number 0 : string
        type 1 : integer
    }

    phone 3 : *PhoneNumber
}

.AddressBook {
    person 0 : *Person(id)
    others 1 : *Person
}
]]

sp = core.newproto(sp)
core.dumpproto(sp)
local st = core.querytype(sp, "AddressBook")

local ab = {
    person = {
        [10000] = {
            name = "Alice",
            id = 10000,
            phone = {
                { number = "123456789" , type = 1 },
                { number = "87654321" , type = 2 },
            }
        },
        [20000] = {
            name = "Bob",
            id = 20000,
            phone = {
                { number = "01234567890" , type = 3 },
            }
        }
    },
    others = {
        {
            name = "Carol",
            id = 30000,
            phone = {
                { number = "9876543210" },
            }
        },
    }
}

collectgarbage "stop"

local code = core.encode(st, ab)
local addr = core.decode(st, code)
print_r(addr)

你将看到这样的输出:

=== 3 types ===
AddressBook
        person (0) *Person(1)
        others (1) *Person
Person
        name (0) string
        id (1) integer
        email (2) string
        phone (3) *Person.PhoneNumber
Person.PhoneNumber
        number (0) string
        type (1) integer
=== 0 protocol ===
+person+10000+phone+1+type [1]
|      |     |     | +number [123456789]
|      |     |     +2+type [2]
|      |     |       +number [87654321]
|      |     +id [10000]
|      |     +name [Alice]
|      +20000+phone+1+type [3]
|            |       +number [01234567890]
|            +id [20000]
|            +name [Bob]
+others+1+phone+1+number [9876543210]
         +id [30000]
         +name [Carol]

注意 person 子结构下有两项数据,它们的 key 分别是 10000 和 20000,对应 person.id = 10000, 和 person.id = 20000 ,而不再是 1 和 2 了。


最后谈谈 google protocal buffers 3 。

根据 最近的 release log ,他们打算支持

message Foo {
  map values = 1;
}

这样的数据结构。

我个人觉得 protobuffer 这样选择主要是它的应用语言相关:主要用于 C++ 的程序方便处理数据。所以 unordered map 的定义语法也和 C++ 类似。估计最终也可以映射到 C++ 的 map 中去。

但如果主要用动态语言做开发的话,我个人很不喜欢引入这种新特性。其实实际使用中,map 的值是一个简单类型的环境真的不多,这种数据结构如我前面所说,也就是为了在业务处理中,有一个方便的索引手段而已。

protobuffer 的设计已有一定的年头,看起来 google 自己也觉得积重难返了。第 3 版已经不考虑向前兼容性,大刀阔斧的修改是件好事;原话是:

We recommend that new Protocol Buffers users use proto3. However, we do not generally recommend that existing users migrate from proto2 from proto3 due to API incompatibility, and we will continue to support proto2 for a long time.

但如果开新项目,何不尝试一下其他呢?我是说,如果你在用 lua 做开发,sproto 会是个更好的选择。

ps. sproto 的 python binding 目前由我的同事开发中。

pps. 这次的修改暂时在 github 的 sproto 项目的 map 分支中,一旦确认基本没有问题,会合并到 master 分支上。

March 11, 2015

跳出死循环

在 skynet 中,有一个叫 monitor 的内部模块,它会监测是否有服务可能陷入了死循环。

工作原理是这样的:每次处理一个服务的一个消息时,都会在一个和服务相关的全局变量处自增 1 。而 monitor 是一个独立线程,它每隔一小段时间(5 秒左右)都检测一下所有的工作线程,看有没有长期没有自增的,若有就认为其正在处理的消息可能陷入死循环了。

而发现这种异常情况后,skynet 能做的也仅仅是输出一行 log 。它无法从外部中断消息处理过程,而死循环的服务,将永久占据一个核心,让系统整体性能下降。

采用 skynet 的 kill 指令是无法杀掉死循环的服务的。


当服务用 lua 编写时,我们则有可能做多一点工作。

对于正统的 lua 程序,我们可以给正在运行的 coroutine 加上一个 debug hook 。在 debug hook 里,则可以检查有没有外部通知说需要中断运行。

由于 skynet 的 lua 服务都由一个 coroutine 的调度器管理,的确可以作到为每个请求都加上这个 debug hook 。但问题是,加上 debug hook 后,对正常运行状态的性能影响很大。

尤其是 lua 版本的 debug hook ,比直接调用 C API 设置一个 C 版的 debug hook 有更大开销。

我认为这个特性对于解决 bug, 尤其是线上解决问题很有帮助,如果能比较少的影响性能,那么用一丁点性呢功能开销才换取这个便利还是值得的。所以我动手修改了 lua 的 vm 实现。

反正在 多虚拟机共享字节码 这个问题上我已经改过一次了,再改改也不嫌多 :) 。只要还可以同时支持未修改版的 lua vm 也可以放在 skynet 里工作就好了。不喜欢的同学可以自己换掉。

我的方案是,在 lua vm 在处理 JMP CALL TAILCALL FORLOOP 这几条 opcode 时,去检查一个全局变量,如果全局变量被设置成和自己的 lua state 相同的指针,就立刻抛出一个异常。

我做了一个简单的测试,对于纯粹的空循环,for 1 亿次,加上这条全局变量检测的代码,会对性能造成 3% 左右的损失。我认为这完全是可以接受的。


剩下的工作就是给 skynet 的服务加上发送信号的接口了。

目前 skynet 的服务在初始化后,只有唯一的一个叫做 callback 的接口在每次有消息进入的时候被调用,且由框架保证线程安全(这个 callback 不会被并发)。

我增加了一个可选接口叫做 signal ,允许在任何时候调用。发送 signal 被放在 skynet 的 command 里,这样可以由任意一个服务向同进程内的其它服务发送信号。信号可以用一个整数来表示及区别其种类。目前,只有 snlua 模块实现了 signal 接口,并不区分具体信号是什么。

snlua_signal 中,仅仅只是设置了 lua vm 的那个全局变量。如果 lua 虚拟机用的是原版,则什么都不做。

就这样,我们以一个非常小的代价增加了对异常服务的管理能力。以后当发现 log 中出现了 "maybe in an endless loop" 的信息后,就可以用 debug console 登录上去,通过 signal address 的方式向其发送一个信号。如果 address 是一个 snlua 服务,它将会产生一个 error 。在 log 中可以看到陷入死循环的 lua 代码的调用栈了。 如果之前企图 kill 掉这个异常服务,也会因为异常的消息处理结束而自己退出。

这个 patch 提交在这里。 代码中可以了解更多细节。