asterism-games 0.22.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6e27396e105e0e9ab5c096030c51cb529a437291460df76fc6b0510fc9dff6d
4
- data.tar.gz: 237c4a6b962e98dc9c3a7e1288c646989a256c1773dd8244ddc9146d5873db55
3
+ metadata.gz: 7bff07a0b8aceb0d5caa131285b00cd98198e8149496b512d22e8009c69f2614
4
+ data.tar.gz: fc8c5a631fd8a3e93d39fb0bdd8f11c7bab64d201bd1c1b3173455590e72a1c2
5
5
  SHA512:
6
- metadata.gz: e428ec7e87cc08c9727743c322448eb8a4c39c2d2fa0d6005eb4855dea335d3884d82ceae0a3da66e35e8ca88a07c03518a7c7329cc0d4f6b0395ba702b3da4f
7
- data.tar.gz: 1f61104e967413c4c5ee65a75a71ad9ca572007b8fdfa3dd0518257ea585be1e6798cc804dfa0752b6a8fa002bf41046efabe94b80df80fa7cb2766a9af19663
6
+ metadata.gz: cd5c7400e36ce7ef56787061ddd9470ef58994f1fdb49328cb0cdf22c4501510c718eceda73e0cd0ce6c7fde2acc749bbdba05e91e8d09c1c16e0e1cc4a9e64c
7
+ data.tar.gz: a7ab03721da396f87a387709edd5466da2bc97cb08ad1464808dd4a7cb2ca24035b2da13718b84734617052678bc76c3492e4958db203e2d1782f82142183740
data/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ described ("games 0.10.0" and the like).
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.23.0
9
+
10
+ Asterism wire protocol 2 (asterism 0.14.0; docs/install.md "From 0.22.0
11
+ to 0.23.0"). **Not compatible with protocol 1**: the host, the console
12
+ (0.4.0), the boards' firmware and the browser build move together. A
13
+ protocol 1 board or browser page still sees the tables' values, but its
14
+ calls (sit, play, view) are refused with the error kind `protocol`.
15
+
16
+ - Needs asterism 0.14.0 (`~> 0.14`): the host, `bin/mahjong-bots`,
17
+ `asterism-games play` and the commentary go through the connection
18
+ object `Asterism.connect` returns.
19
+ - The tables' keys are protocol 2 topics with their types:
20
+ `<table>/state` and what a game tells (`<table>/strokes` of the board)
21
+ are declared when the host connects; the games' types
22
+ (`asterism-games/othello.state`, `mahjong.state`, `mahjong.spectate`,
23
+ `board.strokes`, ...) are defined by the host, field types by name,
24
+ with `any` and repeated `[]` for the members, the final ranks and pao.
25
+ A reader finds them with `asterism topic list` before a value comes.
26
+ - The spectate views move to the node's shared place:
27
+ `asterism/<node>/@shared/spectate/<table>/state` (was
28
+ `asterism/<node>/spectate/<table>/state`). Outside the rooms the host's
29
+ own application `games` declares them; a room's host whose certificate
30
+ is its own opens the application `room<slot>` of the host's node outside
31
+ the room and declares its tables there (`spectate/room<slot>-<table>/state`).
32
+ Each value carries its sender.
33
+ - The present value is asked, not kept on the network: `games` and each
34
+ `room<slot>` expose the object `spectate`, whose `state(table)` answers
35
+ the last spectate view (as delayed as the key). The commentary asks it
36
+ at a sender's first value, after lost values, when the sender started
37
+ again and after it left; the play clients ask their view when a state
38
+ value says values were lost or the host started again.
39
+ - `record(id)`'s head gives `time` in ms since 1970 (was seconds). The
40
+ record book keeps seconds in its own files and database.
41
+ - The roles in the console (docs/install.md section 7): `games-host` adds
42
+ `asterism/${node}/*/@profile`, `asterism/${node}/*/@pubs`,
43
+ `asterism/${node}/@shared/**`, `asterism/${node}/room$*`,
44
+ `asterism/${node}/room$*/**` and Read `asterism/*/game/protocol/*`;
45
+ `spectator` reads `asterism/*/@shared/spectate/**`, the senders'
46
+ `@profile`, `@pubs` and liveliness tokens (`games/protocol/*`,
47
+ `room$*/protocol/*`: its `asterism topic list` shows the spectate keys),
48
+ and calls `spectate` under its own name.
49
+ asterism-console 0.4.0's update adds all of these to roles made before.
50
+ - The Gemfile takes asterism from a checkout with `ASTERISM_DIR` (a lock
51
+ of its own, `Gemfile.asterism_dir.lock`, not committed).
52
+ - The image can be built before an asterism release is published:
53
+ `ASTERISM_REF=<commit>` (a build arg of the Dockerfile and of
54
+ `docker-compose.build.yml`) takes asterism from its git repository at
55
+ that commit, locked in the image's copy of `Gemfile.lock`
56
+ (docs/install.md "Building the image yourself"). The image keeps the lock
57
+ its build resolved (`COPY . .` no longer puts the checkout's back).
58
+
8
59
  ## 0.22.0
9
60
 
10
61
  - Needs CRuby 3.4 or newer (`required_ruby_version >= 3.4`; 3.2 is past its end of life). The image is built on `ruby:3.4.11-slim`.
data/README.md CHANGED
@@ -166,6 +166,15 @@ seats and tokens). When the router goes away, it connects again every 3 s.
166
166
 
167
167
  asterism comes from rubygems at a fixed version (0.8.0); riichi_engine from
168
168
  the `fmrb` branch of the fork on GitHub (see "riichi_engine" below).
169
+ `ASTERISM_DIR=<a checkout of asterism>` takes asterism from that checkout
170
+ instead, for developing both at once: the Gemfile then uses a lock file of
171
+ its own, `Gemfile.asterism_dir.lock` (not committed; Bundler 2.6 or newer),
172
+ and `Gemfile.lock` keeps the released version.
173
+
174
+ ```
175
+ ASTERISM_DIR=../asterism bundle install
176
+ ASTERISM_DIR=../asterism bundle exec rake test
177
+ ```
169
178
 
170
179
  ## Settings
171
180
 
@@ -235,8 +244,10 @@ An Asterism console (0.2.2 and later) makes rooms: a key space of its own
235
244
  beyond the one a room needs anyway. The people in the room open the link
236
245
  and see the table in the screen's list, as on a home network
237
246
  (`games/games/mahjong1`).
238
- - **One process per room.** The asterism gem keeps one connection a
239
- process, so the supervisor forks a host process (Host) per room. It joins
247
+ - **One process per room.** Every room's host is the same application
248
+ (`<node>/games`, each in its room's namespace), which one program of the
249
+ asterism gem opens once, so the supervisor forks a host process (Host)
250
+ per room. It joins
240
251
  the router in the room's namespace (`room/<slot>`): its keys are the
241
252
  room's, and nothing of it reaches other rooms or the registered side (the
242
253
  router's ACL). **Which certificate** (0.5.0): when `rooms.json` lists the
@@ -248,7 +259,10 @@ An Asterism console (0.2.2 and later) makes rooms: a key space of its own
248
259
  router. With a console before 0.3.0 it joins with the slot's certificate
249
260
  (`certs/room-<slot>.tar`, in the same volume, read-only), as before. A
250
261
  console that lists the host while the host has no certificate set: the
251
- log says so and the rooms get no table that works. At most `rooms.max` rooms have tables (3); a room
262
+ log says so and the rooms get no table that works. With its own
263
+ certificate a room's host whose tables give a spectate view (mahjong)
264
+ is also the application `room<slot>` of the host's node outside the
265
+ room, for its tables' spectate keys ("Spectate"). At most `rooms.max` rooms have tables (3); a room
252
266
  beyond that gets none (the status says so). A host process that stops is
253
267
  started again after 1, 2, 4 ... 30 s.
254
268
  - **A slot used again.** The tables of a room are saved under
@@ -440,7 +454,7 @@ Whether the host sees the others come and go is the router's business:
440
454
  |---|---|---|
441
455
  | a home network (a plain zenohd, no access control) | yes | away at once (measured 10-20 ms after the node left; 6-14 ms after the table app closed while its node kept another application, games 0.10.0) |
442
456
  | a room (console 0.3.0, seats) | yes: a room's host reads the whole room, the seats' liveliness tokens included | away at once (measured 6-7 ms) |
443
- | the permanent tables (console roles `games-host` and `games`) | yes, when the role `games-host` has `Read asterism/*/game` (the console's docs/rooms.md): the router sends the host the players' table apps' liveliness tokens | away at once (measured 13 ms on the console's rehearsal) |
457
+ | the permanent tables (console roles `games-host` and `games`) | yes, when the role `games-host` has `Read asterism/*/game` and `asterism/*/game/protocol/*` (the console's docs/rooms.md; protocol 2's token is `asterism/<node>/game/protocol/2`): the router sends the host the players' table apps' liveliness tokens | away at once (measured 13 ms on the console's rehearsal) |
444
458
 
445
459
  A role `games-host` made before that line (with only `Watch asterism/**`)
446
460
  gets just the tokens on its own keys: the host then falls back to the time
@@ -456,6 +470,49 @@ sent); at a table, a host that leaves shows the connection lost, the
456
470
  screen stops asking its view (one every 30 s, should the word not come)
457
471
  and asks again as soon as the host is back.
458
472
 
473
+ ## Topics and types
474
+
475
+ What the host puts on its application's keys are topics of protocol 2
476
+ (asterism's `docs/protocol.md` 9-11): each table's `<table>/state` and
477
+ what a game tells (the board's `<table>/strokes`), declared with
478
+ `net.publish` on the host's connection when it connects, with their
479
+ types, so a reader finds them before any value comes (`asterism topic
480
+ list games/games`, `net.pubs_of("games", "games")`), and each value
481
+ carries its sequence number (a reader counts what it missed). The types
482
+ are defined with `net.define_type` (`asterism topic info
483
+ games/games/othello1/state`, `net.types_of`):
484
+
485
+ | Topic | Type | |
486
+ |---|---|---|
487
+ | `<table>/state` of othello | `asterism-games/othello.state` | the public view ("Views" of othello); `res` an `asterism-games/othello.result` |
488
+ | `<table>/state` of mahjong | `asterism-games/mahjong.state` | the public view ("Views"); `res` an `asterism-games/mahjong.result` with `wn` of `asterism-games/mahjong.win` |
489
+ | `<table>/state` of the board | `asterism-games/board.state` | the board's state |
490
+ | `<table>/strokes` of the board | `asterism-games/board.strokes` | each change of the picture ("The drawing board") |
491
+
492
+ A field of mixed parts (`mb`, the members as `[name, seat, flags]`) is
493
+ an `any[]`, an array of arrays `int[][]` (mahjong's `fin`) or `any[][]`
494
+ (a winner's `pa`). A game declares its types in its class (`Game::TYPES`, `STATE_TYPE`,
495
+ `TOLD_TYPES`).
496
+
497
+ The spectate keys (`asterism/<node>/@shared/spectate/<table>/state`,
498
+ "Spectate") are in the node's shared place (asterism's `docs/protocol.md`
499
+ 17), not under the host's application: the host declares each one on its
500
+ own connection with `net.publish("spectate/<table>/state", shared: :node,
501
+ type: ...)`, and a room's tables are declared by the room's application
502
+ `room<slot>` ("Spectate", "Outside the rooms"), each with the types
503
+ defined on its own connection (a reader reads a shared key's type from
504
+ its sender, `info.from`; `asterism topic list games/games`, `asterism
505
+ topic info games/@shared/spectate/mahjong1/state`):
506
+
507
+ | Shared topic | Type | |
508
+ |---|---|---|
509
+ | `spectate/<table>/state` of mahjong | `asterism-games/mahjong.spectate` | the spectate view ("Spectate"); `seats` of `asterism-games/mahjong.seat` |
510
+
511
+ A game declares them in its class too (`Game::SPECTATE_TYPE`,
512
+ `SPECTATE_TYPES`). Every value carries its sender and its sequence number
513
+ (`[seq, nil, "<node>/<app>"]`): a reader counts what it missed per
514
+ sender.
515
+
459
516
  ## Status
460
517
 
461
518
  The host tells its tables' counts on the network (games 0.9.0), as
@@ -501,18 +558,18 @@ the host offers: its money of the day (`usd`), the limit it stops at
501
558
  - **Who reads it.** `asterism watch` and `asterism tree` (the asterism
502
559
  gem's command line), its MCP bridge (`asterism_status`), the console's
503
560
  details of the host's node (console 0.3.4), or anyone with
504
- `Asterism.status_of("games", "games")`.
561
+ `net.status_of("games", "games")` (which asks the application's
562
+ profile, `asterism/<node>/<app>/@profile?status`).
505
563
  - **Where.** On a home network and on the permanent tables. The role
506
- `games-host` needs `asterism/${node}/*/@status` in "Read and write" (the
507
- console's docs/rooms.md): a `**` does not match the chunk `@status`. A
508
- room's host tells none (the rooms' rules let it write only its own
509
- place, which `@status` is not part of, and the console only watches who
510
- is in a room).
511
- - **The asterism gem.** With an asterism that has `Asterism.status`
512
- (0.8.0, the version this repository is pinned to) the host uses it
513
- (`net.status`); with an asterism 0.7.1 (the gem's lowest) it does the
514
- same by hand on the connection's session (`Host::TablesStatus`: a
515
- queryable on the key and a put when the counts change).
564
+ `games-host` needs `asterism/${node}/*/@status` and
565
+ `asterism/${node}/*/@pubs` in "Read and write" and the queryable of
566
+ `asterism/${node}/*/@profile` (the console's docs/rooms.md): a `**` does
567
+ not match a chunk that starts with `@`. A room's host tells none (the
568
+ rooms' rules let it write only its own place, which `@status` is not
569
+ part of, and the console only watches who is in a room).
570
+ - **The asterism gem.** The host uses Asterism's status (`net.status`,
571
+ `Host::TablesStatus`): Asterism puts it when it changes and answers the
572
+ application's profile.
516
573
  - **Size.** Five tables take about 300 bytes (the largest put is in the
517
574
  log's `largest message` with the others).
518
575
 
@@ -526,22 +583,33 @@ asterism gem's command line and MCP bridge). The mahjong table puts the
526
583
  four hands there; othello and the drawing board put nothing (they hide
527
584
  nothing: their state is the whole game).
528
585
 
529
- - **The key.** `asterism/<node>/spectate/<table>/state`: the host's node,
530
- the application `spectate` (not the host's `games`, so the players' role
531
- `asterism/*/games/**` does not match it), and the table. A room's table
532
- is `room<slot>-<table>` there (`asterism/games/spectate/room01-mahjong1/state`),
533
- so the rooms' tables and the permanent ones never meet. The value is
534
- MessagePack, like the state.
586
+ - **The key.** `asterism/<node>/@shared/spectate/<table>/state`: the
587
+ host's node and its shared place (asterism's `docs/protocol.md` 17: data
588
+ with no single owner application, a chunk `@shared` that the players'
589
+ role `asterism/*/games/**` and every `**` do not match), and the table. A
590
+ room's table is `room<slot>-<table>` there
591
+ (`asterism/games/@shared/spectate/room01-mahjong1/state`), so the rooms'
592
+ tables and the permanent ones never meet. The value is MessagePack, like
593
+ the state.
594
+ - **Who declares it.** The permanent tables' host and a host of its own
595
+ declare their tables' keys on their own application's connection
596
+ (`games`, with `shared: :node`), with the game's spectate types; they
597
+ are in its `@pubs`. Each value carries its sender (`"games/games"`), so
598
+ a reader tells who put it and counts what it missed per sender.
535
599
  - **Outside the rooms.** Every key a room's host writes on its own
536
600
  connection gets the room's namespace (`room/<slot>/...`), which every
537
- seat of the room reads whole. So a room's host opens a second connection
538
- of its own for the spectate keys, with its own certificate and without
539
- the namespace: the key is then on the registered side, where the seats'
540
- rules do not reach. It does so only with the host's own certificate (a
541
- console from 0.3.0 lists the host in `rooms.json`'s `"hosts"`); with the
542
- slot's certificate of an older console it puts nothing (that
543
- certificate is the room's). The permanent tables' host and a host of its
544
- own put on their one connection.
601
+ seat of the room reads whole. So a room's host opens a second Asterism
602
+ connection, the application `room<slot>` of the host's node (`room01`;
603
+ asterism: one application a connection, several in one program), with
604
+ its own certificate and without the namespace: it declares the room's
605
+ tables' keys in the node's shared place, on the registered side, where
606
+ the seats' rules do not reach, with its own types, liveliness token and
607
+ profile. Its values' sender is `"games/room01"`. It does so only with
608
+ the host's own certificate (a console from 0.3.0 lists the host in
609
+ `rooms.json`'s `"hosts"`); with the slot's certificate of an older
610
+ console it puts nothing (that certificate is the room's). It goes and
611
+ comes back with the room's host's connection (tried again every 30 s
612
+ when it cannot open).
545
613
  - **What the mahjong table puts.** The table in words, as `look` gives it
546
614
  ("Playing from a program") to a member without a seat, with each seat's
547
615
  `hand` and `drawn` tile, the same four hands a spectator member's view
@@ -553,7 +621,7 @@ nothing: their state is the whole game).
553
621
  member's node or token (`test/test_spectate.rb` looks for them):
554
622
 
555
623
  ```
556
- games/spectate/mahjong1/state
624
+ games/@shared/spectate/mahjong1/state
557
625
  {"table":"mahjong1","status":"play","sv":212,
558
626
  "seats":[{"seat":0,"name":"CPU1","kind":"cpu","score":21000,"wind":"north","riichi":false,
559
627
  "river":["2z","4z","5z","1m",...],"melds":[{"type":"pon","tiles":["7z","7z","7z"],"from":1}],
@@ -572,25 +640,42 @@ nothing: their state is the whole game).
572
640
  At most 8 MB is kept per table; past that the oldest is dropped (never
573
641
  shown rather than shown early). The last one put is put again every
574
642
  5 s, so a new subscriber has it within that.
643
+ - **The value now.** Asterism keeps no current value on the network
644
+ (protocol 2): a reader that missed values (`info.lost > 0`) or saw the
645
+ sender start again (`info.restarted?`, counted per sender) asks the
646
+ sender. The application that declares the keys exposes the object
647
+ `spectate` (`games/games/spectate`, a room's `games/room01/spectate`):
648
+ `state(table)` answers the value last put on
649
+ `@shared/spectate/<table>/state` (the table as the key names it, a room's
650
+ `room01-mahjong1`; as delayed as the key), nil before the first one. The
651
+ commentary does so ("Commentary"); `asterism call
652
+ games/games/spectate state '"mahjong1"'` too. A table named `spectate` keeps
653
+ its name, and its host does not expose the object.
575
654
  - **Who reads it.** The console's docs/rooms.md, "Watching the tables
576
- from outside": the host's role `games-host` needs
577
- `asterism/${node}/spectate` and `asterism/${node}/spectate/**` in "Read
578
- and write" (without them the router drops the puts, and nothing else
579
- changes); a role `spectator` (Read `asterism/*/spectate` and
580
- `asterism/*/spectate/**`, Watch `asterism/**`) reads them with the
581
- command line, and the `viewer` role (Read `asterism/**`) with the
582
- command line and the MCP bridge. The players' role `games` and the
583
- rooms' seats do not reach them.
584
- - **Looking.** With the asterism gem's command line (asterism 0.10.0's
585
- `object` commands): `asterism object echo 'games/spectate/*' --credentials
586
- spectator.tar` (each put as it comes; `-o json` a line each), `asterism
587
- object get games/spectate/mahjong1 --credentials viewer.tar` (the next
588
- one). The MCP bridge (`asterism mcp --credentials viewer.tar`):
589
- `asterism_values` with the pattern `games/spectate/*/state`. The bridge
590
- subscribes to `asterism/**`, which a `spectator` row may not, so it
591
- needs the viewer role. The host's application `spectate` is on the
592
- network (a liveliness token) while it puts, so the values show as an
593
- application that is there.
655
+ from outside", and `docs/install.md` section 7: the host's role
656
+ `games-host` needs `asterism/${node}/@shared/**` (the keys),
657
+ `asterism/${node}/room$*` and `asterism/${node}/room$*/**` (the rooms'
658
+ applications) in "Read and write" beside its lines for `games` (without
659
+ them the router drops the puts, and nothing else changes); a role
660
+ `spectator` reads `asterism/*/@shared/spectate/**` (Watch
661
+ `asterism/**`), and, for the types (`asterism topic info`,
662
+ `net.type_of`), the senders' declarations (Read
663
+ `asterism/*/games/@profile`, `asterism/*/games/@pubs`,
664
+ `asterism/*/room$*/@profile`, `asterism/*/room$*/@pubs`; Ask
665
+ `asterism/*/games/@profile`, `asterism/*/room$*/@profile`), and, to
666
+ ask the value now, Read and Ask `asterism/*/games/spectate/call/${node}`
667
+ and `asterism/*/room$*/spectate/call/${node}`, and, for `asterism topic
668
+ list` to find the senders, Read `asterism/*/games/protocol/*` and
669
+ `asterism/*/room$*/protocol/*` (their liveliness tokens only). The
670
+ players' role `games` and the rooms' seats do not reach them.
671
+ - **Looking.** With the asterism gem's command line: `asterism object echo
672
+ 'games/@shared/spectate/*/state' --credentials spectator.tar` (each put
673
+ as it comes, with its sender; `-o json` a line each), `asterism object
674
+ get games/@shared/spectate/mahjong1/state --credentials viewer.tar` (the
675
+ next one). The MCP bridge: `asterism_values` with the pattern
676
+ `games/@shared/spectate/*/state` (each sender's latest value). `asterism
677
+ topic list games/games` and `asterism topic list games/room01` show who
678
+ declares them.
594
679
  - **Writing a game.** `spectate_view(now)` (nil, the default: nothing) and
595
680
  `spectate_delay_ms` (0) of `AsterismGames::Game`.
596
681
 
@@ -673,7 +758,7 @@ What the framework gives a game:
673
758
  | `changed`, `dirty?`, `unsaved?` | a change to save and put; a change only one member's view shows (`@unsaved = true`) is saved, not put |
674
759
  | `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 |
675
760
  | `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) |
676
- | `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 |
761
+ | `spectate_view(now)`, `spectate_delay_ms` | what the host puts on `asterism/<node>/@shared/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 |
677
762
  | `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` |
678
763
  | `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 |
679
764
 
@@ -844,7 +929,7 @@ hand.
844
929
  spectators are. A host started again starts the delay over (the past is
845
930
  not saved).
846
931
  - **From outside the table.** The same four hands, in words and as old
847
- as the spectators' view, go on `asterism/<node>/spectate/<table>/state`
932
+ as the spectators' view, go on `asterism/<node>/@shared/spectate/<table>/state`
848
933
  for a console's spectators and viewers ("Spectate"), a key the players
849
934
  cannot read.
850
935
  - **The default is no delay.** The spectators see the hands as they are:
@@ -1218,7 +1303,7 @@ assistant through `asterism mcp`).
1218
1303
  | Method | Arguments | Returns |
1219
1304
  |---|---|---|
1220
1305
  | `records` | from (optional, 0) | `{"records" => [...], "total", "next"}`: the games, newest first, from the from-th, as many as fit one message; `next` is the from of the next page (none: the end). Each: `id`, `at` (the host's local time, `"2026-10-18 21:05"`), `game` (`hanchan` / `tonpuu`), `names`, `ranks`, `scores` (in seat order), `watch` (`true` for a watch game, "Watch games"; else absent) |
1221
- | `record` | id | the game's head: `id`, `at`, `time` (Unix seconds), `table`, `game`, `rules` (as `look`'s), `names`, `kinds` (human / cpu / ai), `final` (`[[seat, rank, score, points x 10], ...]`), `reason` (as the view's `fr`), `rounds` (`[[wind, number, honba, kind, [four changes]], ...]`) |
1306
+ | `record` | id | the game's head: `id`, `at`, `time` (ms since 1970), `table`, `game`, `rules` (as `look`'s), `names`, `kinds` (human / cpu / ai), `final` (`[[seat, rank, score, points x 10], ...]`), `reason` (as the view's `fr`), `rounds` (`[[wind, number, honba, kind, [four changes]], ...]`) |
1222
1307
  | `record` | id, round | round `round` (from 0) as below, with `id`, `round`, `of` (how many rounds), `part`, `parts` |
1223
1308
  | `record` | id, round, part | a round too big for one message comes in parts (`parts` > 1): part 0 has everything with the first moves, part k the moves from `move_from` on |
1224
1309
 
@@ -1543,7 +1628,7 @@ when it is taken), prints the address and opens it in the browser (macOS
1543
1628
  |---|---|
1544
1629
  | `--router LOCATOR`, `--credentials FILE` | as `play` (with neither, `ASTERISM_ROUTER` / `ASTERISM_CREDENTIALS`, all or nothing; with nothing, `tcp/127.0.0.1:7447`) |
1545
1630
  | `--namespace PREFIX` | a room's tables: `room/01` shows `room01-<table>` (the spectate keys are outside the room, so the connection has no namespace) |
1546
- | `--table TABLE` | the table to show first: `mahjong1`, `games/mahjong1` or `games/spectate/mahjong1`; without it, every table that puts its spectate key is offered and the first one shown |
1631
+ | `--table TABLE` | the table to show first: `mahjong1`, `games/mahjong1` or `games/@shared/spectate/mahjong1`; without it, every table that puts its spectate key is offered and the first one shown |
1547
1632
  | `--node NODE` | the node ID (default `commentate-<host>-<pid>`, or the `.tar`'s name) |
1548
1633
  | `--port N` | the page's port (default 7470) |
1549
1634
  | `--public` | hide the hands: the views sent to the page lose `hand` and `drawn`, and no line tells a hand |
@@ -1555,10 +1640,22 @@ when it is taken), prints the address and opens it in the browser (macOS
1555
1640
  | `--ai-model MODEL` | the model they ask (default `claude-haiku-5-5`) |
1556
1641
  | `--ai-daily-usd USD` | what they may cost a day (default 3); then the fixed commentary alone |
1557
1642
 
1558
- - **The role.** The spectate keys are `asterism/<host>/spectate/<table>/state`.
1559
- On a console's network a `viewer` (Read `asterism/**`) or a `spectator`
1560
- (Read `asterism/*/spectate` and `asterism/*/spectate/**`) reads them; the
1643
+ - **The role.** The spectate keys are
1644
+ `asterism/<host>/@shared/spectate/<table>/state` (it subscribes
1645
+ `*/@shared/spectate/*/state`; the host of a value is its sender's node,
1646
+ `info.from`, the permanent tables' `games` or a room's `room<slot>`).
1647
+ On a console's network a `spectator` (Read
1648
+ `asterism/*/@shared/spectate/**`) or a `viewer` whose role reads the
1649
+ nodes' shared places reads them ("Spectate", "Who reads it"); the
1561
1650
  players' role `games` does not. On a plain zenohd anyone does.
1651
+ - **Values lost, a host started again.** At a table's first value from
1652
+ its sender (the host, a room), when a value comes after lost ones, when
1653
+ the sender started again, and when it left the network and came back,
1654
+ the command asks the sender for the table now (`<node>/<app>/spectate`,
1655
+ `state`, "Spectate", "The value now") and shows that, unless a newer
1656
+ value came meanwhile; one ask at a time per table, on a thread of its
1657
+ own. Without the role's lines for the call it says so and goes on with
1658
+ the next value.
1562
1659
  - **The page.** A bar of states on top (the table, the voice, the delay the
1563
1660
  host puts on the table, a host that is not there), the table in a
1564
1661
  canvas (the round, honba, riichi sticks, the wall and the dora
@@ -2293,7 +2390,8 @@ ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 bundle exec rake test # also through a
2293
2390
  letter), the countdown rewritten in place on a terminal (only the time
2294
2391
  line, between ESC 7 and ESC 8), and the screens (a round in kanji with
2295
2392
  its two-row hand and a river that goes on below, emoji and MPSZ; the lobby, a result,
2296
- the end, a call, a spectator). With `ASTERISM_TEST_ROUTER`, a host in a
2393
+ the end, a call, a spectator), the view asked again after lost state
2394
+ values or a host that started again. With `ASTERISM_TEST_ROUTER`, a host in a
2297
2395
  process of its own and three clients at once (kanji by default, emoji,
2298
2396
  MPSZ) that play a
2299
2397
  tonpuu game against three CPUs each to the end from scripted input
@@ -2311,7 +2409,9 @@ ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 bundle exec rake test # also through a
2311
2409
  frames, `--public`, `--json`, a page that opens late, a host that leaves
2312
2410
  and comes back), the web server (the page, the sheets byte for byte,
2313
2411
  404 / 405 / a foreign Host refused, 127.0.0.1 only, the events stream, a
2314
- port taken), the options and `--help`; with the table there, a CPU
2412
+ port taken), the options and `--help`, the table asked again after lost
2413
+ values or a sender that started again (once at a time, a newer value
2414
+ wins, a failed ask); with the table there, a CPU
2315
2415
  round played here through the hub.
2316
2416
  - `test_commentate_voice.rb`: the commentary's voice (in `rake test:gem`
2317
2417
  too), against a small VOICEVOX engine started in the test: a character
@@ -2332,8 +2432,13 @@ ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 bundle exec rake test # also through a
2332
2432
  board), the keys of the permanent tables and of a room, what the rooms
2333
2433
  give their hosts, the mahjong view (the four hands, no token, node, wall
2334
2434
  or ura dora), a CPU game on the host, the delay (0, and 3 s across a
2335
- round's end, in order, never sooner), a room's table on the connection
2336
- outside the room only, and the last put again after 5 s.
2435
+ round's end, in order, never sooner), the keys in the node's shared
2436
+ place declared by the host's application and numbered with their
2437
+ sender, a room's table declared and put by the room's application
2438
+ `room<slot>` outside the room only, who opens that application, the
2439
+ last put again after 5 s, and the object `spectate` that answers the
2440
+ value last put (the host's and a room's application; none for a table
2441
+ named `spectate`).
2337
2442
  - `test_play_commands.rb`: `--commands "sit,cpu,start"` against a table
2338
2443
  in this process: they wait for the lobby when a game is going on as
2339
2444
  the client joins (and then sit, fill and start), the owner's wait for
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.22.0
1
+ 0.23.0
@@ -56,6 +56,20 @@ module AsterismGames
56
56
  # A page of picture: at most this many points.
57
57
  PAGE_POINTS = 400
58
58
  KEY = "strokes"
59
+ # The state's type (public_view's keys; mb, the members as [name,
60
+ # flags], an any[]) and the strokes' (the messages above).
61
+ STATE_TYPE = "#{TYPE_PREFIX}board.state".freeze
62
+ STROKES_TYPE = "#{TYPE_PREFIX}board.strokes".freeze
63
+ TOLD_TYPES = { KEY => STROKES_TYPE }.freeze
64
+ TYPES = {
65
+ STATE_TYPE => [[%w[v int], %w[t str], %w[st str], %w[n int], %w[np int], %w[last int], %w[mb any[]],
66
+ %w[lim int[]]],
67
+ "A drawing board: its version, the strokes and points on it, the last stroke's id, the " \
68
+ "members as [name, flags], the limits"],
69
+ STROKES_TYPE => [[%w[o str], %w[id int], %w[c int], %w[w int], %w[p int[]], %w[k int], %w[v int]],
70
+ "A change of a board: o \"a\" points of stroke id (k 0 a new stroke), \"u\" stroke id " \
71
+ "taken back, \"c\" the board cleared; v the board's version"]
72
+ }.freeze
59
73
 
60
74
  attr_reader :strokes, :points, :owner
61
75
 
@@ -266,10 +266,111 @@ module AsterismGames
266
266
  end
267
267
  end
268
268
 
269
+ # Asterism's protocol 2 keeps no current value on the network: a
270
+ # reader asks the sender for it (Host#spectate_now, the object
271
+ # <node>/<app>/spectate) at the first value of a table from a sender,
272
+ # and again when it missed values (info.lost > 0) or saw the sender
273
+ # start again (info.restarted?; both counted per sender on the shared
274
+ # place). A sender that left the network (on_app_leave) is forgotten:
275
+ # Asterism then forgets its numbers too, so its next value comes as a
276
+ # first one (lost 0, not restarted), and is asked for again. The
277
+ # answer goes to the hub unless a newer value came while asking. The
278
+ # asking is on a thread of its own: the values come on the receiving
279
+ # thread, where no call may wait.
280
+ class Refetch
281
+ attr_reader :asked, :taken
282
+
283
+ # call: ->(from, name) { the current value } ("<node>/<app>" of the
284
+ # sender, the table's name under spectate/); log: ->(message).
285
+ def initialize(hub, call:, log: ->(_m) {})
286
+ @hub = hub
287
+ @call = call
288
+ @log = log
289
+ @lock = Monitor.new
290
+ @seen = Hash.new(0) # table id => values given to the hub
291
+ @from = {} # table id => the sender of its last value
292
+ @want = {} # table id => [from, host, name, @seen when asked]
293
+ @queue = Thread::Queue.new
294
+ @asked = 0
295
+ @taken = 0
296
+ end
297
+
298
+ # A value of a table came (the receiving thread): blk gives it to
299
+ # the hub; at a sender's first value, after lost values or when the
300
+ # sender started again, the current one is asked for (once at a time
301
+ # per table). Returns blk's.
302
+ def came(info, host, name, &blk)
303
+ id = MahjongHub.id(host, name)
304
+ why = nil
305
+ out = @lock.synchronize do
306
+ r = blk.call
307
+ @seen[id] += 1
308
+ from = info.from.to_s
309
+ first = @from[id] != from
310
+ @from[id] = from
311
+ why = if !from.include?("/") || @want.key?(id) then nil
312
+ elsif info.restarted? then "#{from} started again"
313
+ elsif info.lost.to_i.positive? then "#{info.lost} value(s) lost"
314
+ elsif first then "the first value from #{from}"
315
+ end
316
+ @want[id] = [from, host, name, @seen[id]] if why
317
+ r
318
+ end
319
+ if why
320
+ @log.call("#{id}: #{why}; asking #{info.from}/#{Commentate::MahjongRunner::SPECTATE_OBJECT} for the table now")
321
+ @queue << id
322
+ end
323
+ out
324
+ end
325
+
326
+ # A sender ("<node>/<app>") left the network: its next value is a
327
+ # first one.
328
+ def forget(from)
329
+ @lock.synchronize { @from.delete_if { |_id, f| f == from } }
330
+ end
331
+
332
+ # Asks for one table's current value and gives it to the hub (a
333
+ # newer value that came meanwhile wins). :taken, :newer, :none (the
334
+ # sender has nothing yet) or :error.
335
+ def run_one(id)
336
+ from, host, name, at = @lock.synchronize { @want[id] }
337
+ return :none unless from
338
+ @asked += 1
339
+ v = @call.call(from, name)
340
+ @lock.synchronize do
341
+ @want.delete(id)
342
+ return :newer if @seen[id] != at
343
+ return :none unless v.is_a?(Hash)
344
+ @hub.update(host, name, v)
345
+ @seen[id] += 1
346
+ end
347
+ @taken += 1
348
+ @log.call("#{id}: took the table now from #{from}")
349
+ :taken
350
+ rescue StandardError => e
351
+ @lock.synchronize { @want.delete(id) }
352
+ @log.call("#{id}: asking #{from}/#{Commentate::MahjongRunner::SPECTATE_OBJECT}: #{e.class}: #{e.message}")
353
+ :error
354
+ end
355
+
356
+ def start
357
+ @thread = Thread.new { loop { run_one(@queue.pop) } }
358
+ self
359
+ end
360
+
361
+ def stop
362
+ @thread&.kill
363
+ end
364
+ end
365
+
269
366
  # `asterism-games commentate mahjong`: the connection, the spectate
270
367
  # keys, the web server and the browser.
271
368
  module MahjongRunner
272
- SPECTATE = "asterism/*/spectate/*/state"
369
+ # The spectate keys in every node's shared place (README "Spectate").
370
+ SPECTATE = "*/@shared/spectate/*/state"
371
+ # The sender's object that answers a table's current spectate value
372
+ # (Host::SPECTATE_OBJECT): <node>/<app>/spectate, state(table).
373
+ SPECTATE_OBJECT = "spectate"
273
374
  STALE_S = 15
274
375
 
275
376
  module_function
@@ -289,18 +390,20 @@ module AsterismGames
289
390
  info.puts "commentate: hands hidden (--public)" if opts[:public]
290
391
  open_browser(server.url, err) if opts[:open]
291
392
  prefix = room_prefix(opts[:namespace])
393
+ refetch = nil
292
394
  connect(opts, err) do |net|
293
395
  net.on_leave { |n| hub.host_left(n) }
294
396
  net.on_join { |n| hub.host_back(n) }
397
+ refetch = Refetch.new(hub, call: ->(from, name) { net["#{from}/#{SPECTATE_OBJECT}", timeout: 2].state(name) },
398
+ log: ->(m) { err.puts "commentate: #{m}" }).start
399
+ net.on_app_leave { |n, a| refetch.forget("#{n}/#{a}") }
295
400
  net.start
296
- net.session.subscribe(SPECTATE) do |sample|
297
- next if sample.respond_to?(:kind) && sample.kind == :delete
298
- _, host, _, name, = sample.key.to_s.split("/")
401
+ net.subscribe(SPECTATE) do |v, info|
402
+ host, name = spectate_source(info)
299
403
  next unless wanted?(host, name, opts[:table], prefix)
300
- v = Asterism::Codec.unpack(sample.payload)
301
- hub.update(host, name, v)
404
+ refetch.came(info, host, name) { hub.update(host, name, v) }
302
405
  rescue StandardError => e
303
- err.puts "commentate: #{sample.key}: #{e.class}: #{e.message}"
406
+ err.puts "commentate: #{info.key}: #{e.class}: #{e.message}"
304
407
  end
305
408
  stop = Thread::Queue.new
306
409
  %w[INT TERM].each { |s| trap(s) { stop << true } }
@@ -310,6 +413,7 @@ module AsterismGames
310
413
  end
311
414
  end
312
415
  ensure
416
+ refetch&.stop
313
417
  server&.stop
314
418
  voice&.stop
315
419
  ai&.stop
@@ -349,6 +453,17 @@ module AsterismGames
349
453
  end
350
454
  end
351
455
 
456
+ # [host node, table] of a spectate value: the node of its sender
457
+ # (info.from, "<node>/<app>": the host's games or a room's room<slot>,
458
+ # the same node), else the key's; the table from the name under
459
+ # @shared/ (spectate/<table>/state).
460
+ def spectate_source(info)
461
+ from = info.from.to_s
462
+ host = from.include?("/") ? from.split("/").first : info.node
463
+ parts = info.name.to_s.split("/")
464
+ [host, parts[0] == "spectate" ? parts[1] : nil]
465
+ end
466
+
352
467
  # --namespace room/01: the room's tables are room01-<table> outside it.
353
468
  def room_prefix(ns)
354
469
  return nil if ns.to_s.empty?
@@ -363,8 +478,8 @@ module AsterismGames
363
478
  parts.size >= 3 ? "#{parts[0]}/#{parts[-1]}" : t
364
479
  end
365
480
 
366
- # --table: <table>, <host>/<table>, <host>/spectate/<table> or the
367
- # play's <host>/games/<table>.
481
+ # --table: <table>, <host>/<table>, <host>/@shared/spectate/<table>
482
+ # or the play's <host>/games/<table>.
368
483
  def wanted?(host, name, table, prefix)
369
484
  return false if host.nil? || name.nil?
370
485
  return false if prefix && !name.start_with?(prefix)