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