asterism-games 0.18.0 → 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 +109 -0
- data/README.md +385 -14
- data/VERSION +1 -1
- data/bin/asterism-games +8 -0
- data/lib/asterism_games/ai.rb +64 -0
- data/lib/asterism_games/commentate/ai_comment.rb +633 -0
- data/lib/asterism_games/commentate/assets/TILES_LICENSE.txt +42 -0
- data/lib/asterism_games/commentate/assets/commentate.css +100 -0
- data/lib/asterism_games/commentate/assets/commentate.js +735 -0
- data/lib/asterism_games/commentate/assets/index.html +26 -0
- data/lib/asterism_games/commentate/assets/tiles_hand.bmp +0 -0
- data/lib/asterism_games/commentate/assets/tiles_river.bmp +0 -0
- data/lib/asterism_games/commentate/mahjong.rb +400 -0
- data/lib/asterism_games/commentate/mahjong_events.rb +320 -0
- data/lib/asterism_games/commentate/mahjong_phrases.rb +535 -0
- data/lib/asterism_games/commentate/mahjong_voice.yml +93 -0
- data/lib/asterism_games/commentate/server.rb +204 -0
- data/lib/asterism_games/commentate/voice.rb +394 -0
- data/lib/asterism_games/commentate/voicevox.rb +110 -0
- data/lib/asterism_games/commentate.rb +223 -0
- 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 +18 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsterismGames
|
|
4
|
+
module Commentate
|
|
5
|
+
# What happened at a mahjong table between two of its spectate views
|
|
6
|
+
# (README "Spectate", "Commentary"): pure functions, no network, no
|
|
7
|
+
# clock, no randomness, so the same views always give the same events
|
|
8
|
+
# (and the host can take this over later).
|
|
9
|
+
#
|
|
10
|
+
# events, memo = MahjongEvents.diff(prev, cur, memo)
|
|
11
|
+
#
|
|
12
|
+
# prev is the view before (nil for the first one), memo what the last
|
|
13
|
+
# call returned ({} the first time): what has been said already this
|
|
14
|
+
# round (the tenpai seats), the leader, and the round. Each event is a
|
|
15
|
+
# Hash with "type", "prio" (0 small .. 3 the big moments) and its own
|
|
16
|
+
# keys; "hands" => true when it tells something only the hands show
|
|
17
|
+
# (a tenpai, the waits): hidden when the hands are hidden.
|
|
18
|
+
#
|
|
19
|
+
# The views come as the host puts them (after a change, and the last
|
|
20
|
+
# one every 5 s again), so several moves may lie between two of them:
|
|
21
|
+
# a river is compared tile by tile, a meld by count and type.
|
|
22
|
+
module MahjongEvents
|
|
23
|
+
# The tile names of the views: 1m..9m, 0m the red five, ..., 1z..7z.
|
|
24
|
+
SUITS = %w[m p s].freeze
|
|
25
|
+
LAST_WALL = 10 # "残り 10 枚"
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# [events, memo].
|
|
30
|
+
def diff(prev, cur, memo = {})
|
|
31
|
+
memo = dup_memo(memo)
|
|
32
|
+
out = []
|
|
33
|
+
return [out, memo] unless cur.is_a?(Hash)
|
|
34
|
+
prev = nil unless prev.is_a?(Hash)
|
|
35
|
+
st = cur["status"]
|
|
36
|
+
rk = round_key(cur)
|
|
37
|
+
if prev.nil?
|
|
38
|
+
out << { "type" => "hello", "prio" => 1, "status" => st, "round" => cur["round"] }.compact
|
|
39
|
+
memo["round"] = rk
|
|
40
|
+
memo["leader"] = leader(scores(cur), nil)
|
|
41
|
+
memo["tenpai"] = tenpai_seats(cur).keys if st == "play"
|
|
42
|
+
memo["oorasu"] = rk if rk && last_round?(cur)
|
|
43
|
+
return [out, memo]
|
|
44
|
+
end
|
|
45
|
+
pst = prev["status"]
|
|
46
|
+
new_round = st == "play" && (rk != memo["round"] || pst != "play" && pst != "result")
|
|
47
|
+
if new_round
|
|
48
|
+
memo["round"] = rk
|
|
49
|
+
memo["tenpai"] = []
|
|
50
|
+
first = cur.dig("round", "wind") == "east" && cur.dig("round", "number") == 1 && cur.dig("round", "honba").to_i.zero? &&
|
|
51
|
+
pst != "result"
|
|
52
|
+
oorasu = last_round?(cur)
|
|
53
|
+
ev = { "type" => "round_start", "prio" => oorasu ? 3 : 1, "round" => cur["round"],
|
|
54
|
+
"dora" => Array(cur["dora_indicators"]).map { |t| dora_of(t) } }
|
|
55
|
+
ev["game_start"] = true if first
|
|
56
|
+
if oorasu
|
|
57
|
+
ev["oorasu"] = true
|
|
58
|
+
ev["again"] = true if memo["oorasu"]
|
|
59
|
+
memo["oorasu"] = rk
|
|
60
|
+
end
|
|
61
|
+
out << ev
|
|
62
|
+
memo["leader"] = leader(scores(cur), memo["leader"])
|
|
63
|
+
# A round just begun: nothing of it to compare with the last.
|
|
64
|
+
return [out, memo]
|
|
65
|
+
end
|
|
66
|
+
same_round = %w[play result].include?(st) && %w[play result].include?(pst) && rk == round_key(prev)
|
|
67
|
+
out.concat(moves(prev, cur, memo)) if same_round
|
|
68
|
+
if %w[result over].include?(st) && cur["result"] && (pst == "play" || prev["result"] != cur["result"])
|
|
69
|
+
out.concat(result_events(prev, cur, memo))
|
|
70
|
+
end
|
|
71
|
+
if st == "over" && pst != "over" && cur["final"]
|
|
72
|
+
out << { "type" => "game_over", "prio" => 2, "final" => cur["final"] }
|
|
73
|
+
end
|
|
74
|
+
[out, memo]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The events inside one round: discards (a dora, a red five), calls,
|
|
78
|
+
# riichi, a new dora indicator, the wall running low, a tenpai.
|
|
79
|
+
def moves(prev, cur, memo)
|
|
80
|
+
out = []
|
|
81
|
+
dora = Array(cur["dora_indicators"]).map { |t| dora_of(t) }
|
|
82
|
+
ps = Array(prev["seats"])
|
|
83
|
+
Array(cur["seats"]).each_with_index do |s, i|
|
|
84
|
+
p = ps[i] || {}
|
|
85
|
+
river = Array(s["river"])
|
|
86
|
+
old = Array(p["river"])
|
|
87
|
+
if river.size > old.size
|
|
88
|
+
river[old.size..].each do |t|
|
|
89
|
+
red = t.start_with?("0")
|
|
90
|
+
if red || dora.include?(plain(t))
|
|
91
|
+
out << { "type" => "dora_discard", "prio" => 0, "seat" => i, "tile" => t, "red" => red,
|
|
92
|
+
"dora" => dora.include?(plain(t)) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
call = new_meld(Array(p["melds"]), Array(s["melds"]))
|
|
97
|
+
out << call.merge("type" => "call", "prio" => 1, "seat" => i) if call
|
|
98
|
+
next unless s["riichi"] && !p["riichi"]
|
|
99
|
+
ev = { "type" => "riichi", "prio" => 2, "seat" => i, "turn" => river.size }
|
|
100
|
+
w = waits_of(s)
|
|
101
|
+
ev["waits"] = w if w && !w.empty?
|
|
102
|
+
memo["tenpai"] |= [i]
|
|
103
|
+
out << ev
|
|
104
|
+
end
|
|
105
|
+
old_n = Array(prev["dora_indicators"]).size
|
|
106
|
+
ind = Array(cur["dora_indicators"])
|
|
107
|
+
out << { "type" => "new_dora", "prio" => 1, "indicator" => ind.last, "dora" => dora_of(ind.last) } if ind.size > old_n && old_n.positive?
|
|
108
|
+
pw = prev.dig("round", "wall_left")
|
|
109
|
+
cw = cur.dig("round", "wall_left")
|
|
110
|
+
if pw.is_a?(Integer) && cw.is_a?(Integer) && pw > LAST_WALL && cw <= LAST_WALL
|
|
111
|
+
out << { "type" => "wall", "prio" => 0, "left" => cw }
|
|
112
|
+
end
|
|
113
|
+
if cur["status"] == "play"
|
|
114
|
+
tenpai_seats(cur).each do |i, w|
|
|
115
|
+
next if memo["tenpai"].include?(i)
|
|
116
|
+
memo["tenpai"] << i
|
|
117
|
+
out << { "type" => "tenpai", "prio" => 1, "seat" => i, "waits" => w, "hands" => true }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
out
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The new meld of a seat between two lists, or nil: one more meld
|
|
124
|
+
# (chi, pon, the kans), or a pon turned into an added kan.
|
|
125
|
+
def new_meld(old, cur)
|
|
126
|
+
if cur.size > old.size
|
|
127
|
+
m = cur.last
|
|
128
|
+
return { "call" => m["type"], "tiles" => m["tiles"], "from" => m["from"] }.compact
|
|
129
|
+
end
|
|
130
|
+
cur.each_with_index do |m, j|
|
|
131
|
+
o = old[j]
|
|
132
|
+
next unless o && o["type"] != m["type"] && m["type"] == "added_kan"
|
|
133
|
+
return { "call" => "added_kan", "tiles" => m["tiles"] }
|
|
134
|
+
end
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A win (each winner), a draw; the lead changing hands.
|
|
139
|
+
def result_events(prev, cur, memo)
|
|
140
|
+
r = cur["result"]
|
|
141
|
+
out = []
|
|
142
|
+
kind = r["kind"].to_s
|
|
143
|
+
case kind
|
|
144
|
+
when "tsumo_agari", "ron_agari"
|
|
145
|
+
Array(r["wins"]).each do |w|
|
|
146
|
+
pts = w["points"].to_s
|
|
147
|
+
level = pts[/三倍満|倍満|跳満|満貫|役満/]
|
|
148
|
+
level = "役満" if pts.include?("役満")
|
|
149
|
+
big = !level.nil?
|
|
150
|
+
out << { "type" => "win", "prio" => big ? 3 : 2, "by" => kind == "ron_agari" ? "ron" : "tsumo",
|
|
151
|
+
"seat" => w["seat"], "from" => r["from"], "yaku" => Array(w["yaku"]), "han" => w["han"],
|
|
152
|
+
"fu" => w["fu"], "points" => pts, "level" => level, "tile" => w["tile"] }.compact
|
|
153
|
+
end
|
|
154
|
+
when "ryuukyoku"
|
|
155
|
+
out << { "type" => "draw", "prio" => 2, "tenpai" => Array(r["tenpai"]) }
|
|
156
|
+
else
|
|
157
|
+
out << { "type" => "abort", "prio" => 2, "kind" => kind }
|
|
158
|
+
end
|
|
159
|
+
before = scores(prev)
|
|
160
|
+
after = scores(cur)
|
|
161
|
+
changes = Array(r["changes"])
|
|
162
|
+
after = before.each_with_index.map { |x, i| x.to_i + changes[i].to_i } if after == before && changes.size == 4
|
|
163
|
+
old = memo["leader"] || leader(before, nil)
|
|
164
|
+
now = leader(after, old)
|
|
165
|
+
if old && now && now != old
|
|
166
|
+
out << { "type" => "lead_change", "prio" => 3, "seat" => now, "was" => old, "score" => after[now] }
|
|
167
|
+
end
|
|
168
|
+
memo["leader"] = now
|
|
169
|
+
out
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------ the table
|
|
173
|
+
|
|
174
|
+
def round_key(v)
|
|
175
|
+
r = v["round"]
|
|
176
|
+
return nil unless r.is_a?(Hash)
|
|
177
|
+
[r["wind"], r["number"], r["honba"]]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def scores(v)
|
|
181
|
+
Array(v["seats"]).map { |s| s["score"].to_i }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The seat with the most points; a tie keeps the one before (old).
|
|
185
|
+
def leader(sc, old)
|
|
186
|
+
return nil if sc.empty?
|
|
187
|
+
top = sc.max
|
|
188
|
+
return old if old && sc[old] == top
|
|
189
|
+
sc.index(top)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The last round of the game: south 4 in a hanchan, east 4 in a tonpuu
|
|
193
|
+
# (a round after it, the west round, counts too).
|
|
194
|
+
def last_round?(v)
|
|
195
|
+
r = v["round"] or return false
|
|
196
|
+
game = v.dig("rules", "game").to_s
|
|
197
|
+
last = game.start_with?("tonpuu") || game == "east" ? "east" : "south"
|
|
198
|
+
order = %w[east south west north]
|
|
199
|
+
wi = order.index(r["wind"].to_s) or return false
|
|
200
|
+
li = order.index(last)
|
|
201
|
+
wi > li || (wi == li && r["number"].to_i == 4)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The seats whose hand is tenpai now (not after riichi: riichi says
|
|
205
|
+
# it): {seat => waits}, from the hands in the view.
|
|
206
|
+
def tenpai_seats(v)
|
|
207
|
+
out = {}
|
|
208
|
+
Array(v["seats"]).each_with_index do |s, i|
|
|
209
|
+
next if s["riichi"]
|
|
210
|
+
w = waits_of(s)
|
|
211
|
+
out[i] = w if w && !w.empty?
|
|
212
|
+
end
|
|
213
|
+
out
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The waits of a seat's hand (tile names, the red five as a five), or
|
|
217
|
+
# nil when the hand is not there or not between two discards.
|
|
218
|
+
def waits_of(seat)
|
|
219
|
+
h = seat["hand"]
|
|
220
|
+
return nil unless h.is_a?(Array) && seat["drawn"].nil? && (h.size % 3) == 1
|
|
221
|
+
waits(h)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The tiles that complete a hand of 3n+1 tiles (any n: the melds are
|
|
225
|
+
# outside it): a standard hand (n sets and a pair), seven pairs,
|
|
226
|
+
# thirteen orphans.
|
|
227
|
+
def waits(hand)
|
|
228
|
+
c = counts(hand)
|
|
229
|
+
return [] if c.nil?
|
|
230
|
+
out = []
|
|
231
|
+
34.times do |t|
|
|
232
|
+
next if c[t] >= 4
|
|
233
|
+
c[t] += 1
|
|
234
|
+
out << name34(t) if complete?(c, hand.size + 1)
|
|
235
|
+
c[t] -= 1
|
|
236
|
+
end
|
|
237
|
+
out
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def complete?(c, n)
|
|
241
|
+
return true if n == 14 && (seven_pairs?(c) || orphans?(c))
|
|
242
|
+
34.times do |t|
|
|
243
|
+
next if c[t] < 2
|
|
244
|
+
c[t] -= 2
|
|
245
|
+
ok = sets?(c, 0)
|
|
246
|
+
c[t] += 2
|
|
247
|
+
return true if ok
|
|
248
|
+
end
|
|
249
|
+
false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Whether the counts from index i on split into sets.
|
|
253
|
+
def sets?(c, i)
|
|
254
|
+
i += 1 while i < 34 && c[i].zero?
|
|
255
|
+
return true if i >= 34
|
|
256
|
+
if c[i] >= 3
|
|
257
|
+
c[i] -= 3
|
|
258
|
+
ok = sets?(c, i)
|
|
259
|
+
c[i] += 3
|
|
260
|
+
return true if ok
|
|
261
|
+
end
|
|
262
|
+
if i < 27 && i % 9 <= 6 && c[i + 1].positive? && c[i + 2].positive?
|
|
263
|
+
c[i] -= 1
|
|
264
|
+
c[i + 1] -= 1
|
|
265
|
+
c[i + 2] -= 1
|
|
266
|
+
ok = sets?(c, i)
|
|
267
|
+
c[i] += 1
|
|
268
|
+
c[i + 1] += 1
|
|
269
|
+
c[i + 2] += 1
|
|
270
|
+
return true if ok
|
|
271
|
+
end
|
|
272
|
+
false
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def seven_pairs?(c) = c.count(2) == 7
|
|
276
|
+
ORPHANS = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33].freeze
|
|
277
|
+
def orphans?(c) = ORPHANS.all? { |t| c[t].positive? } && ORPHANS.sum { |t| c[t] } == 14
|
|
278
|
+
|
|
279
|
+
# 34 counts (1m..9m 1p..9p 1s..9s 1z..7z), or nil for a name not a tile.
|
|
280
|
+
def counts(names)
|
|
281
|
+
c = Array.new(34, 0)
|
|
282
|
+
names.each do |n|
|
|
283
|
+
i = index34(n) or return nil
|
|
284
|
+
c[i] += 1
|
|
285
|
+
end
|
|
286
|
+
c
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def index34(name)
|
|
290
|
+
m = name.to_s.match(/\A([0-9])([mpsz])\z/) or return nil
|
|
291
|
+
d = m[1].to_i
|
|
292
|
+
return (d.between?(1, 7) ? 26 + d : nil) if m[2] == "z"
|
|
293
|
+
d = 5 if d.zero?
|
|
294
|
+
SUITS.index(m[2]) * 9 + d - 1
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def name34(i) = i >= 27 ? "#{i - 26}z" : "#{(i % 9) + 1}#{SUITS[i / 9]}"
|
|
298
|
+
|
|
299
|
+
# A red five as the five.
|
|
300
|
+
def plain(name) = name.to_s.start_with?("0") ? "5#{name[1]}" : name.to_s
|
|
301
|
+
|
|
302
|
+
# The dora an indicator shows: the next of its suit (9 -> 1), the
|
|
303
|
+
# winds in turn (north -> east), the dragons in turn (red -> white).
|
|
304
|
+
def dora_of(ind)
|
|
305
|
+
i = index34(ind) or return nil
|
|
306
|
+
nxt = if i < 27 then (i / 9 * 9) + ((i % 9) + 1) % 9
|
|
307
|
+
elsif i < 31 then 27 + (i - 27 + 1) % 4
|
|
308
|
+
else 31 + (i - 31 + 1) % 3
|
|
309
|
+
end
|
|
310
|
+
name34(nxt)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def dup_memo(m)
|
|
314
|
+
m = (m || {}).dup
|
|
315
|
+
m["tenpai"] = Array(m["tenpai"]).dup
|
|
316
|
+
m
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|