asterism-games 0.18.0 → 0.19.1

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.
@@ -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