mjai 0.0.2 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|