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