diff --git a/transmau_ws/MaujongPlugin/Akagi_1.0.dll b/transmau_ws/MaujongPlugin/Akagi_1.0.dll new file mode 100644 index 0000000..2ecc223 Binary files /dev/null and b/transmau_ws/MaujongPlugin/Akagi_1.0.dll differ diff --git a/transmau_ws/MaujongPlugin/akagi_1.0.zip b/transmau_ws/MaujongPlugin/akagi_1.0.zip new file mode 100644 index 0000000..9743935 Binary files /dev/null and b/transmau_ws/MaujongPlugin/akagi_1.0.zip differ diff --git a/transmau_ws/MaujongPlugin/akagi_1.0/Akagi_1.0.dll b/transmau_ws/MaujongPlugin/akagi_1.0/Akagi_1.0.dll new file mode 100644 index 0000000..2ecc223 Binary files /dev/null and b/transmau_ws/MaujongPlugin/akagi_1.0/Akagi_1.0.dll differ diff --git a/transmau_ws/MaujongPlugin/akagi_1.0/akagi.txt b/transmau_ws/MaujongPlugin/akagi_1.0/akagi.txt new file mode 100644 index 0000000..21dd744 --- /dev/null +++ b/transmau_ws/MaujongPlugin/akagi_1.0/akagi.txt @@ -0,0 +1,58 @@ +2002/9/3 +            まうじゃん用対戦相手プラグイン + +                 「アカギ」ver. 1.00 + +                              製作: セコイア +                      E-mail : sequoia_tm@hotmail.com + +○内容 + これはフリーソフトの簡易麻雀ゲーム「まうじゃん」及び「ねっと_まうじゃん」で +サポートされている対戦相手プラグイン仕様のプラグインソフトです。これを使うと、 +「まうじゃん」で対戦できる相手として、「アカギ」が追加されます。 + +○作者からのコメント + 吹き出しに凝ってみました。アカギが色々ぼやいてきます。うまい打ち方をすると + 誉めてくれますが、まずい打ち方をすると(しなくても(^^;)嫌味なことを言って + きます。初心者の方には感じの悪い打ち手に思えるかもしれませんが、嫌味を言わ + れないように打っていけば自然と上達すると思います。(ホントかよ) + ver0.50 2001/9/25 + リリース + ver1.00 2002/9/3 + ・鳴きができるようになった。(完先ルールではあまり鳴きません) + ・吹き出しの追加・変更(総数100個以上)。 + ・シャンテン崩し、聴牌とらずができるようになった。 + ・振り込みにくくなった。 + 基本的にデフォルトルール対応なので赤牌・割れ目などは考慮していません。 + 順位を意識した打ち方ができるようになれば、またバージョンアップする予定です。 + 感想やバグ報告などは、こちらまで→ sequoia_tm@hotmail.com + +○使用方法 + 書庫ファイルを解凍したら、出てきた Akagi_1.0.dll というファイルを「まうじゃ +ん」がインストールされているディレクトリに置いてください。これだけでOKです。 + +○解凍してもファイルが表示されない場合 + Windowsのデフォルトの設定では、DLLは表示されないようになっています。この設 +定は、以下のようにして変更することが出来ます。 + +  ☆InternetExplorerと統合している場合 +  ・[マイ コンピュータ]を開いてください。 +  ・ウィンドウ上部のメニューから、[表示]-[フォルダ オプション]を選択してく +   ださい。 +  ・[表示]タブを選んでください。 +  ・「ファイルの表示」を「すべてのファイルを表示する」にして、[OK]をクリッ +   クしてください。 + +  ☆InternetExplorerと統合していない場合 +  ・[マイ コンピュータ]を開いてください。 +  ・ウィンドウ上部のメニューから、[表示]-[オプション]を選択してください。 +  ・[表示]タブを選んでください。 +  ・「すべてのファイルを表示」をチェックして、[OK]をクリックしてください。 + +○著作権等 + 本ソフトウェアはフリーウェアです。 + 本ソフトウェアの著作権は作者が所有しています。 + 配布は自由ですが転載を行う場合は事前にメールでご連絡ください。 + このソフトウェアを使用した事によって生じたいかなる障害、損害に対して + 作者は一切の責任を負わないものとします。 + できれば感想をメールで下さい。 \ No newline at end of file diff --git a/transmau_ws/bit_operation.rb b/transmau_ws/bit_operation.rb new file mode 100644 index 0000000..e4221d9 --- /dev/null +++ b/transmau_ws/bit_operation.rb @@ -0,0 +1,18 @@ +module BitOperation + LOWORD_MASK = 0x0000_ffff + HIWORD_MASK = 0xffff_0000 + WORD_BITS = 16 + + def loword(bit) + bit & LOWORD_MASK + end + + def hiword(bit) + (bit & HIWORD_MASK) >> WORD_BITS + end + + def make_lparam(hiword, loword) + (LOWORD_MASK | HIWORD_MASK) & ((hiword << WORD_BITS) | (loword & LOWORD_MASK)) + end +end + diff --git a/transmau_ws/mipiface.rb b/transmau_ws/mipiface.rb new file mode 100644 index 0000000..a5547c3 --- /dev/null +++ b/transmau_ws/mipiface.rb @@ -0,0 +1,164 @@ +require 'mjai/pai.rb' + +module TransMaujong + module MJPI + INITIALIZE = 1 + SUTEHAI = 2 + ONACTION = 3 + STARTGAME = 4 + STARTKYOKU = 5 + ENDKYOKU = 6 + ENDGAME = 7 + DESTROY = 8 + YOURNAME = 9 + CREATEINSTANCE = 10 + BASHOGIME = 11 + ISEXCHANGEABLE = 12 + ONEXCHANGE = 13 + end + + module MJMI + GETTEHAI = 1 + GETKAWA = 2 + GETDORA = 3 + GETSCORE = 4 + GETHONBA = 5 + GETREACHBOU = 6 + GETRULE = 7 + GETVERSION = 8 + GETMACHI = 9 + GETAGARITEN = 10 + GETHAIREMAIN = 11 + GETVISIBLEHAIS = 12 + FUKIDASHI = 13 + KKHAIABILITY = 14 + GETWAREME = 15 + SETSTRUCTTYPE = 16 + SETAUTOFUKIDASHI = 17 + LASTTSUMOGIRI = 18 + SSPUTOABILITY = 19 + GETYAKUHAN = 20 + GETKYOKU = 21 + GETKAWAEX = 22 + ANKANABILITY = 23 + end + + module MJPIR + NO_AKA5 = 0x0000_0001 + HAI_MASK = 0x0000_00ff + NAKI_MASK = 0xffff_ff00 + SUTEHAI = 0x0000_0100 + REACH = 0x0000_0200 + KAN = 0x0000_0400 + TSUMO = 0x0000_0800 + NAGASHI = 0x0000_1000 + PON = 0x0000_2000 + CHII1 = 0x0000_4000 + CHII2 = 0x0000_8000 + CHII3 = 0x0001_0000 + MINKAN = 0x0002_0000 + ANKAN = 0x0004_0000 + RON = 0x0008_0000 + ERROR = 0x8000_0000 + end + + module MJRL + KUITAN = 1 + KANSAKI = 2 + PAO = 3 + RON = 4 + MOCHITEN = 5 + BUTTOBI = 6 + WAREME = 7 + AKA5 = 8 + SHANYU = 9 + SHANYU_SCORE = 10 + KUINAOSHI = 11 + AKA5S = 12 + URADORA = 13 + SCORE0REACH = 14 + RYANSHIBA = 15 + DORAPLUS = 16 + FURITEN_REACH = 17 + NANNYU = 18 + NANNYU_SCORE = 19 + KARATEN = 20 + PINZUMO = 21 + NOTENOYANAGARE = 22 + KANINREACH = 23 + TOPOYAAGARIEND = 24 + KIRIAGE_MANGAN = 25 + DBLRONCHONBO = 26 + + end + + module MJR + NOTCARED = 0xffff_ffff + end + + module MJEK + AGARI = 1 + RYUKYOKU = 2 + CHONBO = 3 + end + + module MJST + INKYOKU = 1 + BASHOGIME = 2 + end + + module MJKS + REACH = 1 + NAKI = 2 + end + + class Mjai::Pai + @@offset_map = {"m" => 0, "p" => 9 , "s" => 18, "t" => 27} + + # Mjai::Pai -> Pai number + def to_mau_i + # Maujong defines pai's id below + # 1m, ..., 9m, 1p, ..., 9p, 1s, ..., 9s, E, S, W, N, P, F, C + # 0, ..., 8, 9, ..., 17, 18, ..., 26, 27, 28, 29, 30, 31, 32, 33 + @number + @@offset_map[@type] - 1 + end + + def to_mau_i_r + red_offset = (@red) ? 64 : 0 + + @number + @@offset_map[@type] - 1 + red_offset + end + + # Pai number -> Mjai::Pai + def self.from_mau_i(pai_number) + red = false + type = nil + + # number of red hais is added by 64 + if [68, 77, 86].include?(pai_number) then + red = true + pai_number = pai_number - 64 + end + + case pai_number + when 0..8 + pai_number = pai_number - 0 + 1 + type = "m" + when 9..17 + pai_number = pai_number - 9 + 1 + type = "p" + when 18..26 + pai_number = pai_number - 18 + 1 + type = "s" + when 27..33 + pai_number = pai_number - 27 + 1 + type = "t" + else + raise(ArgumentError, "wrong pai number: #{pai_number}") + end + + Mjai::Pai.new(type, pai_number, red) + end + end + +end diff --git a/transmau_ws/mjai/action.rb b/transmau_ws/mjai/action.rb new file mode 100644 index 0000000..b970846 --- /dev/null +++ b/transmau_ws/mjai/action.rb @@ -0,0 +1,48 @@ +require "mjai/jsonizable" + + +module Mjai + + class Action < JSONizable + + define_fields([ + [:type, :symbol], + [:reason, :symbol], + [:gametype, :symbol], + [:actor, :player], + [:target, :player], + [:pao, :player], + [:pai, :pai], + [:consumed, :pais], + [:pais, :pais], + [:tsumogiri, :boolean], + [:possible_actions, :actions], + [:cannot_dahai, :pais], + [:id, :number], + [:bakaze, :pai], + [:kyoku, :number], + [:honba, :number], + [:kyotaku, :number], + [:oya, :player], + [:dora_marker, :pai], + [:uradora_markers, :pais], + [:tehais, :pais_list], + [:uri, :string], + [:names, :strings], + [:hora_tehais, :pais], + [:yakus, :yakus], + [:fu, :number], + [:fan, :number], + [:hora_points, :number], + [:tenpais, :booleans], + [:deltas, :numbers], + [:scores, :numbers], + [:text, :string], + [:message, :string], + [:log, :string_or_null], + [:logs, :strings_or_nulls], + ]) + + end + +end diff --git a/transmau_ws/mjai/active_game.rb b/transmau_ws/mjai/active_game.rb new file mode 100644 index 0000000..4e52640 --- /dev/null +++ b/transmau_ws/mjai/active_game.rb @@ -0,0 +1,410 @@ +require "mjai/game" +require "mjai/action" +require "mjai/hora" +require "mjai/validation_error" + + +module Mjai + + class ActiveGame < Game + + ACTION_PREFERENCES = { + :hora => 4, + :ryukyoku => 3, + :pon => 2, + :daiminkan => 2, + :chi => 1, + } + + def initialize(players) + super(players.shuffle()) + @game_type = :one_kyoku + end + + attr_accessor(:game_type) + + def play() + if ![:one_kyoku, :tonpu, :tonnan].include?(@game_type) + raise("Unknown game_type") + end + begin + do_action({:type => :start_game, :names => self.players.map(){ |pl| pl.name }, :gametype => @game_type}) + @ag_oya = @ag_chicha = @players[0] + @ag_bakaze = Pai.new("E") + @ag_honba = 0 + @ag_kyotaku = 0 + while !self.game_finished? + play_kyoku() + end + + fin_score = get_final_scores() + do_action({:type => :end_game, :scores => fin_score}) + return fin_score + rescue GameFailError + do_action({:type => :error, :message => "Player" + $!.player.to_s + "'s illegal response: " + $!.message + " - Original Action: " + $!.orig_action.to_s + ", Player's Response: " + $!.response.to_s}) + raise $! + end + end + + def play_kyoku() + catch(:end_kyoku) do + @pipais = @all_pais.shuffle() + @pipais.shuffle!() + + cheat_player = @players.select{|p| p.name.include?("cheat") } + if cheat_player.count == 1 # && false + cheattehais = Pai.parse_pais("468m5pr2458sPPCCC") + cheattehais.each { |p| + pai_index = @pipais.index(p) + @pipais.delete_at(pai_index) + } + insert_index = @pipais.size - 14 - 1 - 13 * cheat_player[0].id + @pipais.insert(insert_index, cheattehais).flatten! + end + + @wanpais = @pipais.pop(14) + dora_marker = @wanpais.pop() + tehais = Array.new(4){ @pipais.pop(13).sort() } + do_action({ + :type => :start_kyoku, + :bakaze => @ag_bakaze, + :kyoku => (4 + @ag_oya.id - @ag_chicha.id) % 4 + 1, + :honba => @ag_honba, + :kyotaku => @ag_kyotaku, + :oya => @ag_oya, + :dora_marker => dora_marker, + :tehais => tehais, + }) + @actor = self.oya + while !@pipais.empty? + mota() + @actor = @players[(@actor.id + 1) % 4] + end + process_fanpai() + end + do_action({:type => :end_kyoku}) + end + + # 鞫ク謇 + def mota() + reach_pending = false + kandora_pending = false + tsumo_actor = @actor + actions = [Action.new({:type => :tsumo, :actor => @actor, :pai => @pipais.pop()})] + while !actions.empty? + if actions[0].type == :hora + if actions.size >= 3 + process_ryukyoku(:sanchaho, actions.map(){ |a| a.actor }) + else + process_hora(actions) + end + throw(:end_kyoku) + elsif actions[0].type == :ryukyoku + raise("should not happen") if actions.size != 1 + process_ryukyoku(:kyushukyuhai, [actions[0].actor]) + throw(:end_kyoku) + else + raise("should not happen") if actions.size != 1 + action = actions[0] + responses = do_action(action) + next_actions = nil + next_actions ||= choose_actions(responses) + case action.type + when :daiminkan, :kakan, :ankan + if action.type == :ankan + add_dora() + end + # Actually takes one from wanpai and moves one pai from pipai to wanpai, + # but it's equivalent to taking from pipai. + if next_actions.empty? + next_actions = + [Action.new({:type => :tsumo, :actor => action.actor, :pai => @pipais.pop()})] + else + raise("should not happen") if next_actions[0].type != :hora + end + # TODO Handle 4 kans. + when :reach + reach_pending = true + end + if reach_pending && + (next_actions.empty? || ![:dahai, :hora].include?(next_actions[0].type)) + @ag_kyotaku += 1 + deltas = [0, 0, 0, 0] + deltas[tsumo_actor.id] = -1000 + do_action({ + :type => :reach_accepted, + :actor => tsumo_actor, + :deltas => deltas, + :scores => get_scores(deltas), + }) + reach_pending = false + end + if kandora_pending && + !next_actions.empty? && [:dahai, :tsumo].include?(next_actions[0].type) + add_dora() + kandora_pending = false + end + if [:daiminkan, :kakan].include?(action.type) && ![:hora].include?(next_actions[0].type) + kandora_pending = true + end + if action.type == :dahai && (next_actions.empty? || next_actions[0].type != :hora) + check_ryukyoku() + end + actions = next_actions + end + end + end + + def check_ryukyoku() + if players.all?(){ |pl| pl.reach? } + process_ryukyoku(:suchareach) + throw(:end_kyoku) + end + if first_turn? && !players[0].sutehais.empty? && players[0].sutehais[0].fonpai? && + players.all?(){ |pl| pl.sutehais == [players[0].sutehais[0]] } + process_ryukyoku(:sufonrenta) + throw(:end_kyoku) + end + kan_counts = players.map(){ |pl| pl.furos.count(){ |f| f.kan? } } + if kan_counts.inject(0){ |total, n| total + n } == 4 && !kan_counts.include?(4) + process_ryukyoku(:sukaikan) + throw(:end_kyoku) + end + end + + def update_state(action) + super(action) + if action.type == :tsumo && @pipais.size != self.num_pipais + raise("num pipais mismatch: %p != %p" % [@pipais.size, self.num_pipais]) + end + end + + def choose_actions(actions) + actions = actions.select(){ |a| a } + max_pref = actions.map(){ |a| ACTION_PREFERENCES[a.type] || 0 }.max + max_actions = actions.select(){ |a| (ACTION_PREFERENCES[a.type] || 0) == max_pref } + return max_actions + end + + def process_hora(actions) + tsumibo = self.honba + ura = nil + for action in actions.sort_by(){ |a| distance(a.actor, a.target) } + if action.actor.reach? && !ura + ura = @wanpais.pop(self.dora_markers.size) + end + uradora_markers = action.actor.reach? ? ura : [] + hora = get_hora(action, { + :uradora_markers => uradora_markers, + :previous_action => self.previous_action, + }) + raise("no yaku") if !hora.valid? + deltas = [0, 0, 0, 0] + deltas[action.actor.id] += hora.points + tsumibo * 300 + @ag_kyotaku * 1000 + + pao_id = action.actor.pao_for_id + if hora.hora_type == :tsumo + if pao_id != nil + deltas[pao_id] -= (hora.points + tsumibo * 300) + else + for player in self.players + next if player == action.actor + deltas[player.id] -= + ((player == self.oya ? hora.oya_payment : hora.ko_payment) + tsumibo * 100) + end + end + else + if pao_id == action.target.id + pao_id = nil + end + if pao_id != nil + deltas[pao_id] -= (hora.points/2 + tsumibo * 300) + deltas[action.target.id] -= (hora.points/2) + else + deltas[action.target.id] -= (hora.points + tsumibo * 300) + end + end + do_action({ + :type => action.type, + :actor => action.actor, + :target => action.target, + :pai => action.pai, + :hora_tehais => hora.tehais, + :uradora_markers => uradora_markers, + :yakus => hora.yakus, + :fu => hora.fu, + :fan => hora.fan, + :hora_points => hora.points, + :deltas => deltas, + :scores => get_scores(deltas), + }.merge( pao_id!=nil ? {:pao=> self.players[pao_id]} : {} ) ) + # Only kamicha takes them in case of daburon. + tsumibo = 0 + @ag_kyotaku = 0 + end + update_oya(actions.any?(){ |a| a.actor == self.oya }, false) + end + + def process_ryukyoku(reason, actors=[]) + actor = (reason == :kyushukyuhai) ? actors[0] : nil + tenpais = [] + tehais = [] + for player in players + if reason == :suchareach || actors.include?(player) # :sanchaho, :kyushukyuhai + tenpais.push(reason != :kyushukyuhai) + tehais.push(player.tehais) + else + tenpais.push(false) + tehais.push([Pai::UNKNOWN] * player.tehais.size) + end + end + do_action({ + :type => :ryukyoku, + :actor => actor, + :reason => reason, + :tenpais => tenpais, + :tehais => tehais, + :deltas => [0, 0, 0, 0], + :scores => players.map(){ |player| player.score } + }) + update_oya(true, reason) + end + + def process_fanpai() + tenpais = [] + tehais = [] + + is_nagashi = false + nagashi_deltas = [0,0,0,0] + + for player in players + #豬√@貅雋ォ縺ョ蛻、螳 + if player.sutehais.size == player.ho.size && #魑エ縺九l縺ヲ縺翫i縺 + player.sutehais.all?{ |p| p.yaochu? } + is_nagashi = true + if player == self.oya + nagashi_deltas = nagashi_deltas.map{|i| i - 4000} + nagashi_deltas[player.id] += (4000 + 12000) + else + nagashi_deltas = nagashi_deltas.map{|i| i - 2000} + nagashi_deltas[player.id] += (2000 + 8000) + nagashi_deltas[self.oya.id] -= 2000 + end + end + + if player.tenpai? + tenpais.push(true) + tehais.push(player.tehais) + else + tenpais.push(false) + tehais.push([Pai::UNKNOWN] * player.tehais.size) + end + end + tenpai_ids = (0...4).select(){ |i| tenpais[i] } + noten_ids = (0...4).select(){ |i| !tenpais[i] } + + if is_nagashi + deltas = nagashi_deltas + else + deltas = [0, 0, 0, 0] + if (1..3).include?(tenpai_ids.size) + for id in tenpai_ids + deltas[id] += 3000 / tenpai_ids.size + end + for id in noten_ids + deltas[id] -= 3000 / noten_ids.size + end + end + end + + reason = is_nagashi ? :nagashimangan : :fanpai + do_action({ + :type => :ryukyoku, + :reason => reason, + :tenpais => tenpais, + :tehais => tehais, + :deltas => deltas, + :scores => get_scores(deltas), + }) + update_oya(tenpais[self.oya.id], reason) + end + + def update_oya(renchan, ryukyoku_reason) + if renchan + @ag_oya = self.oya + else + @ag_oya = @players[(self.oya.id + 1) % 4] + @ag_bakaze = @ag_bakaze.succ if @ag_oya == @players[0] + end + if renchan || ryukyoku_reason + @ag_honba += 1 + else + @ag_honba = 0 + end + case @game_type + when :tonpu + @last = decide_last(Pai.new("E"), renchan, ryukyoku_reason) + when :tonnan + @last = decide_last(Pai.new("S"), renchan, ryukyoku_reason) + end + end + + def decide_last(last_bakaze, renchan, ryukyoku_reason) + if @players.any? { |pl| pl.score < 0 } + return true + end + + if @ag_bakaze == last_bakaze.succ.succ + return true + end + + if ryukyoku_reason && ![:fanpai, :nagashimangan].include?(ryukyoku_reason) + return false + end + + if renchan + if (@ag_bakaze == last_bakaze.succ) || (@ag_bakaze == last_bakaze && @ag_oya == @players[3]) #繧ェ繝シ繝ゥ繧ケ + return @ag_oya.score >= 30000 && + (0...4).all? { |i| @ag_oya.id == i || @ag_oya.score > @players[i].score } + end + else + if @ag_bakaze == last_bakaze.succ #繧ェ繝シ繝ゥ繧ケ + return @players.any? { |pl| pl.score >= 30000 } + end + end + + return false + end + + def add_dora() + dora_marker = @wanpais.pop() + do_action({:type => :dora, :dora_marker => dora_marker}) + end + + def game_finished? + if @last + return true + else + @last = true if @game_type == :one_kyoku + return false + end + end + + def get_final_scores() + # The winner takes remaining kyotaku. + deltas = [0, 0, 0, 0] + deltas[self.ranked_players[0].id] = @ag_kyotaku * 1000 + return get_scores(deltas) + end + + def expect_response_from?(player) + return true + end + + def get_scores(deltas) + return (0...4).map(){ |i| self.players[i].score + deltas[i] } + end + + end + +end diff --git a/transmau_ws/mjai/archive.rb b/transmau_ws/mjai/archive.rb new file mode 100644 index 0000000..244d1aa --- /dev/null +++ b/transmau_ws/mjai/archive.rb @@ -0,0 +1,56 @@ +require "mjai/game" + + +module Mjai + + autoload(:TenhouArchive, "mjai/tenhou_archive") + autoload(:MjsonArchive, "mjai/mjson_archive") + + class Archive < Game + + def self.load(path) + case File.extname(path) + when ".mjlog" + return TenhouArchive.new(path, :gzip) + when ".xml" + return TenhouArchive.new(path, :xml) + when ".mjson" + return MjsonArchive.new(path) + else + raise("unknown format " + File.extname(path)) + end + end + + def initialize() + super((0...4).map(){ |i| PuppetPlayer.new(i) }) + @actions = nil + end + + def trimnewdora!() + @actions = self.actions.select! { |a| a.type != :dora } + end + + def each_action(&block) + if block + on_action(&block) + play() + else + return enum_for(:each_action) + end + end + + def actions + return @actions ||= self.each_action.to_a() + end + + def expect_response_from?(player) + return false + end + + def inspect + return '#<%p:path=%p>' % [self.class, self.path] + end + + end + +end diff --git a/transmau_ws/mjai/archive_player.rb b/transmau_ws/mjai/archive_player.rb new file mode 100644 index 0000000..10a277f --- /dev/null +++ b/transmau_ws/mjai/archive_player.rb @@ -0,0 +1,113 @@ +require "mjai/player" +require "mjai/archive" + + +module Mjai + + class ArchivePlayer < Player + + def initialize(archive_path) + super() + @archive = Archive.load(archive_path) + @archive.trimnewdora! + @action_index = 0 + @id = nil + end + + def update_state(action) + super(action) + expected_action = @archive.actions[@action_index] + if [:dora].include?(action.type) + # 繝√ぉ繝繧ッ繧ゅき繧ヲ繝ウ繧ソ騾イ繧√b縺励↑縺 + return + end + + if (action.type == expected_action.type) + if action.type == :start_game + @id = action.id + expected_action = expected_action.merge({:id => @id}) + elsif action.type == :hora + [:uradora_markers, :hora_tehais, :yakus].map{|x| + action.public_send(x).sort! + expected_action.public_send(x).sort! + } + + if expected_action.fan >= 100 #蠖ケ貅縺ョ縺ィ縺阪ョ謨ー蛟、繧帝←蠖薙↓縺ゅo縺帙k + expected_action = expected_action.merge({:fan => expected_action.yakus.size * 100}) + + if expected_action.yakus.include?( [:kokushimuso, 100] ) + expected_action = expected_action.merge({:fu => 0}) + end + end + elsif action.type == :ryukyoku + 4.times{|i| + action.tehais[i].sort! + expected_action.tehais[i].sort! + } + end + end + + if (action.type != expected_action.type) || + ( action.actor && action.actor.id == @id && (action.to_json() != expected_action.to_json()) ) || + ( (action.to_json() != expected_action.to_json()) && ![:start_game, :start_kyoku, :tsumo].include?(action.type) ) + raise(( + "live action doesn't match one in archive\n" + + "actual: %s\n" + + "expected: %s\n") % + [action, expected_action]) + end + @action_index += 1 + end + + def respond_to_action(action) + if [:dora, :hora, :reach_accepted].include?(action.type) then + return nil + end + if action.actor && action.actor.id == @id && action.type == :kakan then + # 閾ェ蛻縺ョ蜉讒薙↓讒肴ァ薙☆繧九%縺ィ縺ッ縺ェ縺 + return nil + end + + next_action = @archive.actions[@action_index] + nextnext_action = @archive.actions[@action_index+1] + + + # 繝繝悶Ο繝ウ + if next_action && next_action.type == :hora && + nextnext_action && nextnext_action.type == :hora && + nextnext_action.actor.id == @id + return Action.from_json(nextnext_action.to_json(), self.game) + end + + # 繝ェ繝シ繝∝ョ」險迚後r魑エ縺 + if next_action && next_action.type == :reach_accepted + next_action = nextnext_action + end + + if next_action && + next_action.type == :ryukyoku && next_action.reason == :sanchaho && + action.actor.id != @id + # 荳牙ョカ蜥後ョ縺ィ縺阪ッ荳我ココ縺ョ繝ュ繝ウ逋コ螢ー繧偵お繝溘Η繝ャ繝シ繝医☆繧 + return Action.new({:type => :hora, :actor => self, :target => action.actor, :pai => action.pai}) + end + + # 豬∝ア縺ョ縺縺。縲√励Ξ繧、繝、縺九i縺ョ逋コ螢ー縺ッ荵晉ィョ荵晉煙縺ョ縺ソ + if next_action && next_action.type == :ryukyoku && next_action.reason == :kyushukyuhai && + action.actor.id == @id + return Action.new({:type => :ryukyoku, :actor => self, :reason => :kyushukyuhai}) + end + + if next_action && + next_action.actor && + next_action.actor.id == @id && + [:dahai, :chi, :pon, :daiminkan, :kakan, :ankan, :reach, :hora].include?( + next_action.type) + return Action.from_json(next_action.to_json(), self.game) + else + return nil + end + end + + end + +end diff --git a/transmau_ws/mjai/confidence_interval.rb b/transmau_ws/mjai/confidence_interval.rb new file mode 100644 index 0000000..1598845 --- /dev/null +++ b/transmau_ws/mjai/confidence_interval.rb @@ -0,0 +1,37 @@ +module Mjai + + module ConfidenceInterval + + module_function + + # Uses bootstrap resampling. + def calculate(samples, params = {}) + params = {:min => 0.0, :max => 1.0, :conf_level => 0.95}.merge(params) + num_tries = 1000 + averages = [] + num_tries.times() do + sum = 0.0 + (samples.size + 2).times() do + idx = rand(samples.size + 2) + case idx + when samples.size + sum += params[:min] + when samples.size + 1 + sum += params[:max] + else + sum += samples[idx] + end + end + averages.push(sum / (samples.size + 2)) + end + averages.sort!() + margin = (1.0 - params[:conf_level]) / 2 + return [ + averages[(num_tries * margin).to_i()], + averages[(num_tries * (1.0 - margin)).to_i()], + ] + end + + end + +end diff --git a/transmau_ws/mjai/context.rb b/transmau_ws/mjai/context.rb new file mode 100644 index 0000000..212d799 --- /dev/null +++ b/transmau_ws/mjai/context.rb @@ -0,0 +1,34 @@ +require "mjai/with_fields" + + +module Mjai + + # Context of the game which affects hora yaku and points. + class Context + + extend(WithFields) + + define_fields([ + :oya, :bakaze, :jikaze, :doras, :uradoras, + :reach, :double_reach, :ippatsu, + :rinshan, :haitei, :first_turn, :chankan, + ]) + + def initialize(fields) + @fields = fields + end + + def fanpai_fan(pai) + if pai.sangenpai? + return 1 + else + fan = 0 + fan += 1 if pai == self.bakaze + fan += 1 if pai == self.jikaze + return fan + end + end + + end + +end diff --git a/transmau_ws/mjai/file_converter.rb b/transmau_ws/mjai/file_converter.rb new file mode 100644 index 0000000..7bed213 --- /dev/null +++ b/transmau_ws/mjai/file_converter.rb @@ -0,0 +1,95 @@ +require "erb" +require "fileutils" + +require "mjai/archive" + + +module Mjai + + class FileConverter + + include(ERB::Util) + + def convert(src_path, dest_path) + src_ext = File.extname(src_path) + dest_ext = File.extname(dest_path) + case [src_ext, dest_ext] + when [".mjson", ".html"] + mjson_to_html(src_path, dest_path) + when [".mjlog", ".xml"] + archive = Archive.load(src_path) + open(dest_path, "w"){ |f| f.write(archive.xml) } + when [".mjson", ".human"], [".mjlog", ".human"] + dump_archive(src_path, dest_path, :human) + when [".mjlog", ".mjson"] + dump_archive(src_path, dest_path, :mjson) + when [".xml", ".mjson"] + dump_archive(src_path, dest_path, :mjson) + else + raise("unsupported ext pair: #{src_ext}, #{dest_ext}") + end + end + + def dump_archive(archive_path, output_path, output_format) + archive = Archive.load(archive_path) + open(output_path, "w") do |f| + archive.on_action() do |action| + if output_format == :human + archive.dump_action(action, f) + else + f.puts(action.to_json()) + end + end + archive.play() + end + end + + def mjson_to_html(mjson_path, html_path) + + res_dir = File.dirname(__FILE__) + "/../../share/html" + + +=begin + make("#{res_dir}/js/archive_player.coffee", + "#{res_dir}/js/archive_player.js", + "coffee -cb #{res_dir}/js/archive_player.coffee") + make("#{res_dir}/js/dytem.coffee", + "#{res_dir}/js/dytem.js", + "coffee -cb #{res_dir}/js/dytem.coffee") + make("#{res_dir}/css/style.scss", + "#{res_dir}/css/style.css", + "sass #{res_dir}/css/style.scss #{res_dir}/css/style.css") +=end + + # Variants used in template. + action_jsons = File.readlines(mjson_path).map(){ |s| s.chomp().gsub(/\//){ "\\/" } } + actions_json = "[%s]" % action_jsons.join(",\n") + base_name = File.basename(html_path) + + html = ERB.new(File.read("#{res_dir}/views/archive_player.erb"), nil, "<>"). + result(binding) + open(html_path, "w"){ |f| f.write(html) } + +=begin + for src_path in Dir["#{res_dir}/css/*.css"] + Dir["#{res_dir}/js/*.js"] + exp = Regexp.new("^%s\\/" % Regexp.escape(res_dir)) + dest_path = src_path.gsub(exp){ "#{html_path}.files/" } + FileUtils.mkdir_p(File.dirname(dest_path)) + FileUtils.cp(src_path, dest_path) + end +=end + + end + + def make(src_path, dest_path, command) + if !File.exist?(dest_path) || File.mtime(src_path) > File.mtime(dest_path) + puts(command) + if !system(command) + exit(1) + end + end + end + + end + +end diff --git a/transmau_ws/mjai/furo.rb b/transmau_ws/mjai/furo.rb new file mode 100644 index 0000000..2ddb784 --- /dev/null +++ b/transmau_ws/mjai/furo.rb @@ -0,0 +1,61 @@ +require "mjai/with_fields" +require "mjai/mentsu" + + +module Mjai + + # 蜑ッ髴イ + class Furo + + extend(WithFields) + + # type: :chi, :pon, :daiminkan, :kakan, :ankan + define_fields([:type, :taken, :consumed, :target]) + + FURO_TYPE_TO_MENTSU_TYPE = { + :chi => :shuntsu, + :pon => :kotsu, + :daiminkan => :kantsu, + :kakan => :kantsu, + :ankan => :kantsu, + } + + def initialize(fields) + @fields = fields + end + + def kan? + return FURO_TYPE_TO_MENTSU_TYPE[self.type] == :kantsu + end + + def pais + return (self.taken ? [self.taken] : []) + self.consumed + end + + def to_mentsu() + return Mentsu.new({ + :type => FURO_TYPE_TO_MENTSU_TYPE[self.type], + :pais => self.pais, + :visibility => self.type == :ankan ? :an : :min, + }) + end + + def to_s() + if self.type == :ankan + return '[# %s %s #]' % self.consumed[0, 2] + else + return "[%s(%p)/%s]" % [ + self.taken, + self.target && self.target.id, + self.consumed.join(" "), + ] + end + end + + def inspect + return "\#<%p %s>" % [self.class, to_s()] + end + + end + +end diff --git a/transmau_ws/mjai/game.rb b/transmau_ws/mjai/game.rb new file mode 100644 index 0000000..6cec68d --- /dev/null +++ b/transmau_ws/mjai/game.rb @@ -0,0 +1,448 @@ +# coding: utf-8 +require "mjai/action" +require "mjai/pai" +require "mjai/furo" +require "mjai/hora" +require "mjai/validation_error" + + +module Mjai + + class Game + + def initialize(players = nil) + self.players = players if players + @bakaze = nil + @kyoku_num = nil + @honba = nil + @chicha = nil + @oya = nil + @dora_markers = nil + @current_action = nil + @previous_action = nil + @num_pipais = nil + @num_initial_pipais = nil + @first_turn = false + end + + attr_reader(:players) + attr_reader(:all_pais) + attr_reader(:bakaze) + attr_reader(:oya) + attr_reader(:honba) + attr_reader(:dora_markers) # 繝峨Λ陦ィ遉コ迚 + attr_reader(:current_action) + attr_reader(:previous_action) + attr_reader(:all_pais) + attr_reader(:num_pipais) + attr_accessor(:last) # kari + + def players=(players) + @players = players + for player in @players + player.game = self + end + end + + def on_action(&block) + @on_action = block + end + + def on_responses(&block) + @on_responses = block + end + + # Executes the action and returns responses for it from players. + def do_action(action) + + if action.is_a?(Hash) + action = Action.new(action) + end + + update_state(action) + + @on_action.call(action) if @on_action + + responses = (0...4).map() do |i| + @players[i].respond_to_action(action_in_view(action, i, true)) + end + + action_with_logs = action.merge({:logs => responses.map(){ |r| r && r.log }}) + responses_with_log = responses.map() do |r| + if (!r) then + nil + elsif ( defined? r.log ) then + r + else + r.merge({:log => nil}) + end + end + @on_responses.call(action_with_logs, responses_with_log) if @on_responses + + responses = responses.map() do |r| + if (!r || r.type == :none ) then + nil + else + r + end + end + + @previous_action = action + validate_responses(responses, action) + return responses + + end + + # Updates internal state of Game and Player objects by the action. + def update_state(action) + + @current_action = action + @actor = action.actor if action.actor + + case action.type + when :start_game + # TODO change this by red config + pais = (0...4).map() do |i| + ["m", "p", "s"].map(){ |t| (1..9).map(){ |n| Pai.new(t, n, n == 5 && i == 0) } } + + (1..7).map(){ |n| Pai.new("t", n) } + end + @all_pais = pais.flatten().sort() + when :start_kyoku + @bakaze = action.bakaze + @kyoku_num = action.kyoku + @honba = action.honba + @oya = action.oya + @chicha ||= @oya + @dora_markers = [action.dora_marker] + @num_pipais = @num_initial_pipais = @all_pais.size - 13 * 4 - 14 + @first_turn = true + when :tsumo + @num_pipais -= 1 + if @num_initial_pipais - @num_pipais > 4 + @first_turn = false + end + when :chi, :pon, :daiminkan, :kakan, :ankan + @first_turn = false + when :dora + @dora_markers.push(action.dora_marker) + end + + for i in 0...4 + @players[i].update_state(action_in_view(action, i, false)) + end + + end + + def action_in_view(action, player_id, for_response) + player = @players[player_id] + with_response_hint = for_response && expect_response_from?(player) + case action.type + when :start_game + return action.merge({:id => player_id}) + when :start_kyoku + tehais_list = action.tehais.dup() + for i in 0...4 + if i != player_id + tehais_list[i] = [Pai::UNKNOWN] * tehais_list[i].size + end + end + return action.merge({:tehais => tehais_list}) + when :tsumo + if action.actor == player + return action.merge({ + :possible_actions => + with_response_hint ? player.possible_actions : nil, + }) + else + return action.merge({:pai => Pai::UNKNOWN}) + end + when :dahai, :kakan + if action.actor != player + return action.merge({ + :possible_actions => + with_response_hint ? player.possible_actions : nil, + }) + else + return action + end + when :chi, :pon + if action.actor == player + return action.merge({ + :cannot_dahai => + with_response_hint ? player.kuikae_dahais : nil, + }) + else + return action + end + when :reach + if action.actor == player + return action.merge({ + :cannot_dahai => + with_response_hint ? (player.tehais.uniq() - player.possible_dahais) : nil, + }) + else + return action + end + else + return action + end + end + + def validate_responses(responses, action) + for i in 0...4 + response = responses[i] + begin + if response && response.actor != @players[i] + raise ValidationError.new("Invalid actor.") + end + validate_response_type(response, @players[i], action) + validate_response_content(response, action) if response + rescue ValidationError + raise GameFailError.new(response.to_s + ": " + $!.message, i, action, response) + end + end + end + + def validate_response_type(orig_response, player, action) + if orig_response && orig_response.type == :none + response = nil + else + response = orig_response + end + + if response && response.type == :error + raise GameFailError.new("(Error Returned) " + response.message.to_s, player.id, action, orig_response) + end + is_actor = player == action.actor + if expect_response_from?(player) + case action.type + when :start_game, :start_kyoku, :end_kyoku, :end_game, :error, + :hora, :ryukyoku, :dora, :reach_accepted + valid = !response + when :tsumo + if is_actor + valid = response && + [:dahai, :reach, :ankan, :kakan, :hora, :ryukyoku].include?(response.type) + else + valid = !response + end + when :dahai + if is_actor + valid = !response + else + valid = !response || [:chi, :pon, :daiminkan, :hora].include?(response.type) + end + when :chi, :pon, :reach + if is_actor + valid = response && response.type == :dahai + else + valid = !response + end + when :ankan, :daiminkan + # Actor should wait for tsumo. + valid = !response + when :kakan + if is_actor + # Actor should wait for tsumo. + valid = !response + else + # hora is for chankan. + valid = !response || response.type == :hora + end + when :log + valid = !response + else + raise(ValidationError, "Unknown action type: '#{action.type}'") + end + else + valid = !response + end + if !valid + raise(ValidationError, + "Unexpected response type '%s' for %s." % [response ? response.type : :none, action]) + end + end + + def validate_response_content(response, action) + + case response.type + + when :dahai + + validate_fields_exist(response, [:pai, :tsumogiri]) + if action.actor.reach? + # possible_dahais check doesn't subsume this check. Consider karagiri + # (with tsumogiri=false) after reach. + validate(response.tsumogiri, "tsumogiri must be true after reach.") + end + validate( + response.actor.possible_dahais.include?(response.pai), + "Cannot dahai this pai. The pai is not in the tehais, " + + "it's kuikae, or it causes noten reach.") + + # Validates that pai and tsumogiri fields are consistent. + if [:tsumo, :reach].include?(action.type) + if response.tsumogiri + tsumo_pai = response.actor.tehais[-1] + validate( + response.pai == tsumo_pai, + "tsumogiri is true but the pai is not tsumo pai: %s != %s" % + [response.pai, tsumo_pai]) + else + validate( + response.actor.tehais[0...-1].include?(response.pai), + "tsumogiri is false but the pai is not in tehais.") + end + else # after furo + validate( + !response.tsumogiri, + "tsumogiri must be false on dahai after furo.") + end + + when :chi, :pon, :daiminkan, :ankan, :kakan + if response.type == :ankan + validate_fields_exist(response, [:consumed]) + elsif response.type == :kakan + validate_fields_exist(response, [:pai, :consumed]) + else + validate_fields_exist(response, [:target, :pai, :consumed]) + validate( + response.target == action.actor, + "target must be %d." % action.actor.id) + end + valid = response.actor.possible_furo_actions.any?() do |a| + a.type == response.type && + a.pai == response.pai && + a.consumed.sort() == response.consumed.sort() + end + validate(valid, "The furo is not allowed.") + + when :reach + validate(response.actor.can_reach?, "Cannot reach.") + + when :hora + validate_fields_exist(response, [:target, :pai]) + validate( + response.target == action.actor, + "target must be %d." % action.actor.id) + if response.target == response.actor + tsumo_pai = response.actor.tehais[-1] + validate( + response.pai == tsumo_pai, + "pai is not tsumo pai: %s != %s" % [response.pai, tsumo_pai]) + else + validate( + response.pai == action.pai, + "pai is not previous dahai: %s != %s" % [response.pai, action.pai]) + end + validate(response.actor.can_hora?, "Cannot hora.") + + when :ryukyoku + validate_fields_exist(response, [:reason]) + validate(response.reason == :kyushukyuhai, "reason must be kyushukyuhai.") + validate(response.actor.can_ryukyoku?, "Cannot ryukyoku.") + + end + + end + + def validate(criterion, message) + raise(ValidationError, message) if !criterion + end + + def validate_fields_exist(response, field_names) + for name in field_names + if !response.fields.has_key?(name) + raise(ValidationError, "%s missing." % name) + end + end + end + + def doras + return @dora_markers ? @dora_markers.map(){ |pai| pai.succ } : nil + end + + def get_hora(action, params = {}) + raise("should not happen") if action.type != :hora + hora_type = action.actor == action.target ? :tsumo : :ron + if hora_type == :tsumo + tehais = action.actor.tehais[0...-1] + else + tehais = action.actor.tehais + end + uradoras = (params[:uradora_markers] || []).map(){ |pai| pai.succ } + return Hora.new({ + :tehais => tehais, + :furos => action.actor.furos, + :taken => action.pai, + :hora_type => hora_type, + :oya => action.actor == self.oya, + :bakaze => self.bakaze, + :jikaze => action.actor.jikaze, + :doras => self.doras, + :uradoras => uradoras, + :reach => action.actor.reach?, + :double_reach => action.actor.double_reach?, + :ippatsu => action.actor.ippatsu_chance?, + :rinshan => action.actor.rinshan?, + :haitei => (self.num_pipais == 0 && !action.actor.rinshan?), + :first_turn => @first_turn, + :chankan => params[:previous_action].type == :kakan, + }) + end + + def first_turn? + return @first_turn + end + + def can_kan? + return @dora_markers.size < 5 + end + + def ranked_players + return @players.sort_by(){ |pl| [-pl.score, distance(pl, @chicha)] } + end + + def distance(player1, player2) + return (4 + player1.id - player2.id) % 4 + end + + def dump_action(action, io = $stdout) + io.puts(action.to_json()) + io.print(render_board()) + end + + def render_board() + result = "" + if @bakaze && @kyoku_num && @honba + result << ("%s-%d kyoku %d honba " % [@bakaze, @kyoku_num, @honba]) + end + result << ("pipai: %d " % self.num_pipais) if self.num_pipais + result << ("dora_marker: %s " % @dora_markers.join(" ")) if @dora_markers + result << "\n" + @players.each_with_index() do |player, i| + if player.tehais + result << ("%s%s%d%s tehai: %s %s\n" % + [player == @actor ? "*" : " ", + player == @oya ? "{" : "[", + i, + player == @oya ? "}" : "]", + Pai.dump_pais(player.tehais), + player.furos.join(" ")]) + if player.reach_ho_index + ho_str = + Pai.dump_pais(player.ho[0...player.reach_ho_index]) + "=" + + Pai.dump_pais(player.ho[player.reach_ho_index..-1]) + else + ho_str = Pai.dump_pais(player.ho) + end + result << (" ho: %s\n" % ho_str) + end + end + result << ("-" * 80) << "\n" + return result + end + + end + +end diff --git a/transmau_ws/mjai/game_stats.rb b/transmau_ws/mjai/game_stats.rb new file mode 100644 index 0000000..36914ee --- /dev/null +++ b/transmau_ws/mjai/game_stats.rb @@ -0,0 +1,221 @@ +# coding: utf-8 + +require "mjai/archive" +require "mjai/confidence_interval" + + +module Mjai + + class GameStats + + YAKU_JA_NAMES = { + :menzenchin_tsumoho => "髱「蜑肴ク閾ェ鞫ク蜥", :reach => "遶狗峩", :ippatsu => "荳逋コ", + :chankan => "讒肴ァ", :rinshankaiho => "蠍コ荳企幕闃ア", :haiteiraoyue => "豬キ蠎墓尊譛", + :hoteiraoyui => "豐ウ蠎墓宙鬲", :pinfu => "蟷ウ蜥", :tanyaochu => "譁ュ荵井ケ", + :ipeko => "荳逶蜿」", :jikaze => "髱「鬚ィ迚", :bakaze => "蝨城「ィ迚", + :sangenpai => "荳牙迚", :double_reach => "繝繝悶Ν遶狗峩", :chitoitsu => "荳蟇セ蟄", + :honchantaiyao => "豺キ蜈ィ蟶ッ荵井ケ", :ikkitsukan => "荳豌鈴夊イォ", + :sanshokudojun => "荳芽牡蜷碁", :sanshokudoko => "荳芽牡蜷悟綾", :sankantsu => "荳画ァ灘ュ", + :toitoiho => "蟇セ縲蜥", :sananko => "荳画囓蛻サ", :shosangen => "蟆丈ク牙", + :honroto => "豺キ閠鬆ュ", :ryanpeko => "莠檎寃蜿」", :junchantaiyao => "邏泌ィ蟶ッ荵井ケ", + :honiso => "豺キ荳濶イ", :chiniso => "貂荳濶イ", :renho => "莠コ蜥", :tenho => "螟ゥ蜥", + :chiho => "蝨ー蜥", :daisangen => "螟ァ荳牙", :suanko => "蝗帶囓蛻サ", + :tsuiso => "蟄嶺ク濶イ", :ryuiso => "邱台ク濶イ", :chinroto => "貂閠鬆ュ", + :churenpoton => "荵晁動螳晉", :kokushimuso => "蝗ス螢ォ辟。蜿", + :daisushi => "螟ァ蝗帛万", :shosushi => "蟆丞屁蝟", :sukantsu => "蝗帶ァ灘ュ", + :dora => "繝峨Λ", :uradora => "陬上ラ繝ゥ", :akadora => "襍、繝峨Λ", + } + + def self.print(mjson_paths) + + num_errors = 0 + name_to_ranks = {} + name_to_scores = {} + name_to_kyoku_count = {} + name_to_hora_count = {} + name_to_yaku_stats = {} + name_to_dora_stats = {} + name_to_hoju_count = {} + name_to_furo_kyoku_count = {} + name_to_reach_count = {} + name_to_hora_points = {} + + for path in mjson_paths + + archive = Archive.load(path) + first_action = archive.raw_actions[0] + last_action = archive.raw_actions[-1] + if !last_action || last_action.type != :end_game + num_errors += 1 + next + end + archive.do_action(first_action) + + scores = last_action.scores + id_to_name = first_action.names + + chicha_id = archive.raw_actions[1].oya.id + ranked_player_ids = + (0...4).sort_by(){ |i| [-scores[i], (i + 4 - chicha_id) % 4] } + for r in 0...4 + name = id_to_name[ranked_player_ids[r]] + name_to_ranks[name] ||= [] + name_to_ranks[name].push(r + 1) + end + + for p in 0...4 + name = id_to_name[p] + name_to_scores[name] ||= [] + name_to_scores[name].push(scores[p]) + end + + # Kyoku specific fields. + id_to_done_reach = {} + id_to_done_furo = {} + for raw_action in archive.raw_actions + if raw_action.type == :hora + name = id_to_name[raw_action.actor.id] + name_to_hora_count[name] ||= 0 + name_to_hora_count[name] += 1 + name_to_hora_points[name] ||= [] + name_to_hora_points[name].push(raw_action.hora_points) + for yaku, fan in raw_action.yakus + if yaku == :dora || yaku == :akadora || yaku == :uradora + name_to_dora_stats[name] ||= {} + name_to_dora_stats[name][yaku] ||= 0 + name_to_dora_stats[name][yaku] += fan + next + end + name_to_yaku_stats[name] ||= {} + name_to_yaku_stats[name][yaku] ||= 0 + name_to_yaku_stats[name][yaku] += 1 + end + if raw_action.actor.id != raw_action.target.id + target_name = id_to_name[raw_action.target.id] + name_to_hoju_count[target_name] ||= 0 + name_to_hoju_count[target_name] += 1 + end + end + if raw_action.type == :reach_accepted + id_to_done_reach[raw_action.actor.id] = true + end + if raw_action.type == :pon + id_to_done_furo[raw_action.actor.id] = true + end + if raw_action.type == :chi + id_to_done_furo[raw_action.actor.id] = true + end + if raw_action.type == :daiminkan + id_to_done_furo[raw_action.actor.id] = true + end + if raw_action.type == :end_kyoku + for p in 0...4 + name = id_to_name[p] + + if id_to_done_furo[p] + name_to_furo_kyoku_count[name] ||= 0 + name_to_furo_kyoku_count[name] += 1 + end + if id_to_done_reach[p] + name_to_reach_count[name] ||= 0 + name_to_reach_count[name] += 1 + end + + name_to_kyoku_count[name] ||= 0 + name_to_kyoku_count[name] += 1 + end + + # Reset kyoku specific fields. + id_to_done_furo = {} + id_to_done_reach = {} + end + end + end + if num_errors > 0 + puts("errors: %d / %d" % [num_errors, mjson_paths.size]) + end + + puts("Ranks:") + for name, ranks in name_to_ranks.sort + rank_conf_interval = ConfidenceInterval.calculate(ranks, :min => 1.0, :max => 4.0) + puts(" %s: %.3f [%.3f, %.3f]" % [ + name, + ranks.inject(0, :+).to_f() / ranks.size, + rank_conf_interval[0], + rank_conf_interval[1], + ]) + end + + puts("Scores:") + for name, scores in name_to_scores.sort + puts(" %s: %d" % [ + name, + scores.inject(0, :+).to_i() / scores.size, + ]) + end + + puts("Hora rates:") + for name, hora_count in name_to_hora_count.sort + puts(" %s: %.1f%%" % [ + name, + 100.0 * hora_count / name_to_kyoku_count[name] + ]) + end + + puts("Hoju rates:") + for name, hoju_count in name_to_hoju_count.sort + puts(" %s: %.1f%%" % [ + name, + 100.0 * hoju_count / name_to_kyoku_count[name] + ]) + end + + puts("Furo rates:") + for name, furo_kyoku_count in name_to_furo_kyoku_count.sort + puts(" %s: %.1f%%" % [ + name, + 100.0 * furo_kyoku_count / name_to_kyoku_count[name] + ]) + end + + puts("Reach rates:") + for name, reach_count in name_to_reach_count.sort + puts(" %s: %.1f%%" % [ + name, + 100.0 * reach_count / name_to_kyoku_count[name] + ]) + end + + puts("Average hora points:") + for name, hora_points in name_to_hora_points.sort + puts(" %s: %d" % [ + name, + hora_points.inject(0, :+).to_i() / hora_points.size, + ]) + end + + puts("Yaku stats:") + for name, yaku_stats in name_to_yaku_stats.sort + hora_count = name_to_hora_count[name] + puts(" %s (%d horas):" % [name, hora_count]) + for yaku, count in yaku_stats.sort_by{|yaku, count| -count} + yaku_name = YAKU_JA_NAMES[yaku] + puts(" %s: %d (%.1f%%)" % [yaku_name, count, 100.0 * count / hora_count]) + end + end + + puts("Dora stats:") + for name, dora_stats in name_to_dora_stats.sort + hora_count = name_to_hora_count[name] + puts(" %s (%d horas):" % [name, hora_count]) + for dora, count in dora_stats.sort_by{|dora, count| -count} + dora_name = YAKU_JA_NAMES[dora] + puts(" %s: %d (%.3f/hora)" % [dora_name, count, count.to_f() / hora_count]) + end + end + + end + + end + +end diff --git a/transmau_ws/mjai/hora.rb b/transmau_ws/mjai/hora.rb new file mode 100644 index 0000000..d532eef --- /dev/null +++ b/transmau_ws/mjai/hora.rb @@ -0,0 +1,530 @@ +require "set" +require "forwardable" + +require "mjai/shanten_analysis" +require "mjai/pai" +require "mjai/with_fields" + + +module Mjai + + class Hora + + Mentsu = Struct.new(:type, :visibility, :pais) + + FURO_TYPE_TO_MENTSU_TYPE = { + :chi => :shuntsu, + :pon => :kotsu, + :daiminkan => :kantsu, + :kakan => :kantsu, + :ankan => :kantsu, + } + + BASE_FU_MAP = { + :shuntsu => 0, + :kotsu => 2, + :kantsu => 8, + } + + GREEN_PAIS = Set.new(Pai.parse_pais("23468sF")) + CHURENPOTON_NUMBERS = [1, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9] + YAKUMAN_FAN = 100 + + class PointsDatum + + def initialize(fu, fan, oya, hora_type) + + @fu = fu + @fan = fan + if @fan >= YAKUMAN_FAN + @base_points = 8000 * (@fan / YAKUMAN_FAN) + elsif @fan >= 13 + @base_points = 8000 + elsif @fan >= 11 + @base_points = 6000 + elsif @fan >= 8 + @base_points = 4000 + elsif @fan >= 6 + @base_points = 3000 + elsif @fan >= 5 || (@fan >= 4 && @fu >= 40) || (@fan >= 3 && @fu >= 70) + @base_points = 2000 + else + @base_points = @fu * (2 ** (@fan + 2)) + end + + if hora_type == :ron + @oya_payment = @ko_payment = @points = + ceil_points(@base_points * (oya ? 6 : 4)) + else + if oya + @ko_payment = ceil_points(@base_points * 2) + @oya_payment = 0 + @points = @ko_payment * 3 + else + @oya_payment = ceil_points(@base_points * 2) + @ko_payment = ceil_points(@base_points) + @points = @oya_payment + @ko_payment * 2 + end + end + + end + + attr_reader(:yaku, :fu, :points, :oya_payment, :ko_payment) + + def ceil_points(points) + return (points / 100.0).ceil * 100 + end + + end + + class Candidate + + def initialize(hora, combination, taken_index) + + @hora = hora + @combination = combination + @all_pais = hora.all_pais.map(){ |pai| pai.remove_red() } + + @mentsus = [] + @janto = nil + total_taken = 0 + if combination == :chitoitsu + @machi = :tanki + for pai in @all_pais.uniq() + mentsu = Mentsu.new(:toitsu, :an, [pai, pai]) + if pai.same_symbol?(hora.taken) + @janto = mentsu + else + @mentsus.push(mentsu) + end + end + elsif combination == :kokushimuso + @machi = :tanki + else + for mentsu_type, mentsu_pais in combination + num_this_taken = mentsu_pais.select(){ |pai| pai.same_symbol?(hora.taken) }.size + has_taken = taken_index >= total_taken && taken_index < total_taken + num_this_taken + if mentsu_type == :toitsu + raise("should not happen") if @janto + @janto = Mentsu.new(:toitsu, nil, mentsu_pais) + else + @mentsus.push(Mentsu.new( + mentsu_type, + has_taken && hora.hora_type == :ron ? :min : :an, + mentsu_pais)) + end + if has_taken + case mentsu_type + when :toitsu + @machi = :tanki + when :kotsu + @machi = :shanpon + when :shuntsu + if mentsu_pais[1].same_symbol?(@hora.taken) + @machi = :kanchan + elsif (mentsu_pais[0].number == 1 && @hora.taken.number == 3) || + (mentsu_pais[0].number == 7 && @hora.taken.number == 7) + @machi = :penchan + else + @machi = :ryanmen + end + end + end + total_taken += num_this_taken + end + end + for furo in hora.furos + @mentsus.push(Mentsu.new( + FURO_TYPE_TO_MENTSU_TYPE[furo.type], + furo.type == :ankan ? :an : :min, + furo.pais.map(){ |pai| pai.remove_red() }.sort())) + end + #p @mentsus + #p @janto + #p @machi + + get_yakus() + #p @yakus + @fan = @yakus.map(){ |y, f| f }.inject(0, :+) + #p [:fan, @fan] + @fu = get_fu() + #p [:fu, @fu] + + datum = PointsDatum.new(@fu, @fan, @hora.oya, @hora.hora_type) + @points = datum.points + @oya_payment = datum.oya_payment + @ko_payment = datum.ko_payment + #p [:points, @points, @oya_payment, @ko_payment] + + end + + attr_reader(:points, :oya_payment, :ko_payment, :yakus, :fan, :fu) + + def valid? + return !@yakus.select(){ |n, f| ![:dora, :uradora, :akadora].include?(n) }.empty? + end + + # http://ja.wikipedia.org/wiki/%E9%BA%BB%E9%9B%80%E3%81%AE%E5%BD%B9%E4%B8%80%E8%A6%A7 + def get_yakus() + + @yakus = [] + + # 蠖ケ貅 + if @hora.first_turn && @hora.hora_type == :tsumo && @hora.oya + add_yaku(:tenho, YAKUMAN_FAN, 0) + end + if @hora.first_turn && @hora.hora_type == :tsumo && !@hora.oya + add_yaku(:chiho, YAKUMAN_FAN, 0) + end + if @combination == :kokushimuso + add_yaku(:kokushimuso, YAKUMAN_FAN, 0) + return + end + if self.num_sangenpais == 3 + add_yaku(:daisangen, YAKUMAN_FAN, YAKUMAN_FAN) + end + if self.n_anko?(4) + add_yaku(:suanko, YAKUMAN_FAN, 0) + end + if @all_pais.all?(){ |pai| pai.type == "t" } + add_yaku(:tsuiso, YAKUMAN_FAN, YAKUMAN_FAN) + end + if self.ryuiso? + add_yaku(:ryuiso, YAKUMAN_FAN, YAKUMAN_FAN) + end + if self.chinroto? + add_yaku(:chinroto, YAKUMAN_FAN, YAKUMAN_FAN) + end + if self.daisushi? + add_yaku(:daisushi, YAKUMAN_FAN, YAKUMAN_FAN) + end + if self.shosushi? + add_yaku(:shosushi, YAKUMAN_FAN, YAKUMAN_FAN) + end + if self.n_kantsu?(4) + add_yaku(:sukantsu, YAKUMAN_FAN, YAKUMAN_FAN) + end + if self.churenpoton? + add_yaku(:churenpoton, YAKUMAN_FAN, 0) + end + return if !@yakus.empty? + + # 繝峨Λ + add_yaku(:dora, @hora.num_doras, @hora.num_doras) + add_yaku(:uradora, @hora.num_uradoras, @hora.num_uradoras) + add_yaku(:akadora, @hora.num_akadoras, @hora.num_akadoras) + + # 荳鬟 + if @hora.reach + add_yaku(:reach, 1, 0) + end + if @hora.ippatsu + add_yaku(:ippatsu, 1, 0) + end + if self.menzen? && @hora.hora_type == :tsumo + add_yaku(:menzenchin_tsumoho, 1, 0) + end + if @all_pais.all?(){ |pai| !pai.yaochu? } + add_yaku(:tanyaochu, 1, 1) + end + if self.pinfu? + add_yaku(:pinfu, 1, 0) + end + if self.ipeko? + add_yaku(:ipeko, 1, 0) + end + + ["P","F","C"].each{|c| + if self.yakuhai?(Pai.new(c)) + add_yaku(("sangenpai"+c).to_sym, 1, 1) + end + } + if self.yakuhai?(@hora.bakaze) + add_yaku(("bakaze"+@hora.bakaze.to_s).to_sym, 1, 1) + end + if self.yakuhai?(@hora.jikaze) + add_yaku( ("jikaze"+@hora.jikaze.to_s).to_sym, 1, 1) + end + if @hora.rinshan + add_yaku(:rinshankaiho, 1, 1) + end + if @hora.chankan + add_yaku(:chankan, 1, 1) + end + if @hora.haitei && @hora.hora_type == :tsumo + add_yaku(:haiteiraoyue, 1, 1) + end + if @hora.haitei && @hora.hora_type == :ron + add_yaku(:hoteiraoyui, 1, 1) + end + + # 莠碁」 + if self.sanshoku?([:shuntsu]) + add_yaku(:sanshokudojun, 2, 1) + end + if self.ikkitsukan? + add_yaku(:ikkitsukan, 2, 1) + end + if self.honchantaiyao? + add_yaku(:honchantaiyao, 2, 1) + end + if @combination == :chitoitsu + add_yaku(:chitoitsu, 2, 0) + end + if @mentsus.all?(){ |m| [:kotsu, :kantsu].include?(m.type) } + add_yaku(:toitoiho, 2, 2) + end + if self.n_anko?(3) + add_yaku(:sananko, 2, 2) + end + if @all_pais.all?(){ |pai| pai.yaochu? } + add_yaku(:honroto, 2, 2) + delete_yaku(:honchantaiyao) + end + if self.sanshoku?([:kotsu, :kantsu]) + add_yaku(:sanshokudoko, 2, 2) + end + if self.n_kantsu?(3) + add_yaku(:sankantsu, 2, 2) + end + if self.shosangen? + add_yaku(:shosangen, 2, 2) + end + if @hora.double_reach + add_yaku(:double_reach, 2, 0) + delete_yaku(:reach) + end + + # 荳蛾」 + if self.honiso? + add_yaku(:honiso, 3, 2) + end + if self.junchantaiyao? + add_yaku(:junchantaiyao, 3, 2) + delete_yaku(:honchantaiyao) + end + if self.ryanpeko? + add_yaku(:ryanpeko, 3, 0) + delete_yaku(:ipeko) + end + + # 蜈ュ鬟 + if self.chiniso? + add_yaku(:chiniso, 6, 5) + delete_yaku(:honiso) + end + + end + + def add_yaku(name, menzen_fan, kui_fan) + fan = self.menzen? ? menzen_fan : kui_fan + @yakus.push([name, fan]) if fan > 0 + end + + def delete_yaku(name) + @yakus.delete_if(){ |n, f| n == name } + end + + def get_fu() + case @combination + when :chitoitsu + return 25 + when :kokushimuso + return 0 + else + fu = 20 + fu += 10 if self.menzen? && @hora.hora_type == :ron + fu += 2 if @hora.hora_type == :tsumo && !self.pinfu? + fu += 2 if !self.menzen? && self.pinfu? + for mentsu in @mentsus + mfu = BASE_FU_MAP[mentsu.type] + mfu *= 2 if mentsu.pais[0].yaochu? + mfu *= 2 if mentsu.visibility == :an + fu += mfu + end + fu += fanpai_fan(@janto.pais[0]) * 2 + fu += 2 if [:kanchan, :penchan, :tanki].include?(@machi) + #p [:raw_fu, fu] + return (fu / 10.0).ceil * 10 + end + end + + def menzen? + return @hora.furos.select(){ |f| f.type != :ankan }.empty? + end + + def ryuiso? + return @all_pais.all?(){ |pai| GREEN_PAIS.include?(pai) } + end + + def chinroto? + return @all_pais.all?(){ |pai| pai.type != "t" && [1, 9].include?(pai.number) } + end + + def daisushi? + return @mentsus.all?(){ |m| [:kotsu, :kantsu].include?(m.type) && m.pais[0].fonpai? } + end + + def shosushi? + fonpai_kotsus = @mentsus. + select(){ |m| [:kotsu, :kantsu].include?(m.type) && m.pais[0].fonpai? } + return fonpai_kotsus.size == 3 && @janto.pais[0].fonpai? + end + + def churenpoton? + return false if !self.chiniso? + all_numbers = @all_pais.map(){ |pai| pai.number }.sort() + return (1..9).any?() do |i| + all_numbers == (CHURENPOTON_NUMBERS + [i]).sort() + end + end + + def pinfu? + return @mentsus.all?(){ |m| m.type == :shuntsu } && + @machi == :ryanmen && + fanpai_fan(@janto.pais[0]) == 0 + end + + def ipeko? + return @mentsus.any?() do |m1| + m1.type == :shuntsu && + @mentsus.any?() do |m2| + !m2.equal?(m1) && m2.type == :shuntsu && m2.pais[0].same_symbol?(m1.pais[0]) + end + end + end + + def yakuhai?(hai) + @mentsus.any?(){ |m| [:kotsu, :kantsu].include?(m.type) && m.pais[0] == hai } + end + + def sanshoku?(types) + return @mentsus.any?() do |m1| + types.include?(m1.type) && + ["m", "p", "s"].all?() do |t| + @mentsus.any?() do |m2| + types.include?(m2.type) && m2.pais[0].same_symbol?(Pai.new(t, m1.pais[0].number)) + end + end + end + end + + def ikkitsukan? + return ["m", "p", "s"].any?() do |t| + [1, 4, 7].all?() do |n| + @mentsus.any?(){ |m| m.type == :shuntsu && m.pais[0].same_symbol?(Pai.new(t, n)) } + end + end + end + + def honchantaiyao? + return (@mentsus + [@janto]).all?(){ |m| m.pais.any?(){ |pai| pai.yaochu? } } + end + + def n_anko?(n) + ankos = @mentsus.select() do |m| + [:kotsu, :kantsu].include?(m.type) && m.visibility == :an + end + return ankos.size == n + end + + def n_kantsu?(n) + return @mentsus.select(){ |m| m.type == :kantsu }.size == n + end + + def shosangen? + return self.num_sangenpais == 2 && @janto.pais[0].sangenpai? + end + + def honiso? + return ["m", "p", "s"].any?() do |t| + @all_pais.all?(){ |pai| [t, "t"].include?(pai.type) } + end + end + + def junchantaiyao? + return (@mentsus + [@janto]).all?() do |m| + m.pais.any?(){ |pai| pai.type != "t" && [1, 9].include?(pai.number) } + end + end + + def ryanpeko? + return @mentsus.all?() do |m1| + m1.type == :shuntsu && + @mentsus.any?() do |m2| + !m2.equal?(m1) && m2.type == :shuntsu && m2.pais[0].same_symbol?(m1.pais[0]) + end + end + end + + def chiniso? + return ["m", "p", "s"].any?() do |t| + @all_pais.all?(){ |pai| pai.type == t } + end + end + + def num_sangenpais + return @mentsus. + select(){ |m| m.pais[0].sangenpai? && [:kotsu, :kantsu].include?(m.type) }. + size + end + + def fanpai_fan(pai) + if pai.sangenpai? + return 1 + else + fan = 0 + fan += 1 if pai == @hora.bakaze + fan += 1 if pai == @hora.jikaze + return fan + end + end + + end + + extend(WithFields) + extend(Forwardable) + + define_fields([ + :tehais, :furos, :taken, :hora_type, + :oya, :bakaze, :jikaze, :doras, :uradoras, + :reach, :double_reach, :ippatsu, + :rinshan, :haitei, :first_turn, :chankan, + ]) + + def initialize(params) + + @fields = params + raise("tehais is missing") if !self.tehais + raise("taken is missing") if !self.taken + + @free_pais = self.tehais + [self.taken] + @all_pais = @free_pais + self.furos.map(){ |f| f.pais }.flatten() + + @num_doras = count_doras(self.doras) + @num_uradoras = count_doras(self.uradoras) + @num_akadoras = @all_pais.select(){ |pai| pai.red? }.size + + num_same_as_taken = @free_pais.select(){ |pai| pai.same_symbol?(self.taken) }.size + @shanten = ShantenAnalysis.new(@free_pais, -1) + raise("not hora") if @shanten.shanten > -1 + unflatten_cands = @shanten.combinations.map() do |c| + (0...num_same_as_taken).map(){ |i| Candidate.new(self, c, i) } + end + @candidates = unflatten_cands.flatten() + @best_candidate = @candidates.max_by(){ |c| [c.points, c.fan, c.fu] } + + end + + attr_reader(:free_pais, :all_pais, :num_doras, :num_uradoras, :num_akadoras) + def_delegators(:@best_candidate, + :valid?, :points, :oya_payment, :ko_payment, :yakus, :fan, :fu) + + def count_doras(target_doras) + return @all_pais.map(){ |pai| target_doras.select(){ |d| d.same_symbol?(pai) }.size }. + inject(0, :+) + end + + end + +end diff --git a/transmau_ws/mjai/jsonizable.rb b/transmau_ws/mjai/jsonizable.rb new file mode 100644 index 0000000..75db776 --- /dev/null +++ b/transmau_ws/mjai/jsonizable.rb @@ -0,0 +1,191 @@ +require "rubygems" +require "json" + +require "mjai/pai" + + +module Mjai + + class JSONizable + + def self.define_fields(specs) + @@field_specs = specs + @@field_specs.each() do |name, type| + define_method(name) do + return @fields[name] + end + end + end + + def self.from_json(json, game) + plain = JSON.parse(json) + begin + return from_plain(plain, nil, game) + rescue ValidationError => ex + raise(ValidationError, "%s JSON: %s" % [ex.message, json]) + end + end + + def self.from_plain(plain, name, game) + validate(plain.is_a?(Hash), "%s must be an object." % (name || "The response")) + fields = {} + for field_name, type in @@field_specs + field_plain = plain[field_name.to_s()] + next if field_plain == nil + fields[field_name] = plain_to_obj( + field_plain, type, name ? "#{name}.#{field_name}" : field_name.to_s(), game) + end + return new(fields) + end + + def self.plain_to_obj(plain, type, name, game) + case type + when :number + validate_class(plain, Integer, name) + return plain + when :string + validate_class(plain, String, name) + return plain + when :string_or_null + validate(plain.is_a?(String) || plain == nil, "#{name} must be String or null.") + return plain + when :boolean + validate( + plain.is_a?(TrueClass) || plain.is_a?(FalseClass), + "#{name} must be either true or false.") + return plain + when :symbol + validate_class(plain, String, name) + validate(!plain.empty?, "#{name} must not be empty.") + return plain.intern() + when :player + validate_class(plain, Integer, name) + validate((0...4).include?(plain), "#{name} must be either 0, 1, 2 or 3.") + return game.players[plain] + when :pai + validate_class(plain, String, name) + begin + return Pai.new(plain) + rescue ArgumentError => ex + raise(ValidationError, "Error in %s: %s" % [name, ex.message]) + end + when :yaku + validate_class(plain, Array, name) + validate( + plain.size == 2 && plain[0].is_a?(String) && plain[1].is_a?(Integer), + "#{name} must be an array of [String, Integer].") + validate(!plain[0].empty?, "#{name}[0] must not be empty.") + return [plain[0].intern(), plain[1]] + when :action + return from_plain(plain, name, game) + when :numbers + return plains_to_objs(plain, :number, name, game) + when :strings + return plains_to_objs(plain, :string, name, game) + when :strings_or_nulls + return plains_to_objs(plain, :string_or_null, name, game) + when :booleans + return plains_to_objs(plain, :boolean, name, game) + when :symbols + return plains_to_objs(plain, :symbol, name, game) + when :pais + return plains_to_objs(plain, :pai, name, game) + when :pais_list + return plains_to_objs(plain, :pais, name, game) + when :yakus + return plains_to_objs(plain, :yaku, name, game) + when :actions + return plains_to_objs(plain, :action, name, game) + else + raise("unknown type") + end + end + + def self.plains_to_objs(plains, type, name, game) + validate_class(plains, Array, name) + return plains.each_with_index().map() do |c, i| + plain_to_obj(c, type, "#{name}[#{i}]", game) + end + end + + def self.validate(criterion, message) + raise(ValidationError, message) if !criterion + end + + def self.validate_class(plain, klass, name) + validate(plain.is_a?(klass), "%s must be %p." % [name, klass]) + end + + def initialize(fields) + for name, value in fields + if !@@field_specs.any?(){ |n, t| n == name } + raise(ArgumentError, "unknown field: %p" % name) + end + end + @fields = fields + end + + attr_reader(:fields) + + def to_json() + return JSON.dump(to_plain()) + end + + def to_plain() + hash = {} + for name, type in @@field_specs + obj = @fields[name] + next if obj == nil + case type + when :symbol, :pai + plain = obj.to_s() + when :player + plain = obj.id + when :symbols, :pais + plain = obj.map(){ |a| a.to_s() } + when :pais_list + plain = obj.map(){ |o| o.map(){ |a| a.to_s() } } + when :yakus + plain = obj.map(){ |s, n| [s.to_s(), n] } + when :actions + plain = obj.map(){ |a| a.to_plain() } + when :number, :numbers, :string, :strings, :string_or_null, :strings_or_nulls, :boolean, :booleans + plain = obj + else + raise("unknown type") + end + hash[name.to_s()] = plain + end + return hash + end + + alias to_s to_json + + def merge(hash) + fields = @fields.dup() + for name, value in hash + if !@@field_specs.any?(){ |n, t| n == name } + raise(ArgumentError, "unknown field: %p" % k) + end + if value == nil + fields.delete(name) + else + fields[name] = value + end + end + return self.class.new(fields) + end + + def ==(other) + return self.class == other.class && @fields == other.fields + end + + alias eql? == + + def hash + return @fields.hash + end + + end + +end diff --git a/transmau_ws/mjai/mentsu.rb b/transmau_ws/mjai/mentsu.rb new file mode 100644 index 0000000..eb2bcc6 --- /dev/null +++ b/transmau_ws/mjai/mentsu.rb @@ -0,0 +1,46 @@ +require "mjai/with_fields" + + +module Mjai + + class Mentsu + + extend(WithFields) + include(Comparable) + + # type: :shuntsu, :kotsu, :toitsu, :ryanmen, :kanchan, :penchan, :single + # visibility: :an, :min + define_fields([:pais, :type, :visibility]) + + def initialize(fields) + @fields = fields + end + + attr_reader(:fields) + + def inspect + return "\#<%p %p>" % [self.class, @fields] + end + + def ==(other) + return self.class == other.class && @fields == other.fields + end + + alias eql? == + + def hash() + return @fields.hash() + end + + def <=>(other) + if self.class == other.class + return Mentsu.field_names.map(){ |s| @fields[s] } <=> + Mentsu.field_names.map(){ |s| other.fields[s] } + else + raise(ArgumentError, "invalid comparison") + end + end + + end + +end diff --git a/transmau_ws/mjai/mjson_archive.rb b/transmau_ws/mjai/mjson_archive.rb new file mode 100644 index 0000000..f121931 --- /dev/null +++ b/transmau_ws/mjai/mjson_archive.rb @@ -0,0 +1,33 @@ +require "mjai/archive" +require "mjai/puppet_player" +require "mjai/action" + + +module Mjai + + class MjsonArchive < Archive + + def initialize(path) + super() + @path = path + @raw_actions = [] + File.foreach(@path) do |line| + @raw_actions.push(Action.from_json(line.chomp(), self)) + end + end + + attr_reader(:path, :raw_actions) + + def play() + for action in @raw_actions + do_action(action) + end + end + + def actions() + return @raw_actions + end + + end + +end diff --git a/transmau_ws/mjai/pai.rb b/transmau_ws/mjai/pai.rb new file mode 100644 index 0000000..cf846fe --- /dev/null +++ b/transmau_ws/mjai/pai.rb @@ -0,0 +1,165 @@ +module Mjai + + class Pai + + include(Comparable) + + TSUPAI_STRS = " ESWNPFC".split(//) + + def self.parse_pais(str) + type = nil + pais = [] + red = false + str.gsub(/\s+/, "").split(//).reverse_each() do |ch| + next if ch =~ /^\s$/ + if ch =~ /^[mps]$/ + type = ch + elsif ch =~ /^[1-9]$/ + raise(ArgumentError, "type required after number") if !type + pais.push(Pai.new(type, ch.to_i(), red)) + red = false + elsif TSUPAI_STRS.include?(ch) + pais.push(Pai.new(ch)) + elsif ch == "r" + red = true + else + raise(ArgumentError, "unexpected character: %s", ch) + end + end + return pais.reverse() + end + + def self.dump_pais(pais) + return pais.map(){ |pai| "%-3s" % pai }.join("") + end + + def initialize(*args) + case args.size + when 1 + str = args[0] + if str == "?" + @type = @number = nil + @red = false + elsif str =~ /\A([1-9])([mps])(r)?\z/ + @type = $2 + @number = $1.to_i() + @red = $3 != nil + elsif number = TSUPAI_STRS.index(str) + @type = "t" + @number = number + @red = false + else + raise(ArgumentError, "Unknown pai string: %s" % str) + end + when 2, 3 + (@type, @number, @red) = args + @red = false if @red == nil + else + raise(ArgumentError, "Wrong number of args.") + end + if @type != nil || @number != nil + if !["m", "p", "s", "t"].include?(@type) + raise("Bad type: %p" % @type) + end + if !@number.is_a?(Integer) + raise("number must be Integer: %p" % @number) + end + if @red != true && @red != false + raise("red must be boolean: %p" % @red) + end + end + end + + def to_s() + if !@type + return "?" + elsif @type == "t" + return TSUPAI_STRS[@number] + else + return "%d%s%s" % [@number, @type, @red ? "r" : ""] + end + end + + def inspect + return "Pai[%s]" % self.to_s() + end + + attr_reader(:type, :number) + + def valid? + if @type == nil && @number == nil + return true + elsif @type == "t" + return (1..7).include?(@number) + else + return (1..9).include?(@number) + end + end + + def red? + return @red + end + + def yaochu? + return @type == "t" || @number == 1 || @number == 9 + end + + def fonpai? + return @type == "t" && (1..4).include?(@number) + end + + def sangenpai? + return @type == "t" && (5..7).include?(@number) + end + + def next(n) + return Pai.new(@type, @number + n) + end + + def data + return [@type || "", @number || -1, @red ? 1 : 0] + end + + def ==(other) + return self.class == other.class && self.data == other.data + end + + alias eql? == + + def hash() + return self.data.hash() + end + + def <=>(other) + if self.class == other.class + return self.data <=> other.data + else + raise(ArgumentError, "invalid comparison") + end + end + + def remove_red() + return Pai.new(@type, @number) + end + + def same_symbol?(other) + return @type == other.type && @number == other.number + end + + # Next pai in terms of dora derivation. + def succ + if (@type == "t" && @number == 4) || (@type != "t" && @number == 9) + number = 1 + elsif @type == "t" && @number == 7 + number = 5 + else + number = @number + 1 + end + return Pai.new(@type, number) + end + + UNKNOWN = Pai.new(nil, nil) + + end + +end diff --git a/transmau_ws/mjai/player.rb b/transmau_ws/mjai/player.rb new file mode 100644 index 0000000..e5ec7ff --- /dev/null +++ b/transmau_ws/mjai/player.rb @@ -0,0 +1,445 @@ +require "ostruct" + +require "mjai/pai" +require "mjai/tenpai_analysis" + + +module Mjai + + class Player + + attr_reader(:id) + attr_reader(:tehais) # 謇狗煙 + attr_reader(:furos) # 蜑ッ髴イ + attr_reader(:ho) # 豐ウ (魑エ縺九l縺溽煙繧貞性縺セ縺ェ縺) + attr_reader(:sutehais) # 謐ィ迚 (魑エ縺九l縺溽煙繧貞性繧) + attr_reader(:extra_anpais) # sutehais莉・螟悶ョ縺薙ョ繝励Ξ繝シ繝、縺ォ蟇セ縺吶k螳臥煙 + attr_reader(:reach_state) + attr_reader(:reach_ho_index) + attr_reader(:pao_for_id) + attr_reader(:attributes) + attr_accessor(:name) + attr_accessor(:game) + attr_accessor(:score) + + def anpais + return @sutehais + @extra_anpais + end + + def reach? + return @reach_state == :accepted + end + + def double_reach? + return @double_reach + end + + def ippatsu_chance? + return @ippatsu_chance + end + + def rinshan? + return @rinshan + end + + def update_state(action) + + if @game.previous_action && + [:dahai, :kakan].include?(@game.previous_action.type) && + @game.previous_action.actor != self && + action.type != :hora + @extra_anpais.push(@game.previous_action.pai) + end + + case action.type + when :start_game + @id = action.id + @name = action.names[@id] if action.names + @score = 25000 + @attributes = OpenStruct.new() + @tehais = nil + @furos = nil + @ho = nil + @sutehais = nil + @extra_anpais = nil + @reach_state = nil + @reach_ho_index = nil + @double_reach = false + @ippatsu_chance = false + @pao_for_id = nil + @rinshan = false + when :start_kyoku + @tehais = action.tehais[self.id] + @furos = [] + @ho = [] + @sutehais = [] + @extra_anpais = [] + @reach_state = :none + @reach_ho_index = nil + @double_reach = false + @ippatsu_chance = false + @pao_for_id = nil + @rinshan = false + when :chi, :pon, :daiminkan, :ankan + @ippatsu_chance = false + when :tsumo + # - 邏疲ュ」蟾。豸医@縺ッ逋コ螢ーシ蜥御コ謇楢ィコ蠕鯉シ亥刈讒薙ョ縺ソ)縲∝カコ荳翫ヤ繝「縺ョ蜑搾シ磯」邯壹☆繧句刈讒薙ョシ貞屓逶ョ縺ォ縺ッ荳逋コ縺ッ莉倥°縺ェ縺シ + if @game.previous_action && + @game.previous_action.type == :kakan + @ippatsu_chance = false + end + when :ryukyoku + if action.tehais[self.id][0].type != nil + @tehais = action.tehais[self.id] + end + end + + if action.actor == self + case action.type + when :tsumo + @tehais.sort!() + @tehais.push(action.pai) + when :dahai + delete_tehai(action.pai) + @tehais.sort!() + @ho.push(action.pai) + @sutehais.push(action.pai) + @ippatsu_chance = false + @rinshan = false + @extra_anpais.clear() if !self.reach? + when :chi, :pon, :daiminkan, :ankan + for pai in action.consumed + delete_tehai(pai) + end + @furos.push(Furo.new({ + :type => action.type, + :taken => action.pai, + :consumed => action.consumed, + :target => action.target, + })) + if [:daiminkan, :ankan].include?(action.type) + @rinshan = true + end + + # 蛹 + if [:daiminkan, :pon].include?(action.type) + if (action.pai.sangenpai? && @furos.select{|f| f.pais[0].sangenpai?}.size == 3) || + (action.pai.fonpai? && @furos.select{|f| f.pais[0].fonpai? }.size == 4) + @pao_for_id = action.target.id + end + end + when :kakan + delete_tehai(action.pai) + pon_index = + @furos.index(){ |f| f.type == :pon && f.taken.same_symbol?(action.pai) } + raise("should not happen") if !pon_index + @furos[pon_index] = Furo.new({ + :type => :kakan, + :taken => @furos[pon_index].taken, + :consumed => @furos[pon_index].consumed + [action.pai], + :target => @furos[pon_index].target, + }) + @rinshan = true + when :reach + @reach_state = :declared + @double_reach = true if @game.first_turn? + when :reach_accepted + @reach_state = :accepted + @reach_ho_index = @ho.size - 1 + @ippatsu_chance = true + when :hora + hora_type = action.actor == action.target ? :tsumo : :ron + if hora_type == :tsumo + @tehais = action.hora_tehais.dup + [action.pai] + else + @tehais = action.hora_tehais.dup + end + end + end + + if action.target == self + case action.type + when :chi, :pon, :daiminkan + pai = @ho.pop() + raise("should not happen") if pai != action.pai + end + end + + if action.scores + @score = action.scores[self.id] + end + + end + + def jikaze + if @game.oya + return Pai.new("t", 1 + (4 + @id - @game.oya.id) % 4) + else + return nil + end + end + + def tenpai? + return TenpaiAnalysis.new(@tehais).tenpai? + end + + def furiten? + return false if @tehais.size % 3 != 1 + return false if @tehais.include?(Pai::UNKNOWN) + tenpai_info = TenpaiAnalysis.new(@tehais) + return false if !tenpai_info.tenpai? + anpais = self.anpais + return tenpai_info.waited_pais.any?(){ |pai| anpais.include?(pai) } + end + + def can_reach?(shanten_analysis = nil) + shanten_analysis ||= ShantenAnalysis.new(@tehais, 0) + return @game.current_action.type == :tsumo && + @game.current_action.actor == self && + shanten_analysis.shanten <= 0 && + @furos.all?{|f| f.type == :ankan} && + !self.reach? && + self.game.num_pipais >= 4 && + @score >= 1000 + end + + def can_hora?(shanten_analysis = nil) + action = @game.current_action + if action.type == :tsumo && action.actor == self + hora_type = :tsumo + pais = @tehais + elsif [:dahai, :kakan].include?(action.type) && action.actor != self + hora_type = :ron + pais = @tehais + [action.pai] + else + return false + end + shanten_analysis ||= ShantenAnalysis.new(pais, -1) + hora_action = + create_action({:type => :hora, :target => action.actor, :pai => pais[-1]}) + return shanten_analysis.shanten == -1 && + @game.get_hora(hora_action, {:previous_action => action}).valid? && + (hora_type == :tsumo || !self.furiten?) + end + + def can_ryukyoku? + return @game.current_action.type == :tsumo && + @game.current_action.actor == self && + @game.first_turn? && + @tehais.select(){ |pai| pai.yaochu? }.uniq().size >= 9 + end + + # Possible actions except for dahai. + def possible_actions + action = @game.current_action + result = [] + if (action.type == :tsumo && action.actor == self) || + ([:dahai, :kakan].include?(action.type) && action.actor != self) + if can_hora? + result.push(create_action({ + :type => :hora, + :target => action.actor, + :pai => action.pai, + })) + end + if can_reach? + result.push(create_action({:type => :reach})) + end + if can_ryukyoku? + result.push(create_action({:type => :ryukyoku, :reason => :kyushukyuhai})) + end + end + result += self.possible_furo_actions + return result + end + + def possible_furo_actions + + action = @game.current_action + result = [] + + if action.type == :dahai && + action.actor != self && + !self.reach? && + @game.num_pipais > 0 + + if @game.can_kan? + for consumed in get_pais_combinations([action.pai] * 3, @tehais) + result.push(create_action({ + :type => :daiminkan, + :pai => action.pai, + :consumed => consumed, + :target => action.actor + })) + end + end + for consumed in get_pais_combinations([action.pai] * 2, @tehais) + result.push(create_action({ + :type => :pon, + :pai => action.pai, + :consumed => consumed, + :target => action.actor + })) + end + if (action.actor.id + 1) % 4 == self.id && action.pai.type != "t" + for i in 0...3 + target_pais = (((-i)...(-i + 3)).to_a() - [0]).map() do |j| + Pai.new(action.pai.type, action.pai.number + j) + end + for consumed in get_pais_combinations(target_pais, @tehais) + result.push(create_action({ + :type => :chi, + :pai => action.pai, + :consumed => consumed, + :target => action.actor, + })) + end + end + end + # Excludes furos which forces kuikae afterwards. + result = result.select() do |a| + a.type == :daiminkan || !possible_dahais_after_furo(a).empty? + end + + elsif action.type == :tsumo && + action.actor == self && + @game.num_pipais > 0 && + @game.can_kan? + + for pai in self.tehais.uniq + same_pais = self.tehais.select(){ |tp| tp.same_symbol?(pai) } + if same_pais.size >= 4 && !pai.red? + if self.reach? + orig_tenpai = TenpaiAnalysis.new(self.tehais[0...-1]) + new_tenpai = TenpaiAnalysis.new( + self.tehais.select(){ |tp| !tp.same_symbol?(pai) }) + ok = new_tenpai.tenpai? && new_tenpai.waited_pais == orig_tenpai.waited_pais + else + ok = true + end + result.push(create_action({:type => :ankan, :consumed => same_pais})) if ok + end + pon = self.furos.find(){ |f| f.type == :pon && f.taken.same_symbol?(pai) } + if pon + result.push(create_action({:type => :kakan, :pai => pai, :consumed => pon.pais})) + end + end + + end + + return result + + end + + def get_pais_combinations(target_pais, source_pais) + return Set.new([[]]) if target_pais.empty? + result = Set.new() + for pai in source_pais.select(){ |pai| target_pais[0].same_symbol?(pai) }.uniq + new_source_pais = source_pais.dup() + new_source_pais.delete_at(new_source_pais.index(pai)) + for cdr_pais in get_pais_combinations(target_pais[1..-1], new_source_pais) + result.add(([pai] + cdr_pais).sort()) + end + end + return result + end + + def possible_dahais(action = @game.current_action, tehais = @tehais) + + if self.reach? && action.type == :tsumo && action.actor == self + + # Only tsumogiri is allowed after reach. + return [action.pai] + + elsif action.type == :reach + + # Tehais after the dahai must be tenpai just after reach. + result = [] + for pai in tehais.uniq() + pais = tehais.dup() + pais.delete_at(pais.index(pai)) + if ShantenAnalysis.new(pais, 0).shanten <= 0 + result.push(pai) + end + end + return result + + else + + # Excludes kuikae. + return tehais.uniq() - kuikae_dahais(action, tehais) + + end + + end + + def kuikae_dahais(action = @game.current_action, tehais = @tehais) + consumed = action.consumed ? action.consumed.sort() : nil + if action.type == :chi && action.actor == self + if consumed[1].number == consumed[0].number + 1 + forbidden_rnums = [-1, 2] + else + forbidden_rnums = [1] + end + elsif action.type == :pon && action.actor == self + forbidden_rnums = [0] + else + forbidden_rnums = [] + end + if forbidden_rnums.empty? + return [] + else + key_pai = consumed[0] + return tehais.uniq().select() do |pai| + pai.type == key_pai.type && + forbidden_rnums.any?(){ |rn| key_pai.number + rn == pai.number } + end + end + end + + def possible_dahais_after_furo(action) + remains = @tehais.dup() + for pai in action.consumed + remains.delete_at(remains.index(pai)) + end + return possible_dahais(action, remains) + end + + def context + return Context.new({ + :oya => self == self.game.oya, + :bakaze => self.game.bakaze, + :jikaze => self.jikaze, + :doras => self.game.doras, + :uradoras => [], # TODO + :reach => self.reach?, + :double_reach => false, # TODO + :ippatsu => false, # TODO + :rinshan => false, # TODO + :haitei => self.game.num_pipais == 0, + :first_turn => false, # TODO + :chankan => false, # TODO + }) + end + + def delete_tehai(pai) + pai_index = @tehais.index(pai) || @tehais.index(Pai::UNKNOWN) + raise("trying to delete %p which is not in tehais: %p" % [pai, @tehais]) if !pai_index + @tehais.delete_at(pai_index) + end + + def create_action(params = {}) + return Action.new({:actor => self}.merge(params)) + end + + def rank + return @game.ranked_players.index(self) + 1 + end + + def inspect + return "\#<%p:%p>" % [self.class, self.id] + end + + end + +end diff --git a/transmau_ws/mjai/puppet_player.rb b/transmau_ws/mjai/puppet_player.rb new file mode 100644 index 0000000..4d9efdd --- /dev/null +++ b/transmau_ws/mjai/puppet_player.rb @@ -0,0 +1,18 @@ +require "mjai/player" + + +module Mjai + + class PuppetPlayer < Player + + def initialize(id) + @id = id + end + + def respond_to_action(action) + return nil + end + + end + +end diff --git a/transmau_ws/mjai/replay_game.rb b/transmau_ws/mjai/replay_game.rb new file mode 100644 index 0000000..c394324 --- /dev/null +++ b/transmau_ws/mjai/replay_game.rb @@ -0,0 +1,124 @@ +require "mjai/game" +require "mjai/action" +require "mjai/hora" +require "mjai/validation_error" + +require "mjai/archive_player" + + +module Mjai + + class ReplayGame < ActiveGame + + def initialize(archive_path) + super((0...4).map(){ ArchivePlayer.new(archive_path) }) + @archive = Archive.load(archive_path) + + @action_index = 0 + end + + def play() + + @arcpais = [] + arcpos = -1 + for act in @archive.actions do + if act.type == :start_kyoku then + arcpos += 1 + @arcpais.push( {:pipais => [], :dora => [act.dora_marker], :uraadded => false} ) + for pp in act.tehais do + for tp in pp do + @arcpais[arcpos][:pipais].unshift(tp) + end + end + elsif act.type == :tsumo then + @arcpais[arcpos][:pipais].unshift(act.pai) + elsif act.type == :dora then + @arcpais[arcpos][:dora].unshift(act.dora_marker) + elsif act.type == :hora then + if @arcpais[arcpos][:uraadded] == false && act.uradora_markers.size >0 then + for up in act.uradora_markers do + @arcpais[arcpos][:dora].unshift(up) + end + @arcpais[arcpos][:uraadded] = true + end + end + end + + for arck in @arcpais do + (122 - arck[:pipais].size).times { arck[:pipais].unshift("X?X") } + (14 - arck[:dora].size).times { arck[:dora].unshift("Y?Y") } + end + + if @archive.actions[0].type != :start_game + raise "first action is not start_game" + end + @game_type = @archive.actions[0].gametype + + 4.times { |i| self.players[i].name = @archive.actions[0].names[i] } + + begin + do_action({:type => :start_game, :names => self.players.map(){ |pl| pl.name }}) + @ag_oya = @ag_chicha = @players[0] + @ag_bakaze = Pai.new("E") + @ag_honba = 0 + @ag_kyotaku = 0 + + @rep_arcpos = 0 + while !self.game_finished? + + print @rep_arcpos, " " + play_kyoku() + + print @bakaze, @kyoku_num, "-", @honba + print " " + print self.get_scores([0,0,0,0]) + print "\n" + @rep_arcpos += 1 + end + + fin_score = get_final_scores() + do_action({:type => :end_game, :scores => fin_score}) + + print "final " , fin_score, "\n" + return fin_score + rescue GameFailError + do_action({:type => :error, :message => "Player" + $!.player.to_s + "'s illegal response: " + $!.message}) + raise $! + end + end + + + def play_kyoku() + catch(:end_kyoku) do + @pipais = @arcpais[@rep_arcpos][:pipais] + @wanpais = @arcpais[@rep_arcpos][:dora] + dora_marker = @wanpais.pop() + tehais = Array.new(4){ @pipais.pop(13).sort() } + do_action({ + :type => :start_kyoku, + :bakaze => @ag_bakaze, + :kyoku => (4 + @ag_oya.id - @ag_chicha.id) % 4 + 1, + :honba => @ag_honba, + :kyotaku => @ag_kyotaku, + :oya => @ag_oya, + :dora_marker => dora_marker, + :tehais => tehais, + }) + @actor = self.oya + while !@pipais.empty? + mota() + @actor = @players[(@actor.id + 1) % 4] + end + process_fanpai() + end + do_action({:type => :end_kyoku}) + end + + + def expect_response_from?(player) + return true + end + + end +end + diff --git a/transmau_ws/mjai/shanten_analysis.rb b/transmau_ws/mjai/shanten_analysis.rb new file mode 100644 index 0000000..624acf5 --- /dev/null +++ b/transmau_ws/mjai/shanten_analysis.rb @@ -0,0 +1,274 @@ +require "set" +require "mjai/pai" +require "mjai/mentsu" + + +module Mjai + + class ShantenAnalysis + + # ryanpen = 荳。髱「 or 霎コ謳ュ + MENTSU_TYPES = [:kotsu, :shuntsu, :toitsu, :ryanpen, :kanta, :single] + + MENTSU_CATEGORIES = { + :kotsu => :complete, + :shuntsu => :complete, + :toitsu => :toitsu, + :ryanpen => :tatsu, + :kanta => :tatsu, + :single => :single, + } + + MENTSU_SIZES = { + :complete => 3, + :toitsu => 2, + :tatsu => 2, + :single => 1, + } + + ALL_TYPES = [:normal, :chitoitsu, :kokushimuso] + + def self.benchmark() + all_pais = (["m", "p", "s"].map(){ |t| (1..9).map(){ |n| Pai.new(t, n) } }.flatten() + + (1..7).map(){ |n| Pai.new("t", n) }) * 4 + start_time = Time.now.to_f + 100.times() do + pais = all_pais.sample(14).sort() + p pais.join(" ") + shanten = ShantenAnalysis.count(pais) + p shanten +=begin + for i in 0...pais.size + remains_pais = pais.dup() + remains_pais.delete_at(i) + if ShantenAnalysis.count(remains_pais) == shanten + p pais[i] + end + end +=end + #gets() + end + p Time.now.to_f - start_time + end + + def initialize(pais, max_shanten = nil, types = ALL_TYPES, + num_used_pais = pais.size, need_all_combinations = true) + + @pais = pais + @max_shanten = max_shanten + @num_used_pais = num_used_pais + @need_all_combinations = need_all_combinations + raise(ArgumentError, "invalid number of pais") if @num_used_pais % 3 == 0 + @pai_set = Hash.new(0) + for pai in @pais + @pai_set[pai.remove_red()] += 1 + end + + @cache = {} + results = [] + results.push(count_normal(@pai_set, [])) if types.include?(:normal) + results.push(count_chitoi(@pai_set)) if types.include?(:chitoitsu) + results.push(count_kokushi(@pai_set)) if types.include?(:kokushimuso) + + @shanten = 1.0/0.0 + @combinations = [] + for shanten, combinations in results + next if @max_shanten && shanten > @max_shanten + if shanten < @shanten + @shanten = shanten + @combinations = combinations + elsif shanten == @shanten + @combinations += combinations + end + end + + end + + attr_reader(:pais, :shanten, :combinations) + + DetailedCombination = Struct.new(:janto, :mentsus) + + def detailed_combinations + num_required_mentsus = @pais.size / 3 + result = [] + for mentsus in @combinations.map(){ |ms| ms.map(){ |m| convert_mentsu(m) } } + for janto_index in [nil] + (0...mentsus.size).to_a() + t_mentsus = mentsus.dup() + janto = nil + if janto_index + next if ![:toitsu, :kotsu].include?(mentsus[janto_index].type) + janto = t_mentsus.delete_at(janto_index) + end + current_shanten = + -1 + + (janto_index ? 0 : 1) + + t_mentsus.map(){ |m| 3 - m.pais.size }. + sort()[0, num_required_mentsus]. + inject(0, :+) + next if current_shanten != @shanten + result.push(DetailedCombination.new(janto, t_mentsus)) + end + end + return result + end + + def convert_mentsu(mentsu) + (type, pais) = mentsu + if type == :ryanpen + if [[1, 2], [8, 9]].include?(pais.map(){ |pai| pai.number }) + type = :penta + else + type = :ryanmen + end + end + return Mentsu.new({:type => type, :pais => pais, :visibility => :an}) + end + + def count_chitoi(pai_set) + num_toitsus = pai_set.select(){ |pai, n| n >= 2 }.size + num_singles = pai_set.select(){ |pai, n| n == 1 }.size + if num_toitsus == 6 && num_singles == 0 + # toitsu * 5 + kotsu * 1 or toitsu * 5 + kantsu * 1 + shanten = 1 + else + shanten = -1 + [7 - num_toitsus, 0].max + end + return [shanten, [:chitoitsu]] + end + + def count_kokushi(pai_set) + yaochus = pai_set.select(){ |pai, n| pai.yaochu? } + has_yaochu_toitsu = yaochus.any?(){ |pai, n| n >= 2 } + return [(13 - yaochus.size) - (has_yaochu_toitsu ? 1 : 0), [:kokushimuso]] + end + + def count_normal(pai_set, mentsus) + # TODO 荳翫′繧顔煙繧貞ィ驛ィ閾ェ蛻縺梧戟縺」縺ヲ縺繧九こ繝シ繧ケ繧定諷ョ + key = get_key(pai_set, mentsus) + if !@cache[key] + if pai_set.empty? + #p mentsus + min_shanten = get_min_shanten_for_mentsus(mentsus) + min_combinations = [mentsus] + else + shanten_lowerbound = get_min_shanten_for_mentsus(mentsus) if @max_shanten + if @max_shanten && shanten_lowerbound > @max_shanten + min_shanten = 1.0/0.0 + min_combinations = [] + else + min_shanten = 1.0/0.0 + first_pai = pai_set.keys.sort()[0] + for type in MENTSU_TYPES + if @max_shanten == -1 + next if [:ryanpen, :kanta].include?(type) + next if mentsus.any?(){ |t, ps| t == :toitsu } && type == :toitsu + end + (removed_pais, remains_set) = remove(pai_set, type, first_pai) + if remains_set + (shanten, combinations) = + count_normal(remains_set, mentsus + [[type, removed_pais]]) + if shanten < min_shanten + min_shanten = shanten + min_combinations = combinations + break if !@need_all_combinations && min_shanten == -1 + elsif shanten == min_shanten && shanten < 1.0/0.0 + min_combinations += combinations + end + end + end + end + end + @cache[key] = [min_shanten, min_combinations] + end + return @cache[key] + end + + def get_key(pai_set, mentsus) + return [pai_set, Set.new(mentsus)] + end + + def get_min_shanten_for_mentsus(mentsus) + + mentsu_categories = mentsus.map(){ |t, ps| MENTSU_CATEGORIES[t] } + num_current_pais = mentsu_categories.map(){ |m| MENTSU_SIZES[m] }.inject(0, :+) + num_remain_pais = @pais.size - num_current_pais + + min_shantens = [] + if index = mentsu_categories.index(:toitsu) + # Assumes the 蟇セ蟄 is 髮鬆ュ. + mentsu_categories.delete_at(index) + min_shantens.push(get_min_shanten_without_janto(mentsu_categories, num_remain_pais)) + else + # Assumes 髮鬆ュ is missing. + min_shantens.push(get_min_shanten_without_janto(mentsu_categories, num_remain_pais) + 1) + if num_remain_pais >= 2 + # Assumes 髮鬆ュ is in remaining pais. + min_shantens.push(get_min_shanten_without_janto(mentsu_categories, num_remain_pais - 2)) + end + end + return min_shantens.min + + end + + def get_min_shanten_without_janto(mentsu_categories, num_remain_pais) + + # Assumes remaining pais generates best combinations. + mentsu_categories += [:complete] * (num_remain_pais / 3) + case num_remain_pais % 3 + when 1 + mentsu_categories.push(:single) + when 2 + mentsu_categories.push(:toitsu) + end + + sizes = mentsu_categories.map(){ |m| MENTSU_SIZES[m] }.sort_by(){ |n| -n } + num_required_mentsus = @num_used_pais / 3 + return -1 + sizes[0...num_required_mentsus].inject(0){ |r, n| r + (3 - n) } + + end + + def remove(pai_set, type, first_pai) + case type + when :kotsu + removed_pais = [first_pai] * 3 + when :shuntsu + removed_pais = shuntsu_piece(first_pai, [0, 1, 2]) + when :toitsu + removed_pais = [first_pai] * 2 + when :ryanpen + removed_pais = shuntsu_piece(first_pai, [0, 1]) + when :kanta + removed_pais = shuntsu_piece(first_pai, [0, 2]) + when :single + removed_pais = [first_pai] + else + raise("should not happen") + end + return [nil, nil] if !removed_pais + result_set = pai_set.dup() + for pai in removed_pais + if result_set[pai] > 0 + result_set[pai] -= 1 + result_set.delete(pai) if result_set[pai] == 0 + else + return [nil, nil] + end + end + return [removed_pais, result_set] + end + + def shuntsu_piece(first_pai, relative_numbers) + if first_pai.type == "t" + return nil + else + return relative_numbers.map(){ |i| Pai.new(first_pai.type, first_pai.number + i) } + end + end + + def inspect + return "\#<%p shanten=%d pais=<%s>>" % [self.class, @shanten, @pais.join(" ")] + end + + end + +end diff --git a/transmau_ws/mjai/shanten_player.rb b/transmau_ws/mjai/shanten_player.rb new file mode 100644 index 0000000..1983a3a --- /dev/null +++ b/transmau_ws/mjai/shanten_player.rb @@ -0,0 +1,95 @@ +require "mjai/player" +require "mjai/shanten_analysis" +require "mjai/pai" + + +module Mjai + + class ShantenPlayer < Player + + def initialize(params) + super() + @use_furo = params[:use_furo] + end + + def respond_to_action(action) + + if action.actor == self + + case action.type + + when :tsumo, :chi, :pon, :reach + + current_shanten_analysis = ShantenAnalysis.new(self.tehais, nil, [:normal]) + current_shanten = current_shanten_analysis.shanten + if can_hora?(current_shanten_analysis) + if @use_furo + return create_action({:type => :dahai, :pai => action.pai, :tsumogiri => true}) + else + return create_action({ + :type => :hora, + :target => action.actor, + :pai => action.pai, + }) + end + elsif can_reach?(current_shanten_analysis) + return create_action({:type => :reach}) + elsif self.reach? + return create_action({:type => :dahai, :pai => action.pai, :tsumogiri => true}) + end + + # Ankan, kakan + furo_actions = self.possible_furo_actions + if !furo_actions.empty? + return furo_actions[0] + end + + sutehai_cands = [] + for pai in self.possible_dahais + remains = self.tehais.dup() + remains.delete_at(self.tehais.index(pai)) + if ShantenAnalysis.new(remains, current_shanten, [:normal]).shanten == + current_shanten + sutehai_cands.push(pai) + end + end + if sutehai_cands.empty? + sutehai_cands = self.possible_dahais + end + #log("sutehai_cands = %p" % [sutehai_cands]) + sutehai = sutehai_cands[rand(sutehai_cands.size)] + tsumogiri = [:tsumo, :reach].include?(action.type) && sutehai == self.tehais[-1] + return create_action({:type => :dahai, :pai => sutehai, :tsumogiri => tsumogiri}) + + end + + else # action.actor != self + + case action.type + when :dahai + if self.can_hora? + if @use_furo + return nil + else + return create_action({ + :type => :hora, + :target => action.actor, + :pai => action.pai, + }) + end + elsif @use_furo + furo_actions = self.possible_furo_actions + if !furo_actions.empty? + return furo_actions[0] + end + end + end + + end + + return nil + end + + end + +end diff --git a/transmau_ws/mjai/tenhou_archive.rb b/transmau_ws/mjai/tenhou_archive.rb new file mode 100644 index 0000000..bf9505d --- /dev/null +++ b/transmau_ws/mjai/tenhou_archive.rb @@ -0,0 +1,520 @@ +# Reference: http://tenhou.net/1/script/tenhou.js + +require "zlib" +require "uri" +require "nokogiri" + +require "mjai/archive" +require "mjai/pai" +require "mjai/action" +require "mjai/puppet_player" + + +module Mjai + + class TenhouArchive < Archive + + module Util + + YAKU_ID_TO_NAME = [ + :menzenchin_tsumoho, :reach, :ippatsu, :chankan, :rinshankaiho, + :haiteiraoyue, :hoteiraoyui, :pinfu, :tanyaochu, :ipeko, + :jikazeE, :jikazeS, :jikazeW, :jikazeN, + :bakazeE, :bakazeS, :bakazeW, :bakazeN, + :sangenpaiP, :sangenpaiF, :sangenpaiC, + :double_reach, :chitoitsu, :honchantaiyao, :ikkitsukan, :sanshokudojun, + :sanshokudoko, :sankantsu, :toitoiho, :sananko, :shosangen, :honroto, + :ryanpeko, :junchantaiyao, :honiso, + :chiniso, + :renho, + :tenho, :chiho, :daisangen, :suanko, :suanko, :tsuiso, + :ryuiso, :chinroto, :churenpoton, :churenpoton, :kokushimuso, + :kokushimuso, :daisushi, :shosushi, :sukantsu, + :dora, :uradora, :akadora, + ] + + def on_tenhou_event(elem, next_elem = nil) + verify_tenhou_tehais() if @first_kyoku_started + case elem.name + when "GO" + if elem["type"].to_i & 16 != 0 # Sanma. + #raise(Archive::UnsupportedArchiveError, "Sanma is not supported.") + return :broken + end + if elem["type"].to_i & 8 != 0 + @gametype = :tonnan + else + @gametype = :tonpu + end + when "SHUFFLE", "BYE" + # BYE: log out + return nil + when "UN" + if !@names # Somehow there can be multiple UN's. + escaped_names = (0...4).map(){ |i| elem["n%d" % i] } + return :broken if escaped_names.index(nil) # Something is wrong. + @names = escaped_names.map(){ |s| URI.decode(s) } + end + return nil + when "TAIKYOKU" + oya = elem["oya"].to_i() + log_name = elem["log"] || File.basename(self.path, ".mjlog") + uri = "http://tenhou.net/0/?log=%s&tw=%d" % [log_name, (4 - oya) % 4] + @first_kyoku_started = false + return do_action({:type => :start_game, :uri => uri, :names => @names, :gametype => @gametype}) + when "INIT" + if @first_kyoku_started + # Ends the previous kyoku. This is here because there can be multiple AGARIs in + # case of daburon, so we cannot detect the end of kyoku in AGARI. + do_action({:type => :end_kyoku}) + end + (kyoku_id, honba, _, _, _, dora_marker_pid) = elem["seed"].split(/,/).map(&:to_i) + bakaze = Pai.new("t", kyoku_id / 4 + 1) + kyoku_num = kyoku_id % 4 + 1 + oya = elem["oya"].to_i() + @first_kyoku_started = true + tehais_list = [] + for i in 0...4 + if i == 0 + hai_str = elem["hai"] || elem["hai0"] + else + hai_str = elem["hai%d" % i] + end + pids = hai_str ? hai_str.split(/,/) : [nil] * 13 + self.players[i].attributes.tenhou_tehai_pids = pids + tehais_list.push(pids.map(){ |s| pid_to_pai(s) }) + end + @is_afterfuro = false + do_action({ + :type => :start_kyoku, + :bakaze => bakaze, + :kyoku => kyoku_num, + :honba => honba, + :oya => self.players[oya], + :dora_marker => pid_to_pai(dora_marker_pid.to_s()), + :tehais => tehais_list, + }) + return nil + when /^([T-W])(\d+)?$/i + player_id = ["T", "U", "V", "W"].index($1.upcase) + pid = $2 + self.players[player_id].attributes.tenhou_tehai_pids.push(pid) + @is_afterfuro = false + return do_action({ + :type => :tsumo, + :actor => self.players[player_id], + :pai => pid_to_pai(pid), + }) + when /^([D-G])(\d+)?$/i + prefix = $1 + pid = $2 + player_id = ["D", "E", "F", "G"].index(prefix.upcase) + if @is_afterfuro + tsumogiri = false + elsif pid && pid == self.players[player_id].attributes.tenhou_tehai_pids[-1] + tsumogiri = true + elsif prefix != prefix.upcase + tsumogiri = true + else + tsumogiri = false + end + delete_tehai_by_pid(self.players[player_id], pid) + return do_action({ + :type => :dahai, + :actor => self.players[player_id], + :pai => pid_to_pai(pid), + :tsumogiri => tsumogiri, + }) + when "REACH" + actor = self.players[elem["who"].to_i()] + case elem["step"] + when "1" + return do_action({:type => :reach, :actor => actor}) + when "2" + deltas = [0, 0, 0, 0] + deltas[actor.id] = -1000 + # Old Tenhou archive doesn't have "ten" attribute. Calculates it manually. + scores = (0...4).map() do |i| + self.players[i].score + deltas[i] + end + return do_action({ + :type => :reach_accepted, + :actor => actor, + :deltas => deltas, + :scores => scores, + }) + else + raise("should not happen") + end + when "AGARI" + tehais = (elem["hai"].split(/,/) - [elem["machi"]]).map(){ |pid| pid_to_pai(pid) } + points_params = get_points_params(elem["sc"]) + (fu, hora_points, _) = elem["ten"].split(/,/).map(&:to_i) + if elem["yakuman"] + fan = Hora::YAKUMAN_FAN + else + fan = elem["yaku"].split(/,/).each_slice(2).map(){ |y, f| f.to_i() }.inject(0, :+) + end + uradora_markers = (elem["doraHaiUra"] || ""). + split(/,/).map(){ |pid| pid_to_pai(pid) } + + if elem["yakuman"] + yakus = elem["yakuman"]. + split(/,/). + map(){ |y| [YAKU_ID_TO_NAME[y.to_i()], Hora::YAKUMAN_FAN] } + else + yakus = elem["yaku"]. + split(/,/). + enum_for(:each_slice, 2). + map(){ |y, f| [YAKU_ID_TO_NAME[y.to_i()], f.to_i()] }. + select(){ |y, f| f != 0 } + end + + pao = elem["paoWho"] + + do_action({ + :type => :hora, + :actor => self.players[elem["who"].to_i()], + :target => self.players[elem["fromWho"].to_i()], + :pai => pid_to_pai(elem["machi"]), + :hora_tehais => tehais, + :uradora_markers => uradora_markers, + :fu => fu, + :fan => fan, + :yakus => yakus, + :hora_points => hora_points, + :deltas => points_params[:deltas], + :scores => points_params[:scores], + }.merge( pao!=nil ? {:pao=> self.players[pao.to_i()]} : {} ) ) + if elem["owari"] + do_action({:type => :end_kyoku}) + do_action({:type => :end_game, :scores => points_params[:scores]}) + end + return nil + when "RYUUKYOKU" + points_params = get_points_params(elem["sc"]) + tenpais = [] + tehais = [] + kyushu_act = nil + for i in 0...4 + name = "hai%d" % i + if elem[name] + tenpais.push(true) + kyushu_act = i + tehais.push(elem[name].split(/,/).map(){ |pid| pid_to_pai(pid) }) + else + tenpais.push(false) + tehais.push([Pai::UNKNOWN] * self.players[i].tehais.size) + end + end + reason_map = { + "yao9" => :kyushukyuhai, + "kaze4" => :sufonrenta, + "reach4" => :suchareach, + "ron3" => :sanchaho, + "nm" => :nagashimangan, + "kan4" => :sukaikan, + nil => :fanpai, + } + reason = reason_map[elem["type"]] + raise("unknown reason") if !reason + + ryu_act = {} + if reason == :kyushukyuhai then + ryu_act = {:actor => self.players[kyushu_act]} + tenpais = [false, false, false, false] + end + + # TODO add actor for some reasons + do_action({ + :type => :ryukyoku, + :reason => reason, + :tenpais => tenpais, + :tehais => tehais, + :deltas => points_params[:deltas], + :scores => points_params[:scores], + }.merge(ryu_act)) + if elem["owari"] + do_action({:type => :end_kyoku}) + do_action({:type => :end_game, :scores => get_points_params(elem["owari"], true)[:scores] }) + end + return nil + when "N" + actor = self.players[elem["who"].to_i()] + furo = TenhouFuro.new(elem["m"].to_i()) + consumed_pids = furo.type == :kakan ? [furo.taken_pid] : furo.consumed_pids + for pid in consumed_pids + delete_tehai_by_pid(actor, pid) + end + if [:pon, :chi].include?(furo.type) + @is_afterfuro = true + end + return do_action(furo.to_action(self, actor)) + when "DORA" + do_action({:type => :dora, :dora_marker => pid_to_pai(elem["hai"])}) + return nil + when "FURITEN" + return nil + else + raise("unknown tag name: %s" % elem.name) + end + end + + def path + return nil + end + + def get_points_params(sc_str, is_owari=false) + sc_nums = sc_str.split(/,/).map(&:to_i) + result = {} + result[:deltas] = (0...4).map(){ |i| sc_nums[2 * i + 1] * 100 } + result[:scores] = + (0...4).map(){ |i| sc_nums[2 * i] * 100 + (is_owari ? 0 : (result[:deltas][i])) } + return result + end + + def delete_tehai_by_pid(player, pid) + idx = player.attributes.tenhou_tehai_pids.index(){ |tp| !tp || tp == pid } + if !idx + raise("%d not found in %p" % [pid, player.attributes.tenhou_tehai_pids]) + end + player.attributes.tenhou_tehai_pids.delete_at(idx) + end + + def verify_tenhou_tehais() + for player in self.players + next if !player.tehais + tenhou_tehais = + player.attributes.tenhou_tehai_pids.map(){ |pid| pid_to_pai(pid) }.sort() + tehais = player.tehais.sort() + if tenhou_tehais != tehais + raise("tenhou_tehais != tehais: %p != %p" % [tenhou_tehais, tehais]) + end + end + end + + module_function + + def pid_to_pai(pid) + return pid ? get_pai(*decompose_pid(pid)) : Pai::UNKNOWN + end + + def decompose_pid(pid) + pid = pid.to_i() + return [ + (pid / 4) / 9, + (pid / 4) % 9 + 1, + pid % 4, + ] + end + + def compose_pid(type_id, number, cid) + return ((type_id * 9 + (number - 1)) * 4 + cid).to_s() + end + + def get_pai(type_id, number, cid) + type = ["m", "p", "s", "t"][type_id] + # TODO only for games with red 5p + red = type != "t" && number == 5 && cid == 0 + return Pai.new(type, number, red) + end + + end + + # http://p.tenhou.net/img/mentsu136.txt + class TenhouFuro + + include(Util) + + def initialize(fid) + @num = fid + @target_dir = read_bits(2) + if read_bits(1) == 1 + parse_chi() + return + end + if read_bits(1) == 1 + parse_pon() + return + end + if read_bits(1) == 1 + parse_kakan() + return + end + if read_bits(1) == 1 + parse_nukidora() + return + end + parse_kan() + end + + attr_reader(:type, :target_dir, :taken_pid, :consumed_pids) + + def to_action(game, actor) + params = { + :type => @type, + :actor => actor, + :consumed => @consumed_pids.map(){ |pid| pid_to_pai(pid) }, + } + if ![:ankan, :kakan].include?(@type) + params[:target] = game.players[(actor.id + @target_dir) % 4] + end + if @type != :ankan then + params[:pai] = pid_to_pai(@taken_pid) + end + return Action.new(params) + end + + def parse_chi() + cids = (0...3).map(){ |i| read_bits(2) } + read_bits(1) + pattern = read_bits(6) + seq_kind = pattern / 3 + taken_pos = pattern % 3 + pai_type = seq_kind / 7 + first_number = seq_kind % 7 + 1 + @type = :chi + @consumed_pids = [] + for i in 0...3 + pid = compose_pid(pai_type, first_number + i, cids[i]) + if i == taken_pos + @taken_pid = pid + else + @consumed_pids.push(pid) + end + end + end + + def parse_pon() + read_bits(1) + unused_cid = read_bits(2) + read_bits(2) + pattern = read_bits(7) + pai_kind = pattern / 3 + taken_pos = pattern % 3 + pai_type = pai_kind / 9 + pai_number = pai_kind % 9 + 1 + @type = :pon + @consumed_pids = [] + j = 0 + for i in 0...4 + next if i == unused_cid + pid = compose_pid(pai_type, pai_number, i) + if j == taken_pos + @taken_pid = pid + else + @consumed_pids.push(pid) + end + j += 1 + end + end + + def parse_kan() + read_bits(2) + pid = read_bits(8) + (pai_type, pai_number, key_cid) = decompose_pid(pid) + @type = @target_dir == 0 ? :ankan : :daiminkan + @consumed_pids = [] + for i in 0...4 + pid = compose_pid(pai_type, pai_number, i) + if i == key_cid && @type != :ankan + @taken_pid = pid + else + @consumed_pids.push(pid) + end + end + end + + def parse_kakan() + taken_cid = read_bits(2) + read_bits(2) + pattern = read_bits(7) + pai_kind = pattern / 3 + taken_pos = pattern % 3 + pai_type = pai_kind / 9 + pai_number = pai_kind % 9 + 1 + @type = :kakan + @target_dir = 0 + @consumed_pids = [] + for i in 0...4 + pid = compose_pid(pai_type, pai_number, i) + if i == taken_cid + @taken_pid = pid + else + @consumed_pids.push(pid) + end + end + end + + def read_bits(num_bits) + mask = (1 << num_bits) - 1 + result = @num & mask + @num >>= num_bits + return result + end + + end + + include(Util) + + def initialize(path, type= :gzip) + super() + @path = path + if type == :gzip + begin + Zlib::GzipReader.open(path) do |f| + @xml = f.read().force_encoding("utf-8") + end + return + rescue Zlib::GzipFile::Error + end + end + + File.open(path) do |f| + @xml = f.read().force_encoding("utf-8") + end + end + + attr_reader(:path) + attr_reader(:xml) + + def play() + if !@raw_action + @raw_action = [] + end + @doc = Nokogiri.XML(@xml) + elems = @doc.root.children + elems.each_with_index() do |elem, j| + begin + if on_tenhou_event(elem, elems[j + 1]) == :broken + raise "broken tenhou log" + break # Something is wrong. + end + rescue + $stderr.puts("While interpreting element: %s" % elem) + raise + end + end + end + + + def do_action(action) + if !action.kind_of?(Action) + action = Action.new(action) + end + @raw_action.push( Action.from_json(action.to_json(), self) ) + super(action) + end + + def actions + if !@raw_action + @raw_action = [] + self.play + end + return @raw_action + end + + + end + +end diff --git a/transmau_ws/mjai/tenpai_analysis.rb b/transmau_ws/mjai/tenpai_analysis.rb new file mode 100644 index 0000000..cd9b5fa --- /dev/null +++ b/transmau_ws/mjai/tenpai_analysis.rb @@ -0,0 +1,64 @@ +require "mjai/shanten_analysis" +require "mjai/pai" + + +module Mjai + + class TenpaiAnalysis + + ALL_YAOCHUS = Pai.parse_pais("19m19s19pESWNPFC") + + def initialize(pais) + @pais = pais + @shanten = ShantenAnalysis.new(@pais, 0) + end + + def tenpai? + return @shanten.shanten == 0 && + # 謇鍋煙驕ク謚槫庄閭ス縺ェ謇狗煙縺ァ蠕縺。繧剃スソ縺縺阪▲縺ヲ縺繧句エ蜷医r髯、螟 + ( @pais.size % 3 != 1 || self.waited_pais.any?{ |w| @pais.select{ |t| t.remove_red == w }.size < 4 } ) + end + + def waited_pais + raise(ArgumentError, "invalid number of pais") if @pais.size % 3 != 1 + raise("not tenpai") if @shanten.shanten != 0 + pai_set = Hash.new(0) + for pai in @pais + pai_set[pai.remove_red()] += 1 + end + result = [] + for mentsus in @shanten.combinations + case mentsus + when :chitoitsu + result.push(pai_set.find(){ |pai, n| n == 1 }[0]) + when :kokushimuso + missing = ALL_YAOCHUS - pai_set.keys + if missing.empty? + result += ALL_YAOCHUS + else + result.push(missing[0]) + end + else + case mentsus.select(){ |t, ps| t == :toitsu }.size + when 0 # 蜊倬ィ + (type, pais) = mentsus.find(){ |t, ps| t == :single } + result.push(pais[0]) + when 1 # 荳。髱「縲∬セコ蠑オ縲∝オ悟シオ + (type, pais) = mentsus.find(){ |t, ps| [:ryanpen, :kanta].include?(t) } + relative_numbers = type == :ryanpen ? [-1, 2] : [1] + result += relative_numbers.map(){ |r| pais[0].number + r }. + select(){ |n| (1..9).include?(n) }. + map(){ |n| Pai.new(pais[0].type, n) } + when 2 # 蜿檎「ー + result += mentsus.select(){ |t, ps| t == :toitsu }.map(){ |t, ps| ps[0] } + else + raise("should not happen") + end + end + end + return result.sort().uniq() + end + + end + +end diff --git a/transmau_ws/mjai/tsumogiri_player.rb b/transmau_ws/mjai/tsumogiri_player.rb new file mode 100644 index 0000000..3355db5 --- /dev/null +++ b/transmau_ws/mjai/tsumogiri_player.rb @@ -0,0 +1,20 @@ +require "mjai/player" + + +module Mjai + + class TsumogiriPlayer < Player + + def respond_to_action(action) + case action.type + when :tsumo, :chi, :pon + if action.actor == self + return create_action({:type => :dahai, :pai => self.tehais[-1], :tsumogiri => true}) + end + end + return nil + end + + end + +end diff --git a/transmau_ws/mjai/validation_error.rb b/transmau_ws/mjai/validation_error.rb new file mode 100644 index 0000000..c585dee --- /dev/null +++ b/transmau_ws/mjai/validation_error.rb @@ -0,0 +1,19 @@ +module Mjai + + class ValidationError < StandardError + end + + class GameFailError < StandardError + attr_reader(:player) + attr_reader(:orig_action) + attr_reader(:response) + + def initialize(message, player, orig_action, response) + super(message) + @player = player + @orig_action = orig_action + @response = response + end + end + +end diff --git a/transmau_ws/mjai/with_fields.rb b/transmau_ws/mjai/with_fields.rb new file mode 100644 index 0000000..eea0870 --- /dev/null +++ b/transmau_ws/mjai/with_fields.rb @@ -0,0 +1,18 @@ +module Mjai + + module WithFields + + def define_fields(names) + @field_names = names + @field_names.each() do |name| + define_method(name) do + return @fields[name] + end + end + end + + attr_reader(:field_names) + + end + +end diff --git a/transmau_ws/mjai/ws_client_game.rb b/transmau_ws/mjai/ws_client_game.rb new file mode 100644 index 0000000..6cc9344 --- /dev/null +++ b/transmau_ws/mjai/ws_client_game.rb @@ -0,0 +1,92 @@ +require "websocket-client-simple" + +require "rubygems" +require "json" + +require "mjai/game" +require "mjai/action" +require "mjai/puppet_player" + + +module Mjai + + class WSClientGame < Game + + def initialize(params) + super() + @params = params + end + + def play() + ws = WebSocket::Client::Simple.connect @params[:url] + wsout, wsin = IO.pipe + + ws.on :message do |msg| + wsin.puts msg + end + + ws.on :close do |e| + p e + exit 1 + end + + wsout.each_line() do |line| + puts("<-\t%s" % line.chomp()) + action_json = line.chomp() + action_obj = JSON.parse(action_json) + case action_obj["type"] + when "hello" + response_json = JSON.dump({ + "type" => "join", + "name" => @params[:name], + "room" => "default", + }) + when "error" + break + else + if action_obj["type"] == "start_game" + @my_id = action_obj["id"] + self.players = Array.new(4) do |i| + i == @my_id ? @params[:player] : PuppetPlayer.new(i) + end + end + action = Action.from_json(action_json, self) + + begin + responses = do_action(action) + break if action.type == :end_game + response = responses && responses[@my_id] + rescue GameFailError + response = { + :type => :error, + :actor => @my_id, + :message => "%s - Original Action: %s, My Response: %s" % [$!.message, $!.orig_action.to_s, $!.response.to_s] + } + rescue + ex = $! + mess = ("%s: %s (%p)\n" % [ex.backtrace[0], ex.message, ex.class]) + for s in ex.backtrace[1..-1] + mess += (" %s\n" % s) + end + response = { + :type => :error, + :actor => @my_id, + :message => ex.message, + :log => mess + } + end + + response_json = response ? response.to_json() : JSON.dump({"type" => "none"}) + end + puts("->\t%s" % response_json) + ws.send response_json + end + end + + def expect_response_from?(player) + return player.id == @my_id + end + + end + +end diff --git a/transmau_ws/mjai/ymatsux_shanten_analysis.rb b/transmau_ws/mjai/ymatsux_shanten_analysis.rb new file mode 100644 index 0000000..4243b84 --- /dev/null +++ b/transmau_ws/mjai/ymatsux_shanten_analysis.rb @@ -0,0 +1,105 @@ +require "mjai/pai" +require "mjai/mentsu" + + +module Mjai + + class YmatsuxShantenAnalysis + + NUM_PIDS = 9 * 3 + 7 + TYPES = ["m", "p", "s", "t"] + TYPE_TO_TYPE_ID = {"m" => 0, "p" => 1, "s" => 2, "t" => 3} + + def self.create_mentsus() + mentsus = [] + for i in 0...NUM_PIDS + mentsus.push([i] * 3) + end + for t in 0...3 + for n in 0...7 + pid = t * 9 + n + mentsus.push([pid, pid + 1, pid + 2]) + end + end + return mentsus + end + + MENTSUS = create_mentsus() + + def initialize(pais) + @pais = pais + count_vector = YmatsuxShantenAnalysis.pais_to_count_vector(pais) + @shanten = YmatsuxShantenAnalysis.calculate_shantensu_internal(count_vector, [0] * NUM_PIDS, 4, 0, 1.0/0.0) + end + + attr_reader(:pais, :shanten) + + def self.pais_to_count_vector(pais) + count_vector = [0] * NUM_PIDS + for pai in pais + count_vector[pai_to_pid(pai)] += 1 + end + return count_vector + end + + def self.pai_to_pid(pai) + return TYPE_TO_TYPE_ID[pai.type] * 9 + (pai.number - 1) + end + + def self.pid_to_pai(pid) + return Pai.new(TYPES[pid / 9], pid % 9 + 1) + end + + def self.calculate_shantensu_internal( + current_vector, target_vector, left_mentsu, min_mentsu_id, found_min_shantensu) + min_shantensu = found_min_shantensu + if left_mentsu == 0 + for pid in 0...NUM_PIDS + target_vector[pid] += 2 + if valid_target_vector?(target_vector) + shantensu = calculate_shantensu_lowerbound(current_vector, target_vector) + min_shantensu = [shantensu, min_shantensu].min + end + target_vector[pid] -= 2 + end + else + for mentsu_id in min_mentsu_id...MENTSUS.size + add_mentsu(target_vector, mentsu_id) + lower_bound = calculate_shantensu_lowerbound(current_vector, target_vector) + if valid_target_vector?(target_vector) && lower_bound < found_min_shantensu + shantensu = calculate_shantensu_internal( + current_vector, target_vector, left_mentsu - 1, mentsu_id, min_shantensu) + min_shantensu = [shantensu, min_shantensu].min + end + remove_mentsu(target_vector, mentsu_id) + end + end + return min_shantensu + end + + def self.calculate_shantensu_lowerbound(current_vector, target_vector) + count = (0...NUM_PIDS).inject(0) do |c, pid| + c + (target_vector[pid] > current_vector[pid] ? target_vector[pid] - current_vector[pid] : 0) + end + return count - 1 + end + + def self.valid_target_vector?(target_vector) + return target_vector.all?(){ |c| c <= 4 } + end + + def self.add_mentsu(target_vector, mentsu_id) + for pid in MENTSUS[mentsu_id] + target_vector[pid] += 1 + end + end + + def self.remove_mentsu(target_vector, mentsu_id) + for pid in MENTSUS[mentsu_id] + target_vector[pid] -= 1 + end + end + + end + +end diff --git a/transmau_ws/test.rb b/transmau_ws/test.rb new file mode 100644 index 0000000..71278c8 --- /dev/null +++ b/transmau_ws/test.rb @@ -0,0 +1,21 @@ +$:.unshift File.dirname(__FILE__) + +require 'mjai/ws_client_game.rb' + +$dllname = "MaujongPlugin/%s.dll" % ARGV[0] +if ( ARGV[0].include?("/") || ARGV[0].include?("\\") ) then + $dllname = $ARGV[0] +end + +require 'wrapper_player.rb' + +player = TransMaujong::WrapperPlayer.new + +game = Mjai::WSClientGame.new({ + :player => player, + :url => "ws://www.logos.t.u-tokyo.ac.jp/mjai/", + :name => player.name +# :name => "Akagi" +}) + +game.play() diff --git a/transmau_ws/wrapper_player.rb b/transmau_ws/wrapper_player.rb new file mode 100644 index 0000000..d700164 --- /dev/null +++ b/transmau_ws/wrapper_player.rb @@ -0,0 +1,980 @@ +require 'pp' +require 'fiddle/import' + +require 'mjai/pai.rb' +require 'mjai/player.rb' +require 'mjai/hora.rb' +require 'mjai/tenpai_analysis.rb' + +require './mipiface.rb' +require './bit_operation.rb' + +module TransMaujong + VERSION = 12 + + + PADDING_NUM = 0 + + class WrapperPlayer < Mjai::Player + include BitOperation + + module M extend Fiddle::Importer + #DLLNAME = "debugging/Debug/debugging.dll" + #DLLNAME = "debugging/wrapper/DebugWorking/wrapper.dll" + #DLLNAME = "MaujongPlugin/Occam0.31.dll" + + #dlload DLLNAME + dlload $dllname + + UINT_TYPE = -Fiddle::TYPE_INT + UINT_STRING = "unsigned long" + + MJITehai = struct ([ + "#{UINT_STRING} tehai[14]", + "#{UINT_STRING} tehai_max", + "#{UINT_STRING} minshun[4]", + "#{UINT_STRING} minshun_max", + "#{UINT_STRING} minkou[4]", + "#{UINT_STRING} minkou_max", + "#{UINT_STRING} minkan[4]", + "#{UINT_STRING} minkan_max", + "#{UINT_STRING} ankan[4]", + "#{UINT_STRING} ankan_max", + + "#{UINT_STRING} reserved1", + "#{UINT_STRING} reserved2" + ]) + + MJITehai1 = struct ([ + "#{UINT_STRING} tehai[14]", + "#{UINT_STRING} tehai_max", + "#{UINT_STRING} minshun[4]", + "#{UINT_STRING} minshun_max", + "#{UINT_STRING} minkou[4]", + "#{UINT_STRING} minkou_max", + "#{UINT_STRING} minkan[4]", + "#{UINT_STRING} minkan_max", + "#{UINT_STRING} ankan[4]", + "#{UINT_STRING} ankan_max", + + "#{UINT_STRING} minshun_hai[12]", + "#{UINT_STRING} minkou_hai[12]", + "#{UINT_STRING} minkan_hai[16]", + "#{UINT_STRING} ankan_hai[16]", + + "#{UINT_STRING} reserved1", + "#{UINT_STRING} reserved2" + ]) + + #MJIKawahai = struct ([ + # "unsigned short hai", + # "unsigned short state" + #]) + + # 3rd and 4th arguments should be able to contain a memory address for the environment where this program will be executed + # therefore in 64bit environments, they should be able to contain 64bit unsigned integer + extern "#{UINT_STRING} MJPInterfaceFunc(void *, #{UINT_STRING}, #{UINT_STRING}, #{UINT_STRING})", :stdcall + end + + module STD extend Fiddle::Importer + dlload "msvcrt.dll" + extern 'void * memmove(void *, void *, unsigned long)' + end + + attr_reader :name + + def initialize + super() + + inst_size = M.MJPInterfaceFunc(nil, MJPI::CREATEINSTANCE, 0, 0) + safe_margin = 2 ** 10 + @malloc_size = inst_size + safe_margin + + @instance_ptr = Fiddle::Pointer.malloc(@malloc_size) + # 縺縺。縺翫≧蛻晄悄蛹 + @instance_ptr[0, @malloc_size] = "\0" * @malloc_size + + @struct_type = 0 + + callback_return_type = M::UINT_TYPE + callback_signature = [Fiddle::TYPE_VOIDP, -M::UINT_TYPE, -M::UINT_TYPE, -M::UINT_TYPE] + + @callback_closure = Fiddle::Closure::BlockCaller.new(callback_return_type, callback_signature, abi=Fiddle::Function::STDCALL) do |inst, message, p1, p2| + callback(inst, message, p1, p2) + end + + callback_addr = Fiddle::Pointer[@callback_closure].to_i + + unless M.MJPInterfaceFunc(@instance_ptr, MJPI::INITIALIZE, 0, callback_addr) == 0 then + raise "Initialization of plugin failed" + end + + @name = + #"cheat_" + + "dll_" + + Fiddle::Pointer[M.MJPInterfaceFunc(nil, MJPI::YOURNAME, 0, 0)].to_s.encode("UTF-8", "Shift_JIS") + end + + def self.finalizer + M.MJPInterfaceFunc(nil, MJPI::DESTROY, 0, 0) + Fiddle.free(@instance_ptr) + end + + def relative_seat_pos(target, base) + return (4 + target - base) % 4 + end + + def absolute_seat_pos(target, base) + return (base + target) % 4 + end + + def self.vaild_tile_number?(tile, struct_type) + if struct_type == 1 then + [*0..33, 68, 77, 86].include?(tile) + else + [*0..33].include?(tile) + end + end + + def respond_to_action(action) + pp action + + response = + case action.type + when :start_game then on_game_start + when :end_game then on_game_end(action) + when :start_kyoku then on_round_start(action) + when :end_kyoku then on_round_end(action) + when :tsumo then on_draw(action) + when :reach then on_reach(action) + when :chi then on_chow(action) + when :pon then on_pong(action) + when :ankan, :daiminkan, :kakan then on_kong(action) + when :dahai then on_discard(action) + when :ryukyoku then on_ryukyoku(action) + when :hora then on_hora(action) + else nil + end + + pp response + return response + end + + def callback(inst, message, p1, p2) + + ret = + case message + when MJMI::GETTEHAI then on_get_tehai(p1, p2) + when MJMI::GETMACHI then on_get_machi(p1, p2) + when MJMI::GETAGARITEN then on_get_agari_ten(p1, p2) + when MJMI::GETKAWA then on_get_kawa(p1, p2) + when MJMI::GETKAWAEX then on_get_kawa_ex(p1, p2) + when MJMI::GETDORA then on_get_dora(p1, p2) + when MJMI::GETSCORE then on_get_score(p1, p2) + when MJMI::GETKYOKU then on_get_kyoku(p1, p2) + when MJMI::GETHONBA then on_get_honba(p1, p2) + when MJMI::GETREACHBOU then on_get_reach_bou(p1, p2) + when MJMI::GETHAIREMAIN then on_get_hai_remain(p1, p2) + when MJMI::GETVISIBLEHAIS then on_get_visible_hais(p1, p2) + when MJMI::KKHAIABILITY then on_kk_hai_ability(p1, p2) + when MJMI::ANKANABILITY then on_ankan_ability(p1, p2) + when MJMI::SSPUTOABILITY then 0 + when MJMI::LASTTSUMOGIRI then on_last_tsumogiri(p1, p2) + when MJMI::GETRULE then on_get_rule(p1, p2) + when MJMI::SETSTRUCTTYPE then on_set_struct_type(p1, p2) + when MJMI::FUKIDASHI then on_fukidashi(p1, p2) + when MJMI::SETAUTOFUKIDASHI then 0 + when MJMI::GETWAREME then 0 + when MJMI::GETVERSION then VERSION + else raise(ArgumentError, "Unknown callback message: #{message}") + end + + puts "Send: %d (%d, %d) ret=%d" % [message, p1, p2, ret] + + return ret + end + + def on_set_struct_type(p1, p2) + if [0, 1].include?(p1) then + old_type = @struct_type + @struct_type = p1 + return old_type + end + + return MJR::NOTCARED + end + + def on_fukidashi(p1, p2) + str = Fiddle::Pointer[p1].to_s.encode("UTF-8", "Shift_JIS") + puts "Fukidashi: %s" % str + return 1 + end + + def on_last_tsumogiri(p1, p2) + prev = self.game.previous_action + + return (@last_is_tsumogiri) ? 1 : 0 + end + + def on_get_rule(p1, p2) + # http://tenhou.net/man/#RULE + + case p1 + when MJRL::KUITAN then 1 + when MJRL::KANSAKI then 0 + when MJRL::PAO then 1 + when MJRL::RON then 1 + when MJRL::MOCHITEN then 250 #縺ェ縺懊°x100縺ョ蜊倅ス + when MJRL::BUTTOBI then 1 + when MJRL::WAREME then 0 + when MJRL::AKA5 then 1 + when MJRL::AKA5S then 0b0001_0001_0001 # 5sr => 1, 5pr => 1, 5mr => 1 + when MJRL::SHANYU then 0 #2 莉・荳九∝濠闕俶姶縺ョ蝣エ蜷 + when MJRL::SHANYU_SCORE then 0 #30000 #縺薙%縺ッ縺ェ縺懊°蠕礼せ縺昴ョ縺セ縺セ + when MJRL::NANNYU then 2 #1 + when MJRL::NANNYU_SCORE then 30000 + when MJRL::KUINAOSHI then 0 + when MJRL::URADORA then 2 + when MJRL::SCORE0REACH then 0 + when MJRL::RYANSHIBA then 0 + when MJRL::DORAPLUS then 1 + when MJRL::FURITEN_REACH then 0b11 + when MJRL::KARATEN then 1 + when MJRL::PINZUMO then 1 + when MJRL::NOTENOYANAGARE then 0b1111 + when MJRL::KANINREACH then 1 + when MJRL::TOPOYAAGARIEND then 1 + when MJRL::KIRIAGE_MANGAN then 0 + when MJRL::DBLRONCHONBO then 0 + else raise(ArgumentError, "Unknown rule: #{p1}") + end + end + + def on_get_score(p1, p2) + target_wind = absolute_seat_pos(p1, self.id) + + return self.game.players[target_wind].score + end + + def on_get_kyoku(p1, p2) + return @round + end + + def on_get_honba(p1, p2) + return self.game.honba + end + + def on_get_reach_bou(p1, p2) + return @reach_bed + end + + def self.print_mjitehai(mjitehai) + puts "[" + puts " [#{mjitehai.tehai_max}]#{mjitehai.tehai}" + puts " [#{mjitehai.minshun_max}]#{mjitehai.minshun}" + puts " [#{mjitehai.minkou_max}]#{mjitehai.minkou}" + puts " [#{mjitehai.minkan_max}]#{mjitehai.minkan}" + puts " [#{mjitehai.ankan_max}]#{mjitehai.ankan}" + puts "]" + end + + def self.get_mjitehai(dest_ptr, tehais_, furos_, contain_tsumo) + tehais, furos = tehais_.dup, furos_.dup + + # remove unknown tiles + tehais.reject! { |p| p.to_s == "?" } + + # remove the tile just drawn in this turn + tehais.pop if contain_tsumo + + chows = furos.select{ |f| f.type == :chi } + pongs = furos.select{ |f| f.type == :pon } + open_kongs = furos.select{ |f| f.type == :daiminkan || f.type == :kakan } + closed_kongs = furos.select{ |f| f.type == :ankan } + + mji = M::MJITehai.new(dest_ptr) + + mji.tehai_max = tehais.size + + mji.minshun_max = chows.size + mji.minkou_max = pongs.size + mji.minkan_max = open_kongs.size + mji.ankan_max = closed_kongs.size + + mji.tehai = [tehais.map(&:to_mau_i), [PADDING_NUM] * (14 - mji.tehai_max)].flatten + + least_tile = -> furo { furo.pais.flatten.min } + array_maker = -> furos { [furos.map(&least_tile).map(&:to_mau_i), [PADDING_NUM] * (4 - furos.size)].flatten } + + mji.minshun = array_maker[chows] + mji.minkou = array_maker[pongs] + mji.minkan = array_maker[open_kongs] + mji.ankan = array_maker[closed_kongs] + + puts "get_mjitehai (%d)" % mji.tehai_max + + return mji + end + + def self.get_mjitehai1(dest_ptr, tehais_, furos_, contain_tsumo) + tehais, furos = tehais_.dup, furos_.dup + + tehais.reject! { |p| p.to_s == "?" } + + # remove the tile just drawn in this turn + tehais.pop if contain_tsumo + + chows = furos.select{ |f| f.type == :chi } + pongs = furos.select{ |f| f.type == :pon } + open_kongs = furos.select{ |f| f.type == :daiminkan || f.type == :kakan } + closed_kongs = furos.select{ |f| f.type == :ankan } + + mji1 = M::MJITehai1.new(dest_ptr) + + mji1.tehai_max = tehais.size + + mji1.minshun_max = chows.size + mji1.minkou_max = pongs.size + mji1.minkan_max = open_kongs.size + mji1.ankan_max = closed_kongs.size + + mji1.tehai = [tehais.map(&:to_mau_i_r), [PADDING_NUM] * (14 - mji1.tehai_max)].flatten + + least_tile = -> furo { furo.pais.min } + # minshun 遲峨↓縺ッ襍、縺ェ縺励ョ逡ェ蜿キ縺ァ繧医>シ医i縺励>シ + array_maker = -> furos { [furos.map(&least_tile).map(&:to_mau_i), [PADDING_NUM] * (4 - furos.size)].flatten } + + mji1.minshun = array_maker[chows] + mji1.minkou = array_maker[pongs] + mji1.minkan = array_maker[open_kongs] + mji1.ankan = array_maker[closed_kongs] + + r_ch = [PADDING_NUM]*12 + chows.size.times { |i| + r_ch[0*4 +i] = chows[i].taken.to_mau_i_r + r_ch[1*4 +i] = chows[i].consumed[0].to_mau_i_r + r_ch[2*4 +i] = chows[i].consumed[1].to_mau_i_r + } + mji1.minshun_hai = r_ch + + r_po = [PADDING_NUM]*12 + pongs.size.times { |i| + r_po[0*4 +i] = pongs[i].taken.to_mau_i_r + r_po[1*4 +i] = pongs[i].consumed[0].to_mau_i_r + r_po[2*4 +i] = pongs[i].consumed[1].to_mau_i_r + } + mji1.minkou_hai = r_po + + r_oko = [PADDING_NUM]*16 + open_kongs.size.times { |i| + r_oko[0*4 +i] = open_kongs[i].taken.to_mau_i_r + r_oko[1*4 +i] = open_kongs[i].consumed[0].to_mau_i_r + r_oko[2*4 +i] = open_kongs[i].consumed[1].to_mau_i_r + r_oko[3*4 +i] = open_kongs[i].consumed[2].to_mau_i_r + } + mji1.minkan_hai = r_oko + + r_cko = [PADDING_NUM]*16 + closed_kongs.size.times { |i| + for pj in 0..3 do + r_cko[pj*4 +i] = closed_kongs[i].consumed[pj].to_mau_i_r + end + } + mji1.ankan_hai = r_cko + + + return mji1 + end + + def on_get_tehai(p1, p2) + target_seat = absolute_seat_pos(p1, self.id) + target_player = self.game.players[target_seat] + + if @struct_type == 0 then + WrapperPlayer.get_mjitehai(p2, target_player.tehais, target_player.furos, @tehais_contain_tsumo) + elsif @struct_type == 1 then + WrapperPlayer.get_mjitehai1(p2, target_player.tehais, target_player.furos, @tehais_contain_tsumo) + else + throw "Unknown struct_type " + @struct_type.to_s + end + + return 1 + end + + def on_get_machi(p1, p2) + hand = + if p1 == 0 then + self.tehais.dup + else + target_pais, target_furos = WrapperPlayer.get_tiles(p1, @struct_type) + target_pais + end + + result = [0]*34 + + hand.pop if hand.size % 3 == 2 + if hand.size % 3 != 1 + pp hand + throw "get_machi hand is not 3n+1" + end + + ta = Mjai::TenpaiAnalysis.new(hand) + + is_tenpai = ta.tenpai? + + if is_tenpai then + waited = ta.waited_pais + + waited.each do |pai| + result[pai.to_mau_i] = 1 + end + end + + STD.memmove(p2, result.pack("V*"), Fiddle::SIZEOF_INT * 34) + + return (is_tenpai) ? 1 : 0 + end + + def on_get_visible_hais(p1, p2) + visibles = self.game.players.map { |player| player.sutehais.map(&:to_mau_i) } .flatten + return visibles.count(p1) + end + + def on_get_dora(p1, p2) + doras = self.game.dora_markers.map { |dora_marker| dora_marker.succ.to_mau_i } + STD.memmove(p1, doras.pack("V*"), Fiddle::SIZEOF_INT * doras.size) + + return doras.size + end + + def on_get_kawa(p1, p2) + target_id = absolute_seat_pos(loword(p1), self.id) + target_ho = game.players[target_id].ho + + result_size = [hiword(p1), target_ho.size].min + STD.memmove(p2, target_ho.map(&:to_mau_i).pack("V*"), Fiddle::SIZEOF_INT * result_size) + + return result_size + end + + def on_get_kawa_ex(p1, p2) + target_id = absolute_seat_pos(loword(p1), self.id) + target_player = game.players[target_id] + target_ho = target_player.ho + target_sutehais = target_player.sutehais + + reached_tile_index = target_player.reach_ho_index + + kawahai_size = 4 #unsigned short * 2 + result_size = [hiword(p1), target_sutehais.size].min + result = [] + + target_sutehais.each_with_index do |pai, i| + break if i >= result_size + + hai = pai.to_mau_i + state = 0 + state |= MJKS::REACH if reached_tile_index && reached_tile_index == i + state |= MJKS::NAKI unless target_ho.include?(pai) + + result << hai + result << state + end + + STD.memmove(p2, result.pack("v*"), kawahai_size * result_size) + + return result_size + end + + def on_get_hai_remain(p1, p2) + return self.game.num_pipais + end + + def on_kk_hai_ability(p1, p2) + return 0 if !self.game.first_turn? + + num_terminals_and_honors = self.tehais.uniq.count { |pai| pai.yaochu? } + + return (num_terminals_and_honors >= 9) ? 1 : 0 + end + + def on_ankan_ability(p1, p2) + kanlist = self.possible_furo_actions.select{ |f| [:ankan, :kakan].include?(f.type) }.map{ |f| f.consumed[0].to_mau_i } + + if ( kanlist.size == 0 ) then + return 0 + end + + STD.memmove(p1, result.pack("v*"), 2 * kanlist.size) + return kanlist.size + end + + def self.get_tiles(pointer, struct_type) + + mji = nil + melds = [] + + if struct_type == 0 + mji = M::MJITehai.new(pointer) + + mji.minshun_max.times { |i| + taken = Mjai::Pai.from_mau_i( mji.minshun[i] ) + melds << Mjai::Furo.new({:type => :chi, :taken => taken, :consumed => [taken.succ, taken.succ.succ] }) + } + mji.minkou_max.times { |i| + taken = Mjai::Pai.from_mau_i( mji.minkou[i] ) + melds << Mjai::Furo.new({:type => :pon, :taken => taken, :consumed => [taken]*2 }) + } + mji.minkan_max.times { |i| + taken = Mjai::Pai.from_mau_i( mji.minkan[i] ) + melds << Mjai::Furo.new({:type => :kakan, :taken => taken, :consumed => [taken]*3 }) + } + mji.ankan_max.times { |i| + taken = Mjai::Pai.from_mau_i( mji.ankan[i] ) + melds << Mjai::Furo.new({:type => :ankan, :consumed => [taken]*4 }) + } + + elsif struct_type == 1 + + mji = M::MJITehai1.new(pointer) + hand = mji.tehai[0...mji.tehai_max].map { |pai| Mjai::Pai.from_mau_i(pai) } + + + mji.minshun_max.times { |i| + taken = Mjai::Pai.from_mau_i( mji.minshun_hai[0*4 +i] ) + consumed = [1,2].map{ |pn| Mjai::Pai.from_mau_i( mji.minshun_hai[pn*4 +i] ) } + melds << Mjai::Furo.new({:type => :chi, :taken => taken, :consumed => consumed }) + } + mji.minkou_max.times { |i| + taken = Mjai::Pai.from_mau_i( mji.minkou_hai[0*4 +i] ) + consumed = [1,2].map{ |pn| Mjai::Pai.from_mau_i( mji.minkou_hai[pn*4 +i] ) } + melds << Mjai::Furo.new({:type => :pon, :taken => taken, :consumed => consumed }) + } + mji.minkan_max.times { |i| + taken = Mjai::Pai.from_mau_i( mji.minkan_hai[0*4 +i] ) + consumed = [1,2,3].map{ |pn| Mjai::Pai.from_mau_i( mji.minkan_hai[pn*4 +i] ) } + melds << Mjai::Furo.new({:type => :kakan, :taken => taken, :consumed => consumed }) + } + mji.ankan_max.times { |i| + consumed = [0,1,2,3].map{ |pn| Mjai::Pai.from_mau_i( mji.minkan_hai[pn*4 +i] ) } + melds << Mjai::Furo.new({:type => :ankan, :consumed => consumed }) + } + + + else + throw "Unknown struct_type " + struct_type.to_s + end + + + hand = [] + mji.tehai[0...mji.tehai_max].each { |pai| + if WrapperPlayer.vaild_tile_number?(pai, struct_type) + hand << Mjai::Pai.from_mau_i(pai) + else + #繧「繧ォ繧ョ縺後%縺縺縺縺薙→繧偵@縺ヲ縺繧九@縲√∪縺縺倥c繧薙b縺薙l縺ァ蜍穂ス懊@縺ヲ縺繧九ョ縺ァ + end + } + + + p "get_tiles (structtype " + struct_type.to_s + ")" + pp hand + pp melds + + return [hand, melds] + end + + def on_get_agari_ten(p1, p2) + agari_hai = Mjai::Pai.from_mau_i(p2) + + target_pais = [] + target_furos = [] + + if p1 == 0 then + target_pais = tehais.dup + target_furos = furos.dup + else + target_pais, target_furos = WrapperPlayer.get_tiles(p1, @struct_type) + end + + if target_pais.size % 3 == 2 + target_pais.pop + end + if target_pais.size % 3 != 1 + pp target_pais + throw "on_get_agari_ten hand is not 3n+1" + end + + if Mjai::ShantenAnalysis.new(target_pais + [agari_hai], -1).shanten > -1 + return 0 + end + + hora = Mjai::Hora.new({ + :tehais => target_pais, + :furos => target_furos, + :taken => agari_hai, + + :oya => self.game.oya == self, + :bakaze => self.game.bakaze, + :jikaze => self.jikaze, + :doras => self.game.doras, + :reach => self.reach?, + :double_reach => self.double_reach?, + :hora_type => @tehais_contain_tsumo ? :tsumo : :ron, + :uradoras => [], + :ippatsu => self.ippatsu_chance?, + :rinshan => self.rinshan?, + :haitei => (self.game.num_pipais == 0 && !self.rinshan?), + :first_turn => self.game.first_turn?, + :chankan => self.game.previous_action ? self.game.previous_action.type == :kakan : false + }) + + return (hora.valid?) ? hora.points : 0 + end + + def on_draw(action) + @declaration_action = nil + + return nil if action.actor != self + + @tehais_contain_tsumo = true + + res = M.MJPInterfaceFunc(@instance_ptr, MJPI::SUTEHAI, action.pai.to_mau_i, 0) + puts "Draw (%d, %d) res = %d" % [action.pai.to_mau_i, 0, res] + p self.tehais + + + orig_tile_ind = res & MJPIR::HAI_MASK # 0x0000** + next_action = res - orig_tile_ind # 0x****00 + if res == MJR::NOTCARED then + orig_tile_ind = 13 + next_action = MJPIR::SUTEHAI + end + + # 縺セ縺縺倥c繧薙ョ繧オ繧、繝医↓縺ッ縲√ヤ繝「蛻繧翫ッ蟶ク縺ォ13縺ォ縺ェ繧九→縺ゅk縺後∝憶髴イ譎ゅ↓縺昴≧縺ェ繧峨↑縺ДLL繧ゅ≠繧翫◎縺縺ェ縺ョ縺ァ + tile_ind = [orig_tile_ind, self.tehais.size - 1].min + is_tsumogiri = (tile_ind == (self.tehais.size-1)) + + response = + case next_action + when MJPIR::SUTEHAI then + next_tile = self.tehais[tile_ind] + create_action({:type => :dahai, :pai => next_tile, :tsumogiri => is_tsumogiri}) + + when MJPIR::REACH then + next_tile = self.tehais[tile_ind] + + if self.can_reach? + @declaration_action = create_action({:type => :dahai, :pai => next_tile, :tsumogiri => is_tsumogiri}) + create_action({:type => :reach}) + else + create_action({:type => :dahai, :pai => next_tile, :tsumogiri => is_tsumogiri, :log => "DLLWarning: Tried REACH but cannot reach now. res = 0x%x" % res}) + end + + when MJPIR::KAN then + tile_in_quad = Mjai::Pai.from_mau_i(orig_tile_ind).remove_red() + + puts "MJPIR::KAN tile_in_quad" + p tile_in_quad + + furoact = self.possible_furo_actions.select do |f| + ([:ankan, :kakan].include?(f.type)) && f.consumed.include?(tile_in_quad) + end + + if furoact.size > 0 then + furoact.first + else + tile_by_index = self.tehais[tile_ind] + furoact = self.possible_furo_actions.select do |f| + ([:ankan, :kakan].include?(f.type)) && f.consumed.include?(tile_by_index) + end + + if furoact.size > 0 then + furoact.first.merge({:log => "DLLWarning: KAN tile specified by index (should be hai_no). res = 0x%x" % res}) + else + nil + end + end + + when MJPIR::TSUMO then + create_action({:type => :hora, :target => self, :pai => action.pai}) + + when MJPIR::NAGASHI then + create_action({:type => :ryukyoku, :reason => :kyushukyuhai}) + + else + nil + end + + @tehais_contain_tsumo = false + + if response == nil then + raise Mjai::GameFailError.new("Unexpected MJPI::SUTEHAI result 0x%x" % res, self.id, action.to_s, nil) + end + + if !(defined? resonse.log) then + response = response.merge({:log => "sres0x%x" % res}) + end + + puts "on_draw decision:" + p response + return response + end + + def on_reach(action) + @reach_bed += 1 + + return nil if action.actor != self + + unless @declaration_action then + raise(ArgumentError, "reach declaration action not found") + end + + return @declaration_action + end + + def on_chow(action) + actor_seat = relative_seat_pos(action.actor.id, self.id) + target_seat = relative_seat_pos(action.target.id, self.id) + + melded = action.pai.to_mau_i + consumed = action.consumed.map(&:to_mau_i).sort + + chi_flag = 0 + + if melded < consumed.min then + chi_flag = MJPIR::CHII1 + elsif melded > consumed.max then + chi_flag = MJPIR::CHII2 + else + chi_flag = MJPIR::CHII3 + end + + melded = action.pai.to_mau_i_r if @struct_type == 1 + + M.MJPInterfaceFunc(@instance_ptr, MJPI::ONACTION, make_lparam(target_seat, actor_seat), chi_flag | melded) + p "on_chow" + p "send onaction 0x%x 0x%x" % [ ( make_lparam(target_seat, actor_seat)), chi_flag | melded] + + return action_after_meld(action) + end + + def on_pong(action) + actor_seat = relative_seat_pos(action.actor.id, self.id) + target_seat = relative_seat_pos(action.target.id, self.id) + + melded = action.pai.to_mau_i + melded = action.pai.to_mau_i_r if @struct_type == 1 + + M.MJPInterfaceFunc(@instance_ptr, MJPI::ONACTION, make_lparam(target_seat, actor_seat), MJPIR::PON | melded) + + return action_after_meld(action) + end + + def action_after_meld(action) + return nil if action.actor != self + + res = M.MJPInterfaceFunc(@instance_ptr, MJPI::SUTEHAI, 0x3f, 0) + puts "After (%d, %d) res = %d" % [0x3f, 0, res] + + if res == MJR::NOTCARED then + return create_action({:type => :dahai, :pai => self.possible_dahais[-1], :tsumogiri => false, :log => "mres0x%x" % res}) + end + + orig_tile_ind = res & MJPIR::HAI_MASK + next_action = res - orig_tile_ind + tile_ind = [orig_tile_ind, self.tehais.size - 1].min + + response = + case next_action + when MJPIR::SUTEHAI then + create_action({:type => :dahai, :pai => self.tehais[tile_ind], :tsumogiri => false, :log => "mres0x%x" % res}) + + +# FIXME # +# when MJPIR::TSUMO then # win by drawing a replacement tile (rinshan-kaiho) +# self.possible_actions.select { |a| a.type == :hora } .first +# +# when MJPIR::KAN then +# self.possible_furo_actions.select { |f| f.type == :kan && f.consumed.include?(Mjai::Pai.from_mau_i(tile_id)) } .first +# + else + raise(ArgumentError, "invalid action after meld: res = 0x%x" % res) + end + + return response + end + + def on_kong(action) + actor_seat = relative_seat_pos(action.actor.id, self.id) + + if [:ankan, :kakan].include?(action.type) + target_seat = actor_seat + else + target_seat = relative_seat_pos(action.target.id, self.id) + end + + type = (action.type == :ankan) ? MJPIR::ANKAN : MJPIR::MINKAN + + pai_id = action.consumed[0].to_mau_i + + res = M.MJPInterfaceFunc(@instance_ptr, MJPI::ONACTION, make_lparam(target_seat, actor_seat), type | pai_id) + puts "Kan (%d, %d) res = %d" % [make_lparam(target_seat, actor_seat), type | pai_id, res] + + if res == 0 || res == MJR::NOTCARED then + return nil + end + + # 讒肴ァ + if (res & MJPIR::RON ) != 0 then + act = self.possible_actions.select { |a| a.type == :hora } .first + + if act == nil then + raise Mjai::GameFailError.new("Unexpected MJPI::ONACTION (Chankan) result 0x%x" % res, self.id, action.to_s, nil) + end + + return act.merge({:log => "Chankan res = 0x%x" % res}) + end + + # 繧ォ繝ウ縺ョ縺ゅ→縺ッ繝ェ繝ウ繧キ繝」繝ウ繧貞シ輔¥縺九i縲]il繧定ソ斐@縺ヲon_draw縺ョ逋コ逕溘r蠕縺、 + return nil + end + + def on_discard(action) + actor_seat = relative_seat_pos(action.actor.id, id) + + prev = self.game.previous_action + + occured = (prev.type == :reach) ? MJPIR::REACH : MJPIR::SUTEHAI + + @last_is_tsumogiri = action.tsumogiri + + res = M.MJPInterfaceFunc(@instance_ptr, MJPI::ONACTION, make_lparam(actor_seat, actor_seat), occured | action.pai.to_mau_i) + puts "Discard(%d, %d) res = %d" % [make_lparam(actor_seat, actor_seat), occured | action.pai.to_mau_i, res] + + return nil if res == 0 || res == MJR::NOTCARED + + no_aka5_flag = res & MJPIR::HAI_MASK + prefer_aka5 = no_aka5_flag == 0 + next_action = res - no_aka5_flag + + furos = self.possible_actions + + response = + case next_action + when MJPIR::RON + furos.select { |f| f.type == :hora } .first + + when MJPIR::KAN + furos.select { |f| f.type == :daiminkan } .first + + when MJPIR::PON + pons = furos.select { |f| f.type == :pon } + pons.sort_by! { |f| [f.pai, f.consumed].flatten.count { |pai| pai.red? } } + + (prefer_aka5) ? pons.first : pons.last + + when MJPIR::CHII1, MJPIR::CHII2, MJPIR::CHII3 + WrapperPlayer.make_chow(furos, next_action, prefer_aka5) + + else + raise(ArgumentError, "invalid action on_action: res = 0x%x" % res) + + end + + if !response + raise Mjai::GameFailError.new("Unexpected MJPI::ONACTION (Discard) result 0x%x" % res, self.id, action.to_s, nil) + end + + response = response.merge({:log => "ares0x%x" % res}) + return response + end + + def self.make_chow(possible_furos, chow_flag, prefer_aka5) + chis = [] + + if chow_flag == MJPIR::CHII1 then + chis = possible_furos.select { |f| f.type == :chi && f.pai < f.consumed.min } + elsif chow_flag == MJPIR::CHII2 then + chis = possible_furos.select { |f| f.type == :chi && f.pai > f.consumed.max } + elsif chow_flag == MJPIR::CHII3 then + chis = possible_furos.select { |f| f.type == :chi && f.pai.same_symbol?(f.consumed.min.succ) } + else + throw "Unknown chow_flag" + end + + chis.sort_by! { |f| [f.pai, f.consumed].flatten.count { |pai| pai.red? } } + (prefer_aka5) ? chis.first : chis.last + end + + def on_ryukyoku(action) + if action.reason != :kyushukyuhai then + return nil + end + + actor_seat = relative_seat_pos(action.actor.id, self.id) + M.MJPInterfaceFunc(@instance_ptr, MJPI::ONACTION, make_lparam(0, actor_seat), MJPIR::NAGASHI) + + return nil + end + + def on_hora(action) + actor_seat = relative_seat_pos(action.actor.id, self.id) + target_seat = relative_seat_pos(action.target.id, self.id) + + occured = (actor_seat == target_seat) ? MJPIR::TSUMO : MJPIR::RON | action.pai.to_mau_i + + M.MJPInterfaceFunc(@instance_ptr, MJPI::ONACTION, make_lparam(target_seat, actor_seat), occured) + + return nil + end + + def on_round_start(action) + @round = (action.bakaze.data[1] - 1) * 4 + action.kyoku - 1 + @my_wind = relative_seat_pos(action.oya.id, self.id) + @tehais_contain_tsumo = false + @last_is_tsumogiri = false + M.MJPInterfaceFunc(@instance_ptr, MJPI::STARTKYOKU, @round, @my_wind) + return nil + end + + def on_round_end(action) + prev = self.game.previous_action + + p "round_end" + pp game.players.map(&:tehais) + + reason = + case prev.type + when :hora then MJEK::AGARI + when :ryukyoku then MJEK::RYUKYOKU + else raise + end + + deltas = Fiddle::Pointer.malloc(Fiddle::SIZEOF_INT * 4) + STD.memmove(deltas, prev.deltas.pack("l<*"), Fiddle::SIZEOF_INT * 4) + M.MJPInterfaceFunc(@instance_ptr, MJPI::ENDKYOKU, reason, deltas) + + Fiddle.free(deltas) + + @reach_bed = 0 unless reason == MJEK::RYUKYOKU + + return nil + end + + def on_game_start + @reach_bed = 0 + + M.MJPInterfaceFunc(@instance_ptr, MJPI::STARTGAME, 0, 0) + return nil + end + + def on_game_end(action) + # TODO + rank, points = 0, 0 + M.MJPInterfaceFunc(@instance_ptr, MJPI::ENDGAME, rank, points) + + M.MJPInterfaceFunc(@instance_ptr, MJPI::DESTROY, 0, 0) + return nil + end + end +end