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,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,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: []
|