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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +64 -11
- data/VERSION +1 -1
- data/lib/asterism_games/host.rb +1 -1
- data/lib/asterism_games/othello/table.rb +13 -10
- data/lib/asterism_games/play/mahjong.rb +23 -10
- data/lib/asterism_games/play/mahjong_screen.rb +33 -12
- data/lib/asterism_games/play/mahjong_text.rb +47 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a35db1346246e5d20505026e701fef357536cc75fa53f22bf47878398fb01c3
|
|
4
|
+
data.tar.gz: 79c4c46763e2104c3e4d03ba521327e9ceb5fa77fc74521cec6041eb3ca48b89
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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).
|
|
716
|
-
|
|
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;
|
|
1829
|
-
|
|
1830
|
-
then
|
|
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
|
|
1908
|
-
decisions
|
|
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.
|
|
1
|
+
0.20.0
|
data/lib/asterism_games/host.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
|
317
|
-
# are used up): all of them,
|
|
318
|
-
|
|
319
|
-
|
|
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,
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
|
393
|
-
#
|
|
394
|
-
|
|
395
|
-
|
|
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 } }
|