#-- vim:sw=2:et
#kate: indent-width 2
#++
# 
# :title: Shiritori Plugin for RBot
#
# Author:: Yaohan Chen <yaohan.chen@gmail.com>
# Copyright:: (c) 2007 Yaohan Chen
# License:: GNU Public License
#
#
# Shiritori is a word game where a few people take turns to continue a chain of words.
# To continue a word, the next word must start with the ending of the previous word,
# usually defined as the one to few letters/characters at the end. This plugin allows
# playing several games, each per channel. A game can be turn-based, where only new
# players can interrupt a turn to join, or a free mode where anyone can speak at any
# time.
#
# In Japanese mode, if present, the plugin can use normalize-japanese 
# <http://neruchan.mine.nu:60880/normalize-japanese.rb> to allow
# katakana words be used like hiragana.
# 
# TODO
# * a system to describe settings, so they can be displayed, changed and saved
# * adjust settings during game
# * allow other definitions of continues?
# * read default settings from configuration
# * keep statistics
# * other forms of dictionaries


# Abstract class representing a dictionary used by Shiritori
class Dictionary
  # whether string s is a word
  def has_word?(s)
    raise NotImplementedError
  end
  
  # whether any word starts with prefix, excluding words in excludes. This can be
  # possible with non-enumerable dictionaries since some dictionary engines provide
  # prefix searching.
  def any_word_starting?(prefix, excludes)
    raise NotImplementedError
  end
end

# A Dictionary that uses a enumrable word list.
class WordlistDictionary < Dictionary
  def initialize(words)
    super()
    @words = words
    # debug "Created dictionary with #{@words.length} words"
  end
  
    # whether string s is a word
  def has_word?(s)
    @words.include? s
  end
  
  # whether any word starts with prefix, excluding words in excludes
  def any_word_starting?(prefix, excludes)
    # (@words - except).any? {|w| w =~ /\A#{prefix}.+/}
    # this seems to be faster:
    !(@words.grep(/\A#{prefix}.+/) - excludes).empty?
  end
end

# Logic of shiritori game, deals with checking whether words continue the chain, and
# whether it's possible to continue a word
class Shiritori
  attr_reader :used_words
  
  # dictionary:: a Dictionary object
  # overlap_lengths:: a Range for allowed lengths to overlap when continuing words
  # check_continuable:: whether all words are checked whether they're continuable,
  #                     before being commited
  # allow_reuse:: whether words are allowed to be used again
  def initialize(dictionary, overlap_lengths, check_continuable, allow_reuse)
    @dictionary = dictionary
    @overlap_lengths = overlap_lengths
    @check_continuable = check_continuable
    @allow_reuse = allow_reuse
    @used_words = []
  end
  
  # Prefix of s with length n
  def head_of(s, n)
    # TODO ruby2 unicode
    s.split(//u)[0, n].join
  end
  # Suffix of s with length n
  def tail_of(s, n)
    # TODO ruby2 unicode
    s.split(//u)[-n, n].join
  end
  # Number of unicode characters in string
  def len(s)
    # TODO ruby2 unicode
    s.split(//u).length
  end
  # return subrange of range r that's under n
  def range_under(r, n)
    r.begin .. [r.end, n-1].min
  end
  
  # TODO allow the ruleset to customize this
  def continues?(w2, w1)
    # this uses the definition w1[-n,n] == w2[0,n] && n < [w1.length, w2.length].min
    # TODO it might be worth allowing <= for the second clause
    range_under(@overlap_lengths, [len(w1), len(w2)].min).any? {|n|
      tail_of(w1, n)== head_of(w2, n)}
  end
  
  # Checks whether *any* unused word in the dictionary completes the word
  # This has the limitation that it can't detect when a word is continuable, but the
  # only continuers aren't continuable
  def continuable_from?(s)
    range_under(@overlap_lengths, len(s)).any? {|n|
      @dictionary.any_word_starting?(tail_of(s, n), @used_words) }
  end
  
  # Given a string, give a verdict based on current shiritori state and dictionary 
  def process(s)
    # TODO optionally allow used words
    # TODO ruby2 unicode
    if len(s) < @overlap_lengths.min || !@dictionary.has_word?(s)
      # debug "#{s} is too short or not in dictionary"
      :ignore
    elsif @used_words.empty?
      if !@check_continuable || continuable_from?(s)
        @used_words << s
        :start
      else
        :start_end
      end
    elsif continues?(s, @used_words.last)
      if !@allow_reuse && @used_words.include?(s)
        :used
      elsif !@check_continuable || continuable_from?(s)
        @used_words << s
        :next
      else
        :end
      end
    else
      :ignore
    end
  end
end

# A shiritori game on a channel. keeps track of rules related to timing and turns,
# and interacts with players
class ShiritoriGame
  # timer:: the bot.timer object
  # say:: a Proc which says the given message on the channel
  # when_die:: a Proc that removes the game from plugin's list of games
  def initialize(channel, ruleset, timer, say, when_die)
    raise ArgumentError unless [:words, :overlap_lengths, :check_continuable,
         :end_when_uncontinuable, :allow_reuse, :listen, :normalize, :time_limit,
         :lose_when_timeout].all? {|r| ruleset.has_key?(r)}
    @last_word = nil
    @players = []
    @booted_players = []
    @ruleset = ruleset
    @channel = channel
    @timer = timer
    @timer_handle = nil
    @say = say
    @when_die = when_die
    
    # TODO allow other forms of dictionaries
    dictionary = WordlistDictionary.new(@ruleset[:words])
    @game = Shiritori.new(dictionary, @ruleset[:overlap_lengths],
                                      @ruleset[:check_continuable],
                                      @ruleset[:allow_reuse])
  end
  
  def say(s)
     @say.call(s)
  end

  # Whether the players must take turns
  # * when there is only one player, turns are not enforced
  # * when time_limit > 0, new players can join at any time, but existing players must
  #   take turns, each of which expires after time_limit
  # * when time_imit is 0, anyone can speak in the game at any time
  def take_turns? 
    @players.length > 1 && @ruleset[:time_limit] > 0
  end
  
  # the player who has the current turn
  def current_player
    @players.first
  end
  # the word to continue in the current turn
  def current_word
    @game.used_words[-1]
  end
  # the word in the chain before current_word
  def previous_word
    @game.used_words[-2]
  end
  
  # announce the current word, and player if take_turns?
  def announce
    say(if take_turns?
      _("%{current_player}, it's your turn. %{previous_word} -> %{current_word}") %
       { :current_player => current_player, :previous_word => previous_word,
         :current_word => current_word }
    elsif @players.empty?
      _("No one has given the first word yet. Say the first word to start.")
    else
      _("Poor %{current_player} is playing alone! Anyone care to join? %{previous_word} -> %{current_word}") %
      { :current_player => current_player, :previous_word => previous_word,
        :current_word => current_word }
    end)
  end
  # create/reschedule timer
  def restart_timer
    # the first time the method is called, a new timer is added
    @timer_handle = @timer.add(@ruleset[:time_limit]) {time_out}
    # afterwards, it will reschdule the timer
    instance_eval do
      def restart_timer
        @timer.reschedule(@timer_handle, @ruleset[:time_limit])
      end
    end
  end
  # switch to the next player's turn if take_turns?, and announce current words
  def next_player
    # when there's only one player, turns and timer are meaningless
    if take_turns?
      # place the current player to the last position, to implement circular queue
      @players << @players.shift
      # stop previous timer and set time for this turn
      restart_timer
    end
    announce
  end
  
  # handle when turn time limit goes out
  def time_out
    if @ruleset[:lose_when_timeout]
      say _("%{player} took too long and is out of the game. Try again next game!") %
      { :player => current_player }
      if @players.length == 2 
        # 2 players before, and one should remain now
        # since the game is ending, save the trouble of removing and booting the player
        say _("%{player} is the last remaining player and the winner! Congratulations!") %
          {:player => @players.last}
        die
      else
        @booted_players << @players.shift
        announce
      end
    else
      say _("%{player} took too long and skipped the turn.") %
          {:player => current_player}
      next_player
    end
  end

  # change the rules, and update state when necessary
  def change_rules(rules)
    @ruleset.update! rules
  end

  # handle a message to @channel
  def handle_message(m)
    message = m.message
    speaker = m.sourcenick.to_s
    
    return unless @ruleset[:listen] =~ message

    # in take_turns mode, only new players are allowed to interrupt a turn
    return if @booted_players.include? speaker ||
              (take_turns? && 
               speaker != current_player &&
               (@players.length > 1 && @players.include?(speaker)))

    # let Shiritori process the message, and act according to result
    case @game.process @ruleset[:normalize].call(message)
    when :start
      @players << speaker
      m.reply _("%{player} has given the first word: %{word}") %
              {:player => speaker, :word => current_word}
    when :next
      if !@players.include?(speaker)
        # A new player
        @players.unshift speaker
        m.reply _("Welcome to shiritori, %{player}.") %
                {:player => speaker}
      end
      next_player
    when :used
      m.reply _("The word %{used_word} has been used. Retry from %{word}") %
              {:used_word => message, :word => current_word}
    when :end
      # TODO respect shiritori.end_when_uncontinuable setting
      if @ruleset[:end_when_uncontinuable]
        m.reply _("It's impossible to continue the chain from %{word}. The game has ended. Thanks a lot, %{player}! :(") %
                {:word => message, :player => speaker}
        die
      else
        m.reply _("It's impossible to continue the chain from %{bad_word}. Retry from %{word}") % {:bad_word => message, :word => current_word}
      end
    when :start_end
      # when the first word is uncontinuable, the game doesn't stop, as presumably
      # someone wanted to play
      m.reply _("It's impossible to continue the chain from %{word}. Start with another word.") % {:word => message}
    end
  end
  
  # end the game
  def die
    # redefine restart_timer to no-op
    instance_eval do
      def restart_timer
      end
    end
    # remove any registered timer
    @timer.remove @timer_handle unless @timer_handle.nil?
    # should remove the game object from plugin's @games list
    @when_die.call
  end
end

# shiritori plugin for rbot
class ShiritoriPlugin < Plugin
  def help(plugin, topic="")
    _("A game in which each player must continue the previous player's word, by using its last one or few characters/letters of the word to start a new word. 'shiritori <ruleset>' => Play shiritori with a set of rules. Available rulesets: %{rulesets}. 'shiritori stop' => Stop the current shiritori game.") %
      {:rulesets => @rulesets.keys.join(', ')}
  end
  
  def initialize()
    super
    @games = {}
    
    # TODO make rulesets more easily customizable
    # TODO initialize default ruleset from config
    # Default values of rulesets
    @default_ruleset = {
      # the range of the length of "tail" that must be followed to continue the chain
      :overlap_lengths => 1..2,
      # messages cared about, pre-normalize
      :listen => /\A\S+\Z/u,
      # normalize messages with this function before checking with Shiritori
      :normalize => lambda {|w| w},
      # number of seconds for each player's turn
      :time_limit => 60,
      # when the time limit is reached, the player's booted out of the game and cannot
      # join until the next game
      :lose_when_timeout => true,
      # check whether the word is continuable before adding it into chain
      :check_continuable => true,
      # allow reusing used words
      :allow_reuse => false,
      # end the game when an uncontinuable word is said
      :end_when_uncontinuable => true
    }
    @rulesets = {
      'english' => {
        :wordlist_file => 'english',
        :listen => /\A[a-zA-Z]+\Z/,
        :overlap_lengths => 2..5,
        :normalize => lambda {|w| w.downcase},
        :desc => 'Use English words; case insensitive; 2-6 letters at the beginning of the next word must overlap with those at the end of the previous word.'
      },
      'japanese' => {
        :wordlist_file => 'japanese',
        :listen => /\A\S+\Z/u,
        :overlap_lengths => 1..4,
        :desc => 'Use Japanese words in hiragana; 1-4 kana at the beginning of the next word must overlap with those at the end of the previous word.',
        :normalize =>
          begin
            require 'normalize-japanese'
            lambda {|w| w.to_hiragana}
          rescue LoadError
            lambda {|w| w}
          end
      }
    }
  end

  def load_ruleset(ruleset_name)
    # set default values for each rule to default_ruleset's values
    ruleset = @rulesets[ruleset_name].dup
    ruleset.replace @default_ruleset.merge(ruleset)
    unless ruleset.has_key?(:words)
      if ruleset.has_key?(:wordlist_file)
        begin
          ruleset[:words] =
            File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep(
              ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp}
        rescue
          raise "unable to load word list"
        end
      else
        raise NotImplementedError, "ruleset not implemented (properly)"
      end
    end
    return ruleset
  end
  
  # start shiritori in a channel
  def cmd_shiritori(m, params)
    if @games.has_key?( m.channel )
      m.reply _("Already playing shiritori here")
      @games[m.channel].announce
    else
      ruleset = params[:ruleset].downcase
      if @rulesets.has_key? ruleset
        begin
          @games[m.channel] = ShiritoriGame.new(
            m.channel, load_ruleset(ruleset),
            @bot.timer,
            lambda {|msg| m.reply msg},
            lambda {remove_game m.channel} )
          m.reply _("Shiritori has started. Please say the first word")
        rescue => e
          m.reply _("couldn't start %{ruleset} shiritori: %{error}") %
                  {:ruleset => ruleset, :error => e}
        end
      else
        m.reply _("There is no ruleset named %{ruleset}") % {:ruleset => ruleset}
      end
    end
  end
  
  # change rules for current game
  def cmd_set(m, params)
    require 'enumerator'
    new_rules = {}
    params[:rules].each_slice(2) {|opt, value| new_rules[opt] = value}
    raise NotImplementedError
  end
  
  # stop the current game
  def cmd_stop(m, params)
    if @games.has_key? m.channel
      # TODO display statistics
      @games[m.channel].die
      m.reply _("Shiritori has stopped. Hope you had fun!")
    else
      # TODO display statistics
      m.reply _("No game to stop here, because no game is being played.")
    end
  end
  
  # remove the game, so channel messages are no longer processed, and timer removed
  def remove_game(channel)
    @games.delete channel
  end
  
  # all messages from a channel is sent to its shiritori game if any
  def message(m)
    return unless @games.has_key?(m.channel)
    # send the message to the game in the channel to handle it
    @games[m.channel].handle_message m
  end
  
  # remove all games
  def cleanup
    @games.each_key {|g| g.die}
    @games.clear
    super
  end
end

plugin = ShiritoriPlugin.new
plugin.default_auth( 'edit', false )

# Normal commandsi have a stop_gamei have a stop_game
plugin.map 'shiritori stop',
           :action => 'cmd_stop',
           :private => false
# plugin.map 'shiritori set ',
#            :action => 'cmd_set'
#            :private => false
# plugin.map 'shiritori challenge',
#            :action => 'cmd_challenge'
plugin.map 'shiritori [:ruleset]',
           :action => 'cmd_shiritori',
           :defaults => {:ruleset => 'japanese'},
           :private => false
