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/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 ++++++++++++++++ 5 files changed, 2242 insertions(+) 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 (limited to 'data/rbot/plugins/games') 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 -- cgit v1.2.3