OSDN Git Service

* [usiToCsa] - Added a new program, which is a bridge for a USI engine to connect...
authorDaigo Moriwaki <daigo@debian.org>
Sat, 14 Dec 2013 12:31:08 +0000 (21:31 +0900)
committerDaigo Moriwaki <daigo@debian.org>
Sun, 15 Dec 2013 13:35:39 +0000 (22:35 +0900)
bin/usiToCsa [new file with mode: 0755]
bin/usiToCsa.rb [new file with mode: 0755]
changelog
shogi_server/usi.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..f8bde2c
--- /dev/null
@@ -0,0 +1,681 @@
+#!/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 "%TYORO"
+    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
+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 03541cc..c498f62 100644 (file)
--- a/changelog
+++ b/changelog
@@ -1,3 +1,10 @@
+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]
index f18f8c3..39ca24e 100644 (file)
@@ -140,7 +140,7 @@ module ShogiServer # for a namespace
     # Convert USI moves to CSA one by one from the initial position
     #
     class UsiToCsa
-      attr_reader :board, :csa_moves
+      attr_reader :board, :csa_moves, :usi_moves
 
       # Constructor
       #
@@ -149,24 +149,31 @@ module ShogiServer # for a namespace
         @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, :usi_moves
+      attr_reader :board, :csa_moves, :usi_moves
 
       # Constructor
       #
@@ -174,13 +181,19 @@ module ShogiServer # for a namespace
         @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)