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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +282 -0
- data/LICENSE +21 -0
- data/README.md +2009 -0
- data/VERSION +1 -0
- data/bin/asterism-games +116 -0
- data/bin/othello-tty +14 -0
- data/lib/asterism_games/ai.rb +457 -0
- data/lib/asterism_games/ai_hub.rb +171 -0
- data/lib/asterism_games/board/remote.rb +77 -0
- data/lib/asterism_games/board/table.rb +280 -0
- data/lib/asterism_games/board.rb +11 -0
- data/lib/asterism_games/config.rb +228 -0
- data/lib/asterism_games/game.rb +456 -0
- data/lib/asterism_games/host.rb +436 -0
- data/lib/asterism_games/othello/board.rb +161 -0
- data/lib/asterism_games/othello/remote.rb +85 -0
- data/lib/asterism_games/othello/table.rb +533 -0
- data/lib/asterism_games/othello.rb +12 -0
- data/lib/asterism_games/play/keys.rb +160 -0
- data/lib/asterism_games/play/mahjong.rb +508 -0
- data/lib/asterism_games/play/mahjong_screen.rb +607 -0
- data/lib/asterism_games/play/mahjong_text.rb +456 -0
- data/lib/asterism_games/play/othello.rb +456 -0
- data/lib/asterism_games/play.rb +254 -0
- data/lib/asterism_games/record_book.rb +468 -0
- data/lib/asterism_games/remote.rb +100 -0
- data/lib/asterism_games/rooms.rb +509 -0
- data/lib/asterism_games/store.rb +39 -0
- data/lib/asterism_games/version.rb +5 -0
- data/lib/asterism_games.rb +14 -0
- metadata +105 -0
|
@@ -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
|