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
|
@@ -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
|