asterism-games 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,456 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsterismGames
4
+ module Play
5
+ # The pieces of the mahjong terminal client that need no network:
6
+ # tiles as text (emoji or MPSZ), the width of text in a terminal, rows
7
+ # that keep their columns whatever the terminal makes of an emoji, and
8
+ # reading what the player types. The tile numbers and the action types
9
+ # are the table's wire (README "Views"); the client does not load the
10
+ # table (nor riichi_engine).
11
+ module MahjongText
12
+ # Action types (README "Views", "Action types").
13
+ DAHAI = 1
14
+ RIICHI = 2
15
+ TSUMO = 3
16
+ ANKAN = 4
17
+ KAKAN = 5
18
+ PON = 6
19
+ CHI = 7
20
+ DAIMINKAN = 8
21
+ RON = 9
22
+ SKIP = 10
23
+ KYUUSHU = 11
24
+ NEXT = 12
25
+ SANCHAHOU = 13
26
+ KANS = [ANKAN, KAKAN, DAIMINKAN].freeze
27
+
28
+ BACK = 37
29
+ WINDS = %w[東 南 西 北].freeze
30
+ HONORS = %w[東 南 西 北 白 發 中].freeze
31
+ HONOR_ALIASES = { "発" => 35, "白" => 34, "中" => 36 }.freeze
32
+ LETTERS = ("a".."n").to_a.freeze
33
+
34
+ # Unicode's mahjong tiles (U+1F000..): the honours (east south west
35
+ # north red green white), the characters 1-9 (U+1F007), the bamboos
36
+ # (U+1F010) and the circles (U+1F019); U+1F02B the back. Indexed by the
37
+ # wire's tile numbers; a red five is the five (marked apart).
38
+ EMOJI = begin
39
+ m = (0..8).map { |i| (0x1F007 + i).chr(Encoding::UTF_8) }
40
+ s = (0..8).map { |i| (0x1F010 + i).chr(Encoding::UTF_8) }
41
+ p = (0..8).map { |i| (0x1F019 + i).chr(Encoding::UTF_8) }
42
+ honors = [0x1F000, 0x1F001, 0x1F002, 0x1F003, 0x1F006, 0x1F005, 0x1F004].map { |c| c.chr(Encoding::UTF_8) }
43
+ (m + [m[4]] + p + [p[4]] + s + [s[4]] + honors + [0x1F02B.chr(Encoding::UTF_8)]).freeze
44
+ end
45
+ EMOJI_CODE = EMOJI.each_with_index.reject { |_, i| [9, 19, 29].include?(i) }.to_h.freeze
46
+
47
+ # The kanji style (as the table app draws a tile): two rows, the
48
+ # number on top (一..九 for the characters, full-width 1..9 for the
49
+ # circles and the bamboos; not ①②③, which many fonts draw small) and
50
+ # the suit below (萬 筒 索); an honour is its one character on top.
51
+ # Every glyph is two columns wide in any terminal.
52
+ KANJI_NUM = %w[一 二 三 四 五 六 七 八 九].freeze
53
+ WIDE_NUM = %w[1 2 3 4 5 6 7 8 9].freeze
54
+ SUITS = %w[萬 筒 索].freeze
55
+ BACK_KANJI = "裏"
56
+ STYLES = %w[emoji kanji mpsz].freeze
57
+
58
+ module_function
59
+
60
+ # The two rows of a tile in the kanji style: [top, bottom] ("" below
61
+ # an honour or a back).
62
+ def kanji_rows(code)
63
+ return [" ", ""] if code.nil? || code.negative?
64
+ return [BACK_KANJI, ""] if code >= BACK
65
+ return [HONORS[code - 30], ""] if code >= 30
66
+ suit = code / 10
67
+ n = code % 10
68
+ n = 4 if n == 9
69
+ [suit.zero? ? KANJI_NUM[n] : WIDE_NUM[n], SUITS[suit]]
70
+ end
71
+
72
+ def red?(code) = [9, 19, 29].include?(code)
73
+
74
+ # A tile in MPSZ: 1m..9m, 0m the red five, ... 1z..7z.
75
+ def mpsz(code)
76
+ return "--" if code.nil? || code.negative?
77
+ return "##" if code >= BACK
78
+ return "#{code - 29}z" if code >= 30
79
+ n = code % 10
80
+ "#{n == 9 ? 0 : n + 1}#{%w[m p s][code / 10]}"
81
+ end
82
+
83
+ # A tile as the screen shows it: an emoji, or in the text style
84
+ # MPSZ for the suits and the kanji for the honours (東 ... 中: two
85
+ # columns like "1m", and what a Japanese player reads).
86
+ def tile(code, style)
87
+ return (style == "mpsz" ? "--" : " ") if code.nil? || code.negative?
88
+ return EMOJI.fetch(code) if style == "emoji"
89
+ return kanji_rows(code).join if style == "kanji"
90
+ return "##" if code >= BACK
91
+ return HONORS[code - 30] if code >= 30
92
+ mpsz(code)
93
+ end
94
+
95
+ # A tile typed: 6s, 0p (the red five), 1z..7z, 東 ... 中, an emoji, or
96
+ # the kanji style's 一萬 / 4筒 / 3索 (the five is the plain one).
97
+ # Returns the tile number or nil.
98
+ def parse_tile(text)
99
+ t = text.to_s.strip
100
+ if (m = t.match(/\A([0-9])([mps])\z/i))
101
+ n = m[1].to_i
102
+ base = { "m" => 0, "p" => 10, "s" => 20 }[m[2].downcase]
103
+ return n.zero? ? base + 9 : base + n - 1
104
+ end
105
+ return 29 + Regexp.last_match(1).to_i if t =~ /\A([1-7])z\z/i
106
+ return 30 + HONORS.index(t) if HONORS.include?(t)
107
+ return HONOR_ALIASES[t] if HONOR_ALIASES.key?(t)
108
+ if t.size == 2 && (suit = SUITS.index(t[1]))
109
+ n = KANJI_NUM.index(t[0]) || WIDE_NUM.index(t[0]) || ("1".."9").to_a.index(t[0])
110
+ return suit * 10 + n if n
111
+ end
112
+ EMOJI_CODE[t]
113
+ end
114
+
115
+ # Several tiles typed together: "3m4m", "34m", "3m 4m", "東東".
116
+ def parse_tiles(text)
117
+ out = []
118
+ t = text.to_s.delete(" ")
119
+ until t.empty?
120
+ if (m = t.match(/\A([0-9]+)([mpsz])/i))
121
+ m[1].each_char { |d| out << parse_tile("#{d}#{m[2]}") }
122
+ t = m.post_match
123
+ elsif t.size >= 2 && SUITS.include?(t[1])
124
+ out << parse_tile(t[0, 2])
125
+ t = t[2..]
126
+ else
127
+ out << parse_tile(t[0])
128
+ t = t[1..]
129
+ end
130
+ end
131
+ out.include?(nil) ? nil : out
132
+ end
133
+
134
+ # ------------------------------------------------------------ width
135
+
136
+ # Columns a character takes in a terminal (the usual wcwidth): 0 for
137
+ # the combining marks, the variation selectors and the joiner; 2 for
138
+ # the East Asian wide and fullwidth ranges and the emoji shown wide
139
+ # by default (U+1F004, the red dragon, among them; the other mahjong
140
+ # tiles are neutral, 1); 1 for the rest.
141
+ WIDE = [
142
+ 0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF, 0x4E00..0x9FFF, 0xA000..0xA4CF,
143
+ 0xAC00..0xD7A3, 0xF900..0xFAFF, 0xFE30..0xFE4F, 0xFF00..0xFF60, 0xFFE0..0xFFE6,
144
+ 0x1F004..0x1F004, 0x1F0CF..0x1F0CF, 0x1F18E..0x1F18E, 0x1F191..0x1F19A, 0x1F200..0x1F251,
145
+ 0x1F300..0x1F64F, 0x1F680..0x1F6FF, 0x1F900..0x1F9FF, 0x20000..0x3FFFD
146
+ ].freeze
147
+ ZERO = [0x0300..0x036F, 0x200B..0x200F, 0xFE00..0xFE0F, 0x20D0..0x20FF].freeze
148
+
149
+ def char_width(ch)
150
+ c = ch.ord
151
+ return 0 if c < 0x20 || ZERO.any? { |r| r.cover?(c) }
152
+ WIDE.any? { |r| r.cover?(c) } ? 2 : 1
153
+ end
154
+
155
+ # Columns of a text, the escape sequences (\e[...m, \e[...G) not
156
+ # counted.
157
+ def width(text)
158
+ text.to_s.gsub(/\e\[[0-9;]*[A-Za-z]/, "").each_char.sum { |ch| char_width(ch) }
159
+ end
160
+
161
+ # text cut to at most cols columns and padded to cols.
162
+ def fit(text, cols)
163
+ out = +""
164
+ w = 0
165
+ text.to_s.each_char do |ch|
166
+ cw = char_width(ch)
167
+ break if w + cw > cols
168
+ out << ch
169
+ w += cw
170
+ end
171
+ out << " " * (cols - w)
172
+ end
173
+
174
+ # One line of the screen in columns. Each part is given the columns it
175
+ # takes; where the text's own width may be wrong (an emoji tile: a
176
+ # font may draw it wider than the terminal counts it, a terminal may
177
+ # count 🀄 as 1 or 2), the column is not left to the terminal: on a
178
+ # terminal (cursor) every part after the first is put at its column
179
+ # by CHA (ESC [ n G), so a tile drawn wider never pushes the letters
180
+ # or the next tile along; elsewhere (a pipe, a file, the tests) the
181
+ # part is padded with spaces by width.
182
+ class Row
183
+ attr_reader :col
184
+
185
+ def initialize(cursor: false)
186
+ @cursor = cursor
187
+ @out = +""
188
+ @col = 0
189
+ end
190
+
191
+ # Text whose width the terminal counts right (ASCII, kanji).
192
+ def text(s)
193
+ put(s, MahjongText.width(s))
194
+ end
195
+
196
+ # A part of cols columns (a tile and the room after it).
197
+ def cell(s, cols)
198
+ put(s, cols)
199
+ end
200
+
201
+ # Moves on to a column (spaces, or the cursor).
202
+ def to(col)
203
+ return self if col <= @col
204
+ @out << (@cursor ? "\e[#{col + 1}G" : " " * (col - @col))
205
+ @col = col
206
+ self
207
+ end
208
+
209
+ def to_s
210
+ @out.dup
211
+ end
212
+
213
+ private
214
+
215
+ def put(s, cols)
216
+ @out << "\e[#{@col + 1}G" if @cursor && @col.positive?
217
+ @out << s.to_s
218
+ pad = cols - MahjongText.width(s)
219
+ @out << " " * pad if !@cursor && pad.positive?
220
+ @col += cols
221
+ self
222
+ end
223
+ end
224
+
225
+ # ------------------------------------------------------------ input
226
+
227
+ # The view's offers as options to choose from: [[type, [tiles]], ...],
228
+ # one per thing that may be sent (a discard per tile, a riichi per
229
+ # discard, each pon or chi pair, each kan).
230
+ def options(view)
231
+ out = []
232
+ (view["a"] || []).each do |type, bytes|
233
+ tiles = bytes.to_s.b.unpack("C*")
234
+ case type
235
+ when DAHAI, RIICHI then tiles.each { |t| out << [type, [t]] }
236
+ else out << [type, tiles]
237
+ end
238
+ end
239
+ out
240
+ end
241
+
242
+ # The member's hand with the drawn tile last: [tile, ...].
243
+ def hand_tiles(view)
244
+ h = view["h"].to_s.b.unpack("C*")
245
+ dt = view["dt"].to_i
246
+ dt >= 0 && view.key?("dt") ? h + [dt] : h
247
+ end
248
+
249
+ # A tile the player names: a letter of the hand (a..n, the drawn tile
250
+ # last), or a tile typed (6s; 5s also takes the red five when the hand
251
+ # has no other five). nil when it names none.
252
+ def tile_of(word, view)
253
+ hand = hand_tiles(view)
254
+ w = word.to_s
255
+ if w =~ /\A[a-n]\z/
256
+ i = LETTERS.index(w)
257
+ return i < hand.size ? hand[i] : nil
258
+ end
259
+ t = parse_tile(w)
260
+ return nil unless t
261
+ return t if hand.include?(t) || !hand.any?
262
+ five = { 4 => 9, 14 => 19, 24 => 29 }[t]
263
+ five && hand.include?(five) ? five : t
264
+ end
265
+
266
+ # Reads one line. Returns one of
267
+ # [:act, type, tiles] send it (act)
268
+ # [:choose, type, options] several options: show them, the next line chooses
269
+ # [:cmd, word, args] a command of the client (sit, start, quit, ...)
270
+ # [:error, text] why not (Japanese, for the screen)
271
+ # [:none] an empty line
272
+ # pending: the options of a choose before ([type, options]), which a
273
+ # number or a tile chooses from.
274
+ COMMANDS = %w[quit exit q help ? sit stand cpu nocpu ai start rule tonpuu hanchan view auto leave tables].freeze
275
+ WORDS = { "tsumo" => TSUMO, "ron" => RON, "pass" => SKIP, "skip" => SKIP, "kyuushu" => KYUUSHU,
276
+ "ok" => NEXT, "next" => NEXT }.freeze
277
+
278
+ def parse(line, view, pending = nil)
279
+ words = line.to_s.strip.split
280
+ return [:none] if words.empty?
281
+ w0 = words[0].downcase
282
+ if pending && (pick = choose(pending[1], words, view))
283
+ return [:act, *pick]
284
+ end
285
+ return [:cmd, w0, words.drop(1)] if COMMANDS.include?(w0)
286
+ return [:error, "画面がまだ無い"] unless view
287
+ opts = options(view)
288
+ if WORDS.key?(w0)
289
+ type = WORDS[w0]
290
+ o = opts.find { |t, _| t == type }
291
+ return o ? [:act, *o] : [:error, "今はできない"]
292
+ end
293
+ group = case w0
294
+ when "riichi", "reach" then opts.select { |t, _| t == RIICHI }
295
+ when "pon" then opts.select { |t, _| t == PON }
296
+ when "chi" then opts.select { |t, _| t == CHI }
297
+ when "kan" then opts.select { |t, _| KANS.include?(t) }
298
+ end
299
+ if group
300
+ return [:error, "今はできない"] if group.empty?
301
+ # Alone: the one option, or a choice among several (a riichi:
302
+ # which tile to discard with it).
303
+ return [:act, *group[0]] if group.size == 1 && words.size == 1
304
+ return [:choose, group[0][0], group] if words.size == 1
305
+ pick = choose(group, words.drop(1), view)
306
+ return pick ? [:act, *pick] : [:error, "その中には無い"]
307
+ end
308
+ # A tile alone: the discard.
309
+ t = tile_of(words[0], view)
310
+ return [:error, "? (help)"] unless t
311
+ o = opts.find { |ty, ts| ty == DAHAI && ts == [t] }
312
+ return [:act, *o] if o
313
+ opts.any? { |ty, _| ty == DAHAI } ? [:error, "その牌は打てない"] : [:error, "今は打てない"]
314
+ end
315
+
316
+ # One of options by what was typed: its number (1..), a tile (the
317
+ # discard of a riichi, a kan's tile) or the tiles of a pon or chi
318
+ # (3m4m, or letters). nil when none matches.
319
+ def choose(options, words, view)
320
+ return nil if options.nil? || options.empty? || words.empty?
321
+ w = words.join(" ")
322
+ if w =~ /\A[1-9]\z/
323
+ o = options[w.to_i - 1]
324
+ return o && [o[0], o[1]]
325
+ end
326
+ tiles = if words.all? { |x| x =~ /\A[a-n]\z/ }
327
+ words.map { |x| tile_of(x, view) }
328
+ elsif w =~ /\A[a-n]+\z/ && w.size > 1
329
+ w.chars.map { |x| tile_of(x, view) }
330
+ else
331
+ parse_tiles(w)
332
+ end
333
+ return nil if tiles.nil? || tiles.include?(nil) || tiles.empty?
334
+ # A five typed as 5 takes a red five offered when no plain one is.
335
+ norm = ->(ts) { ts.map { |t| { 9 => 4, 19 => 14, 29 => 24 }.fetch(t, t) }.sort }
336
+ exact = options.find { |_, ts| ts.sort == tiles.sort }
337
+ exact ||= options.find { |_, ts| norm.call(ts) == norm.call(tiles) }
338
+ exact ||= options.find { |_, ts| tiles.size == 1 && ts.include?(tiles[0]) }
339
+ exact ||= options.find { |_, ts| tiles.size == 1 && norm.call(ts).include?(norm.call(tiles)[0]) }
340
+ exact && [exact[0], exact[1]]
341
+ end
342
+
343
+ # ------------------------------------------------------------ keys
344
+
345
+ # The keys of the table app (fmruby-core flash/app/game/mahjong):
346
+ # C V B the first, second and third chi offered, K L J the kans.
347
+ CHI_KEYS = %w[c v b].freeze
348
+ KAN_KEYS = %w[k l j].freeze
349
+ QUIT_KEYS = [:ctrl_c, :ctrl_q, "q"].freeze
350
+ LINE_KEYS = [":", "/"].freeze
351
+
352
+ # The hand tile a key would discard: sel when it is on the hand, else
353
+ # the last (the drawn tile).
354
+ def chosen(view, sel)
355
+ n = hand_tiles(view).size
356
+ sel.is_a?(Integer) && sel.between?(0, n - 1) ? sel : n - 1
357
+ end
358
+
359
+ # Whether the hand's tiles are being chosen (a discard or a riichi
360
+ # offered): the cursor under the hand shows then.
361
+ def choosing?(view)
362
+ (view["a"] || []).any? { |t, _| t == DAHAI || t == RIICHI }
363
+ end
364
+
365
+ # The n-th offer (0: the first) of the types, in the view's order.
366
+ def nth_offer(opts, types, n)
367
+ opts.select { |t, _| types.include?(t) }[n]
368
+ end
369
+
370
+ # One key (Keys' names) as the table app reads it. sel: the chosen
371
+ # hand tile (-1: the drawn one), pick: riichi, choosing the tile to
372
+ # discard with it. Returns one of
373
+ # [:act, type, tiles] send it
374
+ # [:select, i] choose hand tile i
375
+ # [:pick, on] riichi's choosing on or off
376
+ # [:cmd, word, args] a command of the client (sit, start ...)
377
+ # [:line] open the line to type a command in
378
+ # [:quit]
379
+ # [:error, text] why not (Japanese, for the screen)
380
+ # [:none] nothing
381
+ def key_command(key, view, sel: -1, pick: false)
382
+ k = key.is_a?(String) ? key.downcase : key
383
+ return [:quit] if QUIT_KEYS.include?(k)
384
+ return [:line] if LINE_KEYS.include?(k)
385
+ return [:none] unless view.is_a?(Hash) && !view["error"]
386
+ me = view["me"].to_i
387
+ return lobby_key(k, view, me) if view["st"] == "wait"
388
+ return [:none] if me.negative? || !%w[play result over].include?(view["st"])
389
+ table_key(k, view, sel, pick)
390
+ end
391
+
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
396
+ case k
397
+ when "1", "2", "3", "4" then [:cmd, "sit", [k]]
398
+ when "s" then me >= 0 ? [:cmd, "stand", []] : [:none]
399
+ when "c" then owner ? [:cmd, "cpu", []] : [:error, "卓の主だけができる"]
400
+ when :enter then owner ? [:cmd, "start", []] : [:none]
401
+ else [:none]
402
+ end
403
+ end
404
+
405
+ def table_key(k, view, sel, pick)
406
+ opts = options(view)
407
+ has = ->(type) { opts.any? { |t, _| t == type } }
408
+ hand = hand_tiles(view)
409
+ case k
410
+ when :left, :right
411
+ return [:none] if hand.empty? || !choosing?(view)
412
+ cur = chosen(view, sel)
413
+ [:select, (cur + (k == :left ? -1 : 1)) % hand.size]
414
+ when :enter, :space
415
+ return [:act, NEXT, []] if has.call(NEXT)
416
+ return [:none] if hand.empty?
417
+ t = hand[chosen(view, sel)]
418
+ if pick
419
+ o = opts.find { |ty, ts| ty == RIICHI && ts == [t] } || opts.find { |ty, _| ty == RIICHI }
420
+ return o ? [:act, *o] : [:pick, false]
421
+ end
422
+ return [:none] unless has.call(DAHAI)
423
+ o = opts.find { |ty, ts| ty == DAHAI && ts == [t] }
424
+ o ? [:act, *o] : [:error, "その牌は打てない"]
425
+ when :esc
426
+ return [:pick, false] if pick
427
+ has.call(SKIP) ? [:act, SKIP, []] : [:none]
428
+ when "r" then has.call(RIICHI) ? [:pick, !pick] : [:error, "今はできない"]
429
+ when "t" then offer_key(opts, [TSUMO, RON], 0)
430
+ when "p" then offer_key(opts, [PON], 0)
431
+ when "n" then offer_key(opts, [KYUUSHU], 0)
432
+ when *CHI_KEYS then offer_key(opts, [CHI], CHI_KEYS.index(k))
433
+ when *KAN_KEYS then offer_key(opts, KANS, KAN_KEYS.index(k))
434
+ else [:none]
435
+ end
436
+ end
437
+
438
+ def offer_key(opts, types, n)
439
+ o = nth_offer(opts, types, n)
440
+ o ? [:act, *o] : [:error, "今はできない"]
441
+ end
442
+
443
+ # The key of an offer, as the screen shows it ("C" for the first chi,
444
+ # "L" for the second kan ...).
445
+ def offer_key_name(opts, type, tiles)
446
+ group, keys = if type == CHI then [[CHI], CHI_KEYS]
447
+ elsif KANS.include?(type) then [KANS, KAN_KEYS]
448
+ end
449
+ return { RIICHI => "R", TSUMO => "T", RON => "T", PON => "P", KYUUSHU => "N", SKIP => "Esc",
450
+ NEXT => "Enter" }[type] unless group
451
+ i = opts.select { |t, _| group.include?(t) }.index([type, tiles])
452
+ i && i < keys.size ? keys[i].upcase : nil
453
+ end
454
+ end
455
+ end
456
+ end