riichi_engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +17 -0
  4. data/.github/pull_request_template.md +15 -0
  5. data/.github/workflows/ci.yml +22 -0
  6. data/.github/workflows/release.yml +31 -0
  7. data/.gitignore +6 -0
  8. data/.ruby-version +1 -0
  9. data/CHANGELOG.md +17 -0
  10. data/Gemfile +3 -0
  11. data/LICENSE +21 -0
  12. data/README.md +164 -0
  13. data/docs/adapter_contract.md +144 -0
  14. data/docs/api_reference.md +309 -0
  15. data/docs/concepts.md +134 -0
  16. data/docs/public_api_policy.md +94 -0
  17. data/docs/state_machine.md +360 -0
  18. data/docs/usage_examples.md +265 -0
  19. data/lib/mahjong/config/rule_config.rb +53 -0
  20. data/lib/mahjong/cpu/ai/base_ai.rb +50 -0
  21. data/lib/mahjong/cpu/ai/easy_ai.rb +11 -0
  22. data/lib/mahjong/cpu/ai/hard_ai.rb +11 -0
  23. data/lib/mahjong/cpu/ai/normal_ai.rb +11 -0
  24. data/lib/mahjong/cpu/analysis/shanten.rb +112 -0
  25. data/lib/mahjong/cpu/analysis/tile_evaluator.rb +54 -0
  26. data/lib/mahjong/cpu/judges/naki_judge.rb +60 -0
  27. data/lib/mahjong/cpu/judges/riichi_judge.rb +42 -0
  28. data/lib/mahjong/cpu/selectors/dahai_selector.rb +97 -0
  29. data/lib/mahjong/errors/engine_error.rb +5 -0
  30. data/lib/mahjong/errors/invalid_action_error.rb +5 -0
  31. data/lib/mahjong/errors/invalid_state_error.rb +5 -0
  32. data/lib/mahjong/errors/tile_not_found_error.rb +5 -0
  33. data/lib/mahjong/flow/detectors/naki_detector.rb +82 -0
  34. data/lib/mahjong/flow/games/game_flow.rb +186 -0
  35. data/lib/mahjong/flow/resolvers/action_resolver.rb +22 -0
  36. data/lib/mahjong/flow/rounds/round_flow.rb +588 -0
  37. data/lib/mahjong/flow/validators/action_validator.rb +110 -0
  38. data/lib/mahjong/results/final_result.rb +11 -0
  39. data/lib/mahjong/results/game_flow_result.rb +20 -0
  40. data/lib/mahjong/results/game_result.rb +26 -0
  41. data/lib/mahjong/results/round_end_info.rb +18 -0
  42. data/lib/mahjong/results/round_flow_result.rb +18 -0
  43. data/lib/mahjong/scoring/calculators/fu_calculator.rb +68 -0
  44. data/lib/mahjong/scoring/calculators/score_calculator.rb +73 -0
  45. data/lib/mahjong/scoring/evaluators/win_evaluator.rb +111 -0
  46. data/lib/mahjong/scoring/judges/agari_judge.rb +68 -0
  47. data/lib/mahjong/scoring/judges/furiten_judge.rb +34 -0
  48. data/lib/mahjong/scoring/judges/machi_judge.rb +78 -0
  49. data/lib/mahjong/scoring/judges/yaku_judge.rb +161 -0
  50. data/lib/mahjong/scoring/parsers/hand_parser.rb +87 -0
  51. data/lib/mahjong/scoring/value_objects/score_result.rb +57 -0
  52. data/lib/mahjong/scoring/value_objects/yaku_context.rb +70 -0
  53. data/lib/mahjong/scoring/value_objects/yaku_entry.rb +7 -0
  54. data/lib/mahjong/scoring/value_objects/yaku_judge_result.rb +27 -0
  55. data/lib/mahjong/scoring/yaku/chankan.rb +6 -0
  56. data/lib/mahjong/scoring/yaku/chanta.rb +15 -0
  57. data/lib/mahjong/scoring/yaku/chiihou.rb +7 -0
  58. data/lib/mahjong/scoring/yaku/chiitoitsu.rb +6 -0
  59. data/lib/mahjong/scoring/yaku/chinitsu.rb +10 -0
  60. data/lib/mahjong/scoring/yaku/chinroutou.rb +6 -0
  61. data/lib/mahjong/scoring/yaku/chuuren_poutou.rb +20 -0
  62. data/lib/mahjong/scoring/yaku/daisangen.rb +8 -0
  63. data/lib/mahjong/scoring/yaku/daisuushii.rb +8 -0
  64. data/lib/mahjong/scoring/yaku/double_riichi.rb +6 -0
  65. data/lib/mahjong/scoring/yaku/haitei.rb +6 -0
  66. data/lib/mahjong/scoring/yaku/honitsu.rb +11 -0
  67. data/lib/mahjong/scoring/yaku/honroutou.rb +9 -0
  68. data/lib/mahjong/scoring/yaku/houtei.rb +6 -0
  69. data/lib/mahjong/scoring/yaku/iipeiko.rb +16 -0
  70. data/lib/mahjong/scoring/yaku/ikkitsuukan.rb +15 -0
  71. data/lib/mahjong/scoring/yaku/ippatsu.rb +6 -0
  72. data/lib/mahjong/scoring/yaku/junchan.rb +15 -0
  73. data/lib/mahjong/scoring/yaku/kokushi_musou.rb +6 -0
  74. data/lib/mahjong/scoring/yaku/menzen_tsumo.rb +6 -0
  75. data/lib/mahjong/scoring/yaku/pinfu.rb +16 -0
  76. data/lib/mahjong/scoring/yaku/riichi.rb +6 -0
  77. data/lib/mahjong/scoring/yaku/rinshan_kaihou.rb +6 -0
  78. data/lib/mahjong/scoring/yaku/ryanpeiko.rb +11 -0
  79. data/lib/mahjong/scoring/yaku/ryuuiisou.rb +6 -0
  80. data/lib/mahjong/scoring/yaku/san_ankou.rb +15 -0
  81. data/lib/mahjong/scoring/yaku/san_kantsu.rb +6 -0
  82. data/lib/mahjong/scoring/yaku/sanshoku_doujun.rb +15 -0
  83. data/lib/mahjong/scoring/yaku/sanshoku_doukou.rb +14 -0
  84. data/lib/mahjong/scoring/yaku/shousangen.rb +14 -0
  85. data/lib/mahjong/scoring/yaku/shousuushii.rb +14 -0
  86. data/lib/mahjong/scoring/yaku/suuankou.rb +15 -0
  87. data/lib/mahjong/scoring/yaku/suukantsu.rb +6 -0
  88. data/lib/mahjong/scoring/yaku/tanyao.rb +7 -0
  89. data/lib/mahjong/scoring/yaku/tenhou.rb +7 -0
  90. data/lib/mahjong/scoring/yaku/toitoihou.rb +7 -0
  91. data/lib/mahjong/scoring/yaku/tsuuiisou.rb +6 -0
  92. data/lib/mahjong/scoring/yaku/yakuhai.rb +27 -0
  93. data/lib/mahjong/snapshots/final_result_snapshot.rb +8 -0
  94. data/lib/mahjong/snapshots/game_progress_snapshot.rb +12 -0
  95. data/lib/mahjong/snapshots/game_setup_snapshot.rb +12 -0
  96. data/lib/mahjong/snapshots/win_evaluation_snapshot.rb +11 -0
  97. data/lib/mahjong/state/fuuro.rb +45 -0
  98. data/lib/mahjong/state/hand.rb +64 -0
  99. data/lib/mahjong/state/kawa.rb +68 -0
  100. data/lib/mahjong/state/mentsu.rb +55 -0
  101. data/lib/mahjong/state/round_state.rb +193 -0
  102. data/lib/mahjong/tiles/dora.rb +19 -0
  103. data/lib/mahjong/tiles/tile.rb +168 -0
  104. data/lib/mahjong/tiles/tile_set.rb +51 -0
  105. data/lib/mahjong/tiles/wall.rb +47 -0
  106. data/lib/mahjong/tiles/wanpai.rb +79 -0
  107. data/lib/riichi_engine/api.rb +62 -0
  108. data/lib/riichi_engine/version.rb +3 -0
  109. data/lib/riichi_engine.rb +15 -0
  110. data/riichi_engine.gemspec +32 -0
  111. metadata +207 -0
@@ -0,0 +1,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,11 @@
1
+ module Mahjong
2
+ module Cpu
3
+ module Ai
4
+ class EasyAi < BaseAi
5
+ def initialize
6
+ super(level: 1)
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Mahjong
2
+ module Cpu
3
+ module Ai
4
+ class HardAi < BaseAi
5
+ def initialize
6
+ super(level: 3)
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Mahjong
2
+ module Cpu
3
+ module Ai
4
+ class NormalAi < BaseAi
5
+ def initialize
6
+ super(level: 2)
7
+ end
8
+ end
9
+ end
10
+ end
11
+ 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,5 @@
1
+ module Mahjong
2
+ module Errors
3
+ class EngineError < StandardError; end
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Mahjong
2
+ module Errors
3
+ class InvalidActionError < EngineError; end
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Mahjong
2
+ module Errors
3
+ class InvalidStateError < EngineError; end
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Mahjong
2
+ module Errors
3
+ class TileNotFoundError < EngineError; end
4
+ end
5
+ 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