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,186 @@
1
+ module Mahjong
2
+ module Flow
3
+ module Games
4
+ # 半荘/東風の局間進行を管理する。
5
+ class GameFlow
6
+ # 次局に進むべきか、ゲーム終了かを判定する
7
+ # @param progress_snapshot [Mahjong::Snapshots::GameProgressSnapshot]
8
+ # @param round_end_info [Hash]
9
+ # @param rule [RuleConfig]
10
+ # @return [GameFlowResult]
11
+ def self.judge_next(progress_snapshot:, round_end_info:, rule:)
12
+ scores = progress_snapshot.scores
13
+
14
+ if rule.tobi? && scores.values.any?(&:negative?)
15
+ return Results::GameFlowResult.new(
16
+ action: :game_end, reason: :tobi,
17
+ next_bakaze: nil, next_kyoku: nil, next_honba: nil, next_oya_index: nil, renchan: false
18
+ )
19
+ end
20
+
21
+ info = normalize_round_end_info(round_end_info)
22
+ renchan = renchan?(progress_snapshot, info)
23
+ next_honba = calc_next_honba(progress_snapshot, info, renchan)
24
+
25
+ if renchan
26
+ if all_last_oya_agari?(progress_snapshot, info, scores, rule)
27
+ return Results::GameFlowResult.new(
28
+ action: :game_end, reason: :all_last_oya_agari,
29
+ next_bakaze: nil, next_kyoku: nil, next_honba: nil, next_oya_index: nil, renchan: true
30
+ )
31
+ end
32
+
33
+ return Results::GameFlowResult.new(
34
+ action: :next_round, reason: :renchan,
35
+ next_bakaze: progress_snapshot.bakaze, next_kyoku: progress_snapshot.kyoku, next_honba: next_honba, next_oya_index: progress_snapshot.oya_index, renchan: true
36
+ )
37
+ end
38
+
39
+ next_oya_index = (progress_snapshot.oya_index + 1) % 4
40
+ next_bakaze = progress_snapshot.bakaze
41
+ next_kyoku = progress_snapshot.kyoku + 1
42
+
43
+ if next_kyoku > 4
44
+ next_kyoku = 1
45
+ next_bakaze = next_bakaze_value(progress_snapshot.bakaze)
46
+ end
47
+
48
+ if should_end_game?(next_bakaze, scores, rule)
49
+ return Results::GameFlowResult.new(
50
+ action: :game_end, reason: :normal_end,
51
+ next_bakaze: nil, next_kyoku: nil, next_honba: nil, next_oya_index: nil, renchan: false
52
+ )
53
+ end
54
+
55
+ Results::GameFlowResult.new(
56
+ action: :next_round, reason: :oya_nagare,
57
+ next_bakaze: next_bakaze, next_kyoku: next_kyoku, next_honba: next_honba, next_oya_index: next_oya_index, renchan: false
58
+ )
59
+ end
60
+
61
+ # 最終結果の計算(ウマ・オカ適用)
62
+ # @param final_result_snapshot [Mahjong::Snapshots::FinalResultSnapshot]
63
+ # @param rule [RuleConfig]
64
+ # @return [Array<FinalResult>]
65
+ def self.calculate_final_result(final_result_snapshot:, rule:)
66
+ scores = final_result_snapshot.scores
67
+ base_results = Results::GameResult.calculate(scores:, rule:)
68
+ by_seat = final_result_snapshot.players_by_seat
69
+
70
+ base_results.map do |result|
71
+ gp = by_seat[result.seat]
72
+ Results::FinalResult.new(
73
+ seat: result.seat,
74
+ player_id: gp[:player_id],
75
+ name: gp[:name],
76
+ rank: result.rank,
77
+ score: result.score,
78
+ adjusted_score: result.adjusted_score,
79
+ point: result.point
80
+ )
81
+ end
82
+ end
83
+
84
+ def self.calculate_tenpai_payments(tenpai_seats)
85
+ changes = { 0 => 0, 1 => 0, 2 => 0, 3 => 0 }
86
+ count = tenpai_seats.size
87
+ return changes if count.zero? || count == 4
88
+
89
+ receive, pay = case count
90
+ when 1 then [3000, 1000]
91
+ when 2 then [1500, 1500]
92
+ when 3 then [1000, 3000]
93
+ end
94
+
95
+ 4.times do |seat|
96
+ changes[seat] = tenpai_seats.include?(seat) ? receive : -pay
97
+ end
98
+
99
+ changes
100
+ end
101
+
102
+ def self.renchan?(progress_snapshot, round_end_info)
103
+ case round_end_info.result_type
104
+ when :tsumo_agari, :ron_agari, :tsumo, :ron
105
+ round_end_info.winners.any? { |w| w[:seat] == progress_snapshot.oya_index }
106
+ when :ryuukyoku
107
+ round_end_info.tenpai_seats.include?(progress_snapshot.oya_index)
108
+ when :draw_abortive, :kyuushu_kyuuhai
109
+ true
110
+ else
111
+ false
112
+ end
113
+ end
114
+
115
+ def self.calc_next_honba(progress_snapshot, round_end_info, renchan)
116
+ case round_end_info.result_type
117
+ when :tsumo_agari, :ron_agari, :tsumo, :ron
118
+ renchan ? progress_snapshot.honba + 1 : 0
119
+ when :ryuukyoku, :draw_abortive, :kyuushu_kyuuhai
120
+ progress_snapshot.honba + 1
121
+ else
122
+ 0
123
+ end
124
+ end
125
+
126
+ def self.next_bakaze_value(current)
127
+ case current
128
+ when "ton" then "nan"
129
+ when "nan" then "sha"
130
+ else current
131
+ end
132
+ end
133
+
134
+ def self.should_end_game?(next_bakaze, scores, rule)
135
+ case rule.game_type
136
+ when "tonpuusen"
137
+ next_bakaze == "nan"
138
+ when "hanchan"
139
+ if next_bakaze == "sha"
140
+ return false if rule.nishi_iri? && scores.values.max < rule.nishi_iri_threshold
141
+
142
+ return true
143
+ end
144
+ false
145
+ else
146
+ false
147
+ end
148
+ end
149
+
150
+ def self.all_last_oya_agari?(progress_snapshot, round_end_info, scores, rule)
151
+ is_all_last = (progress_snapshot.bakaze == "nan" && progress_snapshot.kyoku == 4 && rule.game_type == "hanchan") ||
152
+ (progress_snapshot.bakaze == "ton" && progress_snapshot.kyoku == 4 && rule.game_type == "tonpuusen")
153
+ return false unless is_all_last
154
+ return false unless round_end_info.winners.any? { |winner| winner[:seat] == progress_snapshot.oya_index }
155
+
156
+ scores[progress_snapshot.oya_index] == scores.values.max && scores[progress_snapshot.oya_index].positive?
157
+ end
158
+
159
+ def self.normalize_round_end_info(info)
160
+ return info if info.is_a?(Results::RoundEndInfo)
161
+
162
+ result_type = info[:result_type] || info[:type]
163
+ winners = info[:winners]
164
+ losers = info[:losers]
165
+
166
+ if winners.nil? && info.key?(:winner_seat)
167
+ winners = info[:winner_seat].nil? ? [] : [{ seat: info[:winner_seat] }]
168
+ end
169
+
170
+ if losers.nil? && info.key?(:loser_seat)
171
+ losers = info[:loser_seat].nil? ? [] : [{ seat: info[:loser_seat] }]
172
+ end
173
+
174
+ Results::RoundEndInfo.new(
175
+ result_type: result_type,
176
+ winners: winners,
177
+ losers: losers,
178
+ tenpai_seats: info[:tenpai_seats],
179
+ draw_reason: info[:draw_reason],
180
+ score_changes: info[:score_changes]
181
+ )
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,22 @@
1
+ module Mahjong
2
+ module Flow
3
+ module Resolvers
4
+ class ActionResolver
5
+ PRIORITY = {
6
+ ron_agari: 1,
7
+ daiminkan: 2,
8
+ pon: 2,
9
+ chi: 3
10
+ }.freeze
11
+
12
+ def self.resolve(pending_actions)
13
+ return nil if pending_actions.nil? || pending_actions.empty?
14
+
15
+ pending_actions
16
+ .flat_map { |seat, actions| Array(actions).map { |action| action.merge(seat: seat) } }
17
+ .min_by { |action| [PRIORITY[action[:type].to_sym] || 99, action[:seat]] }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end