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,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
|