5 # :title: Shiritori Plugin for RBot
7 # Author:: Yaohan Chen <yaohan.chen@gmail.com>
8 # Copyright:: (c) 2007 Yaohan Chen
9 # License:: GNU Public License
12 # Shiritori is a word game where a few people take turns to continue a chain of words.
13 # To continue a word, the next word must start with the ending of the previous word,
14 # usually defined as the one to few letters/characters at the end. This plugin allows
15 # playing several games, each per channel. A game can be turn-based, where only new
16 # players can interrupt a turn to join, or a free mode where anyone can speak at any
20 # * a system to describe settings, so they can be displayed, changed and saved
21 # * adjust settings during game
22 # * allow other definitions of continues?
23 # * read default settings from configuration
25 # * other forms of dictionaries
28 # Abstract class representing a dictionary used by Shiritori
30 # whether string s is a word
32 raise NotImplementedError
35 # whether any word starts with prefix, excluding words in excludes. This can be
36 # possible with non-enumerable dictionaries since some dictionary engines provide
38 def any_word_starting?(prefix, excludes)
39 raise NotImplementedError
43 # A Dictionary that uses a enumrable word list.
44 class WordlistDictionary < Dictionary
48 debug "Created dictionary with #{@words.length} words"
51 # whether string s is a word
56 # whether any word starts with prefix, excluding words in excludes
57 def any_word_starting?(prefix, excludes)
58 # (@words - except).any? {|w| w =~ /\A#{prefix}.+/}
59 # this seems to be faster:
60 !(@words.grep(/\A#{prefix}.+/) - excludes).empty?
64 # Logic of shiritori game, deals with checking whether words continue the chain, and
65 # whether it's possible to continue a word
67 attr_reader :used_words
69 # dictionary:: a Dictionary object
70 # overlap_lengths:: a Range for allowed lengths to overlap when continuing words
71 # check_continuable:: whether all words are checked whether they're continuable,
72 # before being commited
73 # allow_reuse:: whether words are allowed to be used again
74 def initialize(dictionary, overlap_lengths, check_continuable, allow_reuse)
75 @dictionary = dictionary
76 @overlap_lengths = overlap_lengths
77 @check_continuable = check_continuable
78 @allow_reuse = allow_reuse
82 # Prefix of s with length n
85 s.split(//u)[0, n].join
87 # Suffix of s with length n
90 s.split(//u)[-n, n].join
92 # Number of unicode characters in string
97 # return subrange of range r that's under n
99 r.begin .. [r.end, n-1].min
102 # TODO allow the ruleset to customize this
103 def continues?(w2, w1)
104 # this uses the definition w1[-n,n] == w2[0,n] && n < [w1.length, w2.length].min
105 # TODO it might be worth allowing <= for the second clause
106 range_under(@overlap_lengths, [len(w1), len(w2)].min).any? {|n|
107 tail_of(w1, n)== head_of(w2, n)}
110 # Checks whether *any* unused word in the dictionary completes the word
111 # This has the limitation that it can't detect when a word is continuable, but the
112 # only continuers aren't continuable
113 def continuable_from?(s)
114 range_under(@overlap_lengths, len(s)).any? {|n|
115 @dictionary.any_word_starting?(tail_of(s, n), @used_words) }
118 # Given a string, give a verdict based on current shiritori state and dictionary
120 # TODO optionally allow used words
122 if len(s) < @overlap_lengths.min || !@dictionary.has_word?(s)
123 debug "#{s} is too short or not in dictionary"
125 elsif @used_words.empty?
126 if !@check_continuable || continuable_from?(s)
132 elsif continues?(s, @used_words.last)
133 if !@allow_reuse && @used_words.include?(s)
135 elsif !@check_continuable || continuable_from?(s)
147 # A shiritori game on a channel. keeps track of rules related to timing and turns,
148 # and interacts with players
150 # timer:: the bot.timer object
151 # say:: a Proc which says the given message on the channel
152 # when_die:: a Proc that removes the game from plugin's list of games
153 def initialize(channel, ruleset, timer, say, when_die)
154 raise ArgumentError unless [:words, :overlap_lengths, :check_continuable,
155 :end_when_uncontinuable, :allow_reuse, :listen, :normalize, :time_limit,
156 :lose_when_timeout].all? {|r| ruleset.has_key?(r)}
167 # TODO allow other forms of dictionaries
168 dictionary = WordlistDictionary.new(@ruleset[:words])
169 @game = Shiritori.new(dictionary, @ruleset[:overlap_lengths],
170 @ruleset[:check_continuable],
171 @ruleset[:allow_reuse])
174 # Whether the players must take turns
175 # * when there is only one player, turns are not enforced
176 # * when time_limit > 0, new players can join at any time, but existing players must
177 # take turns, each of which expires after time_limit
178 # * when time_imit is 0, anyone can speak in the game at any time
180 @players.length > 1 && @ruleset[:time_limit] > 0
183 # the player who has the current turn
187 # the word to continue in the current turn
191 # the word in the chain before current_word
196 # announce the current word, and player if take_turns?
199 @say.call "#{current_player}, it's your turn. #{previous_word} -> #{current_word}"
200 elsif @players.empty?
201 @say.call "No one has given the first word yet. Say the first word to start."
203 @say.call "Poor #{current_player} is playing alone! Anyone care to join? #{previous_word} -> #{current_word}"
206 # create/reschedule timer
208 # the first time the method is called, a new timer is added
209 @timer_handle = @timer.add(@ruleset[:time_limit]) {time_out}
210 # afterwards, it will reschdule the timer
213 @timer.reschedule(@timer_handle, @ruleset[:time_limit])
217 # switch to the next player's turn if take_turns?, and announce current words
219 # when there's only one player, turns and timer are meaningless
221 # place the current player to the last position, to implement circular queue
222 @players << @players.shift
223 # stop previous timer and set time for this turn
229 # handle when turn time limit goes out
231 if @ruleset[:lose_when_timeout]
232 @say.call "#{current_player} took too long and is out of the game. Try again next game!"
233 if @players.length == 2
234 # 2 players before, and one should remain now
235 # since the game is ending, save the trouble of removing and booting the player
236 @say.call "#{@players[1]} is the last remaining player and the winner! Congratulations!"
239 @booted_players << @players.shift
243 @say.call "#{current_player} took too long and skipped the turn."
248 # change the rules, and update state when necessary
249 def change_rules(rules)
250 @ruleset.update! rules
253 # handle a message to @channel
254 def handle_message(m)
256 speaker = m.sourcenick.to_s
258 return unless @ruleset[:listen] =~ message
260 # in take_turns mode, only new players are allowed to interrupt a turn
261 return if @booted_players.include? speaker ||
263 speaker != current_player &&
264 (@players.length > 1 && @players.include?(speaker)))
266 # let Shiritori process the message, and act according to result
267 case @game.process @ruleset[:normalize].call(message)
270 m.reply "#{speaker} has given the first word: #{current_word}"
272 if !@players.include?(speaker)
274 @players.unshift speaker
275 m.reply "Welcome to shiritori, #{speaker}."
279 m.reply "The word #{message} has been used. Retry from #{current_word}"
281 # TODO respect shiritori.end_when_uncontinuable setting
282 if @ruleset[:end_when_uncontinuable]
283 m.reply "It's impossible to continue the chain from #{message}. The game has ended. Thanks a lot, #{speaker}! :("
286 m.reply "It's impossible to continue the chain from #{message}. Retry from #{current_word}"
289 # when the first word is uncontinuable, the game doesn't stop, as presumably
290 # someone wanted to play
291 m.reply "It's impossible to continue the chain from #{message}. Start with another word."
297 # redefine restart_timer to no-op
302 # remove any registered timer
303 @timer.remove @timer_handle unless @timer_handle.nil?
304 # should remove the game object from plugin's @games list
309 # shiritori plugin for rbot
310 class ShiritoriPlugin < Plugin
311 def help(plugin, topic="")
312 "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."
319 # TODO make rulesets more easily customizable
320 # TODO initialize default ruleset from config
321 # Default values of rulesets
323 # the range of the length of "tail" that must be followed to continue the chain
324 :overlap_lengths => 1..2,
325 # messages cared about, pre-normalize
326 :listen => /\A\S+\Z/u,
327 # normalize messages with this function before checking with Shiritori
328 :normalize => lambda {|w| w},
329 # number of seconds for each player's turn
331 # when the time limit is reached, the player's booted out of the game and cannot
332 # join until the next game
333 :lose_when_timeout => true,
334 # check whether the word is continuable before adding it into chain
335 :check_continuable => true,
336 # allow reusing used words
337 :allow_reuse => false,
338 # end the game when an uncontinuable word is said
339 :end_when_uncontinuable => true
343 :wordlist_file => 'english',
344 :listen => /\A[a-zA-Z]+\Z/,
345 :overlap_lengths => 2..5,
346 :normalize => lambda {|w| w.downcase},
347 :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.'
350 :wordlist_file => 'japanese',
351 :listen => /\A\S+\Z/u,
352 :overlap_lengths => 1..4,
353 :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.',
354 # Optionally use a module to normalize Japanese words, enabling input in multiple writing systems
359 def load_ruleset(ruleset_name)
360 # set default values for each rule to default_ruleset's values
361 ruleset = @rulesets[ruleset_name].dup
362 ruleset.replace @default_ruleset.merge(ruleset)
363 unless ruleset.has_key?(:words)
364 if ruleset.has_key?(:wordlist_file)
367 File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep(
368 ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp}
370 raise "unable to load word list"
373 raise NotImplementedError, "ruleset not implemented (properly)"
379 # start shiritori in a channel
380 def cmd_shiritori(m, params)
381 if @games.has_key?( m.channel )
382 m.reply "Already playing shiritori here"
383 @games[m.channel].announce
385 ruleset = params[:ruleset].downcase
386 if @rulesets.has_key? ruleset
388 @games[m.channel] = ShiritoriGame.new(
389 m.channel, load_ruleset(ruleset),
391 lambda {|msg| m.reply msg},
392 lambda {remove_game m.channel} )
393 m.reply "Shiritori has started. Please say the first word"
395 m.reply "couldn't start #{ruleset} shiritori: #{e}"
398 m.reply "There is no defined ruleset named #{ruleset}"
403 # change rules for current game
404 def cmd_set(m, params)
407 params[:rules].each_slice(2) {|opt, value| new_rules[opt] = value}
408 raise NotImplementedError
411 # stop the current game
412 def cmd_stop(m, params)
413 if @games.has_key? m.channel
414 # TODO display statistics
415 @games[m.channel].die
416 m.reply "Shiritori has stopped. Hope you had fun!"
418 # TODO display statistics
419 m.reply "No game to stop here, because no game is being played."
423 # remove the game, so channel messages are no longer processed, and timer removed
424 def remove_game(channel)
425 @games.delete channel
428 # all messages from a channel is sent to its shiritori game if any
430 return unless m.kind_of?(PrivMessage)
431 return unless @games.has_key?(m.channel)
432 # send the message to the game in the channel to handle it
433 @games[m.channel].handle_message m
438 @games.each_key {|g| g.die}
443 plugin = ShiritoriPlugin.new
444 plugin.default_auth( 'edit', false )
446 # Normal commandsi have a stop_gamei have a stop_game
447 plugin.map 'shiritori stop',
448 :action => 'cmd_stop',
450 # plugin.map 'shiritori set ',
451 # :action => 'cmd_set'
453 # plugin.map 'shiritori challenge',
454 # :action => 'cmd_challenge'
455 plugin.map 'shiritori [:ruleset]',
456 :action => 'cmd_shiritori',
457 :defaults => {:ruleset => 'japanese'},