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