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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module AsterismGames
6
+ module Play
7
+ # One key at a time from a terminal (README "Playing in a terminal",
8
+ # "Keys"): the terminal is put in raw mode, the bytes that come are
9
+ # turned into names (:up :down :left :right :enter :space :esc :ctrl_c
10
+ # :ctrl_q :backspace :tab, or the character typed as a String). Only on
11
+ # a terminal: a pipe, a file and the tests keep reading lines.
12
+ module Keys
13
+ # ESC alone or the start of a sequence (an arrow): how long to wait
14
+ # for the rest before taking it as ESC.
15
+ ESC_WAIT_S = 0.05
16
+
17
+ ARROWS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left, "H" => :home, "F" => :end }.freeze
18
+ TILDE = { "1" => :home, "2" => :insert, "3" => :delete, "4" => :end, "5" => :page_up, "6" => :page_down,
19
+ "7" => :home, "8" => :end }.freeze
20
+ CONTROLS = { "\r" => :enter, "\n" => :enter, " " => :space, "\t" => :tab, "\x7f" => :backspace,
21
+ "\b" => :backspace, "\x03" => :ctrl_c, "\x11" => :ctrl_q, "\x04" => :ctrl_d }.freeze
22
+
23
+ module_function
24
+
25
+ # The keys in buf (a String of the bytes read). Returns [keys, rest]:
26
+ # rest is the start of a sequence (ESC, ESC [ 1 ; 5, a part of a UTF-8
27
+ # character) that more bytes may complete; with final: true nothing is
28
+ # kept (a lone ESC is :esc).
29
+ def decode(buf, final: false)
30
+ b = buf.b
31
+ keys = []
32
+ i = 0
33
+ while i < b.bytesize
34
+ c = b.getbyte(i)
35
+ if c == 0x1B
36
+ n, key = escape(b, i)
37
+ if n.nil?
38
+ return [keys, b.byteslice(i..)] unless final
39
+ keys << :esc
40
+ i += 1
41
+ next
42
+ end
43
+ keys << key
44
+ i += n
45
+ elsif c < 0x80
46
+ ch = c.chr
47
+ keys << (CONTROLS[ch] || (c < 0x20 ? :"ctrl_#{(c + 0x60).chr}" : ch))
48
+ i += 1
49
+ else
50
+ len = c >= 0xF0 ? 4 : (c >= 0xE0 ? 3 : (c >= 0xC0 ? 2 : 1))
51
+ if i + len > b.bytesize
52
+ return [keys, b.byteslice(i..)] unless final
53
+ break
54
+ end
55
+ s = b.byteslice(i, len).force_encoding(Encoding::UTF_8)
56
+ keys << (s.valid_encoding? ? s : :unknown)
57
+ i += len
58
+ end
59
+ end
60
+ [keys, "".b]
61
+ end
62
+
63
+ # An escape sequence at b[i]: [bytes, key], or nil when it is not
64
+ # complete yet (more may come).
65
+ def escape(b, i)
66
+ return nil if i + 1 >= b.bytesize
67
+ c1 = b.getbyte(i + 1).chr
68
+ case c1
69
+ when "["
70
+ j = i + 2
71
+ j += 1 while j < b.bytesize && b.getbyte(j).between?(0x20, 0x3F) # the parameters
72
+ return nil if j >= b.bytesize
73
+ final = b.getbyte(j).chr
74
+ params = b.byteslice(i + 2, j - i - 2)
75
+ key = final == "~" ? TILDE[params.split(";").first.to_s] : ARROWS[final]
76
+ [j - i + 1, key || :unknown]
77
+ when "O"
78
+ return nil if i + 2 >= b.bytesize
79
+ [3, ARROWS[b.getbyte(i + 2).chr] || :unknown]
80
+ when "\e"
81
+ [1, :esc] # ESC ESC: the first is ESC alone
82
+ else
83
+ [1, :esc] # ESC then a key (Alt+key): ESC, and the key after it
84
+ end
85
+ end
86
+
87
+ # Reads keys from a terminal in raw mode (the caller puts it there).
88
+ class Reader
89
+ def initialize(io)
90
+ @io = io
91
+ @keys = []
92
+ end
93
+
94
+ # The next key, or nil when none comes within timeout seconds.
95
+ def read(timeout = nil)
96
+ return @keys.shift unless @keys.empty?
97
+ return nil unless @io.wait_readable(timeout)
98
+ buf = @io.readpartial(64)
99
+ loop do
100
+ keys, rest = Keys.decode(buf)
101
+ @keys.concat(keys)
102
+ break if rest.empty?
103
+ # The start of a sequence: the rest within ESC_WAIT_S, or it
104
+ # stands alone (ESC).
105
+ if @io.wait_readable(ESC_WAIT_S)
106
+ buf = rest + @io.readpartial(64)
107
+ else
108
+ @keys.concat(Keys.decode(rest, final: true)[0])
109
+ break
110
+ end
111
+ end
112
+ @keys.shift
113
+ rescue EOFError, IOError
114
+ :eof
115
+ end
116
+ end
117
+
118
+ # Output for a terminal in raw mode: the terminal no longer turns
119
+ # "\n" into a new line at the left (raw mode turns off its output
120
+ # processing too), so every "\n" written goes as "\r\n".
121
+ class CrlfOut
122
+ def initialize(io)
123
+ @io = io
124
+ end
125
+
126
+ def write(*s) = @io.write(*s.map { |x| x.to_s.gsub(/\r?\n/, "\r\n") })
127
+
128
+ def print(*s)
129
+ write(*s)
130
+ nil
131
+ end
132
+
133
+ def puts(*lines)
134
+ lines = lines.flatten
135
+ return write("\n") && nil if lines.empty?
136
+ lines.each { |l| write(l.to_s.end_with?("\n") ? l.to_s : "#{l}\n") }
137
+ nil
138
+ end
139
+
140
+ def <<(s)
141
+ write(s)
142
+ self
143
+ end
144
+
145
+ def flush
146
+ @io.flush
147
+ self
148
+ end
149
+
150
+ def tty? = @io.tty?
151
+ def winsize = @io.winsize
152
+ def sync = @io.sync
153
+
154
+ def sync=(v)
155
+ @io.sync = v
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asterism"
4
+ require "io/console"
5
+ require_relative "mahjong_text"
6
+ require_relative "mahjong_screen"
7
+
8
+ module AsterismGames
9
+ module Play
10
+ # A mahjong table in a terminal (README "Playing in a terminal"):
11
+ # `asterism-games play mahjong`. CRuby and the asterism gem only; it
12
+ # calls the table as the table app does (join, view, act, sit, cpu,
13
+ # start, rule, leave) and draws the view as text whenever it changes.
14
+ # The table's host leaving the network (Asterism's on_leave) is said on
15
+ # the screen and the client waits; the host back (on_join) and it asks
16
+ # again (a host that forgot the member: it joins again).
17
+ class Mahjong
18
+ include MahjongText
19
+
20
+ HELP = <<~TEXT
21
+ On a terminal it reads keys, as the table app does: Left/Right choose a tile,
22
+ Enter or Space discards it; R riichi, T tsumo/ron, P pon, C V B chi, K L J kan,
23
+ N nine kinds, Esc pass, Enter the result's OK; in the lobby 1-4 sit, S stand,
24
+ C CPUs, Enter start; Q or Ctrl+C quits; : (or /) opens a line for a command.
25
+ Mahjong commands (one per line, or after :):
26
+ a .. n discard that tile of the hand (the drawn tile is the last letter)
27
+ 6s, 0p, 東 discard that tile (0: the red five; 1z..7z for the honours too)
28
+ riichi [e|6s] riichi, discarding that tile (alone: one choice, or the choices)
29
+ tsumo, ron win
30
+ pon, chi, kan call (several ways: a number or the tiles, "chi 2", "chi 3m4m")
31
+ pass do not call
32
+ kyuushu nine kinds (an abortive draw)
33
+ ok the result's OK (an empty line too); at the end, the owner: back to the lobby
34
+ sit [1-4], stand, cpu [1-4] (empty seats to CPUs; the owner), nocpu 1-4,
35
+ ai 1-4 KIND (an AI player, when the lobby offers one), tonpuu, hanchan,
36
+ rule ITEM VALUE (README "Local rules"), start (the owner)
37
+ auto let the client play for you (wins, passes, discards the drawn tile,
38
+ OKs the results); again to stop
39
+ tables, view, help, quit
40
+ Without --table it takes the mahjong table it finds (several: it lists them and asks
41
+ on a terminal, else the first).
42
+ TEXT
43
+
44
+ HOST_APP = "games"
45
+ ASSIST = 1 # the riichi help (README "Assist")
46
+ VIEW_EVERY_S = 2.0
47
+ HOST_GONE_EVERY_S = 30.0
48
+ # The countdown on a terminal: the lines with the time are rewritten
49
+ # in place this often.
50
+ TIMER_EVERY_S = 1.0
51
+
52
+ # --commands that only work in the lobby, and those of the owner.
53
+ LOBBY_WORDS = %w[sit stand cpu nocpu ai start tonpuu hanchan rule].freeze
54
+ OWNER_WORDS = %w[cpu nocpu ai start tonpuu hanchan rule].freeze
55
+
56
+ REASONS = {
57
+ "token" => "卓の人ではない", "not seated" => "席に着いていない", "stale" => "画面が古い: 読み直し中",
58
+ "not offered" => "今はできない", "not playing" => "対局中ではない", "not waiting" => "待ち合わせ中ではない",
59
+ "taken" => "その席は埋まっている", "no seat" => "その席は無い", "humans" => "人の席がもう無い",
60
+ "not owner" => "卓の主だけができる", "seats" => "4 席がそろっていない", "no human" => "人が 1 人もいない",
61
+ "no change" => "変わらない", "full" => "卓が満員", "spectators" => "観戦の人数が上限",
62
+ "value" => "選べない値", "no rule" => "その項目は無い", "no ai" => "その AI は今は出せない",
63
+ "identity" => "この部屋での名乗りが合わない"
64
+ }.freeze
65
+
66
+ # The whole run: connect, find the table, play until quit (or over).
67
+ def self.run(opts, input: $stdin, out: $stdout)
68
+ client = nil
69
+ joined = Queue.new
70
+ on_join = lambda do |n|
71
+ joined << n
72
+ client&.node_joined(n)
73
+ end
74
+ Play.connect(opts, on_join: on_join, on_leave: ->(n) { client&.node_left(n) }, label: "play mahjong") do |net|
75
+ path = opts[:table] || pick_table(net, joined, input, out)
76
+ abort "play mahjong: no mahjong table found (--table <host>/games/<table>)" unless path
77
+ keys = Play.keys?(input)
78
+ kout = Play.key_out(input, out)
79
+ client = new(net, path, opts[:name] || net.node_id, style: opts[:tiles], out: kout, keys: keys)
80
+ client.start
81
+ trap("INT") { exit }
82
+ begin
83
+ Play.read_loop(client, opts, input: input, out: kout)
84
+ ensure
85
+ client.stop
86
+ end
87
+ end
88
+ end
89
+
90
+ # The tables: one, or on a terminal a list to choose from (Enter: the
91
+ # first).
92
+ def self.pick_table(net, joined, input, out)
93
+ found = Play.find_tables(net, "mahjong", joined)
94
+ return found.first if found.size <= 1 || !input.tty?
95
+ out.puts "卓 (番号で入る、Enter で 1):"
96
+ found.each_with_index do |p, i|
97
+ out.puts " #{i + 1}: #{p} #{table_line(net, p)}"
98
+ end
99
+ out.print "> "
100
+ n = input.gets.to_s.strip.to_i
101
+ found[(n.positive? ? n : 1) - 1] || found.first
102
+ end
103
+
104
+ # One line about a table: its state and who sits (the public view).
105
+ def self.table_line(net, path)
106
+ v = net[path, timeout: 3.0].table
107
+ return "" unless v.is_a?(Hash)
108
+ names = (v["nm"] || []).map { |n| n.to_s.empty? ? "空き" : n }
109
+ "#{MahjongScreen::STATES[v['st']] || v['st']} #{names.join(' / ')} (#{(v['mb'] || []).size} 人)"
110
+ rescue Asterism::Error
111
+ "(答えが無い)"
112
+ end
113
+
114
+ attr_reader :view
115
+
116
+ def initialize(net, path, name, style: "kanji", out: $stdout, keys: false)
117
+ @net = net
118
+ # Keys (README "Keys"): the chosen hand tile (-1: the drawn one),
119
+ # riichi's choosing, a line being typed after ":".
120
+ @keys = keys
121
+ @sel = -1
122
+ @pick = false
123
+ @typing = false
124
+ @path = path
125
+ @name = name
126
+ @out = out
127
+ @table = net[path, timeout: 3.0]
128
+ @screen = MahjongScreen.new(style: style, cursor: out.tty?, path: path)
129
+ @lock = Mutex.new
130
+ @view = nil
131
+ @drawn = nil
132
+ @token = nil
133
+ @note = ""
134
+ @pending = nil
135
+ @auto = false
136
+ @wake = Queue.new
137
+ @played = false
138
+ @host = path.split("/").first
139
+ @host_gone = false
140
+ @queue = []
141
+ @queue_lock = Mutex.new
142
+ @waiting_for = nil
143
+ @drawn_lines = nil
144
+ end
145
+
146
+ # Asterism's on_leave / on_join (the receiving thread: nothing here
147
+ # waits; the ticker asks the view).
148
+ def node_left(node)
149
+ return unless node == @host
150
+ @host_gone = true
151
+ @wake << :gone
152
+ say("ホストとのつながりが切れた (戻るのを待っています)")
153
+ end
154
+
155
+ def node_joined(node)
156
+ return unless node == @host && @host_gone
157
+ @host_gone = false
158
+ say("ホストが戻った")
159
+ @wake << true
160
+ end
161
+
162
+ # A game was played and is over now (--until-over).
163
+ def finished? = @played && @view.is_a?(Hash) && @view["st"] == "over"
164
+
165
+ def start
166
+ r = @table.join(@name)
167
+ unless r.is_a?(Hash) && r["token"]
168
+ why = r.is_a?(Hash) ? r["error"] : r.inspect
169
+ abort "play mahjong: #{@path}: join refused: #{REASONS[why] || why}"
170
+ end
171
+ @token = r["token"]
172
+ host, app, tname = @path.split("/")
173
+ # The state the host puts after every change: a newer one wakes the
174
+ # ticker (no call waits on the receiving thread).
175
+ @net.session.subscribe("asterism/#{host}/#{app}/#{tname}/state") do |sample|
176
+ v = Asterism::Codec.unpack(sample.payload)
177
+ @wake << true if v.is_a?(Hash) && (!@view.is_a?(Hash) || v["v"] != @view["v"])
178
+ end
179
+ refresh
180
+ @timer = Thread.new { timer_loop } if @out.tty?
181
+ # And a view every VIEW_EVERY_S anyway: the host sees that this
182
+ # member is still here.
183
+ @ticker = Thread.new do
184
+ loop do
185
+ woke = @wake.pop(timeout: @host_gone ? HOST_GONE_EVERY_S : VIEW_EVERY_S)
186
+ @wake.clear
187
+ next if woke == :gone
188
+ refresh
189
+ rescue Asterism::Error => e
190
+ say("ホストが答えない (#{e.class})")
191
+ end
192
+ end
193
+ end
194
+
195
+ def stop
196
+ @ticker&.kill
197
+ @timer&.kill
198
+ @table.leave(@token) if @token
199
+ rescue Asterism::Error
200
+ nil
201
+ end
202
+
203
+ # Asks the member's view and draws it when it changed.
204
+ def refresh
205
+ v = @table.view(@token, ASSIST)
206
+ if v.is_a?(Hash) && v["error"] == "token"
207
+ # The host forgot this member (a new state): join again.
208
+ r = @table.join(@name)
209
+ @token = r["token"] if r.is_a?(Hash) && r["token"]
210
+ v = @table.view(@token, ASSIST)
211
+ end
212
+ @lock.synchronize do
213
+ old = @view
214
+ @view = v
215
+ @played = true if v.is_a?(Hash) && %w[play result].include?(v["st"])
216
+ # A choice belongs to the state it was offered in.
217
+ @pending = nil if @pending && (!v.is_a?(Hash) || !old.is_a?(Hash) || v["sv"] != old["sv"])
218
+ took_keys(v, old)
219
+ draw if signature(v) != @drawn
220
+ end
221
+ play_auto
222
+ run_queue
223
+ end
224
+
225
+ # Keys: riichi's choosing ends when riichi is no longer offered; a hand
226
+ # that changed (a tile drawn, a call) has the drawn tile chosen again.
227
+ def took_keys(v, old)
228
+ return unless v.is_a?(Hash)
229
+ @pick = false unless MahjongText.options(v).any? { |t, _| t == RIICHI }
230
+ @sel = -1 if !old.is_a?(Hash) || MahjongText.hand_tiles(v) != MahjongText.hand_tiles(old)
231
+ end
232
+
233
+ # One key (Play.key_loop); false to quit, :line for a line to type.
234
+ def key(k)
235
+ v = @view.is_a?(Hash) ? @view : nil
236
+ r = MahjongText.key_command(k, v, sel: @sel, pick: @pick)
237
+ case r[0]
238
+ when :quit then return false
239
+ when :line then return :line
240
+ when :select
241
+ @lock.synchronize do
242
+ @sel = r[1]
243
+ draw
244
+ end
245
+ when :pick
246
+ @lock.synchronize do
247
+ @pick = r[1]
248
+ @note = ""
249
+ draw
250
+ end
251
+ when :act
252
+ answer = act(r[1], r[2])
253
+ say(answer == "ok" ? "" : answer)
254
+ refresh
255
+ when :cmd
256
+ answer = client_command(r[1], r[2])
257
+ say(answer == "ok" ? "" : "#{r[1]}: #{answer}")
258
+ refresh
259
+ when :error then say(r[1])
260
+ end
261
+ true
262
+ rescue Asterism::Error => e
263
+ say("#{e.class}: #{e.message}")
264
+ true
265
+ end
266
+
267
+ # A line is being typed after ":" (the screen shows the commands and
268
+ # the letters then, and a prompt).
269
+ def typing(on)
270
+ @lock.synchronize do
271
+ @typing = on
272
+ draw
273
+ end
274
+ end
275
+
276
+ # --commands: run in order, each when the table can take it. A lobby
277
+ # command waits for the lobby (the table may still show the end of a
278
+ # game, or a game may be going on, when the client joins), an owner's
279
+ # command for this member to be seated and the owner, start for the
280
+ # four seats to be taken; one that is answered "no" is not tried
281
+ # again. Run from every refresh (the ticker's too), so a wait ends by
282
+ # itself.
283
+ def queue_commands(list)
284
+ @queue_lock.synchronize { @queue.concat(list) }
285
+ run_queue
286
+ end
287
+
288
+ def run_queue
289
+ return unless @queue_lock.try_lock
290
+ begin
291
+ until @queue.empty?
292
+ c = @queue.first
293
+ why = not_ready(c)
294
+ if why
295
+ if @waiting_for != [c, why]
296
+ @waiting_for = [c, why]
297
+ say("--commands: #{c} (#{why})")
298
+ end
299
+ break
300
+ end
301
+ @queue.shift
302
+ @waiting_for = nil
303
+ command(c)
304
+ end
305
+ ensure
306
+ @queue_lock.unlock
307
+ end
308
+ end
309
+
310
+ # Why a command cannot run yet (Japanese, for the screen), or nil.
311
+ def not_ready(c)
312
+ w = c.split.first.to_s.downcase
313
+ return nil unless LOBBY_WORDS.include?(w)
314
+ v = @view
315
+ return "画面を待っています" unless v.is_a?(Hash) && !v["error"]
316
+ return "待ち合わせを待っています" unless v["st"] == "wait"
317
+ return nil unless OWNER_WORDS.include?(w)
318
+ return "席に着くのを待っています" unless v["me"].to_i >= 0
319
+ return "卓の主を待っています" unless v["ow"] == v["me"]
320
+ return "4 席がそろうのを待っています" if w == "start" && (v["sk"] || [0]).any?(&:zero?)
321
+ nil
322
+ end
323
+
324
+ # What a redraw depends on: the view without the times that only
325
+ # count down (the screen is drawn again when something happens, not
326
+ # every second, so a line being typed is not wiped).
327
+ def signature(v)
328
+ return v unless v.is_a?(Hash)
329
+ s = v.dup
330
+ s["dl"] = v["dl"] && v["dl"][0]
331
+ s["ok"] = v["ok"] && v["ok"][0, 2]
332
+ s["in"] = v.key?("in")
333
+ s
334
+ end
335
+
336
+ def say(text)
337
+ @lock.synchronize do
338
+ @note = text
339
+ draw
340
+ end
341
+ end
342
+
343
+ # One command line; false to quit.
344
+ def command(line)
345
+ text = line.to_s.strip
346
+ v = @view.is_a?(Hash) ? @view : nil
347
+ # An empty line: the result's OK when it is offered.
348
+ if text.empty?
349
+ return true unless v && MahjongText.options(v).any? { |t, _| t == NEXT } && v["st"] == "result"
350
+ text = "ok"
351
+ end
352
+ r = MahjongText.parse(text, v, @pending)
353
+ @pending = nil
354
+ answer = case r[0]
355
+ when :act then act(r[1], r[2])
356
+ when :choose
357
+ @pending = [r[1], r[2]]
358
+ nil
359
+ when :cmd
360
+ return false if %w[quit exit q leave].include?(r[1])
361
+ client_command(r[1], r[2])
362
+ when :error then r[1]
363
+ end
364
+ say(answer ? "#{text}: #{answer}" : "")
365
+ refresh
366
+ true
367
+ rescue Asterism::Error => e
368
+ say("#{text}: #{e.class}: #{e.message}")
369
+ true
370
+ end
371
+
372
+ def act(type, tiles)
373
+ v = @view or return "画面がまだ無い"
374
+ reason(@table.act(@token, v["sv"].to_i, type, tiles.pack("C*").b))
375
+ end
376
+
377
+ def reason(r)
378
+ return "ok" if r == "ok" || r == true
379
+ REASONS[r.to_s] || r.to_s
380
+ end
381
+
382
+ def seat_arg(args, default = -1)
383
+ return default if args.empty?
384
+ n = args[0].to_i
385
+ n.between?(1, 4) ? n - 1 : default
386
+ end
387
+
388
+ def client_command(word, args)
389
+ case word
390
+ when "help", "?" then HELP.lines.map(&:strip).reject(&:empty?).first(9).join(" / ")
391
+ when "sit" then reason(@table.sit(@token, seat_arg(args)))
392
+ when "stand" then reason(@table.stand(@token))
393
+ when "cpu" then reason(@table.cpu(@token, seat_arg(args), true))
394
+ when "nocpu" then args.empty? ? "nocpu 1-4" : reason(@table.cpu(@token, seat_arg(args), false))
395
+ when "ai" then args.size < 2 ? "ai 1-4 KIND" : reason(@table.cpu(@token, seat_arg(args), args[1]))
396
+ when "start" then reason(@table.start(@token))
397
+ when "tonpuu" then reason(@table.rule(@token, 0, 1))
398
+ when "hanchan" then reason(@table.rule(@token, 0, 0))
399
+ when "rule"
400
+ args.size < 2 ? "rule ITEM VALUE" : reason(@table.rule(@token, args[0].to_i, args[1].to_i))
401
+ when "auto" then (@auto = !@auto) ? "入 (もう一度で切)" : "切"
402
+ when "view" then "ok"
403
+ when "tables" then Play.find_tables(@net, "mahjong", Queue.new, wait: 0).join(" ")
404
+ else "? (help)"
405
+ end
406
+ end
407
+
408
+ # auto: the wins, the OKs, a pass on calls, else the drawn tile (or
409
+ # the last tile that may be discarded).
410
+ def play_auto
411
+ return unless @auto
412
+ v = @view
413
+ return unless v.is_a?(Hash) && v["me"].to_i >= 0
414
+ opts = MahjongText.options(v)
415
+ return if opts.empty?
416
+ pick = opts.find { |t, _| [TSUMO, RON].include?(t) } ||
417
+ (v["st"] == "result" && opts.find { |t, _| t == NEXT }) ||
418
+ opts.find { |t, _| t == SKIP }
419
+ unless pick
420
+ dahai = opts.select { |t, _| t == DAHAI }
421
+ dt = v["dt"].to_i
422
+ pick = dahai.find { |_, ts| ts == [dt] } || dahai.last
423
+ end
424
+ return unless pick
425
+ r = act(pick[0], pick[1])
426
+ say("auto #{MahjongScreen::CALL_NAMES[pick[0]] || (pick[0] == NEXT ? 'ok' : mpsz(pick[1][0]))}: #{r}")
427
+ @wake << true
428
+ end
429
+
430
+ # ------------------------------------------------------------ the screen
431
+
432
+ def draw
433
+ v = @view
434
+ return unless v.is_a?(Hash)
435
+ @drawn = signature(v)
436
+ args = { note: @note, pending: @pending, keys: key_state }
437
+ lines = @screen.lines(v, **args)
438
+ @drawn_lines = lines
439
+ @drawn_view = v
440
+ @drawn_args = args
441
+ @drawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
442
+ if @out.tty?
443
+ @out.print("\e[H\e[2J")
444
+ @out.puts(lines)
445
+ @out.print("> ") unless @keys && !@typing
446
+ else
447
+ # A pipe or a file: one screen after another, each ending in "> ".
448
+ @out.puts(lines)
449
+ @out.puts("> ")
450
+ end
451
+ @out.flush
452
+ end
453
+
454
+ # What the screen needs of the keys (nil: commands are typed).
455
+ def key_state
456
+ @keys && !@typing ? { sel: @sel, pick: @pick } : nil
457
+ end
458
+
459
+ # The countdown on a terminal (the move's time and the bank, the
460
+ # result's OK wait): about once a second the screen is worked out
461
+ # again from the view as drawn, with the time gone since taken off,
462
+ # and only the lines that changed are rewritten where they are:
463
+ # ESC 7 saves the cursor (where the player is typing, after "> "),
464
+ # the line is cleared and written, ESC 8 puts the cursor back. The
465
+ # line being typed is never touched, and the terminal's own line
466
+ # editing (it echoes and erases at the cursor) goes on as before.
467
+ def timer_loop
468
+ loop do
469
+ sleep TIMER_EVERY_S
470
+ @lock.synchronize { tick_timer }
471
+ rescue StandardError
472
+ nil
473
+ end
474
+ end
475
+
476
+ def tick_timer
477
+ v = @drawn_view
478
+ return unless v.is_a?(Hash) && @drawn_lines && (v["dl"] || v["ok"])
479
+ lines = @screen.lines(counted_down(v), **@drawn_args)
480
+ return unless lines.size == @drawn_lines.size
481
+ rows = (@out.winsize[0] rescue nil)
482
+ # The screen scrolled when it is taller than the terminal: the
483
+ # lines that went off the top are not rewritten.
484
+ shift = rows ? [@drawn_lines.size + 1 - rows, 0].max : 0
485
+ out = +""
486
+ lines.each_with_index do |l, i|
487
+ next if l == @drawn_lines[i] || i < shift
488
+ out << "\e[#{i + 1 - shift};1H\e[2K#{l}"
489
+ end
490
+ @drawn_lines = lines
491
+ return if out.empty?
492
+ @out.print("\e7#{out}\e8")
493
+ @out.flush
494
+ end
495
+
496
+ # The view with the time since it was drawn taken off its clocks.
497
+ def counted_down(v)
498
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @drawn_at) * 1000).to_i
499
+ c = v.dup
500
+ if (dl = v["dl"]).is_a?(Array) && dl.size > 1
501
+ c["dl"] = [dl[0], [dl[1].to_i - ms, 0].max, *dl[2..]]
502
+ end
503
+ c["ok"] = [v["ok"][0], v["ok"][1], [v["ok"][2].to_i - ms, 0].max] if v["ok"].is_a?(Array) && v["ok"].size > 2
504
+ c
505
+ end
506
+ end
507
+ end
508
+ end