asterism-games 0.17.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.
@@ -0,0 +1,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mahjong_text"
4
+
5
+ module AsterismGames
6
+ module Play
7
+ # The mahjong table as lines of text, from a member's view (README
8
+ # "Views"): the lobby, the round (the four seats with their rivers and
9
+ # melds, the hand with its letters, what can be done now), the result
10
+ # and the end of the game. The words are the table app's (fmruby-core
11
+ # flash/app/game/mahjong): 東1局, 本場, 供託, ドラ, リーチ, ポン ...
12
+ class MahjongScreen
13
+ include MahjongText
14
+
15
+ KIND_NAMES = { 1 => "チー", 2 => "ポン", 3 => "暗槓", 4 => "明槓", 5 => "加槓" }.freeze
16
+ CALL_NAMES = { RIICHI => "リーチ", TSUMO => "ツモ", ANKAN => "カン", KAKAN => "カン", DAIMINKAN => "カン",
17
+ PON => "ポン", CHI => "チー", RON => "ロン", KYUUSHU => "九種九牌", SANCHAHOU => "三家和" }.freeze
18
+ DRAWS = { "kyuushu_kyuuhai" => "九種九牌で流局", "sanchahou" => "三家和で流局", "suufon_renda" => "四風連打で流局",
19
+ "suukaikan" => "四槓散了で流局", "suucha_riichi" => "四家立直で流局" }.freeze
20
+ ENDS = { "tobi" => "トビ", "all_last_oya_agari" => "アガリ止め", "all_last_oya_tenpai" => "テンパイ止め",
21
+ "sudden_death" => "サドンデス", "west" => "西4局まで", "rounds" => "局数の上限",
22
+ "normal_end" => "最後の局まで" }.freeze
23
+ STATES = { "wait" => "待ち合わせ", "play" => "対局中", "result" => "対局中", "over" => "終局" }.freeze
24
+
25
+ attr_reader :style
26
+
27
+ # style: "kanji" (the default), "emoji" or "mpsz"; cursor: a terminal (columns by CHA, the
28
+ # red fives in red).
29
+ def initialize(style: "kanji", cursor: false, path: "")
30
+ @style = style
31
+ @cursor = cursor
32
+ @path = path
33
+ # A tile's slot in a run with letters under it (the hand): two
34
+ # columns for an emoji, three for "1m" or the kanji style's
35
+ # two-row tile, the room after it included.
36
+ @slot = style == "emoji" ? 2 : 3
37
+ end
38
+
39
+ # The width of a screen line the rivers and melds wrap at.
40
+ COLS = 80
41
+
42
+ # A run of tiles on one line, or in the kanji style on two (the number
43
+ # above, the suit below), with text before it; each tile in its slot.
44
+ class Strip
45
+ def initialize(screen, two)
46
+ @screen = screen
47
+ @top = screen.row
48
+ @bottom = two ? screen.row : nil
49
+ end
50
+
51
+ def col = @top.col
52
+
53
+ def text(s)
54
+ @top.text(s)
55
+ @bottom&.cell("", MahjongText.width(s))
56
+ self
57
+ end
58
+
59
+ def to(col)
60
+ @top.to(col)
61
+ @bottom&.to(col)
62
+ self
63
+ end
64
+
65
+ def blank(cols)
66
+ @top.cell("", cols)
67
+ @bottom&.cell("", cols)
68
+ self
69
+ end
70
+
71
+ def tile(code, cols)
72
+ if @bottom
73
+ up, down = MahjongText.kanji_rows(code)
74
+ @top.cell(@screen.paint(up, code), cols)
75
+ @bottom.cell(@screen.paint(down, code), cols)
76
+ else
77
+ @top.cell(@screen.paint(MahjongText.tile(code, @screen.style), code), cols)
78
+ end
79
+ self
80
+ end
81
+
82
+ def lines
83
+ [@top.to_s.rstrip, @bottom&.to_s&.rstrip].compact
84
+ end
85
+ end
86
+
87
+ # The screen: lines (Strings). note: the last answer or news; pending:
88
+ # a choice being made ([type, options]); keys: the client reads keys
89
+ # (README "Keys"): {sel: the chosen hand tile, pick: riichi's choosing},
90
+ # the cursor under the hand instead of the letters and the keys at the
91
+ # bottom (nil: the commands typed).
92
+ def lines(v, note: "", pending: nil, keys: nil)
93
+ return ["卓: #{v['error']}"] if v["error"]
94
+ @v = v
95
+ @keys = keys
96
+ @me = v["me"].to_i
97
+ out = case v["st"]
98
+ when "wait" then lobby
99
+ when "play" then round
100
+ when "result" then round(top_only: true) + [""] + result
101
+ when "over" then round(top_only: true) + [""] + result + [""] + over
102
+ else ["#{@path}: #{v['st']}"]
103
+ end
104
+ out += choice_lines(pending) if pending
105
+ out << pick_line if keys && keys[:pick] && v["st"] == "play"
106
+ out << " #{note}" unless note.to_s.empty?
107
+ out << hint
108
+ out
109
+ end
110
+
111
+ # ------------------------------------------------------------ parts
112
+
113
+ def name_of(seat)
114
+ return "自分" if seat == @me
115
+ n = (@v["nm"] || [])[seat].to_s
116
+ n.empty? ? "空き" : n
117
+ end
118
+
119
+ def oya = (@v["r"] || [])[4].to_i
120
+
121
+ def wind_of(seat) = WINDS[(seat - oya) % 4]
122
+
123
+ def seat_order
124
+ @me >= 0 ? [1, 2, 3, 0].map { |d| (@me + d) % 4 } : (0..3).map { |d| (oya + d) % 4 }
125
+ end
126
+
127
+ def row = Row.new(cursor: @cursor)
128
+
129
+ # The red five in red on a terminal.
130
+ def paint(text, code)
131
+ @cursor && red?(code) && !text.empty? ? "\e[31m#{text}\e[0m" : text
132
+ end
133
+
134
+ # A tile on one line, in cols columns (default: its own width and a
135
+ # space; the kanji style's 一萬 takes five, 東 three).
136
+ def tile_cell(r, code, cols = line_cols(code))
137
+ r.cell(paint(tile(code, @style), code), cols)
138
+ end
139
+
140
+ def line_cols(code)
141
+ @style == "kanji" ? width(tile(code, @style)) + 1 : @slot
142
+ end
143
+
144
+ # A run of tiles with letters or a hand: two rows in the kanji style.
145
+ def strip = Strip.new(self, @style == "kanji")
146
+
147
+ def bytes(s) = s.to_s.b.unpack("C*")
148
+
149
+ def round_name(rd)
150
+ "#{WINDS[rd[0].to_i % 4]}#{rd[1]}局 #{rd[2]}本場"
151
+ end
152
+
153
+ def seconds(ms) = (ms.to_i + 999) / 1000
154
+
155
+ # ------------------------------------------------------------ lobby
156
+
157
+ def lobby
158
+ v = @v
159
+ out = ["#{v['t'] || @path} 待ち合わせ#{" ゲームホスト #{v['hv']}" if v['hv']}"]
160
+ sk = v["sk"] || [0, 0, 0, 0]
161
+ ai = v["ai"] || []
162
+ 4.times do |s|
163
+ tags = []
164
+ tags << "卓の主" if v["ow"] == s
165
+ tags << (ai[s].to_s.empty? ? "CPU" : "AI") if sk[s] == 2
166
+ tags << "途切れ" if v["aw"].to_i[s] == 1
167
+ tags << "自分" if s == @me
168
+ n = sk[s].zero? ? "空き" : (v["nm"] || [])[s].to_s
169
+ out << " 席#{s + 1} #{n}#{" (#{tags.join(', ')})" unless tags.empty?}"
170
+ end
171
+ out << " ルール: #{rules_text}" if v["ru"]
172
+ watchers = (v["mb"] || []).select { |m| m[1] == -1 }.map(&:first)
173
+ out << " 観戦: #{watchers.join(' ')}" unless watchers.empty?
174
+ out
175
+ end
176
+
177
+ def rules_text
178
+ ru = @v["ru"]
179
+ game = ru[0].to_i == 1 ? "東風" : "半荘"
180
+ kuitan = ru[1].to_i == 1 ? "あり" : "なし"
181
+ bank = @v["tb"].to_i
182
+ time = bank.positive? ? "#{ru[10]}+#{bank}秒" : "#{ru[10]}秒"
183
+ "#{game} 喰いタン#{kuitan} 赤#{ru[2]} 持ち時間 #{time}"
184
+ end
185
+
186
+ # ------------------------------------------------------------ round
187
+
188
+ def round(top_only: false)
189
+ v = @v
190
+ r = v["r"] || []
191
+ out = []
192
+ if r.size >= 4
193
+ h = row.text("#{round_name(r)} 供託#{r[3]} 残り#{r[5]} ドラ ")
194
+ bytes(v["dora"]).each { |t| tile_cell(h, t) }
195
+ out << h.to_s.rstrip
196
+ end
197
+ return out if top_only
198
+ names = seat_order.map { |s| name_of(s) }
199
+ nw = [[names.map { |n| width(n) }.max.to_i, 4].max, 10].min
200
+ river_col = 2 + 1 + nw + 1 + 6 + 2
201
+ seat_order.each do |s|
202
+ out.concat(seat_lines(s, nw, river_col))
203
+ out.concat(meld_lines(s, river_col))
204
+ out.concat(hand_lines(s, river_col)) if @me.negative? && v["sh"]
205
+ end
206
+ if (cl = v["cl"]) && cl.size > 2 && CALL_NAMES[cl[0]]
207
+ seats = cl[0] == SANCHAHOU ? cl[2..] : [cl[2]]
208
+ out << " #{seats.map { |s| "#{wind_of(s)} #{name_of(s)}" }.join('、')}: #{CALL_NAMES[cl[0]]}"
209
+ end
210
+ if @me >= 0 && v["h"]
211
+ out << ""
212
+ out.concat(my_hand)
213
+ end
214
+ out
215
+ end
216
+
217
+ # A seat's line and its river (on more lines when it is longer than
218
+ # COLS: the kanji style's tiles take five columns).
219
+ def seat_lines(s, nw, river_col)
220
+ v = @v
221
+ sc = (v["sc"] || [])[s]
222
+ mark = v["ri"].to_i[s] == 1 ? "*" : " "
223
+ away = v["aw"].to_i[s] == 1 ? " (途切れ)" : ""
224
+ out = []
225
+ r = row.text("#{wind_of(s)} ").text(fit(name_of(s), nw)).text(" #{sc.to_s.rjust(6)}#{mark}")
226
+ r.to(river_col)
227
+ riichi_next = false
228
+ bytes((v["kw"] || [])[s]).each do |b|
229
+ code = b & 0x3F
230
+ riichi = b & 0x40 != 0 || riichi_next
231
+ if b & 0x80 != 0 # called away: the tile is in the meld (a riichi mark moves on)
232
+ riichi_next = riichi
233
+ next
234
+ end
235
+ riichi_next = false
236
+ inner = @style == "kanji" ? width(tile(code, @style)) : 2
237
+ need = riichi ? inner + 3 : line_cols(code)
238
+ if r.col + need > COLS
239
+ out << r.to_s.rstrip
240
+ r = row.to(river_col)
241
+ end
242
+ if riichi
243
+ r.cell("[", 1)
244
+ tile_cell(r, code, inner)
245
+ r.cell("]", 2)
246
+ else
247
+ tile_cell(r, code)
248
+ end
249
+ end
250
+ r.text(away) unless away.empty?
251
+ out << r.to_s.rstrip
252
+ end
253
+
254
+ # The melds of a seat: each meld's tiles (a closed kan with its ends
255
+ # face down).
256
+ def melds_of(s)
257
+ b = bytes((@v["fu"] || [])[s])
258
+ out = []
259
+ i = 0
260
+ while i + 2 < b.size
261
+ kind = b[i]
262
+ n = b[i + 2]
263
+ tiles = b[i + 3, n]
264
+ tiles = [BACK, *tiles[1..-2], BACK] if kind == 3 && tiles.size == 4
265
+ out << tiles
266
+ i += 3 + n
267
+ end
268
+ out
269
+ end
270
+
271
+ # The melds of a seat, under its river (a meld kept on one line).
272
+ def meld_lines(s, river_col)
273
+ melds = melds_of(s)
274
+ return [] if melds.empty?
275
+ out = []
276
+ r = row.text(" 鳴き").to(river_col)
277
+ melds.each do |tiles|
278
+ need = tiles.sum { |t| line_cols(t) } + 1
279
+ if r.col + need > COLS && r.col > river_col
280
+ out << r.to_s.rstrip
281
+ r = row.to(river_col)
282
+ end
283
+ tiles.each { |t| tile_cell(r, t) }
284
+ r.cell("", 1)
285
+ end
286
+ out << r.to_s.rstrip
287
+ end
288
+
289
+ # A spectator's lines of a seat's hand (sh, sd).
290
+ def hand_lines(s, river_col)
291
+ r = strip.text(" 手牌").to(river_col)
292
+ bytes((@v["sh"] || [])[s]).each { |t| r.tile(t, @slot) }
293
+ d = (@v["sd"] || [])[s].to_i
294
+ if d >= 0 && (@v["sd"] || [])[s]
295
+ r.blank(@slot)
296
+ r.tile(d, @slot)
297
+ end
298
+ r.lines
299
+ end
300
+
301
+ # The member's hand and drawn tile, the letters under them; the red
302
+ # fives and the riichi help (wt) after.
303
+ def my_hand
304
+ h = bytes(@v["h"])
305
+ dt = @v["dt"].to_i
306
+ tiles = strip
307
+ letters = row
308
+ # Keys: "^^" under the chosen tile while a discard is offered, in
309
+ # place of the letters.
310
+ cur = @keys && MahjongText.choosing?(@v) ? MahjongText.chosen(@v, @keys[:sel]) : nil
311
+ mark = ->(i) { @keys ? (i == cur ? "^^" : "") : LETTERS[i] }
312
+ h.each_with_index do |t, i|
313
+ tiles.tile(t, @slot)
314
+ letters.cell(mark.call(i), @slot)
315
+ end
316
+ if dt >= 0 && @v.key?("dt")
317
+ tiles.blank(@slot)
318
+ letters.cell("", @slot)
319
+ tiles.tile(dt, @slot)
320
+ letters.cell(mark.call(h.size), @slot)
321
+ end
322
+ out = tiles.lines + [letters.to_s.rstrip]
323
+ all = h + (dt >= 0 && @v.key?("dt") ? [dt] : [])
324
+ reds = all.each_index.select { |i| red?(all[i]) }.map { |i| LETTERS[i] }
325
+ # (With keys there are no letters to name; the red fives are red.)
326
+ out << "赤: #{reds.join(' ')}" if !reds.empty? && @style != "mpsz" && !@keys
327
+ out.concat(waits_lines(all))
328
+ out
329
+ end
330
+
331
+ # wt (README "Assist"): per discard the tile, n, n pairs [wait, left].
332
+ def waits_lines(all)
333
+ b = bytes(@v["wt"])
334
+ return [] if b.empty?
335
+ parts = []
336
+ i = 0
337
+ while i + 1 < b.size
338
+ t = b[i]
339
+ n = b[i + 1]
340
+ pairs = b[i + 2, n * 2].each_slice(2).map { |w, left| "#{mpsz(w)}(#{left})" }
341
+ letter = all.index(t)
342
+ what = if @keys then tile(t, @style)
343
+ elsif letter then LETTERS[letter]
344
+ else mpsz(t)
345
+ end
346
+ parts << "#{what}→ #{pairs.join(' ')}"
347
+ i += 2 + n * 2
348
+ end
349
+ ["待ち: #{parts.join(' ')}"]
350
+ end
351
+
352
+ # ------------------------------------------------------------ result
353
+
354
+ def result
355
+ res = @v["res"] or return []
356
+ out = []
357
+ rd = res["rd"]
358
+ head = rd.is_a?(Array) && rd.size > 2 ? "#{round_name(rd)} " : ""
359
+ wins = res["wn"] || []
360
+ if wins.empty?
361
+ k = res["k"].to_s
362
+ h = DRAWS[k] || "流局"
363
+ ng = res["ng"] || []
364
+ h += " 流し満貫: #{ng.map { |s| name_of(s) }.join(' ')}" unless ng.empty?
365
+ out << "#{head}#{h}"
366
+ end
367
+ wins.each do |w|
368
+ ws = w["w"].to_i
369
+ what = if res["k"] == "tsumo_agari"
370
+ "#{wind_of(ws)} #{name_of(ws)} ツモ"
371
+ else
372
+ f = res["f"]
373
+ "#{wind_of(ws)} #{name_of(ws)} ロン#{" (#{wind_of(f)} #{name_of(f)} から)" if f}"
374
+ end
375
+ out << "#{head}#{what}"
376
+ r = strip.text(" ")
377
+ bytes(w["h"]).each { |t| r.tile(t, @slot) }
378
+ r.blank(@slot)
379
+ r.tile(w["ag"].to_i, @slot)
380
+ melds_of(ws).each do |tiles|
381
+ r.blank(@slot)
382
+ tiles.each { |t| r.tile(t, @slot) }
383
+ end
384
+ out.concat(r.lines)
385
+ ys = w["y"] || []
386
+ ys.each_index { |i| out << " #{yaku_label(w, i)}" }
387
+ pao = (w["pa"] || []).map { |pa| "#{name_of(pa[0].to_i)} (#{pa[1]})" }
388
+ out << " #{w['pt']}#{" 包: #{pao.join(' ')}" unless pao.empty?}"
389
+ end
390
+ out << "点数:"
391
+ d = res["d"] || [0, 0, 0, 0]
392
+ sc = @v["sc"] || [0, 0, 0, 0]
393
+ tp = res["tp"]
394
+ nw = [[(0..3).map { |s| width(name_of(s)) }.max, 4].max, 10].min
395
+ seat_order.each do |s|
396
+ dv = d[s].to_i
397
+ after = sc[s].to_i
398
+ mark = tp.is_a?(Array) && res["k"] == "ryuukyoku" ? (tp.include?(s) ? " テンパイ" : " ノーテン") : ""
399
+ change = dv.zero? ? "±0" : format("%+d", dv)
400
+ out << " #{wind_of(s)} #{fit(name_of(s), nw)} #{(after - dv).to_s.rjust(6)} → #{after.to_s.rjust(6)} #{change}#{mark}"
401
+ end
402
+ if (ok = @v["ok"]).is_a?(Array) && @v["st"] == "result"
403
+ done = ok[0].to_i.digits(2).count(1)
404
+ all = (ok[0].to_i | ok[1].to_i).digits(2).count(1)
405
+ out << "OK を待っています (#{done}/#{all}) あと #{seconds(ok[2])} 秒"
406
+ end
407
+ out
408
+ end
409
+
410
+ # "断么九 1翻", "ドラ 2翻" (the engine names the dora with its count),
411
+ # "大三元 役満".
412
+ def yaku_label(w, i)
413
+ s = w["y"][i].to_s
414
+ h = (w["yh"] || [])[i]
415
+ return s if h.nil?
416
+ s = s.sub(/ \d+\z/, "")
417
+ return "#{s} ダブル役満" if h <= -2
418
+ h.negative? ? "#{s} 役満" : "#{s} #{h}翻"
419
+ end
420
+
421
+ def over
422
+ why = ENDS[@v["fr"].to_s]
423
+ out = ["終局#{" (#{why})" if why}"]
424
+ (@v["fin"] || []).sort_by { |f| f[1] }.each do |seat, rank, score, pt|
425
+ out << format(" %d位 %s %6d (%+.1f)", rank, fit(name_of(seat), 10), score, pt.to_i / 10.0)
426
+ end
427
+ out
428
+ end
429
+
430
+ # ------------------------------------------------------------ the bottom
431
+
432
+ def choice_lines(pending)
433
+ type, opts = pending
434
+ if type == RIICHI
435
+ list = opts.map do |_, ts|
436
+ l = MahjongText.hand_tiles(@v).index(ts[0])
437
+ "#{l ? LETTERS[l] : ''} #{tile(ts[0], @style)}"
438
+ end
439
+ return ["リーチ: 切る牌を選ぶ (文字か 6s): #{list.join(' ')}"]
440
+ end
441
+ list = opts.each_with_index.map do |(t, ts), i|
442
+ name = CALL_NAMES[t] || t.to_s
443
+ tiles = ts.empty? ? "" : " #{ts.map { |x| tile(x, @style) }.join(@style == 'mpsz' ? '' : ' ')}"
444
+ "#{i + 1}: #{name}#{tiles}"
445
+ end
446
+ ["どれにする (番号か牌): #{list.join(' ')}"]
447
+ end
448
+
449
+ # Riichi with keys: the tiles that may be discarded with it.
450
+ def pick_line
451
+ ts = MahjongText.options(@v).select { |t, _| t == RIICHI }.map { |_, x| tile(x[0], @style) }
452
+ "リーチ: 切る牌を選ぶ (#{ts.join(' ')})"
453
+ end
454
+
455
+ # The keys that work everywhere, at the end of the last line.
456
+ COMMON_KEYS = "[:]打ち込む [Q]閉じる"
457
+
458
+ # What can be done now (the last line before the prompt).
459
+ def hint
460
+ return key_hint if @keys
461
+ v = @v
462
+ opts = MahjongText.options(v)
463
+ types = opts.map(&:first).uniq
464
+ case v["st"]
465
+ when "wait"
466
+ return "観戦中: sit で座る、quit で出る" if @me.negative?
467
+ owner = v["ow"] == @me
468
+ return "stand、cpu [席] (空きを CPU で)、nocpu 席、tonpuu / hanchan、start、quit" if owner
469
+ return "4 席がそろったら卓の主が始める (stand、quit)"
470
+ when "over"
471
+ return "ok で待ち合わせに戻す (quit で出る)" if types.include?(NEXT)
472
+ return "次へ: 卓の主が待ち合わせに戻す (quit で出る)"
473
+ when "result"
474
+ return "ok で次へ" if types.include?(NEXT)
475
+ return @me.negative? ? "観戦中" : "次の局を待っています"
476
+ end
477
+ r = v["r"] || []
478
+ return "#{round_name(r)} が始まります" if v["in"]
479
+ return "観戦中 (quit で出る)#{turn_text(r)}" if @me.negative?
480
+ if types.include?(DAHAI)
481
+ words = ["打つ牌 (文字か 6s)"]
482
+ words << "riichi" if types.include?(RIICHI)
483
+ words << "tsumo" if types.include?(TSUMO)
484
+ words << "kan" if (types & KANS).any?
485
+ words << "kyuushu" if types.include?(KYUUSHU)
486
+ return "自分の番: #{words.join('、')}#{time_text}"
487
+ end
488
+ unless types.empty?
489
+ words = []
490
+ words << "ron" if types.include?(RON)
491
+ words << "pon" if types.include?(PON)
492
+ words << "chi" if types.include?(CHI)
493
+ words << "kan" if (types & KANS).any?
494
+ words << "tsumo" if types.include?(TSUMO)
495
+ words << "pass" if types.include?(SKIP)
496
+ ls = v["ls"]
497
+ what = ls ? " (#{wind_of(ls[0])} #{name_of(ls[0])} の #{tile(ls[1], @style)})" : ""
498
+ return "鳴ける#{what}: #{words.join('、')}#{time_text}"
499
+ end
500
+ dl = v["dl"]
501
+ return "ほかの人の答えを待っています" if r[7] == 2 && dl && dl[0].to_i & (1 << @me) == 0 && dl[0].to_i.positive?
502
+ turn_text(r).strip
503
+ end
504
+
505
+ # The last line with keys (README "Keys"): each key in [ ] before what
506
+ # it does, as the table app labels its buttons.
507
+ def key_hint
508
+ v = @v
509
+ opts = MahjongText.options(v)
510
+ types = opts.map(&:first).uniq
511
+ case v["st"]
512
+ when "wait" then return lobby_keys
513
+ when "over"
514
+ return "[Enter]待ち合わせに戻す #{COMMON_KEYS}" if types.include?(NEXT)
515
+ return "次へ: 卓の主が待ち合わせに戻す #{COMMON_KEYS}"
516
+ when "result"
517
+ return "[Enter]OK #{COMMON_KEYS}" if types.include?(NEXT)
518
+ return "#{@me.negative? ? '観戦中' : '次の局を待っています'} #{COMMON_KEYS}"
519
+ end
520
+ r = v["r"] || []
521
+ return "#{round_name(r)} が始まります #{COMMON_KEYS}" if v["in"]
522
+ return "観戦中#{turn_text(r)} #{COMMON_KEYS}" if @me.negative?
523
+ if @keys[:pick] && types.include?(RIICHI)
524
+ return "リーチ: [←][→]選ぶ [Enter]切ってリーチ [Esc]やめる#{time_text} #{COMMON_KEYS}"
525
+ end
526
+ if types.include?(DAHAI)
527
+ words = ["[←][→]選ぶ", "[Enter]打つ"]
528
+ words.concat(offer_words(opts, [RIICHI, TSUMO, *KANS, KYUUSHU]))
529
+ return "自分の番: #{words.join(' ')}#{time_text} #{COMMON_KEYS}"
530
+ end
531
+ unless types.empty?
532
+ words = offer_words(opts, [RON, PON, CHI, *KANS, TSUMO, SKIP])
533
+ ls = v["ls"]
534
+ what = ls ? " (#{wind_of(ls[0])} #{name_of(ls[0])} の #{tile(ls[1], @style)})" : ""
535
+ return "鳴ける#{what}: #{words.join(' ')}#{time_text} #{COMMON_KEYS}"
536
+ end
537
+ dl = v["dl"]
538
+ if r[7] == 2 && dl && dl[0].to_i & (1 << @me) == 0 && dl[0].to_i.positive?
539
+ return "ほかの人の答えを待っています #{COMMON_KEYS}"
540
+ end
541
+ "#{turn_text(r).strip} #{COMMON_KEYS}".strip
542
+ end
543
+
544
+ # "[T]ツモ", "[C]チー 3m4m", "[K]カン 東": the offers of the types in
545
+ # that order (each chi and kan with its key and tiles; one riichi
546
+ # however many discards declare it).
547
+ def offer_words(opts, order)
548
+ seen = {}
549
+ out = []
550
+ order.each do |type|
551
+ opts.each do |t, ts|
552
+ next unless t == type
553
+ key = MahjongText.offer_key_name(opts, t, ts)
554
+ next unless key
555
+ next if [RIICHI, PON].include?(t) && seen[t]
556
+ seen[t] = true
557
+ name = CALL_NAMES[t] || { SKIP => "パス" }[t] || t.to_s
558
+ tiles = case t
559
+ when CHI then " #{ts.map { |x| tile(x, @style) }.join(@style == 'mpsz' ? '' : ' ')}"
560
+ when ANKAN, KAKAN then " #{tile(ts[0], @style)}"
561
+ else ""
562
+ end
563
+ out << "[#{key}]#{name}#{tiles}"
564
+ end
565
+ end
566
+ out
567
+ end
568
+
569
+ # The lobby's keys: 1-4 for the seats that may be taken (empty or a
570
+ # CPU's), S stand, C CPUs and Enter start (the owner).
571
+ def lobby_keys
572
+ v = @v
573
+ sk = v["sk"] || [0, 0, 0, 0]
574
+ free = (0..3).select { |s| sk[s] != 1 && s != @me }.map { |s| "[#{s + 1}]" }
575
+ words = []
576
+ words << "#{free.join}座る" unless free.empty?
577
+ if @me >= 0
578
+ words << "[S]立つ"
579
+ if v["ow"] == @me
580
+ words << "[C]CPU で埋める" if sk.any?(&:zero?)
581
+ words << "[Enter]始める" unless sk.any?(&:zero?)
582
+ else
583
+ words << "(卓の主が始める)"
584
+ end
585
+ end
586
+ words << COMMON_KEYS
587
+ "#{@me.negative? ? '観戦中: ' : ''}#{words.join(' ')}"
588
+ end
589
+
590
+ def turn_text(r)
591
+ cur = r[6]
592
+ return "" if cur.nil? || cur.negative? || r[7].to_i >= 2
593
+ " #{wind_of(cur)}家 (#{name_of(cur)}) の番です"
594
+ end
595
+
596
+ def time_text
597
+ dl = @v["dl"]
598
+ return "" unless dl.is_a?(Array) && @me >= 0 && dl[0].to_i[@me] == 1
599
+ ms = dl[1].to_i
600
+ bank = dl[2].to_i
601
+ # The move's own time first, then the bank (the last part of ms).
602
+ return " あと #{seconds([ms - bank, 0].max)}+#{seconds([bank, ms].min)}秒" if bank.positive?
603
+ " あと #{seconds(ms)}秒"
604
+ end
605
+ end
606
+ end
607
+ end