asterism-games 0.18.0 → 0.19.1

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.
@@ -0,0 +1,535 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsterismGames
4
+ module Commentate
5
+ # The words of the commentary (README "Commentary"): an event of
6
+ # MahjongEvents into a line, in Japanese, the way the table app says
7
+ # things (東1局, 本場, 供託, ドラ, リーチ, ポン). Pure: the view gives the
8
+ # names, rng (anything with rand(n)) chooses among the phrasings.
9
+ #
10
+ # line = MahjongPhrases.line(event, view, rng, tone: "zundamon")
11
+ # # => {"type", "prio", "text", "speech", "ptext", "pspeech", "clips"}
12
+ #
13
+ # "text" is the caption, "speech" the same for a voice (the tiles and
14
+ # the yaku as they are read: サンマン, リーチ). "ptext" / "pspeech" are
15
+ # the line with the hands hidden: the same when the event does not
16
+ # touch a hand, a shorter one when part of it does (a riichi's waits),
17
+ # nil when all of it does (a tenpai): then nothing is said. "clips" are
18
+ # the keys of the fixed phrases (mahjong_voice.yml) a voice plays
19
+ # before the line: never one that tells a hand.
20
+ #
21
+ # The tone (TONES) is the character's way of speaking: the plain one,
22
+ # and ずんだもん's (〜なのだ) when the voice is VOICEVOX's ずんだもん. A
23
+ # tone has phrasings of its own for every event (the same number as
24
+ # the plain ones, so one choice gives the same line in both), and a
25
+ # few words for an AI asked to speak in it (C3).
26
+ module MahjongPhrases
27
+ WIND_TEXT = { "east" => "東", "south" => "南", "west" => "西", "north" => "北" }.freeze
28
+ WIND_READ = { "east" => "トン", "south" => "ナン", "west" => "シャー", "north" => "ペー" }.freeze
29
+ HONOR_TEXT = %w[東 南 西 北 白 發 中].freeze
30
+ HONOR_READ = %w[トン ナン シャー ペー ハク ハツ チュン].freeze
31
+ SUIT_TEXT = { "m" => "萬", "p" => "筒", "s" => "索" }.freeze
32
+ SUIT_READ = { "m" => "マン", "p" => "ピン", "s" => "ソー" }.freeze
33
+ NUM_READ = %w[イー リャン サン スー ウー ロー チー パー キュー].freeze
34
+ CALL_TEXT = { "chi" => "チー", "pon" => "ポン", "open_kan" => "カン", "closed_kan" => "カン",
35
+ "added_kan" => "カン" }.freeze
36
+ CALL_KIND = { "chi" => "チー", "pon" => "ポン", "open_kan" => "大明槓", "closed_kan" => "暗槓",
37
+ "added_kan" => "加槓" }.freeze
38
+ CALL_KIND_READ = { "chi" => "チー", "pon" => "ポン", "open_kan" => "ダイミンカン", "closed_kan" => "アンカン",
39
+ "added_kan" => "カカン" }.freeze
40
+ YAKU_READ = {
41
+ "立直" => "リーチ", "ダブル立直" => "ダブルリーチ", "一発" => "イッパツ", "門前清自摸和" => "メンゼンツモ",
42
+ "平和" => "ピンフ", "断么九" => "タンヤオ", "一盃口" => "イーペーコー", "二盃口" => "リャンペーコー",
43
+ "七対子" => "チートイツ", "三色同順" => "サンショク", "三色同刻" => "サンショクドウコー", "一気通貫" => "イッツー",
44
+ "混全帯么九" => "チャンタ", "純全帯么九" => "ジュンチャン", "対々和" => "トイトイ", "三暗刻" => "サンアンコー",
45
+ "三槓子" => "サンカンツ", "混老頭" => "ホンロートー", "小三元" => "ショウサンゲン", "混一色" => "ホンイツ",
46
+ "清一色" => "チンイツ", "嶺上開花" => "リンシャンカイホー", "槍槓" => "チャンカン", "海底摸月" => "ハイテイ",
47
+ "河底撈魚" => "ホーテイ", "国士無双" => "コクシムソウ", "国士無双十三面" => "コクシムソウ ジューサンメン",
48
+ "四暗刻" => "スーアンコー", "四暗刻単騎" => "スーアンコー タンキ", "大三元" => "ダイサンゲン", "字一色" => "ツーイーソー",
49
+ "緑一色" => "リューイーソー", "清老頭" => "チンロートー", "小四喜" => "ショウスーシー", "大四喜" => "ダイスーシー",
50
+ "四槓子" => "スーカンツ", "九蓮宝燈" => "チューレンポートー", "純正九蓮宝燈" => "ジュンセイ チューレンポートー",
51
+ "天和" => "テンホー", "地和" => "チーホー"
52
+ }.freeze
53
+ LEVEL_READ = { "満貫" => "マンガン", "跳満" => "ハネマン", "倍満" => "バイマン", "三倍満" => "サンバイマン",
54
+ "役満" => "ヤクマン" }.freeze
55
+ ABORT_TEXT = { "kyuushu_kyuuhai" => "九種九牌", "sanchahou" => "三家和", "suufon_renda" => "四風連打",
56
+ "suucha_riichi" => "四家立直", "suukaikan" => "四開槓" }.freeze
57
+ ABORT_READ = { "kyuushu_kyuuhai" => "キューシュキューハイ", "sanchahou" => "サンチャホー",
58
+ "suufon_renda" => "スーフォンレンダ", "suucha_riichi" => "スーチャリーチ", "suukaikan" => "スーカイカン" }.freeze
59
+
60
+ # The phrasings of each event: [caption, speech] templates; "strong"
61
+ # for the big moments. %{...} are filled by fill (the speech's own
62
+ # values where they are read differently).
63
+ PHRASES = {
64
+ "hello" => ["%{status_line}"],
65
+ "round_start" => [
66
+ "%{round}、親は%{dealer}。ドラは%{dora}。",
67
+ "さあ%{round}が始まります。親は%{dealer}、ドラ%{dora}。",
68
+ "%{round}。%{dealer}の親番です。ドラは%{dora}。"
69
+ ],
70
+ "round_start_game" => [
71
+ "対局開始! 東1局、起家は%{dealer}。ドラは%{dora}。",
72
+ "さあ始まりました。東1局、親は%{dealer}。ドラは%{dora}。"
73
+ ],
74
+ "round_start_oorasu" => [
75
+ "いよいよオーラス! %{round}、親は%{dealer}。トップは%{leader}、%{lead_score}点。",
76
+ "最後の局、オーラスです! %{round}。%{leader}が%{lead_score}点でトップ、逃げ切れるか。",
77
+ "オーラス! %{round}。この一局で決まります。トップは%{leader}。"
78
+ ],
79
+ "round_start_oorasu_again" => [
80
+ "オーラス続行、%{round}。親は%{dealer}。",
81
+ "まだ終わらない! %{round}。親の%{dealer}が粘ります。"
82
+ ],
83
+ "dora_discard" => [
84
+ "%{name}、%{tile}を切りました。",
85
+ "%{name}からドラの%{tile}。",
86
+ "%{name}、%{tile}を手放します。"
87
+ ],
88
+ "red_discard" => [
89
+ "%{name}、赤の%{plain}を切りました。",
90
+ "%{name}から赤ドラ、%{plain}。"
91
+ ],
92
+ "call" => [
93
+ "%{name}、%{call}! %{called}。",
94
+ "%{name}が%{called}を%{call}。",
95
+ "%{call}! %{name}、仕掛けました。"
96
+ ],
97
+ "kan" => [
98
+ "%{name}、%{kind}!",
99
+ "%{kind}です。%{name}がカンをしました。"
100
+ ],
101
+ "riichi" => [
102
+ "%{name}、リーチ! 待ちは%{waits}。",
103
+ "リーチが入りました、%{name}。待ちは%{waits}。",
104
+ "%{name}から%{turn}巡目のリーチ! %{waits}待ち。"
105
+ ],
106
+ "riichi_public" => [
107
+ "%{name}、リーチ!",
108
+ "リーチが入りました、%{name}。",
109
+ "%{name}から%{turn}巡目のリーチ!"
110
+ ],
111
+ "new_dora" => [
112
+ "新ドラは%{dora}。",
113
+ "カンドラがめくれて、ドラは%{dora}。"
114
+ ],
115
+ "wall" => [
116
+ "残り%{left}枚。",
117
+ "山は残り%{left}枚になりました。"
118
+ ],
119
+ "tenpai" => [
120
+ "%{name}、テンパイ。待ちは%{waits}。",
121
+ "%{name}の手がテンパイしました。%{waits}待ち。",
122
+ "%{name}、静かにテンパイ。%{waits}待ちです。"
123
+ ],
124
+ "win" => [
125
+ "%{by_word}! %{name}、%{yaku}。%{points}。",
126
+ "%{name}の%{by_word}! %{yaku}で%{points}。",
127
+ "%{name}、%{from_part}%{by_word}です。%{yaku}、%{points}。"
128
+ ],
129
+ "win_strong" => [
130
+ "%{by_word}!! %{name}、%{level}! %{yaku}、%{points}!",
131
+ "出ました%{level}! %{name}の%{by_word}、%{yaku}。%{points}!",
132
+ "大きい! %{name}、%{from_part}%{by_word}で%{level}! %{yaku}、%{points}!"
133
+ ],
134
+ "draw" => [
135
+ "流局。%{tenpai_line}",
136
+ "ここで流局です。%{tenpai_line}"
137
+ ],
138
+ "abort" => [
139
+ "途中流局、%{kind}。",
140
+ "%{kind}で流局です。"
141
+ ],
142
+ "lead_change" => [
143
+ "逆転! %{name}がトップに立ちました、%{score}点。",
144
+ "トップが入れ替わります! %{name}、%{score}点でトップ。",
145
+ "ここで逆転、%{name}がトップへ!"
146
+ ],
147
+ "game_over" => [
148
+ "終局! トップは%{top}、%{top_score}点。%{ranks}",
149
+ "対局終了。%{top}が%{top_score}点でトップです。%{ranks}"
150
+ ]
151
+ }.freeze
152
+
153
+ # ずんだもん's phrasings (TONES): the same events, the same number of
154
+ # phrasings, the endings 〜なのだ / 〜のだ.
155
+ ZUNDAMON_PHRASES = {
156
+ "hello" => ["%{status_line}"],
157
+ "round_start" => [
158
+ "%{round}なのだ。親は%{dealer}、ドラは%{dora}なのだ。",
159
+ "さあ%{round}が始まるのだ! 親は%{dealer}、ドラ%{dora}なのだ。",
160
+ "%{round}、%{dealer}の親番なのだ。ドラは%{dora}なのだ。"
161
+ ],
162
+ "round_start_game" => [
163
+ "対局開始なのだ! 東1局、起家は%{dealer}。ドラは%{dora}なのだ。",
164
+ "さあ始まったのだ! 東1局、親は%{dealer}。ドラは%{dora}なのだ。"
165
+ ],
166
+ "round_start_oorasu" => [
167
+ "いよいよオーラスなのだ! %{round}、親は%{dealer}。トップは%{leader}、%{lead_score}点なのだ。",
168
+ "最後の局、オーラスなのだ! %{round}。%{leader}が%{lead_score}点でトップ、逃げ切れるのか。",
169
+ "オーラスなのだ! %{round}。この一局で決まるのだ。トップは%{leader}なのだ。"
170
+ ],
171
+ "round_start_oorasu_again" => [
172
+ "オーラス続行なのだ、%{round}。親は%{dealer}なのだ。",
173
+ "まだ終わらないのだ! %{round}。親の%{dealer}が粘るのだ。"
174
+ ],
175
+ "dora_discard" => [
176
+ "%{name}、%{tile}を切ったのだ。",
177
+ "%{name}からドラの%{tile}なのだ。",
178
+ "%{name}、%{tile}を手放したのだ。"
179
+ ],
180
+ "red_discard" => [
181
+ "%{name}、赤の%{plain}を切ったのだ。",
182
+ "%{name}から赤ドラ、%{plain}なのだ。"
183
+ ],
184
+ "call" => [
185
+ "%{name}、%{call}なのだ! %{called}。",
186
+ "%{name}が%{called}を%{call}したのだ。",
187
+ "%{call}なのだ! %{name}、仕掛けたのだ。"
188
+ ],
189
+ "kan" => [
190
+ "%{name}、%{kind}なのだ!",
191
+ "%{kind}なのだ。%{name}がカンをしたのだ。"
192
+ ],
193
+ "riichi" => [
194
+ "%{name}、リーチなのだ! 待ちは%{waits}なのだ。",
195
+ "リーチが入ったのだ、%{name}。待ちは%{waits}なのだ。",
196
+ "%{name}から%{turn}巡目のリーチなのだ! %{waits}待ちなのだ。"
197
+ ],
198
+ "riichi_public" => [
199
+ "%{name}、リーチなのだ!",
200
+ "リーチが入ったのだ、%{name}。",
201
+ "%{name}から%{turn}巡目のリーチなのだ!"
202
+ ],
203
+ "new_dora" => [
204
+ "新ドラは%{dora}なのだ。",
205
+ "カンドラがめくれて、ドラは%{dora}なのだ。"
206
+ ],
207
+ "wall" => [
208
+ "残り%{left}枚なのだ。",
209
+ "山は残り%{left}枚になったのだ。"
210
+ ],
211
+ "tenpai" => [
212
+ "%{name}、テンパイなのだ。待ちは%{waits}なのだ。",
213
+ "%{name}の手がテンパイしたのだ。%{waits}待ちなのだ。",
214
+ "%{name}、静かにテンパイなのだ。%{waits}待ちなのだ。"
215
+ ],
216
+ "win" => [
217
+ "%{by_word}なのだ! %{name}、%{yaku}。%{points}なのだ。",
218
+ "%{name}の%{by_word}なのだ! %{yaku}で%{points}なのだ。",
219
+ "%{name}、%{from_part}%{by_word}なのだ。%{yaku}、%{points}なのだ。"
220
+ ],
221
+ "win_strong" => [
222
+ "%{by_word}なのだ!! %{name}、%{level}なのだ! %{yaku}、%{points}なのだ!",
223
+ "出たのだ、%{level}! %{name}の%{by_word}、%{yaku}。%{points}なのだ!",
224
+ "大きいのだ! %{name}、%{from_part}%{by_word}で%{level}なのだ! %{yaku}、%{points}なのだ!"
225
+ ],
226
+ "draw" => [
227
+ "流局なのだ。%{tenpai_line}",
228
+ "ここで流局なのだ。%{tenpai_line}"
229
+ ],
230
+ "abort" => [
231
+ "途中流局なのだ、%{kind}。",
232
+ "%{kind}で流局なのだ。"
233
+ ],
234
+ "lead_change" => [
235
+ "逆転なのだ! %{name}がトップに立ったのだ、%{score}点。",
236
+ "トップが入れ替わるのだ! %{name}、%{score}点でトップなのだ。",
237
+ "ここで逆転、%{name}がトップなのだ!"
238
+ ],
239
+ "game_over" => [
240
+ "終局なのだ! トップは%{top}、%{top_score}点。%{ranks}",
241
+ "対局終了なのだ。%{top}が%{top_score}点でトップなのだ。%{ranks}"
242
+ ]
243
+ }.freeze
244
+
245
+ # The characters' tones: which VOICEVOX characters speak in each, its
246
+ # phrasings, and how to ask an AI to speak so (C3). Any other
247
+ # character, and the browser's voice, speak plain.
248
+ TONES = {
249
+ "plain" => {
250
+ "speakers" => [],
251
+ "phrases" => PHRASES,
252
+ "prompt" => "落ち着いた実況の口調で、です・ます調で話す。"
253
+ },
254
+ "zundamon" => {
255
+ "speakers" => ["ずんだもん"],
256
+ "phrases" => ZUNDAMON_PHRASES,
257
+ "prompt" => "ずんだもんの口調で話す。語尾は「〜なのだ」「〜のだ」、一人称は「ボク」。明るく元気に、短く。"
258
+ }
259
+ }.freeze
260
+
261
+ # Words a speech synthesiser reads wrongly (VOICEVOX: 流局 ナガレキョク,
262
+ # 起家 オコリカ, 跳満 トベミツル, 三萬 サンヨロズ): the speech and the
263
+ # fixed phrases are read through this (reading), longest first. The
264
+ # tiles and the yaku are read by their own tables already; these are
265
+ # the words of the phrasings and what could slip through. 本場, 供託,
266
+ # 巡目, 親番, 終局, 逆転 are read rightly as they are (checked on
267
+ # VOICEVOX 0.25.0) and stay kanji.
268
+ SPEECH_READ = {
269
+ "途中流局" => "途中リューキョク", "流局" => "リューキョク", "起家" => "チーチャ", "東1局" => "トン1局",
270
+ "三倍満" => "サンバイマン", "跳満" => "ハネマン", "倍満" => "バイマン", "満貫" => "マンガン", "役満" => "ヤクマン",
271
+ "立直" => "リーチ", "一発" => "イッパツ", "裏ドラ" => "ウラドラ", "赤ドラ" => "アカドラ",
272
+ "符" => "フ", "翻" => "ハン", "暗槓" => "アンカン", "加槓" => "カカン",
273
+ "大明槓" => "ダイミンカン", "聴牌" => "テンパイ", "和了" => "ホーラ"
274
+ }.freeze
275
+ SPEECH_READ_RE = Regexp.union(SPEECH_READ.keys.sort_by { |k| -k.size })
276
+
277
+ LEVEL_CLIP = { "満貫" => "mangan", "跳満" => "haneman", "倍満" => "baiman", "三倍満" => "sanbaiman",
278
+ "役満" => "yakuman" }.freeze
279
+
280
+ module_function
281
+
282
+ # The tone of a VOICEVOX character (nil: the browser's voice).
283
+ def tone_for(speaker)
284
+ TONES.find { |_, t| t["speakers"].include?(speaker) }&.first || "plain"
285
+ end
286
+
287
+ def phrases(tone) = (TONES[tone] || TONES["plain"])["phrases"]
288
+
289
+ # The keys of the fixed phrases played before the line of ev
290
+ # (mahjong_voice.yml): the round's name, riichi, the call, ron or
291
+ # tsumo and the level, ... Nothing for what tells a hand (a tenpai)
292
+ # or for the small things (a discard, the wall).
293
+ def clips(ev)
294
+ case ev["type"]
295
+ when "round_start"
296
+ return ["oorasu"] if ev["oorasu"]
297
+ return ["game_start"] if ev["game_start"]
298
+ r = ev["round"]
299
+ r.is_a?(Hash) && r["wind"] && r["number"] ? ["round_#{r['wind']}#{r['number']}"] : []
300
+ when "riichi" then ["riichi"]
301
+ when "call"
302
+ c = ev["call"].to_s
303
+ return ["kan"] if c.end_with?("kan")
304
+ %w[chi pon].include?(c) ? [c] : []
305
+ when "win"
306
+ out = [ev["by"] == "ron" ? "ron" : "tsumo"]
307
+ out << LEVEL_CLIP[ev["level"]] if LEVEL_CLIP[ev["level"]]
308
+ out
309
+ when "draw" then ["ryuukyoku"]
310
+ when "abort" then ["abort"]
311
+ when "lead_change" then ["lead_change"]
312
+ when "game_over" then ["game_over"]
313
+ else []
314
+ end
315
+ end
316
+
317
+ # The line of an event in view (the view the event came from).
318
+ def line(ev, view, rng = Random.new, tone: "plain")
319
+ key, pkey = keys_of(ev)
320
+ book = phrases(tone)
321
+ vals = values(ev, view, tone)
322
+ i = rng.rand(book.fetch(key).size)
323
+ text, speech = fill(book[key][i], vals)
324
+ out = { "type" => ev["type"], "prio" => ev["prio"].to_i, "text" => text, "speech" => speech }
325
+ if ev["hands"]
326
+ out["ptext"] = nil
327
+ out["pspeech"] = nil
328
+ elsif pkey
329
+ pt = book.fetch(pkey)
330
+ out["ptext"], out["pspeech"] = fill(pt[i % pt.size], vals)
331
+ else
332
+ out["ptext"] = text
333
+ out["pspeech"] = speech
334
+ end
335
+ out["clips"] = clips(ev)
336
+ out
337
+ end
338
+
339
+ # The phrasing's key, and the one with the hands hidden (nil: the same).
340
+ def keys_of(ev)
341
+ case ev["type"]
342
+ when "round_start"
343
+ return ["round_start_oorasu_again", nil] if ev["oorasu"] && ev["again"]
344
+ return ["round_start_oorasu", nil] if ev["oorasu"]
345
+ return ["round_start_game", nil] if ev["game_start"]
346
+ ["round_start", nil]
347
+ when "dora_discard" then [ev["red"] && !ev["dora"] ? "red_discard" : "dora_discard", nil]
348
+ when "call" then [ev["call"].to_s.end_with?("kan") ? "kan" : "call", nil]
349
+ when "riichi" then ev["waits"] ? %w[riichi riichi_public] : ["riichi_public", nil]
350
+ when "win" then [ev["level"] ? "win_strong" : "win", nil]
351
+ else [ev["type"], nil]
352
+ end
353
+ end
354
+
355
+ # The values of the templates: {key => [caption, speech]}.
356
+ def values(ev, view, tone = "plain")
357
+ z = tone == "zundamon"
358
+ v = {}
359
+ seats = Array(view.is_a?(Hash) ? view["seats"] : nil)
360
+ name = ->(i) { seat_name(seats, i) }
361
+ both = ->(s) { [s, s] }
362
+ v["name"] = both.call(name.call(ev["seat"])) if ev.key?("seat")
363
+ r = ev["round"] || (view.is_a?(Hash) ? view["round"] : nil)
364
+ if r.is_a?(Hash)
365
+ v["round"] = round_words(r)
366
+ v["dealer"] = both.call(name.call(r["dealer"]))
367
+ end
368
+ if ev["dora"].is_a?(Array)
369
+ d = ev["dora"].compact
370
+ v["dora"] = d.empty? ? %w[なし なし] : tiles_words(d)
371
+ elsif ev["dora"].is_a?(String) && ev["type"] == "new_dora"
372
+ v["dora"] = tile_words(ev["dora"])
373
+ end
374
+ if ev["tile"]
375
+ v["tile"] = tile_words(ev["tile"])
376
+ v["plain"] = tile_words(MahjongEvents.plain(ev["tile"]))
377
+ end
378
+ v["waits"] = tiles_words(ev["waits"]) if ev["waits"]
379
+ v["turn"] = both.call(ev["turn"].to_s) if ev["turn"]
380
+ v["left"] = both.call(ev["left"].to_s) if ev["left"]
381
+ if ev["type"] == "call"
382
+ v["call"] = [CALL_TEXT[ev["call"]] || ev["call"].to_s, CALL_TEXT[ev["call"]] || ev["call"].to_s]
383
+ v["kind"] = [CALL_KIND[ev["call"]] || ev["call"].to_s, CALL_KIND_READ[ev["call"]] || ev["call"].to_s]
384
+ tiles = Array(ev["tiles"])
385
+ v["tiles"] = tiles_words(tiles, sep: "")
386
+ # A pon or a kan is one tile said once; a chi its run.
387
+ v["called"] = ev["call"] == "chi" ? tiles_words(tiles, sep: "") : tile_words(tiles.min_by { |t| t.start_with?("0") ? 1 : 0 } || "")
388
+ end
389
+ if ev["type"] == "win"
390
+ v["by_word"] = ev["by"] == "ron" ? %w[ロン ロン] : %w[ツモ ツモ]
391
+ v["yaku"] = yaku_words(ev["yaku"])
392
+ # The level (満貫 ...) is said by the strong phrasings already.
393
+ v["points"] = points_words(ev["level"] ? ev["points"].to_s.sub(ev["level"].to_s, "").squeeze(" ") : ev["points"])
394
+ v["level"] = [ev["level"].to_s, LEVEL_READ[ev["level"]] || ev["level"].to_s]
395
+ v["from_part"] = ev["by"] == "ron" && ev["from"].is_a?(Integer) ? both.call("#{name.call(ev['from'])}から") : ["", ""]
396
+ end
397
+ if ev["type"] == "draw"
398
+ t = Array(ev["tenpai"])
399
+ v["tenpai_line"] = if t.empty? then both.call(z ? "全員ノーテンなのだ。" : "全員ノーテン。")
400
+ elsif t.size == 4 then both.call(z ? "全員テンパイなのだ。" : "全員テンパイ。")
401
+ else both.call("テンパイは#{t.map { |i| name.call(i) }.join('、')}#{'なのだ' if z}。")
402
+ end
403
+ end
404
+ v["kind"] = [ABORT_TEXT[ev["kind"]] || ev["kind"].to_s, ABORT_READ[ev["kind"]] || ev["kind"].to_s] if ev["type"] == "abort"
405
+ v["score"] = both.call(ev["score"].to_s) if ev["score"]
406
+ if ev["type"] == "game_over"
407
+ fin = Array(ev["final"]).sort_by { |f| f[1].to_i }
408
+ top = fin.first || []
409
+ v["top"] = both.call(name.call(top[0]))
410
+ v["top_score"] = both.call(top[2].to_s)
411
+ rest = fin.drop(1).map { |f| "#{f[1]}位 #{name.call(f[0])} #{f[2]}点" }.join("、")
412
+ v["ranks"] = both.call(rest.empty? ? "" : "#{rest}。")
413
+ end
414
+ if ev["type"] == "round_start"
415
+ sc = seats.map { |s| s["score"].to_i }
416
+ unless sc.empty?
417
+ li = sc.index(sc.max)
418
+ v["leader"] = both.call(name.call(li))
419
+ v["lead_score"] = both.call(sc[li].to_s)
420
+ end
421
+ end
422
+ v["status_line"] = hello_words(ev, view, tone) if ev["type"] == "hello"
423
+ v
424
+ end
425
+
426
+ # The speech as a synthesiser should read it: SPEECH_READ, and a tile
427
+ # name that slipped through (5萬, 赤5筒, or an AI's 三萬 / 五筒) read
428
+ # as a player says it.
429
+ KANJI_NUM = "一二三四五六七八九"
430
+ def reading(speech)
431
+ s = speech.to_s.gsub(/(赤)?([0-9#{KANJI_NUM}])(萬|筒|索)/o) do
432
+ red, d, suit = Regexp.last_match.captures
433
+ d = (KANJI_NUM.index(d) + 1).to_s if KANJI_NUM.include?(d)
434
+ red ||= d == "0"
435
+ d = "5" if d == "0"
436
+ "#{'アカ' if red}#{NUM_READ[d.to_i - 1]}#{SUIT_READ[SUIT_TEXT.key(suit)]}"
437
+ end
438
+ s.gsub(SPEECH_READ_RE) { |w| SPEECH_READ[w] }
439
+ end
440
+
441
+ # Fills a template from the values: [caption, speech].
442
+ def fill(tpl, vals)
443
+ text = tpl.gsub(/%\{(\w+)\}/) { (vals[Regexp.last_match(1)] || ["", ""])[0] }
444
+ speech = tpl.gsub(/%\{(\w+)\}/) { (vals[Regexp.last_match(1)] || ["", ""])[1] }
445
+ [text, reading(speech.delete("!").gsub(/\s+/, " ").strip)]
446
+ end
447
+
448
+ def seat_name(seats, i)
449
+ return "?" unless i.is_a?(Integer)
450
+ n = seats[i].is_a?(Hash) ? seats[i]["name"].to_s.strip : ""
451
+ n.empty? ? "#{i + 1}番の席" : n
452
+ end
453
+
454
+ # 東1局 1本場 (供託 1): [caption, speech].
455
+ def round_words(r)
456
+ w = r["wind"].to_s
457
+ text = "#{WIND_TEXT[w] || w}#{r['number']}局"
458
+ read = "#{WIND_READ[w] || w}#{r['number']}局"
459
+ h = r["honba"].to_i
460
+ if h.positive?
461
+ text += " #{h}本場"
462
+ read += " #{h}本場"
463
+ end
464
+ [text, read]
465
+ end
466
+
467
+ # A tile: 3萬 / 赤5筒 / 東, and サンマン / アカウーピン / トン.
468
+ def tile_words(name)
469
+ n = name.to_s
470
+ m = n.match(/\A([0-9])([mpsz])\z/) or return [n, n]
471
+ d = m[1].to_i
472
+ if m[2] == "z"
473
+ return [HONOR_TEXT[d - 1] || n, HONOR_READ[d - 1] || n]
474
+ end
475
+ red = d.zero?
476
+ d = 5 if red
477
+ ["#{'赤' if red}#{d}#{SUIT_TEXT[m[2]]}", "#{'アカ' if red}#{NUM_READ[d - 1]}#{SUIT_READ[m[2]]}"]
478
+ end
479
+
480
+ def tiles_words(names, sep: "・")
481
+ ws = names.map { |t| tile_words(t) }
482
+ [ws.map(&:first).join(sep), ws.map(&:last).join("、")]
483
+ end
484
+
485
+ # 立直・ドラ 1 / リーチ、ドラ1.
486
+ def yaku_words(list)
487
+ list = Array(list)
488
+ return %w[役なし 役なし] if list.empty?
489
+ text = list.map { |y| y.to_s.strip.squeeze(" ") }.join("・")
490
+ read = list.map { |y| yaku_read(y.to_s) }.join("、")
491
+ [text, read]
492
+ end
493
+
494
+ def yaku_read(y)
495
+ return YAKU_READ[y] if YAKU_READ.key?(y)
496
+ if (m = y.match(/\A(役牌|自風|場風)\s*(\S+)\z/))
497
+ i = HONOR_TEXT.index(m[2])
498
+ tile = i ? HONOR_READ[i] : m[2]
499
+ return { "役牌" => tile, "自風" => "ジカゼ #{tile}", "場風" => "バカゼ #{tile}" }[m[1]]
500
+ end
501
+ if (m = y.match(/\A(赤ドラ|裏ドラ|ドラ)\s*(\d+)\z/))
502
+ return "#{{ '赤ドラ' => 'アカドラ', '裏ドラ' => 'ウラドラ', 'ドラ' => 'ドラ' }[m[1]]}#{m[2]}"
503
+ end
504
+ y
505
+ end
506
+
507
+ # 30符 2翻 2000点 → the same, read: 30フ 2ハン 2000点; 満貫 → マンガン.
508
+ def points_words(pts)
509
+ text = pts.to_s.strip
510
+ read = text.gsub("符", "フ").gsub("翻", "ハン").gsub("点オール", "点オール").gsub("/", "、")
511
+ LEVEL_READ.each { |k, r| read = read.gsub(k, r) }
512
+ [text, read]
513
+ end
514
+
515
+ # What the first view says: where the table is.
516
+ def hello_words(ev, _view, tone = "plain")
517
+ z = tone == "zundamon"
518
+ case ev["status"]
519
+ when "play", "result"
520
+ r = ev["round"] || {}
521
+ rt, rr = round_words(r)
522
+ left = r["wall_left"]
523
+ sticks = r["riichi_sticks"].to_i
524
+ extra = +""
525
+ extra << "、供託#{sticks}本" if sticks.positive?
526
+ extra << "、残り#{left}枚" if left
527
+ return ["観戦を始めるのだ! #{rt}#{extra}なのだ。", "観戦を始めるのだ! #{rr}#{extra}なのだ。"] if z
528
+ ["観戦を始めます。#{rt}#{extra}。", "観戦を始めます。#{rr}#{extra}。"]
529
+ when "over" then [z ? "観戦を始めるのだ! 対局は終わったところなのだ。" : "観戦を始めます。対局は終わったところです。"] * 2
530
+ else [z ? "観戦を始めるのだ! 卓は対局の始まりを待っているのだ。" : "観戦を始めます。卓は対局の始まりを待っています。"] * 2
531
+ end
532
+ end
533
+ end
534
+ end
535
+ end
@@ -0,0 +1,93 @@
1
+ # The fixed phrases of the mahjong commentary (README "Commentary", "The
2
+ # voice"): played at once as WAV files while the rest of a line is being
3
+ # synthesised. No sound is in the gem: `asterism-games commentate voices
4
+ # --make` (and the commentary at its start) has this machine's VOICEVOX
5
+ # say each phrase into ~/.cache/asterism-games/voices/<speaker>-<style>/.
6
+ #
7
+ # <key>:
8
+ # word: the word as the line's own speech begins with it; the line
9
+ # leaves it out when the phrase was played before it
10
+ # plain: [the phrasings in a plain tone] (one is chosen at random)
11
+ # zundamon: [the same in ずんだもん's tone (〜なのだ)]
12
+ #
13
+ # A tone without phrasings of its own uses plain's. The phrasings are
14
+ # written as they are read (トン1局, not 東1局). Change a phrasing and its
15
+ # file is made again (the files are named by the phrasing's digest).
16
+ start:
17
+ plain: [実況を始めます。]
18
+ zundamon: [実況を始めるのだ!]
19
+ game_start:
20
+ word: 対局開始
21
+ plain: [対局開始!]
22
+ zundamon: [対局開始なのだ!]
23
+ oorasu:
24
+ word: オーラス
25
+ plain: [オーラス!]
26
+ zundamon: [オーラスなのだ!]
27
+ round_east1: { word: トン1局, plain: [トン1局。], zundamon: [トン1局なのだ。] }
28
+ round_east2: { word: トン2局, plain: [トン2局。], zundamon: [トン2局なのだ。] }
29
+ round_east3: { word: トン3局, plain: [トン3局。], zundamon: [トン3局なのだ。] }
30
+ round_east4: { word: トン4局, plain: [トン4局。], zundamon: [トン4局なのだ。] }
31
+ round_south1: { word: ナン1局, plain: [ナン1局。], zundamon: [ナン1局なのだ。] }
32
+ round_south2: { word: ナン2局, plain: [ナン2局。], zundamon: [ナン2局なのだ。] }
33
+ round_south3: { word: ナン3局, plain: [ナン3局。], zundamon: [ナン3局なのだ。] }
34
+ round_south4: { word: ナン4局, plain: [ナン4局。], zundamon: [ナン4局なのだ。] }
35
+ round_west1: { word: シャー1局, plain: [シャー1局。], zundamon: [シャー1局なのだ。] }
36
+ round_west2: { word: シャー2局, plain: [シャー2局。], zundamon: [シャー2局なのだ。] }
37
+ round_west3: { word: シャー3局, plain: [シャー3局。], zundamon: [シャー3局なのだ。] }
38
+ round_west4: { word: シャー4局, plain: [シャー4局。], zundamon: [シャー4局なのだ。] }
39
+ riichi:
40
+ word: リーチ
41
+ plain: [リーチ!]
42
+ zundamon: [リーチなのだ!, リーチしたのだ!]
43
+ chi:
44
+ word: チー
45
+ plain: [チー!]
46
+ zundamon: [チーなのだ!]
47
+ pon:
48
+ word: ポン
49
+ plain: [ポン!]
50
+ zundamon: [ポンなのだ!]
51
+ kan:
52
+ word: カン
53
+ plain: [カン!]
54
+ zundamon: [カンなのだ!]
55
+ ron:
56
+ word: ロン
57
+ plain: [ロン!]
58
+ zundamon: [ロンなのだ!]
59
+ tsumo:
60
+ word: ツモ
61
+ plain: [ツモ!]
62
+ zundamon: [ツモなのだ!]
63
+ mangan:
64
+ plain: [満貫!]
65
+ zundamon: [満貫なのだ!]
66
+ haneman:
67
+ plain: [跳満!]
68
+ zundamon: [跳満なのだ!]
69
+ baiman:
70
+ plain: [倍満!]
71
+ zundamon: [倍満なのだ!]
72
+ sanbaiman:
73
+ plain: [三倍満!]
74
+ zundamon: [三倍満なのだ!]
75
+ yakuman:
76
+ plain: [役満!]
77
+ zundamon: [役満なのだ!, 役満が出たのだ!]
78
+ ryuukyoku:
79
+ word: 流局
80
+ plain: [流局。]
81
+ zundamon: [流局なのだ。]
82
+ abort:
83
+ word: 途中流局
84
+ plain: [途中流局。]
85
+ zundamon: [途中流局なのだ。]
86
+ lead_change:
87
+ word: 逆転
88
+ plain: [逆転!]
89
+ zundamon: [逆転なのだ!]
90
+ game_over:
91
+ word: 終局
92
+ plain: [終局!]
93
+ zundamon: [終局なのだ!]