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,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "asterism"
|
|
4
|
+
|
|
5
|
+
module AsterismGames
|
|
6
|
+
module Play
|
|
7
|
+
# An othello table in a terminal (README "The othello table"):
|
|
8
|
+
# `asterism-games play othello` (and bin/othello-tty, which calls it).
|
|
9
|
+
# It joins the table, shows the board as text at every change (the
|
|
10
|
+
# state the host puts) and reads commands: watch, or sit and type the
|
|
11
|
+
# squares ("d3"). When the table's host leaves the network (Asterism's
|
|
12
|
+
# on_leave) it says the connection is lost and stops asking; when the
|
|
13
|
+
# host is back (on_join) it asks again at once (and every 30 s
|
|
14
|
+
# meanwhile, should the word not come).
|
|
15
|
+
class OthelloTty
|
|
16
|
+
HELP = <<~TEXT
|
|
17
|
+
On a terminal it reads keys: the arrows move the cursor on the board, Enter (or
|
|
18
|
+
Space) plays there, Esc resigns (after "resign? [Y/N]"); in the lobby S sit or stand, C the CPU, A an AI
|
|
19
|
+
player (Jev, when the host offers one; again: back to the CPU), Enter start; Q or Ctrl+C quits; : (or /)
|
|
20
|
+
opens a line for a command.
|
|
21
|
+
Othello commands (one per line, or after :): d3 (play there), sit [0|1], stand, cpu (fill the
|
|
22
|
+
empty seat, or turn the AI player back into the CPU; the owner), ai [KIND] (an AI player the host
|
|
23
|
+
offers in the other seat; the owner), start (the owner), resign, auto (let the client play your
|
|
24
|
+
moves: the first legal square; again to stop), view, help, quit.
|
|
25
|
+
Without --table it takes the first othello table it finds (looking again whenever
|
|
26
|
+
a node joins the network, for 10 s).
|
|
27
|
+
TEXT
|
|
28
|
+
|
|
29
|
+
# The whole run: connect, find the table, play until quit (or over).
|
|
30
|
+
def self.run(opts, input: $stdin, out: $stdout)
|
|
31
|
+
tty = nil
|
|
32
|
+
joined = Queue.new
|
|
33
|
+
on_join = lambda do |n|
|
|
34
|
+
joined << n
|
|
35
|
+
tty&.node_joined(n)
|
|
36
|
+
end
|
|
37
|
+
Play.connect(opts, on_join: on_join, on_leave: ->(n) { tty&.node_left(n) }, label: "play othello") do |net|
|
|
38
|
+
path = opts[:table] || Play.find_tables(net, "othello", joined).first
|
|
39
|
+
abort "play othello: no othello table found (--table <host>/games/<table>)" unless path
|
|
40
|
+
kout = Play.key_out(input, out)
|
|
41
|
+
tty = new(net, path, opts[:name] || net.node_id, out: kout, keys: Play.keys?(input))
|
|
42
|
+
tty.start
|
|
43
|
+
trap("INT") { exit }
|
|
44
|
+
begin
|
|
45
|
+
Play.read_loop(tty, opts, input: input, out: kout)
|
|
46
|
+
ensure
|
|
47
|
+
tty.stop
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
HOST_APP = "games"
|
|
53
|
+
VIEW_EVERY_S = 2.0
|
|
54
|
+
# While the host is gone: a view now and then, should on_join not come.
|
|
55
|
+
HOST_GONE_EVERY_S = 30.0
|
|
56
|
+
|
|
57
|
+
def initialize(net, path, name, out: $stdout, keys: false)
|
|
58
|
+
@net = net
|
|
59
|
+
# Keys (README "Keys"): the cursor on the board ([x, y], nil until
|
|
60
|
+
# a game shows), a line being typed after ":".
|
|
61
|
+
@keys = keys
|
|
62
|
+
@cursor = nil
|
|
63
|
+
# Esc during this member's game: "resign? [Y/N]" (as the othello app).
|
|
64
|
+
@confirm = false
|
|
65
|
+
@typing = false
|
|
66
|
+
@path = path
|
|
67
|
+
@name = name
|
|
68
|
+
@out = out
|
|
69
|
+
@table = net[path, timeout: 3.0]
|
|
70
|
+
@lock = Mutex.new
|
|
71
|
+
@view = nil
|
|
72
|
+
@token = nil
|
|
73
|
+
@note = ""
|
|
74
|
+
@auto = false
|
|
75
|
+
@wake = Queue.new
|
|
76
|
+
@played = false
|
|
77
|
+
@host = path.split("/").first
|
|
78
|
+
@host_gone = false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Asterism's on_leave / on_join (on the receiving thread: nothing here
|
|
82
|
+
# waits; the ticker asks the view).
|
|
83
|
+
def node_left(node)
|
|
84
|
+
return unless node == @host
|
|
85
|
+
@host_gone = true
|
|
86
|
+
@wake << :gone # the ticker waits again, now the long way
|
|
87
|
+
say("the connection is lost: the host left the network (waiting for it)")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def node_joined(node)
|
|
91
|
+
return unless node == @host && @host_gone
|
|
92
|
+
@host_gone = false
|
|
93
|
+
say("the host is back")
|
|
94
|
+
@wake << true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A game was played and is over now (--until-over).
|
|
98
|
+
def finished? = @played && @view && @view["st"] == "over"
|
|
99
|
+
|
|
100
|
+
def start
|
|
101
|
+
r = @table.join(@name)
|
|
102
|
+
# "identity": in a room, only the seats and the host's names join.
|
|
103
|
+
abort "othello-tty: #{@path}: join refused: #{r.is_a?(Hash) ? r['error'] : r.inspect}" unless r.is_a?(Hash) && r["token"]
|
|
104
|
+
@token = r["token"]
|
|
105
|
+
host, app, tname = @path.split("/")
|
|
106
|
+
# The state the host puts after every change (and every 5 s): a newer
|
|
107
|
+
# one wakes the thread that asks the view (no call waits on the
|
|
108
|
+
# receiving thread).
|
|
109
|
+
@net.session.subscribe("asterism/#{host}/#{app}/#{tname}/state") do |sample|
|
|
110
|
+
v = Asterism::Codec.unpack(sample.payload)
|
|
111
|
+
@wake << true if v.is_a?(Hash) && (@view.nil? || v["v"] != @view["v"])
|
|
112
|
+
end
|
|
113
|
+
refresh
|
|
114
|
+
# And a view every VIEW_EVERY_S anyway: the host sees that this member
|
|
115
|
+
# is still here (where it cannot see the nodes come and go).
|
|
116
|
+
@ticker = Thread.new do
|
|
117
|
+
loop do
|
|
118
|
+
woke = @wake.pop(timeout: @host_gone ? HOST_GONE_EVERY_S : VIEW_EVERY_S)
|
|
119
|
+
@wake.clear
|
|
120
|
+
next if woke == :gone
|
|
121
|
+
refresh
|
|
122
|
+
rescue Asterism::Error => e
|
|
123
|
+
say("no answer from the table (#{e.class})")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def stop
|
|
129
|
+
@ticker&.kill
|
|
130
|
+
@table.leave(@token) if @token
|
|
131
|
+
rescue Asterism::Error
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Asks the member's view and draws it.
|
|
136
|
+
def refresh
|
|
137
|
+
v = @table.view(@token)
|
|
138
|
+
if v.is_a?(Hash) && v["error"] == "token"
|
|
139
|
+
# The host forgot this member (a new state): join again.
|
|
140
|
+
r = @table.join(@name)
|
|
141
|
+
@token = r["token"] if r.is_a?(Hash) && r["token"]
|
|
142
|
+
v = @table.view(@token)
|
|
143
|
+
end
|
|
144
|
+
@lock.synchronize do
|
|
145
|
+
@view = v
|
|
146
|
+
@played = true if v.is_a?(Hash) && v["st"] == "play"
|
|
147
|
+
@cursor = self.class.place_cursor(v, @cursor)
|
|
148
|
+
@confirm = false unless self.class.own_game?(v)
|
|
149
|
+
draw
|
|
150
|
+
end
|
|
151
|
+
play_auto
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def say(text)
|
|
155
|
+
@lock.synchronize do
|
|
156
|
+
@note = text
|
|
157
|
+
draw
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One command line; false to quit.
|
|
162
|
+
def command(line)
|
|
163
|
+
words = line.to_s.strip.downcase.split
|
|
164
|
+
return true if words.empty?
|
|
165
|
+
r = case words[0]
|
|
166
|
+
when "quit", "exit", "q" then return false
|
|
167
|
+
when "help", "?" then "d3 | sit [0|1] | stand | cpu | ai [KIND] | start | resign | auto | view | quit"
|
|
168
|
+
when "sit" then @table.sit(@token, (words[1] || -1).to_i)
|
|
169
|
+
when "stand" then @table.stand(@token)
|
|
170
|
+
when "cpu"
|
|
171
|
+
o = self.class.other_seat(@view)
|
|
172
|
+
o && self.class.ai_seat?(@view, o) ? @table.cpu(@token, o, true) : @table.cpu(@token, -1, true)
|
|
173
|
+
when "ai"
|
|
174
|
+
kind = words[1] || self.class.ai_kinds(@view).first
|
|
175
|
+
o = self.class.other_seat(@view)
|
|
176
|
+
if kind.nil? then "the host offers no AI players"
|
|
177
|
+
elsif o.nil? then "sit first (the owner seats an AI player)"
|
|
178
|
+
else @table.cpu(@token, o, kind)
|
|
179
|
+
end
|
|
180
|
+
when "start" then @table.start(@token)
|
|
181
|
+
when "resign" then @table.resign(@token)
|
|
182
|
+
when "auto" then (@auto = !@auto) ? "auto: on" : "auto: off"
|
|
183
|
+
when "view" then "ok"
|
|
184
|
+
when /\A[a-h][1-8]\z/ then move(words[0])
|
|
185
|
+
else "? (help)"
|
|
186
|
+
end
|
|
187
|
+
say("#{line.strip}: #{r}")
|
|
188
|
+
refresh
|
|
189
|
+
true
|
|
190
|
+
rescue Asterism::Error => e
|
|
191
|
+
say("#{line.strip}: #{e.class}: #{e.message}")
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------ keys
|
|
196
|
+
|
|
197
|
+
KEY_STEPS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }.freeze
|
|
198
|
+
|
|
199
|
+
# The cursor for a view: none outside a game or without a seat; at a
|
|
200
|
+
# legal square of this member's (the first) when it has none yet.
|
|
201
|
+
def self.place_cursor(v, cursor)
|
|
202
|
+
return nil unless v.is_a?(Hash) && v["st"] == "play" && v["me"].to_i >= 0
|
|
203
|
+
return cursor if cursor
|
|
204
|
+
sq = v["tn"] == v["me"] ? v["lg"].to_s[0, 2] : ""
|
|
205
|
+
sq.size == 2 ? square_xy(sq) : nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.square_xy(sq) = ["abcdefgh".index(sq[0]), sq[1].to_i - 1]
|
|
209
|
+
|
|
210
|
+
def self.square(xy) = "#{'abcdefgh'[xy[0]]}#{xy[1] + 1}"
|
|
211
|
+
|
|
212
|
+
# The cursor moved by an arrow, stopping at the board's edge (from
|
|
213
|
+
# the middle when there is none yet).
|
|
214
|
+
def self.step(cursor, key)
|
|
215
|
+
x, y = cursor || [3, 3]
|
|
216
|
+
dx, dy = KEY_STEPS.fetch(key)
|
|
217
|
+
[(x + dx).clamp(0, 7), (y + dy).clamp(0, 7)]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The AI players the host offers (the lobby view's "ak"; none from a
|
|
221
|
+
# host without them).
|
|
222
|
+
def self.ai_kinds(v)
|
|
223
|
+
ak = v.is_a?(Hash) ? v["ak"] : nil
|
|
224
|
+
ak.is_a?(Array) ? ak.map(&:to_s) : []
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Seat i's AI player ("jev"), or nil.
|
|
228
|
+
def self.ai_kind(v, i)
|
|
229
|
+
ai = v.is_a?(Hash) ? v["ai"] : nil
|
|
230
|
+
k = ai.is_a?(Array) ? ai[i].to_s : ""
|
|
231
|
+
k.empty? ? nil : k
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def self.ai_seat?(v, i) = !ai_kind(v, i).nil?
|
|
235
|
+
|
|
236
|
+
# The seat that is not this member's (nil without a seat).
|
|
237
|
+
def self.other_seat(v)
|
|
238
|
+
me = v.is_a?(Hash) ? v["me"].to_i : -1
|
|
239
|
+
me.between?(0, 1) ? 1 - me : nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# What A does for the owner: Jev into the other seat (empty or a
|
|
243
|
+
# CPU), or the AI player there back into the CPU. nil: nothing to do.
|
|
244
|
+
def self.ai_key_command(v)
|
|
245
|
+
o = other_seat(v) or return nil
|
|
246
|
+
return nil if (v["sk"] || [])[o] == 1
|
|
247
|
+
return "cpu" if ai_seat?(v, o)
|
|
248
|
+
kind = ai_kinds(v).first
|
|
249
|
+
kind ? "ai #{kind}" : nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The name an AI player goes by (as the host names it).
|
|
253
|
+
def self.ai_label(kind) = kind == "jev" ? "Jev" : kind.to_s.capitalize
|
|
254
|
+
|
|
255
|
+
# A game this member plays (seated, going on): Esc offers to resign.
|
|
256
|
+
def self.own_game?(v) = v.is_a?(Hash) && v["st"] == "play" && v["me"].to_i >= 0
|
|
257
|
+
|
|
258
|
+
# One key as a command. Returns [:cursor, [x, y]], [:cmd, line] (a
|
|
259
|
+
# square, sit, stand, cpu, ai KIND, start, resign), [:confirm, on] (the
|
|
260
|
+
# question "resign?" shown or put away), [:note, text], [:line],
|
|
261
|
+
# [:quit] or [:none]. confirm: the question is up (Y resigns, N or Esc
|
|
262
|
+
# cancels, other keys wait).
|
|
263
|
+
def self.key_command(key, view, cursor, confirm: false)
|
|
264
|
+
k = key.is_a?(String) ? key.downcase : key
|
|
265
|
+
return [:quit] if [:ctrl_c, :ctrl_q, "q"].include?(k)
|
|
266
|
+
if confirm && own_game?(view)
|
|
267
|
+
return [:cmd, "resign"] if k == "y"
|
|
268
|
+
return [:confirm, false] if k == "n" || k == :esc
|
|
269
|
+
return [:none]
|
|
270
|
+
end
|
|
271
|
+
return [:line] if [":", "/"].include?(k)
|
|
272
|
+
return [:none] unless view.is_a?(Hash) && !view["error"]
|
|
273
|
+
me = view["me"].to_i
|
|
274
|
+
if view["st"] == "play"
|
|
275
|
+
return [:none] if me.negative?
|
|
276
|
+
return [:confirm, true] if k == :esc
|
|
277
|
+
return [:cursor, step(cursor, k)] if KEY_STEPS.key?(k)
|
|
278
|
+
return [:none] unless %i[enter space].include?(k)
|
|
279
|
+
return [:note, "not your move"] unless view["tn"] == me
|
|
280
|
+
return [:note, "move the cursor to a square first"] unless cursor
|
|
281
|
+
sq = square(cursor)
|
|
282
|
+
return view["lg"].to_s.scan(/../).include?(sq) ? [:cmd, sq] : [:note, "#{sq}: not a legal square"]
|
|
283
|
+
end
|
|
284
|
+
case k
|
|
285
|
+
when "s" then [:cmd, me >= 0 ? "stand" : "sit"]
|
|
286
|
+
when "c" then view["ow"] == me && me >= 0 ? [:cmd, "cpu"] : [:note, "the owner puts the CPU in"]
|
|
287
|
+
when "a"
|
|
288
|
+
return [:note, "the owner seats an AI player"] unless view["ow"] == me && me >= 0
|
|
289
|
+
c = ai_key_command(view)
|
|
290
|
+
c ? [:cmd, c] : [:note, ai_kinds(view).empty? ? "the host offers no AI players" : "the other seat is a player's"]
|
|
291
|
+
when :enter then view["ow"] == me && me >= 0 ? [:cmd, "start"] : [:none]
|
|
292
|
+
else [:none]
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# One key (Play.key_loop); false to quit, :line for a line to type.
|
|
297
|
+
def key(k)
|
|
298
|
+
r = self.class.key_command(k, @view, @cursor, confirm: @confirm)
|
|
299
|
+
case r[0]
|
|
300
|
+
when :quit then return false
|
|
301
|
+
when :line then return :line
|
|
302
|
+
when :confirm
|
|
303
|
+
@lock.synchronize do
|
|
304
|
+
@confirm = r[1]
|
|
305
|
+
draw
|
|
306
|
+
end
|
|
307
|
+
when :cmd
|
|
308
|
+
@confirm = false
|
|
309
|
+
return command(r[1])
|
|
310
|
+
when :cursor
|
|
311
|
+
@lock.synchronize do
|
|
312
|
+
@cursor = r[1]
|
|
313
|
+
draw
|
|
314
|
+
end
|
|
315
|
+
when :note then say(r[1])
|
|
316
|
+
end
|
|
317
|
+
true
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# A line is being typed after ":" (a prompt at the bottom).
|
|
321
|
+
def typing(on)
|
|
322
|
+
@lock.synchronize do
|
|
323
|
+
@typing = on
|
|
324
|
+
draw
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def move(square)
|
|
329
|
+
v = @view or return "no view yet"
|
|
330
|
+
@table.move(@token, v["n"].to_i, square)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# auto: plays the first legal square when it is this member's turn.
|
|
334
|
+
def play_auto
|
|
335
|
+
return unless @auto
|
|
336
|
+
v = @view
|
|
337
|
+
return unless v && v["st"] == "play" && v["tn"] == v["me"] && v["lg"].to_s.size >= 2
|
|
338
|
+
sq = v["lg"][0, 2]
|
|
339
|
+
r = @table.move(@token, v["n"].to_i, sq)
|
|
340
|
+
say("auto #{sq}: #{r}")
|
|
341
|
+
@wake << true
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------ the screen
|
|
345
|
+
|
|
346
|
+
def draw
|
|
347
|
+
v = @view
|
|
348
|
+
return unless v.is_a?(Hash)
|
|
349
|
+
return @out.puts("table: #{v['error']}") if v["error"]
|
|
350
|
+
me = v["me"].to_i
|
|
351
|
+
names = v["nm"] || ["", ""]
|
|
352
|
+
lines = []
|
|
353
|
+
lines << "#{@path} #{status_text(v)}"
|
|
354
|
+
bd = v["bd"]
|
|
355
|
+
hints = me >= 0 && v["tn"] == me ? v["lg"].to_s.scan(/../) : []
|
|
356
|
+
cur = keys_on? && v["st"] == "play" ? @cursor : nil
|
|
357
|
+
lines << " a b c d e f g h"
|
|
358
|
+
8.times do |y|
|
|
359
|
+
row = (0...8).map do |x|
|
|
360
|
+
c = bd ? bd[y * 8 + x] : "."
|
|
361
|
+
code = "#{'abcdefgh'[x]}#{y + 1}"
|
|
362
|
+
c == "B" ? "X" : (c == "W" ? "O" : (hints.include?(code) ? "*" : "."))
|
|
363
|
+
end
|
|
364
|
+
lines << " #{y + 1}#{self.class.board_row(row, cur && cur[1] == y ? cur[0] : nil)}"
|
|
365
|
+
end
|
|
366
|
+
cnt = v["cnt"] || [0, 0]
|
|
367
|
+
2.times do |i|
|
|
368
|
+
who = names[i].to_s.empty? ? "(empty)" : names[i]
|
|
369
|
+
tags = []
|
|
370
|
+
tags << "you" if i == me
|
|
371
|
+
ai = self.class.ai_kind(v, i)
|
|
372
|
+
tags << (ai ? "AI" : "CPU") if v["sk"].to_a[i] == 2
|
|
373
|
+
tags << "owner" if v["ow"] == i
|
|
374
|
+
tags << "away" if v["aw"].to_i[i] == 1
|
|
375
|
+
turn = v["tn"] == i ? " <" : ""
|
|
376
|
+
colour = v["st"] == "wait" ? "seat#{i + 1}" : (i.zero? ? "black" : "white")
|
|
377
|
+
lines << format(" %s %-5s %-24s %2d%s", i.zero? ? "X" : "O", colour,
|
|
378
|
+
"#{who}#{" (#{tags.join(', ')})" unless tags.empty?}", cnt[i], turn)
|
|
379
|
+
end
|
|
380
|
+
watchers = (v["mb"] || []).select { |m| m[1] == -1 }.map(&:first)
|
|
381
|
+
lines << " watching: #{watchers.join(', ')}" unless watchers.empty?
|
|
382
|
+
lines << " #{@note}" unless @note.empty?
|
|
383
|
+
lines << " #{keys_on? ? key_prompt(v, me) : prompt(v, me)}"
|
|
384
|
+
@out.print("\e[H\e[2J") if @out.tty?
|
|
385
|
+
@out.puts(lines)
|
|
386
|
+
@out.print("> ") if @keys && @typing
|
|
387
|
+
@out.flush
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def keys_on? = @keys && !@typing
|
|
391
|
+
|
|
392
|
+
# A row of the board: each cell after a space, the cursor's cell
|
|
393
|
+
# between [ ] (in the spaces around it, so the columns stay).
|
|
394
|
+
def self.board_row(cells, cx)
|
|
395
|
+
out = +""
|
|
396
|
+
cells.each_with_index do |c, x|
|
|
397
|
+
out << (x == cx ? "[" : (cx && x == cx + 1 ? "]" : " "))
|
|
398
|
+
out << c
|
|
399
|
+
end
|
|
400
|
+
out << (cx == 7 ? "]" : "")
|
|
401
|
+
out
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The last line with keys: each key in [ ] before what it does.
|
|
405
|
+
def key_prompt(v, me)
|
|
406
|
+
common = "[:] type a command [Q] quit"
|
|
407
|
+
if v["st"] == "play"
|
|
408
|
+
return "watching #{common}" if me.negative?
|
|
409
|
+
return "resign? [Y] yes [N] no" if @confirm
|
|
410
|
+
return "your move: [arrows] move [Enter] play (* marks the squares) [Esc] resign #{common}" if v["tn"] == me
|
|
411
|
+
return "waiting for the other player [arrows] move [Esc] resign #{common}"
|
|
412
|
+
end
|
|
413
|
+
words = [me >= 0 ? "[S] stand" : "[S] sit"]
|
|
414
|
+
if me >= 0 && v["ow"] == me
|
|
415
|
+
sk = v["sk"] || [0, 0]
|
|
416
|
+
o = self.class.other_seat(v)
|
|
417
|
+
words << "[C] CPU" if sk.any?(&:zero?) || self.class.ai_seat?(v, o)
|
|
418
|
+
a = self.class.ai_key_command(v)
|
|
419
|
+
if a == "cpu"
|
|
420
|
+
words << "[A] back to the CPU"
|
|
421
|
+
elsif a
|
|
422
|
+
words << "[A] #{self.class.ai_label(a.split[1])}"
|
|
423
|
+
end
|
|
424
|
+
words << "[Enter] start" unless sk.any?(&:zero?)
|
|
425
|
+
end
|
|
426
|
+
"#{words.join(' ')} #{common}"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def status_text(v)
|
|
430
|
+
case v["st"]
|
|
431
|
+
when "wait" then "lobby (sit, cpu, start)"
|
|
432
|
+
when "play"
|
|
433
|
+
t = v["dl"] ? format(", %d s left", (v["dl"].to_i + 999) / 1000) : ""
|
|
434
|
+
tn = v["tn"].to_i
|
|
435
|
+
think = tn >= 0 && self.class.ai_seat?(v, tn) ? ", #{(v['nm'] || [])[tn]} is thinking" : ""
|
|
436
|
+
"move #{v['n'].to_i + 1}, last #{v['last'] || '-'}#{think}#{t}"
|
|
437
|
+
when "over"
|
|
438
|
+
r = v["res"] || {}
|
|
439
|
+
w = r["w"].to_i
|
|
440
|
+
who = w.negative? ? "a draw" : "#{(v['nm'] || [])[w]} (#{w.zero? ? 'black' : 'white'}) wins"
|
|
441
|
+
"over: #{who}, #{r['b']}-#{r['wc']}#{' by resignation' if r['why'] == 'resign'}"
|
|
442
|
+
else v["st"].to_s
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def prompt(v, me)
|
|
447
|
+
return "watching (sit to play; help)" if me.negative? && v["st"] != "play"
|
|
448
|
+
return "watching" if me.negative?
|
|
449
|
+
return "your move: a square (* marks them), or resign" if v["st"] == "play" && v["tn"] == me
|
|
450
|
+
return "waiting for the other player" if v["st"] == "play"
|
|
451
|
+
return "seated (start: the owner plays again; stand: leave the seat)" if v["st"] == "over"
|
|
452
|
+
"seated (the colours are drawn at the start)#{'; ai: an AI player in the other seat' if v['ow'] == me && !self.class.ai_kinds(v).empty?}"
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|