发牌姬Princess定义在libsaki/table/princess.h

发牌姬并非一个看得见、摸得着的实体, 只是有关发牌的逻辑比较多,所以才单独分出了一个Princess类。 Princess对象在每次发牌前创建,维护一些有关发牌过程的变量, 最终在该次发牌结束时销毁。

由于Princess这个词有些长,代码中常将其缩写为hrh


发牌过程

目前的发牌过程由以下几步组成:

  1. 非猴子阶段
    1. 突击阶段
    2. 协商阶段
    3. 表宝牌指示牌决定阶段
    4. 乞讨阶段
  2. 猴子阶段

我们先解释猴子阶段,再解释非猴子阶段。


猴子阶段

「猴子」指的是猴子算法。猴子算法的思想大致是这样的:

do {
    结果 = 随机生成();
} while (!满意(结果));

放到配牌的问题下,猴子算法就是这样:

for (int i = 0; i < 4; i++) {
    do {
        临时配牌 = 随机生成();
    } while (!所有Girl满意(临时配牌));
    操作者i的配牌 = 临时配牌;
}

「所有的Girl满意」指的是 4 个GirlcheckInit()函数都返回true。 也就是说,checkInit不仅可以对自己的配牌表达不满, 还可以对别人的配牌表达不满,从此控制自己和他家的配牌。

checkInit还有一个iter参数,表示当前是第几轮生成。 我们约定:所有GirlcheckInit都必须在iter达到一个大数时返回true, 也就是放弃继续表达任何不满,从而无条件接受下一轮生成的配牌。 这有两个作用,一是避免死循环,二是刻画配牌挂的「毅力」。 弃疗的iter阈值越大,技能的毅力就越强,成功率就越高。

Girl还有一个和猴子阶段相关的回调onMonkey()。 这个回调的使用方式和onDraw()是类似的, 通过干涉牌山调整摸到各种牌的概率。

猴子算法的优劣

猴子算法常用于理论教学和玩梗, 不过在我们这个应用场景下倒是非常的合适,原因如下:

  • 猴子算法可以涵盖所有可能的配牌
    PSV 中存在「配牌感觉老是那几个套路」的问题, 因为配牌结果面涵盖过小,缺乏多样性。 满足一个技能所规定的条件的配牌结果数其实相当地多, 设计一种能够涵盖所有可能情况的主动生成算法并不容易。 而「被动生成 + 筛选」则是一个简单有效的方式。 因为被动生成没有任何方向性,理论上存在的配牌都有可能被生成出来。
  • 未被约束的因素应保持自然分布
    即使我们设计了一个能涵盖所有情况的主动生成算法, 这些结果出现的概率分布的自然性也是没法得以保证的。 例如,我们的目标是生成一个「万子混一色手牌」,由于目标对其它因素只字未提, 我们应当使结果保持正常的向听数概率分布和字牌比例。 如果采用了主动式生成算法,这些问题不仅都需要处理, 对于处理是否成功的验证也是一个问题; 而如果使用「被动生成 + 筛选」,这些性质已自动具备,不需再管。
  • 基于放弃检测矛盾
    有时角色间的能力存在逻辑矛盾。如果采用主动式生成, 必须设计一套精巧的机制检测并解决这些矛盾。 但用了基于iter放弃的猴子算法,所有的一切已自动完成。 可妥协的能力可在iter变大时放宽条件, 不可妥协的能力可以多坚持几个iter。 而「明明没有矛盾,却因为运气不好而导致iter过多失败」则是一个小概率事件, 我们可以想办法把这个概率降低到天麻动画出第四季的概率以下, 这样一来解决这个问题的优先度就低于准备看天麻第四季了。 (真是没办法啊……淫神威!)

以上是猴子算法的优点。 要说缺点,其实就是慢。除了慢没别的毛病。

因为太慢,4 家配牌是依次被决定的——前一家的配牌确定后,再生成下一家的配牌。 其实最合理的方式是这样的:

do {
    配牌1 = 随机生成();
    配牌2 = 随机生成();
    配牌3 = 随机生成();
    配牌4 = 随机生成();
} while (!所有人对所有牌满意);

也就是「稍有不满,四家重来」——而我们现在是「稍有不满,一家重来」。 比起一家重来,四家重来的组合数更多、更自由,结果的涵盖面也更广。 但是四家重来实在是太慢了。

为了解决猴子算法过慢的问题,我们在猴子阶段前面加了个「非猴子阶段」。 (非猴子阶段同时也解决一些其它问题)


非猴子阶段

非猴子阶段被分成了四个子阶段:

  1. 突击阶段
  2. 协商阶段
  3. 表宝牌指示牌决定阶段
  4. 乞讨阶段

「表宝牌指示牌决定阶段」的作用是以常规的牌山算法决定表宝牌指示牌, 没什么需要解释的。 下面重点解释其它的三个阶段。


突击阶段

突击阶段以简单粗暴的绝对优先级, 给予各技能直接从牌山中挑出特定牌的机会。

目前只有茶杯和四喜用到了突击阶段。 根据「设计哲学」,我们直接指定茶杯优先于四喜。 如果当前局为 all last,首先茶杯会收回之前每一局的首张弃牌。 随后,如果当前局为四喜北家,四喜会拿走一组东北对子(如果没被茶杯拿走)。

四喜同时也会在该阶段将西南刻子加载到牌山 B 区。

协商阶段

在协商阶段,发牌姬会向所有的Girl收取一些「麻学式」, 组成一个「麻学式组」,并从解集中随机挑出一个解通知众Girl

一个「麻学式」陈述一个Girl对一种牌的要求。 所谓「要求」,主要指这些:

  • 玄要求存在一种牌, 使得自己手中的,与 A 区里的这种牌的个数和为 4。 同时,也要求禁止他家在配牌阶段拿到这种牌。 另外,这种牌的指示牌不得全被他家拿走,至少要留下一张。
  • 淡的要求基本与玄一样。 在此之上,还要求该种牌不为数牌 5 或役牌。
    • 我们暂时认为南四的红中暗杠是鸭子捣乱。
  • 霞要求存在一种数牌花色,使得并没有人禁止自己摸这种花色的牌, 同时禁止他家摸到这种花色的牌。
  • 爽在白云状态下要求他家并不禁止自己摸索子。
  • 爽在对他家用赤云后,要求他家并不禁止自己摸字牌。
  • 爽在使用海神威后,要求他家并不禁止自己摸万子(未实现)。
  • ……

在这些「要求」的约束之下, 我们在配牌时将面临一连串的问题:

  • 玄的宝牌可以是什么?
  • 淡的杠里宝牌可以是什么?
  • 霞可以绝哪一门?
  • ……

这些问题对人类来讲都很简单,只要手算就能轻易得出一个解。 然而 Libsaki 作为一个可扩展的基础库, 应当提供更高的抽象和更为形式化的表述方法, 以供开发者更加清晰、高效地编辑人物技能, 而不是苦逼地肝一堆剪不断、理还乱的具体技能逻辑。

因此我们引入了「麻学式」的概念。 一个麻学式由四个参数组成:

  • 在哪条世界线
  • 对哪种牌
  • 有什么要求

举例:

  • 「玄」在「Kuro-1 世界线」对「1m」要求「独占四张」
  • 「玄」在「Kuro-1 世界线」对「9m」要求「别被他家独占」
  • 「玄」在「Kuro-2 世界线」对「2m」要求「独占四张」
  • 「玄」在「Kuro-2 世界线」对「1m」要求「别被他家独占」
  • ……
  • 「霞」在「Kasumi-1 世界线」对「1m」要求「独占」
  • 「霞」在「Kasumi-1 世界线」对「2m」要求「独占」
  • 「霞」在「Kasumi-1 世界线」对「3m」要求「独占」
  • ……
  • 「霞」在「Kasumi-2 世界线」对「1p」要求「独占」
  • 「霞」在「Kasumi-2 世界线」对「2p」要求「独占」
  • ……

由上面的例子可以看出, 一个Girl在一个世界线提出的麻学式的个数可多可少(最多 34 个), 同时每个Girl可具备的总世界线条数也各不相同。 玄一共有 34 条世界线,每条世界线含 2 个麻学式。 霞则一共有 3 条世界线,每条世界线含 9 个麻学式。

发牌姬在汇总了所有的麻学式后,会联立出一个麻学式组, 并随机求出一个满足所有麻学式的世界线的组合的解。 该解会被通知给所有的Girl

整个「求方程组」的过程本质上是由发牌姬「辅助」的Girl之间的协商, 发牌姬并不强制Girl服从协商的结果,需要靠Girl的自律。 这种设计是有意的,原因有二:

  • 协商结果的执行方式是能力的一部分。
    是否使用 B 区、毫兔值多大,这些都应由Girl定义。 因挂力不够导致配牌挂不起作用的剧本是容许的。
  • 发牌姬管得越多,越容易出问题。
    权力越大,责任就越大。如果由发牌姬确保协商结果能强制执行, 就会再次面临一堆能力间错综复杂的制约关系问题。 这里我们让发牌姬把之后的问题推锅给牌山系统去 regression。

乞讨阶段

与突击阶段类似,乞讨阶段做的事情也是直接从 A 区里拿特定牌。 与突击不同的是,乞讨阶段没有明确定义的优先级, Girl拿牌的顺序是未定义的。

之所以不设优先级,是因为乞讨发生在协商之后, 干货都已经被挑走,只能喝汤了。 本来就只是残羹剩饭,还拼着命去抢,实在太没梦想。 没了梦想,连麻将都打不了,又如何开挂呢? 因此对乞讨阶段设优先级的意义不大。

乞讨阶段多用于实现对手役有要求的配牌。 典型的例子是淡 —— 利用被人挑剩下的牌凑一副「无役、无宝、可杠、愚形、一向听」的配牌。