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,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module AsterismGames
6
+ # `asterism-games play <game>` (README "Playing in a terminal"): the
7
+ # terminal clients of the tables, CRuby and the asterism gem only. The
8
+ # connection options are the same for every game; each game has a class
9
+ # with `run(opts)` (play/othello.rb, play/mahjong.rb), loaded only when
10
+ # it is played (neither needs the framework or riichi_engine).
11
+ module Play
12
+ GAMES = {
13
+ "othello" => ["othello", "an othello table: the board as text, play by the arrows and Enter (or d3)"],
14
+ "mahjong" => ["mahjong", "a mahjong table: the tiles as kanji, emoji or 123m text, play by one key (or 6s)"]
15
+ }.freeze
16
+
17
+ USAGE = <<~TEXT
18
+ Usage: asterism-games play <game> [options]
19
+
20
+ Games:
21
+ %<games>s
22
+
23
+ Options (every game):
24
+ --router LOCATOR the Zenoh router (tcp/127.0.0.1:7447 when nothing says otherwise)
25
+ --credentials FILE a console's .tar for a router over mutual TLS (the router and
26
+ the node ID come from it)
27
+ --namespace PREFIX inside a room's key space (room/01)
28
+ --table PATH the table (<host>/games/<table>); without it the first one found
29
+ --name NAME the name at the table (default: the node ID)
30
+ --node NODE the node ID (default: tty-<host>-<pid>, or the .tar's name)
31
+ --commands LIST commands to run first, comma separated ("sit,cpu,start")
32
+ --until-over quit when a game it saw played is over
33
+ -h, --help this text (and the game's own: play <game> --help)
34
+ mahjong only:
35
+ --tiles kanji|emoji|mpsz
36
+ how tiles show: kanji (default: 一萬 4筒 3索 東, the hand in
37
+ two rows like the table app), emoji (🀇🀈🀉; the font draws
38
+ them, 🀄 often in colour) or text (1m 4p 3s 東)
39
+
40
+ On a terminal the clients read keys (the table apps' keys; the last line
41
+ says which work now, Q quits, : opens a line for a command); in a pipe
42
+ or a file, a command per line.
43
+
44
+ With neither --router nor --credentials, ASTERISM_ROUTER and
45
+ ASTERISM_CREDENTIALS from the environment are used (as the asterism
46
+ command line does: all or nothing).
47
+ TEXT
48
+
49
+ module_function
50
+
51
+ def usage
52
+ format(USAGE, games: GAMES.map { |n, (_, text)| format(" %-9s %s", n, text) }.join("\n"))
53
+ end
54
+
55
+ # The entry (bin/asterism-games play ...); returns the exit status.
56
+ def main(argv, out: $stdout, err: $stderr)
57
+ game = argv.first
58
+ if game.nil? || %w[-h --help help].include?(game)
59
+ out.puts usage
60
+ return 0
61
+ end
62
+ unless GAMES.key?(game)
63
+ err.puts "asterism-games play: no game #{game.inspect} (#{GAMES.keys.join(', ')})"
64
+ return 2
65
+ end
66
+ opts = parse(argv.drop(1), game)
67
+ if opts[:help]
68
+ out.puts game_help(game)
69
+ return 0
70
+ end
71
+ run(game, opts)
72
+ 0
73
+ rescue ArgumentError => e
74
+ err.puts "asterism-games play #{game}: #{e.message} (--help)"
75
+ 2
76
+ end
77
+
78
+ def run(game, opts)
79
+ require_relative "play/#{GAMES.fetch(game)[0]}"
80
+ { "othello" => -> { OthelloTty.run(opts) }, "mahjong" => -> { Mahjong.run(opts) } }.fetch(game).call
81
+ end
82
+
83
+ def game_help(game)
84
+ require_relative "play/#{GAMES.fetch(game)[0]}"
85
+ "#{usage}\n#{(game == 'othello' ? OthelloTty : Mahjong)::HELP}"
86
+ end
87
+
88
+ # The options of every game (and --tiles); raises ArgumentError.
89
+ def parse(args, game = nil, env: ENV)
90
+ opts = { router: nil, table: nil, name: nil, node: nil, credentials: nil, namespace: nil,
91
+ commands: [], until_over: false, tiles: "kanji", help: false }
92
+ args = args.dup
93
+ until args.empty?
94
+ k = args.shift
95
+ need = lambda do
96
+ v = args.shift
97
+ raise ArgumentError, "#{k} takes a value" if v.nil? || v.start_with?("--")
98
+ v
99
+ end
100
+ case k
101
+ when "--router" then opts[:router] = need.call
102
+ when "--table" then opts[:table] = need.call
103
+ when "--name" then opts[:name] = need.call
104
+ when "--node" then opts[:node] = need.call
105
+ when "--credentials" then opts[:credentials] = need.call
106
+ when "--namespace" then opts[:namespace] = need.call
107
+ when "--commands" then opts[:commands] = need.call.split(",").map(&:strip).reject(&:empty?)
108
+ when "--until-over" then opts[:until_over] = true
109
+ when "--tiles"
110
+ raise ArgumentError, "--tiles is mahjong's" unless game.nil? || game == "mahjong"
111
+ opts[:tiles] = need.call
112
+ raise ArgumentError, "--tiles kanji, emoji or mpsz" unless %w[kanji emoji mpsz].include?(opts[:tiles])
113
+ when "-h", "--help" then opts[:help] = true
114
+ else raise ArgumentError, "unknown option #{k}"
115
+ end
116
+ end
117
+ network!(opts, env)
118
+ opts
119
+ end
120
+
121
+ # The router and the credentials: the line's; with neither on the
122
+ # line, ASTERISM_ROUTER and ASTERISM_CREDENTIALS (all or nothing, so a
123
+ # router on the line never meets a .tar from the environment); with
124
+ # nothing at all, a zenohd on this machine. opts[:locator] is what
125
+ # Asterism.connect takes (nil: the .tar's router).
126
+ def network!(opts, env = ENV)
127
+ if opts[:router].nil? && opts[:credentials].nil?
128
+ r = env["ASTERISM_ROUTER"].to_s
129
+ c = env["ASTERISM_CREDENTIALS"].to_s
130
+ opts[:router] = r unless r.empty?
131
+ opts[:credentials] = c unless c.empty?
132
+ end
133
+ opts[:router] = "tcp/127.0.0.1:7447" if opts[:router].nil? && opts[:credentials].nil?
134
+ opts[:locator] = opts[:router]
135
+ opts
136
+ end
137
+
138
+ # Asterism.connect with the options; yields the Net (started, with
139
+ # on_join / on_leave passed to the given handlers).
140
+ def connect(opts, app: "game", on_join: nil, on_leave: nil, label: "play")
141
+ require "asterism"
142
+ require "socket"
143
+ node = opts[:node] || (opts[:credentials] ? nil : "tty-#{Socket.gethostname[/\A[A-Za-z0-9_-]+/]}-#{Process.pid}")
144
+ kw = { app: app }
145
+ kw[:node] = node if node
146
+ kw[:credentials] = opts[:credentials] if opts[:credentials]
147
+ kw[:config] = { "namespace" => opts[:namespace] } if opts[:namespace]
148
+ Asterism.connect(opts[:locator], **kw) do |net|
149
+ net.on_error { |e, where| warn "#{label}: #{where}: #{e.class}: #{e.message}" }
150
+ net.on_join { |n| on_join&.call(n) }
151
+ net.on_leave { |n| on_leave&.call(n) }
152
+ net.start
153
+ yield net
154
+ end
155
+ end
156
+
157
+ # The first table whose name starts with prefix (looking again whenever
158
+ # a node joins, for wait seconds); nil when none.
159
+ def find_tables(net, prefix, joined, wait: 10)
160
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
161
+ loop do
162
+ found = net.each("*/games/*").map(&:asterism_path).sort.select { |p| p.split("/")[2].to_s.start_with?(prefix) }
163
+ return found unless found.empty?
164
+ return [] if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
165
+ # A node that joins may be a host: look again then (its objects
166
+ # follow its application within moments, so also a little later).
167
+ joined.pop(timeout: 0.5)
168
+ end
169
+ end
170
+
171
+ # Reads stdin lines into the client (command(line) false: quit) and
172
+ # waits; returns when the client quits, stdin ends on a terminal, or
173
+ # (until_over) the game it saw is over. The end of a pipe or a file
174
+ # keeps it running (auto plays on) until Ctrl-C.
175
+ def read_loop(client, opts, input: $stdin, out: $stdout)
176
+ # A client that can wait for the right moment takes them as a queue.
177
+ if client.respond_to?(:queue_commands)
178
+ client.queue_commands(opts[:commands])
179
+ else
180
+ opts[:commands].each { |c| client.command(c) }
181
+ end
182
+ return key_loop(client, opts, input, out) if keys?(input) && client.respond_to?(:key)
183
+ reader = Thread.new do
184
+ quit = false
185
+ while (line = input.gets)
186
+ break quit = true unless client.command(line)
187
+ end
188
+ sleep unless quit || input.tty?
189
+ end
190
+ sleep 0.2 while reader.alive? && !(opts[:until_over] && client.finished?)
191
+ ensure
192
+ reader&.kill
193
+ end
194
+
195
+ # Keys one at a time (README "Keys") when the input is a terminal; a
196
+ # pipe, a file and the tests read lines.
197
+ def keys?(input)
198
+ require_relative "play/keys"
199
+ input.respond_to?(:tty?) && input.tty? && input.respond_to?(:raw)
200
+ end
201
+
202
+ # The output for a client that reads keys: in raw mode a "\n" must be
203
+ # written as "\r\n" (Keys::CrlfOut); elsewhere the output as it is.
204
+ def key_out(input, out)
205
+ require_relative "play/keys"
206
+ keys?(input) && out.tty? ? Keys::CrlfOut.new(out) : out
207
+ end
208
+
209
+ # The key loop: the terminal in raw mode, each key to client.key (false:
210
+ # quit; :line: a line is typed, the terminal back in cooked mode for it,
211
+ # and the line goes to client.command as before). The terminal is put
212
+ # back however it ends (an exception, Ctrl-C, the game over), and the
213
+ # cursor shown again.
214
+ def key_loop(client, opts, input, out)
215
+ require_relative "play/keys"
216
+ reader = Keys::Reader.new(input)
217
+ err = $stderr
218
+ $stderr = Keys::CrlfOut.new(err) if err.tty? && !err.is_a?(Keys::CrlfOut)
219
+ input.raw(intr: false) do
220
+ out.print("\e[?25l")
221
+ out.flush
222
+ thread = Thread.new do
223
+ loop do
224
+ k = reader.read(0.5)
225
+ next unless k
226
+ break if k == :eof
227
+ r = client.key(k)
228
+ break unless r
229
+ next unless r == :line
230
+ line = input.cooked do
231
+ out.print("\e[?25h")
232
+ client.typing(true)
233
+ input.gets
234
+ end
235
+ out.print("\e[?25l")
236
+ client.typing(false)
237
+ break if line.nil?
238
+ break if !line.strip.empty? && !client.command(line)
239
+ end
240
+ end
241
+ sleep 0.2 while thread.alive? && !(opts[:until_over] && client.finished?)
242
+ ensure
243
+ # Ended before the raw mode is undone: a thread still in cooked
244
+ # mode would put raw mode back after it.
245
+ thread&.kill
246
+ thread&.join(1)
247
+ end
248
+ ensure
249
+ $stderr = err if err
250
+ out.print("\e[?25h")
251
+ out.flush
252
+ end
253
+ end
254
+ end