]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/shiritori.rb
f13afeb83ca93caa123ebbb6787d496c1a54a62e
[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     @rulesets.each_value do |ruleset|
358       # set default values for each rule to default_ruleset's values
359       ruleset.replace @default_ruleset.merge(ruleset)
360       unless ruleset.has_key?(:words)
361         if ruleset.has_key?(:wordlist_file)
362           # TODO read words only when rule is used
363           # read words separated by newlines from file
364           ruleset[:words] =
365             File.new("#{@bot.botclass}/shiritori/#{ruleset[:wordlist_file]}").grep(
366               ruleset[:listen]) {|l| ruleset[:normalize].call l.chomp}
367         else
368           raise NotImplementedError
369         end
370       end
371     end
372   end
373   
374   # start shiritori in a channel
375   def cmd_shiritori(m, params)
376     if @games.has_key?( m.channel )
377       m.reply "Already playing shiritori here"
378       @games[m.channel].announce
379     else
380       if @rulesets.has_key? params[:ruleset]
381         @games[m.channel] = ShiritoriGame.new(
382           m.channel, @rulesets[params[:ruleset]],
383           @bot.timer,
384           lambda {|msg| m.reply msg},
385           lambda {remove_game m.channel} )
386         m.reply "Shiritori has started. Please say the first word"
387       else
388         m.reply "There is no defined ruleset named #{params[:ruleset]}"
389       end
390     end
391   end
392   
393   # change rules for current game
394   def cmd_set(m, params)
395     require 'enumerator'
396     new_rules = {}
397     params[:rules].each_slice(2) {|opt, value| new_rules[opt] = value}
398     raise NotImplementedError
399   end
400   
401   # stop the current game
402   def cmd_stop(m, params)
403     if @games.has_key? m.channel
404       # TODO display statistics
405       @games[m.channel].die
406       m.reply "Shiritori has stopped. Hope you had fun!"
407     else
408       # TODO display statistics
409       m.reply "No game to stop here, because no game is being played."
410     end
411   end
412   
413   # remove the game, so channel messages are no longer processed, and timer removed
414   def remove_game(channel)
415     @games.delete channel
416   end
417   
418   # all messages from a channel is sent to its shiritori game if any
419   def listen(m)
420     return unless m.kind_of?(PrivMessage)
421     return unless @games.has_key?(m.channel)
422     # send the message to the game in the channel to handle it
423     @games[m.channel].handle_message m
424   end
425   
426   # remove all games
427   def cleanup
428     @games.each_key {|g| g.die}
429     @games.clear
430   end
431 end
432
433 plugin = ShiritoriPlugin.new
434 plugin.default_auth( 'edit', false )
435
436 # Normal commandsi have a stop_gamei have a stop_game
437 plugin.map 'shiritori stop',
438            :action => 'cmd_stop',
439            :private => false
440 # plugin.map 'shiritori set ',
441 #            :action => 'cmd_set'
442 #            :private => false
443 # plugin.map 'shiritori challenge',
444 #            :action => 'cmd_challenge'
445 plugin.map 'shiritori [:ruleset]',
446            :action => 'cmd_shiritori',
447            :defaults => {:ruleset => 'japanese'},
448            :private => false