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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +282 -0
- data/LICENSE +21 -0
- data/README.md +2009 -0
- data/VERSION +1 -0
- data/bin/asterism-games +116 -0
- data/bin/othello-tty +14 -0
- data/lib/asterism_games/ai.rb +457 -0
- data/lib/asterism_games/ai_hub.rb +171 -0
- data/lib/asterism_games/board/remote.rb +77 -0
- data/lib/asterism_games/board/table.rb +280 -0
- data/lib/asterism_games/board.rb +11 -0
- data/lib/asterism_games/config.rb +228 -0
- data/lib/asterism_games/game.rb +456 -0
- data/lib/asterism_games/host.rb +436 -0
- data/lib/asterism_games/othello/board.rb +161 -0
- data/lib/asterism_games/othello/remote.rb +85 -0
- data/lib/asterism_games/othello/table.rb +533 -0
- data/lib/asterism_games/othello.rb +12 -0
- data/lib/asterism_games/play/keys.rb +160 -0
- data/lib/asterism_games/play/mahjong.rb +508 -0
- data/lib/asterism_games/play/mahjong_screen.rb +607 -0
- data/lib/asterism_games/play/mahjong_text.rb +456 -0
- data/lib/asterism_games/play/othello.rb +456 -0
- data/lib/asterism_games/play.rb +254 -0
- data/lib/asterism_games/record_book.rb +468 -0
- data/lib/asterism_games/remote.rb +100 -0
- data/lib/asterism_games/rooms.rb +509 -0
- data/lib/asterism_games/store.rb +39 -0
- data/lib/asterism_games/version.rb +5 -0
- data/lib/asterism_games.rb +14 -0
- metadata +105 -0
|
@@ -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
|