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.
- checksums.yaml +7 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +17 -0
- data/.github/pull_request_template.md +15 -0
- data/.github/workflows/ci.yml +22 -0
- data/.github/workflows/release.yml +31 -0
- data/.gitignore +6 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +17 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/docs/adapter_contract.md +144 -0
- data/docs/api_reference.md +309 -0
- data/docs/concepts.md +134 -0
- data/docs/public_api_policy.md +94 -0
- data/docs/state_machine.md +360 -0
- data/docs/usage_examples.md +265 -0
- data/lib/mahjong/config/rule_config.rb +53 -0
- data/lib/mahjong/cpu/ai/base_ai.rb +50 -0
- data/lib/mahjong/cpu/ai/easy_ai.rb +11 -0
- data/lib/mahjong/cpu/ai/hard_ai.rb +11 -0
- data/lib/mahjong/cpu/ai/normal_ai.rb +11 -0
- data/lib/mahjong/cpu/analysis/shanten.rb +112 -0
- data/lib/mahjong/cpu/analysis/tile_evaluator.rb +54 -0
- data/lib/mahjong/cpu/judges/naki_judge.rb +60 -0
- data/lib/mahjong/cpu/judges/riichi_judge.rb +42 -0
- data/lib/mahjong/cpu/selectors/dahai_selector.rb +97 -0
- data/lib/mahjong/errors/engine_error.rb +5 -0
- data/lib/mahjong/errors/invalid_action_error.rb +5 -0
- data/lib/mahjong/errors/invalid_state_error.rb +5 -0
- data/lib/mahjong/errors/tile_not_found_error.rb +5 -0
- data/lib/mahjong/flow/detectors/naki_detector.rb +82 -0
- data/lib/mahjong/flow/games/game_flow.rb +186 -0
- data/lib/mahjong/flow/resolvers/action_resolver.rb +22 -0
- data/lib/mahjong/flow/rounds/round_flow.rb +588 -0
- data/lib/mahjong/flow/validators/action_validator.rb +110 -0
- data/lib/mahjong/results/final_result.rb +11 -0
- data/lib/mahjong/results/game_flow_result.rb +20 -0
- data/lib/mahjong/results/game_result.rb +26 -0
- data/lib/mahjong/results/round_end_info.rb +18 -0
- data/lib/mahjong/results/round_flow_result.rb +18 -0
- data/lib/mahjong/scoring/calculators/fu_calculator.rb +68 -0
- data/lib/mahjong/scoring/calculators/score_calculator.rb +73 -0
- data/lib/mahjong/scoring/evaluators/win_evaluator.rb +111 -0
- data/lib/mahjong/scoring/judges/agari_judge.rb +68 -0
- data/lib/mahjong/scoring/judges/furiten_judge.rb +34 -0
- data/lib/mahjong/scoring/judges/machi_judge.rb +78 -0
- data/lib/mahjong/scoring/judges/yaku_judge.rb +161 -0
- data/lib/mahjong/scoring/parsers/hand_parser.rb +87 -0
- data/lib/mahjong/scoring/value_objects/score_result.rb +57 -0
- data/lib/mahjong/scoring/value_objects/yaku_context.rb +70 -0
- data/lib/mahjong/scoring/value_objects/yaku_entry.rb +7 -0
- data/lib/mahjong/scoring/value_objects/yaku_judge_result.rb +27 -0
- data/lib/mahjong/scoring/yaku/chankan.rb +6 -0
- data/lib/mahjong/scoring/yaku/chanta.rb +15 -0
- data/lib/mahjong/scoring/yaku/chiihou.rb +7 -0
- data/lib/mahjong/scoring/yaku/chiitoitsu.rb +6 -0
- data/lib/mahjong/scoring/yaku/chinitsu.rb +10 -0
- data/lib/mahjong/scoring/yaku/chinroutou.rb +6 -0
- data/lib/mahjong/scoring/yaku/chuuren_poutou.rb +20 -0
- data/lib/mahjong/scoring/yaku/daisangen.rb +8 -0
- data/lib/mahjong/scoring/yaku/daisuushii.rb +8 -0
- data/lib/mahjong/scoring/yaku/double_riichi.rb +6 -0
- data/lib/mahjong/scoring/yaku/haitei.rb +6 -0
- data/lib/mahjong/scoring/yaku/honitsu.rb +11 -0
- data/lib/mahjong/scoring/yaku/honroutou.rb +9 -0
- data/lib/mahjong/scoring/yaku/houtei.rb +6 -0
- data/lib/mahjong/scoring/yaku/iipeiko.rb +16 -0
- data/lib/mahjong/scoring/yaku/ikkitsuukan.rb +15 -0
- data/lib/mahjong/scoring/yaku/ippatsu.rb +6 -0
- data/lib/mahjong/scoring/yaku/junchan.rb +15 -0
- data/lib/mahjong/scoring/yaku/kokushi_musou.rb +6 -0
- data/lib/mahjong/scoring/yaku/menzen_tsumo.rb +6 -0
- data/lib/mahjong/scoring/yaku/pinfu.rb +16 -0
- data/lib/mahjong/scoring/yaku/riichi.rb +6 -0
- data/lib/mahjong/scoring/yaku/rinshan_kaihou.rb +6 -0
- data/lib/mahjong/scoring/yaku/ryanpeiko.rb +11 -0
- data/lib/mahjong/scoring/yaku/ryuuiisou.rb +6 -0
- data/lib/mahjong/scoring/yaku/san_ankou.rb +15 -0
- data/lib/mahjong/scoring/yaku/san_kantsu.rb +6 -0
- data/lib/mahjong/scoring/yaku/sanshoku_doujun.rb +15 -0
- data/lib/mahjong/scoring/yaku/sanshoku_doukou.rb +14 -0
- data/lib/mahjong/scoring/yaku/shousangen.rb +14 -0
- data/lib/mahjong/scoring/yaku/shousuushii.rb +14 -0
- data/lib/mahjong/scoring/yaku/suuankou.rb +15 -0
- data/lib/mahjong/scoring/yaku/suukantsu.rb +6 -0
- data/lib/mahjong/scoring/yaku/tanyao.rb +7 -0
- data/lib/mahjong/scoring/yaku/tenhou.rb +7 -0
- data/lib/mahjong/scoring/yaku/toitoihou.rb +7 -0
- data/lib/mahjong/scoring/yaku/tsuuiisou.rb +6 -0
- data/lib/mahjong/scoring/yaku/yakuhai.rb +27 -0
- data/lib/mahjong/snapshots/final_result_snapshot.rb +8 -0
- data/lib/mahjong/snapshots/game_progress_snapshot.rb +12 -0
- data/lib/mahjong/snapshots/game_setup_snapshot.rb +12 -0
- data/lib/mahjong/snapshots/win_evaluation_snapshot.rb +11 -0
- data/lib/mahjong/state/fuuro.rb +45 -0
- data/lib/mahjong/state/hand.rb +64 -0
- data/lib/mahjong/state/kawa.rb +68 -0
- data/lib/mahjong/state/mentsu.rb +55 -0
- data/lib/mahjong/state/round_state.rb +193 -0
- data/lib/mahjong/tiles/dora.rb +19 -0
- data/lib/mahjong/tiles/tile.rb +168 -0
- data/lib/mahjong/tiles/tile_set.rb +51 -0
- data/lib/mahjong/tiles/wall.rb +47 -0
- data/lib/mahjong/tiles/wanpai.rb +79 -0
- data/lib/riichi_engine/api.rb +62 -0
- data/lib/riichi_engine/version.rb +3 -0
- data/lib/riichi_engine.rb +15 -0
- data/riichi_engine.gemspec +32 -0
- 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
|