asterism-games 0.18.0 → 0.19.1

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,26 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>麻雀の実況</title>
7
+ <link rel="stylesheet" href="/commentate.css">
8
+ </head>
9
+ <body>
10
+ <header id="bar">
11
+ <span id="tname" class="item strong">卓を待っています</span>
12
+ <select id="tables" class="item" title="卓" hidden></select>
13
+ <span id="voice" class="item" title="M で切り替え">声: 切</span>
14
+ <span id="ai" class="item"></span>
15
+ <span id="delay" class="item"></span>
16
+ <span id="mode" class="item"></span>
17
+ <span id="conn" class="item"></span>
18
+ </header>
19
+ <div id="hint" role="button" tabindex="0">画面をクリックすると実況の声が出ます (ブラウザの自動再生の決まり)</div>
20
+ <main id="stage"><canvas id="table" aria-label="卓"></canvas></main>
21
+ <section id="captions" aria-live="polite"></section>
22
+ <footer id="keys">Space 一時停止 / M 声 / H 手牌を隠す</footer>
23
+ <div id="credit" hidden></div>
24
+ <script src="/commentate.js"></script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require_relative "mahjong_events"
6
+ require_relative "mahjong_phrases"
7
+ require_relative "server"
8
+ require_relative "voice"
9
+ require_relative "ai_comment"
10
+
11
+ module AsterismGames
12
+ module Commentate
13
+ # What the pages are told (README "Commentary"): each table's last
14
+ # spectate view and its commentary, kept here and pushed to the open
15
+ # pages (Server). Fed by the runner (update, host_left, host_back);
16
+ # nothing in it waits on the network, so the tests feed it directly.
17
+ class MahjongHub
18
+ KEEP_LINES = 100
19
+
20
+ attr_reader :tables
21
+
22
+ # public: hide the hands from everything that leaves this process (the
23
+ # views lose hand and drawn, the lines are the public ones). json: an
24
+ # IO for a JSON line per commentary line (--json), or nil. voice: a
25
+ # Voice (VOICEVOX: the lines in its character's tone, each with the
26
+ # URLs of its sound), or nil (the browser's voice; voice_note says
27
+ # why, for the page). ai: an AIComment (the AI's one-liners), or nil
28
+ # (ai_status says why, for the page).
29
+ def initialize(public: false, table: nil, json: nil, rng: Random.new, clock: -> { Time.now }, version: nil,
30
+ voice: nil, voice_note: nil, ai: nil, ai_status: nil)
31
+ @public = public
32
+ @want = table
33
+ @json = json
34
+ @rng = rng
35
+ @clock = clock
36
+ @version = version
37
+ @voice = voice
38
+ @voice_note = voice_note
39
+ @tone = voice ? voice.tone : "plain"
40
+ @lock = Monitor.new
41
+ @tables = {} # id => {"host", "name", "view", "memo", "lines", "at", "gone"}
42
+ @clients = []
43
+ @seq = 0
44
+ @ai = ai
45
+ @ai_status = ai_status || { "state" => "off", "note" => "off" }
46
+ @ai&.attach(deliver: ->(id, text, job) { ai_line(id, text, hands: job["hands"]) },
47
+ changed: -> { @lock.synchronize { broadcast_status } },
48
+ targets: -> { ai_targets }, hands: -> { hands_for_ai? }, tone: @tone)
49
+ end
50
+
51
+ attr_reader :ai
52
+
53
+ def public? = @public
54
+
55
+ # A table's id on the page: <host>/<table>.
56
+ def self.id(host, name) = "#{host}/#{name}"
57
+
58
+ # A spectate view arrived. Returns the lines it made.
59
+ def update(host, name, view)
60
+ return [] unless view.is_a?(Hash)
61
+ id = MahjongHub.id(host, name)
62
+ frames = []
63
+ lines = []
64
+ @lock.synchronize do
65
+ t = (@tables[id] ||= { "host" => host, "name" => name, "view" => nil, "memo" => {}, "lines" => [] })
66
+ fresh = t["view"].nil?
67
+ was_gone = t["gone"]
68
+ t["gone"] = false
69
+ t["at"] = @clock.call
70
+ evs, t["memo"] = MahjongEvents.diff(t["view"], view, t["memo"])
71
+ evs.each do |ev|
72
+ l = MahjongPhrases.line(ev, view, @rng, tone: @tone)
73
+ l = public_line(l) if @public
74
+ next unless l
75
+ l["voice"] = @voice.plan(l, @rng) if @voice
76
+ @seq += 1
77
+ l["id"] = @seq
78
+ l["at"] = @clock.call.strftime("%H:%M:%S")
79
+ l["table"] = id
80
+ t["lines"] << l
81
+ lines << l
82
+ end
83
+ t["lines"].shift while t["lines"].size > KEEP_LINES
84
+ t["view"] = view
85
+ out = shown(view)
86
+ frames << [id, Server.frame("state", { "table" => id, "view" => out })]
87
+ lines.each { |l| frames << [id, Server.frame("line", l)] }
88
+ broadcast_status if fresh || was_gone
89
+ frames.each { |tid, f| each_client(tid) { |c| c.push(f) } }
90
+ end
91
+ lines.each { |l| @json.puts(JSON.generate(json_line(l))) } if @json
92
+ @json&.flush
93
+ @ai&.observe(id, view, lines)
94
+ lines
95
+ end
96
+
97
+ # An AI's one-liner for a table (AIComment, after its fixed lines):
98
+ # the caption as it came, the speech read as the fixed lines are
99
+ # (SPEECH_READ, the tiles' names), marked "ai" for the page (said
100
+ # after the fixed lines, dropped first). hands: whether the AI saw
101
+ # the hands (then a page that hides them does not show it).
102
+ def ai_line(id, text, hands: false)
103
+ speech = MahjongPhrases.reading(text.delete("!").gsub(/\s+/, " ").strip)
104
+ l = { "type" => "ai", "prio" => 0, "ai" => true, "text" => text, "speech" => speech,
105
+ "ptext" => hands ? nil : text, "pspeech" => hands ? nil : speech, "clips" => [] }
106
+ frames = []
107
+ @lock.synchronize do
108
+ t = @tables[id] or return nil
109
+ l["voice"] = @voice.plan(l) if @voice
110
+ @seq += 1
111
+ l["id"] = @seq
112
+ l["at"] = @clock.call.strftime("%H:%M:%S")
113
+ l["table"] = id
114
+ t["lines"] << l
115
+ t["lines"].shift while t["lines"].size > KEEP_LINES
116
+ frames << Server.frame("line", l)
117
+ frames.each { |f| each_client(id) { |c| c.push(f) } }
118
+ end
119
+ if @json
120
+ @json.puts(JSON.generate(json_line(l)))
121
+ @json.flush
122
+ end
123
+ l
124
+ end
125
+
126
+ # Every few seconds (the runner): a quiet table may get a one-liner.
127
+ def tick
128
+ @ai&.tick
129
+ end
130
+
131
+ # The tables the AI comments on: those a page watches (and with
132
+ # --json the first one): nobody watching, nothing is spent.
133
+ def ai_targets
134
+ @lock.synchronize do
135
+ ids = @clients.map(&:table).compact
136
+ ids << default_table if @json && default_table
137
+ ids.uniq
138
+ end
139
+ end
140
+
141
+ # Whether the AI may see the hands: not with --public, nor while a
142
+ # page hides them (H).
143
+ def hands_for_ai?
144
+ return false if @public
145
+ @lock.synchronize { @clients.none? { |c| c.respond_to?(:hide) && c.hide } }
146
+ end
147
+
148
+ # The host of a table left the network (Asterism's on_leave): the pages
149
+ # say so; nothing stops.
150
+ def host_left(host) = mark(host, true)
151
+ def host_back(host) = mark(host, false)
152
+
153
+ # Tables not heard from for stale_s (the host puts every 5 s): gone.
154
+ def check_stale(stale_s = 15)
155
+ @lock.synchronize do
156
+ now = @clock.call
157
+ changed = false
158
+ @tables.each_value do |t|
159
+ next if t["gone"] || t["at"].nil? || now - t["at"] < stale_s
160
+ t["gone"] = true
161
+ changed = true
162
+ end
163
+ broadcast_status if changed
164
+ end
165
+ end
166
+
167
+ # ------------------------------------------------------------ the pages
168
+
169
+ def subscribe(client)
170
+ @lock.synchronize { @clients << client }
171
+ end
172
+
173
+ def unsubscribe(client)
174
+ @lock.synchronize { @clients.delete(client) }
175
+ end
176
+
177
+ # The first frames of a page: who we are, its table and the lines so far.
178
+ def hello(client)
179
+ @lock.synchronize do
180
+ client.table = nil if client.table && !@tables.key?(client.table) && !@tables.empty? && !want?(client.table)
181
+ client.table ||= default_table
182
+ out = [["status", status]]
183
+ t = @tables[client.table]
184
+ if t
185
+ out << ["state", { "table" => client.table, "view" => shown(t["view"]) }]
186
+ t["lines"].each { |l| out << ["line", l.merge("old" => true)] }
187
+ end
188
+ out
189
+ end
190
+ end
191
+
192
+ def status
193
+ { "public" => @public, "version" => @version, "want" => @want, "voice" => voice_status,
194
+ "ai" => @ai ? @ai.status : @ai_status,
195
+ "tables" => @tables.sort.map { |id, t| { "id" => id, "name" => t["name"], "host" => t["host"], "gone" => t["gone"] ? true : false } } }
196
+ end
197
+
198
+ attr_reader :voice
199
+
200
+ private
201
+
202
+ def voice_status
203
+ return @voice.status if @voice
204
+ { "engine" => "browser", "note" => @voice_note }
205
+ end
206
+
207
+ def want?(id) = @want && id.end_with?(@want)
208
+
209
+ def default_table
210
+ ids = @tables.keys.sort
211
+ ids.find { |id| want?(id) } || ids.first
212
+ end
213
+
214
+ def mark(host, gone)
215
+ @lock.synchronize do
216
+ hit = @tables.values.select { |t| t["host"] == host && t["gone"] != gone }
217
+ hit.each { |t| t["gone"] = gone }
218
+ broadcast_status unless hit.empty?
219
+ end
220
+ end
221
+
222
+ def broadcast_status
223
+ f = Server.frame("status", status)
224
+ # A page that had no table yet takes the first one now.
225
+ @clients.each do |c|
226
+ if c.table.nil? && (d = default_table)
227
+ c.table = d
228
+ t = @tables[d]
229
+ c.push(Server.frame("state", { "table" => d, "view" => shown(t["view"]) })) if t["view"]
230
+ end
231
+ c.push(f)
232
+ end
233
+ end
234
+
235
+ def each_client(id, &blk)
236
+ @clients.each do |c|
237
+ c.table ||= default_table
238
+ blk.call(c) if c.table == id
239
+ end
240
+ end
241
+
242
+ # The view as it leaves: without the hands with --public.
243
+ def shown(view)
244
+ return view unless @public && view.is_a?(Hash)
245
+ v = view.dup
246
+ v["seats"] = Array(view["seats"]).map { |s| s.reject { |k, _| %w[hand drawn].include?(k) } }
247
+ v
248
+ end
249
+
250
+ def public_line(l)
251
+ return nil if l["ptext"].nil?
252
+ l.merge("text" => l["ptext"], "speech" => l["pspeech"]).reject { |k, _| %w[ptext pspeech].include?(k) }
253
+ end
254
+
255
+ def json_line(l)
256
+ out = { "at" => l["at"], "table" => l["table"], "type" => l["type"], "prio" => l["prio"], "text" => l["text"] }
257
+ out["ai"] = true if l["ai"]
258
+ out
259
+ end
260
+ end
261
+
262
+ # `asterism-games commentate mahjong`: the connection, the spectate
263
+ # keys, the web server and the browser.
264
+ module MahjongRunner
265
+ SPECTATE = "asterism/*/spectate/*/state"
266
+ STALE_S = 15
267
+
268
+ module_function
269
+
270
+ def run(opts, out: $stdout, err: $stderr)
271
+ require "asterism"
272
+ # A pipe or a file (--json to another tool) gets each line at once.
273
+ out.sync = true if out.respond_to?(:sync=)
274
+ info = opts[:json] ? err : out
275
+ voice, note = start_voice(opts, info)
276
+ ai, ai_status = AIComment.from_options(opts, log: ->(m) { info.puts "commentate: #{m}" })
277
+ hub = MahjongHub.new(public: opts[:public], table: table_filter(opts), json: opts[:json] ? out : nil,
278
+ version: AsterismGames::VERSION, voice: voice, voice_note: note, ai: ai,
279
+ ai_status: ai_status)
280
+ server = Server.new(hub, port: opts[:port], log: ->(m) { err.puts("commentate: #{m}") }).start
281
+ info.puts "commentate: #{server.url} (127.0.0.1 only; Ctrl-C quits)"
282
+ info.puts "commentate: hands hidden (--public)" if opts[:public]
283
+ open_browser(server.url, err) if opts[:open]
284
+ prefix = room_prefix(opts[:namespace])
285
+ connect(opts, err) do |net|
286
+ net.on_leave { |n| hub.host_left(n) }
287
+ net.on_join { |n| hub.host_back(n) }
288
+ net.start
289
+ net.session.subscribe(SPECTATE) do |sample|
290
+ next if sample.respond_to?(:kind) && sample.kind == :delete
291
+ _, host, _, name, = sample.key.to_s.split("/")
292
+ next unless wanted?(host, name, opts[:table], prefix)
293
+ v = Asterism::Codec.unpack(sample.payload)
294
+ hub.update(host, name, v)
295
+ rescue StandardError => e
296
+ err.puts "commentate: #{sample.key}: #{e.class}: #{e.message}"
297
+ end
298
+ stop = Thread::Queue.new
299
+ %w[INT TERM].each { |s| trap(s) { stop << true } }
300
+ until stop.pop(timeout: 2)
301
+ hub.check_stale(STALE_S)
302
+ hub.tick
303
+ end
304
+ end
305
+ ensure
306
+ server&.stop
307
+ voice&.stop
308
+ ai&.stop
309
+ end
310
+
311
+ # The VOICEVOX voice ([voice, nil]), or [nil, note] when the engine is
312
+ # not there (the browser's voice reads). Voicevox::NotFound (no such
313
+ # character or style) goes up: the command says what there is.
314
+ def start_voice(opts, info, root: ClipBank.cache_root)
315
+ url = opts[:voicevox] || Voicevox::DEFAULT_URL
316
+ log = ->(m) { info.puts "commentate: #{m}" }
317
+ voice = Voice.connect(url: url, speaker: opts[:speaker] || DEFAULT_SPEAKER, style: opts[:style] || DEFAULT_STYLE,
318
+ root: root, log: log)
319
+ unless voice
320
+ info.puts "commentate: no VOICEVOX at #{url}: the browser's voice reads (README \"Commentary\", \"The voice\")"
321
+ info.puts "commentate: VOICEVOX is a free app you install yourself (it is not in this gem): " \
322
+ "#{Voicevox::HOME}; start it and run this again for #{opts[:speaker] || DEFAULT_SPEAKER}'s voice"
323
+ return [nil, "missing"]
324
+ end
325
+ info.puts "commentate: VOICEVOX #{voice.engine_version} at #{url}: #{voice.speaker} (#{voice.style}, #{voice.id}), " \
326
+ "fixed phrases in #{voice.bank.dir}; credit: #{voice.credit}"
327
+ voice.make_missing_async
328
+ [voice, nil]
329
+ end
330
+
331
+ def connect(opts, err, &blk)
332
+ require "socket"
333
+ node = opts[:node] || (opts[:credentials] ? nil : "commentate-#{Socket.gethostname[/\A[A-Za-z0-9_-]+/]}-#{Process.pid}")
334
+ kw = { app: "commentate" }
335
+ kw[:node] = node if node
336
+ kw[:credentials] = opts[:credentials] if opts[:credentials]
337
+ # No namespace: the spectate keys are outside every room (README
338
+ # "Spectate"); --namespace room/01 picks that room's tables.
339
+ Asterism.connect(opts[:locator], **kw) do |net|
340
+ net.on_error { |e, where| err.puts "commentate: #{where}: #{e.class}: #{e.message}" }
341
+ blk.call(net)
342
+ end
343
+ end
344
+
345
+ # --namespace room/01: the room's tables are room01-<table> outside it.
346
+ def room_prefix(ns)
347
+ return nil if ns.to_s.empty?
348
+ "#{ns.to_s.delete('/')}-"
349
+ end
350
+
351
+ # What --table / --namespace name, for the page's first choice.
352
+ def table_filter(opts)
353
+ t = opts[:table].to_s
354
+ return nil if t.empty?
355
+ parts = t.split("/")
356
+ parts.size >= 3 ? "#{parts[0]}/#{parts[-1]}" : t
357
+ end
358
+
359
+ # --table: <table>, <host>/<table>, <host>/spectate/<table> or the
360
+ # play's <host>/games/<table>.
361
+ def wanted?(host, name, table, prefix)
362
+ return false if host.nil? || name.nil?
363
+ return false if prefix && !name.start_with?(prefix)
364
+ return true if table.to_s.empty?
365
+ parts = table.to_s.split("/")
366
+ want = parts.last
367
+ want = "#{prefix}#{want}" if prefix && !want.start_with?(prefix)
368
+ return false unless name == want
369
+ parts.size == 1 || parts.first == host
370
+ end
371
+
372
+ # The page in the browser: macOS open, WSL wslview or Windows' start,
373
+ # elsewhere xdg-open. A failure only says the URL again.
374
+ def open_browser(url, err)
375
+ cmd = if RUBY_PLATFORM.include?("darwin") then ["open", url]
376
+ elsif wsl?
377
+ which("wslview") ? ["wslview", url] : ["cmd.exe", "/c", "start", "", url]
378
+ elsif which("xdg-open") then ["xdg-open", url]
379
+ end
380
+ return err.puts("commentate: open #{url} in a browser") unless cmd
381
+ # cmd.exe complains in a directory of Linux's: start it in C:.
382
+ dir = cmd[0] == "cmd.exe" && File.directory?("/mnt/c") ? "/mnt/c" : Dir.pwd
383
+ pid = Process.spawn(*cmd, %i[out err] => File::NULL, chdir: dir)
384
+ Process.detach(pid)
385
+ rescue SystemCallError
386
+ err.puts "commentate: open #{url} in a browser"
387
+ end
388
+
389
+ def wsl?
390
+ File.read("/proc/version").match?(/microsoft/i)
391
+ rescue SystemCallError
392
+ false
393
+ end
394
+
395
+ def which(cmd)
396
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |d| File.executable?(File.join(d, cmd)) }
397
+ end
398
+ end
399
+ end
400
+ end