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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +109 -0
- data/README.md +385 -14
- data/VERSION +1 -1
- data/bin/asterism-games +8 -0
- data/lib/asterism_games/ai.rb +64 -0
- data/lib/asterism_games/commentate/ai_comment.rb +633 -0
- data/lib/asterism_games/commentate/assets/TILES_LICENSE.txt +42 -0
- data/lib/asterism_games/commentate/assets/commentate.css +100 -0
- data/lib/asterism_games/commentate/assets/commentate.js +735 -0
- data/lib/asterism_games/commentate/assets/index.html +26 -0
- data/lib/asterism_games/commentate/assets/tiles_hand.bmp +0 -0
- data/lib/asterism_games/commentate/assets/tiles_river.bmp +0 -0
- data/lib/asterism_games/commentate/mahjong.rb +400 -0
- data/lib/asterism_games/commentate/mahjong_events.rb +320 -0
- data/lib/asterism_games/commentate/mahjong_phrases.rb +535 -0
- data/lib/asterism_games/commentate/mahjong_voice.yml +93 -0
- data/lib/asterism_games/commentate/server.rb +204 -0
- data/lib/asterism_games/commentate/voice.rb +394 -0
- data/lib/asterism_games/commentate/voicevox.rb +110 -0
- data/lib/asterism_games/commentate.rb +223 -0
- data/lib/asterism_games/host.rb +1 -1
- data/lib/asterism_games/othello/table.rb +13 -10
- data/lib/asterism_games/play/mahjong.rb +23 -10
- data/lib/asterism_games/play/mahjong_screen.rb +33 -12
- data/lib/asterism_games/play/mahjong_text.rb +47 -6
- metadata +18 -2
|
@@ -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
|
data/lib/asterism_games/host.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
|
317
|
-
# are used up): all of them,
|
|
318
|
-
|
|
319
|
-
|
|
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,
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|