asterism-games 0.17.0 → 0.18.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73a018990c0459db79342e9b10b9272e6ab214b9cf50d09e326130921828d128
4
- data.tar.gz: 2b4fffccb723ce03389c5a1fd96175556a1571fd32929095c70542b3a8bfcadc
3
+ metadata.gz: 8555b735e1a25aff6167fe382f450dd367c4c6d81d13497c932265cf3a61a5f4
4
+ data.tar.gz: 5185c71e72d56963cebb26d480bf647763dd51156a0285db1104f6a9f600ae22
5
5
  SHA512:
6
- metadata.gz: 1856261b67b3b6e45090cbe8d9e2b385a274ee03798ac03d2ad8f5bfa0cb18b1a9a26f5c8926f748380a74ed39bf1e89dec433302f2f3ae3892dc2dbff569b89
7
- data.tar.gz: b34655b8d465dffc37e388b0dbd9f0203a26a39488b049541953e326e51d0d6a33c99ca5e1554b5a86364da2ed721373bf99cafc5fdf72195c7e6f282e2b8a71
6
+ metadata.gz: 48a9fc1196c4f3fb5b2b2b8cf8c9f3c4f0682a4b1beee018ec0518c2c131068c5113ccb309d008f97a8ce479dd2ba994a2e268b5bcd50869bca5ee692144a030
7
+ data.tar.gz: 8227908a95b02825a6e53ba1c1d75d9008f33b802fca25c82b41c03b0194b3d58e3f7c317ab3e6df7f770140b9a34410467ec3c0ea0a2be189ddc56ffdabca5b
data/CHANGELOG.md CHANGED
@@ -3,6 +3,41 @@
3
3
  Versions before 0.11.1 are noted in the README where each feature is
4
4
  described ("games 0.10.0" and the like).
5
5
 
6
+ ## 0.18.0
7
+
8
+ - **Spectate** (README "Spectate"): the host puts a table's spectate view
9
+ on `asterism/<node>/spectate/<table>/state` (a room's table as
10
+ `room<slot>-<table>`), a key outside the players' role
11
+ (`asterism/*/games/**`) and outside every room, for a console's
12
+ spectators and viewers and for the asterism gem's command line and MCP
13
+ bridge. The framework's `Game#spectate_view` (nil by default: nothing
14
+ is put) and `Game#spectate_delay_ms`; the host takes the view when it
15
+ puts the state, keeps it for the delay and puts each one in order, and
16
+ puts the last one again every 5 s. A room's host puts its tables' views
17
+ on a second connection of its own (the host's own certificate, no
18
+ namespace), never into the room; with a slot's certificate (a console
19
+ before 0.3.0) nothing. The application `spectate` is on the network (a
20
+ liveliness token) while the host puts.
21
+ - The mahjong table's spectate view: the table in words as `look` gives
22
+ it, with every seat's hand and drawn tile (the hands a spectator member
23
+ sees); never the wall, the ura dora, an indicator not turned yet, a node
24
+ or a token. Held for the table's spectator delay (none by default).
25
+ Othello and the drawing board put nothing.
26
+ - The console's role `games-host` needs two more lines for it (Read and
27
+ write `asterism/${node}/spectate`, `asterism/${node}/spectate/**`); the
28
+ console's docs/rooms.md has them and the `spectator` role. Without them
29
+ the router drops the puts and nothing else changes.
30
+ - The host's last log line counts the spectate puts and their largest
31
+ size apart from the players' messages.
32
+
33
+ ## 0.17.1
34
+
35
+ - `bin/othello-tty` is gone: `asterism-games play othello` is the same
36
+ client, and the gem installs `asterism-games` only.
37
+ - The first version published on rubygems.org: `gem install
38
+ asterism-games` gives `asterism-games play` with the asterism gem and
39
+ nothing else. The host and the tables are 0.17.0's.
40
+
6
41
  ## 0.17.0
7
42
 
8
43
  - **`asterism-games play <game>`** (README "Playing in a terminal"): the
data/README.md CHANGED
@@ -46,7 +46,7 @@ and the image"): the framework with the two sample games is the gem
46
46
  └──requires──> asterism_games <───┘
47
47
  (Host, Rooms, Config, Store, Game, Remote) ──> asterism
48
48
 
49
- the gem asterism-games: lib/ (framework, othello, board, the terminal clients), bin/asterism-games, bin/othello-tty
49
+ the gem asterism-games: lib/ (framework, othello, board, the terminal clients), bin/asterism-games
50
50
  the image: all of the above, app/ (mahjong), config/, the Gemfile's gems
51
51
  ```
52
52
 
@@ -62,7 +62,6 @@ the console ("Permanent tables"; the server's setup: docs/install.md).
62
62
  | `asterism-games.gemspec` | the gem: what it holds and needs ("The gem and the image") |
63
63
  | `bin/asterism-games` | the host (loads the framework and the games); `status`; `play <game>`, a table in this terminal ("Playing in a terminal"); `help` |
64
64
  | `bin/mahjong-bots` | players without screens, for trying a mahjong table (one process each; not in the gem) |
65
- | `bin/othello-tty` | `asterism-games play othello`, under its old name |
66
65
  | `lib/asterism_games/play.rb`, `lib/asterism_games/play/` | the terminal clients ("Playing in a terminal"): the options, `othello.rb`, `mahjong.rb` with `mahjong_text.rb` (tiles as text, widths, reading the input) and `mahjong_screen.rb` (the screen); they need neither the framework nor riichi_engine |
67
66
  | `lib/asterism_games.rb` | the framework, without any game |
68
67
  | `lib/asterism_games/game.rb` | the base of a game: the registry, the members and tokens, who joins in a room, the views, the time, saving ("Writing a game") |
@@ -90,7 +89,7 @@ the console ("Permanent tables"; the server's setup: docs/install.md).
90
89
  game host of your own: the framework, the two sample games that need
91
90
  nothing else (othello, the drawing board), `bin/asterism-games` (with
92
91
  `play`, the terminal clients of othello and mahjong: a mahjong table needs
93
- riichi_engine, its client does not) and `bin/othello-tty`. It depends on
92
+ riichi_engine, its client does not). It depends on
94
93
  the asterism gem only (0.7.1 or later in
95
94
  0.x; what came after 0.7.1, such as `@status` and `on_app_join`, is used
96
95
  when there). Not in it: mahjong (`app/`, it needs the riichi_engine fork
@@ -402,7 +401,7 @@ list's flag), a mahjong seat is played for after a short wait, the status
402
401
  counts it. The host learns it from Asterism, not from the calls (games
403
402
  0.8.0), and by the player's table app (games 0.10.0): a table app runs as
404
403
  the application **`game`** of its node (the boards' and the browsers'
405
- table apps, `bin/othello-tty`, `bin/mahjong-bots` do), and the host
404
+ table apps, `asterism-games play`, `bin/mahjong-bots` do), and the host
406
405
  follows the liveliness token `asterism/<node>/game`. The Net's
407
406
  `on_app_leave` tells it that the table app left the network (a closed
408
407
  browser tab, a board's app that closed, a process killed, a line that
@@ -447,7 +446,7 @@ without a call (away after `away_seconds`, measured 13.6 s with the default
447
446
  nothing in the host changes with it.
448
447
 
449
448
  The screens follow the host the same way: the tables' apps (fmruby-core
450
- `flash/app/game/`) and `bin/othello-tty` take a host's tables off their
449
+ `flash/app/game/`) and `asterism-games play` take a host's tables off their
451
450
  list when its node leaves and look for them again when a node joins
452
451
  (Asterism's `each` reads the liveliness they already follow: no message is
453
452
  sent); at a table, a host that leaves shows the connection lost, the
@@ -513,6 +512,84 @@ the host offers: its money of the day (`usd`), the limit it stops at
513
512
  - **Size.** Five tables take about 300 bytes (the largest put is in the
514
513
  log's `largest message` with the others).
515
514
 
515
+ ## Spectate
516
+
517
+ A table can also be watched from outside it, with what the players do not
518
+ see of each other (games 0.18.0): the host puts a game's spectate view on
519
+ a key no player's role reads and no room reaches, for an admin, a
520
+ spectator's row of the console, or an AI assistant that only looks (the
521
+ asterism gem's command line and MCP bridge). The mahjong table puts the
522
+ four hands there; othello and the drawing board put nothing (they hide
523
+ nothing: their state is the whole game).
524
+
525
+ - **The key.** `asterism/<node>/spectate/<table>/state`: the host's node,
526
+ the application `spectate` (not the host's `games`, so the players' role
527
+ `asterism/*/games/**` does not match it), and the table. A room's table
528
+ is `room<slot>-<table>` there (`asterism/games/spectate/room01-mahjong1/state`),
529
+ so the rooms' tables and the permanent ones never meet. The value is
530
+ MessagePack, like the state.
531
+ - **Outside the rooms.** Every key a room's host writes on its own
532
+ connection gets the room's namespace (`room/<slot>/...`), which every
533
+ seat of the room reads whole. So a room's host opens a second connection
534
+ of its own for the spectate keys, with its own certificate and without
535
+ the namespace: the key is then on the registered side, where the seats'
536
+ rules do not reach. It does so only with the host's own certificate (a
537
+ console from 0.3.0 lists the host in `rooms.json`'s `"hosts"`); with the
538
+ slot's certificate of an older console it puts nothing (that
539
+ certificate is the room's). The permanent tables' host and a host of its
540
+ own put on their one connection.
541
+ - **What the mahjong table puts.** The table in words, as `look` gives it
542
+ ("Playing from a program") to a member without a seat, with each seat's
543
+ `hand` and `drawn` tile, the same four hands a spectator member's view
544
+ has (`sh`, `sd`): the seats (name, kind, wind, score, riichi, river,
545
+ melds, hand count, hand, drawn), the round (wind, number, honba, riichi
546
+ sticks, dealer, wall left, turn, phase), the dora indicators turned so
547
+ far, the last discard, the result and the final ranks, the rules, and
548
+ `delay_s`. Never the wall, the ura dora, an indicator not turned yet, a
549
+ member's node or token (`test/test_spectate.rb` looks for them):
550
+
551
+ ```
552
+ games/spectate/mahjong1/state
553
+ {"table":"mahjong1","status":"play","sv":212,
554
+ "seats":[{"seat":0,"name":"CPU1","kind":"cpu","score":21000,"wind":"north","riichi":false,
555
+ "river":["2z","4z","5z","1m",...],"melds":[{"type":"pon","tiles":["7z","7z","7z"],"from":1}],
556
+ "hand_count":10,"hand":["7m","7m","2p","3p","3p","4p","4p","5s","6s","7s"],"drawn":null}, ...],
557
+ "round":{"wind":"east","number":2,"honba":0,"riichi_sticks":1,"dealer":1,"wall_left":19,"turn":3,"phase":"discard"},
558
+ "dora_indicators":["9p"],"last_discard":{...},"rules":{...},"delay_s":0}
559
+ ```
560
+ - **The delay.** The table's spectator delay ("Spectators": the setting
561
+ `spectator_delay_minutes`, or the owner's choice in the lobby; none by
562
+ default). The host takes the view when it puts the state (after a change,
563
+ and every 5 s), keeps each one in order and puts it once it is the delay
564
+ old, each in turn: what is put is what the table was the delay ago,
565
+ never sooner, also across a round's end or when the delay is chosen
566
+ again (a shorter delay lets out what is old enough at once). A host
567
+ started again starts with nothing kept (nothing is put for the delay).
568
+ At most 8 MB is kept per table; past that the oldest is dropped (never
569
+ shown rather than shown early). The last one put is put again every
570
+ 5 s, so a new subscriber has it within that.
571
+ - **Who reads it.** The console's docs/rooms.md, "Watching the tables
572
+ from outside": the host's role `games-host` needs
573
+ `asterism/${node}/spectate` and `asterism/${node}/spectate/**` in "Read
574
+ and write" (without them the router drops the puts, and nothing else
575
+ changes); a role `spectator` (Read `asterism/*/spectate` and
576
+ `asterism/*/spectate/**`, Watch `asterism/**`) reads them with the
577
+ command line, and the `viewer` role (Read `asterism/**`) with the
578
+ command line and the MCP bridge. The players' role `games` and the
579
+ rooms' seats do not reach them.
580
+ - **Looking.** With the asterism gem's command line (asterism 0.10.0's
581
+ `object` commands): `asterism object echo 'games/spectate/*' --credentials
582
+ spectator.tar` (each put as it comes; `-o json` a line each), `asterism
583
+ object get games/spectate/mahjong1 --credentials viewer.tar` (the next
584
+ one). The MCP bridge (`asterism mcp --credentials viewer.tar`):
585
+ `asterism_values` with the pattern `games/spectate/*/state`. The bridge
586
+ subscribes to `asterism/**`, which a `spectator` row may not, so it
587
+ needs the viewer role. The host's application `spectate` is on the
588
+ network (a liveliness token) while it puts, so the values show as an
589
+ application that is there.
590
+ - **Writing a game.** `spectate_view(now)` (nil, the default: nothing) and
591
+ `spectate_delay_ms` (0) of `AsterismGames::Game`.
592
+
516
593
  ## Writing a game
517
594
 
518
595
  A game is two classes on the framework: the table (`AsterismGames::Game`:
@@ -592,6 +669,7 @@ What the framework gives a game:
592
669
  | `changed`, `dirty?`, `unsaved?` | a change to save and put; a change only one member's view shows (`@unsaved = true`) is saved, not put |
593
670
  | `to_saved` / `restore` / `resume_clock`, `SAVE_FORMAT` | the saved form (one JSON file per table, `state_dir/<table>.json`, written to a temporary file and renamed); a state of another format is not read. Times are saved as the time left and resumed with the host's clock |
594
671
  | `tell(sub, value)`, `SAVE_HOLD_MS` | more than the state: the host puts `value` on `asterism/<node>/<app>/<table>/<sub>` on its next step, in the order told, before the state (the board's strokes). A game that changes many times a second writes its saved state at most once per `SAVE_HOLD_MS` (the host writes what is held when it stops) |
672
+ | `spectate_view(now)`, `spectate_delay_ms` | what the host puts on `asterism/<node>/spectate/<table>/state` for those who watch from outside the table ("Spectate"), held for the delay; nil (the default): nothing. It may hold what the players do not see of each other, never what nobody knows yet |
595
673
  | `summary(now)` | the counts the status shows (`st`, `members`, `humans`, `cpus`, `away`, and the game's own): no names, no nodes, no tokens. `busy` (`bin/asterism-games status --idle`) is `humans - away` |
596
674
  | `settings`, `tables`, `screens`, `DEFAULTS` | the table keys of the settings file and the environment, the tables a host serves by default, the table apps that get the tables in a room, the table's options |
597
675
 
@@ -759,6 +837,10 @@ hand.
759
837
  CPU) or every 20 s (a person) does not change how far behind the
760
838
  spectators are. A host started again starts the delay over (the past is
761
839
  not saved).
840
+ - **From outside the table.** The same four hands, in words and as old
841
+ as the spectators' view, go on `asterism/<node>/spectate/<table>/state`
842
+ for a console's spectators and viewers ("Spectate"), a key the players
843
+ cannot read.
762
844
  - **The default is no delay.** The spectators see the hands as they are:
763
845
  good for watching a game, and for an audience in the same room. A seated
764
846
  player who joins the same table from a second screen sees the others'
@@ -1584,8 +1666,7 @@ squares next to a corner last), then the most discs turned.
1584
1666
 
1585
1667
  ### In a terminal: `asterism-games play othello`
1586
1668
 
1587
- (`bin/othello-tty` until games 0.17.0; it is still there and runs this,
1588
- with the same options.) CRuby and the asterism gem only (no Family
1669
+ (`bin/othello-tty` until games 0.17.0; it is gone in 0.17.1.) CRuby and the asterism gem only (no Family
1589
1670
  mruby). The options are every client's ("Playing in a terminal"). It joins the table,
1590
1671
  draws the board as text at every change (it takes the state the host puts
1591
1672
  and asks its view; X black, O white, `*` the squares it may play) and on a
@@ -1820,6 +1901,13 @@ ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 bundle exec rake test # also through a
1820
1901
  tonpuu game against three CPUs each to the end from scripted input
1821
1902
  (letters, tiles, calls, passes, OKs; `PLAY_TRANSCRIPT_DIR` keeps what
1822
1903
  they showed).
1904
+ - `test_spectate.rb`: the spectate view (README "Spectate"; its
1905
+ framework part in `rake test:gem` too): nothing by default (othello, the
1906
+ board), the keys of the permanent tables and of a room, what the rooms
1907
+ give their hosts, the mahjong view (the four hands, no token, node, wall
1908
+ or ura dora), a CPU game on the host, the delay (0, and 3 s across a
1909
+ round's end, in order, never sooner), a room's table on the connection
1910
+ outside the room only, and the last put again after 5 s.
1823
1911
  - `test_play_commands.rb`: `--commands "sit,cpu,start"` against a table
1824
1912
  in this process: they wait for the lobby when a game is going on as
1825
1913
  the client joins (and then sit, fill and start), the owner's wait for
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.17.0
1
+ 0.18.0
@@ -38,7 +38,9 @@ module AsterismGames
38
38
  # (the board's strokes) holds its writes
39
39
  #
40
40
  # and writes the rules: tick(now), public_view(now) (and spectator_view,
41
- # summary when they differ), to_saved(now) / restore(data, now) and the
41
+ # summary when they differ; spectate_view and spectate_delay_ms for what
42
+ # the host puts outside the players' keys, README "Spectate"),
43
+ # to_saved(now) / restore(data, now) and the
42
44
  # methods its Remote calls. A game that hands out more than its state
43
45
  # (the board's strokes) tells it with tell(sub, value): the host puts
44
46
  # value on asterism/<node>/<app>/<table>/<sub>, in the order told, on its
@@ -153,6 +155,22 @@ module AsterismGames
153
155
  spectator_view(now)
154
156
  end
155
157
 
158
+ # What those who watch from outside the table see (README "Spectate"):
159
+ # the host puts it on asterism/<node>/spectate/<table>/state (a room's
160
+ # table: room<slot>-<table>), a key no player's role reads and outside
161
+ # every room, after spectate_delay_ms. It may hold what the players do
162
+ # not see of each other (the mahjong hands), never what nobody knows
163
+ # yet (a wall) nor a token. nil (the default): nothing is put.
164
+ def spectate_view(_now)
165
+ nil
166
+ end
167
+
168
+ # How old what spectate_view gives is when the host puts it (ms; 0: at
169
+ # once). A game with a spectator delay gives it here.
170
+ def spectate_delay_ms
171
+ 0
172
+ end
173
+
156
174
  # Counts for the host's status (GET /status of the rooms' host, README
157
175
  # "Rooms"): no names, no nodes, no tokens. "st" (the game's status),
158
176
  # "members", "humans" (members in a seat), "cpus", "away" (humans not
@@ -52,6 +52,7 @@ module AsterismGames
52
52
  @methods = {}
53
53
  @docs = {}
54
54
  @put_at = {}
55
+ @spec = {} # table => {"q" => [[ms, bytes], ...], "bytes", "last", "out", "at"} (README "Spectate")
55
56
  @stats = Hash.new(0)
56
57
  config["tables"].each do |t|
57
58
  klass = AsterismGames.game(t["game"]) or raise ArgumentError, "unknown game #{t['game'].inspect}"
@@ -132,7 +133,8 @@ module AsterismGames
132
133
  end
133
134
  save_all
134
135
  @ai&.stop
135
- @log.call("games: largest message #{@stats['max_bytes']} B (#{@stats['max_what']})")
136
+ @log.call("games: largest message #{@stats['max_bytes']} B (#{@stats['max_what']})" \
137
+ "#{"; spectate #{@stats['spectate']} puts, at most #{@stats['spectate_max_bytes']} B" if @stats['spectate'].positive?}")
136
138
  if @ai&.any?
137
139
  n, avg, max = @ai.times
138
140
  @log.call("games: AI players: #{@ai.stats.to_h.inspect}; #{n} decisions, #{avg} ms on average, #{max} ms at most; " \
@@ -195,6 +197,7 @@ module AsterismGames
195
197
  @log.call("games: connection lost: #{net.lost_reason}")
196
198
  return
197
199
  end
200
+ open_spectate
198
201
  ::Asterism::LOCK.synchronize { step }
199
202
  sleep LOOP_S
200
203
  end
@@ -234,6 +237,7 @@ module AsterismGames
234
237
  end
235
238
 
236
239
  def down
240
+ close_spectate
237
241
  @net = nil
238
242
  @tables_status = nil
239
243
  @connected = false
@@ -277,12 +281,15 @@ module AsterismGames
277
281
  changed = true
278
282
  g.save(now)
279
283
  put_state(name, g, now)
284
+ take_spectate(name, g, now)
280
285
  elsif g.unsaved?
281
286
  # A change only one member's own view shows: saved, not put.
282
287
  g.save(now)
283
288
  elsif now - @put_at.fetch(name, 0) >= PUT_EVERY_MS
284
289
  put_state(name, g, now)
290
+ take_spectate(name, g, now)
285
291
  end
292
+ put_spectate(name, g, now)
286
293
  end
287
294
  write_status(now) if @config["status_file"] && now - @status_at.to_i >= STATUS_EVERY_MS
288
295
  tell_tables(now, changed: changed)
@@ -368,6 +375,138 @@ module AsterismGames
368
375
  @log.call("games: put #{name}/#{sub}: #{e.message}")
369
376
  end
370
377
 
378
+ # ------------------------------------------------------------ spectate
379
+ #
380
+ # What a game gives for those who watch from outside the table
381
+ # (Game#spectate_view, README "Spectate"): taken when the state is put
382
+ # (a change, and every PUT_EVERY_MS), kept in order and put once it is
383
+ # Game#spectate_delay_ms old, each in turn, on spectate_key. Nothing is
384
+ # put sooner than the delay asks, whatever happens between (a round
385
+ # ends, the delay is chosen again); a host started again starts with
386
+ # nothing kept. The last one put is put again every PUT_EVERY_MS, so a
387
+ # new subscriber has it within that.
388
+
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
+ # What one table keeps for its delay at most (bytes); the oldest goes
393
+ # first (it is then never shown, rather than shown early).
394
+ SPECTATE_KEEP_BYTES = 8 * 1024 * 1024
395
+ # How often a room's host tries again to open its connection outside
396
+ # the room (ms).
397
+ SPECTATE_RETRY_MS = 30_000
398
+
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)
403
+ table = @config["namespace"] ? "#{spectate_label}-#{name}" : name
404
+ "asterism/#{node}/#{SPECTATE_APP}/#{table}/state"
405
+ end
406
+
407
+ # A room's part of its tables' spectate names: the setting (Rooms gives
408
+ # room<slot>), else the namespace without its slashes.
409
+ def spectate_label
410
+ @config["spectate_label"] || @config["namespace"].to_s.delete("/")
411
+ end
412
+
413
+ # Takes what the game gives now (nothing when it gives nil, or the same
414
+ # as last time).
415
+ def take_spectate(name, game, now)
416
+ v = game.spectate_view(now)
417
+ return if v.nil?
418
+ bytes = ::Asterism::Codec.pack(v)
419
+ s = (@spec[name] ||= { "q" => [], "bytes" => 0 })
420
+ return if bytes == s["last"]
421
+ s["last"] = bytes
422
+ s["q"] << [now, bytes]
423
+ s["bytes"] += bytes.bytesize
424
+ while s["bytes"] > SPECTATE_KEEP_BYTES && s["q"].size > 1
425
+ s["bytes"] -= s["q"].shift[1].bytesize
426
+ @stats["spectate_dropped"] += 1
427
+ end
428
+ end
429
+
430
+ # Puts what is old enough now, in order; or the last one again.
431
+ def put_spectate(name, game, now)
432
+ s = @spec[name] or return
433
+ q = s["q"]
434
+ unless q.empty?
435
+ delay = game.spectate_delay_ms.to_i
436
+ while q.first && q.first[0] <= now - delay
437
+ bytes = q.shift[1]
438
+ s["bytes"] -= bytes.bytesize
439
+ s["out"] = bytes
440
+ send_spectate(name, bytes, now)
441
+ @stats["spectate"] += 1
442
+ end
443
+ end
444
+ send_spectate(name, s["out"], now) if s["out"] && now - s["at"].to_i >= PUT_EVERY_MS
445
+ end
446
+
447
+ def send_spectate(name, bytes, now)
448
+ @spec[name]["at"] = now
449
+ sess = spectate_session or return
450
+ spectate_token(sess)
451
+ key = spectate_key(name)
452
+ # 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)
455
+ rescue ::Asterism::Error => e
456
+ @log.call("games: put spectate #{name}: #{e.message}")
457
+ end
458
+
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}; " \
496
+ "again in #{SPECTATE_RETRY_MS / 1000} s")
497
+ end
498
+
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
506
+ rescue ::Asterism::Error
507
+ nil
508
+ end
509
+
371
510
  # The largest message seen so far, key and payload together.
372
511
  def note_size(what, bytes)
373
512
  return unless bytes > @stats["max_bytes"]
@@ -5,7 +5,7 @@ require "asterism"
5
5
  module AsterismGames
6
6
  module Play
7
7
  # An othello table in a terminal (README "The othello table"):
8
- # `asterism-games play othello` (and bin/othello-tty, which calls it).
8
+ # `asterism-games play othello`.
9
9
  # It joins the table, shows the board as text at every change (the
10
10
  # state the host puts) and reads commands: watch, or sit and type the
11
11
  # squares ("d3"). When the table's host leaves the network (Asterism's
@@ -100,7 +100,7 @@ module AsterismGames
100
100
  def start
101
101
  r = @table.join(@name)
102
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"]
103
+ abort "play othello: #{@path}: join refused: #{r.is_a?(Hash) ? r['error'] : r.inspect}" unless r.is_a?(Hash) && r["token"]
104
104
  @token = r["token"]
105
105
  host, app, tname = @path.split("/")
106
106
  # The state the host puts after every change (and every 5 s): a newer
@@ -264,6 +264,7 @@ module AsterismGames
264
264
  # "Rooms", "Who joins"); trust: trust_for's; app: what the room's link
265
265
  # opens (its game's tables; nil: every table of rooms.tables); group:
266
266
  # its invite link's group (nil: none), kept with its games' records.
267
+ # spectate_label / spectate_outside: README "Spectate".
267
268
  def child_config(slot, tar, dir, room_id = nil, trust = { "own" => false, "seats" => [], "prefix" => nil }, app = nil,
268
269
  group = nil)
269
270
  tables = app ? Config.tables_for_app(@rooms["tables"], app) : @rooms["tables"]
@@ -273,7 +274,12 @@ module AsterismGames
273
274
  "status_file" => File.join(dir, "host.json"), "tables" => tables, "room_id" => room_id,
274
275
  "trusted_nodes" => trust["seats"], "trusted_prefix" => trust["prefix"],
275
276
  "seat_reuse_ms" => @rooms["seat_reuse_seconds"].to_i * 1000, "ai_usage_file" => ai_usage_file,
276
- "records_db" => records_db, "records_place" => "room", "records_group" => group }
277
+ "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).
282
+ "spectate_label" => "room#{slot}", "spectate_outside" => trust["own"] ? true : false }
277
283
  end
278
284
 
279
285
  # What the AI players cost, shared by every host process (README "AI
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism-games
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asterism
@@ -39,7 +39,6 @@ description: 'A framework for the server side of a game on an Asterism network:
39
39
  email:
40
40
  executables:
41
41
  - asterism-games
42
- - othello-tty
43
42
  extensions: []
44
43
  extra_rdoc_files: []
45
44
  files:
@@ -48,7 +47,6 @@ files:
48
47
  - README.md
49
48
  - VERSION
50
49
  - bin/asterism-games
51
- - bin/othello-tty
52
50
  - lib/asterism_games.rb
53
51
  - lib/asterism_games/ai.rb
54
52
  - lib/asterism_games/ai_hub.rb
data/bin/othello-tty DELETED
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # An othello table in a terminal: kept for the scripts and the habits of
5
- # before games 0.17.0. It is `asterism-games play othello` (README
6
- # "Playing in a terminal"), with the same options:
7
- #
8
- # bundle exec bin/othello-tty [--router tcp/127.0.0.1:7447] [--table games/games/othello1]
9
- # [--name NAME] [--node NODE] [--credentials alice.tar]
10
- # [--namespace room/01] [--commands "sit,cpu,start,auto"] [--until-over]
11
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
12
- require "asterism_games/play"
13
-
14
- exit AsterismGames::Play.main(["othello", *ARGV])