added the data directory for searching translatable source files
[rbot] / data / rbot / plugins / games / shiritori.rb
1 #-- vim:sw=2:et
2 #kate: indent-width 2
3 #++
4
5 # :title: Shiritori Plugin for RBot
6 #
7 # Author:: Yaohan Chen <yaohan.chen@gmail.com>
8 # Copyright:: (c) 2007 Yaohan Chen
9 # License:: GNU Public License
10 #
11 #
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
17 # time.
18
19 # TODO
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
24 # * keep statistics
25 # * other forms of dictionaries
26
27
28 # Abstract class representing a dictionary used by Shiritori
29 class Dictionary
30   # whether string s is a word
31   def has_word?(s)
32     raise NotImplementedError
33   end
34   
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
37   # prefix searching.
38   def any_word_starting?(prefix, excludes)
39     raise NotImplementedError
40   end
41 end
42
43 # A Dictionary that uses a enumrable word list.
44 class WordlistDictionary < Dictionary
45   def initialize(words)
46     super()
47     @words = words
48     # debug "Created dictionary with #{@words.length} words"
49   end
50   
51     # whether string s is a word
52   def has_word?(s)
53     @words.include? s
54   end
55   
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?
61   end
62 end
63
64 # Logic of shiritori game, deals with checking whether words continue the chain, and
65 # whether it's possible to continue a word
66 class Shiritori
67   attr_reader :used_words
68   
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
79     @used_words = []
80   end
81   
82   # Prefix of s with length n
83   def head_of(s, n)
84     # TODO ruby2 unicode
85     s.split(//u)[0, n].join
86   end
87   # Suffix of s with length n
88   def tail_of(s, n)
89     # TODO ruby2 unicode
90     s.split(//u)[-n, n].join
91   end
92   # Number of unicode characters in string
93   def len(s)
94     # TODO ruby2 unicode
95     s.split(//u).length
96   end
97   # return subrange of range r that's under n
98   def range_under(r, n)
99     r.begin .. [r.end, n-1].min
100   end
101   
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)}
108   end
109   
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) }
116   end
117   
118   # Given a string, give a verdict based on current shiritori state and dictionary 
119   def process(s)
120     # TODO optionally allow used words
121     # TODO ruby2 unicode
122     if len(s) < @overlap_lengths.min || !@dictionary.has_word?(s)
123       # debug "#{s} is too short or not in dictionary"
124       :ignore
125     elsif @used_words.empty?
126       if !@check_continuable || continuable_from?(s)
127         @used_words << s
128         :start
129       else
130         :start_end
131       end
132     elsif continues?(s, @used_words.last)
133       if !@allow_reuse && @used_words.include?(s)
134         :used
135       elsif !@check_continuable || continuable_from?(s)
136         @used_words << s
137         :next
138       else
139         :end
140       end
141     else
142       :ignore
143     end
144   end
145 end
146
147 # A shiritori game on a channel. keeps track of rules related to timing and turns,
148 # and interacts with players
149 class ShiritoriGame
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)}
157     @last_word = nil
158     @players = []
159     @booted_players = []
160     @ruleset = ruleset
161     @channel = channel
162     @timer = timer
163     @timer_handle = nil
164     @say = say
165     @when_die = when_die
166     
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])
172   end
173   
174   def say(s)
175      @say.call(s)
176   end
177
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
183   def take_turns? 
184     @players.length > 1 && @ruleset[:time_limit] > 0
185   end
186   
187   # the player who has the current turn
188   def current_player
189     @players.first
190   end
191   # the word to continue in the current turn
192   def current_word
193     @game.used_words[-1]
194   end
195   # the word in the chain before current_word
196   def previous_word
197     @game.used_words[-2]
198   end
199   
200   # announce the current word, and player if take_turns?
201   def announce
202     say (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.")
208     else
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 }
212     end)
213   end
214   # create/reschedule timer
215   def restart_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
219     instance_eval do
220       def restart_timer
221         @timer.reschedule(@timer_handle, @ruleset[:time_limit])
222       end
223     end
224   end
225   # switch to the next player's turn if take_turns?, and announce current words
226   def next_player
227     # when there's only one player, turns and timer are meaningless
228     if take_turns?
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
232       restart_timer
233     end
234     announce
235   end
236   
237   # handle when turn time limit goes out
238   def time_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}
247         die
248       else
249         @booted_players << @players.shift
250         announce
251       end
252     else
253       say _("%{player} took too long and skipped the turn.") %
254           {:player => current_player}
255       next_player
256     end
257   end
258
259   # change the rules, and update state when necessary
260   def change_rules(rules)
261     @ruleset.update! rules
262   end
263
264   # handle a message to @channel
265   def handle_message(m)
266     message = m.message
267     speaker = m.sourcenick.to_s
268     
269     return unless @ruleset[:listen] =~ message
270
271     # in take_turns mode, only new players are allowed to interrupt a turn
272     return if @booted_players.include? speaker ||
273               (take_turns? && 
274                speaker != current_player &&
275                (@players.length > 1 && @players.include?(speaker)))
276
277     # let Shiritori process the message, and act according to result
278     case @game.process @ruleset[:normalize].call(message)
279     when :start
280       @players << speaker
281       m.reply _("%{player} has given the first word: %{word}") %
282               {:player => speaker, :word => current_word}
283     when :next
284       if !@players.include?(speaker)
285         # A new player
286         @players.unshift speaker
287         m.reply _("Welcome to shiritori, %{speaker}.") %
288                 {:player => speaker}
289       end
290       next_player
291     when :used
292       m.reply _("The word %{used_word} has been used. Retry from %{word}") %
293               {:used_word => message, :word => current_word}
294     when :end
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}
299         die
300       else
301         m.reply _("It's impossible to continue the chain from %{bad_word}. Retry from %{word}") % {:bad_word => message, :word => current_word}
302       end
303     when :start_end
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}
307     end
308   end
309   
310   # end the game
311   def die
312     # redefine restart_timer to no-op
313     instance_eval do
314       def restart_timer
315       end
316     end
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
320     @when_die.call
321   end
322 end
323
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(', ')}
329   end
330   
331   def initialize()
332     super
333     @games = {}
334     
335     # TODO make rulesets more easily customizable
336     # TODO initialize default ruleset from config
337     # Default values of rulesets
338     @default_ruleset = {
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
346       :time_limit => 60,
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
356     }
357     @rulesets = {
358       'english' => {
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.'
364       },
365       'japanese' => {
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
371       }
372     }
373   end
374
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)
381         begin
382           ruleset[:words] =
383             File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep(
384               ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp}
385         rescue
386           raise "unable to load word list"
387         end
388       else
389         raise NotImplementedError, "ruleset not implemented (properly)"
390       end
391     end
392     return ruleset
393   end
394   
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
400     else
401       ruleset = params[:ruleset].downcase
402       if @rulesets.has_key? ruleset
403         begin
404           @games[m.channel] = ShiritoriGame.new(
405             m.channel, load_ruleset(ruleset),
406             @bot.timer,
407             lambda {|msg| m.reply msg},
408             lambda {remove_game m.channel} )
409           m.reply _("Shiritori has started. Please say the first word")
410         rescue => e
411           m.reply _("couldn't start %{ruleset} shiritori: %{error}") %
412                   {:ruleset => ruleset, :error => e}
413         end
414       else
415         m.reply _("There is no ruleset named %{ruleset}") % {:ruleset => ruleset}
416       end
417     end
418   end
419   
420   # change rules for current game
421   def cmd_set(m, params)
422     require 'enumerator'
423     new_rules = {}
424     params[:rules].each_slice(2) {|opt, value| new_rules[opt] = value}
425     raise NotImplementedError
426   end
427   
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!")
434     else
435       # TODO display statistics
436       m.reply _("No game to stop here, because no game is being played.")
437     end
438   end
439   
440   # remove the game, so channel messages are no longer processed, and timer removed
441   def remove_game(channel)
442     @games.delete channel
443   end
444   
445   # all messages from a channel is sent to its shiritori game if any
446   def listen(m)
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
451   end
452   
453   # remove all games
454   def cleanup
455     @games.each_key {|g| g.die}
456     @games.clear
457   end
458 end
459
460 plugin = ShiritoriPlugin.new
461 plugin.default_auth( 'edit', false )
462
463 # Normal commandsi have a stop_gamei have a stop_game
464 plugin.map 'shiritori stop',
465            :action => 'cmd_stop',
466            :private => false
467 # plugin.map 'shiritori set ',
468 #            :action => 'cmd_set'
469 #            :private => false
470 # plugin.map 'shiritori challenge',
471 #            :action => 'cmd_challenge'
472 plugin.map 'shiritori [:ruleset]',
473            :action => 'cmd_shiritori',
474            :defaults => {:ruleset => 'japanese'},
475            :private => false