riichi_engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +17 -0
  4. data/.github/pull_request_template.md +15 -0
  5. data/.github/workflows/ci.yml +22 -0
  6. data/.github/workflows/release.yml +31 -0
  7. data/.gitignore +6 -0
  8. data/.ruby-version +1 -0
  9. data/CHANGELOG.md +17 -0
  10. data/Gemfile +3 -0
  11. data/LICENSE +21 -0
  12. data/README.md +164 -0
  13. data/docs/adapter_contract.md +144 -0
  14. data/docs/api_reference.md +309 -0
  15. data/docs/concepts.md +134 -0
  16. data/docs/public_api_policy.md +94 -0
  17. data/docs/state_machine.md +360 -0
  18. data/docs/usage_examples.md +265 -0
  19. data/lib/mahjong/config/rule_config.rb +53 -0
  20. data/lib/mahjong/cpu/ai/base_ai.rb +50 -0
  21. data/lib/mahjong/cpu/ai/easy_ai.rb +11 -0
  22. data/lib/mahjong/cpu/ai/hard_ai.rb +11 -0
  23. data/lib/mahjong/cpu/ai/normal_ai.rb +11 -0
  24. data/lib/mahjong/cpu/analysis/shanten.rb +112 -0
  25. data/lib/mahjong/cpu/analysis/tile_evaluator.rb +54 -0
  26. data/lib/mahjong/cpu/judges/naki_judge.rb +60 -0
  27. data/lib/mahjong/cpu/judges/riichi_judge.rb +42 -0
  28. data/lib/mahjong/cpu/selectors/dahai_selector.rb +97 -0
  29. data/lib/mahjong/errors/engine_error.rb +5 -0
  30. data/lib/mahjong/errors/invalid_action_error.rb +5 -0
  31. data/lib/mahjong/errors/invalid_state_error.rb +5 -0
  32. data/lib/mahjong/errors/tile_not_found_error.rb +5 -0
  33. data/lib/mahjong/flow/detectors/naki_detector.rb +82 -0
  34. data/lib/mahjong/flow/games/game_flow.rb +186 -0
  35. data/lib/mahjong/flow/resolvers/action_resolver.rb +22 -0
  36. data/lib/mahjong/flow/rounds/round_flow.rb +588 -0
  37. data/lib/mahjong/flow/validators/action_validator.rb +110 -0
  38. data/lib/mahjong/results/final_result.rb +11 -0
  39. data/lib/mahjong/results/game_flow_result.rb +20 -0
  40. data/lib/mahjong/results/game_result.rb +26 -0
  41. data/lib/mahjong/results/round_end_info.rb +18 -0
  42. data/lib/mahjong/results/round_flow_result.rb +18 -0
  43. data/lib/mahjong/scoring/calculators/fu_calculator.rb +68 -0
  44. data/lib/mahjong/scoring/calculators/score_calculator.rb +73 -0
  45. data/lib/mahjong/scoring/evaluators/win_evaluator.rb +111 -0
  46. data/lib/mahjong/scoring/judges/agari_judge.rb +68 -0
  47. data/lib/mahjong/scoring/judges/furiten_judge.rb +34 -0
  48. data/lib/mahjong/scoring/judges/machi_judge.rb +78 -0
  49. data/lib/mahjong/scoring/judges/yaku_judge.rb +161 -0
  50. data/lib/mahjong/scoring/parsers/hand_parser.rb +87 -0
  51. data/lib/mahjong/scoring/value_objects/score_result.rb +57 -0
  52. data/lib/mahjong/scoring/value_objects/yaku_context.rb +70 -0
  53. data/lib/mahjong/scoring/value_objects/yaku_entry.rb +7 -0
  54. data/lib/mahjong/scoring/value_objects/yaku_judge_result.rb +27 -0
  55. data/lib/mahjong/scoring/yaku/chankan.rb +6 -0
  56. data/lib/mahjong/scoring/yaku/chanta.rb +15 -0
  57. data/lib/mahjong/scoring/yaku/chiihou.rb +7 -0
  58. data/lib/mahjong/scoring/yaku/chiitoitsu.rb +6 -0
  59. data/lib/mahjong/scoring/yaku/chinitsu.rb +10 -0
  60. data/lib/mahjong/scoring/yaku/chinroutou.rb +6 -0
  61. data/lib/mahjong/scoring/yaku/chuuren_poutou.rb +20 -0
  62. data/lib/mahjong/scoring/yaku/daisangen.rb +8 -0
  63. data/lib/mahjong/scoring/yaku/daisuushii.rb +8 -0
  64. data/lib/mahjong/scoring/yaku/double_riichi.rb +6 -0
  65. data/lib/mahjong/scoring/yaku/haitei.rb +6 -0
  66. data/lib/mahjong/scoring/yaku/honitsu.rb +11 -0
  67. data/lib/mahjong/scoring/yaku/honroutou.rb +9 -0
  68. data/lib/mahjong/scoring/yaku/houtei.rb +6 -0
  69. data/lib/mahjong/scoring/yaku/iipeiko.rb +16 -0
  70. data/lib/mahjong/scoring/yaku/ikkitsuukan.rb +15 -0
  71. data/lib/mahjong/scoring/yaku/ippatsu.rb +6 -0
  72. data/lib/mahjong/scoring/yaku/junchan.rb +15 -0
  73. data/lib/mahjong/scoring/yaku/kokushi_musou.rb +6 -0
  74. data/lib/mahjong/scoring/yaku/menzen_tsumo.rb +6 -0
  75. data/lib/mahjong/scoring/yaku/pinfu.rb +16 -0
  76. data/lib/mahjong/scoring/yaku/riichi.rb +6 -0
  77. data/lib/mahjong/scoring/yaku/rinshan_kaihou.rb +6 -0
  78. data/lib/mahjong/scoring/yaku/ryanpeiko.rb +11 -0
  79. data/lib/mahjong/scoring/yaku/ryuuiisou.rb +6 -0
  80. data/lib/mahjong/scoring/yaku/san_ankou.rb +15 -0
  81. data/lib/mahjong/scoring/yaku/san_kantsu.rb +6 -0
  82. data/lib/mahjong/scoring/yaku/sanshoku_doujun.rb +15 -0
  83. data/lib/mahjong/scoring/yaku/sanshoku_doukou.rb +14 -0
  84. data/lib/mahjong/scoring/yaku/shousangen.rb +14 -0
  85. data/lib/mahjong/scoring/yaku/shousuushii.rb +14 -0
  86. data/lib/mahjong/scoring/yaku/suuankou.rb +15 -0
  87. data/lib/mahjong/scoring/yaku/suukantsu.rb +6 -0
  88. data/lib/mahjong/scoring/yaku/tanyao.rb +7 -0
  89. data/lib/mahjong/scoring/yaku/tenhou.rb +7 -0
  90. data/lib/mahjong/scoring/yaku/toitoihou.rb +7 -0
  91. data/lib/mahjong/scoring/yaku/tsuuiisou.rb +6 -0
  92. data/lib/mahjong/scoring/yaku/yakuhai.rb +27 -0
  93. data/lib/mahjong/snapshots/final_result_snapshot.rb +8 -0
  94. data/lib/mahjong/snapshots/game_progress_snapshot.rb +12 -0
  95. data/lib/mahjong/snapshots/game_setup_snapshot.rb +12 -0
  96. data/lib/mahjong/snapshots/win_evaluation_snapshot.rb +11 -0
  97. data/lib/mahjong/state/fuuro.rb +45 -0
  98. data/lib/mahjong/state/hand.rb +64 -0
  99. data/lib/mahjong/state/kawa.rb +68 -0
  100. data/lib/mahjong/state/mentsu.rb +55 -0
  101. data/lib/mahjong/state/round_state.rb +193 -0
  102. data/lib/mahjong/tiles/dora.rb +19 -0
  103. data/lib/mahjong/tiles/tile.rb +168 -0
  104. data/lib/mahjong/tiles/tile_set.rb +51 -0
  105. data/lib/mahjong/tiles/wall.rb +47 -0
  106. data/lib/mahjong/tiles/wanpai.rb +79 -0
  107. data/lib/riichi_engine/api.rb +62 -0
  108. data/lib/riichi_engine/version.rb +3 -0
  109. data/lib/riichi_engine.rb +15 -0
  110. data/riichi_engine.gemspec +32 -0
  111. metadata +207 -0
@@ -0,0 +1,87 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Parsers
4
+ class HandParser
5
+ # 手牌を全ての有効なメンツ分解パターンに分解する
6
+ def self.parse(tiles_34)
7
+ results = []
8
+ work = tiles_34.dup
9
+
10
+ 34.times do |i|
11
+ next unless work[i] >= 2
12
+
13
+ work[i] -= 2
14
+ find_mentsu(work, 0, [], results, i)
15
+ work[i] += 2
16
+ end
17
+
18
+ results
19
+ end
20
+
21
+ private
22
+
23
+ def self.find_mentsu(tiles_34, start, mentsu_list, results, jantou_index)
24
+ if tiles_34.all?(&:zero?)
25
+ results << ParsedHand.new(
26
+ jantou_index: jantou_index,
27
+ mentsu_indices: mentsu_list.dup
28
+ )
29
+ return
30
+ end
31
+
32
+ i = (start...34).find { |idx| tiles_34[idx] > 0 }
33
+ return unless i
34
+
35
+ if tiles_34[i] >= 3
36
+ tiles_34[i] -= 3
37
+ mentsu_list << { type: :koutsu, index: i }
38
+ find_mentsu(tiles_34, i, mentsu_list, results, jantou_index)
39
+ mentsu_list.pop
40
+ tiles_34[i] += 3
41
+ end
42
+
43
+ if i < 27 && (i % 9) <= 6
44
+ if tiles_34[i] > 0 && tiles_34[i + 1] > 0 && tiles_34[i + 2] > 0
45
+ tiles_34[i] -= 1
46
+ tiles_34[i + 1] -= 1
47
+ tiles_34[i + 2] -= 1
48
+ mentsu_list << { type: :shuntsu, index: i }
49
+ find_mentsu(tiles_34, i, mentsu_list, results, jantou_index)
50
+ mentsu_list.pop
51
+ tiles_34[i] += 1
52
+ tiles_34[i + 1] += 1
53
+ tiles_34[i + 2] += 1
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ class ParsedHand
60
+ attr_reader :jantou_index, :mentsu_indices
61
+
62
+ def initialize(jantou_index:, mentsu_indices:)
63
+ @jantou_index = jantou_index
64
+ @mentsu_indices = mentsu_indices.freeze
65
+ freeze
66
+ end
67
+
68
+ def jantou_notation
69
+ Tiles::TileSet.from_index(jantou_index)
70
+ end
71
+
72
+ def mentsu_notations
73
+ mentsu_indices.map do |m|
74
+ case m[:type]
75
+ when :shuntsu
76
+ tiles = (0..2).map { |d| Tiles::TileSet.from_index(m[:index] + d) }
77
+ { type: :shuntsu, tiles: tiles }
78
+ when :koutsu
79
+ tile = Tiles::TileSet.from_index(m[:index])
80
+ { type: :koutsu, tiles: [tile, tile, tile] }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,57 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module ValueObjects
4
+ class ScoreResult
5
+ attr_reader :fu, :han, :base_points, :level, :is_oya,
6
+ :agari_type, :payments, :total, :kyoutaku_bonus, :honba, :is_yakuman
7
+
8
+ def initialize(**attrs)
9
+ attrs.each { |k, v| instance_variable_set(:"@#{k}", v) }
10
+ end
11
+
12
+ def display
13
+ parts = []
14
+ parts << "#{fu}符" if fu && level.empty?
15
+ parts << "#{han}翻" if han && !is_yakuman
16
+ parts << level unless level.empty?
17
+ parts << score_text
18
+ parts.join(" ")
19
+ end
20
+
21
+ def score_text
22
+ if agari_type == :tsumo
23
+ is_oya ? "#{payments[:ko_pays]}点オール" : "#{payments[:ko_pays]}/#{payments[:oya_pays]}点"
24
+ else
25
+ "#{payments[:ron_pay]}点"
26
+ end
27
+ end
28
+
29
+ def score_changes(winner_seat:, loser_seat:, oya_seat:)
30
+ changes = Hash.new(0)
31
+
32
+ if agari_type == :tsumo
33
+ 4.times do |seat|
34
+ next if seat == winner_seat
35
+
36
+ pay = if is_oya
37
+ payments[:ko_pays]
38
+ elsif seat == oya_seat
39
+ payments[:oya_pays]
40
+ else
41
+ payments[:ko_pays]
42
+ end
43
+ changes[seat] -= pay
44
+ changes[winner_seat] += pay
45
+ end
46
+ else
47
+ changes[loser_seat] -= payments[:ron_pay]
48
+ changes[winner_seat] += payments[:ron_pay]
49
+ end
50
+
51
+ changes[winner_seat] += kyoutaku_bonus
52
+ changes
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,70 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module ValueObjects
4
+ class YakuContext
5
+ attr_reader :agari_form, :agari_tile, :agari_type, :hand_tiles, :fuuros, :is_menzen,
6
+ :jikaze, :bakaze, :is_riichi, :is_double_riichi, :is_ippatsu, :is_first_turn,
7
+ :is_haitei, :is_houtei, :is_rinshan, :is_chankan, :dora_tiles, :uradora_tiles,
8
+ :machi_type, :rule
9
+
10
+ def initialize(**attrs)
11
+ attrs.each { |k, v| instance_variable_set(:"@#{k}", v) }
12
+ end
13
+
14
+ def with_agari_form(form)
15
+ self.class.new(
16
+ agari_form: form,
17
+ agari_tile: agari_tile,
18
+ agari_type: agari_type,
19
+ hand_tiles: hand_tiles,
20
+ fuuros: fuuros,
21
+ is_menzen: is_menzen,
22
+ jikaze: jikaze,
23
+ bakaze: bakaze,
24
+ is_riichi: is_riichi,
25
+ is_double_riichi: is_double_riichi,
26
+ is_ippatsu: is_ippatsu,
27
+ is_first_turn: is_first_turn,
28
+ is_haitei: is_haitei,
29
+ is_houtei: is_houtei,
30
+ is_rinshan: is_rinshan,
31
+ is_chankan: is_chankan,
32
+ dora_tiles: dora_tiles,
33
+ uradora_tiles: uradora_tiles,
34
+ machi_type: machi_type,
35
+ rule: rule
36
+ )
37
+ end
38
+
39
+ def all_tiles
40
+ hand_tiles + fuuros.flat_map(&:tiles)
41
+ end
42
+
43
+ def all_mentsu
44
+ return [] unless agari_form&.regular?
45
+
46
+ parsed_mentsu = agari_form.parsed_hand.mentsu_notations.map do |m|
47
+ tiles = m[:tiles].map { |n| Tiles::Tile.new(n) }
48
+ State::Mentsu.new(type: m[:type], tiles: tiles, state: :closed)
49
+ end
50
+
51
+ parsed_mentsu + fuuros.map(&:to_mentsu)
52
+ end
53
+
54
+ def jantou_tile
55
+ return nil unless agari_form&.regular?
56
+
57
+ Tiles::Tile.new(Tiles::TileSet.from_index(agari_form.parsed_hand.jantou_index))
58
+ end
59
+
60
+ def jikaze_yakuhai?(tile)
61
+ tile.kazehai? && Tiles::Tile::KAZE_NAMES[tile.number] == jikaze
62
+ end
63
+
64
+ def bakaze_yakuhai?(tile)
65
+ tile.kazehai? && Tiles::Tile::KAZE_NAMES[tile.number] == bakaze
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,7 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module ValueObjects
4
+ YakuEntry = Data.define(:name, :han, :display)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,27 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module ValueObjects
4
+ class YakuJudgeResult
5
+ attr_reader :yaku, :is_yakuman, :total_han
6
+
7
+ def initialize(yaku:, is_yakuman:, total_han:)
8
+ @yaku = yaku
9
+ @is_yakuman = is_yakuman
10
+ @total_han = total_han
11
+ end
12
+
13
+ def yakuman?
14
+ is_yakuman
15
+ end
16
+
17
+ def has_yaku?
18
+ yaku.any?
19
+ end
20
+
21
+ def to_a
22
+ yaku.map { |y| { name: y.name, han: y.han } }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Chankan
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_chankan
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "chankan", han: 1, display: "槍槓")
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ module Mahjong::Scoring::Yaku::Chanta
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ mentsu = ctx.all_mentsu
6
+ jantou = ctx.jantou_tile
7
+
8
+ return nil unless mentsu.all?(&:includes_yaochu?) && jantou.yaochu?
9
+ return nil unless mentsu.any?(&:shuntsu?)
10
+ return nil unless ctx.all_tiles.any?(&:jihai?)
11
+
12
+ han = ctx.is_menzen ? 2 : 1
13
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "chanta", han: han, display: "混全帯么九")
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Mahjong::Scoring::Yaku::Chiihou
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_first_turn && ctx.agari_type == :tsumo
4
+ return nil if ctx.jikaze == "ton"
5
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "chiihou", han: -1, display: "地和")
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Chiitoitsu
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.chiitoitsu?
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "chiitoitsu", han: 2, display: "七対子")
5
+ end
6
+ end
@@ -0,0 +1,10 @@
1
+ module Mahjong::Scoring::Yaku::Chinitsu
2
+ def self.check(ctx)
3
+ all = ctx.all_tiles
4
+ return nil if all.any?(&:jihai?)
5
+ return nil unless all.map(&:suit).uniq.size == 1
6
+
7
+ han = ctx.is_menzen ? 6 : 5
8
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "chinitsu", han: han, display: "清一色")
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Chinroutou
2
+ def self.check(ctx)
3
+ return nil unless ctx.all_tiles.all?(&:routou?)
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "chinroutou", han: -1, display: "清老頭")
5
+ end
6
+ end
@@ -0,0 +1,20 @@
1
+ module Mahjong::Scoring::Yaku::ChuurenPoutou
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_menzen
4
+
5
+ all = ctx.all_tiles
6
+ return nil if all.any?(&:jihai?)
7
+
8
+ suits = all.map(&:suit).uniq
9
+ return nil unless suits.size == 1
10
+
11
+ counts = Array.new(10, 0)
12
+ all.each { |t| counts[t.effective_number] += 1 }
13
+
14
+ # 1112345678999 + 任意1枚 の形
15
+ base = [3, 1, 1, 1, 1, 1, 1, 1, 3]
16
+ (1..9).each { |n| return nil if counts[n] < base[n - 1] }
17
+
18
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "chuuren_poutou", han: -1, display: "九蓮宝燈")
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ module Mahjong::Scoring::Yaku::Daisangen
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+ count = ctx.all_mentsu.count { |m| (m.koutsu? || m.kantsu?) && m.representative_tile.sangenpai? }
5
+ return nil unless count == 3
6
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "daisangen", han: -1, display: "大三元")
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Mahjong::Scoring::Yaku::Daisuushii
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+ count = ctx.all_mentsu.count { |m| (m.koutsu? || m.kantsu?) && m.representative_tile.kazehai? }
5
+ return nil unless count == 4
6
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "daisuushii", han: -1, display: "大四喜")
7
+ end
8
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::DoubleRiichi
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_double_riichi
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "double_riichi", han: 2, display: "ダブル立直")
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Haitei
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_haitei && ctx.agari_type == :tsumo
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "haitei", han: 1, display: "海底摸月")
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ module Mahjong::Scoring::Yaku::Honitsu
2
+ def self.check(ctx)
3
+ all = ctx.all_tiles
4
+ suits = all.select(&:suuhai?).map(&:suit).uniq
5
+ return nil unless suits.size == 1
6
+ return nil unless all.any?(&:jihai?)
7
+
8
+ han = ctx.is_menzen ? 3 : 2
9
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "honitsu", han: han, display: "混一色")
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Mahjong::Scoring::Yaku::Honroutou
2
+ def self.check(ctx)
3
+ all = ctx.all_tiles
4
+ return nil unless all.all?(&:yaochu?)
5
+ return nil unless all.any?(&:jihai?)
6
+ return nil unless all.any?(&:routou?)
7
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "honroutou", han: 2, display: "混老頭")
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Houtei
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_houtei && ctx.agari_type == :ron
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "houtei", han: 1, display: "河底撈魚")
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ module Mahjong::Scoring::Yaku::Iipeiko
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_menzen
4
+ return nil unless ctx.agari_form&.regular?
5
+
6
+ shuntsu = ctx.all_mentsu.select(&:shuntsu?)
7
+ return nil unless count_shuntsu_pairs(shuntsu) == 1
8
+
9
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "iipeiko", han: 1, display: "一盃口")
10
+ end
11
+
12
+ def self.count_shuntsu_pairs(shuntsu_list)
13
+ keys = shuntsu_list.map { |m| t = m.representative_tile; [t.suit, t.effective_number] }
14
+ keys.tally.values.count { |c| c >= 2 }
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ module Mahjong::Scoring::Yaku::Ikkitsuukan
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ numbers = ctx.all_mentsu.select(&:shuntsu?).map do |m|
6
+ [m.representative_tile.suit, m.representative_tile.effective_number]
7
+ end
8
+
9
+ found = %w[m p s].any? { |s| [1, 4, 7].all? { |n| numbers.include?([s, n]) } }
10
+ return nil unless found
11
+
12
+ han = ctx.is_menzen ? 2 : 1
13
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "ikkitsuukan", han: han, display: "一気通貫")
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Ippatsu
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_ippatsu
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "ippatsu", han: 1, display: "一発")
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ module Mahjong::Scoring::Yaku::Junchan
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ mentsu = ctx.all_mentsu
6
+ jantou = ctx.jantou_tile
7
+
8
+ return nil unless mentsu.all?(&:includes_yaochu?) && jantou.routou?
9
+ return nil unless mentsu.any?(&:shuntsu?)
10
+ return nil if ctx.all_tiles.any?(&:jihai?)
11
+
12
+ han = ctx.is_menzen ? 3 : 2
13
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "junchan", han: han, display: "純全帯么九")
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::KokushiMusou
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.kokushi?
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "kokushi_musou", han: -1, display: "国士無双")
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::MenzenTsumo
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_menzen && ctx.agari_type == :tsumo
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "menzen_tsumo", han: 1, display: "門前清自摸和")
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ module Mahjong::Scoring::Yaku::Pinfu
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_menzen
4
+ return nil unless ctx.agari_form&.regular?
5
+ return nil unless ctx.machi_type == :ryanmen
6
+
7
+ return nil unless ctx.all_mentsu.all?(&:shuntsu?)
8
+
9
+ jt = ctx.jantou_tile
10
+ return nil if jt.sangenpai?
11
+ return nil if ctx.jikaze_yakuhai?(jt)
12
+ return nil if ctx.bakaze_yakuhai?(jt)
13
+
14
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "pinfu", han: 1, display: "平和")
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Riichi
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_riichi && !ctx.is_double_riichi
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "riichi", han: 1, display: "立直")
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::RinshanKaihou
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_rinshan
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "rinshan_kaihou", han: 1, display: "嶺上開花")
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ module Mahjong::Scoring::Yaku::Ryanpeiko
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_menzen
4
+ return nil unless ctx.agari_form&.regular?
5
+
6
+ shuntsu = ctx.all_mentsu.select(&:shuntsu?)
7
+ return nil unless ::Mahjong::Scoring::Yaku::Iipeiko.count_shuntsu_pairs(shuntsu) == 2
8
+
9
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "ryanpeiko", han: 3, display: "二盃口")
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Ryuuiisou
2
+ def self.check(ctx)
3
+ return nil unless ctx.all_tiles.all?(&:green?)
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "ryuuiisou", han: -1, display: "緑一色")
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ module Mahjong::Scoring::Yaku::SanAnkou
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ ankou_count = ctx.all_mentsu.count { |m| (m.koutsu? || m.kantsu?) && m.closed? }
6
+
7
+ # ロンかつ双碰の場合、和了牌で完成した刻子は明刻扱い
8
+ if ctx.agari_type == :ron && ctx.machi_type == :shanpon
9
+ ankou_count -= 1 if ankou_count > 0
10
+ end
11
+
12
+ return nil unless ankou_count >= 3
13
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "san_ankou", han: 2, display: "三暗刻")
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::SanKantsu
2
+ def self.check(ctx)
3
+ return nil unless ctx.all_mentsu.count(&:kantsu?) >= 3
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "san_kantsu", han: 2, display: "三槓子")
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ module Mahjong::Scoring::Yaku::SanshokuDoujun
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ numbers = ctx.all_mentsu.select(&:shuntsu?).map do |m|
6
+ [m.representative_tile.suit, m.representative_tile.effective_number]
7
+ end
8
+
9
+ found = (1..7).any? { |n| %w[m p s].all? { |s| numbers.include?([s, n]) } }
10
+ return nil unless found
11
+
12
+ han = ctx.is_menzen ? 2 : 1
13
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "sanshoku_doujun", han: han, display: "三色同順")
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Mahjong::Scoring::Yaku::SanshokuDoukou
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ numbers = ctx.all_mentsu.select { |m| m.koutsu? || m.kantsu? }.map do |m|
6
+ [m.representative_tile.suit, m.representative_tile.effective_number]
7
+ end
8
+
9
+ found = (1..9).any? { |n| %w[m p s].all? { |s| numbers.include?([s, n]) } }
10
+ return nil unless found
11
+
12
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "sanshoku_doukou", han: 2, display: "三色同刻")
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module Mahjong::Scoring::Yaku::Shousangen
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ mentsu = ctx.all_mentsu
6
+ jantou = ctx.jantou_tile
7
+
8
+ sangen_koutsu = mentsu.count { |m| (m.koutsu? || m.kantsu?) && m.representative_tile.sangenpai? }
9
+ sangen_jantou = jantou&.sangenpai? ? 1 : 0
10
+
11
+ return nil unless sangen_koutsu == 2 && sangen_jantou == 1
12
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "shousangen", han: 2, display: "小三元")
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module Mahjong::Scoring::Yaku::Shousuushii
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ mentsu = ctx.all_mentsu
6
+ jantou = ctx.jantou_tile
7
+
8
+ kaze_koutsu = mentsu.count { |m| (m.koutsu? || m.kantsu?) && m.representative_tile.kazehai? }
9
+ kaze_jantou = jantou&.kazehai? ? 1 : 0
10
+
11
+ return nil unless kaze_koutsu == 3 && kaze_jantou == 1
12
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "shousuushii", han: -1, display: "小四喜")
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ module Mahjong::Scoring::Yaku::Suuankou
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+ return nil unless ctx.is_menzen
5
+
6
+ ankou_count = ctx.all_mentsu.count { |m| (m.koutsu? || m.kantsu?) && m.closed? }
7
+
8
+ if ctx.agari_type == :ron && ctx.machi_type == :shanpon
9
+ ankou_count -= 1
10
+ end
11
+
12
+ return nil unless ankou_count >= 4
13
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "suuankou", han: -1, display: "四暗刻")
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Suukantsu
2
+ def self.check(ctx)
3
+ return nil unless ctx.all_mentsu.count(&:kantsu?) == 4
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "suukantsu", han: -1, display: "四槓子")
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module Mahjong::Scoring::Yaku::Tanyao
2
+ def self.check(ctx)
3
+ return nil if !ctx.is_menzen && !ctx.rule.kuitan?
4
+ return nil unless ctx.all_tiles.all?(&:chunchan?)
5
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "tanyao", han: 1, display: "断么九")
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Mahjong::Scoring::Yaku::Tenhou
2
+ def self.check(ctx)
3
+ return nil unless ctx.is_first_turn && ctx.agari_type == :tsumo
4
+ return nil unless ctx.jikaze == "ton"
5
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "tenhou", han: -1, display: "天和")
6
+ end
7
+ end