麻雀の副露判断アルゴリズム(2)

麻雀の副露判断アルゴリズム(1) - koba::blog で準備ができたので、今回は特定の役に対する向聴数計算ルーチンを実装していく。

日本の麻雀では以下の役について向聴数が計算できればすべての喰い仕掛けがカバーできる。

  1. 翻牌*1
  2. 断幺九
  3. 対々和*2
  4. 混一色*3
  5. 全帯幺*4
  6. 三色同順一気通貫
  7. 三色同刻三槓子

5以下はレアケースなので、1〜4の向聴数計算ルーチンを実装することにした*5

翻牌

翻牌の向聴数計算は以下の通り。

    function xiangting_fanpai(shoupai) {
        var n_fanpai = 0;                                 // 翻牌刻子の数
        for (var n of [self._zhuangfeng+1, self._menfeng+1, 5, 6, 7]) {
            if (shoupai._bingpai.z[n] >= 3) n_fanpai++;   // 明刻子
            for (var m of shoupai._fulou) {
                if (m[0] == 'z' && m[1] == n) n_fanpai++; // 暗刻子
            }
        }
        if (! n_fanpai) return Infinity;   // 翻牌刻子がなければ向聴数は無限大
        return Majiang.Util.xiangting(shoupai);
                 // 翻牌刻子がある場合は汎用の向聴数計算ルーチンに処理を任せる
    }

翻牌刻子がある場合は役が確定しているので汎用の向聴数計算ルーチンで向聴数が計算できる。翻牌刻子がない場合は向聴数は無限大とする。ただしこの方法では後付け(いわゆる役牌バック)の場合に対応できないので、以下のように改良した。

    function xiangting_fanpai(shoupai) {
        var n_fanpai = 0, back;
        for (var n of [self._zhuangfeng+1, self._menfeng+1, 5, 6, 7]) {
            if (shoupai._bingpai.z[n] >= 3) n_fanpai++;
            else if (shoupai._bingpai.z[n] == 2           // 翻牌対子があり
                        && self._suanpai.paishu('z'+n) > 0) back = n;
                                                          // 鳴ける可能性がある
            for (var m of shoupai._fulou) {
                if (m[0] == 'z' && m[1] == n) n_fanpai++;
            }
        }
        if (n_fanpai) return Majiang.Util.xiangting(shoupai);
        if (back) {                                   // 役牌バックの場合
            var new_shoupai = shoupai.clone();            // 手牌を複製し、
            new_shoupai._bingpai.z[back] = 0;             // バック対象の牌で
            new_shoupai._fulou.push('z'+n+n+n+'=');       // 明刻を作る
            return Majiang.Util.xiangting(new_shoupai) + 1;
                                   // 汎用の向聴数計算ルーチンの結果に1加える
        }
        return Infinity;
    }

上記の方法は牌を1枚加えて明刻を作っているため多牌の状態になってしまっている。汎用の向聴数計算ルーチンは多牌の場合を考慮していない。例えば以下の手牌に白を1枚加えて明刻を作った場合5面子となり、向聴数を -2 と計算してしまう。

一萬二萬白白三萬横一筒二筒三筒横四索五索六索横七萬八萬九萬

翻牌の向聴数計算ルーチンはそれに1加えるので -1 (和了形)となるが、実際は翻牌への向聴数は 1 である*6

そこで汎用の向聴数計算ルーチンが多牌の場合でも処理できるよう、以下に修正した。

function mianzi_all(shoupai) {

    var r = {};
    
    r.m = mianzi(shoupai._bingpai.m ,1);
    r.p = mianzi(shoupai._bingpai.p ,1);
    r.s = mianzi(shoupai._bingpai.s ,1);
    
    var z = [0, 0];
    for (var n = 1; n <=7; n++) {
        if (shoupai._bingpai.z[n] >= 3) z[0]++;
        if (shoupai._bingpai.z[n] == 2) z[1]++;
    }

    var n_fulou = shoupai._fulou.length;

    var min_xiangting = 8;
    
    for (var m of r.m) {
        for (var p of r.p) {
            for (var s of r.s) {
                var n_mianzi = m[0] + p[0] + s[0] + z[0] + n_fulou;
                var n_dazi   = m[1] + p[1] + s[1] + z[1];
                if (n_mianzi > 4) n_mianzi = 4;
                                            // 5面子以上ある場合は4面子に補正
                if (n_mianzi + n_dazi > 4) n_dazi = 4 - n_mianzi;
                var xiangting = 8 - n_mianzi * 2 - n_dazi;
                if (xiangting < min_xiangting) min_xiangting = xiangting;
            }
        }
    }
    
    return min_xiangting;
}

断幺九

断幺九の向聴数計算は以下の通り。

    function xiangting_duanyao(shoupai) {

        /* 幺九牌を含む副露(暗槓含む)がある場合、向聴数は無限大 */
        if (shoupai._fulou.filter(function(m){return m.match(/^z|[19]/)}).length)
                                                            return Infinity;

        var new_shoupai = shoupai.clone();           // 手牌を複製し、
        for (var s of ['m','p','s']) {
            for (var n of [1,9]) {
                new_shoupai._bingpai[s][n] = 0;      // 一九牌を引き抜く
            }
        }
        new_shoupai._bingpai.z = [0,0,0,0,0,0,0,0];  // 字牌はすべて不要

        return Majiang.Util.xiangting(new_shoupai);
                                   // 汎用の向聴数計算ルーチンに処理を任せる
    }

幺九牌をすべて引き抜いて汎用の向聴数計算ルーチンに処理を任せるのだが、汎用の向聴数計算ルーチンは少牌の場合も考慮していない。例えば以下の手牌は幺九牌を抜くと雀頭のない4面子になるので向聴数を 0 (聴牌)と計算するが、実際は中張牌を1枚引いてはじめて単騎待ち聴牌になる断幺九の1向聴である。

九萬二筒三筒四筒五索六索七索二萬横二萬二萬横赤五筒六筒七筒

汎用の向聴数計算ルーチンが少牌の場合でも処理できるよう、さらに修正した。

/* 手牌の牌数を求める関数を追加 */
function paishu(shoupai) {
 
    var n_pai = shoupai._fulou.length * 3;
    for (var s in shoupai._bingpai) {
        var bingpai = shoupai._bingpai[s];
        for (var n = 1; n < bingpai.length; n++) {
            n_pai += bingpai[n];
        }
    }
    return n_pai;
}

function xiangting_yiban(shoupai) {
    
    var min_xiangting = mianzi_all(shoupai) + (paishu(shoupai) < 13 ? 1 : 0);
                        // 雀頭がなく手牌が少牌の状態のときは向聴数に1加える
    
    for (var s of ['m','p','s','z']) {
        for (var n = 1; n <= shoupai._bingpai[s].length; n++) {
            if (shoupai._bingpai[s][n] >= 2) {
                shoupai._bingpai[s][n] -= 2;
                var xiangting = mianzi_all(shoupai) - 1;
                shoupai._bingpai[s][n] += 2;
                if (xiangting < min_xiangting) min_xiangting = xiangting;
            }
        }
    }
    
    return min_xiangting;
}

対々和

対々和の向聴数計算は以下の通り。

    function xiangting_duidui(shoupai) {

        /* 順子の副露がある場合、向聴数は無限大 */
        if (shoupai._fulou.filter(
                function(m){return ! m.match(/^[mpsz](\d)\1\1/)}).length)
                                                            return Infinity;

        /* 刻子(槓子を含む)と対子の数を数える */
        var n_kezi = shoupai._fulou.length, n_duizi = 0;
        for (var s in shoupai._bingpai) {
            var bingpai = shoupai._bingpai[s];
            for (var n = 1; n < bingpai.length; n++) {
                if (bingpai[n] >= 3) n_kezi++;
                if (bingpai[n] == 2) n_duizi++;
            }
        }
        if (n_kezi + n_duizi > 5) n_duizi = 5 - n_kezi;
                                               // 搭子オーバーの場合は補正する
        return 8 - n_kezi * 2 - n_duizi;       // 向聴数を計算
    }

刻子(槓子を含む)のみを面子、対子のみを搭子として向聴数を計算する。

混一色

最後に混一色の向聴数計算は以下の通り。

    function xiangting_yise(shoupai, sort) {

        /* sort 以外の色の副露がある場合、向聴数は無限大 */
        var regexp = new RegExp('^[^z'+sort+']');
        if (shoupai._fulou.filter(function(m){return m.match(regexp)}).length)
                                                            return Infinity;

        /* 手牌を複製し、sort 以外の色の牌をすべて引き抜く */
        var new_shoupai = shoupai.clone();
        for (var s of ['m','p','s']) {
            if (s != sort) new_shoupai._bingpai[s] = [0,0,0,0,0,0,0,0,0,0];
        }
        return Majiang.Util.xiangting(new_shoupai);
                                   // 汎用の向聴数計算ルーチンに処理を任せる
    }

特定の色と字牌以外の牌をすべて引き抜き、汎用の向聴数計算ルーチンに処理を任せる。やはり少牌となるケースが発生するが、それは先ほど対処したので問題ない。

向聴数の選択

役ごとの向聴数は計算できるようになったので、それらの内の最小の向聴数をその手牌の向聴数とすればよい。

Majiang.Player.prototype.xiangting = function(shoupai) {

    function xiangting_menqian(shoupai)    { /* ... */ }
    function xiangting_fanpai(shoupai)     { /* ... */ }
    function xiangting_duanyao(shoupai)    { /* ... */ }
    function xiangting_duidui(shoupai)     { /* ... */ }
    function xiangting_yise(shoupai, sort) { /* ... */ }

    var self = this;
 
    /* 各役向けの向聴数のうち最低の向聴数を選択する */
    return Math.min(
                xiangting_menqian(shoupai),
                xiangting_fanpai(shoupai),
                xiangting_duanyao(shoupai),
                xiangting_duidui(shoupai),
                xiangting_yise(shoupai, 'm'),
                xiangting_yise(shoupai, 'p'),
                xiangting_yise(shoupai, 's')
            );
}

これで喰い仕掛けができるようになったが、大明槓してしまったり、オリている最中に鳴いてしまったりしているので、次回バランスを調整する。

*1:小三元大三元は翻牌の特殊ケース

*2:混老頭字一色清老頭対々和の特殊ケース

*3:清一色、四喜和、緑一色は混一色の特殊ケース

*4:純全帯幺九は混全帯幺九の特殊ケース

*5:全帯幺九についてもトライしたのだが、難しくて断念した

*6:白を引いて単騎待ちになるか、一萬〜三萬を引いて双碰待ちになれば聴牌