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,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module AsterismGames
7
+ # Where a game keeps the games its tables played to the end (README
8
+ # "Game records"): a book per table, in one of two forms.
9
+ #
10
+ # - SQLite (the image, a checkout with the Gemfile): every table of every
11
+ # host process that shares the state volume writes into one database,
12
+ # state_dir/records.sqlite3 (the rooms' and the permanent tables' hosts
13
+ # are given the supervisor's), in WAL mode, once per game at its end.
14
+ # The newest `kept` games of a table are kept (a room's tables count
15
+ # together, by table name: the rooms' mahjong1). A room's games stay
16
+ # when the room closes; only that room's host gives them out (its book
17
+ # is scoped to the room's ID), the others count them in their stats.
18
+ # A room made from a console's invite link has the link's group
19
+ # (rooms.json "group", games 0.16.0), kept with each of its games: a
20
+ # room of the same group gives out those games too (its list and
21
+ # record, not its stats nor its last result).
22
+ # After every game a consistent copy is made with VACUUM INTO next to
23
+ # the database (records-backup.sqlite3, through a *.tmp name), which a
24
+ # backup takes; a host that finds no database but the copy starts from
25
+ # the copy (the way a restore puts it back).
26
+ # - JSON (no sqlite3 gem: the gem alone, or records_db "json"): the
27
+ # newest `json_kept` games of a table in the table's store, next to its
28
+ # saved state (<table>.records.json), as before 0.14.0; a room's go
29
+ # with the room.
30
+ #
31
+ # A game gives each record (a Hash, its own form, with "id", "at" and
32
+ # "time" filled in here) and a summary of it (summarize: rec ->
33
+ # {"variant", "reason", "rounds", "seats" => [{"seat", "name", "kind",
34
+ # "rank", "score", "points", "counts" => {name => n}}]}), from which the
35
+ # lists and the stats are read without the record itself.
36
+ module RecordBook
37
+ DB_NAME = "records.sqlite3"
38
+ COPY_NAME = "records-backup.sqlite3"
39
+ BUSY_MS = 10_000
40
+
41
+ # Whether the sqlite3 gem is there (loaded on the first ask).
42
+ def self.sqlite?
43
+ return @sqlite unless @sqlite.nil?
44
+ @sqlite = begin
45
+ require "sqlite3"
46
+ true
47
+ rescue LoadError
48
+ false
49
+ end
50
+ end
51
+
52
+ # The book of one table. db: the database's path ("json" or nil: the
53
+ # JSON form); game: the game's name; place: "lan" / "permanent" /
54
+ # "room"; room: the room's ID in a room; group: in a room, the group
55
+ # of the invite link it was made from (nil: none; the JSON form has no
56
+ # use for it, a room's records go with the room).
57
+ def self.open(db:, game:, table:, place:, room:, store:, kept:, json_kept:, summarize:, log: nil, group: nil)
58
+ if db && db != "json" && sqlite?
59
+ SQLiteBook.new(Database.shared(db, log: log), game: game, table: table, place: place, room: room, kept: kept,
60
+ summarize: summarize, log: log, group: group)
61
+ else
62
+ JSONBook.new(store, "#{table}.records", kept: json_kept, summarize: summarize, log: log)
63
+ end
64
+ end
65
+
66
+ # The stats of the rows (stat_rows: [kind, name, rank, score, points,
67
+ # counts]): one entry per player, a person by name ("human"), every
68
+ # other kind as one: {"kind", "name", "games", "ranks" => [n of 1st,
69
+ # 2nd, ...], "score" (the sum), "points" (the sum, as stored),
70
+ # "counts" => {name => sum}}. Humans first, by games; then the kinds.
71
+ def self.aggregate(rows, seats: 4)
72
+ by = {}
73
+ rows.each do |kind, name, rank, score, points, counts|
74
+ key = kind == "human" ? ["human", name.to_s] : [kind.to_s, ""]
75
+ e = by[key] ||= { "kind" => key[0], "name" => key[1], "games" => 0, "ranks" => [0] * seats, "score" => 0,
76
+ "points" => 0, "counts" => Hash.new(0) }
77
+ e["games"] += 1
78
+ r = rank.to_i
79
+ e["ranks"][r - 1] += 1 if r.between?(1, seats)
80
+ e["score"] += score.to_i
81
+ e["points"] += points.to_i
82
+ counts.to_h.each { |k, v| e["counts"][k] += v.to_i }
83
+ end
84
+ by.values.each { |e| e["counts"] = e["counts"].to_h }
85
+ by.values.sort_by { |e| [e["kind"] == "human" ? 0 : 1, -e["games"], e["kind"], e["name"]] }
86
+ end
87
+
88
+ # The JSON form (README "Game records", before 0.14.0): the newest kept
89
+ # games in the table's store ({"format", "seq", "games"}), in memory
90
+ # without a store (tests).
91
+ class JSONBook
92
+ FORMAT = 1
93
+
94
+ def initialize(store, name, kept:, summarize:, log: nil)
95
+ @store = store
96
+ @name = name
97
+ @kept = kept
98
+ @summarize = summarize
99
+ @log = log
100
+ @games = []
101
+ @seq = 0
102
+ load
103
+ end
104
+
105
+ def kind = "json"
106
+
107
+ def load
108
+ d = @store&.read(@name)
109
+ return unless d.is_a?(Hash) && d["format"] == FORMAT
110
+ @games = Array(d["games"])
111
+ @seq = d["seq"].to_i
112
+ rescue JSON::ParserError, SystemCallError => e
113
+ @log&.call("records: #{@name}: #{e.class}: #{e.message}")
114
+ end
115
+
116
+ def add(rec, now = Time.now)
117
+ rec["id"] = (@seq += 1)
118
+ rec["at"] = now.strftime("%Y-%m-%d %H:%M")
119
+ rec["time"] = now.to_i
120
+ @games << rec
121
+ @games.shift while @games.size > @kept
122
+ @store&.write(@name, { "format" => FORMAT, "seq" => @seq, "games" => @games })
123
+ rec["id"]
124
+ end
125
+
126
+ def size = @games.size
127
+
128
+ # [rec, summary] newest first, from the from-th, at most limit.
129
+ def list(from, limit)
130
+ @games.reverse[from, limit].to_a.map { |r| [r, @summarize.call(r)] }
131
+ end
132
+
133
+ def get(id)
134
+ @games.find { |r| r["id"] == id.to_i }
135
+ end
136
+
137
+ def last
138
+ r = @games.last
139
+ r && [r, @summarize.call(r)]
140
+ end
141
+
142
+ # The rows of this table's games (the JSON form knows no others):
143
+ # since (Unix s, nil: all), variant (nil: all).
144
+ def stat_rows(since: nil, variant: nil, everything: false)
145
+ @games.flat_map do |r|
146
+ next [] if since && r["time"].to_i < since
147
+ s = @summarize.call(r)
148
+ next [] if variant && s["variant"] != variant
149
+ s["seats"].map { |x| [x["kind"], x["name"], x["rank"], x["score"], x["points"], x["counts"]] }
150
+ end
151
+ end
152
+ end
153
+
154
+ # One database file, shared by the books of a process (one connection;
155
+ # the host's loop and its calls take Asterism::LOCK, the mutex is for
156
+ # anything else). Never carried over a fork: a process opens its own.
157
+ class Database
158
+ VERSION = 1
159
+ @open = {}
160
+ @lock = Mutex.new
161
+
162
+ def self.shared(path, log: nil)
163
+ @lock.synchronize do
164
+ d = @open[path]
165
+ return d if d && d.pid == Process.pid
166
+ @open[path] = new(path, log: log)
167
+ end
168
+ end
169
+
170
+ # Closes every database of this process (tests).
171
+ def self.close_all
172
+ @lock.synchronize do
173
+ @open.each_value { |d| d.close if d.pid == Process.pid }
174
+ @open.clear
175
+ end
176
+ end
177
+
178
+ attr_reader :path, :pid, :db
179
+
180
+ def initialize(path, log: nil)
181
+ @path = path
182
+ @log = log
183
+ @pid = Process.pid
184
+ @mutex = Mutex.new
185
+ FileUtils.mkdir_p(File.dirname(path))
186
+ restore_from_copy
187
+ @db = SQLite3::Database.new(path)
188
+ @db.busy_timeout = BUSY_MS
189
+ pragma("PRAGMA journal_mode=WAL")
190
+ pragma("PRAGMA synchronous=NORMAL")
191
+ pragma("PRAGMA foreign_keys=ON")
192
+ schema
193
+ end
194
+
195
+ def close
196
+ @db&.close
197
+ @db = nil
198
+ end
199
+
200
+ def copy_path = File.join(File.dirname(@path), COPY_NAME)
201
+
202
+ # A restore puts the copy back without the database (docs/install.md,
203
+ # "Backups and restoring"): it becomes the database. Every host process
204
+ # of the state volume starts at once; the copy is put in place with a
205
+ # hard link, which only one of them makes (a rename would put a new
206
+ # file under one that another process has opened already).
207
+ def restore_from_copy
208
+ return if File.exist?(@path) || !File.file?(copy_path)
209
+ tmp = "#{@path}.restore-#{Process.pid}.tmp"
210
+ FileUtils.cp(copy_path, tmp)
211
+ begin
212
+ File.link(tmp, @path)
213
+ @log&.call("records: #{@path} made from #{COPY_NAME}")
214
+ rescue Errno::EEXIST
215
+ nil # another process put it there first
216
+ ensure
217
+ FileUtils.rm_f(tmp)
218
+ end
219
+ end
220
+
221
+ # Turning a new database to WAL takes it for a moment: several
222
+ # processes opening it at once wait for each other.
223
+ def pragma(sql, tries = 50)
224
+ @db.execute(sql)
225
+ rescue SQLite3::BusyException
226
+ raise if (tries -= 1) <= 0
227
+ sleep 0.1
228
+ retry
229
+ end
230
+
231
+ # Format 1, and the column grp added to it (games 0.16.0: the group
232
+ # of a room's invite link, NULL for every other game and for the games
233
+ # before it). The column is added in place and the format stays 1, so
234
+ # a host before 0.16.0 put back reads and writes the same database
235
+ # (it names its columns, and leaves grp NULL).
236
+ def schema
237
+ transaction do
238
+ v = @db.get_first_value("PRAGMA user_version").to_i
239
+ raise "records: #{@path} has format #{v}, this host reads #{VERSION}" if v > VERSION
240
+ create_tables if v < VERSION
241
+ add_group_column
242
+ end
243
+ end
244
+
245
+ def add_group_column
246
+ cols = @db.execute("PRAGMA table_info(games)").map { |r| r[1] }
247
+ return if cols.include?("grp")
248
+ @db.execute("ALTER TABLE games ADD COLUMN grp TEXT")
249
+ @db.execute("CREATE INDEX IF NOT EXISTS games_grp ON games (grp, tbl, id)")
250
+ end
251
+
252
+ def create_tables
253
+ @db.execute_batch(<<~SQL)
254
+ CREATE TABLE IF NOT EXISTS games (
255
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
256
+ game TEXT NOT NULL,
257
+ place TEXT NOT NULL,
258
+ room TEXT,
259
+ tbl TEXT NOT NULL,
260
+ time INTEGER NOT NULL,
261
+ at TEXT NOT NULL,
262
+ variant TEXT,
263
+ reason TEXT,
264
+ rounds INTEGER,
265
+ body TEXT NOT NULL
266
+ );
267
+ CREATE INDEX IF NOT EXISTS games_place ON games (game, place, tbl, id);
268
+ CREATE INDEX IF NOT EXISTS games_room ON games (room, tbl, id);
269
+ CREATE INDEX IF NOT EXISTS games_time ON games (game, time);
270
+ CREATE TABLE IF NOT EXISTS seats (
271
+ game_id INTEGER NOT NULL REFERENCES games (id) ON DELETE CASCADE,
272
+ seat INTEGER NOT NULL,
273
+ name TEXT NOT NULL,
274
+ kind TEXT NOT NULL,
275
+ rank INTEGER,
276
+ score INTEGER,
277
+ points INTEGER,
278
+ counts TEXT,
279
+ PRIMARY KEY (game_id, seat)
280
+ );
281
+ CREATE INDEX IF NOT EXISTS seats_who ON seats (kind, name);
282
+ PRAGMA user_version = #{VERSION};
283
+ SQL
284
+ end
285
+
286
+ # A write transaction (BEGIN IMMEDIATE: the lock is taken first, so
287
+ # two processes ending a game at once wait for each other within the
288
+ # busy timeout instead of failing at the commit).
289
+ def transaction(&blk)
290
+ @mutex.synchronize do
291
+ @db.transaction(:immediate) { blk.call(@db) }
292
+ end
293
+ end
294
+
295
+ def read(&blk)
296
+ @mutex.synchronize { blk.call(@db) }
297
+ end
298
+
299
+ # The consistent copy a backup takes (VACUUM INTO a *.tmp name, then
300
+ # renamed over the last one). Returns its size.
301
+ def write_copy
302
+ tmp = "#{copy_path}.#{Process.pid}.tmp"
303
+ FileUtils.rm_f(tmp)
304
+ @mutex.synchronize { @db.execute("VACUUM INTO ?", [tmp]) }
305
+ File.rename(tmp, copy_path)
306
+ File.size(copy_path)
307
+ rescue SQLite3::Exception, SystemCallError => e
308
+ FileUtils.rm_f(tmp) if tmp
309
+ @log&.call("records: the copy for the backup: #{e.class}: #{e.message}")
310
+ nil
311
+ end
312
+ end
313
+
314
+ # The SQLite form: a table's view of the shared database (its game,
315
+ # place, table and, in a room, the room's ID and its invite link's
316
+ # group).
317
+ class SQLiteBook
318
+ # What a group may look like (the console writes a random hex string);
319
+ # anything else is taken as none.
320
+ GROUP = /\A[A-Za-z0-9_-]{8,64}\z/
321
+
322
+ attr_reader :db, :group
323
+
324
+ def initialize(db, game:, table:, place:, room:, kept:, summarize:, log: nil, group: nil)
325
+ @db = db
326
+ @game = game
327
+ @table = table
328
+ @place = place.to_s
329
+ @room = place.to_s == "room" ? room.to_s : nil
330
+ @group = @room && group.is_a?(String) && group.match?(GROUP) ? group : nil
331
+ @kept = kept
332
+ @summarize = summarize
333
+ @log = log
334
+ end
335
+
336
+ def kind = "sqlite"
337
+
338
+ # The games this table gives out (list, record): in a room the room's
339
+ # and those of the rooms of the same group, else the table's of its
340
+ # place.
341
+ def scope_sql(t = "")
342
+ return own_sql(t) unless @group
343
+ ["#{t}game = ? AND #{t}place = 'room' AND #{t}tbl = ? AND (#{t}room = ? OR #{t}grp = ?)",
344
+ [@game, @table, @room, @group]]
345
+ end
346
+
347
+ # The games this table played itself: in a room the room's, else the
348
+ # table's of its place (the stats of "table", the last result).
349
+ def own_sql(t = "")
350
+ if @room
351
+ ["#{t}game = ? AND #{t}place = 'room' AND #{t}room = ? AND #{t}tbl = ?", [@game, @room, @table]]
352
+ else
353
+ ["#{t}game = ? AND #{t}place = ? AND #{t}room IS NULL AND #{t}tbl = ?", [@game, @place, @table]]
354
+ end
355
+ end
356
+
357
+ def add(rec, now = Time.now, copy: true)
358
+ rec["at"] ||= now.strftime("%Y-%m-%d %H:%M")
359
+ rec["time"] ||= now.to_i
360
+ id = nil
361
+ @db.transaction do |d|
362
+ id = insert(d, rec)
363
+ trim(d)
364
+ end
365
+ @db.write_copy if copy
366
+ id
367
+ end
368
+
369
+ # The game's row and its seats' (the record's id is the row's); group:
370
+ # the group kept with it (the games taken in from the JSON form, of
371
+ # before 0.16.0, keep none).
372
+ def insert(d, rec, group = @group)
373
+ s = @summarize.call(rec)
374
+ rec.delete("id")
375
+ d.execute("INSERT INTO games (game, place, room, grp, tbl, time, at, variant, reason, rounds, body) " \
376
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '')",
377
+ [@game, @place, @room, group, @table, rec["time"].to_i, rec["at"].to_s, s["variant"], s["reason"],
378
+ s["rounds"]])
379
+ id = d.last_insert_row_id
380
+ rec["id"] = id
381
+ d.execute("UPDATE games SET body = ? WHERE id = ?", [JSON.generate(rec), id])
382
+ s["seats"].each do |x|
383
+ d.execute("INSERT INTO seats (game_id, seat, name, kind, rank, score, points, counts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
384
+ [id, x["seat"], x["name"].to_s, x["kind"].to_s, x["rank"], x["score"], x["points"],
385
+ JSON.generate(x["counts"] || {})])
386
+ end
387
+ id
388
+ end
389
+
390
+ # The newest kept of the table: a room's tables count together (the
391
+ # rooms' table of that name), the others by their place.
392
+ def trim(d)
393
+ where, args = @room ? ["game = ? AND place = 'room' AND tbl = ?", [@game, @table]] : own_sql
394
+ d.execute("DELETE FROM games WHERE id IN (SELECT id FROM games WHERE #{where} ORDER BY id DESC LIMIT -1 OFFSET ?)",
395
+ args + [@kept])
396
+ end
397
+
398
+ # The games of the JSON form (a host before 0.14.0): added in their
399
+ # order, with their own times; returns how many.
400
+ def import(recs)
401
+ n = 0
402
+ @db.transaction do |d|
403
+ recs.each do |r|
404
+ r = r.dup
405
+ insert(d, r, nil)
406
+ n += 1
407
+ end
408
+ trim(d)
409
+ end
410
+ @db.write_copy
411
+ n
412
+ end
413
+
414
+ def size
415
+ where, args = scope_sql
416
+ @db.read { |d| d.get_first_value("SELECT COUNT(*) FROM games WHERE #{where}", args).to_i }
417
+ end
418
+
419
+ def list(from, limit)
420
+ where, args = scope_sql
421
+ rows = @db.read do |d|
422
+ d.execute("SELECT body FROM games WHERE #{where} ORDER BY id DESC LIMIT ? OFFSET ?", args + [limit, from])
423
+ end
424
+ rows.map do |(body)|
425
+ r = JSON.parse(body)
426
+ [r, @summarize.call(r)]
427
+ end
428
+ end
429
+
430
+ def get(id)
431
+ where, args = scope_sql
432
+ body = @db.read { |d| d.get_first_value("SELECT body FROM games WHERE id = ? AND #{where}", [id.to_i] + args) }
433
+ body && JSON.parse(body)
434
+ end
435
+
436
+ # The newest game this table played itself (a room's own, not its
437
+ # group's).
438
+ def last
439
+ where, args = own_sql
440
+ body = @db.read { |d| d.get_first_value("SELECT body FROM games WHERE #{where} ORDER BY id DESC LIMIT 1", args) }
441
+ return nil unless body
442
+ r = JSON.parse(body)
443
+ [r, @summarize.call(r)]
444
+ end
445
+
446
+ # The rows of the stats: this table's games (everything: false; in a
447
+ # room the room's own, not its group's) or every game of this game in
448
+ # the database (everything: true: every table, place and room, closed
449
+ # rooms too).
450
+ def stat_rows(since: nil, variant: nil, everything: false)
451
+ where, args = everything ? ["g.game = ?", [@game]] : own_sql("g.")
452
+ if since
453
+ where += " AND g.time >= ?"
454
+ args += [since]
455
+ end
456
+ if variant
457
+ where += " AND g.variant = ?"
458
+ args += [variant]
459
+ end
460
+ rows = @db.read do |d|
461
+ d.execute("SELECT s.kind, s.name, s.rank, s.score, s.points, s.counts FROM seats s JOIN games g ON g.id = s.game_id " \
462
+ "WHERE #{where}", args)
463
+ end
464
+ rows.map { |k, n, r, sc, pt, c| [k, n, r, sc, pt, c ? JSON.parse(c) : {}] }
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsterismGames
4
+ # A table as an Asterism object (<node>/<app>/<table>): the methods a
5
+ # game exposes, with their numbers of arguments (exposes), each taking
6
+ # the caller's node ID as from: (the host exposes it with caller: :from,
7
+ # so Asterism fills from: in from the call's key and refuses a call that
8
+ # brings from: itself). The methods run on the host's receiving thread,
9
+ # one at a time with the host's loop, and never wait.
10
+ #
11
+ # join(name) -> the game's member_reply, or {"error" => why}
12
+ # mine -> the same for a node that joined, or nil
13
+ # leave(token) -> true / false
14
+ #
15
+ # A game's Remote adds its own methods (exposes) and calls mark(token,
16
+ # from) in those a member makes: a call by a member from its own node
17
+ # counts as a sign that it is there.
18
+ class Remote
19
+ class << self
20
+ # The exposed methods: {name => number of arguments} (-1: any; the
21
+ # arguments are not counted). Those given come first, in their order;
22
+ # the inherited ones not given follow.
23
+ def exposes(methods = nil)
24
+ if methods
25
+ inherited = superclass.respond_to?(:exposes) ? superclass.exposes : {}
26
+ @exposes = methods.transform_keys(&:to_sym).merge(inherited.reject { |k, _| methods.key?(k) }).freeze
27
+ end
28
+ @exposes || (superclass.respond_to?(:exposes) ? superclass.exposes : {})
29
+ end
30
+
31
+ # What the methods take and do, for the meta (docs/protocol.md 5 of
32
+ # asterism: asterism mcp and the command line show them): {name =>
33
+ # [argument names, text]}. Inherited like exposes.
34
+ def docs(table = nil)
35
+ if table
36
+ inherited = superclass.respond_to?(:docs) ? superclass.docs : {}
37
+ @docs = inherited.merge(table.transform_keys(&:to_sym)).freeze
38
+ end
39
+ @docs || (superclass.respond_to?(:docs) ? superclass.docs : {})
40
+ end
41
+
42
+ # The object's own text in the meta.
43
+ def object_doc(text = nil)
44
+ @object_doc = text if text
45
+ @object_doc || (superclass.respond_to?(:object_doc) ? superclass.object_doc : nil)
46
+ end
47
+
48
+ # The methods: as Asterism.expose takes them: with the names and
49
+ # texts when the asterism gem knows them (with_docs), else the
50
+ # numbers (asterism 0.7.1).
51
+ def expose_methods(with_docs)
52
+ exposes.to_h do |m, n|
53
+ d = docs[m]
54
+ next [m, n] unless with_docs && d
55
+ [m, { args: d[0], doc: d[1], arity: n }]
56
+ end
57
+ end
58
+ end
59
+
60
+ exposes join: 1, mine: 0, leave: 1
61
+ docs join: [%w[name], "Joins the table as name (or gets this node's membership back). Returns {\"token\", ...}: " \
62
+ "every later call brings the token. {\"error\" => why} when the table is full."],
63
+ mine: [[], "This node's membership ({\"token\", ...}), or nil when it has not joined."],
64
+ leave: [%w[token], "Leaves the table (a seat during a game goes to a CPU). true / false."]
65
+
66
+ attr_reader :game
67
+
68
+ def initialize(game, clock)
69
+ @game = game
70
+ @clock = clock
71
+ end
72
+
73
+ def join(name, from: nil)
74
+ @game.join(from.to_s, name.to_s, now)
75
+ end
76
+
77
+ def mine(from: nil)
78
+ node = from.to_s
79
+ r = @game.mine(node, now)
80
+ @game.touch(node, now) if r && !r["error"]
81
+ r
82
+ end
83
+
84
+ def leave(token, from: nil)
85
+ @game.leave(token)
86
+ end
87
+
88
+ private
89
+
90
+ def now
91
+ @clock.call
92
+ end
93
+
94
+ # A call with a member's token from that member's own node.
95
+ def mark(token, from)
96
+ node = @game.node_of(token)
97
+ @game.touch(node, now) if node && node == from.to_s
98
+ end
99
+ end
100
+ end