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