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,588 @@
1
+ module Mahjong
2
+ module Flow
3
+ module Rounds
4
+ # 局の進行制御(ステートマシン)。
5
+ class RoundFlow
6
+ # 局を初期化(配牌・ドラ設定)
7
+ # @param game_snapshot [Mahjong::Snapshots::GameSetupSnapshot]
8
+ # @param rule [RuleConfig]
9
+ # @param seed [String, nil]
10
+ # @return [RoundState]
11
+ def self.setup(game_snapshot:, rule:, seed: nil)
12
+ wall = Tiles::Wall.build(aka_config: rule.aka_dora_config, seed: seed)
13
+ wanpai_tiles = Array.new(14) { wall.draw_from_end! }
14
+ wanpai = Tiles::Wanpai.new(wanpai_tiles)
15
+
16
+ hands = {}
17
+ 4.times do |seat|
18
+ hands[seat] = State::Hand.new(wall.draw_many!(13))
19
+ end
20
+
21
+ state = State::RoundState.new
22
+ state.bakaze = game_snapshot.bakaze
23
+ state.kyoku = game_snapshot.kyoku
24
+ state.honba = game_snapshot.honba
25
+ state.kyoutaku = game_snapshot.kyoutaku
26
+ state.oya_seat = game_snapshot.oya_index
27
+ state.hands = hands
28
+ state.fuuros = { 0 => [], 1 => [], 2 => [], 3 => [] }
29
+ state.kawas = { 0 => State::Kawa.new, 1 => State::Kawa.new, 2 => State::Kawa.new, 3 => State::Kawa.new }
30
+ state.scores = game_snapshot.scores.dup
31
+ state.wall = wall
32
+ state.wanpai = wanpai
33
+ state.dora_hyouji = wanpai.dora_hyouji.dup
34
+ state.uradora_hyouji = wanpai.uradora_hyouji.dup
35
+ state.current_seat = game_snapshot.oya_index
36
+ state.junme = 0
37
+ state.phase = :tsumo
38
+ state.last_dahai = nil
39
+ state.last_tsumo = nil
40
+
41
+ state.pending_actions = {}
42
+ state
43
+ end
44
+
45
+ # アクションを適用
46
+ # @param state [RoundState]
47
+ # @param action [Hash] { type:, seat:, tile:, tiles: }
48
+ # @param rule [RuleConfig]
49
+ # @return [RoundFlowResult]
50
+ def self.apply_action(state:, action:, rule:)
51
+ validation = Validators::ActionValidator.validate(state: state, action: action, rule: rule)
52
+ raise Errors::InvalidActionError, validation.error unless validation.valid?
53
+
54
+ events = case action[:type].to_sym
55
+ when :tsumo
56
+ process_tsumo(state, action, rule)
57
+ when :dahai
58
+ process_dahai(state, action, rule)
59
+ when :skip
60
+ process_skip(state, action, rule)
61
+ when :pon
62
+ process_pon(state, action, rule)
63
+ when :chi
64
+ process_chi(state, action, rule)
65
+ when :riichi
66
+ process_riichi(state, action, rule)
67
+ when :ankan
68
+ process_ankan(state, action, rule)
69
+ when :kakan
70
+ process_kakan(state, action, rule)
71
+ when :daiminkan
72
+ process_daiminkan(state, action, rule)
73
+ when :tsumo_agari
74
+ process_tsumo_agari(state, action, rule)
75
+ when :ron_agari
76
+ process_ron_agari(state, action, rule)
77
+ when :kyuushu_kyuuhai
78
+ process_kyuushu_kyuuhai(state, action, rule)
79
+ else
80
+ raise Errors::InvalidActionError, "unsupported action: #{action[:type]}"
81
+ end
82
+
83
+ Results::RoundFlowResult.new(
84
+ state: state,
85
+ events: events,
86
+ round_end: state.phase == :round_end,
87
+ round_end_info: state.instance_variable_get(:@round_end_info)
88
+ )
89
+ end
90
+
91
+ # 指定プレイヤーが可能なアクション一覧を取得
92
+ # @param state [RoundState]
93
+ # @param seat [Integer]
94
+ # @param rule [RuleConfig]
95
+ # @return [Array<Hash>]
96
+ def self.available_actions(state:, seat:, rule:)
97
+ case state.phase
98
+ when :tsumo
99
+ []
100
+ when :dahai
101
+ return [] unless state.current_seat == seat
102
+
103
+ actions = if state.riichi_flags[seat]
104
+ []
105
+ else
106
+ state.hands[seat].tiles.uniq(&:to_s).map { |tile| { type: :dahai, tile: tile.to_s } }
107
+ end
108
+
109
+ if !state.riichi_flags[seat] && can_riichi?(state, seat, rule)
110
+ choices = riichi_dahai_candidates(state, seat)
111
+ actions << { type: :riichi, choices: choices } if choices.any?
112
+ end
113
+
114
+ if can_tsumo_agari?(state, seat, rule)
115
+ actions << { type: :tsumo_agari }
116
+ end
117
+
118
+ unless state.riichi_flags[seat]
119
+ ankan_candidates(state, seat, rule).each do |tile|
120
+ actions << { type: :ankan, tile: tile }
121
+ end
122
+
123
+ kakan_candidates(state, seat).each do |tile|
124
+ actions << { type: :kakan, tile: tile }
125
+ end
126
+ end
127
+
128
+ actions << { type: :kyuushu_kyuuhai } if !state.riichi_flags[seat] && can_kyuushu?(state, seat)
129
+ actions
130
+ when :naki_wait
131
+ return [] if state.current_seat == seat
132
+ return [] unless state.pending_actions.key?(seat)
133
+
134
+ pending = state.pending_actions[seat]
135
+ return pending.select { |action| action[:type].to_sym == :ron_agari } if state.riichi_flags[seat]
136
+
137
+ normalized = []
138
+ chi_actions, other_actions = pending.partition { |action| action[:type].to_sym == :chi }
139
+
140
+ normalized.concat(other_actions)
141
+ if chi_actions.any?
142
+ chi_choices = chi_actions.map { |action| Array(action[:tiles]).map(&:to_s) }.uniq
143
+ normalized << if chi_choices.one?
144
+ { type: :chi, tiles: chi_choices.first }
145
+ else
146
+ { type: :chi, choices: chi_choices }
147
+ end
148
+ end
149
+
150
+ normalized + [{ type: :skip }]
151
+ else
152
+ []
153
+ end
154
+ end
155
+
156
+ def self.process_tsumo(state, _action, _rule)
157
+ clear_agari_flags(state)
158
+
159
+ if state.wall.remaining <= 0
160
+ state.phase = :round_end
161
+ set_round_end_info(state, result_type: :ryuukyoku)
162
+ return [{ type: :ryuukyoku }]
163
+ end
164
+
165
+ tile = state.wall.draw!
166
+ seat = state.current_seat
167
+
168
+ state.hands[seat] = state.hands[seat].add(tile)
169
+ state.last_tsumo = tile
170
+ state.phase = :dahai
171
+ state.junme += 1 if seat == state.oya_seat
172
+
173
+ [{ type: :tsumo, seat: seat, tile: tile.to_s }]
174
+ end
175
+
176
+ def self.process_kyuushu_kyuuhai(state, action, _rule)
177
+ state.phase = :round_end
178
+ set_round_end_info(state, result_type: :kyuushu_kyuuhai, seat: action[:seat])
179
+
180
+ [{ type: :kyuushu_kyuuhai, seat: action[:seat] }]
181
+ end
182
+
183
+ def self.process_dahai(state, action, rule)
184
+ seat = state.current_seat
185
+ tile = Tiles::Tile.new(action[:tile])
186
+ tsumogiri = state.last_tsumo && tile.same_kind?(state.last_tsumo) && state.hands[seat].count_of(tile) <= 1
187
+
188
+ state.hands[seat] = state.hands[seat].remove(tile)
189
+ state.kawas[seat].add(tile, tsumogiri: tsumogiri, riichi: action[:riichi] || false)
190
+ state.last_dahai = { seat: seat, tile: tile }
191
+ state.last_tsumo = nil
192
+ clear_agari_flags(state)
193
+ 4.times { |s| state.first_turn_flags[s] = false }
194
+
195
+ naki_options = Detectors::NakiDetector.detect(state: state, dahai_seat: seat, dahai_tile: tile, rule: rule)
196
+ if naki_options.any?
197
+ state.phase = :naki_wait
198
+ state.pending_actions = naki_options
199
+ else
200
+ advance_turn(state)
201
+ end
202
+
203
+ [{ type: :dahai, seat: seat, tile: tile.to_s, tsumogiri: tsumogiri }]
204
+ end
205
+
206
+ def self.process_skip(state, action, rule)
207
+ seat = action[:seat]
208
+ state.pending_actions.delete(seat)
209
+ agari_tile = state.chankan_tile || state.last_dahai&.dig(:tile)
210
+
211
+ if agari_tile && can_ron_without_furiten?(state, seat, agari_tile, rule)
212
+ state.dojun_furiten_flags[seat] = true
213
+ state.riichi_furiten_flags[seat] = true if state.riichi_flags[seat]
214
+ end
215
+
216
+ if state.pending_actions.empty?
217
+ if state.chankan_tile
218
+ finalize_kakan(state)
219
+ else
220
+ advance_turn(state)
221
+ end
222
+ end
223
+ [{ type: :skip, seat: seat }]
224
+ end
225
+
226
+ def self.process_pon(state, action, _rule)
227
+ seat = action[:seat]
228
+ called_tile = state.last_dahai[:tile]
229
+ tiles = action[:tiles] || pon_choices(state, seat, called_tile)
230
+
231
+ state.hands[seat] = state.hands[seat].remove_tiles(tiles)
232
+ state.fuuros[seat] << State::Fuuro.new(
233
+ type: :pon,
234
+ tiles: (tiles + [called_tile.to_s]).map { |t| Tiles::Tile.new(t) },
235
+ called_tile: called_tile,
236
+ from_seat: state.last_dahai[:seat]
237
+ )
238
+ state.kawas[state.last_dahai[:seat]].mark_last_called!
239
+ state.current_seat = seat
240
+ state.phase = :dahai
241
+ state.pending_actions = {}
242
+ state.menzen_flags[seat] = false
243
+ state.last_tsumo = nil
244
+ clear_ippatsu_flags(state)
245
+
246
+ [{ type: :pon, seat: seat, tiles: tiles }]
247
+ end
248
+
249
+ def self.process_chi(state, action, _rule)
250
+ seat = action[:seat]
251
+ called_tile = state.last_dahai[:tile]
252
+ tiles = action[:tiles] || action[:choice] || chi_choices(state, seat, called_tile).first
253
+
254
+ state.hands[seat] = state.hands[seat].remove_tiles(tiles)
255
+ state.fuuros[seat] << State::Fuuro.new(
256
+ type: :chi,
257
+ tiles: (tiles + [called_tile.to_s]).map { |t| Tiles::Tile.new(t) }.sort,
258
+ called_tile: called_tile,
259
+ from_seat: state.last_dahai[:seat]
260
+ )
261
+ state.kawas[state.last_dahai[:seat]].mark_last_called!
262
+ state.current_seat = seat
263
+ state.phase = :dahai
264
+ state.pending_actions = {}
265
+ state.menzen_flags[seat] = false
266
+ state.last_tsumo = nil
267
+ clear_ippatsu_flags(state)
268
+
269
+ [{ type: :chi, seat: seat, tiles: tiles }]
270
+ end
271
+
272
+ def self.process_riichi(state, action, rule)
273
+ seat = action[:seat]
274
+ state.riichi_flags[seat] = true
275
+ state.riichi_junme[seat] = state.junme
276
+ state.ippatsu_flags[seat] = true
277
+ state.double_riichi_flags[seat] = state.first_turn_flags[seat]
278
+ state.scores[seat] -= 1000
279
+ state.kyoutaku += 1
280
+
281
+ process_dahai(state, action.merge(riichi: true), rule)
282
+ end
283
+
284
+ def self.process_ankan(state, action, _rule)
285
+ seat = action[:seat]
286
+ tile = Tiles::Tile.new(action[:tile])
287
+ removed = Array.new(4, tile.to_s)
288
+
289
+ state.hands[seat] = state.hands[seat].remove_tiles(removed)
290
+ state.fuuros[seat] << State::Fuuro.new(type: :ankan, tiles: removed.map { Tiles::Tile.new(_1) })
291
+ apply_kan_transition(state, seat, tile, open_kan: false)
292
+
293
+ [{ type: :ankan, seat: seat, tile: tile.to_s }]
294
+ end
295
+
296
+ def self.process_kakan(state, action, rule)
297
+ seat = action[:seat]
298
+ tile = Tiles::Tile.new(action[:tile])
299
+ target_fuuro = state.fuuros[seat].find { |fuuro| fuuro.pon? && fuuro.tiles.all? { |t| t.same_kind?(tile) } }
300
+ raise Errors::InvalidActionError, "pon fuuro not found for kakan" unless target_fuuro
301
+
302
+ state.hands[seat] = state.hands[seat].remove(tile)
303
+ state.fuuros[seat] = state.fuuros[seat].reject { |fuuro| fuuro.equal?(target_fuuro) }
304
+ state.fuuros[seat] << State::Fuuro.new(type: :kakan, tiles: target_fuuro.tiles + [tile], called_tile: target_fuuro.called_tile, from_seat: target_fuuro.from_seat)
305
+ state.current_seat = seat
306
+ state.chankan_tile = tile
307
+ state.last_dahai = { seat: seat, tile: tile }
308
+
309
+ pending_actions = detect_chankan_actions(state, seat, rule)
310
+ if pending_actions.any?
311
+ state.phase = :naki_wait
312
+ state.pending_actions = pending_actions
313
+ else
314
+ finalize_kakan(state)
315
+ end
316
+
317
+ [{ type: :kakan, seat: seat, tile: tile.to_s }]
318
+ end
319
+
320
+ def self.process_daiminkan(state, action, _rule)
321
+ seat = action[:seat]
322
+ tile = state.last_dahai[:tile]
323
+ removed = Array.new(3, tile.to_s)
324
+
325
+ state.hands[seat] = state.hands[seat].remove_tiles(removed)
326
+ state.fuuros[seat] << State::Fuuro.new(type: :daiminkan, tiles: (removed + [tile.to_s]).map { Tiles::Tile.new(_1) }, called_tile: tile, from_seat: state.last_dahai[:seat])
327
+ state.kawas[state.last_dahai[:seat]].mark_last_called!
328
+ state.current_seat = seat
329
+ state.menzen_flags[seat] = false
330
+ apply_kan_transition(state, seat, tile, open_kan: true, keep_turn: true)
331
+
332
+ [{ type: :daiminkan, seat: seat, tile: tile.to_s }]
333
+ end
334
+
335
+ def self.process_tsumo_agari(state, action, _rule)
336
+ seat = action[:seat]
337
+ state.phase = :round_end
338
+ set_round_end_info(state, result_type: :tsumo_agari, winner_seat: seat, rinshan: state.rinshan_flag)
339
+
340
+ [{ type: :tsumo_agari, seat: seat }]
341
+ end
342
+
343
+ def self.process_ron_agari(state, action, _rule)
344
+ seat = action[:seat]
345
+ state.phase = :round_end
346
+ set_round_end_info(
347
+ state,
348
+ result_type: :ron_agari,
349
+ winner_seat: seat,
350
+ loser_seat: state.last_dahai[:seat],
351
+ chankan: !state.chankan_tile.nil?
352
+ )
353
+
354
+ [{ type: :ron_agari, seat: seat, from: state.last_dahai[:seat] }]
355
+ end
356
+
357
+ def self.advance_turn(state)
358
+ if state.wall.remaining <= 0
359
+ state.phase = :round_end
360
+ clear_agari_flags(state)
361
+ set_round_end_info(state, result_type: :ryuukyoku)
362
+ return
363
+ end
364
+
365
+ state.current_seat = (state.current_seat + 1) % 4
366
+ state.phase = :tsumo
367
+ state.dojun_furiten_flags[state.current_seat] = false
368
+ clear_agari_flags(state)
369
+ end
370
+
371
+ def self.can_tsumo_agari?(state, seat, rule)
372
+ agari_tile = state.last_tsumo
373
+ return false unless agari_tile
374
+
375
+ can_agari?(state:, seat:, agari_type: :tsumo, agari_tile:, rule:, extra_flags: { rinshan: state.rinshan_flag })
376
+ end
377
+
378
+ def self.can_ron_agari?(state, seat, agari_tile, rule)
379
+ can_agari?(state:, seat:, agari_type: :ron, agari_tile:, rule:, extra_flags: { chankan: !state.chankan_tile.nil? })
380
+ end
381
+
382
+ def self.can_agari?(state:, seat:, agari_type:, agari_tile:, rule:, extra_flags: {})
383
+ agari_tile = Tiles::Tile.new(agari_tile) unless agari_tile.is_a?(Tiles::Tile)
384
+ full_hand_tiles = agari_type == :tsumo ? state.hands[seat].tiles.dup : state.hands[seat].tiles.dup + [agari_tile]
385
+ tiles_34 = Tiles::TileSet.to_34(full_hand_tiles)
386
+ return false unless Scoring::Judges::AgariJudge.agari?(tiles_34)
387
+
388
+ forms = Scoring::Judges::AgariJudge.all_forms(tiles_34)
389
+ return false if forms.empty?
390
+
391
+ base_hand_tiles = remove_one_tile(full_hand_tiles, agari_tile)
392
+ machi_results = Scoring::Judges::MachiJudge.find_machi(Tiles::TileSet.to_34(base_hand_tiles))
393
+ oya_seat = state.oya_seat || 0
394
+ jikaze = %w[ton nan sha pei][(seat - oya_seat) % 4]
395
+
396
+ forms.any? do |form|
397
+ machi_type = machi_results.find do |result|
398
+ matching_form?(result.agari_form, form) && result.tile_index == Tiles::TileSet.tile_index(agari_tile)
399
+ end&.machi_type
400
+
401
+ context = Scoring::ValueObjects::YakuContext.new(
402
+ agari_form: form,
403
+ agari_tile: agari_tile,
404
+ agari_type: agari_type,
405
+ hand_tiles: full_hand_tiles,
406
+ fuuros: state.fuuros[seat],
407
+ is_menzen: state.menzen_flags[seat],
408
+ jikaze: jikaze,
409
+ bakaze: state.bakaze,
410
+ is_riichi: state.riichi_flags[seat],
411
+ is_double_riichi: state.double_riichi_flags[seat],
412
+ is_ippatsu: state.ippatsu_flags[seat],
413
+ is_first_turn: state.first_turn_flags[seat],
414
+ is_haitei: state.is_haitei?,
415
+ is_houtei: state.is_houtei?,
416
+ is_rinshan: extra_flags[:rinshan] == true,
417
+ is_chankan: extra_flags[:chankan] == true,
418
+ dora_tiles: state.wanpai ? state.wanpai.dora_tiles : [],
419
+ uradora_tiles: state.riichi_flags[seat] && state.wanpai ? state.wanpai.uradora_tiles : [],
420
+ machi_type: machi_type || :tanki,
421
+ rule: rule
422
+ )
423
+
424
+ result = Scoring::Judges::YakuJudge.judge_single(context)
425
+ result.yakuman? || result.total_han.to_i.positive?
426
+ end
427
+ end
428
+
429
+ def self.remove_one_tile(tiles, agari_tile)
430
+ removed = false
431
+ tiles.each_with_object([]) do |tile, array|
432
+ if !removed && tile.same_kind?(agari_tile)
433
+ removed = true
434
+ else
435
+ array << tile
436
+ end
437
+ end
438
+ end
439
+ private_class_method :remove_one_tile
440
+
441
+ def self.matching_form?(left, right)
442
+ return false unless left.type == right.type
443
+ return true unless left.regular?
444
+
445
+ left.parsed_hand.jantou_index == right.parsed_hand.jantou_index &&
446
+ left.parsed_hand.mentsu_notations == right.parsed_hand.mentsu_notations
447
+ end
448
+ private_class_method :matching_form?
449
+
450
+ def self.can_riichi?(state, seat, _rule)
451
+ return false unless state.menzen_flags[seat]
452
+ return false if state.riichi_flags[seat]
453
+ return false unless state.scores[seat] >= 1000
454
+ return false unless state.wall.remaining >= 4
455
+
456
+ riichi_dahai_candidates(state, seat).any?
457
+ end
458
+
459
+ def self.riichi_dahai_candidates(state, seat)
460
+ hand = state.hands[seat]
461
+ hand.tiles.uniq(&:to_s).filter_map do |tile|
462
+ next unless Scoring::Judges::MachiJudge.tenpai?(hand.remove(tile).to_34)
463
+
464
+ tile.to_s
465
+ rescue Errors::TileNotFoundError
466
+ nil
467
+ end
468
+ end
469
+
470
+ def self.can_kyuushu?(state, seat)
471
+ state.first_turn_flags[seat] &&
472
+ state.kawas[seat].count == 0 &&
473
+ Tiles::TileSet.yaochu_kind_count(state.hands[seat].tiles) >= 9
474
+ end
475
+
476
+ def self.can_ron_without_furiten?(state, seat, tile, rule)
477
+ can_ron_agari?(state, seat, tile, rule)
478
+ end
479
+
480
+ def self.pon_choices(state, seat, tile)
481
+ state.hands[seat].tiles.select { |t| t.same_kind?(tile) }.first(2).map(&:to_s)
482
+ end
483
+
484
+ def self.chi_choices(state, seat, tile)
485
+ Detectors::NakiDetector.find_chi_sets(state.hands[seat], tile)
486
+ end
487
+
488
+ def self.ankan_candidates(state, seat, _rule)
489
+ return [] unless kan_available?(state)
490
+
491
+ state.hands[seat].tiles
492
+ .group_by { |tile| [tile.suit, tile.effective_number] }
493
+ .select { |_key, tiles| tiles.size >= 4 }
494
+ .values
495
+ .map { |tiles| tiles.first.to_s }
496
+ end
497
+
498
+ def self.kakan_candidates(state, seat)
499
+ return [] unless kan_available?(state)
500
+
501
+ state.fuuros[seat]
502
+ .select(&:pon?)
503
+ .filter_map do |fuuro|
504
+ tile = fuuro.tiles.first
505
+ tile.to_s if state.hands[seat].count_of(tile) >= 1
506
+ end
507
+ end
508
+
509
+ def self.clear_ippatsu_flags(state)
510
+ state.ippatsu_flags.keys.each { |seat| state.ippatsu_flags[seat] = false }
511
+ end
512
+
513
+ def self.kan_available?(state)
514
+ return true unless state.wanpai
515
+
516
+ state.wanpai&.rinshan_remaining.to_i.positive? && state.wanpai&.dora_revealable?
517
+ end
518
+
519
+ def self.apply_kan_transition(state, seat, tile, open_kan:, keep_turn: false)
520
+ state.current_seat = seat if keep_turn
521
+ state.phase = :dahai
522
+ state.kan_count += 1
523
+ state.kan_by_player[seat] += 1
524
+ state.rinshan_flag = true
525
+ state.chankan_tile = open_kan ? tile : nil
526
+ state.wanpai.reveal_new_dora!
527
+ state.dora_hyouji = state.wanpai.dora_hyouji.dup
528
+ rinshan_tile = state.wanpai.draw_rinshan!
529
+ state.hands[seat] = state.hands[seat].add(rinshan_tile)
530
+ state.last_tsumo = rinshan_tile
531
+ clear_ippatsu_flags(state)
532
+ end
533
+ private_class_method :apply_kan_transition
534
+
535
+ def self.finalize_kakan(state)
536
+ tile = state.chankan_tile
537
+ apply_kan_transition(state, state.current_seat, tile, open_kan: true)
538
+ state.chankan_tile = nil
539
+ state.pending_actions = {}
540
+ end
541
+ private_class_method :finalize_kakan
542
+
543
+ def self.detect_chankan_actions(state, kakan_seat, rule)
544
+ options = {}
545
+
546
+ 4.times do |seat|
547
+ next if seat == kakan_seat
548
+
549
+ next unless can_ron_agari?(state, seat, state.chankan_tile, rule)
550
+
551
+ furiten = Scoring::Judges::FuritenJudge.judge(state: state, seat: seat)
552
+ options[seat] = [{ type: :ron_agari }] unless furiten.furiten?
553
+ end
554
+
555
+ options
556
+ end
557
+ private_class_method :detect_chankan_actions
558
+
559
+ def self.clear_agari_flags(state)
560
+ state.rinshan_flag = false
561
+ state.chankan_tile = nil
562
+ end
563
+ private_class_method :clear_agari_flags
564
+
565
+ def self.set_round_end_info(state, result_type:, winner_seat: nil, loser_seat: nil, **extra)
566
+ winners = winner_seat.nil? ? [] : [{ seat: winner_seat }]
567
+ losers = loser_seat.nil? ? [] : [{ seat: loser_seat }]
568
+
569
+ state.instance_variable_set(
570
+ :@round_end_info,
571
+ {
572
+ type: result_type,
573
+ winner_seat: winner_seat,
574
+ loser_seat: loser_seat,
575
+ result_type: result_type,
576
+ winners: winners,
577
+ losers: losers,
578
+ tenpai_seats: extra[:tenpai_seats] || [],
579
+ draw_reason: extra[:draw_reason],
580
+ score_changes: extra[:score_changes] || {}
581
+ }.merge(extra)
582
+ )
583
+ end
584
+ private_class_method :set_round_end_info
585
+ end
586
+ end
587
+ end
588
+ end
@@ -0,0 +1,110 @@
1
+ module Mahjong
2
+ module Flow
3
+ module Validators
4
+ class ActionValidator
5
+ ValidationResult = Data.define(:valid, :error) do
6
+ def valid?
7
+ valid
8
+ end
9
+ end
10
+
11
+ def self.valid?(state:, action:, rule:)
12
+ validate(state:, action:, rule:).valid?
13
+ end
14
+
15
+ def self.validate(state:, action:, rule:)
16
+ type = action[:type]&.to_sym
17
+ return invalid("action type is required") unless type
18
+
19
+ case type
20
+ when :tsumo
21
+ return invalid("phase must be tsumo") unless state.phase == :tsumo
22
+ valid
23
+ when :dahai
24
+ return invalid("phase must be dahai") unless state.phase == :dahai
25
+ return invalid("seat mismatch") unless action[:seat] == state.current_seat
26
+ return invalid("tile is required") if missing_value?(action[:tile])
27
+ return invalid("tile not in hand") unless state.hands[state.current_seat].include?(action[:tile])
28
+ valid
29
+ when :skip
30
+ return invalid("phase must be naki_wait") unless state.phase == :naki_wait
31
+ return invalid("seat is required") if action[:seat].nil?
32
+ return invalid("seat has no pending action") unless state.pending_actions.key?(action[:seat])
33
+ valid
34
+ when :pon, :chi
35
+ return invalid("phase must be naki_wait") unless state.phase == :naki_wait
36
+ return invalid("seat is required") if action[:seat].nil?
37
+ return invalid("seat has no pending action") unless state.pending_actions.key?(action[:seat])
38
+ return invalid("riichi player cannot call") if state.riichi_flags[action[:seat]]
39
+ return invalid("action is not pending") unless state.pending_actions[action[:seat]].any? { |a| a[:type].to_sym == type }
40
+ return invalid("higher priority action exists") unless highest_priority_action?(state, action)
41
+ valid
42
+ when :riichi
43
+ return invalid("phase must be dahai") unless state.phase == :dahai
44
+ return invalid("seat mismatch") unless action[:seat] == state.current_seat
45
+ return invalid("tile is required") if missing_value?(action[:tile])
46
+ return invalid("cannot riichi") unless Rounds::RoundFlow.can_riichi?(state, state.current_seat, rule)
47
+ return invalid("tile is not riichi candidate") unless Rounds::RoundFlow.riichi_dahai_candidates(state, state.current_seat).include?(action[:tile])
48
+ valid
49
+ when :ankan
50
+ return invalid("phase must be dahai") unless state.phase == :dahai
51
+ return invalid("seat mismatch") unless action[:seat] == state.current_seat
52
+ return invalid("tile is required") if missing_value?(action[:tile])
53
+ return invalid("cannot ankan") unless Rounds::RoundFlow.ankan_candidates(state, state.current_seat, rule).include?(action[:tile])
54
+ valid
55
+ when :kakan
56
+ return invalid("phase must be dahai") unless state.phase == :dahai
57
+ return invalid("seat mismatch") unless action[:seat] == state.current_seat
58
+ return invalid("tile is required") if missing_value?(action[:tile])
59
+ return invalid("cannot kakan") unless Rounds::RoundFlow.kakan_candidates(state, state.current_seat).include?(action[:tile])
60
+ valid
61
+ when :daiminkan, :ron_agari
62
+ return invalid("phase must be naki_wait") unless state.phase == :naki_wait
63
+ return invalid("seat is required") if action[:seat].nil?
64
+ return invalid("seat has no pending action") unless state.pending_actions.key?(action[:seat])
65
+ return invalid("riichi player cannot call") if type == :daiminkan && state.riichi_flags[action[:seat]]
66
+ return invalid("action is not pending") unless state.pending_actions[action[:seat]].any? { |a| a[:type].to_sym == type }
67
+ return invalid("higher priority action exists") unless highest_priority_action?(state, action)
68
+ if type == :ron_agari
69
+ agari_tile = state.chankan_tile || state.last_dahai&.dig(:tile)
70
+ return invalid("cannot ron agari") unless agari_tile && Rounds::RoundFlow.can_ron_agari?(state, action[:seat], agari_tile, rule)
71
+ end
72
+ valid
73
+ when :tsumo_agari
74
+ return invalid("phase must be dahai") unless state.phase == :dahai
75
+ return invalid("seat mismatch") unless action[:seat] == state.current_seat
76
+ return invalid("cannot tsumo agari") unless Rounds::RoundFlow.can_tsumo_agari?(state, state.current_seat, rule)
77
+ valid
78
+ when :kyuushu_kyuuhai
79
+ return invalid("phase must be dahai") unless state.phase == :dahai
80
+ return invalid("seat mismatch") unless action[:seat] == state.current_seat
81
+ return invalid("cannot kyuushu_kyuuhai") unless Rounds::RoundFlow.can_kyuushu?(state, state.current_seat)
82
+ valid
83
+ else
84
+ invalid("unsupported action: #{type}")
85
+ end
86
+ end
87
+
88
+ def self.valid
89
+ ValidationResult.new(valid: true, error: nil)
90
+ end
91
+
92
+ def self.invalid(message)
93
+ ValidationResult.new(valid: false, error: message)
94
+ end
95
+
96
+ def self.highest_priority_action?(state, action)
97
+ resolved = Resolvers::ActionResolver.resolve(state.pending_actions)
98
+ return false unless resolved
99
+
100
+ resolved[:seat] == action[:seat] && resolved[:type].to_sym == action[:type].to_sym
101
+ end
102
+
103
+ def self.missing_value?(value)
104
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
105
+ end
106
+ private_class_method :missing_value?
107
+ end
108
+ end
109
+ end
110
+ end