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,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
|