asterism-games 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +282 -0
- data/LICENSE +21 -0
- data/README.md +2009 -0
- data/VERSION +1 -0
- data/bin/asterism-games +116 -0
- data/bin/othello-tty +14 -0
- data/lib/asterism_games/ai.rb +457 -0
- data/lib/asterism_games/ai_hub.rb +171 -0
- data/lib/asterism_games/board/remote.rb +77 -0
- data/lib/asterism_games/board/table.rb +280 -0
- data/lib/asterism_games/board.rb +11 -0
- data/lib/asterism_games/config.rb +228 -0
- data/lib/asterism_games/game.rb +456 -0
- data/lib/asterism_games/host.rb +436 -0
- data/lib/asterism_games/othello/board.rb +161 -0
- data/lib/asterism_games/othello/remote.rb +85 -0
- data/lib/asterism_games/othello/table.rb +533 -0
- data/lib/asterism_games/othello.rb +12 -0
- data/lib/asterism_games/play/keys.rb +160 -0
- data/lib/asterism_games/play/mahjong.rb +508 -0
- data/lib/asterism_games/play/mahjong_screen.rb +607 -0
- data/lib/asterism_games/play/mahjong_text.rb +456 -0
- data/lib/asterism_games/play/othello.rb +456 -0
- data/lib/asterism_games/play.rb +254 -0
- data/lib/asterism_games/record_book.rb +468 -0
- data/lib/asterism_games/remote.rb +100 -0
- data/lib/asterism_games/rooms.rb +509 -0
- data/lib/asterism_games/store.rb +39 -0
- data/lib/asterism_games/version.rb +5 -0
- data/lib/asterism_games.rb +14 -0
- metadata +105 -0
data/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.17.0
|
data/bin/asterism-games
ADDED
|
@@ -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
|