エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

TypeScriptの型で麻雀の点数計算をしたい

こんにちは。プロダクト支援チームでエンジニアをしている堀田です。 最近はデジスマ診療をメインで開発しています。

個人的に、一時期 type-challengesにハマっていました。 mediumの後半くらいまで完了していて、いつか全部クリアしたいなと思っています。

type-challengesをやっている間に、まだ誰も作ったことが無い型を作りたいと思うようになりました。 麻雀の役判定を型でやっている素晴らしい記事はありましたが、点数計算までやっている例は無かったので、それをやろうと思いました。

完璧では無いですが、麻雀の点数計算型、通称 麻雀君 ができました。 今日はこれのかなりミニマムバージョン (ミニ雀君) を紹介したいなと思っています。

www.youtube.com (社内勉強会でも発表しました)

想定読者

  • TypeScriptを書いたことある方
  • 麻雀のルールを知っている方 (麻雀用語を説明無しで使うため)

環境

  • TypeScript 4.5.4

ミニ雀君の仕様

  • 符は、基本符(20符) + メンゼンロン (10符) + カンチャン待ち (2符)の3つ
  • 役はタンヤオのみ
  • 子の場合の点数計算だけ

つまり、子のメンゼンロン タンヤオ カンチャン待ち上がりだけをサポートする仕様です。(他の役、ツモあがり、字牌、カン、親の点数などは考えません) この仕様を満たすための機能は以下のとおりです。

- 手牌型を定義できる
- 手牌型から役・翻数を取得できる
- 手牌型から符数を取得できる
- 翻数と符数から点数を計算できる

最終的なイメージはこの通りです。

const point: MinijongKun<
    ["o2", "o3", "o4", "I2", "I3", "I4", "C4", "C5", "C6", "I5", "I6", "I7", "o4", "o4"], "o3"
> = "1300";
// compile error
// const point: MinijongKun<
//  ["o2", "o3", "o4", "I2", "I3", "I4", "C4", "C5", "C6", "I5", "I6", "I7", "o4", "o4"], "o3"
// > = "1600";

手牌と上がり牌を型パラメータで渡すと、その得点以外の値はコンパイルエラーとなるようなイメージです。

1. 飜数と符数の集計と点数計算

手牌の定義や役・符の判定はコードの量が多くなってしまいます。 そのため「役と符の判定ができている」という前提で先に計算部分を実装します。

今回、サポートする役はタンヤオのみなので飜数は最大1となります。

次に符数を考えます。

メンゼンロン、カンチャン待ちの場合、

基本符 (20符) + メンゼンロン (10符) + カンチャン待ち (2符) = 32符 ==繰り上げ==> 40符

その上がり形の符は40符となります。

これを実装するには、型レベルでの足し算、繰り上げが必要です。

型レベルで足し算を行う

足し算はTypeScriptのタプル型を使って実現できます。タプル型には以下のような特徴があります。

  • コンパイル時にそのタプル型の要素数と、どの位置(index)にどの型があるのか決定される
  • TupleType["length"] で、その型の要素数を取得できる
  • [...TupleType] のように ... 演算子で展開できる (Variadic Tuple Types)

型レベルの足し算はVariadic Tuple Typesと "length" での要素数取得によって実現できます。

まず、符の値を以下のようにタプル型で定義します。

type HuValue20 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
type HuValue10 = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
type HuValue2 = [1, 1];

こうすると、

type Value20 = HuValue20["length"]
type Value10 = HuValue10["length"]
type Value2 = HuValue2["length"]

"length" アクセスによって要素数(の型)が得られるので、それぞれ 20, 10, 2の型となります。

この性質はVariadic Tuple Typesで展開した後に取得できるタプル型に対しても有効です。

type Value32 = [...HuValue20, ...HuValue10, ...HuValue2]["length"]

このように ... 演算子でこれらの型を1つのタプル型に展開することで、それらの合計値が得られます。

ミニ雀君では役はタンヤオだけをサポートしていますが、対応する役が増えて飜数の合計値が必要になった時もこの要領で計算可能です。

麻雀では取り得る飜数と符数はあらかじめ決まっています。 ここで紹介した方法は非常に限定的な足し算ですが、麻雀の点数計算に限って言えば有効です。

繰り上げ型

次に繰り上げ型を定義します。 繰り上げ型は、下記のようにnumber型の制約が付いた型パラメータを1つ受け取る型として定義します。

type HuRoundedValue<T extends number>

麻雀で登場する符は七対子の25符を除いて、全て偶数です。 七対子や国士無双などの特殊な上がり形は別で考えた方が処理しやすいので、ここでは符は偶数として考えます。

すると、

type Even = 2 | 4 | 6 | 8;
type HuRoundedValue<T extends number> = `${T}` extends `${infer X}${Even}` ...

HuRoundedValueextends 条件が正になった場合、繰り上げが必要な値だと判定できます。

infer X しているので正の条件になった時、このパターンに一致する型を使用してさらに計算できます。

例えば、 HuRoundedValue<32> とすると、1の位の 2Even にマッチするので、X3 として扱えます。 繰り上げた後の最終的な符は (X + 1) x 10 で取得できるので、

type NextNumber = {
  "2": 30;
  "3": 40;
  "4": 50;
}

のような型を定義すれば、最終的に

type Even = 2 | 4 | 6 | 8;
type Number = 2 | 3 | 4;
type NextNumber = {
  "2": 30;
  "3": 40;
  "4": 50;
}
type HuRoundedValue<T extends number> = `${T}` extends `${infer X}${Even}`
  ? X extends `${Number}`
    ? NextNumber[X]
    : never
  : T;

として、繰り上がり型が定義できます。対応する符の数を増やすには、 NumberNextNumber に追加すればOKです。

点数

符の計算ができるようになったので、最後に実際の点数を計算します。

type PointRonChild = {
  1: {
    20: "arienai";
    25: "arienai";
    30: "1000";
    40: "1300";
    50: "1600";
// 60符以降は省略
  };
// 2飜以上は省略
}

計算と言っても飜数と符数をキーに持つオブジェクト型を定義するだけです。

type Han
type Hu

で、飜数と符数が取得できると仮定すると、

type TotalPoint = PointRonChild[Han][Hu]

と、 Indexed Access Typesを使って、点数を得られます。

2. 牌の定義

点数計算の仕組みは落ち着いたので、役の判定に移ります。 牌の型定義(Tile型)から始めます。(字牌は考えないので、索子、筒子、萬子の3種類)

Template Literal TypeにはUnion型を渡した場合、Unionの1つずつの型で 評価した後、再度Unionにして返してくれるという特徴 があります。

これを使って一気に定義しましょう。

type TileNum = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
// I: 索子 (形が索子っぽいから)
// o: 筒子 (形が筒子っぽいから)
// C: 萬子 (CharacterのC、cだとoに似ているので大文字ににしています)
type TileMark = "I" | "o" | "C";
type Tile = `${TileMark}${TileNum}`;

3. 手牌、面子、雀頭の定義

手牌の定義に移ります。上がり牌も含めた4面子・1雀頭の計14枚を手牌型として定義します。

type Hand = [
  Tile, Tile, Tile,
  Tile, Tile, Tile,
  Tile, Tile, Tile,
  Tile, Tile, Tile,
  Tile, Tile,
]

先ほどの Tile型を使って、このように定義できます。 これは点数計算でも使ったタプル型です。コンパイル時に要素数(ここでは14個)が決定されるので、13個、15個の手牌を誤って入力した場合はコンパイルエラーにできます。

次に、面子と雀頭を定義します。

type Chunk = [Tile, Tile, Tile]
type Pair = [Tile, Tile]

タプル型を使ってこのように定義できますが、この定義では麻雀で使われる任意の牌の3つの組み合わせとなっています。

麻雀は手牌の組み合わせで面子、雀頭を作るゲームですので、任意の牌ではなく 手牌の中で という制約を付けましょう。

type Chunk1<HAND extends Hand> = [HAND[0], HAND[1], HAND[2]]
type Chunk2<HAND extends Hand> = [HAND[3], HAND[4], HAND[5]]
type Chunk3<HAND extends Hand> = [HAND[6], HAND[7], HAND[8]]
type Chunk4<HAND extends Hand> = [HAND[9], HAND[10], HAND[11]]
type Pair<HAND extends Hand> = [HAND[12], HAND[13]]

TypeScriptのgenericsでは extends キーワードを使って、渡ってくる型パラメータに制約を付けられます。 extends Hand の制約が付いている HAND もタプル型として扱えるためインデックスアクセスによって、 その位置の型を取得できます。

先頭から3つずつの牌を面子としていき、最後の2つの牌を雀頭としています。

(この制約によって、手牌は入力のタイミングで並び替えなければならなくなりましたが、ヨシとします。)

4. 面子の刻子、順子の判定

面子の定義ができましたので、次はその面子が刻子か順子か判定していきます。

刻子の判定

// 刻子
type IsSet<SET extends [Tile, Tile, Tile]> = And<Equal<SET[0], SET[1]>, Equal<SET[0], SET[2]>>;

刻子は「3つの牌が同じ牌である」という条件の面子なので、このように定義できます。ただし、And, Equal のような型は 存在しないので、これも定義する必要があります。 まず Equal ですが、

type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;

このように定義します。 これは https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650 で紹介されている2つの型が全く同じであった場合に true をそれ以外は false を返す型です。

// Two conditional types 'T1 extends U1 ? X1 : Y1' and 'T2 extends U2 ? X2 : Y2' are related if
// one of T1 and T2 is related to the other, U1 and U2 are identical types, X1 is related to X2,
// and Y1 is related to Y2.

TypeScriptのchecker.tsに、このようなコメントがあります。

  • <T>() => T extends X ? 1 : 2 のようにコンパイル時に T が決定できない場合、この型は型チェック時にはConditional Typeとして扱われる
  • 2つの型が同じ(identical types) であると判定するには、2つのConditional Typeの比較によって実現できる

というのが私の理解です。

実は、type-challengeでも、このEqual型が使われています。

ミニ雀君でもこれを採用しました。 And はEqualよりもシンプルに定義できます。

type And<X extends boolean, Y extends boolean> = X extends true ? (Y extends true ? true : false) : false;

順子の判定

type IsChow<SET extends [Tile, Tile, Tile]> = SET extends [
  `${TileMark}${infer X}`,
  `${TileMark}${infer Y}`,
  `${TileMark}${infer Z}`
]
  ? And<IsContinuousNumber<X, Y, Z>, IsSameMark<SET>>
  : false;

順子は「同じ種類の数牌で、3つ数字が連続している」という条件の面子なので、このように定義できます。

まず IsSameMark は、先ほどのEqual, And型を使って

type IsSameMark<SET extends [Tile, Tile, Tile]> = SET extends [
  `${infer XMARK}${TileNum}`,
  `${infer YMARK}${TileNum}`,
  `${infer ZMARK}${TileNum}`
]
  ? And<Equal<XMARK, YMARK>, Equal<XMARK, ZMARK>> extends true
    ? true
    : false
  : false;

このように定義できます。次に IsContinuousNumber 型は、以下のようになります。

type Next<NUM extends string> = NUM extends "1"
  ? "2"
  : NUM extends "2"
  ? "3"
  : NUM extends "3"
  ? "4"
  : NUM extends "4"
  ? "5"
  : NUM extends "5"
  ? "6"
  : NUM extends "6"
  ? "7"
  : NUM extends "7"
  ? "8"
  : NUM extends "8"
  ? "9"
  : never;
type IsContinuousNumber<X extends string, Y extends string, Z extends string> = And<
  Equal<Y, Next<X>>,
  Equal<Z, Next<Next<X>>>
>;

愚直に頑張る部分も必要ですが、And, Equal型を最初に作ったおかげでサクッと実装できました。

5. タンヤオの役判定

例外を除き、「手牌が4面子・1雀頭になっている」というのが上がりの条件です。手牌の定義と、面子の刻子・順子の判定ができるようになったのでいよいよ役判定の実装に移ります。

Orの導入とAndの拡張

役判定では、「この面子が刻子または順子である」や「全ての牌がチュンチャン牌である」といった判定が多く登場します。 このタイミングで Or型AndAll型 を導入しておきましょう。

type Or<X extends boolean, Y extends boolean> = X extends true ? true : Y extends true ? true : false;

And型 を導入した時と同じ要領で、 Or型 を定義できます。

type IsChowOrSet<SET extends [Tile, Tile, Tile]> = Or<IsChow<SET>, IsSet<SET>>;
type IsPair<PAIR extends [Tile, Tile]> = Equal<PAIR[0], PAIR[1]>;

これで、面子が「刻子または順子である」という判定ができるようになりました。 (ついでに、雀頭判定用の IsPair型 も定義しておきます )

次に、 AndAll型 は下記のように定義できます。

export type AndAll<ALL extends boolean[]> = ALL extends [infer X, ...infer Y]
  ? X extends boolean
    ? Y extends boolean[]
      ? And<X, AndAll<Y>>
      : false
    : false
  : true;

ALL パラメータ はbooleanの配列を受け取ります。そして、それを extends [infer X, ...infer Y] によって1つずつ(X)取り出します。 その X と残りの Y で再帰的に And<X, AndAll<Y>> の条件にかけると、型パラメータ ALL の全ての要素が true の場合に true を返す型(AndAll)が定義できます。

(TS 4.7から導入された extends Constraints on infer Type Variables を使えば、もう少しシュッと書けます。)

空配列が渡された時に、 extends [infer X, ...infer Y] の判定が誤になるので、そのタイミングでループを抜けます。

またこのような再帰的な型定義のループには上限があり、それを超えるとコンパイルエラーになります。

タンヤオ上がり判定

準備は整ったので、タンヤオの判定をしましょう。 「4面子・1雀頭になっており、全ての牌がチュンチャン牌」という条件なので

type YakuTanyao<HAND extends Hand, WINNING extends HAND[number]> = AndAll<
  [
    IsChowOrSet<Chunk1<HAND>>,
    IsChowOrSet<Chunk2<HAND>>,
    IsChowOrSet<Chunk3<HAND>>,
    IsChowOrSet<Chunk4<HAND>>,
    IsPair<Pair<HAND>>
    IsChunChan<HAND[0]>,
    IsChunChan<HAND[1]>,
    IsChunChan<HAND[2]>,
    IsChunChan<HAND[3]>,
    IsChunChan<HAND[4]>,
    IsChunChan<HAND[5]>,
    IsChunChan<HAND[6]>,
    IsChunChan<HAND[7]>,
    IsChunChan<HAND[8]>,
    IsChunChan<HAND[9]>,
    IsChunChan<HAND[10]>,
    IsChunChan<HAND[11]>,
    IsChunChan<HAND[12]>,
    IsChunChan<HAND[13]>
  ]
> extends true
  ? [1]
  : [];

これでタンヤオの判定ができます。タンヤオは1飜の役なので、 [1] を返しています。IsChunChan型の定義

符の判定

最後に、符の判定をします。改めて、ミニ雀君の仕様では、メンゼンロン タンヤオのカンチャン待ち上がりだけをサポートすればOKです。この内、手牌で符が決まるのはカンチャン待ちだけなので、これを判定します。

カンチャン待ちは、順子の数字の内、真ん中の数牌を待っている状況を指します。

type IsKanchan<SET extends [Tile, Tile, Tile], WINNING extends SET[number]> = And<IsChow<SET>, Equal<WINNING, SET[1]>>;

これまでに定義してきた型を使ってこのように定義できます。

そして、カンチャン待ちの符は

type HuMachi<HAND extends Hand, WINNING extends HAND[number]> = OrAll<
  [
    IsKanchan<Chunk1<HAND>, WINNING>,
    IsKanchan<Chunk2<HAND>, WINNING>,
    IsKanchan<Chunk3<HAND>, WINNING>,
    IsKanchan<Chunk4<HAND>, WINNING>
  ]
> extends true
  ? HuValue2
  : HuValue0;

このようにして決定できます。 (OrAll型の実装)

遂に、手牌と上がり牌(WINNING) から、役・飜数と符数を計算できました。 これを一番最初に作った計算君に渡せば、その手牌の点数型が取得できます。

まとめ

長くなりましたが、読んで頂きありがとうございます。 点数計算など重要な部分の説明ができれば十分だったので、ミニ雀君では強めの制約をかけました。 麻雀君では平和や七対子などもケアしているので興味があれば、見て頂けると嬉しいです。

We are hiring!

社外の方も Tech Talk にご参加いただけます。以下ページの「カジュアル面談はこちら」からお申し込みください。

jobs.m3.com