OSDN Git Service

Merge branch 'wdoor-stable'
authorDaigo Moriwaki <daigo@debian.org>
Mon, 13 Oct 2014 09:43:25 +0000 (18:43 +0900)
committerDaigo Moriwaki <daigo@debian.org>
Mon, 13 Oct 2014 09:43:25 +0000 (18:43 +0900)
Conflicts:
changelog

bin/usiToCsa [new file with mode: 0755]
bin/usiToCsa.rb [new file with mode: 0755]
changelog
mk_rate
shogi-server
shogi_server/board.rb
shogi_server/piece.rb
shogi_server/usi.rb
test/TC_usi.rb
utils/csa-filter.rb

diff --git a/bin/usiToCsa b/bin/usiToCsa
new file mode 100755 (executable)
index 0000000..eaf0e4d
--- /dev/null
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+engine=${1:?Specify engine binary path}
+if [ ! -x "$engine" ] ; then
+  echo "Engine not found: $engine"
+  exit 1
+fi
+
+curdir=$(cd `dirname $0`; pwd)
+
+if [ -z "$ID" ] ; then
+  echo "Specify ID"
+  exit 1
+fi
+
+if [ -z "$PASSWORD" ] ; then
+  password_file="$HOME/.$ID.password"
+  if [ ! -f "$password_file" ] ; then
+    echo "Prepare a passowrd file at $password_file"
+  fi
+  export PASSWORD=`cat "$password_file"`
+fi
+
+while true
+do
+  logger -s "$ID: Restarting..."
+
+  $curdir/usiToCsa.rb "$engine"
+
+  if [ $? -ne 0 ] ; then
+    logger -s "$ID: Sleeping..."
+    sleep 900
+  fi
+done
diff --git a/bin/usiToCsa.rb b/bin/usiToCsa.rb
new file mode 100755 (executable)
index 0000000..e1e4ad5
--- /dev/null
@@ -0,0 +1,682 @@
+#!/usr/bin/env ruby
+# $Id$
+#
+# Author:: Daigo Moriwaki
+# Homepage:: http://sourceforge.jp/projects/shogi-server/
+#
+#--
+# Copyright (C) 2013 Daigo Moriwaki (daigo at debian dot org)
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#++
+#
+#
+
+$:.unshift(File.join(File.dirname(File.expand_path(__FILE__)), ".."))
+require 'shogi_server'
+require 'logger'
+require 'socket'
+
+# Global variables
+
+$options = nil
+$logger  = nil   # main log IO
+$engine  = nil   # engine IO
+$server  = nil   # shogi server IO
+$bridge_state = nil
+
+def usage
+    print <<EOM
+NAME
+        #{File.basename($0)} - Brige program for a USI engine to connect to a CSA shogi server
+
+SYNOPSIS
+        #{File.basename($0)} [OPTIONS]... path_to_usi_engine
+
+DESCRIPTION
+        Bridge program for a USI engine to connect to a CSA shogi server
+
+OPTIONS
+        gamename
+                a gamename
+        hash
+                hash size in MB
+        host
+                a host name to connect to a CSA server
+        id
+                player id for a CSA server
+        keep-alive
+                Interval in seconds to send a keep-alive packet to the server. [default 0]
+                Disabled if it is 0.
+        log-dir
+                directory to put log files
+        margin-msec
+                margin time [milliseconds] for byoyomi
+        options
+                option key and value for a USI engine. Use dedicated options
+                for USI_Ponder and USI_Hash.
+                ex --options "key_a=value_a,key_b=value_b"
+        password
+                password for a CSA server
+        ponder
+                enble ponder
+        port
+                a port number to connect to a CSA server. 4081 is often used.
+
+EXAMPLES
+
+LICENSE
+        GPL versoin 2 or later
+
+SEE ALSO
+
+REVISION
+        #{ShogiServer::Revision}
+
+EOM
+end
+
+# Parse command line options. Return a hash containing the option strings
+# where a key is the option name without the first two slashes. For example,
+# {"pid-file" => "foo.pid"}.
+#
+def parse_command_line
+  options = Hash::new
+  parser = GetoptLong.new(
+    ["--gamename",    GetoptLong::REQUIRED_ARGUMENT],
+    ["--hash",        GetoptLong::REQUIRED_ARGUMENT],
+    ["--host",        GetoptLong::REQUIRED_ARGUMENT],
+    ["--id",          GetoptLong::REQUIRED_ARGUMENT],
+    ["--keep-alive",  GetoptLong::REQUIRED_ARGUMENT],
+    ["--log-dir",     GetoptLong::REQUIRED_ARGUMENT],
+    ["--margin-msec", GetoptLong::REQUIRED_ARGUMENT],
+    ["--options",     GetoptLong::REQUIRED_ARGUMENT],
+    ["--password",    GetoptLong::REQUIRED_ARGUMENT],
+    ["--ponder",      GetoptLong::NO_ARGUMENT],
+    ["--port",        GetoptLong::REQUIRED_ARGUMENT])
+  parser.quiet = true
+  begin
+    parser.each_option do |name, arg|
+      name.sub!(/^--/, '')
+      name.sub!(/-/,'_')
+      options[name.to_sym] = arg.dup
+    end
+  rescue
+    usage
+    raise parser.error_message
+  end
+
+  # Set default values
+  options[:gamename]    ||= ENV["GAMENAME"] || "floodgate-900-0"
+  options[:hash]        ||= ENV["HASH"] || 256
+  options[:hash]        = options[:hash].to_i
+  options[:host]        ||= ENV["HOST"] || "wdoor.c.u-tokyo.ac.jp"
+  options[:margin_msec] ||= ENV["MARGIN_MSEC"] || 2500
+  options[:id]          ||= ENV["ID"]
+  options[:keep_alive]  ||= ENV["KEEP_ALIVE"] || 0
+  options[:keep_alive]  = options[:keep_alive].to_i
+  options[:log_dir]     ||= ENV["LOG_DIR"] || "."
+  options[:password]    ||= ENV["PASSWORD"]
+  options[:ponder]      ||= ENV["PONDER"] || false
+  options[:port]        ||= ENV["PORT"] || 4081
+  options[:port]        = options[:port].to_i
+
+  return options
+end
+
+# Check command line options.
+# If any of them is invalid, exit the process.
+#
+def check_command_line
+  if (ARGV.length < 1)
+    usage
+    exit 2
+  end
+
+  $options[:engine_path] = ARGV.shift
+end
+
+class BridgeFormatter < ::Logger::Formatter
+  def initialize
+    super
+    @datetime_format = "%Y-%m-%dT%H:%M:%S.%6N"
+  end
+
+  def call(severity, time, progname, msg)
+    str = msg2str(msg)
+    str.strip! if str
+    %!%s [%s]\n%s\n\n! % [format_datetime(time), severity, str]
+  end
+end
+
+def setup_logger(log_file)
+  logger = ShogiServer::Logger.new(log_file, 'daily')
+  logger.formatter = BridgeFormatter.new
+  logger.level = $DEBUG ? Logger::DEBUG : Logger::INFO  
+  return logger
+end
+
+def log_engine_recv(msg)
+  $logger.info ">>> RECV LOG_ENGINE\n#{msg.gsub(/^/,"    ")}"
+end
+
+def log_engine_send(msg)
+  $logger.info "<<< SEND LOG_ENGINE\n#{msg.gsub(/^/,"    ")}"
+end
+
+def log_server_recv(msg)
+  $logger.info ">>> RECV LOG_SERVER\n#{msg.gsub(/^/,"    ")}"
+end
+
+def log_server_send(msg)
+  $logger.info "<<< SEND LOG_SERVER\n#{msg.gsub(/^/,"    ")}"
+end
+
+def log_info(msg, sout=true)
+  $stdout.puts msg if sout
+  $logger.info msg
+end
+
+def log_error(msg)
+  $stdout.puts msg
+  $logger.error msg
+end
+
+# Holds the state of this Bridge program
+#
+class BridgeState
+  attr_reader :state
+
+  %W!CONNECTED GAME_WAITING_CSA AGREE_WAITING_CSA GAME_CSA GAME_END PONDERING!.each do |s|
+    class_eval <<-EVAL, __FILE__, __LINE__ + 1
+      def #{s}?
+        return @state == :#{s}
+      end
+
+      def assert_#{s}
+        unless #{s}?
+          throw "Illegal state: #{@state}"
+        end
+      end
+    EVAL
+  end
+
+  def initialize
+    @state      = :GAME_WAITING_CSA
+    @csaToUsi   = ShogiServer::Usi::CsaToUsi.new
+    @usiToCsa   = ShogiServer::Usi::UsiToCsa.new
+    @last_server_send_time = Time.now
+
+    @game_id    = nil
+    @side       = nil    # my side; true for Black, false for White
+    @black_time = nil    # milliseconds
+    @white_time = nil    # milliseconds
+    @byoyomi    = nil    # milliseconds
+
+    @depth       = nil
+    @cp          = nil
+    @pv          = nil
+    @ponder_move = nil
+  end
+
+  def next_turn
+    @depth      = nil
+    @cp         = nil
+    @pv         = nil
+    @ponder_move = nil
+  end
+
+  def update_last_server_send_time
+    @last_server_send_time = Time.now
+  end
+
+  def too_quiet?
+    if $options[:keep_alive] <= 0
+      return false
+    end
+
+    return $options[:keep_alive] < (Time.now - @last_server_send_time)
+  end
+
+  def transite(state)
+    @state   = state
+  end
+
+  def byoyomi
+    if (@byoyomi - $options[:margin_msec]) > 0
+      return (@byoyomi - $options[:margin_msec])
+    else
+      return @byoyomi
+    end
+  end
+
+  def do_sever_recv
+    case $bridge_state.state
+    when :CONNECTED
+    when :GAME_WAITING_CSA
+      event_game_summary
+    when :AGREE_WAITING_CSA
+      event_game_start
+    when :GAME_CSA, :PONDERING
+      event_server_recv
+    when :GAME_END
+    end
+  end
+
+  def do_engine_recv
+    case $bridge_state.state
+    when :CONNECTED
+    when :GAME_WAITING_CSA
+    when :AGREE_WAITING_CSA
+    when :GAME_CSA, :PONDERING
+      event_engine_recv
+    when :GAME_END
+    end
+  end
+
+  def parse_game_summary(str)
+    str.each_line do |line|
+      case line.strip
+      when /^Your_Turn:([\+\-])/
+        case $1
+        when "+"
+          @side = true
+        when "-"
+          @side = false
+        end
+      when /^Total_Time:(\d+)/
+        @black_time = $1.to_i * 1000
+        @white_time = $1.to_i * 1000
+      when /^Byoyomi:(\d+)/
+        @byoyomi = $1.to_i * 1000
+      end
+    end
+
+    if [@side, @black_time, @white_time, @byoyomi].include?(nil)
+      throw "Bad game summary: str"
+    end
+  end
+
+  def event_game_summary
+    assert_GAME_WAITING_CSA
+
+    str = recv_until($server, /^END Game_Summary/)
+    log_server_recv str
+
+    parse_game_summary(str)
+
+    server_puts "AGREE"
+    transite :AGREE_WAITING_CSA
+  end
+
+  def event_game_start
+    assert_AGREE_WAITING_CSA
+
+    str = $server.gets
+    return if str.nil? || str.strip.empty?
+    log_server_recv str
+
+    case str
+    when /^START:(.*)/
+      @game_id = $1
+      log_info "game crated #@game_id"
+      
+      next_turn
+      engine_puts "usinewgame"
+      if @side
+        engine_puts "position startpos"
+        engine_puts "go btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
+      end
+      transite :GAME_CSA
+    when /^REJECT:(.*)/
+      log_info "game rejected."
+      transite :GAME_END
+    else         
+      throw "Bad message in #{@state}: #{str}" 
+    end
+  end
+
+  def handle_one_move(usi)
+    state, csa  = @usiToCsa.next(usi)
+    # TODO state :normal
+    if state != :normal
+      log_error "Found bad move #{usi} (#{csa}): #{state}"
+    end
+    c = comment()
+    unless c.empty?
+      csa += ",#{c}"
+    end
+    server_puts csa
+  end
+
+  def event_engine_recv
+    unless [:GAME_CSA, :PONDERING].include?(@state)
+      throw "Bad state at event_engine_recv: #@state"
+    end
+
+    str = $engine.gets
+    return if str.nil? || str.strip.empty?
+    log_engine_recv str
+
+    case str.strip
+    when /^bestmove\s+resign/
+      server_puts "%TORYO"
+    when /^bestmove\swin/
+      server_puts "%KACHI"
+    when /^bestmove\s+(.*)/
+      str = $1.strip
+      
+      if PONDERING?
+        log_info "Ignore bestmove after 'stop'", false
+        # Trigger the next turn
+        transite :GAME_CSA
+        next_turn
+        engine_puts "position startpos moves #{@csaToUsi.usi_moves.join(" ")}\ngo btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
+      else
+        case str
+        when /^(.*)\s+ponder\s+(.*)/
+          usi          = $1.strip
+          @ponder_move = $2.strip
+
+          handle_one_move(usi)
+
+          if $options[:ponder]
+            moves = @usiToCsa.usi_moves.clone
+            moves << @ponder_move
+            engine_puts "position startpos moves #{moves.join(" ")}\ngo ponder btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
+            transite :PONDERING
+          end
+        else
+          handle_one_move(str)
+        end
+      end
+    when /^info\s+(.*)/
+      str = $1
+      if /depth\s(\d+)/ =~ str
+        @depth = $1
+      end
+      if /score\s+cp\s+(\d+)/ =~ str
+        @cp = $1.to_i
+        if !@side
+          @cp *= -1
+        end
+      end
+      if /pv\s+(.*)$/ =~str
+        @pv = $1
+      end
+    end
+  end
+
+  def event_server_recv
+    unless [:GAME_CSA, :PONDERING].include?(@state)
+      throw "Bad state at event_engine_recv: #@state"
+    end
+
+    str = $server.gets
+    return if str.nil? || str.strip.empty?
+    log_server_recv str
+
+    case str.strip
+    when /^%TORYO,T(\d+)/
+      log_info str
+    when /^#(\w+)/
+      s = $1
+      log_info str
+      if %w!WIN LOSE DRAW!.include?(s)
+        server_puts "LOGOUT"
+        engine_puts "gameover #{s.downcase}"
+        transite :GAME_END
+      end
+    when /^([\+\-]\d{4}\w{2}),T(\d+)/
+      csa  = $1
+      msec = $2.to_i * 1000
+
+      if csa[0..0] == "+"
+        @black_time = [@black_time - msec, 0].max
+      else
+        @white_time = [@white_time - msec, 0].max
+      end
+
+      state1, usi = @csaToUsi.next(csa)
+
+      # TODO state
+      
+      if csa[0..0] != (@side ? "+" : "-")
+        # Recive a new move from the opponent
+        state2, dummy = @usiToCsa.next(usi)
+
+        if PONDERING?
+          if usi == @ponder_move
+            engine_puts "ponderhit"
+            transite :GAME_CSA
+            next_turn
+            # Engine keeps on thinking
+          else
+            engine_puts "stop"
+          end
+        else
+          transite :GAME_CSA
+          next_turn
+          engine_puts "position startpos moves #{@csaToUsi.usi_moves.join(" ")}\ngo btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
+        end
+      end
+    end
+  end
+
+  def comment
+    if [@depth, @cp, @pv].include?(nil)
+      return ""
+    end
+
+    usiToCsa = @usiToCsa.deep_copy
+    pvs = @pv.split(" ")
+    if usiToCsa.usi_moves.last == pvs.first
+      pvs.shift
+    end
+
+    moves = []
+    pvs.each do |usi|
+      begin
+        state, csa = usiToCsa.next(usi)
+        moves << csa
+      rescue
+        # ignore
+      end
+    end
+    
+    if moves.empty?
+      return ""
+    else
+      return "'* #@cp #{moves.join(" ")}"
+    end
+  end
+end # class BridgeState
+
+def recv_until(io, regexp)
+  lines = []
+  while line = io.gets
+    #puts "=== #{line}"
+    lines << line
+    break if regexp =~ line
+  end
+  return lines.join("")
+end
+
+def engine_puts(str)
+  log_engine_send str
+  $engine.puts str
+end
+
+def server_puts(str)
+  log_server_send str
+  $server.puts str
+  $bridge_state.update_last_server_send_time
+end
+
+# Start an engine process
+#
+def start_engine
+  log_info("Starting engine...  #{$options[:engine_path]}")
+
+  cmd = %Q!| #{$options[:engine_path]}!
+  $engine = open(cmd, "w+")
+  $engine.sync = true
+
+  select(nil, [$engine], nil)
+  log_engine_send "usi"
+  $engine.puts "usi"
+  r = recv_until $engine, /usiok/
+  log_engine_recv r
+
+  lines =  ["setoption name USI_Hash value #{$options[:hash]}"]
+  lines << ["setoption name Hash value #{$options[:hash]}"] # for gpsfish
+  if $options[:ponder]
+    lines << "setoption name USI_Ponder value true"
+    lines << "setoption name Ponder value true" # for gpsfish
+  end
+  if $options[:options] 
+    $options[:options].split(",").each do |str|
+      key, value = str.split("=")
+      lines << "setoption name #{key} value #{value}"
+    end
+  end
+  engine_puts lines.join("\n")
+
+  log_engine_send "isready"
+  $engine.puts "isready"
+  r = recv_until $engine, /readyok/
+  log_engine_recv r
+end
+
+# Login to the shogi server
+#
+def login
+  log_info("Connecting to #{$options[:host]}:#{$options[:port]}...")
+  begin
+    $server = TCPSocket.open($options[:host], $options[:port])
+    $server.sync = true
+  rescue
+    log_error "Failed to connect to the server"
+    $server = nil
+    return false
+  end
+
+  begin
+    log_info("Login...  #{$options[:gamename]} #{$options[:id]},xxxxxxxx")
+    if select(nil, [$server], nil, 15)
+      $server.puts "LOGIN #{$options[:id]} #{$options[:gamename]},#{$options[:password]}"
+    else
+      log_error("Failed to send login message to the server")
+      $server.close
+      $server = nil
+      return false
+    end
+
+    if select([$server], nil, nil, 15)
+      line = $server.gets
+      if /LOGIN:.* OK/ =~ line
+        log_info(line)
+      else
+        log_error("Failed to login to the server")
+        $server.close
+        $server = nil
+        return false
+      end
+    else
+      log_error("Login attempt to the server timed out")
+      $server.close
+      $server = nil
+    end
+  rescue Exception => ex
+    log_error("login_loop: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}")
+    return false
+  end
+
+  return true
+end
+
+# MAIN LOOP
+#
+def main_loop
+  while true
+    ret, = select([$server, $engine], nil, nil, 60)
+    unless ret
+      # Send keep-alive
+      if $bridge_state.too_quiet?
+        $server.puts ""
+        $bridge_state.update_last_server_send_time
+      end
+      next
+    end
+
+    ret.each do |io|
+      case io
+      when $engine
+        $bridge_state.do_engine_recv
+      when $server
+        $bridge_state.do_sever_recv
+      end
+    end
+
+    if $bridge_state.GAME_END?
+      log_info "game finished."
+      break
+    end
+  end
+rescue Exception => ex
+  log_error "main: #{ex.class}: #{ex.message}\n\t#{ex.backtrace.join("\n\t")}"
+end
+
+# MAIN
+#
+def main
+  $logger = setup_logger("main.log")
+
+  # Parse command line options
+  $options = parse_command_line
+  check_command_line
+
+  # Start engine
+  start_engine
+
+  # Login to the shogi server
+  if login
+    $bridge_state = BridgeState.new
+    log_info("Wait for a game start...")
+    main_loop
+  else
+    exit 1
+  end
+end
+
+if ($0 == __FILE__)
+  STDOUT.sync = true
+  STDERR.sync = true
+  TCPSocket.do_not_reverse_lookup = true
+  Thread.abort_on_exception = $DEBUG ? true : false
+
+  begin
+    main
+  rescue Exception => ex
+    if $logger
+      log_error("main: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}")
+    else
+      $stderr.puts "main: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}"
+    end
+    exit 1
+  end
+  
+  exit 0
+end
index 28f1f02..c8332dc 100644 (file)
--- a/changelog
+++ b/changelog
@@ -1,3 +1,19 @@
+2014-10-13  Daigo Moriwaki <daigo at debian dot org>
+
+       * [shogi-server]
+         - A player, attempting to login with the current live player
+           stalling for more than a day, can forcibly override the current
+           player.
+
+2014-07-19  Daigo Moriwaki <daigo at debian dot org>
+
+       * [mk_rate]
+         - Added a new option, --abnormal-threshold n:
+           Games that end with the 'abnormal' status are counted in
+           win/lost games for the rating calculation if a game plays more
+           than n plies. Otherwise (or if n is zero), abnormal games are
+           counted out of rating games.
+
 2014-01-07  Daigo Moriwaki <daigo at debian dot org>
 
        * [shogi-server]
        * [shogi-server]
          - Released: Revision "20131215"
 
+2013-12-14  Daigo Moriwaki <daigo at debian dot org>
+
+       * [usiToCsa]
+         - Added a new program, bin/usiToCsa.rb, which is a bridge for a
+           USI engine to connect to the Shogi-server.
+         - bin/usiToCsa is a sample wrapper script.
+
 2013-12-13  Daigo Moriwaki <daigo at debian dot org>
 
        * [shogi-server]
@@ -59,6 +82,8 @@
            factory function name generating a pairing method which will be
            used in a specific Floodgate game.
            ex. set pairing_factory floodgate_zyunisen 
+         - Implemented conversion of move representation between CSA format
+           and USI one.
 
 2013-11-24  Daigo Moriwaki <daigo at debian dot org>
 
diff --git a/mk_rate b/mk_rate
index 1f9e54a..6090225 100755 (executable)
--- a/mk_rate
+++ b/mk_rate
 # ./mk_rate [options]
 # 
 # GAME_RESULTS_FILE::
-#   a path to a file listing results of games, which is genrated by the
+#   a path to a file listing results of games, which is generated by the
 #   mk_game_results command.
 #   In the second style above, the file content can be read from the stdin.
 #
+# --abnormal-threshold::
+#   n [plies] (default 30)
+#   Games that end with the 'abnormal' status are counted in win/lost games
+#   for the rating calculation if a game plays more than n plies. Otherwise
+#   (or if n is zero), abnormal games are counted out of rating games.
+#
 # --base-date::
-#   a base time point for this calicuration (default now). Ex. '2009-10-31'
+#   a base time point for this calculation (default now). Ex. '2009-10-31'
 #
 # --half-life::
 #   n [days] (default 60)
@@ -68,7 +74,7 @@
 #
 # == PREREQUIRE
 #
-# Sample Command lines that isntall prerequires will work on Debian.
+# Sample Command lines that install prerequires will work on Debian.
 #
 # * Ruby 1.9.3 or 1.8.7 (including Rubygems)
 #
 # * (Rated) players, who played more than $GAMES_LIMIT [15] (rated) games. 
 #
 
+$:.unshift(File.dirname(File.expand_path(__FILE__)))
+require 'utils/csa-filter'
 require 'yaml'
 require 'time'
 require 'getoptlong'
@@ -670,7 +678,12 @@ def parse(line)
     return
   end
 
-  return if state == "abnormal"
+  if state == "abnormal"
+    csa = CsaFileReader.new(file, "EUC-JP")
+    if $options["abnormal-threshold"] == 0 || csa.ply <= $options["abnormal-threshold"]
+      return
+    end
+  end
   time = Time.parse(time)
   return if $options["base-date"] < time
   how_long_days = ($options["base-date"] - time)/(3600*24)
@@ -728,14 +741,15 @@ end
 def main
   $options = Hash::new
   parser = GetoptLong.new(
-    ["--base-date",         GetoptLong::REQUIRED_ARGUMENT],
-    ["--half-life",         GetoptLong::REQUIRED_ARGUMENT],
-    ["--half-life-ignore",  GetoptLong::REQUIRED_ARGUMENT],
-    ["--help", "-h",        GetoptLong::NO_ARGUMENT],
-    ["--ignore",            GetoptLong::REQUIRED_ARGUMENT],
-    ["--fixed-rate-player", GetoptLong::REQUIRED_ARGUMENT],
-    ["--fixed-rate",        GetoptLong::REQUIRED_ARGUMENT],
-    ["--skip-draw-games",   GetoptLong::NO_ARGUMENT])
+    ["--abnormal-threshold", GetoptLong::REQUIRED_ARGUMENT],
+    ["--base-date",          GetoptLong::REQUIRED_ARGUMENT],
+    ["--half-life",          GetoptLong::REQUIRED_ARGUMENT],
+    ["--half-life-ignore",   GetoptLong::REQUIRED_ARGUMENT],
+    ["--help", "-h",         GetoptLong::NO_ARGUMENT],
+    ["--ignore",             GetoptLong::REQUIRED_ARGUMENT],
+    ["--fixed-rate-player",  GetoptLong::REQUIRED_ARGUMENT],
+    ["--fixed-rate",         GetoptLong::REQUIRED_ARGUMENT],
+    ["--skip-draw-games",    GetoptLong::NO_ARGUMENT])
   parser.quiet = true
   begin
     parser.each_option do |name, arg|
@@ -761,6 +775,8 @@ def main
   else
     $options["base-date"] = Time.now
   end
+  $options["abnormal-threshold"] ||= 30
+  $options["abnormal-threshold"] = $options["abnormal-threshold"].to_i
   $options["half-life"] ||= 60
   $options["half-life"] = $options["half-life"].to_i
   $options["half-life-ignore"] ||= 7
index 33a2d70..263a3f3 100755 (executable)
@@ -40,6 +40,8 @@ require 'tempfile'
 # MAIN
 #
 
+ONE_DAY = 3600 * 24   # in seconds
+
 ShogiServer.reload
 
 # Return
@@ -330,9 +332,14 @@ def login_loop(client)
         player = ShogiServer::Player::new(str, client, eol)
         login  = ShogiServer::Login::factory(str, player)
         if (current_player = $league.find(player.name))
+          # Even if a player is in the 'game' state, when the status of the
+          # player has not been updated for more than a day, it is very
+          # likely that the player is stalling. In such a case, a new player
+          # can override the current player.
           if (current_player.password == player.password &&
-              current_player.status != "game")
-            log_message(sprintf("user %s login forcely", player.name))
+              (current_player.status != "game" ||
+               Time.now - current_player.modifiled_at > ONE_DAY))
+            log_message("user %s login forcely (previously modified at %s)" % [player.name, player.modified_at])
             current_player.kill
           else
             login.incorrect_duplicated_player(str)
index 3bd01b1..66d4d01 100644 (file)
@@ -94,6 +94,7 @@ EOF
     @move_count = move_count
     @teban = nil # black => true, white => false
     @initial_moves = []
+    @move = nil
     @ous = [nil, nil] # keep OU pieces of Sente and Gote
   end
   attr_accessor :array, :sente_hands, :gote_hands, :history, :sente_history, :gote_history, :teban
@@ -104,6 +105,11 @@ EOF
   # moves.
   attr_reader :initial_moves
 
+  # A move parsed by handle_one_move. If the move is not :normal, the board
+  # position may or may not be rolled back.
+  #
+  attr_reader :move
+
   # See if self equals rhs, including a logical board position (i.e.
   # not see object IDs) and sennichite stuff.
   #
@@ -678,18 +684,18 @@ EOF
       return :illegal           # can't put on existing piece
     end
 
-    move = Move.new(x0, y0, x1, y1, name, sente)
-    result = move_to(move)
+    @move = Move.new(x0, y0, x1, y1, name, sente)
+    result = move_to(@move)
     if (result == :illegal)
       # self is unchanged
       return :illegal 
     end
     if (checkmated?(sente))
-      move_back(move)
+      move_back(@move)
       return :oute_kaihimore 
     end
     if ((x0 == 0) && (y0 == 0) && (name == "FU") && uchifuzume?(sente))
-      move_back(move)
+      move_back(@move)
       return :uchifuzume
     end
 
@@ -697,12 +703,12 @@ EOF
     update_sennichite(sente)
     os_result = oute_sennichite?(sente)
     if os_result # :oute_sennichite_sente_lose or :oute_sennichite_gote_lose
-      move_back(move)
+      move_back(@move)
       restore_sennichite_stuff(*sennichite_stuff)
       return os_result 
     end
     if sennichite?
-      move_back(move)
+      move_back(@move)
       restore_sennichite_stuff(*sennichite_stuff)
       return :sennichite 
     end
index 64b918a..8717dff 100644 (file)
@@ -154,18 +154,17 @@ class Piece
     @promoted_name
   end
 
+  def current_name
+    return @promoted ? @promoted_name : @name
+  end
+
   def to_s
     if (@sente)
       sg = "+"
     else
       sg = "-"
     end
-    if (@promoted)
-      n = @promoted_name
-    else
-      n = @name
-    end
-    return sg + n
+    return sg + current_name
   end
 end
 
index 7f70122..39ca24e 100644 (file)
@@ -13,7 +13,194 @@ module ShogiServer # for a namespace
             gsub("@", "+").
             gsub(".", " ")
       end
-    end
+
+      # 1 -> a
+      # 2 -> b
+      # ...
+      # 9 -> i
+      def danToAlphabet(int)
+        return (int+96).chr
+      end
+
+      # a -> 1
+      # b -> 2
+      # ...
+      # i -> 9
+      def alphabetToDan(s)
+        if RUBY_VERSION >= "1.9.1"
+          return s.bytes[0]-96
+        else
+          return s[0]-96
+        end
+      end
+
+      def csaPieceToUsi(csa, sente)
+        str = ""
+        case csa
+        when "FU"
+          str = "p"
+        when "KY"
+          str = "l"
+        when "KE"
+          str = "n"
+        when "GI"
+          str = "s"
+        when "KI"
+          str = "g"
+        when "KA"
+          str = "b"
+        when "HI"
+          str = "r"
+        when "OU"
+          str = "k"
+        when "TO"
+          str = "+p"
+        when "NY"
+          str = "+l"
+        when "NK"
+          str = "+n"
+        when "NG"
+          str = "+s"
+        when "UM"
+          str = "+b"
+        when "RY"
+          str = "+r"
+        end
+        return sente ? str.upcase : str
+      end
+
+      def usiPieceToCsa(str)
+        ret = ""
+        case str.downcase
+        when "p"
+          ret = "FU"
+        when "l"
+          ret = "KY"
+        when "n"
+          ret = "KE"
+        when "s"
+          ret = "GI"
+        when "g"
+          ret = "KI"
+        when "b"
+          ret = "KA"
+        when "r"
+          ret = "HI"
+        when "+p"
+          ret = "TO"
+        when "+l"
+          ret = "NY"
+        when "+n"
+          ret = "NK"
+        when "+s"
+          ret = "NG"
+        when "+b"
+          ret = "UM"
+        when "+r"
+          ret = "RY"
+        when "k"
+          ret = "OU"
+        end
+        return ret
+      end
+
+      def moveToUsi(move)
+        str = ""
+        if move.is_drop?
+          str += "%s*%s%s" % [csaPieceToUsi(move.name, move.sente).upcase, move.x1, danToAlphabet(move.y1)]
+        else
+          str += "%s%s%s%s" % [move.x0, danToAlphabet(move.y0), move.x1, danToAlphabet(move.y1)]
+          str += "+" if move.promotion
+        end
+
+        return str
+      end
+
+      def usiToCsa(str, board, sente)
+        ret = ""
+        if str[1..1] == "*" 
+          # drop
+          ret += "00%s%s%s" % [str[2..2], alphabetToDan(str[3..3]), usiPieceToCsa(str[0..0])]
+        else
+          from_x = str[0..0]
+          from_y = alphabetToDan(str[1..1])
+          ret += "%s%s%s%s" % [from_x, from_y, str[2..2], alphabetToDan(str[3..3])]
+          csa_piece = board.array[from_x.to_i][from_y.to_i]
+          if str.size == 5 && str[4..4] == "+"
+            # Promoting move
+            ret += csa_piece.promoted_name
+          else
+            ret += csa_piece.current_name
+          end
+        end
+        return (sente ? "+" : "-") + ret
+      end
+    end # class methods
+
+    # Convert USI moves to CSA one by one from the initial position
+    #
+    class UsiToCsa
+      attr_reader :board, :csa_moves, :usi_moves
+
+      # Constructor
+      #
+      def initialize
+        @board = ShogiServer::Board.new
+        @board.initial
+        @sente = true
+        @csa_moves = []
+        @usi_moves = []
+      end
+
+      def deep_copy
+        return Marshal.load(Marshal.dump(self))
+      end
+
+      # Parses a usi move string and returns an array of [move_result_state,
+      # csa_move_string]
+      #
+      def next(usi)
+        usi_moves << usi
+        csa = Usi.usiToCsa(usi, @board, @sente)
+        state = @board.handle_one_move(csa, @sente)
+        @sente = !@sente
+        @csa_moves << csa
+        return [state, csa]
+      end
+
+    end # class UsiToCsa
+
+    # Convert CSA moves to USI one by one from the initial position
+    #
+    class CsaToUsi
+      attr_reader :board, :csa_moves, :usi_moves
+
+      # Constructor
+      #
+      def initialize
+        @board = ShogiServer::Board.new
+        @board.initial
+        @sente = true
+        @csa_moves = []
+        @usi_moves = []
+      end
+
+      def deep_copy
+        return Marshal.load(Marshal.dump(self))
+      end
+      
+      # Parses a csa move string and returns an array of [move_result_state,
+      # usi_move_string]
+      #
+      def next(csa)
+        csa_moves << csa
+        state = @board.handle_one_move(csa, @sente)
+        @sente = !@sente
+        usi = Usi.moveToUsi(@board.move)
+        @usi_moves << usi
+        return [state, usi]
+      end
+    end # class CsaToUsi
 
     def charToPiece(c)
       player = nil
@@ -164,6 +351,7 @@ module ShogiServer # for a namespace
       s += hands2usi(board.gote_hands).downcase
       return s
     end
+
   end # class
 
 end # module
index 206dab1..8dbe045 100644 (file)
@@ -84,4 +84,22 @@ P-00FU00FU
 EOB
     assert_equal "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b 2p", @usi.board2usi(b, b.teban)
   end
+
+  def test_usiToCsa
+    # 26th Ryuousen 5th match Moriuchi vs Watanabe on Nov 28th, 2013
+    usi_moves = %w!7g7f 8c8d 7i6h 3c3d 6g6f 7a6b 5g5f 5c5d 3i4h 3a4b 4i5h 4a3b 6i7h 5a4a 5i6i 6a5b 6h7g 4b3c 8h7i 2b3a 3g3f 4c4d 4h3g 3a6d 5h6g 7c7d 7i6h 5b4c 6i7i 4a3a 7i8h 9c9d 3g4f 6b5c 2i3g 6d7c 1g1f 1c1d 2g2f 3c2d 2h3h 9d9e 1i1h 3a2b 3g2e 4d4e 4f4e 7c1i+ 6h4f 1i4f 4g4f B*5i B*3g 5i3g+ 3h3g B*1i 3g3h 1i4f+ P*4d 4c4d B*7a 4d4c 6g5g 4f5g 7a8b+ P*4d 4e3d 4c3d R*5a 5c4b 5a8a+ S*6i 7h6h 5g6h 3h6h G*5h 8b4f 5h6h 4f6h R*4h G*7i 6i5h+ 6h7h G*6i 7i6i 5h6i B*5g 4h1h+ P*4h 1h2g 5g2d 3d2d 7h6i L*6g S*6h 6g6h+ 6i6h S*5g N*3c 4b3c 2e3c+ 2b3c L*3e P*3d 8a2a G*3a 2a1a 5g6h 7g6h N*6d S*6g 3d3e S*5c 2g3f N*2e 3c4c 5c6d+ 6c6d G*3c 4c5c 3c3b 3a3b N*5e 5d5e 1a5a L*5b L*5d 5c5d 5a5b 5d4e L*4g 3f4g 4h4g 4e3f G*3h!
+    uc = ShogiServer::Usi::UsiToCsa.new
+    usi_moves.each do |m|
+      state, csa = uc.next(m)
+      assert_equal(:normal, state)
+    end
+
+    cu = ShogiServer::Usi::CsaToUsi.new
+    uc.csa_moves.each do |m|
+      state, usi = cu.next(m)
+      assert_equal(:normal, state)
+    end
+
+    assert_equal(usi_moves, cu.usi_moves)
+  end
 end
index 6c29bbd..72a8662 100755 (executable)
@@ -42,21 +42,23 @@ class CsaFileReader
   attr_reader :winner, :loser
   attr_reader :state
   attr_reader :start_time, :end_time
+  attr_reader :ply
 
-  def initialize(file_name)
+  def initialize(file_name, encoding="Shift_JIS:EUC-JP")
     @file_name = file_name
+    @encoding = encoding
+    @ply = 0
     grep
   end
 
   def grep
-    @str = File.open(@file_name, "r:Shift_JIS:EUC-JP").read
+    @str = File.open(@file_name, "r:#{@encoding}").read
 
 
     if /^N\+(.*)$/ =~ @str then @black_name = $1.strip end
     if /^N\-(.*)$/ =~ @str then @white_name = $1.strip end
     if /^'summary:(.*)$/ =~ @str
       @state, p1, p2 = $1.split(":").map {|a| a.strip}    
-      return if @state == "abnormal"
       p1_name, p1_mark = p1.split(" ")
       p2_name, p2_mark = p2.split(" ")
       if p1_name == @black_name
@@ -92,6 +94,12 @@ class CsaFileReader
         end
       end
     end
+
+    @str.each_line do |line|
+      if /^[\+\-]\d{4}[A-Z]{2}/ =~ line
+        @ply += 1
+      end
+    end
   end
 
   def movetimes
@@ -107,7 +115,8 @@ class CsaFileReader
            "BlackName #{@black_name}, WhiteName #{@white_name}\n" +
            "BlackId #{@black_id}, WhiteId #{@white_id}\n" +
            "Winner #{@winner}, Loser #{@loser}\n"    +
-           "Start #{@start_time}, End #{@end_time}\n"
+           "Start #{@start_time}, End #{@end_time}\n" +
+           "Ply #{@ply}"
   end
 
   def identify_id(id)