summaryrefslogtreecommitdiff
path: root/data/rbot/plugins/games
diff options
context:
space:
mode:
authorGiuseppe Bilotta <giuseppe.bilotta@gmail.com>2007-03-16 20:48:28 +0000
committerGiuseppe Bilotta <giuseppe.bilotta@gmail.com>2007-03-16 20:48:28 +0000
commit2e6f388addc0bb9ddc0cb991af50220c1e5e64c3 (patch)
tree580a3d0a086c95392903df10be302186f9f3cebb /data/rbot/plugins/games
parent8669de30cea27aee0116bb4420cbf15232c8c352 (diff)
Plugins: move games into their own directory
Diffstat (limited to 'data/rbot/plugins/games')
-rw-r--r--data/rbot/plugins/games/azgame.rb550
-rw-r--r--data/rbot/plugins/games/quiz.rb963
-rw-r--r--data/rbot/plugins/games/roshambo.rb62
-rw-r--r--data/rbot/plugins/games/roulette.rb219
-rw-r--r--data/rbot/plugins/games/shiritori.rb448
5 files changed, 2242 insertions, 0 deletions
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 <giuseppe.bilotta@gmail.com>
+# Author:: Yaohan Chen <yaohan.chen@gmail.com>: 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(/<anchor>#{word} - (.*?)<go href="lemma.php\?ID=([^"]*?)"/) { |qual, url|
+ debug "new word #{word} of type #{qual}"
+ if qual =~ rules[:good] and qual !~ rules[:bad]
+ wc[word.to_sym] = {:who => :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><a href="([^"]+?)" title="consulta il lemma ([^ "][^ "]+?)">.*?&nbsp;(.+?)<\/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 =~ /<span class="(?:hwd|srch)">#{word}<\/span>([^\n]+?)<span class="psa">#{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 class="(?:hwd|srch)">(.*?)<\/span>([^\n]+?)<span class="psa">#{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 <word> => checks <word> 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 <language>' 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 <markey@web.de>
+# Author:: Jocke Andersson <ajocke@gmail.com>
+# Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
+# Author:: Yaohan Chen <yaohan.chen@gmail.com>
+#
+# 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 << "<who> guessed right! The answer was <answer>"
+ 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( /&nbsp;/, " " ).gsub( /&amp;/, "&" ).gsub( /&quot;/, "\"" )
+ 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 <on/off>' => enable/disable autoask mode. 'quiz autoask delay <secs>' => delay next quiz by <secs> seconds when in autoask mode. 'quiz transfer <source> <dest> [score] [jokers]' => transfer [score] points and [jokers] jokers from <source> to <dest> (default is entire score and all jokers). 'quiz setscore <player> <score>' => set <player>'s score to <score>. 'quiz setjokers <player> <jokers>' => set <player>'s number of jokers to <jokers>. 'quiz deleteplayer <player>' => 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 <number>' => show top <number> 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!( "<who>", nick )
+ reply.gsub!( "<answer>", 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 <rock|paper|scissors> or rps <rock|paper|scissors> => 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 <player> => show stats for <player>, 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 <yaohan.chen@gmail.com>
+# Copyright:: (c) 2007 Yaohan Chen
+# License:: GNU Public License
+#
+#
+# Shiritori is a word game where a few people take turns to continue a chain of words.
+# To continue a word, the next word must start with the ending of the previous word,
+# usually defined as the one to few letters/characters at the end. This plugin allows
+# playing several games, each per channel. A game can be turn-based, where only new
+# players can interrupt a turn to join, or a free mode where anyone can speak at any
+# time.
+#
+# 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 <ruleset>' => 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