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
19 # In Japanese mode, if present, the plugin can use normalize-japanese
20 # <http://neruchan.mine.nu:60880/normalize-japanese.rb> to allow
21 # katakana words be used like hiragana.
24 # * a system to describe settings, so they can be displayed, changed and saved
25 # * adjust settings during game
26 # * allow other definitions of continues?
27 # * read default settings from configuration
29 # * other forms of dictionaries
32 # Abstract class representing a dictionary used by Shiritori
34 # whether string s is a word
36 raise NotImplementedError
39 # whether any word starts with prefix, excluding words in excludes. This can be
40 # possible with non-enumerable dictionaries since some dictionary engines provide
42 def any_word_starting?(prefix, excludes)
43 raise NotImplementedError
47 # A Dictionary that uses a enumrable word list.
48 class WordlistDictionary < Dictionary
52 # debug "Created dictionary with #{@words.length} words"
55 # whether string s is a word
60 # whether any word starts with prefix, excluding words in excludes
61 def any_word_starting?(prefix, excludes)
62 # (@words - except).any? {|w| w =~ /\A#{prefix}.+/}
63 # this seems to be faster:
64 !(@words.grep(/\A#{prefix}.+/) - excludes).empty?
68 # Logic of shiritori game, deals with checking whether words continue the chain, and
69 # whether it's possible to continue a word
71 attr_reader :used_words
73 # dictionary:: a Dictionary object
74 # overlap_lengths:: a Range for allowed lengths to overlap when continuing words
75 # check_continuable:: whether all words are checked whether they're continuable,
76 # before being commited
77 # allow_reuse:: whether words are allowed to be used again
78 def initialize(dictionary, overlap_lengths, check_continuable, allow_reuse)
79 @dictionary = dictionary
80 @overlap_lengths = overlap_lengths
81 @check_continuable = check_continuable
82 @allow_reuse = allow_reuse
86 # Prefix of s with length n
89 s.split(//u)[0, n].join
91 # Suffix of s with length n
94 s.split(//u)[-n, n].join
96 # Number of unicode characters in string
101 # return subrange of range r that's under n
102 def range_under(r, n)
103 r.begin .. [r.end, n-1].min
106 # TODO allow the ruleset to customize this
107 def continues?(w2, w1)
108 # this uses the definition w1[-n,n] == w2[0,n] && n < [w1.length, w2.length].min
109 # TODO it might be worth allowing <= for the second clause
110 range_under(@overlap_lengths, [len(w1), len(w2)].min).any? {|n|
111 tail_of(w1, n)== head_of(w2, n)}
114 # Checks whether *any* unused word in the dictionary completes the word
115 # This has the limitation that it can't detect when a word is continuable, but the
116 # only continuers aren't continuable
117 def continuable_from?(s)
118 range_under(@overlap_lengths, len(s)).any? {|n|
119 @dictionary.any_word_starting?(tail_of(s, n), @used_words) }
122 # Given a string, give a verdict based on current shiritori state and dictionary
124 # TODO optionally allow used words
126 if len(s) < @overlap_lengths.min || !@dictionary.has_word?(s)
127 # debug "#{s} is too short or not in dictionary"
129 elsif @used_words.empty?
130 if !@check_continuable || continuable_from?(s)
136 elsif continues?(s, @used_words.last)
137 if !@allow_reuse && @used_words.include?(s)
139 elsif !@check_continuable || continuable_from?(s)
151 # A shiritori game on a channel. keeps track of rules related to timing and turns,
152 # and interacts with players
154 # timer:: the bot.timer object
155 # say:: a Proc which says the given message on the channel
156 # when_die:: a Proc that removes the game from plugin's list of games
157 def initialize(channel, ruleset, timer, say, when_die)
158 raise ArgumentError unless [:words, :overlap_lengths, :check_continuable,
159 :end_when_uncontinuable, :allow_reuse, :listen, :normalize, :time_limit,
160 :lose_when_timeout].all? {|r| ruleset.has_key?(r)}
171 # TODO allow other forms of dictionaries
172 dictionary = WordlistDictionary.new(@ruleset[:words])
173 @game = Shiritori.new(dictionary, @ruleset[:overlap_lengths],
174 @ruleset[:check_continuable],
175 @ruleset[:allow_reuse])
182 # Whether the players must take turns
183 # * when there is only one player, turns are not enforced
184 # * when time_limit > 0, new players can join at any time, but existing players must
185 # take turns, each of which expires after time_limit
186 # * when time_imit is 0, anyone can speak in the game at any time
188 @players.length > 1 && @ruleset[:time_limit] > 0
191 # the player who has the current turn
195 # the word to continue in the current turn
199 # the word in the chain before current_word
204 # announce the current word, and player if take_turns?
207 _("%{current_player}, it's your turn. %{previous_word} -> %{current_word}") %
208 { :current_player => current_player, :previous_word => previous_word,
209 :current_word => current_word }
210 elsif @players.empty?
211 _("No one has given the first word yet. Say the first word to start.")
213 _("Poor %{current_player} is playing alone! Anyone care to join? %{previous_word} -> %{current_word}") %
214 { :current_player => current_player, :previous_word => previous_word,
215 :current_word => current_word }
218 # create/reschedule timer
220 # the first time the method is called, a new timer is added
221 @timer_handle = @timer.add(@ruleset[:time_limit]) {time_out}
222 # afterwards, it will reschdule the timer
225 @timer.reschedule(@timer_handle, @ruleset[:time_limit])
229 # switch to the next player's turn if take_turns?, and announce current words
231 # when there's only one player, turns and timer are meaningless
233 # place the current player to the last position, to implement circular queue
234 @players << @players.shift
235 # stop previous timer and set time for this turn
241 # handle when turn time limit goes out
243 if @ruleset[:lose_when_timeout]
244 say _("%{player} took too long and is out of the game. Try again next game!") %
245 { :player => current_player }
246 if @players.length == 2
247 # 2 players before, and one should remain now
248 # since the game is ending, save the trouble of removing and booting the player
249 say _("%{player} is the last remaining player and the winner! Congratulations!") %
250 {:player => @players.last}
253 @booted_players << @players.shift
257 say _("%{player} took too long and skipped the turn.") %
258 {:player => current_player}
263 # change the rules, and update state when necessary
264 def change_rules(rules)
265 @ruleset.update! rules
268 # handle a message to @channel
269 def handle_message(m)
271 speaker = m.sourcenick.to_s
273 return unless @ruleset[:listen] =~ message
275 # in take_turns mode, only new players are allowed to interrupt a turn
276 return if @booted_players.include? speaker ||
278 speaker != current_player &&
279 (@players.length > 1 && @players.include?(speaker)))
281 # let Shiritori process the message, and act according to result
282 case @game.process @ruleset[:normalize].call(message)
285 m.reply _("%{player} has given the first word: %{word}") %
286 {:player => speaker, :word => current_word}
288 if !@players.include?(speaker)
290 @players.unshift speaker
291 m.reply _("Welcome to shiritori, %{player}.") %
296 m.reply _("The word %{used_word} has been used. Retry from %{word}") %
297 {:used_word => message, :word => current_word}
299 # TODO respect shiritori.end_when_uncontinuable setting
300 if @ruleset[:end_when_uncontinuable]
301 m.reply _("It's impossible to continue the chain from %{word}. The game has ended. Thanks a lot, %{player}! :(") %
302 {:word => message, :player => speaker}
305 m.reply _("It's impossible to continue the chain from %{bad_word}. Retry from %{word}") % {:bad_word => message, :word => current_word}
308 # when the first word is uncontinuable, the game doesn't stop, as presumably
309 # someone wanted to play
310 m.reply _("It's impossible to continue the chain from %{word}. Start with another word.") % {:word => message}
316 # redefine restart_timer to no-op
321 # remove any registered timer
322 @timer.remove @timer_handle unless @timer_handle.nil?
323 # should remove the game object from plugin's @games list
328 # shiritori plugin for rbot
329 class ShiritoriPlugin < Plugin
330 def help(plugin, topic="")
331 _("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.") %
332 {:rulesets => @rulesets.keys.join(', ')}
339 # TODO make rulesets more easily customizable
340 # TODO initialize default ruleset from config
341 # Default values of rulesets
343 # the range of the length of "tail" that must be followed to continue the chain
344 :overlap_lengths => 1..2,
345 # messages cared about, pre-normalize
346 :listen => /\A\S+\Z/u,
347 # normalize messages with this function before checking with Shiritori
348 :normalize => lambda {|w| w},
349 # number of seconds for each player's turn
351 # when the time limit is reached, the player's booted out of the game and cannot
352 # join until the next game
353 :lose_when_timeout => true,
354 # check whether the word is continuable before adding it into chain
355 :check_continuable => true,
356 # allow reusing used words
357 :allow_reuse => false,
358 # end the game when an uncontinuable word is said
359 :end_when_uncontinuable => true
363 :wordlist_file => 'english',
364 :listen => /\A[a-zA-Z]+\Z/,
365 :overlap_lengths => 2..5,
366 :normalize => lambda {|w| w.downcase},
367 :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.'
370 :wordlist_file => 'japanese',
371 :listen => /\A\S+\Z/u,
372 :overlap_lengths => 1..4,
373 :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.',
376 require 'normalize-japanese'
377 lambda {|w| w.to_hiragana}
385 def load_ruleset(ruleset_name)
386 # set default values for each rule to default_ruleset's values
387 ruleset = @rulesets[ruleset_name].dup
388 ruleset.replace @default_ruleset.merge(ruleset)
389 unless ruleset.has_key?(:words)
390 if ruleset.has_key?(:wordlist_file)
393 File.new(datafile(ruleset[:wordlist_file])).grep(
394 ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp}
396 raise "unable to load word list"
399 raise NotImplementedError, "ruleset not implemented (properly)"
405 # start shiritori in a channel
406 def cmd_shiritori(m, params)
407 if @games.has_key?( m.channel )
408 m.reply _("Already playing shiritori here")
409 @games[m.channel].announce
411 ruleset = params[:ruleset].downcase
412 if @rulesets.has_key? ruleset
414 @games[m.channel] = ShiritoriGame.new(
415 m.channel, load_ruleset(ruleset),
417 lambda {|msg| m.reply msg},
418 lambda {remove_game m.channel} )
419 m.reply _("Shiritori has started. Please say the first word")
421 m.reply _("couldn't start %{ruleset} shiritori: %{error}") %
422 {:ruleset => ruleset, :error => e}
425 m.reply _("There is no ruleset named %{ruleset}") % {:ruleset => ruleset}
430 # change rules for current game
431 def cmd_set(m, params)
434 params[:rules].each_slice(2) {|opt, value| new_rules[opt] = value}
435 raise NotImplementedError
438 # stop the current game
439 def cmd_stop(m, params)
440 if @games.has_key? m.channel
441 # TODO display statistics
442 @games[m.channel].die
443 m.reply _("Shiritori has stopped. Hope you had fun!")
445 # TODO display statistics
446 m.reply _("No game to stop here, because no game is being played.")
450 # remove the game, so channel messages are no longer processed, and timer removed
451 def remove_game(channel)
452 @games.delete channel
455 # all messages from a channel is sent to its shiritori game if any
457 return unless @games.has_key?(m.channel)
458 # send the message to the game in the channel to handle it
459 @games[m.channel].handle_message m
464 @games.each_key {|g| g.die}
470 plugin = ShiritoriPlugin.new
471 plugin.default_auth( 'edit', false )
473 # Normal commandsi have a stop_gamei have a stop_game
474 plugin.map 'shiritori stop',
475 :action => 'cmd_stop',
477 # plugin.map 'shiritori set ',
478 # :action => 'cmd_set'
480 # plugin.map 'shiritori challenge',
481 # :action => 'cmd_challenge'
482 plugin.map 'shiritori [:ruleset]',
483 :action => 'cmd_shiritori',
484 :defaults => {:ruleset => 'japanese'},