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.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.17.0
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The game host. Settings: README "Settings" (a YAML file given by --config
5
+ # or ASTERISM_GAMES_CONFIG, and the environment).
6
+ #
7
+ # bin/asterism-games [--config PATH] [--require FILE]...
8
+ # # the host (tables, or the rooms' tables);
9
+ # # --require: a game of your own (README
10
+ # # "Writing a game"), loaded after the others
11
+ # bin/asterism-games status [--idle] [URL] # the rooms' host: what GET /status says
12
+ # # (the rooms and the permanent tables);
13
+ # # --idle: exit 0 when nobody sits at a table
14
+ # bin/asterism-games play <game> [options] # play at a table in this terminal (othello,
15
+ # # mahjong; README "Playing in a terminal")
16
+ # bin/asterism-games help # this text (also --help, -h)
17
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
18
+
19
+ USAGE = <<~TEXT
20
+ Usage:
21
+ asterism-games [--config PATH] [--require FILE]... the game host (its tables, or the rooms' tables);
22
+ settings: a YAML file (--config or
23
+ ASTERISM_GAMES_CONFIG) and the environment;
24
+ --require: a game of your own, loaded after the others
25
+ asterism-games status [--idle] [URL] what a rooms' host says on GET /status
26
+ (--idle: exit 0 when nobody sits at a table)
27
+ asterism-games play <game> [options] play at a table in this terminal
28
+ (othello, mahjong; play --help for the options)
29
+ asterism-games help this text (also --help, -h)
30
+ TEXT
31
+
32
+ case ARGV.first
33
+ when "help", "--help", "-h"
34
+ puts USAGE
35
+ exit 0
36
+ when "play"
37
+ require "asterism_games/play"
38
+ exit AsterismGames::Play.main(ARGV.drop(1))
39
+ when "status"
40
+ nil
41
+ else
42
+ # The host's own options: a --help among them is a question, not a start.
43
+ if ARGV.include?("--help") || ARGV.include?("-h")
44
+ puts USAGE
45
+ exit 0
46
+ end
47
+ end
48
+
49
+ if ARGV.first == "status"
50
+ require "json"
51
+ require "net/http"
52
+ args = ARGV.drop(1)
53
+ idle = args.delete("--idle")
54
+ url = args.first || "http://127.0.0.1:#{(ENV['ASTERISM_GAMES_STATUS_LISTEN'] || ':7460')[/\d+\z/]}/status"
55
+ begin
56
+ s = JSON.parse(Net::HTTP.get(URI(url)))
57
+ rescue StandardError => e
58
+ warn "asterism-games status: #{url}: #{e.message}"
59
+ exit 2
60
+ end
61
+ rooms = s["rooms"] || {}
62
+ puts "asterism-games #{s['version']} since #{s['since']}: #{rooms.size} room(s) with tables (at most #{s['max']}), " \
63
+ "RSS #{(s['rss_kb'].to_i / 1024.0).round(1)} MB"
64
+ text = lambda do |r|
65
+ tables = (r["tables"] || {}).map do |n, t|
66
+ "#{n} #{t['st']} (#{t['humans']} seated, #{t['cpus']} CPU, #{t['members']} at the table)"
67
+ end
68
+ "#{r['state']}, #{tables.empty? ? 'no tables yet' : tables.join('; ')}"
69
+ end
70
+ rooms.each { |slot, r| puts " room #{slot}: #{text.call(r)}" }
71
+ if (perm = s["permanent"])
72
+ puts " permanent tables#{" (#{perm['node']})" if perm['node']}: #{text.call(perm)}"
73
+ end
74
+ if (ai = s["ai"])
75
+ own = ai["kind_limit_usd"] || {}
76
+ kinds = (ai["kinds"] || {}).map { |k, e| "#{k} #{e['calls']} (#{e['in'].to_i + e['cache_read'].to_i + e['cache_write'].to_i} in, #{e['out']} out tokens, $#{format('%.4f', e['usd'].to_f)}#{" of $#{own[k]}" if own[k]}#{", #{e['failed']} failed" if e['failed']})" }
77
+ puts " AI players #{ai['date']}: #{ai['calls']} decisions, $#{format('%.4f', ai['usd'].to_f)} of $#{ai['limit_usd']}" \
78
+ "#{" (#{kinds.join('; ')})" unless kinds.empty?}"
79
+ end
80
+ puts " rooms waiting for a table: #{s['without_table'].join(', ')}" unless s["without_table"].to_a.empty?
81
+ puts s["busy"] ? "busy: someone sits at a table" : "idle: nobody sits at a table"
82
+ exit(s["busy"] ? 1 : 0) if idle
83
+ exit 0
84
+ end
85
+
86
+ require "asterism_games"
87
+ # The games outside the gem (README "The gem and the image"): app/ of this
88
+ # checkout and of the image, mahjong (which needs riichi_engine, the
89
+ # Gemfile's). Registered first, so a table without "game" is mahjong's as
90
+ # before. The gem has no app/: there the first game is othello.
91
+ app = File.expand_path("../app", __dir__)
92
+ if File.directory?(app)
93
+ $LOAD_PATH.unshift app
94
+ require "asterism_games/mahjong"
95
+ end
96
+ # The sample games, in the gem.
97
+ require "asterism_games/othello"
98
+ require "asterism_games/board"
99
+
100
+ path = ENV["ASTERISM_GAMES_CONFIG"]
101
+ ARGV.each_with_index do |a, i|
102
+ case a
103
+ when "--config" then path = ARGV[i + 1]
104
+ when "--require"
105
+ f = ARGV[i + 1].to_s
106
+ abort "asterism-games: --require takes a file" if f.empty?
107
+ require File.exist?(f) ? File.expand_path(f) : f
108
+ end
109
+ end
110
+ $stdout.sync = true
111
+ config = AsterismGames::Config.load(path)
112
+ log = ->(m) { puts "#{Time.now.strftime('%H:%M:%S')} #{m}" }
113
+ host = config["rooms"] ? AsterismGames::Rooms.new(config, log: log) : AsterismGames::Host.new(config, log: log)
114
+ %w[INT TERM].each { |s| trap(s) { host.stop! } }
115
+ host.run
116
+ puts "games: stopped (state saved)"
data/bin/othello-tty ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # An othello table in a terminal: kept for the scripts and the habits of
5
+ # before games 0.17.0. It is `asterism-games play othello` (README
6
+ # "Playing in a terminal"), with the same options:
7
+ #
8
+ # bundle exec bin/othello-tty [--router tcp/127.0.0.1:7447] [--table games/games/othello1]
9
+ # [--name NAME] [--node NODE] [--credentials alice.tar]
10
+ # [--namespace room/01] [--commands "sit,cpu,start,auto"] [--until-over]
11
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
12
+ require "asterism_games/play"
13
+
14
+ exit AsterismGames::Play.main(["othello", *ARGV])
@@ -0,0 +1,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module AsterismGames
7
+ # AI players (README "AI players"): a table's owner puts one in an empty
8
+ # seat as it puts a CPU there. The host asks it for each decision through
9
+ # one connection shape (a "decider"), outside the game's lock, and hands
10
+ # the answer back to the table; a decision that does not come in time, or
11
+ # fails, is played by the CPU. What it costs is counted per day, with a
12
+ # limit.
13
+ #
14
+ # A decider takes a request and returns a Decision:
15
+ #
16
+ # request = { "game" => "mahjong", "table" => "mahjong1", "seat" => 2,
17
+ # "instructions" => "how the game is played (a long text)",
18
+ # "view" => { ... the seat's look, with "moves" numbered ... } }
19
+ # decider.decide(request, timeout:) # => Decision.new(n: 3, tokens: {...})
20
+ #
21
+ # n is the number of one of the view's moves. A decider raises AI::Error
22
+ # when it has no answer (the reason is a short text without the key).
23
+ module AI
24
+ class Error < StandardError; end
25
+
26
+ # tokens: {"in", "out", "cache_write", "cache_read"} as the API counted them.
27
+ Decision = Struct.new(:n, :tokens, :ms, keyword_init: true)
28
+
29
+ LABELS = { "claude" => "Claude", "jev" => "Jev" }.freeze
30
+
31
+ # The kinds each game may seat (README "AI players"): the one place
32
+ # that decides it. Othello offers Jev only (games 0.17.0), whatever
33
+ # other keys the host has; a game not named here offers none.
34
+ GAME_KINDS = { "mahjong" => %w[claude jev], "othello" => %w[jev] }.freeze
35
+
36
+ # The kinds a table of game may offer, of those the host can offer
37
+ # now (in the host's order).
38
+ def self.kinds_for(game, kinds)
39
+ allowed = GAME_KINDS[game.to_s] || []
40
+ Array(kinds).map(&:to_s).select { |k| allowed.include?(k) }
41
+ end
42
+
43
+ # The settings, from the environment (README "AI players").
44
+ class Settings
45
+ attr_reader :env
46
+
47
+ def initialize(env = ENV)
48
+ @env = env
49
+ end
50
+
51
+ def daily_usd = num("ASTERISM_GAMES_AI_DAILY_USD", 3.0)
52
+
53
+ # The kinds' own daily limits, ASTERISM_GAMES_AI_DAILY_USD_<KIND>
54
+ # (CLAUDE, JEV): {kind => USD}, only the kinds that have one. A kind
55
+ # without one follows daily_usd (the day's total of every kind). A
56
+ # value that is not a number, or is negative, is left out (said in
57
+ # the log when log is given).
58
+ def kind_daily_usd(log: nil)
59
+ LABELS.keys.each_with_object({}) do |kind, out|
60
+ k = "ASTERISM_GAMES_AI_DAILY_USD_#{kind.upcase}"
61
+ v = @env[k].to_s.strip
62
+ next if v.empty?
63
+ f = Float(v, exception: false)
64
+ if f.nil? || !f.finite? || f.negative?
65
+ log&.call("games: AI players: #{k} is not a number of dollars (0 or more); ignored, " \
66
+ "#{kind} follows ASTERISM_GAMES_AI_DAILY_USD")
67
+ next
68
+ end
69
+ out[kind] = f
70
+ end
71
+ end
72
+ def calls_per_game = num("ASTERISM_GAMES_AI_CALLS_PER_GAME", 400).to_i
73
+ def threads = [num("ASTERISM_GAMES_AI_THREADS", 4).to_i, 1].max
74
+ def fake? = %w[1 true yes].include?(@env["ASTERISM_GAMES_AI_FAKE"].to_s.downcase)
75
+
76
+ def claude_model = str("ASTERISM_GAMES_CLAUDE_MODEL", "claude-sonnet-5-5")
77
+ def claude_effort = str("ASTERISM_GAMES_CLAUDE_EFFORT", "low")
78
+ def jev_model = str("ASTERISM_GAMES_JEV_MODEL", "jev-latest")
79
+
80
+ # USD per million tokens. The defaults are the published prices:
81
+ # Claude Sonnet 5.5 $2 input, $10 output, $2.50 a 5-minute cache
82
+ # write, $0.10 a cache hit (checked 2026-10-17); Jev (TypeSafe's API)
83
+ # $0.042 input, output free (checked 2026-10-18).
84
+ def prices(kind)
85
+ case kind
86
+ when "claude"
87
+ { "in" => num("ASTERISM_GAMES_CLAUDE_USD_IN", 2.0), "out" => num("ASTERISM_GAMES_CLAUDE_USD_OUT", 10.0),
88
+ "cache_write" => num("ASTERISM_GAMES_CLAUDE_USD_CACHE_WRITE", 2.5),
89
+ "cache_read" => num("ASTERISM_GAMES_CLAUDE_USD_CACHE_READ", 0.10) }
90
+ when "jev"
91
+ { "in" => num("ASTERISM_GAMES_JEV_USD_IN", 0.042), "out" => num("ASTERISM_GAMES_JEV_USD_OUT", 0.0),
92
+ "cache_write" => 0.0, "cache_read" => 0.0 }
93
+ else { "in" => 0.0, "out" => 0.0, "cache_write" => 0.0, "cache_read" => 0.0 }
94
+ end
95
+ end
96
+
97
+ # The deciders there are keys (or fakes) for: {kind => decider}.
98
+ # Without a key a kind is not offered at all.
99
+ def deciders(log: nil)
100
+ if fake?
101
+ opts = { delay_ms: num("ASTERISM_GAMES_AI_FAKE_DELAY_MS", 300).to_i,
102
+ slow_every: num("ASTERISM_GAMES_AI_FAKE_SLOW_EVERY", 0).to_i,
103
+ fail_every: num("ASTERISM_GAMES_AI_FAKE_FAIL_EVERY", 0).to_i }
104
+ log&.call("games: AI players are fakes (ASTERISM_GAMES_AI_FAKE): no API is called")
105
+ return { "claude" => Fake.new(**opts), "jev" => Fake.new(**opts) }
106
+ end
107
+ out = {}
108
+ key = @env["ANTHROPIC_API_KEY"].to_s
109
+ unless key.empty?
110
+ begin
111
+ out["claude"] = Claude.new(key, model: claude_model, effort: claude_effort)
112
+ rescue LoadError
113
+ log&.call("games: ANTHROPIC_API_KEY is set but the anthropic gem is missing: no Claude players")
114
+ end
115
+ end
116
+ tok = @env["TYPESAFE_API_KEY"].to_s
117
+ out["jev"] = Jev.new(tok, model: jev_model) unless tok.empty?
118
+ out
119
+ end
120
+
121
+ private
122
+
123
+ def num(k, default)
124
+ v = @env[k].to_s
125
+ v.empty? ? default : Float(v)
126
+ rescue ArgumentError
127
+ default
128
+ end
129
+
130
+ def str(k, default)
131
+ v = @env[k].to_s
132
+ v.empty? ? default : v
133
+ end
134
+ end
135
+
136
+ # What the AI players cost, per day (the host's local date), kept in a
137
+ # JSON file every host process shares (the rooms' hosts, the permanent
138
+ # tables): {"days" => {"2026-10-17" => {kind => {"calls", "in", "out",
139
+ # "cache_write", "cache_read", "usd"}}}}. Counts and money only: no
140
+ # key, no table, no player.
141
+ #
142
+ # The limits: daily_usd for the day's total of every kind, and
143
+ # kind_usd ({kind => USD}) for the kinds that have their own. A kind
144
+ # with its own stops when its own money of the day reaches it; the
145
+ # others when the day's total reaches daily_usd.
146
+ class Budget
147
+ KEEP_DAYS = 31
148
+
149
+ attr_reader :path, :daily_usd, :kind_usd
150
+
151
+ def initialize(path, daily_usd:, prices:, kind_usd: {}, clock: -> { Time.now })
152
+ @path = path
153
+ @daily_usd = daily_usd.to_f
154
+ @kind_usd = kind_usd.to_h { |k, v| [k.to_s, v.to_f] }.freeze
155
+ @prices = prices
156
+ @clock = clock
157
+ @lock = Mutex.new
158
+ @cache = nil
159
+ @cache_at = 0
160
+ end
161
+
162
+ def today
163
+ @clock.call.strftime("%Y-%m-%d")
164
+ end
165
+
166
+ # Adds one decision's tokens; returns its cost (USD).
167
+ def record(kind, tokens)
168
+ p = @prices.call(kind)
169
+ usd = %w[in out cache_write cache_read].sum { |k| tokens[k].to_i * p[k].to_f } / 1_000_000.0
170
+ update do |days|
171
+ d = (days[today] ||= {})
172
+ e = (d[kind] ||= { "calls" => 0, "in" => 0, "out" => 0, "cache_write" => 0, "cache_read" => 0, "usd" => 0.0 })
173
+ e["calls"] += 1
174
+ %w[in out cache_write cache_read].each { |k| e[k] += tokens[k].to_i }
175
+ e["usd"] = (e["usd"].to_f + usd).round(6)
176
+ end
177
+ usd
178
+ end
179
+
180
+ # A decision that failed before any token was counted.
181
+ def record_failure(kind)
182
+ update do |days|
183
+ d = (days[today] ||= {})
184
+ e = (d[kind] ||= { "calls" => 0, "in" => 0, "out" => 0, "cache_write" => 0, "cache_read" => 0, "usd" => 0.0 })
185
+ e["failed"] = e["failed"].to_i + 1
186
+ end
187
+ end
188
+
189
+ # Today's counts: {kind => {...}} (read again at most once a second).
190
+ def day(date = today)
191
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
192
+ if @cache.nil? || now - @cache_at > 1.0
193
+ @cache = read
194
+ @cache_at = now
195
+ end
196
+ (@cache["days"] || {})[date] || {}
197
+ end
198
+
199
+ # Today's money: every kind's (kind nil), or one kind's.
200
+ def usd_today(kind = nil)
201
+ return day.values.sum { |e| e["usd"].to_f } if kind.nil?
202
+ (day[kind.to_s] || {})["usd"].to_f
203
+ end
204
+
205
+ # Whether the kind has a limit of its own.
206
+ def own_limit?(kind) = @kind_usd.key?(kind.to_s)
207
+
208
+ # The limit the kind stops at: its own, else the total's (kind nil:
209
+ # the total's).
210
+ def limit_usd(kind = nil) = (kind && @kind_usd[kind.to_s]) || @daily_usd
211
+
212
+ # Whether the kind stops for today: its own money against its own
213
+ # limit, else the day's total against daily_usd (kind nil: the total).
214
+ def over?(kind = nil)
215
+ return usd_today(kind) >= @kind_usd[kind.to_s] if kind && own_limit?(kind)
216
+ usd_today >= @daily_usd
217
+ end
218
+
219
+ # The totals of today, for the status: {"date", "calls", "tokens_in",
220
+ # "tokens_out", "usd", "limit_usd"} (and per kind with kinds: true),
221
+ # and "kind_limit_usd" ({kind => USD}) when some kind has its own
222
+ # limit.
223
+ def summary(kinds: false)
224
+ d = day
225
+ out = { "date" => today, "calls" => d.values.sum { |e| e["calls"].to_i },
226
+ "tokens_in" => d.values.sum { |e| e["in"].to_i + e["cache_write"].to_i + e["cache_read"].to_i },
227
+ "tokens_out" => d.values.sum { |e| e["out"].to_i },
228
+ "usd" => d.values.sum { |e| e["usd"].to_f }.round(4), "limit_usd" => @daily_usd }
229
+ out["kind_limit_usd"] = @kind_usd.dup unless @kind_usd.empty?
230
+ out["kinds"] = d if kinds
231
+ out
232
+ end
233
+
234
+ private
235
+
236
+ def read
237
+ JSON.parse(File.read(@path))
238
+ rescue Errno::ENOENT, JSON::ParserError
239
+ { "days" => {} }
240
+ end
241
+
242
+ # Read, change and write the file under an exclusive lock (several
243
+ # host processes share it).
244
+ def update
245
+ @lock.synchronize do
246
+ File.open(@path, File::RDWR | File::CREAT, 0o600) do |f|
247
+ f.flock(File::LOCK_EX)
248
+ data = begin
249
+ JSON.parse(f.read)
250
+ rescue JSON::ParserError
251
+ {}
252
+ end
253
+ data = {} unless data.is_a?(Hash)
254
+ days = (data["days"] ||= {})
255
+ yield days
256
+ days.keys.sort[0...-KEEP_DAYS].each { |k| days.delete(k) } if days.size > KEEP_DAYS
257
+ f.rewind
258
+ f.truncate(0)
259
+ f.write(JSON.generate(data))
260
+ f.flush
261
+ @cache = data
262
+ @cache_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
+ end
264
+ end
265
+ end
266
+ end
267
+
268
+ # A decider for tests and for the LAN without keys
269
+ # (ASTERISM_GAMES_AI_FAKE): picks a move after delay_ms (the first
270
+ # discard of the help's tenpai list, else the drawn tile, else the
271
+ # first move); every slow_every-th decision sleeps past its time limit,
272
+ # every fail_every-th fails. Counts 100 tokens in, 1 out.
273
+ class Fake
274
+ attr_reader :count
275
+
276
+ def initialize(delay_ms: 0, slow_every: 0, fail_every: 0, choose: nil)
277
+ @delay_ms = delay_ms
278
+ @slow_every = slow_every
279
+ @fail_every = fail_every
280
+ @choose = choose
281
+ @count = 0
282
+ @lock = Mutex.new
283
+ end
284
+
285
+ def decide(request, timeout: 30)
286
+ i = @lock.synchronize { @count += 1 }
287
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
288
+ if @slow_every.positive? && (i % @slow_every).zero?
289
+ sleep(timeout + 1)
290
+ else
291
+ sleep(@delay_ms / 1000.0) if @delay_ms.positive?
292
+ end
293
+ raise Error, "fake: failing on purpose (#{i})" if @fail_every.positive? && (i % @fail_every).zero?
294
+ n = @choose ? @choose.call(request) : pick(request["view"])
295
+ Decision.new(n: n, tokens: { "in" => 100, "out" => 1 },
296
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round)
297
+ end
298
+
299
+ private
300
+
301
+ def pick(view)
302
+ moves = view["moves"] || []
303
+ tenpai = view.dig("help", "tenpai")
304
+ if tenpai && !tenpai.empty?
305
+ m = moves.find { |x| x["type"] == "discard" && x["tile"] == tenpai[0]["discard"] }
306
+ return m["n"] if m
307
+ end
308
+ win = moves.find { |x| %w[tsumo ron].include?(x["type"]) }
309
+ return win["n"] if win
310
+ drawn = moves.find { |x| x["type"] == "discard" && x["tile"] == view["drawn"] }
311
+ return drawn["n"] if drawn
312
+ pass = moves.find { |x| x["type"] == "pass" }
313
+ return pass["n"] if pass
314
+ moves.first ? moves.first["n"] : 0
315
+ end
316
+ end
317
+
318
+ # The answer both real deciders ask for, and its check.
319
+ def self.move_number(n, view)
320
+ n = Integer(n, exception: false) if n.is_a?(String)
321
+ raise Error, "no move number in the answer" unless n.is_a?(Integer)
322
+ raise Error, "move #{n} is not offered" unless (view["moves"] || []).any? { |m| m["n"] == n }
323
+ n
324
+ end
325
+
326
+ # Claude through the Messages API (the anthropic gem): the
327
+ # instructions as the system prompt (cached), the view as JSON, and a
328
+ # structured answer {"n": <move number>}.
329
+ class Claude
330
+ SCHEMA = { type: "object", properties: { n: { type: "integer" } }, required: ["n"],
331
+ additionalProperties: false }.freeze
332
+
333
+ def initialize(key, model:, effort: "low")
334
+ require "anthropic"
335
+ @client = ::Anthropic::Client.new(api_key: key, max_retries: 0)
336
+ @model = model
337
+ @effort = effort
338
+ end
339
+
340
+ def decide(request, timeout: 30)
341
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
342
+ view = request["view"]
343
+ msg = @client.messages.create(
344
+ model: @model.to_sym,
345
+ max_tokens: 4096,
346
+ system_: [{ type: "text", text: Claude.system_text(request), cache_control: { type: "ephemeral" } }],
347
+ output_config: { effort: @effort.to_sym, format_: { type: :json_schema, schema: SCHEMA } },
348
+ messages: [{ role: "user", content: Claude.user_text(request) }],
349
+ request_options: { timeout: timeout }
350
+ )
351
+ u = msg.usage
352
+ tokens = { "in" => u.input_tokens.to_i, "out" => u.output_tokens.to_i,
353
+ "cache_write" => u.cache_creation_input_tokens.to_i, "cache_read" => u.cache_read_input_tokens.to_i }
354
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
355
+ if msg.stop_reason == :refusal
356
+ raise TokenError.new("claude: refused", tokens)
357
+ end
358
+ text = msg.content.select { |b| b.type == :text }.map(&:text).join
359
+ n = begin
360
+ AI.move_number(JSON.parse(text)["n"], view)
361
+ rescue JSON::ParserError, NoMethodError, Error => e
362
+ raise TokenError.new("claude: #{e.is_a?(Error) ? e.message : 'no JSON answer'}", tokens)
363
+ end
364
+ Decision.new(n: n, tokens: tokens, ms: ms)
365
+ rescue ::Anthropic::Errors::APIStatusError => e
366
+ raise Error, "claude: HTTP #{e.status} #{e.class.name.split('::').last}"
367
+ rescue ::Anthropic::Errors::APIConnectionError => e
368
+ raise Error, "claude: #{e.class.name.split('::').last}"
369
+ end
370
+
371
+ def self.system_text(request)
372
+ "You play #{request['game']} at a table of an Asterism game host, as seat #{request['seat']}. " \
373
+ "Each message is the table as your seat sees it (JSON). Choose one of its \"moves\" and answer " \
374
+ "{\"n\": <its number>}. Play to win the game: score points, avoid dealing in to riichi and tenpai " \
375
+ "hands.\n\n#{request['instructions']}"
376
+ end
377
+
378
+ def self.user_text(request)
379
+ JSON.generate(request["view"])
380
+ end
381
+ end
382
+
383
+ # A failure after the API counted tokens (they still cost).
384
+ class TokenError < Error
385
+ attr_reader :tokens
386
+
387
+ def initialize(msg, tokens)
388
+ super(msg)
389
+ @tokens = tokens
390
+ end
391
+ end
392
+
393
+ # Jev (TypeSafe) through its own API, POST /v1/systemone: a decision
394
+ # model that picks one of a set of answers. The view is the state, the
395
+ # moves are one choice question's criteria (their numbers as the keys,
396
+ # their texts as the descriptions); the answer's "choice" is the key it
397
+ # picked, "usage" the tokens it counted (input only is paid).
398
+ class Jev
399
+ BASE = "https://api.typesafe.ai"
400
+ PATH = "/v1/systemone"
401
+
402
+ def initialize(key, model: "jev-latest", base: BASE)
403
+ require "net/http"
404
+ require "openssl"
405
+ @key = key
406
+ @model = model
407
+ @uri = URI(base.to_s.chomp("/") + PATH)
408
+ end
409
+
410
+ # The request body (without the key).
411
+ def body(request)
412
+ view = request["view"]
413
+ state = view.merge("moves" => nil).compact
414
+ criteria = (view["moves"] || []).to_h { |m| [m["n"].to_s, m["text"]] }
415
+ { "state" => state, "model" => @model,
416
+ "questions" => { "move" => { "type" => "choice", "instructions" => Jev.instructions(request),
417
+ "criteria" => criteria } } }
418
+ end
419
+
420
+ def decide(request, timeout: 30)
421
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
422
+ view = request["view"]
423
+ res = Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https",
424
+ open_timeout: [timeout, 5].min, read_timeout: timeout,
425
+ write_timeout: timeout) do |h|
426
+ req = Net::HTTP::Post.new(@uri)
427
+ req["Authorization"] = "Bearer #{@key}"
428
+ req["Content-Type"] = "application/json"
429
+ req.body = JSON.generate(body(request))
430
+ h.request(req)
431
+ end
432
+ raise Error, "jev: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
433
+ data = JSON.parse(res.body)
434
+ raise Error, "jev: not an object" unless data.is_a?(Hash)
435
+ u = data["usage"].is_a?(Hash) ? data["usage"] : {}
436
+ tokens = { "in" => u["input_tokens"].to_i, "out" => u["output_tokens"].to_i }
437
+ choice = data.dig("answers", "move", "choice")
438
+ begin
439
+ n = AI.move_number(choice, view)
440
+ rescue Error => e
441
+ raise TokenError.new("jev: #{e.message}", tokens)
442
+ end
443
+ Decision.new(n: n, tokens: tokens, ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round)
444
+ rescue JSON::ParserError
445
+ raise Error, "jev: not JSON"
446
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, IOError, SocketError, SystemCallError,
447
+ OpenSSL::SSL::SSLError => e
448
+ raise Error, "jev: #{e.class.name}"
449
+ end
450
+
451
+ def self.instructions(request)
452
+ "You are seat #{request['seat']} at a #{request['game']} table; the state is the table as that seat sees " \
453
+ "it. Which move should the seat play to win the game? #{request['instructions']}"
454
+ end
455
+ end
456
+ end
457
+ end