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,509 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "socket"
6
+ require "time"
7
+
8
+ module AsterismGames
9
+ # Tables in the rooms of an Asterism console (G2, the console's
10
+ # docs/rooms.md): README "Rooms".
11
+ #
12
+ # The console writes the open rooms to rooms.json (the file its door
13
+ # reads) in the "rooms" volume, next to the room slots' certificates:
14
+ #
15
+ # {"slots": {"01": {"room": "<uid>", "until": <Unix s>, "max": 16, "app": "/app/...", "group": "<hex>"}},
16
+ # "revoked": [{"room": "<uid>", "until": <Unix s>}], ...}
17
+ # certs/room-01.tar the slot's certificate (namespace room/01)
18
+ #
19
+ # A room whose link opens one of the configured apps (by default the
20
+ # games' screens, Game.screens) gets the tables of the game whose screen
21
+ # it opens (of rooms.tables; by default each game's first table,
22
+ # Config.tables_for_app): this supervisor forks one host process (Host)
23
+ # for the room, which joins the router in the room's key space
24
+ # (namespace room/<slot>) and serves the tables there, as browsers in the
25
+ # room see them (asterism/games/games/<table>). One process per room,
26
+ # since the asterism gem keeps one connection a process. Other rooms
27
+ # (another app, or an app no configured table's game shows) get nothing.
28
+ #
29
+ # Which certificate (console 0.3.0, its docs/rooms.md "Seats"): when
30
+ # rooms.json lists this host's node in "hosts", the router keeps the
31
+ # rooms' asterism/<node>... for the host's own certificate (the
32
+ # permanent tables' games.tar, or rooms.credentials) and the process
33
+ # joins with it; nobody in the room can write a table's keys or answer
34
+ # its calls. Otherwise (a console before 0.3.0) it joins with the slot's
35
+ # certificate, certs/room-<slot>.tar, as before.
36
+ #
37
+ # Who the tables trust (README "Who joins"): in a slot with seats
38
+ # (rooms.json "seats"), the router binds every page's calls to its seat's
39
+ # node ID (<seat_node_prefix><slot><seat>, wasm-0103), and the host's own
40
+ # names (<node>..., its bots) to its certificate: those callers are taken
41
+ # as they are; nobody else joins. A host whose trust changes (the
42
+ # console was updated during the room) is started again.
43
+ #
44
+ # Game records (README "Game records"): a room made from an invite link
45
+ # has the link's group ("group", console 0.3.8; not secret, the same for
46
+ # every room of the link). The host keeps it with the room's games and
47
+ # lists the games of the rooms of the same group too. A room without one
48
+ # (made by an admin, or a console before 0.3.8) sees its own games only.
49
+ # A room's group is read when its host starts.
50
+ #
51
+ # A room's tables are saved under state_dir/rooms/<slot>/ with the room's
52
+ # ID (room.json). When the slot holds another room, or the room was
53
+ # closed or has ended, the directory is removed: a new room on the slot
54
+ # starts with an empty lobby. A host process that stops on its own is
55
+ # started again (after 1, 2, 4 .. 30 s); a restarted supervisor finds the
56
+ # rooms and their saved tables again.
57
+ #
58
+ # The permanent tables (README "Permanent tables", the "permanent"
59
+ # settings): with a certificate of the host's own (a row of the console's
60
+ # registry), one more host process joins the router on the registered
61
+ # side, outside every room, and serves its tables there for the boards
62
+ # and the browsers with a pass. Saved under state_dir/permanent/, kept
63
+ # across restarts, never removed by the rooms' clean-up; started again
64
+ # like a room's host.
65
+ class Rooms
66
+ CHECK_S = 1.0
67
+ STOP_WAIT_S = 8
68
+ BACKOFF_MAX_S = 30
69
+ # The permanent tables' host's profile name: the console's graph shows
70
+ # "permanent tables (games)", apart from the rooms' hosts of the same ID.
71
+ PERMANENT_PROFILE_NAME = "permanent tables"
72
+
73
+ # The rooms that should have a host, from rooms.json's content:
74
+ # {slot => {"room", "until", "app", "seats", "group"}} (open, not ended,
75
+ # opening one of the apps; "group" nil when it has none or one that
76
+ # does not look like one). nil content (no file, not readable): nil.
77
+ def self.wanted(data, apps, now)
78
+ return nil unless data.is_a?(Hash) && data["slots"].is_a?(Hash)
79
+ revoked = Array(data["revoked"]).map { |r| r.is_a?(Hash) ? r["room"] : nil }.compact
80
+ data["slots"].each_with_object({}) do |(slot, r), out|
81
+ next unless slot.to_s.match?(/\A\d{2}\z/) && r.is_a?(Hash) && r["room"].is_a?(String)
82
+ next if revoked.include?(r["room"]) || r["until"].to_i <= now
83
+ next unless apps.include?(r["app"].to_s)
84
+ group = r["group"].is_a?(String) && r["group"].match?(RecordBook::SQLiteBook::GROUP) ? r["group"] : nil
85
+ out[slot.to_s] = { "room" => r["room"], "until" => r["until"].to_i, "app" => r["app"].to_s,
86
+ "seats" => r["seats"].to_i.clamp(0, 99), "group" => group }
87
+ end
88
+ end
89
+
90
+ # Whether a slot's saved tables (room.json: {"room", "until"}) are of a
91
+ # room that is over: the slot holds another room now, the room is
92
+ # revoked (closed), or it has ended. With no readable rooms.json nothing
93
+ # is over (a missing file is not a closed room).
94
+ def self.over?(saved, data, now)
95
+ return false unless saved.is_a?(Hash) && data.is_a?(Hash) && data["slots"].is_a?(Hash)
96
+ return true if saved["until"].to_i <= now
97
+ revoked = Array(data["revoked"]).map { |r| r.is_a?(Hash) ? r["room"] : nil }
98
+ return true if revoked.include?(saved["room"])
99
+ held = data["slots"][saved["slot"].to_s]
100
+ held.is_a?(Hash) && held["room"] != saved["room"]
101
+ end
102
+
103
+ attr_reader :children
104
+
105
+ # config: Config (its "rooms" and "state_dir"); spawn: a lambda(slot,
106
+ # child config) -> pid (tests give their own).
107
+ def initialize(config, log: ->(m) { warn m }, spawn: nil, clock: -> { Time.now.to_f })
108
+ @config = config
109
+ @rooms = config["rooms"]
110
+ @log = log
111
+ @clock = clock
112
+ @spawn = spawn || method(:fork_host)
113
+ # The apps of rooms.apps that get tables (a game of rooms.tables shows them).
114
+ @serving = @rooms["apps"].select { |a| !Config.tables_for_app(@rooms["tables"], a).empty? }
115
+ @dir = File.join(config["state_dir"], "rooms")
116
+ FileUtils.mkdir_p(@dir)
117
+ @children = {} # slot => {"pid", "room", "until", "since", "restarts", "next_at"}
118
+ @permanent = config["permanent"]
119
+ @perm = { "restarts" => 0 } # the permanent tables' host: {"pid", "since", "restarts", "next_at", "last_exit", "waiting"}
120
+ @mtime = nil
121
+ @data = nil
122
+ @read_ok = false
123
+ @read_at = nil
124
+ @stop = false
125
+ @started_at = Time.now.utc
126
+ @lock = Mutex.new
127
+ end
128
+
129
+ def stop!
130
+ @stop = true
131
+ end
132
+
133
+ def rooms_file = File.join(@rooms["dir"], "rooms.json")
134
+ def certs_dir = File.join(@rooms["dir"], "certs")
135
+ def slot_dir(slot) = File.join(@dir, slot)
136
+ def permanent_dir = File.join(@config["state_dir"], "permanent")
137
+ attr_reader :perm
138
+
139
+ def run
140
+ @rooms["apps"].each do |a|
141
+ names = Config.tables_for_app(@rooms["tables"], a).map { |t| t["name"] }
142
+ @log.call("games: rooms opening #{a}: #{names.empty? ? 'no table (no game of rooms.tables shows it)' : names.join(', ')}")
143
+ end
144
+ @log.call("games: rooms from #{rooms_file} (at most #{@rooms['max']} rooms with tables)")
145
+ if @permanent
146
+ @log.call("games: permanent tables #{@permanent['tables'].map { |t| t['name'] }.join(', ')} " \
147
+ "with #{@permanent['credentials']}")
148
+ end
149
+ start_status_server
150
+ until @stop
151
+ @lock.synchronize { check }
152
+ sleep CHECK_S
153
+ end
154
+ @lock.synchronize { stop_all }
155
+ @log.call("games: rooms: stopped")
156
+ end
157
+
158
+ # One pass: read rooms.json when it changed, stop and remove what is
159
+ # over, start what is wanted, start again what stopped.
160
+ def check(now = @clock.call)
161
+ read_file
162
+ reap(now)
163
+ reap_permanent(now)
164
+ start_permanent(now)
165
+ wanted = self.class.wanted(@data, @serving, now.to_i)
166
+ @children.keys.each do |slot|
167
+ c = @children[slot]
168
+ w = wanted && wanted[slot]
169
+ next if wanted.nil?
170
+ if w && w["room"] == c["room"]
171
+ next if c["trust"].nil? || c["trust"] == trust_for(slot, w)
172
+ stop_child(slot, "the room's seats or hosts changed in rooms.json")
173
+ next
174
+ end
175
+ stop_child(slot, w ? "the slot holds another room" : "the room is closed or ended")
176
+ end
177
+ clean_dirs(now)
178
+ return unless wanted
179
+ wanted.keys.sort.each do |slot|
180
+ next if @children[slot] && @children[slot]["pid"]
181
+ if @children[slot].nil? && running_count >= @rooms["max"]
182
+ note_full(slot)
183
+ next
184
+ end
185
+ start_child(slot, wanted[slot], now)
186
+ end
187
+ end
188
+
189
+ def running_count = @children.count { |_s, c| c["pid"] || c["next_at"] }
190
+
191
+ def read_file
192
+ st = File.stat(rooms_file)
193
+ return if @mtime && st.mtime == @mtime && @read_ok
194
+ @data = JSON.parse(File.read(rooms_file))
195
+ @mtime = st.mtime
196
+ @read_ok = true
197
+ @read_at = Time.now.utc
198
+ rescue SystemCallError, JSON::ParserError => e
199
+ @log.call("games: rooms: #{rooms_file}: #{e.class}: #{e.message}; nothing changes until it is readable") if @read_ok != false || @data
200
+ @read_ok = false
201
+ @data = nil
202
+ end
203
+
204
+ # Removes the saved tables of rooms that are over (and of slots no room
205
+ # file knows of, once the file is readable).
206
+ def clean_dirs(now)
207
+ Dir.children(@dir).each do |slot|
208
+ next if @children[slot]
209
+ d = slot_dir(slot)
210
+ saved = read_json(File.join(d, "room.json"))
211
+ next unless saved.nil? ? @data : self.class.over?(saved.merge("slot" => slot), @data, now.to_i)
212
+ FileUtils.rm_rf(d)
213
+ @log.call("games: room #{slot}: removed the saved tables of #{saved ? "room #{saved['room']}" : 'an unknown room'}")
214
+ end
215
+ rescue SystemCallError => e
216
+ @log.call("games: rooms: #{e.message}")
217
+ end
218
+
219
+ def start_child(slot, room, now)
220
+ c = @children[slot] ||= { "restarts" => 0 }
221
+ return if c["next_at"] && now < c["next_at"]
222
+ d = slot_dir(slot)
223
+ saved = read_json(File.join(d, "room.json"))
224
+ if saved && saved["room"] != room["room"]
225
+ FileUtils.rm_rf(d)
226
+ @log.call("games: room #{slot}: room #{saved['room']} is over; its tables are removed")
227
+ end
228
+ FileUtils.mkdir_p(d)
229
+ write_json(File.join(d, "room.json"), { "room" => room["room"], "until" => room["until"], "slot" => slot })
230
+ trust = trust_for(slot, room)
231
+ tar = trust["own"] ? @rooms["credentials"] : File.join(certs_dir, "room-#{slot}.tar")
232
+ unless File.exist?(tar)
233
+ @log.call("games: room #{slot}: no #{tar}; trying again later")
234
+ c["next_at"] = now + BACKOFF_MAX_S
235
+ return
236
+ end
237
+ cfg = child_config(slot, tar, d, room["room"], trust, room["app"], room["group"])
238
+ c.merge!("room" => room["room"], "until" => room["until"], "since" => Time.now.utc.iso8601, "next_at" => nil,
239
+ "trust" => trust)
240
+ c["pid"] = @spawn.call(slot, cfg)
241
+ @log.call("games: room #{slot}: room #{room['room']} opens #{room['app']}: host pid #{c['pid']} " \
242
+ "(#{trust['own'] ? "its own certificate #{File.basename(tar)}" : 'the slot certificate'}, " \
243
+ "#{trust['seats'].size} seats; #{cfg['tables'].map { |t| t['name'] }.join(', ')})")
244
+ end
245
+
246
+ # Whom a room's host trusts and how it joins (from rooms.json):
247
+ # {"own" => joins with its own certificate, "seats" => the seats' node
248
+ # IDs, "prefix" => its own names (nil: none)}.
249
+ def trust_for(slot, room)
250
+ hosts = @data.is_a?(Hash) ? Array(@data["hosts"]) : []
251
+ listed = hosts.include?(@rooms["node"])
252
+ own = listed && !@rooms["credentials"].to_s.empty?
253
+ if listed && !own && !@noted_no_credentials
254
+ @noted_no_credentials = true
255
+ @log.call("games: rooms: the console keeps #{@rooms['node']}'s names for its own certificate, but no " \
256
+ "rooms.credentials (or permanent.credentials) is set: the tables cannot be served in the rooms")
257
+ end
258
+ prefix = @data.is_a?(Hash) && @data["seat_node_prefix"].to_s.match?(/\A[A-Za-z0-9][A-Za-z0-9_.-]{0,20}\z/) ? @data["seat_node_prefix"] : "wasm-"
259
+ seats = (1..room["seats"].to_i).map { |i| format("%s%s%02d", prefix, slot, i) }
260
+ { "own" => own, "seats" => seats, "prefix" => listed ? @rooms["node"] : nil }
261
+ end
262
+
263
+ # room_id: the room's ID, with which its tables check who joins (README
264
+ # "Rooms", "Who joins"); trust: trust_for's; app: what the room's link
265
+ # opens (its game's tables; nil: every table of rooms.tables); group:
266
+ # its invite link's group (nil: none), kept with its games' records.
267
+ def child_config(slot, tar, dir, room_id = nil, trust = { "own" => false, "seats" => [], "prefix" => nil }, app = nil,
268
+ group = nil)
269
+ tables = app ? Config.tables_for_app(@rooms["tables"], app) : @rooms["tables"]
270
+ router = @rooms["router"] || (trust["own"] && @permanent ? @permanent["router"] : nil)
271
+ { "node" => @rooms["node"], "app" => @config["app"], "credentials" => tar,
272
+ "credentials_router" => router, "namespace" => "room/#{slot}", "state_dir" => dir,
273
+ "status_file" => File.join(dir, "host.json"), "tables" => tables, "room_id" => room_id,
274
+ "trusted_nodes" => trust["seats"], "trusted_prefix" => trust["prefix"],
275
+ "seat_reuse_ms" => @rooms["seat_reuse_seconds"].to_i * 1000, "ai_usage_file" => ai_usage_file,
276
+ "records_db" => records_db, "records_place" => "room", "records_group" => group }
277
+ end
278
+
279
+ # What the AI players cost, shared by every host process (README "AI
280
+ # players").
281
+ def ai_usage_file = File.join(@config["state_dir"], "ai_usage.json")
282
+
283
+ # The games' records, one database for every host process (README
284
+ # "Game records"): a room's stay when the room's directory goes.
285
+ def records_db = @config["records_db"] || File.join(@config["state_dir"], RecordBook::DB_NAME)
286
+
287
+ # The permanent tables' host (when configured and not running): its
288
+ # certificate must be there (else it is looked for again on every pass,
289
+ # said once in the log).
290
+ def start_permanent(now)
291
+ return unless @permanent && @perm["pid"].nil?
292
+ return if @perm["next_at"] && now < @perm["next_at"]
293
+ tar = @permanent["credentials"]
294
+ unless File.file?(tar) && File.readable?(tar)
295
+ @log.call("games: permanent: no readable #{tar}; the permanent tables wait for it") unless @perm["waiting"]
296
+ @perm["waiting"] = true
297
+ return
298
+ end
299
+ @perm["waiting"] = false
300
+ FileUtils.mkdir_p(permanent_dir)
301
+ cfg = { "node" => @permanent["node"], "app" => @config["app"], "credentials" => tar,
302
+ "credentials_router" => @permanent["router"], "state_dir" => permanent_dir,
303
+ "status_file" => File.join(permanent_dir, "host.json"), "tables" => @permanent["tables"],
304
+ "profile_name" => PERMANENT_PROFILE_NAME, "ai_usage_file" => ai_usage_file,
305
+ "records_db" => records_db, "records_place" => "permanent" }
306
+ @perm.merge!("since" => Time.now.utc.iso8601, "next_at" => nil)
307
+ @perm["pid"] = @spawn.call("permanent", cfg)
308
+ @log.call("games: permanent: host pid #{@perm['pid']} (#{@permanent['tables'].map { |t| t['name'] }.join(', ')})")
309
+ end
310
+
311
+ def reap_permanent(now)
312
+ return unless @perm["pid"]
313
+ pid, st = Process.wait2(@perm["pid"], Process::WNOHANG)
314
+ return unless pid
315
+ @perm["pid"] = nil
316
+ @perm["restarts"] += 1
317
+ delay = [2**[@perm["restarts"] - 1, 5].min, BACKOFF_MAX_S].min
318
+ @perm["next_at"] = now + delay
319
+ @perm["last_exit"] = st.exitstatus || "signal #{st.termsig}"
320
+ @log.call("games: permanent: host pid #{pid} stopped (#{@perm['last_exit']}); again in #{delay} s")
321
+ rescue Errno::ECHILD
322
+ @perm["pid"] = nil
323
+ end
324
+
325
+ # who: a room's slot ("01") or "permanent".
326
+ def fork_host(who, cfg)
327
+ label = who == "permanent" ? "permanent" : "room #{who}"
328
+ fork do
329
+ @server&.close
330
+ $PROGRAM_NAME = "asterism-games #{label}"
331
+ host = Host.new(cfg, log: ->(m) { @log.call("#{label}: #{m}") })
332
+ %w[INT TERM].each { |s| trap(s) { host.stop! } }
333
+ host.run
334
+ exit!(0)
335
+ end
336
+ end
337
+
338
+ def reap(now)
339
+ @children.each do |slot, c|
340
+ next unless c["pid"]
341
+ pid, st = Process.wait2(c["pid"], Process::WNOHANG)
342
+ next unless pid
343
+ c["pid"] = nil
344
+ c["restarts"] += 1
345
+ delay = [2**[c["restarts"] - 1, 5].min, BACKOFF_MAX_S].min
346
+ c["next_at"] = now + delay
347
+ c["last_exit"] = st.exitstatus || "signal #{st.termsig}"
348
+ @log.call("games: room #{slot}: host pid #{pid} stopped (#{c['last_exit']}); again in #{delay} s")
349
+ rescue Errno::ECHILD
350
+ c["pid"] = nil
351
+ end
352
+ end
353
+
354
+ def stop_child(slot, why)
355
+ c = @children.delete(slot)
356
+ return unless c && c["pid"]
357
+ @log.call("games: room #{slot}: stopping host pid #{c['pid']} (#{why})")
358
+ terminate([c["pid"]])
359
+ end
360
+
361
+ def stop_all
362
+ pids = @children.values.map { |c| c["pid"] }.compact
363
+ pids << @perm["pid"] if @perm["pid"]
364
+ terminate(pids)
365
+ @children.clear
366
+ @perm["pid"] = nil
367
+ end
368
+
369
+ def terminate(pids)
370
+ pids.each do |pid|
371
+ Process.kill("TERM", pid)
372
+ rescue Errno::ESRCH
373
+ nil
374
+ end
375
+ deadline = Time.now + STOP_WAIT_S
376
+ pids.each do |pid|
377
+ loop do
378
+ break if Process.wait(pid, Process::WNOHANG)
379
+ if Time.now > deadline
380
+ Process.kill("KILL", pid)
381
+ Process.wait(pid)
382
+ break
383
+ end
384
+ sleep 0.05
385
+ end
386
+ rescue Errno::ECHILD, Errno::ESRCH
387
+ nil
388
+ end
389
+ end
390
+
391
+ def note_full(slot)
392
+ @full_noted ||= {}
393
+ return if @full_noted[slot]
394
+ @full_noted[slot] = true
395
+ @log.call("games: room #{slot}: no table (#{@rooms['max']} rooms have tables already; rooms.max)")
396
+ end
397
+
398
+ # ------------------------------------------------------------ status
399
+
400
+ # What the console's admin page shows (GET /status): the rooms with a
401
+ # host and their tables, as counts.
402
+ def status
403
+ @lock.synchronize do
404
+ now = @clock.call
405
+ wanted = self.class.wanted(@data, @serving, now.to_i) || {}
406
+ rooms = @children.keys.sort.to_h do |slot|
407
+ c = @children[slot]
408
+ h = read_json(File.join(slot_dir(slot), "host.json")) || {}
409
+ fresh = h["at"].to_i >= now.to_i - 10 && h["pid"] == c["pid"]
410
+ [slot, { "room" => c["room"], "until" => c["until"], "pid" => c["pid"], "since" => c["since"],
411
+ "restarts" => c["restarts"], "last_exit" => c["last_exit"],
412
+ "state" => c["pid"] ? (fresh && h["connected"] ? "up" : "starting") : "restarting",
413
+ "connected" => fresh ? h["connected"] : false, "tables" => fresh ? h["tables"] : {} }]
414
+ end
415
+ perm = permanent_status(now)
416
+ # Someone sits at a table and is there: an away seat (a player gone
417
+ # without leaving) does not keep an update waiting (--when-idle).
418
+ seated = ->(r) { r["tables"].to_h.values.any? { |t| t["humans"].to_i - t["away"].to_i > 0 } }
419
+ { "host" => "asterism-games", "version" => AsterismGames::VERSION, "since" => @started_at.iso8601,
420
+ "rooms_file" => { "ok" => @read_ok, "read_at" => @read_at&.iso8601 },
421
+ "apps" => @rooms["apps"], "max" => @rooms["max"], "rooms" => rooms,
422
+ "without_table" => (wanted.keys - rooms.keys).sort,
423
+ "permanent" => perm,
424
+ "busy" => rooms.values.any?(&seated) || (perm ? seated.call(perm) : false),
425
+ "ai" => ai_status,
426
+ "rss_kb" => rss_kb([Process.pid, @perm["pid"]].compact + @children.values.map { |c| c["pid"] }.compact) }
427
+ end
428
+ end
429
+
430
+ # Today's AI players' totals (README "AI players"), or nil when none
431
+ # has played today.
432
+ def ai_status
433
+ s = AI::Settings.new
434
+ b = AI::Budget.new(ai_usage_file, daily_usd: s.daily_usd, kind_usd: s.kind_daily_usd, prices: ->(k) { s.prices(k) })
435
+ sum = b.summary(kinds: true)
436
+ sum["calls"].zero? && sum["kinds"].empty? ? nil : sum
437
+ end
438
+
439
+ # The permanent tables' host for the status (nil: not configured).
440
+ # "node" is the setting (nil: the certificate's name), never read from
441
+ # the certificate here.
442
+ def permanent_status(now)
443
+ return nil unless @permanent
444
+ h = read_json(File.join(permanent_dir, "host.json")) || {}
445
+ fresh = @perm["pid"] && h["at"].to_i >= now.to_i - 10 && h["pid"] == @perm["pid"]
446
+ state = if @perm["pid"] then fresh && h["connected"] ? "up" : "starting"
447
+ elsif @perm["waiting"] then "no certificate"
448
+ else "restarting"
449
+ end
450
+ { "node" => fresh ? h["node"] : nil, "table_names" => @permanent["tables"].map { |t| t["name"] },
451
+ "pid" => @perm["pid"], "since" => @perm["since"], "restarts" => @perm["restarts"],
452
+ "last_exit" => @perm["last_exit"], "state" => state, "connected" => fresh ? h["connected"] : false,
453
+ "tables" => fresh ? h["tables"] : {} }
454
+ end
455
+
456
+ def rss_kb(pids)
457
+ pids.sum do |pid|
458
+ File.read("/proc/#{pid}/status")[/^VmRSS:\s+(\d+)/, 1].to_i
459
+ rescue SystemCallError
460
+ 0
461
+ end
462
+ end
463
+
464
+ # GET /status on rooms.status_listen (host:port): one request per
465
+ # connection, JSON. Nothing else is answered.
466
+ def start_status_server
467
+ listen = @rooms["status_listen"].to_s
468
+ return if listen.empty?
469
+ host, port = listen.split(/:(?=\d+\z)/)
470
+ @server = TCPServer.new(host, port.to_i)
471
+ @log.call("games: status on #{listen} (GET /status)")
472
+ Thread.new do
473
+ loop do
474
+ s = @server.accept
475
+ Thread.new(s) { |c| answer(c) }
476
+ rescue IOError
477
+ break
478
+ end
479
+ end
480
+ end
481
+
482
+ def answer(sock)
483
+ line = sock.gets.to_s
484
+ while (l = sock.gets) && l != "\r\n"; end
485
+ ok = line.start_with?("GET /status ")
486
+ body = ok ? JSON.generate(status) : "not found\n"
487
+ sock.write("HTTP/1.1 #{ok ? '200 OK' : '404 Not Found'}\r\nContent-Type: #{ok ? 'application/json' : 'text/plain'}\r\n" \
488
+ "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
489
+ rescue StandardError
490
+ nil
491
+ ensure
492
+ sock.close rescue nil
493
+ end
494
+
495
+ private
496
+
497
+ def read_json(path)
498
+ JSON.parse(File.read(path))
499
+ rescue SystemCallError, JSON::ParserError
500
+ nil
501
+ end
502
+
503
+ def write_json(path, data)
504
+ tmp = "#{path}.tmp"
505
+ File.write(tmp, JSON.generate(data))
506
+ File.rename(tmp, path)
507
+ end
508
+ end
509
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module AsterismGames
7
+ # Saved states, one JSON file per game object in a directory. A write goes
8
+ # to a temporary file first and is renamed over the old one, so a host
9
+ # killed in the middle leaves the previous state, never half of one.
10
+ class Store
11
+ attr_reader :dir
12
+
13
+ def initialize(dir)
14
+ @dir = dir
15
+ FileUtils.mkdir_p(dir)
16
+ end
17
+
18
+ def path(name)
19
+ File.join(@dir, "#{name}.json")
20
+ end
21
+
22
+ def write(name, data)
23
+ p = path(name)
24
+ tmp = "#{p}.tmp"
25
+ File.open(tmp, "w") do |f|
26
+ f.write(JSON.generate(data))
27
+ f.flush
28
+ f.fsync
29
+ end
30
+ File.rename(tmp, p)
31
+ end
32
+
33
+ def read(name)
34
+ p = path(name)
35
+ return nil unless File.exist?(p)
36
+ JSON.parse(File.read(p))
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsterismGames
4
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The framework (README "Writing a game"): the host, the rooms'
4
+ # supervisor, the settings, the store and the bases of a game (Game,
5
+ # Remote). No game is in here; a game is required next to it
6
+ # (asterism_games/mahjong) and registers itself.
7
+ require_relative "asterism_games/version"
8
+ require_relative "asterism_games/game"
9
+ require_relative "asterism_games/remote"
10
+ require_relative "asterism_games/store"
11
+ require_relative "asterism_games/record_book"
12
+ require_relative "asterism_games/config"
13
+ require_relative "asterism_games/host"
14
+ require_relative "asterism_games/rooms"
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asterism-games
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.17.0
5
+ platform: ruby
6
+ authors:
7
+ - Katsuhiko Kageyama
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: asterism
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.7'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 0.7.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '0.7'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.7.1
33
+ description: 'A framework for the server side of a game on an Asterism network: the
34
+ host (tables exposed as Asterism objects, the state put and saved, who is there,
35
+ time), the rooms of an Asterism console and the permanent tables next to them. A
36
+ game subclasses AsterismGames::Game and AsterismGames::Remote and writes only its
37
+ rules and the methods it exposes. Comes with othello and a shared drawing board
38
+ as samples, and terminal clients for othello and mahjong (asterism-games play).'
39
+ email:
40
+ executables:
41
+ - asterism-games
42
+ - othello-tty
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - CHANGELOG.md
47
+ - LICENSE
48
+ - README.md
49
+ - VERSION
50
+ - bin/asterism-games
51
+ - bin/othello-tty
52
+ - lib/asterism_games.rb
53
+ - lib/asterism_games/ai.rb
54
+ - lib/asterism_games/ai_hub.rb
55
+ - lib/asterism_games/board.rb
56
+ - lib/asterism_games/board/remote.rb
57
+ - lib/asterism_games/board/table.rb
58
+ - lib/asterism_games/config.rb
59
+ - lib/asterism_games/game.rb
60
+ - lib/asterism_games/host.rb
61
+ - lib/asterism_games/othello.rb
62
+ - lib/asterism_games/othello/board.rb
63
+ - lib/asterism_games/othello/remote.rb
64
+ - lib/asterism_games/othello/table.rb
65
+ - lib/asterism_games/play.rb
66
+ - lib/asterism_games/play/keys.rb
67
+ - lib/asterism_games/play/mahjong.rb
68
+ - lib/asterism_games/play/mahjong_screen.rb
69
+ - lib/asterism_games/play/mahjong_text.rb
70
+ - lib/asterism_games/play/othello.rb
71
+ - lib/asterism_games/record_book.rb
72
+ - lib/asterism_games/remote.rb
73
+ - lib/asterism_games/rooms.rb
74
+ - lib/asterism_games/store.rb
75
+ - lib/asterism_games/version.rb
76
+ homepage: https://github.com/ruby-asterism/asterism-games
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/ruby-asterism/asterism-games
81
+ source_code_uri: https://github.com/ruby-asterism/asterism-games
82
+ changelog_uri: https://github.com/ruby-asterism/asterism-games/blob/main/CHANGELOG.md
83
+ bug_tracker_uri: https://github.com/ruby-asterism/asterism-games/issues
84
+ rubygems_mfa_required: 'true'
85
+ post_install_message:
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.2'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.4.19
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: 'Game hosts on Asterism: write the rules of a game, get tables people play
104
+ over Zenoh'
105
+ test_files: []