hrtyy.dev

麻雀君

TypeScriptの型だけで、麻雀の点数計算を行う

麻雀ルールの簡単な確認

麻雀は「牌」を14枚手札(手牌)に揃え、その牌の組み合わせによって決まる点数を積み上げていくゲームである。

牌は大きく分けて数牌と字牌の2種類が存在し、それぞれ下記の種類がある。

数牌 (マンズ)

C1C2C3C4C5C6C7C8C9

数牌 (ソーズ)

I1I2I3I4I5I6I7I8I9

数牌 (ピンズ)

o1o2o3o4o5o6o7o8o9

字牌

eastsouthwestnorthwhitegreenred

牌は1種類あたり4枚ずつ用意されており、 ゲーム全体を見ると 34種類の牌が 136枚 用意されていることになる。


冒頭で、牌の組み合わせによって点数が決まると紹介したが、点数が決まるためには手牌を4面子・1雀頭の形にする必要がある。

「面子」とは、3つの牌の組を差し、3つとも同じ牌の 刻子、3つの牌が同じ種類の数牌かつ数字が連続している 順子 がある。

刻子
easteasteast
順子
o1o2o3

2つの牌の組で2つとも同じ牌の場合、その組のことを「雀頭」と呼ぶ

雀頭
I6I6

すなわち、4面子・1雀頭とはこのような形の手牌のことを示している。

o2o3o4o2o3o4C8C8C8I3I4I5I6I6

麻雀の点数の決まり方

手牌を4面子・1雀頭の形にした時、その形によって「役」「飜」「符」が決まり、これらによって点数が計算される。

例えば上図の手牌には、同じ牌の順子が2つ存在する。これによって決まる役が「一盃口」である。

o2o3o4o2o3o4

また、この手牌では2〜8までの数牌だけが使われている。このように、1、9の数牌、字牌が無い状態で成立するのが「断么」という役である。

一盃口と断么はそれぞれ1飜の役であるため、この手牌は全体で 2飜となる。


次に「符」を確認する。この手牌ではC8が刻子で揃っている(4符)。

そして、I6をロン上がりしたとすると、これはI3I6のノベタン待ちになるため、2符が付く。

基本符の20符 + メンゼンロン上がりの10符 + 刻子の4符 + ノベタン待ちの2符 = 36符 => 40符 (1の位は繰り上げする)

この手牌は2飜・40符、 2600点の手となる。

(国士無双・七対子は例外)

全体像とゴール

1const point: MahjongKun<["o2", "o3", "o4", "o2", "o3", "o4", "C9", "C9", "C9", "I5", "I6", "I7", "o4", "o4"], "o2", [], "ron", "child"> = "1300";
2
3// compile error
4// const point: MahjongKun<["o2", "o3", "o4", "o2", "o3", "o4", "C9", "C9", "C9", "I5", "I6", "I7", "o4", "o4"], "o2", [], "ron", "child"> = "1000";

このように、 MahjongKun という型に、「自分の手牌」、「どの牌で上がったか」、「上がり方はロン・ツモのどちらか?」の情報を渡すことで、 その手配の点数が型として返ってくる型を作れそうだと思って頂くことがゴールである。

ここで、これからの手順を整理する。

1牌の定義
2手牌、面子、雀頭の定義
3面子の刻子、順子の判定
4役・飜・符が決まった時の点数計算
5断么の役判定
6面子・待ちの形の符計算

1. 牌の定義

typescriptでは Literal TypeTemplate Literal Typeの2つの型が使える。 Literal Typeはリテラル自身を型として扱えるというものである。文字列を例に説明すると、 string 型の変数にはどのような文字列でも代入可能であるが、 "hoge" 型の変数には "hoge" という文字列だけが代入可能となる。

1type AnyString = string;
2type OnlyHoge = "hoge";
3
4const s1: AnyString = "any_string"
5const s2: OnlyHoge = "hoge"
6// compile error
7// const s3: OnlyHoge = "fuga"

Template Literal Typeはテンプレートリテラルを型として扱えるというものである。

1type OnlyHogePrefixString = `hoge_${string}`;
2
3const s1: OnlyHogePrefixString = "hoge_hoge"
4const s2: OnlyHogePrefixString = "hoge_fuga"
5// compile error
6// const s3: OnlyHogePrefixString = "fuga_hoge"

このような OnlyHogePrefixString 型を用意すると、 hoge_ から始まる文字列だけを受け入れる型を定義出来る。 Template Literal Typeは、Union型を渡された場合、それらのUnion型の全ての組み合わせの型として定義される。

1type Fruit = "apple" | "orange" | "banana"
2type Animal = "dog" | "cat"
3// dog_apple, dog_orange, dog_banana, cat_apple, cat_orange, cat_banana
4type AnimalPlusFruitType = `${Animal}_${Fruit}`;

Fruit型とAnimal型の組み合わせは6通りあるので、AnimalPlusFruitTypeは6種類の型のUnion型となる。

この性質を使うことで、牌の型を簡単に定義出来る。それぞれの字牌の形から連想して、 ソーズ=I, ピンズ=o, マンズ=C という割り当てを行うと、

1type Num = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
2type TileMark = "I" | "o" | "C";
3type Tile = `${TileMark}${Num}`;

これだけで、字牌の定義が完了である。

1type I1 = "I1";
2type I2 = "I2";
3type I3 = "I3";
4...

というような作業を行う必要が無い。

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

typescriptでは配列の型を扱う場合、array型とtuple型の2種類が存在する。tuple型はコンパイル時に各インデックスに、どの型の要素が入っているかが決まるという点でarray型と異なる。

1type ArrayString = ("a" | "b" | string)[];
2type TupleString = ["a", "c", string];

ここで、 ArrayStringの型では ["a", "b", "a", "hogehoge"] のようにインデックスと要素の型の縛りが無い配列を取りうる。 しかし TupleString では ["a", "c", "hoge"] のように3番目の要素にはどのような文字列でも入りうるが1番目と2番目の要素にはそれぞれ "a""c" しか入らない。

必然的に配列の要素数も決定されるので、

1type NumberOfElements = ["a", "c", string]["length"]
2const num: NumberOfElements = 3
3// compile error
4// const num: NumberOfElements = 4

"length" でその配列の要素数を取得できる。

それでは、手牌の定義を行う。4面子・1雀頭の計14枚の手牌が定義できれば十分である。(ここではカンは考慮しない)

1type Hand = [
2 Tile, Tile, Tile,
3 Tile, Tile, Tile,
4 Tile, Tile, Tile,
5 Tile, Tile, Tile,
6 Tile, Tile,
7]

このように、手牌の定義にtuple型を使うことで、手牌の3番目の牌の型を Hand[2] のように取得できたり、 Hand[14] のように手牌の枚数以上の牌を取得しようとした時にコンパイルエラーにしてくれるので、便利である。

次に、面子と雀頭の定義を行う。ここでは手配の並びは人間によって並び替えられていると仮定する。

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

すると、このようにインデックスアクセスによって、14牌を4面子・1雀頭に分けて扱えるようになる。 extends キーワードは型に制約を与えるために使われている。Pair2 のように、何の制約も付けない場合、コンパイラはインデックスアクセス可能な型であることを判別できないため、 この制約は必須である。(Genericでstring, numberなど任意の型を取り得るので、明らかである)

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

  • Template Literal Typeでinferして、数字の順番を判定
  • Conditional extends で、刻子判定

4. 役・飜・符が決まった時の点数計算

  • ConcatAllとlengthで麻雀計算限定の足し算ができる
  • 符は繰り上げ (これも麻雀特化)

5. 断么の役判定

  • 便利型 AndAll
  • 3.を使って、4面子・1雀頭の形になっていること
  • 全てに1, 9が含まれていないこと

6. 面子・待ちの形の符計算