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,360 @@
1
+ # 局ステートマシン
2
+
3
+ `riichi_engine` の局進行は 4 つのフェーズ(状態)を持つステートマシンとして実装されています。
4
+ このドキュメントでは各フェーズの意味・遷移条件・アクション優先順位・特殊シーケンスを解説します。
5
+
6
+ ---
7
+
8
+ ## フェーズ一覧
9
+
10
+ | フェーズ | 意味 | 次に起こること |
11
+ |---|---|---|
12
+ | `:tsumo` | 自摸待ち。現在の手番プレイヤーが牌を引く前 | `:tsumo` アクションを受け取ると牌を引いて `:dahai` へ |
13
+ | `:dahai` | 打牌待ち。手番プレイヤーが牌を切る前 | 打牌・リーチ・カン・和了・九種を選択できる |
14
+ | `:naki_wait` | 鳴き待ち。打牌後に他プレイヤーが鳴き・ロンを宣言できる猶予 | 宣言を集めて解決後、打牌か局終了へ |
15
+ | `:round_end` | 局終了。和了・流局など | これ以上アクションは受け付けない |
16
+
17
+ 現在のフェーズは `state.phase` で取得できます。
18
+
19
+ ---
20
+
21
+ ## 状態遷移図
22
+
23
+ ```mermaid
24
+ stateDiagram-v2
25
+ state "自摸待ち" as tsumo
26
+ state "打牌待ち" as dahai
27
+ state "鳴き待ち" as naki_wait
28
+ state "局終了" as round_end
29
+
30
+ [*] --> tsumo : setup_round
31
+
32
+ tsumo --> dahai : tsumo(牌山あり)
33
+ tsumo --> round_end : tsumo(牌山切れ → 荒牌流局)
34
+
35
+ dahai --> tsumo : dahai・riichi(鳴きなし → 次プレイヤー)
36
+ dahai --> naki_wait : dahai・riichi(鳴きあり)
37
+ dahai --> dahai : ankan・kakan(嶺上ドロー)
38
+ dahai --> naki_wait : kakan(槍槓確認)
39
+ dahai --> round_end : tsumo_agari・kyuushu_kyuuhai
40
+
41
+ naki_wait --> dahai : pon・chi・daiminkan
42
+ naki_wait --> tsumo : 全員 skip → 次プレイヤー
43
+ naki_wait --> round_end : ron_agari
44
+
45
+ round_end --> [*]
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 各フェーズの詳細
51
+
52
+ ### :tsumo フェーズ
53
+
54
+ **何をするか:** 手番プレイヤーが牌山から1枚引きます。
55
+
56
+ **受け付けるアクション:**
57
+
58
+ | アクション | 条件 | 次のフェーズ |
59
+ |---|---|---|
60
+ | `:tsumo` | 常に可 | 牌山に残りがあれば `:dahai`。残り 0 なら `:round_end`(荒牌流局) |
61
+
62
+ **注意:** `:tsumo` アクションは「引く」という操作そのものです。host app は自動的に送っても、ユーザーが「ツモ」ボタンを押してから送っても構いません。
63
+
64
+ ```ruby
65
+ result = RiichiEngine::API.apply_round_action(
66
+ state: state,
67
+ action: { type: :tsumo },
68
+ rule: rule
69
+ )
70
+ # state.phase => :dahai
71
+ ```
72
+
73
+ ---
74
+
75
+ ### :dahai フェーズ
76
+
77
+ **何をするか:** 手番プレイヤー(`state.current_seat`)が打牌・リーチ・カン・和了のいずれかを選びます。
78
+
79
+ **受け付けるアクション:**
80
+
81
+ | アクション | 条件 | 次のフェーズ |
82
+ |---|---|---|
83
+ | `:dahai` | 手牌にある牌 | 鳴ける人がいれば `:naki_wait`、いなければ次プレイヤーの `:tsumo` |
84
+ | `:riichi` | 門前・テンパイになれる打牌・1000点以上 | `:dahai` と同じ遷移 |
85
+ | `:ankan` | 手牌に同種4枚・カン可能状態 | 嶺上牌を引いて `:dahai`(同じプレイヤーが続行) |
86
+ | `:kakan` | ポン済みの牌が手牌にある・カン可能状態 | 他プレイヤーが槍槓できれば `:naki_wait`、でなければ嶺上後 `:dahai` |
87
+ | `:tsumo_agari` | 手牌が和了形かつ役あり | `:round_end` |
88
+ | `:kyuushu_kyuuhai` | 第一ツモ・河が空・手牌に么九牌9種以上 | `:round_end` |
89
+
90
+ **選択肢を取得する:**
91
+
92
+ ```ruby
93
+ actions = RiichiEngine::API.available_round_actions(
94
+ state: state,
95
+ seat: state.current_seat,
96
+ rule: rule
97
+ )
98
+ # => [
99
+ # { type: :dahai, tile: "1m" },
100
+ # { type: :dahai, tile: "9p" },
101
+ # { type: :riichi, choices: ["3z", "7m"] }, # リーチ可能な打牌候補
102
+ # { type: :tsumo_agari }, # 和了可能な場合
103
+ # { type: :ankan, tile: "5m" }, # カン可能な場合
104
+ # ]
105
+ ```
106
+
107
+ `choices` は複数の打牌候補がある場合の配列です。どれを選ぶかは host app 側で決定します。
108
+
109
+ ---
110
+
111
+ ### :naki_wait フェーズ
112
+
113
+ **何をするか:** 打牌されたあと、他のプレイヤーが「鳴く・ロンする・スキップする」を宣言します。
114
+ 全員がスキップするか、鳴き・ロンが確定するまでこのフェーズに留まります。
115
+
116
+ **受け付けるアクション:**
117
+
118
+ | アクション | 宣言できるプレイヤー | 条件 | 次のフェーズ |
119
+ |---|---|---|---|
120
+ | `:ron_agari` | 打牌プレイヤー以外で和了形になるプレイヤー | フリテンでない | `:round_end` |
121
+ | `:daiminkan` | 手牌に同種3枚のプレイヤー(左の人には関係なし) | カン可能状態 | `:dahai`(嶺上牌を引いてから) |
122
+ | `:pon` | 手牌に同種2枚のプレイヤー | リーチ中でない | `:dahai` |
123
+ | `:chi` | 打牌プレイヤーの左隣のみ | 数牌のみ・連番が手牌にある | `:dahai` |
124
+ | `:skip` | `pending_actions` にいる全員 | 常に可 | 全員スキップ時は次プレイヤーの `:tsumo` |
125
+
126
+ **各プレイヤーの選択肢を取得する:**
127
+
128
+ ```ruby
129
+ # 鳴き待ち中、各プレイヤーが選べるアクションを確認
130
+ [0, 1, 2, 3].each do |seat|
131
+ actions = RiichiEngine::API.available_round_actions(
132
+ state: state,
133
+ seat: seat,
134
+ rule: rule
135
+ )
136
+ next if actions.empty?
137
+
138
+ puts "seat #{seat}: #{actions.map { |a| a[:type] }.join(', ')}"
139
+ end
140
+ # 例: seat 1: ron_agari, skip
141
+ # seat 2: pon, skip
142
+ # seat 3: skip
143
+ ```
144
+
145
+ ---
146
+
147
+ #### アクションの優先順位
148
+
149
+ 複数のプレイヤーが同時に宣言した場合、以下の優先順位で1つに絞ります。
150
+
151
+ | 優先度 | アクション | 備考 |
152
+ |---|---|---|
153
+ | 1(最高) | `:ron_agari` | ロンは最優先。ダブロン(2人同時ロン)はルール設定で許可する場合を除き、優先度の低い方はキャンセル |
154
+ | 2 | `:daiminkan` / `:pon` | 同優先度の場合、座席番号が小さい方が優先 |
155
+ | 3(最低) | `:chi` | 上家からしか鳴けないため実質競合しない |
156
+
157
+ この解決は `RiichiEngine::API.resolve_pending_action` が行います。
158
+
159
+ ```ruby
160
+ # 複数プレイヤーが宣言した場合の競合解決
161
+ # state.pending_actions => { 1 => [{ type: :pon }], 2 => [{ type: :ron_agari }] }
162
+
163
+ resolved = RiichiEngine::API.resolve_pending_action(state.pending_actions)
164
+ # => { type: :ron_agari, seat: 2 } # ロンが優先
165
+
166
+ RiichiEngine::API.apply_round_action(state: state, action: resolved, rule: rule)
167
+ ```
168
+
169
+ **優先度違反はバリデーションでブロックされます:**
170
+
171
+ ```ruby
172
+ # seat 1 がポンを宣言しようとしても、より優先度の高い seat 2 のロンがある場合は弾かれる
173
+ RiichiEngine::API.apply_round_action(
174
+ state: state,
175
+ action: { type: :pon, seat: 1 },
176
+ rule: rule
177
+ )
178
+ # => RiichiEngine::API::InvalidActionError: higher priority action exists
179
+ ```
180
+
181
+ **スキップの扱い:**
182
+
183
+ スキップは「全員がスキップした時点で」次へ進みます。
184
+ 1人がスキップしても他の人がまだ宣言中であれば、`pending_actions` からその人の選択肢が消えるだけで `:naki_wait` は継続します。
185
+
186
+ ```ruby
187
+ # seat 3 がスキップ → まだ seat 1, 2 が残っているなら naki_wait 継続
188
+ RiichiEngine::API.apply_round_action(
189
+ state: state,
190
+ action: { type: :skip, seat: 3 },
191
+ rule: rule
192
+ )
193
+ # state.phase => :naki_wait(続行)
194
+
195
+ # seat 1, 2 もスキップ → 全員パスで次プレイヤーへ
196
+ # state.phase => :tsumo
197
+ ```
198
+
199
+ ---
200
+
201
+ ### :round_end フェーズ
202
+
203
+ **何をするか:** 局が終了した状態です。追加アクションは一切受け付けません。
204
+
205
+ 終了理由は `result.round_end_info.result_type` で確認できます。
206
+
207
+ | result_type | 意味 |
208
+ |---|---|
209
+ | `:tsumo_agari` | ツモ和了 |
210
+ | `:ron_agari` | ロン和了 |
211
+ | `:ryuukyoku` | 荒牌流局(牌山切れ) |
212
+ | `:kyuushu_kyuuhai` | 九種九牌 |
213
+ | `:suufon_renda` | 四風連打 |
214
+ | `:suucha_riichi` | 四家リーチ |
215
+ | `:sanchahou` | 三家和 |
216
+ | `:suukaikan` | 四槓散了 |
217
+
218
+ ```ruby
219
+ result = RiichiEngine::API.apply_round_action(
220
+ state: state,
221
+ action: { type: :tsumo_agari, seat: 0 },
222
+ rule: rule
223
+ )
224
+
225
+ if result.round_end?
226
+ puts result.round_end_info[:result_type] # => :tsumo_agari
227
+ end
228
+ ```
229
+
230
+ ---
231
+
232
+ ## 特殊シーケンス
233
+
234
+ ### 槓(カン)の流れ
235
+
236
+ 槓にはアンカン・カカン・ダイミンカンの3種類があり、いずれも「嶺上牌を引いて `:dahai` フェーズに戻る」流れになります。ただし槓できる回数には上限があります(`kan_available?` で確認)。
237
+
238
+ ```
239
+ :ankan / :kakan / :daiminkan
240
+ → 嶺上牌をドロー(rinshan_flag = true)
241
+ → 新しいドラ表示牌を公開
242
+ → :dahai フェーズへ(同じプレイヤーが続行)
243
+ → :tsumo_agari で嶺上開花が成立
244
+ ```
245
+
246
+ ```ruby
247
+ # アンカン
248
+ RiichiEngine::API.apply_round_action(
249
+ state: state,
250
+ action: { type: :ankan, seat: 0, tile: "5m" },
251
+ rule: rule
252
+ )
253
+ # state.phase => :dahai(嶺上牌が追加されている)
254
+ # state.last_tsumo => 嶺上牌
255
+ ```
256
+
257
+ ### 槍槓(チャンカン)の流れ
258
+
259
+ カカンを宣言した際、他プレイヤーが同じ牌でロンできる(槍槓)場合は一時的に `:naki_wait` を経由します。
260
+
261
+ ```
262
+ :kakan 宣言
263
+ → 槍槓できるプレイヤーがいる → :naki_wait
264
+ └ :ron_agari → :round_end(槍槓成立)
265
+ └ 全員 :skip → 嶺上牌をドロー → :dahai
266
+ → 誰も槍槓できない → 直接 :dahai へ
267
+ ```
268
+
269
+ ---
270
+
271
+ ## 局終了後のゲームフロー
272
+
273
+ `round_end_info` を使って次局へ進むかゲームを終了するかを判定します。
274
+
275
+ ```ruby
276
+ flow = RiichiEngine::API.judge_next_game_round(
277
+ progress_snapshot: progress_snapshot,
278
+ round_end_info: result.round_end_info,
279
+ rule: rule
280
+ )
281
+ ```
282
+
283
+ 判定ロジックの概要:
284
+
285
+ ```mermaid
286
+ flowchart TD
287
+ A([round_end]) --> B{"トビあり<br/>かつ負の点数?"}
288
+ B -->|はい| C(["ゲーム終了<br/>理由: tobi"])
289
+ B -->|いいえ| D{連荘?}
290
+
291
+ D -->|"親が和了 / 親テンパイ流局 / 途中流局"| E{"オーラスかつ<br/>親が首位?"}
292
+ E -->|はい| F(["ゲーム終了<br/>理由: all_last_oya_agari"])
293
+ E -->|いいえ| G(["次局・連荘<br/>本場 +1"])
294
+
295
+ D -->|親流れ| H["oya_index +1 / kyoku +1"]
296
+ H --> I{kyoku > 4?}
297
+ I -->|はい| J["次の bakaze へ<br/>kyoku = 1"]
298
+ I -->|いいえ| K{ゲーム終了条件?}
299
+ J --> K
300
+ K -->|はい| L(["ゲーム終了<br/>理由: normal_end"])
301
+ K -->|いいえ| M([次局・親流れ])
302
+ ```
303
+
304
+ | `flow.action` | 意味 |
305
+ |---|---|
306
+ | `:next_round` | 次局へ。`flow.next_bakaze`, `flow.next_kyoku`, `flow.next_honba`, `flow.next_oya_index` で次局情報を取得 |
307
+ | `:game_end` | ゲーム終了。`flow.reason` に理由が入る |
308
+
309
+ ```ruby
310
+ case flow.action
311
+ when :next_round
312
+ # 次局の snapshot を構築して setup_round へ
313
+ next_snapshot = Mahjong::Snapshots::GameSetupSnapshot.new(
314
+ bakaze: flow.next_bakaze,
315
+ kyoku: flow.next_kyoku,
316
+ honba: flow.next_honba,
317
+ kyoutaku: new_kyoutaku, # リーチ棒の引き継ぎは host app 側で計算
318
+ oya_index: flow.next_oya_index,
319
+ scores: updated_scores
320
+ )
321
+ state = RiichiEngine::API.setup_round(game_snapshot: next_snapshot, rule: rule)
322
+
323
+ when :game_end
324
+ final_results = RiichiEngine::API.calculate_final_results(
325
+ final_result_snapshot: final_snapshot,
326
+ rule: rule
327
+ )
328
+ end
329
+ ```
330
+
331
+ ---
332
+
333
+ ## 1局の全体フローまとめ
334
+
335
+ ```mermaid
336
+ flowchart TD
337
+ S([setup_round]) --> T[tsumo フェーズ]
338
+ T --> TS{牌山あり?}
339
+ TS -->|あり| D[dahai フェーズ]
340
+ TS -->|なし| RE(["round_end<br/>荒牌流局"])
341
+
342
+ D --> DA{アクション選択}
343
+ DA -->|"tsumo_agari / kyuushu_kyuuhai"| RE
344
+ DA -->|"ankan / kakan"| KAN["嶺上ドロー<br/>新ドラ公開"]
345
+ KAN --> D
346
+ DA -->|"dahai / riichi"| NK{鳴き候補あり?}
347
+
348
+ NK -->|なし| NP[次プレイヤーへ]
349
+ NP --> T
350
+ NK -->|あり| NW[naki_wait フェーズ]
351
+
352
+ NW --> NWA{宣言}
353
+ NWA -->|ron_agari| RE
354
+ NWA -->|"pon / chi / daiminkan"| D
355
+ NWA -->|全員 skip| NP
356
+
357
+ RE --> JN[judge_next_game_round]
358
+ JN -->|next_round| S
359
+ JN -->|game_end| FR([calculate_final_results])
360
+ ```
@@ -0,0 +1,265 @@
1
+ # 使用例
2
+
3
+ 局開始から終了・次局判定・最終結果計算までの典型的な流れをコード例で示します。
4
+
5
+ 牌記法や用語については [概念・用語集](concepts.md) を参照してください。
6
+ 各メソッドの詳細仕様は [API リファレンス](api_reference.md) を参照してください。
7
+
8
+ ---
9
+
10
+ ## 典型的な局の流れ
11
+
12
+ ```
13
+ setup_round
14
+ → apply_round_action(:tsumo)
15
+ → available_round_actions / apply_round_action(:dahai)
16
+ → ... (ターンを繰り返す)
17
+ → round_end? == true
18
+ → judge_next_game_round
19
+ → (次局 or ゲーム終了)
20
+ ```
21
+
22
+ ---
23
+
24
+ ## 1. 局を開始する
25
+
26
+ まず `RuleConfig` を作り、局の初期状態を `GameSetupSnapshot` に詰めて `setup_round` を呼びます。
27
+ `seed` を指定すると同じ牌山を再現できます(テストや観戦機能に便利です)。
28
+
29
+ ```ruby
30
+ rule = Mahjong::Config::RuleConfig.new
31
+
32
+ snapshot = Mahjong::Snapshots::GameSetupSnapshot.new(
33
+ bakaze: "ton", # 東場
34
+ kyoku: 1, # 東1局
35
+ honba: 0,
36
+ kyoutaku: 0,
37
+ oya_index: 0, # seat 0 が親
38
+ scores: { 0 => 25_000, 1 => 25_000, 2 => 25_000, 3 => 25_000 }
39
+ )
40
+
41
+ state = RiichiEngine::API.setup_round(
42
+ game_snapshot: snapshot,
43
+ rule: rule,
44
+ seed: "round-001"
45
+ )
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 2. 自摸して打牌する
51
+
52
+ `:tsumo` アクションで手番プレイヤーが牌を引き、`:dahai` で打牌します。
53
+ `available_round_actions` で選択可能なアクションを取得してから打牌するのが基本パターンです。
54
+
55
+ ```ruby
56
+ # 自摸
57
+ RiichiEngine::API.apply_round_action(
58
+ state: state,
59
+ action: { type: :tsumo },
60
+ rule: rule
61
+ )
62
+
63
+ # 現在の手番プレイヤーが選べるアクションを取得
64
+ actions = RiichiEngine::API.available_round_actions(
65
+ state: state,
66
+ seat: state.current_seat,
67
+ rule: rule
68
+ )
69
+
70
+ # 打牌アクションを選んで適用
71
+ dahai = actions.find { |a| a[:type] == :dahai }
72
+
73
+ result = RiichiEngine::API.apply_round_action(
74
+ state: state,
75
+ action: { type: :dahai, seat: state.current_seat, tile: dahai[:tile] },
76
+ rule: rule
77
+ )
78
+ ```
79
+
80
+ `result` は `Mahjong::Results::RoundFlowResult` で、以下のフィールドを持ちます。
81
+
82
+ | フィールド | 説明 |
83
+ |---|---|
84
+ | `result.events` | このアクションで発生したイベントの配列 |
85
+ | `result.round_end?` | 局が終了したか否か |
86
+ | `result.round_end_info` | 局終了情報(局中は `nil`) |
87
+
88
+ 打牌後に他の座席が鳴き・ロンを宣言できる場合、`state.pending_actions` にその情報が入ります。
89
+
90
+ ---
91
+
92
+ ## 3. 鳴き競合を解決する
93
+
94
+ 複数の座席が同時に鳴き・ロンを宣言した場合(例:複数人がロンを宣言、またはロンとポンが競合)、
95
+ `resolve_pending_action` で優先アクションを決定してから適用します。
96
+
97
+ ```ruby
98
+ resolved = RiichiEngine::API.resolve_pending_action(state.pending_actions)
99
+
100
+ if resolved
101
+ RiichiEngine::API.apply_round_action(
102
+ state: state,
103
+ action: resolved,
104
+ rule: rule
105
+ )
106
+ end
107
+ ```
108
+
109
+ `resolved` が `nil` の場合、有効な鳴きがなかった(全員スキップ)ことを意味します。
110
+
111
+ ---
112
+
113
+ ## 4. 流局時のテンパイ料を計算する
114
+
115
+ `round_end?` が `true` かつ流局だった場合、テンパイ座席を調べてノーテン罰符を計算します。
116
+
117
+ ```ruby
118
+ tenpai = RiichiEngine::API.tenpai_seats(state)
119
+ # => [0, 2] # seat 0, 2 がテンパイ
120
+
121
+ payments = RiichiEngine::API.calculate_tenpai_payments(tenpai)
122
+ # => { 0 => 1500, 1 => -1500, 2 => 1500, 3 => -1500 }
123
+ # 正が受け取り、負が支払い
124
+ ```
125
+
126
+ ---
127
+
128
+ ## 5. 和了評価をする
129
+
130
+ ロンや自摸で局が終了した場合、`evaluate_win` で役・翻・符・点数を取得します。
131
+ `WinEvaluationSnapshot` に場風・自風などの情報を詰めて渡します。
132
+
133
+ ```ruby
134
+ win_snapshot = Mahjong::Snapshots::WinEvaluationSnapshot.new(
135
+ bakaze: state.bakaze,
136
+ oya_index: state.oya_seat,
137
+ honba: state.honba,
138
+ kyoutaku: state.kyoutaku,
139
+ jikaze: "ton" # 和了者の自風
140
+ )
141
+
142
+ winner_info, score_result = RiichiEngine::API.evaluate_win(
143
+ state: state,
144
+ seat: 0,
145
+ agari_type: :tsumo, # :tsumo or :ron
146
+ agari_tile: state.last_tsumo,
147
+ rule: rule,
148
+ extra_flags: {},
149
+ snapshot: win_snapshot
150
+ )
151
+ ```
152
+
153
+ `winner_info` には役名・翻数・符などが含まれます。
154
+ `score_result.payments` で各プレイヤーへの支払い点数(正: 受け取り、負: 支払い)を取得できます。
155
+
156
+ ---
157
+
158
+ ## 6. 局終了後に次局を判定する
159
+
160
+ `judge_next_game_round` で次局へ進むかゲームを終了するかを判定します。
161
+ 本場の加算・オーラス条件・飛び判定も自動的に処理されます。
162
+
163
+ ```ruby
164
+ progress_snapshot = Mahjong::Snapshots::GameProgressSnapshot.new(
165
+ bakaze: "ton",
166
+ kyoku: 1,
167
+ honba: 0,
168
+ kyoutaku: 0,
169
+ oya_index: 0,
170
+ scores: { 0 => 24_000, 1 => 26_000, 2 => 25_000, 3 => 25_000 }
171
+ )
172
+
173
+ round_end_info = Mahjong::Results::RoundEndInfo.new(
174
+ result_type: :ron,
175
+ winners: [{ seat: 1 }],
176
+ losers: [{ seat: 0 }],
177
+ tenpai_seats: [],
178
+ draw_reason: nil,
179
+ score_changes: {}
180
+ )
181
+
182
+ flow_result = RiichiEngine::API.judge_next_game_round(
183
+ progress_snapshot: progress_snapshot,
184
+ round_end_info: round_end_info,
185
+ rule: rule
186
+ )
187
+
188
+ # flow_result.next_round? => true なら次局へ
189
+ # flow_result.game_end? => true ならゲーム終了
190
+ ```
191
+
192
+ ---
193
+
194
+ ## 7. 最終結果・順位を計算する
195
+
196
+ ### プレイヤー情報が不要な場合
197
+
198
+ 点棒から直接順位・ウマ・オカを計算します。
199
+
200
+ ```ruby
201
+ ranked = RiichiEngine::API.calculate_ranked_results(
202
+ scores: { 0 => 35_000, 1 => 28_000, 2 => 22_000, 3 => 15_000 },
203
+ rule: rule
204
+ )
205
+ ```
206
+
207
+ ### プレイヤー名・ID も含める場合
208
+
209
+ `FinalResultSnapshot` にプレイヤー情報を含めて渡します。
210
+
211
+ ```ruby
212
+ final_snapshot = Mahjong::Snapshots::FinalResultSnapshot.new(
213
+ scores: { 0 => 35_000, 1 => 28_000, 2 => 22_000, 3 => 15_000 },
214
+ players_by_seat: {
215
+ 0 => { player_id: 10, name: "Alice" },
216
+ 1 => { player_id: 11, name: "Bob" },
217
+ 2 => { player_id: 12, name: "Carol" },
218
+ 3 => { player_id: 13, name: "Dave" }
219
+ }
220
+ )
221
+
222
+ final_results = RiichiEngine::API.calculate_final_results(
223
+ final_result_snapshot: final_snapshot,
224
+ rule: rule
225
+ )
226
+ ```
227
+
228
+ ---
229
+
230
+ ## 8. キャッシュから RoundState を復元する
231
+
232
+ `RoundState` は JSON にシリアライズできます。Redis 等のキャッシュに保存し、後から復元することで
233
+ サーバーサイドで状態を保持せずに局を進行させられます。
234
+
235
+ ```ruby
236
+ # 保存側
237
+ json = state.to_json
238
+ cache.set("round:#{round_id}", json)
239
+
240
+ # 復元側
241
+ json = cache.get("round:#{round_id}")
242
+ state = RiichiEngine::API.deserialize_round_state(json)
243
+ ```
244
+
245
+ ---
246
+
247
+ ## 9. host app 側の責務分担
248
+
249
+ `riichi_engine` と host app の責務の分担は以下の通りです。
250
+
251
+ ### riichi_engine が担うもの
252
+
253
+ - 局進行(自摸・打牌・鳴き・カン)
254
+ - 和了判定・役判定
255
+ - 点数計算・順位計算
256
+
257
+ ### host app が担うもの
258
+
259
+ - スナップショットの生成(ActiveRecord モデル → Snapshot)
260
+ - DB 保存・更新
261
+ - ActionCable / Turbo 配信
262
+ - Job enqueue
263
+ - キャッシュへの `RoundState` 保存・復元
264
+
265
+ 詳細は [Adapter 契約](adapter_contract.md) を参照してください。
@@ -0,0 +1,53 @@
1
+ module Mahjong
2
+ module Config
3
+ class RuleConfig
4
+ DEFAULTS = {
5
+ "game_type" => "hanchan",
6
+ "initial_score" => 25000,
7
+ "oka_origin" => 30000,
8
+ "uma" => [20000, 10000, -10000, -20000],
9
+ "aka_dora" => { "m" => 1, "p" => 1, "s" => 1 },
10
+ "kuitan" => true,
11
+ "atozuke" => true,
12
+ "double_yakuman" => false,
13
+ "kazoe_yakuman" => true,
14
+ "kiriage_mangan" => false,
15
+ "triple_ron_draw" => true,
16
+ "double_ron" => true,
17
+ "tobi" => true,
18
+ "nishi_iri" => false,
19
+ "nishi_iri_threshold" => 30000,
20
+ "renpuu_jantou_fu" => 2
21
+ }.freeze
22
+
23
+ def initialize(config = {})
24
+ normalized = (config || {}).each_with_object({}) do |(key, value), hash|
25
+ hash[key.to_s] = value
26
+ end
27
+ @config = DEFAULTS.merge(normalized)
28
+ end
29
+
30
+ # 動的メソッドアクセス
31
+ DEFAULTS.each_key do |key|
32
+ define_method(key) { @config[key] }
33
+ define_method("#{key}?") { !!@config[key] } if [true, false].include?(DEFAULTS[key])
34
+ end
35
+
36
+ def aka_dora_config
37
+ @config["aka_dora"]
38
+ end
39
+
40
+ def uma_values
41
+ @config["uma"]
42
+ end
43
+
44
+ def to_h
45
+ @config.dup
46
+ end
47
+
48
+ def to_json(*args)
49
+ JSON.generate(@config, *args)
50
+ end
51
+ end
52
+ end
53
+ end