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,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "game"
5
+
6
+ module AsterismGames
7
+ # Settings: a YAML file (ASTERISM_GAMES_CONFIG or --config), then the
8
+ # environment over it. See README "Settings".
9
+ #
10
+ # The games come from the registry (AsterismGames.games): a table's
11
+ # "game" defaults to the first game registered, the tables the settings
12
+ # do not name are that game's (Game.tables), a room whose link opens a
13
+ # game's screen (Game.screens) gets that game's tables of rooms.tables
14
+ # (Config.tables_for_app), and each game says which table keys the file
15
+ # and the environment may set (Game.settings).
16
+ class Config
17
+ DEFAULTS = {
18
+ "router" => "tcp/127.0.0.1:7447",
19
+ "node" => "games",
20
+ "app" => "games",
21
+ "credentials" => nil,
22
+ "state_dir" => "var/state",
23
+ "records_db" => nil, # the games' records (README "Game records"): nil state_dir/records.sqlite3, "json" the JSON files
24
+ "tables" => nil, # nil: the first game's tables
25
+ "rooms" => nil,
26
+ "permanent" => nil
27
+ }.freeze
28
+
29
+ # The rooms of a console (README "Rooms"): given (a "rooms" section or
30
+ # ASTERISM_GAMES_ROOMS_DIR), the host serves tables in rooms instead of
31
+ # the tables above.
32
+ ROOM_DEFAULTS = {
33
+ "dir" => nil, # the console's rooms volume (rooms.json, certs/)
34
+ "apps" => nil, # a room opening one of these gets its game's tables (nil: the games' screens)
35
+ "max" => 3, # rooms with tables at once
36
+ "node" => "games", # the host's node ID in a room
37
+ "router" => nil, # over the slot certificate's router
38
+ "status_listen" => nil, # host:port for GET /status
39
+ "tables" => nil, # nil: each game's first table
40
+ # The host's own certificate in the rooms (README "Rooms", console
41
+ # 0.3.0 lists the host in rooms.json "hosts"): its .tar, else the
42
+ # permanent tables' one; the slot's certificate only when the console
43
+ # does not list the host.
44
+ "credentials" => nil,
45
+ # A seat's node ID silent this long at a table is a new page on that
46
+ # seat (the console's door gives an idle seat to another page after
47
+ # WS_DOOR_SEAT_IDLE, 1800 s).
48
+ "seat_reuse_seconds" => 1800
49
+ }.freeze
50
+ ROOM_ENV_KEYS = {
51
+ "ASTERISM_GAMES_ROOMS_DIR" => ["dir", :s],
52
+ "ASTERISM_GAMES_ROOM_APPS" => ["apps", :list],
53
+ "ASTERISM_GAMES_ROOM_MAX" => ["max", :i],
54
+ "ASTERISM_GAMES_ROOM_NODE" => ["node", :s],
55
+ "ASTERISM_GAMES_ROOM_ROUTER" => ["router", :s],
56
+ "ASTERISM_GAMES_STATUS_LISTEN" => ["status_listen", :s],
57
+ "ASTERISM_GAMES_ROOM_CREDENTIALS" => ["credentials", :s],
58
+ "ASTERISM_GAMES_ROOM_SEAT_REUSE_SECONDS" => ["seat_reuse_seconds", :i]
59
+ }.freeze
60
+
61
+ # The permanent tables next to the rooms (README "Permanent tables"):
62
+ # with a certificate of the host's own (a row of the console's
63
+ # registry, e.g. "games"), the rooms' supervisor runs one more host
64
+ # process on the registered side of the router, for the boards and the
65
+ # browsers with a pass. Only with rooms (the supervisor); without rooms
66
+ # the host itself takes "credentials" and "tables".
67
+ PERMANENT_DEFAULTS = {
68
+ "credentials" => nil, # the host's .tar (none: no permanent tables)
69
+ "router" => nil, # over the certificate's router
70
+ "node" => nil, # the certificate's name
71
+ "tables" => nil # nil: the first game's tables
72
+ }.freeze
73
+ PERMANENT_ENV_KEYS = {
74
+ "ASTERISM_GAMES_PERMANENT_CREDENTIALS" => ["credentials", :s],
75
+ "ASTERISM_GAMES_PERMANENT_ROUTER" => ["router", :s],
76
+ "ASTERISM_GAMES_PERMANENT_NODE" => ["node", :s],
77
+ "ASTERISM_GAMES_PERMANENT_TABLES" => ["tables", :names]
78
+ }.freeze
79
+
80
+ # Environment variable => [key, kind]. The table ones are the games'
81
+ # own (Game.settings), applied to every table of that game.
82
+ ENV_KEYS = {
83
+ "ASTERISM_GAMES_ROUTER" => ["router", :s],
84
+ "ASTERISM_GAMES_NODE" => ["node", :s],
85
+ "ASTERISM_GAMES_APP" => ["app", :s],
86
+ "ASTERISM_GAMES_CREDENTIALS" => ["credentials", :s],
87
+ "ASTERISM_GAMES_STATE_DIR" => ["state_dir", :s],
88
+ "ASTERISM_GAMES_RECORDS_DB" => ["records_db", :s]
89
+ }.freeze
90
+
91
+ # The game a table without "game" plays: the first one registered.
92
+ def self.default_game
93
+ AsterismGames.games.keys.first
94
+ end
95
+
96
+ # The tables of the default game ([{"name", "game"}]).
97
+ def self.default_tables
98
+ name = default_game
99
+ names = name ? AsterismGames.game(name).tables : []
100
+ names.map { |n| { "name" => n, "game" => name } }
101
+ end
102
+
103
+ # A room's tables when the settings name none: each game's first table.
104
+ def self.default_room_tables
105
+ AsterismGames.games.filter_map do |name, klass|
106
+ first = klass.tables.first
107
+ first && { "name" => first, "game" => name }
108
+ end
109
+ end
110
+
111
+ # The table apps of every game (a room opening one gets that game's tables).
112
+ def self.default_apps
113
+ AsterismGames.games.values.flat_map(&:screens).uniq
114
+ end
115
+
116
+ # The tables a room whose link opens app gets: those of tables whose
117
+ # game lists app in its screens (README "Rooms").
118
+ def self.tables_for_app(tables, app)
119
+ Array(tables).select do |t|
120
+ klass = AsterismGames.game(t["game"])
121
+ klass && klass.screens.include?(app.to_s)
122
+ end
123
+ end
124
+
125
+ # The game of a table named in the environment: "name:game", else the
126
+ # game whose own tables have that name, else the default game.
127
+ def self.game_of_name(text)
128
+ name, game = text.split(":", 2).map(&:strip)
129
+ game ||= AsterismGames.games.find { |_g, klass| klass.tables.include?(name) }&.first || default_game
130
+ { "name" => name, "game" => game }
131
+ end
132
+
133
+ attr_reader :data
134
+
135
+ def self.load(path = ENV["ASTERISM_GAMES_CONFIG"], env = ENV)
136
+ file = path && File.exist?(path) ? (YAML.safe_load(File.read(path)) || {}) : {}
137
+ raise ArgumentError, "#{path}: not found" if path && !File.exist?(path)
138
+ new(file, env)
139
+ end
140
+
141
+ def initialize(file = {}, env = ENV)
142
+ @data = DEFAULTS.merge(file)
143
+ ENV_KEYS.each { |k, (key, _)| @data[key] = env[k] if env[k] && !env[k].empty? }
144
+ @data["tables"] = self.class.default_tables if @data["tables"].nil?
145
+ @data["tables"] = Array(@data["tables"]).map { |t| table_options(t, env) }
146
+ @data["rooms"] = room_options(@data["rooms"], env)
147
+ @data["permanent"] = permanent_options(@data["permanent"], env)
148
+ return unless @data["rooms"]
149
+ @data["rooms"]["credentials"] = @data["permanent"]["credentials"] if @data["rooms"]["credentials"].to_s.empty? && @data["permanent"]
150
+ @data["rooms"]["credentials"] = nil if @data["rooms"]["credentials"].to_s.empty?
151
+ end
152
+
153
+ # The permanent section with its defaults and the environment, or nil
154
+ # when no certificate is given or there are no rooms (the supervisor
155
+ # runs it).
156
+ def permanent_options(p, env)
157
+ p = PERMANENT_DEFAULTS.merge(p.is_a?(Hash) ? p : {})
158
+ PERMANENT_ENV_KEYS.each do |k, (key, kind)|
159
+ v = env[k]
160
+ next if v.nil? || v.empty?
161
+ p[key] = if kind == :names
162
+ v.split(",").map(&:strip).reject(&:empty?).map { |n| self.class.game_of_name(n) }
163
+ else
164
+ v
165
+ end
166
+ end
167
+ return nil if p["credentials"].to_s.empty? || @data["rooms"].nil?
168
+ p["node"] = nil if p["node"].to_s.empty?
169
+ p["tables"] = self.class.default_tables if p["tables"].nil?
170
+ p["tables"] = Array(p["tables"]).map { |t| table_options(t, env) }
171
+ raise ArgumentError, "permanent.tables: at least one table" if p["tables"].empty?
172
+ p
173
+ end
174
+
175
+ # The rooms section with its defaults and the environment, or nil when
176
+ # no rooms directory is given.
177
+ def room_options(r, env)
178
+ r = ROOM_DEFAULTS.merge(r.is_a?(Hash) ? r : {})
179
+ ROOM_ENV_KEYS.each do |k, (key, kind)|
180
+ v = env[k]
181
+ next if v.nil? || v.empty?
182
+ r[key] = case kind
183
+ when :list then v.split(",").map(&:strip).reject(&:empty?)
184
+ when :i then Integer(v)
185
+ when :bool then %w[1 true yes].include?(v.downcase)
186
+ else v
187
+ end
188
+ end
189
+ return nil if r["dir"].to_s.empty?
190
+ r["seat_reuse_seconds"] = r["seat_reuse_seconds"].to_i
191
+ r["apps"] = self.class.default_apps if r["apps"].nil?
192
+ r["apps"] = Array(r["apps"]).map(&:to_s)
193
+ r["max"] = r["max"].to_i
194
+ raise ArgumentError, "rooms.max must be 1 or more" if r["max"] < 1
195
+ r["tables"] = self.class.default_room_tables if r["tables"].nil?
196
+ r["tables"] = Array(r["tables"]).map { |t| table_options(t, env) }
197
+ r
198
+ end
199
+
200
+ def [](key)
201
+ @data[key]
202
+ end
203
+
204
+ # A table of the settings: its name, its game and the game's settings
205
+ # (Game.settings: seconds to ms, the text, or a Hash as it is). A game
206
+ # nobody registered keeps its name (the host refuses it).
207
+ def table_options(t, env)
208
+ out = { "name" => t["name"].to_s, "game" => (t["game"] || self.class.default_game).to_s }
209
+ klass = AsterismGames.game(out["game"])
210
+ return out unless klass
211
+ keys = klass.settings
212
+ keys[:file].each do |k, (key, mul)|
213
+ next unless t.key?(k)
214
+ out[key] = case mul
215
+ when :hash then t[k].to_h
216
+ when nil then t[k].to_s
217
+ else (t[k].to_f * mul).round
218
+ end
219
+ end
220
+ keys[:env].each do |k, (key, mul)|
221
+ v = env[k]
222
+ next if v.nil? || v.empty?
223
+ out[key] = mul ? (v.to_f * mul).round : v
224
+ end
225
+ out
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,456 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module AsterismGames
6
+ # The games the host can serve, by name (the "game" of a table in the
7
+ # settings). A game registers itself with Game.register.
8
+ def self.games
9
+ Game.registry
10
+ end
11
+
12
+ # The class of a game by name, or nil.
13
+ def self.game(name)
14
+ Game.registry[name.to_s]
15
+ end
16
+
17
+ # The base of every game (README "Writing a game"). One object is one
18
+ # table: the people at it (members, each with a token), the game's own
19
+ # state, the views and the saved form. Time is passed in (milliseconds)
20
+ # so tests can drive it; nothing here waits or talks to the network. The
21
+ # host (Host) exposes the table's Remote as an Asterism object, calls
22
+ # tick on its loop, saves the table when it is dirty and puts state_view
23
+ # on asterism/<node>/<app>/<table>/state.
24
+ #
25
+ # A game declares, in its class:
26
+ #
27
+ # register "name" the name the settings use ("game: name")
28
+ # remote RemoteClass the exposed object (a subclass of Remote)
29
+ # tables "t1", "t2" the tables a host serves when the settings name none
30
+ # screens "/app/..." the table apps a room's link may open (README "Rooms")
31
+ # settings file: {...}, env: {...}
32
+ # the table settings the file and the environment
33
+ # may give: {key => [option, multiplier]} (README "Settings")
34
+ # DEFAULTS the table's options (over Game::DEFAULTS)
35
+ # SAVE_FORMAT the number in the saved state; another number is not read
36
+ # SAVE_HOLD_MS at most one write of the saved state this often (0: after
37
+ # every change); a game that changes many times a second
38
+ # (the board's strokes) holds its writes
39
+ #
40
+ # and writes the rules: tick(now), public_view(now) (and spectator_view,
41
+ # summary when they differ), to_saved(now) / restore(data, now) and the
42
+ # methods its Remote calls. A game that hands out more than its state
43
+ # (the board's strokes) tells it with tell(sub, value): the host puts
44
+ # value on asterism/<node>/<app>/<table>/<sub>, in the order told, on its
45
+ # next step.
46
+ #
47
+ # Members: join gives a node a token; the token is what every later call
48
+ # of that member brings. Who is there (README "Who is there"): the host
49
+ # tells the table every node that joins the network (node_joined, from
50
+ # Asterism's on_join) and leaves it (node_left, on_leave). A member whose
51
+ # node the host has seen join is there until its node leaves: away at
52
+ # once, back at once. A member whose node the host has not seen (a
53
+ # router that does not show the host the others' presence) is away when
54
+ # it has not called for away_ms (its calls, seen), as before. In a room (room_id set) a joining node is checked
55
+ # (README "Who joins"): the seats' node IDs and the host's own names are
56
+ # bound to their callers by the router and taken as they are; nobody
57
+ # else joins.
58
+ class Game
59
+ SAVE_FORMAT = 1
60
+ SAVE_HOLD_MS = 0
61
+ MAX_MEMBERS = 16
62
+ NAME_MAX = 32
63
+
64
+ DEFAULTS = {
65
+ "away_ms" => 10_000, # a member not heard from this long is away (also away at once when its node leaves)
66
+ "room_id" => nil, # in a room: its ID, against which joins are checked (README "Who joins")
67
+ "trusted_nodes" => [], # in a room: node IDs the router binds to their callers (the seats)
68
+ "trusted_prefix" => nil, # in a room: the host's own names (it and its bots), bound likewise
69
+ # A seat's node ID silent this long is a new page on that seat: the
70
+ # door's WS_DOOR_SEAT_IDLE, after which it may give the seat to
71
+ # another page. A page back with its secret within it (the console
72
+ # 0.3.9) is the same person; a longer time would hand an old
73
+ # member's seat to a new page.
74
+ "seat_reuse_ms" => 1_800_000
75
+ }.freeze
76
+
77
+ @registry = {}
78
+
79
+ class << self
80
+ # name => game class (on Game itself).
81
+ def registry
82
+ Game.instance_variable_get(:@registry)
83
+ end
84
+
85
+ def register(name)
86
+ @game_name = name.to_s
87
+ Game.registry[@game_name] = self
88
+ end
89
+
90
+ def game_name = @game_name
91
+
92
+ def remote(klass = nil)
93
+ @remote = klass if klass
94
+ @remote || (superclass.respond_to?(:remote) ? superclass.remote : nil)
95
+ end
96
+
97
+ def tables(*names)
98
+ @tables = names.map(&:to_s) unless names.empty?
99
+ @tables || []
100
+ end
101
+
102
+ def screens(*apps)
103
+ @screens = apps.map(&:to_s) unless apps.empty?
104
+ @screens || []
105
+ end
106
+
107
+ # file: / env: {key => [option, multiplier]}: a number is multiplied
108
+ # (seconds to ms) and rounded; nil keeps the text.
109
+ def settings(file: nil, env: nil)
110
+ @file_settings = file.freeze if file
111
+ @env_settings = env.freeze if env
112
+ { file: @file_settings || {}, env: @env_settings || {} }
113
+ end
114
+ end
115
+
116
+ attr_reader :name, :opts, :ver
117
+
118
+ def initialize(name, opts = {}, store: nil, log: nil)
119
+ @name = name
120
+ @opts = self.class::DEFAULTS.merge(opts.transform_keys(&:to_s))
121
+ @store = store
122
+ @log = log
123
+ @ver = 1
124
+ @now = 0
125
+ @dirty = true
126
+ @unsaved = false
127
+ @live = {} # node => true: seen on the network (node_joined), not since left
128
+ @told = [] # [sub, value]: what the host puts next to the state (tell)
129
+ @saved_at = nil
130
+ reset_members
131
+ end
132
+
133
+ # ------------------------------------------------------------ the rules (a game writes these)
134
+
135
+ # Moves the game on (timeouts, CPU turns). Called on the host's loop
136
+ # and after the game's own changes; never waits.
137
+ def tick(now)
138
+ @now = now
139
+ end
140
+
141
+ # What everybody may see.
142
+ def public_view(now)
143
+ { "v" => @ver, "t" => @name }
144
+ end
145
+
146
+ # What spectators see (and what the host puts on the state key).
147
+ def spectator_view(now)
148
+ public_view(now)
149
+ end
150
+
151
+ # What the host puts on asterism/<node>/<app>/<table>/state.
152
+ def state_view(now)
153
+ spectator_view(now)
154
+ end
155
+
156
+ # Counts for the host's status (GET /status of the rooms' host, README
157
+ # "Rooms"): no names, no nodes, no tokens. "st" (the game's status),
158
+ # "members", "humans" (members in a seat), "cpus", "away" (humans not
159
+ # heard from); a game adds what it likes.
160
+ def summary(now)
161
+ { "st" => "wait", "members" => @members.size, "humans" => 0, "cpus" => 0, "away" => 0 }
162
+ end
163
+
164
+ # What the host's @status tells of this table besides the counts
165
+ # (README "Status"), e.g. the last game's result; never a node or a
166
+ # token. Not in summary (the rooms' status file has counts only).
167
+ def status_extra(_now)
168
+ {}
169
+ end
170
+
171
+ # The saved form (a Hash for JSON) and back. restore gets what
172
+ # to_saved gave, read from the store, when its "format" is SAVE_FORMAT.
173
+ def to_saved(now)
174
+ { "format" => self.class::SAVE_FORMAT, "name" => @name, "ver" => @ver, "members" => saved_members,
175
+ "counts" => [@join_count] }
176
+ end
177
+
178
+ def restore(data, now)
179
+ @ver = data["ver"].to_i + 1
180
+ @members = data["members"] || {}
181
+ @join_count = Array(data["counts"]).first.to_i
182
+ end
183
+
184
+ # Times read back are relative to when they were saved: a host started
185
+ # again calls this with its own clock. Members count as just seen (the
186
+ # nodes that are not there leave again once the host is connected).
187
+ def resume_clock(now, data = saved)
188
+ return unless data
189
+ @members.each_value do |m|
190
+ m["seen"] = now
191
+ m.delete("left")
192
+ end
193
+ @now = now
194
+ end
195
+
196
+ # ------------------------------------------------------------ saving
197
+
198
+ def changed
199
+ @ver += 1
200
+ @dirty = true
201
+ end
202
+
203
+ # Changed: to save and to put.
204
+ def dirty?
205
+ @dirty
206
+ end
207
+
208
+ # Changed in what only a member's own view shows: to save, not to put.
209
+ def unsaved?
210
+ @unsaved
211
+ end
212
+
213
+ # Writes the saved state. Within SAVE_HOLD_MS of the last write the
214
+ # write waits (unsaved? stays true, so the host's next steps come
215
+ # back); force: writes now (the host stopping).
216
+ def save(now, force: false)
217
+ @dirty = false
218
+ hold = self.class::SAVE_HOLD_MS
219
+ if !force && hold.positive? && @saved_at && now - @saved_at < hold
220
+ @unsaved = true
221
+ return
222
+ end
223
+ @unsaved = false
224
+ @saved_at = now
225
+ return unless @store
226
+ @store.write(@name, to_saved(now))
227
+ end
228
+
229
+ # ------------------------------------------------------------ telling
230
+
231
+ # Hands value out on asterism/<node>/<app>/<table>/<sub> (the host puts
232
+ # it on its next step, after the game's other changes of that step).
233
+ def tell(sub, value)
234
+ @told << [sub.to_s, value]
235
+ end
236
+
237
+ # What was told since the last time ([[sub, value], ...]), and forgets it.
238
+ def take_told
239
+ return [] if @told.empty?
240
+ t = @told
241
+ @told = []
242
+ t
243
+ end
244
+
245
+ # The saved state of this table when it is of this game's format, or nil.
246
+ def saved
247
+ d = @store&.read(@name)
248
+ d.is_a?(Hash) && d["format"] == self.class::SAVE_FORMAT ? d : nil
249
+ end
250
+
251
+ # Reads the saved state back (restore); true when there was one.
252
+ def load_saved(now)
253
+ d = saved
254
+ return false unless d
255
+ restore(d, now)
256
+ resume_clock(now, d)
257
+ true
258
+ end
259
+
260
+ # ------------------------------------------------------------ members
261
+
262
+ # A node joins the table (or gets its membership back). Returns
263
+ # member_reply, or {"error" => why}: "full", what join_refusal says, in
264
+ # a room "identity" for a caller that is neither a seat nor the host's.
265
+ def join(node, name, now)
266
+ node = node.to_s
267
+ bad = identity_error(node)
268
+ return { "error" => bad } if bad
269
+ m = reused_seat(node, now)
270
+ if m.nil?
271
+ why = join_refusal(node)
272
+ return { "error" => why } if why
273
+ m = @members[node] = { "token" => SecureRandom.hex(16), "at" => @join_count += 1 }
274
+ end
275
+ m["name"] = clean_name(name, node)
276
+ m["seen"] = now
277
+ m.delete("left")
278
+ renamed(node, m["name"])
279
+ changed
280
+ member_reply(node)
281
+ end
282
+
283
+ # The caller's membership, or nil (nothing is created); in a room the
284
+ # caller is checked as for join.
285
+ def mine(node, now = nil)
286
+ node = node.to_s
287
+ bad = identity_error(node)
288
+ return { "error" => bad } if bad
289
+ m = now ? reused_seat(node, now) : @members[node]
290
+ return nil unless m
291
+ member_reply(node)
292
+ end
293
+
294
+ # The member leaves the table: true, or false for an unknown token.
295
+ def leave(token)
296
+ node = node_of(token)
297
+ return false unless node
298
+ forget(node)
299
+ changed
300
+ true
301
+ end
302
+
303
+ # What join and mine answer.
304
+ def member_reply(node)
305
+ { "token" => @members[node]["token"] }
306
+ end
307
+
308
+ # Why another member cannot join now, or nil.
309
+ def join_refusal(_node)
310
+ "full" if @members.size >= self.class::MAX_MEMBERS
311
+ end
312
+
313
+ # A member's name changed (join again with another name).
314
+ def renamed(node, name); end
315
+
316
+ # Removes a member (leave, a spectator gone for long, a seat given to a
317
+ # new page): a game frees what the member held, then calls super.
318
+ def forget(node)
319
+ @members.delete(node)
320
+ end
321
+
322
+ # Kept even when gone for long (expire_members): e.g. a seated member.
323
+ def keep_member?(_node)
324
+ false
325
+ end
326
+
327
+ # A call from a node: it is there.
328
+ def touch(node, now)
329
+ m = @members[node.to_s]
330
+ return unless m
331
+ m["seen"] = now
332
+ changed if m.delete("left")
333
+ end
334
+
335
+ # The member's node left the network (the host's on_leave): away now,
336
+ # and the time it has been gone counts from now. true when it changed.
337
+ def node_left(node, now)
338
+ node = node.to_s
339
+ @live.delete(node)
340
+ m = @members[node]
341
+ return false unless m && !m["left"]
342
+ m["left"] = now
343
+ @log&.call("#{self.class.game_name}: #{@name}: #{node} left the network: away")
344
+ changed
345
+ true
346
+ end
347
+
348
+ # A node is on the network (on_join): its member, if any, is there
349
+ # until the node leaves. A seat's node gone for seat_reuse_ms is
350
+ # another page now and the old member leaves (reused_seat). true when
351
+ # a member changed.
352
+ def node_joined(node, now)
353
+ node = node.to_s
354
+ was_away = @members[node] && member_away?(node)
355
+ @live[node] = true
356
+ return false unless @members[node]
357
+ m = reused_seat(node, now)
358
+ return true unless m
359
+ m.delete("left")
360
+ m["seen"] = now
361
+ return false unless was_away
362
+ @log&.call("#{self.class.game_name}: #{@name}: #{node} is back")
363
+ changed
364
+ true
365
+ end
366
+
367
+ # When the member was last there: its last call, or when its node left.
368
+ def last_there(m)
369
+ [m["seen"].to_i, m["left"].to_i].max
370
+ end
371
+
372
+ def node_of(token)
373
+ return nil unless token.is_a?(String) && !token.empty?
374
+ @members.each { |node, m| return node if m["token"] == token }
375
+ nil
376
+ end
377
+
378
+ # Away: its node left the network; or, for a node the host has not
379
+ # seen join (the fallback), no call for away_ms.
380
+ def member_away?(node)
381
+ m = @members[node]
382
+ return true if m.nil? || m["left"]
383
+ return false if @live[node]
384
+ m["seen"].nil? || @now - m["seen"] > @opts["away_ms"]
385
+ end
386
+
387
+ # Members gone for long (six times away_ms, from their last call or
388
+ # from when their node left) are forgotten, unless the game keeps them
389
+ # (keep_member?). At most once a second.
390
+ def expire_members(now)
391
+ return if @members.empty? || now - @expire_at.to_i < 1000
392
+ @expire_at = now
393
+ gone = @members.keys.select do |node|
394
+ !keep_member?(node) && member_away?(node) && now - last_there(@members[node]) > @opts["away_ms"] * 6
395
+ end
396
+ gone.each { |node| forget(node) }
397
+ changed unless gone.empty?
398
+ end
399
+
400
+ def reset_members
401
+ @members = {}
402
+ @join_count = 0
403
+ end
404
+
405
+ def saved_members
406
+ @members.to_h { |n, m| [n, m.reject { |k, _| k == "seen" || k == "left" }] }
407
+ end
408
+
409
+ def clean_name(name, node)
410
+ s = name.to_s.dup.force_encoding("UTF-8")
411
+ s = s.scrub("").delete("\u0000-\u001f").strip
412
+ s = s.byteslice(0, NAME_MAX).scrub("") if s.bytesize > NAME_MAX
413
+ s.empty? ? node : s
414
+ end
415
+
416
+ # ------------------------------------------------------------ who joins (rooms)
417
+ #
418
+ # With seats (the console's 0.3.0) the router binds a page's calls to
419
+ # its seat's node ID (wasm-0103), and the host's own names to the
420
+ # host's certificate: those callers (trusted_nodes, trusted_prefix)
421
+ # are taken as they are, as outside a room, and nobody else joins.
422
+ # Outside a room (no room_id) nothing is checked: a board's certificate
423
+ # is its node ID there.
424
+
425
+ def room?
426
+ !@opts["room_id"].to_s.empty?
427
+ end
428
+
429
+ # A caller the router has bound to its name in this room.
430
+ def trusted?(node)
431
+ return false unless room?
432
+ return true if Array(@opts["trusted_nodes"]).include?(node)
433
+ prefix = @opts["trusted_prefix"].to_s
434
+ !prefix.empty? && node.start_with?(prefix)
435
+ end
436
+
437
+ # The member of a node, or nil; a seat's node ID that has not been
438
+ # there for seat_reuse_ms (no call, its node gone) is another page now
439
+ # (the door gave the seat away after as long without a connection):
440
+ # the old member leaves and nil is returned.
441
+ def reused_seat(node, now)
442
+ m = @members[node]
443
+ return m unless m && trusted?(node) && Array(@opts["trusted_nodes"]).include?(node)
444
+ return m unless m["seen"] && now - last_there(m) > @opts["seat_reuse_ms"].to_i
445
+ @log&.call("#{self.class.game_name}: #{@name}: #{node} was silent for #{(now - last_there(m)) / 60_000} min: " \
446
+ "a new page on that seat")
447
+ leave(m["token"])
448
+ nil
449
+ end
450
+
451
+ # Why a join or mine from node is refused, or nil.
452
+ def identity_error(node)
453
+ room? && !trusted?(node) ? "identity" : nil
454
+ end
455
+ end
456
+ end