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,7 @@
1
+ module Mahjong::Scoring::Yaku::Toitoihou
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+ return nil unless ctx.all_mentsu.all? { |m| m.koutsu? || m.kantsu? }
5
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "toitoihou", han: 2, display: "対々和")
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module Mahjong::Scoring::Yaku::Tsuuiisou
2
+ def self.check(ctx)
3
+ return nil unless ctx.all_tiles.all?(&:jihai?)
4
+ ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "tsuuiisou", han: -1, display: "字一色")
5
+ end
6
+ end
@@ -0,0 +1,27 @@
1
+ module Mahjong::Scoring::Yaku::Yakuhai
2
+ def self.check(ctx)
3
+ return nil unless ctx.agari_form&.regular?
4
+
5
+ results = []
6
+ ctx.all_mentsu.select { |m| m.koutsu? || m.kantsu? }.each do |m|
7
+ tile = m.representative_tile
8
+ next unless tile.jihai?
9
+
10
+ if tile.sangenpai?
11
+ name = { 5 => "yakuhai_haku", 6 => "yakuhai_hatsu", 7 => "yakuhai_chun" }[tile.number]
12
+ display = { 5 => "役牌 白", 6 => "役牌 發", 7 => "役牌 中" }[tile.number]
13
+ results << ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: name, han: 1, display: display)
14
+ end
15
+
16
+ if ctx.jikaze_yakuhai?(tile)
17
+ results << ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "yakuhai_jikaze", han: 1, display: "自風 #{tile.japanese_name}")
18
+ end
19
+
20
+ if ctx.bakaze_yakuhai?(tile)
21
+ results << ::Mahjong::Scoring::ValueObjects::YakuEntry.new(name: "yakuhai_bakaze", han: 1, display: "場風 #{tile.japanese_name}")
22
+ end
23
+ end
24
+
25
+ results.empty? ? nil : results
26
+ end
27
+ end
@@ -0,0 +1,8 @@
1
+ module Mahjong
2
+ module Snapshots
3
+ FinalResultSnapshot = Data.define(
4
+ :scores,
5
+ :players_by_seat
6
+ )
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ module Mahjong
2
+ module Snapshots
3
+ GameProgressSnapshot = Data.define(
4
+ :bakaze,
5
+ :kyoku,
6
+ :honba,
7
+ :kyoutaku,
8
+ :oya_index,
9
+ :scores
10
+ )
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module Mahjong
2
+ module Snapshots
3
+ GameSetupSnapshot = Data.define(
4
+ :bakaze,
5
+ :kyoku,
6
+ :honba,
7
+ :kyoutaku,
8
+ :oya_index,
9
+ :scores
10
+ )
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Mahjong
2
+ module Snapshots
3
+ WinEvaluationSnapshot = Data.define(
4
+ :bakaze,
5
+ :oya_index,
6
+ :honba,
7
+ :kyoutaku,
8
+ :jikaze
9
+ )
10
+ end
11
+ end
@@ -0,0 +1,45 @@
1
+ module Mahjong
2
+ module State
3
+ class Fuuro
4
+ TYPES = %i[chi pon ankan daiminkan kakan].freeze
5
+
6
+ attr_reader :type, :tiles, :called_tile, :from_seat
7
+
8
+ def initialize(type:, tiles:, called_tile: nil, from_seat: nil)
9
+ @type = type
10
+ @tiles = tiles.freeze
11
+ @called_tile = called_tile
12
+ @from_seat = from_seat
13
+ freeze
14
+ end
15
+
16
+ def chi? = type == :chi
17
+ def pon? = type == :pon
18
+ def ankan? = type == :ankan
19
+ def daiminkan? = type == :daiminkan
20
+ def kakan? = type == :kakan
21
+ def kan? = ankan? || daiminkan? || kakan?
22
+
23
+ def keeps_menzen?
24
+ ankan?
25
+ end
26
+
27
+ def to_mentsu
28
+ case type
29
+ when :chi
30
+ State::Mentsu.new(type: :shuntsu, tiles: tiles, state: :open)
31
+ when :pon
32
+ State::Mentsu.new(type: :koutsu, tiles: tiles, state: :open)
33
+ when :ankan
34
+ State::Mentsu.new(type: :kantsu, tiles: tiles, state: :closed)
35
+ when :daiminkan, :kakan
36
+ State::Mentsu.new(type: :kantsu, tiles: tiles, state: :open)
37
+ end
38
+ end
39
+
40
+ def to_s
41
+ "#{type}[#{tiles.map(&:to_s).join(',')}]"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,64 @@
1
+ module Mahjong
2
+ module State
3
+ class Hand
4
+ attr_reader :tiles
5
+
6
+ def initialize(tiles = [])
7
+ @tiles = tiles.map { |t| t.is_a?(Tiles::Tile) ? t : Tiles::Tile.new(t) }
8
+ end
9
+
10
+ def add(tile)
11
+ tile = Tiles::Tile.new(tile) unless tile.is_a?(Tiles::Tile)
12
+ self.class.new(@tiles + [tile])
13
+ end
14
+
15
+ def remove(tile)
16
+ tile = Tiles::Tile.new(tile) unless tile.is_a?(Tiles::Tile)
17
+ idx = @tiles.index { |t| t.same_kind?(tile) }
18
+ raise Errors::TileNotFoundError, "Tile #{tile} not found in hand" unless idx
19
+
20
+ self.class.new(@tiles.dup.tap { |a| a.delete_at(idx) })
21
+ end
22
+
23
+ def remove_tiles(tiles_to_remove)
24
+ result = self
25
+ tiles_to_remove.each { |t| result = result.remove(t) }
26
+ result
27
+ end
28
+
29
+ def count
30
+ @tiles.size
31
+ end
32
+
33
+ def include?(tile)
34
+ tile = Tiles::Tile.new(tile) unless tile.is_a?(Tiles::Tile)
35
+ @tiles.any? { |t| t.same_kind?(tile) }
36
+ end
37
+
38
+ def count_of(tile)
39
+ tile = Tiles::Tile.new(tile) unless tile.is_a?(Tiles::Tile)
40
+ @tiles.count { |t| t.same_kind?(tile) }
41
+ end
42
+
43
+ def sorted
44
+ self.class.new(@tiles.sort)
45
+ end
46
+
47
+ def to_34
48
+ Tiles::TileSet.to_34(@tiles)
49
+ end
50
+
51
+ def to_a
52
+ @tiles.map(&:to_s)
53
+ end
54
+
55
+ def self.from_array(arr)
56
+ new(arr.map { |s| Tiles::Tile.new(s) })
57
+ end
58
+
59
+ def to_s
60
+ sorted.tiles.map(&:to_s).join(" ")
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,68 @@
1
+ module Mahjong
2
+ module State
3
+ class Kawa
4
+ KawaTile = Data.define(:tile, :tsumogiri, :riichi, :called)
5
+
6
+ attr_reader :tiles
7
+
8
+ def initialize
9
+ @tiles = []
10
+ end
11
+
12
+ def add(tile, tsumogiri: false, riichi: false)
13
+ @tiles << KawaTile.new(
14
+ tile: tile.is_a?(Tiles::Tile) ? tile : Tiles::Tile.new(tile),
15
+ tsumogiri: tsumogiri,
16
+ riichi: riichi,
17
+ called: false
18
+ )
19
+ end
20
+
21
+ def mark_last_called!
22
+ return if @tiles.empty?
23
+
24
+ last = @tiles.pop
25
+ @tiles << KawaTile.new(tile: last.tile, tsumogiri: last.tsumogiri,
26
+ riichi: last.riichi, called: true)
27
+ end
28
+
29
+ def discarded_tile_kinds
30
+ @tiles.map { |kt| [kt.tile.suit, kt.tile.effective_number] }.uniq
31
+ end
32
+
33
+ def includes_kind?(tile)
34
+ @tiles.any? { |kt| kt.tile.same_kind?(tile) }
35
+ end
36
+
37
+ def count
38
+ @tiles.size
39
+ end
40
+
41
+ def last_tile
42
+ @tiles.last&.tile
43
+ end
44
+
45
+ def to_a
46
+ @tiles.map do |kt|
47
+ {
48
+ tile: kt.tile.to_s,
49
+ tsumogiri: kt.tsumogiri,
50
+ riichi: kt.riichi,
51
+ called: kt.called
52
+ }
53
+ end
54
+ end
55
+
56
+ def self.from_array(arr)
57
+ kawa = new
58
+ arr.each do |h|
59
+ kawa.add(h["tile"] || h[:tile],
60
+ tsumogiri: h["tsumogiri"] || h[:tsumogiri] || false,
61
+ riichi: h["riichi"] || h[:riichi] || false)
62
+ kawa.mark_last_called! if h["called"] || h[:called]
63
+ end
64
+ kawa
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,55 @@
1
+ module Mahjong
2
+ module State
3
+ class Mentsu
4
+ TYPES = %i[shuntsu koutsu kantsu].freeze
5
+ STATES = %i[open closed].freeze
6
+
7
+ attr_reader :type, :tiles, :state
8
+
9
+ def initialize(type:, tiles:, state: :closed)
10
+ @type = type
11
+ @tiles = tiles.freeze
12
+ @state = state
13
+ freeze
14
+ end
15
+
16
+ def shuntsu? = type == :shuntsu
17
+ def koutsu? = type == :koutsu
18
+ def kantsu? = type == :kantsu
19
+ def open? = state == :open
20
+ def closed? = state == :closed
21
+
22
+ def representative_tile
23
+ tiles.min
24
+ end
25
+
26
+ def includes_yaochu?
27
+ tiles.any?(&:yaochu?)
28
+ end
29
+
30
+ def all_yaochu?
31
+ tiles.all?(&:yaochu?)
32
+ end
33
+
34
+ def all_chunchan?
35
+ tiles.all?(&:chunchan?)
36
+ end
37
+
38
+ def fu
39
+ base = case type
40
+ when :shuntsu then 0
41
+ when :koutsu then open? ? 2 : 4
42
+ when :kantsu then open? ? 8 : 16
43
+ end
44
+ yaochu_multiplier = representative_tile.yaochu? ? 2 : 1
45
+ base * yaochu_multiplier
46
+ end
47
+
48
+ def to_s
49
+ state_str = open? ? "明" : "暗"
50
+ type_str = { shuntsu: "順", koutsu: "刻", kantsu: "槓" }[type]
51
+ "[#{state_str}#{type_str}: #{tiles.map(&:to_s).join(',')}]"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,193 @@
1
+ module Mahjong
2
+ module State
3
+ # 局の全状態を保持する中核オブジェクト。
4
+ # Solid Cache に JSON シリアライズして保存する。
5
+ # 実装詳細は D09: 局進行ステートマシン で行う。
6
+ class RoundState
7
+ VALID_PHASES = %i[tsumo dahai naki_wait round_end].freeze
8
+ VALID_FUURO_TYPES = %i[chi pon daiminkan ankan kakan].freeze
9
+ VALID_PENDING_ACTION_KEYS = %i[type seat tile tiles].freeze
10
+ VALID_PENDING_ACTION_TYPES = %i[pon chi daiminkan ron_agari skip].freeze
11
+
12
+ attr_accessor :bakaze, :kyoku, :honba, :kyoutaku, :oya_seat
13
+ attr_accessor :hands, :fuuros, :kawas, :scores
14
+ attr_accessor :wall, :wanpai, :dora_hyouji, :uradora_hyouji
15
+ attr_accessor :current_seat, :junme, :phase, :last_dahai, :last_tsumo
16
+ attr_accessor :state_version
17
+ attr_accessor :riichi_flags, :riichi_junme, :ippatsu_flags
18
+ attr_accessor :double_riichi_flags, :menzen_flags
19
+ attr_accessor :first_turn_flags, :kan_count, :kan_by_player
20
+ attr_accessor :pending_actions, :rinshan_flag, :chankan_tile
21
+ attr_accessor :dojun_furiten_flags, :riichi_furiten_flags
22
+
23
+ def initialize
24
+ @hands = {}
25
+ @fuuros = { 0 => [], 1 => [], 2 => [], 3 => [] }
26
+ @kawas = {}
27
+ @scores = { 0 => 25000, 1 => 25000, 2 => 25000, 3 => 25000 }
28
+ @riichi_flags = { 0 => false, 1 => false, 2 => false, 3 => false }
29
+ @riichi_junme = { 0 => nil, 1 => nil, 2 => nil, 3 => nil }
30
+ @ippatsu_flags = { 0 => false, 1 => false, 2 => false, 3 => false }
31
+ @double_riichi_flags = { 0 => false, 1 => false, 2 => false, 3 => false }
32
+ @menzen_flags = { 0 => true, 1 => true, 2 => true, 3 => true }
33
+ @first_turn_flags = { 0 => true, 1 => true, 2 => true, 3 => true }
34
+ @kan_count = 0
35
+ @kan_by_player = { 0 => 0, 1 => 0, 2 => 0, 3 => 0 }
36
+ @pending_actions = {}
37
+ @rinshan_flag = false
38
+ @chankan_tile = nil
39
+ @dojun_furiten_flags = { 0 => false, 1 => false, 2 => false, 3 => false }
40
+ @riichi_furiten_flags = { 0 => false, 1 => false, 2 => false, 3 => false }
41
+ @junme = 0
42
+ @phase = :tsumo
43
+ @state_version = 0
44
+ end
45
+
46
+ # --- クエリ ---
47
+
48
+ def is_haitei?
49
+ wall&.remaining == 0
50
+ end
51
+
52
+ def is_houtei?
53
+ is_haitei? && phase == :naki_wait
54
+ end
55
+
56
+ def is_menzen?(seat)
57
+ menzen_flags[seat] == true
58
+ end
59
+
60
+ def is_riichi?(seat)
61
+ riichi_flags[seat] == true
62
+ end
63
+
64
+ def remaining_tiles
65
+ wall&.remaining || 0
66
+ end
67
+
68
+ # --- シリアライズ(D09 で完全実装)---
69
+
70
+ def to_json
71
+ JSON.generate(
72
+ {
73
+ bakaze: bakaze, kyoku: kyoku, honba: honba, kyoutaku: kyoutaku, oya_seat: oya_seat,
74
+ hands: hands.transform_keys(&:to_s).transform_values(&:to_a),
75
+ fuuros: fuuros.transform_keys(&:to_s).transform_values { |list|
76
+ list.map do |fuuro|
77
+ { type: fuuro.type, tiles: fuuro.tiles.map(&:to_s), called_tile: fuuro.called_tile&.to_s, from_seat: fuuro.from_seat }
78
+ end
79
+ },
80
+ kawas: kawas.transform_keys(&:to_s).transform_values(&:to_a),
81
+ scores: scores.transform_keys(&:to_s),
82
+ wall: wall&.to_a,
83
+ wanpai: wanpai&.to_h,
84
+ dora_hyouji: dora_hyouji&.map(&:to_s),
85
+ uradora_hyouji: uradora_hyouji&.map(&:to_s),
86
+ state_version: state_version,
87
+ current_seat: current_seat, junme: junme, phase: phase, last_dahai: serialize_last_dahai, last_tsumo: last_tsumo&.to_s,
88
+ riichi_flags: riichi_flags, riichi_junme: riichi_junme, ippatsu_flags: ippatsu_flags,
89
+ double_riichi_flags: double_riichi_flags, menzen_flags: menzen_flags,
90
+ first_turn_flags: first_turn_flags, kan_count: kan_count, kan_by_player: kan_by_player,
91
+ pending_actions: pending_actions, rinshan_flag: rinshan_flag, chankan_tile: chankan_tile&.to_s,
92
+ dojun_furiten_flags: dojun_furiten_flags, riichi_furiten_flags: riichi_furiten_flags
93
+ }
94
+ )
95
+ end
96
+
97
+ def self.from_json(json)
98
+ data = JSON.parse(json)
99
+ state = new
100
+
101
+ state.bakaze = data["bakaze"]
102
+ state.kyoku = data["kyoku"]
103
+ state.honba = data["honba"]
104
+ state.kyoutaku = data["kyoutaku"]
105
+ state.oya_seat = data["oya_seat"]
106
+ state.hands = (data["hands"] || {}).transform_keys(&:to_i).transform_values { |tiles| State::Hand.from_array(tiles) }
107
+ state.fuuros = (data["fuuros"] || {}).transform_keys(&:to_i).transform_values do |list|
108
+ Array(list).map do |fuuro|
109
+ type_str = fuuro["type"].to_s
110
+ type_sym = VALID_FUURO_TYPES.find { |t| t.to_s == type_str }
111
+ raise Errors::EngineError, "invalid fuuro type: #{type_str.inspect}" unless type_sym
112
+
113
+ State::Fuuro.new(
114
+ type: type_sym,
115
+ tiles: Array(fuuro["tiles"]).map { Tiles::Tile.new(_1) },
116
+ called_tile: fuuro["called_tile"] ? Tiles::Tile.new(fuuro["called_tile"]) : nil,
117
+ from_seat: fuuro["from_seat"]
118
+ )
119
+ end
120
+ end
121
+ state.kawas = (data["kawas"] || {}).transform_keys(&:to_i).transform_values { |arr| State::Kawa.from_array(arr) }
122
+ state.scores = (data["scores"] || {}).transform_keys(&:to_i).transform_values(&:to_i)
123
+ state.wall = data["wall"] ? Tiles::Wall.new(Array(data["wall"]).map { Tiles::Tile.new(_1) }) : nil
124
+ state.wanpai = data["wanpai"] ? Tiles::Wanpai.from_hash(data["wanpai"]) : nil
125
+ state.dora_hyouji = Array(data["dora_hyouji"]).map { Tiles::Tile.new(_1) }
126
+ state.uradora_hyouji = Array(data["uradora_hyouji"]).map { Tiles::Tile.new(_1) }
127
+ state.state_version = data["state_version"].to_i
128
+ state.current_seat = data["current_seat"]
129
+ state.junme = data["junme"]
130
+
131
+ phase_str = data["phase"].to_s
132
+ phase_sym = VALID_PHASES.find { |p| p.to_s == phase_str }
133
+ raise Errors::EngineError, "invalid phase: #{phase_str.inspect}" unless phase_sym
134
+
135
+ state.phase = phase_sym
136
+ state.last_dahai = deserialize_last_dahai(data["last_dahai"])
137
+ state.last_tsumo = data["last_tsumo"] ? Tiles::Tile.new(data["last_tsumo"]) : nil
138
+ state.riichi_flags = symbolize_int_hash(data["riichi_flags"])
139
+ state.riichi_junme = symbolize_int_hash(data["riichi_junme"])
140
+ state.ippatsu_flags = symbolize_int_hash(data["ippatsu_flags"])
141
+ state.double_riichi_flags = symbolize_int_hash(data["double_riichi_flags"])
142
+ state.menzen_flags = symbolize_int_hash(data["menzen_flags"])
143
+ state.first_turn_flags = symbolize_int_hash(data["first_turn_flags"])
144
+ state.kan_count = data["kan_count"]
145
+ state.kan_by_player = symbolize_int_hash(data["kan_by_player"])
146
+ state.pending_actions = deserialize_pending_actions(data["pending_actions"])
147
+ state.rinshan_flag = data["rinshan_flag"]
148
+ state.chankan_tile = data["chankan_tile"] ? Tiles::Tile.new(data["chankan_tile"]) : nil
149
+ state.dojun_furiten_flags = symbolize_int_hash(data["dojun_furiten_flags"])
150
+ state.riichi_furiten_flags = symbolize_int_hash(data["riichi_furiten_flags"])
151
+ state
152
+ rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
153
+ raise Errors::EngineError, "RoundState の復元に失敗しました: #{e.message}"
154
+ end
155
+
156
+ def serialize_last_dahai
157
+ return nil unless last_dahai
158
+
159
+ { seat: last_dahai[:seat], tile: last_dahai[:tile]&.to_s }
160
+ end
161
+
162
+ def self.deserialize_last_dahai(value)
163
+ return nil unless value
164
+
165
+ { seat: value["seat"], tile: value["tile"] ? Tiles::Tile.new(value["tile"]) : nil }
166
+ end
167
+
168
+ def self.symbolize_int_hash(hash)
169
+ (hash || {}).transform_keys(&:to_i)
170
+ end
171
+
172
+ def self.deserialize_pending_actions(hash)
173
+ (hash || {}).transform_keys(&:to_i).transform_values do |actions|
174
+ Array(actions).filter_map do |action|
175
+ next unless action
176
+
177
+ action.to_h.each_with_object({}) do |(k, v), result|
178
+ key = VALID_PENDING_ACTION_KEYS.find { |s| s.to_s == k.to_s }
179
+ next unless key
180
+
181
+ result[key] = if key == :type
182
+ VALID_PENDING_ACTION_TYPES.find { |s| s.to_s == v.to_s } ||
183
+ raise(Errors::EngineError, "invalid pending action type: #{v.inspect}")
184
+ else
185
+ v
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,19 @@
1
+ module Mahjong
2
+ module Tiles
3
+ class Dora
4
+ def self.count(all_tiles, dora_tiles)
5
+ dora_tiles.sum do |dora|
6
+ all_tiles.count { |t| t.same_kind?(dora) }
7
+ end
8
+ end
9
+
10
+ def self.aka_count(all_tiles)
11
+ all_tiles.count(&:aka?)
12
+ end
13
+
14
+ def self.uradora_count(all_tiles, uradora_tiles)
15
+ count(all_tiles, uradora_tiles)
16
+ end
17
+ end
18
+ end
19
+ end