OSDN Git Service

Merge branch '201312-usiToCsa'
[shogi-server/shogi-server.git] / bin / usiToCsa.rb
1 #!/usr/bin/env ruby
2 # $Id$
3 #
4 # Author:: Daigo Moriwaki
5 # Homepage:: http://sourceforge.jp/projects/shogi-server/
6 #
7 #--
8 # Copyright (C) 2013 Daigo Moriwaki (daigo at debian dot org)
9 #
10 # This program is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 2 of the License, or
13 # (at your option) any later version.
14 #
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 # GNU General Public License for more details.
19 #
20 # You should have received a copy of the GNU General Public License
21 # along with this program; if not, write to the Free Software
22 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
23 #++
24 #
25 #
26
27 $:.unshift(File.join(File.dirname(File.expand_path(__FILE__)), ".."))
28 require 'shogi_server'
29 require 'logger'
30 require 'socket'
31
32 # Global variables
33
34 $options = nil
35 $logger  = nil   # main log IO
36 $engine  = nil   # engine IO
37 $server  = nil   # shogi server IO
38 $bridge_state = nil
39
40 def usage
41     print <<EOM
42 NAME
43         #{File.basename($0)} - Brige program for a USI engine to connect to a CSA shogi server
44
45 SYNOPSIS
46         #{File.basename($0)} [OPTIONS]... path_to_usi_engine
47
48 DESCRIPTION
49         Bridge program for a USI engine to connect to a CSA shogi server
50
51 OPTIONS
52         gamename
53                 a gamename
54         hash
55                 hash size in MB
56         host
57                 a host name to connect to a CSA server
58         id
59                 player id for a CSA server
60         keep-alive
61                 Interval in seconds to send a keep-alive packet to the server. [default 0]
62                 Disabled if it is 0.
63         log-dir
64                 directory to put log files
65         margin-msec
66                 margin time [milliseconds] for byoyomi
67         options
68                 option key and value for a USI engine. Use dedicated options
69                 for USI_Ponder and USI_Hash.
70                 ex --options "key_a=value_a,key_b=value_b"
71         password
72                 password for a CSA server
73         ponder
74                 enble ponder
75         port
76                 a port number to connect to a CSA server. 4081 is often used.
77
78 EXAMPLES
79
80 LICENSE
81         GPL versoin 2 or later
82
83 SEE ALSO
84
85 REVISION
86         #{ShogiServer::Revision}
87
88 EOM
89 end
90
91 # Parse command line options. Return a hash containing the option strings
92 # where a key is the option name without the first two slashes. For example,
93 # {"pid-file" => "foo.pid"}.
94 #
95 def parse_command_line
96   options = Hash::new
97   parser = GetoptLong.new(
98     ["--gamename",    GetoptLong::REQUIRED_ARGUMENT],
99     ["--hash",        GetoptLong::REQUIRED_ARGUMENT],
100     ["--host",        GetoptLong::REQUIRED_ARGUMENT],
101     ["--id",          GetoptLong::REQUIRED_ARGUMENT],
102     ["--keep-alive",  GetoptLong::REQUIRED_ARGUMENT],
103     ["--log-dir",     GetoptLong::REQUIRED_ARGUMENT],
104     ["--margin-msec", GetoptLong::REQUIRED_ARGUMENT],
105     ["--options",     GetoptLong::REQUIRED_ARGUMENT],
106     ["--password",    GetoptLong::REQUIRED_ARGUMENT],
107     ["--ponder",      GetoptLong::NO_ARGUMENT],
108     ["--port",        GetoptLong::REQUIRED_ARGUMENT])
109   parser.quiet = true
110   begin
111     parser.each_option do |name, arg|
112       name.sub!(/^--/, '')
113       name.sub!(/-/,'_')
114       options[name.to_sym] = arg.dup
115     end
116   rescue
117     usage
118     raise parser.error_message
119   end
120
121   # Set default values
122   options[:gamename]    ||= ENV["GAMENAME"] || "floodgate-900-0"
123   options[:hash]        ||= ENV["HASH"] || 256
124   options[:hash]        = options[:hash].to_i
125   options[:host]        ||= ENV["HOST"] || "wdoor.c.u-tokyo.ac.jp"
126   options[:margin_msec] ||= ENV["MARGIN_MSEC"] || 2500
127   options[:id]          ||= ENV["ID"]
128   options[:keep_alive]  ||= ENV["KEEP_ALIVE"] || 0
129   options[:keep_alive]  = options[:keep_alive].to_i
130   options[:log_dir]     ||= ENV["LOG_DIR"] || "."
131   options[:password]    ||= ENV["PASSWORD"]
132   options[:ponder]      ||= ENV["PONDER"] || false
133   options[:port]        ||= ENV["PORT"] || 4081
134   options[:port]        = options[:port].to_i
135
136   return options
137 end
138
139 # Check command line options.
140 # If any of them is invalid, exit the process.
141 #
142 def check_command_line
143   if (ARGV.length < 1)
144     usage
145     exit 2
146   end
147
148   $options[:engine_path] = ARGV.shift
149 end
150
151 class BridgeFormatter < ::Logger::Formatter
152   def initialize
153     super
154     @datetime_format = "%Y-%m-%dT%H:%M:%S.%6N"
155   end
156
157   def call(severity, time, progname, msg)
158     str = msg2str(msg)
159     str.strip! if str
160     %!%s [%s]\n%s\n\n! % [format_datetime(time), severity, str]
161   end
162 end
163
164 def setup_logger(log_file)
165   logger = ShogiServer::Logger.new(log_file, 'daily')
166   logger.formatter = BridgeFormatter.new
167   logger.level = $DEBUG ? Logger::DEBUG : Logger::INFO  
168   return logger
169 end
170
171 def log_engine_recv(msg)
172   $logger.info ">>> RECV LOG_ENGINE\n#{msg.gsub(/^/,"    ")}"
173 end
174
175 def log_engine_send(msg)
176   $logger.info "<<< SEND LOG_ENGINE\n#{msg.gsub(/^/,"    ")}"
177 end
178
179 def log_server_recv(msg)
180   $logger.info ">>> RECV LOG_SERVER\n#{msg.gsub(/^/,"    ")}"
181 end
182
183 def log_server_send(msg)
184   $logger.info "<<< SEND LOG_SERVER\n#{msg.gsub(/^/,"    ")}"
185 end
186
187 def log_info(msg, sout=true)
188   $stdout.puts msg if sout
189   $logger.info msg
190 end
191
192 def log_error(msg)
193   $stdout.puts msg
194   $logger.error msg
195 end
196
197 # Holds the state of this Bridge program
198 #
199 class BridgeState
200   attr_reader :state
201
202   %W!CONNECTED GAME_WAITING_CSA AGREE_WAITING_CSA GAME_CSA GAME_END PONDERING!.each do |s|
203     class_eval <<-EVAL, __FILE__, __LINE__ + 1
204       def #{s}?
205         return @state == :#{s}
206       end
207
208       def assert_#{s}
209         unless #{s}?
210           throw "Illegal state: #{@state}"
211         end
212       end
213     EVAL
214   end
215
216   def initialize
217     @state      = :GAME_WAITING_CSA
218     @csaToUsi   = ShogiServer::Usi::CsaToUsi.new
219     @usiToCsa   = ShogiServer::Usi::UsiToCsa.new
220     @last_server_send_time = Time.now
221
222     @game_id    = nil
223     @side       = nil    # my side; true for Black, false for White
224     @black_time = nil    # milliseconds
225     @white_time = nil    # milliseconds
226     @byoyomi    = nil    # milliseconds
227
228     @depth       = nil
229     @cp          = nil
230     @pv          = nil
231     @ponder_move = nil
232   end
233
234   def next_turn
235     @depth      = nil
236     @cp         = nil
237     @pv         = nil
238     @ponder_move = nil
239   end
240
241   def update_last_server_send_time
242     @last_server_send_time = Time.now
243   end
244
245   def too_quiet?
246     if $options[:keep_alive] <= 0
247       return false
248     end
249
250     return $options[:keep_alive] < (Time.now - @last_server_send_time)
251   end
252
253   def transite(state)
254     @state   = state
255   end
256
257   def byoyomi
258     if (@byoyomi - $options[:margin_msec]) > 0
259       return (@byoyomi - $options[:margin_msec])
260     else
261       return @byoyomi
262     end
263   end
264
265   def do_sever_recv
266     case $bridge_state.state
267     when :CONNECTED
268     when :GAME_WAITING_CSA
269       event_game_summary
270     when :AGREE_WAITING_CSA
271       event_game_start
272     when :GAME_CSA, :PONDERING
273       event_server_recv
274     when :GAME_END
275     end
276   end
277
278   def do_engine_recv
279     case $bridge_state.state
280     when :CONNECTED
281     when :GAME_WAITING_CSA
282     when :AGREE_WAITING_CSA
283     when :GAME_CSA, :PONDERING
284       event_engine_recv
285     when :GAME_END
286     end
287   end
288
289   def parse_game_summary(str)
290     str.each_line do |line|
291       case line.strip
292       when /^Your_Turn:([\+\-])/
293         case $1
294         when "+"
295           @side = true
296         when "-"
297           @side = false
298         end
299       when /^Total_Time:(\d+)/
300         @black_time = $1.to_i * 1000
301         @white_time = $1.to_i * 1000
302       when /^Byoyomi:(\d+)/
303         @byoyomi = $1.to_i * 1000
304       end
305     end
306
307     if [@side, @black_time, @white_time, @byoyomi].include?(nil)
308       throw "Bad game summary: str"
309     end
310   end
311
312   def event_game_summary
313     assert_GAME_WAITING_CSA
314
315     str = recv_until($server, /^END Game_Summary/)
316     log_server_recv str
317
318     parse_game_summary(str)
319
320     server_puts "AGREE"
321     transite :AGREE_WAITING_CSA
322   end
323
324   def event_game_start
325     assert_AGREE_WAITING_CSA
326
327     str = $server.gets
328     return if str.nil? || str.strip.empty?
329     log_server_recv str
330
331     case str
332     when /^START:(.*)/
333       @game_id = $1
334       log_info "game crated #@game_id"
335       
336       next_turn
337       engine_puts "usinewgame"
338       if @side
339         engine_puts "position startpos"
340         engine_puts "go btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
341       end
342       transite :GAME_CSA
343     when /^REJECT:(.*)/
344       log_info "game rejected."
345       transite :GAME_END
346     else         
347       throw "Bad message in #{@state}: #{str}" 
348     end
349   end
350
351   def handle_one_move(usi)
352     state, csa  = @usiToCsa.next(usi)
353     # TODO state :normal
354     if state != :normal
355       log_error "Found bad move #{usi} (#{csa}): #{state}"
356     end
357     c = comment()
358     unless c.empty?
359       csa += ",#{c}"
360     end
361     server_puts csa
362   end
363
364   def event_engine_recv
365     unless [:GAME_CSA, :PONDERING].include?(@state)
366       throw "Bad state at event_engine_recv: #@state"
367     end
368
369     str = $engine.gets
370     return if str.nil? || str.strip.empty?
371     log_engine_recv str
372
373     case str.strip
374     when /^bestmove\s+resign/
375       server_puts "%TORYO"
376     when /^bestmove\swin/
377       server_puts "%KACHI"
378     when /^bestmove\s+(.*)/
379       str = $1.strip
380       
381       if PONDERING?
382         log_info "Ignore bestmove after 'stop'", false
383         # Trigger the next turn
384         transite :GAME_CSA
385         next_turn
386         engine_puts "position startpos moves #{@csaToUsi.usi_moves.join(" ")}\ngo btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
387       else
388         case str
389         when /^(.*)\s+ponder\s+(.*)/
390           usi          = $1.strip
391           @ponder_move = $2.strip
392
393           handle_one_move(usi)
394
395           if $options[:ponder]
396             moves = @usiToCsa.usi_moves.clone
397             moves << @ponder_move
398             engine_puts "position startpos moves #{moves.join(" ")}\ngo ponder btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
399             transite :PONDERING
400           end
401         else
402           handle_one_move(str)
403         end
404       end
405     when /^info\s+(.*)/
406       str = $1
407       if /depth\s(\d+)/ =~ str
408         @depth = $1
409       end
410       if /score\s+cp\s+(\d+)/ =~ str
411         @cp = $1.to_i
412         if !@side
413           @cp *= -1
414         end
415       end
416       if /pv\s+(.*)$/ =~str
417         @pv = $1
418       end
419     end
420   end
421
422   def event_server_recv
423     unless [:GAME_CSA, :PONDERING].include?(@state)
424       throw "Bad state at event_engine_recv: #@state"
425     end
426
427     str = $server.gets
428     return if str.nil? || str.strip.empty?
429     log_server_recv str
430
431     case str.strip
432     when /^%TORYO,T(\d+)/
433       log_info str
434     when /^#(\w+)/
435       s = $1
436       log_info str
437       if %w!WIN LOSE DRAW!.include?(s)
438         server_puts "LOGOUT"
439         engine_puts "gameover #{s.downcase}"
440         transite :GAME_END
441       end
442     when /^([\+\-]\d{4}\w{2}),T(\d+)/
443       csa  = $1
444       msec = $2.to_i * 1000
445
446       if csa[0..0] == "+"
447         @black_time = [@black_time - msec, 0].max
448       else
449         @white_time = [@white_time - msec, 0].max
450       end
451
452       state1, usi = @csaToUsi.next(csa)
453
454       # TODO state
455       
456       if csa[0..0] != (@side ? "+" : "-")
457         # Recive a new move from the opponent
458         state2, dummy = @usiToCsa.next(usi)
459
460         if PONDERING?
461           if usi == @ponder_move
462             engine_puts "ponderhit"
463             transite :GAME_CSA
464             next_turn
465             # Engine keeps on thinking
466           else
467             engine_puts "stop"
468           end
469         else
470           transite :GAME_CSA
471           next_turn
472           engine_puts "position startpos moves #{@csaToUsi.usi_moves.join(" ")}\ngo btime #@black_time wtime #@white_time byoyomi #{byoyomi()}"
473         end
474       end
475     end
476   end
477
478   def comment
479     if [@depth, @cp, @pv].include?(nil)
480       return ""
481     end
482
483     usiToCsa = @usiToCsa.deep_copy
484     pvs = @pv.split(" ")
485     if usiToCsa.usi_moves.last == pvs.first
486       pvs.shift
487     end
488
489     moves = []
490     pvs.each do |usi|
491       begin
492         state, csa = usiToCsa.next(usi)
493         moves << csa
494       rescue
495         # ignore
496       end
497     end
498     
499     if moves.empty?
500       return ""
501     else
502       return "'* #@cp #{moves.join(" ")}"
503     end
504   end
505 end # class BridgeState
506
507 def recv_until(io, regexp)
508   lines = []
509   while line = io.gets
510     #puts "=== #{line}"
511     lines << line
512     break if regexp =~ line
513   end
514   return lines.join("")
515 end
516
517 def engine_puts(str)
518   log_engine_send str
519   $engine.puts str
520 end
521
522 def server_puts(str)
523   log_server_send str
524   $server.puts str
525   $bridge_state.update_last_server_send_time
526 end
527
528 # Start an engine process
529 #
530 def start_engine
531   log_info("Starting engine...  #{$options[:engine_path]}")
532
533   cmd = %Q!| #{$options[:engine_path]}!
534   $engine = open(cmd, "w+")
535   $engine.sync = true
536
537   select(nil, [$engine], nil)
538   log_engine_send "usi"
539   $engine.puts "usi"
540   r = recv_until $engine, /usiok/
541   log_engine_recv r
542
543   lines =  ["setoption name USI_Hash value #{$options[:hash]}"]
544   lines << ["setoption name Hash value #{$options[:hash]}"] # for gpsfish
545   if $options[:ponder]
546     lines << "setoption name USI_Ponder value true"
547     lines << "setoption name Ponder value true" # for gpsfish
548   end
549   if $options[:options] 
550     $options[:options].split(",").each do |str|
551       key, value = str.split("=")
552       lines << "setoption name #{key} value #{value}"
553     end
554   end
555   engine_puts lines.join("\n")
556
557   log_engine_send "isready"
558   $engine.puts "isready"
559   r = recv_until $engine, /readyok/
560   log_engine_recv r
561 end
562
563 # Login to the shogi server
564 #
565 def login
566   log_info("Connecting to #{$options[:host]}:#{$options[:port]}...")
567   begin
568     $server = TCPSocket.open($options[:host], $options[:port])
569     $server.sync = true
570   rescue
571     log_error "Failed to connect to the server"
572     $server = nil
573     return false
574   end
575
576   begin
577     log_info("Login...  #{$options[:gamename]} #{$options[:id]},xxxxxxxx")
578     if select(nil, [$server], nil, 15)
579       $server.puts "LOGIN #{$options[:id]} #{$options[:gamename]},#{$options[:password]}"
580     else
581       log_error("Failed to send login message to the server")
582       $server.close
583       $server = nil
584       return false
585     end
586
587     if select([$server], nil, nil, 15)
588       line = $server.gets
589       if /LOGIN:.* OK/ =~ line
590         log_info(line)
591       else
592         log_error("Failed to login to the server")
593         $server.close
594         $server = nil
595         return false
596       end
597     else
598       log_error("Login attempt to the server timed out")
599       $server.close
600       $server = nil
601     end
602   rescue Exception => ex
603     log_error("login_loop: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}")
604     return false
605   end
606
607   return true
608 end
609
610 # MAIN LOOP
611 #
612 def main_loop
613   while true
614     ret, = select([$server, $engine], nil, nil, 60)
615     unless ret
616       # Send keep-alive
617       if $bridge_state.too_quiet?
618         $server.puts ""
619         $bridge_state.update_last_server_send_time
620       end
621       next
622     end
623
624     ret.each do |io|
625       case io
626       when $engine
627         $bridge_state.do_engine_recv
628       when $server
629         $bridge_state.do_sever_recv
630       end
631     end
632
633     if $bridge_state.GAME_END?
634       log_info "game finished."
635       break
636     end
637   end
638 rescue Exception => ex
639   log_error "main: #{ex.class}: #{ex.message}\n\t#{ex.backtrace.join("\n\t")}"
640 end
641
642 # MAIN
643 #
644 def main
645   $logger = setup_logger("main.log")
646
647   # Parse command line options
648   $options = parse_command_line
649   check_command_line
650
651   # Start engine
652   start_engine
653
654   # Login to the shogi server
655   if login
656     $bridge_state = BridgeState.new
657     log_info("Wait for a game start...")
658     main_loop
659   else
660     exit 1
661   end
662 end
663
664 if ($0 == __FILE__)
665   STDOUT.sync = true
666   STDERR.sync = true
667   TCPSocket.do_not_reverse_lookup = true
668   Thread.abort_on_exception = $DEBUG ? true : false
669
670   begin
671     main
672   rescue Exception => ex
673     if $logger
674       log_error("main: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}")
675     else
676       $stderr.puts "main: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}"
677     end
678     exit 1
679   end
680   
681   exit 0
682 end