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.
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ai"
4
+
5
+ module AsterismGames
6
+ module AI
7
+ # The host's side of the AI players: takes the decisions the tables ask
8
+ # for (game.ai_requests, under the host's lock), asks the deciders on
9
+ # threads of its own (never holding the lock while an API thinks),
10
+ # counts what they cost (Budget) and hands the answers back
11
+ # (game.ai_answer, under the lock again, on the host's next step).
12
+ #
13
+ # The tables learn which AI players they may offer (game.ai_kinds=):
14
+ # those with a decider whose money of today is not spent (Budget#over?,
15
+ # per kind: a kind with its own limit stops alone, the others when the
16
+ # day's total reaches the shared limit); then the AI seats of a kind
17
+ # that stopped are played by the CPU (game.ai_off!).
18
+ class Hub
19
+ attr_reader :stats, :budget, :deciders
20
+
21
+ def initialize(deciders, budget, calls_per_game: 400, threads: 4, log: nil)
22
+ @deciders = deciders
23
+ @budget = budget
24
+ @calls_per_game = calls_per_game
25
+ @log = log
26
+ @jobs = Queue.new
27
+ @results = Queue.new
28
+ @stats = Hash.new(0)
29
+ @times = [] # ms of each answered decision (the last 5000)
30
+ @over = [] # the kinds stopped for today
31
+ @threads = Array.new(@deciders.empty? ? 0 : threads) { Thread.new { work } }
32
+ end
33
+
34
+ def any? = !@deciders.empty?
35
+
36
+ # The limits for the log: "at most $3.0 a day" when no kind has its
37
+ # own, else "at most $10.0 a day for claude, $2.0 for jev" (a kind
38
+ # without its own: "$3.0 for jev with every kind's together").
39
+ def self.limits_text(kinds, budget)
40
+ return "at most $#{budget.daily_usd} a day" if budget.kind_usd.empty?
41
+ parts = kinds.map do |k|
42
+ budget.own_limit?(k) ? "$#{budget.limit_usd(k)} for #{k}" : "$#{budget.daily_usd} for #{k} with every kind's together"
43
+ end
44
+ parts[0] = parts[0].sub(" for ", " a day for ")
45
+ "at most #{parts.join(', ')}"
46
+ end
47
+
48
+ # The kinds a table may offer now.
49
+ def kinds
50
+ return [] if @deciders.empty?
51
+ @deciders.keys - @over
52
+ end
53
+
54
+ # The kinds stopped for today.
55
+ def over = @over.dup
56
+
57
+ # One pass (on the host's loop, under its lock): the limits, the
58
+ # answers that came, the new requests.
59
+ def step(games, now)
60
+ return if @deciders.empty?
61
+ over = @deciders.keys.select { |kind| @budget.over?(kind) }
62
+ say_over(over - @over)
63
+ @over = over
64
+ all = over.size == @deciders.size
65
+ k = kinds
66
+ games.each_value do |g|
67
+ next unless g.respond_to?(:ai_requests)
68
+ g.ai_kinds = AI.kinds_for(g.class.respond_to?(:game_name) ? g.class.game_name : nil, k)
69
+ g.ai_calls_per_game = @calls_per_game
70
+ if @budget.kind_usd.empty?
71
+ # No kind has its own limit: all stop together, as before 0.15.0.
72
+ g.ai_off!("today's limit") if all
73
+ else
74
+ over.each { |kind| g.ai_off!("today's limit for #{kind}", kind: kind) }
75
+ end
76
+ end
77
+ until @results.empty?
78
+ table, seat, key, n = @results.pop
79
+ g = games[table]
80
+ g&.ai_answer(seat, key, n, now)
81
+ end
82
+ return if all
83
+ games.each do |name, g|
84
+ next unless g.respond_to?(:ai_requests)
85
+ g.ai_requests(now).each do |r|
86
+ next if over.include?(r[:kind]) # its seat is a CPU from this pass on
87
+ @stats["asked"] += 1
88
+ @jobs << [name, r[:seat], r[:key], r[:kind], r[:request], r[:until]]
89
+ end
90
+ end
91
+ end
92
+
93
+ # Stops the threads (the host is stopping).
94
+ def stop
95
+ @threads.size.times { @jobs << nil }
96
+ @threads.each { |t| t.join(1) }
97
+ end
98
+
99
+ # The decision times (ms) answered so far: [count, average, max].
100
+ def times
101
+ return [0, 0, 0] if @times.empty?
102
+ [@times.size, (@times.sum / @times.size.to_f).round, @times.max]
103
+ end
104
+
105
+ private
106
+
107
+ # The log, once for each kind that has just stopped (again after a
108
+ # new day has let it play).
109
+ def say_over(kinds)
110
+ return if kinds.empty?
111
+ if @budget.kind_usd.empty?
112
+ @log&.call("games: AI players: today's limit of $#{@budget.daily_usd} is reached; " \
113
+ "no more AI players today, those seated are played by the CPU")
114
+ return
115
+ end
116
+ kinds.each do |kind|
117
+ shared = @budget.own_limit?(kind) ? "" : ", every kind's together"
118
+ @log&.call("games: AI players: today's limit for #{kind} ($#{@budget.limit_usd(kind)}#{shared}) is reached; " \
119
+ "no more #{kind} players today, those seated are played by the CPU")
120
+ end
121
+ end
122
+
123
+ def work
124
+ while (job = @jobs.pop)
125
+ table, seat, key, kind, request, until_ms = job
126
+ left = (until_ms - Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)) / 1000.0
127
+ if left <= 0
128
+ @stats["late"] += 1
129
+ @results << [table, seat, key, nil]
130
+ next
131
+ end
132
+ n = decide(kind, request, left)
133
+ @results << [table, seat, key, n]
134
+ end
135
+ rescue StandardError => e
136
+ @log&.call("games: AI players: a worker stopped: #{e.class}")
137
+ end
138
+
139
+ # Asks one decider; the move number, or nil (failed).
140
+ def decide(kind, request, left)
141
+ d = @deciders[kind].decide(request, timeout: left)
142
+ @budget.record(kind, d.tokens || {})
143
+ @stats["answered"] += 1
144
+ @stats["answered_#{kind}"] += 1
145
+ if d.ms
146
+ @times << d.ms
147
+ @times.shift if @times.size > 5000
148
+ end
149
+ d.n
150
+ rescue TokenError => e
151
+ @budget.record(kind, e.tokens)
152
+ failed(kind, e.message)
153
+ rescue Error => e
154
+ @budget.record_failure(kind)
155
+ failed(kind, e.message)
156
+ rescue StandardError => e
157
+ # Nothing of a library's message (it could carry a request's
158
+ # details): the class only.
159
+ @budget.record_failure(kind)
160
+ failed(kind, e.class.name)
161
+ end
162
+
163
+ def failed(kind, why)
164
+ @stats["failed"] += 1
165
+ @stats["failed_#{kind}"] += 1
166
+ @log&.call("games: AI player #{kind}: #{why}; the CPU plays this move")
167
+ nil
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../remote"
4
+
5
+ module AsterismGames
6
+ module Board
7
+ # The drawing board as an Asterism object (<node>/<app>/<board>), on
8
+ # the framework's Remote (join, mine, leave). Every method takes the
9
+ # caller's node ID as from: (Asterism fills it in).
10
+ #
11
+ # join(name) -> {"token", "owner"} or {"error" => why}
12
+ # line(token, c, w, pts) -> {"id" => stroke id} or why not
13
+ # more(token, id, pts) -> "ok" or why not
14
+ # undo(token) -> "ok" or why not ("nothing")
15
+ # clear(token) -> "ok" or why not ("not owner")
16
+ # picture(id, at) -> a page of the picture (Table#picture)
17
+ # view(token) -> the board's view, "own" and "mine", or {"error" => "token"}
18
+ # table -> the board's view (what the state key has)
19
+ class Remote < AsterismGames::Remote
20
+ METHODS = exposes(join: 1, mine: 0, line: 4, more: 3, undo: 1, clear: 1, picture: 2, view: 1, table: 0,
21
+ leave: 1)
22
+ object_doc "A shared drawing board (#{Table::WIDTH}x#{Table::HEIGHT}). join, then line / more to draw; " \
23
+ "the strokes go out on the board's strokes key."
24
+ docs join: [%w[name], "Joins the board as name (or gets this node's membership back). Returns " \
25
+ "{\"token\", \"owner\"}: every later call brings the token. The first to join " \
26
+ "is the owner, who may clear the board."],
27
+ line: [%w[token c w pts], "Starts a stroke: colour c 0-#{Table::COLORS - 1}, width step w " \
28
+ "0-#{Table::WIDTHS - 1}, pts [x, y, ...] (1-#{Table::PART_POINTS} points, " \
29
+ "x 0-#{Table::WIDTH - 1}, y 0-#{Table::HEIGHT - 1}). {\"id\" => id} or why not."],
30
+ more: [%w[token id pts], "More points of your stroke id (your newest one): up to " \
31
+ "#{Table::LINE_POINTS} points a stroke. \"ok\" or why not."],
32
+ undo: [%w[token], "Takes back your last stroke still on the board. \"ok\" or \"nothing\"."],
33
+ clear: [%w[token], "The owner clears the board. \"ok\" or \"not owner\"."],
34
+ picture: [%w[id at], "A page of the picture from stroke id (0: the first) and its point at: " \
35
+ "{\"v\", \"n\" strokes, \"p\" => [[id, c, w, [x, y, ...], k], ...], \"nx\" => " \
36
+ "[id, at] of the next page or nil}. k 1: the points start with the stroke's " \
37
+ "point before at."],
38
+ view: [%w[token], "The board: n strokes, np points, last the newest stroke's id, mb [name, flags " \
39
+ "(1 away, 2 owner)], lim [width, height, colours, widths, points a message, " \
40
+ "points a stroke, strokes, points], own (you are the owner), mine (your strokes)."],
41
+ table: [[], "The board's view without own and mine (what the state key has)."]
42
+
43
+ def line(token, c, w, pts, from: nil)
44
+ mark(token, from)
45
+ @game.line(token, c, w, pts)
46
+ end
47
+
48
+ def more(token, id, pts, from: nil)
49
+ mark(token, from)
50
+ @game.more(token, id, pts)
51
+ end
52
+
53
+ def undo(token, from: nil)
54
+ mark(token, from)
55
+ @game.undo(token)
56
+ end
57
+
58
+ def clear(token, from: nil)
59
+ mark(token, from)
60
+ @game.clear(token)
61
+ end
62
+
63
+ def picture(id, at, from: nil)
64
+ @game.picture(id, at)
65
+ end
66
+
67
+ def view(token, from: nil)
68
+ mark(token, from)
69
+ @game.member_view(token, now) || { "error" => "token" }
70
+ end
71
+
72
+ def table(from: nil)
73
+ @game.public_view(now)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../game"
4
+
5
+ module AsterismGames
6
+ module Board
7
+ # One drawing board (README "The drawing board"): a picture in board
8
+ # coordinates (WIDTH x HEIGHT) made of strokes, each a colour (one of
9
+ # COLORS), a width (one of WIDTHS steps) and a run of points. Nobody
10
+ # wins; every member draws. The first member to join is the owner (the
11
+ # next one in the order of joining when it goes), who alone may clear
12
+ # the board; each member may take back its own last stroke.
13
+ #
14
+ # Handing it out: every change goes out on the board's strokes key
15
+ # (asterism/<node>/<app>/<board>/strokes, Game#tell) as one message:
16
+ #
17
+ # {"o" => "a", "id", "c", "w", "p" => [x, y, ...], "k", "v"}
18
+ # points of stroke id: k 0 starts the stroke, k 1 goes on with it
19
+ # (p then starts with the stroke's last point so far, so every
20
+ # message draws as a line of its own); at most PART_POINTS points
21
+ # {"o" => "u", "id", "v"} stroke id taken back
22
+ # {"o" => "c", "v"} the board cleared
23
+ #
24
+ # v is the board's version when it was told. A screen draws these as
25
+ # they come; a recording of the key (the console's) played back puts
26
+ # the same messages again, and a screen draws them the same way (its
27
+ # picture may then differ from the saved one until it reads the
28
+ # picture again, picture). The same messages, so the same size limit:
29
+ # a message stays under the 4096 bytes a board receives (Zenoh's header
30
+ # included): PART_POINTS points of 3-byte coordinates are 1.5 KB.
31
+ #
32
+ # Limits: a stroke has at most LINE_POINTS points (a screen starts a
33
+ # new stroke past it); the board keeps at most MAX_STROKES strokes and
34
+ # MAX_POINTS points, and drops the oldest strokes past either (nothing
35
+ # is told: a screen that keeps the same limits drops them too, and
36
+ # they leave its picture when it is drawn again).
37
+ class Table < AsterismGames::Game
38
+ register "board"
39
+ tables "board1"
40
+ screens "/app/game/board/board.app.rb"
41
+ settings file: { "away_seconds" => ["away_ms", 1000] },
42
+ env: { "BOARD_AWAY_SECONDS" => ["away_ms", 1000] }
43
+
44
+ SAVE_FORMAT = 1
45
+ # Strokes come several times a second while somebody draws: the
46
+ # saved picture is written at most once a second (and when the host stops).
47
+ SAVE_HOLD_MS = 1000
48
+ WIDTH = 640
49
+ HEIGHT = 360
50
+ COLORS = 16
51
+ WIDTHS = 4
52
+ PART_POINTS = 256
53
+ LINE_POINTS = 1024
54
+ MAX_STROKES = 1000
55
+ MAX_POINTS = 40_000
56
+ # A page of picture: at most this many points.
57
+ PAGE_POINTS = 400
58
+ KEY = "strokes"
59
+
60
+ attr_reader :strokes, :points, :owner
61
+
62
+ def initialize(name, opts = {}, store: nil, log: nil)
63
+ super
64
+ @strokes = [] # [{"id", "node", "c", "w", "p" => [x, y, ...]}], oldest first
65
+ @points = 0
66
+ @next_id = 1
67
+ @owner = nil
68
+ @dropped = 0
69
+ load_saved(0)
70
+ end
71
+
72
+ # ------------------------------------------------------------ members
73
+
74
+ def member_reply(node)
75
+ { "token" => @members[node]["token"], "owner" => @owner == node }
76
+ end
77
+
78
+ def join(node, name, now)
79
+ r = super
80
+ if r["token"] && @owner.nil?
81
+ @owner = node.to_s
82
+ r["owner"] = true
83
+ end
84
+ r
85
+ end
86
+
87
+ def forget(node)
88
+ super
89
+ pass_owner if @owner == node
90
+ end
91
+
92
+ def pass_owner
93
+ @owner = @members.min_by { |_n, m| m["at"].to_i }&.first
94
+ end
95
+
96
+ # ------------------------------------------------------------ drawing
97
+
98
+ # A new stroke: colour c (0...COLORS), width w (0...WIDTHS), points
99
+ # [x, y, ...] (1..PART_POINTS points). Returns {"id" => id} or why not.
100
+ def line(token, c, w, pts)
101
+ node = node_of(token) or return "token"
102
+ c = int(c)
103
+ w = int(w)
104
+ return "colour" unless c && c >= 0 && c < COLORS
105
+ return "width" unless w && w >= 0 && w < WIDTHS
106
+ pts = clean_points(pts) or return "points"
107
+ s = { "id" => @next_id, "node" => node, "c" => c, "w" => w, "p" => pts }
108
+ @next_id += 1
109
+ @strokes << s
110
+ @points += pts.size / 2
111
+ changed
112
+ tell(KEY, { "o" => "a", "id" => s["id"], "c" => c, "w" => w, "p" => pts.dup, "k" => 0, "v" => @ver })
113
+ trim
114
+ { "id" => s["id"] }
115
+ end
116
+
117
+ # More points of the caller's stroke id (its newest stroke, still on
118
+ # the board, under LINE_POINTS points with these). "ok" or why not.
119
+ def more(token, id, pts)
120
+ node = node_of(token) or return "token"
121
+ s = @strokes.reverse_each.find { |x| x["node"] == node }
122
+ return "not yours" unless s && s["id"] == int(id)
123
+ pts = clean_points(pts) or return "points"
124
+ return "long" if s["p"].size / 2 + pts.size / 2 > LINE_POINTS
125
+ last = s["p"][-2, 2]
126
+ s["p"].concat(pts)
127
+ @points += pts.size / 2
128
+ changed
129
+ tell(KEY, { "o" => "a", "id" => s["id"], "c" => s["c"], "w" => s["w"], "p" => last + pts, "k" => 1, "v" => @ver })
130
+ trim
131
+ "ok"
132
+ end
133
+
134
+ # Takes back the caller's last stroke still on the board. "ok" or why not.
135
+ def undo(token)
136
+ node = node_of(token) or return "token"
137
+ i = @strokes.rindex { |x| x["node"] == node } or return "nothing"
138
+ s = @strokes.delete_at(i)
139
+ @points -= s["p"].size / 2
140
+ changed
141
+ tell(KEY, { "o" => "u", "id" => s["id"], "v" => @ver })
142
+ "ok"
143
+ end
144
+
145
+ # The owner clears the board. "ok" or why not.
146
+ def clear(token)
147
+ node = node_of(token) or return "token"
148
+ return "not owner" unless @owner == node
149
+ @strokes = []
150
+ @points = 0
151
+ changed
152
+ tell(KEY, { "o" => "c", "v" => @ver })
153
+ @log&.call("board: #{@name}: cleared")
154
+ "ok"
155
+ end
156
+
157
+ # A page of the picture from stroke id (the first one at or after it)
158
+ # and its point at: {"v", "n", "p" => [[id, c, w, [x, y, ...], k], ...],
159
+ # "nx" => [id, at] for the next page, or nil}. k 1: the points start
160
+ # with the stroke's point before at (the page goes on with a stroke
161
+ # the last page began). At most PAGE_POINTS points a page.
162
+ def picture(id, at)
163
+ id = int(id) || 0
164
+ at = int(at) || 0
165
+ at = 0 if at.negative?
166
+ out = []
167
+ left = PAGE_POINTS
168
+ i = @strokes.index { |s| s["id"] >= id } || @strokes.size
169
+ at = 0 if i < @strokes.size && @strokes[i]["id"] != id
170
+ nx = nil
171
+ while i < @strokes.size
172
+ s = @strokes[i]
173
+ n = s["p"].size / 2
174
+ if left <= 0
175
+ nx = [s["id"], at]
176
+ break
177
+ end
178
+ take = [n - at, left].min
179
+ from = at.positive? ? at - 1 : 0
180
+ out << [s["id"], s["c"], s["w"], s["p"][from * 2, (at + take - from) * 2], at.positive? ? 1 : 0]
181
+ left -= take
182
+ if at + take < n
183
+ nx = [s["id"], at + take]
184
+ break
185
+ end
186
+ at = 0
187
+ i += 1
188
+ end
189
+ { "v" => @ver, "n" => @strokes.size, "p" => out, "nx" => nx }
190
+ end
191
+
192
+ # Past MAX_STROKES or MAX_POINTS: the oldest strokes go (the newest stays).
193
+ def trim
194
+ while @strokes.size > 1 && (@strokes.size > MAX_STROKES || @points > MAX_POINTS)
195
+ s = @strokes.shift
196
+ @points -= s["p"].size / 2
197
+ @dropped += 1
198
+ end
199
+ end
200
+
201
+ def tick(now)
202
+ super
203
+ expire_members(now)
204
+ end
205
+
206
+ # ------------------------------------------------------------ views
207
+
208
+ # Everybody's view (members, spectators, the state key):
209
+ # st "draw" n strokes np points
210
+ # last the newest stroke's id (0: none)
211
+ # mb [name, flags (1 away, 2 owner)] in the order they joined
212
+ # lim [WIDTH, HEIGHT, COLORS, WIDTHS, PART_POINTS, LINE_POINTS, MAX_STROKES, MAX_POINTS]
213
+ def public_view(now)
214
+ { "v" => @ver, "t" => @name, "st" => "draw", "n" => @strokes.size, "np" => @points,
215
+ "last" => @strokes.empty? ? 0 : @strokes[-1]["id"], "mb" => member_list,
216
+ "lim" => [WIDTH, HEIGHT, COLORS, WIDTHS, PART_POINTS, LINE_POINTS, MAX_STROKES, MAX_POINTS] }
217
+ end
218
+
219
+ # A member's view: the public one, whether it is the owner and how
220
+ # many of the strokes are its own.
221
+ def member_view(token, now)
222
+ node = node_of(token) or return nil
223
+ public_view(now).merge("own" => @owner == node, "mine" => @strokes.count { |s| s["node"] == node })
224
+ end
225
+
226
+ def member_list
227
+ @members.sort_by { |_n, m| m["at"].to_i }.map do |node, m|
228
+ [m["name"].to_s, (member_away?(node) ? 1 : 0) | (@owner == node ? 2 : 0)]
229
+ end
230
+ end
231
+
232
+ def summary(now)
233
+ away = @members.keys.count { |n| member_away?(n) }
234
+ { "st" => "draw", "members" => @members.size, "humans" => @members.size - away, "cpus" => 0,
235
+ "away" => away, "strokes" => @strokes.size, "points" => @points }
236
+ end
237
+
238
+ # ------------------------------------------------------------ saving
239
+
240
+ def to_saved(now)
241
+ super.merge("owner" => @owner, "next_id" => @next_id,
242
+ "strokes" => @strokes.map { |s| s.values_at("id", "node", "c", "w", "p") })
243
+ end
244
+
245
+ def restore(data, now)
246
+ super
247
+ @owner = data["owner"]
248
+ @next_id = data["next_id"].to_i
249
+ @strokes = Array(data["strokes"]).filter_map do |id, node, c, w, p|
250
+ next unless id.is_a?(Integer) && p.is_a?(Array) && p.size >= 2 && p.size.even?
251
+ { "id" => id, "node" => node.to_s, "c" => c.to_i, "w" => w.to_i, "p" => p }
252
+ end
253
+ @points = @strokes.sum { |s| s["p"].size / 2 }
254
+ @next_id = [@next_id, (@strokes.map { |s| s["id"] }.max || 0) + 1].max
255
+ @owner = nil unless @owner && @members.key?(@owner)
256
+ pass_owner unless @owner
257
+ @log&.call("board: #{@name}: resumed (#{@strokes.size} strokes, #{@points} points, #{@members.size} members)")
258
+ end
259
+
260
+ # ------------------------------------------------------------ helpers
261
+
262
+ def int(x)
263
+ x.is_a?(Integer) ? x : nil
264
+ end
265
+
266
+ # [x, y, ...] of integers on the board, 1..PART_POINTS points; nil when not.
267
+ def clean_points(pts)
268
+ return nil unless pts.is_a?(Array) && pts.size >= 2 && pts.size.even? && pts.size <= PART_POINTS * 2
269
+ i = 0
270
+ while i < pts.size
271
+ x = pts[i]
272
+ y = pts[i + 1]
273
+ return nil unless x.is_a?(Integer) && y.is_a?(Integer) && x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT
274
+ i += 2
275
+ end
276
+ pts.dup
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The shared drawing board (README "The drawing board") on the framework
4
+ # (asterism_games): one picture that every member draws on, handed out
5
+ # as strokes. Requiring this registers the game "board". It needs nothing
6
+ # but the framework.
7
+ require_relative "../asterism_games"
8
+ require_relative "board/table"
9
+ require_relative "board/remote"
10
+
11
+ AsterismGames::Board::Table.remote(AsterismGames::Board::Remote)