X-Git-Url: http://git.sourceforge.jp/view?p=shogi-server%2Fshogi-server.git;a=blobdiff_plain;f=shogi-server;h=d959b4f3c803820130535fa8902afb438fd3bc9b;hp=6cb041d871cd2f536f86d26e9853e8fd00de5bc0;hb=3ffaeead65a729c5d6203eaf2a56050c3269402b;hpb=b16b9c875f84ee8691ea179ce65f2db8dd1ba772 diff --git a/shogi-server b/shogi-server index 6cb041d..d959b4f 100755 --- a/shogi-server +++ b/shogi-server @@ -1,509 +1,202 @@ -#! /usr/bin/env ruby -## -*-Ruby-*- $RCSfile$ $Revision$ $Name$ - -## Copyright (C) 2004 773@2ch -## -## 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 - -DEFAULT_TIMEOUT = 10 # for single socket operation -Total_Time = 1500 -Least_Time_Per_Move = 1 -Watchdog_Time = 30 # time for ping -Login_Time = 300 # time for LOGIN - -Release = "$Name$".split[1].sub(/\A[^\d]*/, '').gsub(/_/, '.') -Release.concat("-") if (Release == "") -Revision = "$Revision$".gsub(/[^\.\d]/, '') - -STDOUT.sync = true -STDERR.sync = true - -require 'getoptlong' -require 'thread' -require 'timeout' -require 'socket' -require 'ping' - -TCPSocket.do_not_reverse_lookup = true - -class TCPSocket - def gets_timeout(t = DEFAULT_TIMEOUT) - begin - timeout(t) do - return self.gets - end - rescue TimeoutError - return nil - rescue - return nil - end - end - def gets_safe - begin - return self.gets - rescue - return nil - end - end - def write_safe(str) - begin - return self.write(str) - rescue - return nil - end - end -end - - -class League - def initialize - @hash = Hash::new - end - attr_accessor :hash - - def add(player) - @hash[player.name] = player - end - def delete(player) - @hash.delete(player.name) - end - def duplicated?(player) - if (@hash[player.name]) - return true - else - return false - end - end - def get_player(status, game_name, sente, searcher=nil) - @hash.each do |name, player| - if ((player.status == status) && - (player.game_name == game_name) && - ((player.sente == nil) || (player.sente == sente)) && - ((searcher == nil) || (player != searcher))) - return player - end - end - return nil - end - def new_game(game_name, player0, player1) - game = Game::new(game_name, player0, player1) - end -end - - - - -class Player - def initialize(str, socket) - @name = nil - @password = nil - @socket = socket - @status = "connected" # game_waiting -> agree_waiting -> start_waiting -> game - - @protocol = nil # CSA or x1 - @eol = "\m" # favorite eol code - @game = nil - @game_name = "" - @mytime = Total_Time - @sente = nil - @watchdog_thread = nil - - login(str) - end - - attr_accessor :name, :password, :socket, :status - attr_accessor :protocol, :eol, :game, :mytime, :watchdog_thread, :game_name, :sente - - def finish - log_message(sprintf("user %s finish", @name)) - Thread::kill(@watchdog_thread) if @watchdog_thread - @socket.close if (! @socket.closed?) - end - - def watchdog(time) - while true - begin - Ping.pingecho(@socket.addr[3]) - rescue - end - sleep(time) - end - end - - def to_s - if ((status == "game_waiting") || - (status == "agree_waiting") || - (status == "game")) - if (@sente) - return sprintf("%s %s %s +", @name, @status, @game_name) - elsif (@sente == false) - return sprintf("%s %s %s -", @name, @status, @game_name) - elsif (@sente == nil) - return sprintf("%s %s %s +-", @name, @status, @game_name) - end - else - return sprintf("%s %s", @name, @status) - end - end - - def write_help - @socket.write_safe('##[HELP] available commands "%%WHO", "%%CHAT str", "%%GAME game_name +", "%%GAME game_name -"') - end - - def write_safe(str) - @socket.write_safe(str.gsub(/[\r\n]+/, @eol)) - end - - def login(str) - str =~ /([\r\n]*)$/ - @eol = $1 - str.chomp! - (login, @name, @password, ext) = str.split - if (ext) - @protocol = "x1" - else - @protocol = "CSA" - end - @watchdog_thread = Thread::start do - watchdog(Watchdog_Time) - end - end - - def run - if (@protocol != "CSA") - log_message(sprintf("user %s run in %s mode", @name, @protocol)) - write_safe(sprintf("##[LOGIN] +OK %s\n", @protocol)) - else - log_message(sprintf("user %s run in CSA mode", @name)) - csa_1st_str = "%%GAME default +-" - end - - - while (csa_1st_str || (str = @socket.gets_safe)) - begin - $mutex.lock - if (csa_1st_str) - str = csa_1st_str - csa_1st_str = nil - end - str.chomp! - case str - when /^[\+\-%][^%]/ - if (@status == "game") - s = @game.handle_one_move(str, self) - return if (s && @protocol == "CSA") - else - next - end - when /^AGREE/ - if (@status == "agree_waiting") - @status = "start_waiting" - if ((@game.sente.status == "start_waiting") && - (@game.gote.status == "start_waiting")) - @game.start - @game.sente.status = "game" - @game.gote.status = "game" - end - else - write_safe("## you are in %s status. AGREE is valid in agree_waiting status\n", @status) - next - end - when /^%%HELP/ - write_help - when /^%%GAME\s+(\S+)\s+([\+\-]+)/ - if ((@status == "connected") || (@status == "game_waiting")) - @status = "game_waiting" - else - write_safe("## you are in %s status. GAME is valid in connected or game_waiting status\n", @status) - next - end - @status = "game_waiting" - @game_name = $1 - sente_str = $2 - if (sente_str == "+") - @sente = true - rival_sente = false - elsif (sente_str == "-") - @sente = false - rival_sente = true - else - @sente = nil - rival_sente = nil - end - rival = LEAGUE.get_player("game_waiting", @game_name, rival_sente, self) - rival = LEAGUE.get_player("game_waiting", @game_name, nil, self) if (! rival) - if (rival) - if (@sente == nil) - if (rand(2) == 0) - @sente = true - rival_sente = false - else - @sente = false - rival_sente = true - end - elsif (rival_sente == nil) - if (@sente) - rival_sente = false - else - rival_sente = true - end - end - rival.sente = rival_sente - LEAGUE.new_game(@game_name, self, rival) - self.status = "agree_waiting" - rival.status = "agree_waiting" - end - when /^%%CHAT\s+(\S+)/ - message = $1 - LEAGUE.hash.each do |name, player| - if (player.protocol != "CSA") - s = player.write_safe(sprintf("##[CHAT][%s] %s\n", @name, message)) - player.status = "zombie" if (! s) - end - end - when /^%%WHO/ - buf = Array::new - LEAGUE.hash.each do |name, player| - buf.push(sprintf("##[WHO] %s\n", player.to_s)) - end - buf.push("##[WHO] +OK\n") - write_safe(buf.join) - when /^%%LOGOUT/ - finish - return - else - write_safe(sprintf("## unknown command %s\n", str)) - end - ensure - $mutex.unlock - end - end # enf of while - end -end - -class Board -end - -class Game - def initialize(game_name, player0, player1) - @game_name = game_name - if (player0.sente) - @sente = player0 - @gote = player1 - else - @sente = player1 - @gote = player0 - end - @current_player = @sente - @next_player = @gote - - @sente.game = self - @gote.game = self - @sente.status = "agree_waiting" - @gote.status = "agree_waiting" - @id = sprintf("%s-%s-%s-%s", @game_name, @sente.name, @gote.name, Time::new.strftime("%Y%m%d%H%M%S")) - log_message(sprintf("game created %s %s %s", game_name, sente.name, gote.name)) - - @logfile = @id + ".csa" - @board = Board::new - @start_time = nil - @fh = nil - - propose - end - attr_accessor :game_name, :sente, :gote, :id, :board, :current_player, :next_player, :fh - - def finish - log_message(sprintf("game finished %s %s %s", game_name, sente.name, gote.name)) - @fh.printf("'$END_TIME:%s\n", Time::new.strftime("%Y/%m/%d %H:%M:%S")) - @fh.close - @sente.status = "connected" - @gote.status = "connected" - if (@current_player.protocol == "CSA") - @current_player.finish - end - end - - def handle_one_move(str, player) - finish_flag = false - if (@current_player == player) - @end_time = Time::new - t = @end_time - @start_time - t = Least_Time_Per_Move if (t < Least_Time_Per_Move) - @sente.write_safe(sprintf("%s,T%d\n", str, t)) - @gote.write_safe(sprintf("%s,T%d\n", str, t)) - @fh.printf("%s\nT%d\n", str, t) - @current_player.mytime = @current_player.mytime - t - if (@current_player.mytime < 0) - timeout_end() - finish_flag = true - elsif (str =~ /%KACHI/) - kachi_end() - finish_flag = true - elsif (str =~ /%TORYO/) - toryo_end - finish_flag = true - end - (@current_player, @next_player) = [@next_player, @current_player] - @start_time = Time::new - finish if (finish_flag) - return finish_flag - end - end - - def timeout_end - @current_player.status = "connected" - @next_player.status = "connected" - @current_player.write_safe("#TIME_UP\n#LOSE\n") - @next_player.write_safe("#TIME_UP\n#WIN\n") - end - - def kachi_end - @current_player.status = "connected" - @next_player.status = "connected" - @current_player.write_safe("#JISHOGI\n#WIN\n") - @next_player.write_safe("#JISHOGI\n#LOSE\n") - end - - def toryo_end - @current_player.status = "connected" - @next_player.status = "connected" - @current_player.write_safe("#RESIGN\n#LOSE\n") - @next_player.write_safe("#RESIGN\n#WIN\n") - end - - def start - log_message(sprintf("game started %s %s %s", game_name, sente.name, gote.name)) - @sente.write_safe(sprintf("START:%s\n", @id)) - @gote.write_safe(sprintf("START:%s\n", @id)) - @start_time = Time::new - end - - def propose - begin - @fh = open(@logfile, "w") - @fh.sync = true - - @fh.printf("V2\n") - @fh.printf("N+%s\n", @sente.name) - @fh.printf("N-%s\n", @gote.name) - @fh.printf("$EVENT:%s\n", @id) - - @sente.write_safe(propose_message("+")) - @gote.write_safe(propose_message("-")) - - @fh.printf("$START_TIME:%s\n", Time::new.strftime("%Y/%m/%d %H:%M:%S")) - @fh.print < ex + log_error("gets_safe: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}") + return :exception end def usage print <.conf". The file will be re-read once just after a + game starts. + + For example, a floodgate-3600-30 game group requires + floodgate-3600-30.conf. However, for floodgate-900-0 and + floodgate-3600-0, which were default enabled in previous + versions, configuration files are optional if you are happy with + default time settings. + File format is: + Line format: + # This is a comment line + DoW Time + ... + where + DoW := "Sun" | "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | + "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | + "Friday" | "Saturday" + Time := HH:MM + + For example, + Sat 13:00 + Sat 22:00 + Sun 13:00 + + PAREMETER SETTING + + In addition, this configuration file allows to set parameters + for the specific Floodaget group. A list of parameters is the + following: + + * pairing_factory: + Specifies a factory function name generating a pairing + method which will be used in a specific Floodgate game. + ex. set pairing_factory floodgate_zyunisen + * sacrifice: + Specifies a sacrificed player. + ex. set sacrifice gps500+e293220e3f8a3e59f79f6b0efffaa931 LICENSE - this file is distributed under GPL version2 and might be compiled by Exerb + GPL versoin 2 or later SEE ALSO -RELEASE - #{Release} - REVISION - #{Revision} + #{ShogiServer::Revision} + EOM end + +def log_debug(str) + $logger.debug(str) +end + def log_message(str) - printf("%s message: %s\n", Time::new.to_s, str) + $logger.info(str) +end +def log_info(str) + log_message(str) end def log_warning(str) - printf("%s message: %s\n", Time::new.to_s, str) + $logger.warn(str) end def log_error(str) - printf("%s error: %s\n", Time::new.to_s, str) + $logger.error(str) 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 - parser.ordering = GetoptLong::REQUIRE_ORDER - parser.set_options( - ["--pid-file", GetoptLong::REQUIRED_ARGUMENT]) - + parser = GetoptLong.new( + ["--daemon", GetoptLong::REQUIRED_ARGUMENT], + ["--floodgate-games", GetoptLong::REQUIRED_ARGUMENT], + ["--pid-file", GetoptLong::REQUIRED_ARGUMENT], + ["--player-log-dir", GetoptLong::REQUIRED_ARGUMENT]) parser.quiet = true begin parser.each_option do |name, arg| @@ -517,80 +210,276 @@ def parse_command_line return options end -LEAGUE = League::new +# Check command line options. +# If any of them is invalid, exit the process. +# +def check_command_line + if (ARGV.length != 2) + usage + exit 2 + end -def good_login?(str) - return false if (str !~ /^LOGIN /) - tokens = str.split - if ((tokens.length == 3) || - ((tokens.length == 4) && tokens[3] == "x1")) - ## ok - else + if $options["daemon"] + $options["daemon"] = File.expand_path($options["daemon"], File.dirname(__FILE__)) + unless is_writable_dir? $options["daemon"] + usage + $stderr.puts "Can not create a file in the daemon directory: %s" % [$options["daemon"]] + exit 5 + end + end + + $topdir = $options["daemon"] || File.expand_path(File.dirname(__FILE__)) + + if $options["player-log-dir"] + $options["player-log-dir"] = File.expand_path($options["player-log-dir"], $topdir) + unless is_writable_dir?($options["player-log-dir"]) + usage + $stderr.puts "Can not write a file in the player log dir: %s" % [$options["player-log-dir"]] + exit 3 + end + end + + if $options["pid-file"] + $options["pid-file"] = File.expand_path($options["pid-file"], $topdir) + unless ShogiServer::is_writable_file? $options["pid-file"] + usage + $stderr.puts "Can not create the pid file: %s" % [$options["pid-file"]] + exit 4 + end + end + + if $options["floodgate-games"] + names = $options["floodgate-games"].split(",") + new_names = + names.select do |name| + ShogiServer::League::Floodgate::game_name?(name) + end + if names.size != new_names.size + $stderr.puts "Found a wrong Floodgate game: %s" % [names.join(",")] + exit 6 + end + $options["floodgate-games"] = new_names + end + + if $options["floodgate-history"] + $stderr.puts "WARNING: --floodgate-history has been deprecated." + $options["floodgate-history"] = nil + end +end + +# See if a file can be created in the directory. +# Return true if a file is writable in the directory, otherwise false. +# +def is_writable_dir?(dir) + unless File.directory? dir return false end - return true + + result = true + + begin + temp_file = Tempfile.new("dummy-shogi-server", dir) + temp_file.close true + rescue + result = false + end + + return result end -def write_pid_file(file) +def write_pid_file(file) open(file, "w") do |fh| - fh.print Process::pid, "\n" + fh.puts "#{$$}" end end -def main +def mutex_watchdog(mutex, sec) + sec = 1 if sec < 1 + queue = [] + while true + if mutex.try_lock + queue.clear + mutex.unlock + else + queue.push(Object.new) + if queue.size > sec + # timeout + log_error("mutex watchdog timeout: %d sec" % [sec]) + queue.clear + end + end + sleep(1) + end +end + +def login_loop(client) + player = login = nil + + while r = select([client], nil, nil, ShogiServer::Login_Time) do + str = nil + begin + break unless str = r[0].first.gets + rescue Exception => ex + # It is posssible that the socket causes an error (ex. Errno::ECONNRESET) + log_error("login_loop: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}") + break + end + $mutex.lock # guards $league + begin + str =~ /([\r\n]*)$/ + eol = $1 + if (ShogiServer::Login::good_login?(str)) + 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" || + 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) + player = nil + break + end + end + $league.add(player) + break + else + client.write("LOGIN:incorrect" + eol) + client.write("type 'LOGIN name password' or 'LOGIN name password x1'" + eol) if (str.split.length >= 4) + end + ensure + $mutex.unlock + end + end # login loop + return [player, login] +end + +def setup_logger(log_file) + logger = ShogiServer::Logger.new(log_file, 'daily') + logger.formatter = ShogiServer::Formatter.new + logger.level = $DEBUG ? Logger::DEBUG : Logger::INFO + logger.datetime_format = "%Y-%m-%d %H:%M:%S" + return logger +end + +def setup_watchdog_for_giant_lock $mutex = Mutex::new - $options = parse_command_line - if (ARGV.length != 2) - usage - exit 2 + Thread::start do + Thread.pass + mutex_watchdog($mutex, 10) end - event = ARGV.shift +end + +def main + + $options = parse_command_line + check_command_line + $config = ShogiServer::Config.new $options + + $league = ShogiServer::League.new($topdir) + + $league.event = ARGV.shift port = ARGV.shift - write_pid_file($options["pid-file"]) if ($options["pid-file"]) + log_file = $options["daemon"] ? File.join($options["daemon"], "shogi-server.log") : STDOUT + $logger = setup_logger(log_file) + $league.dir = $topdir - Thread.abort_on_exception = true + config = {} + config[:BindAddress] = "0.0.0.0" + config[:Port] = port + config[:ServerType] = WEBrick::Daemon if $options["daemon"] + config[:Logger] = $logger - server = TCPserver.open(port) - log_message("server started") + setup_floodgate = nil + + config[:StartCallback] = Proc.new do + srand + if $options["pid-file"] + write_pid_file($options["pid-file"]) + end + setup_watchdog_for_giant_lock + $league.setup_players_database + setup_floodgate = ShogiServer::SetupFloodgate.new($options["floodgate-games"]) + setup_floodgate.start + end + + config[:StopCallback] = Proc.new do + if $options["pid-file"] + FileUtils.rm($options["pid-file"], :force => true) + end + end + + srand + server = WEBrick::GenericServer.new(config) + ["INT", "TERM"].each do |signal| + trap(signal) do + server.shutdown + setup_floodgate.kill + end + end + unless (RUBY_PLATFORM.downcase =~ /mswin|mingw|cygwin|bccwin/) + trap("HUP") do + Dependencies.clear + end + end + $stderr.puts("server started as a deamon [Revision: #{ShogiServer::Revision}]") if $options["daemon"] + log_message("server started [Revision: #{ShogiServer::Revision}]") + + server.start do |client| + begin + # client.sync = true # this is already set in WEBrick + client.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) + # Keepalive time can be set by /proc/sys/net/ipv4/tcp_keepalive_time + player, login = login_loop(client) # loop + unless player + log_error("Detected a timed out login attempt") + next + end - while true - Thread::start(server.accept) do |client| - client.sync = true - player = nil - while (str = client.gets_timeout(Login_Time)) - begin - $mutex.lock - Thread::kill(Thread::current) if (! str) # disconnected - str =~ /([\r\n]*)$/ - eol = $1 - if (good_login?(str)) - player = Player::new(str, client) - if (LEAGUE.duplicated?(player)) - client.write_safe(sprintf("username %s is already connected%s", player.name, eol)) - client.close - Thread::kill(Thread::current) - end - LEAGUE.add(player) - break - else - client.write_safe("type 'LOGIN name password' or 'LOGIN name password x1'" + eol) - client.close - Thread::kill(Thread::current) - end - ensure - $mutex.unlock - end - end # login loop log_message(sprintf("user %s login", player.name)) - player.run - LEAGUE.delete(player) - log_message(sprintf("user %s logout", player.name)) + login.process + player.setup_logger($options["player-log-dir"]) if $options["player-log-dir"] + player.run(login.csa_1st_str) # loop + $mutex.lock + begin + if (player.game) + player.game.kill(player) + end + player.finish + $league.delete(player) + log_message(sprintf("user %s logout", player.name)) + ensure + $mutex.unlock + end + player.wait_write_thread_finish(1000) # milliseconds + rescue Exception => ex + log_error("server.start: #{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}") end end end + if ($0 == __FILE__) - main + 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 + end end