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,11 @@
1
+ module Mahjong
2
+ module Results
3
+ class FinalResult
4
+ attr_reader :seat, :player_id, :name, :rank, :score, :adjusted_score, :point
5
+
6
+ def initialize(**attrs)
7
+ attrs.each { |k, v| instance_variable_set(:"@#{k}", v) }
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ module Mahjong
2
+ module Results
3
+ class GameFlowResult
4
+ attr_reader :action, :reason, :next_bakaze, :next_kyoku,
5
+ :next_honba, :next_oya_index, :renchan
6
+
7
+ def initialize(**attrs)
8
+ attrs.each { |k, v| instance_variable_set(:"@#{k}", v) }
9
+ end
10
+
11
+ def game_end?
12
+ action == :game_end
13
+ end
14
+
15
+ def next_round?
16
+ action == :next_round
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ module Mahjong
2
+ module Results
3
+ class GameResult
4
+ def self.calculate(scores:, rule:)
5
+ ranking = scores.sort_by { |seat, score| [-score, seat] }
6
+ uma = rule.uma_values
7
+ oka_total = (rule.oka_origin - rule.initial_score) * 4
8
+
9
+ ranking.each_with_index.map do |(seat, score), index|
10
+ adjusted = score - rule.oka_origin + uma[index]
11
+ adjusted += oka_total if index.zero?
12
+
13
+ Results::FinalResult.new(
14
+ seat: seat,
15
+ player_id: nil,
16
+ name: nil,
17
+ rank: index + 1,
18
+ score: score,
19
+ adjusted_score: adjusted,
20
+ point: (adjusted / 1000.0).round(1)
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ module Mahjong
2
+ module Results
3
+ class RoundEndInfo
4
+ attr_reader :result_type, :winners, :losers, :tenpai_seats, :draw_reason, :score_changes, :honba, :kyoutaku
5
+
6
+ def initialize(**attrs)
7
+ @result_type = attrs[:result_type]
8
+ @winners = attrs[:winners] || []
9
+ @losers = attrs[:losers] || []
10
+ @tenpai_seats = attrs[:tenpai_seats] || []
11
+ @draw_reason = attrs[:draw_reason]
12
+ @score_changes = attrs[:score_changes] || {}
13
+ @honba = attrs[:honba]
14
+ @kyoutaku = attrs[:kyoutaku]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ module Mahjong
2
+ module Results
3
+ class RoundFlowResult
4
+ attr_reader :state, :events, :round_end, :round_end_info
5
+
6
+ def initialize(state:, events: [], round_end: false, round_end_info: nil)
7
+ @state = state
8
+ @events = events
9
+ @round_end = round_end
10
+ @round_end_info = round_end_info
11
+ end
12
+
13
+ def round_end?
14
+ @round_end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,68 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Calculators
4
+ class FuCalculator
5
+ def self.calculate(context)
6
+ return 25 if context.agari_form&.chiitoitsu?
7
+ return 30 if context.agari_form&.kokushi?
8
+
9
+ fu = 20
10
+ fu += 10 if context.is_menzen && context.agari_type == :ron
11
+
12
+ if context.agari_type == :tsumo
13
+ return 20 if pinfu?(context)
14
+
15
+ fu += 2
16
+ end
17
+
18
+ fu += context.all_mentsu.sum(&:fu)
19
+ fu += jantou_fu(context)
20
+ fu += machi_fu(context.machi_type)
21
+ fu = 30 if !context.is_menzen && fu == 20
22
+
23
+ round_up_fu(fu)
24
+ end
25
+
26
+ def self.pinfu?(context)
27
+ return false unless context.agari_form&.regular?
28
+ return false unless context.is_menzen
29
+ return false unless context.machi_type == :ryanmen
30
+ return false unless context.all_mentsu.all?(&:shuntsu?)
31
+
32
+ jantou = context.jantou_tile
33
+ return false if jantou.sangenpai?
34
+ return false if context.jikaze_yakuhai?(jantou)
35
+ return false if context.bakaze_yakuhai?(jantou)
36
+
37
+ true
38
+ end
39
+
40
+ def self.jantou_fu(context)
41
+ jantou = context.jantou_tile
42
+ return 0 unless jantou
43
+
44
+ return context.rule.renpuu_jantou_fu if context.jikaze_yakuhai?(jantou) && context.bakaze_yakuhai?(jantou)
45
+
46
+ fu = 0
47
+ fu += 2 if jantou.sangenpai?
48
+ fu += 2 if context.jikaze_yakuhai?(jantou)
49
+ fu += 2 if context.bakaze_yakuhai?(jantou)
50
+ fu
51
+ end
52
+
53
+ def self.machi_fu(machi_type)
54
+ case machi_type
55
+ when :penchan, :kanchan, :tanki then 2
56
+ else 0
57
+ end
58
+ end
59
+
60
+ def self.round_up_fu(fu)
61
+ return 25 if fu == 25
62
+
63
+ ((fu + 9) / 10) * 10
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,73 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Calculators
4
+ class ScoreCalculator
5
+ def self.calculate(fu:, han:, is_oya:, agari_type:, honba: 0, kyoutaku: 0, is_yakuman: false, rule: nil)
6
+ base = base_points(fu, han, is_yakuman, rule)
7
+ payments = compute_payments(base, is_oya, agari_type, honba)
8
+
9
+ ValueObjects::ScoreResult.new(
10
+ fu: fu,
11
+ han: han,
12
+ base_points: base,
13
+ level: score_level(base, han, is_yakuman),
14
+ is_oya: is_oya,
15
+ agari_type: agari_type,
16
+ payments: payments,
17
+ total: payments[:total],
18
+ kyoutaku_bonus: kyoutaku * 1000,
19
+ honba: honba,
20
+ is_yakuman: is_yakuman
21
+ )
22
+ end
23
+
24
+ def self.base_points(fu, han, is_yakuman, rule)
25
+ return 8000 if is_yakuman
26
+ return rule&.kazoe_yakuman? == false ? 6000 : 8000 if han >= 13
27
+ return 6000 if han >= 11
28
+ return 4000 if han >= 8
29
+ return 3000 if han >= 6
30
+ return 2000 if han >= 5
31
+
32
+ base = fu * (2**(2 + han))
33
+ return 2000 if rule&.kiriage_mangan? && ((fu == 30 && han == 4) || (fu == 60 && han == 3))
34
+
35
+ [base, 2000].min
36
+ end
37
+
38
+ def self.compute_payments(base, is_oya, agari_type, honba)
39
+ if agari_type == :tsumo
40
+ if is_oya
41
+ ko_pay = ceil100(base * 2) + honba * 100
42
+ { ko_pays: ko_pay, total: ko_pay * 3 }
43
+ else
44
+ ko_pay = ceil100(base) + honba * 100
45
+ oya_pay = ceil100(base * 2) + honba * 100
46
+ { ko_pays: ko_pay, oya_pays: oya_pay, total: ko_pay * 2 + oya_pay }
47
+ end
48
+ else
49
+ ron_pay = ceil100(base * (is_oya ? 6 : 4)) + honba * 300
50
+ { ron_pay: ron_pay, total: ron_pay }
51
+ end
52
+ end
53
+
54
+ def self.score_level(base, han, is_yakuman)
55
+ return "役満" if is_yakuman
56
+ return "数え役満" if han >= 13 && base == 8000
57
+
58
+ case base
59
+ when 6000 then "三倍満"
60
+ when 4000 then "倍満"
61
+ when 3000 then "跳満"
62
+ when 2000 then "満貫"
63
+ else ""
64
+ end
65
+ end
66
+
67
+ def self.ceil100(value)
68
+ ((value + 99) / 100) * 100
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,111 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Evaluators
4
+ class WinEvaluator
5
+ def self.evaluate(state:, seat:, agari_type:, agari_tile:, rule:, extra_flags:, snapshot:)
6
+ full_hand_tiles = winning_hand_tiles(state, seat, agari_type, agari_tile)
7
+ concealed_hand_tiles = remove_agari_tile(full_hand_tiles, agari_tile)
8
+ forms = Scoring::Judges::AgariJudge.all_forms(Tiles::TileSet.to_34(full_hand_tiles))
9
+ machi_results = Scoring::Judges::MachiJudge.find_machi(Tiles::TileSet.to_34(concealed_hand_tiles))
10
+
11
+ scored = forms.filter_map do |form|
12
+ machi_type = machi_results.find do |result|
13
+ matching_form?(result.agari_form, form) && result.tile_index == Tiles::TileSet.tile_index(agari_tile)
14
+ end&.machi_type
15
+
16
+ context = ValueObjects::YakuContext.new(
17
+ agari_form: form,
18
+ agari_tile: agari_tile,
19
+ agari_type: agari_type,
20
+ hand_tiles: full_hand_tiles,
21
+ fuuros: state.fuuros[seat],
22
+ is_menzen: state.menzen_flags[seat],
23
+ jikaze: snapshot.jikaze,
24
+ bakaze: snapshot.bakaze,
25
+ is_riichi: state.riichi_flags[seat],
26
+ is_double_riichi: state.double_riichi_flags[seat],
27
+ is_ippatsu: state.ippatsu_flags[seat],
28
+ is_first_turn: state.first_turn_flags[seat],
29
+ is_haitei: state.is_haitei?,
30
+ is_houtei: state.is_houtei?,
31
+ is_rinshan: extra_flags[:rinshan] == true,
32
+ is_chankan: extra_flags[:chankan] == true,
33
+ dora_tiles: state.dora_hyouji,
34
+ uradora_tiles: state.uradora_hyouji,
35
+ machi_type: machi_type || :tanki,
36
+ rule: rule
37
+ )
38
+
39
+ yaku_result = Scoring::Judges::YakuJudge.judge_single(context)
40
+ next if !yaku_result.yakuman? && yaku_result.total_han.to_i <= 0
41
+
42
+ fu = Scoring::Calculators::FuCalculator.calculate(context)
43
+ score_result = Scoring::Calculators::ScoreCalculator.calculate(
44
+ fu: fu,
45
+ han: yaku_result.yakuman? ? 13 : yaku_result.total_han,
46
+ is_oya: seat == snapshot.oya_index,
47
+ agari_type: agari_type,
48
+ honba: snapshot.honba,
49
+ kyoutaku: snapshot.kyoutaku,
50
+ is_yakuman: yaku_result.yakuman?,
51
+ rule: rule
52
+ )
53
+
54
+ [context, yaku_result, fu, score_result]
55
+ end
56
+
57
+ best = scored.max_by do |_context, yaku_result, fu, score_result|
58
+ [
59
+ yaku_result.yakuman? ? 1 : 0,
60
+ score_result.total,
61
+ yaku_result.total_han || 13,
62
+ fu
63
+ ]
64
+ end
65
+ raise Errors::EngineError, "No valid winning result found" unless best
66
+
67
+ context, yaku_result, fu, score_result = best
68
+ winner_info = {
69
+ yaku: yaku_result.yaku.map { |entry| { name: entry.name, han: entry.han, display: entry.display } },
70
+ han: yaku_result.yakuman? ? 13 : yaku_result.total_han,
71
+ fu: fu,
72
+ hand_tiles: concealed_hand_tiles.sort.map(&:to_s),
73
+ fuuro_tiles: context.fuuros.flat_map { |fuuro| fuuro.tiles.map(&:to_s) },
74
+ agari_hai: agari_tile.to_s
75
+ }
76
+
77
+ [winner_info, score_result]
78
+ end
79
+
80
+ def self.winning_hand_tiles(state, seat, agari_type, agari_tile)
81
+ tiles = state.hands[seat].tiles.dup
82
+ return tiles if agari_type == :tsumo
83
+
84
+ tiles + [agari_tile]
85
+ end
86
+ private_class_method :winning_hand_tiles
87
+
88
+ def self.remove_agari_tile(full_hand_tiles, agari_tile)
89
+ removed = false
90
+ full_hand_tiles.each_with_object([]) do |tile, array|
91
+ if !removed && tile.same_kind?(agari_tile)
92
+ removed = true
93
+ else
94
+ array << tile
95
+ end
96
+ end
97
+ end
98
+ private_class_method :remove_agari_tile
99
+
100
+ def self.matching_form?(left, right)
101
+ return false unless left.type == right.type
102
+ return true unless left.regular?
103
+
104
+ left.parsed_hand.jantou_index == right.parsed_hand.jantou_index &&
105
+ left.parsed_hand.mentsu_notations == right.parsed_hand.mentsu_notations
106
+ end
107
+ private_class_method :matching_form?
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,68 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Judges
4
+ class AgariJudge
5
+ # 和了形か判定する(通常形 / 七対子 / 国士無双)
6
+ #
7
+ # @param tiles_34 [Array<Integer>] 手牌14枚の34配列
8
+ # @return [Boolean]
9
+ def self.agari?(tiles_34)
10
+ regular_agari?(tiles_34) || chiitoitsu?(tiles_34) || kokushi?(tiles_34)
11
+ end
12
+
13
+ def self.regular_agari?(tiles_34)
14
+ Scoring::Parsers::HandParser.parse(tiles_34).any?
15
+ end
16
+
17
+ def self.chiitoitsu?(tiles_34)
18
+ pairs = tiles_34.count { |c| c >= 2 }
19
+ pairs == 7 && tiles_34.all? { |c| c <= 2 }
20
+ end
21
+
22
+ def self.kokushi?(tiles_34)
23
+ yaochu = Tiles::TileSet::YAOCHU_INDICES
24
+ has_all = yaochu.all? { |i| tiles_34[i] >= 1 }
25
+ has_pair = yaochu.any? { |i| tiles_34[i] >= 2 }
26
+ total = yaochu.sum { |i| tiles_34[i] }
27
+ has_all && has_pair && total == 14
28
+ end
29
+
30
+ def self.all_forms(tiles_34)
31
+ forms = []
32
+
33
+ Scoring::Parsers::HandParser.parse(tiles_34).each do |parsed|
34
+ forms << AgariForm.new(type: :regular, parsed_hand: parsed)
35
+ end
36
+
37
+ if chiitoitsu?(tiles_34)
38
+ pairs = (0...34).select { |i| tiles_34[i] >= 2 }
39
+ forms << AgariForm.new(type: :chiitoitsu, pair_indices: pairs)
40
+ end
41
+
42
+ if kokushi?(tiles_34)
43
+ pair_idx = Tiles::TileSet::YAOCHU_INDICES.find { |i| tiles_34[i] >= 2 }
44
+ forms << AgariForm.new(type: :kokushi, kokushi_pair_index: pair_idx)
45
+ end
46
+
47
+ forms
48
+ end
49
+ end
50
+
51
+ class AgariForm
52
+ attr_reader :type, :parsed_hand, :pair_indices, :kokushi_pair_index
53
+
54
+ def initialize(type:, parsed_hand: nil, pair_indices: nil, kokushi_pair_index: nil)
55
+ @type = type
56
+ @parsed_hand = parsed_hand
57
+ @pair_indices = pair_indices
58
+ @kokushi_pair_index = kokushi_pair_index
59
+ freeze
60
+ end
61
+
62
+ def regular? = type == :regular
63
+ def chiitoitsu? = type == :chiitoitsu
64
+ def kokushi? = type == :kokushi
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,34 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Judges
4
+ class FuritenJudge
5
+ def self.judge(state:, seat:)
6
+ hand = state.hands[seat]
7
+ kawa = state.kawas[seat]
8
+ tiles_34 = hand.to_34
9
+
10
+ machi_indices = MachiJudge.machi_tile_indices(tiles_34)
11
+ return FuritenResult.new(furiten: false, type: nil) if machi_indices.empty?
12
+
13
+ machi_tiles = machi_indices.map { |i| Tiles::Tile.new(Tiles::TileSet.from_index(i)) }
14
+ return FuritenResult.new(furiten: true, type: :normal) if machi_tiles.any? { |mt| kawa.includes_kind?(mt) }
15
+ return FuritenResult.new(furiten: true, type: :dojun) if state.respond_to?(:dojun_furiten_flags) && state.dojun_furiten_flags[seat]
16
+
17
+ if state.riichi_flags[seat] &&
18
+ state.respond_to?(:riichi_furiten_flags) &&
19
+ state.riichi_furiten_flags[seat]
20
+ return FuritenResult.new(furiten: true, type: :riichi)
21
+ end
22
+
23
+ FuritenResult.new(furiten: false, type: nil)
24
+ end
25
+ end
26
+
27
+ FuritenResult = Data.define(:furiten, :type) do
28
+ def furiten?
29
+ furiten
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,78 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Judges
4
+ class MachiJudge
5
+ MachiResult = Data.define(:tile_index, :machi_type, :agari_form)
6
+
7
+ def self.find_machi(tiles_34)
8
+ results = []
9
+ work = tiles_34.dup
10
+
11
+ 34.times do |i|
12
+ next if work[i] >= 4
13
+
14
+ work[i] += 1
15
+ if AgariJudge.agari?(work)
16
+ forms = AgariJudge.all_forms(work)
17
+ determine_machi_types(work, i, forms).each do |mt|
18
+ results << MachiResult.new(tile_index: i, machi_type: mt[:type], agari_form: mt[:form])
19
+ end
20
+ end
21
+ work[i] -= 1
22
+ end
23
+
24
+ results
25
+ end
26
+
27
+ def self.tenpai?(tiles_34)
28
+ find_machi(tiles_34).any?
29
+ end
30
+
31
+ def self.machi_tile_indices(tiles_34)
32
+ find_machi(tiles_34).map(&:tile_index).uniq
33
+ end
34
+
35
+ private
36
+
37
+ def self.determine_machi_types(_tiles_34, agari_idx, forms)
38
+ results = []
39
+
40
+ forms.each do |form|
41
+ case form.type
42
+ when :chiitoitsu, :kokushi
43
+ results << { type: :tanki, form: form }
44
+ when :regular
45
+ results << { type: regular_machi_type(form.parsed_hand, agari_idx), form: form }
46
+ end
47
+ end
48
+
49
+ results
50
+ end
51
+
52
+ def self.regular_machi_type(parsed, agari_idx)
53
+ return :tanki if parsed.jantou_index == agari_idx
54
+
55
+ parsed.mentsu_indices.each do |m|
56
+ case m[:type]
57
+ when :koutsu
58
+ return :shanpon if m[:index] == agari_idx
59
+ when :shuntsu
60
+ base = m[:index]
61
+ next unless agari_idx >= base && agari_idx <= base + 2
62
+
63
+ pos = agari_idx - base
64
+ suit_pos = base % 9
65
+
66
+ return :kanchan if pos == 1
67
+ return :penchan if pos == 0 && suit_pos == 6
68
+ return :penchan if pos == 2 && suit_pos == 0
69
+ return :ryanmen
70
+ end
71
+ end
72
+
73
+ :tanki
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,161 @@
1
+ module Mahjong
2
+ module Scoring
3
+ module Judges
4
+ class YakuJudge
5
+ # 和了時の成立役を全て判定する
6
+ def self.judge(context)
7
+ contexts = contexts_for_judging(context)
8
+ return judge_single(contexts.first) if contexts.one?
9
+
10
+ contexts
11
+ .map { |ctx| [judge_single(ctx), ctx] }
12
+ .max_by { |result, ctx| result_sort_key(result, ctx) }
13
+ .first
14
+ end
15
+
16
+ def self.judge_single(context)
17
+ yakuman_results = check_yakuman(context)
18
+ unless yakuman_results.empty?
19
+ return ValueObjects::YakuJudgeResult.new(
20
+ yaku: yakuman_results,
21
+ is_yakuman: true,
22
+ total_han: nil
23
+ )
24
+ end
25
+
26
+ yaku_results = check_regular_yaku(context)
27
+ dora_results = check_dora(context)
28
+
29
+ if yaku_results.empty?
30
+ return ValueObjects::YakuJudgeResult.new(yaku: [], is_yakuman: false, total_han: 0)
31
+ end
32
+
33
+ all = yaku_results + dora_results
34
+ total_han = all.sum(&:han)
35
+
36
+ ValueObjects::YakuJudgeResult.new(yaku: all, is_yakuman: false, total_han: total_han)
37
+ end
38
+
39
+ private
40
+
41
+ def self.contexts_for_judging(context)
42
+ return [context] unless context.hand_tiles
43
+
44
+ tiles_34 = Tiles::TileSet.to_34(context.hand_tiles)
45
+ forms = Scoring::Judges::AgariJudge.all_forms(tiles_34)
46
+ return [context] if forms.empty?
47
+
48
+ forms.map { |form| context.with_agari_form(form) }
49
+ end
50
+
51
+ def self.result_sort_key(result, context)
52
+ [
53
+ result.yakuman? ? 1 : 0,
54
+ result.total_han || result.yaku.sum { |entry| entry.han == -1 ? 13 : entry.han },
55
+ estimated_fu(context, result),
56
+ result.yaku.length
57
+ ]
58
+ end
59
+
60
+ def self.estimated_fu(context, result)
61
+ return 0 if result.yakuman?
62
+ return 25 if context.agari_form&.chiitoitsu?
63
+
64
+ fu = 20
65
+
66
+ if context.agari_form&.regular?
67
+ fu += context.all_mentsu.sum(&:fu)
68
+
69
+ jantou = context.jantou_tile
70
+ if jantou
71
+ fu += 2 if jantou.sangenpai?
72
+ fu += 2 if context.jikaze_yakuhai?(jantou)
73
+ fu += 2 if context.bakaze_yakuhai?(jantou)
74
+ end
75
+
76
+ fu += case context.machi_type
77
+ when :tanki, :kanchan, :penchan then 2
78
+ else 0
79
+ end
80
+ end
81
+
82
+ if context.agari_type == :tsumo
83
+ fu += 2 unless result.yaku.any? { |entry| entry.name == "pinfu" }
84
+ elsif context.is_menzen
85
+ fu += 10
86
+ end
87
+
88
+ fu
89
+ end
90
+
91
+ def self.check_yakuman(context)
92
+ results = []
93
+ YAKUMAN_CHECKERS.each do |checker|
94
+ result = checker.check(context)
95
+ results.concat(Array(result).compact) if result
96
+ end
97
+ results
98
+ end
99
+
100
+ def self.check_regular_yaku(context)
101
+ results = []
102
+ REGULAR_CHECKERS.each do |checker|
103
+ result = checker.check(context)
104
+ results.concat(Array(result).compact) if result
105
+ end
106
+
107
+ if results.any? { |r| r.name == "ryanpeiko" }
108
+ results.reject! { |r| r.name == "chiitoitsu" }
109
+ end
110
+
111
+ results
112
+ end
113
+
114
+ def self.check_dora(context)
115
+ results = []
116
+ all_tiles = context.all_tiles
117
+
118
+ dora_count = Tiles::Dora.count(all_tiles, context.dora_tiles)
119
+ if dora_count > 0
120
+ results << ValueObjects::YakuEntry.new(name: "dora", han: dora_count, display: "ドラ #{dora_count}")
121
+ end
122
+
123
+ if context.is_riichi
124
+ ura_count = Tiles::Dora.uradora_count(all_tiles, context.uradora_tiles)
125
+ if ura_count > 0
126
+ results << ValueObjects::YakuEntry.new(name: "uradora", han: ura_count, display: "裏ドラ #{ura_count}")
127
+ end
128
+ end
129
+
130
+ aka_count = Tiles::Dora.aka_count(all_tiles)
131
+ if aka_count > 0
132
+ results << ValueObjects::YakuEntry.new(name: "akadora", han: aka_count, display: "赤ドラ #{aka_count}")
133
+ end
134
+
135
+ results
136
+ end
137
+
138
+ REGULAR_CHECKERS = [
139
+ Scoring::Yaku::Riichi, Scoring::Yaku::DoubleRiichi, Scoring::Yaku::Ippatsu,
140
+ Scoring::Yaku::MenzenTsumo, Scoring::Yaku::Pinfu, Scoring::Yaku::Iipeiko,
141
+ Scoring::Yaku::Tanyao, Scoring::Yaku::Yakuhai,
142
+ Scoring::Yaku::RinshanKaihou, Scoring::Yaku::Chankan, Scoring::Yaku::Haitei, Scoring::Yaku::Houtei,
143
+ Scoring::Yaku::SanshokuDoujun, Scoring::Yaku::Ikkitsuukan, Scoring::Yaku::Chanta,
144
+ Scoring::Yaku::Chiitoitsu, Scoring::Yaku::Toitoihou, Scoring::Yaku::SanAnkou,
145
+ Scoring::Yaku::SanKantsu, Scoring::Yaku::Honroutou, Scoring::Yaku::Shousangen,
146
+ Scoring::Yaku::SanshokuDoukou,
147
+ Scoring::Yaku::Honitsu, Scoring::Yaku::Junchan, Scoring::Yaku::Ryanpeiko,
148
+ Scoring::Yaku::Chinitsu
149
+ ].freeze
150
+
151
+ YAKUMAN_CHECKERS = [
152
+ Scoring::Yaku::Tenhou, Scoring::Yaku::Chiihou,
153
+ Scoring::Yaku::KokushiMusou, Scoring::Yaku::Suuankou,
154
+ Scoring::Yaku::Daisangen, Scoring::Yaku::Shousuushii, Scoring::Yaku::Daisuushii,
155
+ Scoring::Yaku::Tsuuiisou, Scoring::Yaku::Ryuuiisou, Scoring::Yaku::Chinroutou,
156
+ Scoring::Yaku::ChuurenPoutou, Scoring::Yaku::Suukantsu
157
+ ].freeze
158
+ end
159
+ end
160
+ end
161
+ end