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,436 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "asterism"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "game"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "store"
|
|
9
|
+
require_relative "ai_hub"
|
|
10
|
+
|
|
11
|
+
module AsterismGames
|
|
12
|
+
# The game host: one Asterism node that exposes a game object for every
|
|
13
|
+
# table in the settings, moves the games on, saves them after every change
|
|
14
|
+
# and puts their public state on asterism/<node>/<app>/<table>/state.
|
|
15
|
+
#
|
|
16
|
+
# One process is one Asterism application (the gem keeps one connection a
|
|
17
|
+
# process), so every game lives under the same app, each as an object of
|
|
18
|
+
# its own name. The games come from the registry (AsterismGames.games):
|
|
19
|
+
# a table's "game" names one.
|
|
20
|
+
#
|
|
21
|
+
# The connection is Asterism's block API (Asterism.connect { |net| }):
|
|
22
|
+
# the tables are exposed with caller: :from, and the calls are answered
|
|
23
|
+
# on the Net's receiving thread. The games move on in this host's own
|
|
24
|
+
# loop (step: timeouts, CPU turns, the puts), which takes Asterism::LOCK,
|
|
25
|
+
# the lock every tick of the receiving thread holds, so a call and a step
|
|
26
|
+
# never meet inside a game.
|
|
27
|
+
#
|
|
28
|
+
# Who is there (README "Who is there"): Asterism tells the host when a
|
|
29
|
+
# player's table app (the application "game" of its node) leaves the
|
|
30
|
+
# network and comes back (net.on_app_leave / on_app_join, on the
|
|
31
|
+
# receiving thread, inside that lock); the host passes it to every table,
|
|
32
|
+
# where a member on that node is away at once and back at once. A router
|
|
33
|
+
# whose rules do not show the host the players' presence tells it
|
|
34
|
+
# nothing, and the tables fall back on the members' calls (away_ms).
|
|
35
|
+
class Host
|
|
36
|
+
RECONNECT_S = 3
|
|
37
|
+
PUT_EVERY_MS = 5000
|
|
38
|
+
LOOP_S = 0.01
|
|
39
|
+
# The profile's name (outside the rooms): a host of its own on a LAN.
|
|
40
|
+
# The permanent tables' host is named by Rooms (config "profile_name").
|
|
41
|
+
PROFILE_NAME = "asterism-games"
|
|
42
|
+
|
|
43
|
+
attr_reader :games, :stats
|
|
44
|
+
|
|
45
|
+
def initialize(config, log: ->(m) { warn m })
|
|
46
|
+
@config = config
|
|
47
|
+
@log = log
|
|
48
|
+
@clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) }
|
|
49
|
+
@store = Store.new(config["state_dir"])
|
|
50
|
+
@games = {}
|
|
51
|
+
@remotes = {}
|
|
52
|
+
@methods = {}
|
|
53
|
+
@docs = {}
|
|
54
|
+
@put_at = {}
|
|
55
|
+
@stats = Hash.new(0)
|
|
56
|
+
config["tables"].each do |t|
|
|
57
|
+
klass = AsterismGames.game(t["game"]) or raise ArgumentError, "unknown game #{t['game'].inspect}"
|
|
58
|
+
# A room's tables check who joins against the room's ID, and trust
|
|
59
|
+
# the callers the router binds to their names (README "Who joins").
|
|
60
|
+
if config["room_id"]
|
|
61
|
+
t = t.merge(config.slice("room_id", "trusted_nodes", "trusted_prefix", "seat_reuse_ms").compact)
|
|
62
|
+
end
|
|
63
|
+
# Where the games' records go (README "Game records"): the one
|
|
64
|
+
# database of the state volume, and which tables these are; in a
|
|
65
|
+
# room made from an invite link, the link's group (games 0.16.0).
|
|
66
|
+
t = t.merge("records_db" => records_db, "records_place" => records_place)
|
|
67
|
+
t = t.merge("records_group" => config["records_group"]) if config["room_id"] && config["records_group"]
|
|
68
|
+
name = t["name"]
|
|
69
|
+
g = klass.new(name, t.reject { |k, _| %w[name game].include?(k) }, store: @store, log: @log)
|
|
70
|
+
g.resume_clock(@clock.call)
|
|
71
|
+
@games[name] = g
|
|
72
|
+
@remotes[name] = SizeWatch.new(klass.remote.new(g, @clock), self, name)
|
|
73
|
+
@methods[name] = klass.remote.expose_methods(META_DOCS)
|
|
74
|
+
@docs[name] = klass.remote.object_doc
|
|
75
|
+
end
|
|
76
|
+
@ai = ai_hub
|
|
77
|
+
@stop = false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The records' database: the setting ("json": the JSON files), else
|
|
81
|
+
# state_dir/records.sqlite3 (the rooms' and the permanent tables' hosts
|
|
82
|
+
# are given the supervisor's).
|
|
83
|
+
def records_db
|
|
84
|
+
@config["records_db"] || File.join(@config["state_dir"], RecordBook::DB_NAME)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# lan (a host of its own), permanent, room.
|
|
88
|
+
def records_place
|
|
89
|
+
@config["records_place"] || (@config["room_id"] ? "room" : "lan")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Whether the asterism gem tells the methods' names and texts in the
|
|
93
|
+
# meta (after 0.7.1; docs/protocol.md 5 of asterism).
|
|
94
|
+
META_DOCS = defined?(::Asterism::DOC_MAX) ? true : false
|
|
95
|
+
|
|
96
|
+
# The AI players (README "AI players"): the deciders there are keys for
|
|
97
|
+
# (the environment), what they cost counted in ai_usage_file (shared
|
|
98
|
+
# by the rooms' hosts), or the hub given (tests).
|
|
99
|
+
def ai_hub
|
|
100
|
+
return @config["ai_hub"] if @config["ai_hub"]
|
|
101
|
+
settings = AI::Settings.new
|
|
102
|
+
deciders = settings.deciders(log: @log)
|
|
103
|
+
path = @config["ai_usage_file"] || File.join(@config["state_dir"], "ai_usage.json")
|
|
104
|
+
own = deciders.empty? ? settings.kind_daily_usd : settings.kind_daily_usd(log: @log)
|
|
105
|
+
budget = AI::Budget.new(path, daily_usd: settings.daily_usd, kind_usd: own,
|
|
106
|
+
prices: ->(k) { settings.prices(k) })
|
|
107
|
+
unless deciders.empty?
|
|
108
|
+
@log.call("games: AI players: #{deciders.keys.join(', ')} (#{AI::Hub.limits_text(deciders.keys, budget)}, " \
|
|
109
|
+
"#{settings.calls_per_game} decisions a game)")
|
|
110
|
+
end
|
|
111
|
+
AI::Hub.new(deciders, budget, calls_per_game: settings.calls_per_game, threads: settings.threads, log: @log)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
attr_reader :ai
|
|
115
|
+
|
|
116
|
+
def stop!
|
|
117
|
+
@stop = true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run
|
|
121
|
+
until @stop
|
|
122
|
+
begin
|
|
123
|
+
::Asterism.connect(locator, **connect_options) do |net|
|
|
124
|
+
up(net)
|
|
125
|
+
serve(net)
|
|
126
|
+
end
|
|
127
|
+
rescue ::Asterism::Error => e
|
|
128
|
+
@log.call("games: #{e.class}: #{e.message}; again in #{RECONNECT_S} s")
|
|
129
|
+
end
|
|
130
|
+
down
|
|
131
|
+
sleep RECONNECT_S unless @stop
|
|
132
|
+
end
|
|
133
|
+
save_all
|
|
134
|
+
@ai&.stop
|
|
135
|
+
@log.call("games: largest message #{@stats['max_bytes']} B (#{@stats['max_what']})")
|
|
136
|
+
if @ai&.any?
|
|
137
|
+
n, avg, max = @ai.times
|
|
138
|
+
@log.call("games: AI players: #{@ai.stats.to_h.inspect}; #{n} decisions, #{avg} ms on average, #{max} ms at most; " \
|
|
139
|
+
"today #{@ai.budget.summary.inspect}")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# This host's node ID: the setting, else the certificate's name (the
|
|
144
|
+
# permanent tables, README "Permanent tables": the router's ACL knows
|
|
145
|
+
# the host by that name).
|
|
146
|
+
def node
|
|
147
|
+
@node ||= @config["node"] || (@config["credentials"] && ::Asterism::Credentials.load(@config["credentials"], warn: false).name)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def locator
|
|
151
|
+
@config["credentials"] ? @config["credentials_router"] : @config["router"]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def connect_options
|
|
155
|
+
opts = { node: node, app: @config["app"] }
|
|
156
|
+
opts[:credentials] = @config["credentials"] if @config["credentials"]
|
|
157
|
+
# A room's host (Rooms): its own certificate (or the slot's) and the
|
|
158
|
+
# room's key space, so every key is the room's (room/<slot>/asterism/...).
|
|
159
|
+
opts[:config] = { "namespace" => @config["namespace"] } if @config["namespace"]
|
|
160
|
+
opts
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The name its profile gives, or nil in a room (no name there: the
|
|
164
|
+
# console does not read the profiles in a room, and labels a room's
|
|
165
|
+
# host by its node ID and the room).
|
|
166
|
+
def profile_name
|
|
167
|
+
return nil if @config["namespace"]
|
|
168
|
+
@config["profile_name"] || PROFILE_NAME
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Connected: the profile, the tables, the receiving thread.
|
|
172
|
+
def up(net)
|
|
173
|
+
net.profile(name: profile_name) if profile_name
|
|
174
|
+
net.on_error { |e, where| @log.call("games: #{where}: #{e.class}: #{e.message}") }
|
|
175
|
+
follow_players(net)
|
|
176
|
+
@games.each_key do |name|
|
|
177
|
+
extra = META_DOCS && @docs[name] ? { doc: @docs[name] } : {}
|
|
178
|
+
net.expose(name, @remotes[name], methods: @methods[name], caller: :from, **extra)
|
|
179
|
+
end
|
|
180
|
+
@net = net
|
|
181
|
+
@connected = true
|
|
182
|
+
@tables_status = TablesStatus.new(net, node, @config["app"]) if tables_status?
|
|
183
|
+
write_status
|
|
184
|
+
@log.call("games: #{node}/#{@config['app']} up (#{@games.keys.join(', ')}) via " \
|
|
185
|
+
"#{locator || 'credentials'}#{" in #{@config['namespace']}" if @config['namespace']}")
|
|
186
|
+
net.start
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The host's own loop, until it stops or the connection is lost (the
|
|
190
|
+
# receiving thread ends; a client of a router that only reconnects
|
|
191
|
+
# stays, reconnecting?).
|
|
192
|
+
def serve(net)
|
|
193
|
+
until @stop
|
|
194
|
+
unless net.running?
|
|
195
|
+
@log.call("games: connection lost: #{net.lost_reason}")
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
::Asterism::LOCK.synchronize { step }
|
|
199
|
+
sleep LOOP_S
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The application a player's table app runs as (README "Who is there"):
|
|
204
|
+
# the host follows asterism/<node>/game, not the node, so a node that
|
|
205
|
+
# runs other applications too is away as soon as its table app closes.
|
|
206
|
+
PLAYER_APP = "game"
|
|
207
|
+
|
|
208
|
+
# Who is there: Asterism's on_app_join / on_app_leave (asterism after
|
|
209
|
+
# 0.7.1), else a liveliness watch of asterism/*/game on the session.
|
|
210
|
+
def follow_players(net)
|
|
211
|
+
app = @config["player_app"] || PLAYER_APP
|
|
212
|
+
if net.respond_to?(:on_app_join)
|
|
213
|
+
net.on_app_join { |n, a| node_joined(n) if a == app }
|
|
214
|
+
net.on_app_leave { |n, a| node_left(n) if a == app }
|
|
215
|
+
else
|
|
216
|
+
own = "asterism/#{node}/"
|
|
217
|
+
net.session.liveliness_watch("asterism/*/#{app}") do |key, alive|
|
|
218
|
+
next if key.start_with?(own)
|
|
219
|
+
n = key.split("/")[1]
|
|
220
|
+
alive ? node_joined(n) : node_left(n)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A node's table app left the network: its members are away at every
|
|
226
|
+
# table. (Called on the receiving thread, which holds Asterism::LOCK.)
|
|
227
|
+
def node_left(n, now = @clock.call)
|
|
228
|
+
@stats["left"] += 1 if @games.values.map { |g| g.node_left(n, now) }.any?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A node is on the network again: its members are back.
|
|
232
|
+
def node_joined(n, now = @clock.call)
|
|
233
|
+
@stats["back"] += 1 if @games.values.map { |g| g.node_joined(n, now) }.any?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def down
|
|
237
|
+
@net = nil
|
|
238
|
+
@tables_status = nil
|
|
239
|
+
@connected = false
|
|
240
|
+
write_status
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
STATUS_EVERY_MS = 2000
|
|
244
|
+
|
|
245
|
+
# What the room supervisor shows (Rooms#status): written to
|
|
246
|
+
# config["status_file"] every STATUS_EVERY_MS. Counts only, no names,
|
|
247
|
+
# no nodes, no tokens (the host's own node ID only, once known).
|
|
248
|
+
def status_now(now = @clock.call)
|
|
249
|
+
{ "pid" => Process.pid, "at" => Time.now.to_i, "node" => @node, "connected" => @connected ? true : false,
|
|
250
|
+
"reconnecting" => (@connected && ::Asterism.reconnecting?) ? true : false,
|
|
251
|
+
"tables" => @games.transform_values { |g| g.summary(now) } }.tap do |h|
|
|
252
|
+
h["ai"] = @ai.budget.summary if @ai&.any?
|
|
253
|
+
end
|
|
254
|
+
rescue StandardError
|
|
255
|
+
{ "pid" => Process.pid, "at" => Time.now.to_i, "connected" => false, "tables" => {} }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def write_status(now = @clock.call)
|
|
259
|
+
path = @config["status_file"] or return
|
|
260
|
+
@status_at = now
|
|
261
|
+
tmp = "#{path}.tmp"
|
|
262
|
+
File.write(tmp, JSON.generate(status_now(now)))
|
|
263
|
+
File.rename(tmp, path)
|
|
264
|
+
rescue SystemCallError => e
|
|
265
|
+
@log.call("games: status file: #{e.message}")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# One pass over the games (also used by the tests without a network).
|
|
269
|
+
def step
|
|
270
|
+
now = @clock.call
|
|
271
|
+
changed = false
|
|
272
|
+
@ai&.step(@games, now)
|
|
273
|
+
@games.each do |name, g|
|
|
274
|
+
g.tick(now)
|
|
275
|
+
g.take_told.each { |sub, value| put_told(name, sub, value) }
|
|
276
|
+
if g.dirty?
|
|
277
|
+
changed = true
|
|
278
|
+
g.save(now)
|
|
279
|
+
put_state(name, g, now)
|
|
280
|
+
elsif g.unsaved?
|
|
281
|
+
# A change only one member's own view shows: saved, not put.
|
|
282
|
+
g.save(now)
|
|
283
|
+
elsif now - @put_at.fetch(name, 0) >= PUT_EVERY_MS
|
|
284
|
+
put_state(name, g, now)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
write_status(now) if @config["status_file"] && now - @status_at.to_i >= STATUS_EVERY_MS
|
|
288
|
+
tell_tables(now, changed: changed)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------ @status
|
|
292
|
+
|
|
293
|
+
# How often the tables' counts are looked at for the status when no
|
|
294
|
+
# table changed (an away seat changes them with time alone).
|
|
295
|
+
TABLES_STATUS_EVERY_MS = 1000
|
|
296
|
+
# The counts the status gives of each table: those of Rooms#status,
|
|
297
|
+
# nothing that names a member (README "Status").
|
|
298
|
+
TABLES_STATUS_KEYS = %w[st members humans cpus ais away strokes].freeze
|
|
299
|
+
|
|
300
|
+
# Whether this host tells its tables' counts on
|
|
301
|
+
# asterism/<node>/<app>/@status (docs/protocol.md 8 of asterism): on a
|
|
302
|
+
# LAN and for the permanent tables, not in a room (the console's room
|
|
303
|
+
# rules let a room's host write only its own keys, and the verbatim
|
|
304
|
+
# chunk @status is not among them; nobody could read it there either).
|
|
305
|
+
def tables_status?
|
|
306
|
+
!@config["namespace"]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# {table => {"st", "members", "humans", "cpus", "away"}} and what a
|
|
310
|
+
# game adds (Game#status_extra: the mahjong tables' last result).
|
|
311
|
+
def tables_status(now = @clock.call)
|
|
312
|
+
st = @games.transform_values { |g| g.summary(now).slice(*TABLES_STATUS_KEYS).merge(g.status_extra(now)) }
|
|
313
|
+
# The AI players' totals of today (README "AI players"): money and
|
|
314
|
+
# counts only.
|
|
315
|
+
if @ai&.any?
|
|
316
|
+
b = @ai.budget
|
|
317
|
+
u = b.summary
|
|
318
|
+
st[AI_STATUS_KEY] = { "calls" => u["calls"], "usd" => u["usd"].round(2), "limit_usd" => u["limit_usd"] }
|
|
319
|
+
# Per kind (0.15.0), when some kind has a limit of its own: its
|
|
320
|
+
# money, the limit it stops at, whether that is its own, whether
|
|
321
|
+
# it has stopped for today.
|
|
322
|
+
unless b.kind_usd.empty?
|
|
323
|
+
st[AI_STATUS_KEY]["kinds"] = @ai.deciders.keys.to_h do |k|
|
|
324
|
+
[k, { "usd" => b.usd_today(k).round(4), "limit_usd" => b.limit_usd(k), "own" => b.own_limit?(k),
|
|
325
|
+
"over" => b.over?(k) }]
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
st
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
AI_STATUS_KEY = "ai_today"
|
|
333
|
+
|
|
334
|
+
# Hands the counts to the status after a change, and once a second;
|
|
335
|
+
# Asterism puts them only when they differ from the last put.
|
|
336
|
+
def tell_tables(now, changed: false)
|
|
337
|
+
ts = @tables_status
|
|
338
|
+
return unless ts && @net && !::Asterism.reconnecting?
|
|
339
|
+
return unless changed || now - @tables_at.to_i >= TABLES_STATUS_EVERY_MS
|
|
340
|
+
@tables_at = now
|
|
341
|
+
bytes = ts.set(tables_status(now))
|
|
342
|
+
note_size("put @status", bytes) if bytes
|
|
343
|
+
rescue ::Asterism::Error, ArgumentError => e
|
|
344
|
+
@log.call("games: status: #{e.message}")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def put_state(name, game, now)
|
|
348
|
+
@put_at[name] = now
|
|
349
|
+
return unless @net && !::Asterism.reconnecting?
|
|
350
|
+
key = "asterism/#{node}/#{@config['app']}/#{name}/state"
|
|
351
|
+
# What everybody may see, as a spectator sees it (README "Spectators").
|
|
352
|
+
payload = ::Asterism::Codec.pack(game.state_view(now))
|
|
353
|
+
note_size("put #{name}/state", key.bytesize + payload.bytesize)
|
|
354
|
+
@net.session.put(key, payload)
|
|
355
|
+
rescue ::Asterism::Error => e
|
|
356
|
+
@log.call("games: put #{name}: #{e.message}")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# What a game told (Game#tell): put on asterism/<node>/<app>/<table>/<sub>.
|
|
360
|
+
def put_told(name, sub, value)
|
|
361
|
+
return unless @net && !::Asterism.reconnecting?
|
|
362
|
+
key = "asterism/#{node}/#{@config['app']}/#{name}/#{sub}"
|
|
363
|
+
payload = ::Asterism::Codec.pack(value)
|
|
364
|
+
note_size("put #{name}/#{sub}", key.bytesize + payload.bytesize)
|
|
365
|
+
@stats["told"] += 1
|
|
366
|
+
@net.session.put(key, payload)
|
|
367
|
+
rescue ::Asterism::Error => e
|
|
368
|
+
@log.call("games: put #{name}/#{sub}: #{e.message}")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The largest message seen so far, key and payload together.
|
|
372
|
+
def note_size(what, bytes)
|
|
373
|
+
return unless bytes > @stats["max_bytes"]
|
|
374
|
+
@stats["max_bytes"] = bytes
|
|
375
|
+
@stats["max_what"] = what
|
|
376
|
+
@log.call("games: largest message so far: #{bytes} B (#{what})") if bytes > 1024
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def save_all
|
|
380
|
+
now = @clock.call
|
|
381
|
+
@games.each_value { |g| g.save(now, force: true) }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# The host's status: Asterism's (net.status, asterism 0.8.0, the
|
|
385
|
+
# version the Gemfile pins), which puts it when it changes and answers
|
|
386
|
+
# who asks. An asterism 0.7.1 (the gem's lowest) has no status: then
|
|
387
|
+
# the same thing by hand on the connection's session (a queryable on
|
|
388
|
+
# the key and a put when the counts change).
|
|
389
|
+
class TablesStatus
|
|
390
|
+
def initialize(net, node, app)
|
|
391
|
+
@net = net
|
|
392
|
+
@key = "asterism/#{node}/#{app}/@status"
|
|
393
|
+
return if @net.respond_to?(:status)
|
|
394
|
+
@bytes = nil
|
|
395
|
+
@qa = net.session.queryable(@key) { |q| q.reply(@key, @bytes) if @bytes }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Sets the status; returns the size of the put (key and payload) when
|
|
399
|
+
# it changed, nil when it did not.
|
|
400
|
+
def set(st)
|
|
401
|
+
bytes = ::Asterism::Codec.pack(st)
|
|
402
|
+
if @net.respond_to?(:status)
|
|
403
|
+
return nil if @net.status == st
|
|
404
|
+
@net.status(st)
|
|
405
|
+
else
|
|
406
|
+
return nil if bytes == @bytes
|
|
407
|
+
@bytes = bytes
|
|
408
|
+
@net.session.put(@key, bytes)
|
|
409
|
+
end
|
|
410
|
+
@key.bytesize + bytes.bytesize
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Measures every answer an exposed object gives (as the reply payload
|
|
415
|
+
# {"value" => ...} plus the call key, whose last chunk is the caller).
|
|
416
|
+
class SizeWatch
|
|
417
|
+
def initialize(obj, host, name)
|
|
418
|
+
@obj = obj
|
|
419
|
+
@host = host
|
|
420
|
+
@name = name
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def respond_to_missing?(name, priv = false)
|
|
424
|
+
@obj.respond_to?(name, priv) || super
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def method_missing(name, *args, **kw, &blk)
|
|
428
|
+
return super unless @obj.respond_to?(name)
|
|
429
|
+
v = @obj.public_send(name, *args, **kw, &blk)
|
|
430
|
+
key = "asterism/x/x/#{@name}/call/#{kw[:from]}"
|
|
431
|
+
@host.note_size("#{name} reply", ::Asterism::Codec.pack({ "value" => v }).bytesize + key.bytesize)
|
|
432
|
+
v
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsterismGames
|
|
4
|
+
module Othello
|
|
5
|
+
# The rules of othello (8x8): a board of 64 squares (0 empty, 1 black,
|
|
6
|
+
# 2 white; square = y * 8 + x), the legal moves, the discs a move
|
|
7
|
+
# turns, and a game as a history of moves. Squares are written "d3"
|
|
8
|
+
# (column a-h, row 1-8); "--" is a pass. Black moves first.
|
|
9
|
+
class Board
|
|
10
|
+
EMPTY = 0
|
|
11
|
+
BLACK = 1
|
|
12
|
+
WHITE = 2
|
|
13
|
+
DIRS = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]].freeze
|
|
14
|
+
COLS = "abcdefgh"
|
|
15
|
+
|
|
16
|
+
attr_reader :cells
|
|
17
|
+
|
|
18
|
+
def initialize(cells = nil)
|
|
19
|
+
@cells = cells ? cells.dup : Array.new(64, EMPTY)
|
|
20
|
+
return if cells
|
|
21
|
+
@cells[27] = @cells[36] = WHITE # d4, e5
|
|
22
|
+
@cells[28] = @cells[35] = BLACK # e4, d5
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.code(sq)
|
|
26
|
+
sq.nil? || sq.negative? ? "--" : "#{COLS[sq % 8]}#{sq / 8 + 1}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# "d3" -> 19, "--" -> -1, anything else -> nil.
|
|
30
|
+
def self.square(code)
|
|
31
|
+
s = code.to_s.strip.downcase
|
|
32
|
+
return -1 if s == "--"
|
|
33
|
+
return nil unless s.match?(/\A[a-h][1-8]\z/)
|
|
34
|
+
(s[1].to_i - 1) * 8 + COLS.index(s[0])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def [](sq) = @cells[sq]
|
|
38
|
+
|
|
39
|
+
def count(color) = @cells.count(color)
|
|
40
|
+
|
|
41
|
+
# The squares a disc of color at sq turns (empty when the move is not legal).
|
|
42
|
+
def flips(sq, color)
|
|
43
|
+
return [] unless sq.between?(0, 63) && @cells[sq] == EMPTY
|
|
44
|
+
other = 3 - color
|
|
45
|
+
x0 = sq % 8
|
|
46
|
+
y0 = sq / 8
|
|
47
|
+
DIRS.flat_map do |dx, dy|
|
|
48
|
+
line = []
|
|
49
|
+
x = x0 + dx
|
|
50
|
+
y = y0 + dy
|
|
51
|
+
while x.between?(0, 7) && y.between?(0, 7) && @cells[y * 8 + x] == other
|
|
52
|
+
line << y * 8 + x
|
|
53
|
+
x += dx
|
|
54
|
+
y += dy
|
|
55
|
+
end
|
|
56
|
+
x.between?(0, 7) && y.between?(0, 7) && @cells[y * 8 + x] == color ? line : []
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def legal?(sq, color) = !flips(sq, color).empty?
|
|
61
|
+
|
|
62
|
+
def moves(color) = (0..63).select { |sq| legal?(sq, color) }
|
|
63
|
+
|
|
64
|
+
def any_move?(color) = (0..63).any? { |sq| legal?(sq, color) }
|
|
65
|
+
|
|
66
|
+
# Plays a legal move: the disc and the turned ones. Returns the turned squares.
|
|
67
|
+
def place(sq, color)
|
|
68
|
+
turned = flips(sq, color)
|
|
69
|
+
raise ArgumentError, "not legal: #{Board.code(sq)}" if turned.empty?
|
|
70
|
+
@cells[sq] = color
|
|
71
|
+
turned.each { |t| @cells[t] = color }
|
|
72
|
+
turned
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 64 characters: "." empty, "B" black, "W" white (a1 first, row by row).
|
|
76
|
+
def to_s = @cells.map { |c| ".BW"[c] }.join
|
|
77
|
+
|
|
78
|
+
# The board's eight rows, as text.
|
|
79
|
+
def rows = to_s.scan(/.{8}/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A match: the board, whose turn it is, the history ("d3c5--...") and
|
|
83
|
+
# whether it is over. A side with no legal move passes by itself; when
|
|
84
|
+
# neither side can move, the game is over.
|
|
85
|
+
class Match
|
|
86
|
+
attr_reader :board, :turn, :hist
|
|
87
|
+
|
|
88
|
+
def initialize
|
|
89
|
+
@board = Board.new
|
|
90
|
+
@turn = Board::BLACK
|
|
91
|
+
@hist = +""
|
|
92
|
+
@over = false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The match a history replays to, or nil when a move in it is not legal.
|
|
96
|
+
def self.replay(hist)
|
|
97
|
+
g = new
|
|
98
|
+
hist.to_s.scan(/../).each do |c|
|
|
99
|
+
next if c == "--" # the passes are played by themselves
|
|
100
|
+
sq = Board.square(c)
|
|
101
|
+
return nil unless sq && sq >= 0 && g.play(sq)
|
|
102
|
+
end
|
|
103
|
+
g
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def over? = @over
|
|
107
|
+
|
|
108
|
+
def n = @hist.size / 2
|
|
109
|
+
|
|
110
|
+
def last = @hist.empty? ? nil : @hist[-2, 2]
|
|
111
|
+
|
|
112
|
+
def moves = @over ? [] : @board.moves(@turn)
|
|
113
|
+
|
|
114
|
+
# Plays sq for the side to move when it is legal (and the passes that
|
|
115
|
+
# follow); returns whether it did.
|
|
116
|
+
def play(sq)
|
|
117
|
+
return false if @over || !@board.legal?(sq, @turn)
|
|
118
|
+
@board.place(sq, @turn)
|
|
119
|
+
@hist << Board.code(sq)
|
|
120
|
+
@turn = 3 - @turn
|
|
121
|
+
unless @board.any_move?(@turn)
|
|
122
|
+
if @board.any_move?(3 - @turn)
|
|
123
|
+
@hist << "--"
|
|
124
|
+
@turn = 3 - @turn
|
|
125
|
+
else
|
|
126
|
+
@over = true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The winner by the discs: BLACK, WHITE or 0 (a draw).
|
|
133
|
+
def winner
|
|
134
|
+
b = @board.count(Board::BLACK)
|
|
135
|
+
w = @board.count(Board::WHITE)
|
|
136
|
+
if b > w then Board::BLACK elsif b < w then Board::WHITE else 0 end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The CPU: the square with the best weight (corners first, the squares
|
|
141
|
+
# next to an empty corner last), then the most discs turned; ties go to
|
|
142
|
+
# the first square.
|
|
143
|
+
module Cpu
|
|
144
|
+
WEIGHTS = [
|
|
145
|
+
100, -20, 10, 5, 5, 10, -20, 100,
|
|
146
|
+
-20, -50, -2, -2, -2, -2, -50, -20,
|
|
147
|
+
10, -2, 1, 1, 1, 1, -2, 10,
|
|
148
|
+
5, -2, 1, 0, 0, 1, -2, 5,
|
|
149
|
+
5, -2, 1, 0, 0, 1, -2, 5,
|
|
150
|
+
10, -2, 1, 1, 1, 1, -2, 10,
|
|
151
|
+
-20, -50, -2, -2, -2, -2, -50, -20,
|
|
152
|
+
100, -20, 10, 5, 5, 10, -20, 100
|
|
153
|
+
].freeze
|
|
154
|
+
|
|
155
|
+
# The square the CPU plays for color, or nil when it has none.
|
|
156
|
+
def self.choose(board, color)
|
|
157
|
+
board.moves(color).max_by { |sq| [WEIGHTS[sq], board.flips(sq, color).size, -sq] }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../remote"
|
|
4
|
+
|
|
5
|
+
module AsterismGames
|
|
6
|
+
module Othello
|
|
7
|
+
# The othello table as an Asterism object (<node>/<app>/<table>), on
|
|
8
|
+
# the framework's Remote (join, mine, leave). Every method takes the
|
|
9
|
+
# caller's node ID as from: (Asterism fills it in).
|
|
10
|
+
#
|
|
11
|
+
# join(name) -> {"token", "seat", "owner"} or {"error" => why}
|
|
12
|
+
# mine -> the same for a node that joined, or nil
|
|
13
|
+
# sit(token, seat) -> "ok" or why not (seat -1: the free seat)
|
|
14
|
+
# stand(token) -> "ok" or why not
|
|
15
|
+
# cpu(token, seat, on) -> "ok" or why not (the owner; seat -1: every seat;
|
|
16
|
+
# on: true, false or an AI player of the view's "ak")
|
|
17
|
+
# start(token) -> "ok" or why not (the owner, both seats taken)
|
|
18
|
+
# move(token, n, square) -> "ok" or why not ("token", "stale", "illegal",
|
|
19
|
+
# "not your turn", "not playing", "not seated");
|
|
20
|
+
# n: the view's "n", square: "d3"
|
|
21
|
+
# resign(token) -> "ok" or why not
|
|
22
|
+
# view(token) -> the table's view and "me" (the member's seat), or {"error" => "token"}
|
|
23
|
+
# table -> the table's view (what the state key has)
|
|
24
|
+
# leave(token) -> true / false
|
|
25
|
+
class Remote < AsterismGames::Remote
|
|
26
|
+
METHODS = exposes(join: 1, mine: 0, sit: 2, stand: 1, cpu: 3, start: 1, move: 3, resign: 1, view: 1, table: 0,
|
|
27
|
+
leave: 1)
|
|
28
|
+
object_doc "An othello table (two seats). join, sit, then view / move in a loop."
|
|
29
|
+
docs sit: [%w[token seat], "Takes seat 0 or 1 (-1: the free one), in the lobby. \"ok\" or why not."],
|
|
30
|
+
stand: [%w[token], "Gives the seat up, in the lobby."],
|
|
31
|
+
cpu: [%w[token seat on], "The owner (the first to sit): on true puts a CPU in the empty seat (-1: every " \
|
|
32
|
+
"empty seat), false takes it out; on \"jev\" (one of the view's ak) puts that AI " \
|
|
33
|
+
"player there, or turns the seat's CPU into it (true turns it back)."],
|
|
34
|
+
start: [%w[token], "The owner starts the game once both seats are taken (the colours are drawn)."],
|
|
35
|
+
move: [%w[token n square], "Plays square (\"d3\": column a-h, row 1-8) as the seat to move. n is the " \
|
|
36
|
+
"view's \"n\" (moves so far), so a move meant for an older board is refused " \
|
|
37
|
+
"(\"stale\"). \"ok\" or why not."],
|
|
38
|
+
resign: [%w[token], "Resigns the game."],
|
|
39
|
+
view: [%w[token], "The table: st (wait, play, over), nm names, sk seats (0 empty, 1 member, 2 CPU), " \
|
|
40
|
+
"bd the board (64 of . B W, a1 b1 .. h8), tn the seat to move (-1), n moves so far, " \
|
|
41
|
+
"lg the legal squares of the side to move (\"d3c4\"), cnt [black, white], " \
|
|
42
|
+
"res the result, me your seat, ai the seats' AI players (\"jev\"), ak the AI players the owner may seat. Seat 0 is black (B), seat 1 white (W): the seats are drawn at the start."],
|
|
43
|
+
table: [[], "The table's view without me (what the state key has)."]
|
|
44
|
+
|
|
45
|
+
def sit(token, seat, from: nil)
|
|
46
|
+
mark(token, from)
|
|
47
|
+
@game.sit(token, seat.to_i, now)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stand(token, from: nil)
|
|
51
|
+
mark(token, from)
|
|
52
|
+
@game.stand(token)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cpu(token, seat, on, from: nil)
|
|
56
|
+
mark(token, from)
|
|
57
|
+
@game.cpu(token, seat.to_i, on.is_a?(String) ? on : (on ? true : false))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def start(token, from: nil)
|
|
61
|
+
mark(token, from)
|
|
62
|
+
@game.start(token, now)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def move(token, n, square, from: nil)
|
|
66
|
+
mark(token, from)
|
|
67
|
+
@game.move(token, n, square.to_s, now)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resign(token, from: nil)
|
|
71
|
+
mark(token, from)
|
|
72
|
+
@game.resign(token, now)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def view(token, from: nil)
|
|
76
|
+
mark(token, from)
|
|
77
|
+
@game.member_view(token, now) || { "error" => "token" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def table(from: nil)
|
|
81
|
+
@game.public_view(now)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|