asterism-games 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,2009 @@
1
+ # asterism-games
2
+
3
+ A showcase of [Asterism](https://github.com/ruby-asterism/asterism): how
4
+ to write the server side of an application in Ruby on an Asterism
5
+ network. A game host is a CRuby process that joins a Zenoh network as an
6
+ Asterism node, exposes each table as an Asterism object, holds the state
7
+ of the games and lets players on boards (Family mruby), browsers and PCs
8
+ play through Asterism calls.
9
+
10
+ It is two parts, in this one repository, handed out two ways ("The gem
11
+ and the image"): the framework with the two sample games is the gem
12
+ `asterism-games`, and the whole host with mahjong is a docker image.
13
+
14
+ - **The framework** (`lib/asterism_games.rb`, `lib/asterism_games/*.rb`):
15
+ everything a game host needs whatever the game is. The connection
16
+ (Asterism's block API, the tables exposed with `caller: :from`), the
17
+ host's loop, the puts of each table's state, saving and resuming, the
18
+ members and their tokens, who may join in a room, the rooms' supervisor
19
+ (one host process per room of an Asterism console), the permanent tables
20
+ next to the rooms, the status (`GET /status`) and the settings. A game
21
+ subclasses `AsterismGames::Game` and `AsterismGames::Remote` and writes
22
+ only its rules and the methods it exposes ("Writing a game"). The
23
+ framework requires no game.
24
+ - **The games**: riichi mahjong (`app/asterism_games/mahjong/`: four
25
+ players, seats nobody takes are played by CPUs), built on the
26
+ [riichi_engine](https://github.com/HenrikStephensen/riichi_engine) gem (a
27
+ fork with fixes, see "riichi_engine" below), which only that game needs,
28
+ so it is an application on the gem (`app/`) and not in it;
29
+ othello (`lib/asterism_games/othello/`: two seats and the spectators,
30
+ the smallest game on the framework, "The othello table"); and a shared
31
+ drawing board (`lib/asterism_games/board/`: no winner, one picture every
32
+ member draws on, handed out as strokes that a console can record and
33
+ play back, "The drawing board"). The table screens are Family mruby apps
34
+ (fmruby-core's `flash/app/game/mahjong/`, `flash/app/game/othello/` and
35
+ `flash/app/game/board/`), on the boards and in the browser build;
36
+ `asterism-games play othello` and `asterism-games play mahjong` are the
37
+ tables in a terminal, with CRuby and the asterism gem only ("Playing in a
38
+ terminal").
39
+
40
+ ```
41
+ bin/asterism-games ──requires──> asterism_games/mahjong (app/, when there) ──> riichi_engine
42
+ │ ──requires──> asterism_games/othello
43
+ │ ──requires──> asterism_games/board
44
+ │ ──requires──> your game (--require FILE)
45
+ │ │ (Table < Game, Remote < Remote)
46
+ └──requires──> asterism_games <───┘
47
+ (Host, Rooms, Config, Store, Game, Remote) ──> asterism
48
+
49
+ the gem asterism-games: lib/ (framework, othello, board, the terminal clients), bin/asterism-games, bin/othello-tty
50
+ the image: all of the above, app/ (mahjong), config/, the Gemfile's gems
51
+ ```
52
+
53
+ Status: up to four players at a table (a lobby to sit and start, calls by
54
+ several players decided together, players that drop out played for after a
55
+ short wait until they are back), two tables at once on a local network, or
56
+ a table in each room of an Asterism console on a server ("Rooms") and
57
+ permanent tables next to them for the boards and browsers registered with
58
+ the console ("Permanent tables"; the server's setup: docs/install.md).
59
+
60
+ | Path | What |
61
+ |---|---|
62
+ | `asterism-games.gemspec` | the gem: what it holds and needs ("The gem and the image") |
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
+ | `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
+ | `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
+ | `lib/asterism_games.rb` | the framework, without any game |
68
+ | `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") |
69
+ | `lib/asterism_games/remote.rb` | the base of the exposed object: the methods and their numbers of arguments, the caller (`from:`), `join` / `mine` / `leave` |
70
+ | `lib/asterism_games/host.rb` | the connection (Asterism's `Net`), the loop, the puts, the status file |
71
+ | `lib/asterism_games/rooms.rb` | the rooms' supervisor: one host process per room that opens a table ("Rooms"), and one for the permanent tables ("Permanent tables"); `GET /status` |
72
+ | `lib/asterism_games/config.rb` | the settings (file and environment; each game's table keys come from the game) |
73
+ | `lib/asterism_games/store.rb` | saved states, one JSON file per table |
74
+ | `app/asterism_games/mahjong.rb` | the mahjong game, outside the gem: requiring it registers `mahjong` |
75
+ | `app/asterism_games/mahjong/` | `table.rb` (seats, rounds, views, time, the table's settings), `engine.rb` (the calls into riichi_engine), `remote.rb` (the exposed object), `tiles.rb` (tile numbers) |
76
+ | `lib/asterism_games/othello.rb` | the othello game: requiring it registers `othello` |
77
+ | `lib/asterism_games/othello/` | `board.rb` (the rules, a game as a history, the CPU), `table.rb` (seats, the owner, time, views, saving), `remote.rb` (the exposed object) |
78
+ | `lib/asterism_games/board.rb` | the drawing board: requiring it registers `board` |
79
+ | `lib/asterism_games/board/` | `table.rb` (the strokes, the owner, the limits, the picture in pages, saving), `remote.rb` (the exposed object) |
80
+ | `config/example.yml` | settings to copy |
81
+ | `config/rooms.yml` | the settings of the server's image (rooms) |
82
+ | `deploy/vps/` | the server's image, compose files, `update.sh` (docs/install.md) |
83
+ | `.github/workflows/image.yml` | builds the image for each tag `v<version>` and publishes it on ghcr.io |
84
+ | `docs/install.md` | putting the host on a server next to an Asterism console |
85
+ | `test/` | the tests (`rake test`, `rake test:gem`); `test/framework/` a small game on the framework alone |
86
+
87
+ ## The gem and the image
88
+
89
+ **The gem** `asterism-games` (`require "asterism_games"`) is for writing a
90
+ game host of your own: the framework, the two sample games that need
91
+ nothing else (othello, the drawing board), `bin/asterism-games` (with
92
+ `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
94
+ the asterism gem only (0.7.1 or later in
95
+ 0.x; what came after 0.7.1, such as `@status` and `on_app_join`, is used
96
+ when there). Not in it: mahjong (`app/`, it needs the riichi_engine fork
97
+ from git), `bin/mahjong-bots`, `deploy/`, `config/` and the tests. The AI
98
+ players' code is in it, and is offered only with a key; Claude needs the
99
+ anthropic gem as well, which is not a dependency (`gem install anthropic`,
100
+ "AI players").
101
+
102
+ ```
103
+ gem install asterism-games # CRuby 3.2 or newer and a C compiler (asterism-zenoh)
104
+ ```
105
+
106
+ A host of the sample games on a plain zenohd (`games.yml`):
107
+
108
+ ```yaml
109
+ router: tcp/127.0.0.1:7447
110
+ node: games
111
+ state_dir: var/state
112
+ tables:
113
+ - name: othello1
114
+ game: othello
115
+ - name: board1
116
+ game: board
117
+ ```
118
+
119
+ ```
120
+ asterism-games --config games.yml # games/games/othello1, games/games/board1
121
+ asterism-games play othello --commands "sit,cpu,start" # in another terminal: play against the CPU
122
+ ```
123
+
124
+ Without a settings file the host serves the first game's tables: with the
125
+ gem alone that is othello (`othello1`). A game of your own ("Writing a
126
+ game") is a file that requires `asterism_games` and registers its table;
127
+ the host loads it with `--require`, and the settings name its tables:
128
+
129
+ ```
130
+ asterism-games --config my.yml --require ./counter.rb
131
+ ```
132
+
133
+ **The image** `ghcr.io/ruby-asterism/asterism-games:<version>` is the
134
+ whole host as the server runs it: the gem's files, mahjong (`app/`), the
135
+ settings of the rooms (`config/rooms.yml`) and the gems of `Gemfile.lock`
136
+ (riichi_engine at a pinned revision, anthropic). The CI
137
+ (`.github/workflows/image.yml`) builds it for each tag `v<version>` (the
138
+ number of `VERSION`) for amd64 and arm64. `docker pull
139
+ ghcr.io/ruby-asterism/asterism-games:0.12.0`; next to a console:
140
+ docs/install.md (`deploy/vps`, which pulls it, or builds it with
141
+ `docker-compose.build.yml`).
142
+
143
+ ## Running
144
+
145
+ From this checkout (everything, mahjong too). CRuby 3.2 or newer and a C
146
+ compiler (asterism-zenoh compiles its extension). Bundler fetches the
147
+ riichi_engine fork from GitHub (see "riichi_engine"). The `Gemfile` is for
148
+ this checkout and the image (mahjong, the AI players); the gem's own needs
149
+ are in the gemspec.
150
+
151
+ ```
152
+ bundle config set --local path vendor/bundle
153
+ bundle install
154
+ ASTERISM_GAMES_CONFIG=config/example.yml bundle exec bin/asterism-games
155
+ ```
156
+
157
+ The host connects to the router in the settings (by default
158
+ `tcp/127.0.0.1:7447`, a zenohd on this machine) as the node `games` and the
159
+ application `games`, and exposes each table as an object:
160
+ `games/games/mahjong1` and `games/games/mahjong2` (the default settings;
161
+ `config/example.yml` adds `games/games/othello1`). Ctrl-C (or SIGTERM) saves and stops. Started again,
162
+ it goes on from the saved state (the same round, the same turn, the same
163
+ seats and tokens). When the router goes away, it connects again every 3 s.
164
+
165
+ asterism comes from rubygems at a fixed version (0.8.0); riichi_engine from
166
+ the `fmrb` branch of the fork on GitHub (see "riichi_engine" below).
167
+
168
+ ## Settings
169
+
170
+ A YAML file (`--config PATH` or `ASTERISM_GAMES_CONFIG`; see
171
+ `config/example.yml`), with the environment over it:
172
+
173
+ | Key | Environment | Default | |
174
+ |---|---|---|---|
175
+ | `router` | `ASTERISM_GAMES_ROUTER` | `tcp/127.0.0.1:7447` | the Zenoh router |
176
+ | `node` | `ASTERISM_GAMES_NODE` | `games` | this host's node ID |
177
+ | `app` | `ASTERISM_GAMES_APP` | `games` | the application every game lives under |
178
+ | `credentials` | `ASTERISM_GAMES_CREDENTIALS` | none | a console's `.tar`, for a router over mutual TLS (the router comes from it, and the node ID too unless `node` is given) |
179
+ | `state_dir` | `ASTERISM_GAMES_STATE_DIR` | `var/state` | where the tables are saved |
180
+ | `records_db` | `ASTERISM_GAMES_RECORDS_DB` | `state_dir/records.sqlite3` | the games' records ("Game records"): the SQLite database, or `json` for the JSON files of before 0.14.0 (also what a host without the sqlite3 gem uses) |
181
+ | `tables` | | the first game's tables (`mahjong1`, `mahjong2`; with the gem alone `othello1`) | each `{name, game, ...}`; `game` defaults to the first game registered (`mahjong`; with the gem alone `othello`) |
182
+
183
+ The other keys of a table are its game's (`settings` in the game's class,
184
+ "Writing a game"). A mahjong table takes (the environment applies to every
185
+ mahjong table):
186
+
187
+ | Key | Environment | Default | |
188
+ |---|---|---|---|
189
+ | `turn_seconds` | `MAHJONG_TURN_SECONDS` | 5 | a player's time for a move (a discard, riichi, a kan, a win); a move that takes longer goes on into the bank ("Time") |
190
+ | `bank_seconds` | `MAHJONG_BANK_SECONDS` | 20 | each player's bank, full again every round; when the move's time and the bank are used up, the drawn tile is discarded (0: no bank, `turn_seconds` a move and no more) |
191
+ | `call_seconds` | `MAHJONG_CALL_SECONDS` | 8 | the time to call a discard, the same for every player who may; after it the call is passed |
192
+ | `result_seconds` | `MAHJONG_RESULT_SECONDS` | 8 | a table without humans (CPUs and AI players only): a round's result stays this long. A table with humans waits for their OKs instead ("Result", games 0.15.0) |
193
+ | `result_auto_seconds` | `MAHJONG_RESULT_AUTO_SECONDS` | 10 | a table with humans: a round's result moves on by itself this long after its count, whoever has not pressed OK ("Result") |
194
+ | `deal_pause_seconds` | `MAHJONG_DEAL_PAUSE_SECONDS` | 2 | a table with humans: after the deal the round's name shows this long; no clock runs and no CPU or AI player plays ("Result"; 0: none) |
195
+ | `over_seconds` | `MAHJONG_OVER_SECONDS` | 60 | the end of a game stays this long (or until the owner presses next), then the table is back in its lobby |
196
+ | `away_seconds` | `MAHJONG_AWAY_SECONDS` | 10 | a member whose node leaves the network is away at once; one that has not called the table this long is away too (where the host does not see the nodes come and go: "Who is there") |
197
+ | `away_wait_seconds` | `MAHJONG_AWAY_WAIT_SECONDS` | 2 | an away member's time to discard or to call, instead of `turn_seconds` and the bank / `call_seconds` |
198
+ | `cpu_ms` | `MAHJONG_CPU_MS` | 700 | a CPU's pause before it acts |
199
+ | `cpu_level` | `MAHJONG_CPU_LEVEL` | `normal` | `easy` / `normal` / `hard` (riichi_engine's CPU levels) |
200
+ | `ai_turn_seconds` | `MAHJONG_AI_TURN_SECONDS` | 30 | an AI player's time for a move; then the CPU plays it ("AI players") |
201
+ | `ai_call_seconds` | `MAHJONG_AI_CALL_SECONDS` | 15 | an AI player's time to answer a discard it may call |
202
+ | `humans` | | 4 | seats members may take (0: four CPUs that play at once, for tests) |
203
+ | `spectators` | `MAHJONG_SPECTATORS` | 12 | members without a seat at most ("Spectators") |
204
+ | `spectator_delay_minutes` | `MAHJONG_SPECTATOR_DELAY_MINUTES` | 0 | spectators see the game (and the four hands) this much later ("Spectators"); `spectator_delay_seconds` / `MAHJONG_SPECTATOR_DELAY_SECONDS` as before, in seconds |
205
+ | `rule` | | (below) | riichi_engine rule keys over the host's defaults ("Rules"); the owner's local rules go over them ("Local rules") |
206
+ | `records_kept` | `MAHJONG_RECORDS_KEPT` | 1000 (SQLite), 20 (JSON) | the newest games kept of a table ("Game records") |
207
+ | `owner_handover_seconds` | `MAHJONG_OWNER_HANDOVER_SECONDS` | 60 | the owner away this long: the role goes to a member who is there ("Players that drop out"; 0: never) |
208
+ | `abandon_seconds` | `MAHJONG_ABANDON_SECONDS` | 180 | a game whose seated humans have all been away this long (or have left) is abandoned: back to the lobby, not recorded ("Players that drop out"; 0: never) |
209
+
210
+ An othello table's keys: "The othello table".
211
+
212
+ ## Rooms
213
+
214
+ An Asterism console (0.2.2 and later) makes rooms: a key space of its own
215
+ (`room/<slot>/**`) that people join from a browser with the room's link
216
+ (the console's docs/rooms.md). With a `rooms` section (or
217
+ `ASTERISM_GAMES_ROOMS_DIR`) the host serves tables in rooms instead of the
218
+ `tables` above.
219
+
220
+ - **Which rooms, which tables.** The console writes the open rooms to
221
+ `rooms.json` in its "rooms" volume, with the app each room's link opens.
222
+ A room that opens one of `rooms.apps` (by default every game's table
223
+ screen, `Game.screens`: `/app/game/mahjong/mahjong.app.rb`,
224
+ `/app/game/othello/othello.app.rb`, `/app/game/board/board.app.rb`) gets
225
+ the tables of `rooms.tables` whose game has that screen: a room opening
226
+ the mahjong screen gets the mahjong tables, one opening the othello
227
+ screen the othello tables, one opening the board's screen the board. Without
228
+ `rooms.tables` each game's first table is there (`mahjong1`, `othello1`),
229
+ so each room gets one table of its game. Other rooms (another app, or a
230
+ game with no table in `rooms.tables`) get nothing. The admin chooses the
231
+ app when making the room (Admin > Rooms, "Opens": Mahjong; the console's
232
+ `ASTERISM_ROOM_APPS` lists what may be chosen), so a table needs no step
233
+ beyond the one a room needs anyway. The people in the room open the link
234
+ and see the table in the screen's list, as on a home network
235
+ (`games/games/mahjong1`).
236
+ - **One process per room.** The asterism gem keeps one connection a
237
+ process, so the supervisor forks a host process (Host) per room. It joins
238
+ the router in the room's namespace (`room/<slot>`): its keys are the
239
+ room's, and nothing of it reaches other rooms or the registered side (the
240
+ router's ACL). **Which certificate** (0.5.0): when `rooms.json` lists the
241
+ host's node in `"hosts"` (a console 0.3.0, its `ASTERISM_ROOM_HOSTS`),
242
+ the router keeps the rooms' `asterism/games...` for the host's own
243
+ certificate, and the process joins with it: `rooms.credentials`, else the
244
+ permanent tables' `.tar` (`permanent.credentials`; the same row `games`
245
+ of the console's registry), at `rooms.router` or the permanent tables'
246
+ router. With a console before 0.3.0 it joins with the slot's certificate
247
+ (`certs/room-<slot>.tar`, in the same volume, read-only), as before. A
248
+ console that lists the host while the host has no certificate set: the
249
+ log says so and the rooms get no table that works. At most `rooms.max` rooms have tables (3); a room
250
+ beyond that gets none (the status says so). A host process that stops is
251
+ started again after 1, 2, 4 ... 30 s.
252
+ - **A slot used again.** The tables of a room are saved under
253
+ `state_dir/rooms/<slot>/` with the room's ID (`room.json`). When the room
254
+ is closed or has ended (the file lists it as revoked, or its end has
255
+ passed), or the slot holds another room, the host process stops and the
256
+ directory is removed: the next room on the slot starts from an empty
257
+ lobby, with no seats or tokens of the last one. A file that cannot be read
258
+ changes nothing (it is not taken as every room closed).
259
+ - **Restarts.** Stopped (SIGTERM) and started again, the supervisor finds
260
+ the open rooms and each host reads its saved tables: a game goes on from
261
+ the same turn. When the router restarts, each host connects again within
262
+ 3 s.
263
+ - **Status.** `rooms.status_listen` (`0.0.0.0:7460` in the image) answers
264
+ `GET /status`: the rooms with tables, each table's state and counts
265
+ (seated, CPUs, at the table, away; no names, nodes or tokens), whether the
266
+ host can read `rooms.json`, the rooms without a table, `busy` (someone
267
+ sits at a table and is there: away seats do not count) and the RSS. The console's rooms page shows it
268
+ (`ASTERISM_GAMES_STATUS_URL`); `bin/asterism-games status [--idle]` prints
269
+ it (`--idle` exits 0 when nobody sits at a table: update.sh's
270
+ `--when-idle`). In the console's graph each room's frame shows the host's
271
+ node (`games`) and its tables, like any node in a room.
272
+
273
+ ### Who joins
274
+
275
+ A seat at a table goes with the node ID of the member that took it: a
276
+ member's `join` and `mine` give back its token, and with it its hand. So
277
+ the table must know that a call's caller (`from:`, which Asterism fills
278
+ in from the last chunk of the call's key: the tables are exposed with
279
+ `caller: :from`) is who it says.
280
+
281
+ **With seats** (a console 0.3.0: `rooms.json` says how many seats the
282
+ room's slot has, `"seats"`, and the seats' node prefix): the console's door
283
+ gives each page in a room a seat and connects it with the seat's
284
+ certificate, and the router lets a seat call only under its own node ID,
285
+ `<prefix><slot><seat>` (`wasm-0103`), and lets only the host's certificate
286
+ call under the host's names (`games...`, the host and its bots). The room's
287
+ host takes those callers as they come, with no secret, as a board's
288
+ certificate is taken on the permanent tables. The supervisor hands the
289
+ room's host the seats' node IDs (`wasm-0101` .. `wasm-0116`) and the host's
290
+ name; a room whose seats or hosts change in `rooms.json` (the console was
291
+ updated during the room) gets its host started again. A seat may go to
292
+ another page after nobody was on it for a while (the door's
293
+ `WS_DOOR_SEAT_IDLE`, 30 minutes): a seat's node ID that has not called the
294
+ table for `rooms.seat_reuse_seconds` (1800) is a new page, and the old
295
+ member leaves (its seat is played by a CPU in a game) before the new one
296
+ joins. A page opened again in the same browser comes back with its secret
297
+ and gets the same seat (the console 0.3.9: the door cuts what is left of
298
+ the old connection), so it is the same member, at its seat, with its hand:
299
+ the same person within `seat_reuse_seconds`. That time stays at the door's
300
+ 30 minutes (games 0.16.2 looked at it again): the door gives a seat to
301
+ another page only after as long without a connection, and a longer time
302
+ here would hand the old member's seat and hand to that new page.
303
+
304
+ Nobody else joins a room's table: a `join` or `mine` from another caller
305
+ answers `{"error" => "identity"}` (the screen says to open the room's link
306
+ again). The pages of the browser build from before the seats (a node ID
307
+ worked out from a secret) are not taken any more (games 0.7.0).
308
+
309
+ Outside the rooms nothing is checked (a home network; the permanent
310
+ tables, where a board's certificate is its node ID).
311
+
312
+ - **Bots in a room.** `bin/mahjong-bots --credentials games.tar --namespace
313
+ room/01` (the host's own certificate): the bots are `games-bot<pid>-<i>`,
314
+ names the router keeps for that certificate, so the table takes them as
315
+ they are.
316
+ - **What the router now keeps closed** (console 0.3.0): a member cannot
317
+ declare a queryable, put a state or a liveliness token, or reply on the
318
+ host's keys (`room/<slot>/asterism/games...`), so it cannot pose as the
319
+ table and collect what other pages send; nor can a page from before the
320
+ seats (the slot's certificate has a deny on the host's and the seats'
321
+ names).
322
+
323
+ | Key (`rooms:`) | Environment | Default | |
324
+ |---|---|---|---|
325
+ | `dir` | `ASTERISM_GAMES_ROOMS_DIR` | none (no rooms) | the console's rooms volume: `rooms.json`, `certs/` |
326
+ | `apps` | `ASTERISM_GAMES_ROOM_APPS` (comma separated) | the games' screens (`/app/game/mahjong/mahjong.app.rb`, `/app/game/othello/othello.app.rb`, `/app/game/board/board.app.rb`) | a room whose link opens one of these gets the tables of the game with that screen |
327
+ | `max` | `ASTERISM_GAMES_ROOM_MAX` | 3 | rooms with tables at once |
328
+ | `node` | `ASTERISM_GAMES_ROOM_NODE` | `games` | the host's node ID in a room |
329
+ | `router` | `ASTERISM_GAMES_ROOM_ROUTER` | the certificate's (`tls/zenohd:7448`) | the router |
330
+ | `status_listen` | `ASTERISM_GAMES_STATUS_LISTEN` | none | host:port of `GET /status` |
331
+ | `tables` | | each game's first table (`mahjong1`, `othello1`) | as `tables` above; a room gets those of the game its link opens; the `MAHJONG_*` and `OTHELLO_*` environment applies |
332
+ | `credentials` | `ASTERISM_GAMES_ROOM_CREDENTIALS` | `permanent.credentials` | the host's own `.tar` for the rooms (used when `rooms.json` lists the host in `"hosts"`) |
333
+ | `seat_reuse_seconds` | `ASTERISM_GAMES_ROOM_SEAT_REUSE_SECONDS` | 1800 | a seat's node ID silent this long at a table is a new page |
334
+
335
+ Memory (CRuby 3.2, the image, measured): the supervisor about 43 MB RSS,
336
+ each room's host about 45-50 MB more (the forked process shares the loaded
337
+ code with the supervisor at first).
338
+
339
+ ## Permanent tables
340
+
341
+ Next to the rooms, the supervisor can keep tables of its own on the
342
+ registered side of the console's router: the boards that connect with
343
+ their own certificates (P4-Nano, Tab5: `/home/asterism/config.toml` and
344
+ their `.tar`) and the browsers with a pass see them in the table screen's
345
+ list (`games/games/mahjong1`, `games/games/mahjong2`), as on a home network.
346
+
347
+ - **The host's certificate.** The console registers the host as a row of
348
+ its own (kind `client`, name `games`, the role `games-host`) and issues
349
+ its certificate; the `.tar` ("Save all (.tar)") goes to the host
350
+ (`permanent.credentials`). The router's ACL knows the host by that name,
351
+ so the node ID is the certificate's name unless `permanent.node` says
352
+ otherwise. The players' rows get the role `games`. The roles' templates
353
+ and what they keep closed: the console's docs/rooms.md, "A game host's
354
+ permanent tables".
355
+ - **One more process.** The supervisor forks one more host process
356
+ (Host) for the tables of `permanent.tables` (`mahjong1` and `mahjong2`),
357
+ joins the router with that certificate, outside every room (no
358
+ namespace), and puts the host's profile with the name `permanent tables`
359
+ (games 0.7.1; `asterism-games` before). A room's host joins with the same
360
+ certificate, so both have the node ID `games`: the console's graph shows
361
+ this one as `permanent tables (games)` and a room's as `games (room 05)`
362
+ (console 0.3.3). It is started
363
+ again like a room's host (1, 2, 4 ... 30 s). Without the certificate file
364
+ (or not readable) the supervisor waits for it and says so once in the
365
+ log; the rooms go on.
366
+ - **Saving.** The tables are saved under `state_dir/permanent/` after every
367
+ change and taken back at every start; the rooms' clean-up never touches
368
+ them. A game goes on from the same turn after a restart of the host.
369
+ - **Status.** `GET /status` has `permanent` (the host's node, `state`: `up`,
370
+ `starting`, `restarting` or `no certificate`, each table's counts; null
371
+ when not set up); `busy` counts its seats too, and the RSS its process.
372
+ The console (0.2.3) shows it on the rooms' page under the game host's
373
+ line; the graph shows the node `games` and its tables on the registered
374
+ side.
375
+ - **Hands.** A seat's view needs the seat's token, which `join` gives to
376
+ the node that joined. A board's node ID is its certificate's name and the
377
+ router lets only that certificate call under it, so another member cannot
378
+ get a board's token. The browsers are one subject at the router (any
379
+ browser may call under any `wasm-*` node ID, the console's docs/g1_5.md),
380
+ so a browser's seat is as safe as the other pass holders are trusted.
381
+
382
+ | Key (`permanent:`) | Environment | Default | |
383
+ |---|---|---|---|
384
+ | `credentials` | `ASTERISM_GAMES_PERMANENT_CREDENTIALS` | none (no permanent tables) | the host's `.tar` from the console |
385
+ | `router` | `ASTERISM_GAMES_PERMANENT_ROUTER` | the certificate's (`tls/zenohd:7448` in the image) | the router |
386
+ | `node` | `ASTERISM_GAMES_PERMANENT_NODE` | the certificate's name | the host's node ID |
387
+ | `tables` | `ASTERISM_GAMES_PERMANENT_TABLES` (names, comma separated; a name finds its game, or `name:game`) | `mahjong1`, `mahjong2` (the image's `config/rooms.yml`: and `othello1`) | as `tables` above; the `MAHJONG_*` and `OTHELLO_*` environment applies |
388
+
389
+ The section is read only with `rooms` (the supervisor). Without rooms, the
390
+ host itself does the same with `credentials` and `tables`.
391
+
392
+ Memory (the image, measured with a room's table and both permanent tables
393
+ in play): the permanent tables' process 51 MB RSS (24 MB PSS: the rest is
394
+ shared with the supervisor), the container 66 MiB in all with the
395
+ supervisor and one room. With `rooms.max` (3) rooms as well, about 250 MB
396
+ RSS (about 120 MB PSS).
397
+
398
+ ## Who is there
399
+
400
+ A member is away when it is gone: its seat shows it (`"aw"`, the member
401
+ list's flag), a mahjong seat is played for after a short wait, the status
402
+ counts it. The host learns it from Asterism, not from the calls (games
403
+ 0.8.0), and by the player's table app (games 0.10.0): a table app runs as
404
+ the application **`game`** of its node (the boards' and the browsers'
405
+ table apps, `bin/othello-tty`, `bin/mahjong-bots` do), and the host
406
+ follows the liveliness token `asterism/<node>/game`. The Net's
407
+ `on_app_leave` tells it that the table app left the network (a closed
408
+ browser tab, a board's app that closed, a process killed, a line that
409
+ dropped) even when the node stays with other applications (a board that
410
+ runs another Asterism app next to the table), and `on_app_join` that it
411
+ is there again. With asterism 0.7.1 (no `on_app_*`) the host watches the
412
+ tokens `asterism/*/game` itself on the session. The host passes both to
413
+ every table (`Game#node_left` / `node_joined`):
414
+
415
+ - a member whose node leaves is away at once, and back at once when its
416
+ node joins again (before it calls anything);
417
+ - a member whose node the host has seen join is there for as long as the
418
+ node is, calling or not (a screen on the list of tables, a terminal
419
+ watching);
420
+ - a member whose node the host has never seen (see below), or that does
421
+ not run as the application `game` (an `asterism mcp` client started
422
+ without `--app game`, a script with an app name of its own), is away when
423
+ it has not called the table for `away_seconds`, as before 0.8.0: the
424
+ screens still ask their view every few seconds for that.
425
+
426
+ Members gone for long are forgotten six times `away_seconds` after their
427
+ last call or after their node left, whichever is later (seated members are
428
+ kept); in a room a seat's node ID gone for `seat_reuse_seconds` is a new
429
+ page when it comes back ("Who joins"). Presence is not saved: a host that
430
+ starts again counts its members as just seen, and the nodes there join on
431
+ its first polls. When the host's own connection drops, every node leaves
432
+ and joins again once it is back (a second or so, "When the connection is
433
+ lost" of asterism).
434
+
435
+ Whether the host sees the others come and go is the router's business:
436
+
437
+ | Where | The host sees the players' nodes | So |
438
+ |---|---|---|
439
+ | 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) |
440
+ | 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) |
441
+ | 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) |
442
+
443
+ A role `games-host` made before that line (with only `Watch asterism/**`)
444
+ gets just the tokens on its own keys: the host then falls back to the time
445
+ without a call (away after `away_seconds`, measured 13.6 s with the default
446
+ 10 s). Add the line to the role (Admin > Relay > Roles, Edit) and Apply;
447
+ nothing in the host changes with it.
448
+
449
+ 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
451
+ list when its node leaves and look for them again when a node joins
452
+ (Asterism's `each` reads the liveliness they already follow: no message is
453
+ sent); at a table, a host that leaves shows the connection lost, the
454
+ screen stops asking its view (one every 30 s, should the word not come)
455
+ and asks again as soon as the host is back.
456
+
457
+ ## Status
458
+
459
+ The host tells its tables' counts on the network (games 0.9.0), as
460
+ `asterism/<node>/<app>/@status` (asterism's `docs/protocol.md` 8: an
461
+ application's status, a map it puts when it changes and gives to whoever
462
+ asks). One entry per table, with the counts of `GET /status` (no node IDs
463
+ or tokens), and for a mahjong table the result of its most recent game
464
+ (games 0.13.0, "Game records"):
465
+
466
+ ```
467
+ asterism/games/games/@status
468
+ {"mahjong1" => {"st" => "play", "members" => 4, "humans" => 1, "cpus" => 3, "ais" => 1, "away" => 0,
469
+ "last" => {"id" => 12, "at" => "2026-10-18 21:05", "game" => "tonpuu",
470
+ "result" => [["kishima", 1, 38200], ["CPU2", 2, 27100], ["Claude", 3, 21900], ["CPU1", 4, 12800]]}},
471
+ "othello1" => {"st" => "wait", "members" => 0, "humans" => 0, "cpus" => 0, "away" => 0},
472
+ "ai_today" => {"calls" => 84, "usd" => 0.02, "limit_usd" => 3.0}}
473
+ ```
474
+
475
+ `last` is the four in rank order as `[name, rank, score]`, by the names
476
+ the table shows its members anyway (a table app shows them to whoever
477
+ joins); it is there once a game has ended. The rooms' status file
478
+ (`GET /status` of the rooms' host) stays counts only.
479
+
480
+ `ais` counts the AI players' seats (they are counted in `cpus` too), and
481
+ `ai_today` is the AI players' totals of the day (README "AI players"),
482
+ only on a host that can seat them. When some kind has a daily limit of its
483
+ own (games 0.15.0) `ai_today` also has `kinds`, one entry for each kind
484
+ the host offers: its money of the day (`usd`), the limit it stops at
485
+ (`limit_usd`: its own, or the total's), whether that limit is its own
486
+ (`own`) and whether it has stopped for today (`over`):
487
+
488
+ ```
489
+ "ai_today" => {"calls" => 84, "usd" => 0.02, "limit_usd" => 3.0,
490
+ "kinds" => {"claude" => {"usd" => 0.0198, "limit_usd" => 10.0, "own" => true, "over" => false},
491
+ "jev" => {"usd" => 0.0002, "limit_usd" => 2.0, "own" => true, "over" => false}}}
492
+ ```
493
+
494
+ - **When.** After a table changes (a join, a seat taken or left, a move)
495
+ and once a second (an away seat changes the counts with time alone); a
496
+ put goes out only when the counts differ from the last one. A host puts
497
+ it as soon as it is connected.
498
+ - **Who reads it.** `asterism watch` and `asterism tree` (the asterism
499
+ gem's command line), its MCP bridge (`asterism_status`), the console's
500
+ details of the host's node (console 0.3.4), or anyone with
501
+ `Asterism.status_of("games", "games")`.
502
+ - **Where.** On a home network and on the permanent tables. The role
503
+ `games-host` needs `asterism/${node}/*/@status` in "Read and write" (the
504
+ console's docs/rooms.md): a `**` does not match the chunk `@status`. A
505
+ room's host tells none (the rooms' rules let it write only its own
506
+ place, which `@status` is not part of, and the console only watches who
507
+ is in a room).
508
+ - **The asterism gem.** With an asterism that has `Asterism.status`
509
+ (0.8.0, the version this repository is pinned to) the host uses it
510
+ (`net.status`); with an asterism 0.7.1 (the gem's lowest) it does the
511
+ same by hand on the connection's session (`Host::TablesStatus`: a
512
+ queryable on the key and a put when the counts change).
513
+ - **Size.** Five tables take about 300 bytes (the largest put is in the
514
+ log's `largest message` with the others).
515
+
516
+ ## Writing a game
517
+
518
+ A game is two classes on the framework: the table (`AsterismGames::Game`:
519
+ the rules, the views, the saved form) and the object the host exposes for
520
+ each table (`AsterismGames::Remote`: the methods players call). Everything
521
+ else is the framework's: the connection, the members and their tokens, who
522
+ may join in a room, the host's loop, the puts, saving and resuming, the
523
+ rooms, the permanent tables, the status and the settings. A complete small
524
+ game is `test/framework/tally.rb`, and a real one the othello table
525
+ (`lib/asterism_games/othello/`, about 600 lines with its rules and CPU);
526
+ the shape of one:
527
+
528
+ ```ruby
529
+ require "asterism_games"
530
+
531
+ module Counter
532
+ class Remote < AsterismGames::Remote
533
+ exposes press: 2, view: 1 # name => number of arguments (join, mine, leave are there)
534
+
535
+ def press(token, n, from:) # from: the caller's node ID (caller: :from)
536
+ mark(token, from) # a call from the member's own node: it is there
537
+ @game.press(token, n.to_i, now)
538
+ end
539
+
540
+ def view(token, from:)
541
+ mark(token, from)
542
+ @game.member_view(token, now) || { "error" => "token" }
543
+ end
544
+ end
545
+
546
+ class Table < AsterismGames::Game
547
+ register "counter" # tables: [{name: counter1, game: counter}]
548
+ remote Remote
549
+ tables "counter1" # the tables when the settings name none
550
+ screens "/app/game/counter/counter.app.rb" # a room whose link opens this gets them
551
+ settings file: { "goal" => ["goal", 1] }, env: { "COUNTER_GOAL" => ["goal", 1] }
552
+ DEFAULTS = AsterismGames::Game::DEFAULTS.merge("goal" => 10).freeze
553
+
554
+ def initialize(...)
555
+ super
556
+ @n = 0
557
+ load_saved(0) # restore and resume_clock when a saved state is there
558
+ end
559
+
560
+ def press(token, n, now)
561
+ return "token" unless node_of(token)
562
+ @n += n
563
+ changed # saved and put by the host
564
+ "ok"
565
+ end
566
+
567
+ def tick(now) = super.tap { expire_members(now) } # time: called on the host's loop
568
+ def public_view(now) = { "v" => @ver, "t" => @name, "n" => @n } # put on .../<table>/state
569
+ def member_view(token, now) = node_of(token) && public_view(now)
570
+ def to_saved(now) = super.merge("n" => @n)
571
+ def restore(data, now) = super.then { @n = data["n"].to_i }
572
+ end
573
+ end
574
+ ```
575
+
576
+ `bin/asterism-games` requires the framework and the games it serves
577
+ (`asterism_games/mahjong` from `app/` when it is there, then
578
+ `asterism_games/othello` and `asterism_games/board`), then the files given
579
+ with `--require FILE` (a game of your own, with the gem or this checkout);
580
+ the settings name a game per table. The mahjong of `app/` is written the
581
+ same way: it requires the framework by its name, as your game would.
582
+
583
+ What the framework gives a game:
584
+
585
+ | | |
586
+ |---|---|
587
+ | `Remote.exposes` | the exposed methods and their numbers of arguments (-1: any), checked by Asterism before a call; the host exposes them with `caller: :from`, so every method takes `from:` (Asterism fills it in from the call's key and refuses a call that brings it). `join(name)`, `mine`, `leave(token)` are the base's |
588
+ | `Game#join` / `mine` / `leave` | a member per node with a token (`SecureRandom`, 32 hex digits); the same node gets its token back. In a room the caller is checked ("Who joins"). A game adds its own through `member_reply`, `join_refusal`, `renamed`, `forget` (free what the member held) and `keep_member?` |
589
+ | `node_of(token)`, `touch`, `member_away?`, `node_left` / `node_joined`, `expire_members` | the token's member; who is there ("Who is there": the host calls `node_left` / `node_joined` from Asterism's `on_leave` / `on_join`, a member's call is `touch`, and `away_ms` without one is away too); members gone for long (six times `away_ms` from their last call or from when their node left) |
590
+ | `tick(now)` | the host calls it on its loop (every 10 ms) and the game moves on by the time given: deadlines, CPU turns. Never waits |
591
+ | `public_view` / `spectator_view` / `state_view` / a member's view | what everybody, a spectator, the state key and one member see. What only one member may see (a hand) goes in the answer of that member's call, never on the state key (anyone in the key space may subscribe to it) |
592
+ | `changed`, `dirty?`, `unsaved?` | a change to save and put; a change only one member's view shows (`@unsaved = true`) is saved, not put |
593
+ | `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
+ | `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) |
595
+ | `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
+ | `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
+
598
+ The host (`Host`) is written with Asterism's block API:
599
+ `Asterism.connect(...) do |net| ... end` exposes the tables, sets the
600
+ profile (`name: asterism-games` for a host of its own, `permanent tables`
601
+ for the permanent tables' host, no name in a room) and starts the Net's
602
+ receiving thread, which answers the calls. The games move on in the
603
+ host's own loop (`step`: `tick`, saving, the puts, every 10 ms), which
604
+ holds `Asterism::LOCK` while it runs: the receiving thread holds the same
605
+ lock for each of its ticks, so a game never sees a call and a step at
606
+ once, and a game needs no lock of its own. When the connection is lost
607
+ (the receiving thread ends; `net.lost_reason` says why) the host saves and
608
+ connects again after 3 s; while a client of a router only reconnects
609
+ (`reconnecting?`) the tables stay and the puts wait.
610
+
611
+ ## The mahjong table
612
+
613
+ ### Methods
614
+
615
+ | Method | Arguments | Returns |
616
+ |---|---|---|
617
+ | `join` | name | `{"token", "seat" (-1: none), "owner"}`, or `{"error" => "full"}` (16 members) or `{"error" => "spectators"}` ("Spectators"); a join again from the same node keeps the membership and takes the name given (a screen whose player changes its name; at the seat too, during a game as well); at most 32 bytes, no control characters, empty: the node ID |
618
+ | `mine` | none | the same for a node that joined, or nil (nothing is created) |
619
+ | `sit` | token, seat | `"ok"` or why not (`"taken"`, `"not waiting"`, ...); seat -1: the first free one. In the lobby only; a seated member moves |
620
+ | `stand` | token | `"ok"` or why not (lobby only) |
621
+ | `cpu` | token, seat, on | the owner: a CPU into an empty seat (on) or out of it (a CPU or an AI player); seat -1 with on fills every empty seat; on as a name the lobby offers (`"claude"`, `"jev"`) puts that AI player in ("AI players"), `"no ai"` when it is not offered; on one seat that holds a CPU or an AI player of another kind, on turns it into the one asked for (games 0.12.1) |
622
+ | `start` | token | the owner, once the four seats are taken and one is a member: the seats are drawn (who deals first) and the game starts |
623
+ | `rule` | token, item, value | the owner, in the lobby: a local rule ("Local rules"); `"ok"` or why not (`"not owner"`, `"not waiting"`, `"no rule"`, `"value"`, `"no change"`) |
624
+ | `view` | token, assist | the member's view (below), or `{"error" => "token"}`; assist: the help its screen shows (Assist, 0-3) |
625
+ | `act` | token, sv, type, tiles | `"ok"`, or why not: `"token"`, `"not seated"`, `"stale"` (sv is not the current one), `"not offered"`, `"not playing"` |
626
+ | `table` | none | the public view (no hand), as a spectator sees it |
627
+ | `leave` | token | `true` / `false`; in the lobby the seat empties, during a game it goes to a CPU |
628
+ | `describe`, `look`, `choose`, `points` | | for programs ("Playing from a program") |
629
+ | `records`, `record` | from / id, round, part | the games played to the end and their records ("Game records") |
630
+ | `stats` | days, game, scope, from (all optional) | the players' stats ("Stats") |
631
+
632
+ - People join a table and wait in its lobby (`"st" => "wait"`): a member
633
+ sits in a seat or watches. The first member who sat is the owner (when the
634
+ owner stands or leaves, the member seated longest takes over). The owner
635
+ fills empty seats with CPUs and starts. At the end of a game the owner's
636
+ next (or `over_seconds`) brings the table back to the lobby with the same
637
+ seats, for another game.
638
+ - The node that calls is the caller in the call's key (`from:`, filled
639
+ in by Asterism; a call that brings `from:` itself is refused). The
640
+ arguments are as before: a table app that knows nothing of `from:`
641
+ calls as it did. The same node gets its membership and
642
+ token back from `join` (and `mine`), so a player that restarts its
643
+ application finds its table again.
644
+ - The token is 32 hex digits from `SecureRandom`. Everything about a hand
645
+ needs it: without it, or with another, `view` and `act` refuse. A member
646
+ without a seat (a spectator) gets the public view. The public view never
647
+ has a hand (except the winners', shown at a win).
648
+ - `act` takes only what the seat's list (`"a"` in its view) has: the
649
+ host asks riichi_engine for the seat's actions and matches the request
650
+ against them. `sv` is the state version the player saw.
651
+ - After every change the host saves the table and puts the public view on
652
+ `asterism/<node>/<app>/<table>/state` (and every 5 s anyway). A player
653
+ that sees a newer `"v"` there asks its view again; a screen also asks every
654
+ 3 s, which tells the host it is still there.
655
+
656
+ ### Assist
657
+
658
+ The help the table screen shows its player, chosen by the player and sent
659
+ with each `view` (a screen's setting, not the table's: every seat may ask
660
+ for another level). Worked out once per state version, seat and level, only
661
+ from what that seat can see, and only for a seat that asks.
662
+
663
+ | Level | Help |
664
+ |---|---|
665
+ | 0 | none |
666
+ | 1 (default) | when the seat may declare riichi: `wt` for each discard that declares it |
667
+ | 2 | at the seat's discard: `wt` for each discard that leaves the hand tenpai, with or without riichi (an open hand, a hand that stays dama). Whether a wait has a yaku is not judged |
668
+ | 3 | as 2; when no discard leaves the hand tenpai, `uk` for every discard |
669
+
670
+ - Shanten: riichi_engine's own count (`Cpu::Analysis::Shanten.calculate(t34,
671
+ melds:)`, the fork's `fix/shanten-melds`; `Engine.shanten` calls it). It
672
+ takes the melds as sets already made, counts seven pairs and kokushi for
673
+ a closed hand only, and works out each suit once per suit pattern. The
674
+ host had its own count of the regular form until 0.1.1.fmrb3 (0.1.0's
675
+ took the hand as all there is, wrong once there are melds, and took about
676
+ 50 ms a turn for the acceptance); the engine's gives the same help (on
677
+ 6,623 states of CPU hanchan, the level 3 help was the same byte for byte)
678
+ and is about 10 % quicker. The tests compare it with the engine's count
679
+ for closed hands (3000 random hands) and, with melds, tenpai with the
680
+ wait judge.
681
+ - Time (CRuby, one seat's help at one state, one process): over 8 CPU
682
+ hanchan with seats at levels 1, 2 and 3, median 1.3 ms, 95 % 4.8 ms, at
683
+ most about 60 ms (the first ones, before the suit patterns are known, or
684
+ a garbage collection; `rake test` prints the `assist:` line).
685
+
686
+ ### Local rules
687
+
688
+ The owner chooses the table's rules in the lobby, before the start; every
689
+ member sees them there (the view's `ru`), and the game is played by the
690
+ rules it started with (`rule` answers `"not waiting"` once it has started).
691
+ They are saved with the table and stay for the next game at the table. The
692
+ host's rules ("Rules": `Engine::RULE_DEFAULTS` and the table's `rule`
693
+ setting) are where they start, and choosing a host's value again drops the
694
+ choice. A new room (a slot used again) starts from the host's rules.
695
+
696
+ `ru` has one number per item for items 0-10, `rx` for items 11-14 and `ry`
697
+ for items 15-16, in this order (the table screen puts the three together;
698
+ the numbers of the items never change, and a screen shows them in an order
699
+ of its own); `rule(token, item, value)` takes one of the values listed (or
700
+ the host's own value for the item). Item 10 is the time ("Time"): its value is
701
+ a code, the seconds for a move plus 1000 times the seconds of the bank
702
+ (5+20 is 20005, a fixed 20 s is 20). `ru` gives its seconds for a move
703
+ only, `tb` its bank (the screen adds them up again), and in the lobby `tc` the codes to choose from, in order (the
704
+ host's own value among them):
705
+
706
+ | Item | | riichi_engine key | Values |
707
+ |---|---|---|---|
708
+ | 0 | the game | `game_type` | 0 hanchan, 1 tonpuu |
709
+ | 1 | open tanyao | `kuitan` | 0 / 1 |
710
+ | 2 | red fives | `aka_dora` | 0 none, 3 one each of 5m 5p 5s, 4 two of 5p |
711
+ | 3 | double ron | `double_ron` | 0 (the head bump) / 1 |
712
+ | 4 | three rons a draw | `triple_ron_draw` | 0 (all three win) / 1 (sanchahou) |
713
+ | 5 | several yakuman added up | `multiple_yakuman` | 0 / 1 |
714
+ | 6 | kazoe yakuman | `kazoe_yakuman` | 0 / 1 |
715
+ | 7 | kiriage mangan | `kiriage_mangan` | 0 / 1 |
716
+ | 8 | tobi | `tobi` | 0 / 1 |
717
+ | 9 | the extra round ("延長"): west for a hanchan, south for a tonpuu game | `nishi_iri`, `nan_iri` together | 0 / 1 (the value is the key of the game chosen; a table app that calls the item the west round sets both) |
718
+ | 10 | the time for a move and the bank ("持ち時間") | (the host's `turn_seconds`, `bank_seconds`) | `tc`: 5+20 (20005), 5+0 (5), a fixed 30 s (30), 60 s (60) and 600 s (600), and the host's own |
719
+ | 11 | the abortive draws besides nine kinds (four winds, four kans, four riichi) | `suufon_renda`, `suukaikan`, `suucha_riichi` together | 0 / 1 (1 only when all three are on; a host setting with some of them shows 0, and 0 chosen again goes back to it) |
720
+ | 12 | nagashi mangan | `nagashi_mangan` | 0 / 1 |
721
+ | 13 | double yakuman (suuankou tanki, kokushi 13-wait, junsei chuuren) | `double_yakuman` | 0 / 1 |
722
+ | 14 | tenpai-yame | `tenpai_yame` | 0 / 1 |
723
+ | 15 | the CPUs' level | (the host's `cpu_level`) | 0 easy, 1 normal, 2 hard: the CPUs of the table's next game play at it |
724
+ | 16 | the spectators' delay | (the host's `spectator_delay_minutes`) | seconds: 0 (none), 60, 180, 300, and the host's own; in the lobby `dc` lists them in order ("Spectators") |
725
+
726
+ Left out: `atozuke` (riichi_engine has the key but does not use it), the
727
+ starting points, the first place target and the uma (they change the final
728
+ points, not the play: the table's `rule` setting still sets them),
729
+ `renpuu_jantou_fu` (the fu of a pair of the double wind; it is not sure
730
+ what Mahjong Soul does), `pao` (which yakuman; a list, not a switch),
731
+ `minkan_dora_after_discard` (when the kan dora of an open kan shows) and
732
+ `agari_yame` (the screen's column has room for fifteen rows at 640x360).
733
+
734
+ ### Spectators
735
+
736
+ A member without a seat watches: its view is the public one with the four
737
+ hands besides (`sh`, `sd`: each seat's hand and drawn tile, as a seat's
738
+ own `h` and `dt`), and no offers, no help and no sign of who may call. The
739
+ put on `.../state` and `table` have no hand (except the winners', shown at
740
+ a win), so the seated players, who read the put too, never see another
741
+ hand.
742
+
743
+ - **How many.** At most `spectators` members without a seat (12: a room
744
+ of an Asterism console lets 16 pages in, four of them at the seats; the
745
+ host itself connects to the router directly). In the lobby a member that
746
+ could still sit counts in addition, so people can come in and sit. A
747
+ member beyond that gets `{"error" => "spectators"}`; a member gets its
748
+ place back as before.
749
+ - **The delay.** With a delay (`spectator_delay_minutes`, or chosen by the
750
+ owner in the lobby: "Local rules", item 16), the spectators' view, the
751
+ put and `table` show the game as it was that long ago: the host keeps the
752
+ game's part of the public view (`st sv r sc ri dora kw fu hc ls ev res
753
+ fin fr`) and the four hands at every change for that long, and the
754
+ people's part (`nm hu sk aw ow mb ru rx ry`) and `v` are now's (so a
755
+ screen asks again when the delayed picture moves on). There is no
756
+ deadline (`dl`) in a delayed view. The seated players' own views are never
757
+ delayed. Counted in time, not in actions: a round's result then stays as
758
+ long for the spectators as for the players, and an action every 0.7 s (a
759
+ CPU) or every 20 s (a person) does not change how far behind the
760
+ spectators are. A host started again starts the delay over (the past is
761
+ not saved).
762
+ - **The default is no delay.** The spectators see the hands as they are:
763
+ good for watching a game, and for an audience in the same room. A seated
764
+ player who joins the same table from a second screen sees the others'
765
+ hands too; where that matters, choose a delay (a minute or more: the
766
+ hands it shows are then of a past turn).
767
+
768
+ ### Calls on a discard
769
+
770
+ When several seats may call a discard (ron, pon, chi, open kan), every one
771
+ of them answers once: a CPU at once, a player by `act` (a call or pass), or a
772
+ pass when `call_seconds` (counted from the discard, the same deadline for
773
+ everybody) run out. An answer is final (the seat's list is empty after it).
774
+ Once all have answered, riichi_engine's priority decides: ron over pon and
775
+ open kan over chi; several rons by the rule (by default every ron wins,
776
+ two or three; `triple_ron_draw` makes three a draw, sanchahou), equal calls in turn order from the discarder. The players
777
+ see the choices exactly as the engine lists them.
778
+
779
+ Nobody learns who else may call, or who has answered:
780
+
781
+ - A seat sees its own choices only (`a`). Its `dl` has its own bit while it
782
+ still has to answer; every other seat's `dl` has the bits of the three
783
+ others whoever they are (the screen says "the others are answering"),
784
+ with the same time left. The public view (the put, `table`, the
785
+ spectators) has no `dl` while the calls are open.
786
+ - An answer changes only the answering seat's view: its `v` is the table's
787
+ plus one until the calls are decided (its buttons go), the table's own
788
+ version does not move and nothing is put (the table is saved). The
789
+ change that follows the calls moves the version by two, past what that
790
+ seat saw.
791
+ - When everybody passed, the last event (`ev`) is a pass by seat -1.
792
+ - What still shows: that the host waits (the phase `r[7]` is the calls,
793
+ for a human the time of the window), not for whom.
794
+
795
+ ### Time
796
+
797
+ Mahjong Soul's "5+20" by default: a move (a discard, riichi, a kan, a win,
798
+ nine kinds) has `turn_seconds` (5), and each player has a bank of
799
+ `bank_seconds` (20). What a move takes beyond its 5 s comes off the bank;
800
+ when the 5 s and the bank are both used up, the move is played for (the
801
+ drawn tile is discarded). The bank is full again at the start of every
802
+ round. A bank of 0 is
803
+ a fixed time a move (`turn_seconds` and no more). The calls on a discard
804
+ keep `call_seconds` and do not touch the bank. An away player keeps its
805
+ short wait ("Players that drop out"; it takes nothing off the bank while
806
+ `away_wait_seconds` is shorter than `turn_seconds`). The owner chooses the
807
+ time in the lobby ("Local rules", item 10). The banks are saved with the
808
+ table; a table saved before the bank starts its game with full banks (and
809
+ a fixed time the owner had chosen stays fixed).
810
+
811
+ The player sees the time on its own move: `dl` is `[seats, ms left, the
812
+ bank's ms]` when the move has a bank, the bank being the last part of the
813
+ ms left (the move's own time is what comes before it).
814
+
815
+ ### Result
816
+
817
+ (games 0.15.0) A table with a human at it:
818
+
819
+ - **The start of a round.** After the deal the host waits
820
+ `deal_pause_seconds` (2) while every screen shows the round's name in
821
+ big letters ("東1局 0本場"). Nobody's time runs (the dealer's first move
822
+ has its full time from the end of the pause), no CPU or AI player plays,
823
+ and `act` answers `"not offered"`. The view says it with `in`.
824
+ - **The result waits for every human's OK.** A round's result (a win or
825
+ a draw) moves on to the next round once every human seated and there
826
+ (not away, "Players that drop out") has pressed OK: the action next
827
+ (12), so a screen made before 0.15.0 gives its OK with its next as it
828
+ did. A seat that has given it has nothing more in `a` (a second next is
829
+ `"ok"` and changes nothing). An away human is not waited for; with
830
+ nobody there the time decides.
831
+ - **Or by itself after the count.** Whoever does not press, the result
832
+ moves on `result_auto_seconds` (10) after the end of its count. The
833
+ count is the time a screen takes to show the result one step a second,
834
+ the same rule on both sides: **per winner, one step for each line of
835
+ its yaku (`y`) and one for its points; with two or three winners their
836
+ steps one after the other (the sum); a round without a win (a draw, an
837
+ abortive draw, three rons) one step.** A step is 1 s. The host counts
838
+ from the moment of the result; a screen that shows all at once (a
839
+ player who skips the steps) does not change the host's time. The view
840
+ says whom the result waits for and how long with `ok`.
841
+ - The end of a game (`over`) is as before: the owner's next (or
842
+ `over_seconds`) takes the table back to the lobby.
843
+
844
+ A table without humans (`humans` 0, or every human replaced by a CPU)
845
+ has neither: its result stays `result_seconds` (8) and its rounds start
846
+ at once, as before 0.15.0. A table saved with a result before 0.15.0
847
+ moves on by its saved time.
848
+
849
+ ### Players that drop out
850
+
851
+ A member whose node leaves the network is away at once ("Who is there"),
852
+ and so is one that has not called the table for `away_seconds` (its screen
853
+ asks its view every 3 s), the way to tell where the host does not see the
854
+ nodes come and go: the public view marks its seat (`"aw"`) and the member
855
+ list. While it is away its seat is played for after a short wait:
856
+ `away_wait_seconds` from the start of its turn or of the calls (the drawn
857
+ tile is discarded, a call is passed), instead of `turn_seconds` and
858
+ `call_seconds`, so the others are not held up a whole turn every time. The
859
+ other players keep their own times (in the calls, the away seat passes
860
+ early and the rest go on waiting). A wait that started before the member
861
+ went away ends as soon as it is seen away, if its short wait is over by
862
+ then. Once its node is back or it calls again it has the full times again;
863
+ its screen asks its view and goes on. A seat is never handed to a CPU because its player is
864
+ away; a member that leaves (`leave`) during a game is.
865
+
866
+ **The owner away** (games 0.16.2): when the owner has been away for
867
+ `owner_handover_seconds` (60), in the lobby or during a game, the role
868
+ goes to a member who is there: the seated one who sat first, else the
869
+ one who joined first. With nobody there it stays where it is, and passes
870
+ as soon as someone is. The old owner coming back does not take it back.
871
+ The log says `the owner <node> was away for 60 s: <node> is the owner
872
+ now`. In a room a page opened again in the same browser comes back as the
873
+ same seat (the console 0.3.9's door, "Who joins"), and so as the owner if
874
+ the role has not passed yet.
875
+
876
+ **A table left alone** (games 0.16.2): during a game (`play`, `result`),
877
+ when none of the seated humans counts any more, the game is abandoned. A
878
+ seated human counts while it is there and for `abandon_seconds` (180)
879
+ after it went away; a member that left the table (`leave`) counts no
880
+ more at once (its seat is a CPU's already). The table goes back to the
881
+ lobby as at the end of a game: the CPUs and AI players keep their seats,
882
+ the away humans are taken off theirs (they stay members, watching, and
883
+ may sit again), the owner's role passes as when an owner stands, and
884
+ nothing of the unfinished game is kept: no record, nothing in the stats.
885
+ The log says `game abandoned: no human there for 180 s (seats [0, 2]
886
+ emptied; not recorded)`. One human back within the time keeps the game
887
+ going for everyone (the other away seats are played for as before). A
888
+ table of CPUs only (`humans: 0`, the permanent spectator tables) is never
889
+ abandoned.
890
+
891
+ ### Views
892
+
893
+ A Hash (MessagePack); tiles are one byte each in binary strings (bin).
894
+
895
+ | Key | |
896
+ |---|---|
897
+ | `v` / `sv` | the view's version (any change; in a seat's own view one more after its answer to open calls, "Calls on a discard") / the state version (actions) |
898
+ | `t`, `st` | the table's name; `wait` (the lobby), `play`, `result` (a round's result), `over` |
899
+ | `hv` | the game host's version (games 0.16.1), shown by the table apps on the list and the lobby |
900
+ | `nm`, `hu` | the four names (`""` for an empty seat); the human seats as bits |
901
+ | `sk` | the four seats: 0 empty, 1 a member, 2 a CPU (an AI player too) |
902
+ | `ai`, `ak` | when an AI player sits: each seat's kind (`"claude"`, `"jev"`, `""` for the others); in the lobby, the AI players the owner may seat ("AI players") |
903
+ | `aw` | the seats whose member is away, as bits |
904
+ | `ow` | the owner's seat (-1: nobody seated) |
905
+ | `mb` | the members in the order they joined: `[name, seat (-1 none), flags (1 away, 2 owner)]` |
906
+ | `r` | `[bakaze (0 east, 1 south, 2 west), kyoku, honba, riichi sticks, dealer seat, tiles left, seat to act, phase (0 draw, 1 discard, 2 calls, 3 end)]` |
907
+ | `sc`, `ri` | the scores; riichi as bits |
908
+ | `dora` | the dora indicators |
909
+ | `kw` | four rivers, one byte a tile: `0x40` the riichi tile, `0x80` called away |
910
+ | `fu` | four meld lists: per meld `[kind, from, n, n tiles]`; kind 1 chi, 2 pon, 3 closed kan, 4 open kan, 5 added kan; from 1 right, 2 across, 3 left, 0 none. The tiles are in the order a table shows them: the called tile is first when it came from the left, second from across, last from the right (an added kan: the last of the first three, the added tile fourth) |
911
+ | `hc` | the four hand sizes |
912
+ | `ls`, `ev` | the last discard `[seat, tile]`; the last action `[type, seat]` |
913
+ | `cl` | the last declaration, `[type, the state version it came with, seat, ...]` (games 0.13.0): riichi (2), tsumo (3), a kan (4, 5, 8), pon (6), chi (7), ron (9; every winner of a double ron), nine kinds (11), three rons (13; the three seats). A screen shows it for a moment at the seat that made it; a new state version is a new declaration (`ev` is gone by the next draw, a riichi's or a kan's at once). In the public view too (everybody hears a declaration), as old as the rest for the spectators |
914
+ | `dl` | `[seats as bits, ms]`: whom the host waits for and how long it still waits: the seat whose turn it is; in the calls on a discard, in a seat's own view only, its own bit while it has to answer, else the three others' ("Calls on a discard"); on a player's own move with a bank, a third number, the bank's ms in it ("Time") |
915
+ | `res` | at a round's end: `k` (`tsumo_agari`, `ron_agari`, `ryuukyoku`, and the abortive draws `kyuushu_kyuuhai`, `sanchahou`, `suufon_renda`, `suukaikan`, `suucha_riichi`), `rd` (the round: `[bakaze, kyoku, honba]`), `d` (the four score changes), `f` (who dealt in), `wn` (one entry per winner, in turn order from the discarder: `w` seat, `y` the yaku as riichi_engine names them, in Japanese, `yh` the han of each (-1: a yakuman, -2: a double yakuman), `pt` fu, han and points, `h` and `ag` the hand and the winning tile, `pa` pao: `[[the seat that pays, the yakuman], ...]`), `tp` (tenpai seats at a draw), `ng` (the seats with nagashi mangan at a draw; paid in place of the no-ten payments), `rs` (the seats of the three rons at a sanchahou) |
916
+ | `ru`, `rx`, `ry` | the local rules ("Local rules"), one number per item: items 0-10, 11-14 and 15-16 (item 10 the seconds for a move) |
917
+ | `tb`, `tc`, `dc` | item 10's bank, seconds; in the lobby, item 10's choices as codes and item 16's choices in seconds ("Local rules") |
918
+ | `in` | while the round's name shows after the deal ("Result"): the ms left (games 0.15.0) |
919
+ | `ok` | on a result of a table with humans ("Result"): `[the seats that gave their OK as bits, the seats it still waits for as bits (seated, there, no OK yet), the ms until it moves on by itself]`; a screen shows "OK を待っています (n/m)" with n the first's bits and m both together (games 0.15.0) |
920
+ | `fin` | at the end: `[[seat, rank, score, point x 10], ...]` |
921
+ | `fr` | at the end, why the game ended: `normal_end` (the last round played, the fourth round of the extra round too), `tobi`, `all_last_oya_agari` (agari-yame), `all_last_oya_tenpai` (tenpai-yame), `sudden_death` (somebody reached 30,000 in the extra round), `west` (after west 4, the host's, only without `sudden_death`), `rounds` (the host's guard against a loop); none for a game saved before it was added |
922
+ | `me`, `h`, `dt`, `a` | the member's view only: its seat (-1: watching), its hand (sorted, without the drawn tile), the drawn tile (-1 none), what it may do: `[[type, tiles], ...]` |
923
+ | `wt` | the member's view only, the help (Assist, levels 1-3): one binary string, per discard the tile, the number of waits n and n pairs `[wait, left]` (the waits as riichi_engine's wait judge gives them; left: 4 less what the seat can see of the tile: its own hand, the rivers, every meld, the dora indicators) |
924
+ | `sh`, `sd` | a spectator's own view only: the four hands (sorted, without the drawn tile) and the four drawn tiles (-1 none) ("Spectators") |
925
+ | `uk` | the member's view only, level 3 when no discard leaves the hand tenpai: one binary string, per discard the tile, the shanten after it, the number n of tiles that bring it down and n pairs `[tile, left]` (left as in `wt`; a tile the seat sees all four of is left out) |
926
+
927
+ Tile numbers: `0..8` 1m..9m, `9` 0m (red five), `10..18` 1p..9p, `19` 0p,
928
+ `20..28` 1s..9s, `29` 0s, `30..36` 1z..7z (east south west north white
929
+ green red), `37` back. The same order as the tile sheets of Family mruby's
930
+ table screen.
931
+
932
+ Action types: 1 discard (tiles: what may be discarded; send one), 2 riichi
933
+ (tiles: the discards that declare it; send one), 3 tsumo, 4 closed kan, 5
934
+ added kan, 6 pon, 7 chi (one entry per pair), 8 open kan, 9 ron, 10 pass,
935
+ 11 nine kinds, 12 next (after a result, the seat's OK, "Result"; after the
936
+ end of a game, the owner's next goes back to the lobby). 13 is only an event (`ev`): three rons, a draw.
937
+
938
+ ### Message size
939
+
940
+ A board and the browser build (zenoh-pico) take at most 4096 B in one
941
+ message, Zenoh's headers and the key included, and drop a larger one without
942
+ a word. The views stay far below: the largest seen in 20 CPU hanchan was
943
+ about 570 B (the reply with its call key); a view built by hand with full
944
+ rivers, four kans each, sixteen members with long names and three winners
945
+ with fourteen yaku each (and pao, and the reason the game ended) is about 2.1 KB. The riichi help adds at most
946
+ 14 x (2 + 13 x 2) = 392 B (fourteen discards with thirteen waits each, more
947
+ than a hand can have; a nine-sided chuuren gives 80 B); a view built by hand
948
+ with it, full rivers, the other seats' kans and the long names is about
949
+ 1.5 KB. The acceptance (`uk`, level 3) adds at most 14 x (3 + 34 x 2) =
950
+ 994 B (every kind after every discard, more than a hand can have; random
951
+ hands give at most about 250 B, 20 CPU hanchan at most 762 B with a view of
952
+ 1,043 B); the same view built by hand with it is about 2.1 KB. `wt` and `uk` never come together. A spectator's view of the
953
+ largest result adds the four hands (59 B): about 2.2 KB. The tests hold every view
954
+ under 3800 B.
955
+
956
+ ### Time and saving
957
+
958
+ - The host draws for the player. A player that does not move within
959
+ `turn_seconds` and what is left of its bank ("Time") discards the drawn
960
+ tile (the last tile after a call); a call not made within `call_seconds`
961
+ is passed.
962
+ - One host process holds every table of its settings (two by default); each
963
+ is saved to its own file and goes on by itself.
964
+ - After every change the table is written to `state_dir/<table>.json`
965
+ (written to a temporary file and renamed), with the members, the seats and
966
+ the owner (a restarted host keeps the lobby too). The deadline is saved as the
967
+ time left; a host stopped with SIGTERM saves the time left at that moment,
968
+ a host killed gives the player the time left at the last change.
969
+
970
+ ## Rules
971
+
972
+ The host's defaults follow Mahjong Soul's default rules (its four-player
973
+ ranked games) wherever riichi_engine can choose; the engine's own defaults stay as they are and the host passes its
974
+ values (`Engine::RULE_DEFAULTS`). A table's `rule` setting goes over them,
975
+ and the owner's choices in the lobby go over that ("Local rules": the game,
976
+ open tanyao, red fives, double ron, three rons, several yakuman, kazoe
977
+ yakuman, kiriage mangan, tobi, the extra round, the time and the bank, the
978
+ abortive draws, nagashi mangan, double yakuman, tenpai-yame).
979
+ Where the engine cannot choose, the table says so: those rules are left for
980
+ later. "Not sure" marks what could not be confirmed.
981
+
982
+ | Rule | Mahjong Soul's default | riichi_engine | Choosable | The host's default |
983
+ |---|---|---|---|---|
984
+ | Open tanyao (kuitan) | yes | `kuitan` (true) | yes | true |
985
+ | Atozuke | yes | `atozuke` (true) | key only (not used by the engine) | (true) |
986
+ | Red fives | one each of 5m 5p 5s | `aka_dora` (1/1/1) | yes | 1/1/1 |
987
+ | Ippatsu, ura dora, kan dora, kan ura | yes | always | no | yes |
988
+ | When a kan dora shows | at once for a closed kan, after the discard for an open or added kan | `minkan_dora_after_discard` (false: at once for every kan) | key only | true |
989
+ | Double ron | yes | `double_ron` (true) | yes (fork `fix/multiple-ron`) | true |
990
+ | Three rons | all three win (no sanchahou) | `triple_ron_draw` (true) | yes (fork `fix/multiple-ron`) | **false: all three win** (as Mahjong Soul) |
991
+ | Honba and sticks at a double ron | to the first winner from the discarder | the host's scoring | (host) | to the first winner |
992
+ | Dealer at a double ron | keeps the seat if one of the winners | `GameFlow` (winners) | (engine) | as the engine |
993
+ | Abortive draws | nine kinds, four winds, four kans, four riichi; the dealer keeps the seat | nine kinds always; `suufon_renda`, `suukaikan`, `suucha_riichi` (false) | yes (one item for the three) | all |
994
+ | Double yakuman hands (suuankou tanki, kokushi 13-wait, junsei chuuren, daisuushii) | count double | `double_yakuman` (false) | yes | true (daisuushii is a double yakuman in riichi_engine without the key) |
995
+ | Several yakuman in one hand | added up | `multiple_yakuman` (false) | yes (fork `feat/multiple-yakuman`) | true |
996
+ | Kazoe yakuman (13 han) | yes | `kazoe_yakuman` (true) | yes | true |
997
+ | Kiriage mangan | no | `kiriage_mangan` (false) | yes | false |
998
+ | Nagashi mangan | yes; in place of the no-ten payments | `nagashi_mangan` (false) | yes | true |
999
+ | Pao (liability) | daisangen, daisuushii (not suukantsu) | `pao` (a list of yakuman, []; `suukantsu` too) | key only | daisangen, daisuushii |
1000
+ | Tobi | yes; 0 points goes on, below 0 ends | `tobi` (true, below 0) | yes | true |
1001
+ | The extra round: west for a hanchan, south for a tonpuu game | when nobody has 30,000 after the last round | `nishi_iri` (false), `nan_iri` (false), `nishi_iri_threshold` (30,000) | yes (one item for both) | true, true, 30,000 |
1002
+ | Sudden death in the extra round | it ends when somebody has 30,000 (a ron with several winners where the dealer keeps the deal goes on, before agari-yame too); one round at most | `sudden_death` (false: the round goes on until the deal passes with somebody at 30,000, west 4 back to west 1) | key only | true (without it the host ends the game after west 4) |
1003
+ | Agari-yame, tenpai-yame | both, for the last dealer in the lead with 30,000 or more | `agari_yame` (true), `tenpai_yame` (false); the dealer in the lead, above 0 (30,000 when there is an extra round) | tenpai-yame yes, agari-yame key only | both true |
1004
+ | Starting points, return, first place target | 25,000, 25,000 (no oka), 30,000 | `initial_score` (25,000), `oka_origin` (30,000), `nishi_iri_threshold` (30,000) | key only | 25,000, 25,000, 30,000 |
1005
+ | Uma | 5-15 (+15/+5/-5/-15), rank uma only | `uma` (20/10/-10/-20) | key only | 15/5/-5/-15 |
1006
+ | Riichi sticks left at the end | to the top | none (`calculate_ranked_results` takes the points as given) | (host) | to the top |
1007
+ | Equal points at the end | the seat nearer the first dealer ranks higher | by seat number | (engine) | as the engine (the first dealer is always seat 0) |
1008
+ | A pair of the double wind | 4 fu | `renpuu_jantou_fu` (2) | key only | 4 |
1009
+ | Kuikae (swap-calling) | banned (the called tile and, after a chi, the other end) | `kuikae` (true: allowed) | key only | false (banned; a chi that leaves nothing to discard is not offered) |
1010
+ | A closed kan after riichi | only of the drawn tile, when the waits stay the same | `riichi_ankan` (false) | key only | true |
1011
+ | Robbing a closed kan | for kokushi musou only | `kokushi_ankan_chankan` (false) | key only | true |
1012
+ | Honba at a pao ron | paid by the seat liable | `pao_honba` (`"discarder"`) | key only | `"liable"` |
1013
+ | The winning tile of a tenhou | the one that scores most | `tenhou_best_tile` (false: the tile drawn) | key only | true |
1014
+ | Kan ura dora | yes | always (`fix/kan-uradora`) | no | yes |
1015
+ | Thinking time | 5+20: 5 s a move plus a 20 s bank | none (the host's timers) | yes (item 10) | 5 s a move, a 20 s bank every round, 8 s a call (`turn_seconds`, `bank_seconds`, `call_seconds`; "Time") |
1016
+ | Riichi on a tile that is ronned | not made: the 1000 points come back, no stick (a pon or kan of the tile lets it stand) | `riichi_void_on_ron` (false: the stick stays, the winner takes it) | key only | true |
1017
+ | Same-turn furiten | after passing a winning tile (robbing a kan too), until one's next draw | on a pass of a ron offered, and on a winning tile with no ron offered (no yaku on it, or furiten already; `fix/dojun-furiten-no-yaku`) | no | as the engine |
1018
+ | Formal tenpai (karaten) | yes, except a single wait on a tile the hand holds four of | the wait judge leaves out only a tile the hand holds four of (a single wait on the fourth tile of a pon is tenpai; `test/karaten-tenpai`) | no | as the engine |
1019
+
1020
+ ## Game records
1021
+
1022
+ Every game a mahjong table plays to its end is kept as a record (games
1023
+ 0.13.0): the deals, the dora indicators, every draw and discard, the calls,
1024
+ riichi, the wins and draws and the points. The table apps list a table's
1025
+ recent games and step through a record with the four hands face up; a
1026
+ program reads the same with `records` and `record` (the CLI, an AI
1027
+ assistant through `asterism mcp`).
1028
+
1029
+ - **Where they are kept** (games 0.14.0). With the sqlite3 gem (the image,
1030
+ and a checkout with the Gemfile) every game goes into one SQLite
1031
+ database in the state volume, `state_dir/records.sqlite3` (`records_db`),
1032
+ written once at the end of each game. The rooms' and the permanent
1033
+ tables' hosts are processes of their own and all write into the
1034
+ supervisor's database (WAL; a process that finds it locked waits up to
1035
+ 10 s). The newest 1000 games of each table are kept (`records_kept`);
1036
+ the rooms' tables of one name count together (the 1000 newest of every
1037
+ room's `mahjong1`), so the database stays bounded however many rooms
1038
+ open. Without the sqlite3 gem (the gem alone, `records_db: json`) the
1039
+ records stay as before 0.14.0: the 20 newest of each table in
1040
+ `<table>.records.json` next to the table's saved state.
1041
+ - **A room's games** stay in the database when the room closes (its saved
1042
+ tables go). They are given out (`records`, `record`, the recent games of
1043
+ the table apps) only by the room's own host while the room is open: no
1044
+ other room, no permanent table and no LAN table lists them. The stats
1045
+ count them ("Stats": names and numbers, never a record).
1046
+ - **The rooms of one invite link** (games 0.16.0, with a console 0.3.8):
1047
+ a room made through a console's invite link has the link's group
1048
+ (`rooms.json` `"group"`, a random string that is not the link's secret).
1049
+ The room's host keeps it with each game the room plays, and its
1050
+ `records` and `record` give out the room's own games and those of every
1051
+ other room of the same group, open or closed (the same table name:
1052
+ `mahjong1`), newest first. So the next room of the same link lists and
1053
+ replays the games of the last one. A room without a group (made by an
1054
+ admin, or by a console before 0.3.8) gives out its own games only, as
1055
+ before; another group's rooms, the permanent tables and the LAN tables
1056
+ give out none of them. The stats (`"table"` is the room's own games) and
1057
+ `@status`'s `last` stay the room's own. The games played before 0.16.0
1058
+ have no group and stay their own room's. A room's group is read when
1059
+ its host starts (a room open across the console's update gets it at
1060
+ the host's next start). The newest kept are counted as before (the
1061
+ rooms' tables of one name together, whatever their group). In the
1062
+ database: the column `grp` of `games` (NULL outside such rooms), added
1063
+ in place on the first start of 0.16.0; the format stays 1, so a host of
1064
+ 0.14.0 or 0.15.0 put back reads and writes the same database.
1065
+ - **Taken in once.** A host of 0.14.0 that finds a table's
1066
+ `<table>.records.json` (0.13.0) next to a database takes its games in
1067
+ (the same games, in their order, with their times) and renames the file
1068
+ to `<table>.records.json.imported`; nothing reads it again (a host of
1069
+ 0.13.0 put back would start with no records). A room's file is taken in
1070
+ by the room's host, with the room's ID.
1071
+ - **Ids.** A game's `id` is its row in the database: it grows across every
1072
+ table, and a table's ids are not consecutive. An id is never used twice.
1073
+ - **Backups.** After each game the host makes a consistent copy next to
1074
+ the database, `records-backup.sqlite3` (`VACUUM INTO` a `*.tmp` name,
1075
+ then renamed over the last copy), so a backup that copies the files of
1076
+ the state volume (the console's daily backup with
1077
+ `docker-compose.games.yml`) has a whole database whatever the host was
1078
+ writing at that moment. A host that finds the copy but no database
1079
+ starts from the copy: that is how a restore puts it back
1080
+ (docs/install.md, "Backups and restoring").
1081
+ - A game in progress is never given out; its record so far is in the
1082
+ table's saved state (JSON, as before), so a host started again goes on
1083
+ with it.
1084
+ - **Who reads them.** Anyone who can call the table (no token): the router's
1085
+ rules decide, as for joining.
1086
+
1087
+ | Method | Arguments | Returns |
1088
+ |---|---|---|
1089
+ | `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) |
1090
+ | `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]], ...]`) |
1091
+ | `record` | id, round | round `round` (from 0) as below, with `id`, `round`, `of` (how many rounds), `part`, `parts` |
1092
+ | `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 |
1093
+
1094
+ `{"error" => "no record"}` (an id not kept), `"no round"`, `"no part"`.
1095
+
1096
+ A round:
1097
+
1098
+ | Key | |
1099
+ |---|---|
1100
+ | `wind`, `number`, `honba`, `riichi_sticks`, `dealer` | the round (`east` .. `north`, 1-4) as it starts |
1101
+ | `scores` | the four scores as it starts |
1102
+ | `dora_indicators` | the indicator(s) as it starts (a kan's come as moves) |
1103
+ | `hands` | the four deals, sorted |
1104
+ | `moves` | in order, each an array starting with the seat (-1: the table's own) |
1105
+ | `result` | `kind` (as the view's `res`), `changes` (the four), `from`, `wins` (`seat`, `yaku`, `yaku_han`, `han`, `fu`, `points`, `hand` (without the winning tile), `tile`, `pao`), `ura_indicators`, `tenpai`, `nagashi`, `ron_seats` |
1106
+ | `end_scores`, `end_sticks` | the scores and the sticks on the table after it |
1107
+
1108
+ The moves:
1109
+
1110
+ | Move | |
1111
+ |---|---|
1112
+ | `[seat, "draw", tile]` | a draw (a kan's replacement tile too) |
1113
+ | `[seat, "discard", tile]`, `[seat, "discard", tile, true]` | a discard; `true`: the tile just drawn (tsumogiri) |
1114
+ | `[seat, "riichi", tile]` (`, true`) | riichi, declared with that discard |
1115
+ | `[seat, "chi" / "pon" / "open_kan", tile, [tiles], from]` | a call of the discard `tile` of seat `from` with `tiles` from the hand |
1116
+ | `[seat, "closed_kan", [four tiles]]`, `[seat, "added_kan", tile]` | a closed kan; a tile added to a pon |
1117
+ | `[-1, "dora", tile]` | a new dora indicator (after a kan, when the rule shows it) |
1118
+ | `[seat, "tsumo", tile]`, `[seat, "ron", tile, from]` | a win (one `ron` per winner of a double ron; three at a sanchahou) |
1119
+ | `[seat, "nine_kinds"]` | the abortive draw with nine kinds |
1120
+
1121
+ Tiles are `look`'s names: `"1m".."9m"`, `"1p".."9p"`, `"1s".."9s"`,
1122
+ `"0m"` / `"0p"` / `"0s"` the red fives, `"1z".."7z"` east, south, west,
1123
+ north, white, green, red. A whole hanchan takes about 25 KB of JSON (16 KB
1124
+ of MessagePack); a round's answer about 2 KB, the largest seen 2.2 KB
1125
+ (the tests hold every answer under 3800 B and split longer rounds). In
1126
+ the database a tonpuu game takes about 17 KB and a hanchan about 23 KB:
1127
+ 1000 games of a table 17-23 MB, and the copy for the backup as much again.
1128
+
1129
+ From the command line and an AI assistant:
1130
+
1131
+ ```
1132
+ asterism call --router tcp/192.0.2.2:7447 games/games/mahjong1 records
1133
+ asterism call --router tcp/192.0.2.2:7447 games/games/mahjong1 record 12
1134
+ asterism call --router tcp/192.0.2.2:7447 games/games/mahjong1 record 12 0
1135
+
1136
+ claude mcp add records -- asterism mcp --router tcp/192.0.2.2:7447 \
1137
+ --allow 'games/mahjong1.records' --allow 'games/mahjong1.record'
1138
+ ```
1139
+
1140
+ Then ask the assistant, for example: "Read the last game of mahjong1
1141
+ (records, then record with its id and each round) and go through the
1142
+ second player's discards: where did it deal in, and what would have been
1143
+ safer?" Every move of a finished game is there, the four hands included,
1144
+ so an assistant can review a game as a whole.
1145
+
1146
+ ## Stats
1147
+
1148
+ `stats` counts the players' games from the records (games 0.14.0, the
1149
+ SQLite form; the JSON form counts the table's own 20). The table apps show
1150
+ it from their recent games ("Stats", S).
1151
+
1152
+ | Argument | | |
1153
+ |---|---|---|
1154
+ | days | 0 | 0: every game kept; N: the games of the last N days |
1155
+ | game | `""` | `"hanchan"`, `"tonpuu"`, `""` both |
1156
+ | scope | `"all"` | `"all"`: every game in the database (every table of every place, the rooms' too, closed ones included); `"table"`: this table's games (in a room, the room's) |
1157
+ | from | 0 | the page: the `next` of the answer before |
1158
+
1159
+ `{"rows" => [...], "games", "total", "next", "days", "game", "scope",
1160
+ "store"}`: `games` the games counted, `total` the rows, `next` when there
1161
+ are more than fit one message (about 13 rows a page), `store` `sqlite` or
1162
+ `json`. A row per player:
1163
+
1164
+ - **Who.** A person is a name (`"kind" => "human"`): the name entered at the
1165
+ table, so the same name is the same person whichever board or page it
1166
+ came from, and a new name a new person (anyone may take a name; nothing
1167
+ checks it). The CPUs are one row (`"cpu"`, `"CPU"`), each AI player one
1168
+ row (`"claude"`, `"jev"`). Persons first, by games, then the kinds.
1169
+ - **The numbers.** `games`, `ranks` (`[1st, 2nd, 3rd, 4th]`), `avg_rank`,
1170
+ `top_rate` (1st / games), `rounds` (the rounds of the games played),
1171
+ `wins`, `dealins` (a ron from this seat: once a round, a double ron too),
1172
+ `riichi`, `win_rate` / `dealin_rate` / `riichi_rate` (of the rounds),
1173
+ `avg_score` (the final score), `points` (the sum of the rank points,
1174
+ `final`'s points / 10, with the uma).
1175
+ - **What a room sees.** Names and numbers only, never a record: a room's
1176
+ table gives `"all"` like any other (the people of the whole host), and
1177
+ `"table"` for the room's own games. Outside the room, a room's games are
1178
+ in the counts of `"all"` and nowhere else.
1179
+
1180
+ ```
1181
+ asterism call --router tcp/192.0.2.2:7447 games/games/mahjong1 stats
1182
+ asterism call --router tcp/192.0.2.2:7447 games/games/mahjong1 stats 30 tonpuu
1183
+ asterism mcp --router tcp/192.0.2.2:7447 --allow 'games/mahjong1.stats'
1184
+ ```
1185
+
1186
+ The counts are made once per game at its end (the seats' rows of the
1187
+ database), so a stats call reads no record: 1000 games take 20-50 ms.
1188
+
1189
+ ## Playing in a terminal
1190
+
1191
+ (games 0.17.0) `asterism-games play <game>` sits at a table from a
1192
+ terminal: CRuby and the asterism gem only (no curses: the screen is drawn
1193
+ again as text whenever the table changes, cleared with ANSI escapes on a
1194
+ terminal, one screen after another in a pipe or a file).
1195
+
1196
+ ```
1197
+ asterism-games play mahjong --router tcp/127.0.0.1:7447 --name Alice # a home network
1198
+ asterism-games play mahjong --credentials alice.tar # a console's permanent tables
1199
+ asterism-games play mahjong --tiles emoji # the tiles as emoji (🀇🀈🀉)
1200
+ asterism-games play mahjong --tiles mpsz # or as 1m 4p 3s
1201
+ asterism-games play othello --commands "sit,cpu,start" # "The othello table"
1202
+ asterism-games play --help
1203
+ ```
1204
+
1205
+ | Option | |
1206
+ |---|---|
1207
+ | `--router LOCATOR` | the Zenoh router |
1208
+ | `--credentials FILE` | a console's `.tar` (a router over mutual TLS; the router and the node ID come from it) |
1209
+ | `--namespace PREFIX` | inside a room's key space (`room/01`) |
1210
+ | `--table PATH` | the table (`<host>/games/<table>`); without it the first one found (mahjong: several are listed to choose from on a terminal) |
1211
+ | `--name NAME` | the name at the table (default: the node ID) |
1212
+ | `--node NODE` | the node ID (default `tty-<host>-<pid>`, or the `.tar`'s name) |
1213
+ | `--commands LIST` | commands run first, comma separated (`"sit,cpu,tonpuu,start"`) |
1214
+ | `--until-over` | quit when a game it saw played is over |
1215
+ | `--tiles kanji\|emoji\|mpsz` | mahjong: the tiles in kanji (default), as emoji or as text |
1216
+
1217
+ With neither `--router` nor `--credentials` it takes `ASTERISM_ROUTER` and
1218
+ `ASTERISM_CREDENTIALS` from the environment, as the asterism command line
1219
+ does (all or nothing: a router on the line never meets a `.tar` from the
1220
+ environment); with nothing at all, a zenohd on this machine
1221
+ (`tcp/127.0.0.1:7447`). When the table's host leaves the network
1222
+ (Asterism's `on_leave`) the screen says the connection is lost and the
1223
+ client waits; when the host is back (`on_join`) it asks again (and joins
1224
+ again when the host forgot it).
1225
+
1226
+ ### Keys
1227
+
1228
+ On a terminal (the input is a tty) both clients read one key at a time,
1229
+ the keys of the table apps (fmruby-core's `flash/app/game/mahjong/` and
1230
+ `flash/app/game/othello/`); the last line of the screen says which keys
1231
+ work now, each in `[ ]` before what it does (`[R]リーチ [T]ツモ`). In a
1232
+ pipe or a file (and the tests) they read lines as before, and
1233
+ `--commands` runs first either way.
1234
+
1235
+ | Mahjong | |
1236
+ |---|---|
1237
+ | Left, Right | choose a tile of the hand (`^^` under it; the drawn tile is chosen whenever the hand changes) |
1238
+ | Enter, Space | discard the chosen tile; the result's OK; at the end of the game, the owner: back to the lobby |
1239
+ | `R` | riichi: then Left / Right and Enter discard with it (a tile that does not declare it: the first that does), Esc gives it up |
1240
+ | `T` | tsumo or ron |
1241
+ | `P` | pon |
1242
+ | `C` `V` `B` | the first, second and third chi offered (the last line names each one's tiles: `[C]チー 3m4m [V]チー 4m6m`) |
1243
+ | `K` `L` `J` | the first, second and third kan offered |
1244
+ | `N` | nine kinds (an abortive draw) |
1245
+ | Esc | pass (do not call) |
1246
+ | `1`..`4` | the lobby: sit there (an empty seat or a CPU's) |
1247
+ | `S` | the lobby: stand |
1248
+ | `C` | the lobby, the owner: CPUs into the empty seats |
1249
+ | Enter | the lobby, the owner: start (four seats taken) |
1250
+
1251
+ | Othello | |
1252
+ |---|---|
1253
+ | the arrows | move the cursor on the board (`[ ]` around the square; it stops at the edges; it starts on a legal square) |
1254
+ | Enter, Space | play there (a square that is not legal: a short note, nothing sent) |
1255
+ | Esc | during this member's own game: resign, after `resign? [Y] yes [N] no` on the last line (`Y` resigns, `N` or Esc cancels; as the othello app). `:resign` also works |
1256
+ | `S` | the lobby (or after a game): sit, or stand |
1257
+ | `C` | the owner: the CPU into the empty seat |
1258
+ | Enter | the lobby (or after a game), the owner: start |
1259
+
1260
+ | Both | |
1261
+ |---|---|
1262
+ | `Q`, Ctrl+Q, Ctrl+C | quit |
1263
+ | `:` or `/` | a line to type a command in, as in a pipe (`tonpuu`, `rule 3 1`, `chi 2`, `6s`, `resign`, `auto` ...): the terminal is back in its line mode for it, the screen shows the letters and the words again, Enter sends it (an empty line sends nothing) |
1264
+
1265
+ The terminal is put in raw mode while the client reads keys (`io/console`;
1266
+ ESC alone is told from an arrow by waiting 50 ms for the rest of the
1267
+ sequence) and the cursor is hidden; however the client ends (`Q`, Ctrl+C,
1268
+ an exception, a signal) the terminal is put back as it was and the cursor
1269
+ shown again. In raw mode the terminal does not turn `\n` into a new line,
1270
+ so the client writes `\r\n`.
1271
+
1272
+ ### Mahjong
1273
+
1274
+ The lobby (sit, stand, CPUs, the game's length, start), the rounds, the
1275
+ results and the end of the game, in the table app's words (東1局, 本場,
1276
+ 供託, ドラ, リーチ, ポン ...). A member without a seat watches, with the
1277
+ four hands ("Spectators"). The screen is drawn again when the table
1278
+ changes; on a terminal the time left counts down in place once a second
1279
+ (below), and a line being typed is never wiped by it.
1280
+
1281
+ ```
1282
+ 東2局 0本場 供託1 残り32 ドラ 2筒
1283
+ 南 CPU2 25000* 南 白 中 9索 3筒 9筒 8筒 [六萬] 西
1284
+ 西 CPU3 25000 白 1索 九萬 2索 北 8筒 3筒 3索 5索 3索
1285
+ 鳴き 發 發 發
1286
+ 北 CPU1 25000 白 1索 中 九萬 東 東 三萬 二萬 南
1287
+ 東 自分 24000 3索 白 一萬 九萬 2筒 東 8索 發 四萬
1288
+ 南 CPU2: リーチ
1289
+
1290
+ 五 1 1 4 9 9 1 2 2 2 7 8 西 3
1291
+ 萬 筒 筒 筒 筒 筒 索 索 索 索 索 索 索
1292
+ a b c d e f g h i j k l m n
1293
+ 自分の番: 打つ牌 (文字か 6s) あと 5+20秒
1294
+ >
1295
+ ```
1296
+
1297
+ - The first line: the round, the honba, the riichi sticks on the table
1298
+ (供託), the tiles left and the dora indicators. Then the four seats,
1299
+ the next one first and this one (自分) last: the seat wind, the name, the
1300
+ points (`*`: riichi), the river (the riichi tile in `[ ]`; a tile called
1301
+ away is in the meld, not the river; a river longer than the line goes
1302
+ on below, so a line stays within 80 columns) and under it the melds
1303
+ (鳴き; a closed kan with its ends face down, 裏). Then the hand, the
1304
+ drawn tile apart at the end, and a letter under each tile; `赤:` names
1305
+ the letters of the red fives (on a terminal they are drawn in red too),
1306
+ `待ち:` the waits after each discard that declares riichi. The last line
1307
+ says what can be done now, with the time left (the move's and the
1308
+ bank's, "Time").
1309
+ - **The tiles** (`--tiles`), three ways:
1310
+ - `kanji` (the default): as the table app draws a tile. In the hand (and
1311
+ a spectator's hands, a winning hand) two rows, the number on top and
1312
+ the suit below: 一..九 over 萬, full-width 1..9 over 筒 and 索 (not
1313
+ ①②③, which many fonts draw small), an honour as its one character
1314
+ (東 南 西 北 白 發 中). In the rivers, the melds and the dora one line
1315
+ per tile: 一萬 4筒 3索 東. Every glyph is a CJK one, two columns
1316
+ wide in any terminal, so this is the readable choice.
1317
+ - `emoji`: Unicode's mahjong tiles (🀇🀈🀉 ...). How they look is up to
1318
+ the terminal's fonts: 🀄 (the red dragon) is often drawn as a colour
1319
+ emoji, hard to read next to the others, and some fonts draw the
1320
+ tiles small or wider than their column.
1321
+ - `mpsz`: the suits as `1m`..`9m`, `1p`.., `1s`.. (`0m` `0p` `0s` the
1322
+ red fives), the honours as 東 南 西 北 白 發 中.
1323
+
1324
+ ```
1325
+ 北 CPU3 6800* 🀀 🀁 🀇 🀂 🀑 🀆 🀃 🀗 [🀔 ] 🀘 🀝 🀄🀄 (--tiles emoji)
1326
+ 🀇 🀊 🀌 🀎 🀞 🀠 🀑 🀓 🀖 🀗 🀘 🀃 🀅 🀛
1327
+ a b c d e f g h i j k l m n
1328
+
1329
+ 東 CPU1 24000* 南 6s 6s 中 中 2m 8s 0p 9m [8p] 9s 2s 2s 1p 北 (--tiles mpsz)
1330
+ 7m 8m 6p 7p 1s 1s 3s 8s 西 北 6s
1331
+ a b c d e f g h i j k
1332
+ ```
1333
+
1334
+ - **The columns.** The kanji and the full-width digits are two columns
1335
+ wide everywhere. The mahjong emoji are one column wide by Unicode's
1336
+ widths except 🀄, which is two (it is an emoji shown in colour by
1337
+ default), and fonts often draw them wider than the column they get.
1338
+ Every tile in the hand has a slot of two columns for an emoji, three
1339
+ for the kanji or `1m` (the tile and room after it), the letters the
1340
+ same slots. On a terminal the client puts each tile, each
1341
+ letter and the text after them at its column with the cursor (`ESC [ n
1342
+ G`), so a tile drawn wider never pushes the letters or the next tile
1343
+ along; in a pipe or a file it pads with spaces by the widths above (🀄
1344
+ fills its slot). The letters stay under their tiles either way; with
1345
+ the emoji a font that draws a tile wider than two columns overlaps the
1346
+ next one (the kanji never do).
1347
+ - **The time** counts down on a terminal: about once a second the client
1348
+ works the screen out again with the time gone since the view came,
1349
+ and rewrites only the lines that changed (the last line's `あと 5+20秒`,
1350
+ a result's `OK を待っています ... あと N 秒`) where they are: ESC 7 saves
1351
+ the cursor where the player types, the line is cleared and written,
1352
+ ESC 8 puts the cursor back. The line being typed is not touched. In a
1353
+ pipe or a file nothing is rewritten.
1354
+ - **`--commands`** run in order, each when the table can take it: the
1355
+ lobby commands (`sit`, `cpu`, `start`, `tonpuu` ...) wait for the lobby
1356
+ (a game may still be going on, or its end shown, when the client
1357
+ joins), the owner's for this member to be seated and the owner, `start`
1358
+ for the four seats; the screen says what each waits for (`--commands:
1359
+ cpu (卓の主を待っています)`). One the host answers "no" is not tried
1360
+ again.
1361
+
1362
+ Typed (in a pipe or a file each line; on a terminal after `:`):
1363
+
1364
+ | Typed | |
1365
+ |---|---|
1366
+ | `a` .. `n` | discard that tile of the hand (the drawn tile is the last letter) |
1367
+ | `6s`, `0p`, `東` (or `1z`..`7z`), `4筒`, `一萬` | discard that tile (`0`: the red five; `5p` takes the red five when the hand has no other five) |
1368
+ | `riichi` | riichi: alone with one choice, else the client lists the discards that declare it and the next line chooses (a letter or a tile); `riichi e` / `riichi 6s` at once |
1369
+ | `tsumo`, `ron` | win |
1370
+ | `pon`, `chi`, `kan` | call; with several ways the client lists them numbered and the next line chooses (`2`, or the tiles: `3m4m`, `34m`, letters); `chi 2`, `chi 3m4m` at once |
1371
+ | `pass` | do not call |
1372
+ | `kyuushu` | nine kinds (an abortive draw) |
1373
+ | `ok` (or an empty line) | the result's OK ("Result": the screen says `OK を待っています (n/m)`); at the end of the game, the owner: back to the lobby |
1374
+ | `sit [1-4]`, `stand` | take a seat (a CPU's too), leave it (the lobby) |
1375
+ | `cpu [1-4]`, `nocpu 1-4`, `ai 1-4 KIND` | the owner: CPUs into the empty seats (or one), a CPU out, an AI player in when the lobby offers one ("AI players") |
1376
+ | `tonpuu`, `hanchan`, `rule ITEM VALUE` | the owner: the game's length, any local rule ("Local rules") |
1377
+ | `start` | the owner: start |
1378
+ | `auto` | the client plays for you: wins when offered, passes calls, discards the drawn tile, OKs the results (again to stop) |
1379
+ | `tables`, `view`, `help`, `quit` | |
1380
+
1381
+ The host's answers are shown in the table app's words (`今はできない`,
1382
+ `卓の主だけができる` ...).
1383
+
1384
+ ## Playing from a program
1385
+
1386
+ The mahjong table also speaks to programs (games 0.10.0): an AI assistant
1387
+ through `asterism mcp`, a script, the host's own AI players. The screens'
1388
+ view (short keys, tiles as bytes) stays as it is; next to it:
1389
+
1390
+ | Method | Arguments | Returns |
1391
+ |---|---|---|
1392
+ | `describe` | none | a text: how to play from a program, what `look` returns, the tiles' names (read it first) |
1393
+ | `look` | token | the table as the member's seat sees it, in words: `status`, `you`, `seats` (name, kind human / cpu / ai / empty, wind, score, riichi, `river`, `riichi_index`, `called_indexes`, `melds`, `hand_count`), `round` (wind, number, honba, riichi sticks, dealer, wall left, turn, phase), `dora_indicators`, `last_discard`, `hand`, `drawn`, `moves`, `help`, `deadline_ms`, `result`, `final`, `rules`; in the lobby `ai_players` |
1394
+ | `choose` | token, sv, n | plays the move numbered `n` of the `moves` of the look whose `sv` it gives: `"ok"`, `"stale"` (look again), `"no such move"`, or why not as `act` |
1395
+ | `points` | token | what the hand would score: `now` (the win offered now) and `waits` (for each wait of a tenpai hand, after each discard that leaves it tenpai on the seat's turn: `ron` and `tsumo` with han, fu, points, yaku, or `no_yaku`) |
1396
+
1397
+ - `moves` lists what the seat may do now, numbered from 0: `{"n", "type",
1398
+ "tile"/"tiles", "text"}` with the types `discard`, `riichi` (a discard
1399
+ that declares it), `tsumo`, `ron`, `pon`, `chi`, `open_kan`,
1400
+ `closed_kan`, `added_kan`, `pass`, `nine_kinds`, `next`. Empty when the
1401
+ seat has nothing to decide: look again a moment later.
1402
+ - `help` is the help of assist level 3 in words: after each discard that
1403
+ leaves the hand tenpai, its waits as `{tile => unseen}`; otherwise the
1404
+ shanten after each discard and the tiles that bring it down.
1405
+ - A seat sees what its screen shows and no more: its own hand, the rivers,
1406
+ the melds, the dora indicators, the scores. `points` leaves the ura dora
1407
+ out (nobody sees them before a win) and does not check furiten.
1408
+ - Every method of both tables has its arguments' names and a text in the
1409
+ meta (asterism's `docs/protocol.md` 5, with an asterism after 0.7.1):
1410
+ `asterism objects` prints them and `asterism mcp` makes them the tools'
1411
+ inputs and descriptions. With asterism 0.7.1 the meta has the numbers
1412
+ only, as before.
1413
+
1414
+ An AI assistant sits at a table like this:
1415
+
1416
+ ```
1417
+ claude mcp add asterism -- asterism mcp --router tcp/192.0.2.2:7447 --allow 'games/mahjong1.*' --app game
1418
+ ```
1419
+
1420
+ (`--app game`: the host then follows the assistant as it follows a table
1421
+ app, "Who is there"; without it the assistant is away after
1422
+ `away_seconds` without a call, and an away seat gets two seconds for a
1423
+ move.) The table's time for a move (`turn_seconds`, 5 + 20 s of bank) is
1424
+ short for an assistant that thinks: a table for them can take a longer
1425
+ one (`MAHJONG_TURN_SECONDS`, or the owner's local rule item 10).
1426
+
1427
+ ## AI players
1428
+
1429
+ A table's owner puts an AI player into an empty seat as it puts a CPU
1430
+ there (games 0.10.0): `cpu(token, seat, "claude")` or `"jev"`, the names
1431
+ the lobby view offers (`"ak"`; `look`'s `ai_players`). On one seat that
1432
+ holds a CPU or another AI player the same call turns it into the one
1433
+ asked for (games 0.12.1; `true` turns an AI player back into a CPU, and
1434
+ a refused one leaves the seat as it was). The table apps give each seat
1435
+ of the lobby a button that goes round empty, CPU, Claude, Jev (the kinds
1436
+ offered), and show the seat by its name (`Claude`, `Jev`, numbered when there are several) with
1437
+ `[AI]`; the view's `"ai"` gives each seat's kind (`""` for the others),
1438
+ `"sk"` keeps 2 for them (an old screen shows a CPU named Claude).
1439
+
1440
+ | Kind | What answers | Model (setting) |
1441
+ |---|---|---|
1442
+ | `claude` | Claude through the Messages API (the `anthropic` gem): the instructions (`describe`) as the cached system prompt, the seat's `look` as JSON, a structured answer `{"n": <move>}` | `claude-sonnet-5-5` (`ASTERISM_GAMES_CLAUDE_MODEL`), effort `low` (`ASTERISM_GAMES_CLAUDE_EFFORT`) |
1443
+ | `jev` | Jev through TypeSafe's own API (`POST https://api.typesafe.ai/v1/systemone`, games 0.11.1): a decision model asked one choice question, the moves as its answers (keys `"0"`, `"1"`, ... with their texts), the `look` as its state; the answer's `usage` is what is counted | `jev-latest` (`ASTERISM_GAMES_JEV_MODEL`; a versioned id such as `jev-1.13.0` keeps one model) |
1444
+
1445
+ - **How a decision goes.** For each decision of an AI seat the table asks
1446
+ the host (the seat's `look`, exactly what a human in that seat sees); the
1447
+ host asks the API on a thread of its own, never holding the game's lock,
1448
+ and hands the number back. A decision with one move is not asked.
1449
+ - **Time.** An AI seat has its own time: `ai_turn_seconds` (30 s) for a
1450
+ move, `ai_call_seconds` (15 s) for its answer to a discard it may call
1451
+ (`MAHJONG_AI_TURN_SECONDS`, `MAHJONG_AI_CALL_SECONDS`). The humans keep
1452
+ theirs. When the answer does not come in time, or fails (an error, a
1453
+ refusal, a move it was not offered), the CPU plays that move and the AI
1454
+ goes on with the next.
1455
+ - **Money.** What each decision costs is counted from the tokens the API
1456
+ reports, per day (the host's local date), in `state_dir/ai_usage.json`
1457
+ (shared by the rooms' hosts and the permanent tables; counts and money
1458
+ only). When today's total reaches `ASTERISM_GAMES_AI_DAILY_USD` (3.0) no
1459
+ table offers an AI player any more and the seated ones become CPUs; a
1460
+ game asks at most `ASTERISM_GAMES_AI_CALLS_PER_GAME` (400) decisions,
1461
+ then its AI seats become CPUs. `bin/asterism-games status` prints the
1462
+ day's decisions, tokens and money per kind; the host's `@status` has the
1463
+ totals (`ai_today`).
1464
+ - **A limit per kind** (games 0.15.0). `ASTERISM_GAMES_AI_DAILY_USD_CLAUDE`
1465
+ and `ASTERISM_GAMES_AI_DAILY_USD_JEV` (the kind's name in capitals) give
1466
+ a kind a daily limit of its own. A kind with one stops when its own money
1467
+ of the day reaches it: no table offers it any more and its seats become
1468
+ CPUs, while the other kinds go on. A kind without one follows
1469
+ `ASTERISM_GAMES_AI_DAILY_USD` as before: it stops when the day's total of
1470
+ every kind reaches it. Without any of them everything is as before
1471
+ 0.15.0. A value that is not a number of dollars (0 or more) is ignored,
1472
+ with a line in the log. The log says the limits at the start
1473
+ (`games: AI players: claude, jev (at most $10.0 a day for claude, $2.0
1474
+ for jev, 400 decisions a game)`) and once for each kind that stops
1475
+ (`today's limit for claude ($10.0) is reached`); a new day lets it play
1476
+ again. `ai_today` on `@status` then has `kinds` (README "Status").
1477
+ - **Prices.** USD per million tokens, settings with the published prices
1478
+ as defaults: Claude Sonnet 5.5 `ASTERISM_GAMES_CLAUDE_USD_IN` 2.0, `_OUT`
1479
+ 10.0, `_CACHE_WRITE` 2.5 (5-minute cache), `_CACHE_READ` 0.10 (checked
1480
+ 2026-10-17); Jev `ASTERISM_GAMES_JEV_USD_IN` 0.042, `_OUT` 0 (TypeSafe
1481
+ charges input tokens only; checked 2026-10-18). Change them when the
1482
+ prices change.
1483
+ - **Keys.** `ANTHROPIC_API_KEY` for Claude, `TYPESAFE_API_KEY` for Jev
1484
+ (console.typesafe.ai, Keys), from the environment (on the VPS:
1485
+ `deploy/vps/.env`, mode 600, docs/install.md). Without a kind's key the
1486
+ kind is not offered at all (no button). The keys go into no log, no
1487
+ state file, no status and no error text (an API's error is logged as its
1488
+ HTTP status or class only).
1489
+ - `ASTERISM_GAMES_AI_THREADS` (4): decisions asked at once.
1490
+ `ASTERISM_GAMES_AI_FAKE=1` seats fakes under both names, for a LAN
1491
+ without keys and for tests (no API is called; `_FAKE_DELAY_MS`,
1492
+ `_FAKE_SLOW_EVERY`, `_FAKE_FAIL_EVERY` make every n-th decision late or
1493
+ failed).
1494
+ - **Writing another decider.** `decide(request, timeout:)` returning
1495
+ `AI::Decision.new(n:, tokens:, ms:)` (lib/asterism_games/ai.rb); the
1496
+ request is `{"game", "table", "seat", "instructions", "view"}`.
1497
+ - **Which game seats which kinds** (games 0.17.0): `AI::GAME_KINDS` in
1498
+ lib/asterism_games/ai.rb is the one place that says it. Mahjong seats
1499
+ `claude` and `jev`; othello seats `jev` only, whatever other keys the
1500
+ host has (its `"ak"` is `["jev"]` with Jev's key, else absent, and
1501
+ `cpu(token, seat, "claude")` is `"no ai"`). Othello's Jev uses the same
1502
+ key (`TYPESAFE_API_KEY`), the same decider, the same money of the day
1503
+ and the same `ASTERISM_GAMES_AI_CALLS_PER_GAME` as mahjong's; nothing is
1504
+ set for it alone. See "The othello table" for its view and its time.
1505
+
1506
+ ## The othello table
1507
+
1508
+ The smallest game on the framework (`lib/asterism_games/othello/`): the
1509
+ rules and the CPU (`board.rb`), the table (`table.rb`) and its object
1510
+ (`remote.rb`), about 600 lines. The screens: fmruby-core's
1511
+ `flash/app/game/othello/othello.app.rb` ("Othello (table)", a window; the
1512
+ boards, the browser build and the sim) and `asterism-games play othello`
1513
+ (CRuby, "Playing in a terminal"). The
1514
+ game between two machines without a host is fmruby-core's
1515
+ `flash/app/test/othello.app.rb` ("Othello (direct)").
1516
+
1517
+ - Two seats and the spectators. The first member who sits is the owner:
1518
+ the owner may put the CPU in the empty seat and starts once both seats
1519
+ are taken (one of them a member). The colours are drawn at the start:
1520
+ seat 0 is black and moves first.
1521
+ - Standard othello on 8x8: a move must turn at least one disc, every line
1522
+ it closes is turned, a side with no legal move passes by itself (`"--"`
1523
+ in the history), and when neither side can move the discs decide (a draw
1524
+ when they are equal). A player may resign.
1525
+ - After the game the board and the result stay (`"st" => "over"`) until
1526
+ someone sits or stands (back to the lobby) or the owner starts again.
1527
+ - A member who leaves during a game hands its seat to the CPU.
1528
+ - **Jev** (games 0.17.0, "AI players"): when the host has Jev's key the
1529
+ owner may put Jev in the empty seat, or turn the CPU there into Jev,
1530
+ with `cpu(token, seat, "jev")` (`true` turns it back into the CPU, `false`
1531
+ takes it out). Jev is asked each move with more than one legal square:
1532
+ the request's `"game"` is `"othello"`, `"instructions"` the rules and how
1533
+ to read the view, `"view"` `{"you" => "B" / "W", "board" => the eight
1534
+ rows from row 1 (columns a-h), "discs" => {"B", "W"}, "n", "last",
1535
+ "moves" => [{"n", "square", "turns", "text"}]}`; the answer is a move's
1536
+ `n`. One out of range, one that does not come in `ai_turn_seconds` (30 s)
1537
+ or a failure is played by the CPU, and the game goes on. When the day's
1538
+ money is spent, or the game has asked `ASTERISM_GAMES_AI_CALLS_PER_GAME`
1539
+ decisions, Jev's seat becomes the CPU. The table apps show Jev by its
1540
+ name with `[AI]` and say when it is thinking; `dl` counts its time down.
1541
+ - Nothing in othello is hidden, so every member sees the same view: the
1542
+ table's view (the one the host puts on the state key) and `me`, the
1543
+ member's seat.
1544
+
1545
+ | Method | Arguments | Returns |
1546
+ |---|---|---|
1547
+ | `join` | name | `{"token", "seat" (-1: none), "owner"}`, or `{"error" => "full"}` / `"spectators"` |
1548
+ | `mine` | none | the same for a node that joined, or nil |
1549
+ | `sit` | token, seat | `"ok"` or why not; seat -1: the free seat (else the CPU's). Not during a game |
1550
+ | `stand` | token | `"ok"` or why not |
1551
+ | `cpu` | token, seat, on | the owner: the CPU into an empty seat (on) or out of it; seat -1: every seat. on `"jev"` (one of `ak`): Jev instead of the CPU, or the CPU there into Jev; `"no ai"` when it is not offered |
1552
+ | `start` | token | the owner, both seats taken |
1553
+ | `move` | token, n, square | `"ok"`, or why not (`"stale"`: n is not the view's `n`, `"illegal"`, `"not your turn"`, `"not seated"`, `"not playing"`); square as `"d3"` |
1554
+ | `resign` | token | `"ok"` or why not |
1555
+ | `view` | token | the table's view and `me`, or `{"error" => "token"}` |
1556
+ | `table` | none | the table's view |
1557
+ | `leave` | token | `true` / `false` |
1558
+
1559
+ The view: `st` (`wait` / `play` / `over`), `nm` the seats' names, `sk`
1560
+ (0 empty, 1 member, 2 CPU), `aw` (bits: a seated member is away), `ow` the
1561
+ owner's seat, `mb` the members (`[name, seat, flags]`), `ts` the seconds
1562
+ for a move, `ai` the seats' AI players (`["", "jev"]`; only when there is
1563
+ one; `sk` keeps 2 for them, so an old screen shows a CPU named Jev), `ak`
1564
+ the AI players the owner may seat (out of a game, only when the host
1565
+ offers some); with a game `bd` (64 characters of `.` `B` `W`, a1 first, row
1566
+ by row), `tn` the seat to move (-1 none), `n` the moves so far (passes
1567
+ too), `last`, `lg` the legal squares of the side to move (`"d3c4f5e6"`),
1568
+ `cnt` the discs `[black, white]`, `dl` the ms left of a member's move (with
1569
+ a time per move) or of Jev's; after it `res` (`{"w" => the seat that won, -1 a draw,
1570
+ "why" => "count" / "resign", "b", "wc"}`). At most about 900 B with 16
1571
+ members of 32-byte names (`rake test` prints it); a game between two
1572
+ players about 330 B.
1573
+
1574
+ | Key | Environment | Default | |
1575
+ |---|---|---|---|
1576
+ | `turn_seconds` | `OTHELLO_TURN_SECONDS` | 0 | a member's time for a move (0: none); over 0, when it runs out the CPU plays that move |
1577
+ | `cpu_ms` | `OTHELLO_CPU_MS` | 700 | the CPU's pause before it plays |
1578
+ | `away_seconds` | `OTHELLO_AWAY_SECONDS` | 10 | as for mahjong: away at once when its node leaves, or after this long without a call ("Who is there") |
1579
+ | `spectators` | `OTHELLO_SPECTATORS` | 14 | members without a seat at most (in the lobby the free seats count besides) |
1580
+ | `ai_turn_seconds` | `OTHELLO_AI_TURN_SECONDS` | 30 | Jev's time for a move; then the CPU plays it |
1581
+
1582
+ The CPU is small: the square with the best weight (corners first, the
1583
+ squares next to a corner last), then the most discs turned.
1584
+
1585
+ ### In a terminal: `asterism-games play othello`
1586
+
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
1589
+ mruby). The options are every client's ("Playing in a terminal"). It joins the table,
1590
+ draws the board as text at every change (it takes the state the host puts
1591
+ and asks its view; X black, O white, `*` the squares it may play) and on a
1592
+ terminal reads keys ("Keys": the arrows move a cursor, Enter plays), else
1593
+ commands: `sit`, `stand`, `cpu`, `ai [KIND]` (Jev in the other seat, when
1594
+ the host offers it), `start`, a square (`d3`), `resign`, `auto` (play the
1595
+ first legal square for you), `quit`. With keys the owner's `A` puts Jev in
1596
+ the other seat (empty or the CPU's) and turns it back into the CPU the
1597
+ next time (`[A] Jev`, `[A] back to the CPU`; not shown when the host
1598
+ offers no AI player); the seat shows `Jev (AI)` and the first line
1599
+ `Jev is thinking, 28 s left` on its moves. When the table's
1600
+ host leaves the network (Asterism's `on_leave`) it says the connection is
1601
+ lost and stops asking until the host is back (`on_join`; every 30 s
1602
+ meanwhile, should the word not come). Without `--table` it looks for a
1603
+ table when a node joins.
1604
+
1605
+ ```
1606
+ # a home network (a plain zenohd)
1607
+ asterism-games play othello --router tcp/127.0.0.1:7447 --name Alice
1608
+ # the permanent tables of a console (a row's .tar with the role games; the node is its name)
1609
+ asterism-games play othello --credentials alice.tar --table games/games/othello1
1610
+ # start a game against the CPU at once
1611
+ asterism-games play othello --name Alice --commands "sit,cpu,start"
1612
+ ```
1613
+
1614
+ ```
1615
+ games/games/othello1 move 2, last d3
1616
+ a b c d e f g h
1617
+ 1 . . . . . . . .
1618
+ 2 . . . . . . . .
1619
+ 3 . . * X * . . .
1620
+ 4 . . . X X . . .
1621
+ 5 . . * X O . . .
1622
+ 6 . . . . . . . .
1623
+ 7 . . . . . . . .
1624
+ 8 . . . . . . . .
1625
+ X black CPU (CPU) 4
1626
+ O white Alice (you, owner) 1 <
1627
+ your move: a square (* marks them), or resign
1628
+ ```
1629
+
1630
+ On a terminal, with keys:
1631
+
1632
+ ```
1633
+ 3 . .[*]X * . . .
1634
+ ...
1635
+ your move: [arrows] move [Enter] play (* marks the squares) [:] type a command [Q] quit
1636
+ ```
1637
+
1638
+ ## The drawing board
1639
+
1640
+ A game without a winner (`lib/asterism_games/board/`, about 360 lines):
1641
+ one picture that every member of the board draws on. It shows the other
1642
+ half of the framework: a state that is handed out as it changes, rather
1643
+ than a view that is read. The screen is fmruby-core's
1644
+ `flash/app/game/board/board.app.rb` ("Board" / "お絵かき", a window; the
1645
+ boards, the browser build and the sim).
1646
+
1647
+ - The picture is strokes in board coordinates (640x360): a colour (one of
1648
+ 16; the screen's palette), a width step (one of 4: 1, 2, 4 and 8 board
1649
+ pixels on the screen) and points. A screen sends a stroke while it is
1650
+ drawn (`line`, then `more`), so the others see it grow.
1651
+ - The first member to join is the owner (the next one in the order of
1652
+ joining when it goes), who alone may clear the board. Each member may
1653
+ take back its own last stroke still on the board (`undo`).
1654
+ - The host saves the picture (`state_dir/board1.json`, at most once a
1655
+ second while somebody draws, and when it stops) and has it again when it
1656
+ starts. A room's board goes when the room closes, like a room's tables.
1657
+
1658
+ | Method | Arguments | Returns |
1659
+ |---|---|---|
1660
+ | `join` | name | `{"token", "owner"}`, or `{"error" => "full"}` |
1661
+ | `line` | token, c, w, pts | a new stroke: `{"id" => id}`, or why not (`"colour"`, `"width"`, `"points"`); pts `[x, y, ...]`, 1-256 points |
1662
+ | `more` | token, id, pts | more points of the caller's newest stroke: `"ok"`, or `"not yours"`, `"long"` (past 1024 points: the screen starts a new stroke), `"points"` |
1663
+ | `undo` | token | `"ok"` or `"nothing"` |
1664
+ | `clear` | token | `"ok"` or `"not owner"` |
1665
+ | `picture` | id, at | a page of the picture from stroke `id` (0: the first) and its point `at`: `{"v", "n", "p" => [[id, c, w, pts, k], ...], "nx" => [id, at] of the next page, or nil}`; k 1: the page goes on with a stroke the page before began, and its points start with that stroke's point before `at`. At most 400 points a page (2.2 KB) |
1666
+ | `view` | token | the board's view, `own` (the caller is the owner) and `mine` (its strokes) |
1667
+ | `table` | none | the board's view (what the state key has) |
1668
+ | `leave` | token | `true` / `false` |
1669
+
1670
+ The view (the state key): `st` (`draw`), `n` the strokes, `np` the
1671
+ points, `last` the newest stroke's id, `mb` the members (`[name, flags]`,
1672
+ 1 away, 2 owner), `lim` `[640, 360, 16, 4, 256, 1024, 1000, 40000]` (the
1673
+ size, colours, widths, points a message, points a stroke, strokes, points).
1674
+
1675
+ **The strokes key.** Every change goes out on
1676
+ `asterism/<node>/<app>/<board>/strokes` (`Game#tell`) as one message, in
1677
+ the order the host took them:
1678
+
1679
+ | Message | |
1680
+ |---|---|
1681
+ | `{"o" => "a", "id", "c", "w", "p" => [x, y, ...], "k", "v"}` | points of stroke `id`: `k` 0 starts it, `k` 1 goes on with it (`p` then starts with the stroke's last point so far, so each message draws as a line of its own) |
1682
+ | `{"o" => "u", "id", "v"}` | stroke `id` taken back |
1683
+ | `{"o" => "c", "v"}` | the board cleared |
1684
+
1685
+ `v` is the board's version when it was told. A screen draws the messages
1686
+ as they come and keeps the strokes, to draw the place of a stroke taken
1687
+ back again; it reads the whole picture (`picture`, in pages) when it joins
1688
+ and after the connection or the host comes back.
1689
+
1690
+ **Recording and playing back** (the console's recordings, its docs
1691
+ `v4.md` and `rooms.md`): record the Asterism key
1692
+ `asterism/<host>/games/board1/strokes`, and a playback to the network puts
1693
+ the same messages on the same key: the screens draw the board being drawn
1694
+ again. A screen cannot tell a played message from a real one, so the
1695
+ picture it shows may then differ from the board's own (the host does not
1696
+ see the playback); the messages played carry the older versions, so a
1697
+ screen says a recording is playing (`v` older than the last state it saw)
1698
+ and `R` reads the board's own picture again. A console records keys on the
1699
+ registered side only (the boards' own certificates and the browsers with
1700
+ a pass, the permanent tables): it cannot subscribe to or put into a room.
1701
+
1702
+ **Limits.** A message is at most 256 points: with 3-byte coordinates and
1703
+ the largest id and version, 1.6 KB with its key (`rake test` prints it),
1704
+ well under the 4096 bytes a board takes. A stroke is at most 1024 points.
1705
+ The board keeps at most 1000 strokes and 40000 points and drops the oldest
1706
+ strokes past either (nothing is told: a screen keeps the same limits and
1707
+ drops them too; they leave its screen when that place is drawn again).
1708
+ Dropping rather than refusing keeps the board drawable for as long as
1709
+ people draw; the point limit is what a screen can keep (4 bytes a point,
1710
+ about 60 % of a window app's 1.5 MB VM at the limit). 500 strokes of 60
1711
+ points are 240 KB saved and add about 3 MB to the host's RSS.
1712
+
1713
+ | Key | Environment | Default | |
1714
+ |---|---|---|---|
1715
+ | `away_seconds` | `BOARD_AWAY_SECONDS` | 10 | as for mahjong ("Who is there") |
1716
+
1717
+ ## riichi_engine
1718
+
1719
+ The engine runs the rounds (wall, deal, draws, discards, calls, riichi, wins,
1720
+ yaku, points) and the CPUs. The host uses a fork of 0.1.0 with its errors
1721
+ fixed: the `fmrb` branch of
1722
+ [kishima/riichi_engine](https://github.com/kishima/riichi_engine), a fork of
1723
+ [riichi_engine](https://github.com/HenrikStephensen/riichi_engine)
1724
+ (`Gemfile` takes it as a git source, so the lockfile pins a revision). Each fix is a branch of its own
1725
+ (`fix/*`, `feat/*`), written to be sent upstream; `fmrb` merges them (version
1726
+ `0.1.1.fmrb6`, not published). The host has no corrections of its own around
1727
+ the engine any more.
1728
+
1729
+ What 0.1.0 got wrong, and what this host did about it before the fork:
1730
+
1731
+ | | In 0.1.0 | Fork branch | The host's correction (removed) |
1732
+ |---|---|---|---|
1733
+ | 1 | `evaluate_win` counts dora (and uradora) on the indicators | `fix/dora-indicators` | evaluated with the dora tiles |
1734
+ | 2 | no houtei: the phase is already `round_end` (and a houtei-only hand is not offered the ron) | `fix/houtei` | evaluated in the phase of the win |
1735
+ | 3 | a seat in riichi gets no discard, its turn cannot go on | `fix/riichi-discard` | offered the drawn tile |
1736
+ | 4 | ippatsu is not cleared by the player's own next discard | `fix/ippatsu` | cleared it |
1737
+ | 5 | a red five is swapped or lost in a discard, chi, pon or kan | `fix/red-five` | put the exact tiles first, recorded the kan's tiles |
1738
+ | 6 | a seat that could ron cannot pon instead | `fix/call-own-options` | left only the chosen action pending |
1739
+ | 7 | ties between rons by seat number (no head bump) | `fix/ron-turn-order` | decided by turn order; now `resolve_pending_action(..., from_seat:)` |
1740
+ | 8 | seeds are read up to the first non-base-36 character | `fix/wall-seed` | used only base-36 seeds |
1741
+ | 9 | the normal/hard CPU keeps the shape with the least acceptance | `fix/cpu-discard-ukeire` | its own CPU (`Engine::CpuAi`); now the engine's |
1742
+ | 10 | `game_type` `"tonpuu"` (the documented name) never ends | `fix/tonpuu-game-type` | (not used) |
1743
+ | 11 | `RiichiEngine.loader.eager_load` fails | `fix/zeitwerk-eager-load` | (not used) |
1744
+ | 12 | no tanyao with a red five | `fix/tanyao-red-five` | none (found by the comparison below) |
1745
+ | 13 | a triplet completed by ron on a shanpon wait has the concealed fu | `fix/shanpon-ron-fu` | none (comparison) |
1746
+ | 14 | only the first wait of the winning tile is scored (pinfu, higher fu missed) | `fix/machi-interpretation` | none (comparison) |
1747
+ | 15 | four identical sequences are iipeiko, not ryanpeiko | `fix/ryanpeiko-four-sequences` | none (comparison) |
1748
+ | 16 | `double_ron` and `triple_ron_draw` have no effect (one ron always wins, by head bump) | `fix/multiple-ron` | none (Step 2 needs them) |
1749
+ | 17 | several yakuman in one hand always score as one (a new key, `multiple_yakuman`) | `feat/multiple-yakuman` | none |
1750
+ | 18 | the shanten counts the concealed tiles as the whole hand (wrong with melds), and slowly | `fix/shanten-melds` | its own count of the regular form (`Engine.shanten`); now the engine's |
1751
+ | 19 | a kan does not shorten the live wall | `fix/kan-live-wall` | none |
1752
+ | 20 | the CPU judges riichi on the hand before the discard (it never declares riichi) | `fix/cpu-riichi` | none |
1753
+ | 21 | every discard ends the first go-around for all four (no nine kinds, double riichi or chiihou for a child) | `fix/first-turn-flags` | none |
1754
+ | 22 | an unknown `game_type` never ends | `fix/unknown-game-type` | none |
1755
+ | 23 | a kan's ura dora indicator is not counted (the ura indicators stay as dealt) | `fix/kan-uradora` | none |
1756
+ | 24 | a seat that lets a winning tile go by with no ron offered (no yaku on it) is not furiten for the go-around | `fix/dojun-furiten-no-yaku` | none |
1757
+ | 25 | the normal/hard CPU calls without a yaku in sight (it looks at the shanten alone), and a chi with several pairs is counted without taking the tiles out | `fix/cpu-yaku-calls` | none |
1758
+
1759
+ New keys in 0.1.1.fmrb3, each off by default (the engine plays as 0.1.0
1760
+ without them; the host turns them on, "Rules"): `suufon_renda`,
1761
+ `suukaikan`, `suucha_riichi` (`feat/abortive-draws`), `nagashi_mangan`
1762
+ (`feat/nagashi-mangan`), `pao` (`feat/pao`), `minkan_dora_after_discard`
1763
+ (`feat/kan-dora-after-discard`), `double_yakuman` used at last
1764
+ (`feat/double-yakuman`), `tenpai_yame` (`feat/agari-yame`). New keys in
1765
+ 0.1.1.fmrb4, likewise: `kuikae` (`feat/kuikae`; on by default, the host
1766
+ turns it off), `riichi_ankan` (`feat/riichi-ankan`),
1767
+ `kokushi_ankan_chankan` (`feat/kokushi-ankan-chankan`), `pao_honba`
1768
+ (`feat/pao-honba`), `tenhou_best_tile` (`feat/tenhou-best-tile`),
1769
+ `nan_iri` and `sudden_death` (`feat/sudden-death`). New in 0.1.1.fmrb6:
1770
+ `riichi_void_on_ron` (`feat/riichi-void-on-ron`; off by default, the host
1771
+ turns it on).
1772
+
1773
+ The fork compares its win evaluation with the Python package `mahjong`
1774
+ (PyPI) on 30 000 random winning hands and one or more hands per yaku
1775
+ (`spec/compat/`): the yaku, han, fu and payments agree except where a rule
1776
+ differs (two yakuman in one hand score as one yakuman in riichi_engine, as
1777
+ two in the Python package; with `multiple_yakuman` they agree).
1778
+
1779
+ Exhaustive draws in CPU-only hanchan: with 0.1.0, 341 of 452 rounds (75 %)
1780
+ with the engine's own CPU, 127 of 496 (26 %) with the host's corrected CPU;
1781
+ with the fork's own CPU, 85 of 488 (17 %) at normal and 97 of 496 (20 %) at
1782
+ hard over 40 hanchan each, and 72 of 282 (26 %) in the 20 normal hanchan of
1783
+ one `rake test` (0.1.1.fmrb2). With 0.1.1.fmrb3 (its CPU now declares
1784
+ riichi and calls less), over 40 hanchan each: 15 % at normal (68 of 449
1785
+ rounds), 14 % at hard (57 of 419), 33 % at easy.
1786
+
1787
+ Still as in 0.1.0: tobi ends the game below 0 points, not at 0 (the
1788
+ fork's documents now say so, `fix/tobi-doc`); without `sudden_death` the
1789
+ west round never ends without a player above the threshold (the host ends
1790
+ the game after west 4); `tsumo_agari` and `ron_agari` leave the points to
1791
+ the host (the engine's API says so). Without `kuikae` set to false a seat
1792
+ may discard the tile it just called, and without `riichi_void_on_ron` a
1793
+ riichi declared on a tile that is ronned keeps its stick on the table. A
1794
+ CPU decision takes about 2 ms of CRuby time at the median (about 0.4 s at
1795
+ most, the first time a suit pattern with many tiles is counted).
1796
+
1797
+ ## Tests
1798
+
1799
+ ```
1800
+ bundle exec rake test # MJ_GAMES=20 CPU hanchan, MJ_HUMAN_GAMES=1 four-player hanchan
1801
+ MJ_GAMES=4 bundle exec rake test # quicker
1802
+ bundle exec rake test:gem # what the gem holds only: lib/ alone, mahjong never loaded, no sqlite3
1803
+ gem build asterism-games.gemspec # the gem (its files: test/test_gem.rb checks them)
1804
+ ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 bundle exec rake test # also through a router
1805
+ ```
1806
+
1807
+ - `test_play.rb`: `asterism-games play` (in `rake test:gem` too): the
1808
+ options and `ASTERISM_ROUTER` / `ASTERISM_CREDENTIALS`, `--help` (the
1809
+ host's too: it prints and starts nothing), the tiles in kanji (the
1810
+ default), as emoji and MPSZ both ways, the widths (🀄 two columns), rows that keep their columns
1811
+ (padded by width, or each part put at its column on a terminal) with the
1812
+ letters under their tiles, reading discards (letters, `6s`, `0p`, the red
1813
+ five as `5p`), declarations and calls (choosing by number, tile or
1814
+ letter), the countdown rewritten in place on a terminal (only the time
1815
+ line, between ESC 7 and ESC 8), and the screens (a round in kanji with
1816
+ its two-row hand and a river that goes on below, emoji and MPSZ; the lobby, a result,
1817
+ the end, a call, a spectator). With `ASTERISM_TEST_ROUTER`, a host in a
1818
+ process of its own and three clients at once (kanji by default, emoji,
1819
+ MPSZ) that play a
1820
+ tonpuu game against three CPUs each to the end from scripted input
1821
+ (letters, tiles, calls, passes, OKs; `PLAY_TRANSCRIPT_DIR` keeps what
1822
+ they showed).
1823
+ - `test_play_commands.rb`: `--commands "sit,cpu,start"` against a table
1824
+ in this process: they wait for the lobby when a game is going on as
1825
+ the client joins (and then sit, fill and start), the owner's wait for
1826
+ the owner, and run at once in a fresh lobby.
1827
+ - `test_mahjong_games.rb`: MJ_GAMES hanchan by four CPUs in MJ_PROCS
1828
+ processes (default 8) and MJ_HUMAN_GAMES hanchan by four members playing
1829
+ through `act` (wins when offered, calls now and then, passes, timeouts,
1830
+ and now and then something not offered, refused): no exception, the points
1831
+ add up after every round, after every step no seat's view has another
1832
+ seat's hand, the help comes only as each seat's level says (level 1 only
1833
+ with a riichi offer, uk only at level 3 and never with wt), and every
1834
+ view fits in a board's message (the four seats ask for the help at
1835
+ levels 1, 2, 3 and 0); and the worst-case views (a result, a riichi turn
1836
+ with the riichi help at its largest).
1837
+ - `test_mahjong_assist.rb`: the help by level (which views get `wt` and
1838
+ `uk`, the call with and without the level), the shanten against the
1839
+ engine's for closed hands and against the wait judge with melds, the
1840
+ acceptance, and the largest acceptance view.
1841
+ - `test_mahjong_rules.rb`: the lobby (join, sit, stand, the owner, CPUs,
1842
+ start, a spectator, away and back, back to the lobby after a game), tokens,
1843
+ refusals (no token, a wrong token, an action not offered, a stale state),
1844
+ the timeout, saving and reading back mid-round and in the lobby, the
1845
+ deadline across a restart, calls (every player answers, one deadline for
1846
+ all, ron over pon), double ron (turn order, honba and sticks to the first,
1847
+ off: head bump), three rons (a draw; off: three winners), multiple
1848
+ yakuman on and off, the host's rule defaults, the riichi help (the waits
1849
+ after each riichi discard and what is left of them, only in the seat's
1850
+ own view), and the engine fixes the host relies on.
1851
+ - `test_mahjong_local_rules.rb`: the local rules (the host's values to
1852
+ start from, the owner only, the lobby only, the values offered, each item
1853
+ reaching the engine, the time to discard, saved and kept for the next
1854
+ game; a tonpuu game chosen in the lobby ends in the east round or the
1855
+ south round after it, open
1856
+ tanyao off offers no ron on an open tanyao-only hand, the red fives in
1857
+ the wall), the spectators (over a whole game the four hands and no
1858
+ offer, help or sign of a call in a spectator's own view, no hand in the
1859
+ put, `table` or a seat's view; with a delay they see the game and the
1860
+ hands as they were that long ago while the players do not wait; none by
1861
+ default; how many), the CPUs' level and the spectators' delay chosen in
1862
+ the lobby (reaching the CPUs, saved, the delay setting in minutes) and
1863
+ the han of each yaku in a result; the items of
1864
+ 0.1.1.fmrb3 (the three abortive draws as one item, over a setting with
1865
+ only some of them) and the extra round item (both keys, its value by the
1866
+ game chosen, over a setting with only one of them).
1867
+ - `test_mahjong_round_ends.rb`: the rules of 0.1.1.fmrb3, fmrb4 and fmrb6
1868
+ the host turns on: every host key reaching the engine, a ron on the
1869
+ riichi tile giving the 1000 back (a pon of it letting the riichi stand), nagashi mangan paid in place
1870
+ of the no-ten payments (and not when turned off), pao recorded at the pon
1871
+ and paid (all of it at a tsumo, half with the discarder at a ron, the
1872
+ honba of a ron by the seat liable), no swap-calling after a chi (not
1873
+ offered, refused), the west round ended by sudden death (the reason in
1874
+ the view), a tonpuu game going south below 30,000 (not with the extra
1875
+ round turned off), a double
1876
+ yakuman's -2 and points (one yakuman when turned off), four winds ending
1877
+ the round by name (the dealer keeps the seat, one more honba; none when
1878
+ turned off), why a game ended in the view, the riichi sticks left at the
1879
+ end going to the top (equal points by seat), and a table saved before
1880
+ these rules read back and played on.
1881
+ - `test_mahjong_time.rb`: the time ("Time"): 5+20 by default and from the
1882
+ settings, a move beyond its 5 s taking the rest off the bank, the move
1883
+ played for when the bank is used up (then 5 s a move), the bank filled
1884
+ again at every round, an away
1885
+ player's short wait leaving the bank, the calls keeping their time, the
1886
+ owner's choice (a bank, a fixed time, only the lobby's choices) reaching the
1887
+ table, the banks saved, and a table saved before the bank read back.
1888
+ - `test_mahjong_result_ok.rb`: the start of a round and the result
1889
+ ("Result"): the pause after the deal (no clock, no CPU or AI move, every
1890
+ round, saved), the result waiting for every human's OK (an away one not
1891
+ counted, an old screen's next as its OK), moving on by itself after the
1892
+ count and 10 s (the count by the yaku lines, a double ron, a draw), the
1893
+ settings, and a table without humans as before.
1894
+ - `test_mahjong_records.rb`: the game records ("Game records"): a game's
1895
+ record whole (every round replayed from its deal by its moves: each
1896
+ discard and call from the hand that holds it, the wins' hands, no tile
1897
+ more than four times, each seat's points from round to round by the
1898
+ result and its riichi stick, the final scores), going on across a
1899
+ restart of the host mid-round, the 20 newest kept (the numbers go on),
1900
+ without a database a closed room's records gone with it; with SQLite
1901
+ (0.14.0) the records read by the next host, the 1000 newest kept (and
1902
+ the pages walking them), the JSON records of 0.13.0 taken in once (the
1903
+ same games in the list and the same records), a closed room's games kept
1904
+ in the database while no other room, permanent or LAN table gives them
1905
+ out (the stats count them), the stats equal to what the records count
1906
+ another way (wins from the tsumo and ron moves, deal-ins from the ron
1907
+ moves, riichi, ranks, rank points; a person by name, CPU, Claude, Jev),
1908
+ the stats in pages; every answer of `records` and
1909
+ `record` under 3800 B (twenty games of long names in pages, a long round
1910
+ in parts), the remote's calls, the last result on `@status` (and not in
1911
+ the rooms' counts), and the declarations in the view (`cl`). The CPU
1912
+ hanchan of `test_mahjong_games.rb` check each game's record the same way.
1913
+ - `test_mahjong_call_privacy.rb`: the calls on a discard keep their secret:
1914
+ each seat sees its own choices only, and a second seat that may call (or
1915
+ answers) changes no other seat's view, the spectator's, the put or the
1916
+ public view (two tables compared key by key); the versions after the
1917
+ calls, the passes naming nobody, and over whole four-member hanchan no
1918
+ `dl` but a candidate's own bit or the "others" bits.
1919
+ - `test_mahjong_room_identity.rb`: who joins in a room: the seats and the
1920
+ host's names join, nobody else (join and mine), nothing checked outside a
1921
+ room, join and mine without a secret, a seat silent for long is a new
1922
+ page, the room's ID reaching the tables.
1923
+ - `test_othello.rb`: the othello rules (the legal moves, the discs turned in
1924
+ every direction, a side that cannot move passing by itself, the end and
1925
+ the winner by the discs, a history replayed), the CPU playing only legal
1926
+ moves to the end, a game against the CPU, the seats and the owner, the
1927
+ spectators' limit, no time limit by default and the CPU playing a move
1928
+ whose time ran out, a game saved and resumed with the same tokens and the
1929
+ time left, the exposed methods and the largest view.
1930
+ - `test_board.rb`: the drawing board: a stroke and more points on the
1931
+ strokes key (a part going on from the last point), what is refused, a
1932
+ stroke's point limit, taking back one's own last stroke, only the owner
1933
+ clearing and the owner passing on, the oldest strokes dropped past the
1934
+ stroke and point limits (the newest staying), the largest message and
1935
+ the largest page of the picture, the picture in pages giving every point
1936
+ once, saved and resumed (owner, ids, tokens), writes held to one a
1937
+ second, the host putting the strokes before the state and counting them
1938
+ in the status, and a host started again having the picture.
1939
+ - `test_rooms.rb`: the rooms' supervisor with stand-in host processes: which
1940
+ rooms get a host (the app, closed, ended), the child's settings (the
1941
+ slot's certificate, the namespace), a new room on the slot starting from
1942
+ nothing (and a slot that moved while the supervisor was down), the same
1943
+ room keeping its tables across a restart, an unreadable file changing
1944
+ nothing, `max` and a stopped host started again, the status (counts
1945
+ only) and the settings from the environment; which tables a room gets
1946
+ (each game's screen and first table by default, a room opening the
1947
+ mahjong screen gets mahjong tables only and one opening the othello
1948
+ screen othello tables only, the given tables split by game, table names
1949
+ in the environment finding their game).
1950
+ - `test_presence.rb`: who is there ("Who is there"): a member's node that
1951
+ leaves is away at once and back at once, a call or a join brings it back,
1952
+ without the network's word a member is away after `away_ms` as before,
1953
+ the time to forget counts from when the node left (a seated member
1954
+ stays), the saved form keeps no presence, a room's seat gone for
1955
+ `seat_reuse_ms` is a new page when its node comes back, an away mahjong
1956
+ seat, and the host telling every table.
1957
+ - `test_mahjong_owner_handover.rb`: the owner away ("Players that drop
1958
+ out"): the role passes after `owner_handover_ms` to the seated member who
1959
+ sat first, else by join order, stays with nobody there, is not taken
1960
+ back, also during a game, 0 turns it off; a table left alone: a game whose
1961
+ humans are all away for `abandon_ms` goes back to the lobby without a
1962
+ record, a member who left stops counting at once, one human back in time
1963
+ keeps the game, a result is abandoned too, a table of CPUs plays on, 0
1964
+ turns it off.
1965
+ - `test_network.rb`: the same refusals through a router, with the host in a
1966
+ child process, and the caller from Asterism (a call that brings `from:`
1967
+ is refused, the numbers of arguments are counted) (skipped without
1968
+ `ASTERISM_TEST_ROUTER`).
1969
+ - `test_framework.rb`: the framework without a game: its files require
1970
+ nothing of a game, loading it loads only its own files (no riichi_engine),
1971
+ and `test/framework/test_tally.rb` runs in a process of its own with a
1972
+ small game of its own (`test/framework/tally.rb`) and nothing else: the
1973
+ exposed methods, the settings from the game, members and tokens through
1974
+ the Remote with `from:`, saving and another host going on from the saved
1975
+ state, the status file, a room and the permanent tables of that game
1976
+ (who joins, `GET /status`, the room's tables removed when it closes) and,
1977
+ with `ASTERISM_TEST_ROUTER`, the host over a router (calls, the state
1978
+ put, the profile, and another node killed: away at once, back when its
1979
+ node is there again). Also the mahjong game's registration and its exposed
1980
+ methods as before, and that nothing of mahjong is in `lib/`.
1981
+ - `test_gem.rb`: the gem (`asterism-games.gemspec`): its files are `lib/`,
1982
+ the two commands and the texts, never `app/`, `deploy/`, `config/`, the
1983
+ tests or anything like a key; its version is `VERSION`'s and it depends on
1984
+ asterism only. Under `rake test:gem` (with `test_framework.rb`,
1985
+ `test_othello.rb`, `test_board.rb` and `test_record_book.rb`, `lib/`
1986
+ alone on the load path, `test/no_sqlite3/` making `require "sqlite3"`
1987
+ fail) the games are othello and the board and nothing of mahjong is
1988
+ loaded.
1989
+ - `test_record_book.rb`: the records' book of the framework (`RecordBook`):
1990
+ the JSON form (the newest kept in the store, read back, the stats' rows),
1991
+ and without the sqlite3 gem a host takes the JSON form; with SQLite the
1992
+ newest kept of a table (another table apart, the seats going with their
1993
+ games, ids never reused), a room's games given out only in that room and
1994
+ kept when it closes (the rooms' table counted together), the stats by
1995
+ time and kind of game, four processes ending 100 games at once into one
1996
+ database (none lost, `integrity_check` ok, the copy whole) and a restore
1997
+ from the copy alone.
1998
+
1999
+ `bin/mahjong-bots` fills a table with players over the network (to try a
2000
+ second table, or the host under load): `bundle exec bin/mahjong-bots
2001
+ --table games/games/mahjong2`. In a room: `--credentials room-01.tar
2002
+ --namespace room/01 --room <room ID> --table games/games/mahjong1`. `--rules 0=1,1=0` sets
2003
+ local rules before the start (a tonpuu game without open tanyao),
2004
+ `--start-ms` waits in the lobby (for spectators), `--result-ms` looks at
2005
+ each result that long.
2006
+
2007
+ ## License
2008
+
2009
+ MIT (LICENSE).