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,533 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../game"
5
+ require_relative "../ai"
6
+ require_relative "board"
7
+
8
+ module AsterismGames
9
+ module Othello
10
+ # One othello table (README "The othello table"): two seats and the
11
+ # spectators. People join (a member with a token) and wait in the lobby
12
+ # ("wait"); the first member who sits is the owner, who may put a CPU in
13
+ # the empty seat and starts the game once both seats are taken. The
14
+ # colours are drawn at the start: seat 0 is black and moves first. A
15
+ # side with no legal move passes by itself; when neither side can move
16
+ # the discs are counted. After the game ("over") the board and the
17
+ # result stay until someone sits, stands or the owner starts again.
18
+ #
19
+ # Nothing is hidden in othello: every member sees the same view (the
20
+ # public one), and the host puts it on the state key.
21
+ #
22
+ # Time (turn_ms, the setting turn_seconds): 0 is no limit. Over 0, a
23
+ # human whose time for a move runs out has that move played by the CPU.
24
+ #
25
+ # AI players (README "AI players", games 0.17.0): the owner may put
26
+ # one the host offers (Jev only, AI::GAME_KINDS) in a seat as it puts a
27
+ # CPU there; its moves are asked of the host (ai_requests) and played
28
+ # when the answer comes (ai_answer), the CPU's when it does not come
29
+ # in ai_turn_ms or fails.
30
+ class Table < AsterismGames::Game
31
+ register "othello"
32
+ tables "othello1"
33
+ screens "/app/game/othello/othello.app.rb"
34
+ settings file: { "turn_seconds" => ["turn_ms", 1000], "cpu_ms" => ["cpu_ms", 1],
35
+ "away_seconds" => ["away_ms", 1000], "spectators" => ["spectators", 1],
36
+ "ai_turn_seconds" => ["ai_turn_ms", 1000] },
37
+ env: { "OTHELLO_TURN_SECONDS" => ["turn_ms", 1000], "OTHELLO_CPU_MS" => ["cpu_ms", 1],
38
+ "OTHELLO_AWAY_SECONDS" => ["away_ms", 1000], "OTHELLO_SPECTATORS" => ["spectators", 1],
39
+ "OTHELLO_AI_TURN_SECONDS" => ["ai_turn_ms", 1000] }
40
+
41
+ SAVE_FORMAT = 1
42
+ SEATS = 2
43
+ DEFAULTS = AsterismGames::Game::DEFAULTS.merge(
44
+ "turn_ms" => 0, # a human's time for a move (0: none); then the CPU plays it
45
+ "cpu_ms" => 700, # a CPU waits this long before it plays
46
+ "ai_turn_ms" => 30_000, # an AI player's time for a move (README "AI players"); then the CPU plays it
47
+ "spectators" => 14 # members without a seat at most (a room lets 16 pages in: 2 seats + 14)
48
+ ).freeze
49
+
50
+ # The rules for an AI player (the request's "instructions") and how
51
+ # to read its "view" (look).
52
+ DESCRIPTION = <<~TEXT
53
+ Othello (reversi) on an 8x8 board. Columns are a-h from left to right, rows 1-8 from top to bottom; a square
54
+ is written "d3". Black (B) moves first. A move puts a disc of your colour on an empty square so that it closes
55
+ one or more straight lines (across, up and down, or diagonal) of the other colour's discs between it and
56
+ another disc of yours; every closed line is turned to your colour. A move must turn at least one disc. A side
57
+ with no legal move passes by itself; when neither side can move the game ends, and the side with more discs
58
+ wins. Corners can never be turned back; the squares next to an empty corner often give it away.
59
+ The view: "you" is your colour ("B" or "W"), "board" the eight rows from row 1 to row 8 (each 8 characters
60
+ from column a to h: "." empty, "B" black, "W" white), "discs" the count of each colour, "n" the moves so far,
61
+ "last" the last move ("--" a pass), and "moves" the legal moves, each with its number "n", its "square" and
62
+ the discs it "turns". Answer with the number of one of the moves.
63
+ TEXT
64
+
65
+ attr_reader :status, :game, :seats, :stats, :ai_kinds
66
+ attr_accessor :ai_calls_per_game
67
+
68
+ def initialize(name, opts = {}, store: nil, log: nil)
69
+ super
70
+ @status = "wait"
71
+ @seats = [nil] * SEATS
72
+ @owner = nil
73
+ @sit_count = 0
74
+ @game = nil
75
+ @result = nil
76
+ @deadline = nil
77
+ @cpu_at = nil
78
+ @stats = Hash.new(0)
79
+ @ai_kinds = [] # the AI players the host can offer now (the lobby view's "ak")
80
+ @ai_calls_per_game = nil
81
+ @ai_calls = 0 # decisions asked this game
82
+ @ai_want = nil # the decision asked of the AI seat to move: {seat:, key:, kind:, at:, sent:}
83
+ @ai_got = nil # [key, move number or nil] come back
84
+ load_saved(0)
85
+ end
86
+
87
+ # ------------------------------------------------------------ members and seats
88
+
89
+ def member_reply(node)
90
+ { "token" => @members[node]["token"], "seat" => seat_index(node) || -1, "owner" => @owner == node }
91
+ end
92
+
93
+ def join_refusal(node)
94
+ super || (spectators_full? ? "spectators" : nil)
95
+ end
96
+
97
+ def renamed(node, name)
98
+ i = seat_index(node)
99
+ @seats[i]["name"] = name if i
100
+ end
101
+
102
+ # In the lobby a member's seat empties; during a game the CPU takes it.
103
+ def forget(node)
104
+ i = seat_index(node)
105
+ @seats[i] = playing? ? cpu_seat : nil if i
106
+ super
107
+ pass_owner if @owner == node
108
+ schedule(@now) if playing?
109
+ end
110
+
111
+ def keep_member?(node) = !seat_index(node).nil?
112
+
113
+ def spectators_full?
114
+ watching = @members.keys.count { |n| seat_index(n).nil? }
115
+ free = playing? ? 0 : @seats.count(&:nil?)
116
+ watching >= @opts["spectators"].to_i + free
117
+ end
118
+
119
+ def seat_index(node)
120
+ @seats.index { |s| s && s["kind"] == "human" && s["node"] == node }
121
+ end
122
+
123
+ def playing? = @status == "play"
124
+
125
+ # The member sits (seat -1: the free one, else the CPU's). Returns "ok" or why not.
126
+ def sit(token, seat, now)
127
+ node = node_of(token) or return "token"
128
+ return "not waiting" if playing?
129
+ seat = seat.to_i
130
+ if seat.negative?
131
+ return "ok" if seat_index(node)
132
+ seat = @seats.index(nil) || @seats.index { |s| s["kind"] == "cpu" }
133
+ return "taken" unless seat
134
+ end
135
+ return "no seat" unless (0...SEATS).cover?(seat)
136
+ cur = seat_index(node)
137
+ return "ok" if cur == seat
138
+ return "taken" if @seats[seat] && @seats[seat]["kind"] == "human"
139
+ m = @members[node]
140
+ sat = cur ? @seats[cur]["sat"] : (@sit_count += 1)
141
+ @seats[cur] = nil if cur
142
+ @seats[seat] = { "kind" => "human", "node" => node, "token" => m["token"], "name" => m["name"], "sat" => sat }
143
+ @owner ||= node
144
+ m["seen"] = now
145
+ back_to_lobby
146
+ "ok"
147
+ end
148
+
149
+ def stand(token)
150
+ node = node_of(token) or return "token"
151
+ return "not waiting" if playing?
152
+ i = seat_index(node) or return "not seated"
153
+ @seats[i] = nil
154
+ pass_owner if @owner == node
155
+ back_to_lobby
156
+ "ok"
157
+ end
158
+
159
+ # The owner puts a CPU in an empty seat (on) or takes it out (off,
160
+ # an AI player too); seat -1: every empty seat (on) or every CPU and
161
+ # AI player (off). on may name an AI player the host offers ("jev":
162
+ # the lobby view's "ak", README "AI players"): that one goes in
163
+ # instead of a CPU. On one seat that holds a CPU or an AI player of
164
+ # another kind, on turns it into the one asked for (a CPU into Jev,
165
+ # Jev back into a CPU with true); a refused AI ("no ai") leaves it as
166
+ # it was. Seat -1 with on never changes a seat that is taken.
167
+ def cpu(token, seat, on)
168
+ node = node_of(token) or return "token"
169
+ return "not waiting" if playing?
170
+ return "not owner" unless @owner == node
171
+ ai = on.is_a?(String) && !%w[true 1 false].include?(on) ? on : nil
172
+ return "no ai" if ai && !@ai_kinds.include?(ai)
173
+ on = false if on == "false"
174
+ seat = seat.to_i
175
+ targets = seat.negative? ? (0...SEATS).to_a : [seat]
176
+ return "no seat" unless targets.all? { |i| (0...SEATS).cover?(i) }
177
+ n = 0
178
+ targets.each do |i|
179
+ s = @seats[i]
180
+ if on && (s.nil? || (!seat.negative? && s["kind"] == "cpu" && s["ai"] != ai))
181
+ @seats[i] = cpu_seat
182
+ @seats[i]["ai"] = ai if ai
183
+ n += 1
184
+ elsif !on && s && s["kind"] == "cpu"
185
+ @seats[i] = nil
186
+ n += 1
187
+ end
188
+ end
189
+ return "no change" if n.zero?
190
+ name_cpus
191
+ back_to_lobby
192
+ "ok"
193
+ end
194
+
195
+ # ------------------------------------------------------------ the game
196
+
197
+ # The owner starts: both seats taken, at least one human. The colours
198
+ # are drawn.
199
+ def start(token, now)
200
+ node = node_of(token) or return "token"
201
+ return "not waiting" if playing?
202
+ return "not owner" unless @owner == node
203
+ return "seats" unless @seats.all?
204
+ return "no human" if @seats.none? { |s| s["kind"] == "human" }
205
+ @seats = @seats.shuffle(random: Random.new(SecureRandom.random_number(2**32)))
206
+ @game = Othello::Match.new
207
+ @result = nil
208
+ @ai_calls = 0
209
+ @status = "play"
210
+ @log&.call("othello: #{@name}: game started (#{@seats.map { |s| s['name'] }.join(' (black), ')} (white))")
211
+ schedule(now)
212
+ changed
213
+ "ok"
214
+ end
215
+
216
+ # The member plays square (e.g. "d3") as move number n (the moves so
217
+ # far, passes included: a move sent for an older board is "stale").
218
+ def move(token, n, square, now)
219
+ node = node_of(token) or return "token"
220
+ return "not playing" unless playing?
221
+ i = seat_index(node) or return "not seated"
222
+ return "not your turn" unless i == @game.turn - 1
223
+ return "stale" unless n.to_i == @game.n
224
+ sq = Board.square(square)
225
+ return "illegal" unless sq && sq >= 0 && @game.play(sq)
226
+ after_move(now)
227
+ "ok"
228
+ end
229
+
230
+ # The member gives up its game.
231
+ def resign(token, now)
232
+ node = node_of(token) or return "token"
233
+ return "not playing" unless playing?
234
+ i = seat_index(node) or return "not seated"
235
+ finish(1 - i, "resign")
236
+ "ok"
237
+ end
238
+
239
+ def tick(now)
240
+ super
241
+ expire_members(now)
242
+ return unless playing?
243
+ seat = @game.turn - 1
244
+ if @ai_want && @ai_want[:seat] == seat
245
+ ai_turn(seat, now)
246
+ elsif @cpu_at && now >= @cpu_at
247
+ play_for(seat, now, "cpu")
248
+ elsif @deadline && now >= @deadline
249
+ play_for(seat, now, "time")
250
+ end
251
+ end
252
+
253
+ # The CPU plays the move of seat (a CPU's turn, a human's or an AI
254
+ # player's time ran out, an AI player's answer failed).
255
+ def play_for(seat, now, why)
256
+ sq = Cpu.choose(@game.board, seat + 1)
257
+ case why
258
+ when "time"
259
+ @log&.call("othello: #{@name}: time is up for seat #{seat}, the CPU plays #{Board.code(sq)}")
260
+ when "ai time"
261
+ @log&.call("othello: #{@name}: AI seat #{seat} out of time: the CPU plays #{Board.code(sq)}")
262
+ end
263
+ @game.play(sq)
264
+ after_move(now)
265
+ end
266
+
267
+ # ------------------------------------------------------------ AI players
268
+ #
269
+ # An AI seat (README "AI players") is a CPU seat with "ai" => its kind.
270
+ # On its turn the table asks the host (ai_requests: the seat's look)
271
+ # and waits for the answer (ai_answer) until ai_turn_ms; a move that
272
+ # does not come in time, or fails, is the CPU's. A turn with one legal
273
+ # move is not asked (it is played as the CPU plays). The host turns
274
+ # the AI seats into CPUs when the day's money is spent, and the table
275
+ # does when the game has asked ai_calls_per_game decisions.
276
+
277
+ def ai_seat?(seat)
278
+ s = @seats[seat]
279
+ s && s["kind"] == "cpu" && s["ai"] ? true : false
280
+ end
281
+
282
+ # The AI players the host can offer now (the lobby view's "ak").
283
+ def ai_kinds=(kinds)
284
+ kinds = Array(kinds).map(&:to_s)
285
+ return if kinds == @ai_kinds
286
+ @ai_kinds = kinds
287
+ changed unless playing?
288
+ end
289
+
290
+ # The decision to ask, once: [{seat:, key:, kind:, until:, request:}].
291
+ def ai_requests(now)
292
+ w = @ai_want
293
+ return [] if w.nil? || w[:sent]
294
+ if @ai_calls_per_game && @ai_calls >= @ai_calls_per_game
295
+ ai_off!("#{@ai_calls_per_game} decisions in this game")
296
+ return []
297
+ end
298
+ w[:sent] = true
299
+ @ai_calls += 1
300
+ @stats["ai_asked"] += 1
301
+ [{ seat: w[:seat], key: w[:key], kind: w[:kind], until: w[:at],
302
+ request: { "game" => "othello", "table" => @name, "seat" => w[:seat],
303
+ "instructions" => DESCRIPTION, "view" => look(w[:seat]) } }]
304
+ end
305
+
306
+ # An answer came: the number of one of the moves the request listed,
307
+ # or nil (the decider failed). Played at once.
308
+ def ai_answer(seat, key, n, now = @now)
309
+ w = @ai_want
310
+ return false unless w && w[:seat] == seat && w[:key] == key
311
+ @ai_got = [key, n]
312
+ tick(now)
313
+ true
314
+ end
315
+
316
+ # The AI seats become CPUs (the day's money, or the game's decisions,
317
+ # are used up): all of them, or those of one kind.
318
+ def ai_off!(why, kind: nil)
319
+ seats = (0...SEATS).select { |i| ai_seat?(i) && (kind.nil? || @seats[i]["ai"] == kind) }
320
+ return false if seats.empty?
321
+ seats.each { |i| @seats[i].delete("ai") }
322
+ @stats["ai_off"] += 1
323
+ @log&.call("othello: #{@name}: AI seats #{seats.inspect} are CPUs now (#{why})")
324
+ name_cpus
325
+ schedule(@now) if playing? && @ai_want && seats.include?(@ai_want[:seat])
326
+ changed
327
+ true
328
+ end
329
+
330
+ # What an AI seat sees (the request's "view", DESCRIPTION): the
331
+ # board, its colour and the legal moves, numbered.
332
+ def look(seat)
333
+ color = seat + 1
334
+ b = @game.board
335
+ moves = @game.moves.each_with_index.map do |sq, i|
336
+ t = b.flips(sq, color).size
337
+ { "n" => i, "square" => Board.code(sq), "turns" => t,
338
+ "text" => "#{Board.code(sq)} (turns #{t} disc#{'s' unless t == 1})" }
339
+ end
340
+ { "you" => ".BW"[color], "board" => b.rows, "discs" => { "B" => b.count(Board::BLACK), "W" => b.count(Board::WHITE) },
341
+ "n" => @game.n, "last" => @game.last, "moves" => moves }
342
+ end
343
+
344
+ # The AI seat to move: its answer, the CPU when it failed or its
345
+ # time ran out.
346
+ def ai_turn(seat, now)
347
+ w = @ai_want
348
+ got = @ai_got
349
+ if got && got[0] == w[:key]
350
+ @ai_want = @ai_got = nil
351
+ @deadline = nil
352
+ n = got[1]
353
+ moves = @game.moves
354
+ sq = n.is_a?(Integer) && n >= 0 ? moves[n] : nil
355
+ if sq
356
+ @stats["ai_moves"] += 1
357
+ @game.play(sq)
358
+ return after_move(now)
359
+ end
360
+ @stats["ai_failed"] += 1
361
+ return play_for(seat, now, "ai failed")
362
+ end
363
+ return if now < w[:at]
364
+ @stats["ai_timeouts"] += 1
365
+ @ai_want = @ai_got = nil
366
+ @deadline = nil
367
+ play_for(seat, now, "ai time")
368
+ end
369
+
370
+ def after_move(now)
371
+ if @game.over?
372
+ w = @game.winner
373
+ finish(w.zero? ? -1 : w - 1, "count")
374
+ else
375
+ schedule(now)
376
+ changed
377
+ end
378
+ end
379
+
380
+ # The next move's clock: a CPU waits cpu_ms, a human has turn_ms (0:
381
+ # no limit), an AI player ai_turn_ms (its move is asked; one legal
382
+ # move is played as the CPU's).
383
+ def schedule(now)
384
+ @cpu_at = @deadline = nil
385
+ @ai_want = @ai_got = nil
386
+ return unless playing?
387
+ seat = @game.turn - 1
388
+ s = @seats[seat]
389
+ if s["kind"] == "cpu" && s["ai"] && @game.moves.size > 1
390
+ @deadline = now + @opts["ai_turn_ms"].to_i
391
+ @ai_want = { seat: seat, key: "m#{@game.n}", kind: s["ai"], at: @deadline, sent: false }
392
+ elsif s["kind"] == "cpu"
393
+ @cpu_at = now + @opts["cpu_ms"].to_i
394
+ elsif @opts["turn_ms"].to_i.positive?
395
+ @deadline = now + @opts["turn_ms"].to_i
396
+ end
397
+ end
398
+
399
+ # winner: the seat that won (-1: a draw); why: "count" or "resign".
400
+ def finish(winner, why)
401
+ @result = { "w" => winner, "why" => why, "b" => @game.board.count(Board::BLACK),
402
+ "wc" => @game.board.count(Board::WHITE) }
403
+ @status = "over"
404
+ @cpu_at = @deadline = nil
405
+ @ai_want = @ai_got = nil
406
+ @log&.call("othello: #{@name}: game over (#{why}, #{@result['b']}-#{@result['wc']}, " \
407
+ "#{winner.negative? ? 'a draw' : "seat #{winner} wins"})")
408
+ changed
409
+ end
410
+
411
+ def back_to_lobby
412
+ @status = "wait"
413
+ @game = nil
414
+ @result = nil
415
+ changed
416
+ end
417
+
418
+ # ------------------------------------------------------------ views
419
+
420
+ # Everybody's view (members, spectators, the state key):
421
+ # st "wait" / "play" / "over" nm the seats' names (seat 0 black)
422
+ # sk 0 empty, 1 human, 2 CPU (an AI player too)
423
+ # aw bits: a seated human is away
424
+ # ai the seats' AI kinds ("" the others; only when there is one)
425
+ # ak the AI players the owner may seat (out of a game, when the host offers some)
426
+ # ow the owner's seat (-1) mb [name, seat, flags (1 away, 2 owner)] in the order they joined
427
+ # ts the seconds for a move (0: no limit)
428
+ # bd the board (64 of . B W, a1 first) tn the seat to move (-1)
429
+ # n moves so far (passes too) last the last move ("--" a pass)
430
+ # lg the legal squares of the side to move ("d3c4...")
431
+ # cnt [black, white] dl ms left of a human's move (turn_ms over 0) or an AI player's
432
+ # res {"w" => seat that won (-1 a draw), "why" => "count" / "resign", "b", "wc"}
433
+ def public_view(now)
434
+ v = { "v" => @ver, "t" => @name, "st" => @status,
435
+ "nm" => @seats.map { |s| s ? s["name"].to_s : "" },
436
+ "sk" => @seats.map { |s| s.nil? ? 0 : (s["kind"] == "human" ? 1 : 2) },
437
+ "aw" => (0...SEATS).sum { |i| human_away?(i) ? 1 << i : 0 },
438
+ "ow" => (@owner && seat_index(@owner)) || -1,
439
+ "mb" => member_list,
440
+ "ts" => @opts["turn_ms"].to_i / 1000 }
441
+ v["ai"] = @seats.map { |s| s && s["ai"] ? s["ai"] : "" } if @seats.any? { |s| s && s["ai"] }
442
+ v["ak"] = @ai_kinds if !playing? && !@ai_kinds.empty?
443
+ if @game
444
+ v["bd"] = @game.board.to_s
445
+ v["tn"] = playing? ? @game.turn - 1 : -1
446
+ v["n"] = @game.n
447
+ v["last"] = @game.last
448
+ v["lg"] = @game.moves.map { |sq| Board.code(sq) }.join
449
+ v["cnt"] = [@game.board.count(Board::BLACK), @game.board.count(Board::WHITE)]
450
+ end
451
+ v["dl"] = [@deadline - now, 0].max if @deadline
452
+ v["res"] = @result if @result
453
+ v
454
+ end
455
+
456
+ # A member's view: the public one and its own seat ("me", -1: none).
457
+ def member_view(token, now)
458
+ node = node_of(token) or return nil
459
+ public_view(now).merge("me" => seat_index(node) || -1)
460
+ end
461
+
462
+ def member_list
463
+ @members.sort_by { |_n, m| m["at"].to_i }.map do |node, m|
464
+ [m["name"].to_s, seat_index(node) || -1, (member_away?(node) ? 1 : 0) | (@owner == node ? 2 : 0)]
465
+ end
466
+ end
467
+
468
+ def human_away?(i)
469
+ s = @seats[i]
470
+ s && s["kind"] == "human" && member_away?(s["node"])
471
+ end
472
+
473
+ def summary(now)
474
+ humans = @seats.count { |s| s && s["kind"] == "human" }
475
+ { "st" => @status, "members" => @members.size, "humans" => humans,
476
+ "cpus" => @seats.count { |s| s && s["kind"] == "cpu" },
477
+ "ais" => @seats.count { |s| s && s["ai"] },
478
+ "away" => (0...SEATS).count { |i| human_away?(i) }, "n" => @game&.n }
479
+ end
480
+
481
+ # ------------------------------------------------------------ saving
482
+
483
+ def to_saved(now)
484
+ super.merge("counts" => [@join_count, @sit_count], "status" => @status, "seats" => @seats, "owner" => @owner,
485
+ "hist" => @game&.hist, "result" => @result,
486
+ "deadline_left" => @deadline && [@deadline - now, 0].max,
487
+ "cpu_left" => @cpu_at && [@cpu_at - now, 0].max)
488
+ end
489
+
490
+ def restore(data, now)
491
+ super
492
+ @join_count, @sit_count = Array(data["counts"]).map(&:to_i)
493
+ @sit_count ||= 0
494
+ @status = data["status"].to_s
495
+ @seats = Array(data["seats"]).first(SEATS)
496
+ @seats << nil while @seats.size < SEATS
497
+ @owner = data["owner"]
498
+ @game = data["hist"] && Othello::Match.replay(data["hist"])
499
+ @status = "wait" if @game.nil? && @status != "wait"
500
+ @result = data["result"]
501
+ @log&.call("othello: #{@name}: resumed (#{@status}, #{@game ? @game.n : 0} moves, #{@members.size} members)")
502
+ end
503
+
504
+ def resume_clock(now, data = saved)
505
+ return unless data
506
+ @deadline = data["deadline_left"] && now + data["deadline_left"]
507
+ @cpu_at = data["cpu_left"] && now + data["cpu_left"]
508
+ # An AI seat to move: its move is asked again.
509
+ schedule(now) if playing? && ai_seat?(@game.turn - 1)
510
+ super
511
+ end
512
+
513
+ # ------------------------------------------------------------ helpers
514
+
515
+ def pass_owner
516
+ seated = @seats.compact.select { |s| s["kind"] == "human" }
517
+ @owner = seated.min_by { |s| s["sat"].to_i }&.fetch("node")
518
+ end
519
+
520
+ def cpu_seat = { "kind" => "cpu", "name" => "CPU" }
521
+
522
+ # The CPUs are "CPU", the AI players their kind's name (numbered when
523
+ # there are two of one).
524
+ def name_cpus
525
+ @seats.each { |s| s["name"] = "CPU" if s && s["kind"] == "cpu" && !s["ai"] }
526
+ @seats.compact.select { |s| s["ai"] }.group_by { |s| s["ai"] }.each do |kind, list|
527
+ label = AI::LABELS[kind] || kind.capitalize
528
+ list.each_with_index { |s, i| s["name"] = list.size > 1 ? "#{label}#{i + 1}" : label }
529
+ end
530
+ end
531
+ end
532
+ end
533
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The othello game (README "The othello table") on the framework
4
+ # (asterism_games): the rules and the CPU (othello/board), the table and
5
+ # its exposed object. Requiring this registers the game "othello". It
6
+ # needs nothing but the framework.
7
+ require_relative "../asterism_games"
8
+ require_relative "othello/board"
9
+ require_relative "othello/table"
10
+ require_relative "othello/remote"
11
+
12
+ AsterismGames::Othello::Table.remote(AsterismGames::Othello::Remote)