asterism-games 0.18.0 → 0.20.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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module AsterismGames
8
+ module Commentate
9
+ # The VOICEVOX engine's HTTP (README "Commentary", "The voice"): a free
10
+ # Japanese speech synthesiser running on this machine (the VOICEVOX app
11
+ # or its engine in docker). The standard library's net/http only.
12
+ #
13
+ # vv = Voicevox.new("http://127.0.0.1:50021")
14
+ # vv.version # => "0.25.0" (raises Voicevox::Error when it is not there)
15
+ # id = vv.style_id("ずんだもん", "ノーマル") # the number from /speakers, never assumed
16
+ # wav = vv.synthesize("リーチなのだ!", id) # the WAV's bytes
17
+ class Voicevox
18
+ DEFAULT_URL = "http://127.0.0.1:50021"
19
+ # Where people get the VOICEVOX app (it is not in this gem).
20
+ HOME = "https://voicevox.hiroshiba.jp/"
21
+
22
+ # The engine is not there, or said no.
23
+ class Error < StandardError; end
24
+
25
+ # No such character or style: the message lists what there is.
26
+ class NotFound < Error
27
+ attr_reader :candidates
28
+
29
+ def initialize(message, candidates)
30
+ super(message)
31
+ @candidates = candidates
32
+ end
33
+ end
34
+
35
+ attr_reader :url
36
+
37
+ def initialize(url = DEFAULT_URL, open_timeout: 2, read_timeout: 60)
38
+ @url = url.to_s.empty? ? DEFAULT_URL : url.to_s.chomp("/")
39
+ @uri = URI(@url)
40
+ raise Error, "#{@url}: not an http:// URL" unless @uri.is_a?(URI::HTTP) && @uri.host
41
+ @open_timeout = open_timeout
42
+ @read_timeout = read_timeout
43
+ end
44
+
45
+ # GET /version: the engine's version (a JSON string).
46
+ def version
47
+ v = JSON.parse(request(:get, "/version").force_encoding("UTF-8"))
48
+ v.is_a?(String) ? v : v.to_s
49
+ rescue JSON::ParserError => e
50
+ raise Error, "#{@url}/version: #{e.message}"
51
+ end
52
+
53
+ # GET /speakers: [{"name", "styles" => [{"name", "id"}]}], as the engine
54
+ # says them.
55
+ def speakers
56
+ list = JSON.parse(request(:get, "/speakers").force_encoding("UTF-8"))
57
+ raise Error, "#{@url}/speakers: not a list" unless list.is_a?(Array)
58
+ list.map do |s|
59
+ { "name" => s["name"].to_s,
60
+ "styles" => Array(s["styles"]).map { |st| { "name" => st["name"].to_s, "id" => st["id"] } } }
61
+ end
62
+ rescue JSON::ParserError => e
63
+ raise Error, "#{@url}/speakers: #{e.message}"
64
+ end
65
+
66
+ # The style's number, looked up by the character's and the style's
67
+ # names. NotFound lists the characters (or the character's styles).
68
+ def style_id(speaker, style, list = speakers)
69
+ s = list.find { |x| x["name"] == speaker }
70
+ unless s
71
+ raise NotFound.new("VOICEVOX has no character #{speaker.inspect} (there are: #{list.map { |x| x['name'] }.join(', ')})",
72
+ list.map { |x| x["name"] })
73
+ end
74
+ st = s["styles"].find { |x| x["name"] == style }
75
+ unless st
76
+ names = s["styles"].map { |x| x["name"] }
77
+ raise NotFound.new("#{speaker} has no style #{style.inspect} (there are: #{names.join(', ')})", names)
78
+ end
79
+ st["id"]
80
+ end
81
+
82
+ # POST /audio_query then POST /synthesis: the WAV of text in style id.
83
+ def synthesize(text, id)
84
+ q = request(:post, "/audio_query?#{URI.encode_www_form(text: text, speaker: id)}", "")
85
+ wav = request(:post, "/synthesis?#{URI.encode_www_form(speaker: id)}", q, "Content-Type" => "application/json")
86
+ raise Error, "#{@url}/synthesis: not a WAV" unless wav.start_with?("RIFF")
87
+ wav
88
+ end
89
+
90
+ private
91
+
92
+ def request(method, path, body = nil, headers = {})
93
+ http = Net::HTTP.new(@uri.host, @uri.port)
94
+ http.use_ssl = @uri.scheme == "https"
95
+ http.open_timeout = @open_timeout
96
+ http.read_timeout = @read_timeout
97
+ http.write_timeout = @read_timeout if http.respond_to?(:write_timeout=)
98
+ res = http.start do |h|
99
+ method == :get ? h.get(path) : h.post(path, body.to_s, headers)
100
+ end
101
+ raise Error, "#{@url}#{path[/\A[^?]*/]}: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
102
+ res.body.to_s.b
103
+ rescue Error
104
+ raise
105
+ rescue StandardError => e
106
+ raise Error, "#{@url}: #{e.class}: #{e.message}"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+ require_relative "play"
5
+ require_relative "commentate/voicevox"
6
+ require_relative "commentate/voice"
7
+ require_relative "commentate/ai_comment"
8
+
9
+ module AsterismGames
10
+ # `asterism-games commentate <game>` (README "Commentary"): watches a
11
+ # table from outside (its spectate key, README "Spectate"), draws it in
12
+ # a browser and comments on it in captions and a voice (VOICEVOX's, on
13
+ # this machine, else the browser's). It
14
+ # only reads: it never joins or calls a table. CRuby and the asterism gem
15
+ # only; the page is served from this process on 127.0.0.1.
16
+ module Commentate
17
+ GAMES = {
18
+ "mahjong" => "a mahjong table: the four hands drawn with Family mruby's tiles, the commentary as captions and voice"
19
+ }.freeze
20
+ DEFAULT_PORT = 7470
21
+ DEFAULT_SPEAKER = "ずんだもん"
22
+ DEFAULT_STYLE = "ノーマル"
23
+
24
+ USAGE = <<~TEXT
25
+ Usage: asterism-games commentate <game> [options]
26
+
27
+ Games:
28
+ %<games>s
29
+
30
+ Options:
31
+ --router LOCATOR the Zenoh router (tcp/127.0.0.1:7447 when nothing says otherwise)
32
+ --credentials FILE a console's .tar (a viewer's or a spectator's role reads the
33
+ spectate keys; the router and the node ID come from it)
34
+ --namespace PREFIX a room's tables (room/01: their spectate keys room01-<table>,
35
+ outside the room; the connection itself has no namespace)
36
+ --table TABLE the table to show first (<table>, <host>/<table>, or
37
+ <host>/spectate/<table>); without it every table that puts
38
+ its spectate key is listed and the first one shown
39
+ --node NODE the node ID (default: commentate-<host>-<pid>, or the .tar's name)
40
+ --port N the page's port on 127.0.0.1 (default %<port>d; taken: the next free)
41
+ --public hide the hands: the page never gets them and the commentary
42
+ never tells them (for a stream; H on the page does the same
43
+ for that page only)
44
+ --no-open do not open the browser
45
+ --json every commentary line as a JSON line on standard output
46
+ --voicevox URL the VOICEVOX engine (default VOICEVOX_URL, else
47
+ http://127.0.0.1:50021); not there: the browser's voice.
48
+ VOICEVOX is a free app you install and start yourself
49
+ (not in this gem)
50
+ --speaker NAME the VOICEVOX character (default %<speaker>s)
51
+ --style NAME its style (default %<style>s)
52
+ --ai on|off the AI's one-liners (default on; they need
53
+ ANTHROPIC_API_KEY and the anthropic gem)
54
+ --ai-model MODEL the model (default %<ai_model>s)
55
+ --ai-daily-usd USD what they may cost a day (default %<ai_usd>.2f); then the
56
+ fixed commentary alone
57
+ -h, --help this text
58
+
59
+ The page: Space pauses, M turns the voice off and on, H hides the hands.
60
+ The browser speaks once the page has been clicked (its autoplay rule).
61
+
62
+ The voice: VOICEVOX's character says the fixed phrases (リーチ, ポン,
63
+ ロン, the rounds ...) from WAV files made on this machine at the first
64
+ start, and the rest of each line as it comes. The page shows the credit
65
+ (VOICEVOX:<character>); put it in a stream's description too.
66
+
67
+ The AI's one-liners: at the big moments (a riichi, a win, a draw, the
68
+ lead changing hands, the last round, the end) and after a quiet
69
+ minute, Claude says a short line after the fixed commentary (at most
70
+ one call every 20 s, 8 s for a bigger scene than the last, such as a
71
+ win after a riichi; only while a page is open). With the hands hidden
72
+ (--public, H) it is not told the hands. What it costs is counted in
73
+ $XDG_STATE_HOME/asterism-games/commentary_ai_usage.json
74
+ (~/.local/state/... by default); the page shows today's money.
75
+
76
+ Usage: asterism-games commentate voices --list [--voicevox URL]
77
+ asterism-games commentate voices --make [--voicevox URL] [--speaker NAME] [--style NAME]
78
+ --list the engine's characters and their styles
79
+ --make make the fixed phrases' WAV files now (the commentary makes
80
+ the missing ones at its start too), in
81
+ $XDG_CACHE_HOME/asterism-games/voices/<speaker>-<style>/
82
+ (~/.cache/... by default; delete it at any time)
83
+
84
+ With neither --router nor --credentials, ASTERISM_ROUTER and
85
+ ASTERISM_CREDENTIALS from the environment are used (all or nothing).
86
+ TEXT
87
+
88
+ module_function
89
+
90
+ def usage
91
+ format(USAGE, games: GAMES.map { |n, text| format(" %-9s %s", n, text) }.join("\n"), port: DEFAULT_PORT,
92
+ speaker: DEFAULT_SPEAKER, style: DEFAULT_STYLE, ai_model: AIComment::DEFAULT_MODEL,
93
+ ai_usd: AIComment::DEFAULT_DAILY_USD)
94
+ end
95
+
96
+ def main(argv, out: $stdout, err: $stderr)
97
+ game = argv.first
98
+ if game.nil? || %w[-h --help help].include?(game)
99
+ out.puts usage
100
+ return 0
101
+ end
102
+ return voices(argv.drop(1), out: out, err: err) if game == "voices"
103
+ unless GAMES.key?(game)
104
+ err.puts "asterism-games commentate: no game #{game.inspect} (#{GAMES.keys.join(', ')})"
105
+ return 2
106
+ end
107
+ opts = parse(argv.drop(1))
108
+ if opts[:help]
109
+ out.puts usage
110
+ return 0
111
+ end
112
+ require_relative "commentate/mahjong"
113
+ MahjongRunner.run(opts, out: out, err: err)
114
+ 0
115
+ rescue Voicevox::NotFound => e
116
+ err.puts "asterism-games commentate #{game}: #{e.message} (voices --list)"
117
+ 2
118
+ rescue ArgumentError => e
119
+ err.puts "asterism-games commentate #{game}: #{e.message} (--help)"
120
+ 2
121
+ end
122
+
123
+ def parse(args, env: ENV)
124
+ opts = { router: nil, credentials: nil, namespace: nil, table: nil, node: nil, port: DEFAULT_PORT,
125
+ public: false, open: true, json: false, help: false,
126
+ voicevox: env["VOICEVOX_URL"].to_s.empty? ? Voicevox::DEFAULT_URL : env["VOICEVOX_URL"],
127
+ speaker: DEFAULT_SPEAKER, style: DEFAULT_STYLE,
128
+ ai: true, ai_model: AIComment::DEFAULT_MODEL, ai_daily_usd: AIComment::DEFAULT_DAILY_USD }
129
+ args = args.dup
130
+ until args.empty?
131
+ k = args.shift
132
+ need = lambda do
133
+ v = args.shift
134
+ raise ArgumentError, "#{k} takes a value" if v.nil? || v.start_with?("--")
135
+ v
136
+ end
137
+ case k
138
+ when "--ai"
139
+ v = need.call
140
+ raise ArgumentError, "--ai takes on or off" unless %w[on off].include?(v)
141
+ opts[:ai] = v == "on"
142
+ when "--ai-model" then opts[:ai_model] = need.call
143
+ when "--ai-daily-usd"
144
+ v = Float(need.call, exception: false)
145
+ raise ArgumentError, "--ai-daily-usd takes a number of dollars (0 or more)" unless v&.finite? && v >= 0
146
+ opts[:ai_daily_usd] = v
147
+ when "--router" then opts[:router] = need.call
148
+ when "--credentials" then opts[:credentials] = need.call
149
+ when "--namespace" then opts[:namespace] = need.call
150
+ when "--table" then opts[:table] = need.call
151
+ when "--node" then opts[:node] = need.call
152
+ when "--port"
153
+ v = need.call
154
+ raise ArgumentError, "--port takes a number (1-65535)" unless v.match?(/\A\d+\z/) && v.to_i.between?(1, 65_535)
155
+ opts[:port] = v.to_i
156
+ when "--public" then opts[:public] = true
157
+ when "--no-open" then opts[:open] = false
158
+ when "--json" then opts[:json] = true
159
+ when "--voicevox" then opts[:voicevox] = need.call
160
+ when "--speaker" then opts[:speaker] = need.call
161
+ when "--style" then opts[:style] = need.call
162
+ when "-h", "--help" then opts[:help] = true
163
+ else raise ArgumentError, "unknown option #{k}"
164
+ end
165
+ end
166
+ Play.network!(opts, env)
167
+ opts
168
+ end
169
+
170
+ # `commentate voices --list | --make`: the engine's characters, or the
171
+ # fixed phrases' files made now.
172
+ def voices(args, out: $stdout, err: $stderr, env: ENV, root: nil)
173
+ opts = { voicevox: env["VOICEVOX_URL"].to_s.empty? ? Voicevox::DEFAULT_URL : env["VOICEVOX_URL"],
174
+ speaker: DEFAULT_SPEAKER, style: DEFAULT_STYLE, list: false, make: false }
175
+ args = args.dup
176
+ until args.empty?
177
+ k = args.shift
178
+ need = lambda do
179
+ v = args.shift
180
+ raise ArgumentError, "#{k} takes a value" if v.nil? || v.start_with?("--")
181
+ v
182
+ end
183
+ case k
184
+ when "--list" then opts[:list] = true
185
+ when "--make" then opts[:make] = true
186
+ when "--voicevox" then opts[:voicevox] = need.call
187
+ when "--speaker" then opts[:speaker] = need.call
188
+ when "--style" then opts[:style] = need.call
189
+ when "-h", "--help" then return out.puts(usage) || 0
190
+ else raise ArgumentError, "unknown option #{k}"
191
+ end
192
+ end
193
+ raise ArgumentError, "--list or --make" unless opts[:list] || opts[:make]
194
+ vv = Voicevox.new(opts[:voicevox])
195
+ begin
196
+ ver = vv.version
197
+ rescue Voicevox::Error => e
198
+ err.puts "asterism-games commentate voices: no VOICEVOX at #{vv.url} (#{e.message}); " \
199
+ "start the VOICEVOX app or its engine (README \"Commentary\", \"The voice\")"
200
+ return 1
201
+ end
202
+ list = vv.speakers
203
+ if opts[:list]
204
+ out.puts "VOICEVOX #{ver} at #{vv.url}: #{list.size} character(s) (--speaker NAME --style NAME)"
205
+ list.each { |s| out.puts " #{s['name']}: #{s['styles'].map { |st| "#{st['name']} (#{st['id']})" }.join(', ')}" }
206
+ end
207
+ if opts[:make]
208
+ id = vv.style_id(opts[:speaker], opts[:style], list)
209
+ voice = Voice.new(vv, opts[:speaker], opts[:style], id, version: ver, root: root || ClipBank.cache_root)
210
+ out.puts "voices: #{opts[:speaker]} (#{opts[:style]}, #{id}) into #{voice.bank.dir}"
211
+ made, had = voice.bank.make!(log: ->(m) { out.puts " #{m}" })
212
+ out.puts "voices: #{made} made, #{had} already there. Credit: #{voice.credit}"
213
+ end
214
+ 0
215
+ rescue Voicevox::Error => e
216
+ err.puts "asterism-games commentate voices: #{e.message}"
217
+ 1
218
+ rescue ArgumentError => e
219
+ err.puts "asterism-games commentate voices: #{e.message} (--help)"
220
+ 2
221
+ end
222
+ end
223
+ end
@@ -107,7 +107,7 @@ module AsterismGames
107
107
  prices: ->(k) { settings.prices(k) })
108
108
  unless deciders.empty?
109
109
  @log.call("games: AI players: #{deciders.keys.join(', ')} (#{AI::Hub.limits_text(deciders.keys, budget)}, " \
110
- "#{settings.calls_per_game} decisions a game)")
110
+ "#{settings.calls_per_game} decisions a seat in a game)")
111
111
  end
112
112
  AI::Hub.new(deciders, budget, calls_per_game: settings.calls_per_game, threads: settings.threads, log: @log)
113
113
  end
@@ -78,7 +78,7 @@ module AsterismGames
78
78
  @stats = Hash.new(0)
79
79
  @ai_kinds = [] # the AI players the host can offer now (the lobby view's "ak")
80
80
  @ai_calls_per_game = nil
81
- @ai_calls = 0 # decisions asked this game
81
+ @ai_calls = Hash.new(0) # seat => decisions asked this game
82
82
  @ai_want = nil # the decision asked of the AI seat to move: {seat:, key:, kind:, at:, sent:}
83
83
  @ai_got = nil # [key, move number or nil] come back
84
84
  load_saved(0)
@@ -205,7 +205,7 @@ module AsterismGames
205
205
  @seats = @seats.shuffle(random: Random.new(SecureRandom.random_number(2**32)))
206
206
  @game = Othello::Match.new
207
207
  @result = nil
208
- @ai_calls = 0
208
+ @ai_calls = Hash.new(0)
209
209
  @status = "play"
210
210
  @log&.call("othello: #{@name}: game started (#{@seats.map { |s| s['name'] }.join(' (black), ')} (white))")
211
211
  schedule(now)
@@ -272,7 +272,8 @@ module AsterismGames
272
272
  # does not come in time, or fails, is the CPU's. A turn with one legal
273
273
  # move is not asked (it is played as the CPU plays). The host turns
274
274
  # the AI seats into CPUs when the day's money is spent, and the table
275
- # does when the game has asked ai_calls_per_game decisions.
275
+ # turns an AI seat into a CPU when that seat has asked
276
+ # ai_calls_per_game decisions in the game (each seat its own count).
276
277
 
277
278
  def ai_seat?(seat)
278
279
  s = @seats[seat]
@@ -291,12 +292,12 @@ module AsterismGames
291
292
  def ai_requests(now)
292
293
  w = @ai_want
293
294
  return [] if w.nil? || w[:sent]
294
- if @ai_calls_per_game && @ai_calls >= @ai_calls_per_game
295
- ai_off!("#{@ai_calls_per_game} decisions in this game")
295
+ if @ai_calls_per_game && @ai_calls[w[:seat]] >= @ai_calls_per_game
296
+ ai_off!("#{@ai_calls_per_game} decisions of seat #{w[:seat]} in this game", seats: [w[:seat]])
296
297
  return []
297
298
  end
298
299
  w[:sent] = true
299
- @ai_calls += 1
300
+ @ai_calls[w[:seat]] += 1
300
301
  @stats["ai_asked"] += 1
301
302
  [{ seat: w[:seat], key: w[:key], kind: w[:kind], until: w[:at],
302
303
  request: { "game" => "othello", "table" => @name, "seat" => w[:seat],
@@ -313,10 +314,12 @@ module AsterismGames
313
314
  true
314
315
  end
315
316
 
316
- # The AI seats become CPUs (the day's money, or the game's decisions,
317
- # are used up): all of them, or those of one kind.
318
- def ai_off!(why, kind: nil)
319
- seats = (0...SEATS).select { |i| ai_seat?(i) && (kind.nil? || @seats[i]["ai"] == kind) }
317
+ # The AI seats become CPUs (the day's money, or a seat's decisions of
318
+ # the game, are used up): all of them, those of one kind, or the
319
+ # seats named.
320
+ def ai_off!(why, kind: nil, seats: nil)
321
+ only = seats
322
+ seats = (0...SEATS).select { |i| ai_seat?(i) && (kind.nil? || @seats[i]["ai"] == kind) && (only.nil? || only.include?(i)) }
320
323
  return false if seats.empty?
321
324
  seats.each { |i| @seats[i].delete("ai") }
322
325
  @stats["ai_off"] += 1
@@ -21,7 +21,8 @@ module AsterismGames
21
21
  On a terminal it reads keys, as the table app does: Left/Right choose a tile,
22
22
  Enter or Space discards it; R riichi, T tsumo/ron, P pon, C V B chi, K L J kan,
23
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.
24
+ C CPUs, A then 1-4 a seat's CPU or AI player, Enter start, W a watch game;
25
+ Q or Ctrl+C quits; : (or /) opens a line for a command.
25
26
  Mahjong commands (one per line, or after :):
26
27
  a .. n discard that tile of the hand (the drawn tile is the last letter)
27
28
  6s, 0p, 東 discard that tile (0: the red five; 1z..7z for the honours too)
@@ -33,7 +34,8 @@ module AsterismGames
33
34
  ok the result's OK (an empty line too); at the end, the owner: back to the lobby
34
35
  sit [1-4], stand, cpu [1-4] (empty seats to CPUs; the owner), nocpu 1-4,
35
36
  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
+ rule ITEM VALUE (README "Local rules"), start (the owner),
38
+ watch (the owner: the four seats CPUs or AI players, a game only watched)
37
39
  auto let the client play for you (wins, passes, discards the drawn tile,
38
40
  OKs the results); again to stop
39
41
  tables, view, help, quit
@@ -50,8 +52,8 @@ module AsterismGames
50
52
  TIMER_EVERY_S = 1.0
51
53
 
52
54
  # --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
+ LOBBY_WORDS = %w[sit stand cpu nocpu ai start watch tonpuu hanchan rule].freeze
56
+ OWNER_WORDS = %w[cpu nocpu ai start watch tonpuu hanchan rule].freeze
55
57
 
56
58
  REASONS = {
57
59
  "token" => "卓の人ではない", "not seated" => "席に着いていない", "stale" => "画面が古い: 読み直し中",
@@ -60,7 +62,8 @@ module AsterismGames
60
62
  "not owner" => "卓の主だけができる", "seats" => "4 席がそろっていない", "no human" => "人が 1 人もいない",
61
63
  "no change" => "変わらない", "full" => "卓が満員", "spectators" => "観戦の人数が上限",
62
64
  "value" => "選べない値", "no rule" => "その項目は無い", "no ai" => "その AI は今は出せない",
63
- "identity" => "この部屋での名乗りが合わない"
65
+ "identity" => "この部屋での名乗りが合わない", "human" => "人が座っている (観戦対局は 4 席とも CPU か AI)",
66
+ "ai limit" => MahjongText::WATCH_AI_NOTE
64
67
  }.freeze
65
68
 
66
69
  # The whole run: connect, find the table, play until quit (or over).
@@ -120,6 +123,7 @@ module AsterismGames
120
123
  @keys = keys
121
124
  @sel = -1
122
125
  @pick = false
126
+ @ai_pick = false # A in the lobby: a seat's number comes next
123
127
  @typing = false
124
128
  @path = path
125
129
  @name = name
@@ -233,7 +237,8 @@ module AsterismGames
233
237
  # One key (Play.key_loop); false to quit, :line for a line to type.
234
238
  def key(k)
235
239
  v = @view.is_a?(Hash) ? @view : nil
236
- r = MahjongText.key_command(k, v, sel: @sel, pick: @pick)
240
+ r = MahjongText.key_command(k, v, sel: @sel, pick: @pick, ai_pick: @ai_pick)
241
+ @ai_pick = false unless r[0] == :ai_pick
237
242
  case r[0]
238
243
  when :quit then return false
239
244
  when :line then return :line
@@ -248,6 +253,12 @@ module AsterismGames
248
253
  @note = ""
249
254
  draw
250
255
  end
256
+ when :ai_pick
257
+ @lock.synchronize do
258
+ @ai_pick = r[1]
259
+ @note = ""
260
+ draw
261
+ end
251
262
  when :act
252
263
  answer = act(r[1], r[2])
253
264
  say(answer == "ok" ? "" : answer)
@@ -315,9 +326,10 @@ module AsterismGames
315
326
  return "画面を待っています" unless v.is_a?(Hash) && !v["error"]
316
327
  return "待ち合わせを待っています" unless v["st"] == "wait"
317
328
  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?)
329
+ unless MahjongText.owner?(v)
330
+ return v["me"].to_i >= 0 ? "卓の主を待っています" : "席に着くのを待っています"
331
+ end
332
+ return "4 席がそろうのを待っています" if %w[start watch].include?(w) && (v["sk"] || [0]).any?(&:zero?)
321
333
  nil
322
334
  end
323
335
 
@@ -394,6 +406,7 @@ module AsterismGames
394
406
  when "nocpu" then args.empty? ? "nocpu 1-4" : reason(@table.cpu(@token, seat_arg(args), false))
395
407
  when "ai" then args.size < 2 ? "ai 1-4 KIND" : reason(@table.cpu(@token, seat_arg(args), args[1]))
396
408
  when "start" then reason(@table.start(@token))
409
+ when "watch" then reason(@table.watch(@token))
397
410
  when "tonpuu" then reason(@table.rule(@token, 0, 1))
398
411
  when "hanchan" then reason(@table.rule(@token, 0, 0))
399
412
  when "rule"
@@ -453,7 +466,7 @@ module AsterismGames
453
466
 
454
467
  # What the screen needs of the keys (nil: commands are typed).
455
468
  def key_state
456
- @keys && !@typing ? { sel: @sel, pick: @pick } : nil
469
+ @keys && !@typing ? { sel: @sel, pick: @pick, ai_pick: @ai_pick } : nil
457
470
  end
458
471
 
459
472
  # The countdown on a terminal (the move's time and the bank, the
@@ -157,6 +157,7 @@ module AsterismGames
157
157
  def lobby
158
158
  v = @v
159
159
  out = ["#{v['t'] || @path} 待ち合わせ#{" ゲームホスト #{v['hv']}" if v['hv']}"]
160
+ out << " (卓の主: 自分)" if v["mo"] == 1 && @me.negative?
160
161
  sk = v["sk"] || [0, 0, 0, 0]
161
162
  ai = v["ai"] || []
162
163
  4.times do |s|
@@ -169,8 +170,11 @@ module AsterismGames
169
170
  out << " 席#{s + 1} #{n}#{" (#{tags.join(', ')})" unless tags.empty?}"
170
171
  end
171
172
  out << " ルール: #{rules_text}" if v["ru"]
172
- watchers = (v["mb"] || []).select { |m| m[1] == -1 }.map(&:first)
173
+ # Members without a seat; the owner among them (an owner without a
174
+ # seat, README "Watch games") marked.
175
+ watchers = (v["mb"] || []).select { |m| m[1] == -1 }.map { |m| m[2].to_i & 2 == 2 ? "#{m[0]}(卓の主)" : m[0] }
173
176
  out << " 観戦: #{watchers.join(' ')}" unless watchers.empty?
177
+ out << " #{MahjongText::WATCH_AI_NOTE}" if v["wc"] == 2 && MahjongText.owner?(v)
174
178
  out
175
179
  end
176
180
 
@@ -189,6 +193,7 @@ module AsterismGames
189
193
  v = @v
190
194
  r = v["r"] || []
191
195
  out = []
196
+ out << "観戦対局 (CPU と AI だけ)" if v["wm"] == 1
192
197
  if r.size >= 4
193
198
  h = row.text("#{round_name(r)} 供託#{r[3]} 残り#{r[5]} ドラ ")
194
199
  bytes(v["dora"]).each { |t| tile_cell(h, t) }
@@ -463,12 +468,16 @@ module AsterismGames
463
468
  types = opts.map(&:first).uniq
464
469
  case v["st"]
465
470
  when "wait"
466
- return "観戦中: sit で座る、quit で出る" if @me.negative?
467
- owner = v["ow"] == @me
471
+ owner = MahjongText.owner?(v)
472
+ if @me.negative?
473
+ return "観戦中 (卓の主): sit で座る、cpu [席]、ai 席 KIND、watch (観戦で始める)、quit" if owner
474
+ return "観戦中: sit で座る、quit で出る"
475
+ end
468
476
  return "stand、cpu [席] (空きを CPU で)、nocpu 席、tonpuu / hanchan、start、quit" if owner
469
477
  return "4 席がそろったら卓の主が始める (stand、quit)"
470
478
  when "over"
471
479
  return "ok で待ち合わせに戻す (quit で出る)" if types.include?(NEXT)
480
+ return "観戦対局: しばらくすると待ち合わせに戻る (quit で出る)" if v["wm"] == 1
472
481
  return "次へ: 卓の主が待ち合わせに戻す (quit で出る)"
473
482
  when "result"
474
483
  return "ok で次へ" if types.include?(NEXT)
@@ -512,6 +521,7 @@ module AsterismGames
512
521
  when "wait" then return lobby_keys
513
522
  when "over"
514
523
  return "[Enter]待ち合わせに戻す #{COMMON_KEYS}" if types.include?(NEXT)
524
+ return "観戦対局: しばらくすると待ち合わせに戻る #{COMMON_KEYS}" if v["wm"] == 1
515
525
  return "次へ: 卓の主が待ち合わせに戻す #{COMMON_KEYS}"
516
526
  when "result"
517
527
  return "[Enter]OK #{COMMON_KEYS}" if types.include?(NEXT)
@@ -567,26 +577,37 @@ module AsterismGames
567
577
  end
568
578
 
569
579
  # The lobby's keys: 1-4 for the seats that may be taken (empty or a
570
- # CPU's), S stand, C CPUs and Enter start (the owner).
580
+ # CPU's), S stand, C CPUs, A then 1-4 a seat's CPU or AI player,
581
+ # Enter start and W a watch game (the owner; README "Watch games").
571
582
  def lobby_keys
572
583
  v = @v
573
584
  sk = v["sk"] || [0, 0, 0, 0]
585
+ if @keys && @keys[:ai_pick]
586
+ return "AI: [1]-[4] 席を選ぶ (空き→CPU→#{Array(v['ak']).map { |k| AI_LABELS[k] || k }.join('→')}→CPU) " \
587
+ "ほかのキーでやめる"
588
+ end
589
+ owner = MahjongText.owner?(v)
574
590
  free = (0..3).select { |s| sk[s] != 1 && s != @me }.map { |s| "[#{s + 1}]" }
575
591
  words = []
576
592
  words << "#{free.join}座る" unless free.empty?
577
- if @me >= 0
578
- words << "[S]立つ"
579
- if v["ow"] == @me
580
- words << "[C]CPU で埋める" if sk.any?(&:zero?)
581
- words << "[Enter]始める" unless sk.any?(&:zero?)
582
- else
583
- words << "(卓の主が始める)"
593
+ words << "[S]立つ" if @me >= 0
594
+ if owner
595
+ words << "[C]CPU で埋める" if sk.any?(&:zero?)
596
+ words << "[A]AI" unless Array(v["ak"]).empty?
597
+ if v["wc"] == 1
598
+ words << "[W]観戦で始める"
599
+ elsif @me >= 0 && sk.none?(&:zero?)
600
+ words << "[Enter]始める"
584
601
  end
602
+ elsif @me >= 0
603
+ words << "(卓の主が始める)"
585
604
  end
586
605
  words << COMMON_KEYS
587
- "#{@me.negative? ? '観戦中: ' : ''}#{words.join(' ')}"
606
+ "#{@me.negative? ? (owner ? '観戦中 (卓の主): ' : '観戦中: ') : ''}#{words.join(' ')}"
588
607
  end
589
608
 
609
+ AI_LABELS = { "claude" => "Claude", "jev" => "Jev" }.freeze
610
+
590
611
  def turn_text(r)
591
612
  cur = r[6]
592
613
  return "" if cur.nil? || cur.negative? || r[7].to_i >= 2