mjai 0.0.2 → 0.0.3
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.
- data/lib/mjai/action.rb +4 -0
- data/lib/mjai/active_game.rb +29 -8
- data/lib/mjai/game.rb +41 -16
- data/lib/mjai/game_stats.rb +47 -0
- data/lib/mjai/hora.rb +1 -1
- data/lib/mjai/jsonizable.rb +32 -12
- data/lib/mjai/mjai_command.rb +9 -1
- data/lib/mjai/mjson_archive.rb +8 -4
- data/lib/mjai/player.rb +54 -23
- data/lib/mjai/shanten_analysis.rb +1 -0
- data/lib/mjai/shanten_player.rb +1 -1
- data/lib/mjai/tcp_active_game_server.rb +10 -2
- data/lib/mjai/tcp_client_game.rb +1 -0
- data/lib/mjai/tcp_game_server.rb +37 -26
- data/lib/mjai/tcp_player.rb +2 -4
- data/lib/mjai/tenhou_archive.rb +13 -5
- data/lib/mjai/ymatsux_shanten_analysis.rb +105 -0
- data/share/html/css/style.css +28 -24
- data/share/html/css/style.scss +4 -4
- data/share/html/js/archive_player.coffee +7 -9
- data/share/html/js/archive_player.js +68 -49
- metadata +4 -2
data/lib/mjai/action.rb
CHANGED
@@ -14,6 +14,8 @@ module Mjai
|
|
14
14
|
[:consumed, :pais],
|
15
15
|
[:pais, :pais],
|
16
16
|
[:tsumogiri, :boolean],
|
17
|
+
[:possible_actions, :actions],
|
18
|
+
[:cannot_dahai, :pais],
|
17
19
|
[:id, :number],
|
18
20
|
[:bakaze, :pai],
|
19
21
|
[:kyoku, :number],
|
@@ -35,6 +37,8 @@ module Mjai
|
|
35
37
|
[:scores, :numbers],
|
36
38
|
[:text, :string],
|
37
39
|
[:message, :string],
|
40
|
+
[:log, :string_or_null],
|
41
|
+
[:logs, :strings_or_nulls],
|
38
42
|
])
|
39
43
|
|
40
44
|
end
|
data/lib/mjai/active_game.rb
CHANGED
@@ -216,21 +216,42 @@ module Mjai
|
|
216
216
|
if renchan
|
217
217
|
@ag_oya = self.oya
|
218
218
|
else
|
219
|
-
if self.oya == @players[3]
|
220
|
-
@ag_bakaze = @ag_bakaze.succ
|
221
|
-
if (@game_type == :tonpu && @ag_bakaze == Pai.new("S")) ||
|
222
|
-
(@game_type == :tonnan && @ag_bakaze == Pai.new("W"))
|
223
|
-
# TODO Consider 南入, etc.
|
224
|
-
@last = true
|
225
|
-
end
|
226
|
-
end
|
227
219
|
@ag_oya = @players[(self.oya.id + 1) % 4]
|
220
|
+
@ag_bakaze = @ag_bakaze.succ if @ag_oya == @players[0]
|
228
221
|
end
|
229
222
|
if renchan || ryukyoku
|
230
223
|
@ag_honba += 1
|
231
224
|
else
|
232
225
|
@ag_honba = 0
|
233
226
|
end
|
227
|
+
case @game_type
|
228
|
+
when :tonpu
|
229
|
+
@last = decide_last(Pai.new("E"), renchan)
|
230
|
+
when :tonnan
|
231
|
+
@last = decide_last(Pai.new("S"), renchan)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def decide_last(last_bakaze, tenpai_renchan)
|
236
|
+
if @players.any? { |pl| pl.score < 0 }
|
237
|
+
return true
|
238
|
+
end
|
239
|
+
|
240
|
+
if @ag_bakaze == last_bakaze.succ.succ
|
241
|
+
return true
|
242
|
+
end
|
243
|
+
if @ag_bakaze == last_bakaze.succ
|
244
|
+
return @players.any? { |pl| pl.score >= 30000 }
|
245
|
+
end
|
246
|
+
|
247
|
+
# Agari-yame, tenpai-yame
|
248
|
+
if @ag_bakaze == last_bakaze && @ag_oya == @players[3] &&
|
249
|
+
tenpai_renchan && @players[3].score >= 30000 &&
|
250
|
+
(0..2).all? { |i| @players[i].score < @players[3].score }
|
251
|
+
return true
|
252
|
+
end
|
253
|
+
|
254
|
+
return false
|
234
255
|
end
|
235
256
|
|
236
257
|
def add_dora()
|
data/lib/mjai/game.rb
CHANGED
@@ -47,6 +47,10 @@ module Mjai
|
|
47
47
|
@on_action = block
|
48
48
|
end
|
49
49
|
|
50
|
+
def on_responses(&block)
|
51
|
+
@on_responses = block
|
52
|
+
end
|
53
|
+
|
50
54
|
# Executes the action and returns responses for it from players.
|
51
55
|
def do_action(action)
|
52
56
|
|
@@ -54,24 +58,19 @@ module Mjai
|
|
54
58
|
action = Action.new(action)
|
55
59
|
end
|
56
60
|
|
57
|
-
if action.type != :log
|
58
|
-
for player in @players
|
59
|
-
if !player.log_text.empty?
|
60
|
-
do_action({:type => :log, :actor => player, :text => player.log_text})
|
61
|
-
player.clear_log()
|
62
|
-
end
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
61
|
update_state(action)
|
67
62
|
|
68
63
|
@on_action.call(action) if @on_action
|
69
64
|
|
70
65
|
responses = (0...4).map() do |i|
|
71
|
-
@players[i].respond_to_action(action_in_view(action, i))
|
66
|
+
@players[i].respond_to_action(action_in_view(action, i, true))
|
72
67
|
end
|
68
|
+
|
69
|
+
action_with_logs = action.merge({:logs => responses.map(){ |r| r && r.log }})
|
70
|
+
responses = responses.map(){ |r| (!r || r.type == :none) ? nil : r.merge({:log => nil}) }
|
71
|
+
@on_responses.call(action_with_logs, responses) if @on_responses
|
72
|
+
|
73
73
|
@previous_action = action
|
74
|
-
|
75
74
|
validate_responses(responses, action)
|
76
75
|
return responses
|
77
76
|
|
@@ -112,13 +111,14 @@ module Mjai
|
|
112
111
|
end
|
113
112
|
|
114
113
|
for i in 0...4
|
115
|
-
@players[i].update_state(action_in_view(action, i))
|
114
|
+
@players[i].update_state(action_in_view(action, i, false))
|
116
115
|
end
|
117
116
|
|
118
117
|
end
|
119
118
|
|
120
|
-
def action_in_view(action, player_id)
|
119
|
+
def action_in_view(action, player_id, for_response)
|
121
120
|
player = @players[player_id]
|
121
|
+
with_response_hint = for_response && expect_response_from?(player)
|
122
122
|
case action.type
|
123
123
|
when :start_game
|
124
124
|
return action.merge({:id => player_id})
|
@@ -131,8 +131,32 @@ module Mjai
|
|
131
131
|
end
|
132
132
|
return action.merge({:tehais => tehais_list})
|
133
133
|
when :tsumo
|
134
|
-
|
135
|
-
|
134
|
+
if action.actor == player
|
135
|
+
return action.merge({
|
136
|
+
:possible_actions =>
|
137
|
+
with_response_hint ? player.possible_actions : nil,
|
138
|
+
})
|
139
|
+
else
|
140
|
+
return action.merge({:pai => Pai::UNKNOWN})
|
141
|
+
end
|
142
|
+
when :dahai, :kakan
|
143
|
+
if action.actor != player
|
144
|
+
return action.merge({
|
145
|
+
:possible_actions =>
|
146
|
+
with_response_hint ? player.possible_actions : nil,
|
147
|
+
})
|
148
|
+
else
|
149
|
+
return action
|
150
|
+
end
|
151
|
+
when :chi, :pon
|
152
|
+
if action.actor == player
|
153
|
+
return action.merge({
|
154
|
+
:cannot_dahai =>
|
155
|
+
with_response_hint ? player.kuikae_dahais : nil,
|
156
|
+
})
|
157
|
+
else
|
158
|
+
return action
|
159
|
+
end
|
136
160
|
else
|
137
161
|
return action
|
138
162
|
end
|
@@ -222,7 +246,8 @@ module Mjai
|
|
222
246
|
end
|
223
247
|
validate(
|
224
248
|
response.actor.possible_dahais.include?(response.pai),
|
225
|
-
"Cannot dahai this pai. The pai is not in the tehais,
|
249
|
+
"Cannot dahai this pai. The pai is not in the tehais, " +
|
250
|
+
"it's kuikae, or it causes noten reach.")
|
226
251
|
|
227
252
|
# Validates that pai and tsumogiri fields are consistent.
|
228
253
|
if [:tsumo, :reach].include?(action.type)
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require "mjai/archive"
|
2
|
+
require "mjai/confidence_interval"
|
3
|
+
|
4
|
+
|
5
|
+
module Mjai
|
6
|
+
|
7
|
+
class GameStats
|
8
|
+
|
9
|
+
def self.print(mjson_paths)
|
10
|
+
num_errors = 0
|
11
|
+
name_to_ranks = {}
|
12
|
+
for path in mjson_paths
|
13
|
+
archive = Archive.load(path)
|
14
|
+
first_action = archive.raw_actions[0]
|
15
|
+
last_action = archive.raw_actions[-1]
|
16
|
+
archive.do_action(first_action)
|
17
|
+
if last_action.type != :end_game
|
18
|
+
num_errors += 1
|
19
|
+
next
|
20
|
+
end
|
21
|
+
chicha_id = archive.raw_actions[1].oya.id
|
22
|
+
ranked_player_ids =
|
23
|
+
(0...4).sort_by(){ |i| [-last_action.scores[i], (i + 4 - chicha_id) % 4] }
|
24
|
+
for r in 0...4
|
25
|
+
name = first_action.names[ranked_player_ids[r]]
|
26
|
+
name_to_ranks[name] ||= []
|
27
|
+
name_to_ranks[name].push(r + 1)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
if num_errors > 0
|
31
|
+
puts("errors: %d / %d" % [num_errors, mjson_paths.size])
|
32
|
+
end
|
33
|
+
puts("ranks:")
|
34
|
+
for name, ranks in name_to_ranks
|
35
|
+
rank_conf_interval = ConfidenceInterval.calculate(ranks, :min => 1.0, :max => 4.0)
|
36
|
+
puts(" %s: %.3f [%.3f, %.3f]" % [
|
37
|
+
name,
|
38
|
+
ranks.inject(0, :+).to_f() / ranks.size,
|
39
|
+
rank_conf_interval[0],
|
40
|
+
rank_conf_interval[1],
|
41
|
+
])
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
data/lib/mjai/hora.rb
CHANGED
@@ -329,7 +329,7 @@ module Mjai
|
|
329
329
|
else
|
330
330
|
fu = 20
|
331
331
|
fu += 10 if self.menzen? && @hora.hora_type == :ron
|
332
|
-
fu += 2 if @hora.hora_type == :tsumo
|
332
|
+
fu += 2 if @hora.hora_type == :tsumo && !pinfu?
|
333
333
|
for mentsu in @mentsus
|
334
334
|
mfu = BASE_FU_MAP[mentsu.type]
|
335
335
|
mfu *= 2 if mentsu.pais[0].yaochu?
|
data/lib/mjai/jsonizable.rb
CHANGED
@@ -18,20 +18,25 @@ module Mjai
|
|
18
18
|
end
|
19
19
|
|
20
20
|
def self.from_json(json, game)
|
21
|
-
|
21
|
+
plain = JSON.parse(json)
|
22
22
|
begin
|
23
|
-
|
24
|
-
fields = {}
|
25
|
-
for name, type in @@field_specs
|
26
|
-
plain = hash[name.to_s()]
|
27
|
-
next if plain == nil
|
28
|
-
fields[name] = plain_to_obj(plain, type, name.to_s(), game)
|
29
|
-
end
|
30
|
-
return new(fields)
|
23
|
+
return from_plain(plain, nil, game)
|
31
24
|
rescue ValidationError => ex
|
32
25
|
raise(ValidationError, "%s JSON: %s" % [ex.message, json])
|
33
26
|
end
|
34
27
|
end
|
28
|
+
|
29
|
+
def self.from_plain(plain, name, game)
|
30
|
+
validate(plain.is_a?(Hash), "%s must be an object." % (name || "The response"))
|
31
|
+
fields = {}
|
32
|
+
for field_name, type in @@field_specs
|
33
|
+
field_plain = plain[field_name.to_s()]
|
34
|
+
next if field_plain == nil
|
35
|
+
fields[field_name] = plain_to_obj(
|
36
|
+
field_plain, type, name ? "#{name}.#{field_name}" : field_name.to_s(), game)
|
37
|
+
end
|
38
|
+
return new(fields)
|
39
|
+
end
|
35
40
|
|
36
41
|
def self.plain_to_obj(plain, type, name, game)
|
37
42
|
case type
|
@@ -41,6 +46,9 @@ module Mjai
|
|
41
46
|
when :string
|
42
47
|
validate_class(plain, String, name)
|
43
48
|
return plain
|
49
|
+
when :string_or_null
|
50
|
+
validate(plain.is_a?(String) || plain == nil, "#{name} must be String or null.")
|
51
|
+
return plain
|
44
52
|
when :boolean
|
45
53
|
validate(
|
46
54
|
plain.is_a?(TrueClass) || plain.is_a?(FalseClass),
|
@@ -68,10 +76,14 @@ module Mjai
|
|
68
76
|
"#{name} must be an array of [String, Integer].")
|
69
77
|
validate(!plain[0].empty?, "#{name}[0] must not be empty.")
|
70
78
|
return [plain[0].intern(), plain[1]]
|
79
|
+
when :action
|
80
|
+
return from_plain(plain, name, game)
|
71
81
|
when :numbers
|
72
82
|
return plains_to_objs(plain, :number, name, game)
|
73
83
|
when :strings
|
74
84
|
return plains_to_objs(plain, :string, name, game)
|
85
|
+
when :strings_or_nulls
|
86
|
+
return plains_to_objs(plain, :string_or_null, name, game)
|
75
87
|
when :booleans
|
76
88
|
return plains_to_objs(plain, :boolean, name, game)
|
77
89
|
when :symbols
|
@@ -82,6 +94,8 @@ module Mjai
|
|
82
94
|
return plains_to_objs(plain, :pais, name, game)
|
83
95
|
when :yakus
|
84
96
|
return plains_to_objs(plain, :yaku, name, game)
|
97
|
+
when :actions
|
98
|
+
return plains_to_objs(plain, :action, name, game)
|
85
99
|
else
|
86
100
|
raise("unknown type")
|
87
101
|
end
|
@@ -114,6 +128,10 @@ module Mjai
|
|
114
128
|
attr_reader(:fields)
|
115
129
|
|
116
130
|
def to_json()
|
131
|
+
return JSON.dump(to_plain())
|
132
|
+
end
|
133
|
+
|
134
|
+
def to_plain()
|
117
135
|
hash = {}
|
118
136
|
for name, type in @@field_specs
|
119
137
|
obj = @fields[name]
|
@@ -129,16 +147,18 @@ module Mjai
|
|
129
147
|
plain = obj.map(){ |o| o.map(){ |a| a.to_s() } }
|
130
148
|
when :yakus
|
131
149
|
plain = obj.map(){ |s, n| [s.to_s(), n] }
|
132
|
-
when :
|
150
|
+
when :actions
|
151
|
+
plain = obj.map(){ |a| a.to_plain() }
|
152
|
+
when :number, :numbers, :string, :strings, :string_or_null, :strings_or_nulls, :boolean, :booleans
|
133
153
|
plain = obj
|
134
154
|
else
|
135
155
|
raise("unknown type")
|
136
156
|
end
|
137
157
|
hash[name.to_s()] = plain
|
138
158
|
end
|
139
|
-
return
|
159
|
+
return hash
|
140
160
|
end
|
141
|
-
|
161
|
+
|
142
162
|
alias to_s to_json
|
143
163
|
|
144
164
|
def merge(hash)
|
data/lib/mjai/mjai_command.rb
CHANGED
@@ -5,6 +5,7 @@ require "mjai/tcp_client_game"
|
|
5
5
|
require "mjai/tsumogiri_player"
|
6
6
|
require "mjai/shanten_player"
|
7
7
|
require "mjai/file_converter"
|
8
|
+
require "mjai/game_stats"
|
8
9
|
|
9
10
|
|
10
11
|
module Mjai
|
@@ -42,13 +43,20 @@ module Mjai
|
|
42
43
|
server.run()
|
43
44
|
when "convert"
|
44
45
|
FileConverter.new().convert(argv.shift(), argv.shift())
|
46
|
+
when "stats"
|
47
|
+
GameStats.print(argv)
|
45
48
|
else
|
46
49
|
$stderr.puts(
|
47
50
|
"Usage:\n" +
|
51
|
+
" #{$PROGRAM_NAME} server --port=PORT\n" +
|
48
52
|
" #{$PROGRAM_NAME} server --port=PORT " +
|
49
53
|
"[PLAYER1_COMMAND] [PLAYER2_COMMAND] [...]\n" +
|
54
|
+
" #{$PROGRAM_NAME} stats 1.mjson [2.mjson] [...]\n" +
|
50
55
|
" #{$PROGRAM_NAME} convert hoge.mjson hoge.html\n" +
|
51
|
-
" #{$PROGRAM_NAME} convert hoge.mjlog hoge.mjson\n"
|
56
|
+
" #{$PROGRAM_NAME} convert hoge.mjlog hoge.mjson\n\n" +
|
57
|
+
"See here for details:\n" +
|
58
|
+
"http://gimite.net/pukiwiki/index.php?" +
|
59
|
+
"Mjai%20%CB%E3%BF%FDAI%C2%D0%C0%EF%A5%B5%A1%BC%A5%D0\n")
|
52
60
|
exit(1)
|
53
61
|
end
|
54
62
|
|
data/lib/mjai/mjson_archive.rb
CHANGED
@@ -10,16 +10,20 @@ module Mjai
|
|
10
10
|
def initialize(path)
|
11
11
|
super()
|
12
12
|
@path = path
|
13
|
+
@raw_actions = []
|
14
|
+
File.foreach(@path) do |line|
|
15
|
+
@raw_actions.push(Action.from_json(line.chomp(), self))
|
16
|
+
end
|
13
17
|
end
|
14
18
|
|
15
|
-
attr_reader(:path)
|
19
|
+
attr_reader(:path, :raw_actions)
|
16
20
|
|
17
21
|
def play()
|
18
|
-
|
19
|
-
do_action(
|
22
|
+
for action in @raw_actions
|
23
|
+
do_action(action)
|
20
24
|
end
|
21
25
|
end
|
22
|
-
|
26
|
+
|
23
27
|
end
|
24
28
|
|
25
29
|
end
|
data/lib/mjai/player.rb
CHANGED
@@ -8,10 +8,6 @@ module Mjai
|
|
8
8
|
|
9
9
|
class Player
|
10
10
|
|
11
|
-
def initialize()
|
12
|
-
@log_text = ""
|
13
|
-
end
|
14
|
-
|
15
11
|
attr_reader(:id)
|
16
12
|
attr_reader(:tehais) # 手牌
|
17
13
|
attr_reader(:furos) # 副露
|
@@ -21,7 +17,6 @@ module Mjai
|
|
21
17
|
attr_reader(:reach_state)
|
22
18
|
attr_reader(:reach_ho_index)
|
23
19
|
attr_reader(:attributes)
|
24
|
-
attr_reader(:log_text)
|
25
20
|
attr_accessor(:name)
|
26
21
|
attr_accessor(:game)
|
27
22
|
attr_accessor(:score)
|
@@ -197,6 +192,27 @@ module Mjai
|
|
197
192
|
@game.get_hora(hora_action, {:previous_action => action}).valid? &&
|
198
193
|
(hora_type == :tsumo || !self.furiten?)
|
199
194
|
end
|
195
|
+
|
196
|
+
# Possible actions except for dahai.
|
197
|
+
def possible_actions
|
198
|
+
action = @game.current_action
|
199
|
+
result = []
|
200
|
+
if (action.type == :tsumo && action.actor == self) ||
|
201
|
+
([:dahai, :kakan].include?(action.type) && action.actor != self)
|
202
|
+
if can_hora?
|
203
|
+
result.push(create_action({
|
204
|
+
:type => :hora,
|
205
|
+
:target => action.actor,
|
206
|
+
:pai => action.pai,
|
207
|
+
}))
|
208
|
+
end
|
209
|
+
if can_reach?
|
210
|
+
result.push(create_action({:type => :reach}))
|
211
|
+
end
|
212
|
+
end
|
213
|
+
result += self.possible_furo_actions
|
214
|
+
return result
|
215
|
+
end
|
200
216
|
|
201
217
|
def possible_furo_actions
|
202
218
|
|
@@ -287,10 +303,35 @@ module Mjai
|
|
287
303
|
end
|
288
304
|
|
289
305
|
def possible_dahais(action = @game.current_action, tehais = @tehais)
|
306
|
+
|
290
307
|
if self.reach? && action.type == :tsumo && action.actor == self
|
308
|
+
|
309
|
+
# Only tsumogiri is allowed after reach.
|
291
310
|
return [action.pai]
|
311
|
+
|
312
|
+
elsif action.type == :reach
|
313
|
+
|
314
|
+
# Tehais after the dahai must be tenpai just after reach.
|
315
|
+
result = []
|
316
|
+
for pai in tehais.uniq()
|
317
|
+
pais = tehais.dup()
|
318
|
+
pais.delete_at(pais.index(pai))
|
319
|
+
if ShantenAnalysis.new(pais, 0).shanten <= 0
|
320
|
+
result.push(pai)
|
321
|
+
end
|
322
|
+
end
|
323
|
+
return result
|
324
|
+
|
325
|
+
else
|
326
|
+
|
327
|
+
# Excludes kuikae.
|
328
|
+
return tehais.uniq() - kuikae_dahais(action, tehais)
|
329
|
+
|
292
330
|
end
|
293
|
-
|
331
|
+
|
332
|
+
end
|
333
|
+
|
334
|
+
def kuikae_dahais(action = @game.current_action, tehais = @tehais)
|
294
335
|
consumed = action.consumed ? action.consumed.sort() : nil
|
295
336
|
if action.type == :chi && action.actor == self
|
296
337
|
if consumed[1].number == consumed[0].number + 1
|
@@ -303,15 +344,14 @@ module Mjai
|
|
303
344
|
else
|
304
345
|
forbidden_rnums = []
|
305
346
|
end
|
306
|
-
|
307
|
-
|
347
|
+
if forbidden_rnums.empty?
|
348
|
+
return []
|
349
|
+
else
|
308
350
|
key_pai = consumed[0]
|
309
|
-
return
|
310
|
-
|
311
|
-
forbidden_rnums.any?(){ |rn| key_pai.number + rn == pai.number }
|
351
|
+
return tehais.uniq().select() do |pai|
|
352
|
+
pai.type == key_pai.type &&
|
353
|
+
forbidden_rnums.any?(){ |rn| key_pai.number + rn == pai.number }
|
312
354
|
end
|
313
|
-
else
|
314
|
-
return cands
|
315
355
|
end
|
316
356
|
end
|
317
357
|
|
@@ -354,17 +394,8 @@ module Mjai
|
|
354
394
|
return @game.ranked_players.index(self) + 1
|
355
395
|
end
|
356
396
|
|
357
|
-
def log(text)
|
358
|
-
@log_text << text << "\n"
|
359
|
-
puts(text)
|
360
|
-
end
|
361
|
-
|
362
|
-
def clear_log()
|
363
|
-
@log_text = ""
|
364
|
-
end
|
365
|
-
|
366
397
|
def inspect
|
367
|
-
return "\#<%p:%
|
398
|
+
return "\#<%p:%p>" % [self.class, self.id]
|
368
399
|
end
|
369
400
|
|
370
401
|
end
|