From 2e6f388addc0bb9ddc0cb991af50220c1e5e64c3 Mon Sep 17 00:00:00 2001 From: Giuseppe Bilotta Date: Fri, 16 Mar 2007 20:48:28 +0000 Subject: Plugins: move games into their own directory --- data/rbot/plugins/azgame.rb | 550 -------------------- data/rbot/plugins/games/azgame.rb | 550 ++++++++++++++++++++ data/rbot/plugins/games/quiz.rb | 963 +++++++++++++++++++++++++++++++++++ data/rbot/plugins/games/roshambo.rb | 62 +++ data/rbot/plugins/games/roulette.rb | 219 ++++++++ data/rbot/plugins/games/shiritori.rb | 448 ++++++++++++++++ data/rbot/plugins/quiz.rb | 963 ----------------------------------- data/rbot/plugins/roshambo.rb | 62 --- data/rbot/plugins/roulette.rb | 219 -------- data/rbot/plugins/shiritori.rb | 448 ---------------- 10 files changed, 2242 insertions(+), 2242 deletions(-) delete mode 100644 data/rbot/plugins/azgame.rb create mode 100644 data/rbot/plugins/games/azgame.rb create mode 100644 data/rbot/plugins/games/quiz.rb create mode 100644 data/rbot/plugins/games/roshambo.rb create mode 100644 data/rbot/plugins/games/roulette.rb create mode 100644 data/rbot/plugins/games/shiritori.rb delete mode 100644 data/rbot/plugins/quiz.rb delete mode 100644 data/rbot/plugins/roshambo.rb delete mode 100644 data/rbot/plugins/roulette.rb delete mode 100644 data/rbot/plugins/shiritori.rb (limited to 'data/rbot/plugins') diff --git a/data/rbot/plugins/azgame.rb b/data/rbot/plugins/azgame.rb deleted file mode 100644 index a6979830..00000000 --- a/data/rbot/plugins/azgame.rb +++ /dev/null @@ -1,550 +0,0 @@ -#-- vim:sw=2:et -#++ -# -# :title: A-Z Game Plugin for rbot -# -# Author:: Giuseppe "Oblomov" Bilotta -# Author:: Yaohan Chen : Japanese support -# -# Copyright:: (C) 2006 Giuseppe Bilotta -# Copyright:: (C) 2007 GIuseppe Bilotta, Yaohan Chen -# -# License:: GPL v2 -# -# A-Z Game: guess the word by reducing the interval of allowed ones -# -# TODO allow manual addition of words - -class AzGame - - attr_reader :range, :word - attr_reader :lang, :rules, :listener - attr_accessor :tries, :total_tries, :total_failed, :failed, :winner - def initialize(plugin, lang, rules, word) - @plugin = plugin - @lang = lang.to_sym - @word = word.downcase - @rules = rules - @range = [@rules[:first].dup, @rules[:last].dup] - @listener = @rules[:listener] - @total_tries = 0 - @total_failed = 0 # not used, reported, updated - @tries = Hash.new(0) - @failed = Hash.new(0) # not used, not reported, updated - @winner = nil - def @range.to_s - return "%s -- %s" % self - end - end - - def check(word) - w = word.downcase - debug "checking #{w} for #{@word} in #{@range}" - return [:bingo, nil] if w == @word - return [:out, @range] if w < @range.first or w > @range.last - return [:ignore, @range] if w == @range.first or w == @range.last - return [:noexist, @range] unless @plugin.send("is_#{@lang}?", w) - debug "we like it" - if w < @word - @range.first.replace(w) - else - @range.last.replace(w) - end - return [:in, @range] - end - -# TODO scoring: base score is t = ceil(100*exp(-(n-1)^2/50))+p for n attempts -# done by p players; players that didn't win but contributed -# with a attempts will get t*a/n points - - include Math - - def score - n = @total_tries - p = @tries.keys.length - t = (100*exp(-(n-1)**2/50**2)).ceil + p - debug "Total score: #{t}" - ret = Hash.new - @tries.each { |k, a| - ret[k] = [t*a/n, "%d %s" % [a, a > 1 ? "tries" : "try"]] - } - if @winner - debug "replacing winner score of %d with %d" % [ret[@winner].first, t] - tries = ret[@winner].last - ret[@winner] = [t, "winner, #{tries}"] - end - return ret.sort_by { |h| h.last.first }.reverse - end - -end - -class AzGamePlugin < Plugin - - def initialize - super - # if @registry.has_key?(:games) - # @games = @registry[:games] - # else - @games = Hash.new - # end - if @registry.has_key?(:wordcache) and @registry[:wordcache] - @wordcache = @registry[:wordcache] - else - @wordcache = Hash.new - end - debug "\n\n\nA-Z wordcache: #{@wordcache.inspect}\n\n\n" - - @rules = { - :italian => { - :good => /s\.f\.|s\.m\.|agg\.|v\.tr\.|v\.(pronom\.)?intr\./, # avv\.|pron\.|cong\. - :bad => /var\./, - :first => 'abaco', - :last => 'zuzzurellone', - :url => "http://www.demauroparavia.it/%s", - :wapurl => "http://wap.demauroparavia.it/index.php?lemma=%s", - :listener => /^[a-z]+$/ - }, - :english => { - :good => /(?:singular )?noun|verb|adj/, - :first => 'abacus', - :last => 'zuni', - :url => "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st", - :listener => /^[a-z]+$/ - }, - } - - japanese_wordlist = "#{@bot.botclass}/azgame/wordlist-japanese" - if File.exist?(japanese_wordlist) - words = File.readlines(japanese_wordlist) \ - .map {|line| line.strip} .uniq - if(words.length >= 4) # something to guess - @rules[:japanese] = { - :good => /^\S+$/, - :list => words, - :first => words[0], - :last => words[-1], - :listener => /^\S+$/ - } - debug "Japanese wordlist loaded, #{@rules[:japanese][:list].length} lines; first word: #{@rules[:japanese][:first]}, last word: #{@rules[:japanese][:last]}" - end - end - end - - def save - # @registry[:games] = @games - @registry[:wordcache] = @wordcache - end - - def listen(m) - return unless m.kind_of?(PrivMessage) - return if m.channel.nil? or m.address? - k = m.channel.downcase.to_s # to_sym? - return unless @games.key?(k) - return if m.params - word = m.plugin.downcase - return unless word =~ @games[k].listener - word_check(m, k, word) - end - - def word_check(m, k, word) - isit = @games[k].check(word) - case isit.first - when :bingo - m.reply "#{Bold}BINGO!#{Bold}: the word was #{Underline}#{word}#{Underline}. Congrats, #{Bold}#{m.sourcenick}#{Bold}!" - @games[k].total_tries += 1 - @games[k].tries[m.source] += 1 - @games[k].winner = m.source - ar = @games[k].score.inject([]) { |res, kv| - res.push("%s: %d (%s)" % kv.flatten) - } - m.reply "The game was won after #{@games[k].total_tries} tries. Scores for this game: #{ar.join('; ')}" - @games.delete(k) - when :out - m.reply "#{word} is not in the range #{Bold}#{isit.last}#{Bold}" if m.address? - when :noexist - m.reply "#{word} doesn't exist or is not acceptable for the game" - @games[k].total_failed += 1 - @games[k].failed[m.source] += 1 - when :in - m.reply "close, but no cigar. New range: #{Bold}#{isit.last}#{Bold}" - @games[k].total_tries += 1 - @games[k].tries[m.source] += 1 - when :ignore - m.reply "#{word} is already one of the range extrema: #{isit.last}" if m.address? - else - m.reply "hm, something went wrong while verifying #{word}" - end - end - - def manual_word_check(m, params) - k = m.channel.downcase.to_s - word = params[:word].downcase - if not @games.key?(k) - m.reply "no A-Z game running here, can't check if #{word} is valid, can I?" - return - end - if word !~ /^\S+$/ - m.reply "I only accept single words composed by letters only, sorry" - return - end - word_check(m, k, word) - end - - def stop_game(m, params) - return if m.channel.nil? # Shouldn't happen, but you never know - k = m.channel.downcase.to_s # to_sym? - if @games.key?(k) - m.reply "the word in #{Bold}#{@games[k].range}#{Bold} was: #{Bold}#{@games[k].word}" - ar = @games[k].score.inject([]) { |res, kv| - res.push("%s: %d (%s)" % kv.flatten) - } - m.reply "The game was cancelled after #{@games[k].total_tries} tries. Scores for this game would have been: #{ar.join('; ')}" - @games.delete(k) - else - m.reply "no A-Z game running in this channel ..." - end - end - - def start_game(m, params) - return if m.channel.nil? # Shouldn't happen, but you never know - k = m.channel.downcase.to_s # to_sym? - unless @games.key?(k) - lang = (params[:lang] || @bot.config['core.language']).to_sym - method = 'random_pick_'+lang.to_s - m.reply "let me think ..." - if @rules.has_key?(lang) and self.respond_to?(method) - word = self.send(method) - if word.empty? - m.reply "couldn't think of anything ..." - return - end - else - m.reply "I can't play A-Z in #{lang}, sorry" - return - end - m.reply "got it!" - @games[k] = AzGame.new(self, lang, @rules[lang], word) - end - tr = @games[k].total_tries - case tr - when 0 - tr_msg = "" - when 1 - tr_msg = " (after 1 try" - else - tr_msg = " (after #{tr} tries" - end - - unless tr_msg.empty? - f_tr = @games[k].total_failed - case f_tr - when 0 - tr_msg << ")" - when 1 - tr_msg << " and 1 invalid try)" - else - tr_msg << " and #{f_tr} invalid tries)" - end - end - - m.reply "A-Z: #{Bold}#{@games[k].range}#{Bold}" + tr_msg - return - end - - def wordlist(m, params) - pars = params[:params] - lang = (params[:lang] || @bot.config['core.language']).to_sym - wc = @wordcache[lang] || Hash.new rescue Hash.new - cmd = params[:cmd].to_sym rescue :count - case cmd - when :count - m.reply "I have #{wc.size > 0 ? wc.size : 'no'} #{lang} words in my cache" - when :show, :list - if pars.empty? - m.reply "provide a regexp to match" - return - end - begin - regex = /#{pars[0]}/ - matches = wc.keys.map { |k| - k.to_s - }.grep(regex) - rescue - matches = [] - end - if matches.size == 0 - m.reply "no #{lang} word I know match #{pars[0]}" - elsif matches.size > 25 - m.reply "more than 25 #{lang} words I know match #{pars[0]}, try a stricter matching" - else - m.reply "#{matches.join(', ')}" - end - when :info - if pars.empty? - m.reply "provide a word" - return - end - word = pars[0].downcase.to_sym - if not wc.key?(word) - m.reply "I don't know any #{lang} word #{word}" - return - end - tr = "#{word} learned from #{wc[word][:who]}" - (tr << " on #{wc[word][:when]}") if wc[word].key?(:when) - m.reply tr - when :delete - if pars.empty? - m.reply "provide a word" - return - end - word = pars[0].downcase.to_sym - if not wc.key?(word) - m.reply "I don't know any #{lang} word #{word}" - return - end - wc.delete(word) - @bot.okay m.replyto - when :add - if pars.empty? - m.reply "provide a word" - return - end - word = pars[0].downcase.to_sym - if wc.key?(word) - m.reply "I already know the #{lang} word #{word}" - return - end - wc[word] = { :who => m.sourcenick, :when => Time.now } - @bot.okay m.replyto - else - end - end - - def is_japanese?(word) - @rules[:japanese][:list].include?(word) - end - - # return integer between min and max, inclusive - def rand_between(min, max) - rand(max - min + 1) + min - end - - def random_pick_japanese(min=nil, max=nil) - rules = @rules[:japanese] - min = rules[:first] if min.nil_or_empty? - max = rules[:last] if max.nil_or_empty? - debug "Randomly picking word between #{min} and #{max}" - min_index = rules[:list].index(min) - max_index = rules[:list].index(max) - debug "Index between #{min_index} and #{max_index}" - index = rand_between(min_index + 1, max_index - 1) - debug "Index generated: #{index}" - word = rules[:list][index] - debug "Randomly picked #{word}" - word - end - - def is_italian?(word) - unless @wordcache.key?(:italian) - @wordcache[:italian] = Hash.new - end - wc = @wordcache[:italian] - return true if wc.key?(word.to_sym) - rules = @rules[:italian] - p = @bot.httputil.get_cached(rules[:wapurl] % word) - if not p - error "could not connect!" - return false - end - debug p - p.scan(/#{word} - (.*?) :dict} - return true - end - next - } - return false - end - - def random_pick_italian(min=nil,max=nil) - # Try to pick a random word between min and max - word = String.new - min = min.to_s - max = max.to_s - if min > max - m.reply "#{min} > #{max}" - return word - end - rules = @rules[:italian] - min = rules[:first] if min.empty? - max = rules[:last] if max.empty? - debug "looking for word between #{min.inspect} and #{max.inspect}" - return word if min.empty? or max.empty? - begin - while (word <= min or word >= max or word !~ /^[a-z]+$/) - debug "looking for word between #{min} and #{max} (prev: #{word.inspect})" - # TODO for the time being, skip words with extended characters - unless @wordcache.key?(:italian) - @wordcache[:italian] = Hash.new - end - wc = @wordcache[:italian] - - if wc.size > 0 - cache_or_url = rand(2) - if cache_or_url == 0 - debug "getting word from wordcache" - word = wc.keys[rand(wc.size)].to_s - next - end - end - - # TODO when doing ranges, adapt this choice - l = ('a'..'z').to_a[rand(26)] - debug "getting random word from dictionary, starting with letter #{l}" - first = rules[:url] % "lettera_#{l}_0_50" - p = @bot.httputil.get_cached(first) - max_page = p.match(/ \/ (\d+)<\/label>/)[1].to_i - pp = rand(max_page)+1 - debug "getting random word from dictionary, starting with letter #{l}, page #{pp}" - p = @bot.httputil.get_cached(first+"&pagina=#{pp}") if pp > 1 - lemmi = Array.new - good = rules[:good] - bad = rules[:bad] - # We look for a lemma composed by a single word and of length at least two - p.scan(/
  • .*? (.+?)<\/li>/) { |url, prelemma, tipo| - lemma = prelemma.downcase.to_sym - debug "checking lemma #{lemma} (#{prelemma}) of type #{tipo} from url #{url}" - next if wc.key?(lemma) - case tipo - when good - if tipo =~ bad - debug "refusing, #{bad}" - next - end - debug "good one" - lemmi << lemma - wc[lemma] = {:who => :dict} - else - debug "refusing, not #{good}" - end - } - word = lemmi[rand(lemmi.length)].to_s - end - rescue => e - error "error #{e.inspect} while looking up a word" - error e.backtrace.join("\n") - end - return word - end - - def is_english?(word) - unless @wordcache.key?(:english) - @wordcache[:english] = Hash.new - end - wc = @wordcache[:english] - return true if wc.key?(word.to_sym) - rules = @rules[:english] - p = @bot.httputil.get_cached(rules[:url] % URI.escape(word)) - if not p - error "could not connect!" - return false - end - debug p - if p =~ /#{word}<\/span>([^\n]+?)#{rules[:good]}<\/span>/i - debug "new word #{word}" - wc[word.to_sym] = {:who => :dict} - return true - end - return false - end - - def random_pick_english(min=nil,max=nil) - # Try to pick a random word between min and max - word = String.new - min = min.to_s - max = max.to_s - if min > max - m.reply "#{min} > #{max}" - return word - end - rules = @rules[:english] - min = rules[:first] if min.empty? - max = rules[:last] if max.empty? - debug "looking for word between #{min.inspect} and #{max.inspect}" - return word if min.empty? or max.empty? - begin - while (word <= min or word >= max or word !~ /^[a-z]+$/) - debug "looking for word between #{min} and #{max} (prev: #{word.inspect})" - # TODO for the time being, skip words with extended characters - unless @wordcache.key?(:english) - @wordcache[:english] = Hash.new - end - wc = @wordcache[:english] - - if wc.size > 0 - cache_or_url = rand(2) - if cache_or_url == 0 - debug "getting word from wordcache" - word = wc.keys[rand(wc.size)].to_s - next - end - end - - # TODO when doing ranges, adapt this choice - l = ('a'..'z').to_a[rand(26)] - ll = ('a'..'z').to_a[rand(26)] - random = [l,ll].join('*') + '*' - debug "getting random word from dictionary, matching #{random}" - p = @bot.httputil.get_cached(rules[:url] % URI.escape(random)) - debug p - lemmi = Array.new - good = rules[:good] - # We look for a lemma composed by a single word and of length at least two - p.scan(/(.*?)<\/span>([^\n]+?)#{rules[:good]}<\/span>/i) { |prelemma, discard| - lemma = prelemma.downcase - debug "checking lemma #{lemma} (#{prelemma}) and discarding #{discard}" - next if wc.key?(lemma.to_sym) - if lemma =~ /^[a-z]+$/ - debug "good one" - lemmi << lemma - wc[lemma.to_sym] = {:who => :dict} - else - debug "funky characters, not good" - end - } - next if lemmi.empty? - word = lemmi[rand(lemmi.length)] - end - rescue => e - error "error #{e.inspect} while looking up a word" - error e.backtrace.join("\n") - end - return word - end - - def help(plugin, topic="") - case topic - when 'manage' - return "az [lang] word [count|list|add|delete] => manage the az wordlist for language lang (defaults to current bot language)" - when 'cancel' - return "az cancel => abort current game" - when 'check' - return 'az check => checks against current game' - when 'rules' - return "try to guess the word the bot is thinking of; if you guess wrong, the bot will use the new word to restrict the range of allowed words: eventually, the range will be so small around the correct word that you can't miss it" - when 'play' - return "az => start a game if none is running, show the current word range otherwise; you can say 'az ' if you want to play in a language different from the current bot default" - end - return "az topics: play, rules, cancel, manage, check" - end - -end - -plugin = AzGamePlugin.new -plugin.map 'az [:lang] word :cmd *params', :action=>'wordlist', :defaults => { :lang => nil, :cmd => 'count', :params => [] }, :auth_path => '!az::edit!' -plugin.map 'az cancel', :action=>'stop_game', :private => false -plugin.map 'az check :word', :action => 'manual_word_check', :private => false -plugin.map 'az [play] [:lang]', :action=>'start_game', :private => false, :defaults => { :lang => nil } - diff --git a/data/rbot/plugins/games/azgame.rb b/data/rbot/plugins/games/azgame.rb new file mode 100644 index 00000000..a6979830 --- /dev/null +++ b/data/rbot/plugins/games/azgame.rb @@ -0,0 +1,550 @@ +#-- vim:sw=2:et +#++ +# +# :title: A-Z Game Plugin for rbot +# +# Author:: Giuseppe "Oblomov" Bilotta +# Author:: Yaohan Chen : Japanese support +# +# Copyright:: (C) 2006 Giuseppe Bilotta +# Copyright:: (C) 2007 GIuseppe Bilotta, Yaohan Chen +# +# License:: GPL v2 +# +# A-Z Game: guess the word by reducing the interval of allowed ones +# +# TODO allow manual addition of words + +class AzGame + + attr_reader :range, :word + attr_reader :lang, :rules, :listener + attr_accessor :tries, :total_tries, :total_failed, :failed, :winner + def initialize(plugin, lang, rules, word) + @plugin = plugin + @lang = lang.to_sym + @word = word.downcase + @rules = rules + @range = [@rules[:first].dup, @rules[:last].dup] + @listener = @rules[:listener] + @total_tries = 0 + @total_failed = 0 # not used, reported, updated + @tries = Hash.new(0) + @failed = Hash.new(0) # not used, not reported, updated + @winner = nil + def @range.to_s + return "%s -- %s" % self + end + end + + def check(word) + w = word.downcase + debug "checking #{w} for #{@word} in #{@range}" + return [:bingo, nil] if w == @word + return [:out, @range] if w < @range.first or w > @range.last + return [:ignore, @range] if w == @range.first or w == @range.last + return [:noexist, @range] unless @plugin.send("is_#{@lang}?", w) + debug "we like it" + if w < @word + @range.first.replace(w) + else + @range.last.replace(w) + end + return [:in, @range] + end + +# TODO scoring: base score is t = ceil(100*exp(-(n-1)^2/50))+p for n attempts +# done by p players; players that didn't win but contributed +# with a attempts will get t*a/n points + + include Math + + def score + n = @total_tries + p = @tries.keys.length + t = (100*exp(-(n-1)**2/50**2)).ceil + p + debug "Total score: #{t}" + ret = Hash.new + @tries.each { |k, a| + ret[k] = [t*a/n, "%d %s" % [a, a > 1 ? "tries" : "try"]] + } + if @winner + debug "replacing winner score of %d with %d" % [ret[@winner].first, t] + tries = ret[@winner].last + ret[@winner] = [t, "winner, #{tries}"] + end + return ret.sort_by { |h| h.last.first }.reverse + end + +end + +class AzGamePlugin < Plugin + + def initialize + super + # if @registry.has_key?(:games) + # @games = @registry[:games] + # else + @games = Hash.new + # end + if @registry.has_key?(:wordcache) and @registry[:wordcache] + @wordcache = @registry[:wordcache] + else + @wordcache = Hash.new + end + debug "\n\n\nA-Z wordcache: #{@wordcache.inspect}\n\n\n" + + @rules = { + :italian => { + :good => /s\.f\.|s\.m\.|agg\.|v\.tr\.|v\.(pronom\.)?intr\./, # avv\.|pron\.|cong\. + :bad => /var\./, + :first => 'abaco', + :last => 'zuzzurellone', + :url => "http://www.demauroparavia.it/%s", + :wapurl => "http://wap.demauroparavia.it/index.php?lemma=%s", + :listener => /^[a-z]+$/ + }, + :english => { + :good => /(?:singular )?noun|verb|adj/, + :first => 'abacus', + :last => 'zuni', + :url => "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st", + :listener => /^[a-z]+$/ + }, + } + + japanese_wordlist = "#{@bot.botclass}/azgame/wordlist-japanese" + if File.exist?(japanese_wordlist) + words = File.readlines(japanese_wordlist) \ + .map {|line| line.strip} .uniq + if(words.length >= 4) # something to guess + @rules[:japanese] = { + :good => /^\S+$/, + :list => words, + :first => words[0], + :last => words[-1], + :listener => /^\S+$/ + } + debug "Japanese wordlist loaded, #{@rules[:japanese][:list].length} lines; first word: #{@rules[:japanese][:first]}, last word: #{@rules[:japanese][:last]}" + end + end + end + + def save + # @registry[:games] = @games + @registry[:wordcache] = @wordcache + end + + def listen(m) + return unless m.kind_of?(PrivMessage) + return if m.channel.nil? or m.address? + k = m.channel.downcase.to_s # to_sym? + return unless @games.key?(k) + return if m.params + word = m.plugin.downcase + return unless word =~ @games[k].listener + word_check(m, k, word) + end + + def word_check(m, k, word) + isit = @games[k].check(word) + case isit.first + when :bingo + m.reply "#{Bold}BINGO!#{Bold}: the word was #{Underline}#{word}#{Underline}. Congrats, #{Bold}#{m.sourcenick}#{Bold}!" + @games[k].total_tries += 1 + @games[k].tries[m.source] += 1 + @games[k].winner = m.source + ar = @games[k].score.inject([]) { |res, kv| + res.push("%s: %d (%s)" % kv.flatten) + } + m.reply "The game was won after #{@games[k].total_tries} tries. Scores for this game: #{ar.join('; ')}" + @games.delete(k) + when :out + m.reply "#{word} is not in the range #{Bold}#{isit.last}#{Bold}" if m.address? + when :noexist + m.reply "#{word} doesn't exist or is not acceptable for the game" + @games[k].total_failed += 1 + @games[k].failed[m.source] += 1 + when :in + m.reply "close, but no cigar. New range: #{Bold}#{isit.last}#{Bold}" + @games[k].total_tries += 1 + @games[k].tries[m.source] += 1 + when :ignore + m.reply "#{word} is already one of the range extrema: #{isit.last}" if m.address? + else + m.reply "hm, something went wrong while verifying #{word}" + end + end + + def manual_word_check(m, params) + k = m.channel.downcase.to_s + word = params[:word].downcase + if not @games.key?(k) + m.reply "no A-Z game running here, can't check if #{word} is valid, can I?" + return + end + if word !~ /^\S+$/ + m.reply "I only accept single words composed by letters only, sorry" + return + end + word_check(m, k, word) + end + + def stop_game(m, params) + return if m.channel.nil? # Shouldn't happen, but you never know + k = m.channel.downcase.to_s # to_sym? + if @games.key?(k) + m.reply "the word in #{Bold}#{@games[k].range}#{Bold} was: #{Bold}#{@games[k].word}" + ar = @games[k].score.inject([]) { |res, kv| + res.push("%s: %d (%s)" % kv.flatten) + } + m.reply "The game was cancelled after #{@games[k].total_tries} tries. Scores for this game would have been: #{ar.join('; ')}" + @games.delete(k) + else + m.reply "no A-Z game running in this channel ..." + end + end + + def start_game(m, params) + return if m.channel.nil? # Shouldn't happen, but you never know + k = m.channel.downcase.to_s # to_sym? + unless @games.key?(k) + lang = (params[:lang] || @bot.config['core.language']).to_sym + method = 'random_pick_'+lang.to_s + m.reply "let me think ..." + if @rules.has_key?(lang) and self.respond_to?(method) + word = self.send(method) + if word.empty? + m.reply "couldn't think of anything ..." + return + end + else + m.reply "I can't play A-Z in #{lang}, sorry" + return + end + m.reply "got it!" + @games[k] = AzGame.new(self, lang, @rules[lang], word) + end + tr = @games[k].total_tries + case tr + when 0 + tr_msg = "" + when 1 + tr_msg = " (after 1 try" + else + tr_msg = " (after #{tr} tries" + end + + unless tr_msg.empty? + f_tr = @games[k].total_failed + case f_tr + when 0 + tr_msg << ")" + when 1 + tr_msg << " and 1 invalid try)" + else + tr_msg << " and #{f_tr} invalid tries)" + end + end + + m.reply "A-Z: #{Bold}#{@games[k].range}#{Bold}" + tr_msg + return + end + + def wordlist(m, params) + pars = params[:params] + lang = (params[:lang] || @bot.config['core.language']).to_sym + wc = @wordcache[lang] || Hash.new rescue Hash.new + cmd = params[:cmd].to_sym rescue :count + case cmd + when :count + m.reply "I have #{wc.size > 0 ? wc.size : 'no'} #{lang} words in my cache" + when :show, :list + if pars.empty? + m.reply "provide a regexp to match" + return + end + begin + regex = /#{pars[0]}/ + matches = wc.keys.map { |k| + k.to_s + }.grep(regex) + rescue + matches = [] + end + if matches.size == 0 + m.reply "no #{lang} word I know match #{pars[0]}" + elsif matches.size > 25 + m.reply "more than 25 #{lang} words I know match #{pars[0]}, try a stricter matching" + else + m.reply "#{matches.join(', ')}" + end + when :info + if pars.empty? + m.reply "provide a word" + return + end + word = pars[0].downcase.to_sym + if not wc.key?(word) + m.reply "I don't know any #{lang} word #{word}" + return + end + tr = "#{word} learned from #{wc[word][:who]}" + (tr << " on #{wc[word][:when]}") if wc[word].key?(:when) + m.reply tr + when :delete + if pars.empty? + m.reply "provide a word" + return + end + word = pars[0].downcase.to_sym + if not wc.key?(word) + m.reply "I don't know any #{lang} word #{word}" + return + end + wc.delete(word) + @bot.okay m.replyto + when :add + if pars.empty? + m.reply "provide a word" + return + end + word = pars[0].downcase.to_sym + if wc.key?(word) + m.reply "I already know the #{lang} word #{word}" + return + end + wc[word] = { :who => m.sourcenick, :when => Time.now } + @bot.okay m.replyto + else + end + end + + def is_japanese?(word) + @rules[:japanese][:list].include?(word) + end + + # return integer between min and max, inclusive + def rand_between(min, max) + rand(max - min + 1) + min + end + + def random_pick_japanese(min=nil, max=nil) + rules = @rules[:japanese] + min = rules[:first] if min.nil_or_empty? + max = rules[:last] if max.nil_or_empty? + debug "Randomly picking word between #{min} and #{max}" + min_index = rules[:list].index(min) + max_index = rules[:list].index(max) + debug "Index between #{min_index} and #{max_index}" + index = rand_between(min_index + 1, max_index - 1) + debug "Index generated: #{index}" + word = rules[:list][index] + debug "Randomly picked #{word}" + word + end + + def is_italian?(word) + unless @wordcache.key?(:italian) + @wordcache[:italian] = Hash.new + end + wc = @wordcache[:italian] + return true if wc.key?(word.to_sym) + rules = @rules[:italian] + p = @bot.httputil.get_cached(rules[:wapurl] % word) + if not p + error "could not connect!" + return false + end + debug p + p.scan(/#{word} - (.*?) :dict} + return true + end + next + } + return false + end + + def random_pick_italian(min=nil,max=nil) + # Try to pick a random word between min and max + word = String.new + min = min.to_s + max = max.to_s + if min > max + m.reply "#{min} > #{max}" + return word + end + rules = @rules[:italian] + min = rules[:first] if min.empty? + max = rules[:last] if max.empty? + debug "looking for word between #{min.inspect} and #{max.inspect}" + return word if min.empty? or max.empty? + begin + while (word <= min or word >= max or word !~ /^[a-z]+$/) + debug "looking for word between #{min} and #{max} (prev: #{word.inspect})" + # TODO for the time being, skip words with extended characters + unless @wordcache.key?(:italian) + @wordcache[:italian] = Hash.new + end + wc = @wordcache[:italian] + + if wc.size > 0 + cache_or_url = rand(2) + if cache_or_url == 0 + debug "getting word from wordcache" + word = wc.keys[rand(wc.size)].to_s + next + end + end + + # TODO when doing ranges, adapt this choice + l = ('a'..'z').to_a[rand(26)] + debug "getting random word from dictionary, starting with letter #{l}" + first = rules[:url] % "lettera_#{l}_0_50" + p = @bot.httputil.get_cached(first) + max_page = p.match(/ \/ (\d+)<\/label>/)[1].to_i + pp = rand(max_page)+1 + debug "getting random word from dictionary, starting with letter #{l}, page #{pp}" + p = @bot.httputil.get_cached(first+"&pagina=#{pp}") if pp > 1 + lemmi = Array.new + good = rules[:good] + bad = rules[:bad] + # We look for a lemma composed by a single word and of length at least two + p.scan(/
  • .*? (.+?)<\/li>/) { |url, prelemma, tipo| + lemma = prelemma.downcase.to_sym + debug "checking lemma #{lemma} (#{prelemma}) of type #{tipo} from url #{url}" + next if wc.key?(lemma) + case tipo + when good + if tipo =~ bad + debug "refusing, #{bad}" + next + end + debug "good one" + lemmi << lemma + wc[lemma] = {:who => :dict} + else + debug "refusing, not #{good}" + end + } + word = lemmi[rand(lemmi.length)].to_s + end + rescue => e + error "error #{e.inspect} while looking up a word" + error e.backtrace.join("\n") + end + return word + end + + def is_english?(word) + unless @wordcache.key?(:english) + @wordcache[:english] = Hash.new + end + wc = @wordcache[:english] + return true if wc.key?(word.to_sym) + rules = @rules[:english] + p = @bot.httputil.get_cached(rules[:url] % URI.escape(word)) + if not p + error "could not connect!" + return false + end + debug p + if p =~ /#{word}<\/span>([^\n]+?)#{rules[:good]}<\/span>/i + debug "new word #{word}" + wc[word.to_sym] = {:who => :dict} + return true + end + return false + end + + def random_pick_english(min=nil,max=nil) + # Try to pick a random word between min and max + word = String.new + min = min.to_s + max = max.to_s + if min > max + m.reply "#{min} > #{max}" + return word + end + rules = @rules[:english] + min = rules[:first] if min.empty? + max = rules[:last] if max.empty? + debug "looking for word between #{min.inspect} and #{max.inspect}" + return word if min.empty? or max.empty? + begin + while (word <= min or word >= max or word !~ /^[a-z]+$/) + debug "looking for word between #{min} and #{max} (prev: #{word.inspect})" + # TODO for the time being, skip words with extended characters + unless @wordcache.key?(:english) + @wordcache[:english] = Hash.new + end + wc = @wordcache[:english] + + if wc.size > 0 + cache_or_url = rand(2) + if cache_or_url == 0 + debug "getting word from wordcache" + word = wc.keys[rand(wc.size)].to_s + next + end + end + + # TODO when doing ranges, adapt this choice + l = ('a'..'z').to_a[rand(26)] + ll = ('a'..'z').to_a[rand(26)] + random = [l,ll].join('*') + '*' + debug "getting random word from dictionary, matching #{random}" + p = @bot.httputil.get_cached(rules[:url] % URI.escape(random)) + debug p + lemmi = Array.new + good = rules[:good] + # We look for a lemma composed by a single word and of length at least two + p.scan(/(.*?)<\/span>([^\n]+?)#{rules[:good]}<\/span>/i) { |prelemma, discard| + lemma = prelemma.downcase + debug "checking lemma #{lemma} (#{prelemma}) and discarding #{discard}" + next if wc.key?(lemma.to_sym) + if lemma =~ /^[a-z]+$/ + debug "good one" + lemmi << lemma + wc[lemma.to_sym] = {:who => :dict} + else + debug "funky characters, not good" + end + } + next if lemmi.empty? + word = lemmi[rand(lemmi.length)] + end + rescue => e + error "error #{e.inspect} while looking up a word" + error e.backtrace.join("\n") + end + return word + end + + def help(plugin, topic="") + case topic + when 'manage' + return "az [lang] word [count|list|add|delete] => manage the az wordlist for language lang (defaults to current bot language)" + when 'cancel' + return "az cancel => abort current game" + when 'check' + return 'az check => checks against current game' + when 'rules' + return "try to guess the word the bot is thinking of; if you guess wrong, the bot will use the new word to restrict the range of allowed words: eventually, the range will be so small around the correct word that you can't miss it" + when 'play' + return "az => start a game if none is running, show the current word range otherwise; you can say 'az ' if you want to play in a language different from the current bot default" + end + return "az topics: play, rules, cancel, manage, check" + end + +end + +plugin = AzGamePlugin.new +plugin.map 'az [:lang] word :cmd *params', :action=>'wordlist', :defaults => { :lang => nil, :cmd => 'count', :params => [] }, :auth_path => '!az::edit!' +plugin.map 'az cancel', :action=>'stop_game', :private => false +plugin.map 'az check :word', :action => 'manual_word_check', :private => false +plugin.map 'az [play] [:lang]', :action=>'start_game', :private => false, :defaults => { :lang => nil } + diff --git a/data/rbot/plugins/games/quiz.rb b/data/rbot/plugins/games/quiz.rb new file mode 100644 index 00000000..63383262 --- /dev/null +++ b/data/rbot/plugins/games/quiz.rb @@ -0,0 +1,963 @@ +#-- vim:sw=2:et +#++ +# +# :title: Quiz plugin for rbot +# +# Author:: Mark Kretschmann +# Author:: Jocke Andersson +# Author:: Giuseppe Bilotta +# Author:: Yaohan Chen +# +# Copyright:: (C) 2006 Mark Kretschmann, Jocke Andersson, Giuseppe Bilotta +# Copyright:: (C) 2007 Giuseppe Bilotta, Yaohan Chen +# +# License:: GPL v2 +# +# A trivia quiz game. Fast paced, featureful and fun. + +# FIXME:: interesting fact: in the Quiz class, @registry.has_key? seems to be +# case insensitive. Although this is all right for us, this leads to +# rank vs registry mismatches. So we have to make the @rank_table +# comparisons case insensitive as well. For the moment, redefine +# everything to downcase before matching the nick. +# +# TODO:: define a class for the rank table. We might also need it for scoring +# in other games. +# +# TODO:: when Ruby 2.0 gets out, fix the FIXME 2.0 UTF-8 workarounds + +# Class for storing question/answer pairs +QuizBundle = Struct.new( "QuizBundle", :question, :answer ) + +# Class for storing player stats +PlayerStats = Struct.new( "PlayerStats", :score, :jokers, :jokers_time ) +# Why do we still need jokers_time? //Firetech + +# Maximum number of jokers a player can gain +Max_Jokers = 3 + +# Control codes +Color = "\003" +Bold = "\002" + + +####################################################################### +# CLASS QuizAnswer +# Abstract an answer to a quiz question, by providing self as a string +# and a core that can be answered as an alternative. It also provides +# a boolean that tells if the core is numeric or not +####################################################################### +class QuizAnswer + attr_writer :info + + def initialize(str) + @string = str.strip + @core = nil + if @string =~ /#(.+)#/ + @core = $1 + @string.gsub!('#', '') + end + raise ArgumentError, "empty string can't be a valid answer!" if @string.empty? + raise ArgumentError, "empty core can't be a valid answer!" if @core and @core.empty? + + @numeric = (core.to_i.to_s == core) || (core.to_f.to_s == core) + @info = nil + end + + def core + @core || @string + end + + def numeric? + @numeric + end + + def valid?(str) + str.downcase == core.downcase || str.downcase == @string.downcase + end + + def to_str + [@string, @info].join + end + alias :to_s :to_str + + +end + + +####################################################################### +# CLASS Quiz +# One Quiz instance per channel, contains channel specific data +####################################################################### +class Quiz + attr_accessor :registry, :registry_conf, :questions, + :question, :answers, :canonical_answer, :answer_array, + :first_try, :hint, :hintrange, :rank_table, :hinted, :has_errors, + :all_seps + + def initialize( channel, registry ) + if !channel + @registry = registry.sub_registry( 'private' ) + else + @registry = registry.sub_registry( channel.downcase ) + end + @has_errors = false + @registry.each_key { |k| + unless @registry.has_key?(k) + @has_errors = true + error "Data for #{k} is NOT ACCESSIBLE! Database corrupt?" + end + } + if @has_errors + debug @registry.to_a.map { |a| a.join(", ")}.join("\n") + end + + @registry_conf = @registry.sub_registry( "config" ) + + # Per-channel list of sources. If empty, the default one (quiz/quiz.rbot) + # will be used. TODO + @registry_conf["sources"] = [] unless @registry_conf.has_key?( "sources" ) + + # Per-channel copy of the global questions table. Acts like a shuffled queue + # from which questions are taken, until empty. Then we refill it with questions + # from the global table. + @registry_conf["questions"] = [] unless @registry_conf.has_key?( "questions" ) + + # Autoask defaults to true + @registry_conf["autoask"] = true unless @registry_conf.has_key?( "autoask" ) + + # Autoask delay defaults to 0 (instantly) + @registry_conf["autoask_delay"] = 0 unless @registry_conf.has_key?( "autoask_delay" ) + + @questions = @registry_conf["questions"] + @question = nil + @answers = [] + @canonical_answer = nil + # FIXME 2.0 UTF-8 + @answer_array = [] + @first_try = false + # FIXME 2.0 UTF-8 + @hint = [] + @hintrange = nil + @hinted = false + + # True if the answers is entirely done by separators + @all_seps = false + + # We keep this array of player stats for performance reasons. It's sorted by score + # and always synced with the registry player stats hash. This way we can do fast + # rank lookups, without extra sorting. + @rank_table = @registry.to_a.sort { |a,b| b[1].score<=>a[1].score } + end +end + + +####################################################################### +# CLASS QuizPlugin +####################################################################### +class QuizPlugin < Plugin + BotConfig.register BotConfigBooleanValue.new('quiz.dotted_nicks', + :default => true, + :desc => "When true, nicks in the top X scores will be camouflaged to prevent IRC hilighting") + + BotConfig.register BotConfigArrayValue.new('quiz.sources', + :default => ['quiz.rbot'], + :desc => "List of files and URLs that will be used to retrieve quiz questions") + + def initialize() + super + + @questions = Array.new + @quizzes = Hash.new + @waiting = Hash.new + @ask_mutex = Mutex.new + end + + # Function that returns whether a char is a "separator", used for hints + # + def is_sep( ch ) + return ch !~ /^\w$/u + end + + + # Fetches questions from the data sources, which can be either local files + # (in quiz/) or web pages. + # + def fetch_data( m ) + # Read the winning messages file + @win_messages = Array.new + if File.exists? "#{@bot.botclass}/quiz/win_messages" + IO.foreach("#{@bot.botclass}/quiz/win_messages") { |line| @win_messages << line.chomp } + else + warning( "win_messages file not found!" ) + # Fill the array with a least one message or code accessing it would fail + @win_messages << " guessed right! The answer was " + end + + m.reply "Fetching questions ..." + + # TODO Per-channel sources + + data = "" + @bot.config['quiz.sources'].each { |p| + if p =~ /^https?:\/\// + # Wiki data + begin + serverdata = @bot.httputil.get_cached( URI.parse( p ) ) # "http://amarok.kde.org/amarokwiki/index.php/Rbot_Quiz" + serverdata = serverdata.split( "QUIZ DATA START\n" )[1] + serverdata = serverdata.split( "\nQUIZ DATA END" )[0] + serverdata = serverdata.gsub( / /, " " ).gsub( /&/, "&" ).gsub( /"/, "\"" ) + data << "\n\n" << serverdata + rescue + m.reply "Failed to download questions from #{p}, ignoring sources" + end + else + path = "#{@bot.botclass}/quiz/#{p}" + debug "Fetching from #{path}" + + # Local data + begin + datafile = File.new( path, File::RDONLY ) + data << "\n\n" << datafile.read + rescue + m.reply "Failed to read from local database file #{p}, skipping." + end + end + } + + @questions.clear + + # Fuse together and remove comments, then split + entries = data.strip.gsub( /^#.*$/, "" ).split( /(?:^|\n+)Question: / ) + + entries.each do |e| + p = e.split( "\n" ) + # We'll need at least two lines of data + unless p.size < 2 + # Check if question isn't empty + if p[0].length > 0 + while p[1].match( /^Answer: (.*)$/ ) == nil and p.size > 2 + # Delete all lines between the question and the answer + p.delete_at(1) + end + p[1] = p[1].gsub( /Answer: /, "" ).strip + # If the answer was found + if p[1].length > 0 + # Add the data to the array + b = QuizBundle.new( p[0], p[1] ) + @questions << b + end + end + end + end + + m.reply "done, #{@questions.length} questions loaded." + end + + + # Returns new Quiz instance for channel, or existing one + # + def create_quiz( channel ) + unless @quizzes.has_key?( channel ) + @quizzes[channel] = Quiz.new( channel, @registry ) + end + + if @quizzes[channel].has_errors + return nil + else + return @quizzes[channel] + end + end + + + def say_score( m, nick ) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + if q.registry.has_key?( nick ) + score = q.registry[nick].score + jokers = q.registry[nick].jokers + + rank = 0 + q.rank_table.each_index { |rank| break if nick.downcase == q.rank_table[rank][0].downcase } + rank += 1 + + m.reply "#{nick}'s score is: #{score} Rank: #{rank} Jokers: #{jokers}" + else + m.reply "#{nick} does not have a score yet. Lamer." + end + end + + + def help( plugin, topic="" ) + if topic == "admin" + "Quiz game aministration commands (requires authentication): 'quiz autoask ' => enable/disable autoask mode. 'quiz autoask delay ' => delay next quiz by seconds when in autoask mode. 'quiz transfer [score] [jokers]' => transfer [score] points and [jokers] jokers from to (default is entire score and all jokers). 'quiz setscore ' => set 's score to . 'quiz setjokers ' => set 's number of jokers to . 'quiz deleteplayer ' => delete one player from the rank table (only works when score and jokers are set to 0). 'quiz cleanup' => remove players with no points and no jokers." + else + urls = @bot.config['quiz.sources'].select { |p| p =~ /^https?:\/\// } + "A multiplayer trivia quiz. 'quiz' => ask a question. 'quiz hint' => get a hint. 'quiz solve' => solve this question. 'quiz skip' => skip to next question. 'quiz joker' => draw a joker to win this round. 'quiz score [player]' => show score for [player] (default is yourself). 'quiz top5' => show top 5 players. 'quiz top ' => show top players (max 50). 'quiz stats' => show some statistics. 'quiz fetch' => refetch questions from databases. 'quiz refresh' => refresh the question pool for this channel." + (urls.empty? ? "" : "\nYou can add new questions at #{urls.join(', ')}") + end + end + + + # Updates the per-channel rank table, which is kept for performance reasons. + # This table contains all players sorted by rank. + # + def calculate_ranks( m, q, nick ) + if q.registry.has_key?( nick ) + stats = q.registry[nick] + + # Find player in table + found_player = false + i = 0 + q.rank_table.each_index do |i| + if nick.downcase == q.rank_table[i][0].downcase + found_player = true + break + end + end + + # Remove player from old position + if found_player + old_rank = i + q.rank_table.delete_at( i ) + else + old_rank = nil + end + + # Insert player at new position + inserted = false + q.rank_table.each_index do |i| + if stats.score > q.rank_table[i][1].score + q.rank_table[i,0] = [[nick, stats]] + inserted = true + break + end + end + + # If less than all other players' scores, append to table + unless inserted + i += 1 unless q.rank_table.empty? + q.rank_table << [nick, stats] + end + + # Print congratulations/condolences if the player's rank has changed + unless old_rank.nil? + if i < old_rank + m.reply "#{nick} ascends to rank #{i + 1}. Congratulations :)" + elsif i > old_rank + m.reply "#{nick} slides down to rank #{i + 1}. So Sorry! NOT. :p" + end + end + else + q.rank_table << [[nick, PlayerStats.new( 1 )]] + end + end + + + # Reimplemented from Plugin + # + def listen( m ) + return unless m.kind_of?(PrivMessage) + + chan = m.channel + return unless @quizzes.has_key?( chan ) + q = @quizzes[chan] + + return if q.question == nil + + message = m.message.downcase.strip + + nick = m.sourcenick.to_s + + # Support multiple alternate answers and cores + answer = q.answers.find { |ans| ans.valid?(message) } + if answer + # List canonical answer which the hint was based on, to avoid confusion + # FIXME display this more friendly + answer.info = " (hints were for alternate answer #{q.canonical_answer.core})" if answer != q.canonical_answer and q.hinted + + points = 1 + if q.first_try + points += 1 + reply = "WHOPEEE! #{nick} got it on the first try! That's worth an extra point. Answer was: #{answer}" + elsif q.rank_table.length >= 1 and nick.downcase == q.rank_table[0][0].downcase + reply = "THE QUIZ CHAMPION defends his throne! Seems like #{nick} is invicible! Answer was: #{answer}" + elsif q.rank_table.length >= 2 and nick.downcase == q.rank_table[1][0].downcase + reply = "THE SECOND CHAMPION is on the way up! Hurry up #{nick}, you only need #{q.rank_table[0][1].score - q.rank_table[1][1].score - 1} points to beat the king! Answer was: #{answer}" + elsif q.rank_table.length >= 3 and nick.downcase == q.rank_table[2][0].downcase + reply = "THE THIRD CHAMPION strikes again! Give it all #{nick}, with #{q.rank_table[1][1].score - q.rank_table[2][1].score - 1} more points you'll reach the 2nd place! Answer was: #{answer}" + else + reply = @win_messages[rand( @win_messages.length )].dup + reply.gsub!( "", nick ) + reply.gsub!( "", answer ) + end + + m.reply reply + + player = nil + if q.registry.has_key?(nick) + player = q.registry[nick] + else + player = PlayerStats.new( 0, 0, 0 ) + end + + player.score = player.score + points + + # Reward player with a joker every X points + if player.score % 15 == 0 and player.jokers < Max_Jokers + player.jokers += 1 + m.reply "#{nick} gains a new joker. Rejoice :)" + end + + q.registry[nick] = player + calculate_ranks( m, q, nick) + + q.question = nil + if q.registry_conf["autoask"] + delay = q.registry_conf["autoask_delay"] + if delay > 0 + m.reply "#{Bold}#{Color}03Next question in #{Bold}#{delay}#{Bold} seconds" + timer = @bot.timer.add_once(delay) { + @ask_mutex.synchronize do + @waiting.delete(chan) + end + cmd_quiz( m, nil) + } + @waiting[chan] = timer + else + cmd_quiz( m, nil ) + end + end + else + # First try is used, and it wasn't the answer. + q.first_try = false + end + end + + + # Stretches an IRC nick with dots, simply to make the client not trigger a hilight, + # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y + # + def unhilight_nick( nick ) + return nick unless @bot.config['quiz.dotted_nicks'] + return nick.split(//).join(".") + end + + + ####################################################################### + # Command handling + ####################################################################### + def cmd_quiz( m, params ) + fetch_data( m ) if @questions.empty? + chan = m.channel + + @ask_mutex.synchronize do + if @waiting.has_key?(chan) + m.reply "Next quiz question will be automatically asked soon, have patience" + return + end + end + + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + if q.question + m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}" + m.reply "Hint: #{q.hint}" if q.hinted + return + end + + # Fill per-channel questions buffer + if q.questions.empty? + q.questions = @questions.sort_by { rand } + end + + # pick a question and delete it (delete_at returns the deleted item) + picked = q.questions.delete_at( rand(q.questions.length) ) + + q.question = picked.question + q.answers = picked.answer.split(/\s+\|\|\s+/).map { |ans| QuizAnswer.new(ans) } + + # Check if any core answer is numerical and tell the players so, if that's the case + # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon" + # + # The "canonical answer" is also determined here, defined to be the first found numerical answer, or + # the first core. + numeric = q.answers.find { |ans| ans.numeric? } + if numeric + q.question += "#{Color}07 (Numerical answer)#{Color}" + q.canonical_answer = numeric + else + q.canonical_answer = q.answers.first + end + + q.first_try = true + + # FIXME 2.0 UTF-8 + q.hint = [] + q.answer_array.clear + q.canonical_answer.core.scan(/./u) { |ch| + if is_sep(ch) + q.hint << ch + else + q.hint << "^" + end + q.answer_array << ch + } + q.all_seps = false + # It's possible that an answer is entirely done by separators, + # in which case we'll hide everything + if q.answer_array == q.hint + q.hint.map! { |ch| + "^" + } + q.all_seps = true + end + q.hinted = false + + # Generate array of unique random range + q.hintrange = (0..q.hint.length-1).sort_by{ rand } + + m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question + end + + + def cmd_solve( m, params ) + chan = m.channel + + return unless @quizzes.has_key?( chan ) + q = @quizzes[chan] + + m.reply "The correct answer was: #{q.canonical_answer}" + + q.question = nil + + cmd_quiz( m, nil ) if q.registry_conf["autoask"] + end + + + def cmd_hint( m, params ) + chan = m.channel + nick = m.sourcenick.to_s + + return unless @quizzes.has_key?(chan) + q = @quizzes[chan] + + if q.question == nil + m.reply "#{nick}: Get a question first!" + else + num_chars = case q.hintrange.length # Number of characters to reveal + when 25..1000 then 7 + when 20..1000 then 6 + when 16..1000 then 5 + when 12..1000 then 4 + when 8..1000 then 3 + when 5..1000 then 2 + when 1..1000 then 1 + end + + # FIXME 2.0 UTF-8 + num_chars.times do + begin + index = q.hintrange.pop + # New hint char until the char isn't a "separator" (space etc.) + end while is_sep(q.answer_array[index]) and not q.all_seps + q.hint[index] = q.answer_array[index] + end + m.reply "Hint: #{q.hint}" + q.hinted = true + + # FIXME 2.0 UTF-8 + if q.hint == q.answer_array + m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{nick}#{Color}." + + stats = nil + if q.registry.has_key?( nick ) + stats = q.registry[nick] + else + stats = PlayerStats.new( 0, 0, 0 ) + end + + stats["score"] = stats.score - 1 + q.registry[nick] = stats + + calculate_ranks( m, q, nick) + + q.question = nil + cmd_quiz( m, nil ) if q.registry_conf["autoask"] + end + end + end + + + def cmd_skip( m, params ) + chan = m.channel + return unless @quizzes.has_key?(chan) + q = @quizzes[chan] + + q.question = nil + cmd_quiz( m, params ) + end + + + def cmd_joker( m, params ) + chan = m.channel + nick = m.sourcenick.to_s + q = create_quiz(chan) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + if q.question == nil + m.reply "#{nick}: There is no open question." + return + end + + if q.registry[nick].jokers > 0 + player = q.registry[nick] + player.jokers -= 1 + player.score += 1 + q.registry[nick] = player + + calculate_ranks( m, q, nick ) + + if player.jokers != 1 + jokers = "jokers" + else + jokers = "joker" + end + m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{nick} draws a joker and wins this round. You have #{player.jokers} #{jokers} left." + m.reply "The answer was: #{q.canonical_answer}." + + q.question = nil + cmd_quiz( m, nil ) if q.registry_conf["autoask"] + else + m.reply "#{nick}: You don't have any jokers left ;(" + end + end + + + def cmd_fetch( m, params ) + fetch_data( m ) + end + + + def cmd_refresh( m, params ) + q = create_quiz ( m.channel ) + q.questions.clear + fetch_data ( m ) + cmd_quiz( m, params ) + end + + + def cmd_top5( m, params ) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + if q.rank_table.empty? + m.reply "There are no scores known yet!" + return + end + + m.reply "* Top 5 Players for #{chan}:" + + [5, q.rank_table.length].min.times do |i| + player = q.rank_table[i] + nick = player[0] + score = player[1].score + m.reply " #{i + 1}. #{unhilight_nick( nick )} (#{score})" + end + end + + + def cmd_top_number( m, params ) + num = params[:number].to_i + return if num < 1 or num > 50 + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + if q.rank_table.empty? + m.reply "There are no scores known yet!" + return + end + + ar = [] + m.reply "* Top #{num} Players for #{chan}:" + n = [ num, q.rank_table.length ].min + n.times do |i| + player = q.rank_table[i] + nick = player[0] + score = player[1].score + ar << "#{i + 1}. #{unhilight_nick( nick )} (#{score})" + end + m.reply ar.join(" | ") + end + + + def cmd_stats( m, params ) + fetch_data( m ) if @questions.empty? + + m.reply "* Total Number of Questions:" + m.reply " #{@questions.length}" + end + + + def cmd_score( m, params ) + nick = m.sourcenick.to_s + say_score( m, nick ) + end + + + def cmd_score_player( m, params ) + say_score( m, params[:player] ) + end + + + def cmd_autoask( m, params ) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + case params[:enable].downcase + when "on", "true" + q.registry_conf["autoask"] = true + m.reply "Enabled autoask mode." + cmd_quiz( m, nil ) if q.question == nil + when "off", "false" + q.registry_conf["autoask"] = false + m.reply "Disabled autoask mode." + else + m.reply "Invalid autoask parameter. Use 'on' or 'off'." + end + end + + def cmd_autoask_delay( m, params ) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + delay = params[:time].to_i + q.registry_conf["autoask_delay"] = delay + m.reply "Autoask delay now #{q.registry_conf['autoask_delay']} seconds" + end + + + def cmd_transfer( m, params ) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + debug q.rank_table.inspect + + source = params[:source] + dest = params[:dest] + transscore = params[:score].to_i + transjokers = params[:jokers].to_i + debug "Transferring #{transscore} points and #{transjokers} jokers from #{source} to #{dest}" + + if q.registry.has_key?(source) + sourceplayer = q.registry[source] + score = sourceplayer.score + if transscore == -1 + transscore = score + end + if score < transscore + m.reply "#{source} only has #{score} points!" + return + end + jokers = sourceplayer.jokers + if transjokers == -1 + transjokers = jokers + end + if jokers < transjokers + m.reply "#{source} only has #{jokers} jokers!!" + return + end + if q.registry.has_key?(dest) + destplayer = q.registry[dest] + else + destplayer = PlayerStats.new(0,0,0) + end + + if sourceplayer.object_id == destplayer.object_id + m.reply "Source and destination are the same, I'm not going to touch them" + return + end + + sourceplayer.score -= transscore + destplayer.score += transscore + sourceplayer.jokers -= transjokers + destplayer.jokers += transjokers + + q.registry[source] = sourceplayer + calculate_ranks(m, q, source) + + q.registry[dest] = destplayer + calculate_ranks(m, q, dest) + + m.reply "Transferred #{transscore} points and #{transjokers} jokers from #{source} to #{dest}" + else + m.reply "#{source} doesn't have any points!" + end + end + + + def cmd_del_player( m, params ) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + debug q.rank_table.inspect + + nick = params[:nick] + if q.registry.has_key?(nick) + player = q.registry[nick] + score = player.score + if score != 0 + m.reply "Can't delete player #{nick} with score #{score}." + return + end + jokers = player.jokers + if jokers != 0 + m.reply "Can't delete player #{nick} with #{jokers} jokers." + return + end + q.registry.delete(nick) + + player_rank = nil + q.rank_table.each_index { |rank| + if nick.downcase == q.rank_table[rank][0].downcase + player_rank = rank + break + end + } + q.rank_table.delete_at(player_rank) + + m.reply "Player #{nick} deleted." + else + m.reply "Player #{nick} isn't even in the database." + end + end + + + def cmd_set_score(m, params) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + debug q.rank_table.inspect + + nick = params[:nick] + val = params[:score].to_i + if q.registry.has_key?(nick) + player = q.registry[nick] + player.score = val + else + player = PlayerStats.new( val, 0, 0) + end + q.registry[nick] = player + calculate_ranks(m, q, nick) + m.reply "Score for player #{nick} set to #{val}." + end + + + def cmd_set_jokers(m, params) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + debug q.rank_table.inspect + + nick = params[:nick] + val = [params[:jokers].to_i, Max_Jokers].min + if q.registry.has_key?(nick) + player = q.registry[nick] + player.jokers = val + else + player = PlayerStats.new( 0, val, 0) + end + q.registry[nick] = player + m.reply "Jokers for player #{nick} set to #{val}." + end + + + def cmd_cleanup(m, params) + chan = m.channel + q = create_quiz( chan ) + if q.nil? + m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" + return + end + + null_players = [] + q.registry.each { |nick, player| + null_players << nick if player.jokers == 0 and player.score == 0 + } + debug "Cleaning up by removing #{null_players * ', '}" + null_players.each { |nick| + cmd_del_player(m, :nick => nick) + } + + end + +end + + + +plugin = QuizPlugin.new +plugin.default_auth( 'edit', false ) + +# Normal commands +plugin.map 'quiz', :action => 'cmd_quiz' +plugin.map 'quiz solve', :action => 'cmd_solve' +plugin.map 'quiz hint', :action => 'cmd_hint' +plugin.map 'quiz skip', :action => 'cmd_skip' +plugin.map 'quiz joker', :action => 'cmd_joker' +plugin.map 'quiz score', :action => 'cmd_score' +plugin.map 'quiz score :player', :action => 'cmd_score_player' +plugin.map 'quiz fetch', :action => 'cmd_fetch' +plugin.map 'quiz refresh', :action => 'cmd_refresh' +plugin.map 'quiz top5', :action => 'cmd_top5' +plugin.map 'quiz top :number', :action => 'cmd_top_number' +plugin.map 'quiz stats', :action => 'cmd_stats' + +# Admin commands +plugin.map 'quiz autoask :enable', :action => 'cmd_autoask', :auth_path => 'edit' +plugin.map 'quiz autoask delay :time', :action => 'cmd_autoask_delay', :auth_path => 'edit', :requirements => {:time => /\d+/} +plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'} +plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit' +plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit' +plugin.map 'quiz setjokers :nick :jokers', :action => 'cmd_set_jokers', :auth_path => 'edit' +plugin.map 'quiz cleanup', :action => 'cmd_cleanup', :auth_path => 'edit' diff --git a/data/rbot/plugins/games/roshambo.rb b/data/rbot/plugins/games/roshambo.rb new file mode 100644 index 00000000..03338698 --- /dev/null +++ b/data/rbot/plugins/games/roshambo.rb @@ -0,0 +1,62 @@ +#-- vim:sw=2:et +#++ +# +# :title: Roshambo (rock-paper-scissors) plugin for rbot +# +# Author:: Hans Fugal +# Copyright:: (C) 2004 Hans Fugal +# +# Play the game of roshambo (rock-paper-scissors) +# +# Distributed under the same license as rbot itself + +require 'time' + +class RoshamboPlugin < Plugin + + def initialize + super + @scoreboard = {} + @beats = { :rock => :scissors, :paper => :rock, :scissors => :paper} + @plays = @beats.keys + end + + def help(plugin, topic="") + "roshambo or rps => play roshambo" + end + + def rps(m, params) + # simultaneity + bot_choice = @plays.pick_one + + # init scoreboard + if not @scoreboard.has_key?(m.sourcenick) or (Time.now - @scoreboard[m.sourcenick]['timestamp']) > 3600 + @scoreboard[m.sourcenick] = { 'me' => 0, 'you' => 0, 'timestamp' => Time.now } + end + human_choice = params[:play].to_sym + s = score(bot_choice, human_choice) + @scoreboard[m.sourcenick]['timestamp'] = Time.now + myscore=@scoreboard[m.sourcenick]['me'] + yourscore=@scoreboard[m.sourcenick]['you'] + case s + when 1 + yourscore = @scoreboard[m.sourcenick]['you'] += 1 + m.reply "#{bot_choice}. You win. Score: me #{myscore} you #{yourscore}" + when 0 + m.reply "#{bot_choice}. We tie. Score: me #{myscore} you #{yourscore}" + when -1 + myscore = @scoreboard[m.sourcenick]['me'] += 1 + m.reply "#{bot_choice}! I win! Score: me #{myscore} you #{yourscore}" + end + end + + def score(bot_choice, human_choice) + return -1 if @beats[bot_choice] == human_choice + return 1 if @beats[human_choice] == bot_choice + return 0 + end +end + +plugin = RoshamboPlugin.new +plugin.map "roshambo :play", :action => :rps, :requirements => { :play => /rock|paper|scissors/ } +plugin.map "rps :play", :action => :rps, :requirements => { :play => /rock|paper|scissors/ } diff --git a/data/rbot/plugins/games/roulette.rb b/data/rbot/plugins/games/roulette.rb new file mode 100644 index 00000000..9fce8d8a --- /dev/null +++ b/data/rbot/plugins/games/roulette.rb @@ -0,0 +1,219 @@ +RouletteHistory = Struct.new("RouletteHistory", :games, :shots, :deaths, :misses, :wins) + +class RoulettePlugin < Plugin + BotConfig.register BotConfigBooleanValue.new('roulette.autospin', + :default => true, + :desc => "Automatically spins the roulette at the butlast shot") + BotConfig.register BotConfigBooleanValue.new('roulette.kick', + :default => false, + :desc => "Kicks shot players from the channel") + + def initialize + super + reset_chambers + @players = Array.new + end + + def help(plugin, topic="") + "roulette => play russian roulette - starts a new game if one isn't already running. One round in a six chambered gun. Take turns to say roulette to the bot, until somebody dies. roulette reload => force the gun to reload, roulette stats => show stats from all games, roulette stats => show stats for , roulette clearstats => clear stats (config level auth required), roulette spin => spins the cylinder" + end + + def clearstats(m, params) + @registry.clear + m.okay + end + + def roulette(m, params) + if m.private? + m.reply "you gotta play roulette in channel dude" + return + end + + playerdata = nil + if @registry.has_key?("player " + m.sourcenick) + playerdata = @registry["player " + m.sourcenick] + else + playerdata = RouletteHistory.new(0,0,0,0,0) + end + + totals = nil + if @registry.has_key?("totals") + totals = @registry["totals"] + else + totals = RouletteHistory.new(0,0,0,0,0) + end + + unless @players.include?(m.sourcenick) + @players << m.sourcenick + playerdata.games += 1 + end + playerdata.shots += 1 + totals.shots += 1 + + shot = @chambers.pop + if shot + m.reply "#{m.sourcenick}: chamber #{6 - @chambers.length} of 6 => *BANG*" + playerdata.deaths += 1 + totals.deaths += 1 + @players.each {|plyr| + next if plyr == m.sourcenick + pdata = @registry["player " + plyr] + next if pdata == nil + pdata.wins += 1 + totals.wins += 1 + @registry["player " + plyr] = pdata + } + @players = Array.new + @bot.kick(m.replyto, m.sourcenick, "*BANG*") if @bot.config['roulette.kick'] + else + m.reply "#{m.sourcenick}: chamber #{6 - @chambers.length} of 6 => +click+" + playerdata.misses += 1 + totals.misses += 1 + end + + @registry["player " + m.sourcenick] = playerdata + @registry["totals"] = totals + + if shot || @chambers.empty? + reload(m) + elsif @chambers.length == 1 and @bot.config['roulette.autospin'] + spin(m) + end + end + + def reload(m, params = {}) + if m.private? + m.reply "you gotta play roulette in channel dude" + return + end + + m.act "reloads" + reset_chambers + # all players win on a reload + # (allows you to play 3-shot matches etc) + totals = nil + if @registry.has_key?("totals") + totals = @registry["totals"] + else + totals = RouletteHistory.new(0,0,0,0,0) + end + + @players.each {|plyr| + pdata = @registry["player " + plyr] + next if pdata == nil + pdata.wins += 1 + totals.wins += 1 + @registry["player " + plyr] = pdata + } + + totals.games += 1 + @registry["totals"] = totals + + @players = Array.new + end + + def spin(m, params={}) + # Spinning is just like resetting, except that nobody wins + if m.private? + m.reply "you gotta play roulette in channel dude" + return + end + + m.act "spins the cylinder" + reset_chambers + end + + def reset_chambers + @chambers = [false, false, false, false, false, false] + @chambers[rand(@chambers.length)] = true + end + + def playerstats(m, params) + player = params[:player] + pstats = @registry["player " + player] + if pstats.nil? + m.reply "#{player} hasn't played enough games yet" + else + m.reply "#{player} has played #{pstats.games} games, won #{pstats.wins} and lost #{pstats.deaths}. #{player} pulled the trigger #{pstats.shots} times and found the chamber empty on #{pstats.misses} occasions." + end + end + + def stats(m, params) + if @registry.has_key?("totals") + totals = @registry["totals"] + total_games = totals.games + total_shots = totals.shots + else + total_games = 0 + total_shots = 0 + end + + total_players = 0 + + died_most = [nil,0] + won_most = [nil,0] + h_win_percent = [nil,0] + l_win_percent = [nil,0] + h_luck_percent = [nil,0] + l_luck_percent = [nil,0] + @registry.each {|k,v| + match = /player (.+)/.match(k) + next unless match + k = match[1] + + total_players += 1 + + win_rate = v.wins.to_f / v.games * 100 + if h_win_percent[0].nil? || win_rate > h_win_percent[1] && v.games > 2 + h_win_percent = [[k], win_rate] + elsif win_rate == h_win_percent[1] && v.games > 2 + h_win_percent[0] << k + end + if l_win_percent[0].nil? || win_rate < l_win_percent[1] && v.games > 2 + l_win_percent = [[k], win_rate] + elsif win_rate == l_win_percent[1] && v.games > 2 + l_win_percent[0] << k + end + + luck = v.misses.to_f / v.shots * 100 + if h_luck_percent[0].nil? || luck > h_luck_percent[1] && v.games > 2 + h_luck_percent = [[k], luck] + elsif luck == h_luck_percent[1] && v.games > 2 + h_luck_percent[0] << k + end + if l_luck_percent[0].nil? || luck < l_luck_percent[1] && v.games > 2 + l_luck_percent = [[k], luck] + elsif luck == l_luck_percent[1] && v.games > 2 + l_luck_percent[0] << k + end + + if died_most[0].nil? || v.deaths > died_most[1] + died_most = [[k], v.deaths] + elsif v.deaths == died_most[1] + died_most[0] << k + end + if won_most[0].nil? || v.wins > won_most[1] + won_most = [[k], v.wins] + elsif v.wins == won_most[1] + won_most[0] << k + end + } + if total_games < 1 + m.reply "roulette stats: no games completed yet" + else + m.reply "roulette stats: #{total_games} games completed, #{total_shots} shots fired at #{total_players} players. Luckiest: #{h_luck_percent[0].join(',')} (#{sprintf '%.1f', h_luck_percent[1]}% clicks). Unluckiest: #{l_luck_percent[0].join(',')} (#{sprintf '%.1f', l_luck_percent[1]}% clicks). Highest survival rate: #{h_win_percent[0].join(',')} (#{sprintf '%.1f', h_win_percent[1]}%). Lowest survival rate: #{l_win_percent[0].join(',')} (#{sprintf '%.1f', l_win_percent[1]}%). Most wins: #{won_most[0].join(',')} (#{won_most[1]}). Most deaths: #{died_most[0].join(',')} (#{died_most[1]})." + end + end +end + +plugin = RoulettePlugin.new + +plugin.default_auth('clearstats', false) + +plugin.map 'roulette reload', :action => 'reload' +plugin.map 'roulette spin', :action => 'spin' +plugin.map 'roulette stats :player', :action => 'playerstats' +plugin.map 'roulette stats', :action => 'stats' +plugin.map 'roulette clearstats', :action => 'clearstats' +plugin.map 'roulette' + diff --git a/data/rbot/plugins/games/shiritori.rb b/data/rbot/plugins/games/shiritori.rb new file mode 100644 index 00000000..f13afeb8 --- /dev/null +++ b/data/rbot/plugins/games/shiritori.rb @@ -0,0 +1,448 @@ +#-- vim:sw=2:et +#kate: indent-width 2 +#++ +# +# :title: Shiritori Plugin for RBot +# +# Author:: Yaohan Chen +# 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. +# +# 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 + + # 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 + if take_turns? + @say.call "#{current_player}, it's your turn. #{previous_word} -> #{current_word}" + elsif @players.empty? + @say.call "No one has given the first word yet. Say the first word to start." + else + @say.call "Poor #{current_player} is playing alone! Anyone care to join? #{previous_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.call "#{current_player} took too long and is out of the game. Try again next game!" + 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.call "#{@players[1]} is the last remaining player and the winner! Congratulations!" + die + else + @booted_players << @players.shift + announce + end + else + @say.call "#{current_player} took too long and skipped the turn." + 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 "#{speaker} has given the first word: #{current_word}" + when :next + if !@players.include?(speaker) + # A new player + @players.unshift speaker + m.reply "Welcome to shiritori, #{speaker}." + end + next_player + when :used + m.reply "The word #{message} has been used. Retry from #{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 #{message}. The game has ended. Thanks a lot, #{speaker}! :(" + die + else + m.reply "It's impossible to continue the chain from #{message}. Retry from #{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 #{message}. Start with another word." + 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 ' => Play shiritori with a set of rules. Available rulesets: #{@rulesets.keys.join ', '}. 'shiritori stop' => Stop the current shiritori game." + 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.', + # Optionally use a module to normalize Japanese words, enabling input in multiple writing systems + } + } + @rulesets.each_value do |ruleset| + # set default values for each rule to default_ruleset's values + ruleset.replace @default_ruleset.merge(ruleset) + unless ruleset.has_key?(:words) + if ruleset.has_key?(:wordlist_file) + # TODO read words only when rule is used + # read words separated by newlines from file + ruleset[:words] = + File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep( + ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp} + else + raise NotImplementedError + end + end + end + 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 + if @rulesets.has_key? params[:ruleset] + @games[m.channel] = ShiritoriGame.new( + m.channel, @rulesets[params[:ruleset]], + @bot.timer, + lambda {|msg| m.reply msg}, + lambda {remove_game m.channel} ) + m.reply "Shiritori has started. Please say the first word" + else + m.reply "There is no defined ruleset named #{params[: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 listen(m) + return unless m.kind_of?(PrivMessage) + 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 + 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 diff --git a/data/rbot/plugins/quiz.rb b/data/rbot/plugins/quiz.rb deleted file mode 100644 index 63383262..00000000 --- a/data/rbot/plugins/quiz.rb +++ /dev/null @@ -1,963 +0,0 @@ -#-- vim:sw=2:et -#++ -# -# :title: Quiz plugin for rbot -# -# Author:: Mark Kretschmann -# Author:: Jocke Andersson -# Author:: Giuseppe Bilotta -# Author:: Yaohan Chen -# -# Copyright:: (C) 2006 Mark Kretschmann, Jocke Andersson, Giuseppe Bilotta -# Copyright:: (C) 2007 Giuseppe Bilotta, Yaohan Chen -# -# License:: GPL v2 -# -# A trivia quiz game. Fast paced, featureful and fun. - -# FIXME:: interesting fact: in the Quiz class, @registry.has_key? seems to be -# case insensitive. Although this is all right for us, this leads to -# rank vs registry mismatches. So we have to make the @rank_table -# comparisons case insensitive as well. For the moment, redefine -# everything to downcase before matching the nick. -# -# TODO:: define a class for the rank table. We might also need it for scoring -# in other games. -# -# TODO:: when Ruby 2.0 gets out, fix the FIXME 2.0 UTF-8 workarounds - -# Class for storing question/answer pairs -QuizBundle = Struct.new( "QuizBundle", :question, :answer ) - -# Class for storing player stats -PlayerStats = Struct.new( "PlayerStats", :score, :jokers, :jokers_time ) -# Why do we still need jokers_time? //Firetech - -# Maximum number of jokers a player can gain -Max_Jokers = 3 - -# Control codes -Color = "\003" -Bold = "\002" - - -####################################################################### -# CLASS QuizAnswer -# Abstract an answer to a quiz question, by providing self as a string -# and a core that can be answered as an alternative. It also provides -# a boolean that tells if the core is numeric or not -####################################################################### -class QuizAnswer - attr_writer :info - - def initialize(str) - @string = str.strip - @core = nil - if @string =~ /#(.+)#/ - @core = $1 - @string.gsub!('#', '') - end - raise ArgumentError, "empty string can't be a valid answer!" if @string.empty? - raise ArgumentError, "empty core can't be a valid answer!" if @core and @core.empty? - - @numeric = (core.to_i.to_s == core) || (core.to_f.to_s == core) - @info = nil - end - - def core - @core || @string - end - - def numeric? - @numeric - end - - def valid?(str) - str.downcase == core.downcase || str.downcase == @string.downcase - end - - def to_str - [@string, @info].join - end - alias :to_s :to_str - - -end - - -####################################################################### -# CLASS Quiz -# One Quiz instance per channel, contains channel specific data -####################################################################### -class Quiz - attr_accessor :registry, :registry_conf, :questions, - :question, :answers, :canonical_answer, :answer_array, - :first_try, :hint, :hintrange, :rank_table, :hinted, :has_errors, - :all_seps - - def initialize( channel, registry ) - if !channel - @registry = registry.sub_registry( 'private' ) - else - @registry = registry.sub_registry( channel.downcase ) - end - @has_errors = false - @registry.each_key { |k| - unless @registry.has_key?(k) - @has_errors = true - error "Data for #{k} is NOT ACCESSIBLE! Database corrupt?" - end - } - if @has_errors - debug @registry.to_a.map { |a| a.join(", ")}.join("\n") - end - - @registry_conf = @registry.sub_registry( "config" ) - - # Per-channel list of sources. If empty, the default one (quiz/quiz.rbot) - # will be used. TODO - @registry_conf["sources"] = [] unless @registry_conf.has_key?( "sources" ) - - # Per-channel copy of the global questions table. Acts like a shuffled queue - # from which questions are taken, until empty. Then we refill it with questions - # from the global table. - @registry_conf["questions"] = [] unless @registry_conf.has_key?( "questions" ) - - # Autoask defaults to true - @registry_conf["autoask"] = true unless @registry_conf.has_key?( "autoask" ) - - # Autoask delay defaults to 0 (instantly) - @registry_conf["autoask_delay"] = 0 unless @registry_conf.has_key?( "autoask_delay" ) - - @questions = @registry_conf["questions"] - @question = nil - @answers = [] - @canonical_answer = nil - # FIXME 2.0 UTF-8 - @answer_array = [] - @first_try = false - # FIXME 2.0 UTF-8 - @hint = [] - @hintrange = nil - @hinted = false - - # True if the answers is entirely done by separators - @all_seps = false - - # We keep this array of player stats for performance reasons. It's sorted by score - # and always synced with the registry player stats hash. This way we can do fast - # rank lookups, without extra sorting. - @rank_table = @registry.to_a.sort { |a,b| b[1].score<=>a[1].score } - end -end - - -####################################################################### -# CLASS QuizPlugin -####################################################################### -class QuizPlugin < Plugin - BotConfig.register BotConfigBooleanValue.new('quiz.dotted_nicks', - :default => true, - :desc => "When true, nicks in the top X scores will be camouflaged to prevent IRC hilighting") - - BotConfig.register BotConfigArrayValue.new('quiz.sources', - :default => ['quiz.rbot'], - :desc => "List of files and URLs that will be used to retrieve quiz questions") - - def initialize() - super - - @questions = Array.new - @quizzes = Hash.new - @waiting = Hash.new - @ask_mutex = Mutex.new - end - - # Function that returns whether a char is a "separator", used for hints - # - def is_sep( ch ) - return ch !~ /^\w$/u - end - - - # Fetches questions from the data sources, which can be either local files - # (in quiz/) or web pages. - # - def fetch_data( m ) - # Read the winning messages file - @win_messages = Array.new - if File.exists? "#{@bot.botclass}/quiz/win_messages" - IO.foreach("#{@bot.botclass}/quiz/win_messages") { |line| @win_messages << line.chomp } - else - warning( "win_messages file not found!" ) - # Fill the array with a least one message or code accessing it would fail - @win_messages << " guessed right! The answer was " - end - - m.reply "Fetching questions ..." - - # TODO Per-channel sources - - data = "" - @bot.config['quiz.sources'].each { |p| - if p =~ /^https?:\/\// - # Wiki data - begin - serverdata = @bot.httputil.get_cached( URI.parse( p ) ) # "http://amarok.kde.org/amarokwiki/index.php/Rbot_Quiz" - serverdata = serverdata.split( "QUIZ DATA START\n" )[1] - serverdata = serverdata.split( "\nQUIZ DATA END" )[0] - serverdata = serverdata.gsub( / /, " " ).gsub( /&/, "&" ).gsub( /"/, "\"" ) - data << "\n\n" << serverdata - rescue - m.reply "Failed to download questions from #{p}, ignoring sources" - end - else - path = "#{@bot.botclass}/quiz/#{p}" - debug "Fetching from #{path}" - - # Local data - begin - datafile = File.new( path, File::RDONLY ) - data << "\n\n" << datafile.read - rescue - m.reply "Failed to read from local database file #{p}, skipping." - end - end - } - - @questions.clear - - # Fuse together and remove comments, then split - entries = data.strip.gsub( /^#.*$/, "" ).split( /(?:^|\n+)Question: / ) - - entries.each do |e| - p = e.split( "\n" ) - # We'll need at least two lines of data - unless p.size < 2 - # Check if question isn't empty - if p[0].length > 0 - while p[1].match( /^Answer: (.*)$/ ) == nil and p.size > 2 - # Delete all lines between the question and the answer - p.delete_at(1) - end - p[1] = p[1].gsub( /Answer: /, "" ).strip - # If the answer was found - if p[1].length > 0 - # Add the data to the array - b = QuizBundle.new( p[0], p[1] ) - @questions << b - end - end - end - end - - m.reply "done, #{@questions.length} questions loaded." - end - - - # Returns new Quiz instance for channel, or existing one - # - def create_quiz( channel ) - unless @quizzes.has_key?( channel ) - @quizzes[channel] = Quiz.new( channel, @registry ) - end - - if @quizzes[channel].has_errors - return nil - else - return @quizzes[channel] - end - end - - - def say_score( m, nick ) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - if q.registry.has_key?( nick ) - score = q.registry[nick].score - jokers = q.registry[nick].jokers - - rank = 0 - q.rank_table.each_index { |rank| break if nick.downcase == q.rank_table[rank][0].downcase } - rank += 1 - - m.reply "#{nick}'s score is: #{score} Rank: #{rank} Jokers: #{jokers}" - else - m.reply "#{nick} does not have a score yet. Lamer." - end - end - - - def help( plugin, topic="" ) - if topic == "admin" - "Quiz game aministration commands (requires authentication): 'quiz autoask ' => enable/disable autoask mode. 'quiz autoask delay ' => delay next quiz by seconds when in autoask mode. 'quiz transfer [score] [jokers]' => transfer [score] points and [jokers] jokers from to (default is entire score and all jokers). 'quiz setscore ' => set 's score to . 'quiz setjokers ' => set 's number of jokers to . 'quiz deleteplayer ' => delete one player from the rank table (only works when score and jokers are set to 0). 'quiz cleanup' => remove players with no points and no jokers." - else - urls = @bot.config['quiz.sources'].select { |p| p =~ /^https?:\/\// } - "A multiplayer trivia quiz. 'quiz' => ask a question. 'quiz hint' => get a hint. 'quiz solve' => solve this question. 'quiz skip' => skip to next question. 'quiz joker' => draw a joker to win this round. 'quiz score [player]' => show score for [player] (default is yourself). 'quiz top5' => show top 5 players. 'quiz top ' => show top players (max 50). 'quiz stats' => show some statistics. 'quiz fetch' => refetch questions from databases. 'quiz refresh' => refresh the question pool for this channel." + (urls.empty? ? "" : "\nYou can add new questions at #{urls.join(', ')}") - end - end - - - # Updates the per-channel rank table, which is kept for performance reasons. - # This table contains all players sorted by rank. - # - def calculate_ranks( m, q, nick ) - if q.registry.has_key?( nick ) - stats = q.registry[nick] - - # Find player in table - found_player = false - i = 0 - q.rank_table.each_index do |i| - if nick.downcase == q.rank_table[i][0].downcase - found_player = true - break - end - end - - # Remove player from old position - if found_player - old_rank = i - q.rank_table.delete_at( i ) - else - old_rank = nil - end - - # Insert player at new position - inserted = false - q.rank_table.each_index do |i| - if stats.score > q.rank_table[i][1].score - q.rank_table[i,0] = [[nick, stats]] - inserted = true - break - end - end - - # If less than all other players' scores, append to table - unless inserted - i += 1 unless q.rank_table.empty? - q.rank_table << [nick, stats] - end - - # Print congratulations/condolences if the player's rank has changed - unless old_rank.nil? - if i < old_rank - m.reply "#{nick} ascends to rank #{i + 1}. Congratulations :)" - elsif i > old_rank - m.reply "#{nick} slides down to rank #{i + 1}. So Sorry! NOT. :p" - end - end - else - q.rank_table << [[nick, PlayerStats.new( 1 )]] - end - end - - - # Reimplemented from Plugin - # - def listen( m ) - return unless m.kind_of?(PrivMessage) - - chan = m.channel - return unless @quizzes.has_key?( chan ) - q = @quizzes[chan] - - return if q.question == nil - - message = m.message.downcase.strip - - nick = m.sourcenick.to_s - - # Support multiple alternate answers and cores - answer = q.answers.find { |ans| ans.valid?(message) } - if answer - # List canonical answer which the hint was based on, to avoid confusion - # FIXME display this more friendly - answer.info = " (hints were for alternate answer #{q.canonical_answer.core})" if answer != q.canonical_answer and q.hinted - - points = 1 - if q.first_try - points += 1 - reply = "WHOPEEE! #{nick} got it on the first try! That's worth an extra point. Answer was: #{answer}" - elsif q.rank_table.length >= 1 and nick.downcase == q.rank_table[0][0].downcase - reply = "THE QUIZ CHAMPION defends his throne! Seems like #{nick} is invicible! Answer was: #{answer}" - elsif q.rank_table.length >= 2 and nick.downcase == q.rank_table[1][0].downcase - reply = "THE SECOND CHAMPION is on the way up! Hurry up #{nick}, you only need #{q.rank_table[0][1].score - q.rank_table[1][1].score - 1} points to beat the king! Answer was: #{answer}" - elsif q.rank_table.length >= 3 and nick.downcase == q.rank_table[2][0].downcase - reply = "THE THIRD CHAMPION strikes again! Give it all #{nick}, with #{q.rank_table[1][1].score - q.rank_table[2][1].score - 1} more points you'll reach the 2nd place! Answer was: #{answer}" - else - reply = @win_messages[rand( @win_messages.length )].dup - reply.gsub!( "", nick ) - reply.gsub!( "", answer ) - end - - m.reply reply - - player = nil - if q.registry.has_key?(nick) - player = q.registry[nick] - else - player = PlayerStats.new( 0, 0, 0 ) - end - - player.score = player.score + points - - # Reward player with a joker every X points - if player.score % 15 == 0 and player.jokers < Max_Jokers - player.jokers += 1 - m.reply "#{nick} gains a new joker. Rejoice :)" - end - - q.registry[nick] = player - calculate_ranks( m, q, nick) - - q.question = nil - if q.registry_conf["autoask"] - delay = q.registry_conf["autoask_delay"] - if delay > 0 - m.reply "#{Bold}#{Color}03Next question in #{Bold}#{delay}#{Bold} seconds" - timer = @bot.timer.add_once(delay) { - @ask_mutex.synchronize do - @waiting.delete(chan) - end - cmd_quiz( m, nil) - } - @waiting[chan] = timer - else - cmd_quiz( m, nil ) - end - end - else - # First try is used, and it wasn't the answer. - q.first_try = false - end - end - - - # Stretches an IRC nick with dots, simply to make the client not trigger a hilight, - # which is annoying for those not watching. Example: markey -> m.a.r.k.e.y - # - def unhilight_nick( nick ) - return nick unless @bot.config['quiz.dotted_nicks'] - return nick.split(//).join(".") - end - - - ####################################################################### - # Command handling - ####################################################################### - def cmd_quiz( m, params ) - fetch_data( m ) if @questions.empty? - chan = m.channel - - @ask_mutex.synchronize do - if @waiting.has_key?(chan) - m.reply "Next quiz question will be automatically asked soon, have patience" - return - end - end - - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - if q.question - m.reply "#{Bold}#{Color}03Current question: #{Color}#{Bold}#{q.question}" - m.reply "Hint: #{q.hint}" if q.hinted - return - end - - # Fill per-channel questions buffer - if q.questions.empty? - q.questions = @questions.sort_by { rand } - end - - # pick a question and delete it (delete_at returns the deleted item) - picked = q.questions.delete_at( rand(q.questions.length) ) - - q.question = picked.question - q.answers = picked.answer.split(/\s+\|\|\s+/).map { |ans| QuizAnswer.new(ans) } - - # Check if any core answer is numerical and tell the players so, if that's the case - # The rather obscure statement is needed because to_i and to_f returns 99(.0) for "99 red balloons", and 0 for "balloon" - # - # The "canonical answer" is also determined here, defined to be the first found numerical answer, or - # the first core. - numeric = q.answers.find { |ans| ans.numeric? } - if numeric - q.question += "#{Color}07 (Numerical answer)#{Color}" - q.canonical_answer = numeric - else - q.canonical_answer = q.answers.first - end - - q.first_try = true - - # FIXME 2.0 UTF-8 - q.hint = [] - q.answer_array.clear - q.canonical_answer.core.scan(/./u) { |ch| - if is_sep(ch) - q.hint << ch - else - q.hint << "^" - end - q.answer_array << ch - } - q.all_seps = false - # It's possible that an answer is entirely done by separators, - # in which case we'll hide everything - if q.answer_array == q.hint - q.hint.map! { |ch| - "^" - } - q.all_seps = true - end - q.hinted = false - - # Generate array of unique random range - q.hintrange = (0..q.hint.length-1).sort_by{ rand } - - m.reply "#{Bold}#{Color}03Question: #{Color}#{Bold}" + q.question - end - - - def cmd_solve( m, params ) - chan = m.channel - - return unless @quizzes.has_key?( chan ) - q = @quizzes[chan] - - m.reply "The correct answer was: #{q.canonical_answer}" - - q.question = nil - - cmd_quiz( m, nil ) if q.registry_conf["autoask"] - end - - - def cmd_hint( m, params ) - chan = m.channel - nick = m.sourcenick.to_s - - return unless @quizzes.has_key?(chan) - q = @quizzes[chan] - - if q.question == nil - m.reply "#{nick}: Get a question first!" - else - num_chars = case q.hintrange.length # Number of characters to reveal - when 25..1000 then 7 - when 20..1000 then 6 - when 16..1000 then 5 - when 12..1000 then 4 - when 8..1000 then 3 - when 5..1000 then 2 - when 1..1000 then 1 - end - - # FIXME 2.0 UTF-8 - num_chars.times do - begin - index = q.hintrange.pop - # New hint char until the char isn't a "separator" (space etc.) - end while is_sep(q.answer_array[index]) and not q.all_seps - q.hint[index] = q.answer_array[index] - end - m.reply "Hint: #{q.hint}" - q.hinted = true - - # FIXME 2.0 UTF-8 - if q.hint == q.answer_array - m.reply "#{Bold}#{Color}04BUST!#{Color}#{Bold} This round is over. #{Color}04Minus one point for #{nick}#{Color}." - - stats = nil - if q.registry.has_key?( nick ) - stats = q.registry[nick] - else - stats = PlayerStats.new( 0, 0, 0 ) - end - - stats["score"] = stats.score - 1 - q.registry[nick] = stats - - calculate_ranks( m, q, nick) - - q.question = nil - cmd_quiz( m, nil ) if q.registry_conf["autoask"] - end - end - end - - - def cmd_skip( m, params ) - chan = m.channel - return unless @quizzes.has_key?(chan) - q = @quizzes[chan] - - q.question = nil - cmd_quiz( m, params ) - end - - - def cmd_joker( m, params ) - chan = m.channel - nick = m.sourcenick.to_s - q = create_quiz(chan) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - if q.question == nil - m.reply "#{nick}: There is no open question." - return - end - - if q.registry[nick].jokers > 0 - player = q.registry[nick] - player.jokers -= 1 - player.score += 1 - q.registry[nick] = player - - calculate_ranks( m, q, nick ) - - if player.jokers != 1 - jokers = "jokers" - else - jokers = "joker" - end - m.reply "#{Bold}#{Color}12JOKER!#{Color}#{Bold} #{nick} draws a joker and wins this round. You have #{player.jokers} #{jokers} left." - m.reply "The answer was: #{q.canonical_answer}." - - q.question = nil - cmd_quiz( m, nil ) if q.registry_conf["autoask"] - else - m.reply "#{nick}: You don't have any jokers left ;(" - end - end - - - def cmd_fetch( m, params ) - fetch_data( m ) - end - - - def cmd_refresh( m, params ) - q = create_quiz ( m.channel ) - q.questions.clear - fetch_data ( m ) - cmd_quiz( m, params ) - end - - - def cmd_top5( m, params ) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - if q.rank_table.empty? - m.reply "There are no scores known yet!" - return - end - - m.reply "* Top 5 Players for #{chan}:" - - [5, q.rank_table.length].min.times do |i| - player = q.rank_table[i] - nick = player[0] - score = player[1].score - m.reply " #{i + 1}. #{unhilight_nick( nick )} (#{score})" - end - end - - - def cmd_top_number( m, params ) - num = params[:number].to_i - return if num < 1 or num > 50 - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - if q.rank_table.empty? - m.reply "There are no scores known yet!" - return - end - - ar = [] - m.reply "* Top #{num} Players for #{chan}:" - n = [ num, q.rank_table.length ].min - n.times do |i| - player = q.rank_table[i] - nick = player[0] - score = player[1].score - ar << "#{i + 1}. #{unhilight_nick( nick )} (#{score})" - end - m.reply ar.join(" | ") - end - - - def cmd_stats( m, params ) - fetch_data( m ) if @questions.empty? - - m.reply "* Total Number of Questions:" - m.reply " #{@questions.length}" - end - - - def cmd_score( m, params ) - nick = m.sourcenick.to_s - say_score( m, nick ) - end - - - def cmd_score_player( m, params ) - say_score( m, params[:player] ) - end - - - def cmd_autoask( m, params ) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - case params[:enable].downcase - when "on", "true" - q.registry_conf["autoask"] = true - m.reply "Enabled autoask mode." - cmd_quiz( m, nil ) if q.question == nil - when "off", "false" - q.registry_conf["autoask"] = false - m.reply "Disabled autoask mode." - else - m.reply "Invalid autoask parameter. Use 'on' or 'off'." - end - end - - def cmd_autoask_delay( m, params ) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - delay = params[:time].to_i - q.registry_conf["autoask_delay"] = delay - m.reply "Autoask delay now #{q.registry_conf['autoask_delay']} seconds" - end - - - def cmd_transfer( m, params ) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - debug q.rank_table.inspect - - source = params[:source] - dest = params[:dest] - transscore = params[:score].to_i - transjokers = params[:jokers].to_i - debug "Transferring #{transscore} points and #{transjokers} jokers from #{source} to #{dest}" - - if q.registry.has_key?(source) - sourceplayer = q.registry[source] - score = sourceplayer.score - if transscore == -1 - transscore = score - end - if score < transscore - m.reply "#{source} only has #{score} points!" - return - end - jokers = sourceplayer.jokers - if transjokers == -1 - transjokers = jokers - end - if jokers < transjokers - m.reply "#{source} only has #{jokers} jokers!!" - return - end - if q.registry.has_key?(dest) - destplayer = q.registry[dest] - else - destplayer = PlayerStats.new(0,0,0) - end - - if sourceplayer.object_id == destplayer.object_id - m.reply "Source and destination are the same, I'm not going to touch them" - return - end - - sourceplayer.score -= transscore - destplayer.score += transscore - sourceplayer.jokers -= transjokers - destplayer.jokers += transjokers - - q.registry[source] = sourceplayer - calculate_ranks(m, q, source) - - q.registry[dest] = destplayer - calculate_ranks(m, q, dest) - - m.reply "Transferred #{transscore} points and #{transjokers} jokers from #{source} to #{dest}" - else - m.reply "#{source} doesn't have any points!" - end - end - - - def cmd_del_player( m, params ) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - debug q.rank_table.inspect - - nick = params[:nick] - if q.registry.has_key?(nick) - player = q.registry[nick] - score = player.score - if score != 0 - m.reply "Can't delete player #{nick} with score #{score}." - return - end - jokers = player.jokers - if jokers != 0 - m.reply "Can't delete player #{nick} with #{jokers} jokers." - return - end - q.registry.delete(nick) - - player_rank = nil - q.rank_table.each_index { |rank| - if nick.downcase == q.rank_table[rank][0].downcase - player_rank = rank - break - end - } - q.rank_table.delete_at(player_rank) - - m.reply "Player #{nick} deleted." - else - m.reply "Player #{nick} isn't even in the database." - end - end - - - def cmd_set_score(m, params) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - debug q.rank_table.inspect - - nick = params[:nick] - val = params[:score].to_i - if q.registry.has_key?(nick) - player = q.registry[nick] - player.score = val - else - player = PlayerStats.new( val, 0, 0) - end - q.registry[nick] = player - calculate_ranks(m, q, nick) - m.reply "Score for player #{nick} set to #{val}." - end - - - def cmd_set_jokers(m, params) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - debug q.rank_table.inspect - - nick = params[:nick] - val = [params[:jokers].to_i, Max_Jokers].min - if q.registry.has_key?(nick) - player = q.registry[nick] - player.jokers = val - else - player = PlayerStats.new( 0, val, 0) - end - q.registry[nick] = player - m.reply "Jokers for player #{nick} set to #{val}." - end - - - def cmd_cleanup(m, params) - chan = m.channel - q = create_quiz( chan ) - if q.nil? - m.reply "Sorry, the quiz database for #{chan} seems to be corrupt" - return - end - - null_players = [] - q.registry.each { |nick, player| - null_players << nick if player.jokers == 0 and player.score == 0 - } - debug "Cleaning up by removing #{null_players * ', '}" - null_players.each { |nick| - cmd_del_player(m, :nick => nick) - } - - end - -end - - - -plugin = QuizPlugin.new -plugin.default_auth( 'edit', false ) - -# Normal commands -plugin.map 'quiz', :action => 'cmd_quiz' -plugin.map 'quiz solve', :action => 'cmd_solve' -plugin.map 'quiz hint', :action => 'cmd_hint' -plugin.map 'quiz skip', :action => 'cmd_skip' -plugin.map 'quiz joker', :action => 'cmd_joker' -plugin.map 'quiz score', :action => 'cmd_score' -plugin.map 'quiz score :player', :action => 'cmd_score_player' -plugin.map 'quiz fetch', :action => 'cmd_fetch' -plugin.map 'quiz refresh', :action => 'cmd_refresh' -plugin.map 'quiz top5', :action => 'cmd_top5' -plugin.map 'quiz top :number', :action => 'cmd_top_number' -plugin.map 'quiz stats', :action => 'cmd_stats' - -# Admin commands -plugin.map 'quiz autoask :enable', :action => 'cmd_autoask', :auth_path => 'edit' -plugin.map 'quiz autoask delay :time', :action => 'cmd_autoask_delay', :auth_path => 'edit', :requirements => {:time => /\d+/} -plugin.map 'quiz transfer :source :dest :score :jokers', :action => 'cmd_transfer', :auth_path => 'edit', :defaults => {:score => '-1', :jokers => '-1'} -plugin.map 'quiz deleteplayer :nick', :action => 'cmd_del_player', :auth_path => 'edit' -plugin.map 'quiz setscore :nick :score', :action => 'cmd_set_score', :auth_path => 'edit' -plugin.map 'quiz setjokers :nick :jokers', :action => 'cmd_set_jokers', :auth_path => 'edit' -plugin.map 'quiz cleanup', :action => 'cmd_cleanup', :auth_path => 'edit' diff --git a/data/rbot/plugins/roshambo.rb b/data/rbot/plugins/roshambo.rb deleted file mode 100644 index 03338698..00000000 --- a/data/rbot/plugins/roshambo.rb +++ /dev/null @@ -1,62 +0,0 @@ -#-- vim:sw=2:et -#++ -# -# :title: Roshambo (rock-paper-scissors) plugin for rbot -# -# Author:: Hans Fugal -# Copyright:: (C) 2004 Hans Fugal -# -# Play the game of roshambo (rock-paper-scissors) -# -# Distributed under the same license as rbot itself - -require 'time' - -class RoshamboPlugin < Plugin - - def initialize - super - @scoreboard = {} - @beats = { :rock => :scissors, :paper => :rock, :scissors => :paper} - @plays = @beats.keys - end - - def help(plugin, topic="") - "roshambo or rps => play roshambo" - end - - def rps(m, params) - # simultaneity - bot_choice = @plays.pick_one - - # init scoreboard - if not @scoreboard.has_key?(m.sourcenick) or (Time.now - @scoreboard[m.sourcenick]['timestamp']) > 3600 - @scoreboard[m.sourcenick] = { 'me' => 0, 'you' => 0, 'timestamp' => Time.now } - end - human_choice = params[:play].to_sym - s = score(bot_choice, human_choice) - @scoreboard[m.sourcenick]['timestamp'] = Time.now - myscore=@scoreboard[m.sourcenick]['me'] - yourscore=@scoreboard[m.sourcenick]['you'] - case s - when 1 - yourscore = @scoreboard[m.sourcenick]['you'] += 1 - m.reply "#{bot_choice}. You win. Score: me #{myscore} you #{yourscore}" - when 0 - m.reply "#{bot_choice}. We tie. Score: me #{myscore} you #{yourscore}" - when -1 - myscore = @scoreboard[m.sourcenick]['me'] += 1 - m.reply "#{bot_choice}! I win! Score: me #{myscore} you #{yourscore}" - end - end - - def score(bot_choice, human_choice) - return -1 if @beats[bot_choice] == human_choice - return 1 if @beats[human_choice] == bot_choice - return 0 - end -end - -plugin = RoshamboPlugin.new -plugin.map "roshambo :play", :action => :rps, :requirements => { :play => /rock|paper|scissors/ } -plugin.map "rps :play", :action => :rps, :requirements => { :play => /rock|paper|scissors/ } diff --git a/data/rbot/plugins/roulette.rb b/data/rbot/plugins/roulette.rb deleted file mode 100644 index 9fce8d8a..00000000 --- a/data/rbot/plugins/roulette.rb +++ /dev/null @@ -1,219 +0,0 @@ -RouletteHistory = Struct.new("RouletteHistory", :games, :shots, :deaths, :misses, :wins) - -class RoulettePlugin < Plugin - BotConfig.register BotConfigBooleanValue.new('roulette.autospin', - :default => true, - :desc => "Automatically spins the roulette at the butlast shot") - BotConfig.register BotConfigBooleanValue.new('roulette.kick', - :default => false, - :desc => "Kicks shot players from the channel") - - def initialize - super - reset_chambers - @players = Array.new - end - - def help(plugin, topic="") - "roulette => play russian roulette - starts a new game if one isn't already running. One round in a six chambered gun. Take turns to say roulette to the bot, until somebody dies. roulette reload => force the gun to reload, roulette stats => show stats from all games, roulette stats => show stats for , roulette clearstats => clear stats (config level auth required), roulette spin => spins the cylinder" - end - - def clearstats(m, params) - @registry.clear - m.okay - end - - def roulette(m, params) - if m.private? - m.reply "you gotta play roulette in channel dude" - return - end - - playerdata = nil - if @registry.has_key?("player " + m.sourcenick) - playerdata = @registry["player " + m.sourcenick] - else - playerdata = RouletteHistory.new(0,0,0,0,0) - end - - totals = nil - if @registry.has_key?("totals") - totals = @registry["totals"] - else - totals = RouletteHistory.new(0,0,0,0,0) - end - - unless @players.include?(m.sourcenick) - @players << m.sourcenick - playerdata.games += 1 - end - playerdata.shots += 1 - totals.shots += 1 - - shot = @chambers.pop - if shot - m.reply "#{m.sourcenick}: chamber #{6 - @chambers.length} of 6 => *BANG*" - playerdata.deaths += 1 - totals.deaths += 1 - @players.each {|plyr| - next if plyr == m.sourcenick - pdata = @registry["player " + plyr] - next if pdata == nil - pdata.wins += 1 - totals.wins += 1 - @registry["player " + plyr] = pdata - } - @players = Array.new - @bot.kick(m.replyto, m.sourcenick, "*BANG*") if @bot.config['roulette.kick'] - else - m.reply "#{m.sourcenick}: chamber #{6 - @chambers.length} of 6 => +click+" - playerdata.misses += 1 - totals.misses += 1 - end - - @registry["player " + m.sourcenick] = playerdata - @registry["totals"] = totals - - if shot || @chambers.empty? - reload(m) - elsif @chambers.length == 1 and @bot.config['roulette.autospin'] - spin(m) - end - end - - def reload(m, params = {}) - if m.private? - m.reply "you gotta play roulette in channel dude" - return - end - - m.act "reloads" - reset_chambers - # all players win on a reload - # (allows you to play 3-shot matches etc) - totals = nil - if @registry.has_key?("totals") - totals = @registry["totals"] - else - totals = RouletteHistory.new(0,0,0,0,0) - end - - @players.each {|plyr| - pdata = @registry["player " + plyr] - next if pdata == nil - pdata.wins += 1 - totals.wins += 1 - @registry["player " + plyr] = pdata - } - - totals.games += 1 - @registry["totals"] = totals - - @players = Array.new - end - - def spin(m, params={}) - # Spinning is just like resetting, except that nobody wins - if m.private? - m.reply "you gotta play roulette in channel dude" - return - end - - m.act "spins the cylinder" - reset_chambers - end - - def reset_chambers - @chambers = [false, false, false, false, false, false] - @chambers[rand(@chambers.length)] = true - end - - def playerstats(m, params) - player = params[:player] - pstats = @registry["player " + player] - if pstats.nil? - m.reply "#{player} hasn't played enough games yet" - else - m.reply "#{player} has played #{pstats.games} games, won #{pstats.wins} and lost #{pstats.deaths}. #{player} pulled the trigger #{pstats.shots} times and found the chamber empty on #{pstats.misses} occasions." - end - end - - def stats(m, params) - if @registry.has_key?("totals") - totals = @registry["totals"] - total_games = totals.games - total_shots = totals.shots - else - total_games = 0 - total_shots = 0 - end - - total_players = 0 - - died_most = [nil,0] - won_most = [nil,0] - h_win_percent = [nil,0] - l_win_percent = [nil,0] - h_luck_percent = [nil,0] - l_luck_percent = [nil,0] - @registry.each {|k,v| - match = /player (.+)/.match(k) - next unless match - k = match[1] - - total_players += 1 - - win_rate = v.wins.to_f / v.games * 100 - if h_win_percent[0].nil? || win_rate > h_win_percent[1] && v.games > 2 - h_win_percent = [[k], win_rate] - elsif win_rate == h_win_percent[1] && v.games > 2 - h_win_percent[0] << k - end - if l_win_percent[0].nil? || win_rate < l_win_percent[1] && v.games > 2 - l_win_percent = [[k], win_rate] - elsif win_rate == l_win_percent[1] && v.games > 2 - l_win_percent[0] << k - end - - luck = v.misses.to_f / v.shots * 100 - if h_luck_percent[0].nil? || luck > h_luck_percent[1] && v.games > 2 - h_luck_percent = [[k], luck] - elsif luck == h_luck_percent[1] && v.games > 2 - h_luck_percent[0] << k - end - if l_luck_percent[0].nil? || luck < l_luck_percent[1] && v.games > 2 - l_luck_percent = [[k], luck] - elsif luck == l_luck_percent[1] && v.games > 2 - l_luck_percent[0] << k - end - - if died_most[0].nil? || v.deaths > died_most[1] - died_most = [[k], v.deaths] - elsif v.deaths == died_most[1] - died_most[0] << k - end - if won_most[0].nil? || v.wins > won_most[1] - won_most = [[k], v.wins] - elsif v.wins == won_most[1] - won_most[0] << k - end - } - if total_games < 1 - m.reply "roulette stats: no games completed yet" - else - m.reply "roulette stats: #{total_games} games completed, #{total_shots} shots fired at #{total_players} players. Luckiest: #{h_luck_percent[0].join(',')} (#{sprintf '%.1f', h_luck_percent[1]}% clicks). Unluckiest: #{l_luck_percent[0].join(',')} (#{sprintf '%.1f', l_luck_percent[1]}% clicks). Highest survival rate: #{h_win_percent[0].join(',')} (#{sprintf '%.1f', h_win_percent[1]}%). Lowest survival rate: #{l_win_percent[0].join(',')} (#{sprintf '%.1f', l_win_percent[1]}%). Most wins: #{won_most[0].join(',')} (#{won_most[1]}). Most deaths: #{died_most[0].join(',')} (#{died_most[1]})." - end - end -end - -plugin = RoulettePlugin.new - -plugin.default_auth('clearstats', false) - -plugin.map 'roulette reload', :action => 'reload' -plugin.map 'roulette spin', :action => 'spin' -plugin.map 'roulette stats :player', :action => 'playerstats' -plugin.map 'roulette stats', :action => 'stats' -plugin.map 'roulette clearstats', :action => 'clearstats' -plugin.map 'roulette' - diff --git a/data/rbot/plugins/shiritori.rb b/data/rbot/plugins/shiritori.rb deleted file mode 100644 index f13afeb8..00000000 --- a/data/rbot/plugins/shiritori.rb +++ /dev/null @@ -1,448 +0,0 @@ -#-- vim:sw=2:et -#kate: indent-width 2 -#++ -# -# :title: Shiritori Plugin for RBot -# -# Author:: Yaohan Chen -# 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. -# -# 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 - - # 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 - if take_turns? - @say.call "#{current_player}, it's your turn. #{previous_word} -> #{current_word}" - elsif @players.empty? - @say.call "No one has given the first word yet. Say the first word to start." - else - @say.call "Poor #{current_player} is playing alone! Anyone care to join? #{previous_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.call "#{current_player} took too long and is out of the game. Try again next game!" - 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.call "#{@players[1]} is the last remaining player and the winner! Congratulations!" - die - else - @booted_players << @players.shift - announce - end - else - @say.call "#{current_player} took too long and skipped the turn." - 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 "#{speaker} has given the first word: #{current_word}" - when :next - if !@players.include?(speaker) - # A new player - @players.unshift speaker - m.reply "Welcome to shiritori, #{speaker}." - end - next_player - when :used - m.reply "The word #{message} has been used. Retry from #{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 #{message}. The game has ended. Thanks a lot, #{speaker}! :(" - die - else - m.reply "It's impossible to continue the chain from #{message}. Retry from #{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 #{message}. Start with another word." - 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 ' => Play shiritori with a set of rules. Available rulesets: #{@rulesets.keys.join ', '}. 'shiritori stop' => Stop the current shiritori game." - 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.', - # Optionally use a module to normalize Japanese words, enabling input in multiple writing systems - } - } - @rulesets.each_value do |ruleset| - # set default values for each rule to default_ruleset's values - ruleset.replace @default_ruleset.merge(ruleset) - unless ruleset.has_key?(:words) - if ruleset.has_key?(:wordlist_file) - # TODO read words only when rule is used - # read words separated by newlines from file - ruleset[:words] = - File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep( - ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp} - else - raise NotImplementedError - end - end - end - 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 - if @rulesets.has_key? params[:ruleset] - @games[m.channel] = ShiritoriGame.new( - m.channel, @rulesets[params[:ruleset]], - @bot.timer, - lambda {|msg| m.reply msg}, - lambda {remove_game m.channel} ) - m.reply "Shiritori has started. Please say the first word" - else - m.reply "There is no defined ruleset named #{params[: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 listen(m) - return unless m.kind_of?(PrivMessage) - 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 - 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 -- cgit v1.2.3