发牌姬
发牌姬Princess
定义在libsaki/table/princess.h
。
发牌姬并非一个看得见、摸得着的实体,
只是有关发牌的逻辑比较多,所以才单独分出了一个Princess
类。
Princess
对象在每次发牌前创建,维护一些有关发牌过程的变量,
最终在该次发牌结束时销毁。
由于Princess
这个词有些长,代码中常将其缩写为hrh
。
发牌过程
目前的发牌过程由以下几步组成:
- 非猴子阶段
- 突击阶段
- 协商阶段
- 表宝牌指示牌决定阶段
- 乞讨阶段
- 猴子阶段
我们先解释猴子阶段,再解释非猴子阶段。
猴子阶段
「猴子」指的是猴子算法。猴子算法的思想大致是这样的:
do {
结果 = 随机生成();
} while (!满意(结果));
放到配牌的问题下,猴子算法就是这样:
for (int i = 0; i < 4; i++) {
do {
临时配牌 = 随机生成();
} while (!所有Girl满意(临时配牌));
操作者i的配牌 = 临时配牌;
}
「所有的Girl满意」指的是 4 个Girl
的checkInit()
函数都返回true
。
也就是说,checkInit
不仅可以对自己的配牌表达不满,
还可以对别人的配牌表达不满,从此控制自己和他家的配牌。
checkInit
还有一个iter
参数,表示当前是第几轮生成。
我们约定:所有Girl
的checkInit
都必须在iter
达到一个大数时返回true
,
也就是放弃继续表达任何不满,从而无条件接受下一轮生成的配牌。
这有两个作用,一是避免死循环,二是刻画配牌挂的「毅力」。
弃疗的iter
阈值越大,技能的毅力就越强,成功率就越高。
Girl
还有一个和猴子阶段相关的回调onMonkey()
。
这个回调的使用方式和onDraw()
是类似的,
通过干涉牌山调整摸到各种牌的概率。
猴子算法的优劣
猴子算法常用于理论教学和玩梗, 不过在我们这个应用场景下倒是非常的合适,原因如下:
- 猴子算法可以涵盖所有可能的配牌
PSV 中存在「配牌感觉老是那几个套路」的问题, 因为配牌结果面涵盖过小,缺乏多样性。 满足一个技能所规定的条件的配牌结果数其实相当地多, 设计一种能够涵盖所有可能情况的主动生成算法并不容易。 而「被动生成 + 筛选」则是一个简单有效的方式。 因为被动生成没有任何方向性,理论上存在的配牌都有可能被生成出来。 - 未被约束的因素应保持自然分布
即使我们设计了一个能涵盖所有情况的主动生成算法, 这些结果出现的概率分布的自然性也是没法得以保证的。 例如,我们的目标是生成一个「万子混一色手牌」,由于目标对其它因素只字未提, 我们应当使结果保持正常的向听数概率分布和字牌比例。 如果采用了主动式生成算法,这些问题不仅都需要处理, 对于处理是否成功的验证也是一个问题; 而如果使用「被动生成 + 筛选」,这些性质已自动具备,不需再管。 - 基于放弃检测矛盾
有时角色间的能力存在逻辑矛盾。如果采用主动式生成, 必须设计一套精巧的机制检测并解决这些矛盾。 但用了基于iter
放弃的猴子算法,所有的一切已自动完成。 可妥协的能力可在iter
变大时放宽条件, 不可妥协的能力可以多坚持几个iter
。 而「明明没有矛盾,却因为运气不好而导致iter
过多失败」则是一个小概率事件, 我们可以想办法把这个概率降低到天麻动画出第四季的概率以下, 这样一来解决这个问题的优先度就低于准备看天麻第四季了。 (真是没办法啊……淫神威!)
以上是猴子算法的优点。 要说缺点,其实就是慢。除了慢没别的毛病。
因为太慢,4 家配牌是依次被决定的——前一家的配牌确定后,再生成下一家的配牌。 其实最合理的方式是这样的:
do {
配牌1 = 随机生成();
配牌2 = 随机生成();
配牌3 = 随机生成();
配牌4 = 随机生成();
} while (!所有人对所有牌满意);
也就是「稍有不满,四家重来」——而我们现在是「稍有不满,一家重来」。 比起一家重来,四家重来的组合数更多、更自由,结果的涵盖面也更广。 但是四家重来实在是太慢了。
为了解决猴子算法过慢的问题,我们在猴子阶段前面加了个「非猴子阶段」。 (非猴子阶段同时也解决一些其它问题)
非猴子阶段
非猴子阶段被分成了四个子阶段:
- 突击阶段
- 协商阶段
- 表宝牌指示牌决定阶段
- 乞讨阶段
「表宝牌指示牌决定阶段」的作用是以常规的牌山算法决定表宝牌指示牌, 没什么需要解释的。 下面重点解释其它的三个阶段。
突击阶段
突击阶段以简单粗暴的绝对优先级, 给予各技能直接从牌山中挑出特定牌的机会。
目前只有茶杯和四喜用到了突击阶段。 根据「设计哲学」,我们直接指定茶杯优先于四喜。 如果当前局为 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
拿牌的顺序是未定义的。
之所以不设优先级,是因为乞讨发生在协商之后, 干货都已经被挑走,只能喝汤了。 本来就只是残羹剩饭,还拼着命去抢,实在太没梦想。 没了梦想,连麻将都打不了,又如何开挂呢? 因此对乞讨阶段设优先级的意义不大。
乞讨阶段多用于实现对手役有要求的配牌。 典型的例子是淡 —— 利用被人挑剩下的牌凑一副「无役、无宝、可杠、愚形、一向听」的配牌。