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])
178 # Whether the players must take turns
179 # * when there is only one player, turns are not enforced
180 # * when time_limit > 0, new players can join at any time, but existing players must
181 # take turns, each of which expires after time_limit
182 # * when time_imit is 0, anyone can speak in the game at any time
184 @players.length > 1 && @ruleset[:time_limit] > 0
187 # the player who has the current turn
191 # the word to continue in the current turn
195 # the word in the chain before current_word
200 # announce the current word, and player if take_turns?
203 _("%{current_player}, it's your turn. %{previous_word} -> %{current_word}") %
204 { :current_player => current_player, :previous_word => previous_word,
205 :current_word => current_word }
206 elsif @players.empty?
207 _("No one has given the first word yet. Say the first word to start.")
209 _("Poor %{current_player} is playing alone! Anyone care to join? %{previous_word} -> %{current_word}") %
210 { :current_player => current_player, :previous_word => previous_word,
211 :current_word => current_word }
214 # create/reschedule timer
216 # the first time the method is called, a new timer is added
217 @timer_handle = @timer.add(@ruleset[:time_limit]) {time_out}
218 # afterwards, it will reschdule the timer
221 @timer.reschedule(@timer_handle, @ruleset[:time_limit])
225 # switch to the next player's turn if take_turns?, and announce current words
227 # when there's only one player, turns and timer are meaningless
229 # place the current player to the last position, to implement circular queue
230 @players << @players.shift
231 # stop previous timer and set time for this turn
237 # handle when turn time limit goes out
239 if @ruleset[:lose_when_timeout]
240 say _("%{player} took too long and is out of the game. Try again next game!") %
241 { :player => current_player }
242 if @players.length == 2
243 # 2 players before, and one should remain now
244 # since the game is ending, save the trouble of removing and booting the player
245 say _("%{player} is the last remaining player and the winner! Congratulations!") %
246 {:player => @players.last}
249 @booted_players << @players.shift
253 say _("%{player} took too long and skipped the turn.") %
254 {:player => current_player}
259 # change the rules, and update state when necessary
260 def change_rules(rules)
261 @ruleset.update! rules
264 # handle a message to @channel
265 def handle_message(m)
267 speaker = m.sourcenick.to_s
269 return unless @ruleset[:listen] =~ message
271 # in take_turns mode, only new players are allowed to interrupt a turn
272 return if @booted_players.include? speaker ||
274 speaker != current_player &&
275 (@players.length > 1 && @players.include?(speaker)))
277 # let Shiritori process the message, and act according to result
278 case @game.process @ruleset[:normalize].call(message)
281 m.reply _("%{player} has given the first word: %{word}") %
282 {:player => speaker, :word => current_word}
284 if !@players.include?(speaker)
286 @players.unshift speaker
287 m.reply _("Welcome to shiritori, %{player}.") %
292 m.reply _("The word %{used_word} has been used. Retry from %{word}") %
293 {:used_word => message, :word => current_word}
295 # TODO respect shiritori.end_when_uncontinuable setting
296 if @ruleset[:end_when_uncontinuable]
297 m.reply _("It's impossible to continue the chain from %{word}. The game has ended. Thanks a lot, %{player}! :(") %
298 {:word => message, :player => speaker}
301 m.reply _("It's impossible to continue the chain from %{bad_word}. Retry from %{word}") % {:bad_word => message, :word => current_word}
304 # when the first word is uncontinuable, the game doesn't stop, as presumably
305 # someone wanted to play
306 m.reply _("It's impossible to continue the chain from %{word}. Start with another word.") % {:word => message}
312 # redefine restart_timer to no-op
317 # remove any registered timer
318 @timer.remove @timer_handle unless @timer_handle.nil?
319 # should remove the game object from plugin's @games list
324 # shiritori plugin for rbot
325 class ShiritoriPlugin < Plugin
326 def help(plugin, topic="")
327 _("A game in which each player must continue the previous player's word, by using its last one or few characters/letters of the word to start a new word. 'shiritori <ruleset>' => Play shiritori with a set of rules. Available rulesets: %{rulesets}. 'shiritori stop' => Stop the current shiritori game.") %
328 {:rulesets => @rulesets.keys.join(', ')}
335 # TODO make rulesets more easily customizable
336 # TODO initialize default ruleset from config
337 # Default values of rulesets
339 # the range of the length of "tail" that must be followed to continue the chain
340 :overlap_lengths => 1..2,
341 # messages cared about, pre-normalize
342 :listen => /\A\S+\Z/u,
343 # normalize messages with this function before checking with Shiritori
344 :normalize => lambda {|w| w},
345 # number of seconds for each player's turn
347 # when the time limit is reached, the player's booted out of the game and cannot
348 # join until the next game
349 :lose_when_timeout => true,
350 # check whether the word is continuable before adding it into chain
351 :check_continuable => true,
352 # allow reusing used words
353 :allow_reuse => false,
354 # end the game when an uncontinuable word is said
355 :end_when_uncontinuable => true
359 :wordlist_file => 'english',
360 :listen => /\A[a-zA-Z]+\Z/,
361 :overlap_lengths => 2..5,
362 :normalize => lambda {|w| w.downcase},
363 :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.'
366 :wordlist_file => 'japanese',
367 :listen => /\A\S+\Z/u,
368 :overlap_lengths => 1..4,
369 :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.',
370 # Optionally use a module to normalize Japanese words, enabling input in multiple writing systems
375 def load_ruleset(ruleset_name)
376 # set default values for each rule to default_ruleset's values
377 ruleset = @rulesets[ruleset_name].dup
378 ruleset.replace @default_ruleset.merge(ruleset)
379 unless ruleset.has_key?(:words)
380 if ruleset.has_key?(:wordlist_file)
383 File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep(
384 ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp}
386 raise "unable to load word list"
389 raise NotImplementedError, "ruleset not implemented (properly)"
395 # start shiritori in a channel
396 def cmd_shiritori(m, params)
397 if @games.has_key?( m.channel )
398 m.reply _("Already playing shiritori here")
399 @games[m.channel].announce
401 ruleset = params[:ruleset].downcase
402 if @rulesets.has_key? ruleset
404 @games[m.channel] = ShiritoriGame.new(
405 m.channel, load_ruleset(ruleset),
407 lambda {|msg| m.reply msg},
408 lambda {remove_game m.channel} )
409 m.reply _("Shiritori has started. Please say the first word")
411 m.reply _("couldn't start %{ruleset} shiritori: %{error}") %
412 {:ruleset => ruleset, :error => e}
415 m.reply _("There is no ruleset named %{ruleset}") % {:ruleset => ruleset}
420 # change rules for current game
421 def cmd_set(m, params)
424 params[:rules].each_slice(2) {|opt, value| new_rules[opt] = value}
425 raise NotImplementedError
428 # stop the current game
429 def cmd_stop(m, params)
430 if @games.has_key? m.channel
431 # TODO display statistics
432 @games[m.channel].die
433 m.reply _("Shiritori has stopped. Hope you had fun!")
435 # TODO display statistics
436 m.reply _("No game to stop here, because no game is being played.")
440 # remove the game, so channel messages are no longer processed, and timer removed
441 def remove_game(channel)
442 @games.delete channel
445 # all messages from a channel is sent to its shiritori game if any
447 return unless m.kind_of?(PrivMessage)
448 return unless @games.has_key?(m.channel)
449 # send the message to the game in the channel to handle it
450 @games[m.channel].handle_message m
455 @games.each_key {|g| g.die}
461 plugin = ShiritoriPlugin.new
462 plugin.default_auth( 'edit', false )
464 # Normal commandsi have a stop_gamei have a stop_game
465 plugin.map 'shiritori stop',
466 :action => 'cmd_stop',
468 # plugin.map 'shiritori set ',
469 # :action => 'cmd_set'
471 # plugin.map 'shiritori challenge',
472 # :action => 'cmd_challenge'
473 plugin.map 'shiritori [:ruleset]',
474 :action => 'cmd_shiritori',
475 :defaults => {:ruleset => 'japanese'},