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,50 @@
|
|
|
1
|
+
module Mahjong
|
|
2
|
+
module Cpu
|
|
3
|
+
module Ai
|
|
4
|
+
class BaseAi
|
|
5
|
+
attr_reader :level
|
|
6
|
+
|
|
7
|
+
def initialize(level:)
|
|
8
|
+
@level = level
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def think(state:, seat:, available:, rule:)
|
|
12
|
+
return nil if available.empty?
|
|
13
|
+
|
|
14
|
+
case state.phase
|
|
15
|
+
when :dahai
|
|
16
|
+
think_dahai(state, seat, available, rule)
|
|
17
|
+
when :naki_wait
|
|
18
|
+
think_naki(state, seat, available, rule)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def think_dahai(state, seat, available, _rule)
|
|
25
|
+
agari = available.find { |action| action[:type] == :tsumo_agari }
|
|
26
|
+
return agari if agari
|
|
27
|
+
|
|
28
|
+
riichi = available.find { |action| action[:type] == :riichi }
|
|
29
|
+
if riichi && Judges::RiichiJudge.should_riichi?(state: state, seat: seat, level: level)
|
|
30
|
+
tile = riichi[:choices]&.first || state.last_tsumo.to_s
|
|
31
|
+
return { type: :riichi, tile: tile }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ankan = available.find { |action| action[:type] == :ankan }
|
|
35
|
+
return ankan if ankan && should_ankan?(state, seat)
|
|
36
|
+
|
|
37
|
+
{ type: :dahai, tile: Selectors::DahaiSelector.select(hand: state.hands[seat], state: state, level: level) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def think_naki(state, seat, available, _rule)
|
|
41
|
+
Judges::NakiJudge.judge(naki_options: available, state: state, seat: seat, level: level) || { type: :skip }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def should_ankan?(_state, _seat)
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module Mahjong
|
|
2
|
+
module Cpu
|
|
3
|
+
module Analysis
|
|
4
|
+
class Shanten
|
|
5
|
+
def self.calculate(tiles_34)
|
|
6
|
+
[
|
|
7
|
+
regular_shanten(tiles_34.dup),
|
|
8
|
+
chiitoitsu_shanten(tiles_34),
|
|
9
|
+
kokushi_shanten(tiles_34)
|
|
10
|
+
].min
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.regular_shanten(tiles_34)
|
|
14
|
+
@min_shanten = 8
|
|
15
|
+
explore(tiles_34, 0, 0, 0, 0)
|
|
16
|
+
@min_shanten
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.chiitoitsu_shanten(tiles_34)
|
|
20
|
+
pairs = tiles_34.count { |count| count >= 2 }
|
|
21
|
+
kinds = tiles_34.count(&:positive?)
|
|
22
|
+
|
|
23
|
+
shanten = 6 - pairs
|
|
24
|
+
shanten += [7 - kinds, 0].max if kinds < 7
|
|
25
|
+
shanten
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.kokushi_shanten(tiles_34)
|
|
29
|
+
kinds = Tiles::TileSet::YAOCHU_INDICES.count { |i| tiles_34[i].positive? }
|
|
30
|
+
pairs = Tiles::TileSet::YAOCHU_INDICES.count { |i| tiles_34[i] >= 2 }
|
|
31
|
+
|
|
32
|
+
13 - kinds - (pairs.positive? ? 1 : 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.explore(tiles_34, index, mentsu, taatsu, pair)
|
|
36
|
+
while index < 34 && tiles_34[index].zero?
|
|
37
|
+
index += 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if index >= 34
|
|
41
|
+
taatsu = [taatsu, 4 - mentsu].min
|
|
42
|
+
shanten = 8 - (mentsu * 2) - taatsu - pair
|
|
43
|
+
@min_shanten = [@min_shanten, shanten].min
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if tiles_34[index] >= 3
|
|
48
|
+
tiles_34[index] -= 3
|
|
49
|
+
explore(tiles_34, index, mentsu + 1, taatsu, pair)
|
|
50
|
+
tiles_34[index] += 3
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if sequence_start?(tiles_34, index)
|
|
54
|
+
tiles_34[index] -= 1
|
|
55
|
+
tiles_34[index + 1] -= 1
|
|
56
|
+
tiles_34[index + 2] -= 1
|
|
57
|
+
explore(tiles_34, index, mentsu + 1, taatsu, pair)
|
|
58
|
+
tiles_34[index] += 1
|
|
59
|
+
tiles_34[index + 1] += 1
|
|
60
|
+
tiles_34[index + 2] += 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if pair.zero? && tiles_34[index] >= 2
|
|
64
|
+
tiles_34[index] -= 2
|
|
65
|
+
explore(tiles_34, index, mentsu, taatsu, 1)
|
|
66
|
+
tiles_34[index] += 2
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if taatsu < 4
|
|
70
|
+
if tiles_34[index] >= 2
|
|
71
|
+
tiles_34[index] -= 2
|
|
72
|
+
explore(tiles_34, index, mentsu, taatsu + 1, pair)
|
|
73
|
+
tiles_34[index] += 2
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if iipeikou_wait?(tiles_34, index)
|
|
77
|
+
tiles_34[index] -= 1
|
|
78
|
+
tiles_34[index + 1] -= 1
|
|
79
|
+
explore(tiles_34, index, mentsu, taatsu + 1, pair)
|
|
80
|
+
tiles_34[index] += 1
|
|
81
|
+
tiles_34[index + 1] += 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if kanchan_wait?(tiles_34, index)
|
|
85
|
+
tiles_34[index] -= 1
|
|
86
|
+
tiles_34[index + 2] -= 1
|
|
87
|
+
explore(tiles_34, index, mentsu, taatsu + 1, pair)
|
|
88
|
+
tiles_34[index] += 1
|
|
89
|
+
tiles_34[index + 2] += 1
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
tiles_34[index] -= 1
|
|
94
|
+
explore(tiles_34, index, mentsu, taatsu, pair)
|
|
95
|
+
tiles_34[index] += 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.sequence_start?(tiles_34, index)
|
|
99
|
+
index < 27 && (index % 9) <= 6 && tiles_34[index + 1].positive? && tiles_34[index + 2].positive?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.iipeikou_wait?(tiles_34, index)
|
|
103
|
+
index < 27 && (index % 9) <= 7 && tiles_34[index + 1].positive?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.kanchan_wait?(tiles_34, index)
|
|
107
|
+
index < 27 && (index % 9) <= 6 && tiles_34[index + 2].positive?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Mahjong
|
|
2
|
+
module Cpu
|
|
3
|
+
module Analysis
|
|
4
|
+
EvalResult = Data.define(:tile, :shanten, :ukeire)
|
|
5
|
+
|
|
6
|
+
class TileEvaluator
|
|
7
|
+
def self.evaluate_all_discards(hand, visible_tiles: [])
|
|
8
|
+
results = {}
|
|
9
|
+
remaining = build_remaining_counts(visible_tiles + hand.tiles)
|
|
10
|
+
|
|
11
|
+
hand.tiles.map(&:to_s).uniq.each do |tile_str|
|
|
12
|
+
new_hand = hand.remove(Tiles::Tile.new(tile_str))
|
|
13
|
+
tiles_34 = new_hand.to_34
|
|
14
|
+
shanten = Shanten.calculate(tiles_34)
|
|
15
|
+
ukeire = count_ukeire(tiles_34, shanten, remaining)
|
|
16
|
+
|
|
17
|
+
results[tile_str] = EvalResult.new(
|
|
18
|
+
tile: tile_str,
|
|
19
|
+
shanten: shanten,
|
|
20
|
+
ukeire: ukeire
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
results
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.count_ukeire(tiles_34, current_shanten, remaining)
|
|
28
|
+
count = 0
|
|
29
|
+
|
|
30
|
+
34.times do |i|
|
|
31
|
+
next if tiles_34[i] >= 4
|
|
32
|
+
next if remaining[i] <= 0
|
|
33
|
+
|
|
34
|
+
tiles_34[i] += 1
|
|
35
|
+
count += remaining[i] if Shanten.calculate(tiles_34) < current_shanten
|
|
36
|
+
tiles_34[i] -= 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
count
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.build_remaining_counts(visible_tiles)
|
|
43
|
+
counts = Array.new(34, 4)
|
|
44
|
+
|
|
45
|
+
visible_tiles.each do |tile|
|
|
46
|
+
counts[Tiles::TileSet.tile_index(tile)] -= 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
counts
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Mahjong
|
|
2
|
+
module Cpu
|
|
3
|
+
module Judges
|
|
4
|
+
class NakiJudge
|
|
5
|
+
def self.judge(naki_options:, state:, seat:, level:)
|
|
6
|
+
ron = naki_options.find { |option| option[:type] == :ron_agari }
|
|
7
|
+
return ron if ron
|
|
8
|
+
|
|
9
|
+
case level
|
|
10
|
+
when 1 then judge_easy(naki_options)
|
|
11
|
+
when 2 then judge_normal(naki_options, state, seat)
|
|
12
|
+
when 3 then judge_hard(naki_options, state, seat)
|
|
13
|
+
else judge_normal(naki_options, state, seat)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private_class_method def self.judge_easy(options)
|
|
18
|
+
pon = options.find { |option| option[:type] == :pon }
|
|
19
|
+
return nil unless pon
|
|
20
|
+
|
|
21
|
+
tile = Tiles::Tile.new(pon[:tiles].first)
|
|
22
|
+
tile.sangenpai? ? pon : nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private_class_method def self.judge_normal(options, state, seat)
|
|
26
|
+
hand = state.hands[seat]
|
|
27
|
+
current_shanten = Analysis::Shanten.calculate(hand.to_34)
|
|
28
|
+
|
|
29
|
+
options.each do |option|
|
|
30
|
+
next if option[:type] == :skip
|
|
31
|
+
next if should_keep_menzen?(hand, current_shanten)
|
|
32
|
+
|
|
33
|
+
return option if simulate_naki_shanten(hand, option) < current_shanten
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method def self.judge_hard(options, state, seat)
|
|
40
|
+
judge_normal(options, state, seat)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method def self.simulate_naki_shanten(hand, naki_option)
|
|
44
|
+
tiles = Array(naki_option[:tiles]).map { |tile| Tiles::Tile.new(tile) }
|
|
45
|
+
|
|
46
|
+
case naki_option[:type]
|
|
47
|
+
when :pon, :chi
|
|
48
|
+
Analysis::Shanten.calculate(hand.remove_tiles(tiles).to_34) - 2
|
|
49
|
+
else
|
|
50
|
+
Analysis::Shanten.calculate(hand.to_34)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method def self.should_keep_menzen?(_hand, shanten)
|
|
55
|
+
shanten <= 1
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Mahjong
|
|
2
|
+
module Cpu
|
|
3
|
+
module Judges
|
|
4
|
+
class RiichiJudge
|
|
5
|
+
def self.should_riichi?(state:, seat:, level:)
|
|
6
|
+
return false unless state.menzen_flags[seat]
|
|
7
|
+
return false if state.riichi_flags[seat]
|
|
8
|
+
return false if state.scores[seat] < 1000
|
|
9
|
+
return false if state.wall.remaining < 4
|
|
10
|
+
return false unless Scoring::Judges::MachiJudge.tenpai?(state.hands[seat].to_34)
|
|
11
|
+
|
|
12
|
+
case level
|
|
13
|
+
when 1
|
|
14
|
+
true
|
|
15
|
+
when 2
|
|
16
|
+
Scoring::Judges::MachiJudge.machi_tile_indices(state.hands[seat].to_34).size >= 2
|
|
17
|
+
when 3
|
|
18
|
+
should_riichi_hard?(state, seat)
|
|
19
|
+
else
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private_class_method def self.should_riichi_hard?(state, seat)
|
|
25
|
+
machi_indices = Scoring::Judges::MachiJudge.machi_tile_indices(state.hands[seat].to_34)
|
|
26
|
+
visible = Selectors::DahaiSelector.send(:collect_visible_tiles, state)
|
|
27
|
+
remaining = Analysis::TileEvaluator.build_remaining_counts(visible)
|
|
28
|
+
live_machi_count = machi_indices.sum { |i| remaining[i] }
|
|
29
|
+
remaining_turns = state.wall.remaining / 4
|
|
30
|
+
|
|
31
|
+
return true if live_machi_count >= 4 && remaining_turns >= 5
|
|
32
|
+
|
|
33
|
+
other_riichi = (0..3).count { |other_seat| other_seat != seat && state.riichi_flags[other_seat] }
|
|
34
|
+
return false if other_riichi >= 2
|
|
35
|
+
return true if live_machi_count >= 6
|
|
36
|
+
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Mahjong
|
|
2
|
+
module Cpu
|
|
3
|
+
module Selectors
|
|
4
|
+
class DahaiSelector
|
|
5
|
+
def self.select(hand:, state:, level:)
|
|
6
|
+
visible = collect_visible_tiles(state)
|
|
7
|
+
evals = Analysis::TileEvaluator.evaluate_all_discards(hand, visible_tiles: visible)
|
|
8
|
+
|
|
9
|
+
case level
|
|
10
|
+
when 1 then select_easy(evals, hand)
|
|
11
|
+
when 2 then select_normal(evals, hand, state)
|
|
12
|
+
when 3 then select_hard(evals, hand, state)
|
|
13
|
+
else select_normal(evals, hand, state)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private_class_method def self.select_easy(evals, hand)
|
|
18
|
+
min_shanten = evals.values.map(&:shanten).min
|
|
19
|
+
candidates = evals.select { |_tile, result| result.shanten == min_shanten }
|
|
20
|
+
|
|
21
|
+
sorted = candidates.sort_by do |tile_str, result|
|
|
22
|
+
tile = Tiles::Tile.new(tile_str)
|
|
23
|
+
[
|
|
24
|
+
tile.yaochu? ? 0 : 1,
|
|
25
|
+
result.ukeire,
|
|
26
|
+
tile.to_s
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sorted.first&.first || hand.tiles.last.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method def self.select_normal(evals, hand, state)
|
|
34
|
+
min_shanten = evals.values.map(&:shanten).min
|
|
35
|
+
candidates = evals.select { |_tile, result| result.shanten == min_shanten }
|
|
36
|
+
|
|
37
|
+
sorted = candidates.sort_by do |tile_str, result|
|
|
38
|
+
[
|
|
39
|
+
result.ukeire,
|
|
40
|
+
danger_score(Tiles::Tile.new(tile_str), state),
|
|
41
|
+
tile_str
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sorted.first&.first || hand.tiles.last.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method def self.select_hard(evals, hand, state)
|
|
49
|
+
min_shanten = evals.values.map(&:shanten).min
|
|
50
|
+
return select_normal(evals, hand, state) if min_shanten <= 1
|
|
51
|
+
|
|
52
|
+
riichi_seats = (0..3).select { |seat| state.riichi_flags[seat] && seat != state.current_seat }
|
|
53
|
+
return select_defensive(evals, hand, state, riichi_seats) if riichi_seats.any?
|
|
54
|
+
|
|
55
|
+
select_normal(evals, hand, state)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method def self.select_defensive(evals, hand, state, riichi_seats)
|
|
59
|
+
genbutsu = riichi_seats.flat_map do |seat|
|
|
60
|
+
state.kawas[seat].tiles.map { |kawa_tile| kawa_tile.tile.to_s }
|
|
61
|
+
end.uniq
|
|
62
|
+
|
|
63
|
+
min_shanten = evals.values.map(&:shanten).min
|
|
64
|
+
safe_candidates = evals.select do |tile_str, result|
|
|
65
|
+
genbutsu.include?(tile_str) && result.shanten <= min_shanten + 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if safe_candidates.any?
|
|
69
|
+
return safe_candidates.min_by { |_tile, result| [result.shanten, -result.ukeire] }.first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
select_normal(evals, hand, state)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private_class_method def self.danger_score(tile, _state)
|
|
76
|
+
return 0 if tile.jihai?
|
|
77
|
+
return 1 if tile.routou?
|
|
78
|
+
return 3 if tile.effective_number.between?(4, 6)
|
|
79
|
+
|
|
80
|
+
2
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private_class_method def self.collect_visible_tiles(state)
|
|
84
|
+
visible = []
|
|
85
|
+
|
|
86
|
+
4.times do |seat|
|
|
87
|
+
state.kawas[seat].tiles.each { |kawa_tile| visible << kawa_tile.tile }
|
|
88
|
+
state.fuuros[seat].each { |fuuro| visible.concat(fuuro.tiles) }
|
|
89
|
+
end
|
|
90
|
+
state.dora_hyouji.each { |tile| visible << tile }
|
|
91
|
+
|
|
92
|
+
visible
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Mahjong
|
|
2
|
+
module Flow
|
|
3
|
+
module Detectors
|
|
4
|
+
class NakiDetector
|
|
5
|
+
def self.detect(state:, dahai_seat:, rule:, dahai_tile: nil)
|
|
6
|
+
target_tile = dahai_tile || state.last_dahai&.dig(:tile)
|
|
7
|
+
return {} unless target_tile
|
|
8
|
+
|
|
9
|
+
target_tile = Tiles::Tile.new(target_tile) unless target_tile.is_a?(Tiles::Tile)
|
|
10
|
+
options = {}
|
|
11
|
+
|
|
12
|
+
4.times do |seat|
|
|
13
|
+
next if seat == dahai_seat
|
|
14
|
+
|
|
15
|
+
seat_options = []
|
|
16
|
+
hand = state.hands[seat]
|
|
17
|
+
next unless hand
|
|
18
|
+
|
|
19
|
+
test_hand_34 = hand.to_34
|
|
20
|
+
test_hand_34[Tiles::TileSet.tile_index(target_tile)] += 1
|
|
21
|
+
|
|
22
|
+
if Rounds::RoundFlow.can_ron_agari?(state, seat, target_tile, rule)
|
|
23
|
+
furiten = Scoring::Judges::FuritenJudge.judge(state: state, seat: seat)
|
|
24
|
+
seat_options << { type: :ron_agari } unless furiten.furiten?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if state.wall && state.wall.remaining <= 0
|
|
28
|
+
options[seat] = seat_options if seat_options.any?
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if state.riichi_flags[seat]
|
|
33
|
+
options[seat] = seat_options if seat_options.any?
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
seat_options << { type: :daiminkan } if kan_available?(state) && hand.count_of(target_tile) >= 3
|
|
38
|
+
seat_options << { type: :pon, tiles: find_pon_tiles(hand, target_tile) } if hand.count_of(target_tile) >= 2
|
|
39
|
+
|
|
40
|
+
if (dahai_seat + 1) % 4 == seat && target_tile.suuhai?
|
|
41
|
+
find_chi_sets(hand, target_tile).each do |tiles|
|
|
42
|
+
seat_options << { type: :chi, tiles: tiles }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
options[seat] = seat_options unless seat_options.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
options
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.kan_available?(state)
|
|
53
|
+
return true unless state.wanpai
|
|
54
|
+
|
|
55
|
+
state.wanpai&.rinshan_remaining.to_i.positive? && state.wanpai&.dora_revealable?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.find_pon_tiles(hand, target)
|
|
59
|
+
hand.tiles.select { |tile| tile.same_kind?(target) }.first(2).map(&:to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.find_chi_sets(hand, target)
|
|
63
|
+
return [] unless target.suuhai?
|
|
64
|
+
|
|
65
|
+
suit = target.suit
|
|
66
|
+
num = target.effective_number
|
|
67
|
+
found = []
|
|
68
|
+
|
|
69
|
+
[[num + 1, num + 2], [num - 1, num + 1], [num - 2, num - 1]].each do |a, b|
|
|
70
|
+
next unless (1..9).cover?(a) && (1..9).cover?(b)
|
|
71
|
+
|
|
72
|
+
t1 = hand.tiles.find { |tile| tile.suit == suit && tile.effective_number == a }
|
|
73
|
+
t2 = hand.tiles.find { |tile| tile.suit == suit && tile.effective_number == b }
|
|
74
|
+
found << [t1.to_s, t2.to_s] if t1 && t2
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
found.uniq
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|