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