asterism-games 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d10ded9e6550829d2169bb7be24ba662f11d69cec3eaf0fe676923104aa44727
4
- data.tar.gz: '039b615f91c53f50c6bb10500b4e9516c7893bd213af57d1f7823c2bc67e84f5'
3
+ metadata.gz: 0a35db1346246e5d20505026e701fef357536cc75fa53f22bf47878398fb01c3
4
+ data.tar.gz: 79c4c46763e2104c3e4d03ba521327e9ceb5fa77fc74521cec6041eb3ca48b89
5
5
  SHA512:
6
- metadata.gz: c2571c2374914e2f9bb3d299e0482cd3f2f43b9c6ebe7fb4f5423b5c37c121cd5b06c06eb058ee91ecb5b05ddd0d6db22ce89e9b8b06eaf40cd3ab6ac6081f65
7
- data.tar.gz: 260165d7df98b105ec8070f0c850d80f783b220cf4cf819c3a17de189c0ed7b0ccb78f43dc7ce29fe8f31f08aa4d4793c0eca1a2e600a265e52c61c67833852e
6
+ metadata.gz: 2b31fdedf12d610dca0c8cbd4f1ae060b5b42eeab54dee46d62f0ced96d8895468b48e84db2eae2904489448329a19ef5888cf9cb2a514684c2610d4e0937d87
7
+ data.tar.gz: 9908df82204210223cecd09b651da9d12e3a27ee832433d0e57b74d4d895a7a9753c83c20b75cadfeff88ef94eb91f6564290be193b1e501a37ce209124b67d3
data/CHANGELOG.md CHANGED
@@ -3,6 +3,43 @@
3
3
  Versions before 0.11.1 are noted in the README where each feature is
4
4
  described ("games 0.10.0" and the like).
5
5
 
6
+ ## 0.20.0
7
+
8
+ - **Watch games** (README "Watch games"): the owner of a mahjong table
9
+ may start a game of CPUs and AI players only, and watch it with the
10
+ others at the table (and a commentary, `commentate mahjong`). The owner
11
+ need not be seated: a member that joins a table where nobody is seated
12
+ and there is no owner is the owner (`join`'s `"owner"`, the new `"mo"`
13
+ of the member's own view); once somebody sits the first seated is the
14
+ owner as before, so a game with people seated goes as it did. The new
15
+ method `watch(token)` starts it when the four seats are CPUs or AI
16
+ players, nobody is seated and at most one seat is Claude's (Jev may take
17
+ all four; `"human"`, `"ai limit"`, `"seats"`, `"not owner"` otherwise).
18
+ The lobby view's `"wc"` says whether it may start (1), is over the AI
19
+ limit (2) or not (0); views during it have `"wm" => 1` and the host's
20
+ `@status` `"watch" => true`. Nobody is seated, so the table's
21
+ abandoning does not apply; instead the game stops (unrecorded) when
22
+ every member has left. One game, then the lobby with the same seats.
23
+ The record is kept with `"watch" => true` (also on `records`' entries)
24
+ and counts in the stats for the CPU and the AI kinds only, never for a
25
+ person. The AI players' daily limits work as before.
26
+ - **The game's AI decisions are each seat's own**: `ASTERISM_GAMES_AI_CALLS_PER_GAME`
27
+ (400) is now how many decisions each AI seat may ask in a game (mahjong
28
+ and othello, every game, watch games or not); a seat past it becomes a
29
+ CPU alone. Before, the seats of a game shared it, so a game with three
30
+ or four AI seats stopped them all part way through a tonpuu. A game's
31
+ most is now 400 times its AI seats; the daily limits are as before.
32
+ - `play mahjong`: `W` (and the command `watch`) starts a watch game, for
33
+ the owner with or without a seat; `A` then a seat's number turns that
34
+ seat into a CPU, Claude, Jev and back (the kinds the lobby offers); the
35
+ owner's commands (`cpu`, `ai`, `watch`, `--commands`) work for an owner
36
+ without a seat; the lobby marks the owner among those watching and says
37
+ the AI limit when the seats are over it; a watch game is headed
38
+ `観戦対局`.
39
+ - Family mruby's mahjong app (fmruby-core) has a `観戦で始める[W]` button
40
+ for the owner when the host says it may start (`"wc"`); with an older
41
+ host it is not shown.
42
+
6
43
  ## 0.19.1
7
44
 
8
45
  - The commentary tells where to get VOICEVOX when it finds no engine:
data/README.md CHANGED
@@ -476,7 +476,8 @@ asterism/games/games/@status
476
476
 
477
477
  `last` is the four in rank order as `[name, rank, score]`, by the names
478
478
  the table shows its members anyway (a table app shows them to whoever
479
- joins); it is there once a game has ended. The rooms' status file
479
+ joins); it is there once a game has ended. During a watch game the
480
+ table's entry has `"watch" => true` (games 0.20.0). The rooms' status file
480
481
  (`GET /status` of the rooms' host) stays counts only.
481
482
 
482
483
  `ais` counts the AI players' seats (they are counted in `cpus` too), and
@@ -701,6 +702,7 @@ connects again after 3 s; while a client of a router only reconnects
701
702
  | `stand` | token | `"ok"` or why not (lobby only) |
702
703
  | `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) |
703
704
  | `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 |
705
+ | `watch` | token | the owner: a watch game ("Watch games", games 0.20.0): the four seats CPUs or AI players, nobody seated, Claude in one seat at most; `"ok"` or why not (`"not owner"`, `"not waiting"`, `"seats"`, `"human"`, `"ai limit"`) |
704
706
  | `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"`) |
705
707
  | `view` | token, assist | the member's view (below), or `{"error" => "token"}`; assist: the help its screen shows (Assist, 0-3) |
706
708
  | `act` | token, sv, type, tiles | `"ok"`, or why not: `"token"`, `"not seated"`, `"stale"` (sv is not the current one), `"not offered"`, `"not playing"` |
@@ -712,8 +714,9 @@ connects again after 3 s; while a client of a router only reconnects
712
714
 
713
715
  - People join a table and wait in its lobby (`"st" => "wait"`): a member
714
716
  sits in a seat or watches. The first member who sat is the owner (when the
715
- owner stands or leaves, the member seated longest takes over). The owner
716
- fills empty seats with CPUs and starts. At the end of a game the owner's
717
+ owner stands or leaves, the member seated longest takes over). While
718
+ nobody is seated the owner may be a member without a seat (games 0.20.0,
719
+ "Watch games"). The owner fills empty seats with CPUs and starts. At the end of a game the owner's
717
720
  next (or `over_seconds`) brings the table back to the lobby with the same
718
721
  seats, for another game.
719
722
  - The node that calls is the caller in the call's key (`from:`, filled
@@ -973,6 +976,47 @@ going for everyone (the other away seats are played for as before). A
973
976
  table of CPUs only (`humans: 0`, the permanent spectator tables) is never
974
977
  abandoned.
975
978
 
979
+ ### Watch games
980
+
981
+ A game of CPUs and AI players only, which the people at the table watch
982
+ (games 0.20.0): the owner fills the four seats and starts it without
983
+ sitting down, and a commentary (`commentate mahjong`, "Commentary") can
984
+ follow it like any other table.
985
+
986
+ - **The owner without a seat.** A member that joins a table where nobody
987
+ is seated and nobody is the owner is the owner (`join`'s `"owner"`, the
988
+ member's own view's `"mo"`). When somebody sits, the first to sit is the
989
+ owner as before: whenever a member is seated the role is where it always
990
+ was (the seated member who sat first, "Players that drop out"). An owner
991
+ who stands up with nobody else seated keeps the role; an owner who
992
+ leaves with nobody seated hands it to the member who joined first, of
993
+ those there. An older table app, which knows only `"ow"` (the owner's
994
+ seat), shows an owner without a seat as no owner, as it showed a lobby
995
+ without one before.
996
+ - **Starting.** `watch(token)`, the owner, in the lobby: the four seats
997
+ are CPUs or AI players (`cpu`), no member is seated, and at most one seat
998
+ is Claude's (`WATCH_AI_MAX`: Claude 1, Jev 4). The lobby view's `"wc"`
999
+ tells the screens whether it may start (1), or the seats are all CPUs
1000
+ and AI players but over the AI limit (2), or not (0); a host before
1001
+ 0.20.0 has no `"wc"`, and the screens offer no watch game then. The
1002
+ seats are drawn as for `start`. The AI players' money is as for any
1003
+ game ("AI players"): a kind past its day's limit becomes a CPU.
1004
+ - **During it.** Nobody is seated, so nobody is played for and the
1005
+ table's abandoning does not apply ("Players that drop out"); the results
1006
+ move on by themselves as at a table of CPUs (`result_seconds`), and the
1007
+ end of the game after `over_seconds`. The members watch with the four
1008
+ hands ("Spectators", the spectator delay too). Views have `"wm" => 1`
1009
+ and the host's `@status` has `"watch" => true` for the table ("Status").
1010
+ When every member has left the table (`leave`, or forgotten after being
1011
+ away six times `away_seconds`) the game stops: back to the lobby, an
1012
+ unfinished game not recorded. The log says `watch game started (...)`
1013
+ and `watch game stopped: nobody at the table (not recorded)`.
1014
+ - **One game.** At the end the table goes back to the lobby with the same
1015
+ seats; the owner starts another with `watch` again.
1016
+ - **Records.** The game is recorded as any other with `"watch" => true`
1017
+ (in `records`' entries and in `record(id)`). Its seats count in the
1018
+ stats for their kinds (CPU, Claude, Jev), never for a person ("Stats").
1019
+
976
1020
  ### Views
977
1021
 
978
1022
  A Hash (MessagePack); tiles are one byte each in binary strings (bin).
@@ -986,7 +1030,9 @@ A Hash (MessagePack); tiles are one byte each in binary strings (bin).
986
1030
  | `sk` | the four seats: 0 empty, 1 a member, 2 a CPU (an AI player too) |
987
1031
  | `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") |
988
1032
  | `aw` | the seats whose member is away, as bits |
989
- | `ow` | the owner's seat (-1: nobody seated) |
1033
+ | `ow` | the owner's seat (-1: nobody seated, or an owner without a seat) |
1034
+ | `mo` | the member's own view only: 1 when this member is the owner, seated or not (games 0.20.0, "Watch games") |
1035
+ | `wc`, `wm` | in the lobby, whether a watch game may start: 1 yes, 2 the seats are all CPUs and AI players but over the AI limit, 0 no; during a watch game `wm` 1 ("Watch games", games 0.20.0) |
990
1036
  | `mb` | the members in the order they joined: `[name, seat (-1 none), flags (1 away, 2 owner)]` |
991
1037
  | `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)]` |
992
1038
  | `sc`, `ri` | the scores; riichi as bits |
@@ -1171,7 +1217,7 @@ assistant through `asterism mcp`).
1171
1217
 
1172
1218
  | Method | Arguments | Returns |
1173
1219
  |---|---|---|
1174
- | `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) |
1220
+ | `records` | from (optional, 0) | `{"records" => [...], "total", "next"}`: the games, newest first, from the from-th, as many as fit one message; `next` is the from of the next page (none: the end). Each: `id`, `at` (the host's local time, `"2026-10-18 21:05"`), `game` (`hanchan` / `tonpuu`), `names`, `ranks`, `scores` (in seat order), `watch` (`true` for a watch game, "Watch games"; else absent) |
1175
1221
  | `record` | id | the game's head: `id`, `at`, `time` (Unix seconds), `table`, `game`, `rules` (as `look`'s), `names`, `kinds` (human / cpu / ai), `final` (`[[seat, rank, score, points x 10], ...]`), `reason` (as the view's `fr`), `rounds` (`[[wind, number, honba, kind, [four changes]], ...]`) |
1176
1222
  | `record` | id, round | round `round` (from 0) as below, with `id`, `round`, `of` (how many rounds), `part`, `parts` |
1177
1223
  | `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 |
@@ -1331,7 +1377,9 @@ pipe or a file (and the tests) they read lines as before, and
1331
1377
  | `1`..`4` | the lobby: sit there (an empty seat or a CPU's) |
1332
1378
  | `S` | the lobby: stand |
1333
1379
  | `C` | the lobby, the owner: CPUs into the empty seats |
1380
+ | `A`, then `1`..`4` | the lobby, the owner: that seat's next player: an empty seat a CPU, a CPU the first AI player offered, an AI player the next one, the last back a CPU (games 0.20.0; another key gives it up) |
1334
1381
  | Enter | the lobby, the owner: start (four seats taken) |
1382
+ | `W` | the lobby, the owner (seated or not): a watch game, the four seats CPUs or AI players ("Watch games", games 0.20.0) |
1335
1383
 
1336
1384
  | Othello | |
1337
1385
  |---|---|
@@ -1460,6 +1508,7 @@ Typed (in a pipe or a file each line; on a terminal after `:`):
1460
1508
  | `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") |
1461
1509
  | `tonpuu`, `hanchan`, `rule ITEM VALUE` | the owner: the game's length, any local rule ("Local rules") |
1462
1510
  | `start` | the owner: start |
1511
+ | `watch` | the owner, seated or not: a watch game ("Watch games", games 0.20.0); `--commands "cpu,ai 1 claude,watch"` from a client that does not sit |
1463
1512
  | `auto` | the client plays for you: wins when offered, passes calls, discards the drawn tile, OKs the results (again to stop) |
1464
1513
  | `tables`, `view`, `help`, `quit` | |
1465
1514
 
@@ -1825,9 +1874,12 @@ offered), and show the seat by its name (`Claude`, `Jev`, numbered when there ar
1825
1874
  reports, per day (the host's local date), in `state_dir/ai_usage.json`
1826
1875
  (shared by the rooms' hosts and the permanent tables; counts and money
1827
1876
  only). When today's total reaches `ASTERISM_GAMES_AI_DAILY_USD` (3.0) no
1828
- table offers an AI player any more and the seated ones become CPUs; a
1829
- game asks at most `ASTERISM_GAMES_AI_CALLS_PER_GAME` (400) decisions,
1830
- then its AI seats become CPUs. `bin/asterism-games status` prints the
1877
+ table offers an AI player any more and the seated ones become CPUs; each
1878
+ AI seat asks at most `ASTERISM_GAMES_AI_CALLS_PER_GAME` (400) decisions
1879
+ in a game, then that seat becomes a CPU (games 0.20.0: the count is each
1880
+ seat's own, so a game's most is 400 times its AI seats; before, the
1881
+ seats of a game shared the 400 and every AI seat stopped together). A
1882
+ tonpuu takes about 150-200 decisions a seat, a hanchan about twice that. `bin/asterism-games status` prints the
1831
1883
  day's decisions, tokens and money per kind; the host's `@status` has the
1832
1884
  totals (`ai_today`).
1833
1885
  - **A limit per kind** (games 0.15.0). `ASTERISM_GAMES_AI_DAILY_USD_CLAUDE`
@@ -1840,7 +1892,7 @@ offered), and show the seat by its name (`Claude`, `Jev`, numbered when there ar
1840
1892
  0.15.0. A value that is not a number of dollars (0 or more) is ignored,
1841
1893
  with a line in the log. The log says the limits at the start
1842
1894
  (`games: AI players: claude, jev (at most $10.0 a day for claude, $2.0
1843
- for jev, 400 decisions a game)`) and once for each kind that stops
1895
+ for jev, 400 decisions a seat in a game)`) and once for each kind that stops
1844
1896
  (`today's limit for claude ($10.0) is reached`); a new day lets it play
1845
1897
  again. `ai_today` on `@status` then has `kinds` (README "Status").
1846
1898
  - **Prices.** USD per million tokens, settings with the published prices
@@ -1904,8 +1956,9 @@ game between two machines without a host is fmruby-core's
1904
1956
  "moves" => [{"n", "square", "turns", "text"}]}`; the answer is a move's
1905
1957
  `n`. One out of range, one that does not come in `ai_turn_seconds` (30 s)
1906
1958
  or a failure is played by the CPU, and the game goes on. When the day's
1907
- money is spent, or the game has asked `ASTERISM_GAMES_AI_CALLS_PER_GAME`
1908
- decisions, Jev's seat becomes the CPU. The table apps show Jev by its
1959
+ money is spent, or the seat has asked `ASTERISM_GAMES_AI_CALLS_PER_GAME`
1960
+ decisions in the game (each seat its own count, games 0.20.0), Jev's
1961
+ seat becomes the CPU. The table apps show Jev by its
1909
1962
  name with `[AI]` and say when it is thinking; `dl` counts its time down.
1910
1963
  - Nothing in othello is hidden, so every member sees the same view: the
1911
1964
  table's view (the one the host puts on the state key) and `me`, the
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.19.1
1
+ 0.20.0
@@ -107,7 +107,7 @@ module AsterismGames
107
107
  prices: ->(k) { settings.prices(k) })
108
108
  unless deciders.empty?
109
109
  @log.call("games: AI players: #{deciders.keys.join(', ')} (#{AI::Hub.limits_text(deciders.keys, budget)}, " \
110
- "#{settings.calls_per_game} decisions a game)")
110
+ "#{settings.calls_per_game} decisions a seat in a game)")
111
111
  end
112
112
  AI::Hub.new(deciders, budget, calls_per_game: settings.calls_per_game, threads: settings.threads, log: @log)
113
113
  end
@@ -78,7 +78,7 @@ module AsterismGames
78
78
  @stats = Hash.new(0)
79
79
  @ai_kinds = [] # the AI players the host can offer now (the lobby view's "ak")
80
80
  @ai_calls_per_game = nil
81
- @ai_calls = 0 # decisions asked this game
81
+ @ai_calls = Hash.new(0) # seat => decisions asked this game
82
82
  @ai_want = nil # the decision asked of the AI seat to move: {seat:, key:, kind:, at:, sent:}
83
83
  @ai_got = nil # [key, move number or nil] come back
84
84
  load_saved(0)
@@ -205,7 +205,7 @@ module AsterismGames
205
205
  @seats = @seats.shuffle(random: Random.new(SecureRandom.random_number(2**32)))
206
206
  @game = Othello::Match.new
207
207
  @result = nil
208
- @ai_calls = 0
208
+ @ai_calls = Hash.new(0)
209
209
  @status = "play"
210
210
  @log&.call("othello: #{@name}: game started (#{@seats.map { |s| s['name'] }.join(' (black), ')} (white))")
211
211
  schedule(now)
@@ -272,7 +272,8 @@ module AsterismGames
272
272
  # does not come in time, or fails, is the CPU's. A turn with one legal
273
273
  # move is not asked (it is played as the CPU plays). The host turns
274
274
  # the AI seats into CPUs when the day's money is spent, and the table
275
- # does when the game has asked ai_calls_per_game decisions.
275
+ # turns an AI seat into a CPU when that seat has asked
276
+ # ai_calls_per_game decisions in the game (each seat its own count).
276
277
 
277
278
  def ai_seat?(seat)
278
279
  s = @seats[seat]
@@ -291,12 +292,12 @@ module AsterismGames
291
292
  def ai_requests(now)
292
293
  w = @ai_want
293
294
  return [] if w.nil? || w[:sent]
294
- if @ai_calls_per_game && @ai_calls >= @ai_calls_per_game
295
- ai_off!("#{@ai_calls_per_game} decisions in this game")
295
+ if @ai_calls_per_game && @ai_calls[w[:seat]] >= @ai_calls_per_game
296
+ ai_off!("#{@ai_calls_per_game} decisions of seat #{w[:seat]} in this game", seats: [w[:seat]])
296
297
  return []
297
298
  end
298
299
  w[:sent] = true
299
- @ai_calls += 1
300
+ @ai_calls[w[:seat]] += 1
300
301
  @stats["ai_asked"] += 1
301
302
  [{ seat: w[:seat], key: w[:key], kind: w[:kind], until: w[:at],
302
303
  request: { "game" => "othello", "table" => @name, "seat" => w[:seat],
@@ -313,10 +314,12 @@ module AsterismGames
313
314
  true
314
315
  end
315
316
 
316
- # The AI seats become CPUs (the day's money, or the game's decisions,
317
- # are used up): all of them, or those of one kind.
318
- def ai_off!(why, kind: nil)
319
- seats = (0...SEATS).select { |i| ai_seat?(i) && (kind.nil? || @seats[i]["ai"] == kind) }
317
+ # The AI seats become CPUs (the day's money, or a seat's decisions of
318
+ # the game, are used up): all of them, those of one kind, or the
319
+ # seats named.
320
+ def ai_off!(why, kind: nil, seats: nil)
321
+ only = seats
322
+ seats = (0...SEATS).select { |i| ai_seat?(i) && (kind.nil? || @seats[i]["ai"] == kind) && (only.nil? || only.include?(i)) }
320
323
  return false if seats.empty?
321
324
  seats.each { |i| @seats[i].delete("ai") }
322
325
  @stats["ai_off"] += 1
@@ -21,7 +21,8 @@ module AsterismGames
21
21
  On a terminal it reads keys, as the table app does: Left/Right choose a tile,
22
22
  Enter or Space discards it; R riichi, T tsumo/ron, P pon, C V B chi, K L J kan,
23
23
  N nine kinds, Esc pass, Enter the result's OK; in the lobby 1-4 sit, S stand,
24
- C CPUs, Enter start; Q or Ctrl+C quits; : (or /) opens a line for a command.
24
+ C CPUs, A then 1-4 a seat's CPU or AI player, Enter start, W a watch game;
25
+ Q or Ctrl+C quits; : (or /) opens a line for a command.
25
26
  Mahjong commands (one per line, or after :):
26
27
  a .. n discard that tile of the hand (the drawn tile is the last letter)
27
28
  6s, 0p, 東 discard that tile (0: the red five; 1z..7z for the honours too)
@@ -33,7 +34,8 @@ module AsterismGames
33
34
  ok the result's OK (an empty line too); at the end, the owner: back to the lobby
34
35
  sit [1-4], stand, cpu [1-4] (empty seats to CPUs; the owner), nocpu 1-4,
35
36
  ai 1-4 KIND (an AI player, when the lobby offers one), tonpuu, hanchan,
36
- rule ITEM VALUE (README "Local rules"), start (the owner)
37
+ rule ITEM VALUE (README "Local rules"), start (the owner),
38
+ watch (the owner: the four seats CPUs or AI players, a game only watched)
37
39
  auto let the client play for you (wins, passes, discards the drawn tile,
38
40
  OKs the results); again to stop
39
41
  tables, view, help, quit
@@ -50,8 +52,8 @@ module AsterismGames
50
52
  TIMER_EVERY_S = 1.0
51
53
 
52
54
  # --commands that only work in the lobby, and those of the owner.
53
- LOBBY_WORDS = %w[sit stand cpu nocpu ai start tonpuu hanchan rule].freeze
54
- OWNER_WORDS = %w[cpu nocpu ai start tonpuu hanchan rule].freeze
55
+ LOBBY_WORDS = %w[sit stand cpu nocpu ai start watch tonpuu hanchan rule].freeze
56
+ OWNER_WORDS = %w[cpu nocpu ai start watch tonpuu hanchan rule].freeze
55
57
 
56
58
  REASONS = {
57
59
  "token" => "卓の人ではない", "not seated" => "席に着いていない", "stale" => "画面が古い: 読み直し中",
@@ -60,7 +62,8 @@ module AsterismGames
60
62
  "not owner" => "卓の主だけができる", "seats" => "4 席がそろっていない", "no human" => "人が 1 人もいない",
61
63
  "no change" => "変わらない", "full" => "卓が満員", "spectators" => "観戦の人数が上限",
62
64
  "value" => "選べない値", "no rule" => "その項目は無い", "no ai" => "その AI は今は出せない",
63
- "identity" => "この部屋での名乗りが合わない"
65
+ "identity" => "この部屋での名乗りが合わない", "human" => "人が座っている (観戦対局は 4 席とも CPU か AI)",
66
+ "ai limit" => MahjongText::WATCH_AI_NOTE
64
67
  }.freeze
65
68
 
66
69
  # The whole run: connect, find the table, play until quit (or over).
@@ -120,6 +123,7 @@ module AsterismGames
120
123
  @keys = keys
121
124
  @sel = -1
122
125
  @pick = false
126
+ @ai_pick = false # A in the lobby: a seat's number comes next
123
127
  @typing = false
124
128
  @path = path
125
129
  @name = name
@@ -233,7 +237,8 @@ module AsterismGames
233
237
  # One key (Play.key_loop); false to quit, :line for a line to type.
234
238
  def key(k)
235
239
  v = @view.is_a?(Hash) ? @view : nil
236
- r = MahjongText.key_command(k, v, sel: @sel, pick: @pick)
240
+ r = MahjongText.key_command(k, v, sel: @sel, pick: @pick, ai_pick: @ai_pick)
241
+ @ai_pick = false unless r[0] == :ai_pick
237
242
  case r[0]
238
243
  when :quit then return false
239
244
  when :line then return :line
@@ -248,6 +253,12 @@ module AsterismGames
248
253
  @note = ""
249
254
  draw
250
255
  end
256
+ when :ai_pick
257
+ @lock.synchronize do
258
+ @ai_pick = r[1]
259
+ @note = ""
260
+ draw
261
+ end
251
262
  when :act
252
263
  answer = act(r[1], r[2])
253
264
  say(answer == "ok" ? "" : answer)
@@ -315,9 +326,10 @@ module AsterismGames
315
326
  return "画面を待っています" unless v.is_a?(Hash) && !v["error"]
316
327
  return "待ち合わせを待っています" unless v["st"] == "wait"
317
328
  return nil unless OWNER_WORDS.include?(w)
318
- return "席に着くのを待っています" unless v["me"].to_i >= 0
319
- return "卓の主を待っています" unless v["ow"] == v["me"]
320
- return "4 席がそろうのを待っています" if w == "start" && (v["sk"] || [0]).any?(&:zero?)
329
+ unless MahjongText.owner?(v)
330
+ return v["me"].to_i >= 0 ? "卓の主を待っています" : "席に着くのを待っています"
331
+ end
332
+ return "4 席がそろうのを待っています" if %w[start watch].include?(w) && (v["sk"] || [0]).any?(&:zero?)
321
333
  nil
322
334
  end
323
335
 
@@ -394,6 +406,7 @@ module AsterismGames
394
406
  when "nocpu" then args.empty? ? "nocpu 1-4" : reason(@table.cpu(@token, seat_arg(args), false))
395
407
  when "ai" then args.size < 2 ? "ai 1-4 KIND" : reason(@table.cpu(@token, seat_arg(args), args[1]))
396
408
  when "start" then reason(@table.start(@token))
409
+ when "watch" then reason(@table.watch(@token))
397
410
  when "tonpuu" then reason(@table.rule(@token, 0, 1))
398
411
  when "hanchan" then reason(@table.rule(@token, 0, 0))
399
412
  when "rule"
@@ -453,7 +466,7 @@ module AsterismGames
453
466
 
454
467
  # What the screen needs of the keys (nil: commands are typed).
455
468
  def key_state
456
- @keys && !@typing ? { sel: @sel, pick: @pick } : nil
469
+ @keys && !@typing ? { sel: @sel, pick: @pick, ai_pick: @ai_pick } : nil
457
470
  end
458
471
 
459
472
  # The countdown on a terminal (the move's time and the bank, the
@@ -157,6 +157,7 @@ module AsterismGames
157
157
  def lobby
158
158
  v = @v
159
159
  out = ["#{v['t'] || @path} 待ち合わせ#{" ゲームホスト #{v['hv']}" if v['hv']}"]
160
+ out << " (卓の主: 自分)" if v["mo"] == 1 && @me.negative?
160
161
  sk = v["sk"] || [0, 0, 0, 0]
161
162
  ai = v["ai"] || []
162
163
  4.times do |s|
@@ -169,8 +170,11 @@ module AsterismGames
169
170
  out << " 席#{s + 1} #{n}#{" (#{tags.join(', ')})" unless tags.empty?}"
170
171
  end
171
172
  out << " ルール: #{rules_text}" if v["ru"]
172
- watchers = (v["mb"] || []).select { |m| m[1] == -1 }.map(&:first)
173
+ # Members without a seat; the owner among them (an owner without a
174
+ # seat, README "Watch games") marked.
175
+ watchers = (v["mb"] || []).select { |m| m[1] == -1 }.map { |m| m[2].to_i & 2 == 2 ? "#{m[0]}(卓の主)" : m[0] }
173
176
  out << " 観戦: #{watchers.join(' ')}" unless watchers.empty?
177
+ out << " #{MahjongText::WATCH_AI_NOTE}" if v["wc"] == 2 && MahjongText.owner?(v)
174
178
  out
175
179
  end
176
180
 
@@ -189,6 +193,7 @@ module AsterismGames
189
193
  v = @v
190
194
  r = v["r"] || []
191
195
  out = []
196
+ out << "観戦対局 (CPU と AI だけ)" if v["wm"] == 1
192
197
  if r.size >= 4
193
198
  h = row.text("#{round_name(r)} 供託#{r[3]} 残り#{r[5]} ドラ ")
194
199
  bytes(v["dora"]).each { |t| tile_cell(h, t) }
@@ -463,12 +468,16 @@ module AsterismGames
463
468
  types = opts.map(&:first).uniq
464
469
  case v["st"]
465
470
  when "wait"
466
- return "観戦中: sit で座る、quit で出る" if @me.negative?
467
- owner = v["ow"] == @me
471
+ owner = MahjongText.owner?(v)
472
+ if @me.negative?
473
+ return "観戦中 (卓の主): sit で座る、cpu [席]、ai 席 KIND、watch (観戦で始める)、quit" if owner
474
+ return "観戦中: sit で座る、quit で出る"
475
+ end
468
476
  return "stand、cpu [席] (空きを CPU で)、nocpu 席、tonpuu / hanchan、start、quit" if owner
469
477
  return "4 席がそろったら卓の主が始める (stand、quit)"
470
478
  when "over"
471
479
  return "ok で待ち合わせに戻す (quit で出る)" if types.include?(NEXT)
480
+ return "観戦対局: しばらくすると待ち合わせに戻る (quit で出る)" if v["wm"] == 1
472
481
  return "次へ: 卓の主が待ち合わせに戻す (quit で出る)"
473
482
  when "result"
474
483
  return "ok で次へ" if types.include?(NEXT)
@@ -512,6 +521,7 @@ module AsterismGames
512
521
  when "wait" then return lobby_keys
513
522
  when "over"
514
523
  return "[Enter]待ち合わせに戻す #{COMMON_KEYS}" if types.include?(NEXT)
524
+ return "観戦対局: しばらくすると待ち合わせに戻る #{COMMON_KEYS}" if v["wm"] == 1
515
525
  return "次へ: 卓の主が待ち合わせに戻す #{COMMON_KEYS}"
516
526
  when "result"
517
527
  return "[Enter]OK #{COMMON_KEYS}" if types.include?(NEXT)
@@ -567,26 +577,37 @@ module AsterismGames
567
577
  end
568
578
 
569
579
  # The lobby's keys: 1-4 for the seats that may be taken (empty or a
570
- # CPU's), S stand, C CPUs and Enter start (the owner).
580
+ # CPU's), S stand, C CPUs, A then 1-4 a seat's CPU or AI player,
581
+ # Enter start and W a watch game (the owner; README "Watch games").
571
582
  def lobby_keys
572
583
  v = @v
573
584
  sk = v["sk"] || [0, 0, 0, 0]
585
+ if @keys && @keys[:ai_pick]
586
+ return "AI: [1]-[4] 席を選ぶ (空き→CPU→#{Array(v['ak']).map { |k| AI_LABELS[k] || k }.join('→')}→CPU) " \
587
+ "ほかのキーでやめる"
588
+ end
589
+ owner = MahjongText.owner?(v)
574
590
  free = (0..3).select { |s| sk[s] != 1 && s != @me }.map { |s| "[#{s + 1}]" }
575
591
  words = []
576
592
  words << "#{free.join}座る" unless free.empty?
577
- if @me >= 0
578
- words << "[S]立つ"
579
- if v["ow"] == @me
580
- words << "[C]CPU で埋める" if sk.any?(&:zero?)
581
- words << "[Enter]始める" unless sk.any?(&:zero?)
582
- else
583
- words << "(卓の主が始める)"
593
+ words << "[S]立つ" if @me >= 0
594
+ if owner
595
+ words << "[C]CPU で埋める" if sk.any?(&:zero?)
596
+ words << "[A]AI" unless Array(v["ak"]).empty?
597
+ if v["wc"] == 1
598
+ words << "[W]観戦で始める"
599
+ elsif @me >= 0 && sk.none?(&:zero?)
600
+ words << "[Enter]始める"
584
601
  end
602
+ elsif @me >= 0
603
+ words << "(卓の主が始める)"
585
604
  end
586
605
  words << COMMON_KEYS
587
- "#{@me.negative? ? '観戦中: ' : ''}#{words.join(' ')}"
606
+ "#{@me.negative? ? (owner ? '観戦中 (卓の主): ' : '観戦中: ') : ''}#{words.join(' ')}"
588
607
  end
589
608
 
609
+ AI_LABELS = { "claude" => "Claude", "jev" => "Jev" }.freeze
610
+
590
611
  def turn_text(r)
591
612
  cur = r[6]
592
613
  return "" if cur.nil? || cur.negative? || r[7].to_i >= 2
@@ -378,30 +378,71 @@ module AsterismGames
378
378
  # [:quit]
379
379
  # [:error, text] why not (Japanese, for the screen)
380
380
  # [:none] nothing
381
- def key_command(key, view, sel: -1, pick: false)
381
+ # ai_pick: A was pressed in the lobby, a seat's number comes next
382
+ # ([:ai_pick, on] turns it on or off).
383
+ def key_command(key, view, sel: -1, pick: false, ai_pick: false)
382
384
  k = key.is_a?(String) ? key.downcase : key
383
385
  return [:quit] if QUIT_KEYS.include?(k)
384
386
  return [:line] if LINE_KEYS.include?(k)
385
387
  return [:none] unless view.is_a?(Hash) && !view["error"]
386
388
  me = view["me"].to_i
387
- return lobby_key(k, view, me) if view["st"] == "wait"
389
+ return lobby_key(k, view, me, ai_pick) if view["st"] == "wait"
388
390
  return [:none] if me.negative? || !%w[play result over].include?(view["st"])
389
391
  table_key(k, view, sel, pick)
390
392
  end
391
393
 
392
- # The lobby: 1-4 sit there, S stand, C CPUs into the empty seats and
393
- # Enter start (the owner's).
394
- def lobby_key(k, view, me)
395
- owner = me >= 0 && view["ow"] == me
394
+ # The lobby: 1-4 sit there, S stand, C CPUs into the empty seats,
395
+ # A then 1-4 that seat's next player (CPU, the AI players offered),
396
+ # Enter start and W a watch game (the owner's).
397
+ def lobby_key(k, view, me, ai_pick = false)
398
+ owner = owner?(view)
399
+ if ai_pick
400
+ return [:ai_pick, false] unless %w[1 2 3 4].include?(k)
401
+ return ai_seat_command(view, k.to_i - 1)
402
+ end
396
403
  case k
397
404
  when "1", "2", "3", "4" then [:cmd, "sit", [k]]
398
405
  when "s" then me >= 0 ? [:cmd, "stand", []] : [:none]
399
406
  when "c" then owner ? [:cmd, "cpu", []] : [:error, "卓の主だけができる"]
407
+ when "a"
408
+ return [:error, "卓の主だけができる"] unless owner
409
+ return [:error, "ゲームホストが AI を出していない"] if Array(view["ak"]).empty?
410
+ [:ai_pick, true]
411
+ when "w"
412
+ return [:error, "卓の主だけができる"] unless owner
413
+ return [:error, "このゲームホストは観戦対局ができない"] unless view.key?("wc")
414
+ return [:error, WATCH_AI_NOTE] if view["wc"] == 2
415
+ return [:error, "4 席を CPU か AI で埋めると観戦で始められる"] unless view["wc"] == 1
416
+ [:cmd, "watch", []]
400
417
  when :enter then owner ? [:cmd, "start", []] : [:none]
401
418
  else [:none]
402
419
  end
403
420
  end
404
421
 
422
+ WATCH_AI_NOTE = "観戦対局の AI は Claude 1 席・Jev 4 席まで"
423
+
424
+ # This member is the owner: "mo" (games 0.20.0; an owner without a
425
+ # seat too), or its seat is the owner's.
426
+ def owner?(view)
427
+ return true if view["mo"] == 1
428
+ me = view["me"].to_i
429
+ me >= 0 && view["ow"] == me
430
+ end
431
+
432
+ # A then a seat's number: an empty seat gets a CPU, a CPU the first AI
433
+ # player offered, an AI player the next one, the last back a CPU.
434
+ def ai_seat_command(view, seat)
435
+ sk = view["sk"] || [0, 0, 0, 0]
436
+ return [:error, "その席は人が座っている"] if sk[seat] == 1
437
+ n = (seat + 1).to_s
438
+ return [:cmd, "cpu", [n]] if sk[seat].to_i.zero?
439
+ kinds = Array(view["ak"])
440
+ cur = Array(view["ai"])[seat].to_s
441
+ i = cur.empty? ? -1 : kinds.index(cur)
442
+ nxt = i.nil? ? nil : kinds[i + 1]
443
+ nxt ? [:cmd, "ai", [n, nxt]] : [:cmd, "cpu", [n]]
444
+ end
445
+
405
446
  def table_key(k, view, sel, pick)
406
447
  opts = options(view)
407
448
  has = ->(type) { opts.any? { |t, _| t == type } }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism-games
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama