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,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
|