天鳳の牌譜形式を解析する(2)

天鳳の牌譜形式を解析する(1) - koba::blog の続き。前回は天鳳の牌譜形式のXML要素・属性の意味を説明したので、今回はそれを解析するプログラムについて。

「牌番号」「面子コード」は複数の要素で使われているが、これを 麻雀の手牌の文字列表現 - koba::blog の形式に変換する。

牌番号、面子コードは赤牌の有無により解釈が異なるので、まずは赤牌の有無をチェックする。赤牌の有無は GO 要素の type 属性に設定されているので、これを解析する関数を作成した。

my %type;                                 # ルール・卓情報

sub type {
    my ($type) = @_;
    $type{demo}     = ! (0x0001 & $type); # テストプレイのとき真
    $type{hongpai}  = ! (0x0002 & $type); # 赤牌ありのとき真
    $type{ariari}   = ! (0x0004 & $type); # アリアリのとき真
    $type{dongfeng} = ! (0x0008 & $type); # 東風戦のとき真
    $type{sanma}    =   (0x0010 & $type); # 三麻のとき真
    $type{soku}     =   (0x0040 & $type); # 速卓のとき真
    $type{level}    =   (0x0020 & $type) >> 4 | (0x0080 & $type) >> 7;
                                          # 0: 一般、1: 上級、2: 特上、3: 鳳凰
    return  ($type{sanma}    ? '三' : '四')
          . ('般','上','特','鳳')[$type{level}]
          . ($type{dongfang} ? '東' : '南')
          . ($type{ariari}   ? '喰' : '')
          . ($type{hongpai}  ? '赤' : '')
          . ($type{soku}     ? '速' : '');
}

type(41)$type{hongpai} (赤牌の有無)に真を設定し、"四特南喰赤" を返す。

牌番号

天鳳の牌譜内では136枚の牌に 0 〜 135 の一意な番号が振られている。例えば m1 は4枚あるが、牌番号としては 0 〜 3 が振られており、別のものとして識別されている。以下 m2 は 4 〜 7、m3 は 8 〜 11 と続き、z7 は 132 〜 135 となる。m5 は 16 〜 19 であるが、赤牌ありの場合は牌番号16の牌を m0 と見なす。

単独の牌番号、あるいは牌番号のリストを電脳麻将の形式に変換する関数は以下の通り。

sub pai {
    my $pai = '';
    my $suit = '';
    for (sort {$a<=>$b} @_) {               # 理牌する
        my $s = ('m','p','s','z')[$_/36];   # 萬子、筒子、索子、字牌を決定
        $pai .= $s      if ($s ne $suit);
        $suit = $s;
        my $n = int($_ % 36 / 4) + 1;       # 1〜9を決定
        $n = 0      if ($type{hongpai} && $s ne 'z' && $n == 5 && $_ % 4 == 0);
                                            # 赤牌の場合、5 → 0 に変換
        $pai .= $n;
    }
    return $pai;
}

赤牌ありの場合、pai(16)'m0' を返し、pai(43,0,35,105,25,59,84,51,121,74,67,40,52)'m179p224068s149z4' を返す。

面子コード

面子コードは一見しただけではどう解釈すればいいのかまったく分からない*1のだが、オンライン対戦麻雀 天鳳 / マニュアル によると tehai.js を参照せよというので読んでみる。

どうやら16ビットの共用体ビットフィールドを10進数として表記したものらしい。tehai.js から分かるビットフィールドの使用法は以下の通り。

順子の場合

1〜 2 ビット 0x0003 誰から鳴いたか
3 ビット 0x0004 必ず 1
4〜 5 ビット 0x0018 牌添字1
6〜 7 ビット 0x0060 牌添字2
8〜 9 ビット 0x0180 牌添字3
10 ビット (未使用)
11〜16 ビット 0xFC00 順子のパターン

1〜2ビット目(マスク 0x0003)は誰から鳴いたかを示す。0: 鳴きなし、1: 下家、2: 対面、3: 上家。

3ビット目(マスク 0x0004)が1の場合、順子であることを表す。

11〜16ビット目(マスク 0xFC00)の6ビットで「順子のパターン」を示す。赤牌と鳴いた牌を考慮しない場合、順子のパターンは萬子では m1m2m3m7m8m9 の7パターン。筒子、索子も同様なので、7 x 3 = 21 パターン。さらに鳴いた牌を考慮すると 7 x 3 x 3 = 63 パターンとなり、6ビットで表すことができる。

赤牌を考慮する場合はさらに4〜9ビット目の情報も必要となる。4〜5ビット目(マスク 0x0018)、6〜7ビット目(マスク 0x0060)、8〜9ビット目(マスク0x0180)は面子を構成する牌がそれぞれ「何番目の牌」を使用しているかを示している(これを牌添字と呼ぶことにする)。例えば m3-m2m4 の場合、m2 の牌番号は 4〜7 のいずれかであるが、「牌添字1」の値が 3 であれば 4 + 3 = 7 となる。赤牌ありの場合、牌添字が 0 であれば赤牌であることを示す。

刻子あるいは加槓の場合

1〜 2 ビット 0x0003 誰から鳴いたか
3 ビット 0x0004 必ず 0
4 ビット 0x0008 刻子のとき 1
5 ビット 0x0010 加槓のとき 1
6〜 7 ビット 0x0060 牌添字
8〜 9 ビット (未使用)
10〜16 ビット 0xFE00 刻子のパターン

1〜2ビット目(マスク 0x0003)は順子の場合と同じ。

3ビット目(マスク 0x0004)は順子ではないので必ず0となる。

4ビット目(マスク 0x0008)、5ビット目(マスク 0x0010)はそれぞれ刻子、加槓のとき1となる*2

10〜16ビット目(マスク 0xFE00)の7ビットで「刻子のパターン」を示す。赤牌を考慮しなければ刻子はすべて同一の牌から構成されるので 3 x 9 + 7 = 34 パターンとなるが、天鳳の牌譜では牌番号を使用しているためどの牌を鳴いたかの情報も必要で 34 x 3 = 102 パターンとなる。
6〜7ビット目(マスク 0x0060)には刻子に含まれない牌の牌添字が設定されている。赤牌ありの場合、ここが0であれば赤牌以外の3枚で副露したことが分かる。加槓の場合、刻子に含まれない牌はすなわちカンの時に加えた牌である。

暗槓あるいは大明槓の場合

1〜 2 ビット 0x0003 誰から鳴いたか
3 ビット 0x0004 必ず 0
4 ビット 0x0008 必ず 0
5 ビット 0x0010 必ず 0
6 ビット 0x0020 必ず 0
7〜 8 ビット (未使用)
9〜16 ビット 0xFF00 槓子のパターン

1〜2ビット目(マスク 0x0003)は順子、刻子、加槓の場合と同じだが、暗槓の場合は 0 になる。

3〜5ビット目に加えて6ビット目(マスク 0x0020)も必ず0となる*3

9〜16ビット目(マスク 0xFF00)の8ビットで「槓子のパターン」を示す。どの牌を鳴いたかの情報を加えると (3 x 9 + 7) x 4 = 136 パターンあるので8ビット必要。

これらを考慮した面子コードを電脳麻将の形式に変換する関数は以下の通り。

sub mianzi {
    my ($m) = @_;
    my $d = ('','+','=','-')[$m & 0x0003];      # 誰から鳴いたか
    if ($m & 0x0004) {                  # 順子の場合
        my $p = ($m & 0xFC00)>>10;          #「順子のパターン」を取得
        my $r = $p % 3;                     # どの牌を鳴いたか
        $p = int($p / 3);
        my $s = ('m','p','s')[$p/7];        # 萬子、筒子、索子を決定
        my $n = $p % 7 + 1;                 # 1〜7を決定
        my @n = ($n, $n+1, $n+2);
        my @p = ($m & 0x0018, $m & 0x0060, $m & 0x0180);
                                            # 牌添字1〜3を取得
        for (my $i = 0; $i < @n; $i++) {
            $n[$i]  = 0     if ($type{hongpai} && $n[$i] == 5 && $p[$i] == 0);
                                            # 赤牌の場合、5 → 0 に変換
            $n[$i] .= $d    if ($i == $r);  # 鳴いた牌に印を付ける
        }
        return $s.join('', @n);
    }
    elsif ($m & 0x0018) {               # 刻子、加槓の場合
        my $p = ($m & 0xFE00)>>9;           #「刻子のパターン」を取得
        my $r = $p % 3;                     # どの牌を鳴いたか
        $p = int($p / 3);
        my $s = ('m','p','s','z')[$p/9];    # 萬子、筒子、索子、字牌を決定
        my $n = $p % 9 + 1;                 # 1〜9を決定
        my @n = ($n, $n, $n, $n);
        if ($type{hongpai} && $s ne 'z' && $n == 5) {
                                            # 赤牌の場合、5 → 0 に変換
            if (($m & 0x0060) == 0) { $n[3] = 0 }   # 赤牌が刻子に含まれないとき
            elsif ($r == 0)         { $n[2] = 0 }   # 赤牌を鳴いたとき
            else                    { $n[1] = 0 }
        }
        return ($m & 0x0010) ? $s.join('', @n[0,1,2]).$d.$n[3]
                             : $s.join('', @n[0,1,2]).$d;
    }
    else {				# 暗槓、大明槓の場合
        my $p = ($m & 0xFF00)>>8;           #「槓子のパターン」を取得
        my $r = $p % 4;                     # どの牌を鳴いたか
        $p = int($p / 4);
        my $s = ('m','p','s','z')[$p/9];    # 萬子、筒子、索子、字牌を決定
        my $n = $p % 9 + 1;                 # 1〜9を決定
        my @n = ($n, $n, $n, $n);
        if ($type{hongpai} && $s ne 'z' && $n == 5) {
                                            # 赤牌の場合、5 → 0 に変換
            if    ($d eq '') { $n[3] = 0 }      # 暗槓のとき
            elsif ($r == 0)  { $n[3] = 0 }      # 赤牌を鳴いたとき
            else             { $n[2] = 0 }
        }
        return $s.join('', @n).$d;
    }
}

赤牌ありの場合、mianzi(53399)'s40-6'mianzi(20081)'p550+5'mianzi(4098)'m5550=' を返す。

*1:例えば 5431 は m234- を表す

*2:この2ビットは順子では牌添字1に割り当てられており、順子でも1となりうることに注意

*3:ここが1の場合、三麻の抜きドラを表す