asterism-games 0.21.0 → 0.23.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.
@@ -13,14 +13,24 @@ module AsterismGames
13
13
  # table in the settings, moves the games on, saves them after every change
14
14
  # and puts their public state on asterism/<node>/<app>/<table>/state.
15
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.
16
+ # What it puts on its application's keys are topics (asterism's
17
+ # docs/protocol.md 9-11): <table>/state and what a game tells
18
+ # (<table>/<sub>), declared with the game's types (Game::STATE_TYPE,
19
+ # TOLD_TYPES) and the types defined (Game::TYPES), so a reader finds them
20
+ # with @profile?pubs;types before a value comes, and each value carries
21
+ # its sequence number.
22
+ #
23
+ # Every game lives under the host's application (config "app"), each as
24
+ # an object of its own name. The games come from the registry
25
+ # (AsterismGames.games): a table's "game" names one. What spectators see
26
+ # from outside the tables goes on the node's shared place
27
+ # (asterism/<node>/@shared/spectate/..., README "Spectate"), declared by
28
+ # the host's application, and in a room by the room's application
29
+ # room<slot> outside the room.
20
30
  #
21
31
  # The connection is Asterism's block API (Asterism.connect { |net| }):
22
32
  # 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
33
+ # on the connection's receiving thread. The games move on in this host's own
24
34
  # loop (step: timeouts, CPU turns, the puts), which takes Asterism::LOCK,
25
35
  # the lock every tick of the receiving thread holds, so a call and a step
26
36
  # never meet inside a game.
@@ -180,6 +190,7 @@ module AsterismGames
180
190
  net.expose(name, @remotes[name], methods: @methods[name], caller: :from, **extra)
181
191
  end
182
192
  @net = net
193
+ declare_topics(net)
183
194
  @connected = true
184
195
  @tables_status = TablesStatus.new(net, node, @config["app"]) if tables_status?
185
196
  write_status
@@ -197,7 +208,7 @@ module AsterismGames
197
208
  @log.call("games: connection lost: #{net.lost_reason}")
198
209
  return
199
210
  end
200
- open_spectate
211
+ open_room_app
201
212
  ::Asterism::LOCK.synchronize { step }
202
213
  sleep LOOP_S
203
214
  end
@@ -208,21 +219,11 @@ module AsterismGames
208
219
  # runs other applications too is away as soon as its table app closes.
209
220
  PLAYER_APP = "game"
210
221
 
211
- # Who is there: Asterism's on_app_join / on_app_leave (asterism after
212
- # 0.7.1), else a liveliness watch of asterism/*/game on the session.
222
+ # Who is there: Asterism's on_app_join / on_app_leave.
213
223
  def follow_players(net)
214
224
  app = @config["player_app"] || PLAYER_APP
215
- if net.respond_to?(:on_app_join)
216
- net.on_app_join { |n, a| node_joined(n) if a == app }
217
- net.on_app_leave { |n, a| node_left(n) if a == app }
218
- else
219
- own = "asterism/#{node}/"
220
- net.session.liveliness_watch("asterism/*/#{app}") do |key, alive|
221
- next if key.start_with?(own)
222
- n = key.split("/")[1]
223
- alive ? node_joined(n) : node_left(n)
224
- end
225
- end
225
+ net.on_app_join { |n, a| node_joined(n) if a == app }
226
+ net.on_app_leave { |n, a| node_left(n) if a == app }
226
227
  end
227
228
 
228
229
  # A node's table app left the network: its members are away at every
@@ -237,8 +238,9 @@ module AsterismGames
237
238
  end
238
239
 
239
240
  def down
240
- close_spectate
241
+ close_room_app
241
242
  @net = nil
243
+ @topics = {}
242
244
  @tables_status = nil
243
245
  @connected = false
244
246
  write_status
@@ -251,7 +253,7 @@ module AsterismGames
251
253
  # no nodes, no tokens (the host's own node ID only, once known).
252
254
  def status_now(now = @clock.call)
253
255
  { "pid" => Process.pid, "at" => Time.now.to_i, "node" => @node, "connected" => @connected ? true : false,
254
- "reconnecting" => (@connected && ::Asterism.reconnecting?) ? true : false,
256
+ "reconnecting" => (@connected && @net&.reconnecting?) ? true : false,
255
257
  "tables" => @games.transform_values { |g| g.summary(now) } }.tap do |h|
256
258
  h["ai"] = @ai.budget.summary if @ai&.any?
257
259
  end
@@ -342,7 +344,7 @@ module AsterismGames
342
344
  # Asterism puts them only when they differ from the last put.
343
345
  def tell_tables(now, changed: false)
344
346
  ts = @tables_status
345
- return unless ts && @net && !::Asterism.reconnecting?
347
+ return unless ts && @net && !@net.reconnecting?
346
348
  return unless changed || now - @tables_at.to_i >= TABLES_STATUS_EVERY_MS
347
349
  @tables_at = now
348
350
  bytes = ts.set(tables_status(now))
@@ -353,28 +355,58 @@ module AsterismGames
353
355
 
354
356
  def put_state(name, game, now)
355
357
  @put_at[name] = now
356
- return unless @net && !::Asterism.reconnecting?
357
- key = "asterism/#{node}/#{@config['app']}/#{name}/state"
358
+ return unless @net && !@net.reconnecting?
358
359
  # What everybody may see, as a spectator sees it (README "Spectators").
359
- payload = ::Asterism::Codec.pack(game.state_view(now))
360
- note_size("put #{name}/state", key.bytesize + payload.bytesize)
361
- @net.session.put(key, payload)
360
+ view = game.state_view(now)
361
+ note_size("put #{name}/state", "asterism/#{node}/#{@config['app']}/#{name}/state".bytesize +
362
+ ::Asterism::Codec.pack(view).bytesize)
363
+ topic(name, "state").put(view)
362
364
  rescue ::Asterism::Error => e
363
365
  @log.call("games: put #{name}: #{e.message}")
364
366
  end
365
367
 
366
368
  # What a game told (Game#tell): put on asterism/<node>/<app>/<table>/<sub>.
367
369
  def put_told(name, sub, value)
368
- return unless @net && !::Asterism.reconnecting?
370
+ return unless @net && !@net.reconnecting?
369
371
  key = "asterism/#{node}/#{@config['app']}/#{name}/#{sub}"
370
- payload = ::Asterism::Codec.pack(value)
371
- note_size("put #{name}/#{sub}", key.bytesize + payload.bytesize)
372
+ note_size("put #{name}/#{sub}", key.bytesize + ::Asterism::Codec.pack(value).bytesize)
372
373
  @stats["told"] += 1
373
- @net.session.put(key, payload)
374
+ topic(name, sub).put(value)
374
375
  rescue ::Asterism::Error => e
375
376
  @log.call("games: put #{name}/#{sub}: #{e.message}")
376
377
  end
377
378
 
379
+ # ------------------------------------------------------------ topics
380
+
381
+ # Connected: the games' types defined on the connection, and every
382
+ # table's state and told keys declared as topics of this application,
383
+ # and outside the rooms the tables' spectate keys (the types and the
384
+ # topics end with the connection).
385
+ def declare_topics(net)
386
+ @topics = {}
387
+ @games.values.map(&:class).uniq.each do |klass|
388
+ klass::TYPES.each { |tname, (fields, text)| net.define_type(tname, fields, desc: text) }
389
+ end
390
+ @games.each do |name, g|
391
+ topic(name, "state")
392
+ g.class::TOLD_TYPES.each_key { |sub| topic(name, sub) }
393
+ end
394
+ # Outside the rooms the spectate keys are this application's too
395
+ # (in the node's shared place); a room's go on its own application.
396
+ declare_spectate(net) unless @config["namespace"]
397
+ end
398
+
399
+ # The topic <table>/<sub> of this connection (declared the first time,
400
+ # with the game's type for it).
401
+ def topic(name, sub)
402
+ @topics ||= {}
403
+ @topics[[name, sub]] ||= begin
404
+ klass = @games[name].class
405
+ type = sub == "state" ? klass::STATE_TYPE : klass::TOLD_TYPES[sub]
406
+ type ? @net.publish("#{name}/#{sub}", type: type) : @net.publish("#{name}/#{sub}")
407
+ end
408
+ end
409
+
378
410
  # ------------------------------------------------------------ spectate
379
411
  #
380
412
  # What a game gives for those who watch from outside the table
@@ -385,31 +417,45 @@ module AsterismGames
385
417
  # ends, the delay is chosen again); a host started again starts with
386
418
  # nothing kept. The last one put is put again every PUT_EVERY_MS, so a
387
419
  # new subscriber has it within that.
420
+ #
421
+ # The keys are in the node's shared place (asterism's docs/protocol.md
422
+ # 17): asterism/<node>/@shared/spectate/<table>/state, a chunk no
423
+ # player's role (asterism/*/games/**) and no room reaches. Each is a
424
+ # topic declared with shared: :node by the application that puts it,
425
+ # with the game's SPECTATE_TYPE defined on that connection
426
+ # (Game::SPECTATE_TYPES): outside the rooms the host's own application
427
+ # (config "app"); in a room, the room's application room<slot>
428
+ # (room_app), a connection of its own outside the room's namespace.
388
429
 
389
- # The application chunk of the spectate keys: not the host's app (the
390
- # players' role reads asterism/*/<app>/**), and outside every room.
391
- SPECTATE_APP = "spectate"
392
430
  # What one table keeps for its delay at most (bytes); the oldest goes
393
431
  # first (it is then never shown, rather than shown early).
394
432
  SPECTATE_KEEP_BYTES = 8 * 1024 * 1024
395
- # How often a room's host tries again to open its connection outside
433
+ # How often a room's host tries again to open its application outside
396
434
  # the room (ms).
397
435
  SPECTATE_RETRY_MS = 30_000
398
436
 
399
- # asterism/<node>/spectate/<table>/state; a room's table is
400
- # room<slot>-<table> there, so the rooms' tables and the permanent ones
401
- # never meet.
402
- def spectate_key(name)
437
+ # The name of a table's spectate topic in the node's shared place:
438
+ # spectate/<table>/state; a room's table is room<slot>-<table> there,
439
+ # so the rooms' tables and the permanent ones never meet.
440
+ def spectate_name(name)
403
441
  table = @config["namespace"] ? "#{spectate_label}-#{name}" : name
404
- "asterism/#{node}/#{SPECTATE_APP}/#{table}/state"
442
+ "spectate/#{table}/state"
405
443
  end
406
444
 
407
- # A room's part of its tables' spectate names: the setting (Rooms gives
408
- # room<slot>), else the namespace without its slashes.
445
+ # The whole key: asterism/<node>/@shared/spectate/<table>/state.
446
+ def spectate_key(name)
447
+ "asterism/#{node}/@shared/#{spectate_name(name)}"
448
+ end
449
+
450
+ # A room's part of its tables' spectate names, and the name of its
451
+ # application outside the room: the setting (Rooms gives room<slot>),
452
+ # else the namespace without its slashes.
409
453
  def spectate_label
410
454
  @config["spectate_label"] || @config["namespace"].to_s.delete("/")
411
455
  end
412
456
 
457
+ alias room_app spectate_label
458
+
413
459
  # Takes what the game gives now (nothing when it gives nil, or the same
414
460
  # as last time).
415
461
  def take_spectate(name, game, now)
@@ -446,63 +492,161 @@ module AsterismGames
446
492
 
447
493
  def send_spectate(name, bytes, now)
448
494
  @spec[name]["at"] = now
449
- sess = spectate_session or return
450
- spectate_token(sess)
451
- key = spectate_key(name)
452
495
  # Counted apart from the players' messages (the hands make it larger).
453
- @stats["spectate_max_bytes"] = [@stats["spectate_max_bytes"], key.bytesize + bytes.bytesize].max
454
- sess.put(key, bytes)
496
+ @stats["spectate_max_bytes"] = [@stats["spectate_max_bytes"], spectate_key(name).bytesize + bytes.bytesize].max
497
+ topic = spectate_topic(name) or return
498
+ topic.put(::Asterism::Codec.unpack(bytes))
455
499
  rescue ::Asterism::Error => e
456
500
  @log.call("games: put spectate #{name}: #{e.message}")
457
501
  end
458
502
 
459
- # The application asterism/<node>/spectate on the network (a liveliness
460
- # token on the connection that puts), so that the command line and the
461
- # MCP bridge take its keys for the values of an application that is
462
- # there. Once per connection.
463
- def spectate_token(sess)
464
- return if @spectate_token_on.equal?(sess) || !sess.respond_to?(:liveliness)
465
- @spectate_token_on = sess
466
- @spectate_token = sess.liveliness("asterism/#{node}/#{SPECTATE_APP}")
467
- rescue ::Asterism::Error => e
468
- @log.call("games: spectate: token: #{e.message}")
469
- end
470
-
471
- # Where the spectate keys are put: the host's own connection outside
472
- # the rooms; in a room a connection of its own without the room's
473
- # namespace (open_spectate), when the host joins with its own
474
- # certificate (spectate_outside), else nowhere.
475
- def spectate_session
476
- return nil unless @net && !::Asterism.reconnecting?
477
- return @net.session unless @config["namespace"]
478
- c = @spectate_conn
479
- c && !c.reconnecting? ? c : nil
480
- end
481
-
482
- # A room's host opens its connection outside the room (the host's own
483
- # certificate, no namespace) once connected, and again after a failure
484
- # every SPECTATE_RETRY_MS. Outside the lock: opening waits for the router.
485
- def open_spectate(now = @clock.call)
486
- return unless @config["namespace"] && @config["spectate_outside"] && @config["credentials"]
487
- return if @spectate_conn || now < @spectate_retry_at.to_i
488
- return unless @games.values.any? { |g| g.respond_to?(:spectate_view) && g.method(:spectate_view).owner != Game }
489
- @spectate_retry_at = now + SPECTATE_RETRY_MS
490
- @spectate_conn = ::Asterism::Zenoh.open(locator, credentials: @config["credentials"])
491
- @log.call("games: spectate: #{node}/#{SPECTATE_APP} outside #{@config['namespace']} " \
492
- "(#{@games.keys.map { |n| spectate_key(n).split('/')[3] }.join(', ')})")
493
- rescue ::Asterism::Error, SystemCallError => e
494
- @spectate_conn = nil
495
- @log.call("games: spectate: cannot connect outside #{@config['namespace']}: #{e.message}; " \
503
+ # Whether the game puts something for those who watch from outside.
504
+ def spectates?(game)
505
+ Host.spectates?(game.class)
506
+ end
507
+
508
+ # Whether a game class gives a spectate view (Game#spectate_view
509
+ # replaced).
510
+ def self.spectates?(klass)
511
+ klass.instance_method(:spectate_view).owner != Game
512
+ end
513
+
514
+ # The connection that puts the spectate keys, when it can put now: the
515
+ # host's own outside the rooms, the room's application in a room (nil
516
+ # while it is not open, or with the slot's certificate).
517
+ def spectate_conn
518
+ c = @config["namespace"] ? @room : @net
519
+ c && c.connected? && !c.reconnecting? ? c : nil
520
+ end
521
+
522
+ # The topic spectate/<table>/state of the node's shared place on the
523
+ # spectate connection (declared the first time, with the game's type
524
+ # for it), or nil when that connection cannot put now.
525
+ def spectate_topic(name)
526
+ conn = spectate_conn or return nil
527
+ @spectate_topics ||= {}
528
+ @spectate_topics[name] ||= begin
529
+ type = @games[name].class::SPECTATE_TYPE
530
+ type ? conn.publish(spectate_name(name), shared: :node, type: type) : conn.publish(spectate_name(name), shared: :node)
531
+ end
532
+ end
533
+
534
+ # Declares on conn the spectate types and the topics of the tables
535
+ # that give a spectate view, and exposes the object that answers their
536
+ # current values (called when conn is up).
537
+ def declare_spectate(conn)
538
+ @spectate_topics = {}
539
+ spectating = @games.select { |_n, g| spectates?(g) }
540
+ return if spectating.empty?
541
+ spectating.values.map(&:class).uniq.each do |klass|
542
+ klass::SPECTATE_TYPES.each { |tname, (fields, text)| conn.define_type(tname, fields, desc: text) }
543
+ end
544
+ spectating.each_key { |name| spectate_topic(name) }
545
+ expose_spectate(conn)
546
+ end
547
+
548
+ # The object <node>/<app>/spectate of the application that puts the
549
+ # spectate keys (the host's own outside the rooms, room<slot> in a
550
+ # room): state(table) answers the value last put on
551
+ # spectate/<table>/state (README "Spectate"). Asterism's protocol 2
552
+ # keeps no current value on the network: a reader that comes late, or
553
+ # missed values (info.lost) or saw the sender start again
554
+ # (info.restarted?), asks here (the commentary does).
555
+ SPECTATE_OBJECT = "spectate"
556
+ SPECTATE_DOC = "The tables watched from outside (README \"Spectate\"): their spectate values."
557
+ SPECTATE_METHODS = {
558
+ "state" => { args: ["table"], doc: "The value last put on @shared/spectate/<table>/state (as old as the " \
559
+ "table's spectator delay), or nil when none was put yet." }
560
+ }.freeze
561
+
562
+ def expose_spectate(conn)
563
+ if @games.key?(SPECTATE_OBJECT)
564
+ @log.call("games: a table named #{SPECTATE_OBJECT}: its current spectate values are not answered")
565
+ return
566
+ end
567
+ methods = META_DOCS ? SPECTATE_METHODS : { "state" => 1 }
568
+ extra = META_DOCS ? { doc: SPECTATE_DOC } : {}
569
+ conn.expose(SPECTATE_OBJECT, SpectateRemote.new(self), methods: methods, **extra)
570
+ end
571
+
572
+ # The value last put on spectate/<table>/state (unpacked), nil when
573
+ # there is none (no such table, nothing put since the host started).
574
+ # table: the part of the name under spectate/ (room<slot>-<table> in a
575
+ # room). Called on a receiving thread, which holds Asterism::LOCK.
576
+ def spectate_now(table)
577
+ name = @games.keys.find { |n| spectate_name(n) == "spectate/#{table}/state" } or return nil
578
+ out = @spec.dig(name, "out") or return nil
579
+ ::Asterism::Codec.unpack(out)
580
+ end
581
+
582
+ # The exposed spectate object (Host#spectate_now).
583
+ class SpectateRemote
584
+ def initialize(host)
585
+ @host = host
586
+ end
587
+
588
+ def state(table)
589
+ @host.spectate_now(table.to_s)
590
+ end
591
+ end
592
+
593
+ # ---- in a room: the room's application outside the room
594
+ #
595
+ # Every key a room's host writes on its own connection gets the room's
596
+ # namespace, which every seat of the room reads. Its tables' spectate
597
+ # keys go on a second Asterism connection without the namespace, the
598
+ # application <node>/room<slot> (asterism's docs/protocol.md 1.1: one
599
+ # application a connection, several in one program), with the host's
600
+ # own certificate only (the slot's certificate of a console before
601
+ # 0.3.0 is the room's: spectate_outside false, nothing is put). Its
602
+ # liveliness token, @profile and @pubs are its own, so every room is
603
+ # an application of its own beside the permanent tables' games.
604
+
605
+ # The room's application, when the host is connected and one of its
606
+ # games gives a spectate view; opened again after a failure (or a lost
607
+ # connection) every SPECTATE_RETRY_MS. Outside the lock: opening waits
608
+ # for the router.
609
+ def open_room_app(now = @clock.call)
610
+ # Rooms sets spectate_outside only for the host's own certificate.
611
+ return unless @config["namespace"] && @config["spectate_outside"]
612
+ return unless @net && @games.values.any? { |g| spectates?(g) }
613
+ if @room && !@room.running?
614
+ @log.call("games: #{node}/#{room_app}: connection lost: #{@room.lost_reason}")
615
+ close_room_app
616
+ end
617
+ return if @room || now < @room_retry_at.to_i
618
+ @room_retry_at = now + SPECTATE_RETRY_MS
619
+ opts = { node: node, app: room_app }
620
+ opts[:credentials] = @config["credentials"] if @config["credentials"]
621
+ conn = ::Asterism.connect(locator, **opts)
622
+ begin
623
+ conn.on_error { |e, where| @log.call("games: #{node}/#{room_app}: #{where}: #{e.class}: #{e.message}") }
624
+ ::Asterism::LOCK.synchronize { room_up(conn) }
625
+ conn.start
626
+ rescue StandardError
627
+ @room = nil
628
+ conn.close
629
+ raise
630
+ end
631
+ @log.call("games: #{node}/#{room_app} up outside #{@config['namespace']} " \
632
+ "(#{@spectate_topics.keys.map { |n| "@shared/#{spectate_name(n)}" }.join(', ')})")
633
+ rescue ::Asterism::Error, ArgumentError, SystemCallError => e
634
+ @log.call("games: cannot open #{node}/#{room_app} outside #{@config['namespace']}: #{e.message}; " \
496
635
  "again in #{SPECTATE_RETRY_MS / 1000} s")
497
636
  end
498
637
 
499
- def close_spectate
500
- @spectate_token = nil # withdrawn with its connection
501
- @spectate_token_on = nil
502
- @spectate_retry_at = nil # a new connection tries at once
503
- c = @spectate_conn
504
- @spectate_conn = nil
505
- c&.close
638
+ # Takes conn as the room's application: its types and topics.
639
+ def room_up(conn)
640
+ @room = conn
641
+ declare_spectate(conn)
642
+ end
643
+
644
+ def close_room_app
645
+ @room_retry_at = nil # a new connection tries at once
646
+ app = @room
647
+ @room = nil
648
+ @spectate_topics = {}
649
+ app&.close
506
650
  rescue ::Asterism::Error
507
651
  nil
508
652
  end
@@ -520,33 +664,21 @@ module AsterismGames
520
664
  @games.each_value { |g| g.save(now, force: true) }
521
665
  end
522
666
 
523
- # The host's status: Asterism's (net.status, asterism 0.8.0, the
524
- # version the Gemfile pins), which puts it when it changes and answers
525
- # who asks. An asterism 0.7.1 (the gem's lowest) has no status: then
526
- # the same thing by hand on the connection's session (a queryable on
527
- # the key and a put when the counts change).
667
+ # The host's status: Asterism's (net.status), which puts it on
668
+ # asterism/<node>/<app>/@status when it changes and answers who asks its
669
+ # profile (@profile?status, asterism's docs/protocol.md 8).
528
670
  class TablesStatus
529
671
  def initialize(net, node, app)
530
672
  @net = net
531
673
  @key = "asterism/#{node}/#{app}/@status"
532
- return if @net.respond_to?(:status)
533
- @bytes = nil
534
- @qa = net.session.queryable(@key) { |q| q.reply(@key, @bytes) if @bytes }
535
674
  end
536
675
 
537
676
  # Sets the status; returns the size of the put (key and payload) when
538
677
  # it changed, nil when it did not.
539
678
  def set(st)
540
- bytes = ::Asterism::Codec.pack(st)
541
- if @net.respond_to?(:status)
542
- return nil if @net.status == st
543
- @net.status(st)
544
- else
545
- return nil if bytes == @bytes
546
- @bytes = bytes
547
- @net.session.put(@key, bytes)
548
- end
549
- @key.bytesize + bytes.bytesize
679
+ return nil if @net.status == st
680
+ @net.status(st)
681
+ @key.bytesize + ::Asterism::Codec.pack(st).bytesize
550
682
  end
551
683
  end
552
684
 
@@ -39,6 +39,18 @@ module AsterismGames
39
39
  "OTHELLO_AI_TURN_SECONDS" => ["ai_turn_ms", 1000] }
40
40
 
41
41
  SAVE_FORMAT = 1
42
+ # The state's type (public_view's keys; mb, the members as [name,
43
+ # seat, flags], an any[]).
44
+ STATE_TYPE = "#{TYPE_PREFIX}othello.state".freeze
45
+ TYPES = {
46
+ STATE_TYPE => [[%w[v int], %w[t str], %w[st str], %w[nm str[]], %w[sk int[]], %w[aw int], %w[ow int],
47
+ %w[mb any[]], %w[ts int], %w[ai str[]], %w[ak str[]], %w[bd str], %w[tn int], %w[n int], %w[last str],
48
+ %w[lg str], %w[cnt int[]], %w[dl int], ["res", "#{TYPE_PREFIX}othello.result"]],
49
+ "An othello table as everybody sees it (asterism-games README \"Views\" of othello); " \
50
+ "mb: the members as [name, seat, flags]"],
51
+ "#{TYPE_PREFIX}othello.result" => [[%w[w int], %w[why str], %w[b int], %w[wc int]],
52
+ "How a game ended: the seat that won (-1 a draw), why, the counts"]
53
+ }.freeze
42
54
  SEATS = 2
43
55
  DEFAULTS = AsterismGames::Game::DEFAULTS.merge(
44
56
  "turn_ms" => 0, # a human's time for a move (0: none); then the CPU plays it
@@ -176,9 +176,10 @@ module AsterismGames
176
176
  host, app, tname = @path.split("/")
177
177
  # The state the host puts after every change: a newer one wakes the
178
178
  # ticker (no call waits on the receiving thread).
179
- @net.session.subscribe("asterism/#{host}/#{app}/#{tname}/state") do |sample|
180
- v = Asterism::Codec.unpack(sample.payload)
181
- @wake << true if v.is_a?(Hash) && (!@view.is_a?(Hash) || v["v"] != @view["v"])
179
+ @net.subscribe("#{host}/#{app}/#{tname}/state") do |v, info|
180
+ # Values lost, or the host started again (protocol 2 keeps no
181
+ # current value): the view is asked again whatever this one says.
182
+ @wake << true if Play.ask_again?(info) || (v.is_a?(Hash) && (!@view.is_a?(Hash) || v["v"] != @view["v"]))
182
183
  end
183
184
  refresh
184
185
  @timer = Thread.new { timer_loop } if @out.tty?
@@ -106,9 +106,10 @@ module AsterismGames
106
106
  # The state the host puts after every change (and every 5 s): a newer
107
107
  # one wakes the thread that asks the view (no call waits on the
108
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"])
109
+ @net.subscribe("#{host}/#{app}/#{tname}/state") do |v, info|
110
+ # Values lost, or the host started again (protocol 2 keeps no
111
+ # current value): the view is asked again whatever this one says.
112
+ @wake << true if Play.ask_again?(info) || (v.is_a?(Hash) && (@view.nil? || v["v"] != @view["v"]))
112
113
  end
113
114
  refresh
114
115
  # And a view every VIEW_EVERY_S anyway: the host sees that this member
@@ -154,6 +154,15 @@ module AsterismGames
154
154
  end
155
155
  end
156
156
 
157
+ # Whether a value of a table's state says to ask the table again
158
+ # (Asterism's protocol 2 keeps no current value on the network): values
159
+ # were lost before it (info.lost) or the host started a new run
160
+ # (info.restarted?). The play clients then ask their view at once.
161
+ def ask_again?(info)
162
+ return false unless info.respond_to?(:lost)
163
+ info.lost.to_i.positive? || info.restarted? ? true : false
164
+ end
165
+
157
166
  # The first table whose name starts with prefix (looking again whenever
158
167
  # a node joins, for wait seconds); nil when none.
159
168
  def find_tables(net, prefix, joined, wait: 10)
@@ -23,7 +23,8 @@ module AsterismGames
23
23
  # for the room, which joins the router in the room's key space
24
24
  # (namespace room/<slot>) and serves the tables there, as browsers in the
25
25
  # room see them (asterism/games/games/<table>). One process per room,
26
- # since the asterism gem keeps one connection a process. Other rooms
26
+ # since every room's host is the same <node>/<app>, which one program
27
+ # opens once (the asterism gem refuses a second copy). Other rooms
27
28
  # (another app, or an app no configured table's game shows) get nothing.
28
29
  #
29
30
  # Which certificate (console 0.3.0, its docs/rooms.md "Seats"): when
@@ -264,7 +265,8 @@ module AsterismGames
264
265
  # "Rooms", "Who joins"); trust: trust_for's; app: what the room's link
265
266
  # opens (its game's tables; nil: every table of rooms.tables); group:
266
267
  # its invite link's group (nil: none), kept with its games' records.
267
- # spectate_label / spectate_outside: README "Spectate".
268
+ # spectate_label (the room's application and its tables' spectate
269
+ # names) / spectate_outside: README "Spectate".
268
270
  def child_config(slot, tar, dir, room_id = nil, trust = { "own" => false, "seats" => [], "prefix" => nil }, app = nil,
269
271
  group = nil)
270
272
  tables = app ? Config.tables_for_app(@rooms["tables"], app) : @rooms["tables"]
@@ -275,10 +277,11 @@ module AsterismGames
275
277
  "trusted_nodes" => trust["seats"], "trusted_prefix" => trust["prefix"],
276
278
  "seat_reuse_ms" => @rooms["seat_reuse_seconds"].to_i * 1000, "ai_usage_file" => ai_usage_file,
277
279
  "records_db" => records_db, "records_place" => "room", "records_group" => group,
278
- # README "Spectate": the tables' spectate keys, outside the room
279
- # (asterism/<node>/spectate/room<slot>-<table>/state), put on a
280
- # connection without the namespace, with the host's own certificate
281
- # only (the slot's certificate of a console before 0.3.0 is the room's).
280
+ # README "Spectate": the tables' spectate keys in the node's shared
281
+ # place (asterism/<node>/@shared/spectate/room<slot>-<table>/state),
282
+ # put by the room's application room<slot> on a connection without
283
+ # the namespace, with the host's own certificate only (the slot's
284
+ # certificate of a console before 0.3.0 is the room's).
282
285
  "spectate_label" => "room#{slot}", "spectate_outside" => trust["own"] ? true : false }
283
286
  end
284
287
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism-games
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: asterism
@@ -16,20 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '0.7'
20
- - - ">="
21
- - !ruby/object:Gem::Version
22
- version: 0.7.1
18
+ version: '0.14'
23
19
  type: :runtime
24
20
  prerelease: false
25
21
  version_requirements: !ruby/object:Gem::Requirement
26
22
  requirements:
27
23
  - - "~>"
28
24
  - !ruby/object:Gem::Version
29
- version: '0.7'
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: 0.7.1
25
+ version: '0.14'
33
26
  description: 'A framework for the server side of a game on an Asterism network: the
34
27
  host (tables exposed as Asterism objects, the state put and saved, who is there,
35
28
  time), the rooms of an Asterism console and the permanent tables next to them. A
@@ -37,7 +30,6 @@ description: 'A framework for the server side of a game on an Asterism network:
37
30
  rules and the methods it exposes. Comes with othello and a shared drawing board
38
31
  as samples, terminal clients for othello and mahjong (asterism-games play), and
39
32
  a mahjong commentator that draws a table in the browser (asterism-games commentate).'
40
- email:
41
33
  executables:
42
34
  - asterism-games
43
35
  extensions: []
@@ -121,7 +113,6 @@ metadata:
121
113
  changelog_uri: https://github.com/ruby-asterism/asterism-games/blob/main/CHANGELOG.md
122
114
  bug_tracker_uri: https://github.com/ruby-asterism/asterism-games/issues
123
115
  rubygems_mfa_required: 'true'
124
- post_install_message:
125
116
  rdoc_options: []
126
117
  require_paths:
127
118
  - lib
@@ -129,15 +120,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
129
120
  requirements:
130
121
  - - ">="
131
122
  - !ruby/object:Gem::Version
132
- version: '3.2'
123
+ version: '3.4'
133
124
  required_rubygems_version: !ruby/object:Gem::Requirement
134
125
  requirements:
135
126
  - - ">="
136
127
  - !ruby/object:Gem::Version
137
128
  version: '0'
138
129
  requirements: []
139
- rubygems_version: 3.4.19
140
- signing_key:
130
+ rubygems_version: 3.6.9
141
131
  specification_version: 4
142
132
  summary: 'Game hosts on Asterism: write the rules of a game, get tables people play
143
133
  over Zenoh'