]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/shiritori.rb
added DICT (RFC 2229) client plugin
[user/henk/code/ruby/rbot.git] / 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   # 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
179   def take_turns? 
180     @players.length > 1 && @ruleset[:time_limit] > 0
181   end
182   
183   # the player who has the current turn
184   def current_player
185     @players.first
186   end
187   # the word to continue in the current turn
188   def current_word
189     @game.used_words[-1]
190   end
191   # the word in the chain before current_word
192   def previous_word
193     @game.used_words[-2]
194   end
195   
196   # announce the current word, and player if take_turns?
197   def announce
198     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."
202     else
203       @say.call "Poor #{current_player} is playing alone! Anyone care to join? #{previous_word} -> #{current_word}"
204     end
205   end
206   # create/reschedule timer
207   def restart_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
211     instance_eval do
212       def restart_timer
213         @timer.reschedule(@timer_handle, @ruleset[:time_limit])
214       end
215     end
216   end
217   # switch to the next player's turn if take_turns?, and announce current words
218   def next_player
219     # when there's only one player, turns and timer are meaningless
220     if take_turns?
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
224       restart_timer
225     end
226     announce
227   end
228   
229   # handle when turn time limit goes out
230   def time_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!"
237         die
238       else
239         @booted_players << @players.shift
240         announce
241       end
242     else
243       @say.call "#{current_player} took too long and skipped the turn."
244       next_player
245     end
246   end
247
248   # change the rules, and update state when necessary
249   def change_rules(rules)
250     @ruleset.update! rules
251   end
252
253   # handle a message to @channel
254   def handle_message(m)
255     message = m.message
256     speaker = m.sourcenick.to_s
257     
258     return unless @ruleset[:listen] =~ message
259
260     # in take_turns mode, only new players are allowed to interrupt a turn
261     return if @booted_players.include? speaker ||
262               (take_turns? && 
263                speaker != current_player &&
264                (@players.length > 1 && @players.include?(speaker)))
265
266     # let Shiritori process the message, and act according to result
267     case @game.process @ruleset[:normalize].call(message)
268     when :start
269       @players << speaker
270       m.reply "#{speaker} has given the first word: #{current_word}"
271     when :next
272       if !@players.include?(speaker)
273         # A new player
274         @players.unshift speaker
275         m.reply "Welcome to shiritori, #{speaker}."
276       end
277       next_player
278     when :used
279       m.reply "The word #{message} has been used. Retry from #{current_word}"
280     when :end
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}! :("
284         die
285       else
286         m.reply "It's impossible to continue the chain from #{message}. Retry from #{current_word}"
287       end
288     when :start_end
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."
292     end
293   end
294   
295   # end the game
296   def die
297     # redefine restart_timer to no-op
298     instance_eval do
299       def restart_timer
300       end
301     end
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
305     @when_die.call
306   end
307 end
308
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."
313   end
314   
315   def initialize()
316     super
317     @games = {}
318     
319     # TODO make rulesets more easily customizable
320     # TODO initialize default ruleset from config
321     # Default values of rulesets
322     @default_ruleset = {
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
330       :time_limit => 60,
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
340     }
341     @rulesets = {
342       'english' => {
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.'
348       },
349       'japanese' => {
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
355       }
356     }
357   end
358
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)
365         begin
366           ruleset[:words] =
367             File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep(
368               ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp}
369         rescue
370           raise "unable to load word list"
371         end
372       else
373         raise NotImplementedError, "ruleset not implemented (properly)"
374       end
375     end
376     return ruleset
377   end
378   
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
384     else
385       ruleset = params[:ruleset].downcase
386       if @rulesets.has_key? ruleset
387         begin
388           @games[m.channel] = ShiritoriGame.new(
389             m.channel, load_ruleset(ruleset),
390             @bot.timer,
391             lambda {|msg| m.reply msg},
392             lambda {remove_game m.channel} )
393           m.reply "Shiritori has started. Please say the first word"
394         rescue => e
395           m.reply "couldn't start #{ruleset} shiritori: #{e}"
396         end
397       else
398         m.reply "There is no defined ruleset named #{ruleset}"
399       end
400     end
401   end
402   
403   # change rules for current game
404   def cmd_set(m, params)
405     require 'enumerator'
406     new_rules = {}
407     params[:rules].each_slice(2) {|opt, value| new_rules[opt] = value}
408     raise NotImplementedError
409   end
410   
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!"
417     else
418       # TODO display statistics
419       m.reply "No game to stop here, because no game is being played."
420     end
421   end
422   
423   # remove the game, so channel messages are no longer processed, and timer removed
424   def remove_game(channel)
425     @games.delete channel
426   end
427   
428   # all messages from a channel is sent to its shiritori game if any
429   def listen(m)
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
434   end
435   
436   # remove all games
437   def cleanup
438     @games.each_key {|g| g.die}
439     @games.clear
440   end
441 end
442
443 plugin = ShiritoriPlugin.new
444 plugin.default_auth( 'edit', false )
445
446 # Normal commandsi have a stop_gamei have a stop_game
447 plugin.map 'shiritori stop',
448            :action => 'cmd_stop',
449            :private => false
450 # plugin.map 'shiritori set ',
451 #            :action => 'cmd_set'
452 #            :private => false
453 # plugin.map 'shiritori challenge',
454 #            :action => 'cmd_challenge'
455 plugin.map 'shiritori [:ruleset]',
456            :action => 'cmd_shiritori',
457            :defaults => {:ruleset => 'japanese'},
458            :private => false